@remit/web-client 0.0.232 → 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/CalendarViewPlaceholder.tsx +3 -4
- package/src/components/calendar/CalendarWorkspace.stories.tsx +12 -1
- package/src/components/calendar/CalendarWorkspace.tsx +56 -38
- 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/hooks/calendar/agenda-window.test.ts +235 -0
- package/src/hooks/calendar/agenda-window.ts +183 -0
- package/src/hooks/calendar/index.ts +29 -0
- package/src/hooks/calendar/selection.ts +107 -0
- package/src/hooks/calendar/useCalendarEvents.ts +74 -3
- package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -0
- package/src/hooks/calendar/window.ts +8 -3
- package/src/hooks/useCalendarData.ts +27 -53
- package/src/lib/calendar-density.ts +13 -1
- package/src/lib/calendar-route.test.ts +20 -1
- package/src/lib/calendar-route.ts +14 -4
- package/src/routes/calendar/$view.$date.tsx +9 -1
- package/src/routing/calendar.ts +17 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The days the strip currently holds, and the arithmetic that grows them.
|
|
3
|
+
*
|
|
4
|
+
* The agenda never paginates. It holds one contiguous run of days around the
|
|
5
|
+
* day the address names and grows it at whichever end the reader reaches, so
|
|
6
|
+
* there are no page boundaries to lose a place at and no request that throws
|
|
7
|
+
* away what is already on screen. The range is the only thing a window is: what
|
|
8
|
+
* to ask `/calendar-events` and `/calendar-free-busy` for falls out of it.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here reads a clock or a router. Every function takes the day it works
|
|
11
|
+
* from, which is what lets the rules be asserted without either.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
type BusySpan,
|
|
15
|
+
type CalendarDay,
|
|
16
|
+
datesBetween,
|
|
17
|
+
type FreeStretch,
|
|
18
|
+
freeStretchesFromSpans,
|
|
19
|
+
} from "@remit/ui";
|
|
20
|
+
import { isoDate } from "@/lib/calendar-route";
|
|
21
|
+
import { addDays, type CalendarWindow, calendarWindow } from "./window";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Days held behind and ahead of the day the address opened on. More ahead than
|
|
25
|
+
* behind: a reader lands on a calendar to look forward, and the days behind are
|
|
26
|
+
* there so scrolling back a little does not immediately fetch.
|
|
27
|
+
*/
|
|
28
|
+
export const LEAD_IN = 10;
|
|
29
|
+
export const LEAD_OUT = 24;
|
|
30
|
+
|
|
31
|
+
/** What reaching an end adds to it. */
|
|
32
|
+
export const PAGE = 14;
|
|
33
|
+
|
|
34
|
+
export interface AgendaRange {
|
|
35
|
+
/** `YYYY-MM-DD`, inclusive. */
|
|
36
|
+
from: string;
|
|
37
|
+
/** `YYYY-MM-DD`, inclusive. */
|
|
38
|
+
to: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const rangeAround = (date: string): AgendaRange => ({
|
|
42
|
+
from: addDays(date, -LEAD_IN),
|
|
43
|
+
to: addDays(date, LEAD_OUT),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const extendRangeStart = (range: AgendaRange): AgendaRange => ({
|
|
47
|
+
...range,
|
|
48
|
+
from: addDays(range.from, -PAGE),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const extendRangeEnd = (range: AgendaRange): AgendaRange => ({
|
|
52
|
+
...range,
|
|
53
|
+
to: addDays(range.to, PAGE),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The range that has a day in it. A day already held keeps the range it is in —
|
|
58
|
+
* identically, so a scroll that moved the address does not throw away the days
|
|
59
|
+
* either side of it and refetch them. A day outside it is a jump, and a jump
|
|
60
|
+
* opens a window around where it landed rather than stretching one across the
|
|
61
|
+
* months in between.
|
|
62
|
+
*/
|
|
63
|
+
export function rangeCovering(range: AgendaRange, date: string): AgendaRange {
|
|
64
|
+
if (date >= range.from && date <= range.to) return range;
|
|
65
|
+
return rangeAround(date);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The week each day of the range is served by, deduplicated and in order.
|
|
70
|
+
*
|
|
71
|
+
* The strip holds more days than one read may ask for — the server refuses a
|
|
72
|
+
* window over a year — so the range says which weeks to fetch rather than being
|
|
73
|
+
* a window itself. Reaching an end then adds a week to this list instead of
|
|
74
|
+
* widening a single request, which is what keeps the days already on screen
|
|
75
|
+
* where they are.
|
|
76
|
+
*
|
|
77
|
+
* The weeks are `calendarWindow("week", …)` exactly, so they are the grid's own
|
|
78
|
+
* cache entries: a reader who drops into the week grid and comes back out draws
|
|
79
|
+
* from what the strip fetched, and a week the grid has already read costs the
|
|
80
|
+
* strip nothing.
|
|
81
|
+
*/
|
|
82
|
+
export function weekWindowsOver(dates: readonly string[]): CalendarWindow[] {
|
|
83
|
+
const byStart = new Map<string, CalendarWindow>();
|
|
84
|
+
for (const date of dates) {
|
|
85
|
+
const week = calendarWindow("week", date);
|
|
86
|
+
if (!byStart.has(week.from)) byStart.set(week.from, week);
|
|
87
|
+
}
|
|
88
|
+
return [...byStart.values()];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The week window a day belongs to, named by its `from`. */
|
|
92
|
+
export const weekKeyOf = (date: string): string =>
|
|
93
|
+
calendarWindow("week", date).from;
|
|
94
|
+
|
|
95
|
+
const MINUTES_IN_DAY = 24 * 60;
|
|
96
|
+
|
|
97
|
+
const minuteOfInstant = (instant: Date): number =>
|
|
98
|
+
instant.getHours() * 60 + instant.getMinutes();
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Busy spans as minutes of the days they cross, on the clock this device is
|
|
102
|
+
* reading. The API answers in UTC instants because a busy stretch is an
|
|
103
|
+
* interval rather than a civil date, so the split into days happens here, where
|
|
104
|
+
* the zone the strip is drawn in is known.
|
|
105
|
+
*
|
|
106
|
+
* A span crossing midnight becomes one entry per day it touches. Each day's
|
|
107
|
+
* entries come back sorted and merged, which is the shape the free-time rule
|
|
108
|
+
* takes.
|
|
109
|
+
*/
|
|
110
|
+
export function busySpansByDate(
|
|
111
|
+
dates: readonly string[],
|
|
112
|
+
spans: readonly { start: string; end: string }[],
|
|
113
|
+
): Map<string, BusySpan[]> {
|
|
114
|
+
const held = new Set(dates);
|
|
115
|
+
const byDate = new Map<string, BusySpan[]>();
|
|
116
|
+
|
|
117
|
+
for (const span of spans) {
|
|
118
|
+
const start = new Date(span.start);
|
|
119
|
+
const end = new Date(span.end);
|
|
120
|
+
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue;
|
|
121
|
+
if (end.getTime() <= start.getTime()) continue;
|
|
122
|
+
|
|
123
|
+
const firstDate = isoDate(start);
|
|
124
|
+
// The instant before the end, so a span ending at midnight belongs to the
|
|
125
|
+
// day it ran through rather than opening an empty entry on the next one.
|
|
126
|
+
const lastDate = isoDate(new Date(end.getTime() - 1));
|
|
127
|
+
for (let date = firstDate; date <= lastDate; date = addDays(date, 1)) {
|
|
128
|
+
if (!held.has(date)) continue;
|
|
129
|
+
const from = date === firstDate ? minuteOfInstant(start) : 0;
|
|
130
|
+
// Read off the end's own day rather than off the loop: a span ending at
|
|
131
|
+
// midnight belongs to the day it ran through, where its end is the end
|
|
132
|
+
// of that day and not minute zero of the next one.
|
|
133
|
+
const to = isoDate(end) === date ? minuteOfInstant(end) : MINUTES_IN_DAY;
|
|
134
|
+
if (to <= from) continue;
|
|
135
|
+
const day = byDate.get(date);
|
|
136
|
+
if (day) day.push({ from, to });
|
|
137
|
+
else byDate.set(date, [{ from, to }]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const [date, day] of byDate) byDate.set(date, mergeSpans(day));
|
|
142
|
+
return byDate;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mergeSpans(spans: BusySpan[]): BusySpan[] {
|
|
146
|
+
const sorted = [...spans].sort((a, b) => a.from - b.from);
|
|
147
|
+
const merged: BusySpan[] = [];
|
|
148
|
+
for (const span of sorted) {
|
|
149
|
+
const last = merged[merged.length - 1];
|
|
150
|
+
if (last && span.from <= last.to) {
|
|
151
|
+
last.to = Math.max(last.to, span.to);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
merged.push({ ...span });
|
|
155
|
+
}
|
|
156
|
+
return merged;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The free time on every day the strip holds, out of the busy spans the server
|
|
161
|
+
* merged across every calendar. It is `freeStretchesFromSpans` and nothing
|
|
162
|
+
* else: the rule lives in `@remit/ui` beside the strip that draws it, so this
|
|
163
|
+
* only decides which spans a day gets.
|
|
164
|
+
*/
|
|
165
|
+
export function freeStretchesByDate(
|
|
166
|
+
dates: readonly string[],
|
|
167
|
+
spans: readonly { start: string; end: string }[],
|
|
168
|
+
): Map<string, FreeStretch[]> {
|
|
169
|
+
const busy = busySpansByDate(dates, spans);
|
|
170
|
+
return new Map(
|
|
171
|
+
dates.map((date) => [
|
|
172
|
+
date,
|
|
173
|
+
freeStretchesFromSpans(date, busy.get(date) ?? []),
|
|
174
|
+
]),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The days a range names, in order. */
|
|
179
|
+
export const datesInRange = (range: AgendaRange): string[] =>
|
|
180
|
+
datesBetween(range.from, range.to);
|
|
181
|
+
|
|
182
|
+
/** How the strip asks a day what is still open on it. */
|
|
183
|
+
export type FreeLookup = (day: CalendarDay) => FreeStretch[];
|
|
@@ -5,6 +5,22 @@
|
|
|
5
5
|
* they ask them through the same hooks: one window is fetched once however many
|
|
6
6
|
* of them are on screen, and a write invalidates what all of them are showing.
|
|
7
7
|
*/
|
|
8
|
+
export {
|
|
9
|
+
type AgendaRange,
|
|
10
|
+
busySpansByDate,
|
|
11
|
+
datesInRange,
|
|
12
|
+
extendRangeEnd,
|
|
13
|
+
extendRangeStart,
|
|
14
|
+
type FreeLookup,
|
|
15
|
+
freeStretchesByDate,
|
|
16
|
+
LEAD_IN,
|
|
17
|
+
LEAD_OUT,
|
|
18
|
+
PAGE,
|
|
19
|
+
rangeAround,
|
|
20
|
+
rangeCovering,
|
|
21
|
+
weekKeyOf,
|
|
22
|
+
weekWindowsOver,
|
|
23
|
+
} from "./agenda-window";
|
|
8
24
|
export {
|
|
9
25
|
type CreateInput,
|
|
10
26
|
createInputFromDraft,
|
|
@@ -30,16 +46,29 @@ export {
|
|
|
30
46
|
rruleFromIcalData,
|
|
31
47
|
textFromIcalData,
|
|
32
48
|
} from "./resource";
|
|
49
|
+
export {
|
|
50
|
+
type CalendarSelection,
|
|
51
|
+
selectCalendarIds,
|
|
52
|
+
useCalendarSelection,
|
|
53
|
+
useDrawnEvents,
|
|
54
|
+
} from "./selection";
|
|
33
55
|
export {
|
|
34
56
|
type CalendarEventResource,
|
|
35
57
|
useCalendarEvent,
|
|
36
58
|
} from "./useCalendarEvent";
|
|
37
59
|
export {
|
|
60
|
+
CALENDAR_WINDOW_STALE_TIME,
|
|
61
|
+
type CalendarEventWeeksResult,
|
|
38
62
|
type CalendarEventWindowRequest,
|
|
39
63
|
type CalendarEventWindowResult,
|
|
64
|
+
useCalendarEventWeeks,
|
|
40
65
|
useCalendarEventWindow,
|
|
41
66
|
usePrefetchAdjacentWindows,
|
|
42
67
|
} from "./useCalendarEvents";
|
|
68
|
+
export {
|
|
69
|
+
type CalendarFreeBusyResult,
|
|
70
|
+
useCalendarFreeBusyWeeks,
|
|
71
|
+
} from "./useCalendarFreeBusy";
|
|
43
72
|
export { type CalendarsResult, useCalendars } from "./useCalendars";
|
|
44
73
|
export {
|
|
45
74
|
type CalendarWriteOutcome,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which calendars a surface is drawing, and how their occurrences become rows.
|
|
3
|
+
*
|
|
4
|
+
* Both readings of the calendar ask this and neither may answer it its own way:
|
|
5
|
+
* the grid reads one window and the strip reads many, but "which calendars did
|
|
6
|
+
* the address tick, and what does an occurrence look like once drawn" has one
|
|
7
|
+
* answer, and a second copy of it drifts into the two surfaces disagreeing
|
|
8
|
+
* about the same day.
|
|
9
|
+
*/
|
|
10
|
+
import type { RemitImapCalendarEventInstance } from "@remit/api-http-client/types.gen.ts";
|
|
11
|
+
import type {
|
|
12
|
+
CalendarColorId,
|
|
13
|
+
CalendarDescriptor,
|
|
14
|
+
CalendarEventData,
|
|
15
|
+
} from "@remit/ui";
|
|
16
|
+
import { useMemo } from "react";
|
|
17
|
+
import {
|
|
18
|
+
isDrawnInstance,
|
|
19
|
+
toCalendarEventData,
|
|
20
|
+
UNZONED_CALENDAR,
|
|
21
|
+
} from "./instance";
|
|
22
|
+
import { useCalendars } from "./useCalendars";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The calendars a request is asking about. An empty tick list is every
|
|
26
|
+
* calendar rather than none: a reader who has ticked nothing off is looking at
|
|
27
|
+
* all of them, and a URL naming a calendar that no longer exists shows the
|
|
28
|
+
* rest instead of an empty week.
|
|
29
|
+
*/
|
|
30
|
+
export function selectCalendarIds(
|
|
31
|
+
calendars: readonly CalendarDescriptor[],
|
|
32
|
+
ticked: readonly string[],
|
|
33
|
+
): string[] {
|
|
34
|
+
const known = calendars.map((calendar) => calendar.id);
|
|
35
|
+
if (ticked.length === 0) return known;
|
|
36
|
+
const wanted = new Set(ticked);
|
|
37
|
+
const shown = known.filter((id) => wanted.has(id));
|
|
38
|
+
return shown.length === 0 ? known : shown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CalendarSelection {
|
|
42
|
+
calendars: CalendarDescriptor[];
|
|
43
|
+
colorByCalendarId: Record<string, CalendarColorId>;
|
|
44
|
+
timeZoneByCalendarId: Record<string, string>;
|
|
45
|
+
/** Every calendar this address draws, resolved against the real ones. */
|
|
46
|
+
shown: string[];
|
|
47
|
+
/** What to ask the server for: empty where that is every calendar. */
|
|
48
|
+
narrowed: string[];
|
|
49
|
+
/** False while the tick list still has no real calendars to resolve against. */
|
|
50
|
+
resolved: boolean;
|
|
51
|
+
isLoading: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useCalendarSelection(
|
|
55
|
+
calendarIds: readonly string[],
|
|
56
|
+
): CalendarSelection {
|
|
57
|
+
const { calendars, colorByCalendarId, timeZoneByCalendarId, isLoading } =
|
|
58
|
+
useCalendars();
|
|
59
|
+
|
|
60
|
+
const shown = selectCalendarIds(calendars, calendarIds);
|
|
61
|
+
// Asking for every calendar by name and asking for all of them are the same
|
|
62
|
+
// question, so they are the same cache entry: the address ticking each one
|
|
63
|
+
// off must not refetch the week the address ticking none already holds.
|
|
64
|
+
const narrowed = shown.length === calendars.length ? [] : shown;
|
|
65
|
+
// A tick list can only be resolved against calendars that have loaded, so a
|
|
66
|
+
// narrowed address waits for them rather than asking about ids it cannot
|
|
67
|
+
// know are real.
|
|
68
|
+
const resolved = calendarIds.length === 0 || calendars.length > 0;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
calendars,
|
|
72
|
+
colorByCalendarId,
|
|
73
|
+
timeZoneByCalendarId,
|
|
74
|
+
shown,
|
|
75
|
+
narrowed,
|
|
76
|
+
resolved,
|
|
77
|
+
isLoading,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Occurrences as rows: the ones on a calendar being drawn, on the clock their
|
|
83
|
+
* collection keeps. A collection with no zone of its own is sent as unzoned
|
|
84
|
+
* rather than as the reader's — answering with the device's clock would rewrite
|
|
85
|
+
* every time they saved by the difference between the two.
|
|
86
|
+
*/
|
|
87
|
+
export function useDrawnEvents(
|
|
88
|
+
instances: readonly RemitImapCalendarEventInstance[],
|
|
89
|
+
timeZoneByCalendarId: Record<string, string>,
|
|
90
|
+
shown: readonly string[],
|
|
91
|
+
): CalendarEventData[] {
|
|
92
|
+
const shownKey = shown.join(",");
|
|
93
|
+
return useMemo(() => {
|
|
94
|
+
const drawn = new Set(shownKey === "" ? [] : shownKey.split(","));
|
|
95
|
+
return instances
|
|
96
|
+
.filter(
|
|
97
|
+
(instance) =>
|
|
98
|
+
isDrawnInstance(instance) && drawn.has(instance.calendarId),
|
|
99
|
+
)
|
|
100
|
+
.map((instance) =>
|
|
101
|
+
toCalendarEventData(
|
|
102
|
+
instance,
|
|
103
|
+
timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
}, [instances, timeZoneByCalendarId, shownKey]);
|
|
107
|
+
}
|
|
@@ -17,8 +17,8 @@ import type {
|
|
|
17
17
|
RemitImapCalendarEventInstance,
|
|
18
18
|
} from "@remit/api-http-client/types.gen.ts";
|
|
19
19
|
import type { CalendarViewId } from "@remit/ui";
|
|
20
|
-
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
21
|
-
import { useEffect } from "react";
|
|
20
|
+
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
21
|
+
import { useEffect, useMemo } from "react";
|
|
22
22
|
import { stepCalendarDate } from "@/lib/calendar-route";
|
|
23
23
|
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
24
24
|
import { type CalendarWindow, calendarWindow } from "./window";
|
|
@@ -39,7 +39,7 @@ const CALENDAR_READ_META = softErrorStatuses(400, 404);
|
|
|
39
39
|
* timer — every write invalidates the windows it touched — so this only needs
|
|
40
40
|
* to outlast the reader moving back and forth across a few weeks.
|
|
41
41
|
*/
|
|
42
|
-
const CALENDAR_WINDOW_STALE_TIME = 5 * 60_000;
|
|
42
|
+
export const CALENDAR_WINDOW_STALE_TIME = 5 * 60_000;
|
|
43
43
|
|
|
44
44
|
export interface CalendarEventWindowRequest extends CalendarWindow {
|
|
45
45
|
/** Empty asks about every calendar the account holds. */
|
|
@@ -90,6 +90,77 @@ export function useCalendarEventWindow(
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
export interface CalendarEventWeeksResult {
|
|
94
|
+
instances: RemitImapCalendarEventInstance[];
|
|
95
|
+
/** Windows whose answer has not arrived, by their `from`. */
|
|
96
|
+
pendingWindows: Set<string>;
|
|
97
|
+
/** The first refusal any of them met. Null when they all succeeded. */
|
|
98
|
+
error: unknown;
|
|
99
|
+
refetch: () => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Several windows at once, each fetched and cached on its own.
|
|
104
|
+
*
|
|
105
|
+
* This is what lets a surface hold a range wider than a single read may ask
|
|
106
|
+
* for: the server refuses a window over a year, and one request that grows
|
|
107
|
+
* every time the reader reaches an end eventually becomes that window and
|
|
108
|
+
* replaces what is on screen with a refusal. A week per request has no such
|
|
109
|
+
* ceiling — reaching an end adds a key rather than widening one — and because
|
|
110
|
+
* the keys are the grid's own, dropping into the week grid and coming back out
|
|
111
|
+
* draws from what the strip already fetched, and vice versa.
|
|
112
|
+
*
|
|
113
|
+
* Windows already held keep their entries while a new one is in flight, so
|
|
114
|
+
* nothing on screen is given up to fetch what is beside it.
|
|
115
|
+
*/
|
|
116
|
+
export function useCalendarEventWeeks(
|
|
117
|
+
windows: readonly CalendarWindow[],
|
|
118
|
+
calendarIds: readonly string[],
|
|
119
|
+
enabled: boolean,
|
|
120
|
+
): CalendarEventWeeksResult {
|
|
121
|
+
const results = useQueries({
|
|
122
|
+
queries: windows.map((window) => ({
|
|
123
|
+
...calendarEventOperationsListCalendarEventsOptions(
|
|
124
|
+
requestOptions({ ...window, calendarIds }),
|
|
125
|
+
),
|
|
126
|
+
meta: CALENDAR_READ_META,
|
|
127
|
+
staleTime: CALENDAR_WINDOW_STALE_TIME,
|
|
128
|
+
enabled,
|
|
129
|
+
})),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const froms = windows.map((window) => window.from).join("|");
|
|
133
|
+
const settled = results.map((result) => result.data);
|
|
134
|
+
const pendingKey = results.map((result) => result.isPending).join("|");
|
|
135
|
+
const firstError = results.find((result) => result.error)?.error ?? null;
|
|
136
|
+
|
|
137
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the joined keys stand for the per-window results, which are a new array every render
|
|
138
|
+
const instances = useMemo(
|
|
139
|
+
() => settled.flatMap((data) => data?.items ?? []),
|
|
140
|
+
[froms, pendingKey],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: as above
|
|
144
|
+
const pendingWindows = useMemo(() => {
|
|
145
|
+
const pending = new Set<string>();
|
|
146
|
+
windows.forEach((window, index) => {
|
|
147
|
+
if (results[index]?.isPending) pending.add(window.from);
|
|
148
|
+
});
|
|
149
|
+
return pending;
|
|
150
|
+
}, [froms, pendingKey]);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
instances,
|
|
154
|
+
pendingWindows,
|
|
155
|
+
error: firstError,
|
|
156
|
+
refetch: () => {
|
|
157
|
+
for (const result of results) {
|
|
158
|
+
if (result.error) result.refetch();
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
93
164
|
/**
|
|
94
165
|
* The windows either side of the one on screen, warmed while the reader is
|
|
95
166
|
* looking at this one. A step is a navigation, and a navigation that has to
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Busy time over a window, merged across every calendar the account holds.
|
|
3
|
+
*
|
|
4
|
+
* Not the same question as "what is on the strip". A calendar the reader has
|
|
5
|
+
* unticked still takes their Thursday afternoon, so "am I free" is answered off
|
|
6
|
+
* this and never off the rows on screen. The server does the merging, so two
|
|
7
|
+
* overlapping meetings in two calendars are one span here, and an event marked
|
|
8
|
+
* transparent or cancelled is not one at all.
|
|
9
|
+
*
|
|
10
|
+
* The endpoint takes no calendar filter, on purpose: narrowing it would answer
|
|
11
|
+
* a question nobody asked. It carries the same refusals as the window read — a
|
|
12
|
+
* window the server will not accept is stated in place, and a lapsed session
|
|
13
|
+
* escalates rather than being drawn as a clear diary.
|
|
14
|
+
*/
|
|
15
|
+
import { calendarFreeBusyOperationsListCalendarFreeBusyOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
16
|
+
import type { RemitImapCalendarFreeBusySpan } from "@remit/api-http-client/types.gen.ts";
|
|
17
|
+
import { useQueries } from "@tanstack/react-query";
|
|
18
|
+
import { useMemo } from "react";
|
|
19
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
20
|
+
import { CALENDAR_WINDOW_STALE_TIME } from "./useCalendarEvents";
|
|
21
|
+
import type { CalendarWindow } from "./window";
|
|
22
|
+
|
|
23
|
+
const CALENDAR_READ_META = softErrorStatuses(400, 404);
|
|
24
|
+
|
|
25
|
+
export interface CalendarFreeBusyResult {
|
|
26
|
+
spans: RemitImapCalendarFreeBusySpan[];
|
|
27
|
+
/** Windows whose answer has not arrived, by their `from`. */
|
|
28
|
+
pendingWindows: Set<string>;
|
|
29
|
+
error: unknown;
|
|
30
|
+
refetch: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Busy time a window at a time, for the same reason the events are read that
|
|
35
|
+
* way: this endpoint refuses a window over a year too, so a range that grows
|
|
36
|
+
* as the reader scrolls cannot be one request.
|
|
37
|
+
*/
|
|
38
|
+
export function useCalendarFreeBusyWeeks(
|
|
39
|
+
windows: readonly CalendarWindow[],
|
|
40
|
+
): CalendarFreeBusyResult {
|
|
41
|
+
const results = useQueries({
|
|
42
|
+
queries: windows.map((window) => ({
|
|
43
|
+
...calendarFreeBusyOperationsListCalendarFreeBusyOptions({
|
|
44
|
+
query: { from: window.from, to: window.to },
|
|
45
|
+
}),
|
|
46
|
+
meta: CALENDAR_READ_META,
|
|
47
|
+
staleTime: CALENDAR_WINDOW_STALE_TIME,
|
|
48
|
+
})),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const froms = windows.map((window) => window.from).join("|");
|
|
52
|
+
const settled = results.map((result) => result.data);
|
|
53
|
+
const pendingKey = results.map((result) => result.isPending).join("|");
|
|
54
|
+
|
|
55
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the joined keys stand for the per-window results, which are a new array every render
|
|
56
|
+
const spans = useMemo(
|
|
57
|
+
() => settled.flatMap((data) => data?.items ?? []),
|
|
58
|
+
[froms, pendingKey],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: as above
|
|
62
|
+
const pendingWindows = useMemo(() => {
|
|
63
|
+
const pending = new Set<string>();
|
|
64
|
+
windows.forEach((window, index) => {
|
|
65
|
+
if (results[index]?.isPending) pending.add(window.from);
|
|
66
|
+
});
|
|
67
|
+
return pending;
|
|
68
|
+
}, [froms, pendingKey]);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
spans,
|
|
72
|
+
pendingWindows,
|
|
73
|
+
error: results.find((result) => result.error)?.error ?? null,
|
|
74
|
+
refetch: () => {
|
|
75
|
+
for (const result of results) {
|
|
76
|
+
if (result.error) result.refetch();
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -142,9 +142,14 @@ function bounds(view: CalendarViewId, date: string): [string, string] {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
|
-
* The window a view over a day covers.
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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.
|
|
148
153
|
*/
|
|
149
154
|
export function calendarWindow(
|
|
150
155
|
view: CalendarViewId,
|
|
@@ -11,16 +11,13 @@ import type {
|
|
|
11
11
|
CalendarEventData,
|
|
12
12
|
CalendarViewId,
|
|
13
13
|
} from "@remit/ui";
|
|
14
|
-
import { useMemo } from "react";
|
|
15
14
|
import {
|
|
16
15
|
type CalendarInstanceRef,
|
|
17
16
|
calendarWindow,
|
|
18
|
-
isDrawnInstance,
|
|
19
17
|
readCalendarInstanceId,
|
|
20
|
-
toCalendarEventData,
|
|
21
|
-
UNZONED_CALENDAR,
|
|
22
18
|
useCalendarEventWindow,
|
|
23
|
-
|
|
19
|
+
useCalendarSelection,
|
|
20
|
+
useDrawnEvents,
|
|
24
21
|
usePrefetchAdjacentWindows,
|
|
25
22
|
} from "@/hooks/calendar";
|
|
26
23
|
|
|
@@ -30,6 +27,13 @@ export interface CalendarDataRequest {
|
|
|
30
27
|
date: string;
|
|
31
28
|
/** The calendars the address has ticked; empty means every one of them. */
|
|
32
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;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export interface CalendarData {
|
|
@@ -46,66 +50,36 @@ export interface CalendarData {
|
|
|
46
50
|
instanceOf: (eventId: string) => CalendarInstanceRef;
|
|
47
51
|
}
|
|
48
52
|
|
|
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
53
|
export function useCalendarData({
|
|
67
54
|
view,
|
|
68
55
|
date,
|
|
69
56
|
calendarIds,
|
|
57
|
+
enabled = true,
|
|
70
58
|
}: CalendarDataRequest): CalendarData {
|
|
71
|
-
const {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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;
|
|
59
|
+
const {
|
|
60
|
+
calendars,
|
|
61
|
+
colorByCalendarId,
|
|
62
|
+
timeZoneByCalendarId,
|
|
63
|
+
shown,
|
|
64
|
+
narrowed,
|
|
65
|
+
resolved,
|
|
66
|
+
isLoading,
|
|
67
|
+
} = useCalendarSelection(calendarIds);
|
|
83
68
|
|
|
84
69
|
const window = calendarWindow(view, date);
|
|
70
|
+
const active = enabled && resolved;
|
|
85
71
|
const events = useCalendarEventWindow({
|
|
86
72
|
...window,
|
|
87
73
|
calendarIds: narrowed,
|
|
88
|
-
enabled:
|
|
74
|
+
enabled: active,
|
|
89
75
|
});
|
|
90
|
-
usePrefetchAdjacentWindows(view, date, narrowed,
|
|
76
|
+
usePrefetchAdjacentWindows(view, date, narrowed, active);
|
|
91
77
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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]);
|
|
78
|
+
const eventData = useDrawnEvents(
|
|
79
|
+
events.instances,
|
|
80
|
+
timeZoneByCalendarId,
|
|
81
|
+
shown,
|
|
82
|
+
);
|
|
109
83
|
|
|
110
84
|
return {
|
|
111
85
|
calendars,
|
|
@@ -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";
|