Compare commits

...

62 commits
v0.0.2 ... 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
a315a1b3b2
0.0.5 2023-11-14 19:48:09 +02:00
e82cb22b93
Fix npmignore 2023-11-14 19:47:41 +02:00
8f505d39a3
npm publish: get OTP from pass 2023-11-14 19:45:23 +02:00
7c4e07b74c
0.0.4 2023-11-14 19:41:09 +02:00
4664b91545
Fix tsc target dir 2023-11-14 19:40:39 +02:00
99c0b03941
Build package before release 2023-11-14 19:36:01 +02:00
fe9e51a297
Add npmignore
Fixes built files not added to npmjs
2023-11-14 19:35:28 +02:00
793b6aece0
Use github flavored markdown for npmjs README 2023-11-14 19:28:35 +02:00
e388378101
0.0.3 2023-11-14 19:24:34 +02:00
9f7f6a7bf4
Fix package.json repository key 2023-11-14 19:22:45 +02:00
38648bd818
Update readme 2023-11-14 19:18:52 +02:00
5064cf92f9
Add installation instructions to README 2023-11-14 19:15:33 +02:00
1eb65c28a0
Add help to Makefile 2023-11-14 18:57:44 +02:00
b7a5839216
Add release and publish Make targets 2023-11-14 18:54:10 +02:00
518d1f5605
Add markdown readme to npmjs 2023-11-14 18:42:06 +02:00
26 changed files with 1598 additions and 208 deletions

3
.gitignore vendored
View file

@ -1,2 +1,3 @@
node_modules/ node_modules/
target/ dist/
README.md

4
.npmignore Normal file
View file

@ -0,0 +1,4 @@
*
!bin/wtc
!dist/**/*.js
!README.md

View file

@ -1,8 +1,37 @@
build: node_modules .PHONY: help prod dev clean update-npmjs-readme release publish
npm run build
help:
@echo "Available targets:"
@echo " - help: Show this help message"
@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"
node_modules: node_modules:
npm install npm install
prod: node_modules
npm run prod
dev: node_modules
npm run dev
chmod +x dist/wtc-dev.mjs
clean: clean:
rm -r target node_modules rm -r dist node_modules
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."
update-npmjs-readme:
asciidoctor -b docbook -o dist/README.xml README.adoc
pandoc -f docbook -t gfm dist/README.xml -o README.md
publish: update-npmjs-readme
@git push && \
git push --tags && \
npm publish --otp $$(pass otp services/npmjs.com)

View file

@ -1,14 +1,69 @@
== Work time calculator = Work time calculator
An interactive CLI tool to calculate work time. An interactive CLI tool to calculate work time.
This is a highly opinionated CLI tool I built for my specific needs. image::img/demo.png[]
In time, I will probable make it more generic and configurable.
For now, the following assumptions are made.
* You have an unpaid 30 minute lunch break == Install
=== TODO You can run this in your terminal
* [ ] Configuration file for default settings and altering behaviour in interactive mode [,shell]
----
npm i -g work-time-calculator
----
If you get a permission denied error, you can run the previous command
with sudo (**not recommended**), or you can set a local prefix to npm.
Feel free to create the prefix wherever you like, this is just a
location I decided to use. The only requirement is that the location
needs to be readable and writable by your user.
[,shell]
----
npm config set prefix '~/.local/share/npm'
----
After that you can run the installation again. Running the program
requires that you have your npm prefix in your `$PATH`. You can find
an example of this in my https://git.korhonen.cc/FunctionalHacker/dotfiles/src/commit/4442252c659179d860d71982a6b705dcecc54ea6/home/.config/zsh/02-env.zsh#L31-L32[dotfiles]. This configuration file is for ZSH but should also work for bash.
After installation, you should be able to run the program with
[,shell]
----
wtc
----
== Update
To update, just run the install command again
== Rationale
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.
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
* [x] Configuration file for default settings and altering behaviour in interactive mode
* [ ] Non-interactive mode with CLI arguments parsing * [ ] Non-interactive mode with CLI arguments parsing

View file

