From 2e0c6f7f8fbef089b6be9937f92c0f850948aa40 Mon Sep 17 00:00:00 2001 From: Sjoerd de Vries Date: Wed, 20 May 2026 17:44:21 +0200 Subject: [PATCH] feat: duration parser and date utilities --- src/__tests__/date.test.ts | 15 +++++++++++++++ src/__tests__/duration.test.ts | 19 +++++++++++++++++++ src/utils/date.ts | 13 +++++++++++++ src/utils/duration.ts | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 src/__tests__/date.test.ts create mode 100644 src/__tests__/duration.test.ts create mode 100644 src/utils/date.ts create mode 100644 src/utils/duration.ts diff --git a/src/__tests__/date.test.ts b/src/__tests__/date.test.ts new file mode 100644 index 0000000..b9cc702 --- /dev/null +++ b/src/__tests__/date.test.ts @@ -0,0 +1,15 @@ +import { toDateString, todayString, addDays } from '../utils/date'; + +describe('date utils', () => { + it('formats a date as YYYY-MM-DD', () => { + expect(toDateString(new Date('2026-05-20T10:00:00'))).toBe('2026-05-20'); + }); + + it('addDays adds days correctly', () => { + expect(addDays('2026-05-20', 3)).toBe('2026-05-23'); + }); + + it('addDays handles month boundary', () => { + expect(addDays('2026-05-30', 2)).toBe('2026-06-01'); + }); +}); diff --git a/src/__tests__/duration.test.ts b/src/__tests__/duration.test.ts new file mode 100644 index 0000000..db0100e --- /dev/null +++ b/src/__tests__/duration.test.ts @@ -0,0 +1,19 @@ +import { parseDuration, formatDuration } from '../utils/duration'; + +describe('parseDuration', () => { + it('parses hours', () => expect(parseDuration('2h')).toBe(120)); + it('parses hours with space', () => expect(parseDuration('2 h')).toBe(120)); + it('parses minutes with m', () => expect(parseDuration('90m')).toBe(90)); + it('parses minutes with min', () => expect(parseDuration('45min')).toBe(45)); + it('parses plain number as minutes', () => expect(parseDuration('60')).toBe(60)); + it('parses decimal hours', () => expect(parseDuration('1.5h')).toBe(90)); + it('returns null for invalid input', () => expect(parseDuration('abc')).toBeNull()); + it('returns null for zero', () => expect(parseDuration('0m')).toBeNull()); + it('returns null for empty string', () => expect(parseDuration('')).toBeNull()); +}); + +describe('formatDuration', () => { + it('formats whole hours', () => expect(formatDuration(120)).toBe('2h')); + it('formats minutes only', () => expect(formatDuration(45)).toBe('45m')); + it('formats hours and minutes', () => expect(formatDuration(90)).toBe('1h 30m')); +}); diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..b7e6408 --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,13 @@ +export function toDateString(date: Date): string { + return date.toISOString().slice(0, 10); +} + +export function todayString(): string { + return toDateString(new Date()); +} + +export function addDays(dateStr: string, days: number): string { + const d = new Date(dateStr + 'T12:00:00'); + d.setDate(d.getDate() + days); + return toDateString(d); +} diff --git a/src/utils/duration.ts b/src/utils/duration.ts new file mode 100644 index 0000000..b830589 --- /dev/null +++ b/src/utils/duration.ts @@ -0,0 +1,32 @@ +export function parseDuration(input: string): number | null { + const s = input.trim().toLowerCase(); + if (!s) return null; + + const hoursMatch = s.match(/^(\d+(?:\.\d+)?)\s*h(?:ours?)?$/); + if (hoursMatch) { + const minutes = Math.round(parseFloat(hoursMatch[1]!) * 60); + return minutes > 0 ? minutes : null; + } + + const minutesMatch = s.match(/^(\d+)\s*m(?:in(?:utes?)?)?$/); + if (minutesMatch) { + const minutes = parseInt(minutesMatch[1]!, 10); + return minutes > 0 ? minutes : null; + } + + const plainMatch = s.match(/^(\d+)$/); + if (plainMatch) { + const minutes = parseInt(plainMatch[1]!, 10); + return minutes > 0 ? minutes : null; + } + + return null; +} + +export function formatDuration(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m}m`; + if (m === 0) return `${h}h`; + return `${h}h ${m}m`; +}