@remit/web-client 0.0.298 → 0.0.299

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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarEventPane.stories.tsx +16 -0
  3. package/src/components/calendar/OpenCalendarEvent.tsx +23 -6
  4. package/src/components/calendar/WriteCalendarEvent.tsx +8 -4
  5. package/src/components/calendar/calendar-write-flow.render.test.ts +39 -22
  6. package/src/components/compose/ComposeForm.tsx +116 -0
  7. package/src/components/compose/attachment-only-source.render.test.ts +1 -0
  8. package/src/components/compose/compose-send-stops-autosave.render.test.ts +1 -0
  9. package/src/components/compose/compose-starts-a-second-message.render.test.ts +1 -0
  10. package/src/components/compose/forward-autosave-is-not-fatal.render.test.ts +1 -0
  11. package/src/components/compose/quote-leaves-with-the-send.render.test.ts +1 -0
  12. package/src/components/compose/reopened-draft-writes-nothing.render.test.ts +1 -0
  13. package/src/components/compose/reply-all-on-resumed-draft.render.test.ts +1 -0
  14. package/src/hooks/calendar/calendar-collections.render.test.ts +23 -1
  15. package/src/hooks/calendar/draft.test.ts +179 -30
  16. package/src/hooks/calendar/draft.ts +59 -33
  17. package/src/hooks/calendar/index.ts +5 -1
  18. package/src/hooks/calendar/instance.test.ts +70 -0
  19. package/src/hooks/calendar/instance.ts +28 -15
  20. package/src/hooks/calendar/recurrence-rule.test.ts +43 -1
  21. package/src/hooks/calendar/recurrence-rule.ts +44 -0
  22. package/src/hooks/calendar/resource.test.ts +44 -1
  23. package/src/hooks/calendar/resource.ts +30 -0
  24. package/src/hooks/calendar/selection.ts +7 -4
  25. package/src/hooks/calendar/useDraftClashes.ts +3 -1
  26. package/src/hooks/calendar/window.test.ts +28 -0
  27. package/src/hooks/calendar/window.ts +20 -0
  28. package/src/hooks/useComposeAttachments.ts +103 -0
  29. package/src/hooks/useIntelligenceCalendar.ts +2 -0
  30. package/src/lib/calendar-format.ts +7 -2
  31. package/src/lib/compose-attachment-controller.test.ts +242 -0
  32. package/src/lib/compose-attachment-controller.ts +415 -0
@@ -15,26 +15,29 @@ import type {
15
15
  CalendarEventData,
16
16
  ZoneCertainty,
17
17
  } from "@remit/ui";
18
+ import { isoOnClock } from "./window";
18
19
 
19
20
  /** The device's own clock, the only zone a browser can honestly draw in. */
20
21
  export const deviceTimeZone = (): string =>
21
22
  Intl.DateTimeFormat().resolvedOptions().timeZone;
22
23
 
24
+ /** A collection that names no zone of its own, spelled the way the API spells it. */
25
+ export const UNZONED_CALENDAR = "";
26
+
23
27
  /**
24
- * A collection that names no zone of its own, spelled the way the API spells
25
- * it: an empty string, which both ends read as UTC.
26
- *
27
- * There is deliberately no substitute value. The server checks a TZID against
28
- * `Intl.supportedValuesOf("timeZone")`, which is the canonical zone list and so
29
- * holds no spelling of UTC at all — not `UTC`, not `Etc/UTC` — and refuses
30
- * anything else. Sending nothing is what the API asks for, and it is the only
31
- * thing it accepts.
32
- *
33
- * The device's zone is not the fallback either. The listing returns this
34
- * collection's occurrences on its own clock, so answering with the reader's
35
- * would rewrite every time they saved by the difference between the two.
28
+ * The zone a write anchors an event in: the collection's, or where it names
29
+ * none, the device's — a weekly 09:00 written from New York into an unzoned
30
+ * calendar would otherwise be stored in UTC and meet at 10:00 once the clocks
31
+ * change. UTC itself has no name the server takes as a TZID, so a device on it
32
+ * sends nothing, which the server reads as UTC.
36
33
  */
37
- export const UNZONED_CALENDAR = "";
34
+ export function anchorZoneFor(collectionZone: string): string {
35
+ if (collectionZone !== UNZONED_CALENDAR) return collectionZone;
36
+ const device = deviceTimeZone();
37
+ return Intl.supportedValuesOf("timeZone").includes(device)
38
+ ? device
39
+ : UNZONED_CALENDAR;
40
+ }
38
41
 
