@rapidmx/web-client 0.4.0 → 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 (169) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/data-requests/index.tsx +480 -485
  3. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  4. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  5. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  6. package/apps/admin/index.tsx +19 -1
  7. package/apps/admin/ingest-queue/index.tsx +44 -5
  8. package/apps/admin/mailboxes/[uid].tsx +211 -184
  9. package/apps/admin/quarantine/index.tsx +46 -14
  10. package/apps/admin/transport-rules/[uid].tsx +30 -1
  11. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  12. package/apps/admin/transport-rules/index.tsx +24 -6
  13. package/apps/admin/transport-rules/new/index.tsx +30 -1
  14. package/apps/escrow/_layout.tsx +2 -3
  15. package/apps/escrow/audit-log/index.tsx +196 -168
  16. package/apps/escrow/matters/[uid].tsx +621 -521
  17. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
  19. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  20. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  23. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  25. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  26. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  28. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  31. package/apps/shared/components/admin/signOut.ts +79 -0
  32. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  33. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  34. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  35. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  36. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  37. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  38. package/apps/shared/components/calendar/allDay.ts +124 -0
  39. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  40. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  41. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  43. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  44. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  46. package/apps/shared/components/layout/AppShell.tsx +87 -15
  47. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  50. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  51. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  52. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  53. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  54. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  55. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  57. package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
  58. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  59. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  60. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  61. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  62. package/apps/shared/mail/listAllPages.ts +39 -0
  63. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  64. package/apps/shared/search/localIndexBuilder.ts +481 -179
  65. package/apps/shared/search/localIndexRpcClient.ts +228 -28
  66. package/apps/shared/search/localIndexSchema.ts +15 -6
  67. package/apps/shared/search/localIndexStorage.ts +98 -0
  68. package/apps/shared/search/localIndexVFS.ts +32 -1
  69. package/apps/shared/search/localIndexWorker.ts +648 -186
  70. package/apps/www/calendar/index.tsx +438 -417
  71. package/apps/www/contacts/[uid].tsx +21 -2
  72. package/apps/www/contacts/index.tsx +116 -12
  73. package/apps/www/index.tsx +1314 -1041
  74. package/apps/www/messages/[uid].tsx +18 -6
  75. package/apps/www/settings/auto-reply/index.tsx +134 -129
  76. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  77. package/apps/www/settings/encryption/index.tsx +739 -216
  78. package/apps/www/settings/filters/[uid].tsx +171 -155
  79. package/apps/www/settings/filters/new/index.tsx +8 -1
  80. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  81. package/apps/www/settings/privacy/index.tsx +494 -436
  82. package/apps/www/settings/read-receipts/index.tsx +148 -125
  83. package/apps/www/settings/sharing/index.tsx +17 -14
  84. package/apps/www/tasks/index.tsx +638 -579
  85. package/dist/apps/admin/audit-log/index.js +38 -8
  86. package/dist/apps/admin/data-requests/index.js +52 -72
  87. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  88. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  89. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  90. package/dist/apps/admin/index.js +4 -1
  91. package/dist/apps/admin/ingest-queue/index.js +24 -6
  92. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  93. package/dist/apps/admin/quarantine/index.js +20 -11
  94. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  95. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  96. package/dist/apps/admin/transport-rules/index.js +22 -6
  97. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  98. package/dist/apps/escrow/_layout.js +3 -3
  99. package/dist/apps/escrow/audit-log/index.js +26 -1
  100. package/dist/apps/escrow/matters/[uid].js +77 -42
  101. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  102. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
  103. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  104. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  105. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  106. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  107. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  108. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  109. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  110. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  111. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  112. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  113. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  114. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  115. package/dist/apps/shared/components/admin/signOut.js +75 -0
  116. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  117. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  118. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  119. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  120. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  121. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  122. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  123. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  124. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  125. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  126. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  127. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  128. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  129. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  130. package/dist/apps/shared/components/layout/AppShell.js +83 -14
  131. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  132. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  133. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  134. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  135. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  136. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  137. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  138. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  139. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  140. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  141. package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
  142. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  143. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  144. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  145. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  146. package/dist/apps/shared/mail/listAllPages.js +25 -0
  147. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  148. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  149. package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
  150. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  151. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  152. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  153. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  154. package/dist/apps/www/calendar/index.js +28 -10
  155. package/dist/apps/www/contacts/[uid].js +13 -2
  156. package/dist/apps/www/contacts/index.js +80 -10
  157. package/dist/apps/www/index.js +339 -108
  158. package/dist/apps/www/messages/[uid].js +18 -6
  159. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  160. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  161. package/dist/apps/www/settings/encryption/index.js +514 -97
  162. package/dist/apps/www/settings/filters/[uid].js +22 -7
  163. package/dist/apps/www/settings/filters/new/index.js +8 -2
  164. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  165. package/dist/apps/www/settings/privacy/index.js +57 -22
  166. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  167. package/dist/apps/www/settings/sharing/index.js +7 -11
  168. package/dist/apps/www/tasks/index.js +50 -10
  169. package/package.json +2 -2
