Implementing a Command Palette and Task Timer (part 2)

rouilj

John P. Rouillard

Posted on February 19, 2023

Implementing a Command Palette and Task Timer (part 2)

I am a developer for the open source Roundup Issue Tracker. It has many use cases. One is to develop issue trackers like GitHub Issues, Bugzilla, or Request Tracker. I also develop a custom issue tracker for a help desk environment. This article continues with the steps to add a task-timing feature for that tracker.

In part 1 of this series I had just finished installing command-pal. Let's take a closer look at command-pal before we get to the timer.

Enhancing command-pal and Handling a Showstopper

The Superhuman blog post lists other desirable features for a command palette:

  • fuzzy search (for mispelings 8-)) - is included in command-pal using fuse.js. (It looks like there is a fork of Ninja Keys that has fuzzy search support.)
  • icons - I created an issue and pull request to add support
  • synonyms - the fuzzy search includes the description field. This helps broaden the matching terms. But a description shouldn't be a keyword/synonym list. fuse.js can search an array of strings that are part of an object. Adding this functionality is a work in progress.

One interesting possibility is supporting multiple command palettes on a page. Each palette would have a different set of commands. I am not sure that's a good idea. Superhuman suggests making the command palette omnipotent. Multiple palettes force the user to make a decision about which palette to activate. This breaks the idea of "don't make me think". I was able to create and activate multiple palettes with different hotkeys. However, more work on supporting multiple palettes on a page is needed.

At its core, a command palette is a large select modal. Having the ability to activate the modal from Javascript could allow the palette to be used in more places without making the user think.

I am pleased with command-pal. I have found it quite hackable even though I have never used Svelte before. However, I did have one potential showstopper.

The tracker uses a Content Security Policy (CSP). Style blocks in the page include a nonce. If the nonce is missing or doesn't match the one in the CSP the style blocks are ignored. Svelte generates style blocks for each element that it creates/injects. These client-side blocks, don't have access to the server's CSP nonce. If they did have access to the nonce, the nonce would be useless for securing the page's assets. If the style blocks were in a file that could be fetched using a stylesheet link, everything would be fine. However, efforts to do this with Svelt have failed. Another alternative is to generate a secure hash (sha256, 384, or 512). How to get this generated at build time is unclear. However, I did find a way to calculate it at runtime that seems to work. I proposed a patch to allow an administrator to generate the hashes using the command-pal library.

The mechanism for controlling the task timer is done. Now to turn my attention to actually timing tasks.

Let's Time All The Things

I chose the easytimer.js library. It supports:

  • setting an initial value to start counting time
  • one minute timer granularity - to reduce CPU load
  • pausing and restarting timers while keeping their accumulated time.

The command palette allows the gross controls: stop/start/pause/resume. I still need to handle the other parts of the UI. The existing issue page provides a field for manually entering the task time. Rather than trying to create a new UI for the timer, I reused the existing field. The UI is relatively simple. There is a "Time spent" input element referred to as the "time element" below.

  1. If the user prefills the time element with a number of minutes, the timer will start counting up from that time. This is helpful if you forgot to start the timer and start it after say 10 minutes.
  2. The use case only requires 1 minute precision. Since I am counting in minutes, I add one minute to the start time. This rounds the time up to the next minute.
  3. The time element is updated only once a minute. This is great for reducing CPU use, but poor for user feedback. There needs to be some way to notify the user that the timer is running. This needs to work without interfering with the ability to use the rest of the issue interface. Using a popup could work. But popups clutter the interface. If it can't be moved, it may hide something the user wants to use. Instead, I cycle the background color for the time element from yellow to goldenrod every 5 seconds. This is done using CSS rather than javascript. It should perform better than updating the input with a flashing indicator every second.
  4. The animation stops when the timer is paused. But the yellow background color is still shown in the time element.
  5. When the timer stops, the background of the time element returns to white.
  6. Besides the time element displaying state, other elements of the page change as well. Starting the timer makes a pending change to the issue. The issue page already has a mechanism for indicating a page with a pending change. Starting the timer triggers this mechanism. This results in:
    • a change in the background color of the time element label ("Time spent")
    • a change in the background color for the H1 header on the page
    • the H1 header on the page gets "pending changes" appended
    • the title for the page has an exclamation mark prepended to it; allowing the page to be identified in a list of page titles
    • the favicon for the page is overlayed with an exclamation mark inside a yellow dot; allowing the tab to be identified if the text can't be shown.

