Portfolio: Silent Countdown Timer

By

View the app: Silent Countdown Timer

About the App

I needed a countdown timer without an audible alarm and that would keep counting into negative numbers when time expired. This is especially useful for helping people who are speaking in public keep track of time. I couldn't find such a timer, so I wrote one. Configuration can be saved in multiple ways, or specified via the URL to facilitate insertion into existing program flows.

Selected Source Files

timer.ts

import { merge_objects, qs, snackbar } from "./lib/util";
import { show_nav_buttons, update_screen } from "./timer/display";
import { insert_into_single_form } from "./timer/editing";
import { initialize_event_listeners } from "./timer/events";
import { is_fullscreen, toggle_fullscreen } from "./timer/fullscreen";
import g from "./timer/globals";
import { group_add_to_table } from "./timer/groups";
import { hms2s, s2hms } from "./timer/numeric";
import { set_thresholds } from "./timer/settings";
import { start_timer } from "./timer/timer_controls";
import type {
  ScheduleItem,
  ScheduleType,
  TimerOptions,
  TimerType,
} from "./timer/types";
import { add_class, input_get_value, remove_class } from "./timer/util";

/**
 * Loads the timer object and initializes the necessary variables based on the stored data.
 * If there are query parameters in the URL, it updates the timer object accordingly.
 * Also sets up event listeners and handles UI changes based on the timer type.
 */
function load() {
  // Initialize timer object. Object.assign is used to merge timer with the second argument.
  if (localStorage.timer) {
    g.timer = merge_objects<Partial<TimerOptions>>(
      g.timer,
      JSON.parse(localStorage.timer) as Partial<TimerOptions>
    );
  }
  if (localStorage.schedule) {
    g.schedule = JSON.parse(localStorage.schedule) as ScheduleItem[];
  }
  if (g.timer.saved_time) {
    if (g.timer.timer_type === "single") {
      var type: ScheduleType = g.timer.count_to_time ? "time" : "duration";
      insert_into_single_form({ type, time: g.timer.saved_time });
      update_screen(g.timer.saved_time, true);
    } else {
      // timer_type === 'group'
      setTimeout(function () {
        let schedule_elem = <HTMLSelectElement>qs("#schedule");
        schedule_elem.value = "group";
        remove_class("group", "hide");
        add_class("single", "hide");
        g.schedule.forEach((item: ScheduleItem, i: number) => {
          let { h, m, s } = s2hms(item.time);
          group_add_to_table(i, item.type, {h: parseInt(h), m: parseInt(m), s: parseInt(s)});
        });
        if (!g.timer.timer_index) g.timer.timer_index = 0;
        insert_into_single_form(g.schedule[g.timer.timer_index]);
        update_screen(g.schedule[g.timer.timer_index].time, true);
        show_nav_buttons();
      }, 200);
    }
  }
  if (g.query_string.has("fullscreen")) {
    g.timer.fullscreen =
      g.query_string.get("fullscreen") === "true" ? true : false;
    if (g.timer.fullscreen && !is_fullscreen()) {
      g.timer.fullscreen = false; // set to false because toggle_fullscreen
      // expects this variable to contain the
      // current state, not the desired state.
      toggle_fullscreen();
      window.scrollTo(0, 0);
      setTimeout(function () {
        snackbar("no-auto-fullscreen", 10000);
      }, 1000);
    }
  }
  if (g.query_string.has("count_to_time")) {
    g.timer.count_to_time =
      g.query_string.get("count_to_time") === "true" ? true : false;
  }
  if (g.query_string.has("no_color")) {
    g.timer.no_color = g.query_string.get("no_color") === "true" ? true : false;
  }
  if (g.query_string.has("time")) {
    let time = g.query_string.get("time") as string;
    let time_pcs = time.split(":");
    let s = 0;
    if (time_pcs.length == 2) s = hms2s(0, time_pcs[0], time_pcs[1]);
    else s = hms2s(time_pcs[0], time_pcs[1], time_pcs[2]);
    g.timer.time = s;
    g.timer.initial_time = s;
    let type: ScheduleType = g.timer.count_to_time ? "time" : "duration";
    insert_into_single_form({ type: type, time: s });
    update_screen(s, true);
    set_thresholds();
    start_timer();
  }

  qs("#schedule").addEventListener("change", function () {
    var type = input_get_value("schedule");
    g.timer.timer_type = type as TimerType;
    if (type == "group") {
      remove_class("group", "hide");
      add_class("single", "hide");
      add_class("fullscreen", "hide");
      if (is_fullscreen()) toggle_fullscreen();
    } else {
      add_class("group", "hide");
      remove_class("single", "hide");
    }
  });
  initialize_event_listeners();
}

