@remit/ui 0.0.113 → 0.0.114

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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/components/app-shell-slotted.render.test.ts +83 -0
  3. package/src/components/app-shell-slotted.tsx +31 -3
  4. package/src/components/attendee-row.render.test.ts +73 -0
  5. package/src/components/attendee-row.stories.tsx +68 -0
  6. package/src/components/attendee-row.tsx +96 -0
  7. package/src/components/calendar-event-chip.render.test.ts +67 -0
  8. package/src/components/calendar-event-chip.stories.tsx +128 -0
  9. package/src/components/calendar-event-chip.tsx +116 -0
  10. package/src/components/calendar-list.render.test.ts +77 -0
  11. package/src/components/calendar-list.stories.tsx +130 -0
  12. package/src/components/calendar-list.tsx +225 -0
  13. package/src/components/calendar-toolbar.render.test.ts +69 -0
  14. package/src/components/calendar-toolbar.stories.tsx +55 -0
  15. package/src/components/calendar-toolbar.tsx +178 -0
  16. package/src/components/calendar-types.ts +135 -0
  17. package/src/components/custom-recurrence.stories.tsx +106 -0
  18. package/src/components/custom-recurrence.tsx +411 -0
  19. package/src/components/event-detail.render.test.ts +104 -0
  20. package/src/components/event-detail.stories.tsx +178 -0
  21. package/src/components/event-detail.tsx +188 -0
  22. package/src/components/event-editor-pane.tsx +54 -0
  23. package/src/components/event-editor.render.test.ts +83 -0
  24. package/src/components/event-editor.stories.tsx +99 -0
  25. package/src/components/event-editor.tsx +480 -0
  26. package/src/components/event-quick-entry.render.test.ts +40 -0
  27. package/src/components/event-quick-entry.stories.tsx +56 -0
  28. package/src/components/event-quick-entry.tsx +159 -0
  29. package/src/components/event-suggestion-card.render.test.ts +65 -0
  30. package/src/components/event-suggestion-card.stories.tsx +93 -0
  31. package/src/components/event-suggestion-card.tsx +116 -0
  32. package/src/components/flow-screen.tsx +169 -0
  33. package/src/components/intelligence-panel.stories.tsx +5 -1
  34. package/src/components/intelligence-panel.tsx +4 -0
  35. package/src/components/message-list-pane.render.test.ts +2 -2
  36. package/src/components/message-list-pane.stories.tsx +1 -1
  37. package/src/components/nav-sidebar.tsx +20 -0
  38. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  39. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  40. package/src/components/recurrence-scope-prompt.tsx +84 -0
  41. package/src/components/selection-wizard.tsx +19 -69
  42. package/src/index.ts +111 -0
  43. package/src/lib/calendar-color.ts +71 -0
  44. package/src/lib/event-phrase.test.ts +89 -0
  45. package/src/lib/event-phrase.ts +228 -0
  46. package/src/lib/recurrence.test.ts +141 -0
  47. package/src/lib/recurrence.ts +266 -0
  48. package/src/tokens.css +63 -0
