@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.
- package/package.json +1 -1
- package/src/components/calendar/CalendarComposePane.stories.tsx +25 -0
- package/src/components/calendar/CalendarComposePane.tsx +34 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +81 -0
- package/src/components/calendar/CalendarEventPane.tsx +67 -0
- package/src/components/calendar/CalendarShell.tsx +83 -0
- package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +197 -0
- package/src/components/calendar/CalendarWorkspace.tsx +162 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +39 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
- package/src/components/mail/MailSidebarAdapter.tsx +17 -0
- package/src/hooks/useCalendarData.ts +72 -0
- package/src/lib/calendar-density.ts +34 -0
- package/src/lib/calendar-fixtures.ts +207 -0
- package/src/lib/calendar-format.ts +29 -0
- package/src/lib/calendar-route.test.ts +210 -0
- package/src/lib/calendar-route.ts +183 -0
- package/src/lib/search-params.test.ts +77 -0
- package/src/lib/search-params.ts +59 -0
- package/src/routeTree.gen.ts +199 -0
- package/src/router.tsx +5 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
- package/src/routes/calendar/$view.$date/index.tsx +25 -0
- package/src/routes/calendar/$view.$date/new.tsx +20 -0
- package/src/routes/calendar/$view.$date.tsx +100 -0
- package/src/routes/calendar/-calendar-tree.test.ts +267 -0
- package/src/routes/calendar/index.tsx +27 -0
- package/src/routes/calendar.tsx +98 -0
- package/src/routing/calendar-fragment.test.ts +66 -0
- package/src/routing/calendar.ts +139 -0
- package/src/routing/fragment.ts +19 -0
- package/src/routing/index.ts +10 -0
- package/src/routing/nav-selection.ts +2 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
|
|
2
|
+
/**
|
|
3
|
+
* /calendar/{view}/{date} — the calendar, at one zoom, on one day.
|
|
4
|
+
*
|
|
5
|
+
* Both are path segments. The five zooms are mutually exclusive and each is a
|
|
6
|
+
* different surface, so sibling routes cannot both match (R5); the day is a
|
|
7
|
+
* segment because the strip is infinite and the address has to name where it
|
|
8
|
+
* opened — a `?date=` param would be the query compensating for a missing
|
|
9
|
+
* segment (R4).
|
|
10
|
+
*
|
|
11
|
+
* A segment the calendar cannot read is rewritten rather than refused. A
|
|
12
|
+
* hand-edited or stale address is a normal thing to receive, and the half that
|
|
13
|
+
* was readable is kept, so `/calendar/fortnight/2026-06-10` lands on that day's
|
|
14
|
+
* week instead of on an error.
|
|
15
|
+
*/
|
|
16
|
+
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
|
17
|
+
import { useCallback, useState } from "react";
|
|
18
|
+
import { CalendarShell } from "@/components/calendar/CalendarShell";
|
|
19
|
+
import { CalendarWorkspace } from "@/components/calendar/CalendarWorkspace";
|
|
20
|
+
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
21
|
+
import {
|
|
22
|
+
readCalendarDensity,
|
|
23
|
+
writeCalendarDensity,
|
|
24
|
+
} from "@/lib/calendar-density";
|
|
25
|
+
import { fixtureTimeZone } from "@/lib/calendar-fixtures";
|
|
26
|
+
import {
|
|
27
|
+
calendarSearchSchema,
|
|
28
|
+
canonicalCalendarParams,
|
|
29
|
+
isoDate,
|
|
30
|
+
} from "@/lib/calendar-route";
|
|
31
|
+
import {
|
|
32
|
+
useCalendarAddress,
|
|
33
|
+
useCalendarNavigation,
|
|
34
|
+
useIsWritingEvent,
|
|
35
|
+
useOpenCalendarEvent,
|
|
36
|
+
} from "@/routing";
|
|
37
|
+
|
|
38
|
+
export const Route = createFileRoute("/calendar/$view/$date")({
|
|
39
|
+
validateSearch: calendarSearchSchema,
|
|
40
|
+
beforeLoad: ({ params, search }) => {
|
|
41
|
+
const canonical = canonicalCalendarParams(params, isoDate(new Date()));
|
|
42
|
+
if (canonical.view === params.view && canonical.date === params.date)
|
|
43
|
+
return;
|
|
44
|
+
throw redirect({
|
|
45
|
+
to: "/calendar/$view/$date",
|
|
46
|
+
params: canonical,
|
|
47
|
+
search,
|
|
48
|
+
replace: true,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
component: CalendarViewLayout,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function CalendarViewLayout() {
|
|
55
|
+
const { view, date, calendarIds } = useCalendarAddress();
|
|
56
|
+
const { events, colorByCalendarId } = useCalendarData({
|
|
57
|
+
view,
|
|
58
|
+
date,
|
|
59
|
+
calendarIds,
|
|
60
|
+
});
|
|
61
|
+
const { goToView, goToToday, step, openEvent, openComposer } =
|
|
62
|
+
useCalendarNavigation();
|
|
63
|
+
const openedEvent = useOpenCalendarEvent();
|
|
64
|
+
const isWriting = useIsWritingEvent();
|
|
65
|
+
// Held in state rather than read back from storage each render: how much of
|
|
66
|
+
// a day this device shows is not a fact about the calendar, so changing it
|
|
67
|
+
// changes nothing about the address.
|
|
68
|
+
const [density, setDensity] = useState(readCalendarDensity);
|
|
69
|
+
const changeDensity = useCallback((next: typeof density) => {
|
|
70
|
+
writeCalendarDensity(next);
|
|
71
|
+
setDensity(next);
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const workspace = (
|
|
75
|
+
<CalendarWorkspace
|
|
76
|
+
view={view}
|
|
77
|
+
date={date}
|
|
78
|
+
events={events}
|
|
79
|
+
colorByCalendarId={colorByCalendarId}
|
|
80
|
+
density={density}
|
|
81
|
+
selectedEventId={openedEvent?.calendarObjectId ?? ""}
|
|
82
|
+
timeZone={fixtureTimeZone()}
|
|
83
|
+
now={new Date().toISOString()}
|
|
84
|
+
onChangeView={goToView}
|
|
85
|
+
onToday={goToToday}
|
|
86
|
+
onStep={step}
|
|
87
|
+
onChangeDensity={changeDensity}
|
|
88
|
+
onSelectEvent={openEvent}
|
|
89
|
+
onPickSlot={openComposer}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<CalendarShell
|
|
95
|
+
workspace={workspace}
|
|
96
|
+
reading={<Outlet />}
|
|
97
|
+
hasOpenEvent={openedEvent !== undefined || isWriting}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
|
|
2
|
+
/**
|
|
3
|
+
* The calendar's route tree.
|
|
4
|
+
*
|
|
5
|
+
* What is asserted is what the address does rather than what a component
|
|
6
|
+
* renders: which routes a path mounts, which segment carries which fact, and
|
|
7
|
+
* where an address the calendar cannot read is sent instead. The rules are
|
|
8
|
+
* `docs/architecture/url-state.md` — the view and the day are segments (R4/R5),
|
|
9
|
+
* the ticked calendars are query validated by one schema (R7), and no fact
|
|
10
|
+
* appears at two tiers.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import {
|
|
15
|
+
type AnyRoute,
|
|
16
|
+
type AnyRouter,
|
|
17
|
+
createMemoryHistory,
|
|
18
|
+
createRootRoute,
|
|
19
|
+
createRouter,
|
|
20
|
+
isRedirect,
|
|
21
|
+
Outlet,
|
|
22
|
+
} from "@tanstack/react-router";
|
|
23
|
+
import {
|
|
24
|
+
calendarSearchSchema,
|
|
25
|
+
isoDate,
|
|
26
|
+
stepCalendarDate,
|
|
27
|
+
} from "@/lib/calendar-route";
|
|
28
|
+
import { stringifySearch } from "@/lib/search-params";
|
|
29
|
+
import { Route as OccurrenceRoute } from "./$view.$date/$calendarObjectId/$recurrenceId.js";
|
|
30
|
+
import { Route as EventIndexRoute } from "./$view.$date/$calendarObjectId/index.js";
|
|
31
|
+
import { Route as EventRoute } from "./$view.$date/$calendarObjectId.js";
|
|
32
|
+
import { Route as ReadingPaneRoute } from "./$view.$date/index.js";
|
|
33
|
+
import { Route as ComposeRoute } from "./$view.$date/new.js";
|
|
34
|
+
import { Route as ViewRoute } from "./$view.$date.js";
|
|
35
|
+
import { Route as CalendarIndexRoute } from "./index.js";
|
|
36
|
+
|
|
37
|
+
// The router reads `self` at construction; the shared globals stop at `window`.
|
|
38
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
39
|
+
|
|
40
|
+
/** The router types a file route's own `update` more narrowly than a test needs. */
|
|
41
|
+
type Attachable = { update: (options: unknown) => AnyRoute };
|
|
42
|
+
const attach = (route: unknown, options: unknown): AnyRoute =>
|
|
43
|
+
(route as Attachable).update(options);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The calendar's routes, mounted the way the generated tree mounts them but
|
|
47
|
+
* without the `/calendar` layout above them — that route only fetches the
|
|
48
|
+
* account list the sidebar is built from, and no rule under test is its. Built
|
|
49
|
+
* once: a file route is a module singleton, and re-parenting one per case would
|
|
50
|
+
* be re-parenting the same object.
|
|
51
|
+
*/
|
|
52
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
53
|
+
const viewRoute = attach(ViewRoute, {
|
|
54
|
+
path: "/calendar/$view/$date",
|
|
55
|
+
getParentRoute: () => rootRoute,
|
|
56
|
+
});
|
|
57
|
+
const eventRoute = attach(EventRoute, {
|
|
58
|
+
path: "$calendarObjectId",
|
|
59
|
+
getParentRoute: () => viewRoute,
|
|
60
|
+
});
|
|
61
|
+
eventRoute.addChildren([
|
|
62
|
+
attach(EventIndexRoute, { path: "/", getParentRoute: () => eventRoute }),
|
|
63
|
+
attach(OccurrenceRoute, {
|
|
64
|
+
path: "$recurrenceId",
|
|
65
|
+
getParentRoute: () => eventRoute,
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
viewRoute.addChildren([
|
|
69
|
+
attach(ReadingPaneRoute, { path: "/", getParentRoute: () => viewRoute }),
|
|
70
|
+
attach(ComposeRoute, { path: "new", getParentRoute: () => viewRoute }),
|
|
71
|
+
eventRoute,
|
|
72
|
+
]);
|
|
73
|
+
const routeTree = rootRoute.addChildren([
|
|
74
|
+
attach(CalendarIndexRoute, {
|
|
75
|
+
path: "/calendar",
|
|
76
|
+
getParentRoute: () => rootRoute,
|
|
77
|
+
}),
|
|
78
|
+
viewRoute,
|
|
79
|
+
]) as unknown as AnyRoute;
|
|
80
|
+
|
|
81
|
+
interface Match {
|
|
82
|
+
routeIds: string[];
|
|
83
|
+
params: Record<string, string>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A router over the tree, spelling its query string the way the app does. */
|
|
87
|
+
const routerAt = (entry: string): AnyRouter =>
|
|
88
|
+
createRouter({
|
|
89
|
+
routeTree,
|
|
90
|
+
stringifySearch,
|
|
91
|
+
history: createMemoryHistory({ initialEntries: [entry] }),
|
|
92
|
+
}) as unknown as AnyRouter;
|
|
93
|
+
|
|
94
|
+
/** The routes an address mounts, deepest last. */
|
|
95
|
+
function match(entry: string): Match {
|
|
96
|
+
const router = routerAt(entry);
|
|
97
|
+
const matches = router.matchRoutes(router.state.location);
|
|
98
|
+
return {
|
|
99
|
+
routeIds: matches.map((one: { routeId: string }) => one.routeId),
|
|
100
|
+
params: matches[matches.length - 1].params as Record<string, string>,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface SentTo {
|
|
105
|
+
to?: string;
|
|
106
|
+
params?: Record<string, string>;
|
|
107
|
+
search?: Record<string, unknown>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const beforeLoadOf = (route: unknown): ((args: unknown) => void) => {
|
|
111
|
+
const { beforeLoad } = (
|
|
112
|
+
route as { options: { beforeLoad?: (args: unknown) => void } }
|
|
113
|
+
).options;
|
|
114
|
+
assert.ok(beforeLoad, "the route decides nothing about the address it got");
|
|
115
|
+
return beforeLoad;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type Address = { params: Record<string, string>; search: unknown };
|
|
119
|
+
|
|
120
|
+
/** Where a route sends an address it will not serve. */
|
|
121
|
+
function sentTo(route: unknown, address: Address): SentTo {
|
|
122
|
+
let sent: SentTo | undefined;
|
|
123
|
+
assert.throws(
|
|
124
|
+
() => beforeLoadOf(route)(address),
|
|
125
|
+
(thrown: unknown) => {
|
|
126
|
+
assert.ok(isRedirect(thrown), `not a redirect: ${String(thrown)}`);
|
|
127
|
+
sent = (thrown as { options: SentTo }).options;
|
|
128
|
+
return true;
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
return sent as SentTo;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** That the route serves the address as it stands, rewriting nothing. */
|
|
135
|
+
function serves(route: unknown, address: Address): void {
|
|
136
|
+
assert.doesNotThrow(() => beforeLoadOf(route)(address));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const TODAY = isoDate(new Date());
|
|
140
|
+
const held = (view: string, date: string) => ({
|
|
141
|
+
params: { view, date },
|
|
142
|
+
search: {},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("/calendar", () => {
|
|
146
|
+
it("sends a link that names no zoom to this week", () => {
|
|
147
|
+
const sent = sentTo(CalendarIndexRoute, { params: {}, search: {} });
|
|
148
|
+
assert.equal(sent.to, "/calendar/$view/$date");
|
|
149
|
+
assert.deepEqual(sent.params, { view: "week", date: TODAY });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("takes whatever the link ticked off with it", () => {
|
|
153
|
+
const sent = sentTo(CalendarIndexRoute, {
|
|
154
|
+
params: {},
|
|
155
|
+
search: { calendarId: ["cal_a"] },
|
|
156
|
+
});
|
|
157
|
+
assert.deepEqual(sent.search, { calendarId: ["cal_a"] });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("/calendar/{view}/{date}", () => {
|
|
162
|
+
it("serves every zoom the ladder has, on the day named", () => {
|
|
163
|
+
for (const view of ["year", "month", "week", "day", "agenda"]) {
|
|
164
|
+
serves(ViewRoute, held(view, "2026-06-10"));
|
|
165
|
+
const mounted = match(`/calendar/${view}/2026-06-10`);
|
|
166
|
+
assert.ok(mounted.routeIds.includes("/calendar/$view/$date"));
|
|
167
|
+
assert.equal(mounted.params.view, view);
|
|
168
|
+
assert.equal(mounted.params.date, "2026-06-10");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("mounts the reading pane under the view with nothing open", () => {
|
|
173
|
+
assert.deepEqual(match("/calendar/week/2026-06-10").routeIds.slice(-2), [
|
|
174
|
+
"/calendar/$view/$date",
|
|
175
|
+
"/calendar/$view/$date/",
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("rewrites a zoom the ladder does not have, keeping the day", () => {
|
|
180
|
+
const sent = sentTo(ViewRoute, held("fortnight", "2026-06-10"));
|
|
181
|
+
assert.deepEqual(sent.params, { view: "week", date: "2026-06-10" });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("rewrites a day the calendar does not have, keeping the zoom", () => {
|
|
185
|
+
assert.deepEqual(sentTo(ViewRoute, held("day", "2026-02-30")).params, {
|
|
186
|
+
view: "day",
|
|
187
|
+
date: TODAY,
|
|
188
|
+
});
|
|
189
|
+
assert.deepEqual(sentTo(ViewRoute, held("day", "tomorrow")).params, {
|
|
190
|
+
view: "day",
|
|
191
|
+
date: TODAY,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("the ticked calendars", () => {
|
|
197
|
+
it("are validated by the schema the domain reads them with", () => {
|
|
198
|
+
assert.equal(
|
|
199
|
+
(ViewRoute as unknown as { options: { validateSearch: unknown } }).options
|
|
200
|
+
.validateSearch,
|
|
201
|
+
calendarSearchSchema,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The address the toolbar's Next writes. Repeated params are the shape the
|
|
207
|
+
* issue pins and the shape A.3 and C.2 inherit, so what a step writes is
|
|
208
|
+
* asserted rather than assumed: the router's own serializer would have made
|
|
209
|
+
* this a JSON blob.
|
|
210
|
+
*/
|
|
211
|
+
it("are written as repeated params when the week steps", () => {
|
|
212
|
+
const router = routerAt("/calendar/week/2026-06-10?calendarId=cal_a");
|
|
213
|
+
const next = router.buildLocation({
|
|
214
|
+
to: "/calendar/$view/$date",
|
|
215
|
+
params: {
|
|
216
|
+
view: "week",
|
|
217
|
+
date: stepCalendarDate("2026-06-10", "week", 1),
|
|
218
|
+
},
|
|
219
|
+
search: { calendarId: ["cal_a", "cal_b"] },
|
|
220
|
+
});
|
|
221
|
+
assert.equal(next.pathname, "/calendar/week/2026-06-17");
|
|
222
|
+
assert.equal(next.searchStr, "?calendarId=cal_a&calendarId=cal_b");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("leave the query out of a step that has nothing ticked", () => {
|
|
226
|
+
const router = routerAt("/calendar/week/2026-06-10");
|
|
227
|
+
const next = router.buildLocation({
|
|
228
|
+
to: "/calendar/$view/$date",
|
|
229
|
+
params: { view: "week", date: "2026-06-17" },
|
|
230
|
+
search: {},
|
|
231
|
+
});
|
|
232
|
+
assert.equal(next.searchStr, "");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("the composer and the open event cannot both be addressed", () => {
|
|
237
|
+
it("mounts the composer, and nothing of the event route", () => {
|
|
238
|
+
const mounted = match("/calendar/week/2026-06-10/new");
|
|
239
|
+
assert.ok(mounted.routeIds.includes("/calendar/$view/$date/new"));
|
|
240
|
+
assert.equal(
|
|
241
|
+
mounted.routeIds.some((id) => id.includes("$calendarObjectId")),
|
|
242
|
+
false,
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("mounts the event, and nothing of the composer", () => {
|
|
247
|
+
const mounted = match("/calendar/week/2026-06-10/evt_1");
|
|
248
|
+
assert.deepEqual(mounted.routeIds.slice(-2), [
|
|
249
|
+
"/calendar/$view/$date/$calendarObjectId",
|
|
250
|
+
"/calendar/$view/$date/$calendarObjectId/",
|
|
251
|
+
]);
|
|
252
|
+
assert.equal(mounted.params.calendarObjectId, "evt_1");
|
|
253
|
+
assert.equal(mounted.routeIds.includes("/calendar/$view/$date/new"), false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("keeps the series matched under one of its occurrences", () => {
|
|
257
|
+
const mounted = match(
|
|
258
|
+
"/calendar/week/2026-06-10/evt_1/2026-06-11T09%3A15%3A00%2B02%3A00",
|
|
259
|
+
);
|
|
260
|
+
assert.deepEqual(mounted.routeIds.slice(-2), [
|
|
261
|
+
"/calendar/$view/$date/$calendarObjectId",
|
|
262
|
+
"/calendar/$view/$date/$calendarObjectId/$recurrenceId",
|
|
263
|
+
]);
|
|
264
|
+
assert.equal(mounted.params.calendarObjectId, "evt_1");
|
|
265
|
+
assert.equal(mounted.params.recurrenceId, "2026-06-11T09:15:00+02:00");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/calendar` has no view of its own: the five zoom levels are five addresses,
|
|
3
|
+
* so a link that names none is sent to the week the reader is in. The week is
|
|
4
|
+
* the default because it is the one that answers "what is my day like" without
|
|
5
|
+
* either scrolling or squinting.
|
|
6
|
+
*
|
|
7
|
+
* Whatever the link ticked off travels with it. The query modifies the view
|
|
8
|
+
* rather than choosing it, so it survives being told which view to open.
|
|
9
|
+
*/
|
|
10
|
+
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
11
|
+
import {
|
|
12
|
+
calendarSearchSchema,
|
|
13
|
+
DEFAULT_CALENDAR_VIEW,
|
|
14
|
+
isoDate,
|
|
15
|
+
} from "@/lib/calendar-route";
|
|
16
|
+
|
|
17
|
+
export const Route = createFileRoute("/calendar/")({
|
|
18
|
+
validateSearch: calendarSearchSchema,
|
|
19
|
+
beforeLoad: ({ search }) => {
|
|
20
|
+
throw redirect({
|
|
21
|
+
to: "/calendar/$view/$date",
|
|
22
|
+
params: { view: DEFAULT_CALENDAR_VIEW, date: isoDate(new Date()) },
|
|
23
|
+
search,
|
|
24
|
+
replace: true,
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /calendar — a sibling of /mail, sharing its nav and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* The layout owns what the whole calendar needs and no more: the account list
|
|
5
|
+
* the sidebar is built from, and the nav slide-over the narrow tiers open. The
|
|
6
|
+
* view below it decides what the panes hold.
|
|
7
|
+
*
|
|
8
|
+
* The fragment keeps the meaning it has in mail. The calendar has no
|
|
9
|
+
* intelligence rail, so a reader arriving with `#intelligence` carries it
|
|
10
|
+
* through and takes it back to their mail — nothing calendar-specific joins the
|
|
11
|
+
* union, because none of the calendar's own chrome is a panel.
|
|
12
|
+
*/
|
|
13
|
+
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
14
|
+
import { useQuery } from "@tanstack/react-query";
|
|
15
|
+
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
|
|
16
|
+
import { useCallback, useEffect } from "react";
|
|
17
|
+
import { CalendarShellProvider } from "@/components/calendar/CalendarShell";
|
|
18
|
+
import { MailNav } from "@/components/mail/MailNav";
|
|
19
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
20
|
+
import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
21
|
+
import { panelsWithOverlay, useOpenPanels, useSetOpenPanels } from "@/routing";
|
|
22
|
+
import "@/lib/client";
|
|
23
|
+
|
|
24
|
+
export const Route = createFileRoute("/calendar")({
|
|
25
|
+
loader: ({ context: { queryClient } }) => {
|
|
26
|
+
void queryClient.prefetchQuery(configOperationsGetConfigOptions());
|
|
27
|
+
},
|
|
28
|
+
component: CalendarLayout,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function CalendarLayout() {
|
|
32
|
+
const navigate = useNavigate();
|
|
33
|
+
const isSinglePane = isSinglePaneTier(useLayoutTier());
|
|
34
|
+
// Only the nav slide-over is the calendar's to open, and it is written as
|
|
35
|
+
// the whole set so a pane the reader brought with them from mail survives
|
|
36
|
+
// the visit rather than being dropped by the first tap on the hamburger.
|
|
37
|
+
const openPanels = useOpenPanels();
|
|
38
|
+
const setOpenPanels = useSetOpenPanels();
|
|
39
|
+
const navOpen = openPanels.includes("nav");
|
|
40
|
+
const showNav = useCallback(
|
|
41
|
+
(open: boolean) => {
|
|
42
|
+
setOpenPanels(panelsWithOverlay(openPanels, open ? "nav" : undefined));
|
|
43
|
+
},
|
|
44
|
+
[openPanels, setOpenPanels],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const {
|
|
48
|
+
data: config,
|
|
49
|
+
isLoading,
|
|
50
|
+
isError,
|
|
51
|
+
error,
|
|
52
|
+
refetch,
|
|
53
|
+
} = useQuery({
|
|
54
|
+
...configOperationsGetConfigOptions(),
|
|
55
|
+
staleTime: Infinity,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The same first-run guard `/mail` carries: a reader with no account has no
|
|
59
|
+
// calendar either, and the onboarding wizard is where they belong.
|
|
60
|
+
const hasNoAccounts = Boolean(config && (config.accounts?.length ?? 0) === 0);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!hasNoAccounts) return;
|
|
63
|
+
navigate({ to: "/onboarding", replace: true });
|
|
64
|
+
}, [hasNoAccounts, navigate]);
|
|
65
|
+
|
|
66
|
+
const accounts = config?.accounts ?? [];
|
|
67
|
+
|
|
68
|
+
if (isError) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex h-full items-center justify-center bg-canvas p-4">
|
|
71
|
+
<ErrorState
|
|
72
|
+
title="Couldn't load your account"
|
|
73
|
+
error={error}
|
|
74
|
+
onRetry={() => {
|
|
75
|
+
refetch();
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<CalendarShellProvider
|
|
84
|
+
chrome={{
|
|
85
|
+
isSinglePane,
|
|
86
|
+
isLoading: isLoading || hasNoAccounts,
|
|
87
|
+
nav: (
|
|
88
|
+
<MailNav accounts={accounts} onMailboxSelect={() => showNav(false)} />
|
|
89
|
+
),
|
|
90
|
+
navOpen,
|
|
91
|
+
onOpenNav: () => showNav(true),
|
|
92
|
+
onCloseNav: () => showNav(false),
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<Outlet />
|
|
96
|
+
</CalendarShellProvider>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fragment across the mail↔calendar switch.
|
|
3
|
+
*
|
|
4
|
+
* The calendar is a sibling of mail in the shell, not a different app, so the
|
|
5
|
+
* chrome a reader has up travels with them: the rail is a pane they keep, the
|
|
6
|
+
* slide-over and the sheet are overlays that going somewhere dismisses. The
|
|
7
|
+
* calendar owns no panel of its own — nothing calendar-specific joins the union
|
|
8
|
+
* (R3) — so the only thing it does to the fragment is carry it.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import {
|
|
13
|
+
formatOpenPanels,
|
|
14
|
+
panelFragments,
|
|
15
|
+
panelsWithOverlay,
|
|
16
|
+
parseOpenPanels,
|
|
17
|
+
retainOpenPanelsAtTier,
|
|
18
|
+
} from "./fragment.js";
|
|
19
|
+
|
|
20
|
+
const retainOnDesktop = retainOpenPanelsAtTier(true);
|
|
21
|
+
const retainOnPhone = retainOpenPanelsAtTier(false);
|
|
22
|
+
|
|
23
|
+
describe("the calendar names no panel of its own", () => {
|
|
24
|
+
it("leaves the union exactly as mail wrote it", () => {
|
|
25
|
+
assert.deepEqual(panelFragments, ["intelligence", "nav", "shortcuts"]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("crossing between mail and the calendar", () => {
|
|
30
|
+
// The nav entry is a `NavLink`, so both directions go through the same
|
|
31
|
+
// retention the mail lists already navigate under.
|
|
32
|
+
it("carries the rail into the calendar and back out again", () => {
|
|
33
|
+
assert.equal(retainOnDesktop("intelligence"), "intelligence");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("leaves the slide-over the reader picked the entry from behind", () => {
|
|
37
|
+
assert.equal(retainOnDesktop("nav"), "");
|
|
38
|
+
assert.equal(retainOnDesktop("intelligence,nav"), "intelligence");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("leaves the drawer behind on a tier that has no rail", () => {
|
|
42
|
+
assert.equal(retainOnPhone("intelligence"), "");
|
|
43
|
+
assert.equal(retainOnPhone("intelligence,nav"), "");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("reaching for the folders while on the calendar", () => {
|
|
48
|
+
const opened = (hash: string): string =>
|
|
49
|
+
formatOpenPanels(panelsWithOverlay(parseOpenPanels(hash), "nav"));
|
|
50
|
+
const closed = (hash: string): string =>
|
|
51
|
+
formatOpenPanels(panelsWithOverlay(parseOpenPanels(hash), undefined));
|
|
52
|
+
|
|
53
|
+
it("keeps the rail the reader arrived with", () => {
|
|
54
|
+
assert.equal(opened("intelligence"), "intelligence,nav");
|
|
55
|
+
assert.equal(closed("intelligence,nav"), "intelligence");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("puts the slide-over up over nothing else, and takes it down again", () => {
|
|
59
|
+
assert.equal(opened(""), "nav");
|
|
60
|
+
assert.equal(closed("nav"), "");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("replaces whichever overlay was up, because two cannot be", () => {
|
|
64
|
+
assert.equal(opened("intelligence,shortcuts"), "intelligence,nav");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { CalendarViewId } from "@remit/ui";
|
|
2
|
+
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
|
3
|
+
import { useCallback, useMemo } from "react";
|
|
4
|
+
import {
|
|
5
|
+
canonicalCalendarParams,
|
|
6
|
+
isoDate,
|
|
7
|
+
stepCalendarDate,
|
|
8
|
+
} from "@/lib/calendar-route";
|
|
9
|
+
import { useRetainOpenPanels } from "./fragment";
|
|
10
|
+
|
|
11
|
+
const VIEW_ROUTE = "/calendar/$view/$date" as const;
|
|
12
|
+
|
|
13
|
+
/** Which zoom the calendar is at, on which day, over which calendars. */
|
|
14
|
+
export interface CalendarAddress {
|
|
15
|
+
view: CalendarViewId;
|
|
16
|
+
/** `YYYY-MM-DD`. */
|
|
17
|
+
date: string;
|
|
18
|
+
/** The calendars ticked in the query; empty means every one of them. */
|
|
19
|
+
calendarIds: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The calendar the address names.
|
|
24
|
+
*
|
|
25
|
+
* The route canonicalises its own segments before anything mounts, so what
|
|
26
|
+
* arrives here is always a view the ladder has and a day the calendar has —
|
|
27
|
+
* the fallbacks below only cover the frame before the router has resolved
|
|
28
|
+
* the params.
|
|
29
|
+
*/
|
|
30
|
+
export function useCalendarAddress(): CalendarAddress {
|
|
31
|
+
const params = useParams({ from: VIEW_ROUTE, shouldThrow: false });
|
|
32
|
+
const search = useSearch({ from: VIEW_ROUTE, shouldThrow: false });
|
|
33
|
+
const view = params?.view ?? "";
|
|
34
|
+
const date = params?.date ?? "";
|
|
35
|
+
const calendarIds = search?.calendarId;
|
|
36
|
+
return useMemo(() => {
|
|
37
|
+
const canonical = canonicalCalendarParams(
|
|
38
|
+
{ view, date },
|
|
39
|
+
isoDate(new Date()),
|
|
40
|
+
);
|
|
41
|
+
return { ...canonical, calendarIds: calendarIds ?? [] };
|
|
42
|
+
}, [view, date, calendarIds]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The event the reading pane has open, and the instance of it, if any. */
|
|
46
|
+
export interface OpenCalendarEvent {
|
|
47
|
+
calendarObjectId: string;
|
|
48
|
+
/** One instance of a series; absent on the series itself. */
|
|
49
|
+
recurrenceId: string | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useOpenCalendarEvent(): OpenCalendarEvent | undefined {
|
|
53
|
+
const event = useParams({
|
|
54
|
+
from: "/calendar/$view/$date/$calendarObjectId",
|
|
55
|
+
shouldThrow: false,
|
|
56
|
+
});
|
|
57
|
+
const instance = useParams({
|
|
58
|
+
from: "/calendar/$view/$date/$calendarObjectId/$recurrenceId",
|
|
59
|
+
shouldThrow: false,
|
|
60
|
+
});
|
|
61
|
+
const calendarObjectId = event?.calendarObjectId;
|
|
62
|
+
const recurrenceId = instance?.recurrenceId;
|
|
63
|
+
return useMemo(
|
|
64
|
+
() => (calendarObjectId ? { calendarObjectId, recurrenceId } : undefined),
|
|
65
|
+
[calendarObjectId, recurrenceId],
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether the composer is up, which is a route rather than a flag. */
|
|
70
|
+
export function useIsWritingEvent(): boolean {
|
|
71
|
+
const composer = useParams({
|
|
72
|
+
from: "/calendar/$view/$date/new",
|
|
73
|
+
shouldThrow: false,
|
|
74
|
+
});
|
|
75
|
+
return composer !== undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CalendarNavigation {
|
|
79
|
+
/** Change zoom, keeping the day on screen. */
|
|
80
|
+
goToView: (view: CalendarViewId) => void;
|
|
81
|
+
/** The previous or next screenful, measured in the view's own unit. */
|
|
82
|
+
step: (direction: -1 | 1) => void;
|
|
83
|
+
/** Home, from every view and every distance away. */
|
|
84
|
+
goToToday: () => void;
|
|
85
|
+
openEvent: (calendarObjectId: string) => void;
|
|
86
|
+
closeEvent: () => void;
|
|
87
|
+
openComposer: () => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every move the calendar makes, as an address.
|
|
92
|
+
*
|
|
93
|
+
* Prev, next, today and the zoom switch are navigations rather than state
|
|
94
|
+
* changes, so the address always names what is on screen and a link to it
|
|
95
|
+
* reopens exactly that. Each one carries the open panes across the way the nav
|
|
96
|
+
* does, so the reader keeps their chrome moving through the week.
|
|
97
|
+
*/
|
|
98
|
+
export function useCalendarNavigation(): CalendarNavigation {
|
|
99
|
+
const navigate = useNavigate();
|
|
100
|
+
const retainPanels = useRetainOpenPanels();
|
|
101
|
+
const { view, date } = useCalendarAddress();
|
|
102
|
+
|
|
103
|
+
const goTo = useCallback(
|
|
104
|
+
(params: { view: CalendarViewId; date: string }) => {
|
|
105
|
+
navigate({
|
|
106
|
+
to: VIEW_ROUTE,
|
|
107
|
+
params,
|
|
108
|
+
search: true,
|
|
109
|
+
hash: retainPanels,
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
[navigate, retainPanels],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return useMemo(
|
|
116
|
+
() => ({
|
|
117
|
+
goToView: (next: CalendarViewId) => goTo({ view: next, date }),
|
|
118
|
+
step: (direction: -1 | 1) =>
|
|
119
|
+
goTo({ view, date: stepCalendarDate(date, view, direction) }),
|
|
120
|
+
goToToday: () => goTo({ view, date: isoDate(new Date()) }),
|
|
121
|
+
openEvent: (calendarObjectId: string) =>
|
|
122
|
+
navigate({
|
|
123
|
+
to: "/calendar/$view/$date/$calendarObjectId",
|
|
124
|
+
params: { view, date, calendarObjectId },
|
|
125
|
+
search: true,
|
|
126
|
+
hash: retainPanels,
|
|
127
|
+
}),
|
|
128
|
+
closeEvent: () => goTo({ view, date }),
|
|
129
|
+
openComposer: () =>
|
|
130
|
+
navigate({
|
|
131
|
+
to: "/calendar/$view/$date/new",
|
|
132
|
+
params: { view, date },
|
|
133
|
+
search: true,
|
|
134
|
+
hash: retainPanels,
|
|
135
|
+
}),
|
|
136
|
+
}),
|
|
137
|
+
[goTo, navigate, retainPanels, view, date],
|
|
138
|
+
);
|
|
139
|
+
}
|