@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.
@@ -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. 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.
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
- useCalendars,
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 { 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;
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: resolved,
74
+ enabled: active,
89
75
  });
90
- usePrefetchAdjacentWindows(view, date, narrowed, resolved);
76
+ usePrefetchAdjacentWindows(view, date, narrowed, active);
91
77
 
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]);
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";