@rapidmx/web-client 0.3.1 → 0.5.0

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 (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -4,34 +4,41 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useState } from "react";
7
- import { CALENDAR_COLOR_PALETTE, colorForFolder } from "@rapidmx/react-shared/calendar/calendarColors.js";
7
+ import { CALENDAR_COLOR_PALETTE, accentColorForMailbox, colorForFolder } from "@rapidmx/react-shared/calendar/calendarColors.js";
8
8
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
9
  /**
10
- * Calendar's left-sidebar checklist of "My calendars" — one row per `calendar`-type folder, a color
11
- * swatch matching `colorForFolder`, and a checkbox controlling whether that calendar's events are
12
- * currently shown (the checked set is owned by `apps/www/calendar/index.tsx`, since it drives which
13
- * folders get fanned out to on every reload). "+ Add calendar" is a real `createFolder({type:
14
- * "calendar", ...})` call — see the Phase 4 plan's own note that folder creation has no type
15
- * restriction server-side, so this needed no new backend route.
10
+ * Calendar's left-sidebar checklist of calendars — one row per `calendar`-type folder, a color swatch,
11
+ * and a checkbox controlling whether that calendar's events are currently shown (the checked set is owned
12
+ * by `apps/www/calendar/index.tsx`, since it drives which folders get fanned out to on every reload).
13
+ * With one mailbox this is a single "My calendars" list, as before; with several, each mailbox gets its
14
+ * own section (a shared mailbox labeled as such, with a color-dot accent) and its own "+ Add calendar",
15
+ * which creates the calendar in that mailbox. "+ Add calendar" is a real `createFolder({type: "calendar",
16
+ * ...})` call — folder creation has no type restriction server-side, so this needed no new backend route.
16
17
  */