@@ -4,6 +4,7 @@ import {
4
4
  Archive,
5
5
  BellOff,
6
6
  BookmarkPlus,
7
+ CalendarDays,
7
8
  ChevronDown,
8
9
  ChevronRight,
9
10
  FileText,
@@ -558,6 +559,12 @@ export interface NavSidebarProps
558
559
  * fall back to programmatic buttons (static stories / AppShell preview).
559
560
  */
560
561
  linkComponent?: NavLinkComponent;
562
+ /**
563
+ * Whether the calendar destination sits under the daily brief. It is
564
+ * "hidden" until the app has a route behind it — the prototype turns it on,
565
+ * the shipping nav does not offer an entry that leads nowhere.
566
+ */
567
+ calendarNav?: "hidden" | "shown";
561
568
  /** Every saved query, most recently saved first. See `SavedSearchesGroup`. */
562
569
  savedSearches?: string[];
563
570
  /**
@@ -577,6 +584,7 @@ export function NavSidebar({
577
584
  onSelectNav,
578
585
  variant = "desktop",
579
586
  linkComponent,
587
+ calendarNav = "hidden",
580
588
  savedSearches = [],
581
589
  saveableQuery,
582
590
  onSelectSavedSearch,
@@ -604,6 +612,18 @@ export function NavSidebar({
604
612
  onClick={() => onSelectNav?.("brief")}
605
613
  />
606
614
 
615
+ {calendarNav === "shown" && (
616
+ <NavItem
617
+ navId="calendar"
618
+ linkComponent={linkComponent}
619
+ icon={<CalendarDays className="size-4" />}
620
+ label="Calendar"
621
+ ariaLabel="Calendar"
622
+ active={selectedNavId === "calendar"}
623
+ onClick={() => onSelectNav?.("calendar")}
624
+ />
625
+ )}
626
+
607
627
  <NavItem
608
628
  navId="flagged"
609
629
  linkComponent={linkComponent}
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { RecurrenceScopePrompt } from "./recurrence-scope-prompt.js";
6
+
7
+ const render = (touch = false) =>
8
+ renderToString(
9
+ createElement(RecurrenceScopePrompt, {
10
+ title: "Standup",
11
+ ruleText: "Every weekday, 09:15",
12
+ instanceText: "Wednesday 10 June",
13
+ onChoose: () => undefined,
14
+ onCancel: () => undefined,
15
+ touch,
16
+ }),
17
+ );
18
+
19
+ describe("RecurrenceScopePrompt", () => {
20
+ it("offers all three scopes", () => {
21
+ const html = render();
22
+ assert.match(html, /This event/);
23
+ assert.match(html, /This and everything after/);
24
+ assert.match(html, /The whole series/);
25
+ });
26
+
27
+ it("states the rule and the instance that was clicked", () => {
28
+ const html = render();
29
+ assert.match(html, /Every weekday, 09:15/);
30
+ assert.match(html, /Wednesday 10 June/);
31
+ });
32
+
33
+ it("grows the choices for a thumb", () => {
34
+ assert.match(render(true), /min-h-11/);
35
+ });
36
+ });
@@ -0,0 +1,46 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { RecurrenceScopePrompt } from "./recurrence-scope-prompt.js";
3
+
4
+ /**
5
+ * The scope question, asked before the form opens. Editing one instance and
6
+ * editing the rule are different acts, so the choice is made while the change
7
+ * is still an intention.
8
+ */
9
+ const meta: Meta<typeof RecurrenceScopePrompt> = {
10
+ title: "Calendar/Recurrence scope",
11
+ component: RecurrenceScopePrompt,
12
+ parameters: { layout: "padded" },
13
+ };
14
+ export default meta;
15
+
16
+ type Story = StoryObj<typeof RecurrenceScopePrompt>;
17
+
18
+ export const Desktop: Story = {
19
+ render: () => (
20
+ <div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
21
+ <RecurrenceScopePrompt
22
+ title="Standup"
23
+ ruleText="Every weekday, 09:15"
24
+ instanceText="Wednesday 10 June"
25
+ onChoose={() => {}}
26
+ onCancel={() => {}}
27
+ />
28
+ </div>
29
+ ),
30
+ };
31
+
32
+ /** The same question with thumb-sized targets. */
33
+ export const Touch: Story = {
34
+ render: () => (
35
+ <div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
36
+ <RecurrenceScopePrompt
37
+ title="Standup"
38
+ ruleText="Every weekday, 09:15"
39
+ instanceText="Wednesday 10 June"
40
+ onChoose={() => {}}
41
+ onCancel={() => {}}
42
+ touch
43
+ />
44
+ </div>
45
+ ),
46
+ };
@@ -0,0 +1,84 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Button } from "./button.js";
3
+ import type { RecurrenceScope } from "./calendar-types.js";
4
+
5
+ export interface RecurrenceScopePromptProps {
6
+ /** The series being edited, e.g. "Standup". */
7
+ title: string;
8
+ /** The rule in words, e.g. "Every weekday, 09:15". */
9
+ ruleText: string;
10
+ /** The instance that was clicked, already formatted. */
11
+ instanceText: string;
12
+ onChoose: (scope: RecurrenceScope) => void;
13
+ onCancel: () => void;
14
+ touch?: boolean;
15
+ className?: string;
16
+ }
17
+
18
+ const choices: { scope: RecurrenceScope; label: string; note: string }[] = [
19
+ {
20
+ scope: "this",
21
+ label: "This event",
22
+ note: "Leaves the rest of the series alone",
23
+ },
24
+ {
25
+ scope: "following",
26
+ label: "This and everything after",
27
+ note: "Splits the series here",
28
+ },
29
+ { scope: "all", label: "The whole series", note: "Rewrites the rule itself" },
30
+ ];
31
+
32
+ /**
33
+ * The scope question, asked first. A series is one object with one rule, and
34
+ * which instances an edit reaches changes what the edit means — so it is
35
+ * settled before the form opens, not confirmed on the way out when the change
36
+ * has already been typed.
37
+ */
38
+ export function RecurrenceScopePrompt({
39
+ title,
40
+ ruleText,
41
+ instanceText,
42
+ onChoose,
43
+ onCancel,
44
+ touch,
45
+ className,
46
+ }: RecurrenceScopePromptProps) {
47
+ return (
48
+ <div className={cn("flex flex-col gap-3", className)}>
49
+ <div>
50
+ <h2 className="text-md font-semibold text-fg">{title} repeats</h2>
51
+ <p className="text-xs text-fg-muted">{ruleText}</p>
52
+ <p className="text-xs text-fg-subtle">You clicked {instanceText}.</p>
53
+ </div>
54
+
55
+ <p className="text-sm text-fg">What should the change apply to?</p>
56
+
57
+ <div className="flex flex-col gap-1.5">
58
+ {choices.map((choice) => (
59
+ <button
60
+ key={choice.scope}
61
+ type="button"
62
+ onClick={() => onChoose(choice.scope)}
63
+ className={cn(
64
+ "flex flex-col rounded-md border border-line bg-surface px-3 py-2 text-left outline-none transition-colors hover:border-line-strong hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring",
65
+ touch && "min-h-11 justify-center",
66
+ )}
67
+ >
68
+ <span className="text-sm font-medium text-fg">{choice.label}</span>
69
+ <span className="text-2xs text-fg-subtle">{choice.note}</span>
70
+ </button>
71
+ ))}
72
+ </div>
73
+
74
+ <Button
75
+ variant="ghost"
76
+ size={touch ? "md" : "sm"}
77
+ onClick={onCancel}
78
+ className={cn("self-start", touch && "min-h-11")}
79
+ >
80
+ Cancel
81
+ </Button>
82
+ </div>
83
+ );
84
+ }
@@ -69,6 +69,7 @@ import {
69
69
  scopeLabel,
70
70
  } from "./filter-rule.js";