@@ -1,198 +1,244 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React from "react";
6
- import { useDraggable, useDroppable } from "@dnd-kit/core";
7
- import { addMinutes, format, isSameDay, isToday, startOfDay } from "date-fns";
8
- import { dayDropId, eventDragId, resizeDragId, slotDropId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
9
- import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
10
-
11
- const HOUR_HEIGHT_PX = 48;
12
- const SLOT_MINUTES = 30;
13
- const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
14
- const HOURS = Array.from({ length: 24 }, (_, h) => h);
15
-
16
- export interface TimeGridViewProps {
17
- /** One day for the Day view, seven for the Week view, five for the Work Week view. */
18
- days: Date[];
19
- /** Already expanded for the visible range — see `recurrence.ts`'s `expandAllOccurrences`. */
20
- occurrences: CalendarOccurrence[];
21
- /** Each occurrence's own calendar color, keyed by `folderUid` — see `calendarColors.ts`. */
22
- folderColors: Record<string, string>;
23
- onSelectEvent: (occurrence: CalendarOccurrence) => void;
24
- /** Click on an empty slot — start/end default to a 30-minute block there, for the "New event" flow. */
25
- onSelectSlot: (start: Date, end: Date) => void;
26
- }
27
-
28
- /** An hourly time grid (Week, Work Week, or Day view — they share all their rendering/drag logic and
29
- * differ only in how many day columns are shown), with a day-header row and all-day events in a
30
- * header strip above it. */
31
- export default function TimeGridView({ days, occurrences, folderColors, onSelectEvent, onSelectSlot }: TimeGridViewProps) {
32
- const allDayEvents = occurrences.filter((occ) => occ.allDay);
33
- const timedEvents = occurrences.filter((occ) => !occ.allDay);
34
-
35
- return (
36
- <div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
37
- <div className="flex border-b border-border shrink-0">
38
- <div className="w-14 shrink-0" />
39
- {days.map((day) => (
40
- <div
41
- key={day.toISOString()}
42
- className={["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" ")}
43
- >
44
- {format(day, "EEE d")}
45
- </div>
46
- ))}
47
- </div>
48
- {allDayEvents.length > 0 && (
49
- <div className="flex border-b border-border shrink-0">
50
- <div className="w-14 shrink-0" />
51
- {days.map((day) => (
52
- <div key={day.toISOString()} className="flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border">
53
- {allDayEvents
54
- .filter((occ) => isSameDay(new Date(occ.startDate), day))
55
- .map((occ) => (
56
- <button
57
- key={occ.occurrenceKey}
58
- type="button"
59
- onClick={() => onSelectEvent(occ)}
60
- style={{ backgroundColor: folderColors[occ.folderUid], color: "#fff" }}
61
- className="text-xs text-left truncate rounded-sm px-1.5 py-0.5"
62
- >
63
- {occ.title}
64
- </button>
65
- ))}
66
- </div>
67
- ))}
68
- </div>
69
- )}
70
- <div className="flex flex-1">
71
- <div className="w-14 shrink-0">
72
- {HOURS.map((hour) => (
73
- <div key={hour} style={{ height: HOUR_HEIGHT_PX }} className="text-[10px] text-text-muted text-right pr-1.5 -mt-1.5">
74
- {hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha")}
75
- </div>
76
- ))}
77
- </div>
78
- {days.map((day) => (
79
- <DayColumn
80
- key={day.toISOString()}
81
- day={day}
82
- occurrences={timedEvents.filter((occ) => isSameDay(new Date(occ.startDate), day))}
83
- folderColors={folderColors}
84
- onSelectEvent={onSelectEvent}
85
- onSelectSlot={onSelectSlot}
86
- />
87
- ))}
88
- </div>
89
- </div>
90
- );
91
- }
92
-
93
- interface DayColumnProps {
94
- day: Date;
95
- occurrences: CalendarOccurrence[];
96
- folderColors: Record<string, string>;
97
- onSelectEvent: (occurrence: CalendarOccurrence) => void;
98
- onSelectSlot: (start: Date, end: Date) => void;
99
- }
100
-
101
- function DayColumn({ day, occurrences, folderColors, onSelectEvent, onSelectSlot }: DayColumnProps) {
102
- const dayStart = startOfDay(day);
103
-
104
- return (
105
- <div className={["flex-1 min-w-0 relative border-l border-border", isToday(day) ? "bg-primary/5" : ""].join(" ")}>
106
- {Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
107
- const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
108
- return <TimeSlot key={i} start={slotStart} onSelectSlot={onSelectSlot} />;
109
- })}
110
- {occurrences.map((occurrence) => (
111
- <EventBlock
112
- key={occurrence.occurrenceKey}
113
- occurrence={occurrence}
114
- color={folderColors[occurrence.folderUid]}
115
- dayStart={dayStart}
116
- onSelect={onSelectEvent}
117
- />
118
- ))}
119
- </div>
120
- );
121
- }
122
-
123
- function TimeSlot({ start, onSelectSlot }: { start: Date; onSelectSlot: (start: Date, end: Date) => void }) {
124
- const { setNodeRef, isOver } = useDroppable({ id: slotDropId(start) });
125
- return (
126
- <button
127
- ref={setNodeRef}
128
- type="button"
129
- onClick={() => onSelectSlot(start, addMinutes(start, SLOT_MINUTES))}
130
- style={{ height: HOUR_HEIGHT_PX / 2 }}
131
- className={["block w-full border-b border-border/50 text-left", isOver ? "bg-primary/10" : ""].join(" ")}
132
- aria-label={`New event at ${format(start, "h:mm a, MMM d")}`}
133
- />
134
- );
135
- }
136
-
137
- function EventBlock({
138
- occurrence,
139
- color,
140
- dayStart,
141
- onSelect,
142
- }: {
143
- occurrence: CalendarOccurrence;
144
- /** This occurrence's calendar color — see `calendarColors.ts`. Only applied for a "busy" block; a
145
- * "free" one keeps Outlook's own muted/outline treatment regardless of which calendar it's on. */
146
- color: string;
147
- dayStart: Date;
148
- onSelect: (occurrence: CalendarOccurrence) => void;
149
- }) {
150
- const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
151
- const { setNodeRef: setResizeRef, listeners: resizeListeners, attributes: resizeAttributes } = useDraggable({
152
- id: resizeDragId(occurrence),
153
- });
154
- const isFree = occurrence.busyStatus === "free";
155
-
156
- const start = new Date(occurrence.startDate);
157
- const end = new Date(occurrence.endDate);
158
- const top = ((start.getTime() - dayStart.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX;
159
- // The resize handle's own drag position has no live pixel preview (it only applies its ns-resize
160
- // affordance) — the actual new end time is computed from whichever slot it's dropped onto (see
161
- // `resolveDragAction`'s "resize" case), snapped to the grid rather than following the pointer
162
- // continuously. Simpler, and avoids a visual preview that could disagree with the snapped result.
163
- const height = Math.max(((end.getTime() - start.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX, 16);
164
-
165
- return (
166
- <div
167
- ref={setNodeRef}
168
- onClick={() => onSelect(occurrence)}
169
- style={{
170
- position: "absolute",
171
- top,
172
- height,
173
- left: 2,
174
- right: 2,
175
- transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined,
176
- zIndex: isDragging ? 10 : undefined,
177
- ...(isFree ? undefined : { backgroundColor: color, color: "#fff" }),
178
- }}
179
- className={[
180
- "rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
181
- isFree ? "bg-surface-alt text-text-muted" : "",
182
- isDragging ? "opacity-50" : "",
183
- ].join(" ")}
184
- {...listeners}
185
- {...attributes}
186
- >
187
- <div className="font-medium truncate">{occurrence.title}</div>
188
- <div className="truncate">{format(start, "h:mma")}</div>
189
- <div
190
- ref={setResizeRef}
191
- className="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize"
192
- aria-label={`Resize "${occurrence.title}"`}
193
- {...resizeListeners}
194
- {...resizeAttributes}
195
- />
196
- </div>
197
- );
198
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { useDraggable, useDroppable } from "@dnd-kit/core";
7
+ import { addDays, addMinutes, format, isToday, startOfDay } from "date-fns";
8
+ import { dayDropId, eventDragId, resizeDragId, slotDropId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
9
+ import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
10
+ import { occursOnDay, startsOnDay } from "./allDay.js";
11
+
12
+ const HOUR_HEIGHT_PX = 48;
13
+ const SLOT_MINUTES = 30;
14
+ const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
15
+ const HOURS = Array.from({ length: 24 }, (_, h) => h);
16
+
17
+ export interface TimeGridViewProps {
18
+ /** One day for the Day view, seven for the Week view, five for the Work Week view. */
19
+ days: Date[];
20
+ /** Already expanded for the visible range — see `recurrence.ts`'s `expandAllOccurrences`. */
21
+ occurrences: CalendarOccurrence[];
22
+ /** Each occurrence's own calendar color, keyed by `folderUid` — see `calendarColors.ts`. */
23
+ folderColors: Record<string, string>;
24
+ onSelectEvent: (occurrence: CalendarOccurrence) => void;
25
+ /** Click on an empty slot — start/end default to a 30-minute block there, for the "New event" flow. */
26
+ onSelectSlot: (start: Date, end: Date) => void;
27
+ }
28
+
29
+ /** An hourly time grid (Week, Work Week, or Day view — they share all their rendering/drag logic and
30
+ * differ only in how many day columns are shown), with a day-header row and all-day events in a
31
+ * header strip above it. */
32
+ export default function TimeGridView({ days, occurrences, folderColors, onSelectEvent, onSelectSlot }: TimeGridViewProps) {
33
+ const allDayEvents = occurrences.filter((occ) => occ.allDay);
34
+ const timedEvents = occurrences.filter((occ) => !occ.allDay);
35
+
36
+ return (
37
+ <div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
38
+ <div className="flex border-b border-border shrink-0">
39
+ <div className="w-14 shrink-0" />
40
+ {days.map((day) => (
41
+ <div
42
+ key={day.toISOString()}
43
+ className={["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" ")}
44
+ >
45
+ {format(day, "EEE d")}
46
+ </div>
47
+ ))}
48
+ </div>
49
+ {allDayEvents.length > 0 && (
50
+ <div className="flex border-b border-border shrink-0">
51
+ <div className="w-14 shrink-0" />
52
+ {days.map((day) => (
53
+ <div key={day.toISOString()} className="flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border">
54
+ {allDayEvents
55
+ .filter((occ) => occursOnDay(occ, day))
56
+ .map((occ) => (
57
+ <button
58
+ key={occ.occurrenceKey}
59
+ type="button"
60
+ onClick={() => onSelectEvent(occ)}
61
+ style={{ backgroundColor: folderColors[occ.folderUid], color: "#fff" }}
62
+ className="text-xs text-left truncate rounded-sm px-1.5 py-0.5"
63
+ >
64
+ {occ.title}
65
+ </button>
66
+ ))}
67
+ </div>
68
+ ))}
69
+ </div>
70
+ )}
71
+ <div className="flex flex-1">
72
+ <div className="w-14 shrink-0">
73
+ {HOURS.map((hour) => (
74
+ <div key={hour} style={{ height: HOUR_HEIGHT_PX }} className="text-[10px] text-text-muted text-right pr-1.5 -mt-1.5">
75
+ {hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha")}
76
+ </div>
77
+ ))}
78
+ </div>
79
+ {days.map((day) => (
80
+ <DayColumn
81
+ key={day.toISOString()}
82
+ day={day}
83
+ occurrences={timedEvents.filter((occ) => occursOnDay(occ, day))}
84
+ folderColors={folderColors}
85
+ onSelectEvent={onSelectEvent}
86
+ onSelectSlot={onSelectSlot}
87
+ />
88
+ ))}
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ interface DayColumnProps {
95
+ day: Date;
96
+ occurrences: CalendarOccurrence[];
97
+ folderColors: Record<string, string>;
98
+ onSelectEvent: (occurrence: CalendarOccurrence) => void;
99
+ onSelectSlot: (start: Date, end: Date) => void;
100
+ }
101
+
102
+ function DayColumn({ day, occurrences, folderColors, onSelectEvent, onSelectSlot }: DayColumnProps) {
103
+ const dayStart = startOfDay(day);
104
+
105
+ return (
106
+ <div className={["flex-1 min-w-0 relative border-l border-border", isToday(day) ? "bg-primary/5" : ""].join(" ")}>
107
+ {Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
108
+ const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
109
+ return <TimeSlot key={i} start={slotStart} onSelectSlot={onSelectSlot} />;
110
+ })}
111
+ {occurrences.map((occurrence) =>
112
+ startsOnDay(occurrence, day) ? (
113
+ <EventBlock
114
+ key={occurrence.occurrenceKey}
115
+ occurrence={occurrence}
116
+ color={folderColors[occurrence.folderUid]}
117
+ dayStart={dayStart}
118
+ onSelect={onSelectEvent}
119
+ />
120
+ ) : (
121
+ <ContinuationBlock
122
+ key={occurrence.occurrenceKey}
123
+ occurrence={occurrence}
124
+ color={folderColors[occurrence.folderUid]}
125
+ dayStart={dayStart}
126
+ onSelect={onSelectEvent}
127
+ />
128
+ ),
129
+ )}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ function TimeSlot({ start, onSelectSlot }: { start: Date; onSelectSlot: (start: Date, end: Date) => void }) {
135
+ const { setNodeRef, isOver } = useDroppable({ id: slotDropId(start) });
136
+ return (
137
+ <button
138
+ ref={setNodeRef}
139
+ type="button"
140
+ onClick={() => onSelectSlot(start, addMinutes(start, SLOT_MINUTES))}
141
+ style={{ height: HOUR_HEIGHT_PX / 2 }}
142
+ className={["block w-full border-b border-border/50 text-left", isOver ? "bg-primary/10" : ""].join(" ")}
143
+ aria-label={`New event at ${format(start, "h:mm a, MMM d")}`}
144
+ />
145
+ );
146
+ }
147
+
148
+ /** A block's position within its day column. A multi-day event is drawn in every day column it
149
+ * overlaps, clipped to that day. */
150
+ function blockGeometry(occurrence: CalendarOccurrence, dayStart: Date): { top: number; height: number } {
151
+ const visibleStart = Math.max(new Date(occurrence.startDate).getTime(), dayStart.getTime());
152
+ const visibleEnd = Math.min(new Date(occurrence.endDate).getTime(), addDays(dayStart, 1).getTime());
153
+ return {
154
+ top: ((visibleStart - dayStart.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX,
155
+ height: Math.max(((visibleEnd - visibleStart) / 60_000 / 60) * HOUR_HEIGHT_PX, 16),
156
+ };
157
+ }
158
+
159
+ /** A multi-day event's block in a day column after its first: clickable, but not draggable/resizable
160
+ * (its drag ids are already taken by the start day's block). */
161
+ function ContinuationBlock({
162
+ occurrence,
163
+ color,
164
+ dayStart,
165
+ onSelect,
166
+ }: {
167
+ occurrence: CalendarOccurrence;
168
+ color: string;
169
+ dayStart: Date;
170
+ onSelect: (occurrence: CalendarOccurrence) => void;
171
+ }) {
172
+ const isFree = occurrence.busyStatus === "free";
173
+ const { top, height } = blockGeometry(occurrence, dayStart);
174
+ return (
175
+ <div
176
+ onClick={() => onSelect(occurrence)}
177
+ style={{ position: "absolute", top, height, left: 2, right: 2, ...(isFree ? undefined : { backgroundColor: color, color: "#fff" }) }}
178
+ className={["rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer", isFree ? "bg-surface-alt text-text-muted" : ""].join(" ")}
179
+ >
180
+ <div className="font-medium truncate">{occurrence.title}</div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ function EventBlock({
186
+ occurrence,
187
+ color,
188
+ dayStart,
189
+ onSelect,
190
+ }: {
191
+ occurrence: CalendarOccurrence;
192
+ /** This occurrence's calendar color — see `calendarColors.ts`. Only applied for a "busy" block; a
193
+ * "free" one keeps Outlook's own muted/outline treatment regardless of which calendar it's on. */
194
+ color: string;
195
+ dayStart: Date;
196
+ onSelect: (occurrence: CalendarOccurrence) => void;
197
+ }) {
198
+ const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
199
+ const { setNodeRef: setResizeRef, listeners: resizeListeners, attributes: resizeAttributes } = useDraggable({
200
+ id: resizeDragId(occurrence),
201
+ });
202
+ const isFree = occurrence.busyStatus === "free";
203
+
204
+ const start = new Date(occurrence.startDate);
205
+ const { top, height } = blockGeometry(occurrence, dayStart);
206
+ // The resize handle's own drag position has no live pixel preview (it only applies its ns-resize
207
+ // affordance) — the actual new end time is computed from whichever slot it's dropped onto (see
208
+ // `resolveDragAction`'s "resize" case), snapped to the grid rather than following the pointer
209
+ // continuously. Simpler, and avoids a visual preview that could disagree with the snapped result.
210
+
211
+ return (
212
+ <div
213
+ ref={setNodeRef}
214
+ onClick={() => onSelect(occurrence)}
215
+ style={{
216
+ position: "absolute",
217
+ top,
218
+ height,
219
+ left: 2,
220
+ right: 2,
221
+ transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined,
222
+ zIndex: isDragging ? 10 : undefined,
223
+ ...(isFree ? undefined : { backgroundColor: color, color: "#fff" }),
224
+ }}
225
+ className={[
226
+ "rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
227
+ isFree ? "bg-surface-alt text-text-muted" : "",
228
+ isDragging ? "opacity-50" : "",
229
+ ].join(" ")}
230
+ {...listeners}
231
+ {...attributes}
232
+ >
233
+ <div className="font-medium truncate">{occurrence.title}</div>
234
+ <div className="truncate">{format(start, "h:mma")}</div>
235
+ <div
236
+ ref={setResizeRef}
237
+ className="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize"
238
+ aria-label={`Resize "${occurrence.title}"`}
239
+ {...resizeListeners}
240
+ {...resizeAttributes}
241
+ />
242
+ </div>
243
+ );
244
+ }
@@ -0,0 +1,124 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { format } from "date-fns";
6
+ import { WeekdayCode } from "@rapidmx/react-shared/calendar/calendarApi.js";
7
+ import { CalendarOccurrence, toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
8
+
9
+ const MS_PER_DAY = 86_400_000;
10
+
11
+ /**
12
+ * All-day events are stored date-only: `startDate` is UTC midnight of the first day and `endDate` is UTC
13
+ * midnight of the day *after* the last day (exclusive, like iCalendar's `DTEND;VALUE=DATE`). That keeps
14
+ * an all-day event on the same calendar date for every viewer, whatever their timezone.
15
+ *
16
+ * Older events were saved as the creator's *local* midnight converted to UTC (e.g. `...T22:00Z` for a
17
+ * UTC+2 creator), so reading one rounds the instant to the nearest UTC midnight rather than slicing the
18
+ * ISO string — which gives the intended date for both formats.
19
+ */
20
+ export function allDayDateKey(iso: string): string {
21
+ const rounded = Math.round(new Date(iso).getTime() / MS_PER_DAY) * MS_PER_DAY;
22
+ return new Date(rounded).toISOString().slice(0, 10);
23
+ }
24
+
25
+ /** `YYYY-MM-DD` -> the stored UTC-midnight instant for that date. */
26
+ export function allDayInstant(dateKey: string): string {
27
+ return `${dateKey}T00:00:00.000Z`;
28
+ }
29
+
30
+ /** Adds `days` to a `YYYY-MM-DD` date key. */
31
+ export function addDaysToKey(dateKey: string, days: number): string {
32
+ return new Date(new Date(allDayInstant(dateKey)).getTime() + days * MS_PER_DAY).toISOString().slice(0, 10);
33
+ }
34
+
35
+ /** The local calendar date `day` (a local-midnight `Date` from a grid view) as a `YYYY-MM-DD` key. */
36
+ export function localDateKey(day: Date): string {
37
+ return format(day, "yyyy-MM-dd");
38
+ }
39
+
40
+ /**
41
+ * A recurrence rule's inclusive "Ends on" date -> its stored `until` instant: the end of that day in the
42
+ * frame the series expands in. A timed series expands in its own (normally the viewer's) timezone, so that's
43
+ * the end of the local day - `new Date("YYYY-MM-DD")` would be UTC midnight, the previous day west of UTC.
44
+ * An all-day series expands in UTC (react-shared's `expandOccurrences`), so it's the end of the UTC day: the
45
+ * local end of day is already the next UTC day west of UTC and would add one more occurrence.
46
+ */
47
+ export function recurrenceUntilInstant(dateKey: string, allDay: boolean): string {
48
+ if (allDay) {
49
+ return `${dateKey}T23:59:59.999Z`;
50
+ }
51
+ const [y, m, d] = dateKey.split("-").map(Number);
52
+ return new Date(y, m - 1, d, 23, 59, 59, 999).toISOString();
53
+ }
54
+
55
+ /** The stored form of an all-day series' "Ends on" date - the last millisecond of that UTC day. */
56
+ const ALL_DAY_UNTIL_PATTERN = /^(\d{4}-\d{2}-\d{2})T23:59:59\.999Z$/;
57
+ /** A bare UTC midnight - how the oldest editor (and date-only `UNTIL` values) stored the chosen date itself. */
58
+ const UTC_MIDNIGHT_PATTERN = /^(\d{4}-\d{2}-\d{2})T00:00:00(?:\.000)?Z$/;
59
+
60
+ /**
61
+ * Inverse of `recurrenceUntilInstant()`. An all-day series' value is recognized explicitly in its stored
62
+ * `T23:59:59.999Z` form (and as a bare UTC midnight, the oldest form). Anything else is a legacy value stored
63
+ * as the creator's *local* end of day, which can land anywhere from just before 10:00Z on the chosen date (UTC+14)
64
+ * to just before 12:00Z the day after (UTC-12) - a 26-hour spread no UTC-only rounding can resolve (UTC+14 and UTC-10
65
+ * creators' values are exactly 24h apart for the same date), so it reads back on the local calendar, the
66
+ * frame it was written in.
67
+ */
68
+ export function recurrenceUntilDateKey(until: string, allDay: boolean): string {
69
+ if (allDay) {
70
+ const stored = ALL_DAY_UNTIL_PATTERN.exec(until) ?? UTC_MIDNIGHT_PATTERN.exec(until);
71
+ if (stored) {
72
+ return stored[1];
73
+ }
74
+ }
75
+ return format(new Date(until), "yyyy-MM-dd");
76
+ }
77
+
78
+ /** `Date#getUTCDay()` index -> RFC 5545 weekday code. */
79
+ const JS_WEEKDAY_CODES: WeekdayCode[] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
80
+
81
+ /** The weekday an event starts on, in the frame its series expands in: the UTC date for an all-day event
82
+ * (`start`'s date part), the event's own timezone otherwise (`start` being a local `datetime-local` value).
83
+ * `undefined` while the start input is empty/invalid. */
84
+ export function startWeekdayCode(start: string, allDay: boolean, timezone: string | undefined): WeekdayCode | undefined {
85
+ const ms = allDay ? new Date(allDayInstant(start.slice(0, 10))).getTime() : new Date(start).getTime();
86
+ if (Number.isNaN(ms)) {
87
+ return undefined;
88
+ }
89
+ return JS_WEEKDAY_CODES[new Date(toEventWallClock(ms, timezone, allDay)).getUTCDay()];
90
+ }
91
+
92
+ /**
93
+ * Whether `occurrence` should be shown on the grid's local calendar day `day`. All-day events cover every
94
+ * date from their start date up to (not including) their exclusive end date — at least their start date.
95
+ * Timed events cover every local day their [start, end) interval overlaps, so a multi-day event shows on
96
+ * each day rather than only on its start day.
97
+ */
98
+ export function occursOnDay(occurrence: CalendarOccurrence, day: Date): boolean {
99
+ if (occurrence.allDay) {
100
+ const key = localDateKey(day);
101
+ const startKey = allDayDateKey(occurrence.startDate);
102
+ const endKey = allDayDateKey(occurrence.endDate);
103
+ return key === startKey || (key > startKey && key < endKey);
104
+ }
105
+ const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate());
106
+ const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
107
+ const start = new Date(occurrence.startDate);
108
+ const end = new Date(occurrence.endDate);
109
+ if (start.getTime() === end.getTime()) {
110
+ return start >= dayStart && start < dayEnd;
111
+ }
112
+ return start < dayEnd && end > dayStart;
113
+ }
114
+
115
+ /**
116
+ * Whether `day` is the first day `occurrence` covers. Grid views only make that day's chip/block
117
+ * draggable — a drag id must be unique, so the continuation copies on later days are display-only.
118
+ */
119
+ export function startsOnDay(occurrence: CalendarOccurrence, day: Date): boolean {
120
+ if (occurrence.allDay) {
121
+ return allDayDateKey(occurrence.startDate) === localDateKey(day);
122
+ }
123
+ return localDateKey(new Date(occurrence.startDate)) === localDateKey(day);
124
+ }
@@ -108,7 +108,7 @@ export default function CalendarShell({
108
108
  return;
109
109
  }
110
110
  let cancelled = false;
111
- Promise.all(
111
+ void Promise.all(
112
112
  mailboxes.map((mailbox) =>
113
113
  listFolders(mailbox.uid)
114
114
  .then((folders): MailboxCalendars => ({ mailbox, calendarFolders: folders.filter((f) => f.type === "calendar") }))