Compare commits

...

47 commits
v0.0.5 ... main

Author SHA1 Message Date
c97472f6f5
i18n CLI options 2023-11-27 17:57:13 +02:00
3239a7c611
Bundle all files to single JavaScript file, add development mode 2023-11-24 16:45:23 +02:00
820a49efd8
Fix main directive in package.json 2023-11-24 16:06:57 +02:00
e44fc052f2
Add dayjs configurator 2023-11-24 16:03:44 +02:00
bb375e62ea
Update package-lock.json 2023-11-24 16:03:29 +02:00
0ce99d9235
1.0.2 2023-11-23 20:56:40 +02:00
13b4c3a640
Fix typo 2023-11-23 20:56:11 +02:00
e7a67ebfa0
Fix displaying overlogged time as negative 2023-11-23 20:53:37 +02:00
f8cf9bad7d
Fix crash on missing config sections 2023-11-23 20:46:58 +02:00
acb07d0cdc
Fix config schema 2023-11-23 20:31:05 +02:00
a2e53e32a6
1.0.1 2023-11-23 20:25:46 +02:00
6bbfd25745
Fix running on older nodejs versions
The shebang does not work without extension
2023-11-23 20:24:35 +02:00
536aaa01b0
Add update instructions 2023-11-23 19:57:46 +02:00
f693c90dec
1.0.0 2023-11-23 19:51:52 +02:00
b08ab097ba
Add config.defaults.hadLunch and rework lunch messages 2023-11-23 19:51:20 +02:00
91a7362495
Update description 2023-11-23 19:17:12 +02:00
2a9e94389b
Config schema: fix language 2023-11-23 19:16:21 +02:00
567bc7a5f2
Config schema: Don't require any properties 2023-11-23 19:14:33 +02:00
71e8352ecf
Add output for lunch break duration and rework config lunch option 2023-11-23 19:13:39 +02:00
29ded9426b
Add schema to config 2023-11-23 18:57:14 +02:00
87737b0118
Fix finnish time format 2023-11-23 18:33:50 +02:00
cc6b197d82
Resolve rollup build warnings 2023-11-23 18:28:42 +02:00
b76700923d
Add support for configurable timestamp format 2023-11-23 18:04:42 +02:00
aee473b93b
Fix unlogged print 2023-11-23 17:55:42 +02:00
bb79ecdaa1
Fix missing overtime print 2023-11-23 17:49:34 +02:00
cd891bf6a0
Add shebang to output file so a shell script is no longer needed 2023-11-23 17:38:30 +02:00
4b8c8be226
Add i18n support and translations for finnish language 2023-11-23 17:24:08 +02:00
b88e19e311
Split code more into modules 2023-11-22 22:13:00 +02:00
3246815997
Remove unneeded dependencies 2023-11-22 21:36:26 +02:00
28fe8772b1
Remove debug log 2023-11-22 21:33:48 +02:00
df334bc297
Bundle application with rollup 2023-11-22 21:32:31 +02:00
312ac4e819
Improve stop time print 2023-11-22 21:06:10 +02:00
ab51b4c11d
Add configuration file support 2023-11-22 21:03:26 +02:00
d141fe1d36
Add --version and --help 2023-11-15 18:14:39 +02:00
38b1e14b5c
Fix overtime duration constructor 2023-11-14 22:27:08 +02:00
a929c8aec8
0.0.10 2023-11-14 21:48:30 +02:00
1148ba5244
Fix logic error in unlogged calculation 2023-11-14 21:48:08 +02:00
8f794076f8
0.0.9 2023-11-14 21:34:16 +02:00
d2d2dda9a4
Fix pwd in startup script 2023-11-14 21:33:55 +02:00
73b671cab2
0.0.8 2023-11-14 21:29:12 +02:00
82e18eb286
Use a shell script to fix running on older node versions 2023-11-14 21:28:44 +02:00
6571a5daf8
0.0.7 2023-11-14 21:16:05 +02:00
412ec3c887
Revert "0.0.6"
This reverts commit c8536e5f6e.
2023-11-14 21:15:43 +02:00
c8536e5f6e
0.0.6 2023-11-14 21:03:34 +02:00
28d6c8d120
Fix "unknown file extension" nodejs error 2023-11-14 21:03:09 +02:00
9b9666ca32
Add more rationale in the readme 2023-11-14 20:07:32 +02:00
8a119295d5
Add image to README 2023-11-14 19:58:06 +02:00
23 changed files with 1533 additions and 205 deletions