document.addEventListener("DOMContentLoaded", load);

// function toggle_hidden_selectors(id_arr) {
//   for (var i=0; i<id_arr.length; i++) {
//     qs(id_arr[i]).classList.toggle('hide');
//   }
// }

// Object.prototype.merge = function(other) {
//   if (Object.assign) {
//     Object.assign(this, other);
//   } else {
//     for (var attrname in other) {
//       this[attrname] = other[attrname];
//     }
//   }
// }

//TODO: refactor code, add save clear, address bug preventing deletion of saved items in Firefox

settings.ts

import g from "./globals";

/**
 * Sets the thresholds for the timer based on the current time value. This
 * determines when the timer will change color. This should be called before
 * the timer starts; otherwise, the thresholds won't make sense.
 */
export function set_thresholds(): void {
  if (g.timer.time === undefined) return;
  if (g.timer.time >= 60 * 60) {
    // If the time is greater than or equal to 60 minutes, the thresholds are
    // 10 minutes and 5 minutes.
    g.timer.threshold1 = 10 * 60; // 10 minutes
    g.timer.threshold2 = 5 * 60; // 5 minutes
  } else if (g.timer.time >= 10 * 60) {
    // If the time is greater than or equal to 10 minutes, the thresholds are
    // 5 minutes and 2.5 minutes.
    g.timer.threshold1 = 5 * 60;
    g.timer.threshold2 = 2.5 * 60;
  } else if (g.timer.time >= 5 * 60) {
    // If the time is greater than or equal to 5 minutes, the thresholds are
    // 1 minute and 30 seconds.
    g.timer.threshold1 = 60;
    g.timer.threshold2 = 30;
  } else if (g.timer.time >= 60) {
    // If the time is greater than or equal to 1 minute, the thresholds are
    // 30 seconds and 15 seconds.
    g.timer.threshold1 = 15;
    g.timer.threshold2 = 5;
  } else if (g.timer.time >= 30) {
    // If the time is greater than or equal to 30 seconds, the thresholds are
    // 15 seconds and 5 seconds.
    g.timer.threshold1 = 5;
    g.timer.threshold2 = 2;
  } else {
    // If the time is less than 30 seconds, there are no thresholds and the
    // timer will not change color.
    g.timer.threshold1 = undefined;
    g.timer.threshold2 = undefined;
  }
}

timer_controls.ts

import { qs } from "../lib/util";
import { display_expired, hide_nav_buttons, init_timer_display, read_timer_form, show_controls, show_nav_buttons, size_font, update_screen } from "./display";
import g from "./globals";
import { hms2s } from "./numeric";
import { set_thresholds } from "./settings";
import { add_class, has_class, input_get_value, input_get_value_int, input_set_value, remove_class } from "./util";

/**
 * Updates the timer per second. Tick tock.
 */
export function tick(): void {
  if (g.timer.time === undefined || g.timer.initial_time === undefined || g.timer.start_time === undefined) return;
  // calculate the elapsed time since the timer started and use that to decrement the timer
  g.timer.time = g.timer.initial_time - Math.floor((Date.now() - g.timer.start_time) / 1000);
  update_screen();
  check_thresholds();
}

/**
 * Toggles the timer between starting and pausing.
 */
