@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
@@ -13,18 +13,22 @@
13
13
  * was readable is kept, so `/calendar/fortnight/2026-06-10` lands on that day's
14
14
  * week instead of on an error.
15
15
  */
16
+ import type { CalendarSlotPick } from "@remit/ui";
16
17
  import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
17
18
  import { useCallback, useState } from "react";
19
+ import { AgendaView } from "@/components/calendar/AgendaView";
20
+ import { CalendarComposeSeedProvider } from "@/components/calendar/CalendarComposeSeed";
18
21
  import { CalendarShell } from "@/components/calendar/CalendarShell";
19
22
  import { CalendarWorkspace } from "@/components/calendar/CalendarWorkspace";
23
+ import { calendarInstanceId, deviceTimeZone } from "@/hooks/calendar";
20
24
  import { useCalendarData } from "@/hooks/useCalendarData";
21
25
  import {
22
26
  readCalendarDensity,
23
27
  writeCalendarDensity,
24
28
  } from "@/lib/calendar-density";
25
- import { fixtureTimeZone } from "@/lib/calendar-fixtures";
26
29
  import {
27
30
  calendarSearchSchema,
31
+ calendarViewMountsAgenda,
28
32
  canonicalCalendarParams,
29
33
  isoDate,
30
34
  } from "@/lib/calendar-route";
