Compare commits

...
5 Commits
11 changed files with 95 additions and 15 deletions
+2 -1
View File
@@ -3,7 +3,7 @@
Log your daily hours, see how much you are over/under your weekly target,
and keep a running balance of extra/minus hours.
- Enter each day as a **total** (`7.5`, `7:15`, `7h15`; 0–24 h) — the default — or as
- Enter each day as a **total** (`7.15`, `7:15`, `7h15` = 7 h 15 min; `7.5` = 7 h 30 min; 0–24 h) — the default — or as
**start–end + break**, or mark it **off** (vacation, sick, holiday → no target).
- Full-time target defaults to **36 h 15 min/week** (7:15 per weekday).
- **Workload periods** (Settings) scale the target from a given date, e.g. 50% or 25%.
@@ -31,6 +31,7 @@ and keep a running balance of extra/minus hours.
| `DATA_DIR` | `./data` | `users.json` + `users/<id>.json` |
| `SESSION_SECRET` | random | Signs login cookies; without it everyone is logged out on restart. `npm run -s gen-secret` |
| `REGISTRATION_CODE` | — | If set, sign-up requires this invite code |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`; lines below this level are not logged. Format: logfmt |
Everything else is fixed in `backend/config.ts`: at most 100 accounts, 30-day sessions.
The app does no rate limiting of its own; that is left to the ingress (Traefik).
+28 -4
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import { isData, type Data } from '../shared/types';
import { createSessionToken, hashPassword, verifyPassword, verifySessionToken } from './auth';
import * as config from './config';
import { log } from './log';
import { UserStore, type User } from './store';
const COOKIE = 'workh_session';
@@ -156,8 +157,9 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
}
if (legacy) {
fs.renameSync(o.legacyDataFile, `${o.legacyDataFile}.migrated`);
console.log(`WorkH: moved ${o.legacyDataFile} into the new account "${user.username}"`);
log.info('legacy data moved into account', { user: user.username, file: o.legacyDataFile });
}
log.info('account created', { user: user.username });
startSession(res, user);
res.status(201).json({ username: user.username });
});
@@ -173,9 +175,11 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
const user = store.findByName(creds.username);
const ok = await verifyPassword(creds.password, user?.passwordHash ?? (await dummyHash));
if (!user || !ok) {
log.warn('login failed', { user: creds.username });
res.status(401).json({ error: 'Wrong username or password' });
return;
}
log.info('login', { user: user.username });
startSession(res, user);
res.json({ username: user.username });
});
@@ -208,7 +212,7 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
return;
}
store.delete(user.id);
console.log(`WorkH: deleted account "${user.username}"`);
log.info('account deleted', { user: user.username });
res.clearCookie(COOKIE, cookieOptions);
res.status(204).end();
});
@@ -223,7 +227,7 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
const { data, rev } = store.readData((req as AuthedRequest).user.id);
res.set('ETag', `"${rev}"`).json(data);
} catch (err) {
console.error('Could not read data file:', err);
log.error('could not read data file', { user: (req as AuthedRequest).user.username, error: err });
res.status(500).json({ error: 'Could not read data file' });
}
});
@@ -238,11 +242,31 @@ export function createApp(overrides: Partial<AppOptions> = {}) {
res.status(400).json({ error: 'Invalid data shape' });
return;
}
const result = store.writeData((req as AuthedRequest).user.id, req.body, ifMatch);
const { user } = req as AuthedRequest;
const before = store.readData(user.id).data;
const result = store.writeData(user.id, req.body, ifMatch);
if (!result.ok) {
res.status(412).set('ETag', `"${result.current.rev}"`).json(result.current.data);
return;
}
const added = Object.keys(req.body.entries).filter((date) => !(date in before.entries));
if (added.length) log.info('time added', { user: user.username, dates: added.join(',') });
const deleted = Object.keys(before.entries).filter((date) => !(date in req.body.entries));
if (deleted.length) log.info('days deleted', { user: user.username, dates: deleted.join(',') });
const changed = (a: unknown, b: unknown) => JSON.stringify(a) !== JSON.stringify(b);
for (const [date, entry] of Object.entries(req.body.entries)) {
if (date in before.entries && changed(before.entries[date], entry)) {
log.info('day changed', { user: user.username, date, to: entry });
}
}
for (const [key, value] of Object.entries(req.body.settings)) {
if (changed(before.settings[key as keyof Data['settings']], value)) {
log.info('setting changed', { user: user.username, setting: key, to: value });
}
}
if (changed(before.workloadPeriods, req.body.workloadPeriods)) {
log.info('working hours changed', { user: user.username, to: req.body.workloadPeriods });
}
res.set('ETag', `"${result.rev}"`).json({ ok: true });
});
+31
View File
@@ -0,0 +1,31 @@
// Lines are logfmt: `time=… level=info msg="login" user=aaro`. Loki parses them with `| logfmt`.
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
type Level = keyof typeof LEVELS;
type Fields = Record<string, unknown>;
/** Lines below LOG_LEVEL (default info) are skipped. Quiet during tests. */
const minLevel: Level = (process.env.LOG_LEVEL as Level) in LEVELS ? (process.env.LOG_LEVEL as Level) : 'info';
/** Quotes values with spaces, quotes or `=`; newlines are escaped so each entry stays one line. */
function value(v: unknown): string {
const s = v instanceof Error ? (v.stack ?? v.message) : typeof v === 'string' ? v : JSON.stringify(v);
return s === '' || /[\s"=\\]/.test(s) ? JSON.stringify(s) : s;
}
function write(level: Level, msg: string, fields: Fields = {}) {
if (process.env.VITEST || LEVELS[level] < LEVELS[minLevel]) return;
const all: Fields = { time: new Date().toISOString(), level, msg, ...fields };
const line = Object.entries(all)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${k}=${value(v)}`)
.join(' ');
(level === 'warn' || level === 'error' ? console.error : console.log)(line);
}
export const log = {
debug: (msg: string, fields?: Fields) => write('debug', msg, fields),
info: (msg: string, fields?: Fields) => write('info', msg, fields),
warn: (msg: string, fields?: Fields) => write('warn', msg, fields),
error: (msg: string, fields?: Fields) => write('error', msg, fields),
};
+2 -1
View File
@@ -1,8 +1,9 @@
import { createApp } from './app';
import { DATA_DIR, PORT } from './config';
import { log } from './log';
const server = createApp().listen(PORT, () => {
console.log(`WorkH listening on http://localhost:${PORT} (data: ${DATA_DIR})`);
log.info('WorkH listening', { port: PORT, dataDir: DATA_DIR });
});
// Exit promptly when Kubernetes / Docker stops the container.
+2
View File
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { defaultData, type Data } from '../shared/types';
import { log } from './log';
export interface User {
id: string;
@@ -113,6 +114,7 @@ export class UserStore {
const content = JSON.stringify(data, null, 2) + '\n';
if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
writeAtomic(file, content);
log.debug('wrote data file', { file });
return { ok: true, rev: revOf(content) };
}
}
+10
View File
@@ -35,6 +35,8 @@ test.describe('entering total hours', () => {
for (const [typed, minutes] of [
['7.5', 450],
['7,5', 450],
['7.30', 450],
['7,30', 450],
['7:30', 450],
['7h30', 450],
['7h 30min', 450],
@@ -85,6 +87,14 @@ test.describe('entering total hours', () => {
await expect(dayInput(page, MONDAY)).toHaveValue('6:45');
void user;
});
test('Tab moves straight to the next day, skipping the mode buttons', async ({ page, user }) => {
await page.goto('/');
await dayInput(page, MONDAY).focus();
await page.keyboard.press('Tab');
await expect(dayInput(page, TUE)).toBeFocused();
void user;
});
});
test.describe('start–end ranges', () => {
+8 -6
View File
@@ -37,8 +37,10 @@ export function weekDays(monday: string): string[] {
// ---------- Durations ----------
/**
* Parses "7.5", "7,5", "7:30", "7h30", "7h 30min", "45m" or "-1:30" into minutes.
* Returns null for anything it does not understand; "" also returns null.
* Parses "7.5", "7,5", "7.15", "7:30", "7h30", "7h 30min", "45m" or "-1:30" into minutes.
* Two digits after "." or "," are minutes like after ":" ("7.15" = 7 h 15 min); one digit is
* a decimal hour ("7.5" = 7 h 30 min). Returns null for anything it does not understand;
* "" also returns null.
*/
export function parseDuration(input: string): number | null {
const s = input.trim().toLowerCase().replace(/\s+/g, '');
@@ -47,11 +49,11 @@ export function parseDuration(input: string): number | null {
const body = s.replace(/^[+-]/, '');
let m: RegExpMatchArray | null;
if ((m = body.match(/^(\d+)(?:[.,](\d+))?h?$/))) {
return sign * Math.round(parseFloat(`${m[1]}.${m[2] ?? '0'}`) * 60);
if ((m = body.match(/^(\d+)(?:[.,](\d))?h?$/))) {
return sign * (Number(m[1]) * 60 + Number(m[2] ?? 0) * 6);
}
if ((m = body.match(/^(\d+):(\d{1,2})$/)) && Number(m[2]) < 60) {
return sign * (Number(m[1]) * 60 + Number(m[2]));
if ((m = body.match(/^(\d+)(?::(\d{1,2})|[.,](\d{2}))$/)) && Number(m[2] ?? m[3]) < 60) {
return sign * (Number(m[1]) * 60 + Number(m[2] ?? m[3]));
}
if ((m = body.match(/^(\d+)h(\d{1,2})(?:m|min)?$/)) && Number(m[2]) < 60) {
return sign * (Number(m[1]) * 60 + Number(m[2]));
+2
View File
@@ -46,6 +46,8 @@ export function DayRow({ date, entry, worked, target, isToday, onChange }: Props
<button
key={m.mode}
type="button"
// Out of the tab order so Tab goes from one day's input straight to the next.
tabIndex={-1}
className={m.mode === mode ? 'active' : ''}
onClick={() => m.mode !== mode && onChange(emptyEntry(m.mode))}
>
+1 -1
View File
@@ -12,7 +12,7 @@ interface Props {
}
/**
* Free-text duration field ("7.5", "7:15", "7h15"). Valid input is committed as you type;
* Free-text duration field ("7.5", "7.15", "7:15", "7h15"). Valid input is committed as you type;
* on blur the text is normalised to h:mm. Invalid text is kept and outlined until fixed.
*/
export function DurationInput({ value, onChange, placeholder, ariaLabel, min = -Infinity, max = Infinity }: Props) {
+1
View File
@@ -97,6 +97,7 @@ button.link {
}
button.link.danger {
color: var(--neg);
background: none;
}
input:focus-visible,
button:focus-visible {
+8 -2
View File
@@ -35,7 +35,11 @@ const fullWeek = (minutes: number) =>
describe('parseDuration', () => {
it.each([
['7.5', 450],
['7,25', 435],
['7,5', 450],
['7.15', 435],
['7,15', 435],
['7.45', 465],
['7.05', 425],
['7:15', 435],
['7h15', 435],
['7h 15min', 435],
@@ -45,7 +49,9 @@ describe('parseDuration', () => {
['8', 480],
])('%s -> %i', (input, expected) => expect(parseDuration(input)).toBe(expected));
it.each(['', 'abc', '7:75', '7h99'])('rejects %j', (input) => expect(parseDuration(input)).toBeNull());
it.each(['', 'abc', '7:75', '7h99', '7.75', '7.123'])('rejects %j', (input) =>
expect(parseDuration(input)).toBeNull(),
);
});
describe('formatting', () => {