export function toggle_timer(): void {
  if (g.timer.interval_id === undefined) {
    start_timer();
  } else {
    pause_timer();
  }
  show_controls();
}

/**
 * Starts the timer.
 *
 * @remarks
 * This function performs the necessary actions to start the timer. It checks if
 * the time is set, removes expired classes, hides buttons, sets the timer mode,
 * initializes the timer display, and starts the timer interval.
 *
 * @returns void
 */
export function start_timer(): void {
  var larger_time = false;
  if (read_timer_form() === 0)
    return alert("You need to first set the time before starting the timer!");
  remove_class("screen", "expired", "expired-alternate");
  add_class("stop-button", "hide");
  add_class("edit-button", "hide");
  remove_class("pause-button", "hide");
  add_class("play-button", "hide");
  hide_nav_buttons();
  if (g.timer.time === undefined || g.timer.count_to_time) {
    var mode = input_get_value("input[name=mode]:checked", false);
    if (mode == "time") {
      var target = read_timer_form();
      var now_date = new Date(Date.now());
      let now_s = hms2s(
        now_date.getHours(),
        now_date.getMinutes(),
        now_date.getSeconds()
      );
      if (target <= now_s) {
        target += 24 * 60 * 60;
        larger_time = true;
      }
      g.timer.time = target - now_s;
      g.timer.count_to_time = true;
    } else {
      g.timer.time = read_timer_form();
      g.timer.count_to_time = false;
    }
    g.timer.initial_time = g.timer.time;
    set_thresholds();
  }
  init_timer_display();
  g.timer.start_time = Date.now();
  g.timer.interval_id = setInterval(tick, 1000);
  if (g.timer.count_to_time && g.timer.fullscreen && larger_time) {
    size_font();
  }
}

/**
 * Pauses the timer.
 */
export function pause_timer(): void {
  if (g.timer.interval_id !== undefined) {
    clearInterval(g.timer.interval_id);
    g.timer.interval_id = undefined;
  }
  if (g.timer.expired_interval_id !== undefined) {
    clearInterval(g.timer.expired_interval_id);
    g.timer.expired_interval_id = undefined;
  }
  if (g.timer.expired_font_size_timer !== undefined) {
    clearInterval(g.timer.expired_font_size_timer);
    g.timer.expired_font_size_timer = undefined;
  }
  add_class("pause-button", "hide");
  remove_class("stop-button", "hide");
  remove_class("play-button", "hide");
  remove_class("edit-button", "hide");
  if (g.timer.timer_type == "group") {
    show_nav_buttons();
  }
}

/**
 * Stops the timer.
 */
export function stop_timer(): void {
  pause_timer();
  reset_timer();
  add_class("stop-button", "hide");
}

/**
 * Resets the timer.
 * 
 * @remarks
 * This function pauses the timer and resets its values based on the input
 * fields.
 * 
 * If the initial time is not set, it clears all timer values and input fields.
 * 
 * If the input fields have non-zero values and the timer form is different
 * from the initial time, it updates the timer values to match the input fields.
 * Otherwise, it restores the timer values to the initial time.
 * 
 * Finally, it removes certain CSS classes from the screen element, updates the
 * screen, and adjusts the font size if necessary.
 */
export function reset_timer(): void {
  pause_timer();
  var h = input_get_value_int("input-h");
  var m = input_get_value_int("input-m");
  var s = input_get_value_int("input-s");
  if (g.timer.initial_time === undefined) {
    g.timer.time = undefined;
    g.timer.initial_time = undefined;
    g.timer.threshold1 = undefined;
    g.timer.threshold2 = undefined;
    g.timer.count_to_time = false;
    input_set_value("input-h", "");
    input_set_value("input-m", "");
    input_set_value("input-s", "");
  } else if (
    (h > 0 || m > 0 || s > 0) &&
    read_timer_form() != g.timer.initial_time
  ) {
    g.timer.time = read_timer_form();
    g.timer.initial_time = g.timer.time;
  } else {
    g.timer.time = g.timer.initial_time;
  }
  remove_class(
    "screen",
    "threshold2",
    "threshold1",
    "expired",
    "expired-alternate"
  );
  update_screen(undefined, true);
  if (has_class("timer-display", "large")) {
    size_font();
  }
}

