From d7894d28f1ad07fedf7956b2c2d46479bca95071 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 11:24:16 +0000 Subject: [PATCH] fix(filters): read date filter values back in local time, not UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A date filter's `v` holds UTC instants — startOfDayIso/endOfDayIso end in toISOString(). Both readers took `iso.slice(0, 10)`, which is the UTC calendar day. East of UTC those differ: at UTC+3 a local start-of-day is 21:00Z on the PREVIOUS day, so the pill printed the "from" date one day early and reopening the picker preselected that wrong day. The "to" side happened to look right, which is what made it read as cosmetic. isoToLocalDateStr reads the instant back in local time for the picker; the pill goes through the shared formatDate, so it now reads "Aug 20, 2026 → Aug 25, 2026" instead of a raw ISO fragment. Single-sided operators carry their operator word — "Created | Aug 20" alone doesn't say whether that is a floor or a ceiling. --- .../src/components/filters/bodies/DateBody.tsx | 13 +++++++++---- .../backoffice/src/components/filters/dates.ts | 17 +++++++++++++++++ .../backoffice/src/components/filters/format.ts | 15 ++++++++++++--- 3 files changed, 38 insertions(+), 7 deletions(-) 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;