@remit/web-client 0.0.305 → 0.0.307

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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/AgendaView.stories.tsx +2 -6
  3. package/src/components/calendar/CalendarEventPane.stories.tsx +23 -0
  4. package/src/components/calendar/CalendarEventPane.tsx +13 -1
  5. package/src/components/calendar/OpenCalendarEvent.stories.tsx +1 -1
  6. package/src/components/calendar/OpenCalendarEvent.tsx +13 -2
  7. package/src/components/calendar/WriteCalendarEvent.tsx +2 -1
  8. package/src/components/calendar/calendar-app.stories.tsx +119 -0
  9. package/src/components/calendar/calendar-story-fixtures.ts +0 -20
  10. package/src/components/calendar/pending-suggestions.render.test.ts +5 -0
  11. package/src/components/mail/DraftsView.tsx +3 -7
  12. package/src/components/mail/OutboxPane.tsx +14 -22
  13. package/src/components/settings/CalendarSettingsPanel.tsx +22 -16
  14. package/src/components/settings/CalendarSubscribeCard.stories.tsx +89 -0
  15. package/src/components/settings/CalendarSubscribeCard.tsx +115 -0
  16. package/src/components/settings/CalendarSubscriptionStatus.stories.tsx +77 -0
  17. package/src/components/settings/CalendarSubscriptionStatus.tsx +88 -0
  18. package/src/hooks/calendar/instance.ts +7 -0
  19. package/src/hooks/calendar/useCalendarSubscriptions.ts +103 -0
  20. package/src/hooks/calendar/useCalendars.ts +4 -0
  21. package/src/hooks/intelligence-calendar.render.test.ts +5 -0
  22. package/src/lib/calendar-subscription.ts +11 -0
  23. package/src/mocks/story-frame/calendar-window.ts +21 -0
  24. package/src/mocks/story-frame/mail-handlers.ts +21 -3
  25. package/src/mocks/story-frame/mail-world.ts +9 -0
  26. package/src/routes/settings/calendars.tsx +49 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.305",
3
+ "version": "0.0.307",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -1,13 +1,9 @@
1
1
  import type { Density } from "@remit/ui";
2
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
3
  import { expect, within } from "storybook/test";
4
+ import { instancesWithin } from "@/mocks/story-frame/calendar-window";
4
5
  import { AgendaView } from "./AgendaView";
5
- import {
6
- calendars,
7
- fortnight,
8
- instancesWithin,
9
- STORY_DATE,
10
- } from "./calendar-story-fixtures";
6
+ import { calendars, fortnight, STORY_DATE } from "./calendar-story-fixtures";
11
7
  import {
12
8
  type CalendarServer,
13
9
  CalendarStory,
@@ -1,5 +1,6 @@
1
1
  import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, within } from "storybook/test";
3
4
  import { CalendarEventPane } from "./CalendarEventPane";
4
5
 
5
6
  /**
@@ -175,3 +176,25 @@ export const ChangedElsewhere: Story = {
175
176
  export const NotWritableYet: Story = {
176
177
  args: { onEdit: undefined, onDelete: undefined },
177
178
  };
179
+
180
+ /**
181
+ * An event from a subscribed feed. The controls are gone because the feed
182
+ * owns the event, and the note says so where they would have been.
183
+ */
184
+ export const FromSubscription: Story = {
185
+ args: {
186
+ onEdit: undefined,
187
+ onDelete: undefined,
188
+ readOnlyNote:
189
+ "Read-only: from calendar.google.com. Change it where the feed comes from.",
190
+ },
191
+ play: async ({ canvasElement }) => {
192
+ const canvas = within(canvasElement);
193
+ await expect(
194
+ canvas.getByText(
195
+ "Read-only: from calendar.google.com. Change it where the feed comes from.",
196
+ ),
197
+ ).toBeVisible();
198
+ await expect(canvas.queryByRole("button", { name: "Edit" })).toBeNull();
199
+ },
200
+ };
@@ -1,6 +1,6 @@
1
1
  import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
2
  import { EventDetail } from "@remit/ui";
3
- import { AlertCircle, CalendarDays } from "lucide-react";
3
+ import { AlertCircle, CalendarDays, Lock } from "lucide-react";
4
4
  import type { ReactNode } from "react";
5
5
  import { formatEventWhen } from "@/lib/calendar-format";
6
6
 
