@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
@@ -1,180 +1,222 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React from "react";
6
- import { RecurrenceFrequency, RecurrenceRule, WeekdayCode } from "@rapidmx/react-shared/calendar/calendarApi.js";
7
- import { WEEKDAY_CODES, WEEKDAY_LABELS, describeRecurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
8
-
9
- const SELECT_CLASS =
10
- "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
11
- const INPUT_CLASS =
12
- "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
-
14
- const FREQ_LABEL: Record<RecurrenceFrequency, { unit: string; unitPlural: string }> = {
15
- daily: { unit: "day", unitPlural: "days" },
16
- weekly: { unit: "week", unitPlural: "weeks" },
17
- monthly: { unit: "month", unitPlural: "months" },
18
- yearly: { unit: "year", unitPlural: "years" },
19
- };
20
-
21
- type EndCondition = "never" | "count" | "until";
22
-
23
- function endConditionOf(rule: RecurrenceRule): EndCondition {
24
- if (rule.count) return "count";
25
- if (rule.until) return "until";
26
- return "never";
27
- }
28
-
29
- export interface RecurrenceEditorProps {
30
- /** `null` means "does not repeat". */
31
- value: RecurrenceRule | null;
32
- onChange: (value: RecurrenceRule | null) => void;
33
- }
34
-
35
- /**
36
- * A recurring-event editor: frequency + interval, a weekday picker (weekly only), and an end
37
- * condition (never / after N occurrences / on a date) — everything `RecurrenceRule` can express. The
38
- * live "every ... until/for ..." summary comes from `describeRecurrence()` (built on `rrule`'s own
39
- * `.toText()`), so it never drifts out of sync with what will actually be submitted.
40
- */
41
- export default function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
42
- function handleEnable(enabled: boolean) {
43
- onChange(enabled ? { freq: "weekly", interval: 1, byDay: ["MO"], exceptions: [] } : null);
44
- }
45
-
46
- // `update`/`toggleDay` are only ever invoked from handlers rendered inside the `{value && (...)}`
47
- // block below, so `value` is always non-null here — no defensive null check needed.
48
- function update(patch: Partial<RecurrenceRule>) {
49
- onChange({ ...(value as RecurrenceRule), ...patch });
50
- }
51
-
52
- function toggleDay(day: WeekdayCode) {
53
- const current = (value as RecurrenceRule).byDay ?? [];
54
- const next = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
55
- update({ byDay: next });
56
- }
57
-
58
- function handleEndCondition(condition: EndCondition) {
59
- if (condition === "never") {
60
- update({ count: undefined, until: undefined });
61
- } else if (condition === "count") {
62
- update({ count: 10, until: undefined });
63
- } else {
64
- update({ until: new Date().toISOString(), count: undefined });
65
- }
66
- }
67
-
68
- return (
69
- <div className="flex flex-col gap-3">
70
- <label className="flex items-center gap-2 text-sm font-medium">
71
- <input type="checkbox" checked={value !== null} onChange={(e) => handleEnable(e.target.checked)} />
72
- Repeats
73
- </label>
74
-
75
- {value && (
76
- <div className="flex flex-col gap-3 pl-6 border-l-2 border-border">
77
- <div className="flex items-center gap-2 text-sm">
78
- <span>Every</span>
79
- <input
80
- type="number"
81
- min={1}
82
- className={`${INPUT_CLASS} w-16`}
83
- value={value.interval}
84
- onChange={(e) => update({ interval: Number(e.target.value) })}
85
- aria-label="Recurrence interval"
86
- />
87
- <select
88
- className={SELECT_CLASS}
89
- value={value.freq}
90
- onChange={(e) => update({ freq: e.target.value as RecurrenceFrequency })}
91
- aria-label="Recurrence frequency"
92
- >
93
- <option value="daily">{value.interval === 1 ? FREQ_LABEL.daily.unit : FREQ_LABEL.daily.unitPlural}</option>
94
- <option value="weekly">{value.interval === 1 ? FREQ_LABEL.weekly.unit : FREQ_LABEL.weekly.unitPlural}</option>
95
- <option value="monthly">{value.interval === 1 ? FREQ_LABEL.monthly.unit : FREQ_LABEL.monthly.unitPlural}</option>
96
- <option value="yearly">{value.interval === 1 ? FREQ_LABEL.yearly.unit : FREQ_LABEL.yearly.unitPlural}</option>
97
- </select>
98
- </div>
99
-
100
- {value.freq === "weekly" && (
101
- <div className="flex gap-1">
102
- {WEEKDAY_CODES.map((day) => (
103
- <button
104
- key={day}
105
- type="button"
106
- onClick={() => toggleDay(day)}
107
- aria-pressed={(value.byDay ?? []).includes(day)}
108
- aria-label={WEEKDAY_LABELS[day]}
109
- className={[
110
- "w-9 h-9 rounded-full text-xs font-semibold border",
111
- (value.byDay ?? []).includes(day)
112
- ? "bg-primary text-white border-primary"
113
- : "bg-surface text-text-muted border-border hover:border-primary",
114
- ].join(" ")}
115
- >
116
- <span aria-hidden="true">{WEEKDAY_LABELS[day][0]}</span>
117
- </button>
118
- ))}
119
- </div>
120
- )}
121
-
122
- <fieldset className="flex flex-col gap-1.5 text-sm">
123
- <legend className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1">Ends</legend>
124
- <label className="flex items-center gap-2">
125
- <input
126
- type="radio"
127
- name="recurrence-end"
128
- checked={endConditionOf(value) === "never"}
129
- onChange={() => handleEndCondition("never")}
130
- />
131
- Never
132
- </label>
133
- <label className="flex items-center gap-2">
134
- <input
135
- type="radio"
136
- name="recurrence-end"
137
- aria-label="After"
138
- checked={endConditionOf(value) === "count"}
139
- onChange={() => handleEndCondition("count")}
140
- />
141
- After
142
- <input
143
- type="number"
144
- min={1}
145
- className={`${INPUT_CLASS} w-16`}
146
- value={value.count ?? 10}
147
- disabled={endConditionOf(value) !== "count"}
148
- onChange={(e) => update({ count: Number(e.target.value) })}
149
- aria-label="Number of occurrences"
150
- />
151
- occurrences
152
- </label>
153
- <label className="flex items-center gap-2">
154
- <input
155
- type="radio"
156
- name="recurrence-end"
157
- aria-label="On"
158
- checked={endConditionOf(value) === "until"}
159
- onChange={() => handleEndCondition("until")}
160
- />
161
- On
162
- <input
163
- type="date"
164
- className={INPUT_CLASS}
165
- value={value.until ? value.until.slice(0, 10) : ""}
166
- disabled={endConditionOf(value) !== "until"}
167
- onChange={(e) => update({ until: new Date(e.target.value).toISOString() })}
168
- aria-label="End date"
169
- />
170
- </label>
171
- </fieldset>
172
-
173
- {((value.freq !== "weekly" || (value.byDay ?? []).length > 0)) && (
174
- <p className="text-xs text-text-muted">Repeats {describeRecurrence(value)}.</p>
175
- )}
176
- </div>
177
- )}
178
- </div>
179
- );
180
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { format } from "date-fns";
7
+ import { RecurrenceFrequency, RecurrenceRule, WeekdayCode } from "@rapidmx/react-shared/calendar/calendarApi.js";
8
+ import { WEEKDAY_CODES, WEEKDAY_LABELS, describeRecurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
9
+ import { recurrenceUntilDateKey, recurrenceUntilInstant } from "./allDay.js";
10
+
11
+ const SELECT_CLASS =
12
+ "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
+ const INPUT_CLASS =
14
+ "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
15
+
16
+ const FREQ_LABEL: Record<RecurrenceFrequency, { unit: string; unitPlural: string }> = {
17
+ daily: { unit: "day", unitPlural: "days" },
18
+ weekly: { unit: "week", unitPlural: "weeks" },
19
+ monthly: { unit: "month", unitPlural: "months" },
20
+ yearly: { unit: "year", unitPlural: "years" },
21
+ };
22
+
23
+ type EndCondition = "never" | "count" | "until";
24
+
25
+ function endConditionOf(rule: RecurrenceRule): EndCondition {
26
+ if (rule.count) return "count";
27
+ if (rule.until) return "until";
28
+ return "never";
29
+ }
30
+
31
+ export interface RecurrenceEditorProps {
32
+ /** `null` means "does not repeat". */
33
+ value: RecurrenceRule | null;
34
+ onChange: (value: RecurrenceRule | null) => void;
35
+ /** Whether the event is all-day - its series expands in UTC, so the inclusive "Ends on" date is stored as
36
+ * the end of that UTC day rather than the local one (see `allDay.ts`'s `recurrenceUntilInstant()`). */
37
+ allDay?: boolean;
38
+ /** The weekday the event starts on (in the frame its series expands in - see `allDay.ts`'s
39
+ * `startWeekdayCode()`): the day a new weekly rule, or one switched to Weekly, repeats on. Defaults to Monday. */
40
+ startWeekday?: WeekdayCode;
41
+ }
42
+
43
+ /**
44
+ * A recurring-event editor: frequency + interval, a weekday picker (weekly only), and an end
45
+ * condition (never / after N occurrences / on a date) — everything `RecurrenceRule` can express. The
46
+ * live "every ... until/for ..." summary comes from `describeRecurrence()` (built on `rrule`'s own
47
+ * `.toText()`), so it never drifts out of sync with what will actually be submitted.
48
+ */
49
+ export default function RecurrenceEditor({ value, onChange, allDay = false, startWeekday = "MO" }: RecurrenceEditorProps) {
50
+ function handleEnable(enabled: boolean) {
51
+ onChange(enabled ? { freq: "weekly", interval: 1, byDay: [startWeekday], exceptions: [] } : null);
52
+ }
53
+
54
+ // `update`/`toggleDay` are only ever invoked from handlers rendered inside the `{value && (...)}`
55
+ // block below, so `value` is always non-null here — no defensive null check needed.
56
+ function update(patch: Partial<RecurrenceRule>) {
57
+ onChange({ ...(value as RecurrenceRule), ...patch });
58
+ }
59
+
60
+ // A weekly rule always keeps at least one weekday: with none left, rrule would repeat every day instead.
61
+ function toggleDay(day: WeekdayCode) {
62
+ const current = (value as RecurrenceRule).byDay ?? [];
63
+ if (current.length === 1 && current[0] === day) {
64
+ return;
65
+ }
66
+ const next = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
67
+ update({ byDay: next });
68
+ }
69
+
70
+ // react-shared passes `byDay` to rrule for every frequency, so a weekday list left over from Weekly would
71
+ // turn "every day" into "every Monday". Leaving Weekly drops it; entering Weekly starts on the event's own
72
+ // start weekday (a select only reports an actual change, so this is never Weekly -> Weekly).
73
+ function handleFrequency(freq: RecurrenceFrequency) {
74
+ update({ freq, byDay: freq === "weekly" ? [startWeekday] : undefined });
75
+ }
76
+
77
+ function handleEndCondition(condition: EndCondition) {
78
+ if (condition === "never") {
79
+ update({ count: undefined, until: undefined });
80
+ } else if (condition === "count") {
81
+ update({ count: 10, until: undefined });
82
+ } else {
83
+ update({ until: recurrenceUntilInstant(format(new Date(), "yyyy-MM-dd"), allDay), count: undefined });
84
+ }
85
+ }
86
+
87
+ /** A positive whole number from a number input, or `null` while it's empty (a user clearing the field
88
+ * to type a new value) — the rule keeps its previous value then, rather than becoming 0/NaN. */
89
+ function parsePositive(raw: string): number | null {
90
+ if (raw.trim() === "") return null;
91
+ const n = Math.floor(Number(raw));
92
+ return n >= 1 ? n : 1;
93
+ }
94
+
95
+ function handleInterval(raw: string) {
96
+ const interval = parsePositive(raw);
97
+ if (interval !== null) update({ interval });
98
+ }
99
+
100
+ function handleCount(raw: string) {
101
+ const count = parsePositive(raw);
102
+ if (count !== null) update({ count });
103
+ }
104
+
105
+ function handleUntil(raw: string) {
106
+ // A cleared (or partially typed) date input reports "" — keep the previous end date.
107
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) update({ until: recurrenceUntilInstant(raw, allDay) });
108
+ }
109
+
110
+ return (
111
+ <div className="flex flex-col gap-3">
112
+ <label className="flex items-center gap-2 text-sm font-medium">
113
+ <input type="checkbox" checked={value !== null} onChange={(e) => handleEnable(e.target.checked)} />
114
+ Repeats
115
+ </label>
116
+
117
+ {value && (
118
+ <div className="flex flex-col gap-3 pl-6 border-l-2 border-border">
119
+ <div className="flex items-center gap-2 text-sm">
120
+ <span>Every</span>
121
+ <input
122
+ type="number"
123
+ min={1}
124
+ className={`${INPUT_CLASS} w-16`}
125
+ value={value.interval}
126
+ onChange={(e) => handleInterval(e.target.value)}
127
+ aria-label="Recurrence interval"
128
+ />
129
+ <select
130
+ className={SELECT_CLASS}
131
+ value={value.freq}
132
+ onChange={(e) => handleFrequency(e.target.value as RecurrenceFrequency)}
133
+ aria-label="Recurrence frequency"
134
+ >
135
+ <option value="daily">{value.interval === 1 ? FREQ_LABEL.daily.unit : FREQ_LABEL.daily.unitPlural}</option>
136
+ <option value="weekly">{value.interval === 1 ? FREQ_LABEL.weekly.unit : FREQ_LABEL.weekly.unitPlural}</option>
137
+ <option value="monthly">{value.interval === 1 ? FREQ_LABEL.monthly.unit : FREQ_LABEL.monthly.unitPlural}</option>
138
+ <option value="yearly">{value.interval === 1 ? FREQ_LABEL.yearly.unit : FREQ_LABEL.yearly.unitPlural}</option>
139
+ </select>
140
+ </div>
141
+
142
+ {value.freq === "weekly" && (
143
+ <div className="flex gap-1">
144
+ {WEEKDAY_CODES.map((day) => (
145
+ <button
146
+ key={day}
147
+ type="button"
148
+ onClick={() => toggleDay(day)}
149
+ aria-pressed={(value.byDay ?? []).includes(day)}
150
+ aria-label={WEEKDAY_LABELS[day]}
151
+ className={[
152
+ "w-9 h-9 rounded-full text-xs font-semibold border",
153
+ (value.byDay ?? []).includes(day)
154
+ ? "bg-primary text-white border-primary"
155
+ : "bg-surface text-text-muted border-border hover:border-primary",
156
+ ].join(" ")}
157
+ >
158
+ <span aria-hidden="true">{WEEKDAY_LABELS[day][0]}</span>
159
+ </button>
160
+ ))}
161
+ </div>
162
+ )}
163
+
164
+ <fieldset className="flex flex-col gap-1.5 text-sm">
165
+ <legend className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1">Ends</legend>
166
+ <label className="flex items-center gap-2">
167
+ <input
168
+ type="radio"
169
+ name="recurrence-end"
170
+ checked={endConditionOf(value) === "never"}
171
+ onChange={() => handleEndCondition("never")}
172
+ />
173
+ Never
174
+ </label>
175
+ <label className="flex items-center gap-2">
176
+ <input
177
+ type="radio"
178
+ name="recurrence-end"
179
+ aria-label="After"
180
+ checked={endConditionOf(value) === "count"}
181
+ onChange={() => handleEndCondition("count")}
182
+ />
183
+ After
184
+ <input
185
+ type="number"
186
+ min={1}
187
+ className={`${INPUT_CLASS} w-16`}
188
+ value={value.count ?? 10}
189
+ disabled={endConditionOf(value) !== "count"}
190
+ onChange={(e) => handleCount(e.target.value)}
191
+ aria-label="Number of occurrences"
192
+ />
193
+ occurrences
194
+ </label>
195
+ <label className="flex items-center gap-2">
196
+ <input
197
+ type="radio"
198
+ name="recurrence-end"
199
+ aria-label="On"
200
+ checked={endConditionOf(value) === "until"}
201
+ onChange={() => handleEndCondition("until")}
202
+ />
203
+ On
204
+ <input
205
+ type="date"
206
+ className={INPUT_CLASS}
207
+ value={value.until ? recurrenceUntilDateKey(value.until, allDay) : ""}
208
+ disabled={endConditionOf(value) !== "until"}
209
+ onChange={(e) => handleUntil(e.target.value)}
210
+ aria-label="End date"
211
+ />
212
+ </label>
213
+ </fieldset>
214
+
215
+ {(value.freq !== "weekly" || (value.byDay ?? []).length > 0) && (
216
+ <p className="text-xs text-text-muted">Repeats {describeRecurrence(value)}.</p>
217
+ )}
218
+ </div>
219
+ )}
220
+ </div>
221
+ );
222
+ }