39
42
  const COLORS: Record<string, CalendarColorId> = {
40
43
  Cal1: "cal-1",
@@ -99,17 +102,27 @@ export function toCalendarDescriptor(
99
102
  * One occurrence, drawn. `location`, `notes` and the guest list are projections
100
103
  * the listing does not carry — the reading pane fetches the resource itself for
101
104
  * those, and a grid chip has no room for them anyway.
105
+ *
106
+ * A timed occurrence is re-read on `clockZone`, the clock every surface draws
107
+ * on. An all-day one is a civil date and keeps only its date: midnight with an
108
+ * offset is an instant, and a device west of it reads that instant as the day
109
+ * before.
102
110
  */
103
111
  export function toCalendarEventData(
104
112
  instance: RemitImapCalendarEventInstance,
105
113
  timeZone: string,
114
+ clockZone: string,
106
115
  ): CalendarEventData {
107
116
  return {
108
117
  id: calendarInstanceId(instance.calendarObjectId, instance.recurrenceId),
109
118
  calendarId: instance.calendarId,
110
119
  title: instance.summary,
111
- start: instance.start,
112
- end: instance.end,
120
+ start: instance.allDay
121
+ ? instance.start.slice(0, 10)
122
+ : isoOnClock(instance.start, clockZone),
123
+ end: instance.allDay
124
+ ? instance.end.slice(0, 10)
125
+ : isoOnClock(instance.end, clockZone),
113
126
  allDay: instance.allDay,
114
127
  location: "",
115
128
  notes: "",
@@ -12,7 +12,7 @@
12
12
  import assert from "node:assert/strict";
13
13
  import { describe, it } from "node:test";
14
14
  import { repeatChoices } from "@remit/ui";
15
- import { rruleFromText, textFromRrule } from "./recurrence-rule";
15
+ import { moveRule, rruleFromText, textFromRrule } from "./recurrence-rule";
16
16
 
17
17
  const DATE = "2026-06-10";
18
18
  const TIME = "09:15";
@@ -90,3 +90,45 @@ describe("a rule written by something else", () => {
90
90
  assert.equal(textFromRrule("FREQ=WEEKLY", ""), undefined);
91
91
  });
92
92
  });
93
+
94
+ describe("a rule moved onto the day the event's own zone shows", () => {
95
+ it("shifts the weekdays of a weekly rule with the day", () => {
96
+ assert.equal(
97
+ moveRule("FREQ=WEEKLY;BYDAY=SU", "2026-06-14", "2026-06-15"),
98
+ "FREQ=WEEKLY;BYDAY=MO",
99
+ );
100
+ assert.equal(
101
+ moveRule("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "2026-06-15", "2026-06-14"),
102
+ "FREQ=WEEKLY;BYDAY=SU,MO,TU,WE,TH",
103
+ );
104
+ });
105
+
106
+ it("reads a monthly position off the new date rather than shifting it", () => {
107
+ assert.equal(
108
+ moveRule("FREQ=MONTHLY;BYDAY=2WE", "2026-06-10", "2026-06-11"),
109
+ "FREQ=MONTHLY;BYDAY=2TH",
110
+ );
111
+ assert.equal(
112
+ moveRule("FREQ=MONTHLY;BYDAY=2WE", "2026-07-14", "2026-07-15"),
113
+ "FREQ=MONTHLY;BYDAY=3WE",
114
+ );
115
+ });
116
+
117
+ it("moves a yearly date, month and all", () => {
118
+ assert.equal(
119
+ moveRule(
120
+ "FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=31",
121
+ "2026-05-31",
122
+ "2026-06-01",
123
+ ),
124
+ "FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=1",
125
+ );
126
+ });
127
+
128
+ it("leaves a rule alone on the same day", () => {
129
+ assert.equal(
130
+ moveRule("FREQ=WEEKLY;BYDAY=SU", "2026-06-14", "2026-06-14"),
131
+ "FREQ=WEEKLY;BYDAY=SU",
132
+ );
133
+ });
134
+ });
@@ -131,3 +131,47 @@ export function textFromRrule(
131
131
 
132
132
  return undefined;
133
133
  }
134
+
135
+ const dayNumber = (date: string): number =>
136
+ Date.parse(`${date}T00:00:00Z`) / 86_400_000;
137
+
138
+ /**
139
+ * A rule picked on one civil date, rewritten for the same moment read on
140
+ * another. The picker offers rules off the day the device shows, and the
141
+ * server expands them on the day the event's own zone shows; 21:00 on a New
142
+ * York Sunday is a Tokyo Monday, so "every Sunday" has to be stored as BYDAY=MO
143
+ * or the series lands on Saturday evenings. Weekdays shift with the day; a
144
+ * monthly or yearly position is read off the new date itself, because the day
145
+ * after the second Wednesday is not always the second Thursday.
146
+ */
147
+ export function moveRule(
148
+ rule: string,
149
+ fromDate: string,
150
+ toDate: string,
151
+ ): string {
152
+ const shift = dayNumber(toDate) - dayNumber(fromDate);
153
+ if (rule === "" || shift === 0 || Number.isNaN(shift)) return rule;
154
+ const [year, month, dayOfMonth] = toDate.split("-").map(Number);
155
+ const weekday = new Date(Date.UTC(year, month - 1, dayOfMonth)).getUTCDay();
156
+ const frequency = /FREQ=(\w+)/i.exec(rule)?.[1]?.toUpperCase();
157
+ return rule
158
+ .split(";")
159
+ .map((part) => {
160
+ const [name, value = ""] = part.split("=");
161
+ const key = name.toUpperCase();
162
+ if (key === "BYDAY" && frequency === "MONTHLY")
163
+ return `BYDAY=${ORDINAL_NUMBERS[Math.min(Math.floor((dayOfMonth - 1) / 7), 4)]}${BYDAY[weekday]}`;
164
+ if (key === "BYDAY")
165
+ return `BYDAY=${value
166
+ .split(",")
167
+ .map((code) => {
168
+ const index = BYDAY.indexOf(code.toUpperCase());
169
+ return index === -1 ? code : BYDAY[(index + shift + 7) % 7];
170
+ })
171
+ .join(",")}`;
172
+ if (key === "BYMONTH") return `BYMONTH=${month}`;
173
+ if (key === "BYMONTHDAY") return `BYMONTHDAY=${dayOfMonth}`;
174
+ return part;
175
+ })
176
+ .join(";");
177
+ }
@@ -8,7 +8,11 @@
8
8
  */
9
9
  import assert from "node:assert/strict";
10
10
  import { describe, it } from "node:test";
11
- import { rruleFromIcalData, textFromIcalData } from "./resource";
11
+ import {
12
+ rruleFromIcalData,
13
+ storedAnchorZone,
14
+ textFromIcalData,
15
+ } from "./resource";
12
16
 
13
17
  const ics = (...lines: string[]): string =>
14
18
  [
@@ -96,3 +100,42 @@ describe("the text a listing does not carry", () => {
96
100
  });
97
101
  });
98
102
  });