17
- export default function CalendarListSidebar({ calendars, checkedFolderUids, onToggle, onAddCalendar }) {
18
- const [adding, setAdding] = useState(false);
18
+ export default function CalendarListSidebar({ mailboxCalendars, checkedFolderUids, onToggle, onAddCalendar, colorFor = (folder) => colorForFolder(folder), }) {
19
+ const [addingFor, setAddingFor] = useState(null);
19
20
  const [name, setName] = useState("");
20
21
  const [color, setColor] = useState(CALENDAR_COLOR_PALETTE[0]);
21
22
  const [error, setError] = useState(null);
22
23
  const [saving, setSaving] = useState(false);
24
+ const multiple = mailboxCalendars.length > 1;
25
+ function startAdding(mailboxUid) {
26
+ setAddingFor(mailboxUid);
27
+ setName("");
28
+ setColor(CALENDAR_COLOR_PALETTE[0]);
29
+ }
23
30
  async function handleAdd(e) {
24
31
  e.preventDefault();
25
- if (!name.trim()) {
32
+ if (!name.trim() || !addingFor) {
26
33
  return;
27
34
  }
28
35
  setError(null);
29
36
  setSaving(true);
30
37
  try {
31
- await onAddCalendar(name.trim(), color);
38
+ await onAddCalendar(addingFor, name.trim(), color);
32
39
  setName("");
33
40
  setColor(CALENDAR_COLOR_PALETTE[0]);
34
- setAdding(false);
41
+ setAddingFor(null);
35
42
  }
36
43
  catch {
37
44
  setError("Could not create this calendar.");
@@ -40,5 +47,9 @@ export default function CalendarListSidebar({ calendars, checkedFolderUids, onTo
40
47
  setSaving(false);
41
48
  }
42
49
  }
43
- return (_jsxs("nav", { "aria-label": "My calendars", className: "p-3 border-t border-border", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "My calendars" }), _jsx("button", { type: "button", onClick: () => setAdding(true), "aria-label": "Add calendar", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), error && (_jsx("div", { className: "mb-1", children: _jsx(Alert, { children: error }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: calendars.map((folder) => (_jsxs("label", { className: "flex items-center gap-2 text-sm px-1 py-1 rounded-sm hover:bg-surface-alt cursor-pointer", children: [_jsx("input", { type: "checkbox", checked: checkedFolderUids.has(folder.uid), onChange: () => onToggle(folder.uid), style: { accentColor: colorForFolder(folder) } }), _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: colorForFolder(folder) } }), _jsx("span", { className: "truncate", children: folder.name })] }, folder.uid))) }), adding && (_jsxs("form", { onSubmit: handleAdd, className: "flex flex-col gap-1.5 mt-2", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New calendar name", value: name, onChange: (e) => setName(e.target.value), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("div", { className: "flex items-center gap-1", role: "radiogroup", "aria-label": "Calendar color", children: CALENDAR_COLOR_PALETTE.map((swatch) => (_jsx("button", { type: "button", "aria-label": `Color ${swatch}`, "aria-pressed": color === swatch, onClick: () => setColor(swatch), className: ["w-4 h-4 rounded-full shrink-0", color === swatch ? "ring-2 ring-offset-1 ring-text" : ""].join(" "), style: { backgroundColor: swatch } }, swatch))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "submit", disabled: saving, className: "text-xs font-semibold text-primary-dark", children: "Add" }), _jsx("button", { type: "button", onClick: () => setAdding(false), className: "text-xs text-text-muted", children: "Cancel" })] })] }))] }));
50
+ const addForm = (_jsxs("form", { onSubmit: handleAdd, className: "flex flex-col gap-1.5 mt-2", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New calendar name", value: name, onChange: (e) => setName(e.target.value), className: "text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("div", { className: "flex items-center gap-1", role: "radiogroup", "aria-label": "Calendar color", children: CALENDAR_COLOR_PALETTE.map((swatch) => (_jsx("button", { type: "button", "aria-label": `Color ${swatch}`, "aria-pressed": color === swatch, onClick: () => setColor(swatch), className: ["w-4 h-4 rounded-full shrink-0", color === swatch ? "ring-2 ring-offset-1 ring-text" : ""].join(" "), style: { backgroundColor: swatch } }, swatch))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("button", { type: "submit", disabled: saving, className: "text-xs font-semibold text-primary-dark", children: "Add" }), _jsx("button", { type: "button", onClick: () => setAddingFor(null), className: "text-xs text-text-muted", children: "Cancel" })] })] }));
51
+ return (_jsxs("nav", { "aria-label": "My calendars", className: "p-3 border-t border-border flex flex-col gap-3", children: [error && _jsx(Alert, { children: error }), mailboxCalendars.map(({ mailbox, calendarFolders, error: mailboxError }) => {
52
+ const title = multiple ? `${mailbox.displayName}${mailbox.ownerUserUid ? "" : " (shared)"}` : "My calendars";
53
+ return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between mb-1 gap-2", children: [_jsxs("h2", { className: "flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-text-muted min-w-0", children: [multiple && (_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: accentColorForMailbox(mailbox.uid) } })), _jsx("span", { className: "truncate", children: title })] }), _jsx("button", { type: "button", onClick: () => startAdding(mailbox.uid), "aria-label": multiple ? `Add calendar to ${mailbox.displayName}` : "Add calendar", className: "text-xs font-bold text-primary-dark hover:underline shrink-0", children: "+" })] }), mailboxError && (_jsx("div", { className: "mb-1", children: _jsx(Alert, { children: mailboxError }) })), _jsx("div", { className: "flex flex-col gap-0.5", children: calendarFolders.map((folder) => (_jsxs("label", { className: "flex items-center gap-2 text-sm px-1 py-1 rounded-sm hover:bg-surface-alt cursor-pointer", children: [_jsx("input", { type: "checkbox", checked: checkedFolderUids.has(folder.uid), onChange: () => onToggle(folder.uid), style: { accentColor: colorFor(folder) } }), _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: colorFor(folder) } }), _jsx("span", { className: "truncate", children: folder.name })] }, folder.uid))) }), addingFor === mailbox.uid && addForm] }, mailbox.uid));
54
+ })] }));
44
55
  }
@@ -5,14 +5,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useRef, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { createCalendarEvent, respondToEvent, updateCalendarEvent, } from "@rapidmx/react-shared/calendar/calendarApi.js";
8
+ import { createCalendarEvent, getCalendarEvent, respondToEvent, updateCalendarEvent, } from "@rapidmx/react-shared/calendar/calendarApi.js";
9
9
  import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
10
10
  import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
11
+ import { fromEventWallClock, toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
11
12
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
12
13
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
14
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
14
15
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
15
16
  import RecurrenceEditor from "./RecurrenceEditor.js";
17
+ import { addDaysToKey, allDayDateKey, allDayInstant, recurrenceUntilDateKey, recurrenceUntilInstant, startWeekdayCode } from "./allDay.js";
16
18
  import ResourcePicker from "./ResourcePicker.js";
17
19
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
18
20
  const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -25,17 +27,105 @@ const RESPONSE_STATUS_LABEL = {
25
27
  declined: "Declined",
26
28
  tentative: "Tentative",
27
29
  };
30
+ const MS_PER_MINUTE = 60000;
31
+ const MS_PER_DAY = 86400000;
32
+ /** Whether two ISO instants fall in the same minute - a stored `...T15:00:00Z` and the form's re-serialized
33
+ * `...T15:00:00.000Z` name the same time without being equal strings. */
34
+ function sameMinute(a, b) {
35
+ return Math.round(new Date(a).getTime() / MS_PER_MINUTE) === Math.round(new Date(b).getTime() / MS_PER_MINUTE);
36
+ }
37
+ /** Milliseconds since midnight of an instant's wall clock in `timezone` (a timed event's own zone). */
38
+ function wallTimeOfDayMs(instantMs, timezone) {
39
+ const wall = toEventWallClock(instantMs, timezone, false);
40
+ return ((wall % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY;
41
+ }
42
+ /**
43
+ * Rewrites an edited occurrence's `startDate`/`endDate` for a save to the entire series. The modal only
44
+ * ever shows (and edits) *this occurrence's* dates, but the series is stored as its master record starting
45
+ * at the first occurrence - sending the occurrence's dates as-is would move the whole series' start to
46
+ * this occurrence. So only the change in time-of-day (and the new duration) is applied to the master's own
47
+ * start; the series keeps its original first date. Unchanged times aren't sent at all.
48
+ *
49
+ * Times of day are read on the event's own timezone wall clock (the series expands there), not the
50
+ * browser's, and the change is the smallest signed difference modulo 24h - moving 23:00 to 01:00 is two
51
+ * hours later (into the next day), never 22 hours earlier. The master's new start is converted back from
52
+ * that wall clock, so a master on the other side of a DST change keeps the chosen local time.
53
+ */
54
+ async function toSeriesFields(occurrence, fields) {
55
+ const { startDate, endDate, ...rest } = fields;
56
+ if (sameMinute(startDate, occurrence.startDate) && sameMinute(endDate, occurrence.endDate)) {
57
+ return rest;
58
+ }
59
+ const master = await getCalendarEvent(occurrence.uid);
60
+ const timezone = fields.timezone;
61
+ const newStart = new Date(startDate).getTime();
62
+ const durationMs = new Date(endDate).getTime() - newStart;
63
+ let masterStart;
64
+ if (fields.allDay) {
65
+ // Timed -> all-day keeps the master's own date on its wall clock.
66
+ const masterDateKey = master.allDay
67
+ ? allDayDateKey(master.startDate)
68
+ : new Date(toEventWallClock(new Date(master.startDate).getTime(), timezone, false)).toISOString().slice(0, 10);
69
+ masterStart = new Date(allDayInstant(masterDateKey)).getTime();
70
+ }
71
+ else if (master.allDay) {
72
+ // All-day -> timed: the master's own date at the newly chosen wall-clock time.
73
+ const masterDateWall = new Date(allDayInstant(allDayDateKey(master.startDate))).getTime();
74
+ masterStart = fromEventWallClock(masterDateWall + wallTimeOfDayMs(newStart, timezone), timezone, false);
75
+ }
76
+ else {
77
+ const rawDelta = wallTimeOfDayMs(newStart, timezone) - wallTimeOfDayMs(new Date(occurrence.startDate).getTime(), timezone);
78
+ const delta = ((((rawDelta + MS_PER_DAY / 2) % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY) - MS_PER_DAY / 2;
79
+ const masterWall = toEventWallClock(new Date(master.startDate).getTime(), timezone, false);
80
+ masterStart = fromEventWallClock(masterWall + delta, timezone, false);
81
+ }
82
+ return {
83
+ ...rest,
84
+ startDate: new Date(masterStart).toISOString(),
85
+ endDate: new Date(masterStart + durationMs).toISOString(),
86
+ };
87
+ }
28
88
  /**
29
89
  * Create/view/edit/delete for a single calendar event. Editing or deleting a recurring event's
30
90
  * occurrence offers a choice between "this event" and "the entire series" (see
31
91
  * `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
32
92
  */
33
- export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, organizerAddress, occurrence, initialStart, initialEnd, onSaved, onDeleted, }) {
93
+ export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, organizerAddress, organizerAliases, occurrence, initialStart, initialEnd, onSaved, onDeleted, }) {
34
94
  const [targetFolderUid, setTargetFolderUid] = useState(folderUid);
95
+ const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
96
+ const targetMailboxOption = mailboxOptions?.find((option) => option.mailbox.uid === targetMailboxUid);
97
+ // Create mode follows the chosen mailbox; editing an existing event always keeps the passed-in values.
98
+ const calendarChoices = !occurrence && targetMailboxOption ? targetMailboxOption.calendars : calendars;
99
+ const effectiveOrganizerAddress = !occurrence && targetMailboxOption ? targetMailboxOption.mailbox.primarySmtpAddress : organizerAddress;
100
+ // Every address the viewing mailbox receives at - its primary address and its aliases, case-insensitively.
101
+ const ownAddresses = new Set([organizerAddress, ...(organizerAliases ?? mailboxOptions?.find((option) => option.mailbox.uid === mailboxUid)?.mailbox.aliasAddresses ?? [])].map((address) => address.toLowerCase()));
102
+ const isOwnAddress = (address) => ownAddresses.has(address.toLowerCase());
103
+ // An existing event organized by someone else is the viewing mailbox's copy of an invitation: only the
104
+ // organizer can change it (their next update would overwrite local edits anyway), so it's read-only
105
+ // apart from the RSVP controls.
106
+ const isInvited = !!occurrence && !isOwnAddress(occurrence.organizer.address) && !occurrence.attendees.some((a) => a.isOrganizer && isOwnAddress(a.address));
107
+ function handleMailboxChange(nextMailboxUid) {
108
+ setTargetMailboxUid(nextMailboxUid);
109
+ const firstCalendar = mailboxOptions?.find((option) => option.mailbox.uid === nextMailboxUid)?.calendars[0];
110
+ if (firstCalendar) {
111
+ setTargetFolderUid(firstCalendar.uid);
112
+ }
113
+ }
35
114
  const [title, setTitle] = useState(occurrence?.title ?? "");
36
115
  const [location, setLocation] = useState(occurrence?.location ?? "");
37
- const [start, setStart] = useState(toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString()));
38
- const [end, setEnd] = useState(toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * 60000).toISOString()));
116
+ // An all-day event is stored date-only with an exclusive end (see `allDay.ts`); the form shows its
117
+ // inclusive last day instead.
118
+ const [start, setStart] = useState(() => occurrence?.allDay
119
+ ? `${allDayDateKey(occurrence.startDate)}T00:00`
120
+ : toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString()));
121
+ const [end, setEnd] = useState(() => {
122
+ if (occurrence?.allDay) {
123
+ const startKey = allDayDateKey(occurrence.startDate);
124
+ const lastDayKey = addDaysToKey(allDayDateKey(occurrence.endDate), -1);
125
+ return `${lastDayKey < startKey ? startKey : lastDayKey}T00:00`;
126
+ }
127
+ return toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * 60000).toISOString());
128
+ });
39
129
  const [allDay, setAllDay] = useState(occurrence?.allDay ?? false);
40
130
  const [timezone] = useState(occurrence?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone);
41
131
  const [attendees, setAttendees] = useState(occurrence?.attendees ?? []);
@@ -48,17 +138,21 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
48
138
  const [confirmingDelete, setConfirmingDelete] = useState(false);
49
139
  const [error, setError] = useState(null);
50
140
  const [saving, setSaving] = useState(false);
141
+ // A series save that moved the series but couldn't move every separately edited occurrence with it.
142
+ const [syncWarning, setSyncWarning] = useState(false);
51
143
  const [responding, setResponding] = useState(false);
52
144
  const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
53
145
  const addResourceButtonRef = useRef(null);
54
146
  // The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
55
- // `organizerAddress` is always this mailbox's own primary SMTP address (see `EventModalProps`), so
56
- // finding it among `attendees` (and not as that attendee's own organizer flag) identifies exactly
147
+ // `organizerAddress` (plus the aliases) are this mailbox's own addresses (see `EventModalProps`), so
148
+ // finding one among `attendees` (and not as that attendee's own organizer flag) identifies exactly
57
149
  // that case. `@rapidmx/restapi`'s `respond()` route has no occurrence-vs-series scope of its own
58
150
  // (see `calendarApi.ts`'s `respondToEvent` doc comment) — it always acts on `occurrence.uid` as a
59
151
  // single event document, so this deliberately does not consult `editScope`.
60
- const myAttendeeIndex = occurrence ? attendees.findIndex((a) => a.address.toLowerCase() === organizerAddress.toLowerCase()) : -1;
152
+ const myAttendeeIndex = occurrence ? attendees.findIndex((a) => isOwnAddress(a.address)) : -1;
61
153
  const canRespond = myAttendeeIndex !== -1 && !attendees[myAttendeeIndex].isOrganizer;
154
+ // "This event only" detaches a standalone, non-repeating copy - the series' rule doesn't apply to it.
155
+ const editingSingleOccurrence = !!occurrence?.isRecurringOccurrence && editScope === "occurrence";
62
156
  function updateAttendee(index, patch) {
63
157
  setAttendees((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)));
64
158
  }
@@ -79,35 +173,65 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
79
173
  setError("A title is required.");
80
174
  return;
81
175
  }
82
- if (new Date(end) <= new Date(start)) {
83
- setError("The end time must be after the start time.");
84
- return;
176
+ let startDate;
177
+ let endDate;
178
+ if (allDay) {
179
+ const startKey = start.slice(0, 10);
180
+ const lastDayKey = end.slice(0, 10);
181
+ if (lastDayKey < startKey) {
182
+ setError("The end date can't be before the start date.");
183
+ return;
184
+ }
185
+ startDate = allDayInstant(startKey);
186
+ endDate = allDayInstant(addDaysToKey(lastDayKey, 1));
85
187
  }
188
+ else {
189
+ if (new Date(end) <= new Date(start)) {
190
+ setError("The end time must be after the start time.");
191
+ return;
192
+ }
193
+ startDate = new Date(start).toISOString();
194
+ endDate = new Date(end).toISOString();
195
+ }
196
+ // Only a stored event is updated in place, where a cleared field must be sent as `null`; creating
197
+ // (a new event, or the detached copy of one occurrence) just omits it.
198
+ const updatingInPlace = !!occurrence && !editingSingleOccurrence;
199
+ const cleared = updatingInPlace ? null : undefined;
86
200
  const fields = {
87
201
  title: title.trim(),
88
- location: location.trim() || undefined,
89
- startDate: new Date(start).toISOString(),
90
- endDate: new Date(end).toISOString(),
202
+ location: location.trim() || cleared,
203
+ startDate,
204
+ endDate,
91
205
  allDay,
92
206
  timezone,
93
- organizer: { address: organizerAddress, type: "to" },
94
207
  attendees,
95
- recurrenceRule: recurrenceRule ?? undefined,
96
- reminderMinutesBeforeStart: reminderMinutes.trim() ? Number(reminderMinutes) : undefined,
208
+ recurrenceRule: editingSingleOccurrence ? undefined : (recurrenceRule ?? cleared),
209
+ reminderMinutesBeforeStart: reminderMinutes.trim() ? Number(reminderMinutes) : cleared,
97
210
  busyStatus,
98
211
  autoReplyEnabled,
99
- autoReplyMessage: autoReplyEnabled ? autoReplyMessage : undefined,
212
+ autoReplyMessage: autoReplyEnabled ? autoReplyMessage : cleared,
100
213
  };
101
214
  setSaving(true);
102
215
  try {
103
216
  if (!occurrence) {
104
- await createCalendarEvent({ mailboxUid, folderUid: targetFolderUid, ...fields });
217
+ // The organizer is only ever set on create - an edit keeps the event's own organizer.
218
+ await createCalendarEvent({
219
+ mailboxUid: targetMailboxUid,
220
+ folderUid: targetFolderUid,
221
+ ...fields,
222
+ organizer: { address: effectiveOrganizerAddress, type: "to" },
223
+ });
105
224
  }
106
- else if (occurrence.isRecurringOccurrence && editScope === "occurrence") {
225
+ else if (editingSingleOccurrence) {
107
226
  await detachOccurrence(occurrence, fields);
108
227
  }
109
228
  else if (occurrence.isRecurringOccurrence) {
110
- await saveEventSeries(occurrence, fields);
229
+ const saved = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)));
230
+ if (saved.detachedOccurrenceSyncFailed) {
231
+ // The series itself was saved - say so, and leave closing to the user once they've read it.
232
+ setSyncWarning(true);
233
+ return;
234
+ }
111
235
  }
112
236
  else {
113
237
  await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields });
@@ -162,6 +286,15 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
162
286
  setResponding(false);
163
287
  }