The command-pal Javascript Entries

Here are the three commands for working with the timer:

// more commands
{
  name: "Start or Unpause Timer",
  description: "timer",
  contexts: [ "issue.item" ],  // only show timers on an issue page
  handler: () => {
    if ( ! window.userTimer ) {
      try {
    // create the object
    window.userTimer = {
      timer: new easytimer.Timer({precision: "minutes"}),
      timeField: document.getElementById("time"),
      animation: null,
    }
      } catch (err) {
    alert(`Error: ${err.name} - ${err.message}`)
      }
    }

    let timer = window.userTimer.timer;
    let timeField = null;
    if ( window.userTimer.timeField ) {
      timeField = window.userTimer.timeField;
    } else {
      alert("Unable to find 'Time Spent' field. Are you viewing an issue?");
      return;
    }

    if ( timer.isRunning() ) {
      alert("Timer is running for: " + 
        timer.getTimeValues())
      return;
    }

    // restart timer
    if ( timer.isPaused() ) {
      timer.start();
      alert("Timer restarted at: " + 
        timer.getTimeValues())
      window.userTimer.animation.play()
      return;
    }

    // start timer instance
    let timeValue = parseInt(timeField.value);
    if (! timeValue || isNaN(timeValue)) { timeValue=0; }
    // round up time to next minute: 10 seconds -> 1 minute
    let startValues = { minutes: timeValue + 1 }

    timer.start({
      startValues: startValues,
      callback: function (timer) {
    let timeField = window.userTimer.timeField
    timeField.value = timer.getTotalTimeValues().minutes;
      }
    });
    alert("Timer started at: " +
      timer.getTimeValues())
    window.userTimer.animation = animate_timer(timeField);

    timeField.value = timer.getTotalTimeValues().minutes;

    // mark field/page with a pending change
    timeField.dispatchEvent(new Event('change',
                      {bubbles: true}))
  },
  icon: '<span class="icon">&nbsp;⏱️ </span>', // stopwatch emoji
  weight: 10, // sort this to the top of the list
},
{
  name: "Pause Timer",
  contexts: [ "issue.item" ],
  description: "Update time field and snooze the timer",
  handler: () => {
    if (! (window.userTimer && window.userTimer.timer) ) {
      alert("No timer was started.");
      return;
    }

    let timer = window.userTimer.timer
    let timeField = window.userTimer.timeField
    let animation = window.userTimer.animation

    if ( ! timer.isRunning() ) {
      alert("Timer is not running, time is: " +
        timer.getTimeValues());
      return;
    }
    timer.pause();
    animation.pause()

    timeField.value = timer.getTotalTimeValues().minutes;
    alert("Timer paused at: " +  timer.getTimeValues());
  },
},
{
  name: "Stop Timer",
  contexts: [ "issue.item" ],
  handler: () => {
    if (! (window.userTimer && window.userTimer.timer) ) {
      alert("No timer was started.");
      return;
    }

    let timer = window.userTimer.timer

    if ( ! ( timer.isRunning() || timer.isPaused() )) {
      alert("Timer is not running, time is: " +
        timer.getTimeValues());
      return;
    }

    let animation = window.userTimer.animation
    let timeField = window.userTimer.timeField

    timer.pause(); // stop time update
    timeField.value = timer.getTotalTimeValues().minutes;
    timer.stop(); // also zero's timer.
    animation.cancel();
    window.userTimer.animation = null;
  },
},
// more commands
Enter fullscreen mode Exit fullscreen mode

(Note: the version of command-pal that I am running has the change to support icons.)

There is one helper function to set up the animation for the "Time spent" input field:

function animate_timer(input) {
  return input.animate(
    [
      {backgroundColor: 'yellow', easing: 'linear'},
      {backgroundColor: 'goldenrod', easing: 'linear'},
      {backgroundColor: 'yellow', easing: 'linear'}
    ],
    {duration: 5000, iterations: Infinity}
  );
}
Enter fullscreen mode Exit fullscreen mode

Working Example

You can see this in action on the demo site. Use demo/demo for login. Activate the palette using ctrl+space. The code is also published.

💖 💪 🙅 🚩
rouilj
John P. Rouillard

Posted on February 19, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related