/**
 * Checks the thresholds for the timer and updates the screen accordingly.
 */
export function check_thresholds(): void {
  if (g.timer.no_color) return; // return true;
  if (g.timer.expired_interval_id === undefined && g.timer.time !== undefined) {
    let screen = qs("#screen");
    if (g.timer.time <= 0) {
      screen.classList.remove("threshold1");
      screen.classList.remove("threshold2");
      setTimeout(function () {
        g.timer.expired_interval_id = setInterval(display_expired, g.expired_flash_rate);
      }, g.expired_flash_rate);
    } else if (
      g.timer.threshold2 !== undefined &&
      g.timer.time <= g.timer.threshold2
    ) {
      screen.classList.remove("threshold1");
      screen.classList.add("threshold2");
    } else if (
      g.timer.threshold1 !== undefined &&
      g.timer.time <= g.timer.threshold1
    ) {
      screen.classList.remove("threshold2");
      screen.classList.add("threshold1");
    }
  }
}

timer.html

This is an excerpt showing the HTML underlying the timer.


<div id="timer-display"><div class="cell">
  <div id="screen">
    <div id="numbers">
      <span id="h">0</span>:<span id="m">00</span>:<span id="s">00</span>
    </div>
    <div id="timer-setup" class="hide">
      <form method="post" onsubmit="return false;" action="javascript:edit()" id="set-time">
        <div>
          <div id="schedule-wrapper">
            <label for="schedule" style="cursor: default">Timer type:</label>
            <select id="schedule" name="schedule">
              <option value="single" selected>Single timer</option>
              <option value="group">Timer group</option>
            </select>
          </div>
          <div id="single">
            <div id="which-mode">Which mode do you want?
              <table>
                <tr>
                  <td><input type="radio" name="mode" id="mode-countdown" checked value="countdown"></td>
                  <td><label for="mode-countdown">Set the desired duration.</label></td>
                </tr>
                <tr>
                  <td><input type="radio" name="mode" id="mode-time" value="time"></td>
                  <td><label for="mode-time">Count down to the specified 24-hour time.</label></td>
                </tr>
              </table>
            </div>
            <div id="single-input-container" class="one-line">
              <input type="number" placeholder="H" min="0" id="input-h"> :
              <input type="number" placeholder="M" min="0" max="59" id="input-m"> :
              <input type="number" placeholder="S" min="0" max="59" id="input-s">
            </div>
          </div>
          <div id="group" class="hide">
            <table>
              <thead><tr>
                <th>&nbsp;</th>
                <th>Type</th>
                <th>Initial setting</th>
                <th>&nbsp;</th>
              </tr></thead>
              <tbody id="group-list"><tr id="group-new">
                <td>New:</td>
                <td>
                  <select name="new-type" id="new-type">
                    <option value="none" selected disabled>---  Select type  ---</option>
                    <option value="duration">Set duration</option>
                    <option value="time">Set 24-hour time</option>
                  </select>
                </td>
                <td class="one-line" role="status">
                  <input type="number" placeholder="H" min="0" id="new-h"> :
                  <input type="number" placeholder="M" min="0" max="59" id="new-m"> :
                  <input type="number" placeholder="S" min="0" max="59" id="new-s">
                </td>
                <td><i class="material-icons button" id="group-add" title="Add new entry">add</i></td>
              </tr>
            </tbody></table>
          </div>
        </div>
      </form>
    </div>
  </div>
  <div id="controls">
    <i class="material-icons button" id="edit-button" title="Set timer">edit</i>
    <i class="material-icons button hide" id="save-button" title="Save settings">save</i>
    <i class="material-icons button hide" id="delete-button" title="Delete saved settings">delete</i>
    <i class="material-icons button" id="play-button" title="Start">play_arrow</i>
    <i class="material-icons button hide" id="pause-button" title="Pause">pause</i>
    <i class="material-icons button hide" id="stop-button" title="Reset">refresh</i>
    <i class="material-icons button hide" id="schedule-prev-button" title="Move to the previous item in the group">navigate_before</i>
    <i class="material-icons button hide" id="schedule-next-button" title="Move to the next item in the group">navigate_next</i>
    <i class="material-icons button" id="fullscreen" title="Toggle fullscreen">fullscreen</i>
  </div>
