@remit/web-client 0.0.231 → 0.0.233
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/calendar/AgendaStrip.stories.tsx +263 -0
- package/src/components/calendar/AgendaStrip.tsx +145 -0
- package/src/components/calendar/AgendaView.tsx +222 -0
- package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
- package/src/components/calendar/CalendarComposePane.tsx +83 -23
- package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
- package/src/components/calendar/CalendarEventPane.tsx +28 -5
- package/src/components/calendar/CalendarViewPlaceholder.tsx +3 -4
- package/src/components/calendar/CalendarWorkspace.stories.tsx +34 -1
- package/src/components/calendar/CalendarWorkspace.tsx +71 -23
- package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
- package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
- package/src/components/calendar/agenda-address.render.test.ts +389 -0
- package/src/components/calendar/agenda-strip.render.test.ts +241 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +13 -6
- package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
- package/src/hooks/calendar/agenda-window.test.ts +235 -0
- package/src/hooks/calendar/agenda-window.ts +183 -0
- package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
- package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
- package/src/hooks/calendar/draft.test.ts +241 -0
- package/src/hooks/calendar/draft.ts +168 -0
- package/src/hooks/calendar/index.ts +88 -0
- package/src/hooks/calendar/instance.ts +136 -0
- package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
- package/src/hooks/calendar/recurrence-rule.ts +133 -0
- package/src/hooks/calendar/resource.test.ts +98 -0
- package/src/hooks/calendar/resource.ts +74 -0
- package/src/hooks/calendar/selection.ts +107 -0
- package/src/hooks/calendar/useCalendarEvent.ts +46 -0
- package/src/hooks/calendar/useCalendarEvents.ts +195 -0
- package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -0
- package/src/hooks/calendar/useCalendarWrites.ts +174 -0
- package/src/hooks/calendar/useCalendars.ts +43 -0
- package/src/hooks/calendar/window.test.ts +148 -0
- package/src/hooks/calendar/window.ts +168 -0
- package/src/hooks/useCalendarData.ts +62 -40
- package/src/lib/calendar-density.ts +13 -1
- package/src/lib/calendar-route.test.ts +20 -1
- package/src/lib/calendar-route.ts +21 -7
- package/src/routes/calendar/$view.$date/new.tsx +2 -2
- package/src/routes/calendar/$view.$date.tsx +51 -15
- package/src/routing/calendar.ts +32 -4
- package/src/test-support/http.ts +8 -0
- package/src/lib/calendar-fixtures.ts +0 -207
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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: {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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 {
|
|
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
|
|
10
|
+
* Writing an event, and editing one.
|
|
5
11
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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({
|
|
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
|
-
<
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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: {
|
|
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
|
|
11
|
-
* nothing else.
|
|
12
|
-
*
|
|
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
|
/>
|
|
@@ -4,14 +4,13 @@ import { CalendarRange } from "lucide-react";
|
|
|
4
4
|
/**
|
|
5
5
|
* A zoom level that is named but not drawn yet.
|
|
6
6
|
*
|
|
7
|
-
* The ladder is five steps and
|
|
7
|
+
* The ladder is five steps and two of them arrive later, so the route stays
|
|
8
8
|
* addressable and says what it is waiting on. A view that silently rendered
|
|
9
9
|
* nothing would be indistinguishable from a week with nothing in it.
|
|
10
10
|
*/
|
|
11
11
|
const WAITING_ON: Record<string, string> = {
|
|
12
12
|
year: "The year grid arrives with the rest of the zoom ladder.",
|
|
13
13
|
month: "The month grid arrives with the rest of the zoom ladder.",
|
|
14
|
-
agenda: "The agenda arrives with the scrolling day strip.",
|
|
15
14
|
};
|
|
16
15
|
|
|
17
16
|
export interface CalendarViewPlaceholderProps {
|
|
@@ -29,8 +28,8 @@ export function CalendarViewPlaceholder({
|
|
|
29
28
|
<CalendarRange className="size-8 text-fg-subtle" aria-hidden="true" />
|
|
30
29
|
<p className="text-sm font-medium text-fg">Not built yet</p>
|
|
31
30
|
<p className="max-w-xs text-sm text-fg-muted">
|
|
32
|
-
{WAITING_ON[view] ?? "This view arrives in a later stage."} Week
|
|
33
|
-
work now.
|
|
31
|
+
{WAITING_ON[view] ?? "This view arrives in a later stage."} Week, Day
|
|
32
|
+
and Agenda work now.
|
|
34
33
|
</p>
|
|
35
34
|
</div>
|
|
36
35
|
);
|
|
@@ -18,6 +18,9 @@ import { CalendarWorkspace } from "./CalendarWorkspace";
|
|
|
18
18
|
* state and get the same surface the app renders. What the stories are for is
|
|
19
19
|
* what the markup cannot answer — how a week reads once a day is full, and
|
|
20
20
|
* whether a zoom that is not drawn yet reads as unfinished rather than empty.
|
|
21
|
+
*
|
|
22
|
+
* The strip is a slot rather than something this pane builds, so it is storied
|
|
23
|
+
* under App/Calendar/Agenda and stood in for here.
|
|
21
24
|
*/
|
|
22
25
|
const TIME_ZONE = "Europe/Amsterdam";
|
|
23
26
|
const DATE = "2026-06-10";
|
|
@@ -106,9 +109,13 @@ const week: CalendarEventData[] = [
|
|
|
106
109
|
function Workspace({
|
|
107
110
|
initialView,
|
|
108
111
|
events,
|
|
112
|
+
isLoading,
|
|
113
|
+
error,
|
|
109
114
|
}: {
|
|
110
115
|
initialView: CalendarViewId;
|
|
111
116
|
events: CalendarEventData[];
|
|
117
|
+
isLoading?: boolean;
|
|
118
|
+
error?: unknown;
|
|
112
119
|
}) {
|
|
113
120
|
const [view, setView] = useState<CalendarViewId>(initialView);
|
|
114
121
|
const [density, setDensity] = useState<Density>("comfortable");
|
|
@@ -119,6 +126,16 @@ function Workspace({
|
|
|
119
126
|
date={DATE}
|
|
120
127
|
events={events}
|
|
121
128
|
colorByCalendarId={colorByCalendarId}
|
|
129
|
+
// The strip loads its own days and is storied on its own; here it
|
|
130
|
+
// stands in for whatever the route mounts at that zoom.
|
|
131
|
+
agenda={
|
|
132
|
+
<div className="p-4 text-sm text-fg-muted">
|
|
133
|
+
The strip, which App/Calendar/Agenda has to itself.
|
|
134
|
+
</div>
|
|
135
|
+
}
|
|
136
|
+
isLoading={isLoading}
|
|
137
|
+
error={error}
|
|
138
|
+
onRetry={() => undefined}
|
|
122
139
|
density={density}
|
|
123
140
|
selectedEventId={selected}
|
|
124
141
|
timeZone={TIME_ZONE}
|
|
@@ -155,6 +172,21 @@ export const Day: Story = { args: { initialView: "day" } };
|
|
|
155
172
|
/** Nothing scheduled is still a grid, never a "nothing here" surface. */
|
|
156
173
|
export const EmptyWeek: Story = { args: { events: [] } };
|
|
157
174
|
|
|
175
|
+
/** The first read of a week, before the server has answered. */
|
|
176
|
+
export const Loading: Story = { args: { events: [], isLoading: true } };
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The read was refused. This is the state an empty grid must never be confused
|
|
180
|
+
* with: a week drawn blank because the request failed reads as a week with
|
|
181
|
+
* nothing in it, and the reader plans their day around it.
|
|
182
|
+
*/
|
|
183
|
+
export const CouldNotLoad: Story = {
|
|
184
|
+
args: {
|
|
185
|
+
events: [],
|
|
186
|
+
error: new Error("The window has to be shorter than a year."),
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
158
190
|
/**
|
|
159
191
|
* The three zooms that are named but not drawn yet. Each says what it is
|
|
160
192
|
* waiting on, so the route is addressable without pretending to be finished.
|
|
@@ -163,7 +195,8 @@ export const YearNotBuiltYet: Story = { args: { initialView: "year" } };
|
|
|
163
195
|
|
|
164
196
|
export const MonthNotBuiltYet: Story = { args: { initialView: "month" } };
|
|
165
197
|
|
|
166
|
-
|
|
198
|
+
/** The zoom that mounts the strip instead of the grid. */
|
|
199
|
+
export const Agenda: Story = { args: { initialView: "agenda" } };
|
|
167
200
|
|
|
168
201
|
/**
|
|
169
202
|
* The calendar beside the nav it is reached from. The entry sits under the
|
|
@@ -9,10 +9,15 @@ import {
|
|
|
9
9
|
type Density,
|
|
10
10
|
segmentClassName,
|
|
11
11
|
} from "@remit/ui";
|
|
12
|
-
import {
|
|
12
|
+
import { Loader2 } from "lucide-react";
|
|
13
|
+
import { type ReactNode, useId, useState } from "react";
|
|
13
14
|
import { CalendarViewPlaceholder } from "@/components/calendar/CalendarViewPlaceholder";
|
|
14
15
|
import { NavMenuButton } from "@/components/mail/NavMenuButton";
|
|
15
|
-
import {
|
|
16
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
17
|
+
import {
|
|
18
|
+
calendarViewMountsAgenda,
|
|
19
|
+
calendarViewMountsGrid,
|
|
20
|
+
} from "@/lib/calendar-route";
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
23
|
* The calendar's own pane: the toolbar that moves it and whatever the current
|
|
@@ -36,6 +41,21 @@ export interface CalendarWorkspaceProps {
|
|
|
36
41
|
date: string;
|
|
37
42
|
events: CalendarEventData[];
|
|
38
43
|
colorByCalendarId: Record<string, CalendarColorId>;
|
|
44
|
+
/**
|
|
45
|
+
* The strip, at the one zoom that draws it. A slot rather than something
|
|
46
|
+
* built here because it loads its own days over a rolling range, and this
|
|
47
|
+
* pane holds no events and knows no router.
|
|
48
|
+
*/
|
|
49
|
+
agenda: ReactNode;
|
|
50
|
+
/** The first read of this window is still out; the grid has nothing yet. */
|
|
51
|
+
isLoading?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* A refusal the calendar states in place of the grid. A week drawn empty
|
|
54
|
+
* because the read failed is indistinguishable from a week with nothing in
|
|
55
|
+
* it, which is the one thing this must never look like.
|
|
56
|
+
*/
|
|
57
|
+
error?: unknown;
|
|
58
|
+
onRetry?: () => void;
|
|
39
59
|
density: Density;
|
|
40
60
|
/** The event the reading pane has open, empty for none. */
|
|
41
61
|
selectedEventId: string;
|
|
@@ -78,6 +98,10 @@ export function CalendarWorkspace({
|
|
|
78
98
|
date,
|
|
79
99
|
events,
|
|
80
100
|
colorByCalendarId,
|
|
101
|
+
agenda,
|
|
102
|
+
isLoading = false,
|
|
103
|
+
error,
|
|
104
|
+
onRetry,
|
|
81
105
|
density,
|
|
82
106
|
selectedEventId,
|
|
83
107
|
timeZone,
|
|
@@ -136,27 +160,51 @@ export function CalendarWorkspace({
|
|
|
136
160
|
</CalendarDateNav>
|
|
137
161
|
</div>
|
|
138
162
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
{/* The strip rolls its own range, so it answers for its own days: the
|
|
164
|
+
week this pane read is not what it is drawing, and gating it on that
|
|
165
|
+
read would blank a strip that has everything it needs. It also
|
|
166
|
+
scrolls itself, so it gets a column to fill rather than a box. */}
|
|
167
|
+
{calendarViewMountsAgenda(view) ? (
|
|
168
|
+
<div className="flex min-h-0 flex-1 flex-col">{agenda}</div>
|
|
169
|
+
) : (
|
|
170
|
+
<div className="min-h-0 flex-1">
|
|
171
|
+
{error !== undefined && error !== null ? (
|
|
172
|
+
<div className="flex h-full items-center justify-center">
|
|
173
|
+
<ErrorState
|
|
174
|
+
title="Couldn't load this week"
|
|
175
|
+
error={error}
|
|
176
|
+
onRetry={onRetry}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
) : isLoading ? (
|
|
180
|
+
<div
|
|
181
|
+
role="status"
|
|
182
|
+
aria-label="Loading the calendar"
|
|
183
|
+
className="flex h-full items-center justify-center bg-surface"
|
|
184
|
+
>
|
|
185
|
+
<Loader2 className="size-6 animate-spin text-fg-subtle" />
|
|
186
|
+
</div>
|
|
187
|
+
) : calendarViewMountsGrid(view) ? (
|
|
188
|
+
<CalendarGrid
|
|
189
|
+
view={view}
|
|
190
|
+
date={date}
|
|
191
|
+
events={events}
|
|
192
|
+
colorByCalendarId={colorByCalendarId}
|
|
193
|
+
density={density}
|
|
194
|
+
selectedEventId={selectedEventId}
|
|
195
|
+
timeZone={timeZone}
|
|
196
|
+
now={now}
|
|
197
|
+
onSelectEvent={onSelectEvent}
|
|
198
|
+
onPickSlot={onPickSlot}
|
|
199
|
+
onRangeChange={(measuredTitle) =>
|
|
200
|
+
setMeasured({ key: addressKey, title: measuredTitle })
|
|
201
|
+
}
|
|
202
|
+
/>
|
|
203
|
+
) : (
|
|
204
|
+
<CalendarViewPlaceholder view={view} />
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
160
208
|
</div>
|
|
161
209
|
);
|
|
162
210
|
}
|