View file

@ -1,9 +1,10 @@
.PHONY: help build clean update-npmjs-readme release publish
.PHONY: help prod dev clean update-npmjs-readme release publish
help:
@echo "Available targets:"
@echo " - help: Show this help message"
@echo " - build: Build the project"
@echo " - prod: Build the project in production mode"
@echo " - dev: Build the project in development mode"
@echo " - clean: Remove build artifacts"
@echo " - release: Create a new release version"
@echo " - publish: Publish the new version created with the release target"
@ -11,13 +12,17 @@ help:
node_modules:
npm install
build: node_modules
npm run build
prod: node_modules
npm run prod
dev: node_modules
npm run dev
chmod +x dist/wtc-dev.mjs
clean:
rm -r dist node_modules
release: build
release: prod
@read -p "Enter version bump (patch, minor, major): " bump && \
version=$$(npm version $$bump | grep -oP "(?<=v)[^']+") && \
echo "Version $$version created. Run 'make publish' to push the changes and publish the package."

View file

@ -2,6 +2,8 @@
An interactive CLI tool to calculate work time.
image::img/demo.png[]
== Install
You can run this in your terminal
@ -33,15 +35,35 @@ After installation, you should be able to run the program with
wtc
----
== Update
To update, just run the install command again
== Rationale
This is a highly opinionated CLI tool I built for my specific needs.
In time, I will probable make it more generic and configurable.
For now, the following assumptions are made.
Don't know if it's just me but calculating my working hours sometimes
can get difficult. Especially if you have flexible hours and you end up
starting at a weird time, f.ex 08:15. This combined with the fact that
I have to log my hours to many different tasks, at the end of the day
calculating all this can get very confusing.
* You have an unpaid 30 minute lunch break
To alleviate my pains, I included the following features
* Asks wether you already had lunch or not and accommodates this in the calculation
* Asks the hours that you already logged and calculates unlogged hours
* Calculates how much under/overtime you worked
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.
== Configuration file
See the https://git.korhonen.cc/FunctionalHacker/work-time-calculator/src/branch/main/config/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

View file

@ -1,2 +1,4 @@
#!/usr/bin/env node
import '../dist/main.js';
#!/bin/sh
DIR="$(dirname "$(readlink -f "$0")")"
node "$DIR/../dist/wtc.mjs" "$@"

49
config/config-schema.json Normal file
View file

@ -0,0 +1,49 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"unpaidLunchBreakDuration": {
"type": "string",
"description": "Comment out or remove if you don't have an unpaid lunch break or if you normally log your lunch break hours"
},
"language": {
"type": "string",
"enum": ["en", "fi"],
"description": "The language of the application. Currently supported languages are English (en) and Finnish (fi)"
},
"timestampFormat": {
"type": "string",
"description": "Time format used to display timestamps (started, stopped, etc.). Refer to the dayjs documentation on how to set this https://day.js.org/docs/en/display/format"
},
"defaults": {
"type": "object",
"properties": {
"workDayDuration": { "type": "string", "description": "Your work day duration" },
"startTime": { "type": "string", "description": "The time you start working" },
"stopTime": {
"type": ["string", "null"],
"description": "The time you stop working. Can either be 'now' or a time"
},
"hadLunch": { "type": "boolean", "description": "Wether you had lunch already or not" }
},
"additionalProperties": false,
"description": "Default values for inputs"
},
"askInput": {
"type": "object",
"properties": {
"workDayDuration": {
"type": "boolean",
"description": "Disable prompt for work day duration if set to false"
},
"startTime": { "type": "boolean", "description": "Disable prompt for start time if set to false" },
"stopTime": { "type": "boolean", "description": "Disable prompt for stop time if set to false" },
"logged": { "type": "boolean", "description": "Disable prompt for logged time if set to false" }
},
"additionalProperties": false,
"description": "Settings to disable prompts"
}
},
"additionalProperties": false,
"description": "Work Time Calculator configuration file. Configuration file location: $XDG_CONFIG_HOME/wtc/config.toml, usually ~/.config/wtc/config.toml"
}

43
config/config.toml Normal file
View file

