From ab51b4c11d4a9029dcb0e1ec5954dce0bf420996 Mon Sep 17 00:00:00 2001 From: Marko Korhonen Date: Wed, 22 Nov 2023 20:57:42 +0200 Subject: [PATCH] Add configuration file support --- README.adoc | 10 +++-- config.toml | 29 ++++++++++++ package-lock.json | 18 ++++++++ package.json | 2 + src/config.ts | 85 ++++++++++++++++++++++++++++++++++++ src/main.ts | 109 +++++++++++++++++++++++++++------------------- src/parse.ts | 2 +- 7 files changed, 204 insertions(+), 51 deletions(-) create mode 100644 config.toml create mode 100644 src/config.ts diff --git a/README.adoc b/README.adoc index 7ee78f9..b4b689a 100644 --- a/README.adoc +++ b/README.adoc @@ -52,12 +52,14 @@ To alleviate my pains, I included the following features This is a highly opinionated tool I built for my specific needs. There probably exists other tools to do the same task (maybe even better) but I wanted something simple that fits for my -needs specifically. In time, I will probably make it more generic and -configurable. For now, the following assumptions are made. +needs specifically. -* You have an unpaid 30 minute lunch break +== Configuration file + +See the https://git.korhonen.cc/FunctionalHacker/work-time-calculator/src/branch/main/config.toml[default configuration file] +for more information on how to override configurations. == TODO -* [ ] Configuration file for default settings and altering behaviour in interactive mode +* [x] Configuration file for default settings and altering behaviour in interactive mode * [ ] Non-interactive mode with CLI arguments parsing diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..976c159 --- /dev/null +++ b/config.toml @@ -0,0 +1,29 @@ +# Work Time Calculator configuration file +# This is the default configuration. +# You can only partially override the config, +# any missing values will use the defaults described here. +# You can place your configuration file in $XDG_CONFIG_HOME/wtc/config.toml, +# usually ~/.config/wtc/config.toml + +# This section is for default values for inputs +[defaults] +# Leave empty if you don't have an unpaid lunch break +# or if you normally log your lunch break hours +lunchBreakDuration = "00:30" +# Your work day duration +workDayDuration = "07:30" +# The time you start working +startTime = "08:00" +# The time you stop working. Can either be "now" or a time +stopTime = "now" + +# This section can be used to disable prompts for each +# of the questions. The default value will be used automatically +# if the setting is set to false +[askInput] +workDayDuration = true +startTime = true +stopTime = true +logged = true +# It is assumed that you didn't have lunch if this is false +hadLunch = true diff --git a/package-lock.json b/package-lock.json index e5cc049..04db5dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.10", "license": "MIT", "dependencies": { + "@iarna/toml": "^2.2.5", "chalk": "^5.3.0", "dayjs": "^1.11.10", + "xdg-basedir": "^5.1.0", "yargs": "^17.7.2" }, "bin": { @@ -128,6 +130,11 @@ "dev": true, "peer": true }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1779,6 +1786,17 @@ "dev": true, "peer": true }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 61a1b2c..20204b9 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,10 @@ "typescript": "^5.2.2" }, "dependencies": { + "@iarna/toml": "^2.2.5", "chalk": "^5.3.0", "dayjs": "^1.11.10", + "xdg-basedir": "^5.1.0", "yargs": "^17.7.2" } } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..224dad6 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,85 @@ +import fs from 'fs'; +import path from 'path'; +import { xdgConfig } from 'xdg-basedir'; +import toml from '@iarna/toml'; +import { Dayjs } from 'dayjs'; +import { Duration } from 'dayjs/plugin/duration.js'; +import { parseDuration, parseTimestamp } from './parse.js'; + +const { debug } = console; + +interface Config { + defaults: { + workDayDuration: Duration; + lunchBreakDuration: Duration; + startTime: Dayjs; + stopTime: Dayjs; + }; + askInput: { + workDayLength: boolean; + startTime: boolean; + stopTime: boolean; + logged: boolean; + hadLunch: boolean; + }; +} + +interface RawConfig extends Omit { + defaults: { + workDayDuration: string; + lunchBreakDuration: string; + startTime: string; + stopTime: string; + }; +} + +const defaultConfig: RawConfig = { + defaults: { + workDayDuration: '07:30', + lunchBreakDuration: '00:30', + startTime: '08:00', + stopTime: 'now', + }, + askInput: { + workDayLength: true, + startTime: true, + stopTime: true, + logged: true, + hadLunch: true, + }, +}; + +const getConfig = (): Config => { + const configDir = xdgConfig || path.join(process.env.HOME ?? './', '.config'); + let configFilePath = path.join(configDir, 'wct', 'config.toml'); + + let configData: RawConfig; + if (fs.existsSync(configFilePath)) { + configData = toml.parse(fs.readFileSync(configFilePath, 'utf8')) as unknown as RawConfig; + } else { + debug('Configuration file does not exist, loading defaults'); + configData = defaultConfig; + } + + return { + defaults: { + workDayDuration: parseDuration( + configData.defaults.workDayDuration ?? defaultConfig.defaults.workDayDuration, + ), + lunchBreakDuration: parseDuration( + configData.defaults.lunchBreakDuration ?? defaultConfig.defaults.workDayDuration, + ), + startTime: parseTimestamp(configData.defaults.startTime ?? defaultConfig.defaults.startTime), + stopTime: parseTimestamp(configData.defaults.stopTime ?? defaultConfig.defaults.stopTime), + }, + askInput: { + workDayLength: configData.askInput.workDayLength ?? defaultConfig.askInput.workDayLength, + startTime: configData.askInput.startTime ?? defaultConfig.askInput.startTime, + stopTime: configData.askInput.stopTime ?? defaultConfig.askInput.stopTime, + logged: configData.askInput.logged ?? defaultConfig.askInput.logged, + hadLunch: configData.askInput.hadLunch ?? defaultConfig.askInput.hadLunch, + }, + }; +}; + +export default getConfig; diff --git a/src/main.ts b/src/main.ts index e8a0c74..83c92e2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,83 +6,97 @@ import * as readline from 'readline/promises'; import { formatDuration, formatTime, formatTimestamp, getHoursRoundedStr } from './format.js'; import duration, { Duration } from 'dayjs/plugin/duration.js'; import { parseDuration, parseTimestamp } from './parse.js'; +import getConfig from './config.js'; dayjs.extend(duration); const { log, error } = console; -const defaultStartTime = '08:00'; -const lunchBreakDuration = dayjs.duration({ minutes: 30 }); -const defaultWorkDayDuration = dayjs.duration({ hours: 7, minutes: 30 }); const ui = async () => { + const { defaults, askInput } = getConfig(); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); let startedAt: Dayjs | undefined = undefined; - const now = dayjs(); + let stoppedAt: Dayjs | undefined = undefined; + let stoppedWorking = false; try { // Get work day duration let workDayDuration: Duration | undefined = undefined; - const durationAnswer = await rl.question( - `How long is your work day today, excluding the lunch break? [${formatDuration( - defaultWorkDayDuration, - true, - )}] `, - ); - if (durationAnswer !== '') { - workDayDuration = parseDuration(durationAnswer); - if (workDayDuration.asMinutes() <= 0) { - error( - chalk.red( - `Failed to parse ${durationAnswer} to duration, using default work day duration ${defaultWorkDayDuration}`, - ), - ); - workDayDuration = undefined; + if (askInput.workDayLength) { + const durationAnswer = await rl.question( + `How long is your work day today, excluding the lunch break? [${formatDuration( + defaults.workDayDuration, + true, + )}] `, + ); + if (durationAnswer !== '') { + workDayDuration = parseDuration(durationAnswer); + if (workDayDuration.asMinutes() <= 0) { + error( + chalk.red( + `Failed to parse ${durationAnswer} to duration, using default work day duration ${formatDuration( + defaults.workDayDuration, + true, + )}`, + ), + ); + workDayDuration = undefined; + } } } if (!workDayDuration) { - workDayDuration = defaultWorkDayDuration; + workDayDuration = defaults.workDayDuration; } - // Calculate worked time - const startTimeAnswer = await rl.question(`What time did you start work today? [${defaultStartTime}] `); - if (startTimeAnswer !== '') { - startedAt = parseTimestamp(startTimeAnswer); - if (!startedAt.isValid()) { - error( - chalk.red( - `Failed to parse ${startTimeAnswer} to time, using default start time ${defaultStartTime}`, - ), - ); + if (askInput.startTime) { + const startTimeAnswer = await rl.question( + `What time did you start work today? [${formatTime(defaults.startTime)}] `, + ); + if (startTimeAnswer !== '') { + startedAt = parseTimestamp(startTimeAnswer); + if (!startedAt.isValid()) { + error( + chalk.red( + `Failed to parse ${startTimeAnswer} to time, using default start time ${formatTime( + defaults.startTime, + )}`, + ), + ); + } } } if (!startedAt?.isValid()) { - startedAt = parseTimestamp(defaultStartTime); + startedAt = defaults.startTime; } - let stoppedWorking = false; - let stoppedAt: Dayjs | undefined = undefined; - const stoppedAnswer = await rl.question( - `What time did you stop working (default is current time if you didn't stop yet)? [${formatTime(now)}] `, - ); + if (askInput.stopTime) { + const stoppedAnswer = await rl.question( + `What time did you stop working (default is current time if you didn't stop yet)? [${formatTime( + defaults.stopTime, + )}] `, + ); - if (stoppedAnswer === '') { - stoppedAt = now; - } else { - stoppedWorking = true; - stoppedAt = parseTimestamp(stoppedAnswer); - if (!stoppedAt.isValid()) { - error(`Failed to parse ${stoppedAnswer} to time, using current time`); - stoppedAt = dayjs(); + if (stoppedAnswer !== '') { + stoppedWorking = true; + stoppedAt = parseTimestamp(stoppedAnswer); + if (!stoppedAt.isValid()) { + error(`Failed to parse ${stoppedAnswer} to time, using current time`); + stoppedAt = dayjs(); + } } } + if (!stoppedAt) { + stoppedAt = defaults.stopTime; + } + if (stoppedAt.isSame(startedAt) || stoppedAt.isBefore(startedAt)) { error( chalk.red( @@ -96,8 +110,11 @@ const ui = async () => { let worked = dayjs.duration(stoppedAt.diff(startedAt)); - if ((await rl.question('Did you have a lunch break? [Y/n] ')).toLowerCase() !== 'n') { - worked = worked.subtract(lunchBreakDuration); + const hadLunch = + askInput.hadLunch && (await rl.question('Did you have a lunch break? [Y/n] ')).toLowerCase() !== 'n'; + + if (hadLunch) { + worked = worked.subtract(defaults.lunchBreakDuration); } // Calculate unlogged time diff --git a/src/parse.ts b/src/parse.ts index a3cab22..4abe2fd 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -5,7 +5,7 @@ import duration, { Duration } from 'dayjs/plugin/duration.js'; dayjs.extend(customParseFormat); dayjs.extend(duration); -export const parseTimestamp = (time: string): Dayjs => dayjs(time, 'HH:mm', true); +export const parseTimestamp = (time: string): Dayjs => (time === 'now' ? dayjs() : dayjs(time, 'HH:mm', true)); export const parseDuration = (time: string): Duration => { const [hours, minutes] = time.split(':').map(Number);