</div></div>

<div id="help-text">
<h2>Help/about</h2>
<p>This timer is useful for situations where you want a countdown timer but no audible alarm. I wrote it for timing speeches. It can count down from a specified duration (e.g., 30 minutes), or count down to a specified time in 24-hour format (e.g., 20:00).</p>
<p>This timer uses a visible alarm system. First, as time runs down, the background color changes from black to yellow to red to help remind the user of the remaining time. When time expires, the background flashes yellow and red. <i>Importantly, the timer keeps going to negative numbers,</i> so if you go overtime, you’ll know by how much.</p>
<p>To use the timer, press the edit button (<i class="material-icons">edit</i>) to set the time, then press start (<i class="material-icons">play_arrow</i>). You will probably want to click the full screen button (<i class="material-icons">fullscreen</i>) to get a more usable display. It’ll work on any screen, mobile or non-mobile. However, it’s up to you to prevent the screen from turning off while the timer is running.</p>
<p><b>Keyboard shortcuts:</b> <span class="key">Space</span> starts and stops the timer. <span class="key">R</span> resets the timer. <span class="key"><i class="material-icons" style="font-variant: initial">navigate_before</i></span> and <span class="key"><i class="material-icons" style="font-variant: initial">navigate_next</i></span> switch between timers when you’re using timer groups, and <span class="key">E</span> edits the timer and saves your edits.</p>
<h3>API</h3>
<p>You can add text to the URL to control the timer. That way, you can set the
  timer to automatically start with certain parameters regardless of the
  browser. To enable this, insert a <code>?</code> at the end of the URL
  displayed in the address bar. Afterwards, insert any of the options below,
  separated by <code>&amp;</code>.</p>
<ul>
  <li><code>time</code>: Set this to the time to count down to and
    automatically start the timer. This is in h:m:s format or m:s format. For
    example, <code>time=1:00</code> will set the timer for one minute and no
    seconds, while <code>time=3:21:54</code> will set it for three hours, 21
    minutes, and 54 seconds.</li>
  <li><code>count_to_time</code>: If <code>false</code>, the default, the
    timer will interpret the given time as a duration. If
    <code>true</code>, the timer will interpret the time as a clock time
    to count to. This option is meaningless unless the <code>time</code>
    option is also given.</li>
  <li><code>fullscreen</code>: If set to <code>true</code>, the timer will
    automatically enter full screen mode. <b>Important:</b> Browser security
    rules mean that it's not possible to automatically enter true full screen
    mode. The only way to do that is through true user interaction. This mode
    does a mostly-full screen that leaves the browser chrome still visible.</li>
  <li><code>no_color</code>: If set to <code>true</code>, the timer won't change
    color when time is running low or is expired. I added this mode to support
    using the timer in a video setting where the background is keyed out and
    color changes just don't work.</li>
</ul>
<h4>Examples</h4>
<ul>
  <li>Start a full screen timer for 30 minutes:
    <code>https://www.scottseverance.us/timer/?time=30:00&amp;fullscreen=true</code></li>
  <li>Start a full screen timer to count to 10:00 am:
    <code>https://www.scottseverance.us/timer/?time=10:00:00&amp;count_to_time=true&amp;fullscreen=true</code></li>
  <li>Start a 3.5-minute timer, not full screen:
    <code>https://www.scottseverance.us/timer/?time=3:30</code></li>
</ul>
</div>
<div id="no-auto-fullscreen" class="snackbar">Automatically entering full
  screen mode isn't permitted for security reasons. For true full screen,
  manually exit and re-enter full screen mode.</div>