@remit/ui 0.0.113 → 0.0.115
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 +3 -3
- package/src/components/app-shell-slotted.render.test.ts +83 -0
- package/src/components/app-shell-slotted.tsx +31 -3
- package/src/components/attendee-row.render.test.ts +73 -0
- package/src/components/attendee-row.stories.tsx +68 -0
- package/src/components/attendee-row.tsx +96 -0
- package/src/components/calendar-event-chip.render.test.ts +67 -0
- package/src/components/calendar-event-chip.stories.tsx +128 -0
- package/src/components/calendar-event-chip.tsx +116 -0
- package/src/components/calendar-list.render.test.ts +77 -0
- package/src/components/calendar-list.stories.tsx +130 -0
- package/src/components/calendar-list.tsx +225 -0
- package/src/components/calendar-toolbar.render.test.ts +69 -0
- package/src/components/calendar-toolbar.stories.tsx +55 -0
- package/src/components/calendar-toolbar.tsx +178 -0
- package/src/components/calendar-types.ts +135 -0
- package/src/components/custom-recurrence.stories.tsx +106 -0
- package/src/components/custom-recurrence.tsx +411 -0
- package/src/components/event-detail.render.test.ts +104 -0
- package/src/components/event-detail.stories.tsx +178 -0
- package/src/components/event-detail.tsx +188 -0
- package/src/components/event-editor-pane.tsx +54 -0
- package/src/components/event-editor.render.test.ts +83 -0
- package/src/components/event-editor.stories.tsx +99 -0
- package/src/components/event-editor.tsx +480 -0
- package/src/components/event-quick-entry.render.test.ts +40 -0
- package/src/components/event-quick-entry.stories.tsx +56 -0
- package/src/components/event-quick-entry.tsx +159 -0
- package/src/components/event-suggestion-card.render.test.ts +65 -0
- package/src/components/event-suggestion-card.stories.tsx +93 -0
- package/src/components/event-suggestion-card.tsx +116 -0
- package/src/components/flow-screen.tsx +169 -0
- package/src/components/intelligence-panel.stories.tsx +5 -1
- package/src/components/intelligence-panel.tsx +4 -0
- package/src/components/isolated-email-frame.tsx +1 -18
- package/src/components/message-list-pane.render.test.ts +2 -2
- package/src/components/message-list-pane.stories.tsx +1 -1
- package/src/components/nav-sidebar.tsx +20 -0
- package/src/components/popover-menu.tsx +230 -27
- package/src/components/pull-to-refresh.tsx +1 -19
- package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
- package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
- package/src/components/recurrence-scope-prompt.tsx +84 -0
- package/src/components/rich-text-correction-menu.tsx +220 -0
- package/src/components/rich-text-editor.stories.tsx +507 -5
- package/src/components/rich-text-editor.tsx +482 -83
- package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
- package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
- package/src/components/rich-text-spellcheck-provider.ts +68 -3
- package/src/components/rich-text-spellcheck-words.ts +168 -4
- package/src/components/rich-text-spellcheck-worker.ts +11 -0
- package/src/components/rich-text-spellcheck.test.ts +7 -0
- package/src/components/rich-text-spellcheck.ts +28 -2
- package/src/components/selection-wizard.tsx +19 -69
- package/src/index.ts +116 -0
- package/src/lib/calendar-color.ts +71 -0
- package/src/lib/event-phrase.test.ts +89 -0
- package/src/lib/event-phrase.ts +228 -0
- package/src/lib/recurrence.test.ts +141 -0
- package/src/lib/recurrence.ts +266 -0
- package/src/lib/use-match-media.ts +25 -0
- package/src/rich-text.ts +12 -0
- package/src/tokens.css +63 -0
|
@@ -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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -400,7 +479,11 @@ export {
|
|
|
400
479
|
export {
|
|
401
480
|
PopoverMenu,
|
|
402
481
|
type PopoverMenuItem,
|
|
482
|
+
PopoverMenuPanel,
|
|
483
|
+
type PopoverMenuPanelProps,
|
|
403
484
|
type PopoverMenuProps,
|
|
485
|
+
PopoverMenuRow,
|
|
486
|
+
type PopoverMenuRowProps,
|
|
404
487
|
} from "./components/popover-menu.js";
|
|
405
488
|
export {
|
|
406
489
|
ProgressBar,
|
|
@@ -448,6 +531,10 @@ export {
|
|
|
448
531
|
ReadingPaneEmpty,
|
|
449
532
|
type ReadingPaneEmptyProps,
|
|
450
533
|
} from "./components/reading-pane-empty.js";
|
|
534
|
+
export {
|
|
535
|
+
RecurrenceScopePrompt,
|
|
536
|
+
type RecurrenceScopePromptProps,
|
|
537
|
+
} from "./components/recurrence-scope-prompt.js";
|
|
451
538
|
export {
|
|
452
539
|
RefreshButton,
|
|
453
540
|
type RefreshButtonProps,
|
|
@@ -565,6 +652,8 @@ export {
|
|
|
565
652
|
export {
|
|
566
653
|
FolderStepBody,
|
|
567
654
|
type FolderStepProps,
|
|
655
|
+
FooterNav,
|
|
656
|
+
type FooterNavProps,
|
|
568
657
|
MatchStepBody,
|
|
569
658
|
type MatchStepProps,
|
|
570
659
|
NameStepBody,
|
|
@@ -699,6 +788,10 @@ export {
|
|
|
699
788
|
setBriefCategoryInQuery,
|
|
700
789
|
toggleBriefFilterInQuery,
|
|
701
790
|
} from "./lib/brief-filter-query.js";
|
|
791
|
+
export {
|
|
792
|
+
type CalendarColorClasses,
|
|
793
|
+
calendarColorClasses,
|
|
794
|
+
} from "./lib/calendar-color.js";
|
|
702
795
|
export {
|
|
703
796
|
buildCidResolver,
|
|
704
797
|
type CidResolvableBodyPart,
|
|
@@ -731,6 +824,11 @@ export {
|
|
|
731
824
|
sanitizeInlineStyle,
|
|
732
825
|
sanitizeStyleElementCss,
|
|
733
826
|
} from "./lib/email-sanitizer.js";
|
|
827
|
+
export {
|
|
828
|
+
addMinutesToClock,
|
|
829
|
+
type PhraseParse,
|
|
830
|
+
parseEventPhrase,
|
|
831
|
+
} from "./lib/event-phrase.js";
|
|
734
832
|
export {
|
|
735
833
|
collapseFolderTree,
|
|
736
834
|
filterFolderTree,
|
|
@@ -787,6 +885,23 @@ export {
|
|
|
787
885
|
normalizeSubject,
|
|
788
886
|
sharedSubjectFragment,
|
|
789
887
|
} from "./lib/property-prefill.js";
|
|
888
|
+
export {
|
|
889
|
+
type CustomRecurrence,
|
|
890
|
+
dayOfMonthLabel,
|
|
891
|
+
defaultCustomRecurrence,
|
|
892
|
+
defaultEndDate,
|
|
893
|
+
endDateLabel,
|
|
894
|
+
formatCustomRecurrence,
|
|
895
|
+
type MonthlyMode,
|
|
896
|
+
NO_REPEAT,
|
|
897
|
+
ordinalWeekdayLabel,
|
|
898
|
+
type RecurrenceEnd,
|
|
899
|
+
type RecurrenceUnit,
|
|
900
|
+
readCustomRecurrence,
|
|
901
|
+
repeatChoices,
|
|
902
|
+
WEEKDAY_INITIALS,
|
|
903
|
+
weekdayName,
|
|
904
|
+
} from "./lib/recurrence.js";
|
|
790
905
|
export {
|
|
791
906
|
LIST_ROW_ATTRIBUTE,
|
|
792
907
|
LIST_ROW_SELECTOR,
|
|
@@ -835,6 +950,7 @@ export {
|
|
|
835
950
|
type UseLongPressResult,
|
|
836
951
|
useLongPress,
|
|
837
952
|
} from "./lib/use-long-press.js";
|
|
953
|
+
export { useMatchMedia } from "./lib/use-match-media.js";
|
|
838
954
|
export {
|
|
839
955
|
MESSAGE_ROW_SELECTOR,
|
|
840
956
|
useRenderedRowIds,
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deliberately small natural-language reader for the quick-entry field.
|
|
3
|
+
*
|
|
4
|
+
* It handles the phrasings a person actually types at a calendar — a title, a
|
|
5
|
+
* day, a time, a length, and who is coming — and it reports what it consumed
|
|
6
|
+
* for each one. The point is not coverage; it is that the machine's reading is
|
|
7
|
+
* on screen, attributed to the words it came from, before anything is
|
|
8
|
+
* committed. Whatever it could not settle comes back in `unresolved`, and
|
|
9
|
+
* whatever it filled in for you comes back in `assumptions`. Both are meant to
|
|
10
|
+
* be rendered, not swallowed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface PhraseParse {
|
|
14
|
+
/** What is left after the day, time, length and guests are taken out. */
|
|
15
|
+
title: string;
|
|
16
|
+
/** `YYYY-MM-DD`, empty when the phrase named no day. */
|
|
17
|
+
date: string;
|
|
18
|
+
/** The words the day was read from, e.g. "friday". */
|
|
19
|
+
dateText: string;
|
|
20
|
+
/** `HH:MM` on a 24-hour clock, empty when the phrase named no time. */
|
|
21
|
+
startTime: string;
|
|
22
|
+
startTimeText: string;
|
|
23
|
+
/** Minutes; 0 when the phrase named no length. */
|
|
24
|
+
durationMinutes: number;
|
|
25
|
+
durationText: string;
|
|
26
|
+
attendees: string[];
|
|
27
|
+
attendeesText: string;
|
|
28
|
+
/** Things the phrase never said, in the words the UI should show. */
|
|
29
|
+
unresolved: string[];
|
|
30
|
+
/** Defaults applied on the reader's own authority. */
|
|
31
|
+
assumptions: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const WEEKDAYS: Record<string, number> = {
|
|
35
|
+
sun: 0,
|
|
36
|
+
sunday: 0,
|
|
37
|
+
mon: 1,
|
|
38
|
+
monday: 1,
|
|
39
|
+
tue: 2,
|
|
40
|
+
tues: 2,
|
|
41
|
+
tuesday: 2,
|
|
42
|
+
wed: 3,
|
|
43
|
+
weds: 3,
|
|
44
|
+
wednesday: 3,
|
|
45
|
+
thu: 4,
|
|
46
|
+
thur: 4,
|
|
47
|
+
thurs: 4,
|
|
48
|
+
thursday: 4,
|
|
49
|
+
fri: 5,
|
|
50
|
+
friday: 5,
|
|
51
|
+
sat: 6,
|
|
52
|
+
saturday: 6,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const WEEKDAY_ALTERNATION = Object.keys(WEEKDAYS)
|
|
56
|
+
.sort((a, b) => b.length - a.length)
|
|
57
|
+
.join("|");
|
|
58
|
+
|
|
59
|
+
const DURATION_RE =
|
|
60
|
+
/\b(?:for\s+)?(\d+(?:[.,]\d+)?)\s*(hours|hour|hrs|hr|h|minutes|minute|mins|min|m)\b/i;
|
|
61
|
+
const CLOCK_RE =
|
|
62
|
+
/\b(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm)\b|\b(\d{1,2}):(\d{2})\b/i;
|
|
63
|
+
const NOON_RE = /\b(noon|midday|midnight)\b/i;
|
|
64
|
+
const RELATIVE_DAY_RE = /\b(today|tonight|tomorrow)\b/i;
|
|
65
|
+
const NEXT_WEEKDAY_RE = new RegExp(
|
|
66
|
+
`\\bnext\\s+(${WEEKDAY_ALTERNATION})\\b`,
|
|
67
|
+
"i",
|
|
68
|
+
);
|
|
69
|
+
const WEEKDAY_RE = new RegExp(`\\b(?:on\\s+)?(${WEEKDAY_ALTERNATION})\\b`, "i");
|
|
70
|
+
const WITH_RE = /\bwith\s+(.+)$/i;
|
|
71
|
+
|
|
72
|
+
function pad(n: number): string {
|
|
73
|
+
return String(n).padStart(2, "0");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isoDate(d: Date): string {
|
|
77
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function addDays(d: Date, days: number): Date {
|
|
81
|
+
const next = new Date(d);
|
|
82
|
+
next.setDate(next.getDate() + days);
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The soonest day with this weekday, today included. */
|
|
87
|
+
function comingWeekday(now: Date, weekday: number): Date {
|
|
88
|
+
return addDays(now, (weekday - now.getDay() + 7) % 7);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function minutesFrom(amount: number, unit: string): number {
|
|
92
|
+
const isHours = /^h/i.test(unit);
|
|
93
|
+
return Math.round(isHours ? amount * 60 : amount);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cut(source: string, match: RegExpMatchArray): string {
|
|
97
|
+
const at = match.index ?? 0;
|
|
98
|
+
return `${source.slice(0, at)} ${source.slice(at + match[0].length)}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function tidy(text: string): string {
|
|
102
|
+
return text
|
|
103
|
+
.replace(/\s+/g, " ")
|
|
104
|
+
.replace(/^[\s,;–-]+|[\s,;–-]+$/g, "")
|
|
105
|
+
.replace(/\s+(on|at|with|for|from)$/i, "")
|
|
106
|
+
.trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function splitNames(clause: string): string[] {
|
|
110
|
+
return clause
|
|
111
|
+
.split(/\s*(?:,|\band\b|&)\s*/i)
|
|
112
|
+
.map((name) => tidy(name))
|
|
113
|
+
.filter((name) => name.length > 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readClock(match: RegExpMatchArray): string {
|
|
117
|
+
if (match[3]) {
|
|
118
|
+
const meridiem = match[3].toLowerCase();
|
|
119
|
+
const hour12 = Number(match[1]) % 12;
|
|
120
|
+
const hour = meridiem === "pm" ? hour12 + 12 : hour12;
|
|
121
|
+
return `${pad(hour)}:${pad(Number(match[2] ?? 0))}`;
|
|
122
|
+
}
|
|
123
|
+
return `${pad(Number(match[4]))}:${pad(Number(match[5]))}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Reads `phrase` against `now`. `now` is a parameter rather than a call to the
|
|
128
|
+
* clock so the same phrase always gives the same answer in a story and a test.
|
|
129
|
+
*/
|
|
130
|
+
export function parseEventPhrase(phrase: string, now: Date): PhraseParse {
|
|
131
|
+
let rest = ` ${phrase} `;
|
|
132
|
+
const unresolved: string[] = [];
|
|
133
|
+
const assumptions: string[] = [];
|
|
134
|
+
|
|
135
|
+
let durationMinutes = 0;
|
|
136
|
+
let durationText = "";
|
|
137
|
+
const duration = rest.match(DURATION_RE);
|
|
138
|
+
if (duration) {
|
|
139
|
+
durationMinutes = minutesFrom(
|
|
140
|
+
Number(duration[1].replace(",", ".")),
|
|
141
|
+
duration[2],
|
|
142
|
+
);
|
|
143
|
+
durationText = duration[0].trim();
|
|
144
|
+
rest = cut(rest, duration);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let startTime = "";
|
|
148
|
+
let startTimeText = "";
|
|
149
|
+
const named = rest.match(NOON_RE);
|
|
150
|
+
const clock = rest.match(CLOCK_RE);
|
|
151
|
+
if (named) {
|
|
152
|
+
const word = named[1].toLowerCase();
|
|
153
|
+
startTime = word === "midnight" ? "00:00" : "12:00";
|
|
154
|
+
startTimeText = named[0].trim();
|
|
155
|
+
rest = cut(rest, named);
|
|
156
|
+
} else if (clock) {
|
|
157
|
+
startTime = readClock(clock);
|
|
158
|
+
startTimeText = clock[0].trim();
|
|
159
|
+
rest = cut(rest, clock);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let date = "";
|
|
163
|
+
let dateText = "";
|
|
164
|
+
const relative = rest.match(RELATIVE_DAY_RE);
|
|
165
|
+
const nextWeekday = rest.match(NEXT_WEEKDAY_RE);
|
|
166
|
+
const weekday = rest.match(WEEKDAY_RE);
|
|
167
|
+
if (relative) {
|
|
168
|
+
const word = relative[1].toLowerCase();
|
|
169
|
+
date = isoDate(word === "tomorrow" ? addDays(now, 1) : now);
|
|
170
|
+
dateText = relative[0].trim();
|
|
171
|
+
rest = cut(rest, relative);
|
|
172
|
+
} else if (nextWeekday) {
|
|
173
|
+
date = isoDate(
|
|
174
|
+
addDays(comingWeekday(now, WEEKDAYS[nextWeekday[1].toLowerCase()]), 7),
|
|
175
|
+
);
|
|
176
|
+
dateText = nextWeekday[0].trim();
|
|
177
|
+
rest = cut(rest, nextWeekday);
|
|
178
|
+
} else if (weekday) {
|
|
179
|
+
date = isoDate(comingWeekday(now, WEEKDAYS[weekday[1].toLowerCase()]));
|
|
180
|
+
dateText = weekday[0].trim();
|
|
181
|
+
rest = cut(rest, weekday);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let attendees: string[] = [];
|
|
185
|
+
let attendeesText = "";
|
|
186
|
+
const guests = tidy(rest).match(WITH_RE);
|
|
187
|
+
if (guests) {
|
|
188
|
+
attendees = splitNames(guests[1]);
|
|
189
|
+
attendeesText = guests[0].trim();
|
|
190
|
+
rest = tidy(rest).slice(0, guests.index ?? 0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const title = tidy(rest);
|
|
194
|
+
|
|
195
|
+
if (title === "") unresolved.push("No title yet");
|
|
196
|
+
if (date === "") {
|
|
197
|
+
date = isoDate(now);
|
|
198
|
+
assumptions.push("No day given — using today");
|
|
199
|
+
}
|
|
200
|
+
if (startTime === "") {
|
|
201
|
+
unresolved.push("No time given");
|
|
202
|
+
} else if (durationMinutes === 0) {
|
|
203
|
+
durationMinutes = 60;
|
|
204
|
+
assumptions.push("No length given — using an hour");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
title,
|
|
209
|
+
date,
|
|
210
|
+
dateText,
|
|
211
|
+
startTime,
|
|
212
|
+
startTimeText,
|
|
213
|
+
durationMinutes,
|
|
214
|
+
durationText,
|
|
215
|
+
attendees,
|
|
216
|
+
attendeesText,
|
|
217
|
+
unresolved,
|
|
218
|
+
assumptions,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** `13:00` + 90 → `14:30`. Empty in, empty out. */
|
|
223
|
+
export function addMinutesToClock(clock: string, minutes: number): string {
|
|
224
|
+
if (clock === "") return "";
|
|
225
|
+
const [hours, mins] = clock.split(":").map(Number);
|
|
226
|
+
const total = (hours * 60 + mins + minutes) % 1440;
|
|
227
|
+
return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
|
|
228
|
+
}
|