@remit/web-client 0.0.230 → 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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.stories.tsx +115 -0
  3. package/src/components/calendar/CalendarComposePane.tsx +94 -0
  4. package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
  5. package/src/components/calendar/CalendarEventPane.stories.tsx +110 -0
  6. package/src/components/calendar/CalendarEventPane.tsx +90 -0
  7. package/src/components/calendar/CalendarShell.tsx +83 -0
  8. package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
  9. package/src/components/calendar/CalendarWorkspace.stories.tsx +219 -0
  10. package/src/components/calendar/CalendarWorkspace.tsx +192 -0
  11. package/src/components/calendar/OpenCalendarEvent.tsx +221 -0
  12. package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
  13. package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
  14. package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
  15. package/src/components/mail/MailSidebarAdapter.tsx +17 -0
  16. package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
  17. package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
  18. package/src/hooks/calendar/draft.test.ts +241 -0
  19. package/src/hooks/calendar/draft.ts +168 -0
  20. package/src/hooks/calendar/index.ts +59 -0
  21. package/src/hooks/calendar/instance.ts +136 -0
  22. package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
  23. package/src/hooks/calendar/recurrence-rule.ts +133 -0
  24. package/src/hooks/calendar/resource.test.ts +98 -0
  25. package/src/hooks/calendar/resource.ts +74 -0
  26. package/src/hooks/calendar/useCalendarEvent.ts +46 -0
  27. package/src/hooks/calendar/useCalendarEvents.ts +124 -0
  28. package/src/hooks/calendar/useCalendarWrites.ts +174 -0
  29. package/src/hooks/calendar/useCalendars.ts +43 -0
  30. package/src/hooks/calendar/window.test.ts +148 -0
  31. package/src/hooks/calendar/window.ts +163 -0
  32. package/src/hooks/useCalendarData.ts +120 -0
  33. package/src/lib/calendar-density.ts +34 -0
  34. package/src/lib/calendar-format.ts +29 -0
  35. package/src/lib/calendar-route.test.ts +210 -0
  36. package/src/lib/calendar-route.ts +187 -0
  37. package/src/lib/search-params.test.ts +77 -0
  38. package/src/lib/search-params.ts +59 -0
  39. package/src/routeTree.gen.ts +199 -0
  40. package/src/router.tsx +5 -0
  41. package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
  42. package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
  43. package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
  44. package/src/routes/calendar/$view.$date/index.tsx +25 -0
  45. package/src/routes/calendar/$view.$date/new.tsx +20 -0
  46. package/src/routes/calendar/$view.$date.tsx +128 -0
  47. package/src/routes/calendar/-calendar-tree.test.ts +267 -0
  48. package/src/routes/calendar/index.tsx +27 -0
  49. package/src/routes/calendar.tsx +98 -0
  50. package/src/routing/calendar-fragment.test.ts +66 -0
  51. package/src/routing/calendar.ts +150 -0
  52. package/src/routing/fragment.ts +19 -0
  53. package/src/routing/index.ts +10 -0
  54. package/src/routing/nav-selection.ts +2 -0
  55. package/src/test-support/http.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.230",
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": {
@@ -0,0 +1,115 @@
1
+ import type { CalendarDescriptor, EventDraft } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { useState } from "react";
4
+ import { CalendarComposePane } from "./CalendarComposePane";
5
+
6
+ /**
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.
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
+
46
+ const meta: Meta<typeof CalendarComposePane> = {
47
+ title: "App/Calendar/Compose pane",
48
+ component: CalendarComposePane,
49
+ parameters: { layout: "fullscreen" },
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
+ },
69
+ };
70
+ export default meta;
71
+
72
+ type Story = StoryObj<typeof CalendarComposePane>;
73
+
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
+ };
@@ -0,0 +1,94 @@
1
+ import {
2
+ type CalendarDescriptor,
3
+ type EventDraft,
4
+ EventEditor,
5
+ EventEditorPane,
6
+ } from "@remit/ui";
7
+ import { AlertCircle } from "lucide-react";
8
+
9
+ /**
10
+ * Writing an event, and editing one.
11
+ *
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.
15
+ *
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.
20
+ */
21
+ export interface CalendarComposePaneProps {
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;
49
+ }
50
+
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) {
65
+ return (
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>
93
+ );
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);
@@ -0,0 +1,110 @@
1
+ import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { CalendarEventPane } from "./CalendarEventPane";
4
+
5
+ /**
6
+ * The calendar's reading pane, at the three states the routes below the view
7
+ * can put it in: the event, one occurrence of a repeating one, and an address
8
+ * naming an event the calendar does not have on screen.
9
+ */
10
+ const calendar: CalendarDescriptor = {
11
+ id: "cal_work",
12
+ accountId: "acct_work",
13
+ accountLabel: "Work",
14
+ name: "Northwind",
15
+ color: "cal-1",
16
+ };
17
+
18
+ const event: CalendarEventData = {
19
+ id: "evt_roadmap",
20
+ calendarId: calendar.id,
21
+ title: "Roadmap review",
22
+ start: "2026-06-10T10:00:00+02:00",
23
+ end: "2026-06-10T11:30:00+02:00",
24
+ allDay: false,
25
+ location: "Room Zuid",
26
+ notes: "Bring the staffing numbers.",
27
+ attendees: [
28
+ {
29
+ name: "Priya Natarajan",
30
+ email: "priya@northwind.example",
31
+ rsvp: "accepted",
32
+ role: "organizer",
33
+ },
34
+ ],
35
+ myRsvp: "accepted",
36
+ threadId: "",
37
+ threadSubject: "",
38
+ timeZone: "Europe/Amsterdam",
39
+ zoneCertainty: "explicit",
40
+ recurrenceRule: "",
41
+ seriesId: "",
42
+ seriesException: false,
43
+ status: "confirmed",
44
+ };
45
+
46
+ const meta: Meta<typeof CalendarEventPane> = {
47
+ title: "App/Calendar/Event pane",
48
+ component: CalendarEventPane,
49
+ parameters: { layout: "fullscreen" },
50
+ args: {
51
+ event,
52
+ calendar,
53
+ isOccurrence: false,
54
+ problem: "",
55
+ onEdit: () => {},
56
+ onDelete: () => {},
57
+ onClose: () => {},
58
+ },
59
+ render: (args) => (
60
+ <div className="h-dvh max-w-xl border-l border-line bg-canvas">
61
+ <CalendarEventPane {...args} />
62
+ </div>
63
+ ),
64
+ };
65
+ export default meta;
66
+
67
+ type Story = StoryObj<typeof CalendarEventPane>;
68
+
69
+ export const Event: Story = {};
70
+
71
+ /** The series is what the address names; the occurrence is the segment below. */
72
+ export const Occurrence: Story = {
73
+ args: {
74
+ event: {
75
+ ...event,
76
+ title: "Standup",
77
+ start: "2026-06-11T09:15:00+02:00",
78
+ end: "2026-06-11T09:30:00+02:00",
79
+ recurrenceRule: "Every weekday, 09:15",
80
+ seriesId: "ser_standup",
81
+ },
82
+ isOccurrence: true,
83
+ },
84
+ };
85
+
86
+ /** An old link, or a week the event is not in. Never a blank pane. */
87
+ export const NotOnThisWeek: Story = {
88
+ args: { event: undefined, calendar: undefined },
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
+ };
@@ -0,0 +1,90 @@
1
+ import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
+ import { EventDetail } from "@remit/ui";
3
+ import { AlertCircle, CalendarDays } from "lucide-react";
4
+ import { formatEventWhen } from "@/lib/calendar-format";
5
+
6
+ /**
7
+ * The event the address has open.
8
+ *
9
+ * Presentational, like the workspace beside it: the route resolves the event
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.
13
+ */
14
+ export interface CalendarEventPaneProps {
15
+ /** Absent when the address names an event the calendar does not have. */
16
+ event: CalendarEventData | undefined;
17
+ calendar: CalendarDescriptor | undefined;
18
+ /** Whether the address names one occurrence rather than the series. */
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;
27
+ onClose: () => void;
28
+ }
29
+
30
+ export function CalendarEventPane({
31
+ event,
32
+ calendar,
33
+ isOccurrence,
34
+ problem = "",
35
+ onEdit,
36
+ onDelete,
37
+ onClose,
38
+ }: CalendarEventPaneProps) {
39
+ if (!event || !calendar) {
40
+ return (
41
+ <div className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center">
42
+ <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>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ return (
61
+ <div className="flex h-full min-h-0 flex-col bg-surface">
62
+ {isOccurrence && (
63
+ <p className="shrink-0 border-b border-line bg-surface-sunken px-row-inset py-1.5 text-2xs uppercase tracking-wider text-fg-subtle">
64
+ One occurrence of a repeating event
65
+ </p>
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
+ )}
79
+ <EventDetail
80
+ event={event}
81
+ calendar={calendar}
82
+ whenText={formatEventWhen(event)}
83
+ onEdit={onEdit}
84
+ onDelete={onDelete}
85
+ onClose={onClose}
86
+ className="min-h-0 flex-1"
87
+ />
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,83 @@
1
+ import { AppShellSlotted } from "@remit/ui";
2
+ import { createContext, type ReactNode, useContext } from "react";
3
+ import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
4
+
5
+ /**
6
+ * The chrome the calendar shares with mail, published by the `/calendar` layout
7
+ * and consumed by the view route that mounts the shell.
8
+ *
9
+ * It lives in a module reached only through the `@/` alias for the same reason
10
+ * `lib/mail-context.ts` does: the generated route tree imports route files
11
+ * relatively, so a context declared inside one would resolve to a second module
12
+ * instance and hand the consumer an empty default.
13
+ */
14
+ export interface CalendarShellChrome {
15
+ /** Below the reading boundary the shell shows one pane, as mail's does. */
16
+ isSinglePane: boolean;
17
+ isLoading: boolean;
18
+ nav: ReactNode;
19
+ navOpen: boolean;
20
+ onOpenNav: () => void;
21
+ onCloseNav: () => void;
22
+ }
23
+
24
+ const CalendarShellCtx = createContext<CalendarShellChrome | null>(null);
25
+
26
+ export const CalendarShellProvider = ({
27
+ chrome,
28
+ children,
29
+ }: {
30
+ chrome: CalendarShellChrome;
31
+ children: ReactNode;
32
+ }) => (
33
+ <CalendarShellCtx.Provider value={chrome}>
34
+ {children}
35
+ </CalendarShellCtx.Provider>
36
+ );
37
+
38
+ export interface CalendarShellProps {
39
+ /** The grid, or whatever the current zoom draws. */
40
+ workspace: ReactNode;
41
+ /** The open event, from the route below this one. */
42
+ reading: ReactNode;
43
+ /** Whether the address has an event or the composer open. */
44
+ hasOpenEvent: boolean;
45
+ }
46
+
47
+ /**
48
+ * The shell the calendar mounts around its own panes.
49
+ *
50
+ * `listBias="list"` because the grid is the work rather than an index into it:
51
+ * a week of columns costs width it cannot give back when a detail opens, which
52
+ * is the opposite of the split a message list wants.
53
+ *
54
+ * Below the reading boundary there is one pane, so the open event replaces the
55
+ * grid in it — the same swap mail makes, read off the address rather than
56
+ * decided here.
57
+ */
58
+ export function CalendarShell({
59
+ workspace,
60
+ reading,
61
+ hasOpenEvent,
62
+ }: CalendarShellProps) {
63
+ const chrome = useContext(CalendarShellCtx);
64
+ if (!chrome) return <AppShellSkeleton />;
65
+
66
+ const shared = {
67
+ nav: chrome.nav,
68
+ skeleton: <AppShellSkeleton />,
69
+ isLoading: chrome.isLoading,
70
+ navOpen: chrome.navOpen,
71
+ onOpenNav: chrome.onOpenNav,
72
+ onCloseNav: chrome.onCloseNav,
73
+ listBias: "list" as const,
74
+ };
75
+
76
+ if (chrome.isSinglePane) {
77
+ return (
78
+ <AppShellSlotted {...shared} list={hasOpenEvent ? reading : workspace} />
79
+ );
80
+ }
81
+
82
+ return <AppShellSlotted {...shared} list={workspace} reading={reading} />;
83
+ }
@@ -0,0 +1,37 @@
1
+ import type { CalendarViewId } from "@remit/ui";
2
+ import { CalendarRange } from "lucide-react";
3
+
4
+ /**
5
+ * A zoom level that is named but not drawn yet.
6
+ *
7
+ * The ladder is five steps and three of them arrive later, so the route stays
8
+ * addressable and says what it is waiting on. A view that silently rendered
9
+ * nothing would be indistinguishable from a week with nothing in it.
10
+ */
11
+ const WAITING_ON: Record<string, string> = {
12
+ year: "The year grid arrives with the rest of the zoom ladder.",
13
+ month: "The month grid arrives with the rest of the zoom ladder.",
14
+ agenda: "The agenda arrives with the scrolling day strip.",
15
+ };
16
+
17
+ export interface CalendarViewPlaceholderProps {
18
+ view: CalendarViewId;
19
+ }
20
+
21
+ export function CalendarViewPlaceholder({
22
+ view,
23
+ }: CalendarViewPlaceholderProps) {
24
+ return (
25
+ <div
26
+ className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center"
27
+ data-testid={`calendar-placeholder-${view}`}
28
+ >
29
+ <CalendarRange className="size-8 text-fg-subtle" aria-hidden="true" />
30
+ <p className="text-sm font-medium text-fg">Not built yet</p>
31
+ <p className="max-w-xs text-sm text-fg-muted">
32
+ {WAITING_ON[view] ?? "This view arrives in a later stage."} Week and Day
33
+ work now.
34
+ </p>
35
+ </div>
36
+ );
37
+ }