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