@ -0,0 +1,43 @@
#:schema https://git.korhonen.cc/FunctionalHacker/work-time-calculator/raw/branch/main/config/config-schema.json
# 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.
# On Unix/Linux you can place your configuration file in $XDG_CONFIG_HOME/wtc/config.toml,
# usually ~/.config/wtc/config.toml
# For windows, I don't know.
# Comment out or remove if you don't have an unpaid lunch break
# or if you normally log your lunch break hours
unpaidLunchBreakDuration = "00:30"
# The language of the application.
# Currently supported languages are "en", "fi"
language = "en"
# Time format used to display timestamps (started, stopped etc.)
# Refer to the dayjs documentation on how to set this https://day.js.org/docs/en/display/format
# For example, the finnish format would be "MM.DD.YYYY [kello] HH.mm"
timestampFormat = "YYYY-MM-DD HH:mm"
# This section is for default values for inputs
[defaults]
# 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

BIN
img/demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

812
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "work-time-calculator",
"version": "0.0.5",
"version": "1.0.2",
"description": "An interactive CLI tool to calculate work time",
"license": "MIT",
"repository": {
@ -13,12 +13,13 @@
"email": "wtc@functionalhacker.korhonen.cc"
},
"type": "module",
"main": "src/main.ts",
"bin": {
"wtc": "bin/wtc"
},
"main": "dist/wtc.mjs",
"scripts": {
"build": "tsc"
"prod": "rollup -c ./rollup.prod.config.js",
"dev": "rollup -c ./rollup.dev.config.js"
},
"keywords": [
"work",
@ -27,13 +28,22 @@
],
"author": "Marko Korhonen <marko@korhonen.cc>",
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.5",
"@types/node": "^20.9.0",
"@types/yargs": "^17.0.31",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"eslint-config-prettier": "^9.0.0",
"typescript": "^5.2.2"
"rollup": "^4.5.1",
"rollup-plugin-add-shebang": "^0.3.1",
"tslib": "^2.6.2"
},
"dependencies": {
"chalk": "^5.3.0",
"dayjs": "^1.11.10"
"dayjs": "^1.11.10",
"iarna-toml-esm": "^3.0.5",
"xdg-basedir": "^5.1.0",
"yargs": "^17.7.2"
}
}

15
rollup.dev.config.js Normal file
View file

@ -0,0 +1,15 @@
import typescript from '@rollup/plugin-typescript';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import shebang from 'rollup-plugin-add-shebang';
/** @type {import('rollup').RollupOptions} */
const config = {
input: 'src/main.ts',
output: {
format: 'esm',
file: 'dist/wtc-dev.mjs',
},
plugins: [typescript(), nodeResolve({ exportConditions: ['node'] }), shebang({ include: 'dist/wtc-dev.mjs' })],
};
export default config;

15
rollup.prod.config.js Normal file
View file

@ -0,0 +1,15 @@
import typescript from '@rollup/plugin-typescript';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
/** @type {import('rollup').RollupOptions} */
const config = {
input: 'src/main.ts',
output: {
format: 'esm',
file: 'dist/wtc.mjs',
},
plugins: [typescript(), nodeResolve({ exportConditions: ['node'] }), terser()],
};
export default config;

71
src/config.ts Normal file
View file

@ -0,0 +1,71 @@
import fs from 'fs';
import path from 'path';
import { xdgConfig } from 'xdg-basedir';
import {parse} from 'iarna-toml-esm';
import { parseDuration, parseTimestamp } from './parse.js';
import WtcConfig from './types/WtcConfig.js';
import Language from './types/Language.js';
interface RawConfig extends Omit<WtcConfig, 'unpaidLunchBreakDuration' | 'defaults'> {
unpaidLunchBreakDuration: string;
defaults: {
workDayDuration: string;
startTime: string;
stopTime: string;
hadLunch: boolean;
};
}
const defaultConfig: RawConfig = {
language: Language.en,
timestampFormat: 'YYYY-MM-DD HH:mm',
unpaidLunchBreakDuration: '00:30',
defaults: {
workDayDuration: '07:30',
startTime: '08:00',
stopTime: 'now',
hadLunch: true,
},
askInput: {
workDayDuration: true,
startTime: true,
stopTime: true,
logged: true,
},
};
const getConfig = (): WtcConfig => {
const configDir = xdgConfig || path.join(process.env.HOME ?? './', '.config');
const configFilePath = path.join(configDir, 'wtc', 'config.toml');
let configData: Partial<RawConfig>;
if (fs.existsSync(configFilePath)) {
configData = parse(fs.readFileSync(configFilePath, 'utf8')) as unknown as RawConfig;
} else {
configData = defaultConfig;
}
const { language, timestampFormat, unpaidLunchBreakDuration, defaults, askInput } = configData;
return {
language: language ?? defaultConfig.language,
timestampFormat: timestampFormat ?? defaultConfig.timestampFormat,
unpaidLunchBreakDuration: !unpaidLunchBreakDuration
? undefined
: parseDuration(unpaidLunchBreakDuration),
defaults: {
workDayDuration: parseDuration(defaults?.workDayDuration ?? defaultConfig.defaults.workDayDuration),
startTime: parseTimestamp(defaults?.startTime ?? defaultConfig.defaults.startTime),
stopTime: parseTimestamp(defaults?.stopTime ?? defaultConfig.defaults.stopTime),
hadLunch: defaults?.hadLunch ?? defaultConfig.defaults.hadLunch,
},
askInput: {
workDayDuration: askInput?.workDayDuration ?? defaultConfig.askInput.workDayDuration,
startTime: askInput?.startTime ?? defaultConfig.askInput.startTime,
stopTime: askInput?.stopTime ?? defaultConfig.askInput.stopTime,
logged: askInput?.logged ?? defaultConfig.askInput.logged,
},
};
};
export default getConfig;