164
288
  }
165
- return (_jsx(Modal, { open: open, onClose: onClose, title: occurrence ? "Edit event" : "New event", children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-1", children: [error && _jsx(Alert, { children: error }), !occurrence && calendars && calendars.length > 1 && (_jsx(FormField, { label: "Calendar", htmlFor: "event-calendar", children: _jsx("select", { id: "event-calendar", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: calendars.map((cal) => (_jsx("option", { value: cal.uid, children: cal.name }, cal.uid))) }) })), _jsx(FormField, { label: "Title", htmlFor: "event-title", children: _jsx("input", { id: "event-title", type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }) }), _jsx(FormField, { label: "Location", htmlFor: "event-location", children: _jsx("input", { id: "event-location", type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm font-medium mb-3", children: [_jsx("input", { type: "checkbox", checked: allDay, onChange: (e) => setAllDay(e.target.checked) }), "All day"] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Start", htmlFor: "event-start", children: _jsx("input", { id: "event-start", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? start.slice(0, 10) : start, onChange: (e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value) }) }), _jsx(FormField, { label: "End", htmlFor: "event-end", children: _jsx("input", { id: "event-end", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? end.slice(0, 10) : end, onChange: (e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value) }) })] }), _jsx(FormField, { label: "Attendees", htmlFor: "event-attendees", children: _jsxs("div", { id: "event-attendees", className: "flex flex-col gap-2", children: [attendees.map((attendee, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: attendee.address, onChange: (e) => updateAttendee(i, { address: e.target.value }), "aria-label": `Attendee email ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center", children: RESPONSE_STATUS_LABEL[attendee.responseStatus] }), _jsx("button", { type: "button", onClick: () => removeAttendee(i), className: "text-sm text-danger px-2", "aria-label": `Remove attendee ${i + 1}`, children: "\u00D7" })] }, i))), _jsxs("div", { className: "flex gap-3 items-center", children: [_jsx("button", { type: "button", onClick: () => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add attendee" }), _jsx("button", { ref: addResourceButtonRef, type: "button", onClick: () => setResourcePickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" })] }), resourcePickerOpen && (_jsx(ResourcePicker, { anchorRef: addResourceButtonRef, onClose: () => setResourcePickerOpen(false), onSelect: addResource, excludeAddresses: attendees.map((a) => a.address.toLowerCase()) }))] }) }), canRespond && (_jsxs("div", { className: "flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsx("span", { className: "text-sm font-medium", children: "Your response" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("accepted"), children: "Accept" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("tentative"), children: "Tentative" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: responding, onClick: () => handleRespond("declined"), children: "Decline" })] })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Other attendees\u2019 responses may take a few minutes to update." })] })), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Busy status", htmlFor: "event-busyStatus", children: _jsx("select", { id: "event-busyStatus", className: INPUT_CLASS, value: busyStatus, onChange: (e) => setBusyStatus(e.target.value), children: BUSY_STATUSES.map((status) => (_jsx("option", { value: status, children: BUSY_STATUS_LABEL[status] }, status))) }) }), _jsx(FormField, { label: "Reminder (minutes before)", htmlFor: "event-reminder", children: _jsx("input", { id: "event-reminder", type: "number", min: 0, className: INPUT_CLASS, value: reminderMinutes, onChange: (e) => setReminderMinutes(e.target.value), placeholder: "None" }) })] }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: autoReplyEnabled, onChange: (e) => setAutoReplyEnabled(e.target.checked) }), "Send an automatic reply while this event is happening"] }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "Applies in addition to your mailbox\u2019s own Automatic Replies setting (see Settings) \u2014 this event\u2019s message takes over for its own start/end window." }), autoReplyEnabled && (_jsx("textarea", { "aria-label": "Automatic reply message", className: `${INPUT_CLASS} mt-2`, rows: 3, value: autoReplyMessage, onChange: (e) => setAutoReplyMessage(e.target.value), placeholder: "I'm out of office and back on..." }))] }), _jsx(FormField, { label: "Recurrence", htmlFor: "event-recurrence", children: _jsx(RecurrenceEditor, { value: recurrenceRule, onChange: setRecurrenceRule }) }), occurrence?.isRecurringOccurrence && (_jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm mb-3", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Apply changes to" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "occurrence", onChange: () => setEditScope("occurrence") }), "This event only"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "series", onChange: () => setEditScope("series") }), "The entire series"] })] })), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), occurrence &&
289
+ // The recurrence end date is stored in the frame the series expands in (UTC for all-day, local otherwise),
290
+ // so toggling All day re-stores the same chosen date in the new frame.
291
+ function handleAllDayChange(nextAllDay) {
292
+ setAllDay(nextAllDay);
293
+ setRecurrenceRule((rule) => rule?.until ? { ...rule, until: recurrenceUntilInstant(recurrenceUntilDateKey(rule.until, allDay), nextAllDay) } : rule);
294
+ }
295
+ if (syncWarning) {
296
+ return (_jsxs(Modal, { open: open, onClose: onSaved, title: "Series saved", children: [_jsx("p", { role: "alert", className: "text-sm mb-4", children: "The series was saved, but some occurrences you had changed individually couldn\u2019t be moved with it. They may now appear twice - check the calendar and delete any duplicate." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: onSaved, children: "OK" })] }));
297
+ }
298
+ return (_jsx(Modal, { open: open, onClose: onClose, title: occurrence ? "Edit event" : "New event", children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-1", children: [error && _jsx(Alert, { children: error }), canRespond && (_jsxs("div", { className: "flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsx("span", { className: "text-sm font-medium", children: "Your response" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("accepted"), children: "Accept" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("tentative"), children: "Tentative" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: responding, onClick: () => handleRespond("declined"), children: "Decline" })] })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Other attendees\u2019 responses may take a few minutes to update." })] })), isInvited && (_jsx("p", { className: "text-xs text-text-muted mb-3", children: "You were invited to this event. Only the organizer can change its details." })), _jsxs("fieldset", { disabled: isInvited, className: "flex flex-col gap-1 min-w-0", children: [!occurrence && mailboxOptions && mailboxOptions.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "event-mailbox", children: _jsx("select", { id: "event-mailbox", className: INPUT_CLASS, value: targetMailboxUid, onChange: (e) => handleMailboxChange(e.target.value), children: mailboxOptions.map(({ mailbox }) => (_jsxs("option", { value: mailbox.uid, children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }, mailbox.uid))) }) })), !occurrence && calendarChoices && calendarChoices.length > 1 && (_jsx(FormField, { label: "Calendar", htmlFor: "event-calendar", children: _jsx("select", { id: "event-calendar", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: calendarChoices.map((cal) => (_jsx("option", { value: cal.uid, children: cal.name }, cal.uid))) }) })), _jsx(FormField, { label: "Title", htmlFor: "event-title", children: _jsx("input", { id: "event-title", type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }) }), _jsx(FormField, { label: "Location", htmlFor: "event-location", children: _jsx("input", { id: "event-location", type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm font-medium mb-3", children: [_jsx("input", { type: "checkbox", checked: allDay, onChange: (e) => handleAllDayChange(e.target.checked) }), "All day"] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Start", htmlFor: "event-start", children: _jsx("input", { id: "event-start", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? start.slice(0, 10) : start, onChange: (e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value) }) }), _jsx(FormField, { label: "End", htmlFor: "event-end", children: _jsx("input", { id: "event-end", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? end.slice(0, 10) : end, onChange: (e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value) }) })] }), _jsx(FormField, { label: "Attendees", htmlFor: "event-attendees", children: _jsxs("div", { id: "event-attendees", className: "flex flex-col gap-2", children: [attendees.map((attendee, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: attendee.address, onChange: (e) => updateAttendee(i, { address: e.target.value }), "aria-label": `Attendee email ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center", children: RESPONSE_STATUS_LABEL[attendee.responseStatus] }), _jsx("button", { type: "button", onClick: () => removeAttendee(i), className: "text-sm text-danger px-2", "aria-label": `Remove attendee ${i + 1}`, children: "\u00D7" })] }, i))), _jsxs("div", { className: "flex gap-3 items-center", children: [_jsx("button", { type: "button", onClick: () => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add attendee" }), _jsx("button", { ref: addResourceButtonRef, type: "button", onClick: () => setResourcePickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" })] }), resourcePickerOpen && (_jsx(ResourcePicker, { anchorRef: addResourceButtonRef, onClose: () => setResourcePickerOpen(false), onSelect: addResource, excludeAddresses: attendees.map((a) => a.address.toLowerCase()) }))] }) }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Busy status", htmlFor: "event-busyStatus", children: _jsx("select", { id: "event-busyStatus", className: INPUT_CLASS, value: busyStatus, onChange: (e) => setBusyStatus(e.target.value), children: BUSY_STATUSES.map((status) => (_jsx("option", { value: status, children: BUSY_STATUS_LABEL[status] }, status))) }) }), _jsx(FormField, { label: "Reminder (minutes before)", htmlFor: "event-reminder", children: _jsx("input", { id: "event-reminder", type: "number", min: 0, className: INPUT_CLASS, value: reminderMinutes, onChange: (e) => setReminderMinutes(e.target.value), placeholder: "None" }) })] }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: autoReplyEnabled, onChange: (e) => setAutoReplyEnabled(e.target.checked) }), "Send an automatic reply while this event is happening"] }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "Applies in addition to your mailbox\u2019s own Automatic Replies setting (see Settings) \u2014 this event\u2019s message takes over for its own start/end window." }), autoReplyEnabled && (_jsx("textarea", { "aria-label": "Automatic reply message", className: `${INPUT_CLASS} mt-2`, rows: 3, value: autoReplyMessage, onChange: (e) => setAutoReplyMessage(e.target.value), placeholder: "I'm out of office and back on..." }))] }), !editingSingleOccurrence && (_jsx(FormField, { label: "Recurrence", htmlFor: "event-recurrence", children: _jsx(RecurrenceEditor, { value: recurrenceRule, onChange: setRecurrenceRule, allDay: allDay, startWeekday: startWeekdayCode(start, allDay, timezone) }) })), occurrence?.isRecurringOccurrence && !isInvited && (_jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm mb-3", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Apply changes to" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "occurrence", onChange: () => setEditScope("occurrence") }), "This event only"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "series", onChange: () => setEditScope("series") }), "The entire series"] })] }))] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [!isInvited && (_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), occurrence &&
166
299
  (!occurrence.isRecurringOccurrence ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete" })) : !confirmingDelete ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => setConfirmingDelete(true), children: "Delete" })) : (_jsxs("div", { className: "flex gap-2 ml-auto", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("occurrence"), children: "Delete this event" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete series" })] })))] })] }) }));
