mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(filters): read date filter values back in local time, not UTC
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.
This commit is contained in:
@@ -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<Date
|
||||
// filter) would otherwise open on the range UI with no way to switch off
|
||||
// it, since OperatorSelect hides itself when there's only one choice.
|
||||
const [op, setOp] = useState<Operator>(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<string | null>(value?.v[0]?.slice(0, 10) ?? null);
|
||||
const [to, setTo] = useState<string | null>(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<string | null>(
|
||||
value?.v[0] ? isoToLocalDateStr(value.v[0]) : null,
|
||||
);
|
||||
const [to, setTo] = useState<string | null>(value?.v[1] ? isoToLocalDateStr(value.v[1]) : null);
|
||||
|
||||
const apply = () => {
|
||||
if (op === "between") {
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user