Toggl status

Shows hours completed today, hours completed this week.

Image preview of Toggl status plugin.

toggl.1m.js

Edit
Open on GitHub
#!/usr/bin/env /opt/homebrew/bin/node

// <xbar.title>Toggl status</xbar.title>
// <xbar.version>v1.1</xbar.version>
// <xbar.author>Stefan du Fresne</xbar.author>
// <xbar.author.github>SCdF</xbar.author.github>
// <xbar.desc>Shows hours completed today, hours completed this week.</xbar.desc>
// <xbar.image>https://i.imgur.com/1dlNZLW.png</xbar.image>
// <xbar.dependencies>node</xbar.dependencies>

/*jshint esversion: 6 */

const fs = require('fs');

// TODO: Add month-long work leveling
//   You're supposed to work N hours a week, but also N*M hours a month
//   Take longer / shorter weeks into account all the way to the month

/* jshint -W100 */
const AVATARS = {
  '👶': ['👶', '👶🏻', '👶🏼', '👶🏽', '👶🏾', '👶🏿'],
  '👦': ['👦', '👦🏻', '👦🏼', '👦🏽', '👦🏾', '👦🏿'],
  '👧': ['👧', '👧🏻', '👧🏼', '👧🏽', '👧🏾', '👧🏿'],
  '👨': ['👨', '👨🏻', '👨🏼', '👨🏽', '👨🏾', '👨🏿'],
  '👩': ['👩', '👩🏻', '👩🏼', '👩🏽', '👩🏾', '👩🏿'],
  '👱‍♀️': ['👱‍♀️', '👱🏻‍♀️', '👱🏼‍♀️', '👱🏽‍♀️', '👱🏾‍♀️', '👱🏿‍♀️'],
  '👱': ['👱', '👱🏻', '👱🏼', '👱🏽', '👱🏾', '👱🏿'],
  '👴': ['👴', '👴🏻', '👴🏼', '👴🏽', '👴🏾', '👴🏿'],
  '👵': ['👵', '👵🏻', '👵🏼', '👵🏽', '👵🏾', '👵🏿'],
  '👲': ['👲', '👲🏻', '👲🏼', '👲🏽', '👲🏾', '👲🏿'],
  '👳‍♀️': ['👳‍♀️', '👳🏻‍♀️', '👳🏼‍♀️', '👳🏽‍♀️', '👳🏾‍♀️', '👳🏿‍♀️'],
  '👳': ['👳', '👳🏻', '👳🏼', '👳🏽', '👳🏾', '👳🏿'],
  '👮‍♀️': ['👮‍♀️', '👮🏻‍♀️', '👮🏼‍♀️', '👮🏽‍♀️', '👮🏾‍♀️', '👮🏿‍♀️'],
  '👮': ['👮', '👮🏻', '👮🏼', '👮🏽', '👮🏾', '👮🏿'],
  '👷‍♀️': ['👷‍♀️', '👷🏻‍♀️', '👷🏼‍♀️', '👷🏽‍♀️', '👷🏾‍♀️', '👷🏿‍♀️'],
  '👷': ['👷', '👷🏻', '👷🏼', '👷🏽', '👷🏾', '👷🏿'],
  '💂‍♀️': ['💂‍♀️', '💂🏻‍♀️', '💂🏼‍♀️', '💂🏽‍♀️', '💂🏾‍♀️', '💂🏿‍♀️'],
  '💂': ['💂', '💂🏻', '💂🏼', '💂🏽', '💂🏾', '💂🏿'],
  '🕵️‍♀️': ['🕵️‍♀️', '🕵🏻‍♀️', '🕵🏼‍♀️', '🕵🏽‍♀️', '🕵🏾‍♀️', '🕵🏿‍♀️'],
  '🕵️': ['🕵️', '🕵🏻', '🕵🏼', '🕵🏽', '🕵🏾', '🕵🏿'],
  '👩‍⚕️': ['👩‍⚕️', '👩🏻‍⚕️', '👩🏼‍⚕️', '👩🏽‍⚕️', '👩🏾‍⚕️', '👩🏿‍⚕️'],
  '👨‍⚕️': ['👨‍⚕️', '👨🏻‍⚕️', '👨🏼‍⚕️', '👨🏽‍⚕️', '👨🏾‍⚕️', '👨🏿‍⚕️'],
  '👩‍🌾': ['👩‍🌾', '👩🏻‍🌾', '👩🏼‍🌾', '👩🏽‍🌾', '👩🏾‍🌾', '👩🏿‍🌾'],
  '👨‍🌾': ['👨‍🌾', '👨🏻‍🌾', '👨🏼‍🌾', '👨🏽‍🌾', '👨🏾‍🌾', '👨🏿‍🌾'],
  '👩‍🍳': ['👩‍🍳', '👩🏻‍🍳', '👩🏼‍🍳', '👩🏽‍🍳', '👩🏾‍🍳', '👩🏿‍🍳'],
  '👨‍🍳': ['👨‍🍳', '👨🏻‍🍳', '👨🏼‍🍳', '👨🏽‍🍳', '👨🏾‍🍳', '👨🏿‍🍳'],
  '👩‍🎓': ['👩‍🎓', '👩🏻‍🎓', '👩🏼‍🎓', '👩🏽‍🎓', '👩🏾‍🎓', '👩🏿‍🎓'],
  '👨‍🎓': ['👨‍🎓', '👨🏻‍🎓', '👨🏼‍🎓', '👨🏽‍🎓', '👨🏾‍🎓', '👨🏿‍🎓'],
  '👩‍🎤': ['👩‍🎤', '👩🏻‍🎤', '👩🏼‍🎤', '👩🏽‍🎤', '👩🏾‍🎤', '👩🏿‍🎤'],
  '👨‍🎤': ['👨‍🎤', '👨🏻‍🎤', '👨🏼‍🎤', '👨🏽‍🎤', '👨🏾‍🎤', '👨🏿‍🎤'],
  '👩‍🏫': ['👩‍🏫', '👩🏻‍🏫', '👩🏼‍🏫', '👩🏽‍🏫', '👩🏾‍🏫', '👩🏿‍🏫'],
  '👨‍🏫': ['👨‍🏫', '👨🏻‍🏫', '👨🏼‍🏫', '👨🏽‍🏫', '👨🏾‍🏫', '👨🏿‍🏫'],
  '👩‍🏭': ['👩‍🏭', '👩🏻‍🏭', '👩🏼‍🏭', '👩🏽‍🏭', '👩🏾‍🏭', '👩🏿‍🏭'],
  '👨‍🏭': ['👨‍🏭', '👨🏻‍🏭', '👨🏼‍🏭', '👨🏽‍🏭', '👨🏾‍🏭', '👨🏿‍🏭'],
  '👩‍💻': ['👩‍💻', '👩🏻‍💻', '👩🏼‍💻', '👩🏽‍💻', '👩🏾‍💻', '👩🏿‍💻'],
  '👨‍💻': ['👨‍💻', '👨🏻‍💻', '👨🏼‍💻', '👨🏽‍💻', '👨🏾‍💻', '👨🏿‍💻'],
  '👩‍💼': ['👩‍💼', '👩🏻‍💼', '👩🏼‍💼', '👩🏽‍💼', '👩🏾‍💼', '👩🏿‍💼'],
  '👨‍💼': ['👨‍💼', '👨🏻‍💼', '👨🏼‍💼', '👨🏽‍💼', '👨🏾‍💼', '👨🏿‍💼'],
  '👩‍🔧': ['👩‍🔧', '👩🏻‍🔧', '👩🏼‍🔧', '👩🏽‍🔧', '👩🏾‍🔧', '👩🏿‍🔧'],
  '👨‍🔧': ['👨‍🔧', '👨🏻‍🔧', '👨🏼‍🔧', '👨🏽‍🔧', '👨🏾‍🔧', '👨🏿‍🔧'],
  '👩‍🔬': ['👩‍🔬', '👩🏻‍🔬', '👩🏼‍🔬', '👩🏽‍🔬', '👩🏾‍🔬', '👩🏿‍🔬'],
  '👨‍🔬': ['👨‍🔬', '👨🏻‍🔬', '👨🏼‍🔬', '👨🏽‍🔬', '👨🏾‍🔬', '👨🏿‍🔬'],
  '👩‍🎨': ['👩‍🎨', '👩🏻‍🎨', '👩🏼‍🎨', '👩🏽‍🎨', '👩🏾‍🎨', '👩🏿‍🎨'],
  '👨‍🎨': ['👨‍🎨', '👨🏻‍🎨', '👨🏼‍🎨', '👨🏽‍🎨', '👨🏾‍🎨', '👨🏿‍🎨'],
  '👩‍🚒': ['👩‍🚒', '👩🏻‍🚒', '👩🏼‍🚒', '👩🏽‍🚒', '👩🏾‍🚒', '👩🏿‍🚒'],
  '👨‍🚒': ['👨‍🚒', '👨🏻‍🚒', '👨🏼‍🚒', '👨🏽‍🚒', '👨🏾‍🚒', '👨🏿‍🚒'],
  '👩‍✈️': ['👩‍✈️', '👩🏻‍✈️', '👩🏼‍✈️', '👩🏽‍✈️', '👩🏾‍✈️', '👩🏿‍✈️'],
  '👨‍✈️': ['👨‍✈️', '👨🏻‍✈️', '👨🏼‍✈️', '👨🏽‍✈️', '👨🏾‍✈️', '👨🏿‍✈️'],
  '👩‍🚀': ['👩‍🚀', '👩🏻‍🚀', '👩🏼‍🚀', '👩🏽‍🚀', '👩🏾‍🚀', '👩🏿‍🚀'],
  '👨‍🚀': ['👨‍🚀', '👨🏻‍🚀', '👨🏼‍🚀', '👨🏽‍🚀', '👨🏾‍🚀', '👨🏿‍🚀'],
  '👩‍⚖️': ['👩‍⚖️', '👩🏻‍⚖️', '👩🏼‍⚖️', '👩🏽‍⚖️', '👩🏾‍⚖️', '👩🏿‍⚖️'],
  '👨‍⚖️': ['👨‍⚖️', '👨🏻‍⚖️', '👨🏼‍⚖️', '👨🏽‍⚖️', '👨🏾‍⚖️', '👨🏿‍⚖️'],
  '🤶': ['🤶', '🤶🏻', '🤶🏼', '🤶🏽', '🤶🏾', '🤶🏿'],
  '🎅': ['🎅', '🎅🏻', '🎅🏼', '🎅🏽', '🎅🏾', '🎅🏿'],
  '👸': ['👸', '👸🏻', '👸🏼', '👸🏽', '👸🏾', '👸🏿'],
  '🤴': ['🤴', '🤴🏻', '🤴🏼', '🤴🏽', '🤴🏾', '🤴🏿'],
  '👰': ['👰', '👰🏻', '👰🏼', '👰🏽', '👰🏾', '👰🏿'],
  '🤵 ': ['🤵', '🤵🏻', '🤵🏼', '🤵🏽', '🤵🏾', '🤵🏿']
};
/* jshint +W100 */
const randomItem = array => array[Math.floor(Math.random() * array.length)];
const randomAvatar = () => randomItem([].concat(...Object.values(AVATARS)));

