@remit/ui 0.0.112 → 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.
- package/package.json +1 -1
- package/src/components/app-shell-slotted.render.test.ts +83 -0
- package/src/components/app-shell-slotted.tsx +31 -3
- package/src/components/app-shell-types.ts +10 -0
- 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.render.test.ts +94 -0
- package/src/components/intelligence-panel.stories.tsx +10 -6
- package/src/components/intelligence-panel.tsx +7 -5
- package/src/components/message-list-pane.render.test.ts +2 -2
- package/src/components/message-list-pane.stories.tsx +1 -1
- package/src/components/mobile-reading-pane.render.test.ts +1 -1
- package/src/components/nav-sidebar.tsx +20 -0
- 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/selection-wizard.tsx +19 -69
- package/src/index.ts +112 -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/tokens.css +63 -0
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export {
|
|
|
30
30
|
type Density,
|
|
31
31
|
INTELLIGENCE_MIN_WIDTH,
|
|
32
32
|
isBriefCategory,
|
|
33
|
+
isThreadCategory,
|
|
33
34
|
type MessageListKeyboard,
|
|
34
35
|
type MessageListSelection,
|
|
35
36
|
type NarrowView,
|
|
@@ -57,6 +58,14 @@ export {
|
|
|
57
58
|
AttachmentList,
|
|
58
59
|
type AttachmentListProps,
|
|
59
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";
|
|
60
69
|
export { AuthCard, type AuthCardProps } from "./components/auth-card.js";
|
|
61
70
|
export {
|
|
62
71
|
AuthFooter,
|
|
@@ -111,6 +120,35 @@ export {
|
|
|
111
120
|
type ButtonLinkProps,
|
|
112
121
|
type ButtonProps,
|
|
113
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";
|
|
114
152
|
export {
|
|
115
153
|
Card,
|
|
116
154
|
CardBody,
|
|
@@ -165,6 +203,12 @@ export {
|
|
|
165
203
|
ConfirmDialog,
|
|
166
204
|
type ConfirmDialogProps,
|
|
167
205
|
} from "./components/confirm-dialog.js";
|
|
206
|
+
export {
|
|
207
|
+
CustomRecurrenceDialog,
|
|
208
|
+
type CustomRecurrenceDialogProps,
|
|
209
|
+
CustomRecurrenceEditor,
|
|
210
|
+
type CustomRecurrenceEditorProps,
|
|
211
|
+
} from "./components/custom-recurrence.js";
|
|
168
212
|
export {
|
|
169
213
|
DangerZoneSection,
|
|
170
214
|
type DangerZoneSectionProps,
|
|
@@ -175,6 +219,36 @@ export {
|
|
|
175
219
|
type DialogBackdropProps,
|
|
176
220
|
} from "./components/dialog-backdrop.js";
|
|
177
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";
|
|
178
252
|
export {
|
|
179
253
|
FieldLabel,
|
|
180
254
|
type FieldLabelProps,
|
|
@@ -244,6 +318,12 @@ export {
|
|
|
244
318
|
type FilterSheetSource,
|
|
245
319
|
FilterToggle,
|
|
246
320
|
} from "./components/filter-sheet.js";
|
|
321
|
+
export {
|
|
322
|
+
FlowScreen,
|
|
323
|
+
type FlowScreenProps,
|
|
324
|
+
FlowStepRail,
|
|
325
|
+
type FlowStepRailProps,
|
|
326
|
+
} from "./components/flow-screen.js";
|
|
247
327
|
export {
|
|
248
328
|
FolderManageActions,
|
|
249
329
|
type FolderManageActionsProps,
|
|
@@ -447,6 +527,10 @@ export {
|
|
|
447
527
|
ReadingPaneEmpty,
|
|
448
528
|
type ReadingPaneEmptyProps,
|
|
449
529
|
} from "./components/reading-pane-empty.js";
|
|
530
|
+
export {
|
|
531
|
+
RecurrenceScopePrompt,
|
|
532
|
+
type RecurrenceScopePromptProps,
|
|
533
|
+
} from "./components/recurrence-scope-prompt.js";
|
|
450
534
|
export {
|
|
451
535
|
RefreshButton,
|
|
452
536
|
type RefreshButtonProps,
|
|
@@ -564,6 +648,8 @@ export {
|
|
|
564
648
|
export {
|
|
565
649
|
FolderStepBody,
|
|
566
650
|
type FolderStepProps,
|
|
651
|
+
FooterNav,
|
|
652
|
+
type FooterNavProps,
|
|
567
653
|
MatchStepBody,
|
|
568
654
|
type MatchStepProps,
|
|
569
655
|
NameStepBody,
|
|
@@ -698,6 +784,10 @@ export {
|
|
|
698
784
|
setBriefCategoryInQuery,
|
|
699
785
|
toggleBriefFilterInQuery,
|
|
700
786
|
} from "./lib/brief-filter-query.js";
|
|
787
|
+
export {
|
|
788
|
+
type CalendarColorClasses,
|
|
789
|
+
calendarColorClasses,
|
|
790
|
+
} from "./lib/calendar-color.js";
|
|
701
791
|
export {
|
|
702
792
|
buildCidResolver,
|
|
703
793
|
type CidResolvableBodyPart,
|
|
@@ -730,6 +820,11 @@ export {
|
|
|
730
820
|
sanitizeInlineStyle,
|
|
731
821
|
sanitizeStyleElementCss,
|
|
732
822
|
} from "./lib/email-sanitizer.js";
|
|
823
|
+
export {
|
|
824
|
+
addMinutesToClock,
|
|
825
|
+
type PhraseParse,
|
|
826
|
+
parseEventPhrase,
|
|
827
|
+
} from "./lib/event-phrase.js";
|
|
733
828
|
export {
|
|
734
829
|
collapseFolderTree,
|
|
735
830
|
filterFolderTree,
|
|
@@ -786,6 +881,23 @@ export {
|
|
|
786
881
|
normalizeSubject,
|
|
787
882
|
sharedSubjectFragment,
|
|
788
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";
|
|
789
901
|
export {
|
|
790
902
|
LIST_ROW_ATTRIBUTE,
|
|
791
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
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
type CustomRecurrence,
|
|
5
|
+
dayOfMonthLabel,
|
|
6
|
+
defaultCustomRecurrence,
|
|
7
|
+
defaultEndDate,
|
|
8
|
+
formatCustomRecurrence,
|
|
9
|
+
ordinalWeekdayLabel,
|
|
10
|
+
readCustomRecurrence,
|
|
11
|
+
repeatChoices,
|
|
12
|
+
} from "./recurrence.js";
|
|
13
|
+
|
|
14
|
+
/** Thursday 11 June 2026. */
|
|
15
|
+
const DATE = "2026-06-11";
|
|
16
|
+
|
|
17
|
+
const rule = (patch: Partial<CustomRecurrence> = {}): CustomRecurrence => ({
|
|
18
|
+
...defaultCustomRecurrence(DATE),
|
|
19
|
+
...patch,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("repeatChoices", () => {
|
|
23
|
+
it("derives the monthly choice from the date's own weekday", () => {
|
|
24
|
+
assert.ok(
|
|
25
|
+
repeatChoices(DATE, "").includes("Every month on the second Thursday"),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("appends the hour when there is one", () => {
|
|
30
|
+
assert.ok(repeatChoices(DATE, "09:15").includes("Every day, 09:15"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("offers weekdays only when the date is one", () => {
|
|
34
|
+
assert.ok(!repeatChoices("2026-06-13", "").includes("Every weekday"));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("defaultCustomRecurrence", () => {
|
|
39
|
+
it("starts on the event's own weekday", () => {
|
|
40
|
+
assert.deepEqual(defaultCustomRecurrence(DATE).weekdays, [4]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("survives a date it cannot read", () => {
|
|
44
|
+
assert.deepEqual(defaultCustomRecurrence("").weekdays, []);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("formatCustomRecurrence", () => {
|
|
49
|
+
it("reads a rule in words, never as an RRULE", () => {
|
|
50
|
+
const text = formatCustomRecurrence(
|
|
51
|
+
rule({
|
|
52
|
+
interval: 2,
|
|
53
|
+
weekdays: [1, 4],
|
|
54
|
+
ends: { kind: "onDate", date: "2026-10-03" },
|
|
55
|
+
}),
|
|
56
|
+
DATE,
|
|
57
|
+
"",
|
|
58
|
+
);
|
|
59
|
+
assert.equal(text, "Every 2 weeks on Monday and Thursday, until 3 October");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("drops the interval when it is one", () => {
|
|
63
|
+
assert.equal(
|
|
64
|
+
formatCustomRecurrence(rule({ weekdays: [4] }), DATE, ""),
|
|
65
|
+
"Every week on Thursday",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("counts an ending that is counted", () => {
|
|
70
|
+
assert.equal(
|
|
71
|
+
formatCustomRecurrence(
|
|
72
|
+
rule({ unit: "day", ends: { kind: "afterCount", count: 13 } }),
|
|
73
|
+
DATE,
|
|
74
|
+
"",
|
|
75
|
+
),
|
|
76
|
+
"Every day, 13 times",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("says which of the two monthly readings is meant", () => {
|
|
81
|
+
assert.equal(
|
|
82
|
+
formatCustomRecurrence(rule({ unit: "month" }), DATE, ""),
|
|
83
|
+
"Every month on day 11",
|
|
84
|
+
);
|
|
85
|
+
assert.equal(
|
|
86
|
+
formatCustomRecurrence(
|
|
87
|
+
rule({ unit: "month", monthlyMode: "weekdayOfMonth" }),
|
|
88
|
+
DATE,
|
|
89
|
+
"",
|
|
90
|
+
),
|
|
91
|
+
"Every month on the second Thursday",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("carries the hour", () => {
|
|
96
|
+
assert.equal(
|
|
97
|
+
formatCustomRecurrence(rule({ unit: "year" }), DATE, "09:15"),
|
|
98
|
+
"Every year on 11 June, 09:15",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("readCustomRecurrence", () => {
|
|
104
|
+
for (const seed of [
|
|
105
|
+
rule({ interval: 2, weekdays: [1, 4] }),
|
|
106
|
+
rule({ interval: 3, unit: "day" }),
|
|
107
|
+
rule({ unit: "month", monthlyMode: "weekdayOfMonth" }),
|
|
108
|
+
rule({ unit: "year" }),
|
|
109
|
+
rule({ weekdays: [0, 2, 6], ends: { kind: "afterCount", count: 13 } }),
|
|
110
|
+
rule({ ends: { kind: "onDate", date: "2026-10-03" } }),
|
|
111
|
+
])
|
|
112
|
+
it(`reopens on "${formatCustomRecurrence(seed, DATE, "")}"`, () => {
|
|
113
|
+
const text = formatCustomRecurrence(seed, DATE, "09:15");
|
|
114
|
+
const read = readCustomRecurrence(text, DATE);
|
|
115
|
+
assert.ok(read);
|
|
116
|
+
assert.equal(formatCustomRecurrence(read, DATE, "09:15"), text);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("declines a sentence it did not write", () => {
|
|
120
|
+
assert.equal(readCustomRecurrence("Every weekday, 09:15", DATE), undefined);
|
|
121
|
+
assert.equal(
|
|
122
|
+
readCustomRecurrence("whenever Jane is free", DATE),
|
|
123
|
+
undefined,
|
|
124
|
+
);
|
|
125
|
+
assert.equal(
|
|
126
|
+
readCustomRecurrence("Every week on Blursday", DATE),
|
|
127
|
+
undefined,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("labels", () => {
|
|
133
|
+
it("offers an end date a year out", () => {
|
|
134
|
+
assert.equal(defaultEndDate(DATE), "2027-06-11");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("names the two readings of the same day", () => {
|
|
138
|
+
assert.equal(dayOfMonthLabel(DATE), "day 11");
|
|
139
|
+
assert.equal(ordinalWeekdayLabel(DATE), "the second Thursday");
|
|
140
|
+
});
|
|
141
|
+
});
|