@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.
- 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/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/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/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 +111 -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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repeat rules in the words a person would say them.
|
|
3
|
+
*
|
|
4
|
+
* A rule is carried and shown as that sentence — "Every weekday, 09:15" — and
|
|
5
|
+
* never as an RRULE the reader has to decode. The picker offers the handful of
|
|
6
|
+
* rules a date can plausibly repeat by, worked out from the date itself, so
|
|
7
|
+
* "every month" means the second Wednesday when that is the Wednesday you
|
|
8
|
+
* clicked rather than a number nobody chose.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const WEEKDAY_NAMES = [
|
|
12
|
+
"Sunday",
|
|
13
|
+
"Monday",
|
|
14
|
+
"Tuesday",
|
|
15
|
+
"Wednesday",
|
|
16
|
+
"Thursday",
|
|
17
|
+
"Friday",
|
|
18
|
+
"Saturday",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const MONTH_NAMES = [
|
|
22
|
+
"January",
|
|
23
|
+
"February",
|
|
24
|
+
"March",
|
|
25
|
+
"April",
|
|
26
|
+
"May",
|
|
27
|
+
"June",
|
|
28
|
+
"July",
|
|
29
|
+
"August",
|
|
30
|
+
"September",
|
|
31
|
+
"October",
|
|
32
|
+
"November",
|
|
33
|
+
"December",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const ORDINALS = ["first", "second", "third", "fourth", "last"];
|
|
37
|
+
|
|
38
|
+
/** No repeat at all — the value a one-off carries. */
|
|
39
|
+
export const NO_REPEAT = "";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The rules on offer for a date, widest interval last. `startTime` is appended
|
|
43
|
+
* when there is one, because "every weekday" without an hour is only half a
|
|
44
|
+
* rule.
|
|
45
|
+
*/
|
|
46
|
+
export function repeatChoices(date: string, startTime: string): string[] {
|
|
47
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return [];
|
|
48
|
+
const [year, month, dayOfMonth] = date.split("-").map(Number);
|
|
49
|
+
const when = new Date(Date.UTC(year, month - 1, dayOfMonth));
|
|
50
|
+
const weekday = WEEKDAY_NAMES[when.getUTCDay()];
|
|
51
|
+
const ordinal = ORDINALS[Math.min(Math.floor((dayOfMonth - 1) / 7), 4)];
|
|
52
|
+
const clock = startTime === "" ? "" : `, ${startTime}`;
|
|
53
|
+
const onWeekdays = when.getUTCDay() >= 1 && when.getUTCDay() <= 5;
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
`Every day${clock}`,
|
|
57
|
+
...(onWeekdays ? [`Every weekday${clock}`] : []),
|
|
58
|
+
`Every week on ${weekday}${clock}`,
|
|
59
|
+
`Every month on the ${ordinal} ${weekday}${clock}`,
|
|
60
|
+
`Every year on ${dayOfMonth} ${MONTH_NAMES[month - 1]}${clock}`,
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ------------------------------------------------------------------ */
|
|
65
|
+
/* A rule the offered ones do not cover */
|
|
66
|
+
/* ------------------------------------------------------------------ */
|
|
67
|
+
|
|
68
|
+
export type RecurrenceUnit = "day" | "week" | "month" | "year";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A monthly rule can mean two different things about the same date, and which
|
|
72
|
+
* one is meant is never derivable — the 14th and the second Tuesday coincide
|
|
73
|
+
* this month and diverge the next.
|
|
74
|
+
*/
|
|
75
|
+
export type MonthlyMode = "dayOfMonth" | "weekdayOfMonth";
|
|
76
|
+
|
|
77
|
+
export type RecurrenceEnd =
|
|
78
|
+
| { kind: "never" }
|
|
79
|
+
| { kind: "onDate"; date: string }
|
|
80
|
+
| { kind: "afterCount"; count: number };
|
|
81
|
+
|
|
82
|
+
export interface CustomRecurrence {
|
|
83
|
+
/** How many units between occurrences. One means every one. */
|
|
84
|
+
interval: number;
|
|
85
|
+
unit: RecurrenceUnit;
|
|
86
|
+
/** Weekday indexes, Sunday first. Read only when the unit is a week. */
|
|
87
|
+
weekdays: number[];
|
|
88
|
+
/** Read only when the unit is a month. */
|
|
89
|
+
monthlyMode: MonthlyMode;
|
|
90
|
+
ends: RecurrenceEnd;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Single-letter weekday labels, Sunday first, for a row of toggles. */
|
|
94
|
+
export const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"];
|
|
95
|
+
|
|
96
|
+
export const weekdayName = (index: number): string => WEEKDAY_NAMES[index];
|
|
97
|
+
|
|
98
|
+
function parseDate(date: string): Date | undefined {
|
|
99
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return undefined;
|
|
100
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
101
|
+
return new Date(Date.UTC(year, month - 1, day));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The event's own day is what a custom rule starts from. */
|
|
105
|
+
export function defaultCustomRecurrence(date: string): CustomRecurrence {
|
|
106
|
+
const when = parseDate(date);
|
|
107
|
+
return {
|
|
108
|
+
interval: 1,
|
|
109
|
+
unit: "week",
|
|
110
|
+
weekdays: when ? [when.getUTCDay()] : [],
|
|
111
|
+
monthlyMode: "dayOfMonth",
|
|
112
|
+
ends: { kind: "never" },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What the "on" ending offers before anyone picks a day: a year out, so the
|
|
118
|
+
* field reads as a date rather than as an empty box.
|
|
119
|
+
*/
|
|
120
|
+
export function defaultEndDate(date: string): string {
|
|
121
|
+
const when = parseDate(date);
|
|
122
|
+
if (!when) return date;
|
|
123
|
+
when.setUTCFullYear(when.getUTCFullYear() + 1);
|
|
124
|
+
return when.toISOString().slice(0, 10);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** "the second Tuesday", derived from the date the event sits on. */
|
|
128
|
+
export function ordinalWeekdayLabel(date: string): string {
|
|
129
|
+
const when = parseDate(date);
|
|
130
|
+
if (!when) return "";
|
|
131
|
+
const dayOfMonth = when.getUTCDate();
|
|
132
|
+
return `the ${ORDINALS[Math.min(Math.floor((dayOfMonth - 1) / 7), 4)]} ${
|
|
133
|
+
WEEKDAY_NAMES[when.getUTCDay()]
|
|
134
|
+
}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** "day 14", the other reading of the same date. */
|
|
138
|
+
export function dayOfMonthLabel(date: string): string {
|
|
139
|
+
const when = parseDate(date);
|
|
140
|
+
return when ? `day ${when.getUTCDate()}` : "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** "3 October" — an end date read the way the rule reads it back. */
|
|
144
|
+
export function endDateLabel(date: string): string {
|
|
145
|
+
const when = parseDate(date);
|
|
146
|
+
return when
|
|
147
|
+
? `${when.getUTCDate()} ${MONTH_NAMES[when.getUTCMonth()]}`
|
|
148
|
+
: date;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function joinWords(parts: string[]): string {
|
|
152
|
+
if (parts.length <= 1) return parts.join("");
|
|
153
|
+
return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The rule as a sentence. Everything the editor can express reads back in
|
|
158
|
+
* words — an RRULE is never shown, and never typed.
|
|
159
|
+
*/
|
|
160
|
+
export function formatCustomRecurrence(
|
|
161
|
+
rule: CustomRecurrence,
|
|
162
|
+
date: string,
|
|
163
|
+
startTime: string,
|
|
164
|
+
): string {
|
|
165
|
+
const every =
|
|
166
|
+
rule.interval <= 1
|
|
167
|
+
? `Every ${rule.unit}`
|
|
168
|
+
: `Every ${rule.interval} ${rule.unit}s`;
|
|
169
|
+
|
|
170
|
+
let on = "";
|
|
171
|
+
if (rule.unit === "week" && rule.weekdays.length > 0)
|
|
172
|
+
on = ` on ${joinWords(
|
|
173
|
+
[...rule.weekdays].sort((a, b) => a - b).map(weekdayName),
|
|
174
|
+
)}`;
|
|
175
|
+
if (rule.unit === "month")
|
|
176
|
+
on = ` on ${
|
|
177
|
+
rule.monthlyMode === "dayOfMonth"
|
|
178
|
+
? dayOfMonthLabel(date)
|
|
179
|
+
: ordinalWeekdayLabel(date)
|
|
180
|
+
}`;
|
|
181
|
+
if (rule.unit === "year") {
|
|
182
|
+
const when = parseDate(date);
|
|
183
|
+
if (when)
|
|
184
|
+
on = ` on ${when.getUTCDate()} ${MONTH_NAMES[when.getUTCMonth()]}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const clock = startTime === "" ? "" : `, ${startTime}`;
|
|
188
|
+
const ends =
|
|
189
|
+
rule.ends.kind === "onDate"
|
|
190
|
+
? `, until ${endDateLabel(rule.ends.date)}`
|
|
191
|
+
: rule.ends.kind === "afterCount"
|
|
192
|
+
? `, ${rule.ends.count} times`
|
|
193
|
+
: "";
|
|
194
|
+
|
|
195
|
+
return `${every}${on}${clock}${ends}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The inverse of `formatCustomRecurrence`, so reopening the editor shows the
|
|
200
|
+
* rule the event already carries. A sentence it did not write — one of the
|
|
201
|
+
* offered choices, or a rule read out of a mail — returns nothing, and the
|
|
202
|
+
* editor starts from the event's own day instead.
|
|
203
|
+
*/
|
|
204
|
+
export function readCustomRecurrence(
|
|
205
|
+
text: string,
|
|
206
|
+
date: string,
|
|
207
|
+
): CustomRecurrence | undefined {
|
|
208
|
+
let rest = text.trim();
|
|
209
|
+
let ends: RecurrenceEnd = { kind: "never" };
|
|
210
|
+
|
|
211
|
+
const times = rest.match(/,\s*(\d+) times$/);
|
|
212
|
+
if (times) {
|
|
213
|
+
ends = { kind: "afterCount", count: Number(times[1]) };
|
|
214
|
+
rest = rest.slice(0, times.index);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const until = rest.match(/,\s*until (\d{1,2}) ([A-Z][a-z]+)$/);
|
|
218
|
+
if (until) {
|
|
219
|
+
const endDate = isoForDayMonth(Number(until[1]), until[2], date);
|
|
220
|
+
if (endDate === "") return undefined;
|
|
221
|
+
ends = { kind: "onDate", date: endDate };
|
|
222
|
+
rest = rest.slice(0, until.index);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
rest = rest.replace(/,\s*\d{2}:\d{2}$/, "");
|
|
226
|
+
|
|
227
|
+
const head = rest.match(
|
|
228
|
+
/^Every (?:(\d+) )?(day|week|month|year)s?(?: on (.+))?$/,
|
|
229
|
+
);
|
|
230
|
+
if (!head) return undefined;
|
|
231
|
+
|
|
232
|
+
const interval = head[1] === undefined ? 1 : Number(head[1]);
|
|
233
|
+
const unit = head[2] as RecurrenceUnit;
|
|
234
|
+
const on = head[3] ?? "";
|
|
235
|
+
const base = { ...defaultCustomRecurrence(date), interval, unit, ends };
|
|
236
|
+
|
|
237
|
+
if (unit === "week") {
|
|
238
|
+
if (on === "") return base;
|
|
239
|
+
const names = on.split(/,\s*|\s+and\s+/);
|
|
240
|
+
const weekdays = names.map((name) => WEEKDAY_NAMES.indexOf(name));
|
|
241
|
+
if (weekdays.some((index) => index === -1)) return undefined;
|
|
242
|
+
return { ...base, weekdays };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (unit === "month") {
|
|
246
|
+
if (/^day \d{1,2}$/.test(on)) return { ...base, monthlyMode: "dayOfMonth" };
|
|
247
|
+
if (/^the \w+ [A-Z][a-z]+$/.test(on))
|
|
248
|
+
return { ...base, monthlyMode: "weekdayOfMonth" };
|
|
249
|
+
return on === "" ? base : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return base;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** "3 October" against the event's own year, rolling forward if it has passed. */
|
|
256
|
+
function isoForDayMonth(day: number, month: string, from: string): string {
|
|
257
|
+
const index = MONTH_NAMES.indexOf(month);
|
|
258
|
+
const start = parseDate(from);
|
|
259
|
+
if (index === -1 || !start) return "";
|
|
260
|
+
const sameYear = new Date(Date.UTC(start.getUTCFullYear(), index, day));
|
|
261
|
+
const when =
|
|
262
|
+
sameYear < start
|
|
263
|
+
? new Date(Date.UTC(start.getUTCFullYear() + 1, index, day))
|
|
264
|
+
: sameYear;
|
|
265
|
+
return when.toISOString().slice(0, 10);
|
|
266
|
+
}
|