const CONFIG_FILE = `${process.env.HOME}/.toggl.json`;

let configDirty = false;
const config = (() => {
  if (fs.existsSync(CONFIG_FILE)) {
    try {
      return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
    } catch (_) {}
  }

  // Defaults
  configDirty = true;
  return {
    avatar: randomAvatar(),
    hoursInDay: 8,
    daysInWeek: 5,
    style: 'hours'
  };
})();

const relativeThreshold = 15 * 60; // TODO: configurable?

const endOutput = () => {
  if (configDirty) {
    fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
  }

  console.log('---');
  console.log('Refresh | refresh=true');
  process.exit();
};

const badApiToken = wrong => {
  console.log(`🚨 ${wrong ? 'provided api token is wrong' : 'token needed'} 🚨`);
  console.log('---');
  console.log('🖱 Click here to find your token| href=https://toggl.com/app/profile');
  console.log('It will be at the bottom of the page|size=12');
  console.log(`Once you've found your token, copy it (CMD+C)|size=12`);
  console.log(`🤞 I've copied it 🤞|bash='${process.argv[1]}' param1=api_token refresh=true terminal=false `);
  endOutput();
};

const NOW = new Date();

const unix = date => Math.round(date.getTime() / 1000);
const outputUnix = (unixTime, verbose) => {
  const negative = (() => {if (unixTime < 0) {
    // TODO: turn red if it's negative
    unixTime *= -1;
    return true;
  }})();

  const fmt = x => x.toLocaleString(undefined, {minimumIntegerDigits: 2});
  const hours = Math.floor(unixTime / 60 / 60);
  const minutes = Math.floor(unixTime / 60) - (hours * 60);

  if (verbose) {
    return `${negative ? '-' : ''}${hours} hours ${minutes} minutes`;
  } else {
    return `${negative ? '-' : ''}${hours}:${fmt(minutes)}`;
  }
};

