diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx index 32970abef..35b9de21a 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx @@ -4,7 +4,7 @@ import { DatePickerInput } from "@mantine/dates"; import { CalendarDays } from "lucide-react"; import { getDateRangePresets } from "@/components/common/dateRangePresets"; -import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates"; +import { startOfDayIso, endOfDayIso, isoToLocalDateStr, parseDateStr } from "../dates"; import { DEFAULT_OP } from "../types"; import type { DateFilterDef, Operator } from "../types"; import { OperatorSelect } from "../OperatorSelect"; @@ -20,9 +20,14 @@ export function DateBody({ def, value, onChange, onClose }: FilterBodyProps(value?.op ?? def.operators?.[0] ?? DEFAULT_OP.date); - // Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects. - const [from, setFrom] = useState(value?.v[0]?.slice(0, 10) ?? null); - const [to, setTo] = useState(value?.v[1]?.slice(0, 10) ?? null); + // Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects — and + // the stored values are UTC instants, so they must come back through + // `isoToLocalDateStr`, not a slice (see its comment: a slice reopens the + // "from" side a day early east of UTC). + const [from, setFrom] = useState( + value?.v[0] ? isoToLocalDateStr(value.v[0]) : null, + ); + const [to, setTo] = useState(value?.v[1] ? isoToLocalDateStr(value.v[1]) : null); const apply = () => { if (op === "between") { diff --git a/apps/edr-freight-web/backoffice/src/components/filters/dates.ts b/apps/edr-freight-web/backoffice/src/components/filters/dates.ts index b51ef493c..be7623751 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/dates.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/dates.ts @@ -32,6 +32,23 @@ export function parseDateStr(dateStr: string): Date { return new Date(y, (m || 1) - 1, d || 1); } +/** + * Inverse of `parseDateStr` + `startOfDayIso`/`endOfDayIso`: the LOCAL + * `YYYY-MM-DD` an ISO instant falls on. + * + * `iso.slice(0, 10)` is the tempting version and it is wrong. Those instants + * came out of `toISOString()`, so they are UTC — for Ethiopia (UTC+3) a local + * end-of-day is `…T20:59:59.999Z` on the SAME day but a local start-of-day is + * `…T21:00:00.000Z` on the PREVIOUS one. Slicing therefore reopens the picker + * (and printed the pill) a day early on the "from" side only. + */ +export function isoToLocalDateStr(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso.slice(0, 10); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + /** * `toParams` for a date `FilterDef` widened to `["between", "before", "after"]` * operators. `DateBody` always emits a single-element `v` for before/after — diff --git a/apps/edr-freight-web/backoffice/src/components/filters/format.ts b/apps/edr-freight-web/backoffice/src/components/filters/format.ts index 24489abf8..ebb9e167a 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/format.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/format.ts @@ -1,5 +1,6 @@ +import { formatDate } from "@/lib/format"; import { parseFilters } from "./url"; -import type { FilterDef, FilterValue } from "./types"; +import { OPERATOR_LABELS, type FilterDef, type FilterValue } from "./types"; /** Human-readable text for one filter's current value — same text a * FilterPill shows, and what a saved view's auto-generated label is built @@ -10,8 +11,16 @@ export function formatFilterValue(def: FilterDef, value: FilterValue): string { const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v); return labels.join(", "); } - if (def.type === "date" && value.v.length === 2) { - return `${value.v[0].slice(0, 10)} → ${value.v[1].slice(0, 10)}`; + if (def.type === "date") { + // `v` holds UTC instants (startOfDayIso/endOfDayIso call toISOString), so + // slicing the first 10 characters printed the UTC calendar day — one day + // EARLIER than the one picked, for anyone east of UTC. `formatDate` reads + // the instant back in local time, which is the day the user actually chose. + // Single-sided ops carry their operator, since "Created | Aug 20" alone + // doesn't say whether that's a floor or a ceiling. + const days = value.v.map(formatDate); + if (value.op === "between" && days.length === 2) return `${days[0]} → ${days[1]}`; + return `${OPERATOR_LABELS[value.op]} ${days[0]}`; } if (def.type === "route" && value.v.length === 2) { const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id;