@remit/web-client 0.0.302 → 0.0.304

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.302",
3
+ "version": "0.0.304",
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": {
@@ -104,6 +104,57 @@ export const NotOnThisWeek: Story = {
104
104
  args: { event: undefined, calendar: undefined },
105
105
  };
106
106
 
107
+ /** Looking for the day an event the view does not hold falls on. */
108
+ export const Finding: Story = {
109
+ args: { event: undefined, calendar: undefined, absence: { kind: "Finding" } },
110
+ };
111
+
112
+ /** The address names a resource none of the reader's calendars holds. */
113
+ export const Deleted: Story = {
114
+ args: { event: undefined, calendar: undefined, absence: { kind: "Deleted" } },
115
+ };
116
+
117
+ /** A series that falls on no day in its first year or the coming one. */
118
+ export const NoOccurrenceFound: Story = {
119
+ args: {
120
+ event: undefined,
121
+ calendar: undefined,
122
+ absence: { kind: "NoOccurrenceFound" },
123
+ },
124
+ };
125
+
126
+ /** The session ended during the look-up; the way back in is on the pane. */
127
+ export const SignedOut: Story = {
128
+ args: {
129
+ event: undefined,
130
+ calendar: undefined,
131
+ absence: {
132
+ kind: "SignedOut",
133
+ signIn: (
134
+ <button
135
+ type="button"
136
+ className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg"
137
+ >
138
+ Sign in again
139
+ </button>
140
+ ),
141
+ },
142
+ },
143
+ };
144
+
145
+ /** The look-up failed, with the server's reason and a way to report it. */
146
+ export const LookupFailed: Story = {
147
+ args: {
148
+ event: undefined,
149
+ calendar: undefined,
150
+ absence: {
151
+ kind: "Failed",
152
+ reason: "Internal server error (HTTP 500)",
153
+ reportHref: "https://github.com/remit-mail/reader/issues/new",
154
+ },
155
+ },
156
+ };
157
+
107
158
  /**
108
159
  * The event was replaced somewhere else — over CalDAV, or in another tab —
109
160
  * between it being read and the write going out. Nothing was overwritten, and
@@ -1,8 +1,61 @@
1
1
  import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
2
  import { EventDetail } from "@remit/ui";
3
3
  import { AlertCircle, CalendarDays } from "lucide-react";
4
+ import type { ReactNode } from "react";
4
5
  import { formatEventWhen } from "@/lib/calendar-format";
5
6
 
7
+ export type CalendarEventAbsence =
8
+ | { kind: "NotInView" }
9
+ | { kind: "Finding" }
10
+ | { kind: "Deleted" }
11
+ | { kind: "NoOccurrenceFound" }
12
+ | { kind: "SignedOut"; signIn: ReactNode }
13
+ | { kind: "Failed"; reason: string; reportHref: string };
14
+
15
+ const NOT_IN_VIEW: CalendarEventAbsence = { kind: "NotInView" };
16
+
17
+ const LINK =
18
+ "rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring";
19
+
20
+ function absenceText(absence: CalendarEventAbsence): {
21
+ title: string;
22
+ detail: string;
23
+ } {
24
+ switch (absence.kind) {
25
+ case "Finding":
26
+ return {
27
+ title: "Finding the event",
28
+ detail: "Looking for the day it falls on.",
29
+ };
30
+ case "Deleted":
31
+ return {
32
+ title: "This event was deleted",
33
+ detail: "None of your calendars holds it any more.",
34
+ };
35
+ case "NoOccurrenceFound":
36
+ return {
37
+ title: "No occurrence in its first year or the coming year",
38
+ detail:
39
+ "Its repeat rule, or the days taken out of it, leave it no day in either.",
40
+ };
41
+ case "SignedOut":
42
+ return {
43
+ title: "Your session has ended",
44
+ detail: "Sign in again to open this event.",
45
+ };
46
+ case "Failed":
47
+ return {
48
+ title: "The event couldn't be looked up",
49
+ detail: absence.reason,
50
+ };
51
+ case "NotInView":
52
+ return {
53
+ title: "That event isn't on this week",
54
+ detail: "The address names an event the calendar doesn't have here.",
55
+ };
56
+ }
57
+ }
58
+
6
59
  /**
7
60
  * The event the address has open.
8
61
  *
@@ -17,6 +70,7 @@ export interface CalendarEventPaneProps {
17
70
  calendar: CalendarDescriptor | undefined;
18
71
  /** Whether the address names one occurrence rather than the series. */