71
71
  import type { ClauseEditState } from "./filter-rule-editor.js";
72
+ import { FlowScreen, FlowStepRail } from "./flow-screen.js";
72
73
  import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
73
74
  import { Input } from "./input.js";
74
75
  import { ProgressBar } from "./progress-bar.js";
@@ -91,21 +92,8 @@ export interface StepRailProps {
91
92
  }
92
93
 
93
94
  export function StepRail({ steps, step }: StepRailProps) {
94
- const active = stepIndex(steps, step);
95
95
  return (
96
- <ol className="flex items-center gap-1.5" aria-label="Progress">
97
- {steps.map((id, i) => (
98
- <li key={id} className="flex flex-1 items-center gap-1.5">
99
- <span
100
- className={cn(
101
- "h-1 flex-1 rounded-full transition-colors",
102
- i <= active ? "bg-accent" : "bg-surface-sunken",
103
- )}
104
- aria-current={i === active ? "step" : undefined}
105
- />
106
- </li>
107
- ))}
108
- </ol>
96
+ <FlowStepRail count={steps.length} activeStep={stepIndex(steps, step)} />
109
97
  );
110
98
  }
111
99
 
@@ -135,61 +123,22 @@ export function WizardScreen({
135
123
  footer,
136
124
  children,
137
125
  }: WizardScreenProps) {
138
- const active = stepIndex(steps, step);
126
+ // A modal at every width: full-bleed below 768px it covers the list rather
127
+ // than sitting beside it, so the list behind must leave the accessibility
128
+ // tree with it — otherwise the verbs that opened the wizard are still
129
+ // reachable underneath the screen that replaced them.
139
130
  return (
140
- // A modal at every width: full-bleed below 768px it covers the list rather
141
- // than sitting beside it, so the list behind must leave the accessibility
142
- // tree with it — otherwise the verbs that opened the wizard are still
143
- // reachable underneath the screen that replaced them.
144
- <div
145
- role="dialog"
146
- aria-modal="true"
147
- aria-label={title}
148
- className="fixed inset-0 z-50 flex flex-col font-sans text-fg md:items-center md:justify-center md:bg-black/40 md:p-6"
131
+ <FlowScreen
132
+ title={title}
133
+ subtitle={subtitle}
134
+ steps={steps.map(stepLabel)}
135
+ activeStep={stepIndex(steps, step)}
136
+ onBack={onBack}
137
+ onExit={onExit}
138
+ footer={footer}
149
139
  >
150
- <div className="flex min-h-0 w-full flex-1 flex-col bg-canvas md:h-[45rem] md:max-h-[calc(100dvh-3rem)] md:w-[35rem] md:max-w-[calc(100vw-3rem)] md:flex-none md:overflow-hidden md:rounded-xl md:border md:border-line md:shadow-lg">
151
- <header className="shrink-0 border-b border-line px-3 pb-2 pt-[calc(0.75rem+env(safe-area-inset-top,0px))] md:pt-3">
152
- <div className="flex items-center gap-1">
153
- <button
154
- type="button"
155
- onClick={onBack}
156
- aria-label="Back"
157
- className="flex size-11 items-center justify-center rounded-md text-fg-muted"
158
- >
159
- <ArrowLeft className="size-5" />
160
- </button>
161
- <div className="min-w-0 flex-1 text-center">
162
- <h1 className="truncate text-sm font-semibold">{title}</h1>
163
- {subtitle && (
164
- <p className="truncate text-2xs text-fg-muted">{subtitle}</p>
165
- )}
166
- </div>
167
- <button
168
- type="button"
169
- onClick={onExit}
170
- aria-label="Cancel"
171
- className="flex size-11 items-center justify-center rounded-md text-fg-muted"
172
- >
173
- <X className="size-5" />
174
- </button>
175
- </div>
176
- <div className="space-y-1 px-1 pt-2">
177
- <StepRail steps={steps} step={step} />
178
- <p className="text-2xs text-fg-subtle">
179
- Step {active + 1} of {steps.length} · {stepLabel(steps[active])}
180
- </p>
181
- </div>
182
- </header>
183
-
184
- <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
185
- {children}
186
- </div>
187
-
188
- <footer className="shrink-0 border-t border-line px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3">
189
- {footer}
190
- </footer>
191
- </div>
192
- </div>
140
+ {children}
141
+ </FlowScreen>
193
142
  );
194
143
  }