@ -1,2 +1,4 @@
#!/usr/bin/env node #!/bin/sh
import '../target/main.js';
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,11 +1,11 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "work-time-calculator", "name": "work-time-calculator",
"version": "0.0.2", "version": "1.0.2",
"description": "An interactive CLI tool to calculate work time", "description": "An interactive CLI tool to calculate work time",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"url": "https://git/korhonen.cc/FunctionalHacker/work-time-calculator.git", "url": "https://git.korhonen.cc/FunctionalHacker/work-time-calculator.git",
"type": "git" "type": "git"
}, },
"bugs": { "bugs": {
@ -13,12 +13,13 @@
"email": "wtc@functionalhacker.korhonen.cc" "email": "wtc@functionalhacker.korhonen.cc"
}, },
"type": "module", "type": "module",
"main": "src/main.ts",
"bin": { "bin": {
"wtc": "bin/wtc" "wtc": "bin/wtc"
}, },
"main": "dist/wtc.mjs",
"scripts": { "scripts": {
"build": "tsc" "prod": "rollup -c ./rollup.prod.config.js",
"dev": "rollup -c ./rollup.dev.config.js"
}, },
"keywords": [ "keywords": [
"work", "work",
@ -27,13 +28,22 @@
], ],
"author": "Marko Korhonen <marko@korhonen.cc>", "author": "Marko Korhonen <marko@korhonen.cc>",
"devDependencies": { "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/node": "^20.9.0",
"@types/yargs": "^17.0.31",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^6.10.0",
"eslint-config-prettier": "^9.0.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": { "dependencies": {
"chalk": "^5.3.0", "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'; import { Duration } from 'dayjs/plugin/duration.js';
import Language from './types/Language';
export const formatTimestamp = (timestamp: Dayjs): string => timestamp.format('YYYY-MM-DD HH:mm'); import { MessageKey, message } from './i18n';
export const formatTime = (time: Dayjs): string => time.format('HH:mm'); export const formatTime = (time: Dayjs): string => time.format('HH:mm');
export const formatDuration = (duration: Duration, short?: boolean): string => { export const formatDuration =
(language: Language) =>
(duration?: Duration, short?: boolean): string => {
duration = duration ?? dayjs.duration(0, 'minutes');
if (duration.hours() === 0 && duration.minutes() === 0) { if (duration.hours() === 0 && duration.minutes() === 0) {
return 'none'; return 'none';
} }
const formatString = short let formatString;
? 'HH:mm'
: duration.hours() > 0 && duration.minutes() > 0 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' : ''}]` ? `H [hour${duration.hours() > 1 ? 's' : ''} and] m [minute${duration.minutes() > 1 ? 's' : ''}]`
: duration.hours() > 0 : duration.hours() > 0
? `H [hour${duration.hours() > 1 ? 's' : ''}]` ? `H [hour${duration.hours() > 1 ? 's' : ''}]`
: `m [minute${duration.minutes() > 1 ? 's' : ''}]`; : `m [minute${duration.minutes() > 1 ? 's' : ''}]`;
}
return duration.format(formatString); return duration.format(formatString);
}; };
export const getHoursRoundedStr = (duration: Duration) => export const getHoursRoundedStr = (language: Language) => (duration: Duration) =>
`(${getHoursRounded(duration)} as hours rounded to next even 15 minutes)`; `(${getHoursRounded(duration)} ${message(language)(MessageKey.hoursRounded)})`;
const getHoursRounded = (duration: Duration) => { const getHoursRounded = (duration: Duration) => {
// Round up to the next multiple of 15 // 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 yargs from 'yargs';
import dayjs, { Dayjs } from 'dayjs'; import { hideBin } from 'yargs/helpers';
import * as readline from 'readline/promises'; import ui from './ui.js';
import { formatDuration, formatTime, formatTimestamp, getHoursRoundedStr } from './format.js'; import update from './update.js';
import duration, { Duration } from 'dayjs/plugin/duration.js'; import getConfig from './config.js';
import { parseDuration, parseTimestamp } from './parse.js'; import { MessageKey, message } from './i18n.js';
import { WtcRuntimeConfig } from './types/WtcConfig.js';
dayjs.extend(duration); // Build runtime config
const config = getConfig();
const { log, error } = console; const msg = message(config.language);
const defaultStartTime = '08:00'; const runtimeConfig: WtcRuntimeConfig = {
const lunchBreakDuration = dayjs.duration({ minutes: 30 }); config,
const defaultWorkDayDuration = dayjs.duration({ hours: 7, minutes: 30 }); msg,
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();
}
}; };
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 dayjs, { Dayjs, Duration } from './dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
import duration, { Duration } from 'dayjs/plugin/duration.js';
dayjs.extend(customParseFormat); export const parseTimestamp = (time: string): Dayjs => (time === 'now' ? dayjs() : dayjs(time, 'HH:mm', true));
dayjs.extend(duration);
export const parseTimestamp = (time: string): Dayjs => dayjs(time, 'HH:mm', true);
export const parseDuration = (time: string): Duration => { export const parseDuration = (time: string): Duration => {
const [hours, minutes] = time.split(':').map(Number); 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;

View file

@ -8,7 +8,7 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"outDir": "target" "outDir": "dist"
}, },
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"]
} }