19
72
  isOccurrence: boolean;
73
+ absence?: CalendarEventAbsence;
20
74
  /**
21
75
  * A write that did not happen, stated where the reader is looking. Empty
22
76
  * when the last one did.
@@ -31,28 +85,39 @@ export function CalendarEventPane({
31
85
  event,
32
86
  calendar,
33
87
  isOccurrence,
88
+ absence = NOT_IN_VIEW,
34
89
  problem = "",
35
90
  onEdit,
36
91
  onDelete,
37
92
  onClose,
38
93
  }: CalendarEventPaneProps) {
39
94
  if (!event || !calendar) {
95
+ const { title, detail } = absenceText(absence);
96
+ const loud = absence.kind === "Failed" || absence.kind === "SignedOut";
40
97
  return (
41
- <div className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center">
98
+ <div
99
+ role={loud ? "alert" : undefined}
100
+ className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center"
101
+ >
42
102
  <CalendarDays className="size-8 text-fg-subtle" aria-hidden="true" />
43
- <p className="text-sm font-medium text-fg">
44
- That event isn't on this week
45
- </p>
46
- <p className="max-w-xs text-sm text-fg-muted">
47
- The address names an event the calendar doesn't have here.
48
- </p>
49
- <button
50
- type="button"
51
- onClick={onClose}
52
- className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring"
53
- >
54
- Back to the calendar
55
- </button>
103
+ <p className="text-sm font-medium text-fg">{title}</p>
104
+ <p className="max-w-xs break-words text-sm text-fg-muted">{detail}</p>
105
+ <div className="flex flex-wrap items-center justify-center gap-2">
106
+ {absence.kind === "SignedOut" && absence.signIn}
107
+ {absence.kind === "Failed" && (
108
+ <a
109
+ href={absence.reportHref}
110
+ target="_blank"
111
+ rel="noreferrer"
112
+ className={LINK}
113
+ >
114
+ Report an issue
115
+ </a>
116
+ )}
117
+ <button type="button" onClick={onClose} className={LINK}>
118
+ Back to the calendar
119
+ </button>
120
+ </div>
56
121
  </div>
57
122
  );
58
123
  }
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { expect, userEvent, within } from "storybook/test";
3
+ import { useOpenCalendarEvent } from "@/routing";
3
4
  import {
4
5
  calendars,
5
6
  fortnight,
@@ -44,6 +45,17 @@ const answering = (
44
45
  };
45
46
  };
46
47
 
48
+ /** The pane with its props read off the address, as the app's routes hand them. */
49
+ function FromAddress() {
50
+ const opened = useOpenCalendarEvent();
51
+ return (
52
+ <OpenCalendarEvent
53
+ calendarObjectId={opened?.calendarObjectId ?? ""}
54
+ recurrenceId={opened?.recurrenceId}
55
+ />
56
+ );
57
+ }
58
+
47
59
  function Opened({
48
60
  calendarObjectId,
49
61
  recurrenceId,
@@ -59,16 +71,7 @@ function Opened({
59
71
  : `${STORY_WEEK}/${calendarObjectId}/${recurrenceId}`;
60
72
  return (
61
73
  <div className="h-dvh max-w-xl border-l border-line bg-canvas">
62
- <CalendarStory
63
- entry={address}
64
- server={server}
65
- pane={
66
- <OpenCalendarEvent
67
- calendarObjectId={calendarObjectId}
68
- recurrenceId={recurrenceId}
69
- />
70
- }
71
- />
74
+ <CalendarStory entry={address} server={server} pane={<FromAddress />} />
72
75
  </div>
73
76
  );
74
77
  }
@@ -130,6 +133,121 @@ export const RecurringScopePrompt: Story = {
130
133
  },
131
134
  };
132
135
 
136
+ /**
137
+ * A series opened by its plain address lands on an occurrence, so an edit asks
138
+ * which occurrences it means exactly as it does from one.
139
+ */
140
+ export const SeriesFromItsPlainAddress: Story = {
141
+ args: { calendarObjectId: STANDUP_OBJECT },
142
+ play: async ({ canvasElement }) => {
143
+ const canvas = within(canvasElement);
144
+ await expect(
145
+ await canvas.findByText("One occurrence of a repeating event"),
146
+ ).toBeVisible();
147
+ await userEvent.click(await canvas.findByRole("button", { name: "Edit" }));
148
+ await expect(
149
+ await canvas.findByRole("button", { name: /The whole series/ }),
150
+ ).toBeVisible();
151
+ },
152
+ };
153
+
154
+ /** A series that no longer falls on any day says so instead of drawing nothing. */
155
+ export const SeriesWithNoOccurrences: Story = {
156
+ args: {
157
+ calendarObjectId: STANDUP_OBJECT,
158
+ server: answering((request) =>
159
+ new URL(request.url).pathname.endsWith("/calendar-events")
160
+ ? json({ items: [] })
161
+ : undefined,
162
+ ),
163
+ },
164
+ play: async ({ canvasElement }) => {
165
+ const canvas = within(canvasElement);
166
+ await expect(
167
+ await canvas.findByText(
168
+ "No occurrence in its first year or the coming year",
169
+ ),
170
+ ).toBeVisible();
171
+ },
172
+ };
173
+
174
+ /**
175
+ * A week the standup is not on, and the look-up for it answered by `lookUp`:
176
+ * the listing for the one resource, and the resource read with no calendar.
177
+ */
178
+ const lookingUp =
179
+ (lookUp: (url: URL) => Response): CalendarServer =>
180
+ (request) => {
181
+ const url = new URL(request.url);
182
+ if (url.searchParams.has("calendarObjectId")) return lookUp(url);
183
+ if (url.pathname.endsWith("/calendar-events"))
184
+ return json({
185
+ items: instancesWithin(
186
+ url,
187
+ fortnight.filter(
188
+ (instance) => instance.calendarObjectId !== STANDUP_OBJECT,
189
+ ),
190
+ ),
191
+ });
192
+ if (
193
+ url.pathname.includes(STANDUP_OBJECT) &&
194
+ !url.searchParams.has("calendarId")
195
+ )
196
+ return lookUp(url);
197
+ return answering()(request);
198
+ };
199
+
200
+ /** A resource none of the reader's calendars holds any more. */
201
+ export const SeriesDeleted: Story = {
202
+ args: {
203
+ calendarObjectId: STANDUP_OBJECT,
204
+ server: lookingUp((url) =>
205
+ url.pathname.endsWith("/calendar-events")
206
+ ? json({ items: [] })
207
+ : json({ message: "no event on this account" }, 404),
208
+ ),
209
+ },
210
+ play: async ({ canvasElement }) => {
211
+ const canvas = within(canvasElement);
212
+ await expect(
213
+ await canvas.findByText("This event was deleted"),
214
+ ).toBeVisible();
215
+ },
216
+ };
217
+
218
+ /** A failed look-up says why and offers a report, never "not on this week". */
219
+ export const LookupFailed: Story = {
220
+ args: {
221
+ calendarObjectId: STANDUP_OBJECT,
222
+ server: lookingUp(() =>
223
+ json({ message: "The calendar store is unavailable." }, 500),
224
+ ),
225
+ },
226
+ play: async ({ canvasElement }) => {
227
+ const canvas = within(canvasElement);
228
+ await expect(
229
+ await canvas.findByText("The event couldn't be looked up"),
230
+ ).toBeVisible();
231
+ await expect(
232
+ canvas.getByRole("link", { name: "Report an issue" }),
233
+ ).toBeVisible();
234
+ },
235
+ };
236
+
237
+ /** A session that ended during the look-up is said as such. */
238
+ export const SignedOutDuringLookup: Story = {
239
+ args: {
240
+ calendarObjectId: STANDUP_OBJECT,
241
+ server: lookingUp(() => json({ message: "Session expired." }, 401)),
242
+ },
243
+ play: async ({ canvasElement }) => {
244
+ const canvas = within(canvasElement);
245
+ await expect(
246
+ await canvas.findByText("Your session has ended"),
247
+ ).toBeVisible();
248
+ },
249
+ };
250
+
133
251
  /**
134
252
  * Somebody replaced the event between it being read and the delete going out —
135
253
  * over CalDAV, or in another tab. Nothing was removed, and the pane says so
@@ -3,10 +3,16 @@ import {
3
3
  type RecurrenceScope,
4
4
  RecurrenceScopePrompt,
5
5
  } from "@remit/ui";
6
+ import { Navigate } from "@tanstack/react-router";
6
7
  import { useState } from "react";
8
+ import { useAuthProvider } from "@/auth/provider";
7
9
  import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
8
- import { CalendarEventPane } from "@/components/calendar/CalendarEventPane";
9
10
  import {
11
+ type CalendarEventAbsence,
12
+ CalendarEventPane,
13
+ } from "@/components/calendar/CalendarEventPane";
14
+ import {
15
+ type CalendarJumpSearch,
10
16
  type CalendarWriteOutcome,
11
17
  calendarInstanceId,
12
18
  deviceTimeZone,
@@ -17,17 +23,21 @@ import {
17
23
  patchFromDrafts,
18
24
  rruleFromIcalData,
19
25
  type ScopedWrite,
26
+ seriesOccurrenceOf,
20
27
  storedAnchorZone,
21
28
  textFromIcalData,
22
29
  textFromRrule,
23
30
  UNZONED_CALENDAR,
24
31
  useCalendarEvent,
32
+ useCalendarJump,
25
33
  useCalendars,
26
34
  useCalendarWrites,
27
35
  useDraftClashes,
28
36
  } from "@/hooks/calendar";
29
37
  import { useCalendarData } from "@/hooks/useCalendarData";
30
38
  import { formatEventWhen } from "@/lib/calendar-format";
39
+ import { calendarEventReportHref } from "@/lib/calendar-report";
40
+ import type { CalendarSearch } from "@/lib/calendar-route";
31
41
  import { useCalendarAddress, useCalendarNavigation } from "@/routing";
32
42
 
33
43
  /**
@@ -55,12 +65,54 @@ const CONFLICT =
55
65
 
56
66
  type Intent = "edit" | "delete";
57
67
 
68
+ function SignInAgain() {
69
+ const { Account } = useAuthProvider();
70
+ return (
71
+ <Account>
72
+ {({ signOut }) => (
73
+ <button
74
+ type="button"
75
+ onClick={() => signOut()}
76
+ className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring"
77
+ >
78
+ Sign in again
79
+ </button>
80
+ )}
81
+ </Account>
82
+ );
83
+ }
84
+
85
+ const absenceOf = (
86
+ search: CalendarJumpSearch,
87
+ loading: boolean,
88
+ ): CalendarEventAbsence => {
89
+ switch (search.kind) {
90
+ case "Deleted":
91
+ case "NoOccurrenceFound":
92
+ return { kind: search.kind };
93
+ case "SignedOut":
94
+ return { kind: "SignedOut", signIn: <SignInAgain /> };
95
+ case "Failed":
96
+ return {
97
+ kind: "Failed",
98
+ reason: search.reason,
99
+ reportHref: calendarEventReportHref(
100
+ `opening an event failed: ${search.reason}`,
101
+ ),
102
+ };
103
+ case "Searching":
104
+ return { kind: "Finding" };
105
+ default:
106
+ return { kind: loading ? "Finding" : "NotInView" };
107
+ }
108
+ };
109
+
58
110
  export function OpenCalendarEvent({
59
111
  calendarObjectId,
60
112
  recurrenceId,
61
113
  }: OpenCalendarEventProps) {
62
114
  const { view, date, calendarIds } = useCalendarAddress();
63
- const { events } = useCalendarData({ view, date, calendarIds });
115
+ const { events, isLoading } = useCalendarData({ view, date, calendarIds });
64
116
  const { calendars, timeZoneByCalendarId } = useCalendars();
65
117
  const { closeEvent } = useCalendarNavigation();
66
118
  const { updateEvent, deleteEvent, isWriting } = useCalendarWrites();
@@ -69,6 +121,14 @@ export function OpenCalendarEvent({
69
121
  (candidate) =>
70
122
  candidate.id === calendarInstanceId(calendarObjectId, recurrenceId ?? ""),
71
123
  );
124
+ const seriesOnly = recurrenceId === undefined && event === undefined;
125
+ const occurrence = seriesOnly
126
+ ? seriesOccurrenceOf(events, calendarObjectId, new Date().toISOString())
127
+ : undefined;
128
+ const search = useCalendarJump(
129
+ calendarObjectId,
130
+ seriesOnly && !isLoading && occurrence === undefined,
131
+ );
72
132
  const calendar = calendars.find(
73
133
  (candidate) => candidate.id === event?.calendarId,
74
134
  );
@@ -175,6 +235,59 @@ export function OpenCalendarEvent({
175
235
  );
176
236
  };
177
237
 
238
+ if (occurrence) {
239
+ return (
240
+ <Navigate
241
+ to="/calendar/$view/$date/$calendarObjectId/$recurrenceId"
242
+ params={{
243
+ view,
244
+ date,
245
+ calendarObjectId,
246
+ recurrenceId: occurrence.recurrenceId,
247
+ }}
248
+ search={true}
249
+ hash={true}
250
+ replace
251
+ />
252
+ );
253
+ }
254
+
255
+ const jump = search.kind === "Found" ? search.jump : undefined;
256
+ const hidden =
257
+ jump !== undefined &&
258
+ calendarIds.length > 0 &&
259
+ !calendarIds.includes(jump.calendarId);
260
+ if (jump && (jump.date !== date || hidden)) {
261
+ const shown = hidden
262
+ ? (previous: CalendarSearch) => ({
263
+ ...previous,
264
+ calendarId: [...calendarIds, jump.calendarId],
265
+ })
266
+ : true;
267
+ return jump.recurrenceId === "" ? (
268
+ <Navigate
269
+ to="/calendar/$view/$date/$calendarObjectId"
270
+ params={{ view, date: jump.date, calendarObjectId }}
271
+ search={shown}
272
+ hash={true}
273
+ replace
274
+ />
275
+ ) : (
276
+ <Navigate
277
+ to="/calendar/$view/$date/$calendarObjectId/$recurrenceId"
278
+ params={{
279
+ view,
280
+ date: jump.date,
281
+ calendarObjectId,
282
+ recurrenceId: jump.recurrenceId,
283
+ }}
284
+ search={shown}
285
+ hash={true}
286
+ replace
287
+ />
288
+ );
289
+ }
290
+
178
291
  if (asking !== undefined && event) {
179
292
  return (
180
293
  <div className="flex h-full flex-col justify-center bg-surface px-row-inset">
@@ -228,6 +341,7 @@ export function OpenCalendarEvent({
228
341
  }
229
342
  calendar={calendar}
230
343
  isOccurrence={recurrenceId !== undefined}
344
+ absence={absenceOf(search, seriesOnly && isLoading)}
231
345
  problem={problem}
232
346
  onEdit={
233
347
  writable
@@ -106,6 +106,7 @@ export const standupResource = {
106
106
  calendarObjectId: STANDUP_OBJECT,
107
107
  resourceName: "standup.ics",
108
108
  icalUid: "uid-standup",
109
+ dtStart: "2026-06-10T09:15:00+02:00",
109
110
  icalData: [
110
111
  "BEGIN:VCALENDAR",
111
112
  "BEGIN:VEVENT",