@remit/web-client 0.0.231 → 0.0.232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
  3. package/src/components/calendar/CalendarComposePane.tsx +83 -23
  4. package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
  5. package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
  6. package/src/components/calendar/CalendarEventPane.tsx +28 -5
  7. package/src/components/calendar/CalendarWorkspace.stories.tsx +22 -0
  8. package/src/components/calendar/CalendarWorkspace.tsx +31 -1
  9. package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
  10. package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
  11. package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
  12. package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
  13. package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
  14. package/src/hooks/calendar/draft.test.ts +241 -0
  15. package/src/hooks/calendar/draft.ts +168 -0
  16. package/src/hooks/calendar/index.ts +59 -0
  17. package/src/hooks/calendar/instance.ts +136 -0
  18. package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
  19. package/src/hooks/calendar/recurrence-rule.ts +133 -0
  20. package/src/hooks/calendar/resource.test.ts +98 -0
  21. package/src/hooks/calendar/resource.ts +74 -0
  22. package/src/hooks/calendar/useCalendarEvent.ts +46 -0
  23. package/src/hooks/calendar/useCalendarEvents.ts +124 -0
  24. package/src/hooks/calendar/useCalendarWrites.ts +174 -0
  25. package/src/hooks/calendar/useCalendars.ts +43 -0
  26. package/src/hooks/calendar/window.test.ts +148 -0
  27. package/src/hooks/calendar/window.ts +163 -0
  28. package/src/hooks/useCalendarData.ts +70 -22
  29. package/src/lib/calendar-route.ts +7 -3
  30. package/src/routes/calendar/$view.$date/new.tsx +2 -2
  31. package/src/routes/calendar/$view.$date.tsx +43 -15
  32. package/src/routing/calendar.ts +19 -8
  33. package/src/test-support/http.ts +8 -0
  34. package/src/lib/calendar-fixtures.ts +0 -207
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.231",
3
+ "version": "0.0.232",
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,25 +1,115 @@
1
+ import type { CalendarDescriptor, EventDraft } from "@remit/ui";
1
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { useState } from "react";
2
4
  import { CalendarComposePane } from "./CalendarComposePane";
3
5
 
4
6
  /**
5
- * `/calendar/{view}/{date}/new`. The address is real and the editor is not
6
- * written yet, so the pane says which it is and offers the way back — the one
7
- * thing worse than an unfinished surface is one that looks finished and does
8
- * nothing.
7
+ * `/calendar/{view}/{date}/new`, and the same form reopened over an event being
8
+ * edited.
9
+ *
10
+ * It holds no draft of its own, so a story drives it with local state and gets
11
+ * the surface the app renders. What the stories are for is what the markup
12
+ * cannot answer: whether a refusal reads as something to fix rather than as
13
+ * something broken, and whether an edit scoped to one occurrence makes clear
14
+ * that the rule is not up for changing.
9
15
  */
16
+ const calendars: CalendarDescriptor[] = [
17
+ {
18
+ id: "cal_work",
19
+ accountId: "acct_work",
20
+ accountLabel: "Work",
21
+ name: "Northwind",
22
+ color: "cal-1",
23
+ },
24
+ {
25
+ id: "cal_home",
26
+ accountId: "acct_work",
27
+ accountLabel: "Work",
28
+ name: "Personal",
29
+ color: "cal-4",
30
+ },
31
+ ];
32
+
33
+ const draft: EventDraft = {
34
+ title: "Roadmap review",
35
+ date: "2026-06-10",
36
+ startTime: "10:00",
37
+ endTime: "11:30",
38
+ allDay: false,
39
+ calendarId: "cal_work",
40
+ location: "Room Zuid",
41
+ guests: "",
42
+ notes: "",
43
+ repeat: "",
44
+ };
45
+
10
46
  const meta: Meta<typeof CalendarComposePane> = {
11
47
  title: "App/Calendar/Compose pane",
12
48
  component: CalendarComposePane,
13
49
  parameters: { layout: "fullscreen" },
14
- args: { onClose: () => {} },
15
- render: (args) => (
16
- <div className="h-dvh max-w-xl border-l border-line bg-canvas">
17
- <CalendarComposePane {...args} />
18
- </div>
19
- ),
50
+ args: {
51
+ title: "New event",
52
+ subtitle: "2026-06-10",
53
+ calendars,
54
+ draft,
55
+ problem: "",
56
+ saveLabel: "Add",
57
+ isSaving: false,
58
+ onSave: () => {},
59
+ onCancel: () => {},
60
+ },
61
+ render: function Render(args) {
62
+ const [current, setCurrent] = useState(args.draft);
63
+ return (
64
+ <div className="h-dvh max-w-2xl border-l border-line bg-canvas">
65
+ <CalendarComposePane {...args} draft={current} onChange={setCurrent} />
66
+ </div>
67
+ );
68
+ },
20
69
  };
