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 { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react";
|
||||||
import type { Dataset, Enrichment, Preview, State } from "./api";
|
import type { Dataset, Enrichment, Preview, State } from "./api";
|
||||||
import { categoryPath, request } 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({
|
export function Classification({
|
||||||
state,
|
state,
|
||||||
acceptState,
|
acceptState,
|
||||||
@@ -92,6 +92,12 @@ export function Classification({
|
|||||||
className="form-body"
|
className="form-body"
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
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);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -132,24 +138,18 @@ export function Classification({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="two-columns">
|
<div className="two-columns">
|
||||||
<Field label="From">
|
<DateField
|
||||||
<input
|
label="From"
|
||||||
required
|
|
||||||
type="date"
|
|
||||||
max={to || undefined}
|
|
||||||
value={from}
|
value={from}
|
||||||
onChange={(e) => setFrom(e.target.value)}
|
max={to || undefined}
|
||||||
|
onChange={setFrom}
|
||||||
/>
|
/>
|
||||||
</Field>
|
<DateField
|
||||||
<Field label="To">
|
label="To"
|
||||||
<input
|
|
||||||
required
|
|
||||||
type="date"
|
|
||||||
min={from || undefined}
|
|
||||||
value={to}
|
value={to}
|
||||||
onChange={(e) => setTo(e.target.value)}
|
min={from || undefined}
|
||||||
|
onChange={setTo}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
label="Model"
|
label="Model"
|
||||||
|
|||||||
@@ -2095,6 +2095,117 @@ footer span:first-child {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
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 {
|
.category-tree {
|
||||||
padding: 0 17px 23px;
|
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 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 type { Dataset, Filter } from "./api";
|
||||||
import { categoryPath, emptyFilter } from "./api";
|
import { categoryPath, emptyFilter } from "./api";
|
||||||
export function Modal({
|
export function Modal({
|
||||||
@@ -62,6 +69,242 @@ export function Field({
|
|||||||
</label>
|
</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 }) {
|
export function ErrorMessage({ error }: { error: string }) {
|
||||||
return error ? (
|
return error ? (
|
||||||
<div className="alert error" role="alert">
|
<div className="alert error" role="alert">
|
||||||
@@ -162,22 +405,20 @@ export function Filters({
|
|||||||
).sort();
|
).sort();
|
||||||
return (
|
return (
|
||||||
<div className="filters">
|
<div className="filters">
|
||||||
<Field label="From">
|
<DateField
|
||||||
<input
|
label="From"
|
||||||
type="date"
|
clearable
|
||||||
value={value.from}
|
value={value.from}
|
||||||
max={value.to || undefined}
|
max={value.to || undefined}
|
||||||
onChange={(e) => update("from", e.target.value)}
|
onChange={(day) => update("from", day)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
<DateField
|
||||||
<Field label="To">
|
label="To"
|
||||||
<input
|
clearable
|
||||||
type="date"
|
|
||||||
value={value.to}
|
value={value.to}
|
||||||
min={value.from || undefined}
|
min={value.from || undefined}
|
||||||
onChange={(e) => update("to", e.target.value)}
|
onChange={(day) => update("to", day)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
<Field label="Currency">
|
<Field label="Currency">
|
||||||
<select
|
<select
|
||||||
value={value.currency}
|
value={value.currency}
|
||||||
|
|||||||
Reference in New Issue
Block a user