@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,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
+ }