167
300
  }
@@ -1,15 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useDraggable, useDroppable } from "@dnd-kit/core";
3
- import { eachDayOfInterval, endOfMonth, endOfWeek, format, isSameDay, isSameMonth, isToday, startOfMonth, startOfWeek } from "date-fns";
3
+ import { eachDayOfInterval, endOfMonth, endOfWeek, format, isSameMonth, isToday, startOfMonth, startOfWeek } from "date-fns";
4
4
  import { dayDropId, eventDragId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
5
+ import { occursOnDay, startsOnDay } from "./allDay.js";
5
6
  const MAX_CHIPS_PER_DAY = 3;
6
- /** A real 6-week month grid (Mon-start), matching Outlook/Gmail's month view. Multi-day/all-day
7
- * events are shown only on their start day — a deliberate v1 simplification, not a bug. */
7
+ /** A real 6-week month grid (Mon-start), matching Outlook/Gmail's month view. Multi-day and all-day
8
+ * events are shown on every day they cover (see `allDay.ts`'s `occursOnDay`). */
8
9
  export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent }) {
9
10
  const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 1 });
10
11
  const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 1 });
11
12
  const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
12
- return (_jsx("div", { className: "flex-1 grid grid-cols-7 grid-rows-6 min-h-0", role: "grid", "aria-label": "Month", children: days.map((day) => (_jsx(DayCell, { day: day, inCurrentMonth: isSameMonth(day, viewDate), occurrences: occurrences.filter((occ) => isSameDay(new Date(occ.startDate), day)), folderColors: folderColors, onSelectDay: onSelectDay, onSelectEvent: onSelectEvent }, day.toISOString()))) }));
13
+ return (_jsx("div", { className: "flex-1 grid grid-cols-7 grid-rows-6 min-h-0", role: "grid", "aria-label": "Month", children: days.map((day) => (_jsx(DayCell, { day: day, inCurrentMonth: isSameMonth(day, viewDate), occurrences: occurrences.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectDay: onSelectDay, onSelectEvent: onSelectEvent }, day.toISOString()))) }));
13
14
  }
