@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,39 @@
|
|
|
1
|
+
import { CalendarEventPane } from "@/components/calendar/CalendarEventPane";
|
|
2
|
+
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
3
|
+
import { useCalendarAddress, useCalendarNavigation } from "@/routing";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The event the address names, resolved.
|
|
7
|
+
*
|
|
8
|
+
* The two event routes — the series and one of its occurrences — differ only in
|
|
9
|
+
* what the address says, so the lookup and the way back live here once rather
|
|
10
|
+
* than in each of them.
|
|
11
|
+
*/
|
|
12
|
+
export interface OpenCalendarEventProps {
|
|
13
|
+
calendarObjectId: string;
|
|
14
|
+
/** One occurrence of a series, absent on the series itself. */
|
|
15
|
+
recurrenceId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function OpenCalendarEvent({
|
|
19
|
+
calendarObjectId,
|
|
20
|
+
recurrenceId,
|
|
21
|
+
}: OpenCalendarEventProps) {
|
|
22
|
+
const { view, date, calendarIds } = useCalendarAddress();
|
|
23
|
+
const { events, calendars } = useCalendarData({ view, date, calendarIds });
|
|
24
|
+
const { closeEvent } = useCalendarNavigation();
|
|
25
|
+
|
|
26
|
+
const event = events.find((candidate) => candidate.id === calendarObjectId);
|
|
27
|
+
const calendar = calendars.find(
|
|
28
|
+
(candidate) => candidate.id === event?.calendarId,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<CalendarEventPane
|
|
33
|
+
event={event}
|
|
34
|
+
calendar={calendar}
|
|
35
|
+
isOccurrence={recurrenceId !== undefined}
|
|
36
|
+
onClose={closeEvent}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each zoom puts on screen.
|
|
3
|
+
*
|
|
4
|
+
* The route is one route for all five views, so "the view mounts its own
|
|
5
|
+
* component" is a decision this pane makes and the place to assert it. A zoom
|
|
6
|
+
* that is not drawn yet says so out loud: an empty surface would be
|
|
7
|
+
* indistinguishable from a week with nothing in it, which is the dead route
|
|
8
|
+
* this stage exists to avoid.
|
|
9
|
+
*/
|
|
10
|
+
import "@remit/test-dom";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import type { CalendarColorId, CalendarEventData } from "@remit/ui";
|
|
14
|
+
import { AppShellSlotted } from "@remit/ui";
|
|
15
|
+
import { createElement, type ReactElement } from "react";
|
|
16
|
+
import { renderToString } from "react-dom/server";
|
|
17
|
+
import {
|
|
18
|
+
CalendarWorkspace,
|
|
19
|
+
type CalendarWorkspaceProps,
|
|
20
|
+
} from "./CalendarWorkspace.js";
|
|
21
|
+
|
|
22
|
+
const TIME_ZONE = "Europe/Amsterdam";
|
|
23
|
+
const DATE = "2026-06-10";
|
|
24
|
+
const NOW = `${DATE}T09:30:00+02:00`;
|
|
25
|
+
const CALENDAR = "cal_work";
|
|
26
|
+
|
|
27
|
+
const colorByCalendarId: Record<string, CalendarColorId> = {
|
|
28
|
+
[CALENDAR]: "cal-1",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const roadmap: CalendarEventData = {
|
|
32
|
+
id: "roadmap",
|
|
33
|
+
calendarId: CALENDAR,
|
|
34
|
+
title: "Roadmap review",
|
|
35
|
+
start: `${DATE}T10:00:00+02:00`,
|
|
36
|
+
end: `${DATE}T11:00:00+02:00`,
|
|
37
|
+
allDay: false,
|
|
38
|
+
location: "",
|
|
39
|
+
notes: "",
|
|
40
|
+
attendees: [],
|
|
41
|
+
myRsvp: "accepted",
|
|
42
|
+
threadId: "",
|
|
43
|
+
threadSubject: "",
|
|
44
|
+
timeZone: TIME_ZONE,
|
|
45
|
+
zoneCertainty: "explicit",
|
|
46
|
+
recurrenceRule: "",
|
|
47
|
+
seriesId: "",
|
|
48
|
+
seriesException: false,
|
|
49
|
+
status: "confirmed",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const noop = () => {};
|
|
53
|
+
|
|
54
|
+
const workspaceProps = (
|
|
55
|
+
view: "year" | "month" | "week" | "day" | "agenda",
|
|
56
|
+
events: CalendarEventData[] = [roadmap],
|
|
57
|
+
): CalendarWorkspaceProps => ({
|
|
58
|
+
view,
|
|
59
|
+
date: DATE,
|
|
60
|
+
events,
|
|
61
|
+
colorByCalendarId,
|
|
62
|
+
density: "comfortable",
|
|
63
|
+
selectedEventId: "",
|
|
64
|
+
timeZone: TIME_ZONE,
|
|
65
|
+
now: NOW,
|
|
66
|
+
onChangeView: noop,
|
|
67
|
+
onToday: noop,
|
|
68
|
+
onStep: noop,
|
|
69
|
+
onChangeDensity: noop,
|
|
70
|
+
onSelectEvent: noop,
|
|
71
|
+
onPickSlot: noop,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The calendar engine schedules browser timers while it renders. Nothing mounts
|
|
76
|
+
* here, so nothing ever clears them and the suite would sit on a live event
|
|
77
|
+
* loop after its last assertion. Take them back.
|
|
78
|
+
*/
|
|
79
|
+
function renderQuietly(element: ReactElement): string {
|
|
80
|
+
const scheduled: ReturnType<typeof setTimeout>[] = [];
|
|
81
|
+
const native = globalThis.setTimeout;
|
|
82
|
+
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
|
|
83
|
+
const handle = native(...args);
|
|
84
|
+
scheduled.push(handle);
|
|
85
|
+
return handle;
|
|
86
|
+
}) as typeof globalThis.setTimeout;
|
|
87
|
+
try {
|
|
88
|
+
return renderToString(element);
|
|
89
|
+
} finally {
|
|
90
|
+
globalThis.setTimeout = native;
|
|
91
|
+
for (const handle of scheduled) clearTimeout(handle);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const render = (
|
|
96
|
+
view: "year" | "month" | "week" | "day" | "agenda",
|
|
97
|
+
events: CalendarEventData[] = [roadmap],
|
|
98
|
+
): string =>
|
|
99
|
+
renderQuietly(createElement(CalendarWorkspace, workspaceProps(view, events)));
|
|
100
|
+
|
|
101
|
+
describe("the zoom the address names", () => {
|
|
102
|
+
it("draws the grid for the week and the day", () => {
|
|
103
|
+
for (const view of ["week", "day"] as const) {
|
|
104
|
+
const html = render(view);
|
|
105
|
+
assert.ok(
|
|
106
|
+
html.includes("Roadmap review"),
|
|
107
|
+
`${view} did not draw the events it was given`,
|
|
108
|
+
);
|
|
109
|
+
assert.equal(html.includes("Not built yet"), false);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("says so for a zoom that is not drawn yet", () => {
|
|
114
|
+
for (const view of ["year", "month", "agenda"] as const) {
|
|
115
|
+
const html = render(view);
|
|
116
|
+
assert.ok(
|
|
117
|
+
html.includes(`calendar-placeholder-${view}`),
|
|
118
|
+
`${view} rendered no placeholder`,
|
|
119
|
+
);
|
|
120
|
+
assert.ok(html.includes("Not built yet"));
|
|
121
|
+
assert.ok(html.includes("Week and Day work now."));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("names the agenda's own stage rather than a generic one", () => {
|
|
126
|
+
assert.ok(render("agenda").includes("scrolling day strip"));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("the toolbar", () => {
|
|
131
|
+
it("offers every zoom, whichever one is on", () => {
|
|
132
|
+
const html = render("year");
|
|
133
|
+
for (const label of ["Year", "Month", "Week", "Day", "Agenda"]) {
|
|
134
|
+
assert.ok(html.includes(`>${label}<`), `no way to reach ${label}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("offers back, forward and today at a zoom that draws no grid", () => {
|
|
139
|
+
const html = render("month");
|
|
140
|
+
assert.ok(html.includes('aria-label="Previous"'));
|
|
141
|
+
assert.ok(html.includes('aria-label="Next"'));
|
|
142
|
+
assert.ok(html.includes(">Today<"));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("names the range before the grid has measured one", () => {
|
|
146
|
+
// The words are the device's locale's; the year is the part that is this
|
|
147
|
+
// component's answer rather than `Intl`'s.
|
|
148
|
+
assert.ok(render("month").includes("2026"));
|
|
149
|
+
assert.ok(render("year").includes("2026"));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("carries the density control, which the address never names", () => {
|
|
153
|
+
const html = render("week");
|
|
154
|
+
assert.ok(html.includes("Calendar density"));
|
|
155
|
+
assert.ok(html.includes(">Detail<"));
|
|
156
|
+
assert.ok(html.includes(">Glance<"));
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("a week with nothing in it", () => {
|
|
161
|
+
it("draws the grid rather than an empty surface", () => {
|
|
162
|
+
const html = render("week", []);
|
|
163
|
+
assert.equal(html.includes("Not built yet"), false);
|
|
164
|
+
assert.ok(html.includes('aria-label="Previous"'));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The way back to the mail, at a width where the nav is not a pane.
|
|
170
|
+
*
|
|
171
|
+
* The shell renders the sidebar as a slide-over below 1024px and nothing opens
|
|
172
|
+
* a slide-over on its own, so a surface that is the whole width of the app has
|
|
173
|
+
* to carry the control itself — mail's own list headers do, and the calendar is
|
|
174
|
+
* reached from the same sidebar it would otherwise strand a reader away from.
|
|
175
|
+
*/
|
|
176
|
+
describe("the folders control", () => {
|
|
177
|
+
const shell = (width: number): string =>
|
|
178
|
+
renderQuietly(
|
|
179
|
+
createElement(AppShellSlotted, {
|
|
180
|
+
initialWidth: width,
|
|
181
|
+
nav: createElement("nav", null, "Folders"),
|
|
182
|
+
list: createElement(CalendarWorkspace, workspaceProps("week")),
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
it("is in the calendar's header where the nav is a slide-over", () => {
|
|
187
|
+
assert.match(shell(900), /aria-label="Open folders"/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("is absent where the nav is a pane of its own", () => {
|
|
191
|
+
assert.doesNotMatch(shell(1440), /aria-label="Open folders"/);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -219,6 +219,22 @@ export function MailSidebarAdapter({
|
|
|
219
219
|
</NavLink>
|
|
220
220
|
);
|
|
221
221
|
}
|
|
222
|
+
if (navId === "calendar") {
|
|
223
|
+
// No list segment to name: the calendar's index route sends the reader
|
|
224
|
+
// to the week they are in, so a bookmark of the nav entry keeps working
|
|
225
|
+
// tomorrow.
|
|
226
|
+
return (
|
|
227
|
+
<NavLink
|
|
228
|
+
to="/calendar"
|
|
229
|
+
onClick={() => onClick?.()}
|
|
230
|
+
className={className}
|
|
231
|
+
aria-label={ariaLabel}
|
|
232
|
+
title={title}
|
|
233
|
+
>
|
|
234
|
+
{children}
|
|
235
|
+
</NavLink>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
222
238
|
if (navId === "outbox") {
|
|
223
239
|
return (
|
|
224
240
|
<NavLink
|
|
@@ -332,6 +348,7 @@ export function MailSidebarAdapter({
|
|
|
332
348
|
selectedNavId={selectedNavId}
|
|
333
349
|
onSelectNav={handleSelectNav}
|
|
334
350
|
linkComponent={linkComponent}
|
|
351
|
+
calendarNav="shown"
|
|
335
352
|
variant={variant}
|
|
336
353
|
savedSearches={savedSearches}
|
|
337
354
|
saveableQuery={saveableQuery}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the calendar surfaces read, and the only place they read it from.
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
CalendarColorId,
|
|
12
|
+
CalendarDescriptor,
|
|
13
|
+
CalendarEventData,
|
|
14
|
+
CalendarViewId,
|
|
15
|
+
} from "@remit/ui";
|
|
16
|
+
import { useMemo } from "react";
|
|
17
|
+
import {
|
|
18
|
+
fixtureCalendars,
|
|
19
|
+
fixtureColorByCalendarId,
|
|
20
|
+
fixtureEventsAround,
|
|
21
|
+
} from "@/lib/calendar-fixtures";
|
|
22
|
+
|
|
23
|
+
export interface CalendarDataRequest {
|
|
24
|
+
view: CalendarViewId;
|
|
25
|
+
/** The day the view is centred on, `YYYY-MM-DD`. */
|
|
26
|
+
date: string;
|
|
27
|
+
/** The calendars the address has ticked; empty means every one of them. */
|
|
28
|
+
calendarIds: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CalendarData {
|
|
32
|
+
calendars: CalendarDescriptor[];
|
|
33
|
+
events: CalendarEventData[];
|
|
34
|
+
colorByCalendarId: Record<string, CalendarColorId>;
|
|
35
|
+
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;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useCalendarData({
|
|
56
|
+
date,
|
|
57
|
+
calendarIds,
|
|
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]);
|
|
72
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much of a day this device shows at once.
|
|
3
|
+
*
|
|
4
|
+
* A phone on a train and a monitor on a desk want different amounts of the
|
|
5
|
+
* same week, and the answer belongs to the screen rather than to the calendar:
|
|
6
|
+
* it is not a fact about the view, so it is in no tier of the address, and a
|
|
7
|
+
* link shared from one device must not resize the day on another. Stored the
|
|
8
|
+
* way the intelligence rail's preference is (`lib/intelligence-pref.ts`), and
|
|
9
|
+
* a storage failure falls back to the default rather than crashing.
|
|
10
|
+
*/
|
|
11
|
+
import type { Density } from "@remit/ui";
|
|
12
|
+
|
|
13
|
+
export const CALENDAR_DENSITY_KEY = "remit:calendar-density";
|
|
14
|
+
|
|
15
|
+
/** Roomy enough to read a title at a glance, which is the common case. */
|
|
16
|
+
export const DEFAULT_CALENDAR_DENSITY: Density = "comfortable";
|
|
17
|
+
|
|
18
|
+
export function readCalendarDensity(): Density {
|
|
19
|
+
try {
|
|
20
|
+
return localStorage.getItem(CALENDAR_DENSITY_KEY) === "compact"
|
|
21
|
+
? "compact"
|
|
22
|
+
: DEFAULT_CALENDAR_DENSITY;
|
|
23
|
+
} catch {
|
|
24
|
+
return DEFAULT_CALENDAR_DENSITY;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function writeCalendarDensity(density: Density): void {
|
|
29
|
+
try {
|
|
30
|
+
localStorage.setItem(CALENDAR_DENSITY_KEY, density);
|
|
31
|
+
} catch {
|
|
32
|
+
// Storage unavailable — the in-memory default stands.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words a calendar surface puts a time in. Every component in `@remit/ui`
|
|
3
|
+
* takes its text already formatted and owns no clock, so the formatting lives
|
|
4
|
+
* on this side of the seam.
|
|
5
|
+
*/
|
|
6
|
+
import type { CalendarEventData } from "@remit/ui";
|
|
7
|
+
|
|
8
|
+
const DAY = new Intl.DateTimeFormat(undefined, {
|
|
9
|
+
weekday: "short",
|
|
10
|
+
day: "numeric",
|
|
11
|
+
month: "long",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const CLOCK = new Intl.DateTimeFormat(undefined, {
|
|
15
|
+
hour: "2-digit",
|
|
16
|
+
minute: "2-digit",
|
|
17
|
+
hour12: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** "Wed 10 June, 10:00 – 11:00", or the day alone when it runs all of it. */
|
|
21
|
+
export function formatEventWhen(event: CalendarEventData): string {
|
|
22
|
+
const start = new Date(event.start);
|
|
23
|
+
const end = new Date(event.end);
|
|
24
|
+
const day = DAY.format(start);
|
|
25
|
+
if (event.allDay) return `${day}, all day`;
|
|
26
|
+
const sameDay = DAY.format(end) === day;
|
|
27
|
+
if (sameDay) return `${day}, ${CLOCK.format(start)} – ${CLOCK.format(end)}`;
|
|
28
|
+
return `${day}, ${CLOCK.format(start)} – ${DAY.format(end)}, ${CLOCK.format(end)}`;
|
|
29
|
+
}
|