21
70
  export default meta;
22
71
 
23
72
  type Story = StoryObj<typeof CalendarComposePane>;
24
73
 
25
- export const NotBuiltYet: Story = {};
74
+ export const NewEvent: Story = {};
75
+
76
+ /** A refusal says what was wrong and what to do, where the reader is looking. */
77
+ export const Refused: Story = {
78
+ args: {
79
+ draft: { ...draft, title: "" },
80
+ problem: "Give the event a title before saving it.",
81
+ },
82
+ };
83
+
84
+ /** The write is out; the button says so rather than looking unpressed. */
85
+ export const Saving: Story = { args: { isSaving: true } };
86
+
87
+ /**
88
+ * Editing one morning of a series. The rule belongs to the series, so it reads
89
+ * back rather than offering itself for change.
90
+ */
91
+ export const EditingOneOccurrence: Story = {
92
+ args: {
93
+ title: "Edit event",
94
+ subtitle: "This occurrence only",
95
+ saveLabel: "Save",
96
+ repeatEditable: false,
97
+ draft: {
98
+ ...draft,
99
+ title: "Standup",
100
+ startTime: "09:15",
101
+ endTime: "09:30",
102
+ repeat: "Every weekday, 09:15",
103
+ },
104
+ },
105
+ };
106
+
107
+ /** The version on screen was replaced elsewhere. Nothing is overwritten. */
108
+ export const Conflicted: Story = {
109
+ args: {
110
+ title: "Edit event",
111
+ saveLabel: "Save",
112
+ problem:
113
+ "This event changed somewhere else — over CalDAV, or in another tab. Nothing was saved. Close it and open it again to see the version that's stored now.",
114
+ },
115
+ };
@@ -1,34 +1,94 @@
1
- import { CalendarPlus } from "lucide-react";
1
+ import {
2
+ type CalendarDescriptor,
3
+ type EventDraft,
4
+ EventEditor,
5
+ EventEditorPane,
6
+ } from "@remit/ui";
7
+ import { AlertCircle } from "lucide-react";
2
8
 
3
9
  /**
4
- * Writing a new event.
10
+ * Writing an event, and editing one.
5
11
  *
6
- * A sibling of the open event rather than a child, so navigating here unmatches
7
- * whatever the calendar had open in the same transition and a composer over an
8
- * open event cannot be addressed — the move `compose` makes over a thread.
12
+ * Presentational, like the pane it sits beside: it holds no draft of its own
13
+ * and saves nothing. The route owns the draft and the write, so this renders
14
+ * the same form whichever of the two the address named.
9
15
  *
10
- * The editor itself needs somewhere to save to, so it arrives with the calendar
11
- * API. The route says that rather than rendering an empty pane.
16
+ * Guests are left out because the calendar store has nowhere to put them yet,
17
+ * which is the kit's default. A field that takes names and drops them is worse
18
+ * than no field: the reader only finds out afterwards, from an event with
19
+ * nobody on it.
12
20
  */
13
21
  export interface CalendarComposePaneProps {
14
- onClose: () => void;
22
+ /** What the pane is doing: "New event", "Edit event". */
23
+ title: string;
24
+ /** The one line of context the title cannot carry — which day, which series. */
25
+ subtitle?: string;
26
+ calendars: CalendarDescriptor[];
27
+ draft: EventDraft;
28
+ onChange: (draft: EventDraft) => void;
29
+ /**
30
+ * Why the last save did not happen, in the words the reader needs: what was
31
+ * refused and what to do about it. Empty when nothing has been refused.
32
+ */
33
+ problem: string;
34
+ saveLabel: string;
35
+ isSaving: boolean;
36
+ /**
37
+ * The rule belongs to the series rather than to one morning of it, so an
38
+ * edit scoped to a single occurrence reads it back instead of offering it.
39
+ */
40
+ repeatEditable?: boolean;
41
+ /**
42
+ * Whether the event can still be sent to a different calendar. Writing one
43
+ * chooses; editing one cannot, because the collection a resource lives in is
44
+ * part of its address and the patch has no field for it.
45
+ */
46
+ calendarEditable?: boolean;
47
+ onSave: () => void;
48
+ onCancel: () => void;
15
49
  }