14
15
  function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent }) {
15
16
  const { setNodeRef, isOver } = useDroppable({ id: dayDropId(day) });
@@ -22,7 +23,7 @@ function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay,
22
23
  ].join(" "), children: [_jsx("button", { type: "button", onClick: () => onSelectDay(day), className: [
23
24
  "self-start text-xs font-semibold w-6 h-6 rounded-full flex items-center justify-center shrink-0",
24
25
  isToday(day) ? "bg-primary text-white" : inCurrentMonth ? "text-text" : "text-text-muted",
25
- ].join(" "), children: format(day, "d") }), _jsxs("div", { className: "flex-1 flex flex-col gap-0.5 min-h-0 overflow-hidden", children: [visible.map((occurrence) => (_jsx(EventChip, { occurrence: occurrence, color: folderColors[occurrence.folderUid], onSelect: onSelectEvent }, occurrence.occurrenceKey))), overflowCount > 0 && (_jsxs("button", { type: "button", onClick: () => onSelectDay(day), className: "text-xs text-text-muted text-left hover:underline shrink-0", children: ["+", overflowCount, " more"] }))] })] }));
26
+ ].join(" "), children: format(day, "d") }), _jsxs("div", { className: "flex-1 flex flex-col gap-0.5 min-h-0 overflow-hidden", children: [visible.map((occurrence) => startsOnDay(occurrence, day) ? (_jsx(EventChip, { occurrence: occurrence, color: folderColors[occurrence.folderUid], onSelect: onSelectEvent }, occurrence.occurrenceKey)) : (_jsx(ContinuationChip, { occurrence: occurrence, color: folderColors[occurrence.folderUid], onSelect: onSelectEvent }, occurrence.occurrenceKey))), overflowCount > 0 && (_jsxs("button", { type: "button", onClick: () => onSelectDay(day), className: "text-xs text-text-muted text-left hover:underline shrink-0", children: ["+", overflowCount, " more"] }))] })] }));
26
27
  }
