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:
Nathnael
2026-08-20 11:24:16 +00:00
parent 8949325a9c
commit d7894d28f1
3 changed files with 38 additions and 7 deletions

View File

@@ -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") {

View File

@@ -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 —

View File

@@ -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;