103
+
104
+ describe("the zone an edit writes an event back in", () => {
105
+ const vevent = (dtstart: string): string =>
106
+ `BEGIN:VEVENT\r\n${dtstart}\r\nEND:VEVENT`;
107
+
108
+ it("is the TZID the stored start names", () => {
109
+ assert.equal(
110
+ storedAnchorZone(
111
+ vevent("DTSTART;TZID=Asia/Tokyo:20340222T080000"),
112
+ "Europe/Amsterdam",
113
+ ),
114
+ "Asia/Tokyo",
115
+ );
116
+ });
117
+
118
+ it("is UTC for a start stored as a UTC instant, whatever the calendar says", () => {
119
+ assert.equal(
120
+ storedAnchorZone(vevent("DTSTART:20260615T010000Z"), "Europe/Amsterdam"),
121
+ "",
122
+ );
123
+ });
124
+
125
+ it("is the calendar's zone for a floating start", () => {
126
+ assert.equal(
127
+ storedAnchorZone(vevent("DTSTART:20260615T010000"), "Europe/Amsterdam"),
128
+ "Europe/Amsterdam",
129
+ );
130
+ });
131
+
132
+ it("is the calendar's zone for a TZID nothing can resolve", () => {
133
+ assert.equal(
134
+ storedAnchorZone(
135
+ vevent("DTSTART;TZID=W. Europe Standard Time:20260615T010000"),
136
+ "Europe/Amsterdam",
137
+ ),
138
+ "Europe/Amsterdam",
139
+ );
140
+ });
141
+ });
@@ -1,3 +1,5 @@
1
+ import { anchorZoneFor } from "./instance";
2
+
1
3
  /**
2
4
  * The few properties a reading pane needs that the occurrence listing does not
3
5
  * carry, read off the stored resource.
@@ -72,3 +74,31 @@ export const textFromIcalData = (icalData: string): CalendarResourceText => ({
72
74
  location: unescapeText(firstProperty(icalData, "LOCATION")),
73
75
  description: unescapeText(firstProperty(icalData, "DESCRIPTION")),
74
76
  });
77
+
78
+ /**
79
+ * The zone an edit writes an existing event back in: the one its master's
80
+ * DTSTART is stored in. A UTC start stays UTC (`""`), a floating one stays in
81
+ * the collection's zone, and a TZID nothing can resolve is read the way the
82
+ * server reads it, in the collection's zone. Only a resource with no DTSTART
83
+ * falls back to where a new event would be anchored.
84
+ */
85
+ export function storedAnchorZone(
86
+ icalData: string,
87
+ collectionZone: string,
88
+ ): string {
89
+ const line = unfold(icalData)
90
+ .split(/\r?\n/)
91
+ .find(
92
+ (candidate) =>
93
+ candidate.startsWith("DTSTART") &&
94
+ valueAt(candidate, "DTSTART") !== undefined,
95
+ );
96
+ if (line === undefined) return anchorZoneFor(collectionZone);
97
+ const tzid = /;TZID="?([^;:"]+)"?/i.exec(line)?.[1];
98
+ if (tzid !== undefined)
99
+ return Intl.supportedValuesOf("timeZone").includes(tzid)
100
+ ? tzid
101
+ : collectionZone;
102
+ if (valueAt(line, "DTSTART")?.toUpperCase().endsWith("Z")) return "";
103
+ return collectionZone;
104
+ }
@@ -15,6 +15,7 @@ import type {
15
15
  } from "@remit/ui";