27
28
  function EventChip({ occurrence, color, onSelect, }) {
28
29
  const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
@@ -36,3 +37,9 @@ function EventChip({ occurrence, color, onSelect, }) {
36
37
  isDragging ? "opacity-50" : "",
37
38
  ].join(" "), ...listeners, ...attributes, children: [occurrence.allDay ? "" : `${format(new Date(occurrence.startDate), "h:mma")} `, occurrence.title] }));
38
39
  }
40
+ /** A multi-day event's chip on a day after its first: clickable, but not draggable (its drag id is
41
+ * already taken by the start day's chip) and without a start-time prefix. */
42
+ function ContinuationChip({ occurrence, color, onSelect, }) {
43
+ const isFree = occurrence.busyStatus === "free";
44
+ return (_jsx("button", { type: "button", onClick: () => onSelect(occurrence), style: isFree ? undefined : { backgroundColor: color, color: "#fff" }, className: ["text-xs text-left truncate rounded-sm px-1.5 py-0.5 shrink-0", isFree ? "bg-surface-alt text-text-muted" : ""].join(" "), children: occurrence.title }));
45
+ }
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { format } from "date-fns";
2
3
  import { WEEKDAY_CODES, WEEKDAY_LABELS, describeRecurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
4
+ import { recurrenceUntilDateKey, recurrenceUntilInstant } from "./allDay.js";
3
5
  const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
4
6
  const INPUT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
5
7
  const FREQ_LABEL = {
@@ -21,20 +23,30 @@ function endConditionOf(rule) {
21
23
  * live "every ... until/for ..." summary comes from `describeRecurrence()` (built on `rrule`'s own
22
24
  * `.toText()`), so it never drifts out of sync with what will actually be submitted.
23
25
  */
24
- export default function RecurrenceEditor({ value, onChange }) {
26
+ export default function RecurrenceEditor({ value, onChange, allDay = false, startWeekday = "MO" }) {
25
27
  function handleEnable(enabled) {
26
- onChange(enabled ? { freq: "weekly", interval: 1, byDay: ["MO"], exceptions: [] } : null);
28
+ onChange(enabled ? { freq: "weekly", interval: 1, byDay: [startWeekday], exceptions: [] } : null);
27
29
  }
28
30
  // `update`/`toggleDay` are only ever invoked from handlers rendered inside the `{value && (...)}`
29
31
  // block below, so `value` is always non-null here — no defensive null check needed.
30
32
  function update(patch) {
31
33
  onChange({ ...value, ...patch });
32
34
  }
35
+ // A weekly rule always keeps at least one weekday: with none left, rrule would repeat every day instead.
33
36
  function toggleDay(day) {
34
37
  const current = value.byDay ?? [];
38
+ if (current.length === 1 && current[0] === day) {
39
+ return;
40
+ }
35
41
  const next = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
36
42
  update({ byDay: next });
37
43
  }
44
+ // react-shared passes `byDay` to rrule for every frequency, so a weekday list left over from Weekly would
45
+ // turn "every day" into "every Monday". Leaving Weekly drops it; entering Weekly starts on the event's own
46
+ // start weekday (a select only reports an actual change, so this is never Weekly -> Weekly).
47
+ function handleFrequency(freq) {
48
+ update({ freq, byDay: freq === "weekly" ? [startWeekday] : undefined });
49
+ }
38
50
  function handleEndCondition(condition) {
39
51
  if (condition === "never") {
40
52
  update({ count: undefined, until: undefined });
@@ -43,13 +55,36 @@ export default function RecurrenceEditor({ value, onChange }) {
43
55
  update({ count: 10, until: undefined });
44
56
  }
45
57
  else {
46
- update({ until: new Date().toISOString(), count: undefined });
58
+ update({ until: recurrenceUntilInstant(format(new Date(), "yyyy-MM-dd"), allDay), count: undefined });
47
59
  }
48
60
  }
49
- return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: value !== null, onChange: (e) => handleEnable(e.target.checked) }), "Repeats"] }), value && (_jsxs("div", { className: "flex flex-col gap-3 pl-6 border-l-2 border-border", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx("span", { children: "Every" }), _jsx("input", { type: "number", min: 1, className: `${INPUT_CLASS} w-16`, value: value.interval, onChange: (e) => update({ interval: Number(e.target.value) }), "aria-label": "Recurrence interval" }), _jsxs("select", { className: SELECT_CLASS, value: value.freq, onChange: (e) => update({ freq: e.target.value }), "aria-label": "Recurrence frequency", children: [_jsx("option", { value: "daily", children: value.interval === 1 ? FREQ_LABEL.daily.unit : FREQ_LABEL.daily.unitPlural }), _jsx("option", { value: "weekly", children: value.interval === 1 ? FREQ_LABEL.weekly.unit : FREQ_LABEL.weekly.unitPlural }), _jsx("option", { value: "monthly", children: value.interval === 1 ? FREQ_LABEL.monthly.unit : FREQ_LABEL.monthly.unitPlural }), _jsx("option", { value: "yearly", children: value.interval === 1 ? FREQ_LABEL.yearly.unit : FREQ_LABEL.yearly.unitPlural })] })] }), value.freq === "weekly" && (_jsx("div", { className: "flex gap-1", children: WEEKDAY_CODES.map((day) => (_jsx("button", { type: "button", onClick: () => toggleDay(day), "aria-pressed": (value.byDay ?? []).includes(day), "aria-label": WEEKDAY_LABELS[day], className: [
61
+ /** A positive whole number from a number input, or `null` while it's empty (a user clearing the field
62
+ * to type a new value) — the rule keeps its previous value then, rather than becoming 0/NaN. */
63
+ function parsePositive(raw) {
64
+ if (raw.trim() === "")
65
+ return null;
66
+ const n = Math.floor(Number(raw));
67
+ return n >= 1 ? n : 1;
68
+ }
69
+ function handleInterval(raw) {
70
+ const interval = parsePositive(raw);
71
+ if (interval !== null)
72
+ update({ interval });
73
+ }
74
+ function handleCount(raw) {
75
+ const count = parsePositive(raw);
76
+ if (count !== null)
77
+ update({ count });
78
+ }
79
+ function handleUntil(raw) {
80
+ // A cleared (or partially typed) date input reports "" — keep the previous end date.
81
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw))
82
+ update({ until: recurrenceUntilInstant(raw, allDay) });
83
+ }
84
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: value !== null, onChange: (e) => handleEnable(e.target.checked) }), "Repeats"] }), value && (_jsxs("div", { className: "flex flex-col gap-3 pl-6 border-l-2 border-border", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx("span", { children: "Every" }), _jsx("input", { type: "number", min: 1, className: `${INPUT_CLASS} w-16`, value: value.interval, onChange: (e) => handleInterval(e.target.value), "aria-label": "Recurrence interval" }), _jsxs("select", { className: SELECT_CLASS, value: value.freq, onChange: (e) => handleFrequency(e.target.value), "aria-label": "Recurrence frequency", children: [_jsx("option", { value: "daily", children: value.interval === 1 ? FREQ_LABEL.daily.unit : FREQ_LABEL.daily.unitPlural }), _jsx("option", { value: "weekly", children: value.interval === 1 ? FREQ_LABEL.weekly.unit : FREQ_LABEL.weekly.unitPlural }), _jsx("option", { value: "monthly", children: value.interval === 1 ? FREQ_LABEL.monthly.unit : FREQ_LABEL.monthly.unitPlural }), _jsx("option", { value: "yearly", children: value.interval === 1 ? FREQ_LABEL.yearly.unit : FREQ_LABEL.yearly.unitPlural })] })] }), value.freq === "weekly" && (_jsx("div", { className: "flex gap-1", children: WEEKDAY_CODES.map((day) => (_jsx("button", { type: "button", onClick: () => toggleDay(day), "aria-pressed": (value.byDay ?? []).includes(day), "aria-label": WEEKDAY_LABELS[day], className: [
50
85
  "w-9 h-9 rounded-full text-xs font-semibold border",
51
86
  (value.byDay ?? []).includes(day)
52
87
  ? "bg-primary text-white border-primary"
53
88
  : "bg-surface text-text-muted border-border hover:border-primary",
54
- ].join(" "), children: _jsx("span", { "aria-hidden": "true", children: WEEKDAY_LABELS[day][0] }) }, day))) })), _jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Ends" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "recurrence-end", checked: endConditionOf(value) === "never", onChange: () => handleEndCondition("never") }), "Never"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "recurrence-end", "aria-label": "After", checked: endConditionOf(value) === "count", onChange: () => handleEndCondition("count") }), "After", _jsx("input", { type: "number", min: 1, className: `${INPUT_CLASS} w-16`, value: value.count ?? 10, disabled: endConditionOf(value) !== "count", onChange: (e) => update({ count: Number(e.target.value) }), "aria-label": "Number of occurrences" }), "occurrences"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "recurrence-end", "aria-label": "On", checked: endConditionOf(value) === "until", onChange: () => handleEndCondition("until") }), "On", _jsx("input", { type: "date", className: INPUT_CLASS, value: value.until ? value.until.slice(0, 10) : "", disabled: endConditionOf(value) !== "until", onChange: (e) => update({ until: new Date(e.target.value).toISOString() }), "aria-label": "End date" })] })] }), ((value.freq !== "weekly" || (value.byDay ?? []).length > 0)) && (_jsxs("p", { className: "text-xs text-text-muted", children: ["Repeats ", describeRecurrence(value), "."] }))] }))] }));
89
+ ].join(" "), children: _jsx("span", { "aria-hidden": "true", children: WEEKDAY_LABELS[day][0] }) }, day))) })), _jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Ends" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "recurrence-end", checked: endConditionOf(value) === "never", onChange: () => handleEndCondition("never") }), "Never"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "recurrence-end", "aria-label": "After", checked: endConditionOf(value) === "count", onChange: () => handleEndCondition("count") }), "After", _jsx("input", { type: "number", min: 1, className: `${INPUT_CLASS} w-16`, value: value.count ?? 10, disabled: endConditionOf(value) !== "count", onChange: (e) => handleCount(e.target.value), "aria-label": "Number of occurrences" }), "occurrences"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "recurrence-end", "aria-label": "On", checked: endConditionOf(value) === "until", onChange: () => handleEndCondition("until") }), "On", _jsx("input", { type: "date", className: INPUT_CLASS, value: value.until ? recurrenceUntilDateKey(value.until, allDay) : "", disabled: endConditionOf(value) !== "until", onChange: (e) => handleUntil(e.target.value), "aria-label": "End date" })] })] }), (value.freq !== "weekly" || (value.byDay ?? []).length > 0) && (_jsxs("p", { className: "text-xs text-text-muted", children: ["Repeats ", describeRecurrence(value), "."] }))] }))] }));
55
90
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { addMinutes, format, isSameDay, startOfDay } from "date-fns";
2
+ import { addDays, addMinutes, format, startOfDay } from "date-fns";
3
+ import { occursOnDay } from "./allDay.js";
3
4
  const HOUR_HEIGHT_PX = 48;
