Dev #1

Merged
NeilClack merged 25 commits from dev into main 2026-09-30 04:29:28 +00:00
4 changed files with 274 additions and 0 deletions
Showing only changes of commit 35558b0652 - Show all commits
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import {
fiveMinuteOptions,
formatTime,
hmToMinutes,
parseTimeInput,
toHm
} from '$lib/schedule-utils';
// A reusable 12-hour clock input. Displays and accepts AM/PM times, offers
// the 5-minute options as suggestions, and allows typing any minute 00-59.
// Its value model is a 24-hour "HH:MM" string.
let {
value = '00:00',
label,
onchange,
onvalidity,
required = false,
disabled = false
}: {
value?: string;
label?: string;
onchange?: (value: string) => void;
onvalidity?: (valid: boolean) => void;
required?: boolean;
disabled?: boolean;
} = $props();
const inputId = $props.id();
const options = fiveMinuteOptions();
// While the user is typing we show their raw text (`editing`); otherwise we
// show the canonical 12-hour label derived from the 24-hour value.
let editing = $state<string | null>(null);
const valueMinutes = $derived(hmToMinutes(value) ?? 0);
const display = $derived(editing ?? formatTime(valueMinutes));
const error = $derived.by(() => {
if (editing === null) return '';
const result = parseTimeInput(editing);
return result.ok ? '' : result.error;
});
function handleInput(event: Event) {
const raw = (event.currentTarget as HTMLInputElement).value;
editing = raw;
const result = parseTimeInput(raw);
if (result.ok) {
onvalidity?.(true);
onchange?.(toHm(result.minutes));
} else {
onvalidity?.(false);
}
}
function handleBlur() {
if (editing === null) return;
const result = parseTimeInput(editing);
if (result.ok) {
// Drop back to the canonical label derived from the value.
editing = null;
onvalidity?.(true);
}
}
function handleFocus(event: FocusEvent) {
editing = (event.currentTarget as HTMLInputElement).value;
}
</script>
<div class="flex flex-col gap-1">
{#if label}
<label for={inputId} class="text-sm font-medium text-slate-700 dark:text-slate-300">
{label}
</label>
{/if}
<input
id={inputId}
type="text"
inputmode="text"
autocomplete="off"
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
list={`${inputId}-options`}
{required}
{disabled}
aria-label={label}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${inputId}-error` : undefined}
value={display}
oninput={handleInput}
onfocus={handleFocus}
onblur={handleBlur}
/>
<datalist id={`${inputId}-options`}>
{#each options as minutes (minutes)}
<option value={formatTime(minutes)}></option>
{/each}
</datalist>
{#if error}
<p id={`${inputId}-error`} role="alert" class="text-sm text-red-600 dark:text-red-400">
{error}
</p>
{/if}
</div>
@@ -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<string, unknown> = {}) {
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);
});
});
+49
View File
@@ -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);
+56
View File
@@ -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;