16
16
  import { useMemo } from "react";
17
17
  import {
18
+ deviceTimeZone,
18
19
  isDrawnInstance,
19
20
  toCalendarEventData,
20
21
  UNZONED_CALENDAR,
@@ -79,10 +80,10 @@ export function useCalendarSelection(
79
80
  }
80
81
 
81
82
  /**
82
- * Occurrences as rows: the ones on a calendar being drawn, on the clock their
83
- * collection keeps. A collection with no zone of its own is sent as unzoned
84
- * rather than as the reader's — answering with the device's clock would rewrite
85
- * every time they saved by the difference between the two.
83
+ * Occurrences as rows: the ones on a calendar being drawn, on the device's
84
+ * clock, which is the one the grid, the strip and the free time all read. Each
85
+ * row still carries its collection's zone, unzoned where the collection names
86
+ * none, because that is the zone a write anchors the event in.
86
87
  */
87
88
  export function useDrawnEvents(
88
89
  instances: readonly RemitImapCalendarEventInstance[],
@@ -91,6 +92,7 @@ export function useDrawnEvents(
91
92
  ): CalendarEventData[] {
92
93
  const shownKey = shown.join(",");
93
94
  return useMemo(() => {
95
+ const clockZone = deviceTimeZone();
94
96
  const drawn = new Set(shownKey === "" ? [] : shownKey.split(","));
95
97
  return instances
96
98
  .filter(
@@ -101,6 +103,7 @@ export function useDrawnEvents(
101
103
  toCalendarEventData(
102
104
  instance,
103
105
  timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
106
+ clockZone,
104
107
  ),
105
108
  );
106
109
  }, [instances, timeZoneByCalendarId, shownKey]);
@@ -18,6 +18,7 @@ import {
18
18
  import { useMemo } from "react";
19
19
  import { formatEventWhen } from "@/lib/calendar-format";
20
20
  import {
21
+ deviceTimeZone,
21
22
  isDrawnInstance,
22
23
  toCalendarEventData,
23
24
  UNZONED_CALENDAR,
@@ -53,7 +54,7 @@ export function useDraftClashes(
53
54
  enabled: checkable,
54
55
  });
55
56
 
56
- const zone = timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR;
57
+ const zone = deviceTimeZone();
57
58
 
58
59
  return useMemo(() => {
59
60
  if (!checkable || isLoading || error) return undefined;
@@ -69,6 +70,7 @@ export function useDraftClashes(
69
70
  toCalendarEventData(
70
71
  instance,
71
72
  timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
73
+ zone,
72
74
  ),
73
75
  );
74
76
  return clashesWith({ start, end }, others).map((event) => ({
@@ -20,6 +20,7 @@ import {
20
20
  calendarWindowOfDays,
21
21
  isoAt,
22
22
  isoAtInZone,
23
+ isoOnClock,
23
24
  } from "./window";
24
25
 
25
26
  const day = (iso: string): string => iso.slice(0, 10);
@@ -146,3 +147,30 @@ describe("a clock time in a named zone", () => {
146
147
  );
147
148
  });
148
149
  });
150
+
151
+ describe("an occurrence re-read on the device's clock", () => {
152
+ it("moves a Tokyo morning to the New York evening before", () => {
153
+ assert.equal(
154
+ isoOnClock("2034-02-22T08:00:00+09:00", "America/New_York"),
155
+ "2034-02-21T18:00:00-05:00",
156
+ );
157
+ });
158
+
159
+ it("reads each side of a spring-forward on its own offset", () => {
160
+ assert.equal(
161
+ isoOnClock("2034-03-20T08:00:00+00:00", "Europe/Amsterdam"),
162
+ "2034-03-20T09:00:00+01:00",
163
+ );
164
+ assert.equal(
165
+ isoOnClock("2034-03-27T07:00:00+00:00", "Europe/Amsterdam"),
166
+ "2034-03-27T09:00:00+02:00",
167
+ );
168
+ });
169
+
170
+ it("falls back to the device rather than throwing on a zone nothing knows", () => {
171
+ assert.match(
172
+ isoOnClock("2034-02-22T08:00:00+09:00", "Mars/Olympus_Mons"),
173
+ OFFSET,
174
+ );
175
+ });
176
+ });
@@ -112,6 +112,26 @@ export function isoAtInZone(
112
112
  )}`;
113
113
  }
114
114
 
115
+ /**
116
+ * An instant written again as the wall time it reads as on a zone's clock.
117
+ *
118
+ * The listing serves each occurrence on its collection's clock, and the grid,
119
+ * the strip and the form all read the digits. Drawing those digits as they came
120
+ * puts a Tokyo calendar's 08:00 at 08:00 in New York; re-reading the instant on
121
+ * the device's clock is what puts it at 18:00 the evening before.
122
+ */
123
+ export function isoOnClock(iso: string, timeZone: string): string {
124
+ const instant = Date.parse(iso);
125
+ const zoned = zoneOffsetAt(timeZone, instant);
126
+ const offset = Number.isNaN(zoned)
127
+ ? -new Date(instant).getTimezoneOffset()
128
+ : zoned;
129
+ const wall = new Date(instant + offset * 60_000);
130
+ return `${civil(wall)}T${pad(wall.getUTCHours())}:${pad(
131
+ wall.getUTCMinutes(),
132
+ )}:${pad(wall.getUTCSeconds())}${formatOffset(offset)}`;
133
+ }
134
+
115
135
  /** Midnight on a civil date, carrying the offset the device is on that day. */
116
136
  export const startOfDay = (date: string): string => isoAt(date, "00:00");
117
137
 
@@ -0,0 +1,103 @@
1
+ import {
2
+ outboxAttachmentOperationsCompleteOutboxAttachmentMutation,
3
+ outboxDetailOperationsMintOutboxAttachmentMutation,
4
+ outboxDetailOperationsUpdateOutboxMessageMutation,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import { useMutation } from "@tanstack/react-query";
7
+ import { useRef, useState, useSyncExternalStore } from "react";
8
+ import {
9
+ type AttachmentTransport,
10
+ ComposeAttachmentController,
11
+ type DraftForAttachment,
12
+ } from "../lib/compose-attachment-controller";
13
+ import { softErrorMeta } from "../lib/error-classifier";
14
+
15
+ export type { DraftForAttachment };
16
+
17
+ const putFile = (
18
+ uploadUrl: string,
19
+ file: File,
20
+ signal: AbortSignal,
21
+ ): Promise<void> =>
22
+ fetch(uploadUrl, { method: "PUT", body: file, signal }).then((response) => {
23
+ if (!response.ok) {
24
+ throw new Error(`the server answered ${response.status}`);
25
+ }
26
+ });
27
+
28
+ interface UseComposeAttachmentsOptions {
29
+ ensureDraft: () => Promise<DraftForAttachment>;
30
+ }
31
+
32
+ /** The composer's side of `ComposeAttachmentController`, over the app's API. */
33
+ export const useComposeAttachments = ({
34
+ ensureDraft,
35
+ }: UseComposeAttachmentsOptions) => {
36
+ const mintMutation = useMutation({
37
+ ...outboxDetailOperationsMintOutboxAttachmentMutation(),
38
+ meta: softErrorMeta,
39
+ });
40
+ const completeMutation = useMutation({
41
+ ...outboxAttachmentOperationsCompleteOutboxAttachmentMutation(),
42
+ meta: softErrorMeta,
43
+ });
44
+ const updateMutation = useMutation({
45
+ ...outboxDetailOperationsUpdateOutboxMessageMutation(),
46
+ meta: softErrorMeta,
47
+ });
48
+
49
+ const transportRef = useRef<AttachmentTransport | null>(null);
50
+ transportRef.current = {
51
+ mint: (outboxMessageId, file) =>
52
+ mintMutation.mutateAsync({
53
+ path: { outboxMessageId },
54
+ body: {
55
+ filename: file.name,
56
+ contentType: file.type || "application/octet-stream",
57
+ sizeBytes: file.size,
58
+ },
59
+ }),
60
+ put: putFile,
61
+ complete: (outboxMessageId, outboxAttachmentId) =>
62
+ completeMutation
63
+ .mutateAsync({ path: { outboxMessageId, outboxAttachmentId } })
64
+ .then(() => undefined),
65
+ keep: (outboxMessageId, attachmentIds) =>
66
+ updateMutation
67
+ .mutateAsync({
68
+ path: { outboxMessageId },
69
+ body: { attachmentIds },
70
+ })
71
+ .then(() => undefined),
72
+ };
73
+ const ensureDraftRef = useRef(ensureDraft);
74
+ ensureDraftRef.current = ensureDraft;
75
+
76
+ const [controller] = useState(
77
+ () =>
78
+ new ComposeAttachmentController({
79
+ transport: () => {
80
+ const transport = transportRef.current;
81
+ if (!transport) throw new Error("attachment transport not ready");
82
+ return transport;
83
+ },
84
+ ensureDraft: () => ensureDraftRef.current(),
85
+ }),
86
+ );
87
+
88
+ const items = useSyncExternalStore(
89
+ controller.subscribe,
90
+ controller.getSnapshot,
91
+ controller.getSnapshot,
92
+ );
93
+
94
+ return {
95
+ items,
96
+ attach: controller.attach,
97
+ retry: controller.retry,
98
+ remove: controller.remove,
99
+ load: controller.load,
100
+ reset: controller.reset,
101
+ blockingReason: controller.blockingReason(),
102
+ };
103
+ };
@@ -24,6 +24,7 @@ import {
24
24
  } from "@/components/calendar/CalendarUnavailable";
25
25
  import {
26
26
  calendarWindowOfDays,
27
+ deviceTimeZone,
27
28
  isDrawnInstance,
28
29
  readCalendarInstanceId,
29
30
  type SuggestionAnswer,
@@ -214,6 +215,7 @@ export function useIntelligenceCalendar(
214
215
  const event = toCalendarEventData(
215
216
  instance,
216
217
  timeZoneByCalendarId[instance.calendarId] ?? "",
218
+ deviceTimeZone(),
217
219
  );
218
220
  return {
219
221
  event,
@@ -42,12 +42,17 @@ export function formatCivilDay(date: string): string {
42
42
  /** A span as the kit is handed it: when it starts, when it ends, whether it has hours. */
43
43
  export type CalendarSpan = Pick<CalendarEventData, "start" | "end" | "allDay">;
44
44
 
45
- /** "Wed 10 June, 10:00 – 11:00", or the day alone when it runs all of it. */
45
+ /**
46
+ * "Wed 10 June, 10:00 – 11:00", or the day alone when it runs all of it. An
47
+ * all-day span is read off its civil date: parsed as an instant, it lands on
48
+ * the day before anywhere west of UTC.
49
+ */
46
50
  export function formatEventWhen(event: CalendarSpan): string {
51
+ if (event.allDay)
52
+ return `${formatCivilDay(event.start.slice(0, 10))}, all day`;
47
53
  const start = new Date(event.start);
48
54
  const end = new Date(event.end);
49
55
  const day = DAY.format(start);
50
- if (event.allDay) return `${day}, all day`;
51
56
  const sameDay = DAY.format(end) === day;
52
57
  if (sameDay) return `${day}, ${CLOCK.format(start)} – ${CLOCK.format(end)}`;
53
58
  return `${day}, ${CLOCK.format(start)} – ${DAY.format(end)}, ${CLOCK.format(end)}`;