|
1 | | -import { parseAsIsoDate, parseAsStringLiteral } from 'nuqs/server' |
| 1 | +import { createParser, parseAsStringLiteral } from 'nuqs/server' |
2 | 2 |
|
3 | 3 | const CALENDAR_SCOPES = ['day', 'week', 'month'] as const |
4 | 4 |
|
5 | 5 | /** Default calendar granularity; matches the prior `useState` initial scope. */ |
6 | 6 | export const DEFAULT_CALENDAR_SCOPE = 'week' |
7 | 7 |
|
| 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 | + |
8 | 40 | /** |
9 | 41 | * Co-located, typed URL query-param definitions for the scheduled-tasks calendar. |
10 | 42 | * |
11 | 43 | * - `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 |
15 | 47 | * `.withDefault`): the default anchor is "today", which is dynamic and resolved |
16 | 48 | * per-timezone in the hook (`anchor = param ?? zonedClockDate(now, tz)`). A |
17 | 49 | * clean URL therefore means "today", and navigating back to today clears the |
18 | 50 | * param. |
19 | 51 | */ |
20 | 52 | export const calendarParsers = { |
21 | 53 | scope: parseAsStringLiteral(CALENDAR_SCOPES).withDefault(DEFAULT_CALENDAR_SCOPE), |
22 | | - anchor: parseAsIsoDate, |
| 54 | + anchor: parseAsLocalDate, |
23 | 55 | } as const |
24 | 56 |
|
25 | 57 | /** Calendar view-state: clean URLs, no back-stack churn. */ |
|
0 commit comments