// TODO: alter this so you can pass the considered start day (ie Sunday or Monday)
//       Might as well be the offset integer, 0 or 1, but in theory could be 0-6
const startOfWeek = () => {
  const thisWeek = new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() - NOW.getDay());
  return unix(thisWeek);
};

const avatar = () => config.avatar === 'avatar' ?  randomAvatar() : config.avatar;

const outputHeader = (timeDay, timeWeek) => {
  let daySection; // Output string
  let dayAmount; // single amount, if possible
  if (typeof timeDay === 'object') {
    const max = Math.max(...timeDay);
    const min = Math.min(...timeDay);
    if ((max - min) > relativeThreshold) {
      daySection = `${outputUnix(min)} — ${outputUnix(max)}`;
    } else {
      dayAmount = min + Math.round((max - min) / 2);
      daySection = `${outputUnix(dayAmount)}`;
    }
  } else {
    dayAmount = timeDay;
    daySection = `${outputUnix(dayAmount)}`;
  }

  if (dayAmount && Math.abs(timeWeek - dayAmount) > relativeThreshold) {
    console.log(`${avatar()} ${daySection} (${outputUnix(timeWeek)})`);
  } else {
    console.log(`${avatar()} ${daySection}`);
  }
};

const displayTimes = me => {
  // Calculate times
  const unixToday = unix(new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate()));
  const completeDay = config.hoursInDay * 60 * 60;
  const completeWeek = completeDay * config.daysInWeek;

  let full = 0,
      today = 0;
  const days = [];

  let currentlyWorking,
      currentWid,
      currentPid;

  const timeByWidByPid = {};

  (me.data.time_entries || []).forEach(({start, duration:entryDuration, wid, pid}) => {
    // TODO: deal with partial entries that cross over midnight
    //       (both daily and weekly)
    // TODO: respect configured start of week in me.beginning_of_week
    //
    start = new Date(start);

    let duration;
    if (entryDuration > 0) {
      duration = entryDuration;
    } else {
      duration = unix(NOW) - unix(start);
      currentlyWorking = true;
      currentWid = wid;
      currentPid = pid;
    }

    if (!timeByWidByPid[wid]) {
      timeByWidByPid[wid] = {};
    }
    if (!timeByWidByPid[wid][pid]) {
      timeByWidByPid[wid][pid] = 0;
    }
    timeByWidByPid[wid][pid] += duration;

    if (unix(start) > startOfWeek()) {
      full += duration;
      let day = start.getDay();
      const end = new Date(start.getTime() + duration * 1000);
      if (day === end.getDay()) {
        days[day] = (days[day] || 0) + duration;
      } else {
        // TODO: We want to split a duration over midnights and distribute them to the correct days
        days[day] = (days[day] || 0) + duration;
      }
    }

    if (unix(start) > unixToday) {
      today += duration;
    }
  });

  // Output times
  if (!currentlyWorking) {
    process.exit(0);
  }

  switch(config.style) {
    case 'hours': {
      outputHeader(today, full);
      break;
    }
    case 'left': {
      outputHeader(completeDay - today, completeWeek - full);
      break;
    }
    case 'percentage': {
      const completeDay = config.hoursInDay * 60 * 60;
      const completeWeek = completeDay * config.daysInWeek;
      const dayPercent = Math.round((today / completeDay) * 100);
      const weekPercent = Math.round((full / completeWeek) * 100);
      console.log(`${avatar()} ${dayPercent}% (${weekPercent}%)`);
      break;
    }
    case 'relative': {
      const startOfWeekday = 1; // TODO: support using configured day from /me
      const todayWeekday = NOW.getDay();
      const daysLeft = config.daysInWeek - (todayWeekday - startOfWeekday);

      const timeInWeek = config.daysInWeek * config.hoursInDay * 60 * 60;

      const allButTodaysTime = full - today;
      const allButTodaysTimeLeft = timeInWeek - allButTodaysTime;
      const timePerDayLeft = Math.round(allButTodaysTimeLeft / daysLeft);
      const amortisedTimeLeft = timePerDayLeft - today;

      const onTrackTime = (daysLeft - 1) * config.hoursInDay * 60 * 60;
      const timeOffTrack = allButTodaysTimeLeft - onTrackTime - today;
      const fullDay = Math.min(completeDay - today, timeInWeek - full);

      outputHeader([amortisedTimeLeft, timeOffTrack, fullDay], timeInWeek - full);
      break;
    }
  }

  // TODO: add end day time, either as its own option, or as a custom estimate
  //       in the menu.
  //       I think having it as an option might be a bit more chill, you don't
  //       see it counting down, just an idea of when knocking off time is

  // Have to filter first before showing length because Monday is idx 1 / length 2
  if (days.filter(d => !!d).length > 1) {
    console.log('---');
    const dayNames = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
    days.forEach((val, day) => {
      console.log(`${dayNames[day]}:\t${outputUnix(val, true)}`);
    });
    console.log(`Σ:\t${outputUnix(full, true)}`);
    console.log(`T-:\t${outputUnix(completeWeek - full, true)}`);
  }

  // TODO: figure out how we want to pull in wids and pid labels, and if it's worth the extra api calls
  // console.log('---');
  // console.log(`${currentWid} :: ${currentPid}`);
  // console.log(outputUnix(timeByWidByPid[currentWid][currentPid], true));

  // TODO: display project summary for the week
  // TODO: allow muting of a project as it relates to time
  //       (if you're on a muted project just show emoji but not time)
  //       (it would still show in this project summary)
};

