@remit/ui 0.0.145 → 0.0.146

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.145",
3
+ "version": "0.0.146",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -82,6 +82,26 @@ export interface CalendarEventData {
82
82
  status: "confirmed" | "tentative";
83
83
  }
84
84
 
85
+ /**
86
+ * One clock a zoneless time could be on. Present only when the source genuinely
87
+ * did not say which, and the reader is the one who settles it.
88
+ */
89
+ export interface ZoneOption {
90
+ /** IANA zone, and the identity of the choice. */
91
+ timeZone: string;
92
+ /** The time as this clock reads it — "20:25 in Lisbon". */
93
+ label: string;
94
+ /** What that means on the reader's own clock. */
95
+ note: string;
96
+ }
97
+
98
+ /**
99
+ * The clocks a zoneless time could be on. Never empty: a question with no
100
+ * answers is a card the reader can look at and never get past, so having no
101
+ * question to ask is the absent list, not the empty one.
102
+ */
103
+ export type ZoneOptions = readonly [ZoneOption, ...ZoneOption[]];
104
+
85
105
  /**
86
106
  * A candidate read out of mail. It is deliberately not a `CalendarEventData`:
87
107
  * nothing reaches the grid until a person confirms it, so a suggestion cannot
@@ -107,6 +127,11 @@ export interface EventSuggestion {
107
127
  suggestedCalendarId: string;
108
128
  timeZone: string;
109
129
  zoneCertainty: ZoneCertainty;
130
+ /**
131
+ * The clocks the time could be on. Absent when the source said which, which
132
+ * is the only case where `timeZone` above can be trusted on its own.
133
+ */
134
+ zoneOptions?: ZoneOptions;
110
135
  }
111
136
 
112
137
  /** Which instances of a series an edit applies to, chosen before the edit. */
@@ -23,11 +23,13 @@ const suggestion: EventSuggestion = {
23
23
  zoneCertainty: "explicit",
24
24
  };
25
25
 
26
- const render = (overrides: Partial<EventSuggestion>) =>
26
+ const render = (overrides: Partial<EventSuggestion>, zoneChoice?: string) =>
27
27
  renderToString(
28
28
  createElement(EventSuggestionCard, {
29
29
  suggestion: { ...suggestion, ...overrides },
30
30
  whenText: "Friday 19 June – Monday 22 June",
31
+ zoneChoice,
32
+ onZoneChoice: () => undefined,
31
33
  onAdd: () => undefined,
32
34
  onReview: () => undefined,
33
35
  onDismiss: () => undefined,
@@ -35,6 +37,21 @@ const render = (overrides: Partial<EventSuggestion>) =>
35
37
  }),
36
38
  );
37
39
 