@@ -76,6 +76,11 @@ export interface CalendarEventPaneProps {
76
76
  * when the last one did.
77
77
  */
78
78
  problem?: string;
79
+ /**
80
+ * Why this event cannot be changed here, stated where Edit and Delete would
81
+ * be. Empty for an event the reader can change.
82
+ */
83
+ readOnlyNote?: string;
79
84
  onEdit?: () => void;
80
85
  onDelete?: () => void;
81
86
  onClose: () => void;
@@ -87,6 +92,7 @@ export function CalendarEventPane({
87
92
  isOccurrence,
88
93
  absence = NOT_IN_VIEW,
89
94
  problem = "",
95
+ readOnlyNote = "",
90
96
  onEdit,
91
97
  onDelete,
92
98
  onClose,
@@ -129,6 +135,12 @@ export function CalendarEventPane({
129
135
  One occurrence of a repeating event
130
136
  </p>
131
137
  )}
138
+ {readOnlyNote !== "" && (
139
+ <p className="flex shrink-0 items-start gap-2 border-b border-line bg-surface-sunken px-row-inset py-2 text-sm text-fg-muted">
140
+ <Lock className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
141
+ <span className="min-w-0 flex-1 break-words">{readOnlyNote}</span>
142
+ </p>
143
+ )}
132
144
  {problem !== "" && (
133
145
  <div
134
146
  role="alert"
@@ -1,10 +1,10 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { expect, userEvent, within } from "storybook/test";
3
+ import { instancesWithin } from "@/mocks/story-frame/calendar-window";
3
4
  import { useOpenCalendarEvent } from "@/routing";
4
5
  import {
5
6
  calendars,
6
7
  fortnight,
7
- instancesWithin,
8
8
  ROADMAP_OBJECT,
9
9
  roadmapResource,
10
10
  STANDUP_OBJECT,
@@ -38,6 +38,7 @@ import { useCalendarData } from "@/hooks/useCalendarData";
38
38
  import { formatEventWhen } from "@/lib/calendar-format";
39
39
  import { calendarEventReportHref } from "@/lib/calendar-report";
40
40
  import type { CalendarSearch } from "@/lib/calendar-route";
41
+ import { subscriptionReadOnlyNote } from "@/lib/calendar-subscription";
41
42
  import { useCalendarAddress, useCalendarNavigation } from "@/routing";
42
43
 
43
44
  /**
@@ -113,7 +114,7 @@ export function OpenCalendarEvent({
113
114
  }: OpenCalendarEventProps) {
114
115
  const { view, date, calendarIds } = useCalendarAddress();
115
116
  const { events, isLoading } = useCalendarData({ view, date, calendarIds });
116
- const { calendars, timeZoneByCalendarId } = useCalendars();
117
+ const { calendars, collections, timeZoneByCalendarId } = useCalendars();
117
118
  const { closeEvent } = useCalendarNavigation();
118
119
  const { updateEvent, deleteEvent, isWriting } = useCalendarWrites();
119
120
 
@@ -178,7 +179,8 @@ export function OpenCalendarEvent({
178
179
  recurrenceId !== "";
179
180
  // Until the resource is read there is no etag, and a write with no etag is a
180
181
  // write that can silently overwrite somebody.
181
- const writable = event !== undefined && resource !== undefined;
182
+ const writable =
183
+ event !== undefined && resource !== undefined && !calendar?.readOnly;
182
184
 
183
185
  const settle = (outcome: CalendarWriteOutcome, done: () => void) => {
184
186
  if (outcome.kind === "written") {
@@ -340,6 +342,15 @@ export function OpenCalendarEvent({
340
342
  }
341
343
  }
342
344
  calendar={calendar}
345
+ readOnlyNote={
346
+ calendar?.readOnly
347
+ ? subscriptionReadOnlyNote(
348
+ collections.find(
349
+ (collection) => collection.calendarId === calendar.id,
350
+ )?.subscriptionUrl ?? "",
351
+ )
352
+ : ""
353
+ }
343
354
  isOccurrence={recurrenceId !== undefined}
344
355
  absence={absenceOf(search, seriesOnly && isLoading)}
345
356
  problem={problem}
@@ -30,7 +30,8 @@ export interface WriteCalendarEventProps {
30
30
  export function WriteCalendarEvent({ onClose }: WriteCalendarEventProps) {
31
31
  const { date } = useCalendarAddress();
32
32
  const seed = useCalendarComposeSeed();
33
- const { calendars, timeZoneByCalendarId } = useCalendars();
33
+ const { calendars: held, timeZoneByCalendarId } = useCalendars();
34
+ const calendars = held.filter((calendar) => !calendar.readOnly);
34
35
  const { createEvent, isWriting } = useCalendarWrites();
35
36
  const [problem, setProblem] = useState("");
36
37
  const [draft, setDraft] = useState<EventDraft>(() => {
@@ -0,0 +1,119 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent, within } from "storybook/test";
3
+ import { AppStory } from "@/mocks/story-frame/AppStory";
4
+ import { mailHandlers } from "@/mocks/story-frame/mail-handlers";
5
+ import { mailWorld } from "@/mocks/story-frame/mail-world";
6
+ import {
7
+ calendars,
8
+ fortnight,
9
+ ROADMAP_OBJECT,
10
+ roadmapResource,
11
+ STANDUP_OBJECT,
12
+ STANDUP_RECURRENCE,
13
+ STORY_DATE,
14
+ STORY_WEEK,
15
+ standupResource,
16
+ } from "./calendar-story-fixtures";
17
+
18
+ const world = mailWorld({
19
+ calendars,
20
+ calendarEvents: fortnight,
21
+ calendarResources: [roadmapResource, standupResource],
22
+ });
23
+ const desktop = { viewport: { value: "desktop", isRotated: false } };
24
+ const phone = { viewport: { value: "mobile", isRotated: false } };
25
+
26
+ const meta = {
27
+ title: "Playground/Shipped/Calendar/App",
28
+ component: AppStory,
29
+ args: { url: STORY_WEEK },
30
+ parameters: {
31
+ layout: "fullscreen",
32
+ msw: { handlers: mailHandlers(world) },
33
+ },
34
+ globals: desktop,
35
+ } satisfies Meta<typeof AppStory>;
36
+ export default meta;
37
+
38
+ type Story = StoryObj<typeof meta>;
39
+
40
+ const page = () => within(document.body);
41
+
42
+ export const Week: Story = {
43
+ play: async () => {
44
+ await expect(
45
+ (await page().findAllByText("Roadmap review"))[0],
46
+ ).toBeVisible();
47
+ await expect(
48
+ (await page().findAllByRole("link", { name: "Calendar" }))[0],
49
+ ).toBeVisible();
50
+ },
51
+ };
52
+
53
+ export const Empty: Story = {
54
+ parameters: { msw: { handlers: mailHandlers(mailWorld()) } },
55
+ play: async () => {
56
+ await expect(
57
+ (await page().findAllByRole("link", { name: "Calendar" }))[0],
58
+ ).toBeVisible();
59
+ await expect(page().queryByText("Roadmap review")).toBeNull();
60
+ },
61
+ };
62
+
63
+ export const OpenEvent: Story = {
64
+ args: { url: `${STORY_WEEK}/${ROADMAP_OBJECT}` },
65
+ play: async () => {
66
+ await expect(await page().findByText("Room Zuid")).toBeVisible();
67
+ await expect(
68
+ await page().findByRole("button", { name: "Edit" }),
69
+ ).toBeVisible();
70
+ },
71
+ };
72
+
73
+ export const RecurrenceScope: Story = {
74
+ args: { url: `${STORY_WEEK}/${STANDUP_OBJECT}/${STANDUP_RECURRENCE}` },
75
+ play: async () => {
76
+ await userEvent.click(await page().findByRole("button", { name: "Edit" }));
77
+ await expect(
78
+ await page().findByText("What should the change apply to?"),
79
+ ).toBeVisible();
80
+ },
81
+ };
82
+
83
+ export const NewEvent: Story = {
84
+ args: { url: `${STORY_WEEK}/new` },
85
+ play: async () => {
86
+ await expect(await page().findByLabelText("Date")).toHaveValue(STORY_DATE);
87
+ await expect(page().getByLabelText("Title")).toHaveValue("");
88
+ await expect(
89
+ await page().findByRole("radio", { name: "Northwind" }),
90
+ ).toBeChecked();
91
+ },
92
+ };
93
+
94
+ export const PhoneNewEvent: Story = {
95
+ args: { url: `/calendar/day/${STORY_DATE}/new` },
96
+ globals: phone,
97
+ play: async () => {
98
+ await expect(await page().findByLabelText("Date")).toHaveValue(STORY_DATE);
99
+ await expect(page().getByLabelText("Title")).toHaveValue("");
100
+ },
101
+ };
102
+
103
+ export const PhoneDay: Story = {
104
+ args: { url: `/calendar/day/${STORY_DATE}` },
105
+ globals: phone,
106
+ play: async () => {
107
+ await expect(
108
+ (await page().findAllByText("Roadmap review"))[0],
109
+ ).toBeVisible();
110
+ },
111
+ };
112
+
113
+ export const PhoneEvent: Story = {
114
+ args: { url: `/calendar/day/${STORY_DATE}/${ROADMAP_OBJECT}` },
115
+ globals: phone,
116
+ play: async () => {
117
+ await expect(await page().findByText("Room Zuid")).toBeVisible();
118
+ },
119
+ };
@@ -116,23 +116,3 @@ export const standupResource = {
116
116
  "END:VCALENDAR",
117
117
  ].join("\r\n"),
118
118
  } as RemitImapCalendarEventResponse;
119
-
120
- /**
121
- * The occurrences inside the window a request asked for.
122
- *
123
- * The panes read a week at a time and hold several weeks at once, so a server
124
- * that answered every window with the same list would draw each day's events
125
- * once per window it appears in.
126
- */
127
- export const instancesWithin = (
128
- url: URL,
129
- instances: readonly RemitImapCalendarEventInstance[],
130
- ): RemitImapCalendarEventInstance[] => {
131
- const from = Date.parse(url.searchParams.get("from") ?? "");
132
- const to = Date.parse(url.searchParams.get("to") ?? "");
133
- if (Number.isNaN(from) || Number.isNaN(to)) return [...instances];
134
- return instances.filter((instance) => {
135
- const start = Date.parse(instance.start);
136
- return start >= from && start < to;
137
- });
138
- };
@@ -35,6 +35,11 @@ const calendars = [
35
35
  source: "Default",
36
36
  timezone: "Europe/Amsterdam",
37
37
  syncSequence: 1,
38
+ subscriptionUrl: "",
39
+ subscriptionEnabled: false,
40
+ subscriptionCheckedAt: 0,
41
+ subscriptionFetchedAt: 0,
42
+ subscriptionError: "",
38
43
  createdAt: 0,
39
44
  updatedAt: 0,
40
45
  },
@@ -32,6 +32,7 @@ import {
32
32
  ComfortableRowTextContent,
33
33
  cn,
34
34
  comfortableRowClass,
35
+ PaneHeader,
35
36
  type ThreadRowData,
36
37
  type ThreadSection,
37
38
  } from "@remit/ui";
@@ -194,18 +195,13 @@ export function DraftsView({
194
195
 
195
196
  return (
196
197
  <section className="flex h-full w-full flex-col bg-surface">
197
- {/* List datum bar */}
198
- <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
199
- <NavMenuButton />
200
- <h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
201
- {title}
202
- </h1>
198
+ <PaneHeader title={title} leading={<NavMenuButton />}>
203
199
  {(unreadCount ?? 0) > 0 && (
204
200
  <span className="shrink-0 text-2xs text-fg-subtle tabular-nums">
205
201
  {unreadCount} unread
206
202
  </span>
207
203
  )}
208
- </header>
204
+ </PaneHeader>
209
205
 
210
206
  {/* Scrollable body */}
211
207
  <div className="flex-1 overflow-y-auto">
@@ -26,6 +26,7 @@ import {
26
26
  cn,
27
27
  LIST_ROW_SELECTOR,
28
28
  OutboxRow,
29
+ PaneHeader,
29
30
  ReadingPaneEmpty,
30
31
  useRovingFocus,
31
32
  } from "@remit/ui";
@@ -458,18 +459,7 @@ function OutboxList() {
458
459
 
459
460
  return (
460
461
  <div className="h-full flex flex-col bg-canvas">
461
- {/* List datum bar (40px, the shared `--spacing-pane-header`): keeps
462
- the bottom hairline on the same y as the mailbox view so the grid
463
- line stays continuous across nav → outbox (no staircase, #422). */}
464
- <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
465
- <NavMenuButton />
466
- <h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
467
- Outbox
468
- </h1>
469
- <span className="shrink-0 text-2xs text-fg-subtle">
470
- {messages.length} {messages.length === 1 ? "message" : "messages"}
471
- </span>
472
- </header>
462
+ <OutboxListHeader count={messages.length} />
473
463
  <div ref={listRef} className="flex-1 overflow-y-auto">
474
464
  {messages.map((message) => (
475
465
  <OutboxMessageRow
@@ -484,6 +474,16 @@ function OutboxList() {
484
474
  );
485
475
  }
486
476
 
477
+ function OutboxListHeader({ count }: { count: number }) {
478
+ return (
479
+ <PaneHeader title="Outbox" leading={<NavMenuButton />}>
480
+ <span className="shrink-0 text-2xs text-fg-subtle">
481
+ {count} {count === 1 ? "message" : "messages"}
482
+ </span>
483
+ </PaneHeader>
484
+ );
485
+ }
486
+
487
487
  /**
488
488
  * Outbox reading pane. Mount in the `reading` slot of `AppShellSlotted`.
489
489
  * Only rendered ≥ 1024px.
@@ -496,7 +496,7 @@ function OutboxReading() {
496
496
  {/* Detail datum bar (40px): outbox has no message-action toolbar,
497
497
  but the datum bar must be present so its bottom hairline lines
498
498
  up with the list pane's, keeping the grid continuous (#422). */}
499
- <header className="flex h-pane-header shrink-0 items-center border-b border-line px-3" />
499
+ <PaneHeader />
500
500
  <div className="min-h-0 flex-1 overflow-hidden">
501
501
  {selectedMessage ? (
502
502
  <OutboxMessageDetail message={selectedMessage} />
@@ -556,15 +556,7 @@ function OutboxPhone() {
556
556
 
557
557
  return (
558
558
  <div className="h-full flex flex-col bg-canvas">
559
- <header className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-row-inset">
560
- <NavMenuButton />
561
- <h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
562
- Outbox
563
- </h1>
564
- <span className="shrink-0 text-2xs text-fg-subtle">
565
- {messages.length} {messages.length === 1 ? "message" : "messages"}
566
- </span>
567
- </header>
559
+ <OutboxListHeader count={messages.length} />
568
560
  <div ref={listRef} className="flex-1 overflow-y-auto">
569
561
  {messages.map((message) => (
570
562
  <OutboxMessageRow
@@ -2,7 +2,7 @@
2
2
  * One calendar on the settings page: its name and zone, deleting it, and its
3
3
  * subscription address, wired to the server.
4
4
  */
5
- import { useState } from "react";
5
+ import { type ReactNode, useState } from "react";
6
6
  import { CalendarDetailsForm } from "@/components/settings/CalendarDetailsForm";
7
7
  import { CalendarFeedPanel } from "@/components/settings/CalendarFeedPanel";
8
8
  import {
@@ -14,12 +14,15 @@ export interface CalendarSettingsPanelProps {
14
14
  calendarId: string;
15
15
  calendarName: string;
16
16
  timezone: string;
17
+ /** Where a subscribed calendar comes from, drawn above its details. */
18
+ subscription?: ReactNode;
17
19
  }
18
20
 
19
21
  export function CalendarSettingsPanel({
20
22
  calendarId,
21
23
  calendarName,
22
24
  timezone,
25
+ subscription,
23
26
  }: CalendarSettingsPanelProps) {
24
27
  const { updateCalendar, deleteCalendar, isWriting } =
25
28
  useCalendarCollectionWrites();
@@ -33,21 +36,24 @@ export function CalendarSettingsPanel({
33
36
  calendarId={calendarId}
34
37
  calendarName={calendarName}
35
38
  details={
36
- <CalendarDetailsForm
37
- key={`${calendarName}\u0000${timezone}`}
38
- calendarName={calendarName}
39
- timezone={timezone}
40
- isBusy={isWriting}
41
- problem={problem}
42
- onSave={(details) => {
43
- setProblem("");
44
- void updateCalendar(calendarId, details).then(settle);
45
- }}
46
- onDelete={() => {
47
- setProblem("");
48
- void deleteCalendar(calendarId).then(settle);
49
- }}
50
- />
39
+ <>
40
+ {subscription}
41
+ <CalendarDetailsForm
42
+ key={`${calendarName}\u0000${timezone}`}
43
+ calendarName={calendarName}
44
+ timezone={timezone}
45
+ isBusy={isWriting}
46
+ problem={problem}
47
+ onSave={(details) => {
48
+ setProblem("");
49
+ void updateCalendar(calendarId, details).then(settle);
50
+ }}
51
+ onDelete={() => {
52
+ setProblem("");
53
+ void deleteCalendar(calendarId).then(settle);
54
+ }}
55
+ />
56
+ </>
51
57
  }
52
58
  />
53
59
  );
@@ -0,0 +1,89 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, fn, userEvent, within } from "storybook/test";
3
+ import { CalendarSubscribeCard } from "./CalendarSubscribeCard";
4
+
5
+ /**
6
+ * Settings › Calendars: subscribing to another calendar's read-only iCal
7
+ * address (#1261). The server reads the feed before creating anything, so a
8
+ * refusal means nothing was added.
9
+ */
10
+ const meta: Meta<typeof CalendarSubscribeCard> = {
11
+ title: "Playground/Shipped/Settings/Calendars/Subscribe to a calendar",
12
+ component: CalendarSubscribeCard,
13
+ parameters: { layout: "padded" },
14
+ args: {
15
+ isBusy: false,
16
+ error: undefined,
17
+ onSubscribe: fn(),
18
+ },
19
+ };
20
+ export default meta;
21
+
22
+ type Story = StoryObj<typeof CalendarSubscribeCard>;
23
+
24
+ /** Empty: the button waits for a name and an address. */
25
+ export const Empty: Story = {
26
+ play: async ({ canvasElement }) => {
27
+ const canvas = within(canvasElement);
28
+ await expect(
29
+ canvas.getByRole("button", { name: "Subscribe" }),
30
+ ).toBeDisabled();
31
+ },
32
+ };
33
+
34
+ /** Filled in and sent. */
35
+ export const Filled: Story = {
36
+ play: async ({ canvasElement, args }) => {
37
+ const canvas = within(canvasElement);
38
+ await userEvent.type(
39
+ canvas.getByLabelText("Calendar name"),
40
+ "Harbour rota",
41
+ );
42
+ await userEvent.type(
43
+ canvas.getByLabelText("Calendar address"),
44
+ "webcal://calendar.example/harbour/basic.ics",
45
+ );
46
+ await userEvent.click(canvas.getByRole("button", { name: "Subscribe" }));
47
+ await expect(args.onSubscribe).toHaveBeenCalledWith(
48
+ {
49
+ displayName: "Harbour rota",
50
+ url: "webcal://calendar.example/harbour/basic.ics",
51
+ },
52
+ expect.any(Function),
53
+ );
54
+ },
55
+ };
56
+
57
+ /** The feed is being read. */
58
+ export const Subscribing: Story = {
59
+ args: { isBusy: true },
60
+ };
61
+
62
+ /** The feed could not be read, so no calendar was made. */
63
+ export const Refused: Story = {
64
+ args: {
65
+ error: {
66
+ code: "subscription_unreachable",
67
+ message: "the calendar was not created: the feed answered HTTP 404",
68
+ },
69
+ },
70
+ play: async ({ canvasElement }) => {
71
+ const canvas = within(canvasElement);
72
+ await expect(canvas.getByRole("alert")).toHaveTextContent(
73
+ "The calendar was not added.",
74
+ );
75
+ },
76
+ };
77
+
78
+ /** Refused, on the dark theme. */
79
+ export const RefusedDark: Story = {
80
+ name: "Refused (dark)",
81
+ parameters: { theme: "dark" },
82
+ args: {
83
+ error: {
84
+ code: "subscription_unreachable",
85
+ message:
86
+ "the calendar was not created: the feed could not be reached (ENOTFOUND)",
87
+ },
88
+ },
89
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Settings › Calendars: subscribe a new calendar to a read-only iCalendar
3
+ * address (issue #1261).
4
+ *
5
+ * Presentational. The server reads the feed before it creates anything, so a
6
+ * refusal here means nothing was added, and the banner says so in those words.
7
+ */
8
+ import {
9
+ Banner,
10
+ Button,
11
+ Card,
12
+ CardBody,
13
+ CardHeader,
14
+ CardTitle,
15
+ Input,
16
+ } from "@remit/ui";
17
+ import { CalendarPlus } from "lucide-react";
18
+ import { type FormEvent, useId, useState } from "react";
19
+ import { formatErrorMessage } from "@/components/ui/ErrorState";
20
+
21
+ export interface CalendarSubscribeCardProps {
22
+ isBusy: boolean;
23
+ /** The subscribe the server turned down. */
24
+ error: unknown;
25
+ onSubscribe: (
26
+ request: { displayName: string; url: string },
27
+ onDone: () => void,
28
+ ) => void;
29
+ }
30
+
31
+ export const SUBSCRIBE_TITLE = "Subscribe to a calendar";
32
+
33
+ export function CalendarSubscribeCard({
34
+ isBusy,
35
+ error,
36
+ onSubscribe,
37
+ }: CalendarSubscribeCardProps) {
38
+ const titleId = useId();
39
+ const nameId = useId();
40
+ const urlId = useId();
41
+ const [displayName, setDisplayName] = useState("");
42
+ const [url, setUrl] = useState("");
43
+ const ready = displayName.trim() !== "" && url.trim() !== "";
44
+
45
+ const submit = (event: FormEvent) => {
46
+ event.preventDefault();
47
+ if (!ready) return;
48
+ onSubscribe({ displayName, url }, () => {
49
+ setDisplayName("");
50
+ setUrl("");
51
+ });
52
+ };
53
+
54
+ return (
55
+ <Card className="max-w-xl" role="region" aria-labelledby={titleId}>
56
+ <CardHeader>
57
+ <CardTitle id={titleId}>{SUBSCRIBE_TITLE}</CardTitle>
58
+ </CardHeader>
59
+ <CardBody>
60
+ <form className="space-y-3" onSubmit={submit}>
61
+ <p className="text-sm text-fg-muted">
62
+ Paste the secret iCal address from Google Calendar, a published
63
+ Outlook calendar or any other feed. It shows here read-only and
64
+ refreshes on its own.
65
+ </p>
66
+ <div className="space-y-1">
67
+ <label htmlFor={nameId} className="text-sm font-medium text-fg">
68
+ Calendar name
69
+ </label>
70
+ <Input
71
+ id={nameId}
72
+ value={displayName}
73
+ onChange={(event) => setDisplayName(event.target.value)}
74
+ placeholder="Team rota"
75
+ autoComplete="off"
76
+ />
77
+ </div>
78
+ <div className="space-y-1">
79
+ <label htmlFor={urlId} className="text-sm font-medium text-fg">
80
+ Calendar address
81
+ </label>
82
+ <Input
83
+ id={urlId}
84
+ value={url}
85
+ onChange={(event) => setUrl(event.target.value)}
86
+ placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
87
+ autoComplete="off"
88
+ spellCheck={false}
89
+ inputMode="url"
90
+ />
91
+ <p className="text-xs text-fg-muted">
92
+ For Google Calendar the address is the password to the calendar.
93
+ Reader never shows it in full again.
94
+ </p>
95
+ </div>
96
+ <Button
97
+ type="submit"
98
+ variant="primary"
99
+ size="sm"
100
+ disabled={!ready || isBusy}
101
+ icon={<CalendarPlus className="size-3.5" />}
102
+ >
103
+ {isBusy ? "Subscribing…" : "Subscribe"}
104
+ </Button>
105
+ {error !== undefined && error !== null && (
106
+ <Banner tone="danger" variant="soft">
107
+ <p className="font-medium">The calendar was not added.</p>
108
+ <p className="mt-0.5 break-words">{formatErrorMessage(error)}</p>
109
+ </Banner>
110
+ )}
111
+ </form>
112
+ </CardBody>
113
+ </Card>
114
+ );
115
+ }
@@ -0,0 +1,77 @@
1
+ import { Card, CardBody } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, fn, within } from "storybook/test";
4
+ import { CalendarSubscriptionStatus } from "./CalendarSubscriptionStatus";
5
+
6
+ /**
7
+ * Settings › Calendars: a calendar subscribed to a feed (#1261). The address is
8
+ * never drawn in full, since for Google it is the whole credential; its host
9
+ * is enough to recognise it.
10
+ */
11
+ const meta: Meta<typeof CalendarSubscriptionStatus> = {
12
+ title: "Playground/Shipped/Settings/Calendars/Subscribed calendar",
13
+ component: CalendarSubscriptionStatus,
14
+ parameters: { layout: "padded" },
15
+ decorators: [
16
+ (Story) => (
17
+ <Card className="max-w-xl">
18
+ <CardBody>
19
+ <Story />
20
+ </CardBody>
21
+ </Card>
22
+ ),
23
+ ],
24
+ args: {
25
+ calendarName: "Harbour rota",
26
+ subscriptionUrl:
27
+ "https://calendar.google.com/calendar/ical/harbour%40example.com/private-5f1c0ffee/basic.ics",
28
+ enabled: true,
29
+ fetchedAt: Date.parse("2026-09-25T08:15:00Z"),
30
+ error: "",
31
+ isBusy: false,
32
+ actionError: undefined,
33
+ onSetEnabled: fn(),
34
+ },
35
+ };
36
+ export default meta;
37
+
38
+ type Story = StoryObj<typeof CalendarSubscriptionStatus>;
39
+
40
+ /** Refreshing on schedule. Only the host is named. */
41
+ export const Live: Story = {
42
+ play: async ({ canvasElement }) => {
43
+ const canvas = within(canvasElement);
44
+ await expect(
45
+ canvas.getByText(/Read-only, from calendar\.google\.com/),
46
+ ).toBeVisible();
47
+ await expect(canvasElement.textContent).not.toContain("private-5f1c0ffee");
48
+ },
49
+ };
50
+
51
+ /** The last refresh failed. The events already stored stay. */
52
+ export const Failing: Story = {
53
+ args: { error: "the feed answered HTTP 404" },
54
+ play: async ({ canvasElement }) => {
55
+ const canvas = within(canvasElement);
56
+ await expect(canvas.getByRole("alert")).toHaveTextContent(
57
+ "the feed answered HTTP 404",
58
+ );
59
+ },
60
+ };
61
+
62
+ /** Updates paused. Nothing was removed. */
63
+ export const Paused: Story = {
64
+ args: { enabled: false },
65
+ };
66
+
67
+ /** Paused, on the dark theme. */
68
+ export const PausedDark: Story = {
69
+ name: "Paused (dark)",
70
+ parameters: { theme: "dark" },
71
+ args: { enabled: false, error: "the feed answered HTTP 410" },
72
+ };
73
+
74
+ /** Never fetched successfully yet. */
75
+ export const NeverFetched: Story = {
76
+ args: { fetchedAt: 0 },
77
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Settings › Calendars: where a subscribed calendar comes from, when it was
3
+ * last read, why it was not, and the switch that stops its refresh
4
+ * (issue #1261).
5
+ *
6
+ * The address itself is not drawn. For a Google feed it is the whole
7
+ * credential, so only its host is named.
8
+ */
9
+ import { Badge, Banner, Button } from "@remit/ui";
10
+ import { Pause, Play } from "lucide-react";
11
+ import { formatErrorMessage } from "@/components/ui/ErrorState";
12
+ import { subscriptionHostOf } from "@/lib/calendar-subscription";
13
+ import { formatDatePreset } from "@/lib/format";
14
+
15
+ export interface CalendarSubscriptionStatusProps {
16
+ calendarName: string;
17
+ subscriptionUrl: string;
18
+ enabled: boolean;
19
+ fetchedAt: number;
20
+ error: string;
21
+ isBusy: boolean;
22
+ /** A pause or resume the server turned down. */
23
+ actionError: unknown;
24
+ onSetEnabled: (enabled: boolean) => void;
25
+ }
26
+
27
+ export function CalendarSubscriptionStatus({
28
+ calendarName,
29
+ subscriptionUrl,
30
+ enabled,
31
+ fetchedAt,
32
+ error,
33
+ isBusy,
34
+ actionError,
35
+ onSetEnabled,
36
+ }: CalendarSubscriptionStatusProps) {
37
+ return (
38
+ <div className="mb-4 space-y-2">
39
+ <div className="flex flex-wrap items-center gap-2">
40
+ <Badge tone={enabled ? "positive" : "neutral"}>
41
+ {enabled ? "subscribed" : "paused"}
42
+ </Badge>
43
+ <span className="text-xs text-fg-muted">
44
+ Read-only, from {subscriptionHostOf(subscriptionUrl)}
45
+ {fetchedAt > 0
46
+ ? `. Last updated ${formatDatePreset(fetchedAt, "medium")}`
47
+ : ""}
48
+ </span>
49
+ </div>
50
+ {error !== "" && (
51
+ <Banner tone="warning" variant="soft">
52
+ <p className="font-medium">{calendarName} was not refreshed.</p>
53
+ <p className="mt-0.5 break-words">{error}</p>
54
+ </Banner>
55
+ )}
56
+ <p className="text-sm text-fg-muted">
57
+ {enabled
58
+ ? "Events are read from the feed on a schedule and cannot be edited here."
59
+ : "Updates are paused. The events already here stay until you resume or remove the calendar."}
60
+ </p>
61
+ <Button
62
+ variant="secondary"
63
+ size="sm"
64
+ disabled={isBusy}
65
+ icon={
66
+ enabled ? (
67
+ <Pause className="size-3.5" />
68
+ ) : (
69
+ <Play className="size-3.5" />
70
+ )
71
+ }
72
+ onClick={() => onSetEnabled(!enabled)}
73
+ >
74
+ {enabled ? "Pause updates" : "Resume updates"}
75
+ </Button>
76
+ {actionError !== undefined && actionError !== null && (
77
+ <Banner tone="danger" variant="soft">
78
+ <p className="font-medium">
79
+ Updates for {calendarName} were not changed.
80
+ </p>
81
+ <p className="mt-0.5 break-words">
82
+ {formatErrorMessage(actionError)}
83
+ </p>
84
+ </Banner>
85
+ )}
86
+ </div>
87
+ );
88
+ }
@@ -163,6 +163,13 @@ export function toCalendarDescriptor(
163
163
  accountLabel: "",
164
164
  name: calendar.displayName,
165
165
  color: COLORS[calendar.color] ?? "cal-1",
166
+ ...(calendar.source === "Subscribed"
167
+ ? {
168
+ readOnly: true,
169
+ sync: calendar.subscriptionEnabled ? "live" : "paused",
170
+ syncError: calendar.subscriptionError,
171
+ }
172
+ : {}),
166
173
  };
167
174
  }
168
175
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Subscribing a new calendar to a read-only iCalendar URL, and turning an
3
+ * existing subscription's refresh off and on (issue #1261).
4
+ *
5
+ * The server fetches the feed before it creates anything, so a refused
6
+ * subscribe leaves no calendar behind and its reason is what the form states.
7
+ * A 401 is not among the statuses owned here: a lapsed session escalates the
8
+ * way it does everywhere else.
9
+ */
10
+ import {
11
+ calendarDetailOperationsUpdateCalendarMutation,
12
+ calendarOperationsCreateCalendarMutation,
13
+ calendarOperationsListCalendarsQueryKey,
14
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
15
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
16
+ import { useCallback } from "react";
17
+ import { softErrorStatuses } from "@/lib/error-classifier";
18
+ import { useInvalidateCalendarReads } from "./useCalendarWrites";
19
+
20
+ const SUBSCRIPTION_WRITE_META = softErrorStatuses(400, 404);
21
+
22
+ export interface CalendarSubscribeRequest {
23
+ displayName: string;
24
+ url: string;
25
+ }
26
+
27
+ export interface CalendarSubscriptionControls {
28
+ subscribe: (request: CalendarSubscribeRequest, onDone: () => void) => void;
29
+ isSubscribing: boolean;
30
+ subscribeError: unknown;
31
+ setEnabled: (calendarId: string, enabled: boolean) => void;
32
+ togglingCalendarId: string;
33
+ /** The last pause or resume the server turned down, and whose it was. */
34
+ toggleFailure: { calendarId: string; error: unknown };
35
+ }
36
+
37
+ const urlSegmentFor = (): string =>
38
+ `subscribed-${crypto.randomUUID().slice(0, 8)}`;
39
+
40
+ export function useCalendarSubscriptions(): CalendarSubscriptionControls {
41
+ const queryClient = useQueryClient();
42
+ const invalidateReads = useInvalidateCalendarReads();
43
+ const invalidate = () => {
44
+ void queryClient.invalidateQueries({
45
+ queryKey: calendarOperationsListCalendarsQueryKey(),
46
+ });
47
+ invalidateReads();
48
+ };
49
+
50
+ const create = useMutation({
51
+ ...calendarOperationsCreateCalendarMutation(),
52
+ meta: SUBSCRIPTION_WRITE_META,
53
+ onSuccess: invalidate,
54
+ });
55
+
56
+ const toggle = useMutation({
57
+ ...calendarDetailOperationsUpdateCalendarMutation(),
58
+ meta: SUBSCRIPTION_WRITE_META,
59
+ onSuccess: invalidate,
60
+ });
61
+
62
+ const { mutate: createCalendar } = create;
63
+ const subscribe = useCallback(
64
+ (request: CalendarSubscribeRequest, onDone: () => void) => {
65
+ createCalendar(
66
+ {
67
+ body: {
68
+ urlSegment: urlSegmentFor(),
69
+ displayName: request.displayName.trim(),
70
+ subscriptionUrl: request.url.trim(),
71
+ },
72
+ },
73
+ { onSuccess: onDone },
74
+ );
75
+ },
76
+ [createCalendar],
77
+ );
78
+
79
+ const { mutate: updateCalendar } = toggle;
80
+ const setEnabled = useCallback(
81
+ (calendarId: string, enabled: boolean) => {
82
+ updateCalendar({
83
+ path: { calendarId },
84
+ body: { subscriptionEnabled: enabled },
85
+ });
86
+ },
87
+ [updateCalendar],
88
+ );
89
+
90
+ return {
91
+ subscribe,
92
+ isSubscribing: create.isPending,
93
+ subscribeError: create.error,
94
+ setEnabled,
95
+ togglingCalendarId: toggle.isPending
96
+ ? (toggle.variables?.path.calendarId ?? "")
97
+ : "",
98
+ toggleFailure: {
99
+ calendarId: toggle.variables?.path.calendarId ?? "",
100
+ error: toggle.error,
101
+ },
102
+ };
103
+ }
@@ -8,6 +8,7 @@
8
8
  * indistinguishable from a week with nothing in it.
9
9
  */
10
10
  import { calendarOperationsListCalendarsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
11
+ import type { RemitImapCalendarResponse } from "@remit/api-http-client/types.gen.ts";
11
12
  import type { CalendarColorId, CalendarDescriptor } from "@remit/ui";
12
13
  import { useQuery } from "@tanstack/react-query";
13
14
  import { useMemo } from "react";
@@ -15,6 +16,8 @@ import { toCalendarDescriptor } from "./instance";
15
16
 
16
17
  export interface CalendarsResult {
17
18
  calendars: CalendarDescriptor[];
19
+ /** The collections as the API returned them, for a surface that manages them. */
20
+ collections: RemitImapCalendarResponse[];
18
21
  colorByCalendarId: Record<string, CalendarColorId>;
19
22
  /** The zone each collection's floating times are read in. */
20
23
  timeZoneByCalendarId: Record<string, string>;
@@ -39,6 +42,7 @@ export function useCalendars(): CalendarsResult {
39
42
  const calendars = items.map(toCalendarDescriptor);
40
43
  return {
41
44
  calendars,
45
+ collections: items,
42
46
  colorByCalendarId: Object.fromEntries(
43
47
  calendars.map((calendar) => [calendar.id, calendar.color]),
44
48
  ),
@@ -57,6 +57,11 @@ const calendars = [
57
57
  source: "Default",
58
58
  timezone: "Europe/Amsterdam",
59
59
  syncSequence: 1,
60
+ subscriptionUrl: "",
61
+ subscriptionEnabled: false,
62
+ subscriptionCheckedAt: 0,
63
+ subscriptionFetchedAt: 0,
64
+ subscriptionError: "",
60
65
  createdAt: 0,
61
66
  updatedAt: 0,
62
67
  },
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Where a subscribed calendar's events come from, named by host alone. For a
3
+ * Google feed the full address is the credential, so no surface draws it.
4
+ */
5
+ export const subscriptionHostOf = (subscriptionUrl: string): string =>
6
+ URL.canParse(subscriptionUrl)
7
+ ? new URL(subscriptionUrl).host
8
+ : "an iCalendar feed";
9
+
10
+ export const subscriptionReadOnlyNote = (subscriptionUrl: string): string =>
11
+ `Read-only: from ${subscriptionHostOf(subscriptionUrl)}. Change it where the feed comes from.`;
@@ -0,0 +1,21 @@
1
+ import type { RemitImapCalendarEventInstance } from "@remit/api-http-client/types.gen.ts";
2
+
3
+ /**
4
+ * The occurrences inside the window a request asked for.
5
+ *
6
+ * The panes read a week at a time and hold several weeks at once, so a server
7
+ * that answered every window with the same list would draw each day's events
8
+ * once per window it appears in.
9
+ */
10
+ export const instancesWithin = (
11
+ url: URL,
12
+ instances: readonly RemitImapCalendarEventInstance[],
13
+ ): RemitImapCalendarEventInstance[] => {
14
+ const from = Date.parse(url.searchParams.get("from") ?? "");
15
+ const to = Date.parse(url.searchParams.get("to") ?? "");
16
+ if (Number.isNaN(from) || Number.isNaN(to)) return [...instances];
17
+ return instances.filter((instance) => {
18
+ const start = Date.parse(instance.start);
19
+ return start >= from && start < to;
20
+ });
21
+ };
@@ -2,6 +2,7 @@ import type {
2
2
  AccountDetailOperationsUpdateAccountResponse,
3
3
  AddressDetailOperationsUpdateAddressResponse,
4
4
  AddressOperationsSearchAddressesResponse,
5
+ CalendarEventDetailOperationsGetCalendarEventResponse,
5
6
  CalendarEventOperationsListCalendarEventsResponse,
6
7
  CalendarOperationsListCalendarsResponse,
7
8
  CalendarSuggestionOperationsListCalendarSuggestionsResponse,
@@ -35,6 +36,7 @@ import type {
35
36
  } from "@remit/api-http-client/types.gen.ts";
36
37
  import { type HttpHandler, HttpResponse, http } from "msw";
37
38
  import { makeConfig } from "@/test-support/fixtures";
39
+ import { instancesWithin } from "./calendar-window";
38
40
  import type { MailWorld } from "./mail-world";
39
41
 
40
42
  const API = "/api";
@@ -487,13 +489,29 @@ export const mailHandlers = (
487
489
  return new HttpResponse(null, { status: 204 });
488
490
  }),
489
491
  http.get(`${API}/calendars`, () =>
490
- HttpResponse.json<CalendarOperationsListCalendarsResponse>({ items: [] }),
492
+ HttpResponse.json<CalendarOperationsListCalendarsResponse>({
493
+ items: world.calendars,
494
+ }),
491
495
  ),
492
- http.get(`${API}/calendar-events`, () =>
496
+ http.get(`${API}/calendar-events`, ({ request }) =>
493
497
  HttpResponse.json<CalendarEventOperationsListCalendarEventsResponse>({
494
- items: [],
498
+ items: instancesWithin(new URL(request.url), world.calendarEvents),
495
499
  }),
496
500
  ),
501
+ http.get(`${API}/calendar-events/:calendarObjectId`, ({ params }) => {
502
+ const resource = world.calendarResources.find(
503
+ (candidate) =>
504
+ candidate.calendarObjectId === String(params.calendarObjectId),
505
+ );
506
+ if (!resource)
507
+ return HttpResponse.json(
508
+ { status: 404, message: "Calendar event not found" },
509
+ { status: 404 },
510
+ );
511
+ return HttpResponse.json<CalendarEventDetailOperationsGetCalendarEventResponse>(
512
+ resource,
513
+ );
514
+ }),
497
515
  http.get(`${API}/calendar-suggestions`, () =>
498
516
  HttpResponse.json<CalendarSuggestionOperationsListCalendarSuggestionsResponse>(
499
517
  { items: [] },
@@ -1,6 +1,9 @@
1
1
  import type {
2
2
  RemitImapAccountResponse,
3
3
  RemitImapAddressResponse,
4
+ RemitImapCalendarEventInstance,
5
+ RemitImapCalendarEventResponse,
6
+ RemitImapCalendarResponse,
4
7
  RemitImapCanonicalMailboxRole,
5
8
  RemitImapMailboxResponse,
6
9
  RemitImapMessageCategory,
@@ -27,6 +30,9 @@ export interface MailWorld {
27
30
  addresses: RemitImapAddressResponse[];
28
31
  systemUpdate: RemitImapSystemUpdateResponse;
29
32
  bodies: Record<string, string>;
33
+ calendars: RemitImapCalendarResponse[];
34
+ calendarEvents: RemitImapCalendarEventInstance[];
35
+ calendarResources: RemitImapCalendarEventResponse[];
30
36
  }
31
37
 
32
38
  export const MINUTE = 60_000;
@@ -429,5 +435,8 @@ export const mailWorld = (overrides: Partial<MailWorld> = {}): MailWorld => ({
429
435
  run: null,
430
436
  },
431
437
  bodies: {},
438
+ calendars: [],
439
+ calendarEvents: [],
440
+ calendarResources: [],
432
441
  ...overrides,
433
442
  });
@@ -3,7 +3,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
3
3
  import { useState } from "react";
4
4
  import { CalendarCreateCard } from "@/components/settings/CalendarCreateCard";
5
5
  import { CalendarSettingsPanel } from "@/components/settings/CalendarSettingsPanel";
6
+ import { CalendarSubscribeCard } from "@/components/settings/CalendarSubscribeCard";
7
+ import { CalendarSubscriptionStatus } from "@/components/settings/CalendarSubscriptionStatus";
6
8
  import { useCalendarCollectionWrites } from "@/hooks/calendar/useCalendarCollectionWrites";
9
+ import { useCalendarSubscriptions } from "@/hooks/calendar/useCalendarSubscriptions";
7
10
  import { useCalendars } from "@/hooks/calendar/useCalendars";
8
11
  import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
9
12
 
@@ -34,13 +37,19 @@ const calendarsHelp = (
34
37
  looked up later. Replacing it breaks every subscription built on the old
35
38
  one, which is how a shared address is taken back.
36
39
  </p>
40
+ <p>
41
+ Subscribing works the other way round: paste another calendar's iCal
42
+ address and its events show here, read-only, refreshed on a schedule.
43
+ </p>
37
44
  </div>
38
45
  );
39
46
 
40
47
  function CalendarsSettings() {
41
48
  const navigate = useNavigate();
42
49
  const [helpOpen, setHelpOpen] = useState(true);
43
- const { calendars, timeZoneByCalendarId, isLoading } = useCalendars();
50
+ const { calendars, collections, timeZoneByCalendarId, isLoading } =
51
+ useCalendars();
52
+ const subscriptions = useCalendarSubscriptions();
44
53
  const { createCalendar, isWriting } = useCalendarCollectionWrites();
45
54
  const [createProblem, setCreateProblem] = useState("");
46
55
 
@@ -49,7 +58,7 @@ function CalendarsSettings() {
49
58
  items={SETTINGS_NAV_ITEMS}
50
59
  activeId="calendars"
51
60
  title="Calendars"
52
- description="Add, rename and delete calendars, and share one with any client that can subscribe to a URL."
61
+ description="Add, rename and delete calendars, subscribe to a calendar feed, and share one with any client that can subscribe to a URL."
53
62
  help={calendarsHelp}
54
63
  helpOpen={helpOpen}
55
64
  onToggleHelp={() => setHelpOpen((open) => !open)}
@@ -68,14 +77,44 @@ function CalendarsSettings() {
68
77
  />
69
78
  ) : (
70
79
  <div className="space-y-4">
71
- {calendars.map((calendar) => (
72
- <CalendarSettingsPanel
73
- key={calendar.id}
74
- calendarId={calendar.id}
75
- calendarName={calendar.name}
76
- timezone={timeZoneByCalendarId[calendar.id] ?? ""}
77
- />
78
- ))}
80
+ {calendars.map((calendar) => {
81
+ const collection = collections.find(
82
+ (candidate) => candidate.calendarId === calendar.id,
83
+ );
84
+ return (
85
+ <CalendarSettingsPanel
86
+ key={calendar.id}
87
+ calendarId={calendar.id}
88
+ calendarName={calendar.name}
89
+ timezone={timeZoneByCalendarId[calendar.id] ?? ""}
90
+ subscription={
91
+ collection?.source === "Subscribed" && (
92
+ <CalendarSubscriptionStatus
93
+ calendarName={collection.displayName}
94
+ subscriptionUrl={collection.subscriptionUrl}
95
+ enabled={collection.subscriptionEnabled}
96
+ fetchedAt={collection.subscriptionFetchedAt}
97
+ error={collection.subscriptionError}
98
+ isBusy={subscriptions.togglingCalendarId === calendar.id}
99
+ actionError={
100
+ subscriptions.toggleFailure.calendarId === calendar.id
101
+ ? subscriptions.toggleFailure.error
102
+ : undefined
103
+ }
104
+ onSetEnabled={(enabled) =>
105
+ subscriptions.setEnabled(calendar.id, enabled)
106
+ }
107
+ />
108
+ )
109
+ }
110
+ />
111
+ );
112
+ })}
113
+ <CalendarSubscribeCard
114
+ isBusy={subscriptions.isSubscribing}
115
+ error={subscriptions.subscribeError}
116
+ onSubscribe={subscriptions.subscribe}
117
+ />
79
118
  <CalendarCreateCard
80
119
  isBusy={isWriting}
81
120
  problem={createProblem}