@remit/web-client 0.0.285 → 0.0.287

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/web-client",
3
- "version": "0.0.285",
3
+ "version": "0.0.287",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -34,6 +34,7 @@ const draft: EventDraft = {
34
34
  title: "Roadmap review",
35
35
  date: "2026-06-10",
36
36
  startTime: "10:00",
37
+ endDate: "2026-06-10",
37
38
  endTime: "11:30",
38
39
  allDay: false,
39
40
  calendarId: "cal_work",
@@ -104,6 +105,22 @@ export const EditingOneOccurrence: Story = {
104
105
  },
105
106
  };
106
107
 
108
+ /** A night shift opens as one night, ending on the next day. */
109
+ export const EditingOvernight: Story = {
110
+ args: {
111
+ title: "Edit event",
112
+ subtitle: "2026-06-10",
113
+ saveLabel: "Save",
114
+ draft: {
115
+ ...draft,
116
+ title: "Night shift handover",
117
+ startTime: "22:00",
118
+ endDate: "2026-06-11",
119
+ endTime: "01:00",
120
+ },
121
+ },
122
+ };
123
+
107
124
  /** The version on screen was replaced elsewhere. Nothing is overwritten. */
108
125
  export const Conflicted: Story = {
109
126
  args: {
@@ -28,6 +28,7 @@ const server: CalendarServer = (request) => {
28
28
 
29
29
  const slot: CalendarSlotPick = {
30
30
  date: "2026-06-11",
31
+ endDate: "2026-06-11",
31
32
  startTime: "14:00",
32
33
  endTime: "15:00",
33
34
  allDay: false,
@@ -37,6 +37,7 @@ export function WriteCalendarEvent({ onClose }: WriteCalendarEventProps) {
37
37
  ...blank,
38
38
  allDay: seed.allDay,
39
39
  startTime: seed.allDay ? "" : seed.startTime,
40
+ endDate: seed.endDate,
40
41
  endTime: seed.allDay ? "" : seed.endTime,
41
42
  };
42
43
  });
@@ -214,7 +214,9 @@ describe("the appointment ceremony", () => {
214
214
  message: "not settled",
215
215
  details: { mailboxId: "mbx-trash", syncStatus: "pending" },
216
216
  }),
217
- { status: 409, headers: { "content-type": "application/json" } },
217
+ // 422: the folder being appointed has not settled, which is a
218
+ // different answer from a 409's "somebody else is changing it".
219
+ { status: 422, headers: { "content-type": "application/json" } },
218
220
  );
219
221
  });
220
222
 
@@ -9,10 +9,16 @@
9
9
  */
10
10
  import assert from "node:assert/strict";
11
11
  import { describe, it } from "node:test";
