@remit/ui 0.0.155 → 0.0.156

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.155",
3
+ "version": "0.0.156",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -40,6 +40,7 @@
40
40
  "react-resizable-panels": "^2.1.9",
41
41
  "react-simple-pull-to-refresh": "^1.3.4",
42
42
  "tailwind-merge": "^2",
43
+ "temporal-polyfill": "^1.0.1",
43
44
  "tldts": "^7.4.9"
44
45
  },
45
46
  "peerDependencies": {
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Typing is the create path, so the field is never off screen and the reading
3
+ * of the sentence is shown back with the words each part came from. Where the
4
+ * sentence has two honest readings the reader is asked, and that question is a
5
+ * control rather than a note — both are asserted here.
6
+ */
7
+ import "@remit/test-dom";
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { createElement } from "react";
11
+ import { renderToString } from "react-dom/server";
12
+ import {
13
+ AgendaComposer,
14
+ type AgendaComposerProps,
15
+ AgendaPhraseField,
16
+ PhraseReading,
17
+ } from "./agenda-composer.js";
18
+ import type {
19
+ AgendaParse,
20
+ CalendarDescriptor,
21
+ EventDraft,
22
+ } from "./calendar-types.js";
23
+
24
+ const calendars: CalendarDescriptor[] = [
25
+ {
26
+ id: "c1",
27
+ accountId: "a1",
28
+ accountLabel: "Work",
29
+ name: "Northwind",
30
+ color: "cal-1",
31
+ },
32
+ ];
33
+
34
+ const draft: EventDraft = {
35
+ title: "Lunch with Jane",
36
+ date: "2026-06-12",
37
+ startTime: "13:00",
38
+ endTime: "14:00",
39
+ allDay: false,
40
+ calendarId: "c1",
41
+ location: "",
42
+ guests: "Jane",
43
+ notes: "",
44
+ repeat: "",
45
+ };
46
+
47
+ const parse: AgendaParse = {
48
+ title: "Lunch with Jane",
49
+ date: "2026-06-12",
50
+ dateText: "friday",
51
+ startTime: "13:00",
52
+ startTimeText: "1pm",
53
+ endTime: "14:00",
54
+ durationMinutes: 60,
55
+ durationText: "",
56
+ attendees: ["Jane"],
57
+ attendeesText: "with Jane",
58
+ location: "",
59
+ locationText: "",
60
+ repeat: "",
61
+ repeatText: "",
62
+ assumptions: ["An hour, because the sentence never said."],
63
+ unresolved: [],
64
+ choices: [],
65
+ };
66
+
67
+ const ambiguous: AgendaParse = {
68
+ ...parse,
69
+ unresolved: ["No place given."],
70
+ choices: [
71
+ {
72
+ id: "which_friday",
73
+ question: "Which Friday?",
74
+ source: "friday",
75
+ options: [
76
+ { id: "this", label: "12 June", date: "2026-06-12", startTime: "" },
77
+ { id: "next", label: "19 June", date: "2026-06-19", startTime: "" },
78
+ ],
79
+ chosenId: "this",
80
+ },
81
+ ],
82
+ };
83
+
84
+ const base: AgendaComposerProps = {
85
+ phrase: "lunch with Jane friday 1pm",
86
+ onPhraseChange: () => {},
87
+ parse,
88
+ picks: {},
89
+ onPick: () => {},
90
+ draft,
91
+ onDraftChange: () => {},
92
+ calendars,
93
+ expanded: false,
94
+ onToggleExpanded: () => {},
95
+ onSave: () => {},
96
+ onCancel: () => {},
97
+ open: true,
98
+ onOpen: () => {},
99
+ };
100
+
101
+ const render = (props: Partial<AgendaComposerProps> = {}) =>
102
+ renderToString(createElement(AgendaComposer, { ...base, ...props }));
103
+
104
+ describe("AgendaPhraseField", () => {
105
+ it("names the field rather than leaving the placeholder to do it", () => {
106
+ const html = renderToString(
107
+ createElement(AgendaPhraseField, {
108
+ phrase: "",
109
+ onPhraseChange: () => {},
110
+ onOpen: () => {},
111
+ onCommit: () => {},
112
+ }),
113
+ );
114
+ assert.match(html, /aria-label="Describe the event"/);
115
+ assert.match(html, /placeholder="lunch with Jane friday 1pm"/);
116
+ });
117
+
118
+ it("grows the field where a finger has to hit it", () => {
119
+ const html = renderToString(
120
+ createElement(AgendaPhraseField, {
121
+ phrase: "",
122
+ onPhraseChange: () => {},
123
+ onOpen: () => {},
124
+ onCommit: () => {},
125
+ touch: true,
126
+ }),
127
+ );
128
+ assert.match(html, /min-h-11/);
129
+ });
130
+ });
131
+
132
+ describe("AgendaComposer", () => {
133
+ it("folds the form away until the reader opens it", () => {
134
+ assert.doesNotMatch(render({ open: false }), /Northwind/);
135
+ assert.match(render(), /Northwind/);
136
+ });
137
+
138
+ it("shows the reading back with the words each part came from", () => {
139
+ const html = render();
140
+ assert.match(html, /When/);
141
+ assert.match(html, /friday 1pm/);
142
+ assert.match(html, /Fri 12 Jun 13:00 – 14:00/);
143
+ });
144
+
145
+ it("keeps the reading off screen while there is nothing to read", () => {
146
+ const html = render({ phrase: " " });
147
+ assert.doesNotMatch(html, /An hour, because the sentence never said./);
148
+ assert.doesNotMatch(html, /bg-accent-2-soft/);
149
+ });
150
+
151
+ it("says a date on its own when the sentence gave no clock", () => {
152
+ const html = renderToString(
153
+ createElement(PhraseReading, {
154
+ parse: { ...parse, startTime: "", startTimeText: "" },
155
+ picks: {},
156
+ onPick: () => {},
157
+ }),
158
+ );
159
+ assert.match(html, /Fri 12 Jun/);
160
+ assert.doesNotMatch(html, /13:00/);
161
+ });
162
+
163
+ it("asks about a reading it could not settle instead of choosing", () => {
164
+ const html = render({ parse: ambiguous });
165
+ assert.match(html, /Which Friday\?/);
166
+ assert.match(html, /12 June/);
167
+ assert.match(html, /19 June/);
168
+ });
169
+
170
+ it("says which reading is currently applied", () => {
171
+ const html = render({ parse: ambiguous });
172
+ assert.match(html, /aria-pressed="true"/);
173
+ assert.match(html, /aria-pressed="false"/);
174
+ });
175
+
176
+ it("lets an answer override the reading the parser chose", () => {
177
+ const html = renderToString(
178
+ createElement(PhraseReading, {
179
+ parse: ambiguous,
180
+ picks: { which_friday: "next" },
181
+ onPick: () => {},
182
+ }),
183
+ );
184
+ const chosen = html.indexOf('aria-pressed="true"');
185
+ assert.ok(chosen > html.indexOf("12 June"), "the second option is pressed");
186
+ });
187
+
188
+ it("separates what it assumed from what the sentence never said", () => {
189
+ assert.match(render(), /An hour, because the sentence never said\./);
190
+ assert.match(render({ parse: ambiguous }), /No place given\./);
191
+ });
192
+
193
+ it("names the guests the sentence carried", () => {
194
+ assert.match(render(), /with Jane/);
195
+ });
196
+ });
@@ -0,0 +1,225 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { AgendaComposer, AgendaPhraseField } from "./agenda-composer.js";
4
+ import type {
5
+ AgendaParse,
6
+ CalendarDescriptor,
7
+ ChoicePicks,
8
+ EventDraft,
9
+ } from "./calendar-types.js";
10
+
11
+ /**
12
+ * Correcting the machine happens before the event exists. The sentence is read
13
+ * back with the words each part came from, and where it has two honest
14
+ * readings the composer asks rather than choosing.
15
+ */
16
+ const meta: Meta<typeof AgendaComposer> = {
17
+ title: "Calendar/Agenda composer",
18
+ component: AgendaComposer,
19
+ parameters: { layout: "padded" },
20
+ decorators: [
21
+ (Story) => (
22
+ <div className="max-w-96">
23
+ <Story />
24
+ </div>
25
+ ),
26
+ ],
27
+ };
28
+ export default meta;
29
+
30
+ type Story = StoryObj<typeof AgendaComposer>;
31
+
32
+ const calendars: CalendarDescriptor[] = [
33
+ {
34
+ id: "work",
35
+ accountId: "a1",
36
+ accountLabel: "Work",
37
+ name: "Northwind",
38
+ color: "cal-1",
39
+ },
40
+ {
41
+ id: "personal",
42
+ accountId: "a2",
43
+ accountLabel: "Personal",
44
+ name: "Family",
45
+ color: "cal-3",
46
+ },
47
+ ];
48
+
49
+ const draft: EventDraft = {
50
+ title: "Lunch with Jane",
51
+ date: "2026-06-12",
52
+ startTime: "13:00",
53
+ endTime: "14:00",
54
+ allDay: false,
55
+ calendarId: "work",
56
+ location: "",
57
+ guests: "Jane",
58
+ notes: "",
59
+ repeat: "",
60
+ };
61
+
62
+ const parse: AgendaParse = {
63
+ title: "Lunch with Jane",
64
+ date: "2026-06-12",
65
+ dateText: "friday",
66
+ startTime: "13:00",
67
+ startTimeText: "1pm",
68
+ endTime: "14:00",
69
+ durationMinutes: 60,
70
+ durationText: "",
71
+ attendees: ["Jane"],
72
+ attendeesText: "with Jane",
73
+ location: "",
74
+ locationText: "",
75
+ repeat: "",
76
+ repeatText: "",
77
+ assumptions: ["An hour long, because the sentence never said."],
78
+ unresolved: [],
79
+ choices: [],
80
+ };
81
+
82
+ const repeating: AgendaParse = {
83
+ ...parse,
84
+ title: "Standup",
85
+ startTime: "09:30",
86
+ endTime: "09:45",
87
+ startTimeText: "9:30",
88
+ dateText: "every weekday",
89
+ repeat: "Every weekday",
90
+ repeatText: "every weekday",
91
+ attendees: [],
92
+ attendeesText: "",
93
+ assumptions: ["Fifteen minutes, because the sentence never said."],
94
+ };
95
+
96
+ const ambiguous: AgendaParse = {
97
+ ...parse,
98
+ title: "Coffee with Marcus",
99
+ startTime: "08:00",
100
+ startTimeText: "at 8",
101
+ endTime: "09:00",
102
+ attendees: ["Marcus"],
103
+ attendeesText: "with Marcus",
104
+ unresolved: ["No place given."],
105
+ choices: [
106
+ {
107
+ id: "which_eight",
108
+ question: "Eight in the morning or eight at night?",
109
+ source: "at 8",
110
+ options: [
111
+ { id: "am", label: "08:00", date: "", startTime: "08:00" },
112
+ { id: "pm", label: "20:00", date: "", startTime: "20:00" },
113
+ ],
114
+ chosenId: "am",
115
+ },
116
+ ],
117
+ };
118
+
119
+ const base = {
120
+ onPhraseChange: () => {},
121
+ picks: {} as ChoicePicks,
122
+ onPick: () => {},
123
+ draft,
124
+ onDraftChange: () => {},
125
+ calendars,
126
+ expanded: false,
127
+ onToggleExpanded: () => {},
128
+ onSave: () => {},
129
+ onCancel: () => {},
130
+ onOpen: () => {},
131
+ };
132
+
133
+ /** The field on its own — where the composer starts every time. */
134
+ export const FieldOnly: Story = {
135
+ render: () => (
136
+ <AgendaPhraseField
137
+ phrase=""
138
+ onPhraseChange={() => {}}
139
+ onOpen={() => {}}
140
+ onCommit={() => {}}
141
+ />
142
+ ),
143
+ };
144
+
145
+ /** A sentence that read cleanly, with the reading shown back above the form. */
146
+ export const Read: Story = {
147
+ args: {
148
+ ...base,
149
+ phrase: "lunch with Jane friday 1pm",
150
+ parse,
151
+ open: true,
152
+ },
153
+ };
154
+
155
+ /** A rule the sentence carried, named as a rule rather than as one morning. */
156
+ export const Repeating: Story = {
157
+ args: {
158
+ ...base,
159
+ phrase: "standup every weekday 9:30",
160
+ parse: repeating,
161
+ draft: { ...draft, title: "Standup", repeat: "Every weekday" },
162
+ open: true,
163
+ },
164
+ };
165
+
166
+ /** Two honest readings: the question is a control, and the answer is one tap. */
167
+ export const Ambiguous: Story = {
168
+ args: {
169
+ ...base,
170
+ phrase: "coffee with Marcus at 8",
171
+ parse: ambiguous,
172
+ draft: { ...draft, title: "Coffee with Marcus", startTime: "08:00" },
173
+ open: true,
174
+ },
175
+ };
176
+
177
+ /** Folded away until there is something to correct. */
178
+ export const Folded: Story = {
179
+ args: {
180
+ ...base,
181
+ phrase: "lunch with Jane friday 1pm",
182
+ parse,
183
+ open: false,
184
+ },
185
+ };
186
+
187
+ /** Grown for a phone, where the form is the whole screen. */
188
+ export const Touch: Story = {
189
+ args: {
190
+ ...base,
191
+ phrase: "coffee with Marcus at 8",
192
+ parse: ambiguous,
193
+ open: true,
194
+ touch: true,
195
+ },
196
+ };
197
+
198
+ /** Answering the question moves the reading; nothing is settled behind you. */
199
+ export const Interactive: Story = {
200
+ render: () => {
201
+ const [picks, setPicks] = useState<ChoicePicks>({});
202
+ const [phrase, setPhrase] = useState("coffee with Marcus at 8");
203
+ const [open, setOpen] = useState(true);
204
+ const [expanded, setExpanded] = useState(false);
205
+ const [current, setCurrent] = useState(draft);
206
+ return (
207
+ <AgendaComposer
208
+ {...base}
209
+ phrase={phrase}
210
+ onPhraseChange={setPhrase}
211
+ parse={ambiguous}
212
+ picks={picks}
213
+ onPick={(choiceId, optionId) =>
214
+ setPicks((previous) => ({ ...previous, [choiceId]: optionId }))
215
+ }
216
+ draft={current}
217
+ onDraftChange={setCurrent}
218
+ expanded={expanded}
219
+ onToggleExpanded={() => setExpanded((value) => !value)}
220
+ open={open}
221
+ onOpen={() => setOpen(true)}
222
+ />
223
+ );
224
+ },
225
+ };
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Typing is the create path here, so the field is on screen at all times and
3
+ * the form is downstream of it.
4
+ *
5
+ * Every keystroke re-reads the sentence and writes the reading straight into
6
+ * the fields underneath, attributed to the words it came from. Where the
7
+ * sentence has two honest readings — which Friday, eight in the morning or
8
+ * eight at night — the reader asks instead of choosing, and the answer is one
9
+ * tap. Correcting the machine happens before the event exists.
10
+ */
11
+ import { AlertTriangle, Info, Wand2 } from "lucide-react";
12
+ import { formatShortDay } from "../lib/agenda-time.js";
13
+ import { cn } from "../lib/cn.js";
14
+ import type {
15
+ AgendaParse,
16
+ CalendarDescriptor,
17
+ ChoicePicks,
18
+ EventDraft,
19
+ } from "./calendar-types.js";
20
+ import { EventEditor } from "./event-editor.js";
21
+
22
+ export interface AgendaComposerProps {
23
+ phrase: string;
24
+ onPhraseChange: (phrase: string) => void;
25
+ parse: AgendaParse;
26
+ picks: ChoicePicks;
27
+ onPick: (choiceId: string, optionId: string) => void;
28
+ draft: EventDraft;
29
+ onDraftChange: (draft: EventDraft) => void;
30
+ calendars: CalendarDescriptor[];
31
+ expanded: boolean;
32
+ onToggleExpanded: () => void;
33
+ onSave: () => void;
34
+ onCancel: () => void;
35
+ saveLabel?: string;
36
+ /** The rule belongs to the series; an edit scoped to one instance reads it back. */
37
+ repeatEditable?: boolean;
38
+ /** Opens the custom-rule editor from the repeat picker. */
39
+ onCustomRepeat?: () => void;
40
+ /** The form is folded away until there is something to correct. */
41
+ open: boolean;
42
+ onOpen: () => void;
43
+ placeholder?: string;
44
+ touch?: boolean;
45
+ className?: string;
46
+ }
47
+
48
+ export interface AgendaPhraseFieldProps {
49
+ phrase: string;
50
+ onPhraseChange: (phrase: string) => void;
51
+ /** Focus or a keystroke unfolds the form under the field. */
52
+ onOpen: () => void;
53
+ onCommit: () => void;
54
+ placeholder?: string;
55
+ touch?: boolean;
56
+ className?: string;
57
+ }
58
+
59
+ export function AgendaPhraseField({
60
+ phrase,
61
+ onPhraseChange,
62
+ onOpen,
63
+ onCommit,
64
+ placeholder = "lunch with Jane friday 1pm",
65
+ touch,
66
+ className,
67
+ }: AgendaPhraseFieldProps) {
68
+ return (
69
+ <div
70
+ className={cn(
71
+ "flex items-center gap-2 rounded-md border border-line bg-surface-sunken px-3 focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30",
72
+ touch ? "min-h-11" : "h-9",
73
+ className,
74
+ )}
75
+ >
76
+ <Wand2 className="size-4 shrink-0 text-fg-subtle" aria-hidden />
77
+ <input
78
+ value={phrase}
79
+ aria-label="Describe the event"
80
+ placeholder={placeholder}
81
+ onFocus={onOpen}
82
+ onChange={(event) => {
83
+ onOpen();
84
+ onPhraseChange(event.target.value);
85
+ }}
86
+ onKeyDown={(event) => {
87
+ if (event.key === "Enter") onCommit();
88
+ }}
89
+ className="min-w-0 flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-subtle"
90
+ />
91
+ </div>
92
+ );
93
+ }
94
+
95
+ export function AgendaComposer({
96
+ phrase,
97
+ onPhraseChange,
98
+ parse,
99
+ picks,
100
+ onPick,
101
+ draft,
102
+ onDraftChange,
103
+ calendars,
104
+ expanded,
105
+ onToggleExpanded,
106
+ onSave,
107
+ onCancel,
108
+ saveLabel = "Add",
109
+ repeatEditable,
110
+ onCustomRepeat,
111
+ open,
112
+ onOpen,
113
+ placeholder = "lunch with Jane friday 1pm",
114
+ touch,
115
+ className,
116
+ }: AgendaComposerProps) {
117
+ return (
118
+ <div className={cn("flex flex-col gap-2", className)}>
119
+ <AgendaPhraseField
120
+ phrase={phrase}
121
+ onPhraseChange={onPhraseChange}
122
+ onOpen={onOpen}
123
+ onCommit={onSave}
124
+ placeholder={placeholder}
125
+ touch={touch}
126
+ />
127
+
128
+ {open && (
129
+ <EventEditor
130
+ draft={draft}
131
+ onChange={onDraftChange}
132
+ calendars={calendars}
133
+ expanded={expanded}
134
+ onToggleExpanded={onToggleExpanded}
135
+ onSave={onSave}
136
+ onCancel={onCancel}
137
+ saveLabel={saveLabel}
138
+ repeatEditable={repeatEditable}
139
+ onCustomRepeat={onCustomRepeat}
140
+ touch={touch}
141
+ header={
142
+ phrase.trim() === "" ? undefined : (
143
+ <PhraseReading
144
+ parse={parse}
145
+ picks={picks}
146
+ onPick={onPick}
147
+ touch={touch}
148
+ />
149
+ )
150
+ }
151
+ />
152
+ )}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ export function PhraseReading({
158
+ parse,
159
+ picks,
160
+ onPick,
161
+ touch,
162
+ }: {
163
+ parse: AgendaParse;
164
+ picks: ChoicePicks;
165
+ onPick: (choiceId: string, optionId: string) => void;
166
+ touch?: boolean;
167
+ }) {
168
+ const when =
169
+ parse.startTime === ""
170
+ ? formatShortDay(parse.date)
171
+ : `${formatShortDay(parse.date)} ${parse.startTime} – ${parse.endTime}`;
172
+
173
+ return (
174
+ <div className="flex flex-col gap-1 rounded-md border border-line bg-surface px-2.5 py-2">
175
+ <Reading
176
+ label="What"
177
+ value={parse.title === "" ? "—" : parse.title}
178
+ source=""
179
+ />
180
+ <Reading
181
+ label="When"
182
+ value={when}
183
+ source={[parse.dateText, parse.startTimeText]
184
+ .filter((part) => part !== "")
185
+ .join(" ")}
186
+ />
187
+ {parse.repeat !== "" && (
188
+ <Reading
189
+ label="Repeat"
190
+ value={parse.repeat}
191
+ source={parse.repeatText}
192
+ />
193
+ )}
194
+ {parse.attendees.length > 0 && (
195
+ <Reading
196
+ label="Who"
197
+ value={parse.attendees.join(", ")}
198
+ source={parse.attendeesText}
199
+ />
200
+ )}
201
+ {parse.location !== "" && (
202
+ <Reading
203
+ label="Where"
204
+ value={parse.location}
205
+ source={parse.locationText}
206
+ />
207
+ )}
208
+
209
+ {parse.choices.map((choice) => (
210
+ <div
211
+ key={choice.id}
212
+ className="mt-1 flex flex-wrap items-center gap-1.5 rounded-md border border-warning/50 bg-warning-soft/30 px-2 py-1.5"
213
+ >
214
+ <AlertTriangle className="size-3 shrink-0 text-warning" aria-hidden />
215
+ <span className="text-2xs text-warning">{choice.question}</span>
216
+ {choice.options.map((option) => {
217
+ const chosen = (picks[choice.id] ?? choice.chosenId) === option.id;
218
+ return (
219
+ <button
220
+ key={option.id}
221
+ type="button"
222
+ aria-pressed={chosen}
223
+ onClick={() => onPick(choice.id, option.id)}
224
+ className={cn(
225
+ "rounded-full border px-2 text-2xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
226
+ touch ? "min-h-9" : "h-6",
227
+ chosen
228
+ ? "border-warning bg-warning-soft font-medium text-warning"
229
+ : "border-line text-fg-muted hover:border-line-strong hover:text-fg",
230
+ )}
231
+ >
232
+ {option.label}
233
+ </button>
234
+ );
235
+ })}
236
+ </div>
237
+ ))}
238
+
239
+ {parse.assumptions.map((note) => (
240
+ <p
241
+ key={note}
242
+ className="flex items-center gap-1.5 text-2xs text-fg-subtle"
243
+ >
244
+ <Info className="size-3 shrink-0" aria-hidden />
245
+ {note}
246
+ </p>
247
+ ))}
248
+ {parse.unresolved.map((note) => (
249
+ <p
250
+ key={note}
251
+ className="flex items-center gap-1.5 text-2xs text-warning"
252
+ >
253
+ <AlertTriangle className="size-3 shrink-0" aria-hidden />
254
+ {note}
255
+ </p>
256
+ ))}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ function Reading({
262
+ label,
263
+ value,
264
+ source,
265
+ }: {
266
+ label: string;
267
+ value: string;
268
+ source: string;
269
+ }) {
270
+ return (
271
+ <div className="flex min-w-0 items-baseline gap-1.5">
272
+ <span className="w-12 shrink-0 text-2xs uppercase tracking-wider text-fg-subtle">
273
+ {label}
274
+ </span>
275
+ <span className="truncate text-xs font-medium text-fg">{value}</span>
276
+ {source !== "" && (
277
+ <span className="shrink-0 rounded-xs bg-accent-2-soft px-1 text-2xs text-accent-2">
278
+ {source}
279
+ </span>
280
+ )}
281
+ </div>
282
+ );
283
+ }