10
src/dayjs.ts Normal file
View file

@ -0,0 +1,10 @@
import dayjs, {Dayjs} from 'dayjs/esm';
import duration, {Duration} from 'dayjs/esm/plugin/duration';
import customParseFormat from 'dayjs/esm/plugin/customParseFormat';
dayjs.extend(duration);
dayjs.extend(customParseFormat);
export default dayjs;
export type {Dayjs, Duration};

View file

@ -1,28 +1,43 @@
import dayjs, { Dayjs } from 'dayjs';
import dayjs, { Dayjs } from './dayjs';
import { Duration } from 'dayjs/plugin/duration.js';
export const formatTimestamp = (timestamp: Dayjs): string => timestamp.format('YYYY-MM-DD HH:mm');
import Language from './types/Language';
import { MessageKey, message } from './i18n';
export const formatTime = (time: Dayjs): string => time.format('HH:mm');
export const formatDuration = (duration: Duration, short?: boolean): string => {
if (duration.hours() === 0 && duration.minutes() === 0) {
return 'none';
}
export const formatDuration =
(language: Language) =>
(duration?: Duration, short?: boolean): string => {
duration = duration ?? dayjs.duration(0, 'minutes');
if (duration.hours() === 0 && duration.minutes() === 0) {
return 'none';
}
const formatString = short
? 'HH:mm'
: duration.hours() > 0 && duration.minutes() > 0
? `H [hour${duration.hours() > 1 ? 's' : ''} and] m [minute${duration.minutes() > 1 ? 's' : ''}]`
: duration.hours() > 0
? `H [hour${duration.hours() > 1 ? 's' : ''}]`
: `m [minute${duration.minutes() > 1 ? 's' : ''}]`;
let formatString;
return duration.format(formatString);
};
if (short) {
formatString = 'HH:mm';
} else if (language === Language.fi) {
formatString =
duration.hours() > 0 && duration.minutes() > 0
? `H [tunti${duration.hours() > 1 ? 'a' : ''} ja] m [minuuttia]`
: duration.hours() > 0
? `H [tunti${duration.hours() > 1 ? 'a' : ''}]`
: 'm [minuuttia]';
} else {
formatString =
duration.hours() > 0 && duration.minutes() > 0
? `H [hour${duration.hours() > 1 ? 's' : ''} and] m [minute${duration.minutes() > 1 ? 's' : ''}]`
: duration.hours() > 0
? `H [hour${duration.hours() > 1 ? 's' : ''}]`
: `m [minute${duration.minutes() > 1 ? 's' : ''}]`;
}
export const getHoursRoundedStr = (duration: Duration) =>
`(${getHoursRounded(duration)} as hours rounded to next even 15 minutes)`;
return duration.format(formatString);
};
export const getHoursRoundedStr = (language: Language) => (duration: Duration) =>
`(${getHoursRounded(duration)} ${message(language)(MessageKey.hoursRounded)})`;
const getHoursRounded = (duration: Duration) => {
// Round up to the next multiple of 15

150
src/i18n.ts Normal file
View file

@ -0,0 +1,150 @@
import Language from './types/Language';
export enum MessageKey {
cliHelp,
cliVersion,
promptWorkDayDuration,
excludingLunch,
promptStartTime,
promptStopTime,
parseTimeFailed,
startTimeBeforeStopTimeError,
promptLunchBreak,
promptYesNoYes,
promptYesNoNo,
unpaidLunch,
promptLogged,
none,
startedWorking,
stoppedWorking,
workedToday,
loggedOver,
workedOvertime,
workLeft,
hoursCalculated,
klo,
unloggedToday,
hoursRounded,
}
const messages: Record<MessageKey, Record<Language, string>> = {
[MessageKey.cliHelp]: {
[Language.en]: 'Show this help',
[Language.fi]: 'Näytä tämä ohje',
},
[MessageKey.cliVersion]: {
[Language.en]: 'Show program version',
[Language.fi]: 'Näytä ohjelman versio',
},
[MessageKey.promptWorkDayDuration]: {
[Language.en]: 'How long is your work day today{0}? [{1}]: ',
[Language.fi]: 'Kuinka pitkä työpäiväsi on tänään{0}? [{1}]: ',
},
[MessageKey.excludingLunch]: {
[Language.en]: ', excluding the lunch break',
[Language.fi]: ', poisluettuna lounastauko',
},
[MessageKey.promptStartTime]: {
[Language.en]: 'What time did you start work today? [{0}]: ',
[Language.fi]: 'Mihin aikaan aloitit työskentelyn tänään? [{0}]: ',
},
[MessageKey.promptStopTime]: {
[Language.en]: "What time did you stop working? If you didn't stop yet, leave this empty: ",
[Language.fi]: 'Mihin aikaan lopetit työskentelyn? Jos et lopettanut vielä, jätä tämä tyhjäksi: ',
},
[MessageKey.parseTimeFailed]: {
[Language.en]: 'Failed to parse time "{0}", using default value "{1}"',
[Language.fi]: 'Ajan "{0}" parsiminen epäonnistui, käytetään oletusasetusta "{1}"',
},
[MessageKey.startTimeBeforeStopTimeError]: {
[Language.en]: 'Start time ({0}) needs to be before stop time ({1}). Exiting',
[Language.fi]: 'Aloitusaika ({0}) pitää olla ennen lopetusaikaa ({1}). Ohjelma sammuu',
},
[MessageKey.promptLunchBreak]: {
[Language.en]: 'Did you have a lunch break? [{0}]: ',
[Language.fi]: 'Piditkö jo lounastauon? [{0}]: ',
},
[MessageKey.promptYesNoYes]: {
[Language.en]: 'Y/n',
[Language.fi]: 'K/e',
},
[MessageKey.promptYesNoNo]: {
[Language.en]: 'y/N',
[Language.fi]: 'k/E',
},
[MessageKey.unpaidLunch]: {
[Language.en]: 'Unpaid lunch:',
[Language.fi]: 'Palkaton lounas:',
},
[MessageKey.promptLogged]: {
[Language.en]: 'How many hours did you log already? [00:00] ',
[Language.fi]: 'Kuinka monta tuntia kirjasit jo? [00:00] ',
},
[MessageKey.none]: {
[Language.en]: 'None',
[Language.fi]: 'Ei yhtään',
},
[MessageKey.startedWorking]: {
[Language.en]: 'Started working:',
[Language.fi]: 'Aloitit työskentelyn:',
},
[MessageKey.stoppedWorking]: {
[Language.en]: 'Stopped working',
[Language.fi]: 'Lopetit työskentelyn',
},
[MessageKey.hoursCalculated]: {
[Language.en]: 'Hours calculated',
[Language.fi]: 'Tunnit laskettu',
},
[MessageKey.workedToday]: {
[Language.en]: 'Worked today:',
[Language.fi]: 'Tänään työskennelty:',
},
[MessageKey.workedOvertime]: {
[Language.en]: 'You worked {0} overtime today',
[Language.fi]: 'Olet tehnyt {0} ylitöitä tänään',
},
[MessageKey.loggedOver]: {
[Language.en]: 'You have logged {0} more than you worked today!',
[Language.fi]: 'Olet kirjannut {0} enemmän kuin olet työskennellyt!',
},
[MessageKey.workLeft]: {
[Language.en]: 'You still have to work {0} more today',
[Language.fi]: 'Sinun pitää työskennellä tänään vielä {0} lisää',
},
[MessageKey.klo]: {
[Language.en]: 'at',
[Language.fi]: 'klo',
},
[MessageKey.unloggedToday]: {
[Language.en]: 'Unlogged today:',
[Language.fi]: 'Kirjaamattomia tänään:',
},
[MessageKey.hoursRounded]: {
[Language.en]: 'as hours rounded to next even 15 minutes',
[Language.fi]: 'tunteina pyöristettynä seuraavaan 15 minuuttiin',
},
};
/**
* Get a function to fetch messages for a given language
* @param language The language to get the messages for
*/
export const message =
(language: Language) =>
/**
* Get a message for a fiven key
* @param key The key of the message
*/
(key: keyof typeof messages, ...params: string[]) => {
let result = messages[key][language];
if (!result) {
throw `Unknown language: ${language}`;
}
// Replace parameters in the template
for (let i = 0; i < params.length; i++) {
result = result.replace(new RegExp(`\\{${i}\\}`, 'g'), params[i]);
}
return result;
};

146
src/input.ts Normal file
View file

@ -0,0 +1,146 @@
import chalk from 'chalk';
import { parseDuration, parseTimestamp } from './parse';
import * as readline from 'readline/promises';
import { formatDuration, formatTime } from './format';
import { Dayjs } from 'dayjs';
import { WtcPromptResult } from './types/WtcPromptResult';
import { WtcRuntimeConfig } from './types/WtcConfig';
import { MessageKey } from './i18n';
import dayjs, { Duration } from './dayjs';
const { error } = console;
const input = async (runtimeCfg: WtcRuntimeConfig): Promise<WtcPromptResult> => {
const { config, msg } = runtimeCfg;
const fmtDuration = formatDuration(config.language);
const { defaults, askInput, unpaidLunchBreakDuration: lunchBreakDuration } = config;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
let startedAt: Dayjs | undefined = undefined;
let stoppedAt: Dayjs | undefined = undefined;
let stoppedWorking = false;
try {
// Get work day duration
let workDayDuration: Duration | undefined = undefined;
if (askInput.workDayDuration) {
const durationAnswer = await rl.question(
msg(
MessageKey.promptWorkDayDuration,
config.unpaidLunchBreakDuration ? msg(MessageKey.excludingLunch) : '',
fmtDuration(defaults.workDayDuration, true),
),
);
if (durationAnswer !== '') {
workDayDuration = parseDuration(durationAnswer);
if (workDayDuration.asMinutes() <= 0) {
error(
chalk.red(
msg(MessageKey.parseTimeFailed, durationAnswer, fmtDuration(defaults.workDayDuration)),
),
);
workDayDuration = undefined;
}
}
}
if (!workDayDuration) {
workDayDuration = defaults.workDayDuration;
}
if (askInput.startTime) {
const startTimeAnswer = await rl.question(msg(MessageKey.promptStartTime, formatTime(defaults.startTime)));
if (startTimeAnswer !== '') {
startedAt = parseTimestamp(startTimeAnswer);
if (!startedAt.isValid()) {
error(chalk.red(msg(MessageKey.parseTimeFailed, startTimeAnswer, formatTime(defaults.startTime))));
}
}
}
if (!startedAt?.isValid()) {
startedAt = defaults.startTime;
}
if (askInput.stopTime) {
const stoppedAnswer = await rl.question(msg(MessageKey.promptStopTime, formatTime(defaults.stopTime)));
if (stoppedAnswer !== '') {
stoppedWorking = true;
stoppedAt = parseTimestamp(stoppedAnswer);
if (!stoppedAt.isValid()) {
error(chalk.red(msg(MessageKey.parseTimeFailed, stoppedAnswer, formatTime(defaults.stopTime))));
}
}
}
if (!stoppedAt) {
stoppedAt = defaults.stopTime;
}
if (stoppedAt.isSame(startedAt) || stoppedAt.isBefore(startedAt)) {
error(
chalk.red(msg(MessageKey.startTimeBeforeStopTimeError, formatTime(startedAt), formatTime(stoppedAt))),
);
process.exit(1);
}
let worked = dayjs.duration(stoppedAt.diff(startedAt));
let hadLunch = false;
if (lunchBreakDuration) {
const lunchAnswer = (
await rl.question(
msg(
MessageKey.promptLunchBreak,
msg(config.defaults.hadLunch ? MessageKey.promptYesNoYes : MessageKey.promptYesNoNo),
),
)
).toLowerCase();
if (
lunchAnswer === 'y' ||
lunchAnswer === 'k' ||
(config.defaults.hadLunch && lunchAnswer !== 'n' && lunchAnswer !== 'e')
) {
hadLunch = true;
worked = worked.subtract(lunchBreakDuration);
}
}
// Calculate unlogged time
let loggedAnswer = await rl.question(msg(MessageKey.promptLogged));
if (loggedAnswer === '') {
loggedAnswer = '00:00';
}
const logged = parseDuration(loggedAnswer);
const unLogged = worked.subtract(logged);
const workLeft = workDayDuration.subtract(worked);
const workLeftMinutes = workLeft.asMinutes();
let workedOvertime: Duration | undefined;
if (workLeftMinutes < 0) {
workedOvertime = dayjs.duration(Math.round(workLeftMinutes * -1), 'minutes');
}
return {
logged,
unLogged,
startedAt,
stoppedAt,
stoppedWorking,
hadLunch,
worked,
workLeft,
workedOvertime,
};
} finally {
rl.close();
}
};
export default input;

View file

@ -1,150 +1,38 @@
import chalk from 'chalk';
import dayjs, { Dayjs } from 'dayjs';
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 yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import ui from './ui.js';
import update from './update.js';
import getConfig from './config.js';
import { MessageKey, message } from './i18n.js';
import { WtcRuntimeConfig } from './types/WtcConfig.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 main = async () => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
let startedAt: Dayjs | undefined = undefined;
const now = dayjs();
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 (!workDayDuration) {
workDayDuration = defaultWorkDayDuration;
}
// 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 (!startedAt?.isValid()) {
startedAt = parseTimestamp(defaultStartTime);
}
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 (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 (stoppedAt.isSame(startedAt) || stoppedAt.isBefore(startedAt)) {
error(
chalk.red(
`Start time (${formatTime(startedAt)}) needs to be before stop time (${formatTime(
stoppedAt,
)}). Exiting`,
),
);
process.exit(1);
}
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);
}
// Calculate unlogged time
let loggedAnswer = await rl.question('How many hours did you log already? [00:00] ');
if (loggedAnswer === '') {
loggedAnswer = '00:00';
}
const logged = parseDuration(loggedAnswer);
let unLogged: Duration | undefined = undefined;
if (logged.asMinutes() === worked.asMinutes()) {
unLogged = worked.subtract(logged);
} else {
unLogged = worked.subtract(logged);
}
// Log result
log();
log('Started working at:', formatTimestamp(startedAt));
log((stoppedWorking ? 'Stopped working' : 'Hours calculated') + ' at:', formatTimestamp(stoppedAt));
log('Worked today:', chalk.green(formatDuration(worked)), chalk.yellow(getHoursRoundedStr(worked)));
if (unLogged.asMinutes() == 0) {
log('Unlogged today:', chalk.green('none'));
} else if (unLogged.asMinutes() > 0) {
log('Unlogged today:', chalk.red(formatDuration(unLogged)), chalk.yellow(getHoursRoundedStr(unLogged)));
} else if (unLogged.asMinutes() < 0) {
log(
chalk.red(`You have logged ${formatDuration(unLogged)} more than you worked today!`),
chalk.yellow(getHoursRoundedStr(unLogged)),
);
}
const workLeft = workDayDuration.subtract(worked);
const workLeftMinutes = workLeft.asMinutes();
if (workLeftMinutes > 0) {
log('You still have to work', chalk.green(formatDuration(workLeft)), 'more today');
} else if (workLeft.asMinutes() < 0) {
log(
'You worked',
chalk.green(
formatDuration(dayjs.duration({ minutes: Math.round(workLeftMinutes * -1) })),
'overtime today',
),
);
}
} finally {
rl.close();
}
// Build runtime config
const config = getConfig();
const msg = message(config.language);
const runtimeConfig: WtcRuntimeConfig = {
config,
msg,
};
main();
// Process args. Yargs will exit if it detects help or version
const args = await yargs(hideBin(process.argv))
.usage('Work time calculator')
.alias('help', 'h')
.alias('version', 'v')
.options({
help: {
description: msg(MessageKey.cliHelp),
},
version: {
description: msg(MessageKey.cliVersion),
},
}).argv;
// Run updater if requested
if (args.update) {
update();
process.exit(0);
}
// Run UI if no arguments
ui(runtimeConfig);

