diff --git a/src/lib/components/TimeInput.svelte b/src/lib/components/TimeInput.svelte new file mode 100644 index 0000000..3bbbd9f --- /dev/null +++ b/src/lib/components/TimeInput.svelte @@ -0,0 +1,103 @@ + + +
+ {#if label} + + {/if} + + + {#each options as minutes (minutes)} + + {/each} + + {#if error} + + {/if} +
diff --git a/src/lib/components/TimeInput.svelte.spec.ts b/src/lib/components/TimeInput.svelte.spec.ts new file mode 100644 index 0000000..2feff75 --- /dev/null +++ b/src/lib/components/TimeInput.svelte.spec.ts @@ -0,0 +1,66 @@ +import { page } from 'vitest/browser'; +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import TimeInput from './TimeInput.svelte'; + +function mount(props: Record = {}) { + return render(TimeInput, { value: '07:05', label: 'Time', ...props }); +} + +async function type(text: string) { + const input = (await page.getByRole('combobox', { name: 'Time' }).element()) as HTMLInputElement; + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); +} + +describe('TimeInput', () => { + it('displays the 24-hour value as a 12-hour label', async () => { + mount({ value: '21:05' }); + + await expect.element(page.getByRole('combobox', { name: 'Time' })).toHaveValue('9:05 PM'); + }); + + it('calls onchange with a 24-hour value for tolerant input', async () => { + const onchange = vi.fn(); + mount({ onchange }); + + await type('7:05 pm'); + + expect(onchange).toHaveBeenCalledWith('19:05'); + }); + + it('accepts typed minutes that are not 5-minute steps', async () => { + const onchange = vi.fn(); + mount({ value: '00:00', onchange }); + + await type('7:07'); + + expect(onchange).toHaveBeenCalledWith('07:07'); + }); + + it('accepts an hour with no minutes', async () => { + const onchange = vi.fn(); + mount({ onchange }); + + await type('7pm'); + + expect(onchange).toHaveBeenCalledWith('19:00'); + }); + + it('shows clear feedback and reports invalid input', async () => { + const onvalidity = vi.fn(); + mount({ onvalidity }); + + await type('13:00 PM'); + + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + expect(onvalidity).toHaveBeenCalledWith(false); + }); + + it('offers the 5-minute clock options', async () => { + mount(); + + const options = document.querySelectorAll('datalist option'); + expect(options).toHaveLength((24 * 60) / 5); + }); +}); diff --git a/src/lib/schedule-utils.spec.ts b/src/lib/schedule-utils.spec.ts index afa03bd..8d27ef5 100644 --- a/src/lib/schedule-utils.spec.ts +++ b/src/lib/schedule-utils.spec.ts @@ -3,8 +3,11 @@ import { blockDuration, blockSegments, findOverlap, + fiveMinuteOptions, formatTime, + hmToMinutes, isOvernight, + parseTimeInput, snapMinutes, toHm, validateBlock, @@ -44,6 +47,52 @@ describe('toHm', () => { }); }); +describe('hmToMinutes', () => { + it('parses 24-hour HH:MM strings', () => { + expect(hmToMinutes('00:00')).toBe(0); + expect(hmToMinutes('07:05')).toBe(7 * 60 + 5); + expect(hmToMinutes('23:59')).toBe(23 * 60 + 59); + }); + + it('rejects malformed or out-of-range values', () => { + expect(hmToMinutes('7:05')).toBeNull(); + expect(hmToMinutes('24:00')).toBeNull(); + expect(hmToMinutes('12:60')).toBeNull(); + expect(hmToMinutes('')).toBeNull(); + }); +}); + +describe('parseTimeInput', () => { + it('parses tolerant 12-hour input', () => { + expect(parseTimeInput('7:05 pm')).toEqual({ ok: true, minutes: 19 * 60 + 5 }); + expect(parseTimeInput('7:05PM')).toEqual({ ok: true, minutes: 19 * 60 + 5 }); + expect(parseTimeInput('7pm')).toEqual({ ok: true, minutes: 19 * 60 }); + expect(parseTimeInput('7:7')).toEqual({ ok: true, minutes: 7 * 60 + 7 }); + expect(parseTimeInput('12:00 am')).toEqual({ ok: true, minutes: 0 }); + expect(parseTimeInput('12:30 PM')).toEqual({ ok: true, minutes: 12 * 60 + 30 }); + expect(parseTimeInput('11:59 pm')).toEqual({ ok: true, minutes: 23 * 60 + 59 }); + expect(parseTimeInput('0:30')).toEqual({ ok: true, minutes: 30 }); + }); + + it('rejects ambiguous or out-of-range input', () => { + expect(parseTimeInput('13:00 PM').ok).toBe(false); + expect(parseTimeInput('7:70').ok).toBe(false); + expect(parseTimeInput('25:00').ok).toBe(false); + expect(parseTimeInput('').ok).toBe(false); + expect(parseTimeInput('noon').ok).toBe(false); + }); +}); + +describe('fiveMinuteOptions', () => { + it('lists every 5-minute step across the day', () => { + const options = fiveMinuteOptions(); + expect(options[0]).toBe(0); + expect(options[1]).toBe(5); + expect(options[options.length - 1]).toBe(23 * 60 + 55); + expect(options).toHaveLength((24 * 60) / 5); + }); +}); + describe('snapMinutes', () => { it('rounds to the nearest 15 minutes and clamps to the day', () => { expect(snapMinutes(0)).toBe(0); diff --git a/src/lib/schedule-utils.ts b/src/lib/schedule-utils.ts index 2f16e65..935f470 100644 --- a/src/lib/schedule-utils.ts +++ b/src/lib/schedule-utils.ts @@ -45,6 +45,62 @@ export function toHm(minutes: number): string { return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; } +/** + * Parse a 24-hour `HH:MM` string into minutes from midnight. Returns null when + * the input is not a valid 24-hour time. + */ +export function hmToMinutes(hm: string): number | null { + const match = /^(\d{2}):(\d{2})$/.exec(hm.trim()); + if (!match) return null; + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (hours > 23 || minutes > 59) return null; + return hours * 60 + minutes; +} + +export type TimeInputResult = { ok: true; minutes: number } | { ok: false; error: string }; + +/** + * Parse a user-typed 12-hour clock time into minutes from midnight. Tolerant of + * "7:05 pm", "7:05PM", "7pm", "7:7", "12:00 am". A period is optional; when + * present the hour must be 1-12, so "13:00 PM" is rejected. Minutes may be any + * value 00-59, not just the 5-minute steps. + */ +export function parseTimeInput(raw: string): TimeInputResult { + const text = raw.trim().toLowerCase(); + if (!text) return { ok: false, error: 'Enter a time.' }; + + const match = /^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm|a|p)?$/.exec(text); + if (!match) return { ok: false, error: 'Enter a time like 7:05 PM.' }; + + const hour = Number(match[1]); + const minute = match[2] === undefined ? 0 : Number(match[2]); + const period = match[3]; + + if (minute > 59) return { ok: false, error: 'Minutes must be 00-59.' }; + + if (period) { + if (hour < 1 || hour > 12) { + return { ok: false, error: 'Hour must be 1-12 when AM/PM is given.' }; + } + const isPm = period === 'pm' || period === 'p'; + const hours24 = (hour % 12) + (isPm ? 12 : 0); + return { ok: true, minutes: hours24 * 60 + minute }; + } + + if (hour > 23) return { ok: false, error: 'Hour must be 0-23.' }; + return { ok: true, minutes: hour * 60 + minute }; +} + +/** The 5-minute clock options (00:00, 00:05, ..., 23:55) in play order. */ +export function fiveMinuteOptions(): number[] { + const options: number[] = []; + for (let minutes = 0; minutes < MINUTES_PER_DAY; minutes += 5) { + options.push(minutes); + } + return options; +} + /** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */ export function snapMinutes(minutes: number): number { const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;