Skip to content

Commit ebcad6b

Browse files
committed
fix(scheduled-tasks): use local-time date parser for calendar anchor (avoid UTC day-shift)
1 parent 5159136 commit ebcad6b

2 files changed

Lines changed: 43 additions & 7 deletions

File tree

.claude/rules/sim-url-state.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,13 @@ export const thingsParsers = {
164164

165165
Both carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). The defaults must match the list's existing default sort exactly. If a UI exposes "no active sort" as `null`, derive that in the component (`sort === DEFAULT && dir === DEFAULT ? null : { column, direction }`) — the URL still holds the resolved values. "Clear sort" writes the defaults back (which `clearOnDefault` strips from the URL); never write `null`/garbage columns.
166166

167-
## Dates in the URL (`parseAsIsoDate`)
167+
## Dates in the URL (date-only params)
168168

169-
A date-only param (a calendar anchor, a date filter) uses the built-in `parseAsIsoDate` (`yyyy-MM-dd`) — never serialize a full `Date`/timestamp when only the day matters. When the default is **dynamic** (e.g. "today"), make the param **nullable** (omit `.withDefault`) and derive the fallback in the hook (`const anchor = param ?? today`), so a clean URL means the dynamic default and navigating back to it writes `null` (clears the param). See `scheduled-tasks/search-params.ts` + `hooks/use-calendar.ts`.
169+
A date-only param (a calendar anchor, a date filter) is stored as `yyyy-MM-dd` — never serialize a full `Date`/timestamp when only the day matters.
170+
171+
**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString()`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate``new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`).
172+
173+
When the default is **dynamic** (e.g. "today"), make the param **nullable** (omit `.withDefault`) and derive the fallback in the hook (`const anchor = param ?? today`), so a clean URL means the dynamic default and navigating back to it writes `null` (clears the param). See `scheduled-tasks/hooks/use-calendar.ts`.
170174

171175
## Selected-entity deep-link (store the id, derive the object)
172176

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/search-params.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,57 @@
1-
import { parseAsIsoDate, parseAsStringLiteral } from 'nuqs/server'
1+
import { createParser, parseAsStringLiteral } from 'nuqs/server'
22

33
const CALENDAR_SCOPES = ['day', 'week', 'month'] as const
44

55
/** Default calendar granularity; matches the prior `useState` initial scope. */
66
export const DEFAULT_CALENDAR_SCOPE = 'week'
77

8+
const pad2 = (n: number) => String(n).padStart(2, '0')
9+
10+
/**
11+
* Local-time date-only parser (`yyyy-MM-dd`).
12+
*
13+
* Unlike nuqs's built-in `parseAsIsoDate` — which serializes via `toISOString()`
14+
* and parses to **UTC** midnight — this reads and writes the date using the
15+
* browser's **local** calendar fields. The calendar's `anchor` is a local-time
16+
* `Date` (`zonedClockDate`) and all the grid math (`date-fns`) is local, so a
17+
* UTC-based parser shifts the day by ±1 in any non-UTC timezone on
18+
* reload/deep-link/back-forward. This local parser round-trips losslessly against
19+
* that local-time math.
20+
*/
21+
const parseAsLocalDate = createParser<Date>({
22+
parse(value) {
23+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
24+
if (!match) return null
25+
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
26+
return Number.isNaN(date.getTime()) ? null : date
27+
},
28+
serialize(value) {
29+
return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}`
30+
},
31+
eq(a, b) {
32+
return (
33+
a.getFullYear() === b.getFullYear() &&
34+
a.getMonth() === b.getMonth() &&
35+
a.getDate() === b.getDate()
36+
)
37+
},
38+
})
39+
840
/**
941
* Co-located, typed URL query-param definitions for the scheduled-tasks calendar.
1042
*
1143
* - `scope` is the calendar granularity (`day` / `week` / `month`).
12-
* - `anchor` is the focused day, stored date-only (`yyyy-MM-dd`) via
13-
* `parseAsIsoDate`. The calendar grid only reads the anchor's date fields, so a
14-
* date-only param round-trips losslessly. It is intentionally **nullable** (no
44+
* - `anchor` is the focused day, stored date-only (`yyyy-MM-dd`) via the
45+
* local-time {@link parseAsLocalDate} so it matches the calendar's local-time
46+
* date math (no timezone day-shift). It is intentionally **nullable** (no
1547
* `.withDefault`): the default anchor is "today", which is dynamic and resolved
1648
* per-timezone in the hook (`anchor = param ?? zonedClockDate(now, tz)`). A
1749
* clean URL therefore means "today", and navigating back to today clears the
1850
* param.
1951
*/
2052
export const calendarParsers = {
2153
scope: parseAsStringLiteral(CALENDAR_SCOPES).withDefault(DEFAULT_CALENDAR_SCOPE),
22-
anchor: parseAsIsoDate,
54+
anchor: parseAsLocalDate,
2355
} as const
2456

2557
/** Calendar view-state: clean URLs, no back-stack churn. */

0 commit comments

Comments
 (0)