49
src/output.ts Normal file
View file

@ -0,0 +1,49 @@
import chalk from 'chalk';
import { formatDuration, getHoursRoundedStr } from './format';
import { WtcPromptResult } from './types/WtcPromptResult';
import { MessageKey } from './i18n.js';
import { WtcRuntimeConfig } from './types/WtcConfig';
import dayjs from './dayjs';
const { log } = console;
const output = (result: WtcPromptResult, runtimeCfg: WtcRuntimeConfig) => {
const {config, msg} = runtimeCfg;
const { language, timestampFormat } = config;
const fmtDuration = formatDuration(language);
const hoursRounded = getHoursRoundedStr(language);
const { startedAt, stoppedAt, stoppedWorking, worked, unLogged, workLeft, workedOvertime, hadLunch } = result;
log();
log(msg(MessageKey.startedWorking), startedAt.format(timestampFormat));
log(
(stoppedWorking ? msg(MessageKey.stoppedWorking) : msg(MessageKey.hoursCalculated)) +
` ${msg(MessageKey.klo)}:`,
stoppedAt.format(timestampFormat),
);
log(msg(MessageKey.workedToday), chalk.green(fmtDuration(worked)), chalk.yellow(hoursRounded(worked)));
if (hadLunch) {
log(msg(MessageKey.unpaidLunch), chalk.green(fmtDuration(config.unpaidLunchBreakDuration)));
}
const unLoggedMinutes = unLogged.asMinutes();
if (unLoggedMinutes >= 0) {
log(
msg(MessageKey.unloggedToday),
unLoggedMinutes === 0 ? chalk.green(msg(MessageKey.none)) : chalk.red(fmtDuration(unLogged)),
unLoggedMinutes === 0 ? '' : chalk.yellow(hoursRounded(unLogged)),
);
} else if (unLoggedMinutes < 0) {
const overLogged = dayjs.duration(Math.abs(unLogged.asMilliseconds()), 'milliseconds');
log(chalk.red(msg(MessageKey.loggedOver, fmtDuration(overLogged))));
}
if (workLeft.asMinutes() > 0) {
log(msg(MessageKey.workLeft, chalk.green(fmtDuration(workLeft))));
} else if (workedOvertime) {
log(msg(MessageKey.workedOvertime, chalk.green(fmtDuration(workedOvertime))));
}
};
export default output;