@@ -53,11 +57,13 @@ export const Route = createFileRoute("/calendar/$view/$date")({
53
57
 
54
58
  function CalendarViewLayout() {
55
59
  const { view, date, calendarIds } = useCalendarAddress();
56
- const { events, colorByCalendarId } = useCalendarData({
57
- view,
58
- date,
59
- calendarIds,
60
- });
60
+ // The strip fetches the days it holds a week at a time and draws none of
61
+ // this, so at that zoom the layout asks for nothing: the address rewrites on
62
+ // every scroll, and each rewrite would otherwise fetch a week nobody renders
63
+ // along with the two beside it.
64
+ const drawsGrid = !calendarViewMountsAgenda(view);
65
+ const { events, colorByCalendarId, isLoading, error, retry, instanceOf } =
66
+ useCalendarData({ view, date, calendarIds, enabled: drawsGrid });
61
67
  const { goToView, goToToday, step, openEvent, openComposer } =
62
68
  useCalendarNavigation();
63
69
  const openedEvent = useOpenCalendarEvent();
@@ -70,6 +76,23 @@ function CalendarViewLayout() {
70
76
  writeCalendarDensity(next);
71
77
  setDensity(next);
72
78
  }, []);
79
+ const [pick, setPick] = useState<CalendarSlotPick | undefined>(undefined);
80
+
81
+ const selectEvent = useCallback(
82
+ (eventId: string) => {
83
+ const instance = instanceOf(eventId);
84
+ openEvent(instance.calendarObjectId, instance.recurrenceId);
85
+ },
86
+ [instanceOf, openEvent],
87
+ );
88
+
89
+ const pickSlot = useCallback(
90
+ (slot: CalendarSlotPick) => {
91
+ setPick(slot);
92
+ openComposer();
93
+ },
94
+ [openComposer],
95
+ );
73
96
 
74
97
  const workspace = (
75
98
  <CalendarWorkspace
@@ -77,24 +100,37 @@ function CalendarViewLayout() {
77
100
  date={date}
78
101
  events={events}
79
102
  colorByCalendarId={colorByCalendarId}
103
+ agenda={<AgendaView density={density} onPickSlot={pickSlot} />}
104
+ isLoading={isLoading}
105
+ error={error}
106
+ onRetry={retry}
80
107
  density={density}
81
- selectedEventId={openedEvent?.calendarObjectId ?? ""}
82
- timeZone={fixtureTimeZone()}
108
+ selectedEventId={
109
+ openedEvent
110
+ ? calendarInstanceId(
111
+ openedEvent.calendarObjectId,
112
+ openedEvent.recurrenceId ?? "",
113
+ )
114
+ : ""
115
+ }
116
+ timeZone={deviceTimeZone()}
83
117
  now={new Date().toISOString()}
84
118
  onChangeView={goToView}
85
119
  onToday={goToToday}
86
120
  onStep={step}
87
121
  onChangeDensity={changeDensity}
88
- onSelectEvent={openEvent}
89
- onPickSlot={openComposer}
122
+ onSelectEvent={selectEvent}
123
+ onPickSlot={pickSlot}
90
124
  />
91
125
  );
92
126
 
93
127
  return (
94
- <CalendarShell
95
- workspace={workspace}
96
- reading={<Outlet />}
97
- hasOpenEvent={openedEvent !== undefined || isWriting}
98
- />
128
+ <CalendarComposeSeedProvider pick={pick}>
129
+ <CalendarShell
130
+ workspace={workspace}
131
+ reading={<Outlet />}
132
+ hasOpenEvent={openedEvent !== undefined || isWriting}
133
+ />
134
+ </CalendarComposeSeedProvider>
99
135
  );
100
136
  }
@@ -82,7 +82,19 @@ export interface CalendarNavigation {
82
82
  step: (direction: -1 | 1) => void;
83
83
  /** Home, from every view and every distance away. */
84
84
  goToToday: () => void;
85
- openEvent: (calendarObjectId: string) => void;
85
+ /**
86
+ * The day the strip has scrolled to, written by `replace`. Scrolling is not
87
+ * somewhere the reader went — Back belongs to the screen they arrived from,
88
+ * not to every row they passed on the way down.
89
+ */
90
+ setAnchorDate: (date: string) => void;
91
+ /** Out of the list and into the grid for one day. */
92
+ zoomToDay: (date: string) => void;
93
+ /**
94
+ * One occurrence names the series it belongs to and the instance under it;
95
+ * a resource that does not recur names only itself.
96
+ */
97
+ openEvent: (calendarObjectId: string, recurrenceId?: string) => void;
86
98
  closeEvent: () => void;
87
99
  openComposer: () => void;
88
100
  }
@@ -118,13 +130,29 @@ export function useCalendarNavigation(): CalendarNavigation {
118
130
  step: (direction: -1 | 1) =>
119
131
  goTo({ view, date: stepCalendarDate(date, view, direction) }),
120
132
  goToToday: () => goTo({ view, date: isoDate(new Date()) }),
121
- openEvent: (calendarObjectId: string) =>
133
+ setAnchorDate: (next: string) =>
122
134
  navigate({
123
- to: "/calendar/$view/$date/$calendarObjectId",
124
- params: { view, date, calendarObjectId },
135
+ to: VIEW_ROUTE,
136
+ params: { view, date: next },
125
137
  search: true,
126
138
  hash: retainPanels,
139
+ replace: true,
127
140
  }),
141
+ zoomToDay: (next: string) => goTo({ view: "day", date: next }),
142
+ openEvent: (calendarObjectId: string, recurrenceId?: string) =>
143
+ recurrenceId === undefined || recurrenceId === ""
144
+ ? navigate({
145
+ to: "/calendar/$view/$date/$calendarObjectId",
146
+ params: { view, date, calendarObjectId },
147
+ search: true,
148
+ hash: retainPanels,
149
+ })
150
+ : navigate({
151
+ to: "/calendar/$view/$date/$calendarObjectId/$recurrenceId",
152
+ params: { view, date, calendarObjectId, recurrenceId },
153
+ search: true,
154
+ hash: retainPanels,
155
+ }),
128
156
  closeEvent: () => goTo({ view, date }),
129
157
  openComposer: () =>
130
158
  navigate({
@@ -8,6 +8,8 @@ export interface HttpCall {
8
8
  method: string;
9
9
  url: string;
10
10
  path: string;
11
+ /** Every request header, lower-cased — a conditional write is one of these. */
12
+ headers: Record<string, string>;
11
13
  body: Record<string, unknown> | undefined;
12
14
  }
13
15
 
@@ -36,10 +38,16 @@ export const mockFetch = (responder: Responder = () => ({})): HttpMock => {
36
38
  const request = input instanceof Request ? input : undefined;
37
39
  const url = request ? request.url : String(input);
38
40
  const rawBody = request ? await request.clone().text() : undefined;
41
+ const headers: Record<string, string> = {};
42
+ const sent = request ? request.headers : new Headers(init?.headers);
43
+ sent.forEach((value, name) => {
44
+ headers[name.toLowerCase()] = value;
45
+ });
39
46
  const call: HttpCall = {
40
47
  method: (request?.method ?? init?.method ?? "GET").toUpperCase(),
41
48
  url,
42
49
  path: new URL(url, "http://localhost").pathname,
50
+ headers,
43
51
  body: rawBody ? JSON.parse(rawBody) : undefined,
44
52
  };
45
53
  calls.push(call);
@@ -1,207 +0,0 @@
1
- /**
2
- * A week of invented calendar, so the routes and the grid can be walked before
3
- * the REST layer exists. Every name here is made up and every event is
4
- * generated from the day being looked at, which is what keeps the grid full
5
- * wherever the reader navigates instead of only in June 2026.
6
- *
7
- * Stage A.3 deletes this file: `hooks/useCalendarData` swaps its body for the
8
- * query hooks and nothing that renders a calendar changes.
9
- */
10
- import type {
11
- CalendarColorId,
12
- CalendarDescriptor,
13
- CalendarEventData,
14
- } from "@remit/ui";
15
-
16
- export const workCalendarId = "fixture-work";
17
- export const personalCalendarId = "fixture-personal";
18
- export const teamCalendarId = "fixture-team";
19
-
20
- export const fixtureCalendars: CalendarDescriptor[] = [
21
- {
22
- id: workCalendarId,
23
- accountId: "fixture-account-work",
24
- accountLabel: "Work",
25
- name: "Work",
26
- color: "cal-1",
27
- },
28
- {
29
- id: teamCalendarId,
30
- accountId: "fixture-account-work",
31
- accountLabel: "Work",
32
- name: "Team",
33
- color: "cal-6",
34
- },
35
- {
36
- id: personalCalendarId,
37
- accountId: "fixture-account-personal",
38
- accountLabel: "Personal",
39
- name: "Personal",
40
- color: "cal-4",
41
- },
42
- ];
43
-
44
- export const fixtureColorByCalendarId: Record<string, CalendarColorId> =
45
- Object.fromEntries(
46
- fixtureCalendars.map((calendar) => [calendar.id, calendar.color]),
47
- );
48
-
49
- const pad = (value: number): string => String(value).padStart(2, "0");
50
-
51
- /** The Monday of the week a day falls in; the grid starts its weeks there. */
52
- function weekStart(date: string): Date {
53
- const [year, month, day] = date.split("-").map(Number);
54
- const instant = new Date(Date.UTC(year, month - 1, day));
55
- const weekday = (instant.getUTCDay() + 6) % 7;
56
- instant.setUTCDate(instant.getUTCDate() - weekday);
57
- return instant;
58
- }
59
-
60
- function dayOffsetFrom(start: Date, days: number): string {
61
- const instant = new Date(start);
62
- instant.setUTCDate(instant.getUTCDate() + days);
63
- return `${instant.getUTCFullYear()}-${pad(instant.getUTCMonth() + 1)}-${pad(
64
- instant.getUTCDate(),
65
- )}`;
66
- }
67
-
68
- /** The zone the device is on, which is the only clock a fixture can honestly use. */
69
- export const fixtureTimeZone = (): string =>
70
- Intl.DateTimeFormat().resolvedOptions().timeZone;
71
-
72
- /** An ISO instant carrying the device's own offset for that day. */
73
- function at(date: string, hour: number, minute: number): string {
74
- const local = new Date(`${date}T${pad(hour)}:${pad(minute)}:00`);
75
- const offset = -local.getTimezoneOffset();
76
- const sign = offset < 0 ? "-" : "+";
77
- const size = Math.abs(offset);
78
- return `${date}T${pad(hour)}:${pad(minute)}:00${sign}${pad(
79
- Math.floor(size / 60),
80
- )}:${pad(size % 60)}`;
81
- }
82
-
83
- const template: CalendarEventData = {
84
- id: "",
85
- calendarId: workCalendarId,
86
- title: "",
87
- start: "",
88
- end: "",
89
- allDay: false,
90
- location: "",
91
- notes: "",
92
- attendees: [],
93
- myRsvp: "accepted",
94
- threadId: "",
95
- threadSubject: "",
96
- timeZone: "",
97
- zoneCertainty: "explicit",
98
- recurrenceRule: "",
99
- seriesId: "",
100
- seriesException: false,
101
- status: "confirmed",
102
- };
103
-
104
- interface FixtureSpec {
105
- dayOffset: number;
106
- startHour: number;
107
- startMinute: number;
108
- endHour: number;
109
- endMinute: number;
110
- title: string;
111
- calendarId: string;
112
- location: string;
113
- recurrenceRule: string;
114
- seriesId: string;
115
- }
116
-
117
- const spec = (
118
- dayOffset: number,
119
- title: string,
120
- startHour: number,
121
- endHour: number,
122
- over: Partial<FixtureSpec> = {},
123
- ): FixtureSpec => ({
124
- dayOffset,
125
- startHour,
126
- startMinute: 0,
127
- endHour,
128
- endMinute: 0,
129
- title,
130
- calendarId: workCalendarId,
131
- location: "",
132
- recurrenceRule: "",
133
- seriesId: "",
134
- ...over,
135
- });
136
-
137
- /**
138
- * One made-up week: a daily standup, a couple of long blocks, an overlap on
139
- * Wednesday and something on a Saturday, so every band the grid draws has
140
- * something in it.
141
- */
142
- const WEEK: FixtureSpec[] = [
143
- spec(0, "Standup", 9, 9, {
144
- startMinute: 15,
145
- endMinute: 30,
146
- recurrenceRule: "Every weekday, 09:15",
147
- seriesId: "fixture-standup",
148
- }),
149
- spec(0, "Roadmap review", 11, 12, { calendarId: teamCalendarId }),
150
- spec(1, "Standup", 9, 9, {
151
- startMinute: 15,
152
- endMinute: 30,
153
- recurrenceRule: "Every weekday, 09:15",
154
- seriesId: "fixture-standup",
155
- }),
156
- spec(1, "Supplier call", 14, 15, {
157
- calendarId: teamCalendarId,
158
- location: "Meet",
159
- }),
160
- spec(2, "Standup", 9, 9, {
161
- startMinute: 15,
162
- endMinute: 30,
163
- recurrenceRule: "Every weekday, 09:15",
164
- seriesId: "fixture-standup",
165
- }),
166
- spec(2, "Design review", 10, 12, { calendarId: teamCalendarId }),
167
- spec(2, "Dentist", 11, 12, {
168
- calendarId: personalCalendarId,
169
- location: "Kerkstraat 4",
170
- }),
171
- spec(3, "Standup", 9, 9, {
172
- startMinute: 15,
173
- endMinute: 30,
174
- recurrenceRule: "Every weekday, 09:15",
175
- seriesId: "fixture-standup",
176
- }),
177
- spec(3, "One to one", 15, 16, { location: "Room 2" }),
178
- spec(4, "Standup", 9, 9, {
179
- startMinute: 15,
180
- endMinute: 30,
181
- recurrenceRule: "Every weekday, 09:15",
182
- seriesId: "fixture-standup",
183
- }),
184
- spec(4, "Retro", 16, 17, { calendarId: teamCalendarId }),
185
- spec(5, "Climbing", 10, 12, { calendarId: personalCalendarId }),
186
- ];
187
-
188
- /** The invented week around a day, in the zone the device is running on. */
189
- export function fixtureEventsAround(date: string): CalendarEventData[] {
190
- const start = weekStart(date);
191
- const timeZone = fixtureTimeZone();
192
- return WEEK.map((entry) => {
193
- const day = dayOffsetFrom(start, entry.dayOffset);
194
- return {
195
- ...template,
196
- id: `fixture-${day}-${entry.title.toLowerCase().replace(/\s+/g, "-")}`,
197
- calendarId: entry.calendarId,
198
- title: entry.title,
199
- start: at(day, entry.startHour, entry.startMinute),
200
- end: at(day, entry.endHour, entry.endMinute),
201
- location: entry.location,
202
- timeZone,
203
- recurrenceRule: entry.recurrenceRule,
204
- seriesId: entry.seriesId,
205
- };
206
- });
207
- }