12
- import type { EventDraft } from "@remit/ui";
12
+ import {
13
+ type CalendarEventData,
14
+ type EventDraft,
15
+ withAllDay,
16
+ withStartDate,
17
+ } from "@remit/ui";
13
18
  import {
14
19
  type CreateInput,
15
20
  createInputFromDraft,
21
+ draftFromEvent,
16
22
  emptyDraft,
17
23
  patchFromDrafts,
18
24
  type UpdatePatch,
@@ -28,6 +34,30 @@ const draft = (over: Partial<EventDraft> = {}): EventDraft => ({
28
34
  ...over,
29
35
  });
30
36
 
37
+ const STORED = { repeat: "", location: "", notes: "" };
38
+
39
+ const event = (over: Partial<CalendarEventData>): CalendarEventData => ({
40
+ id: "evt_1",
41
+ calendarId: CALENDAR,
42
+ title: "Roadmap review",
43
+ start: "2026-06-10T09:00:00+02:00",
44
+ end: "2026-06-10T10:00:00+02:00",
45
+ allDay: false,
46
+ location: "",
47
+ notes: "",
48
+ attendees: [],
49
+ myRsvp: "accepted",
50
+ threadId: "",
51
+ threadSubject: "",
52
+ timeZone: AMSTERDAM,
53
+ zoneCertainty: "explicit",
54
+ recurrenceRule: "",
55
+ seriesId: "",
56
+ seriesException: false,
57
+ status: "confirmed",
58
+ ...over,
59
+ });
60
+
31
61
  const refusal = (result: CreateInput | UpdatePatch): string => {
32
62
  assert.ok(
33
63
  !result.ok,
@@ -57,6 +87,31 @@ describe("creating an event", () => {
57
87
  assert.equal(built.input.end, "2026-06-11");
58
88
  });
59
89
 
90
+ it("ends a night that runs past midnight on the next day", () => {
91
+ const built = createInputFromDraft(
92
+ draft({ startTime: "22:00", endDate: "2026-06-11", endTime: "01:00" }),
93
+ AMSTERDAM,
94
+ );
95
+ assert.ok(built.ok);
96
+ assert.equal(built.input.start, "2026-06-10T22:00:00+02:00");
97
+ assert.equal(built.input.end, "2026-06-11T01:00:00+02:00");
98
+ });
99
+
100
+ it("ends an all-day event the day after the last day it covers", () => {
101
+ const built = createInputFromDraft(
102
+ draft({
103
+ allDay: true,
104
+ startTime: "",
105
+ endTime: "",
106
+ endDate: "2026-06-12",
107
+ }),
108
+ AMSTERDAM,
109
+ );
110
+ assert.ok(built.ok);
111
+ assert.equal(built.input.start, "2026-06-10");
112
+ assert.equal(built.input.end, "2026-06-13");
113
+ });
114
+
60
115
  it("turns a picked repeat sentence into the rule it means", () => {
61
116
  const built = createInputFromDraft(
62
117
  draft({ repeat: "Every weekday, 09:00" }),
@@ -86,7 +141,24 @@ describe("a refusal", () => {
86
141
  AMSTERDAM,
87
142
  ),
88
143
  ),
89
- /end time is not after the start time/i,
144
+ /end is not after the start/i,
145
+ );
146
+ });
147
+
148
+ it("names a last day before the first on an all-day event", () => {
149
+ assert.match(
150
+ refusal(
151
+ createInputFromDraft(
152
+ draft({
153
+ allDay: true,
154
+ startTime: "",
155
+ endTime: "",
156
+ endDate: "2026-06-09",
157
+ }),
158
+ AMSTERDAM,
159
+ ),
160
+ ),
161
+ /last day is before the first/i,
90
162
  );
91
163
  });
92
164
 
@@ -153,6 +225,41 @@ describe("editing an event", () => {
153
225
  assert.equal(patch.patch.recurrenceRule, "");
154
226
  });
155
227
 
228
+ it("saves another field of a night that runs past midnight", () => {
229
+ const before = draftFromEvent(
230
+ event({
231
+ start: "2026-06-10T22:00:00+02:00",
232
+ end: "2026-06-11T01:00:00+02:00",
233
+ }),
234
+ STORED,
235
+ );
236
+ assert.equal(before.endDate, "2026-06-11");
237
+ const patch = patchFromDrafts(
238
+ before,
239
+ { ...before, title: "Night shift" },
240
+ AMSTERDAM,
241
+ );
242
+ assert.ok(patch.ok);
243
+ assert.deepEqual(patch.patch, { summary: "Night shift" });
244
+ });
245
+
246
+ it("keeps every day of an all-day event it opens", () => {
247
+ const before = draftFromEvent(
248
+ event({ allDay: true, start: "2026-06-10", end: "2026-06-13" }),
249
+ STORED,
250
+ );
251
+ assert.equal(before.date, "2026-06-10");
252
+ assert.equal(before.endDate, "2026-06-12");
253
+ const patch = patchFromDrafts(
254
+ before,
255
+ { ...before, date: "2026-06-17", endDate: "2026-06-19" },
256
+ AMSTERDAM,
257
+ );
258
+ assert.ok(patch.ok);
259
+ assert.equal(patch.patch.start, "2026-06-17");
260
+ assert.equal(patch.patch.end, "2026-06-20");
261
+ });
262
+
156
263
  it("changes nothing when nothing was touched", () => {
157
264
  const before = draft();
158
265
  const patch = patchFromDrafts(before, { ...before }, AMSTERDAM);
@@ -185,7 +292,7 @@ describe("an event anchored somewhere other than the device", () => {
185
292
  const before = draft();
186
293
  const patch = patchFromDrafts(
187
294
  before,
188
- { ...before, date: "2026-06-17" },
295
+ { ...before, date: "2026-06-17", endDate: "2026-06-17" },
189
296
  AMSTERDAM,
190
297
  );
191
298
  assert.ok(patch.ok);
@@ -200,7 +307,7 @@ describe("an event anchored somewhere other than the device", () => {
200
307
 
201
308
  it("reads the zone's own winter offset rather than one it saw in June", () => {
202
309
  const built = createInputFromDraft(
203
- draft({ date: "2026-01-14" }),
310
+ draft({ date: "2026-01-14", endDate: "2026-01-14" }),
204
311
  AMSTERDAM,
205
312
  );
206
313
  assert.ok(built.ok);
@@ -231,7 +338,7 @@ describe("an event anchored somewhere other than the device", () => {
231
338
  const before = draft();
232
339
  const patch = patchFromDrafts(
233
340
  before,
234
- { ...before, date: "2026-06-17" },
341
+ { ...before, date: "2026-06-17", endDate: "2026-06-17" },
235
342
  "",
236
343
  );
237
344
  assert.ok(patch.ok);
@@ -239,3 +346,43 @@ describe("an event anchored somewhere other than the device", () => {
239
346
  assert.equal(patch.patch.start, "2026-06-17T09:00:00+00:00");
240
347
  });
241
348
  });
349
+
350
+ describe("the form moving its own dates", () => {
351
+ const weekend = draft({
352
+ allDay: true,
353
+ startTime: "",
354
+ endTime: "",
355
+ date: "2026-06-12",
356
+ endDate: "2026-06-14",
357
+ });
358
+
359
+ it("carries the last day along when the first day moves", () => {
360
+ const moved = withStartDate(weekend, "2026-06-19");
361
+ assert.equal(moved.date, "2026-06-19");
362
+ assert.equal(moved.endDate, "2026-06-21");
363
+ });
364
+
365
+ it("keeps the last day while the start date is cleared and retyped", () => {
366
+ const cleared = withStartDate(weekend, "");
367
+ assert.equal(cleared.endDate, "2026-06-14");
368
+ const retyped = withStartDate(cleared, "2026-06-12");
369
+ assert.equal(retyped.date, "2026-06-12");
370
+ assert.equal(retyped.endDate, "2026-06-14");
371
+ });
372
+
373
+ it("makes a night that runs past midnight one all-day date", () => {
374
+ const night = draft({
375
+ startTime: "22:00",
376
+ endDate: "2026-06-11",
377
+ endTime: "01:00",
378
+ });
379
+ const allDay = withAllDay(night, true);
380
+ assert.equal(allDay.allDay, true);
381
+ assert.equal(allDay.endDate, "2026-06-10");
382
+ });
383
+
384
+ it("keeps the days of a timed event that spans them when made all day", () => {
385
+ const span = draft({ endDate: "2026-06-12", endTime: "17:00" });
386
+ assert.equal(withAllDay(span, true).endDate, "2026-06-12");
387
+ });
388
+ });
@@ -13,7 +13,7 @@ import type {
13
13
  RemitImapCreateCalendarEventInput,
14
14
  RemitImapUpdateCalendarEventInput,
15
15
  } from "@remit/api-http-client/types.gen.ts";
16
- import type { CalendarEventData, EventDraft } from "@remit/ui";
16
+ import { type CalendarEventData, type EventDraft, lastDayOf } from "@remit/ui";
17
17
  import { rruleFromText } from "./recurrence-rule";
18
18
  import { addDays, isoAtInZone } from "./window";
19
19
 
@@ -33,6 +33,7 @@ export function emptyDraft(date: string, calendarId: string): EventDraft {
33
33
  title: "",
34
34
  date,
35
35
  startTime: "09:00",
36
+ endDate: date,
36
37
  endTime: "10:00",
37
38
  allDay: false,
38
39
  calendarId,
@@ -56,6 +57,7 @@ export function draftFromEvent(
56
57
  title: event.title,
57
58
  date: event.start.slice(0, 10),
58
59
  startTime: event.allDay ? "" : event.start.slice(11, 16),
60
+ endDate: lastDayOf(event),
59
61
  endTime: event.allDay ? "" : event.end.slice(11, 16),
60
62
  allDay: event.allDay,
61
63
  calendarId: event.calendarId,
@@ -79,10 +81,11 @@ function timesFor(
79
81
  draft: EventDraft,
80
82
  timeZone: string,
81
83
  ): { start: string; end: string } {
82
- if (draft.allDay) return { start: draft.date, end: addDays(draft.date, 1) };
84
+ if (draft.allDay)
85
+ return { start: draft.date, end: addDays(draft.endDate, 1) };
83
86
  return {
84
87
  start: isoAtInZone(draft.date, draft.startTime, timeZone),
85
- end: isoAtInZone(draft.date, draft.endTime, timeZone),
88
+ end: isoAtInZone(draft.endDate, draft.endTime, timeZone),
86
89
  };
87
90
  }
88
91
 
@@ -95,11 +98,17 @@ function refuse(draft: EventDraft, checkRepeat: boolean): string {
95
98
  if (draft.title.trim() === "")
96
99
  return "Give the event a title before saving it.";
97
100
  if (draft.date === "") return "Pick the day the event is on.";
101
+ if (draft.endDate === "") return "Pick the day the event ends on.";
98
102
  if (draft.calendarId === "") return "Pick the calendar to save the event in.";
99
103
  if (!draft.allDay && (draft.startTime === "" || draft.endTime === ""))
100
104
  return "Set a start and an end time, or mark the event all day.";
101
- if (!draft.allDay && draft.endTime <= draft.startTime)
102
- return "The end time is not after the start time. Move one of them.";
105
+ if (draft.allDay && draft.endDate < draft.date)
106
+ return "The last day is before the first. Move one of them.";
107
+ if (
108
+ !draft.allDay &&
109
+ `${draft.endDate}T${draft.endTime}` <= `${draft.date}T${draft.startTime}`
110
+ )
111
+ return "The end is not after the start. Move one of them.";
103
112
  if (checkRepeat && rruleFromText(draft.repeat) === undefined)
104
113
  return "This calendar can't store that repeat rule. Pick one of the offered rules, or turn the repeat off.";
105
114
  return "";
@@ -149,6 +158,7 @@ export function patchFromDrafts(
149
158
  const moved =
150
159
  after.date !== before.date ||
151
160
  after.startTime !== before.startTime ||
161
+ after.endDate !== before.endDate ||
152
162
  after.endTime !== before.endTime ||
153
163
  after.allDay !== before.allDay;
154
164
  if (moved) {
@@ -165,11 +165,14 @@ describe("a bulk delete refused for its folder role", () => {
165
165
  await mount((path) => {
166
166
  if (path.endsWith("/config")) return CONFIG;
167
167
  if (path.endsWith("/messages/delete"))
168
+ // A conflict with no code this prompt knows: the status class's own,
169
+ // which is what every refusal that names nothing more specific
170
+ // answers with.
168
171
  return new Response(
169
172
  JSON.stringify({
170
173
  status: 409,
171
174
  message: "boom",
172
- code: "mailbox_not_settled",
175
+ code: "conflict",
173
176
  }),
174
177
  { status: 409, headers: { "content-type": "application/json" } },
175
178
  );