@remit/web-client 0.0.231 → 0.0.233

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 (47) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/AgendaStrip.stories.tsx +263 -0
  3. package/src/components/calendar/AgendaStrip.tsx +145 -0
  4. package/src/components/calendar/AgendaView.tsx +222 -0
  5. package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
  6. package/src/components/calendar/CalendarComposePane.tsx +83 -23
  7. package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
  8. package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
  9. package/src/components/calendar/CalendarEventPane.tsx +28 -5
  10. package/src/components/calendar/CalendarViewPlaceholder.tsx +3 -4
  11. package/src/components/calendar/CalendarWorkspace.stories.tsx +34 -1
  12. package/src/components/calendar/CalendarWorkspace.tsx +71 -23
  13. package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
  14. package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
  15. package/src/components/calendar/agenda-address.render.test.ts +389 -0
  16. package/src/components/calendar/agenda-strip.render.test.ts +241 -0
  17. package/src/components/calendar/calendar-workspace.render.test.ts +13 -6
  18. package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
  19. package/src/hooks/calendar/agenda-window.test.ts +235 -0
  20. package/src/hooks/calendar/agenda-window.ts +183 -0
  21. package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
  22. package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
  23. package/src/hooks/calendar/draft.test.ts +241 -0
  24. package/src/hooks/calendar/draft.ts +168 -0
  25. package/src/hooks/calendar/index.ts +88 -0
  26. package/src/hooks/calendar/instance.ts +136 -0
  27. package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
  28. package/src/hooks/calendar/recurrence-rule.ts +133 -0
  29. package/src/hooks/calendar/resource.test.ts +98 -0
  30. package/src/hooks/calendar/resource.ts +74 -0
  31. package/src/hooks/calendar/selection.ts +107 -0
  32. package/src/hooks/calendar/useCalendarEvent.ts +46 -0
  33. package/src/hooks/calendar/useCalendarEvents.ts +195 -0
  34. package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -0
  35. package/src/hooks/calendar/useCalendarWrites.ts +174 -0
  36. package/src/hooks/calendar/useCalendars.ts +43 -0
  37. package/src/hooks/calendar/window.test.ts +148 -0
  38. package/src/hooks/calendar/window.ts +168 -0
  39. package/src/hooks/useCalendarData.ts +62 -40
  40. package/src/lib/calendar-density.ts +13 -1
  41. package/src/lib/calendar-route.test.ts +20 -1
  42. package/src/lib/calendar-route.ts +21 -7
  43. package/src/routes/calendar/$view.$date/new.tsx +2 -2
  44. package/src/routes/calendar/$view.$date.tsx +51 -15
  45. package/src/routing/calendar.ts +32 -4
  46. package/src/test-support/http.ts +8 -0
  47. package/src/lib/calendar-fixtures.ts +0 -207
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The window a view asks the server about.
3
+ *
4
+ * What matters is that the window is a property of the screenful rather than of
5
+ * the day the address happens to name: every day inside one week has to produce
6
+ * the same `from` and `to`, because that is what makes stepping back to a week
7
+ * already read a cache hit instead of a second request for the same days.
8
+ *
9
+ * A window carries the device's own offsets, so those assertions are about
10
+ * civil dates and about windows agreeing with each other rather than about a
11
+ * literal `+02:00` that would only hold on a runner in Amsterdam. The zoned
12
+ * times below are the opposite: absolute, because the whole point of them is
13
+ * that the runner's zone stays out.
14
+ */
15
+ import assert from "node:assert/strict";
16
+ import { describe, it } from "node:test";
17
+ import {
18
+ addDays,
19
+ calendarWindow,
20
+ calendarWindowOfDays,
21
+ isoAt,
22
+ isoAtInZone,
23
+ } from "./window";
24
+
25
+ const day = (iso: string): string => iso.slice(0, 10);
26
+
27
+ const OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00[+-]\d{2}:\d{2}$/;
28
+
29
+ describe("the window a view covers", () => {
30
+ it("takes the whole week a day falls in, starting Monday", () => {
31
+ const window = calendarWindow("week", "2026-06-10");
32
+ assert.equal(day(window.from), "2026-06-08");
33
+ assert.equal(day(window.to), "2026-06-15");
34
+ });
35
+
36
+ it("gives every day of one week the same window", () => {
37
+ const monday = calendarWindow("week", "2026-06-08");
38
+ for (const date of ["2026-06-09", "2026-06-11", "2026-06-14"]) {
39
+ assert.deepEqual(calendarWindow("week", date), monday);
40
+ }
41
+ });
42
+
43
+ it("takes one day at the day zoom", () => {
44
+ const window = calendarWindow("day", "2026-06-10");
45
+ assert.equal(day(window.from), "2026-06-10");
46
+ assert.equal(day(window.to), "2026-06-11");
47
+ });
48
+
49
+ it("takes the calendar month, ending on the first of the next", () => {
50
+ const window = calendarWindow("month", "2026-06-10");
51
+ assert.equal(day(window.from), "2026-06-01");
52
+ assert.equal(day(window.to), "2026-07-01");
53
+ });
54
+
55
+ it("rolls a December month into the next year", () => {
56
+ assert.equal(day(calendarWindow("month", "2026-12-31").to), "2027-01-01");
57
+ });
58
+
59
+ it("takes the calendar year", () => {
60
+ const window = calendarWindow("year", "2026-06-10");
61
+ assert.equal(day(window.from), "2026-01-01");
62
+ assert.equal(day(window.to), "2027-01-01");
63
+ });
64
+
65
+ it("gives the agenda the week it steps by, so it shares the grid's entry", () => {
66
+ assert.deepEqual(
67
+ calendarWindow("agenda", "2026-06-10"),
68
+ calendarWindow("week", "2026-06-10"),
69
+ );
70
+ });
71
+
72
+ it("carries an explicit offset at both ends, as the API asks for", () => {
73
+ const window = calendarWindow("week", "2026-06-10");
74
+ assert.match(window.from, OFFSET);
75
+ assert.match(window.to, OFFSET);
76
+ });
77
+ });
78
+
79
+ describe("a window a surface rolls itself", () => {
80
+ it("runs whole days from the date it was given", () => {
81
+ const window = calendarWindowOfDays("2026-06-10", 3);
82
+ assert.equal(day(window.from), "2026-06-10");
83
+ assert.equal(day(window.to), "2026-06-13");
84
+ });
85
+ });
86
+
87
+ describe("civil dates", () => {
88
+ it("steps across a month boundary", () => {
89
+ assert.equal(addDays("2026-06-30", 1), "2026-07-01");
90
+ assert.equal(addDays("2026-03-01", -1), "2026-02-28");
91
+ });
92
+
93
+ it("puts a clock time on a day with the offset that day is on", () => {
94
+ assert.match(isoAt("2026-06-10", "09:15"), OFFSET);
95
+ assert.ok(isoAt("2026-06-10", "09:15").startsWith("2026-06-10T09:15:00"));
96
+ });
97
+ });
98
+
99
+ /**
100
+ * A named zone's offset, read without touching the runner's own clock. Every
101
+ * expectation here is absolute, because a test that reads the device offset
102
+ * cannot see the bug where the device offset leaked in.
103
+ */
104
+ describe("a clock time in a named zone", () => {
105
+ it("carries that zone's summer offset", () => {
106
+ assert.equal(
107
+ isoAtInZone("2026-06-10", "09:15", "Europe/Amsterdam"),
108
+ "2026-06-10T09:15:00+02:00",
109
+ );
110
+ assert.equal(
111
+ isoAtInZone("2026-06-10", "09:15", "America/New_York"),
112
+ "2026-06-10T09:15:00-04:00",
113
+ );
114
+ });
115
+
116
+ it("carries its winter offset for a winter date", () => {
117
+ assert.equal(
118
+ isoAtInZone("2026-01-14", "09:15", "Europe/Amsterdam"),
119
+ "2026-01-14T09:15:00+01:00",
120
+ );
121
+ });
122
+
123
+ it("settles a time on the far side of a spring-forward", () => {
124
+ // The Netherlands moves to +02:00 at 02:00 on 29 March 2026.
125
+ assert.equal(
126
+ isoAtInZone("2026-03-29", "09:00", "Europe/Amsterdam"),
127
+ "2026-03-29T09:00:00+02:00",
128
+ );
129
+ assert.equal(
130
+ isoAtInZone("2026-03-29", "00:30", "Europe/Amsterdam"),
131
+ "2026-03-29T00:30:00+01:00",
132
+ );
133
+ });
134
+
135
+ it("handles a zone that is not a whole number of hours off", () => {
136
+ assert.equal(
137
+ isoAtInZone("2026-06-10", "09:15", "Asia/Kolkata"),
138
+ "2026-06-10T09:15:00+05:30",
139
+ );
140
+ });
141
+
142
+ it("falls back to the device rather than throwing on a zone nothing knows", () => {
143
+ assert.match(
144
+ isoAtInZone("2026-06-10", "09:15", "Mars/Olympus_Mons"),
145
+ OFFSET,
146
+ );
147
+ });
148
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The stretch of time a calendar surface is asking the server about.
3
+ *
4
+ * A window is derived from the address rather than from the day the reader
5
+ * happens to be on, so every day inside one week asks for the same window and
6
+ * stepping back to a week already read is a cache hit rather than a request.
7
+ *
8
+ * Nothing here reads a clock: the caller passes the day it is drawing, and the
9
+ * offsets come from that day rather than from today, so a window spanning a DST
10
+ * change carries the right offset at each end.
11
+ */
12
+ import type { CalendarViewId } from "@remit/ui";
13
+
14
+ export interface CalendarWindow {
15
+ /** ISO 8601 date-time with offset. Inclusive. */
16
+ from: string;
17
+ /** ISO 8601 date-time with offset. Exclusive. */
18
+ to: string;
19
+ }
20
+
21
+ const pad = (value: number): string => String(value).padStart(2, "0");
22
+
23
+ const civil = (instant: Date): string =>
24
+ `${instant.getUTCFullYear()}-${pad(instant.getUTCMonth() + 1)}-${pad(
25
+ instant.getUTCDate(),
26
+ )}`;
27
+
28
+ const formatOffset = (minutes: number): string => {
29
+ const sign = minutes < 0 ? "-" : "+";
30
+ const size = Math.abs(minutes);
31
+ return `${sign}${pad(Math.floor(size / 60))}:${pad(size % 60)}`;
32
+ };
33
+
34
+ /** A clock time on a civil date, carrying the offset the device is on then. */
35
+ export function isoAt(date: string, time: string): string {
36
+ const local = new Date(`${date}T${time}:00`);
37
+ return `${date}T${time}:00${formatOffset(-local.getTimezoneOffset())}`;
38
+ }
39
+
40
+ const zoneReaders = new Map<string, Intl.DateTimeFormat | undefined>();
41
+
42
+ /**
43
+ * A zone nothing can resolve is not worth throwing over: the device's own clock
44
+ * is the honest fallback, and the resource records how its start was zoned.
45
+ */
46
+ function zoneReader(timeZone: string): Intl.DateTimeFormat | undefined {
47
+ if (zoneReaders.has(timeZone)) return zoneReaders.get(timeZone);
48
+ let reader: Intl.DateTimeFormat | undefined;
49
+ try {
50
+ reader = new Intl.DateTimeFormat("en-US", {
51
+ timeZone,
52
+ hourCycle: "h23",
53
+ year: "numeric",
54
+ month: "2-digit",
55
+ day: "2-digit",
56
+ hour: "2-digit",
57
+ minute: "2-digit",
58
+ second: "2-digit",
59
+ });
60
+ } catch {
61
+ reader = undefined;
62
+ }
63
+ zoneReaders.set(timeZone, reader);
64
+ return reader;
65
+ }
66
+
67
+ /** How far ahead of UTC a zone is at one instant, in minutes. */
68
+ function zoneOffsetAt(timeZone: string, instant: number): number {
69
+ const reader = zoneReader(timeZone);
70
+ if (!reader) return Number.NaN;
71
+ const parts = reader.formatToParts(new Date(instant));
72
+ const read = (type: Intl.DateTimeFormatPartTypes): number =>
73
+ Number(parts.find((part) => part.type === type)?.value);
74
+ const hour = read("hour") % 24;
75
+ const reading = Date.UTC(
76
+ read("year"),
77
+ read("month") - 1,
78
+ read("day"),
79
+ hour,
80
+ read("minute"),
81
+ read("second"),
82
+ );
83
+ return (reading - instant) / 60_000;
84
+ }
85
+
86
+ /**
87
+ * A clock time on a civil date, carrying the offset **that zone** is on then.
88
+ *
89
+ * The device's own offset is the wrong one for a calendar anchored elsewhere: a
90
+ * 10:00 Amsterdam meeting rewritten with a New York offset is a 16:00 Amsterdam
91
+ * meeting, and nothing on screen says it moved.
92
+ *
93
+ * The offset is read twice because reading it needs an instant and the instant
94
+ * needs the offset. The first pass is off only inside a DST transition, and the
95
+ * second pass — taken at the instant the first one implies — settles it.
96
+ */
97
+ export function isoAtInZone(
98
+ date: string,
99
+ time: string,
100
+ timeZone: string,
101
+ ): string {
102
+ // A collection naming no zone reads as UTC at both ends, and UTC is the one
103
+ // zone the canonical IANA list holds no name for — so it is handled here
104
+ // rather than asked about.
105
+ if (timeZone === "") return `${date}T${time}:00+00:00`;
106
+ const asIfUtc = Date.parse(`${date}T${time}:00Z`);
107
+ const first = zoneOffsetAt(timeZone, asIfUtc);
108
+ if (Number.isNaN(first)) return isoAt(date, time);
109
+ const settled = zoneOffsetAt(timeZone, asIfUtc - first * 60_000);
110
+ return `${date}T${time}:00${formatOffset(
111
+ Number.isNaN(settled) ? first : settled,
112
+ )}`;
113
+ }
114
+
115
+ /** Midnight on a civil date, carrying the offset the device is on that day. */
116
+ export const startOfDay = (date: string): string => isoAt(date, "00:00");
117
+
118
+ export function addDays(date: string, days: number): string {
119
+ const [year, month, day] = date.split("-").map(Number);
120
+ return civil(new Date(Date.UTC(year, month - 1, day + days)));
121
+ }
122
+
123
+ /** The Monday of the week a day falls in — the day the grid starts weeks on. */
124
+ function weekStart(date: string): string {
125
+ const [year, month, day] = date.split("-").map(Number);
126
+ const instant = new Date(Date.UTC(year, month - 1, day));
127
+ return addDays(date, -((instant.getUTCDay() + 6) % 7));
128
+ }
129
+
130
+ function bounds(view: CalendarViewId, date: string): [string, string] {
131
+ const [year, month] = date.split("-").map(Number);
132
+ if (view === "year") return [`${year}-01-01`, `${year + 1}-01-01`];
133
+ if (view === "month") {
134
+ const first = `${year}-${pad(month)}-01`;
135
+ const next =
136
+ month === 12 ? `${year + 1}-01-01` : `${year}-${pad(month + 1)}-01`;
137
+ return [first, next];
138
+ }
139
+ if (view === "day") return [date, addDays(date, 1)];
140
+ const start = weekStart(date);
141
+ return [start, addDays(start, 7)];
142
+ }
143
+
144
+ /**
145
+ * The window a view over a day covers.
146
+ *
147
+ * The week is the unit every surface reads in. A grid asks for the one week it
148
+ * draws; the strip holds many days and asks for each of their weeks
149
+ * (`weekWindowsOver`), because a single read may not cover more than a year and
150
+ * a range that grows as the reader scrolls would eventually become one. Both
151
+ * therefore key on the same entries, so dropping into the grid and coming back
152
+ * out to the strip fetches nothing either of them already holds.
153
+ */
154
+ export function calendarWindow(
155
+ view: CalendarViewId,
156
+ date: string,
157
+ ): CalendarWindow {
158
+ const [from, to] = bounds(view, date);
159
+ return { from: startOfDay(from), to: startOfDay(to) };
160
+ }
161
+
162
+ /** A window of whole days from one civil date, for a surface rolling its own. */
163
+ export function calendarWindowOfDays(
164
+ date: string,
165
+ days: number,
166
+ ): CalendarWindow {
167
+ return { from: startOfDay(date), to: startOfDay(addDays(date, days)) };
168
+ }
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * What the calendar surfaces read, and the only place they read it from.
3
3
  *
4
- * The body is fixture data until the REST layer lands: stage A.3 swaps it for
5
- * `GET /calendars` and `GET /calendar-events` over the range the view asks
6
- * for, and every caller keeps the call it already makes. The seam is here, and
7
- * not inside the grid, because the grid is presentational and expands no
8
- * recurrence of its own — the server returns instances.
4
+ * The seam is here, and not inside the grid, because the grid is presentational
5
+ * and expands no recurrence of its own — the server returns instances and this
6
+ * turns them into what the kit draws. Every caller keeps the call it makes.
9
7
  */
10
8
  import type {
11
9
  CalendarColorId,
@@ -13,12 +11,15 @@ import type {
13
11
  CalendarEventData,
14
12
  CalendarViewId,
15
13
  } from "@remit/ui";
16
- import { useMemo } from "react";
17
14
  import {
18
- fixtureCalendars,
19
- fixtureColorByCalendarId,
20
- fixtureEventsAround,
21
- } from "@/lib/calendar-fixtures";
15
+ type CalendarInstanceRef,
16
+ calendarWindow,
17
+ readCalendarInstanceId,
18
+ useCalendarEventWindow,
19
+ useCalendarSelection,
20
+ useDrawnEvents,
21
+ usePrefetchAdjacentWindows,
22
+ } from "@/hooks/calendar";
22
23
 
23
24
  export interface CalendarDataRequest {
24
25
  view: CalendarViewId;
@@ -26,47 +27,68 @@ export interface CalendarDataRequest {
26
27
  date: string;
27
28
  /** The calendars the address has ticked; empty means every one of them. */
28
29
  calendarIds: readonly string[];
30
+ /**
31
+ * False where the zoom on screen draws nothing from this window. The agenda
32
+ * rolls its own range a week at a time, so asking for this view's week as
33
+ * well would fetch a window nothing renders — and the address rewriting on
34
+ * every scroll would ask again, with its neighbours, all the way down.
35
+ */
36
+ enabled?: boolean;
29
37
  }
30
38
 
31
39
  export interface CalendarData {
32
40
  calendars: CalendarDescriptor[];
33
41
  events: CalendarEventData[];
34
42
  colorByCalendarId: Record<string, CalendarColorId>;
43
+ /** The zone each collection's times are read on — never the device's. */
44
+ timeZoneByCalendarId: Record<string, string>;
35
45
  isLoading: boolean;
36
- }
37
-
38
- /**
39
- * The calendars a request is asking about. An empty tick list is every
40
- * calendar rather than none: a reader who has ticked nothing off is looking at
41
- * all of them, and a URL naming a calendar that no longer exists shows the
42
- * rest instead of an empty week.
43
- */
44
- export function selectCalendarIds(
45
- calendars: readonly CalendarDescriptor[],
46
- ticked: readonly string[],
47
- ): string[] {
48
- const known = calendars.map((calendar) => calendar.id);
49
- if (ticked.length === 0) return known;
50
- const wanted = new Set(ticked);
51
- const shown = known.filter((id) => wanted.has(id));
52
- return shown.length === 0 ? known : shown;
46
+ /** A refusal the calendar renders itself. Null when the read succeeded. */
47
+ error: unknown;
48
+ retry: () => void;
49
+ /** The resource and occurrence behind an event the grid selected. */
50
+ instanceOf: (eventId: string) => CalendarInstanceRef;
53
51
  }
54
52
 
55
53
  export function useCalendarData({
54
+ view,
56
55
  date,
57
56
  calendarIds,
57
+ enabled = true,
58
58
  }: CalendarDataRequest): CalendarData {
59
- const key = [...calendarIds].join(",");
60
- return useMemo(() => {
61
- const ticked = key === "" ? [] : key.split(",");
62
- const shown = new Set(selectCalendarIds(fixtureCalendars, ticked));
63
- return {
64
- calendars: fixtureCalendars,
65
- events: fixtureEventsAround(date).filter((event) =>
66
- shown.has(event.calendarId),
67
- ),
68
- colorByCalendarId: fixtureColorByCalendarId,
69
- isLoading: false,
70
- };
71
- }, [date, key]);
59
+ const {
60
+ calendars,
61
+ colorByCalendarId,
62
+ timeZoneByCalendarId,
63
+ shown,
64
+ narrowed,
65
+ resolved,
66
+ isLoading,
67
+ } = useCalendarSelection(calendarIds);
68
+
69
+ const window = calendarWindow(view, date);
70
+ const active = enabled && resolved;
71
+ const events = useCalendarEventWindow({
72
+ ...window,
73
+ calendarIds: narrowed,
74
+ enabled: active,
75
+ });
76
+ usePrefetchAdjacentWindows(view, date, narrowed, active);
77
+
78
+ const eventData = useDrawnEvents(
79
+ events.instances,
80
+ timeZoneByCalendarId,
81
+ shown,
82
+ );
83
+
84
+ return {
85
+ calendars,
86
+ events: eventData,
87
+ colorByCalendarId,
88
+ timeZoneByCalendarId,
89
+ isLoading: isLoading || events.isLoading,
90
+ error: events.error,
91
+ retry: events.refetch,
92
+ instanceOf: readCalendarInstanceId,
93
+ };
72
94
  }
@@ -8,7 +8,7 @@
8
8
  * way the intelligence rail's preference is (`lib/intelligence-pref.ts`), and
9
9
  * a storage failure falls back to the default rather than crashing.
10
10
  */
11
- import type { Density } from "@remit/ui";
11
+ import type { AgendaDensity, Density } from "@remit/ui";
12
12
 
13
13
  export const CALENDAR_DENSITY_KEY = "remit:calendar-density";
14
14
 
@@ -32,3 +32,15 @@ export function writeCalendarDensity(density: Density): void {
32
32
  // Storage unavailable — the in-memory default stands.
33
33
  }
34
34
  }
35
+
36
+ /**
37
+ * The same preference, as the strip reads it.
38
+ *
39
+ * The strip has three readings and the preference has two, so the third — a
40
+ * month of coloured dots with no titles on it — is not reachable from Glance.
41
+ * That is deliberate: the preference says how much of a day this screen shows,
42
+ * not whether to say what is on it, and one calendar-wide answer beats a second
43
+ * control that means something different at every zoom.
44
+ */
45
+ export const agendaDensityOf = (density: Density): AgendaDensity =>
46
+ density === "compact" ? "pills" : "detail";
@@ -8,6 +8,7 @@ import { describe, it } from "node:test";
8
8
  import { CALENDAR_DENSITY_KEY } from "./calendar-density.js";
9
9
  import {
10
10
  calendarSearchSchema,
11
+ calendarViewMountsAgenda,
11
12
  calendarViewMountsGrid,
12
13
  calendarViews,
13
14
  canonicalCalendarParams,
@@ -89,13 +90,31 @@ describe("calendarViewMountsGrid", () => {
89
90
  assert.equal(calendarViewMountsGrid("day"), true);
90
91
  });
91
92
 
92
- it("has the other three still to come", () => {
93
+ it("leaves the agenda to the strip and the other two to a later stage", () => {
93
94
  assert.equal(calendarViewMountsGrid("year"), false);
94
95
  assert.equal(calendarViewMountsGrid("month"), false);
95
96
  assert.equal(calendarViewMountsGrid("agenda"), false);
96
97
  });
97
98
  });
98
99
 
100
+ describe("calendarViewMountsAgenda", () => {
101
+ it("draws the strip at one zoom and at no other", () => {
102
+ for (const view of calendarViews) {
103
+ assert.equal(calendarViewMountsAgenda(view), view === "agenda");
104
+ }
105
+ });
106
+
107
+ it("never draws the same zoom as the grid", () => {
108
+ for (const view of calendarViews) {
109
+ assert.equal(
110
+ calendarViewMountsAgenda(view) && calendarViewMountsGrid(view),
111
+ false,
112
+ `${view} claimed two surfaces`,
113
+ );
114
+ }
115
+ });
116
+ });
117
+
99
118
  describe("stepCalendarDate", () => {
100
119
  it("steps a screenful in the view's own unit", () => {
101
120
  assert.equal(stepCalendarDate("2026-06-10", "day", 1), "2026-06-11");
@@ -32,15 +32,24 @@ const calendarViewSchema = z.enum(calendarViews);
32
32
  export const DEFAULT_CALENDAR_VIEW: CalendarViewId = "week";
33
33
 
34
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.
35
+ * The views the grid draws. Year and month are named surfaces that arrive in
36
+ * later stages, and they say so on screen rather than matching a route that
37
+ * renders nothing.
38
38
  */
39
39
  const GRID_VIEWS = new Set<CalendarViewId>(["week", "day"]);
40
40
 
41
41
  export const calendarViewMountsGrid = (view: CalendarViewId): boolean =>
42
42
  GRID_VIEWS.has(view);
43
43
 
44
+ /**
45
+ * The one view the strip draws. It is a zoom of the same ladder rather than a
46
+ * different screen: the grid is what a reader drops into for a day that has
47
+ * earned it, and the strip is what they come back out to, with the day and the
48
+ * ticked calendars they left with.
49
+ */
50
+ export const calendarViewMountsAgenda = (view: CalendarViewId): boolean =>
51
+ view === "agenda";
52
+
44
53
  /** The view a segment names, or `undefined` where it names none. */
45
54
  export function parseCalendarView(segment: string): CalendarViewId | undefined {
46
55
  const parsed = calendarViewSchema.safeParse(segment);
@@ -101,7 +110,8 @@ export function canonicalCalendarParams(
101
110
  /**
102
111
  * The day the previous or next screenful starts on. A step is measured in the
103
112
  * 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.
113
+ * came from at every zoom. Agenda steps a week, which is far enough to be worth
114
+ * a press and near enough to stay inside the days it is already holding.
105
115
  */
106
116
  export function stepCalendarDate(
107
117
  date: string,
@@ -137,9 +147,13 @@ function clampedDate(year: number, month: number, day: number): string {
137
147
  }
138
148
 
139
149
  /**
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.
150
+ * A calendar's id, as the URL carries it.
151
+ *
152
+ * Deliberately looser than the id the API mints. An address is something people
153
+ * paste, bookmark and hand-edit, and what to do with an id that names no
154
+ * calendar is already settled downstream: the reader is shown the calendars
155
+ * that do exist. Refusing the segment here would turn a stale link into an
156
+ * error page instead of a calendar.
143
157
  */
144
158
  const calendarIdSchema = z.string().min(1);
145
159
 
@@ -7,12 +7,12 @@
7
7
  * move `compose` makes over a mail list.
8
8
  */
9
9
  import { createFileRoute } from "@tanstack/react-router";
10
- import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
10
+ import { WriteCalendarEvent } from "@/components/calendar/WriteCalendarEvent";
11
11
  import { useCalendarNavigation } from "@/routing";
12
12
 
13
13
  function CalendarComposeRoute() {
14
14
  const { closeEvent } = useCalendarNavigation();
15
- return <CalendarComposePane onClose={closeEvent} />;
15
+ return <WriteCalendarEvent onClose={closeEvent} />;
16
16
  }
17
17
 
18
18
  export const Route = createFileRoute("/calendar/$view/$date/new")({