195
144
 
@@ -354,7 +303,8 @@ export interface FooterNavProps {
354
303
  onBack: () => void;
355
304
  nextLabel: string;
356
305
  onNext: () => void;
357
- nextVariant?: "primary" | "danger";
306
+ /** `commit` reads as primary without the arrow: it ends the flow. */
307
+ nextVariant?: "primary" | "commit" | "danger";
358
308
  /** What the step is still missing. Dims the control; never disables it. */
359
309
  blockedReason?: string;
360
310
  /** Continue was pressed while blocked, so the reason is on screen. */
@@ -397,7 +347,7 @@ export function FooterNav({
397
347
  Back
398
348
  </Button>
399
349
  <Button
400
- variant={nextVariant}
350
+ variant={nextVariant === "danger" ? "danger" : "primary"}
401
351
  size="touch"
402
352
  onClick={onNext}
403
353
  aria-describedby={blockedReason ? reasonId : undefined}
package/src/index.ts CHANGED
@@ -58,6 +58,14 @@ export {
58
58
  AttachmentList,
59
59
  type AttachmentListProps,
60
60
  } from "./components/attachment-list.js";
61
+ export {
62
+ AttendeeList,
63
+ type AttendeeListProps,
64
+ AttendeeRow,
65
+ type AttendeeRowProps,
66
+ RsvpBadge,
67
+ type RsvpBadgeProps,
68
+ } from "./components/attendee-row.js";
61
69
  export { AuthCard, type AuthCardProps } from "./components/auth-card.js";
62
70
  export {
63
71
  AuthFooter,
@@ -112,6 +120,35 @@ export {
112
120
  type ButtonLinkProps,
113
121
  type ButtonProps,
114
122
  } from "./components/button.js";
123
+ export {
124
+ CalendarEventChip,
125
+ type CalendarEventChipProps,
126
+ } from "./components/calendar-event-chip.js";
127
+ export {
128
+ CalendarList,
129
+ type CalendarListProps,
130
+ } from "./components/calendar-list.js";
131
+ export {
132
+ CalendarDateNav,
133
+ type CalendarDateNavProps,
134
+ CalendarViewSwitch,
135
+ type CalendarViewSwitchProps,
136
+ type SegmentOption,
137
+ segmentClassName,
138
+ } from "./components/calendar-toolbar.js";
139
+ export {
140
+ type CalendarAttendee,
141
+ type CalendarColorId,
142
+ type CalendarDescriptor,
143
+ type CalendarEventData,
144
+ type CalendarViewId,
145
+ calendarColorIds,
146
+ type EventDraft,
147
+ type EventSuggestion,
148
+ type RecurrenceScope,
149
+ type RsvpState,
150
+ type ZoneCertainty,
151
+ } from "./components/calendar-types.js";
115
152
  export {
116
153
  Card,
117
154
  CardBody,
@@ -166,6 +203,12 @@ export {
166
203
  ConfirmDialog,
167
204
  type ConfirmDialogProps,
168
205
  } from "./components/confirm-dialog.js";
206
+ export {
207
+ CustomRecurrenceDialog,
208
+ type CustomRecurrenceDialogProps,
209
+ CustomRecurrenceEditor,
210
+ type CustomRecurrenceEditorProps,
211
+ } from "./components/custom-recurrence.js";
169
212
  export {
170
213
  DangerZoneSection,
171
214
  type DangerZoneSectionProps,
@@ -176,6 +219,36 @@ export {
176
219
  type DialogBackdropProps,
177
220
  } from "./components/dialog-backdrop.js";
178
221
  export type { EmailFrameVariant } from "./components/email-frame-css.js";
222
+ export {
223
+ EventDetail,
224
+ type EventDetailProps,
225
+ } from "./components/event-detail.js";
226
+ export {
227
+ EventCalendarField,
228
+ EventEditor,
229
+ type EventEditorLayout,
230
+ type EventEditorProps,
231
+ EventField,
232
+ type EventFieldProps,
233
+ EventGuestsField,
234
+ EventLocationField,
235
+ EventNotesField,
236
+ EventRepeatField,
237
+ EventTitleField,
238
+ EventWhenField,
239
+ } from "./components/event-editor.js";
240
+ export {
241
+ EventEditorPane,
242
+ type EventEditorPaneProps,
243
+ } from "./components/event-editor-pane.js";
244
+ export {
245
+ EventQuickEntry,
246
+ type EventQuickEntryProps,
247
+ } from "./components/event-quick-entry.js";
248
+ export {
249
+ EventSuggestionCard,
250
+ type EventSuggestionCardProps,
251
+ } from "./components/event-suggestion-card.js";
179
252
  export {
180
253
  FieldLabel,
181
254
  type FieldLabelProps,
@@ -245,6 +318,12 @@ export {
245
318
  type FilterSheetSource,
246
319
  FilterToggle,
247
320
  } from "./components/filter-sheet.js";
321
+ export {
322
+ FlowScreen,
323
+ type FlowScreenProps,
324
+ FlowStepRail,
325
+ type FlowStepRailProps,
326
+ } from "./components/flow-screen.js";
248
327
  export {
249
328
  FolderManageActions,
250
329
  type FolderManageActionsProps,
@@ -448,6 +527,10 @@ export {
448
527
  ReadingPaneEmpty,
449
528
  type ReadingPaneEmptyProps,
450
529
  } from "./components/reading-pane-empty.js";
530
+ export {
531
+ RecurrenceScopePrompt,
532
+ type RecurrenceScopePromptProps,
533
+ } from "./components/recurrence-scope-prompt.js";
451
534
  export {
452
535
  RefreshButton,
453
536
  type RefreshButtonProps,
@@ -565,6 +648,8 @@ export {
565
648
  export {
566
649
  FolderStepBody,
567
650
  type FolderStepProps,
651
+ FooterNav,
652
+ type FooterNavProps,
568
653
  MatchStepBody,
569
654
  type MatchStepProps,
570
655
  NameStepBody,
@@ -699,6 +784,10 @@ export {
699
784
  setBriefCategoryInQuery,
700
785
  toggleBriefFilterInQuery,
701
786
  } from "./lib/brief-filter-query.js";
787
+ export {
788
+ type CalendarColorClasses,
789
+ calendarColorClasses,
790
+ } from "./lib/calendar-color.js";
702
791
  export {
703
792
  buildCidResolver,
704
793
  type CidResolvableBodyPart,
@@ -731,6 +820,11 @@ export {
731
820
  sanitizeInlineStyle,
732
821
  sanitizeStyleElementCss,
733
822
  } from "./lib/email-sanitizer.js";
823
+ export {
824
+ addMinutesToClock,
825
+ type PhraseParse,
826
+ parseEventPhrase,
827
+ } from "./lib/event-phrase.js";
734
828
  export {
735
829
  collapseFolderTree,
736
830
  filterFolderTree,
@@ -787,6 +881,23 @@ export {
787
881
  normalizeSubject,
788
882
  sharedSubjectFragment,
789
883
  } from "./lib/property-prefill.js";
884
+ export {
885
+ type CustomRecurrence,
886
+ dayOfMonthLabel,
887
+ defaultCustomRecurrence,
888
+ defaultEndDate,
889
+ endDateLabel,
890
+ formatCustomRecurrence,
891
+ type MonthlyMode,
892
+ NO_REPEAT,
893
+ ordinalWeekdayLabel,
894
+ type RecurrenceEnd,
895
+ type RecurrenceUnit,
896
+ readCustomRecurrence,
897
+ repeatChoices,
898
+ WEEKDAY_INITIALS,
899
+ weekdayName,
900
+ } from "./lib/recurrence.js";
790
901
  export {
791
902
  LIST_ROW_ATTRIBUTE,
792
903
  LIST_ROW_SELECTOR,
@@ -0,0 +1,71 @@
1
+ import type { CalendarColorId } from "../components/calendar-types.js";
2
+
3
+ /**
4
+ * A calendar's hue is data, so the utility names cannot be built by
5
+ * interpolation — Tailwind only sees class strings it can read in source.
6
+ * Every calendar colour therefore names its utilities once, here, and every
7
+ * calendar surface reads them from this table.
8
+ */
9
+ export interface CalendarColorClasses {
10
+ /** Solid fill: the swatch, the dot, the left rail of a chip. */
11
+ solid: string;
12
+ /** Tinted fill for the body of a chip or a day cell. */
13
+ soft: string;
14
+ /** Text and icons that sit on `soft`, or on a plain surface. */
15
+ text: string;
16
+ /** Hairline in the calendar's own hue. */
17
+ border: string;
18
+ /** Border on the leading edge only — the rail a timed chip carries. */
19
+ rail: string;
20
+ }
21
+
22
+ const table: Record<CalendarColorId, CalendarColorClasses> = {
23
+ "cal-1": {
24
+ solid: "bg-cal-1",
25
+ soft: "bg-cal-1-soft",
26
+ text: "text-cal-1",
27
+ border: "border-cal-1",
28
+ rail: "border-l-cal-1",
29
+ },
30
+ "cal-2": {
31
+ solid: "bg-cal-2",
32
+ soft: "bg-cal-2-soft",
33
+ text: "text-cal-2",
34
+ border: "border-cal-2",
35
+ rail: "border-l-cal-2",
36
+ },
37
+ "cal-3": {
38
+ solid: "bg-cal-3",
39
+ soft: "bg-cal-3-soft",
40
+ text: "text-cal-3",
41
+ border: "border-cal-3",
42
+ rail: "border-l-cal-3",
43
+ },
44
+ "cal-4": {
45
+ solid: "bg-cal-4",
46
+ soft: "bg-cal-4-soft",
47
+ text: "text-cal-4",
48
+ border: "border-cal-4",
49
+ rail: "border-l-cal-4",
50
+ },
51
+ "cal-5": {
52
+ solid: "bg-cal-5",
53
+ soft: "bg-cal-5-soft",
54
+ text: "text-cal-5",
55
+ border: "border-cal-5",
56
+ rail: "border-l-cal-5",
57
+ },
58
+ "cal-6": {
59
+ solid: "bg-cal-6",
60
+ soft: "bg-cal-6-soft",
61
+ text: "text-cal-6",
62
+ border: "border-cal-6",
63
+ rail: "border-l-cal-6",
64
+ },
65
+ };
66
+
67
+ export function calendarColorClasses(
68
+ color: CalendarColorId,
69
+ ): CalendarColorClasses {
70
+ return table[color];
71
+ }
@@ -0,0 +1,89 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { addMinutesToClock, parseEventPhrase } from "./event-phrase.js";
4
+
5
+ /** Wednesday 10 June 2026, 09:30 local — the fixtures' fixed "now". */
6
+ const NOW = new Date(2026, 5, 10, 9, 30);
7
+
8
+ describe("parseEventPhrase", () => {
9
+ it("reads a title, a guest, a weekday and a time", () => {
10
+ const parse = parseEventPhrase("lunch with Jane friday 1pm", NOW);
11
+ assert.equal(parse.title, "lunch");
12
+ assert.deepEqual(parse.attendees, ["Jane"]);
13
+ assert.equal(parse.date, "2026-06-12");
14
+ assert.equal(parse.startTime, "13:00");
15
+ assert.equal(parse.dateText, "friday");
16
+ assert.equal(parse.startTimeText, "1pm");
17
+ });
18
+
19
+ it("assumes an hour when no length is given, and says so", () => {
20
+ const parse = parseEventPhrase("standup tomorrow 9:30", NOW);
21
+ assert.equal(parse.date, "2026-06-11");
22
+ assert.equal(parse.startTime, "09:30");
23
+ assert.equal(parse.durationMinutes, 60);
24
+ assert.deepEqual(parse.assumptions, ["No length given — using an hour"]);
25
+ });
26
+
27
+ it("reads an explicit length instead of assuming one", () => {
28
+ const parse = parseEventPhrase("dentist next tuesday 15:00 for 45m", NOW);
29
+ assert.equal(parse.title, "dentist");
30
+ assert.equal(parse.date, "2026-06-23");
31
+ assert.equal(parse.durationMinutes, 45);
32
+ assert.deepEqual(parse.assumptions, []);
33
+ });
34
+
35
+ it("splits several guests", () => {
36
+ const parse = parseEventPhrase("coffee with Marcus and Dana thu 8am", NOW);
37
+ assert.equal(parse.title, "coffee");
38
+ assert.deepEqual(parse.attendees, ["Marcus", "Dana"]);
39
+ assert.equal(parse.date, "2026-06-11");
40
+ assert.equal(parse.startTime, "08:00");
41
+ });
42
+
43
+ it("reports a missing time rather than inventing one", () => {
44
+ const parse = parseEventPhrase("design review monday", NOW);
45
+ assert.equal(parse.startTime, "");
46
+ assert.deepEqual(parse.unresolved, ["No time given"]);
47
+ });
48
+
49
+ it("reports the day it fell back to", () => {
50
+ const parse = parseEventPhrase("call the plumber 3pm", NOW);
51
+ assert.equal(parse.date, "2026-06-10");
52
+ assert.deepEqual(parse.assumptions[0], "No day given — using today");
53
+ });
54
+
55
+ it("reads a named hour", () => {
56
+ const parse = parseEventPhrase("sandwich noon", NOW);
57
+ assert.equal(parse.startTime, "12:00");
58
+ assert.equal(parse.title, "sandwich");
59
+ });
60
+
61
+ it("has nothing to report on an empty phrase but the gaps", () => {
62
+ const parse = parseEventPhrase("", NOW);
63
+ assert.deepEqual(parse.unresolved, ["No title yet", "No time given"]);
64
+ });
65
+
66
+ it("keeps the same weekday when it is today", () => {
67
+ const parse = parseEventPhrase("retro wednesday 4pm", NOW);
68
+ assert.equal(parse.date, "2026-06-10");
69
+ });
70
+
71
+ it("reads hours as well as minutes", () => {
72
+ const parse = parseEventPhrase("workshop friday 10am 2h", NOW);
73
+ assert.equal(parse.durationMinutes, 120);
74
+ });
75
+ });
76
+
77
+ describe("addMinutesToClock", () => {
78
+ it("advances a clock", () => {
79
+ assert.equal(addMinutesToClock("13:00", 90), "14:30");
80
+ });
81
+
82
+ it("wraps past midnight", () => {
83
+ assert.equal(addMinutesToClock("23:30", 60), "00:30");
84
+ });
85
+
86
+ it("returns nothing for no clock", () => {
87
+ assert.equal(addMinutesToClock("", 60), "");
88
+ });
89
+ });