4
5
  const SLOT_MINUTES = 30;
5
6
  const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
@@ -15,7 +16,7 @@ const HOURS = Array.from({ length: 24 }, (_, h) => h);
15
16
  * calendar) — a practical scope cut, not an oversight.
16
17
  */
17
18
  export default function SplitDayView({ day, columns, occurrences, onSelectEvent, onSelectSlot }) {
18
- const dayOccurrences = occurrences.filter((occ) => isSameDay(new Date(occ.startDate), day));
19
+ const dayOccurrences = occurrences.filter((occ) => occursOnDay(occ, day));
19
20
  const dayStart = startOfDay(day);
20
21
  return (_jsxs("div", { role: "region", "aria-label": "Split view", className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), columns.map((col) => (_jsxs("div", { className: "flex-1 min-w-0 py-1 flex items-center justify-center gap-1.5 border-l border-border", children: [_jsx("span", { "aria-hidden": "true", className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: col.color } }), _jsx("span", { className: "text-xs font-semibold truncate", children: col.name })] }, col.folderUid)))] }), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), columns.map((col) => (_jsx(SplitColumn, { column: col, dayStart: dayStart, occurrences: dayOccurrences.filter((occ) => occ.folderUid === col.folderUid), onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, col.folderUid)))] })] }));
