@remit/web-client 0.0.230 → 0.0.231

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.stories.tsx +25 -0
  3. package/src/components/calendar/CalendarComposePane.tsx +34 -0
  4. package/src/components/calendar/CalendarEventPane.stories.tsx +81 -0
  5. package/src/components/calendar/CalendarEventPane.tsx +67 -0
  6. package/src/components/calendar/CalendarShell.tsx +83 -0
  7. package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
  8. package/src/components/calendar/CalendarWorkspace.stories.tsx +197 -0
  9. package/src/components/calendar/CalendarWorkspace.tsx +162 -0
  10. package/src/components/calendar/OpenCalendarEvent.tsx +39 -0
  11. package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
  12. package/src/components/mail/MailSidebarAdapter.tsx +17 -0
  13. package/src/hooks/useCalendarData.ts +72 -0
  14. package/src/lib/calendar-density.ts +34 -0
  15. package/src/lib/calendar-fixtures.ts +207 -0
  16. package/src/lib/calendar-format.ts +29 -0
  17. package/src/lib/calendar-route.test.ts +210 -0
  18. package/src/lib/calendar-route.ts +183 -0
  19. package/src/lib/search-params.test.ts +77 -0
  20. package/src/lib/search-params.ts +59 -0
  21. package/src/routeTree.gen.ts +199 -0
  22. package/src/router.tsx +5 -0
  23. package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
  24. package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
  25. package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
  26. package/src/routes/calendar/$view.$date/index.tsx +25 -0
  27. package/src/routes/calendar/$view.$date/new.tsx +20 -0
  28. package/src/routes/calendar/$view.$date.tsx +100 -0
  29. package/src/routes/calendar/-calendar-tree.test.ts +267 -0
  30. package/src/routes/calendar/index.tsx +27 -0
  31. package/src/routes/calendar.tsx +98 -0
  32. package/src/routing/calendar-fragment.test.ts +66 -0
  33. package/src/routing/calendar.ts +139 -0
  34. package/src/routing/fragment.ts +19 -0
  35. package/src/routing/index.ts +10 -0
  36. package/src/routing/nav-selection.ts +2 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The calendar's address, rule by rule — which tier holds which fact, what a
