From 5a4937afe4fa0a4fe53091731c425a8570e46a8e Mon Sep 17 00:00:00 2001 From: Lars Nolden Date: Fri, 11 Sep 2026 15:00:21 +0200 Subject: [PATCH] date selector --- web/src/Classification.tsx | 38 ++--- web/src/styles.css | 111 +++++++++++++++ web/src/ui.tsx | 277 ++++++++++++++++++++++++++++++++++--- 3 files changed, 389 insertions(+), 37 deletions(-) diff --git a/web/src/Classification.tsx b/web/src/Classification.tsx index 6a479c0..2bd400d 100644 --- a/web/src/Classification.tsx +++ b/web/src/Classification.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react"; import type { Dataset, Enrichment, Preview, State } from "./api"; import { categoryPath, request } from "./api"; -import { Empty, ErrorMessage, Field, Modal } from "./ui"; +import { DateField, Empty, ErrorMessage, Field, Modal } from "./ui"; export function Classification({ state, acceptState, @@ -92,6 +92,12 @@ export function Classification({ className="form-body" onSubmit={async (e) => { e.preventDefault(); + // The month-name picker is not a native date control, so the + // range is validated here instead of by form constraints. + if (!from || !to) { + setError("Choose a start and an end date for the range."); + return; + } setBusy(true); setError(""); try { @@ -132,24 +138,18 @@ export function Classification({ }} >
- - setFrom(e.target.value)} - /> - - - setTo(e.target.value)} - /> - + +
); } + +// Dates are handled as calendar days, never as instants: every helper works on +// the ISO string's integer parts so a browser time zone can never shift a +// booking date. "Sept" follows the four-letter form used in the journal UI. +const MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sept", + "Oct", + "Nov", + "Dec", +]; +const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; +interface Month { + year: number; + month: number; +} +function dayParts(iso: string): (Month & { day: number }) | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null; + const [year, month, day] = iso.split("-").map(Number); + if (month < 1 || month > 12 || day < 1 || day > daysInMonth({ year, month })) + return null; + return { year, month, day }; +} +function isoDay({ year, month }: Month, day: number) { + return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} +function daysInMonth({ year, month }: Month) { + return new Date(Date.UTC(year, month, 0)).getUTCDate(); +} +// Monday-first column index of the first day of the month. +function firstWeekday({ year, month }: Month) { + return (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() + 6) % 7; +} +function shiftMonth({ year, month }: Month, by: number): Month { + const zero = year * 12 + (month - 1) + by; + return { year: Math.floor(zero / 12), month: (zero % 12) + 1 }; +} +// formatDay renders an ISO calendar day as "09 Sept 2026". Unparseable input is +// returned unchanged so an unexpected stored value stays visible instead of +// being replaced by an invented date. +function formatDay(iso: string): string { + const parts = dayParts(iso); + if (!parts) return iso; + return `${String(parts.day).padStart(2, "0")} ${MONTHS[parts.month - 1]} ${parts.year}`; +} + +// DateField picks a calendar day by month name instead of the browser's +// numeric date control, which renders day and month ambiguously across +// locales. The value stays an ISO day, so filters and API payloads are +// unchanged. min/max are inclusive ISO days. +export function DateField({ + label, + value, + onChange, + min, + max, + hint, + clearable = false, +}: { + label: string; + value: string; + onChange: (day: string) => void; + min?: string; + max?: string; + hint?: string; + clearable?: boolean; +}) { + const [open, setOpen] = useState(false); + const clock = new Date(); + const now = isoDay( + { year: clock.getFullYear(), month: clock.getMonth() + 1 }, + clock.getDate(), + ); + const selected = dayParts(value); + const [view, setView] = useState( + () => selected ?? dayParts(now) ?? { year: clock.getFullYear(), month: 1 }, + ); + const blocked = (day: string) => (!!min && day < min) || (!!max && day > max); + const choose = (day: string) => { + onChange(day); + setOpen(false); + }; + const years = []; + const firstYear = (min ? dayParts(min)?.year : undefined) ?? view.year - 12; + const lastYear = (max ? dayParts(max)?.year : undefined) ?? view.year + 2; + for ( + let year = Math.min(firstYear, view.year); + year <= Math.max(lastYear, view.year); + year++ + ) + years.push(year); + const days = []; + for (let day = 1; day <= daysInMonth(view); day++) days.push(day); + // Dismissal must not depend on focus: a click does not focus a button on + // every platform, and Escape then never reaches the popover. preventDefault + // keeps an enclosing dialog open when Escape only closes this picker. + const box = useRef(null); + useEffect(() => { + if (!open) return; + const outside = (e: PointerEvent) => { + if (!box.current?.contains(e.target as Node)) setOpen(false); + }; + const escape = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + e.preventDefault(); + setOpen(false); + }; + document.addEventListener("pointerdown", outside); + document.addEventListener("keydown", escape); + return () => { + document.removeEventListener("pointerdown", outside); + document.removeEventListener("keydown", escape); + }; + }, [open]); + return ( + +
+ + {open && ( +
+
+ + + + +
+
+ {WEEKDAYS.map((weekday) => ( + + {weekday} + + ))} + {Array.from({ length: firstWeekday(view) }, (_, i) => ( + + ))} + {days.map((day) => { + const iso = isoDay(view, day); + return ( + + ); + })} +
+
+ + {clearable && value && ( + + )} +
+
+ )} +
+
+ ); +} export function ErrorMessage({ error }: { error: string }) { return error ? (
@@ -162,22 +405,20 @@ export function Filters({ ).sort(); return (
- - update("from", e.target.value)} - /> - - - update("to", e.target.value)} - /> - + update("from", day)} + /> + update("to", day)} + />