40
+ const twoClocks: Partial<EventSuggestion> = {
41
+ zoneOptions: [
42
+ {
43
+ timeZone: "Europe/Lisbon",
44
+ label: "16:00 in Lisbon",
45
+ note: "17:00 on your own clock.",
46
+ },
47
+ {
48
+ timeZone: "Europe/Amsterdam",
49
+ label: "16:00 in Amsterdam",
50
+ note: "15:00 where she is.",
51
+ },
52
+ ],
53
+ };
54
+
38
55
  describe("EventSuggestionCard", () => {
39
56
  it("says it is a suggestion and not an event", () => {
40
57
  const html = render({});
@@ -63,4 +80,28 @@ describe("EventSuggestionCard", () => {
63
80
  it("needs a person to press Add", () => {
64
81
  assert.match(render({}), />Add</);
65
82
  });
83
+
84
+ it("asks nothing about the clock when the mail stated one", () => {
85
+ const html = render({});
86
+ assert.doesNotMatch(html, /Which clock is this on\?/);
87
+ assert.doesNotMatch(html, /Pick a clock first/);
88
+ assert.doesNotMatch(html, /aria-describedby/);
89
+ });
90
+
91
+ it("offers the clocks and dims Add while none is picked", () => {
92
+ const html = render(twoClocks);
93
+ assert.match(html, /Which clock is this on\?/);
94
+ assert.match(html, /16:00 in Lisbon/);
95
+ assert.match(html, /Pick a clock first/);
96
+ assert.match(html, /aria-describedby/);
97
+ assert.match(html, /opacity-55/);
98
+ });
99
+
100
+ it("undims Add once one of the clocks is picked", () => {
101
+ const html = render(twoClocks, "Europe/Lisbon");
102
+ assert.match(html, /aria-pressed="true"/);
103
+ assert.doesNotMatch(html, /Pick a clock first/);
104
+ assert.doesNotMatch(html, /aria-describedby/);
105
+ assert.doesNotMatch(html, /opacity-55/);
106
+ });
66
107
  });
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
- import type { EventSuggestion } from "./calendar-types.js";
2
+ import { useState } from "react";
3
+ import type { EventSuggestion, ZoneOptions } from "./calendar-types.js";
3
4
  import { EventSuggestionCard } from "./event-suggestion-card.js";
4
5
 
5
6
  /**
@@ -81,6 +82,77 @@ export const WithAmbiguity: Story = {
81
82
  ),
82
83
  };
83
84
 
85
+ const twoClocks: ZoneOptions = [
86
+ {
87
+ timeZone: "Europe/Lisbon",
88
+ label: "16:00 in Lisbon",
89
+ note: "17:00 on your own clock. The hour she keeps.",
90
+ },
91
+ {
92
+ timeZone: "Europe/Amsterdam",
93
+ label: "16:00 in Amsterdam",
94
+ note: "15:00 where she is.",
95
+ },
96
+ ];
97
+
98
+ const call: EventSuggestion = {
99
+ ...base,
100
+ id: "s3",
101
+ title: "Kickoff call — Lisbon venue",
102
+ allDay: false,
103
+ location: "Meet link",
104
+ threadSubject: "Kickoff call on Wednesday at 16:00",
105
+ sender: "Rita Sousa",
106
+ confidence: 0.66,
107
+ ambiguity:
108
+ "Rita writes from Lisbon and names 16:00 without a clock. Lisbon runs an hour behind Amsterdam.",
109
+ timeZone: "",
110
+ zoneCertainty: "ambiguous",
111
+ zoneOptions: twoClocks,
112
+ };
113
+
114
+ function GatedCall({ picked }: { picked: string }) {
115
+ const [zoneChoice, setZoneChoice] = useState(picked);
116
+ const [carried, setCarried] = useState("");
117
+ return (
118
+ <div className="flex flex-col gap-2">
119
+ <EventSuggestionCard
120
+ suggestion={call}
121
+ whenText="Wednesday 17 June · 16:00 – 17:00"
122
+ zoneChoice={zoneChoice}
123
+ onZoneChoice={setZoneChoice}
124
+ onAdd={(timeZone) => setCarried(`Added on ${timeZone}`)}
125
+ onReview={(timeZone) => setCarried(`Editor opened on ${timeZone}`)}
126
+ onDismiss={() => {}}
127
+ onOpenThread={() => {}}
128
+ />
129
+ <p className="text-2xs text-fg-subtle">
130
+ {carried === "" ? "Nothing has left the card yet." : carried}
131
+ </p>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ /**
137
+ * The mail printed an hour and never said whose clock it is on. Both ways out
138
+ * of the card are dimmed and stay dimmed until one is picked — an hour guessed
139
+ * wrong is a call missed, and an editor opened on that hour hides the guess
140
+ * behind a field the reader is invited to trust.
141
+ */
142
+ export const ZoneWeCannotDetermine: Story = {
143
+ name: "The zone we cannot determine",
144
+ render: () => <GatedCall picked="" />,
145
+ };
146
+
147
+ /**
148
+ * Answered. Both buttons are live, and each carries the clock that was named —
149
+ * Add books it, Change first opens on it.
150
+ */
151
+ export const ZonePicked: Story = {
152
+ name: "The clock is picked",
153
+ render: () => <GatedCall picked="Europe/Lisbon" />,
154
+ };
155
+
84
156
  /** The same card sized for a phone sheet. */
85
157
  export const Touch: Story = {
86
158
  render: () => (
@@ -1,16 +1,67 @@
1
- import { AlertTriangle, Mail, Plus, SlidersHorizontal, X } from "lucide-react";
1
+ import {
2
+ AlertTriangle,
3
+ Globe,
4
+ Mail,
5
+ Plus,
6
+ SlidersHorizontal,
7
+ X,
8
+ } from "lucide-react";
9
+ import { useId, useState } from "react";
2
10
  import { cn } from "../lib/cn.js";
11
+ import { BlockedReason } from "./blocked-reason.js";
3
12
  import { Button } from "./button.js";
4
13
  import type { EventSuggestion } from "./calendar-types.js";
5
14
 
15
+ export const ZONE_UNSETTLED_REASON =
16
+ "Pick a clock first. The mail never said, and the reader will not choose for you.";
17
+
18
+ /**
19
+ * Which zone the suggestion would be booked on. `timeZone` is empty when the
20
+ * source said it itself and the reader settled nothing.
21
+ */
22
+ export type ZoneSettlement =
23
+ | { settled: true; timeZone: string }
24
+ | { settled: false; reason: string };
25
+
26
+ /**
27
+ * A time nobody stated a zone for is an hour nobody stated. The choice has to
28
+ * come from the reader and it has to be one of the clocks offered — a stale or
29
+ * unrecognised choice leaves the suggestion unsettled rather than guessing, and
30
+ * nothing here reads the zone the browser happens to be in.
31
+ *
32
+ * This is the only place the rule is written. Every surface that offers the
33
+ * clocks — the card here, the seam's strip, the phone deck behind it — asks
34
+ * this, so none of them can disagree about whether a suggestion is answered.
35
+ */
36
+ export function settleZone(
37
+ suggestion: EventSuggestion,
38
+ zoneChoice: string,
39
+ ): ZoneSettlement {
40
+ const options = suggestion.zoneOptions;
41
+ if (options === undefined) return { settled: true, timeZone: "" };
42
+ if (options.some((option) => option.timeZone === zoneChoice))
43
+ return { settled: true, timeZone: zoneChoice };
44
+ return { settled: false, reason: ZONE_UNSETTLED_REASON };
45
+ }
46
+
6
47
  export interface EventSuggestionCardProps {
7
48
  suggestion: EventSuggestion;
8
49
  /** Already formatted by the caller. */
9
50
  whenText: string;
10
- onAdd: () => void;
11
- onReview: () => void;
51
+ /** The zone the reader settled on, empty when the mail already knew. */
52
+ onAdd: (timeZone: string) => void;
53
+ /**
54
+ * Correcting the reading first. It is handed the same settled zone Add is,
55
+ * and is refused on the same terms — an editor opened on an hour nobody has
56
+ * placed on a clock seeds the draft with the wrong time, which is the
57
+ * mistake Add is dimmed to prevent.
58
+ */
59
+ onReview: (timeZone: string) => void;
12
60
  onDismiss: () => void;
13
61
  onOpenThread: () => void;
62
+ /** The clock picked so far. Omit to let the card hold the choice itself. */
63
+ zoneChoice?: string;
64
+ onZoneChoice?: (timeZone: string) => void;
14
65
  touch?: boolean;
15
66
  className?: string;
16
67
  }
@@ -27,6 +78,11 @@ function confidenceText(confidence: number): string {
27
78
  * dashed card, and only a person pressing Add puts anything on the calendar —
28
79
  * a machine reading of a mail never becomes a provisional block that someone
29
80
  * has to notice and remove.
81
+ *
82
+ * Where the mail never said which clock a time is on, every way out of the card
83
+ * that carries the time — Add, and correcting it first — is dimmed until the
84
+ * reader picks one and refuses the press until then: booking the wrong hour is
85
+ * worse than asking, and so is opening an editor already filled with it.
30
86
  */
31
87
  export function EventSuggestionCard({
32
88
  suggestion,
@@ -35,11 +91,32 @@ export function EventSuggestionCard({
35
91
  onReview,
36
92
  onDismiss,
37
93
  onOpenThread,
94
+ zoneChoice,
95
+ onZoneChoice,
38
96
  touch,
39
97
  className,
40
98
  }: EventSuggestionCardProps) {
99
+ const [ownChoice, setOwnChoice] = useState("");
100
+ const [nudged, setNudged] = useState(false);
101
+ const reasonId = useId();
102
+ const choice = zoneChoice ?? ownChoice;
103
+ const pickZone = (timeZone: string) => {
104
+ setNudged(false);
105
+ if (onZoneChoice) onZoneChoice(timeZone);
106
+ else setOwnChoice(timeZone);
107
+ };
108
+ const settlement = settleZone(suggestion, choice);
109
+ const carryingTheZone = (act: (timeZone: string) => void) => () => {
110
+ if (!settlement.settled) {
111
+ setNudged(true);
112
+ return;
113
+ }
114
+ act(settlement.timeZone);
115
+ };
116
+
41
117
  return (
42
- <div
118
+ <article
119
+ aria-label={suggestion.title}
43
120
  className={cn(
44
121
  "flex flex-col gap-2 rounded-lg border border-dashed border-line-strong bg-surface-sunken p-3",
45
122
  className,
@@ -91,13 +168,57 @@ export function EventSuggestionCard({
91
168
  </span>
92
169
  </button>
93
170
 
171
+ {suggestion.zoneOptions && (
172
+ <div className="flex flex-col gap-1.5 rounded-md border border-warning/40 p-2">
173
+ <p className="flex items-center gap-1.5 text-2xs font-semibold text-warning">
174
+ <Globe className="size-3 shrink-0" aria-hidden />
175
+ Which clock is this on?
176
+ </p>
177
+ {suggestion.zoneOptions.map((option) => (
178
+ <button
179
+ key={option.timeZone}
180
+ type="button"
181
+ aria-pressed={choice === option.timeZone}
182
+ onClick={() => pickZone(option.timeZone)}
183
+ className={cn(
184
+ "rounded-md border px-2 py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
185
+ touch && "min-h-11",
186
+ choice === option.timeZone
187
+ ? "border-accent bg-accent-soft"
188
+ : "border-line bg-surface hover:border-line-strong",
189
+ )}
190
+ >
191
+ <span className="block text-xs font-medium text-fg">
192
+ {option.label}
193
+ </span>
194
+ <span className="block text-2xs text-fg-subtle">
195
+ {option.note}
196
+ </span>
197
+ </button>
198
+ ))}
199
+ </div>
200
+ )}
201
+
202
+ {!settlement.settled && (
203
+ <BlockedReason
204
+ id={reasonId}
205
+ reason={settlement.reason}
206
+ nudged={nudged}
207
+ className="text-2xs text-warning"
208
+ />
209
+ )}
210
+
94
211
  <div className="flex items-center gap-2">
95
212
  <Button
96
213
  variant="primary"
97
214
  size={touch ? "md" : "sm"}
98
215
  icon={<Plus className="size-3.5" />}
99
- onClick={onAdd}
100
- className={touch ? "min-h-11 flex-1" : ""}
216
+ onClick={carryingTheZone(onAdd)}
217
+ aria-describedby={settlement.settled ? undefined : reasonId}
218
+ className={cn(
219
+ touch && "min-h-11 flex-1",
220
+ !settlement.settled && "opacity-55",
221
+ )}
101
222
  >
102
223
  Add
103
224
  </Button>
@@ -105,12 +226,16 @@ export function EventSuggestionCard({
105
226
  variant="secondary"
106
227
  size={touch ? "md" : "sm"}
107
228
  icon={<SlidersHorizontal className="size-3.5" />}
108
- onClick={onReview}
109
- className={touch ? "min-h-11" : ""}
229
+ onClick={carryingTheZone(onReview)}
230
+ aria-describedby={settlement.settled ? undefined : reasonId}
231
+ className={cn(
232
+ touch && "min-h-11",
233
+ !settlement.settled && "opacity-55",
234
+ )}
110
235
  >
111
236
  Change first
112
237
  </Button>
113
238
  </div>
114
- </div>
239
+ </article>
115
240
  );
116
241
  }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * The zone gate — mounted against jsdom rather than `renderToString`, because
3
+ * what has to be proven is that pressing either way out of the card does
4
+ * nothing while the clock is unsettled.
5
+ *
6
+ * Nothing here may read the zone the process happens to run in, so the cases
7
+ * offer clocks the ambient zone is never one of, assert the exact zone that
8
+ * comes back, and move the machine between zones themselves rather than leaving
9
+ * that to whoever runs the suite.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
13
+ import type { JSDOM } from "jsdom";
14
+ import { act, createElement } from "react";
15
+ import { createRoot, type Root } from "react-dom/client";
16
+ import type { EventSuggestion, ZoneOptions } from "./calendar-types.js";
17
+ import {
18
+ EventSuggestionCard,
19
+ settleZone,
20
+ ZONE_UNSETTLED_REASON,
21
+ } from "./event-suggestion-card.js";
22
+
23
+ const LISBON = "Europe/Lisbon";
24
+ const AUCKLAND = "Pacific/Auckland";
25
+
26
+ const suggestion: EventSuggestion = {
27
+ id: "sug_lisbon_call",
28
+ title: "Kickoff call — Lisbon venue",
29
+ start: "2026-06-17T16:00:00+02:00",
30
+ end: "2026-06-17T17:00:00+02:00",
31
+ allDay: false,
32
+ location: "Meet link",
33
+ threadId: "thr_lisbon_call",
34
+ threadSubject: "Kickoff call on Wednesday at 16:00",
35
+ sender: "Rita Sousa",
36
+ senderAddress: "rita@aldeia.example",
37
+ confidence: 0.66,
38
+ ambiguity: "",
39
+ suggestedCalendarId: "c5",
40
+ timeZone: "",
41
+ zoneCertainty: "ambiguous",
42
+ };
43
+
44
+ const zoneless: EventSuggestion = {
45
+ ...suggestion,
46
+ zoneOptions: [
47
+ { timeZone: LISBON, label: "16:00 in Lisbon", note: "17:00 for you." },
48
+ { timeZone: AUCKLAND, label: "16:00 in Auckland", note: "03:00 for you." },
49
+ ],
50
+ };
51
+
52
+ const stated: EventSuggestion = {
53
+ ...suggestion,
54
+ timeZone: LISBON,
55
+ zoneCertainty: "explicit",
56
+ };
57
+
58
+ let dom: JSDOM;
59
+ let container: HTMLElement;
60
+ let root: Root;
61
+
62
+ before(async () => {
63
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
64
+ dom = new JSDOMCtor(
65
+ "<!doctype html><html><body><div id=root></div></body></html>",
66
+ { url: "http://localhost/", pretendToBeVisual: true },
67
+ );
68
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
69
+ globalThis.document = dom.window.document;
70
+ globalThis.HTMLElement = dom.window.HTMLElement;
71
+ globalThis.Element = dom.window.Element;
72
+ globalThis.MouseEvent = dom.window.MouseEvent;
73
+ Object.defineProperty(globalThis, "navigator", {
74
+ value: dom.window.navigator,
75
+ configurable: true,
76
+ });
77
+ (
78
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
79
+ ).IS_REACT_ACT_ENVIRONMENT = true;
80
+ });
81
+
82
+ after(() => {
83
+ dom.window.close();
84
+ });
85
+
86
+ beforeEach(() => {
87
+ container = dom.window.document.getElementById(
88
+ "root",
89
+ ) as unknown as HTMLElement;
90
+ container.innerHTML = "";
91
+ root = createRoot(container);
92
+ });
93
+
94
+ afterEach(() => {
95
+ act(() => {
96
+ root.unmount();
97
+ });
98
+ });
99
+
100
+ interface Answers {
101
+ onAdd?: (timeZone: string) => void;
102
+ onReview?: (timeZone: string) => void;
103
+ }
104
+
105
+ function mount(entry: EventSuggestion, answers: Answers = {}) {
106
+ act(() => {
107
+ root.render(
108
+ createElement(EventSuggestionCard, {
109
+ suggestion: entry,
110
+ whenText: "Friday 19 June · 18:40 – 20:25",
111
+ onAdd: answers.onAdd ?? (() => undefined),
112
+ onReview: answers.onReview ?? (() => undefined),
113
+ onDismiss: () => undefined,
114
+ onOpenThread: () => undefined,
115
+ }),
116
+ );
117
+ });
118
+ }
119
+
120
+ function buttonNamed(text: string): HTMLElement {
121
+ const found = Array.from(container.querySelectorAll("button")).find(
122
+ (candidate) => candidate.textContent?.includes(text),
123
+ );
124
+ assert.ok(found, `no button reading ${text}`);
125
+ return found as unknown as HTMLElement;
126
+ }
127
+
128
+ function press(target: HTMLElement) {
129
+ act(() => {
130
+ target.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
131
+ });
132
+ }
133
+
134
+ /** The paragraph the dimmed Add points at through `aria-describedby`. */
135
+ function describedReason(): HTMLElement | null {
136
+ const id = buttonNamed("Add").getAttribute("aria-describedby");
137
+ if (id === null) return null;
138
+ return dom.window.document.getElementById(id);
139
+ }
140
+
141
+ function announced(): string {
142
+ return Array.from(container.querySelectorAll('[role="status"]'))
143
+ .map((node) => node.textContent ?? "")
144
+ .join(" ");
145
+ }
146
+
147
+ describe("the zone gate", () => {
148
+ it("refuses Add until a clock is picked, and says why", () => {
149
+ const added: string[] = [];
150
+ mount(zoneless, { onAdd: (timeZone) => added.push(timeZone) });
151
+
152
+ assert.equal(announced(), "");
153
+ assert.equal(describedReason()?.textContent, ZONE_UNSETTLED_REASON);
154
+ assert.ok(describedReason()?.classList.contains("sr-only"));
155
+
156
+ press(buttonNamed("Add"));
157
+
158
+ assert.deepEqual(added, []);
159
+ assert.match(announced(), /Pick a clock first/);
160
+ assert.equal(describedReason()?.classList.contains("sr-only"), false);
161
+ });
162
+
163
+ it("adds on the clock that was picked, never on the ambient one", () => {
164
+ const added: string[] = [];
165
+ mount(zoneless, { onAdd: (timeZone) => added.push(timeZone) });
166
+
167
+ press(buttonNamed("16:00 in Auckland"));
168
+ press(buttonNamed("Add"));
169
+
170
+ assert.deepEqual(added, [AUCKLAND]);
171
+ assert.notEqual(
172
+ AUCKLAND,
173
+ Intl.DateTimeFormat().resolvedOptions().timeZone,
174
+ "the case only proves anything while the ambient zone is a different one",
175
+ );
176
+ });
177
+
178
+ it("settles nothing when the mail already stated the clock", () => {
179
+ const added: string[] = [];
180
+ mount(stated, { onAdd: (timeZone) => added.push(timeZone) });
181
+
182
+ press(buttonNamed("Add"));
183
+
184
+ assert.deepEqual(added, [""]);
185
+ assert.equal(announced(), "");
186
+ });
187
+ });
188
+
189
+ /**
190
+ * Correcting the reading first is the other way the time leaves the card, and
191
+ * an editor seeded with the unconverted hour books the wrong one just as
192
+ * quietly as Add would.
193
+ */
194
+ describe("the same gate on Change first", () => {
195
+ it("refuses Change first until a clock is picked, and says why", () => {
196
+ const reviewed: string[] = [];
197
+ mount(zoneless, { onReview: (timeZone) => reviewed.push(timeZone) });
198
+
199
+ const describes =
200
+ buttonNamed("Change first").getAttribute("aria-describedby");
201
+ assert.equal(
202
+ describes,
203
+ buttonNamed("Add").getAttribute("aria-describedby"),
204
+ );
205
+ assert.equal(
206
+ describes === null
207
+ ? null
208
+ : dom.window.document.getElementById(describes)?.textContent,
209
+ ZONE_UNSETTLED_REASON,
210
+ );
211
+
212
+ press(buttonNamed("Change first"));
213
+
214
+ assert.deepEqual(reviewed, []);
215
+ assert.match(announced(), /Pick a clock first/);
216
+ });
217
+
218
+ it("opens on the clock that was picked, never on an empty one", () => {
219
+ const reviewed: string[] = [];
220
+ mount(zoneless, { onReview: (timeZone) => reviewed.push(timeZone) });
221
+
222
+ press(buttonNamed("16:00 in Auckland"));
223
+ press(buttonNamed("Change first"));
224
+
225
+ assert.deepEqual(reviewed, [AUCKLAND]);
226
+ });
227
+
228
+ it("stays live when the mail already stated the clock", () => {
229
+ const reviewed: string[] = [];
230
+ mount(stated, { onReview: (timeZone) => reviewed.push(timeZone) });
231
+
232
+ press(buttonNamed("Change first"));
233
+
234
+ assert.deepEqual(reviewed, [""]);
235
+ assert.equal(
236
+ buttonNamed("Change first").getAttribute("aria-describedby"),
237
+ null,
238
+ );
239
+ });
240
+ });
241
+
242
+ describe("the clocks a suggestion offers", () => {
243
+ it("cannot be an empty list, which would ask with nothing to press", () => {
244
+ // @ts-expect-error
245
+ const none: ZoneOptions = [];
246
+ assert.equal(none.length, 0);
247
+ });
248
+
249
+ it("never asks the question without a clock to answer it with", () => {
250
+ mount(zoneless);
251
+ assert.ok(container.textContent?.includes("Which clock is this on?"));
252
+ assert.equal(container.querySelectorAll("[aria-pressed]").length, 2);
253
+
254
+ mount(stated);
255
+ assert.equal(
256
+ container.textContent?.includes("Which clock is this on?"),
257
+ false,
258
+ );
259
+ assert.equal(container.querySelectorAll("[aria-pressed]").length, 0);
260
+ });
261
+ });
262
+
263
+ /**
264
+ * The zones the machine might be in. The hour is what noon UTC reads as there,
265
+ * asserted so a Node that ignored the switch could not pass the body silently.
266
+ */
267
+ const ZONES = [
268
+ { name: "UTC", hourAtNoonUtc: 12 },
269
+ { name: "Europe/Amsterdam", hourAtNoonUtc: 14 },
270
+ { name: "Pacific/Kiritimati", hourAtNoonUtc: 2 },
271
+ ];
272
+
273
+ function inEveryZone(body: () => void): void {
274
+ const before = process.env.TZ;
275
+ try {
276
+ for (const zone of ZONES) {
277
+ process.env.TZ = zone.name;
278
+ assert.equal(
279
+ new Date("2026-06-17T12:00:00Z").getHours(),
280
+ zone.hourAtNoonUtc,
281
+ `the machine never moved to ${zone.name}`,
282
+ );
283
+ body();
284
+ }
285
+ } finally {
286
+ if (before === undefined) delete process.env.TZ;
287
+ else process.env.TZ = before;
288
+ }
289
+ }
290
+
291
+ describe("settleZone", () => {
292
+ it("settles a suggestion that carries no clocks to answer", () => {
293
+ inEveryZone(() => {
294
+ assert.deepEqual(settleZone(stated, ""), { settled: true, timeZone: "" });
295
+ });
296
+ });
297
+
298
+ it("holds a suggestion whose clock nobody has picked", () => {
299
+ inEveryZone(() => {
300
+ assert.deepEqual(settleZone(zoneless, ""), {
301
+ settled: false,
302
+ reason: ZONE_UNSETTLED_REASON,
303
+ });
304
+ });
305
+ });
306
+
307
+ it("holds a choice that is not one of the clocks offered", () => {
308
+ inEveryZone(() => {
309
+ assert.deepEqual(settleZone(zoneless, "Europe/Amsterdam"), {
310
+ settled: false,
311
+ reason: ZONE_UNSETTLED_REASON,
312
+ });
313
+ assert.deepEqual(settleZone(zoneless, "UTC"), {
314
+ settled: false,
315
+ reason: ZONE_UNSETTLED_REASON,
316
+ });
317
+ });
318
+ });
319
+
320
+ it("carries the picked clock through untouched", () => {
321
+ inEveryZone(() => {
322
+ assert.deepEqual(settleZone(zoneless, LISBON), {
323
+ settled: true,
324
+ timeZone: LISBON,
325
+ });
326
+ });
327
+ });
328
+ });
package/src/index.ts CHANGED
@@ -148,6 +148,8 @@ export {
148
148
  type RecurrenceScope,
149
149
  type RsvpState,
150
150
  type ZoneCertainty,
151
+ type ZoneOption,
152
+ type ZoneOptions,
151
153
  } from "./components/calendar-types.js";
152
154
  export {
153
155
  Card,
@@ -254,6 +256,9 @@ export {
254
256
  export {
255
257
  EventSuggestionCard,
256
258
  type EventSuggestionCardProps,
259
+ settleZone,
260
+ ZONE_UNSETTLED_REASON,
261
+ type ZoneSettlement,
257
262
  } from "./components/event-suggestion-card.js";
258
263
  export {
259
264
  FieldLabel,