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