const avatarChoice = () => {
  console.log(`Change ${config.avatar}`);
  console.log(`--((surprise me))|bash='${process.argv[1]}' param1=avatar param2=avatar refresh=true terminal=false size=10`);
  Object.keys(AVATARS).forEach(k => {
    console.log(`--${k}|size=32`);
    AVATARS[k].forEach(v => {
      console.log(`----${v}|bash='${process.argv[1]}' param1=avatar param2=${v} refresh=true terminal=false size=32`);
    });
  });
};

const styleChoice = () => {
  const current = style => style === config.style ? '✓ ' : '';
  const link = style => `|bash=${process.argv[1]} param1=style param2=${style} refresh=true terminal=false`;
  console.log('Change reporting style');
  console.log(`--${current('hours')}Hours complete${link('hours')}`);
  console.log(`--${current('left')}Hours left${link('left')}`);
  console.log(`--${current('percentage')}Percentage complete${link('percentage')}`);
  console.log(`--${current('relative')}Relative weekly goals${link('relative')}`);
};

const input = () => {
  switch (process.argv[2]) {
    case 'avatar': {
      config.avatar = process.argv[3];
      configDirty = true;
      break;
    }
    case 'api_token': {
      config.apiToken = require('child_process').execSync('pbpaste').toString();
      configDirty = true;
      break;
    }
    case 'style': {
      config.style = process.argv[3];
      configDirty = true;
      break;
    }
  }
};

const output = () => {
  require('https').get({
    hostname: 'track.toggl.com',
    // NB: since is "edited since", and so isn't really reliable
    path: `/api/v8/me?with_related_data=true&since=${startOfWeek()}`,
    auth: `${config.apiToken}:api_token`
  }, res => {
    if (res.statusCode === 403) {
      badApiToken(true);
      endOutput();
    }

    let body = '';
    res.on('data', data => body += data);
    res.on('end', () => {
      try {
        displayTimes(JSON.parse(body));
        console.log('---');
        avatarChoice();
        styleChoice();
        endOutput();
      } catch (error) {
        console.log(':-(');
        console.log('---');
        console.log(error);
        console.log('---');
        console.log(body);
        endOutput();
      }
    });
    res.on('error', err => {
      console.log(':-(');
      console.log('---');
      console.log(err);
      endOutput();
    });
  });
};

input();
if (!config.apiToken) {
  badApiToken();
}
output();