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}
+
+
+ {#if error}
+
+ {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;