diff --git a/README.md b/README.md index 32748bd..afb52ac 100644 --- a/README.md +++ b/README.md @@ -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%. diff --git a/e2e/hours.spec.ts b/e2e/hours.spec.ts index 614a190..dffef6f 100644 --- a/e2e/hours.spec.ts +++ b/e2e/hours.spec.ts @@ -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], diff --git a/frontend/calc.ts b/frontend/calc.ts index 233952e..3ebff25 100644 --- a/frontend/calc.ts +++ b/frontend/calc.ts @@ -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])); diff --git a/frontend/components/DurationInput.tsx b/frontend/components/DurationInput.tsx index fdaf214..013fee5 100644 --- a/frontend/components/DurationInput.tsx +++ b/frontend/components/DurationInput.tsx @@ -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) { diff --git a/tests/frontend/calc.test.ts b/tests/frontend/calc.test.ts index 72c5bc0..69a423f 100644 --- a/tests/frontend/calc.test.ts +++ b/tests/frontend/calc.test.ts @@ -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', () => {