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 (
+
+
+
{
+ if (!open) setView(selected ?? dayParts(now) ?? view);
+ setOpen(!open);
+ }}
+ >
+
+
+ {value ? formatDay(value) : "Any date"}
+
+
+ {open && (
+
+
+ setView(shiftMonth(view, -1))}
+ >
+
+
+
+ setView({ ...view, month: Number(e.target.value) })
+ }
+ >
+ {MONTHS.map((name, index) => (
+
+ {name}
+
+ ))}
+
+
+ setView({ ...view, year: Number(e.target.value) })
+ }
+ >
+ {years.map((year) => (
+
+ {year}
+
+ ))}
+
+ setView(shiftMonth(view, 1))}
+ >
+
+
+
+
+ {WEEKDAYS.map((weekday) => (
+
+ {weekday}
+
+ ))}
+ {Array.from({ length: firstWeekday(view) }, (_, i) => (
+
+ ))}
+ {days.map((day) => {
+ const iso = isoDay(view, day);
+ return (
+ choose(iso)}
+ >
+ {day}
+
+ );
+ })}
+
+
+ choose(now)}
+ >
+ Today
+
+ {clearable && value && (
+ choose("")}
+ >
+ Clear
+
+ )}
+
+
+ )}
+
+
+ );
+}
export function ErrorMessage({ error }: { error: string }) {
return error ? (