date selector
This commit is contained in:
+15
-15
@@ -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({
|
||||
}}
|
||||
>
|
||||
<div className="two-columns">
|
||||
<Field label="From">
|
||||
<input
|
||||
required
|
||||
type="date"
|
||||
max={to || undefined}
|
||||
<DateField
|
||||
label="From"
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
max={to || undefined}
|
||||
onChange={setFrom}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="To">
|
||||
<input
|
||||
required
|
||||
type="date"
|
||||
min={from || undefined}
|
||||
<DateField
|
||||
label="To"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
min={from || undefined}
|
||||
onChange={setTo}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="Model"
|
||||
|
||||
@@ -2095,6 +2095,117 @@ footer span:first-child {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.date-select {
|
||||
position: relative;
|
||||
}
|
||||
.date-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 39px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: #33445a;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.date-trigger:hover {
|
||||
border-color: #c2d3ce;
|
||||
}
|
||||
.date-trigger svg {
|
||||
color: #8ba59d;
|
||||
}
|
||||
.filters .date-trigger {
|
||||
height: 35px;
|
||||
min-height: 35px;
|
||||
font-size: 11px;
|
||||
padding: 7px 9px;
|
||||
background: #fcfdfe;
|
||||
}
|
||||
.date-placeholder {
|
||||
color: #93a0ad;
|
||||
}
|
||||
.date-popover {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
width: 252px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #dbe2ea;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px #10223418;
|
||||
}
|
||||
.date-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.date-nav select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #e3e9ef;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: #33445a;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.date-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.date-weekday {
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #93a0ad;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.date-day {
|
||||
height: 29px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
color: #35485c;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.date-day:hover:not(:disabled) {
|
||||
background: #f0f7f4;
|
||||
}
|
||||
.date-day:disabled {
|
||||
color: #c4ccd4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.date-day.today {
|
||||
box-shadow: inset 0 0 0 1px #b9ded0;
|
||||
}
|
||||
.date-day.selected {
|
||||
background: var(--emerald);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.date-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.date-actions .button {
|
||||
flex: 1;
|
||||
min-height: 30px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.category-tree {
|
||||
padding: 0 17px 23px;
|
||||
|
||||
+253
-12
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { X, Inbox, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
X,
|
||||
Inbox,
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import type { Dataset, Filter } from "./api";
|
||||
import { categoryPath, emptyFilter } from "./api";
|
||||
export function Modal({
|
||||
@@ -62,6 +69,242 @@ export function Field({
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<Month>(
|
||||
() => 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<HTMLDivElement>(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 (
|
||||
<Field label={label} hint={hint}>
|
||||
<div className="date-select" ref={box}>
|
||||
<button
|
||||
type="button"
|
||||
className="date-trigger"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
if (!open) setView(selected ?? dayParts(now) ?? view);
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<CalendarDays size={14} />
|
||||
<span className={value ? "" : "date-placeholder"}>
|
||||
{value ? formatDay(value) : "Any date"}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="date-popover" role="dialog" aria-label={label}>
|
||||
<div className="date-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Previous month"
|
||||
onClick={() => setView(shiftMonth(view, -1))}
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
</button>
|
||||
<select
|
||||
aria-label="Month"
|
||||
value={view.month}
|
||||
onChange={(e) =>
|
||||
setView({ ...view, month: Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
{MONTHS.map((name, index) => (
|
||||
<option key={name} value={index + 1}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Year"
|
||||
value={view.year}
|
||||
onChange={(e) =>
|
||||
setView({ ...view, year: Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
{years.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Next month"
|
||||
onClick={() => setView(shiftMonth(view, 1))}
|
||||
>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="date-grid">
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<span key={weekday} className="date-weekday">
|
||||
{weekday}
|
||||
</span>
|
||||
))}
|
||||
{Array.from({ length: firstWeekday(view) }, (_, i) => (
|
||||
<span key={`pad${i}`} />
|
||||
))}
|
||||
{days.map((day) => {
|
||||
const iso = isoDay(view, day);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className={`date-day${iso === value ? " selected" : ""}${iso === now ? " today" : ""}`}
|
||||
disabled={blocked(iso)}
|
||||
aria-current={iso === value ? "date" : undefined}
|
||||
aria-label={formatDay(iso)}
|
||||
onClick={() => choose(iso)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="date-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle"
|
||||
disabled={blocked(now)}
|
||||
onClick={() => choose(now)}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
{clearable && value && (
|
||||
<button
|
||||
type="button"
|
||||
className="button subtle"
|
||||
onClick={() => choose("")}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
export function ErrorMessage({ error }: { error: string }) {
|
||||
return error ? (
|
||||
<div className="alert error" role="alert">
|
||||
@@ -162,22 +405,20 @@ export function Filters({
|
||||
).sort();
|
||||
return (
|
||||
<div className="filters">
|
||||
<Field label="From">
|
||||
<input
|
||||
type="date"
|
||||
<DateField
|
||||
label="From"
|
||||
clearable
|
||||
value={value.from}
|
||||
max={value.to || undefined}
|
||||
onChange={(e) => update("from", e.target.value)}
|
||||
onChange={(day) => update("from", day)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="To">
|
||||
<input
|
||||
type="date"
|
||||
<DateField
|
||||
label="To"
|
||||
clearable
|
||||
value={value.to}
|
||||
min={value.from || undefined}
|
||||
onChange={(e) => update("to", e.target.value)}
|
||||
onChange={(day) => update("to", day)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Currency">
|
||||
<select
|
||||
value={value.currency}
|
||||
|
||||
Reference in New Issue
Block a user