@remit/web-client 0.0.230 → 0.0.232
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 +115 -0
- package/src/components/calendar/CalendarComposePane.tsx +94 -0
- package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +110 -0
- package/src/components/calendar/CalendarEventPane.tsx +90 -0
- package/src/components/calendar/CalendarShell.tsx +83 -0
- package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +219 -0
- package/src/components/calendar/CalendarWorkspace.tsx +192 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +221 -0
- package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
- package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
- package/src/components/mail/MailSidebarAdapter.tsx +17 -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 +59 -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/useCalendarEvent.ts +46 -0
- package/src/hooks/calendar/useCalendarEvents.ts +124 -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 +163 -0
- package/src/hooks/useCalendarData.ts +120 -0
- package/src/lib/calendar-density.ts +34 -0
- package/src/lib/calendar-format.ts +29 -0
- package/src/lib/calendar-route.test.ts +210 -0
- package/src/lib/calendar-route.ts +187 -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 +128 -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 +150 -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/test-support/http.ts +8 -0
|
@@ -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,163 @@
|
|
|
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. Agenda takes the week it loads, which is
|
|
146
|
+
* also the unit it steps by, so the grid and the strip share a cache entry
|
|
147
|
+
* wherever they are looking at the same days.
|
|
148
|
+
*/
|
|
149
|
+
export function calendarWindow(
|
|
150
|
+
view: CalendarViewId,
|
|
151
|
+
date: string,
|
|
152
|
+
): CalendarWindow {
|
|
153
|
+
const [from, to] = bounds(view, date);
|
|
154
|
+
return { from: startOfDay(from), to: startOfDay(to) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A window of whole days from one civil date, for a surface rolling its own. */
|
|
158
|
+
export function calendarWindowOfDays(
|
|
159
|
+
date: string,
|
|
160
|
+
days: number,
|
|
161
|
+
): CalendarWindow {
|
|
162
|
+
return { from: startOfDay(date), to: startOfDay(addDays(date, days)) };
|
|
163
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the calendar surfaces read, and the only place they read it from.
|
|
3
|
+
*
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
CalendarColorId,
|
|
10
|
+
CalendarDescriptor,
|
|
11
|
+
CalendarEventData,
|
|
12
|
+
CalendarViewId,
|
|
13
|
+
} from "@remit/ui";
|
|
14
|
+
import { useMemo } from "react";
|
|
15
|
+
import {
|
|
16
|
+
type CalendarInstanceRef,
|
|
17
|
+
calendarWindow,
|
|
18
|
+
isDrawnInstance,
|
|
19
|
+
readCalendarInstanceId,
|
|
20
|
+
toCalendarEventData,
|
|
21
|
+
UNZONED_CALENDAR,
|
|
22
|
+
useCalendarEventWindow,
|
|
23
|
+
useCalendars,
|
|
24
|
+
usePrefetchAdjacentWindows,
|
|
25
|
+
} from "@/hooks/calendar";
|
|
26
|
+
|
|
27
|
+
export interface CalendarDataRequest {
|
|
28
|
+
view: CalendarViewId;
|
|
29
|
+
/** The day the view is centred on, `YYYY-MM-DD`. */
|
|
30
|
+
date: string;
|
|
31
|
+
/** The calendars the address has ticked; empty means every one of them. */
|
|
32
|
+
calendarIds: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CalendarData {
|
|
36
|
+
calendars: CalendarDescriptor[];
|
|
37
|
+
events: CalendarEventData[];
|
|
38
|
+
colorByCalendarId: Record<string, CalendarColorId>;
|
|
39
|
+
/** The zone each collection's times are read on — never the device's. */
|
|
40
|
+
timeZoneByCalendarId: Record<string, string>;
|
|
41
|
+
isLoading: boolean;
|
|
42
|
+
/** A refusal the calendar renders itself. Null when the read succeeded. */
|
|
43
|
+
error: unknown;
|
|
44
|
+
retry: () => void;
|
|
45
|
+
/** The resource and occurrence behind an event the grid selected. */
|
|
46
|
+
instanceOf: (eventId: string) => CalendarInstanceRef;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The calendars a request is asking about. An empty tick list is every
|
|
51
|
+
* calendar rather than none: a reader who has ticked nothing off is looking at
|
|
52
|
+
* all of them, and a URL naming a calendar that no longer exists shows the
|
|
53
|
+
* rest instead of an empty week.
|
|
54
|
+
*/
|
|
55
|
+
export function selectCalendarIds(
|
|
56
|
+
calendars: readonly CalendarDescriptor[],
|
|
57
|
+
ticked: readonly string[],
|
|
58
|
+
): string[] {
|
|
59
|
+
const known = calendars.map((calendar) => calendar.id);
|
|
60
|
+
if (ticked.length === 0) return known;
|
|
61
|
+
const wanted = new Set(ticked);
|
|
62
|
+
const shown = known.filter((id) => wanted.has(id));
|
|
63
|
+
return shown.length === 0 ? known : shown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useCalendarData({
|
|
67
|
+
view,
|
|
68
|
+
date,
|
|
69
|
+
calendarIds,
|
|
70
|
+
}: CalendarDataRequest): CalendarData {
|
|
71
|
+
const { calendars, colorByCalendarId, timeZoneByCalendarId, isLoading } =
|
|
72
|
+
useCalendars();
|
|
73
|
+
|
|
74
|
+
const shown = selectCalendarIds(calendars, calendarIds);
|
|
75
|
+
// Asking for every calendar by name and asking for all of them are the same
|
|
76
|
+
// question, so they are the same cache entry: the address ticking each one
|
|
77
|
+
// off must not refetch the week the address ticking none already holds.
|
|
78
|
+
const narrowed = shown.length === calendars.length ? [] : shown;
|
|
79
|
+
// A tick list can only be resolved against calendars that have loaded, so a
|
|
80
|
+
// narrowed address waits for them rather than asking about ids it cannot
|
|
81
|
+
// know are real.
|
|
82
|
+
const resolved = calendarIds.length === 0 || calendars.length > 0;
|
|
83
|
+
|
|
84
|
+
const window = calendarWindow(view, date);
|
|
85
|
+
const events = useCalendarEventWindow({
|
|
86
|
+
...window,
|
|
87
|
+
calendarIds: narrowed,
|
|
88
|
+
enabled: resolved,
|
|
89
|
+
});
|
|
90
|
+
usePrefetchAdjacentWindows(view, date, narrowed, resolved);
|
|
91
|
+
|
|
92
|
+
const shownKey = shown.join(",");
|
|
93
|
+
const instances = events.instances;
|
|
94
|
+
|
|
95
|
+
const eventData = useMemo(() => {
|
|
96
|
+
const drawn = new Set(shownKey === "" ? [] : shownKey.split(","));
|
|
97
|
+
return instances
|
|
98
|
+
.filter(
|
|
99
|
+
(instance) =>
|
|
100
|
+
isDrawnInstance(instance) && drawn.has(instance.calendarId),
|
|
101
|
+
)
|
|
102
|
+
.map((instance) =>
|
|
103
|
+
toCalendarEventData(
|
|
104
|
+
instance,
|
|
105
|
+
timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
}, [instances, timeZoneByCalendarId, shownKey]);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
calendars,
|
|
112
|
+
events: eventData,
|
|
113
|
+
colorByCalendarId,
|
|
114
|
+
timeZoneByCalendarId,
|
|
115
|
+
isLoading: isLoading || events.isLoading,
|
|
116
|
+
error: events.error,
|
|
117
|
+
retry: events.refetch,
|
|
118
|
+
instanceOf: readCalendarInstanceId,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -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,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
|
+
}
|