├── .gitignore ├── assets └── dashboard.png ├── utils ├── date.js ├── config.js ├── paths.js ├── filter.js └── parse.js ├── habit ├── index.js ├── render.js └── calculate.js ├── projects ├── index.js ├── calculate.js └── render.js ├── stats ├── index.js ├── calculate.js └── render.js ├── tags ├── index.js ├── calculate.js └── render.js ├── package.json ├── LICENSE.md ├── cli.js ├── README.md └── cache └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /assets/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szymonkaliski/timav/HEAD/assets/dashboard.png -------------------------------------------------------------------------------- /utils/date.js: -------------------------------------------------------------------------------- 1 | const toHours = ms => ms / (60 * 60 * 1000); 2 | 3 | module.exports = { toHours }; 4 | -------------------------------------------------------------------------------- /habit/index.js: -------------------------------------------------------------------------------- 1 | const calculate = require("./calculate"); 2 | const render = require("./render"); 3 | 4 | module.exports = { calculate, render }; 5 | -------------------------------------------------------------------------------- /projects/index.js: -------------------------------------------------------------------------------- 1 | const calculate = require("./calculate"); 2 | const render = require("./render"); 3 | 4 | module.exports = { calculate, render }; 5 | -------------------------------------------------------------------------------- /stats/index.js: -------------------------------------------------------------------------------- 1 | const calculate = require("./calculate"); 2 | const render = require("./render"); 3 | 4 | module.exports = { calculate, render }; 5 | -------------------------------------------------------------------------------- /tags/index.js: -------------------------------------------------------------------------------- 1 | const calculate = require("./calculate"); 2 | const render = require("./render"); 3 | 4 | module.exports = { calculate, render }; 5 | 6 | -------------------------------------------------------------------------------- /utils/config.js: -------------------------------------------------------------------------------- 1 | const { CONFIG_FILE_PATH } = require("./paths"); 2 | 3 | const loadConfig = () => { 4 | let config; 5 | 6 | try { 7 | config = require(CONFIG_FILE_PATH); 8 | } catch (e) { 9 | console.log(e); 10 | process.exit(1); 11 | } 12 | 13 | return config; 14 | }; 15 | 16 | module.exports = { loadConfig }; 17 | -------------------------------------------------------------------------------- /stats/calculate.js: -------------------------------------------------------------------------------- 1 | const { getSyncInfo } = require("../utils/paths"); 2 | const { sumBy } = require("lodash"); 3 | 4 | module.exports = ({ events, calendar }) => { 5 | const totalTime = sumBy(events, e => e.duration); 6 | 7 | return { 8 | eventsCount: events.length, 9 | totalTime, 10 | lastSync: getSyncInfo({ calendar }).time 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /stats/render.js: -------------------------------------------------------------------------------- 1 | const { formatDistanceToNow } = require("date-fns"); 2 | const { toHours } = require("../utils/date"); 3 | const chalk = require("chalk"); 4 | 5 | module.exports = ({ eventsCount, totalTime, lastSync }) => { 6 | const sync = formatDistanceToNow(lastSync, { addSuffix: true }); 7 | const events = `${chalk.gray(eventsCount)} events`; 8 | const hours = `${chalk.gray(Math.round(toHours(totalTime)))} hours`; 9 | 10 | return `${events} ${chalk.gray("/")} ${hours} ${chalk.gray("/")} last synced ${sync}`; 11 | }; 12 | -------------------------------------------------------------------------------- /tags/calculate.js: -------------------------------------------------------------------------------- 1 | const { chain, sumBy } = require("lodash"); 2 | 3 | module.exports = ({ events, n }) => { 4 | let tags = chain(events) 5 | .flatMap(e => e.tags.map(({ tag }) => ({ tag, duration: e.duration }))) 6 | .groupBy(({ tag }) => tag.trim()) 7 | .map((group, tag) => ({ tag, duration: sumBy(group, "duration") })) 8 | .filter(({ duration }) => duration > 0) 9 | .sortBy("duration"); 10 | 11 | if (n !== undefined) { 12 | tags = tags.takeRight(n); 13 | } 14 | 15 | tags = tags.reverse().value(); 16 | 17 | return tags; 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timav-cli", 3 | "version": "1.0.5", 4 | "description": "cli tool and a library for working with time tracking data", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "bin": { 8 | "timav": "./cli.js" 9 | }, 10 | "keywords": [], 11 | "author": "Szymon Kaliski (http://szymonkaliski.com)", 12 | "license": "MIT", 13 | "dependencies": { 14 | "chalk": "^3.0.0", 15 | "d3-array": "^2.4.0", 16 | "d3-scale": "^3.2.1", 17 | "date-fns": "^2.9.0", 18 | "env-paths": "^2.2.0", 19 | "googleapis": "^47.0.0", 20 | "lodash": "^4.17.15", 21 | "mkdirp": "^1.0.3", 22 | "moment-timezone": "^0.5.27", 23 | "yargs": "^15.1.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /habit/render.js: -------------------------------------------------------------------------------- 1 | const { chain } = require("lodash"); 2 | const chalk = require("chalk"); 3 | 4 | const chart = (data, maxBarLength = 100) => { 5 | return chain(data) 6 | .takeRight(maxBarLength) 7 | .map(d => (d.length > 0 ? "─" : " ")) 8 | .join("") 9 | .value(); 10 | }; 11 | 12 | module.exports = ({ query, streak, histogram }) => { 13 | const width = Math.min(80, process.stdout.columns); 14 | const { current, longest } = streak; 15 | 16 | const status = 17 | current === longest ? `${current}d` : `${current}/${longest}d`; 18 | 19 | const statusWithColors = 20 | current === longest 21 | ? `${chalk.gray(current)}d` 22 | : `${chalk.gray(current)}${chalk.gray("/")}${chalk.gray(longest)}d`; 23 | 24 | let info = query.replace("@", ""); 25 | info += " ".repeat(width - info.length - status.length); 26 | info += statusWithColors; 27 | 28 | return `${info} 29 | ${chalk.gray(chart(histogram, width))}`; 30 | }; 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Szymon Kaliski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /projects/calculate.js: -------------------------------------------------------------------------------- 1 | const { chain, sumBy, minBy, maxBy, groupBy } = require("lodash"); 2 | const { filterEvents } = require("../utils/filter"); 3 | 4 | module.exports = ({ events: allEvents, query, n }) => { 5 | let events = allEvents; 6 | 7 | if (query) { 8 | events = filterEvents(events, query); 9 | } 10 | 11 | let projects = chain(events) 12 | .values() 13 | .filter(e => !!e.project) 14 | .groupBy(e => e.project) 15 | .map(g => { 16 | const start = minBy(g, e => e.start).start; 17 | const end = maxBy(g, e => e.end).end; 18 | const totalTime = sumBy(g, e => e.duration); 19 | const totalDays = Object.keys( 20 | groupBy(g, e => e.startDateStr.split("T")[0]) 21 | ).length; 22 | 23 | const tags = chain(g) 24 | .map(g => g.tags) 25 | .flatten() 26 | .map(t => (t.subTag ? `${t.tag}(${t.subTag})` : t.tag)) 27 | .uniq() 28 | .value(); 29 | 30 | return { 31 | project: g[0].project, 32 | numEvents: g.length, 33 | start, 34 | end, 35 | tags, 36 | totalTime, 37 | totalDays 38 | }; 39 | }) 40 | .sortBy(g => g.end); 41 | 42 | if (n !== undefined) { 43 | projects = projects.takeRight(n); 44 | } 45 | 46 | return { projects: projects.reverse().value() }; 47 | }; 48 | -------------------------------------------------------------------------------- /tags/render.js: -------------------------------------------------------------------------------- 1 | const { maxBy, padEnd } = require("lodash"); 2 | const { toHours } = require("../utils/date"); 3 | const chalk = require("chalk"); 4 | 5 | const bar = (value, maxValue, maxBarLength) => { 6 | const fractions = ["─"]; 7 | 8 | const barLength = (value * maxBarLength) / maxValue; 9 | const wholeNumberPart = Math.floor(barLength); 10 | const fractionalPart = barLength - wholeNumberPart; 11 | 12 | let bar = fractions[fractions.length - 1].repeat(wholeNumberPart); 13 | 14 | if (fractionalPart > 0) { 15 | bar += fractions[Math.floor(fractionalPart * fractions.length)]; 16 | } 17 | 18 | return bar; 19 | }; 20 | 21 | const chart = (data, maxBarLength = 100) => { 22 | const maxDuration = maxBy(data, "duration").duration; 23 | const maxTitleLength = maxBy(data, d => d.tag.length).tag.length; 24 | const maxDurationLength = toHours(maxDuration).toFixed(0).length; 25 | 26 | return data 27 | .map(d => { 28 | const prefix = padEnd(d.tag, maxTitleLength); 29 | 30 | const barText = bar( 31 | d.duration, 32 | maxDuration, 33 | maxBarLength - maxDurationLength - maxTitleLength - 3 34 | ); 35 | 36 | const hours = toHours(d.duration).toFixed(0); 37 | 38 | return `${prefix} ${chalk.gray(barText)} ${chalk.gray(hours)}h`; 39 | }) 40 | .join("\n"); 41 | }; 42 | 43 | module.exports = data => { 44 | return chart(data, Math.min(80, process.stdout.columns)); 45 | }; 46 | -------------------------------------------------------------------------------- /habit/calculate.js: -------------------------------------------------------------------------------- 1 | const { differenceInDays, endOfDay, startOfDay } = require("date-fns"); 2 | const { histogram } = require("d3-array"); 3 | const { scaleTime } = require("d3-scale"); 4 | const { first, last } = require("lodash"); 5 | 6 | const { filterEvents } = require("../utils/filter"); 7 | 8 | const calculateStreak = histogram => { 9 | const { current, longest } = histogram.reduce( 10 | (acc, bin) => { 11 | const current = bin.length > 0 ? acc.current + 1 : 0; 12 | 13 | const longest = 14 | bin.length === 0 ? Math.max(acc.current, acc.longest) : acc.longest; 15 | 16 | return { 17 | current, 18 | longest 19 | }; 20 | }, 21 | { current: 0, longest: 0 } 22 | ); 23 | 24 | return { longest: Math.max(longest, current), current }; 25 | }; 26 | 27 | module.exports = ({ events: allEvents, query, endDate }) => { 28 | const events = Object.values(filterEvents(allEvents, query)); 29 | 30 | const durationDays = differenceInDays(last(events).start, first(events).end); 31 | const domain = [startOfDay(first(events).start), endOfDay(endDate || new Date())]; 32 | const scale = scaleTime().domain(domain); 33 | 34 | const calculateHistogram = histogram() 35 | .value(e => e.start) 36 | .domain(domain) 37 | .thresholds(scale.ticks(durationDays)); 38 | 39 | const result = calculateHistogram(events); 40 | const streak = calculateStreak(result); 41 | 42 | return { 43 | query, 44 | streak, 45 | histogram: result 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /projects/render.js: -------------------------------------------------------------------------------- 1 | const { minBy, maxBy, range } = require("lodash"); 2 | const chalk = require("chalk"); 3 | 4 | const { toHours } = require("../utils/date"); 5 | 6 | const WHISKER = "│"; 7 | const LEFT_WHISKER = "├"; 8 | const RIGHT_WHISKER = "┤"; 9 | const LINE = "─"; 10 | 11 | const chart = (data, maxBarLength = 100) => { 12 | const earliest = minBy(data, d => d.start).start; 13 | const latest = maxBy(data, d => d.end).end; 14 | 15 | const scale = t => { 16 | return ( 17 | (t.getTime() - earliest.getTime()) / 18 | (latest.getTime() - earliest.getTime()) 19 | ); 20 | }; 21 | 22 | return data 23 | .map(d => { 24 | const startPercent = scale(d.start); 25 | const endPercent = scale(d.end); 26 | 27 | const startPosition = Math.round(startPercent * (maxBarLength - 1)); 28 | const endPosition = Math.round(endPercent * (maxBarLength - 1)); 29 | 30 | const bar = range(maxBarLength) 31 | .map(i => { 32 | if (i === startPosition && i === endPosition) { 33 | return WHISKER; 34 | } 35 | if (i === startPosition) { 36 | return LEFT_WHISKER; 37 | } 38 | if (i === endPosition) { 39 | return RIGHT_WHISKER; 40 | } 41 | if (i > startPosition && i < endPosition) { 42 | return LINE; 43 | } 44 | 45 | return " "; 46 | }) 47 | .join(""); 48 | 49 | const totalTime = Math.round(toHours(d.totalTime)); 50 | 51 | const status = `${totalTime}h`; 52 | const statusWithColor = `${chalk.gray(totalTime)}h`; 53 | 54 | const project = 55 | d.project + 56 | " ".repeat(maxBarLength - status.length - d.project.length) + 57 | statusWithColor; 58 | 59 | return project + "\n" + chalk.gray(bar); 60 | }) 61 | .join("\n"); 62 | }; 63 | 64 | module.exports = ({ projects }) => { 65 | return chart(projects, Math.min(80, process.stdout.columns)); 66 | }; 67 | -------------------------------------------------------------------------------- /utils/paths.js: -------------------------------------------------------------------------------- 1 | const envPaths = require("env-paths"); 2 | const fs = require("fs"); 3 | const mkdirp = require("mkdirp"); 4 | const path = require("path"); 5 | 6 | const CONFIG_PATH = envPaths("timav").config; 7 | const DATA_PATH = envPaths("timav").data; 8 | 9 | mkdirp(DATA_PATH); 10 | mkdirp(CONFIG_PATH); 11 | 12 | const CREDENTIALS_PATH = path.join(CONFIG_PATH, "credentials.json"); 13 | const CONFIG_FILE_PATH = path.join(CONFIG_PATH, "config.json"); 14 | 15 | const tokenPath = calendar => path.join(DATA_PATH, `${calendar}-token.json`); 16 | const syncTokenPath = calendar => 17 | path.join(DATA_PATH, `${calendar}-sync_token.json`); 18 | const eventsPath = calendar => path.join(DATA_PATH, `${calendar}-events.json`); 19 | const parsedEventsPath = calendar => 20 | path.join(DATA_PATH, `${calendar}-parsed_events.json`); 21 | 22 | const parseISOLocal = str => { 23 | const d = str.split(/\D/); 24 | return new Date(d[0], d[1] - 1, d[2], d[3], d[4], d[5]); 25 | }; 26 | 27 | const getParsedEvents = ({ calendar }) => { 28 | const fileName = parsedEventsPath(calendar); 29 | 30 | if (fs.existsSync(fileName)) { 31 | const data = fs.readFileSync(fileName, { encoding: "utf-8" }); 32 | const parsed = JSON.parse(data); 33 | 34 | const result = parsed.map(e => { 35 | e.start = parseISOLocal(e.start); 36 | e.end = parseISOLocal(e.end); 37 | 38 | return e; 39 | }); 40 | 41 | return result; 42 | } 43 | 44 | return []; 45 | }; 46 | 47 | const getSyncInfo = ({ calendar }) => { 48 | const fileName = syncTokenPath(calendar); 49 | 50 | if (fs.existsSync(fileName)) { 51 | const data = fs.readFileSync(fileName, { encoding: "utf-8" }); 52 | const parsed = JSON.parse(data); 53 | 54 | return parsed; 55 | } 56 | 57 | return []; 58 | }; 59 | 60 | const hasCredentials = () => fs.existsSync(CREDENTIALS_PATH); 61 | 62 | const hasConfigFile = () => fs.existsSync(CONFIG_FILE_PATH); 63 | 64 | module.exports = { 65 | CREDENTIALS_PATH, 66 | CONFIG_PATH, 67 | CONFIG_FILE_PATH, 68 | 69 | hasCredentials, 70 | hasConfigFile, 71 | 72 | getSyncInfo, 73 | getParsedEvents, 74 | 75 | tokenPath, 76 | syncTokenPath, 77 | eventsPath, 78 | parsedEventsPath 79 | }; 80 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const yargs = require("yargs"); 5 | const { spawn } = require("child_process"); 6 | 7 | const cache = require("./cache"); 8 | const habit = require("./habit"); 9 | const projects = require("./projects"); 10 | const stats = require("./stats"); 11 | const tags = require("./tags"); 12 | 13 | const { 14 | CONFIG_FILE_PATH, 15 | CONFIG_PATH, 16 | hasConfigFile, 17 | hasCredentials, 18 | getParsedEvents 19 | } = require("./utils/paths"); 20 | 21 | const { loadConfig } = require("./utils/config"); 22 | 23 | const args = yargs 24 | .command("config", "open configuration file") 25 | .command("cache", "cache updated events") 26 | .command("stats", "show basic stats") 27 | .command("tags", "top tags", yargs => { 28 | yargs.option("n", { 29 | describe: "show top [n] tags", 30 | default: 10 31 | }); 32 | }) 33 | .command("habit ", "habit and streak for ") 34 | .command( 35 | "projects [query]", 36 | "show projects matching optional [query]", 37 | yargs => { 38 | yargs.option("n", { 39 | describe: "show last [n] project", 40 | default: 10 41 | }); 42 | } 43 | ) 44 | .demandCommand(1, "you need to provide a command") 45 | .help().argv; 46 | 47 | const [TYPE] = args._; 48 | const COMMANDS = { habit, projects, stats, tags }; 49 | 50 | if (TYPE === "config") { 51 | const editor = process.env.EDITOR || "vim"; 52 | 53 | if (!hasConfigFile()) { 54 | fs.writeFileSync( 55 | CONFIG_FILE_PATH, 56 | JSON.stringify({ calendar: "" }, null, 2), 57 | "utf-8" 58 | ); 59 | } 60 | 61 | spawn(editor, [CONFIG_FILE_PATH], { stdio: "inherit" }); 62 | } else if (!hasCredentials()) { 63 | console.log( 64 | ` 65 | No credentials.json found! 66 | 67 | 1. create one here: https://console.developers.google.com/ for a CLI project with access to google calendar 68 | 2. save it to ${CONFIG_PATH}/credentials.json 69 | ` 70 | ); 71 | 72 | process.exit(1); 73 | } else if (TYPE === "cache") { 74 | cache({ calendar: loadConfig().calendar }); 75 | } else if (COMMANDS[TYPE]) { 76 | const { render, calculate } = COMMANDS[TYPE]; 77 | const { calendar } = loadConfig(); 78 | 79 | const events = getParsedEvents({ calendar }); 80 | 81 | const renderData = calculate({ 82 | events, 83 | calendar, 84 | ...args 85 | }); 86 | 87 | console.log(render(renderData)); 88 | } 89 | -------------------------------------------------------------------------------- /utils/filter.js: -------------------------------------------------------------------------------- 1 | const { startCase, isArray } = require("lodash"); 2 | 3 | const { parseProject } = require("./parse"); 4 | 5 | const stringifyFiltering = filtering => { 6 | if (isArray(filtering)) { 7 | const [logic, filter] = filtering; 8 | 9 | if (logic === "and") { 10 | return filter.map(stringifyFiltering).join(" ∧ "); 11 | } 12 | 13 | if (logic === "or") { 14 | return filter.map(stringifyFiltering).join(" ∨ "); 15 | } 16 | } else { 17 | return startCase( 18 | filtering 19 | .replace(/^@/, "") 20 | .replace(/\(/, " ") 21 | .replace(/\)/, " ") 22 | ); 23 | } 24 | }; 25 | 26 | const filterEvent = (event, filtering) => { 27 | const { project, tags } = parseProject(filtering); 28 | 29 | let isMatching = true; 30 | 31 | if (project.length > 0) { 32 | isMatching = isMatching && event.project === project; 33 | } 34 | 35 | if (tags.length > 0) { 36 | tags.forEach(({ tag, subTag }) => { 37 | isMatching = 38 | isMatching && 39 | event.tags.find(({ tag: eventTag, subTag: eventSubTag }) => { 40 | if (tag && subTag) { 41 | return eventTag === tag && eventSubTag === subTag; 42 | } 43 | 44 | if (tag && !subTag) { 45 | return eventTag === tag; 46 | } 47 | 48 | if (!tag && subTag) { 49 | return eventSubTag === subTag; 50 | } 51 | 52 | return false; 53 | }); 54 | }); 55 | } 56 | 57 | return isMatching; 58 | }; 59 | 60 | const filterEvents = (events, filtering) => { 61 | if (isArray(filtering)) { 62 | console.error("array-based filtering is not supported yet"); 63 | 64 | // const [logic, filter] = filtering; 65 | 66 | // // filter each of the children recursively 67 | // const filtered = filter.map(f => filterEvents(events, f)); 68 | 69 | // if (logic === "and") { 70 | // // wa want only events with keys that are in each filter 71 | // return Object.keys(events) 72 | // .filter(key => filtered.every(filteredEvents => Object.keys(filteredEvents).indexOf(key) >= 0)) 73 | // .reduce((memo, key) => Object.assign(memo, { [key]: events[key] }), {}); 74 | // } 75 | 76 | // if (logic === "or") { 77 | // // just merge all the filtered events 78 | // return filtered.reduce((memo, events) => Object.assign(memo, events), {}); 79 | // } 80 | } else { 81 | // if filtering is not an array, it means it's stringified tag + subtag 82 | return events.filter(event => filterEvent(event, filtering)); 83 | } 84 | }; 85 | 86 | module.exports = { filterEvents, stringifyFiltering }; 87 | -------------------------------------------------------------------------------- /utils/parse.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | const { chain } = require("lodash"); 3 | 4 | const FULL_DAY_EVENT_DATE_LENGTH = "yyyy-mm-dd".length; 5 | 6 | const parseProject = title => { 7 | const project = title.split("@")[0].trim(); 8 | 9 | const tags = chain(title) 10 | .split("@") 11 | .slice(1) 12 | .map(tag => { 13 | if (tag.indexOf("(") >= 0) { 14 | const tagName = tag.match(/(.+)\(/)[1]; 15 | const subTags = tag.match(/\((.+)(,|\))/)[1]; 16 | 17 | return subTags.split(",").map(subTag => ({ tag: tagName, subTag })); 18 | } else { 19 | return { tag: tag.trim() }; 20 | } 21 | }) 22 | .flatten() 23 | .value(); 24 | 25 | return { project, tags }; 26 | }; 27 | 28 | const parseToTimezoneIndependentDate = args => { 29 | if (args.date) { 30 | return new Date(args.date); 31 | } 32 | 33 | return new Date(args.dateTime); 34 | }; 35 | 36 | const parseWithTimezone = (dateStr, zone) => { 37 | const parsedStr = zone 38 | ? moment.tz(dateStr, zone).format("YYYY-MM-DDTHH:mm:ss") 39 | : moment(dateStr).format("YYYY-MM-DDTHH:mm:ss"); 40 | 41 | return `${parsedStr}:00.000Z`; 42 | }; 43 | 44 | const parseEvent = event => { 45 | // full-day events are markers 46 | const isMarker = 47 | event.start.date && 48 | event.end.date && 49 | event.start.date.length === FULL_DAY_EVENT_DATE_LENGTH && 50 | event.end.date.length === FULL_DAY_EVENT_DATE_LENGTH; 51 | 52 | const start = parseToTimezoneIndependentDate(event.start); 53 | const end = parseToTimezoneIndependentDate(event.end); 54 | 55 | const orgStart = event.start; 56 | const orgEnd = event.end; 57 | 58 | const startDateStr = parseWithTimezone( 59 | orgStart.dateTime || orgStart.date, 60 | orgStart.timeZone 61 | ); 62 | 63 | const endDateStr = parseWithTimezone( 64 | orgEnd.dateTime || orgEnd.date, 65 | orgEnd.timeZone 66 | ); 67 | 68 | const duration = !isMarker ? end - start : 0; 69 | const id = event.id; 70 | const note = event.description; 71 | 72 | const { project, tags } = parseProject(event.summary); 73 | 74 | const summary = `${project} ${tags 75 | .map(({ tag, subTag }) => (subTag ? `@${tag}(${subTag})` : `@${tag}`)) 76 | .sort() 77 | .join(" ")}`.trim(); 78 | 79 | return { 80 | id, 81 | 82 | summary, 83 | project, 84 | tags, 85 | note, 86 | duration, 87 | isMarker, 88 | 89 | start, 90 | end, 91 | 92 | startDateStr, 93 | endDateStr, 94 | 95 | orgStart, 96 | orgEnd 97 | }; 98 | }; 99 | 100 | module.exports = { parseEvent, parseProject }; 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # timav 2 | 3 | timav is a cli tool and a library for working with time tracking data kept in google calendar. For historical reference check out [timav blog post](https://szymonkaliski.com/writing/2017-04-30-time-tracking/). 4 | 5 |

6 | 7 | This is personal software project, shared mainly as a reference point. I wont be fixing bugs that don't happen to me, or add functionalities I don't want/need. 8 | 9 | ## Tracking 10 | 11 | The system for describing logs looks like this: 12 | 13 | ``` 14 | [Project] @tag1 @tag2(subtag) 15 | ``` 16 | 17 | - `Project` is optional, and allows me to group logs for given project, it's usually a client project I work on, or well-defined personal project 18 | - `@tag` is additional metadata that I can use to analyse the logs, I can add how many tags I want to a given event, but it's usually just a few; at the moment most often used ones are: `@work`, `@personal`, `@code(...)` 19 | - `@tag(subtag)` is a shorthand for having more data about a given tag, but allows me to still nest them under one bigger thing, this might be overly complex, but works for me — I use `@code(js)` when I'm writing JavaScript, `@code(clj)` for Clojure, `@health(gym)` for when I'm training and `@health(walk)` when strolling 20 | 21 | ## Installation 22 | 23 | 1. `npm install -g timav-cli` 24 | 2. `timav config`, and set: 25 | 26 | ```json 27 | { 28 | "calendar": "Tracking" // the name of google calendar with tracking data 29 | } 30 | ``` 31 | 32 | 3. `DEBUG=* timav cache` - first time should be run in interactive bash session, as it will lead you through the setup process 33 | 4. ideally add `timav cache` as a cronjob - I run it every 15 minutes 34 | 35 | ## Usage 36 | 37 | - `timav cache` - download/update cached events 38 | - `timav stats` - basic statistics 39 | - `timav tags` - top-used tags 40 | - `timav tags -n 2` - specify how many tags to display 41 | - `timav habit ` - show habit-like chart for specified query, for example: `timav habit @personal` 42 | - `timav projects [query]` - show last projects, `query` is optional 43 | - `timav projects @work -n 2` - show last two work projects 44 | 45 | ## Library 46 | 47 | In addition, `timav-cli` can be used as a library, example dashboard code used for the screenshot at the top of this file: 48 | 49 | ```js 50 | #!/usr/bin/env node 51 | 52 | const habit = require("timav-cli/habit"); 53 | const projects = require("timav-cli/projects"); 54 | const stats = require("timav-cli/stats"); 55 | const tags = require("timav-cli/tags"); 56 | const { getParsedEvents } = require("timav-cli/utils/paths"); 57 | const { loadConfig } = require("timav-cli/utils/config"); 58 | 59 | const { calendar } = loadConfig(); 60 | const events = getParsedEvents({ calendar }); 61 | 62 | let result = ""; 63 | 64 | // basic stats 65 | result += stats.render(stats.calculate({ events, calendar })); 66 | result += "\n\n"; 67 | 68 | // my habits 69 | result += ["@personal", "@research", "@work", "@health"] 70 | .map(query => habit.render(habit.calculate({ events, query }))) 71 | .join("\n"); 72 | 73 | // top 6 tags 74 | result += "\n\n"; 75 | result += tags.render(tags.calculate({ events, calendar, n: 6 })); 76 | 77 | // last 6 personal projects 78 | result += "\n\n"; 79 | result += projects.render(projects.calculate({ events, calendar, query: "@personal", n: 6 })); 80 | 81 | console.log(result); 82 | ``` 83 | 84 | -------------------------------------------------------------------------------- /cache/index.js: -------------------------------------------------------------------------------- 1 | const debug = require("debug")("cache"); 2 | const fs = require("fs"); 3 | const readline = require("readline"); 4 | const { OAuth2Client } = require("google-auth-library"); 5 | const { chain } = require("lodash"); 6 | const { google } = require("googleapis"); 7 | 8 | const { parseEvent } = require("../utils/parse"); 9 | const { 10 | CREDENTIALS_PATH, 11 | 12 | tokenPath, 13 | syncTokenPath, 14 | eventsPath, 15 | parsedEventsPath, 16 | } = require("../utils/paths"); 17 | 18 | const SCOPES = ["https://www.googleapis.com/auth/calendar"]; 19 | 20 | // tokens 21 | 22 | const storeToken = ({ calendar }, token) => { 23 | const fileName = tokenPath(calendar); 24 | 25 | fs.writeFileSync(fileName, JSON.stringify(token, null, 2)); 26 | debug("Token stored in:", fileName); 27 | }; 28 | 29 | const storeSyncToken = ({ calendar }, token) => { 30 | const fileName = syncTokenPath(calendar); 31 | 32 | const time = new Date().getTime(); 33 | fs.writeFileSync(fileName, JSON.stringify({ token, time }, null, 2)); 34 | debug("Sync token stored in:", fileName); 35 | }; 36 | 37 | const getSyncToken = ({ calendar }) => { 38 | const fileName = syncTokenPath(calendar); 39 | 40 | if (fs.existsSync(fileName)) { 41 | return require(fileName).token; 42 | } 43 | 44 | return { token: null, time: null }; 45 | }; 46 | 47 | const getNewToken = ({ calendar }, oauth2Client, callback) => { 48 | const authUrl = oauth2Client.generateAuthUrl({ 49 | ["access_type"]: "offline", 50 | scope: SCOPES, 51 | }); 52 | 53 | debug("Authorize this app by visiting this url:", authUrl); 54 | 55 | const rl = readline.createInterface({ 56 | input: process.stdin, 57 | output: process.stdout, 58 | }); 59 | 60 | rl.question("Enter the code from that page here:", (code) => { 61 | rl.close(); 62 | 63 | oauth2Client.getToken(code, (err, token) => { 64 | if (err) { 65 | debug("Error while trying to retrieve access token:", err); 66 | return; 67 | } 68 | 69 | oauth2Client.credentials = token; 70 | 71 | storeToken({ calendar }, token); 72 | callback(null, oauth2Client); 73 | }); 74 | }); 75 | }; 76 | 77 | // auth 78 | 79 | const authorize = ({ calendar }, credentials, callback) => { 80 | const clientSecret = credentials.installed.client_secret; 81 | const clientId = credentials.installed.client_id; 82 | const redirectUrl = credentials.installed.redirect_uris[0]; 83 | 84 | const oauth2Client = new OAuth2Client(clientId, clientSecret, redirectUrl); 85 | 86 | fs.readFile(tokenPath(calendar), (err, token) => { 87 | if (err) { 88 | getNewToken({ calendar }, oauth2Client, callback); 89 | } else { 90 | oauth2Client.credentials = JSON.parse(token); 91 | callback(null, oauth2Client); 92 | } 93 | }); 94 | }; 95 | 96 | // calendar 97 | 98 | const getCalendars = (auth, callback) => { 99 | const calendar = google.calendar("v3"); 100 | calendar.calendarList.list({ auth }, callback); 101 | }; 102 | 103 | // events 104 | 105 | const getEvents = ({ auth, calendarId, pageToken, syncToken }, callback) => { 106 | const calendar = google.calendar("v3"); 107 | 108 | const config = { 109 | calendarId, 110 | auth, 111 | maxResults: 100, 112 | singleEvents: true, 113 | }; 114 | 115 | if (pageToken) { 116 | config.pageToken = pageToken; 117 | } 118 | 119 | if (syncToken) { 120 | config.syncToken = syncToken; 121 | } 122 | 123 | calendar.events.list(config, callback); 124 | }; 125 | 126 | const getAllEvents = ( 127 | { auth, calendarId, pageToken, syncToken, allEvents }, 128 | callback 129 | ) => { 130 | allEvents = allEvents || []; 131 | 132 | debug("Downloading page:", { pageToken, syncToken }); 133 | 134 | getEvents({ auth, calendarId, pageToken, syncToken }, (err, response) => { 135 | if (err) { 136 | console.log("Error:", err); 137 | return; 138 | } 139 | 140 | const nextAllEvents = allEvents.concat(response.data.items); 141 | 142 | if (!response.data.nextPageToken) { 143 | return callback(null, { 144 | events: nextAllEvents, 145 | syncToken: response.data.nextSyncToken, 146 | }); 147 | } 148 | 149 | getAllEvents( 150 | { 151 | auth, 152 | calendarId, 153 | pageToken: response.data.nextPageToken, 154 | syncToken, 155 | allEvents: nextAllEvents, 156 | }, 157 | callback 158 | ); 159 | }); 160 | }; 161 | 162 | const storeEvents = ({ calendar }, events) => { 163 | const fileName = eventsPath(calendar); 164 | 165 | fs.writeFileSync(fileName, JSON.stringify(events, null, 2)); 166 | debug("Events stored in:", fileName); 167 | }; 168 | 169 | const storeParsedEvents = ({ calendar }, events) => { 170 | const fileName = parsedEventsPath(calendar); 171 | 172 | fs.writeFileSync(fileName, JSON.stringify(events, null, 2)); 173 | debug("Parsed events stored in:", fileName); 174 | }; 175 | 176 | const getStoredEvents = ({ calendar }) => { 177 | const fileName = eventsPath(calendar); 178 | 179 | if (fs.existsSync(fileName)) { 180 | return require(fileName); 181 | } 182 | 183 | return []; 184 | }; 185 | 186 | // main 187 | 188 | module.exports = (options) => { 189 | return new Promise((resolve) => { 190 | const credentials = require(CREDENTIALS_PATH); 191 | 192 | authorize({ calendar: options.calendar }, credentials, (err, auth) => { 193 | if (err) { 194 | console.log("Error:", err); 195 | process.exit(1); 196 | } 197 | 198 | getCalendars(auth, (err, res) => { 199 | if (err) { 200 | console.log("Error:", err); 201 | process.exit(1); 202 | } 203 | 204 | const calendar = res.data.items.find( 205 | ({ summary }) => summary === options.calendar 206 | ); 207 | 208 | if (!calendar) { 209 | console.log("Error: no matching calendar found"); 210 | process.exit(1); 211 | } 212 | 213 | getAllEvents( 214 | { 215 | auth, 216 | calendarId: calendar.id, 217 | syncToken: getSyncToken({ calendar: options.calendar }), 218 | }, 219 | (err, { events, syncToken }) => { 220 | if (err) { 221 | console.log("Error:", err); 222 | process.exit(1); 223 | } 224 | 225 | storeSyncToken({ calendar: options.calendar }, syncToken); 226 | 227 | if (events.length === 0) { 228 | debug("No changes"); 229 | return resolve(); 230 | } 231 | 232 | debug("API events\n", events); 233 | 234 | const prevEvents = getStoredEvents({ calendar: options.calendar }); 235 | 236 | const finalEvents = chain(prevEvents) 237 | .map((e) => { 238 | const matchingEvent = events.find((e2) => e2.id === e.id); 239 | 240 | if (matchingEvent) { 241 | debug("Updated event\n", matchingEvent); 242 | return matchingEvent; 243 | } 244 | 245 | return e; 246 | }) 247 | .concat( 248 | events.filter((e) => { 249 | // new 250 | const matchingEvent = prevEvents.find((e2) => e2.id === e.id); 251 | 252 | if (!matchingEvent) { 253 | debug("New event\n", e); 254 | } 255 | 256 | return !matchingEvent; 257 | }) 258 | ) 259 | .filter((e) => e.status !== "cancelled") 260 | .value(); 261 | 262 | const parsedEvents = chain(finalEvents) 263 | .map(parseEvent) 264 | .sortBy((e) => e.start) 265 | .value(); 266 | 267 | storeEvents({ calendar: options.calendar }, finalEvents); 268 | storeParsedEvents({ calendar: options.calendar }, parsedEvents); 269 | 270 | resolve(); 271 | } 272 | ); 273 | }); 274 | }); 275 | }); 276 | }; 277 | --------------------------------------------------------------------------------