16
50
 
17
- export function CalendarComposePane({ onClose }: CalendarComposePaneProps) {
51
+ export function CalendarComposePane({
52
+ title,
53
+ subtitle,
54
+ calendars,
55
+ draft,
56
+ onChange,
57
+ problem,
58
+ saveLabel,
59
+ isSaving,
60
+ repeatEditable = true,
61
+ calendarEditable = true,
62
+ onSave,
63
+ onCancel,
64
+ }: CalendarComposePaneProps) {
18
65
  return (
19
- <div className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center">
20
- <CalendarPlus className="size-8 text-fg-subtle" aria-hidden="true" />
21
- <p className="text-sm font-medium text-fg">Not built yet</p>
22
- <p className="max-w-xs text-sm text-fg-muted">
23
- Writing an event arrives with the calendar API.
24
- </p>
25
- <button
26
- type="button"
27
- onClick={onClose}
28
- 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"
29
- >
30
- Back to the calendar
31
- </button>
32
- </div>
66
+ <EventEditorPane title={title} subtitle={subtitle} onClose={onCancel}>
67
+ <EventEditor
68
+ draft={draft}
69
+ onChange={onChange}
70
+ calendars={calendars}
71
+ layout="pane"
72
+ repeatEditable={repeatEditable}
73
+ calendarEditable={calendarEditable}
74
+ saveLabel={isSaving ? "Saving…" : saveLabel}
75
+ onSave={onSave}
76
+ onCancel={onCancel}
77
+ header={
78
+ problem === "" ? undefined : (
79
+ <div
80
+ role="alert"
81
+ className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm"
82
+ >
83
+ <AlertCircle
84
+ className="mt-0.5 size-4 shrink-0 text-danger"
85
+ aria-hidden="true"
86
+ />
87
+ <p className="min-w-0 flex-1 break-words text-fg">{problem}</p>
88
+ </div>
89
+ )
90
+ }
91
+ />
92
+ </EventEditorPane>
33
93
  );
34
94
  }
@@ -0,0 +1,35 @@
1
+ import type { CalendarSlotPick } from "@remit/ui";
2
+ import { createContext, type ReactNode, useContext } from "react";
3
+
4
+ /**
5
+ * The slot a reader dragged out of the grid, handed to the composer the click
6
+ * opened.
7
+ *
8
+ * It is not in the address on purpose. What a half-written event says is not a
9
+ * fact about where the reader is, and a link to a composer pre-filled with
10
+ * somebody else's Tuesday afternoon is not an address worth being able to send.
11
+ * The composer opened without one starts on the day the calendar is showing.
12
+ *
13
+ * It lives in a module reached through the `@/` alias for the same reason
14
+ * `CalendarShell`'s context does: the generated route tree imports route files
15
+ * relatively, so a context declared inside one resolves to a second module
16
+ * instance and hands the consumer an empty default.
17
+ */
18
+ const CalendarComposeSeedCtx = createContext<CalendarSlotPick | undefined>(
19
+ undefined,
20
+ );
21
+
22
+ export const CalendarComposeSeedProvider = ({
23
+ pick,
24
+ children,
25
+ }: {
26
+ pick: CalendarSlotPick | undefined;
27
+ children?: ReactNode;
28
+ }) => (
29
+ <CalendarComposeSeedCtx.Provider value={pick}>
30
+ {children}
31
+ </CalendarComposeSeedCtx.Provider>
32
+ );
33
+
34
+ export const useCalendarComposeSeed = (): CalendarSlotPick | undefined =>
35
+ useContext(CalendarComposeSeedCtx);
@@ -47,7 +47,15 @@ const meta: Meta<typeof CalendarEventPane> = {
47
47
  title: "App/Calendar/Event pane",
48
48
  component: CalendarEventPane,
49
49
  parameters: { layout: "fullscreen" },
50
- args: { event, calendar, isOccurrence: false, onClose: () => {} },
50
+ args: {
51
+ event,
52
+ calendar,
53
+ isOccurrence: false,
54
+ problem: "",
55
+ onEdit: () => {},
56
+ onDelete: () => {},
57
+ onClose: () => {},
58
+ },
51
59
  render: (args) => (
52
60
  <div className="h-dvh max-w-xl border-l border-line bg-canvas">
53
61
  <CalendarEventPane {...args} />
@@ -79,3 +87,24 @@ export const Occurrence: Story = {
79
87
  export const NotOnThisWeek: Story = {
80
88
  args: { event: undefined, calendar: undefined },
81
89
  };
90
+
91
+ /**
92
+ * The event was replaced somewhere else — over CalDAV, or in another tab —
93
+ * between it being read and the write going out. Nothing was overwritten, and
94
+ * the pane says so where the reader is looking rather than anywhere else.
95
+ */
96
+ export const ChangedElsewhere: Story = {
97
+ args: {
98
+ problem:
99
+ "This event changed somewhere else — over CalDAV, or in another tab. Nothing was saved. Close it and open it again to see the version that's stored now.",
100
+ },
101
+ };
102
+
103
+ /**
104
+ * The resource has not been read yet, so there is no version to write against.
105
+ * The controls stay off until there is: a write with no etag is one that can
106
+ * quietly overwrite somebody.
107
+ */
108
+ export const NotWritableYet: Story = {
109
+ args: { onEdit: undefined, onDelete: undefined },
110
+ };
@@ -1,16 +1,15 @@
1
1
  import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
2
  import { EventDetail } from "@remit/ui";
3
- import { CalendarDays } from "lucide-react";
3
+ import { AlertCircle, CalendarDays } from "lucide-react";
4
4
  import { formatEventWhen } from "@/lib/calendar-format";
5
5
 
6
6
  /**
7
7
  * The event the address has open.
8
8
  *
9
9
  * Presentational, like the workspace beside it: the route resolves the event
10
- * and turns closing into a navigation, so this renders what it is handed and
11
- * nothing else. Editing and deleting arrive with the calendar API, and until
12
- * then the header offers neither — a control that leads nowhere is worse than
13
- * no control.
10
+ * and turns closing, editing and deleting into things it does, so this renders
11
+ * what it is handed and nothing else. Each control appears only when the route
12
+ * gave it somewhere to go — a control that leads nowhere is worse than none.
14
13
  */
15
14
  export interface CalendarEventPaneProps {
16
15
  /** Absent when the address names an event the calendar does not have. */
@@ -18,6 +17,13 @@ export interface CalendarEventPaneProps {
18
17
  calendar: CalendarDescriptor | undefined;
19
18
  /** Whether the address names one occurrence rather than the series. */
20
19
  isOccurrence: boolean;
20
+ /**
21
+ * A write that did not happen, stated where the reader is looking. Empty
22
+ * when the last one did.
23
+ */
24
+ problem?: string;
25
+ onEdit?: () => void;
26
+ onDelete?: () => void;
21
27
  onClose: () => void;
22
28
  }
23
29
 
@@ -25,6 +31,9 @@ export function CalendarEventPane({
25
31
  event,
26
32
  calendar,
27
33
  isOccurrence,
34
+ problem = "",
35
+ onEdit,
36
+ onDelete,
28
37
  onClose,
29
38
  }: CalendarEventPaneProps) {
30
39
  if (!event || !calendar) {
@@ -55,10 +64,24 @@ export function CalendarEventPane({
55
64
  One occurrence of a repeating event
56
65
  </p>
57
66
  )}
67
+ {problem !== "" && (
68
+ <div
69
+ role="alert"
70
+ className="flex shrink-0 items-start gap-2 border-b border-danger/40 bg-danger-soft px-row-inset py-2 text-sm"
71
+ >
72
+ <AlertCircle
73
+ className="mt-0.5 size-4 shrink-0 text-danger"
74
+ aria-hidden="true"
75
+ />
76
+ <p className="min-w-0 flex-1 break-words text-fg">{problem}</p>
77
+ </div>
78
+ )}
58
79
  <EventDetail
59
80
  event={event}
60
81
  calendar={calendar}
61
82
  whenText={formatEventWhen(event)}
83
+ onEdit={onEdit}
84
+ onDelete={onDelete}
62
85
  onClose={onClose}
63
86
  className="min-h-0 flex-1"
64
87
  />
@@ -106,9 +106,13 @@ const week: CalendarEventData[] = [
106
106
  function Workspace({
107
107
  initialView,
108
108
  events,
109
+ isLoading,
110
+ error,
109
111
  }: {
110
112
  initialView: CalendarViewId;
111
113
  events: CalendarEventData[];
114
+ isLoading?: boolean;
115
+ error?: unknown;
112
116
  }) {
113
117
  const [view, setView] = useState<CalendarViewId>(initialView);
114
118
  const [density, setDensity] = useState<Density>("comfortable");
@@ -119,6 +123,9 @@ function Workspace({
119
123
  date={DATE}
120
124
  events={events}
121
125
  colorByCalendarId={colorByCalendarId}
126
+ isLoading={isLoading}
127
+ error={error}
128
+ onRetry={() => undefined}
122
129
  density={density}
123
130
  selectedEventId={selected}
124
131
  timeZone={TIME_ZONE}
@@ -155,6 +162,21 @@ export const Day: Story = { args: { initialView: "day" } };
155
162
  /** Nothing scheduled is still a grid, never a "nothing here" surface. */
156
163
  export const EmptyWeek: Story = { args: { events: [] } };
157
164
 
165
+ /** The first read of a week, before the server has answered. */
166
+ export const Loading: Story = { args: { events: [], isLoading: true } };
167
+
168
+ /**
169
+ * The read was refused. This is the state an empty grid must never be confused
170
+ * with: a week drawn blank because the request failed reads as a week with
171
+ * nothing in it, and the reader plans their day around it.
172
+ */
173
+ export const CouldNotLoad: Story = {
174
+ args: {
175
+ events: [],
176
+ error: new Error("The window has to be shorter than a year."),
177
+ },
178
+ };
179
+
158
180
  /**
159
181
  * The three zooms that are named but not drawn yet. Each says what it is
160
182
  * waiting on, so the route is addressable without pretending to be finished.
@@ -9,9 +9,11 @@ import {
9
9
  type Density,
10
10
  segmentClassName,
11
11
  } from "@remit/ui";
12
+ import { Loader2 } from "lucide-react";
12
13
  import { useId, useState } from "react";
13
14
  import { CalendarViewPlaceholder } from "@/components/calendar/CalendarViewPlaceholder";
14
15
  import { NavMenuButton } from "@/components/mail/NavMenuButton";
16
+ import { ErrorState } from "@/components/ui/ErrorState";
15
17
  import { calendarViewMountsGrid } from "@/lib/calendar-route";
16
18
 
17
19
  /**
@@ -36,6 +38,15 @@ export interface CalendarWorkspaceProps {
36
38
  date: string;
37
39
  events: CalendarEventData[];
38
40
  colorByCalendarId: Record<string, CalendarColorId>;
41
+ /** The first read of this window is still out; the grid has nothing yet. */
42
+ isLoading?: boolean;
43
+ /**
44
+ * A refusal the calendar states in place of the grid. A week drawn empty
45
+ * because the read failed is indistinguishable from a week with nothing in
46
+ * it, which is the one thing this must never look like.
47
+ */
48
+ error?: unknown;
49
+ onRetry?: () => void;
39
50
  density: Density;
40
51
  /** The event the reading pane has open, empty for none. */
41
52
  selectedEventId: string;
@@ -78,6 +89,9 @@ export function CalendarWorkspace({
78
89
  date,
79
90
  events,
80
91
  colorByCalendarId,
92
+ isLoading = false,
93
+ error,
94
+ onRetry,
81
95
  density,
82
96
  selectedEventId,
83
97
  timeZone,
@@ -137,7 +151,23 @@ export function CalendarWorkspace({
137
151
  </div>
138
152
 
139
153
  <div className="min-h-0 flex-1">
140
- {calendarViewMountsGrid(view) ? (
154
+ {error !== undefined && error !== null ? (
155
+ <div className="flex h-full items-center justify-center">
156
+ <ErrorState
157
+ title="Couldn't load this week"
158
+ error={error}
159
+ onRetry={onRetry}
160
+ />
161
+ </div>
162
+ ) : isLoading ? (
163
+ <div
164
+ role="status"
165
+ aria-label="Loading the calendar"
166
+ className="flex h-full items-center justify-center bg-surface"
167
+ >
168
+ <Loader2 className="size-6 animate-spin text-fg-subtle" />
169
+ </div>
170
+ ) : calendarViewMountsGrid(view) ? (
141
171
  <CalendarGrid
142
172
  view={view}
143
173
  date={date}