21
22
  }
@@ -26,8 +27,13 @@ function SplitColumn({ column, dayStart, occurrences, onSelectEvent, onSelectSlo
26
27
  }), occurrences.map((occurrence) => {
27
28
  const start = new Date(occurrence.startDate);
28
29
  const end = new Date(occurrence.endDate);
29
- const top = ((start.getTime() - dayStart.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX;
30
- const height = Math.max(((end.getTime() - start.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX, 16);
30
+ // Clipped to this day, so a multi-day event fills only the part of the day it covers. An
31
+ // all-day event is date-only (see `allDay.ts`), so it always spans the whole local day.
32
+ const dayEnd = addDays(dayStart, 1).getTime();
33
+ const visibleStart = occurrence.allDay ? dayStart.getTime() : Math.max(start.getTime(), dayStart.getTime());
34
+ const visibleEnd = occurrence.allDay ? dayEnd : Math.min(end.getTime(), dayEnd);
35
+ const top = ((visibleStart - dayStart.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX;
36
+ const height = Math.max(((visibleEnd - visibleStart) / 60000 / 60) * HOUR_HEIGHT_PX, 16);
31
37
  const isFree = occurrence.busyStatus === "free";
32
38
  return (_jsxs("div", { onClick: () => onSelectEvent(occurrence), style: {
33
39
  position: "absolute",