3
+ * segment the calendar cannot read resolves to, and what a query string it did
4
+ * not write is allowed to say.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "node:test";
8
+ import { CALENDAR_DENSITY_KEY } from "./calendar-density.js";
9
+ import {
10
+ calendarSearchSchema,
11
+ calendarViewMountsGrid,
12
+ calendarViews,
13
+ canonicalCalendarParams,
14
+ DEFAULT_CALENDAR_VIEW,
15
+ isoDate,
16
+ parseCalendarDate,
17
+ parseCalendarView,
18
+ readCalendarIds,
19
+ stepCalendarDate,
20
+ } from "./calendar-route.js";
21
+
22
+ const TODAY = "2026-06-10";
23
+
24
+ describe("parseCalendarView", () => {
25
+ it("reads every zoom the ladder has", () => {
26
+ for (const view of calendarViews) {
27
+ assert.equal(parseCalendarView(view), view);
28
+ }
29
+ });
30
+
31
+ it("reads a segment that names no zoom as none", () => {
32
+ assert.equal(parseCalendarView("fortnight"), undefined);
33
+ assert.equal(parseCalendarView("Week"), undefined);
34
+ assert.equal(parseCalendarView(""), undefined);
35
+ });
36
+ });
37
+
38
+ describe("parseCalendarDate", () => {
39
+ it("reads a day the calendar has", () => {
40
+ assert.equal(parseCalendarDate("2026-06-10"), "2026-06-10");
41
+ assert.equal(parseCalendarDate("2024-02-29"), "2024-02-29");
42
+ });
43
+
44
+ it("refuses a day the calendar does not have", () => {
45
+ assert.equal(parseCalendarDate("2026-02-30"), undefined);
46
+ assert.equal(parseCalendarDate("2026-13-01"), undefined);
47
+ assert.equal(parseCalendarDate("2025-02-29"), undefined);
48
+ });
49
+
50
+ it("refuses anything that is not the one spelling", () => {
51
+ assert.equal(parseCalendarDate("2026-6-10"), undefined);
52
+ assert.equal(parseCalendarDate("10-06-2026"), undefined);
53
+ assert.equal(parseCalendarDate("2026-06-10T09:30"), undefined);
54
+ assert.equal(parseCalendarDate("today"), undefined);
55
+ assert.equal(parseCalendarDate(""), undefined);
56
+ });
57
+ });
58
+
59
+ describe("canonicalCalendarParams", () => {
60
+ it("leaves an address it can read alone", () => {
61
+ assert.deepEqual(
62
+ canonicalCalendarParams({ view: "day", date: "2026-06-10" }, TODAY),
63
+ { view: "day", date: "2026-06-10" },
64
+ );
65
+ });
66
+
67
+ it("keeps the half it could read", () => {
68
+ assert.deepEqual(
69
+ canonicalCalendarParams({ view: "fortnight", date: "2026-06-10" }, TODAY),
70
+ { view: DEFAULT_CALENDAR_VIEW, date: "2026-06-10" },
71
+ );
72
+ assert.deepEqual(
73
+ canonicalCalendarParams({ view: "day", date: "yesterday" }, TODAY),
74
+ { view: "day", date: TODAY },
75
+ );
76
+ });
77
+
78
+ it("sends an address it can read nothing of to this week", () => {
79
+ assert.deepEqual(canonicalCalendarParams({ view: "", date: "" }, TODAY), {
80
+ view: DEFAULT_CALENDAR_VIEW,
81
+ date: TODAY,
82
+ });
83
+ });
84
+ });
85
+
86
+ describe("calendarViewMountsGrid", () => {
87
+ it("draws the week and the day", () => {
88
+ assert.equal(calendarViewMountsGrid("week"), true);
89
+ assert.equal(calendarViewMountsGrid("day"), true);
90
+ });
91
+
92
+ it("has the other three still to come", () => {
93
+ assert.equal(calendarViewMountsGrid("year"), false);
94
+ assert.equal(calendarViewMountsGrid("month"), false);
95
+ assert.equal(calendarViewMountsGrid("agenda"), false);
96
+ });
97
+ });
98
+
99
+ describe("stepCalendarDate", () => {
100
+ it("steps a screenful in the view's own unit", () => {
101
+ assert.equal(stepCalendarDate("2026-06-10", "day", 1), "2026-06-11");
102
+ assert.equal(stepCalendarDate("2026-06-10", "week", 1), "2026-06-17");
103
+ assert.equal(stepCalendarDate("2026-06-10", "agenda", 1), "2026-06-17");
104
+ assert.equal(stepCalendarDate("2026-06-10", "month", 1), "2026-07-10");
105
+ assert.equal(stepCalendarDate("2026-06-10", "year", 1), "2027-06-10");
106
+ });
107
+
108
+ it("steps back the same distance it steps forward", () => {
109
+ for (const view of calendarViews) {
110
+ const forward = stepCalendarDate("2026-06-10", view, 1);
111
+ assert.equal(stepCalendarDate(forward, view, -1), "2026-06-10");
112
+ }
113
+ });
114
+
115
+ it("crosses a month, a year and a leap day", () => {
116
+ assert.equal(stepCalendarDate("2026-12-31", "day", 1), "2027-01-01");
117
+ assert.equal(stepCalendarDate("2026-01-01", "day", -1), "2025-12-31");
118
+ assert.equal(stepCalendarDate("2024-02-28", "day", 1), "2024-02-29");
119
+ assert.equal(stepCalendarDate("2026-12-15", "month", 1), "2027-01-15");
120
+ assert.equal(stepCalendarDate("2026-01-15", "month", -1), "2025-12-15");
121
+ });
122
+
123
+ it("holds a month step to a day that month has", () => {
124
+ assert.equal(stepCalendarDate("2026-03-31", "month", -1), "2026-02-28");
125
+ assert.equal(stepCalendarDate("2026-01-31", "month", 1), "2026-02-28");
126
+ assert.equal(stepCalendarDate("2024-01-31", "month", 1), "2024-02-29");
127
+ assert.equal(stepCalendarDate("2024-02-29", "year", 1), "2025-02-28");
128
+ });
129
+ });
130
+
131
+ describe("isoDate", () => {
132
+ it("names the day an instant falls on by the device's own clock", () => {
133
+ assert.equal(isoDate(new Date(2026, 5, 10, 9, 30)), "2026-06-10");
134
+ assert.equal(isoDate(new Date(2026, 0, 1, 0, 0)), "2026-01-01");
135
+ assert.equal(isoDate(new Date(2026, 11, 31, 23, 59)), "2026-12-31");
136
+ });
137
+ });
138
+
139
+ describe("readCalendarIds", () => {
140
+ it("reads repeated params, a single one, and none", () => {
141
+ assert.deepEqual(readCalendarIds(["cal_a", "cal_b"]), ["cal_a", "cal_b"]);
142
+ assert.deepEqual(readCalendarIds("cal_a"), ["cal_a"]);
143
+ assert.deepEqual(readCalendarIds(undefined), []);
144
+ });
145
+
146
+ it("spells one set of ticked calendars one way", () => {
147
+ assert.deepEqual(readCalendarIds(["cal_b", "cal_a", "cal_b"]), [
148
+ "cal_a",
149
+ "cal_b",
150
+ ]);
151
+ });
152
+
153
+ it("keeps a numeric-looking id the router handed through as a number", () => {
154
+ assert.deepEqual(readCalendarIds([12, "cal_a"]), ["12", "cal_a"]);
155
+ });
156
+
157
+ it("drops what is not an id rather than refusing the address", () => {
158
+ assert.deepEqual(readCalendarIds(["", "cal_a", null, { id: "x" }]), [
159
+ "cal_a",
160
+ ]);
161
+ });
162
+ });
163
+
164
+ describe("calendarSearchSchema", () => {
165
+ it("carries the ticked calendars", () => {
166
+ assert.deepEqual(calendarSearchSchema.parse({ calendarId: "cal_a" }), {
167
+ calendarId: ["cal_a"],
168
+ });
169
+ assert.deepEqual(
170
+ calendarSearchSchema.parse({ calendarId: ["cal_b", "cal_a"] }),
171
+ { calendarId: ["cal_a", "cal_b"] },
172
+ );
173
+ });
174
+
175
+ it("leaves the param out where nothing is ticked off", () => {
176
+ // Absent rather than empty: the router writes no param for `undefined`, so
177
+ // a calendar showing everything is one the query says nothing about.
178
+ assert.equal(calendarSearchSchema.parse({}).calendarId, undefined);
179
+ assert.equal(
180
+ calendarSearchSchema.parse({ calendarId: [] }).calendarId,
181
+ undefined,
182
+ );
183
+ assert.equal(
184
+ calendarSearchSchema.parse({ calendarId: ["", null] }).calendarId,
185
+ undefined,
186
+ );
187
+ });
188
+
189
+ it("strips a param that is not the calendar's to carry", () => {
190
+ const parsed = calendarSearchSchema.parse({
191
+ calendarId: ["cal_a"],
192
+ q: "invoice",
193
+ // Density is a device preference, so an address naming one is ignored
194
+ // rather than obeyed: a link must not resize another reader's day.
195
+ density: "compact",
196
+ date: "2026-06-10",
197
+ });
198
+ assert.deepEqual(parsed, { calendarId: ["cal_a"] });
199
+ });
200
+ });
201
+
202
+ describe("density is not URL state", () => {
203
+ it("is stored against the device, under a key of its own", () => {
204
+ assert.equal(CALENDAR_DENSITY_KEY, "remit:calendar-density");
205
+ });
206
+
207
+ it("has no reading in the query", () => {
208
+ assert.deepEqual(calendarSearchSchema.parse({ density: "compact" }), {});
209
+ });
210
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The calendar's address, read and written in one place.
3
+ *
4
+ * `/calendar/{view}/{date}` and, under it, the event a reader has open. The
5
+ * five zoom levels are mutually exclusive and each is a different surface, so
6
+ * the view is a path segment (R5); the date is one too, because the strip is
7
+ * infinite and the address has to name where it opened — a `?date=` param
8
+ * would be a query compensating for a missing segment (R4). What the reader
9
+ * has ticked off modifies the view without changing what is mounted, so the
10
+ * calendar filter is query (R2), validated here rather than at the read site
11
+ * (R7).
12
+ *
13
+ * Nothing here reads a clock. Every function takes the instant it needs, so
14
+ * "today" is a decision the caller makes once and this module can be tested
15
+ * without one.
16
+ */
17
+ import type { CalendarViewId } from "@remit/ui";
18
+ import { z } from "zod";
19
+
20
+ /** The zoom ladder, widest first. The union `@remit/ui` already publishes. */
21
+ export const calendarViews = [
22
+ "year",
23
+ "month",
24
+ "week",
25
+ "day",
26
+ "agenda",
27
+ ] as const satisfies readonly CalendarViewId[];
28
+
29
+ const calendarViewSchema = z.enum(calendarViews);
30
+
31
+ /** Where `/calendar` sends a reader who named no zoom of their own. */
32
+ export const DEFAULT_CALENDAR_VIEW: CalendarViewId = "week";
33
+
34
+ /**
35
+ * The views the grid draws today. The other three are named surfaces that
36
+ * arrive in later stages, and they say so on screen rather than matching a
37
+ * route that renders nothing.
38
+ */
39
+ const GRID_VIEWS = new Set<CalendarViewId>(["week", "day"]);
40
+
41
+ export const calendarViewMountsGrid = (view: CalendarViewId): boolean =>
42
+ GRID_VIEWS.has(view);
43
+
44
+ /** The view a segment names, or `undefined` where it names none. */
45
+ export function parseCalendarView(segment: string): CalendarViewId | undefined {
46
+ const parsed = calendarViewSchema.safeParse(segment);
47
+ return parsed.success ? parsed.data : undefined;
48
+ }
49
+
50
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
51
+
52
+ /**
53
+ * The day a segment names, or `undefined`. A date the calendar does not have —
54
+ * the 31st of February, the 13th month — is not a day, so it is rejected
55
+ * rather than rolled forward into one the reader did not ask for.
56
+ */
57
+ export function parseCalendarDate(segment: string): string | undefined {
58
+ if (!ISO_DATE.test(segment)) return undefined;
59
+ const [year, month, day] = segment.split("-").map(Number);
60
+ const instant = Date.UTC(year, month - 1, day);
61
+ const round = new Date(instant);
62
+ if (round.getUTCFullYear() !== year) return undefined;
63
+ if (round.getUTCMonth() !== month - 1) return undefined;
64
+ if (round.getUTCDate() !== day) return undefined;
65
+ return segment;
66
+ }
67
+
68
+ const pad = (value: number): string => String(value).padStart(2, "0");
69
+
70
+ /** The day an instant falls on, by the clock the reader's device is running. */
71
+ export function isoDate(instant: Date): string {
72
+ return `${instant.getFullYear()}-${pad(instant.getMonth() + 1)}-${pad(
73
+ instant.getDate(),
74
+ )}`;
75
+ }
76
+
77
+ export interface CalendarParams {
78
+ view: CalendarViewId;
79
+ date: string;
80
+ }
81
+
82
+ /**
83
+ * The address a pair of segments resolves to.
84
+ *
85
+ * A hand-edited or stale URL is a normal thing to receive, so an unreadable
86
+ * segment is replaced rather than thrown: the half that was readable is kept,
87
+ * and the reader lands on a calendar instead of an error. The caller compares
88
+ * this with what it was given and redirects when they differ, so the address
89
+ * bar ends up naming exactly what is on screen.
90
+ */
91
+ export function canonicalCalendarParams(
92
+ segments: { view: string; date: string },
93
+ today: string,
94
+ ): CalendarParams {
95
+ return {
96
+ view: parseCalendarView(segments.view) ?? DEFAULT_CALENDAR_VIEW,
97
+ date: parseCalendarDate(segments.date) ?? today,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * The day the previous or next screenful starts on. A step is measured in the
103
+ * view's own unit, which is what makes back-and-forward land where the reader
104
+ * came from at every zoom. Agenda steps a week, the range it loads.
105
+ */
106
+ export function stepCalendarDate(
107
+ date: string,
108
+ view: CalendarViewId,
109
+ direction: -1 | 1,
110
+ ): string {
111
+ const [year, month, day] = date.split("-").map(Number);
112
+ if (view === "year") return clampedDate(year + direction, month, day);
113
+ if (view === "month") return clampedDate(year, month + direction, day);
114
+ const days = view === "day" ? 1 : 7;
115
+ const stepped = new Date(Date.UTC(year, month - 1, day + days * direction));
116
+ return isoDateUtc(stepped);
117
+ }
118
+
119
+ const isoDateUtc = (instant: Date): string =>
120
+ `${instant.getUTCFullYear()}-${pad(instant.getUTCMonth() + 1)}-${pad(
121
+ instant.getUTCDate(),
122
+ )}`;
123
+
124
+ /**
125
+ * A month or a year away from a date, held to a day that month has. Stepping
126
+ * off the 31st of March must land in February rather than skipping it.
127
+ */
128
+ function clampedDate(year: number, month: number, day: number): string {
129
+ const normalisedYear = year + Math.floor((month - 1) / 12);
130
+ const normalisedMonth = ((((month - 1) % 12) + 12) % 12) + 1;
131
+ const lastDay = new Date(
132
+ Date.UTC(normalisedYear, normalisedMonth, 0),
133
+ ).getUTCDate();
134
+ return `${normalisedYear}-${pad(normalisedMonth)}-${pad(
135
+ Math.min(day, lastDay),
136
+ )}`;
137
+ }
138
+
139
+ /**
140
+ * A calendar's id, as the URL carries it. A hand-written schema is a second
141
+ * owner of a shape the domain will publish, so stage A.3 replaces this with
142
+ * the generated one and every read site keeps working.
143
+ */
144
+ const calendarIdSchema = z.string().min(1);
145
+
146
+ /**
147
+ * The calendars a query string has ticked.
148
+ *
149
+ * Repeated `calendarId=` params are the shape, and the shape the address is
150
+ * written back in (`lib/search-params.ts`). They arrive here in three readings
151
+ * all the same: a list from repeated params, a scalar from a single one, and a
152
+ * number where the id looked like one. One set of ticked calendars has one
153
+ * spelling, which is what deduplicating and sorting is for.
154
+ */
155
+ export function readCalendarIds(value: unknown): string[] {
156
+ const raw = value === undefined || value === null ? [] : [value].flat();
157
+ const named = new Set<string>();
158
+ for (const entry of raw) {
159
+ if (typeof entry !== "string" && typeof entry !== "number") continue;
160
+ const id = calendarIdSchema.safeParse(String(entry));
161
+ if (id.success) named.add(id.data);
162
+ }
163
+ return [...named].sort();
164
+ }
165
+
166
+ /**
167
+ * What the calendar reads out of the query, and all of it.
168
+ *
169
+ * Ticking nothing off leaves the param out rather than writing an empty list:
170
+ * the address states what a reader has narrowed to, so a calendar showing
171
+ * everything is a calendar the query has nothing to say about.
172
+ *
173
+ * Density is absent on purpose: it is how much of a day this device fits on
174
+ * screen, which is a preference the device holds (`lib/calendar-density.ts`)
175
+ * rather than a fact about the view, so it belongs to no tier of the address.
176
+ */
177
+ export const calendarSearchSchema = z.object({
178
+ calendarId: z
179
+ .preprocess(readCalendarIds, z.array(calendarIdSchema))
180
+ .transform((ids) => (ids.length === 0 ? undefined : ids)),
181
+ });
182
+
183
+ export type CalendarSearch = z.infer<typeof calendarSearchSchema>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The spelling the address bar is written in.
3
+ *
4
+ * The router reads a repeated param as a list already; this is the other half,
5
+ * and the assertions are the round trip — anything written here has to come
6
+ * back the value it went in as.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { parseSearchWith } from "@tanstack/react-router";
11
+ import { stringifySearch } from "./search-params.js";
12
+
13
+ /** The router's own reader, which this module has to stay symmetrical with. */
14
+ const parseSearch = parseSearchWith(JSON.parse);
15
+
16
+ /** Spread, because the router's reader answers with a null-prototype object. */
17
+ const roundTrip = (search: Record<string, unknown>): unknown => ({
18
+ ...parseSearch(stringifySearch(search)),
19
+ });
20
+
21
+ describe("stringifySearch", () => {
22
+ it("writes a list as the repeated params it is", () => {
23
+ assert.equal(
24
+ stringifySearch({ calendarId: ["cal_a", "cal_b"] }),
25
+ "?calendarId=cal_a&calendarId=cal_b",
26
+ );
27
+ });
28
+
29
+ it("writes a list of one as one param", () => {
30
+ assert.equal(
31
+ stringifySearch({ calendarId: ["cal_a"] }),
32
+ "?calendarId=cal_a",
33
+ );
34
+ });
35
+
36
+ it("writes nothing for an empty list, an absent value, or no params", () => {
37
+ assert.equal(stringifySearch({ calendarId: [] }), "");
38
+ assert.equal(stringifySearch({ q: undefined }), "");
39
+ assert.equal(stringifySearch({}), "");
40
+ });
41
+
42
+ it("leaves an ordinary value the way the router already wrote it", () => {
43
+ assert.equal(stringifySearch({ q: "invoice" }), "?q=invoice");
44
+ assert.equal(stringifySearch({ wizard: "review" }), "?wizard=review");
45
+ });
46
+
47
+ it("percent-encodes what a query string cannot carry raw", () => {
48
+ assert.equal(stringifySearch({ q: "from:ada &c" }), "?q=from%3Aada+%26c");
49
+ });
50
+ });
51
+
52
+ describe("what is written comes back", () => {
53
+ it("gives a list back as a list", () => {
54
+ assert.deepEqual(roundTrip({ calendarId: ["cal_a", "cal_b"] }), {
55
+ calendarId: ["cal_a", "cal_b"],
56
+ });
57
+ });
58
+
59
+ it("gives text that reads as JSON back as text", () => {
60
+ // Unquoted, the reader would hand back a boolean and a number.
61
+ assert.deepEqual(roundTrip({ q: "true" }), { q: "true" });
62
+ assert.deepEqual(roundTrip({ q: "2026" }), { q: "2026" });
63
+ assert.deepEqual(roundTrip({ q: "[draft]" }), { q: "[draft]" });
64
+ });
65
+
66
+ it("gives ordinary text back unchanged", () => {
67
+ assert.deepEqual(roundTrip({ q: "from:ada invoice" }), {
68
+ q: "from:ada invoice",
69
+ });
70
+ });
71
+
72
+ it("gives a structure back as a structure", () => {
73
+ assert.deepEqual(roundTrip({ filter: { unread: true } }), {
74
+ filter: { unread: true },
75
+ });
76
+ });
77
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * How the address bar spells the query string.
3
+ *
4
+ * The router's own serializer writes one param per key, so a list of values
5
+ * comes out as a JSON blob — `?calendarId=%5B%22cal_a%22%2C%22cal_b%22%5D`. A
6
+ * list of values is what a query string has always been able to say, and
7
+ * saying it the ordinary way is what makes a link hand-writable, readable in a
8
+ * log, and usable by anything that is not this router. The router's own reader
9
+ * already collects repeated params into an array, so only the writing side was
10
+ * missing.
11
+ *
12
+ * Every other value keeps the spelling the router already gives it, including
13
+ * the quoting that text reading as JSON needs: the reader JSON-parses whatever
14
+ * it can, so `?q=true` would come back the boolean unless the string went in
15
+ * quoted.
16
+ */
17
+
18
+ /**
19
+ * Whether reading this text back would produce something other than the text.
20
+ *
21
+ * Decided by shape rather than by parsing it, because a block try/catch is not
22
+ * available and an over-quoted string still reads back as itself — the reader
23
+ * unquotes it — where an under-quoted one comes back the wrong type.
24
+ */
25
+ const readsAsJson = (value: string): boolean =>
26
+ value === "true" ||
27
+ value === "false" ||
28
+ value === "null" ||
29
+ /^["[{]/.test(value) ||
30
+ (value !== "" && Number.isFinite(Number(value)));
31
+
32
+ /** The router's own value spelling. */
33
+ function stringifyValue(value: unknown): string {
34
+ if (typeof value === "object" && value !== null) return JSON.stringify(value);
35
+ if (typeof value === "string")
36
+ return readsAsJson(value) ? JSON.stringify(value) : value;
37
+ return String(value);
38
+ }
39
+
40
+ /**
41
+ * A query string, with a list written as the repeated params it is.
42
+ *
43
+ * `undefined` is absent rather than empty: a fact the view has nothing to say
44
+ * about does not belong in the address at all.
45
+ */
46
+ export function stringifySearch(search: Record<string, unknown>): string {
47
+ const params = new URLSearchParams();
48
+ for (const key of Object.keys(search)) {
49
+ const value = search[key];
50
+ if (value === undefined) continue;
51
+ if (Array.isArray(value)) {
52
+ for (const entry of value) params.append(key, stringifyValue(entry));
53
+ continue;
54
+ }
55
+ params.set(key, stringifyValue(value));
56
+ }
57
+ const written = params.toString();
58
+ return written === "" ? "" : `?${written}`;
59
+ }