View file

@ -1,11 +1,6 @@
import dayjs, { Dayjs } from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
import duration, { Duration } from 'dayjs/plugin/duration.js';
import dayjs, { Dayjs, Duration } from './dayjs';
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);

6
src/types/Language.ts Normal file
View file

@ -0,0 +1,6 @@
enum Language {
en = 'en',
fi = 'fi',
}
export default Language;

29
src/types/WtcConfig.ts Normal file
View file

@ -0,0 +1,29 @@
import { Dayjs } from 'dayjs';
import { Duration } from 'dayjs/plugin/duration.js';
import Language from './Language.js';
import { message } from '../i18n.js';
export default interface WtcConfig {
language: Language,
timestampFormat: string,
unpaidLunchBreakDuration?: Duration;
defaults: {
workDayDuration: Duration;
startTime: Dayjs;
stopTime: Dayjs;
hadLunch: boolean;
};
askInput: {
workDayDuration: boolean;
startTime: boolean;
stopTime: boolean;
logged: boolean;
};
}
/** Config and current language msg function together */
export interface WtcRuntimeConfig {
config: WtcConfig;
msg: ReturnType<typeof message>;
}

View file

@ -0,0 +1,14 @@
import { Dayjs } from 'dayjs';
import { Duration } from 'dayjs/plugin/duration';
export interface WtcPromptResult {
startedAt: Dayjs;
stoppedAt: Dayjs;
stoppedWorking: boolean;
logged: Duration;
unLogged: Duration;
hadLunch: boolean;
worked: Duration;
workLeft: Duration;
workedOvertime?: Duration;
}

7
src/ui.ts Normal file
View file

@ -0,0 +1,7 @@
import input from './input.js';
import output from './output.js';
import { WtcRuntimeConfig } from './types/WtcConfig.js';
const ui = async (config: WtcRuntimeConfig) => output(await input(config), config);
export default ui;

7
src/update.ts Normal file
View file

@ -0,0 +1,7 @@
const { log } = console;
const update = () => {
log('update');
};
export default update;