@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,579 +1,638 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useEffect, useMemo, useState } from "react";
6
- import { endOfWeek, isAfter, isBefore, isToday, startOfDay } from "date-fns";
7
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import {
9
- Task,
10
- TaskPriority,
11
- createTask,
12
- deleteTask,
13
- listTasks,
14
- setTaskCompleted,
15
- setTaskMyDay,
16
- updateTask,
17
- } from "@rapidmx/react-shared/tasks/tasksApi.js";
18
- import { listFlaggedMessages } from "@rapidmx/react-shared/mail/flaggedMessages.js";
19
- import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
20
- import TasksShell, { TasksShellProps, useTasksShell } from "../../shared/components/tasks/layout/TasksShell.js";
21
- import TasksSidebar, { TasksView } from "../../shared/components/tasks/TasksSidebar.js";
22
- import TasksToolbar, { TasksViewMode } from "../../shared/components/tasks/TasksToolbar.js";
23
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
24
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
25
- import { findWellKnownFolderUid } from "../../shared/mail/findWellKnownFolderUid.js";
26
-
27
- const INPUT_CLASS =
28
- "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
29
-
30
- export default function TasksPage(props: TasksShellProps) {
31
- return (
32
- <TasksShell {...props}>
33
- <TasksContent />
34
- </TasksShell>
35
- );
36
- }
37
-
38
- type Bucket = "Overdue" | "Today" | "This Week" | "Later" | "No due date";
39
- const BUCKET_ORDER: Bucket[] = ["Overdue", "Today", "This Week", "Later", "No due date"];
40
-
41
- /** Buckets a task by its due date relative to `now`, matching the Outlook/To-Do convention this list mirrors. */
42
- function bucketFor(task: Task, now: Date): Bucket {
43
- if (!task.dueDate) {
44
- return "No due date";
45
- }
46
- const due = new Date(task.dueDate);
47
- if (isBefore(due, startOfDay(now))) {
48
- return "Overdue";
49
- }
50
- if (isToday(due)) {
51
- return "Today";
52
- }
53
- if (!isAfter(due, endOfWeek(now, { weekStartsOn: 1 }))) {
54
- return "This Week";
55
- }
56
- return "Later";
57
- }
58
-
59
- const PRIORITY_LABEL: Record<TaskPriority, string> = { low: "Low", normal: "Normal", high: "High" };
60
- const PRIORITY_CLASS: Record<TaskPriority, string> = {
61
- low: "text-text-muted",
62
- normal: "text-text-muted",
63
- high: "text-danger",
64
- };
65
-
66
- function TasksContent() {
67
- const { folderUid, mailboxUid, userUid, mailboxes } = useTasksShell();
68
- // The quick-add form's chosen mailbox - `undefined` means "the mailbox being viewed".
69
- const [targetMailboxUid, setTargetMailboxUid] = useState<string | undefined>(undefined);
70
- // Set after a task is added to a different mailbox than the one this list shows.
71
- const [addedElsewhere, setAddedElsewhere] = useState<{ mailboxUid: string; title: string } | null>(null);
72
- const [tasks, setTasks] = useState<Task[]>([]);
73
- const [loading, setLoading] = useState(true);
74
- const [error, setError] = useState<string | null>(null);
75
- const [title, setTitle] = useState("");
76
- const [dueDate, setDueDate] = useState("");
77
- const [priority, setPriority] = useState<TaskPriority>("normal");
78
- const [createError, setCreateError] = useState<string | null>(null);
79
- const [creating, setCreating] = useState(false);
80
- const [view, setView] = useState<TasksView>({ type: "all" });
81
- const [viewMode, setViewMode] = useState<TasksViewMode>("list");
82
- const [checkedUids, setCheckedUids] = useState<Set<string>>(new Set());
83
- const [flaggedMessages, setFlaggedMessages] = useState<Message[]>([]);
84
- const [flaggedLoading, setFlaggedLoading] = useState(false);
85
- const [flaggedError, setFlaggedError] = useState<string | null>(null);
86
-
87
- function reload(): Promise<void> {
88
- if (!folderUid) {
89
- setTasks([]);
90
- setLoading(false);
91
- return Promise.resolve();
92
- }
93
- setLoading(true);
94
- setError(null);
95
- return listTasks(folderUid, { limit: 500 })
96
- .then(setTasks)
97
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load tasks."))
98
- .finally(() => setLoading(false));
99
- }
100
-
101
- useEffect(() => {
102
- void reload();
103
- }, [folderUid]);
104
-
105
- useEffect(() => {
106
- setTargetMailboxUid(undefined);
107
- setAddedElsewhere(null);
108
- }, [mailboxUid]);
109
-
110
- useEffect(() => {
111
- if (view.type !== "flagged" || !mailboxUid) {
112
- return;
113
- }
114
- setFlaggedLoading(true);
115
- setFlaggedError(null);
116
- listFlaggedMessages(mailboxUid)
117
- .then(setFlaggedMessages)
118
- .catch((err) => setFlaggedError(err instanceof ApiRequestError ? err.message : "Could not load flagged email."))
119
- .finally(() => setFlaggedLoading(false));
120
- }, [view, mailboxUid]);
121
-
122
- async function handleCreate(e: FormEvent) {
123
- e.preventDefault();
124
- setCreateError(null);
125
- if (!title.trim()) {
126
- setCreateError("A title is required.");
127
- return;
128
- }
129
- if (!mailboxUid || !folderUid) {
130
- return;
131
- }
132
- const target = targetMailboxUid ?? mailboxUid;
133
- setCreating(true);
134
- try {
135
- const targetFolderUid = target === mailboxUid ? folderUid : await findWellKnownFolderUid(target, "tasks");
136
- if (!targetFolderUid) {
137
- setCreateError("That mailbox has no Tasks folder.");
138
- return;
139
- }
140
- const created = await createTask({
141
- mailboxUid: target,
142
- folderUid: targetFolderUid,
143
- title: title.trim(),
144
- dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
145
- priority,
146
- });
147
- if (target === mailboxUid) {
148
- setAddedElsewhere(null);
149
- setTasks((prev) => [...prev, created]);
150
- } else {
151
- // Created in another mailbox - it doesn't belong in this mailbox's list.
152
- setAddedElsewhere({ mailboxUid: target, title: created.title });
153
- }
154
- setTitle("");
155
- setDueDate("");
156
- setPriority("normal");
157
- } catch (err) {
158
- setCreateError(err instanceof ApiRequestError ? err.message : "Could not create this task.");
159
- } finally {
160
- setCreating(false);
161
- }
162
- }
163
-
164
- async function handleToggle(task: Task) {
165
- setError(null);
166
- const nextCompleted = !task.completed;
167
- setTasks((prev) => prev.map((t) => (t.uid === task.uid ? { ...t, completed: nextCompleted } : t)));
168
- try {
169
- const updated = await setTaskCompleted(task, nextCompleted);
170
- setTasks((prev) => prev.map((t) => (t.uid === task.uid ? updated : t)));
171
- } catch (err) {
172
- setTasks((prev) => prev.map((t) => (t.uid === task.uid ? task : t)));
173
- setError(err instanceof ApiRequestError ? err.message : "Could not update this task.");
174
- }
175
- }
176
-
177
- async function handleDelete(task: Task) {
178
- setError(null);
179
- try {
180
- await deleteTask(task.uid, task.version);
181
- setTasks((prev) => prev.filter((t) => t.uid !== task.uid));
182
- } catch (err) {
183
- setError(err instanceof ApiRequestError ? err.message : "Could not delete this task.");
184
- }
185
- }
186
-
187
- function handleSelectView(next: TasksView) {
188
- setView(next);
189
- setCheckedUids(new Set());
190
- }
191
-
192
- function toggleChecked(uid: string) {
193
- setCheckedUids((prev) => {
194
- const next = new Set(prev);
195
- if (next.has(uid)) {
196
- next.delete(uid);
197
- } else {
198
- next.add(uid);
199
- }
200
- return next;
201
- });
202
- }
203
-
204
- const viewFiltered = useMemo(() => {
205
- switch (view.type) {
206
- case "myDay":
207
- return tasks.filter((t) => t.myDay);
208
- case "important":
209
- return tasks.filter((t) => t.priority === "high");
210
- case "planned":
211
- return tasks.filter((t) => t.dueDate != null);
212
- case "assignedToMe":
213
- return tasks.filter((t) => t.assignedTo === userUid);
214
- case "list":
215
- return tasks.filter((t) => t.taskListUid === view.uid);
216
- case "flagged":
217
- return [];
218
- case "all":
219
- return tasks;
220
- }
221
- }, [tasks, view, userUid]);
222
-
223
- const checkedTasks = viewFiltered.filter((t) => checkedUids.has(t.uid));
224
-
225
- async function handleBulkComplete() {
226
- let bulkError: string | null = null;
227
- for (const task of checkedTasks) {
228
- try {
229
- await setTaskCompleted(task, true);
230
- } catch (err) {
231
- bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more tasks.";
232
- }
233
- }
234
- setCheckedUids(new Set());
235
- await reload();
236
- setError(bulkError);
237
- }
238
-
239
- async function handleBulkAddToMyDay() {
240
- let bulkError: string | null = null;
241
- for (const task of checkedTasks) {
242
- try {
243
- await setTaskMyDay(task, true);
244
- } catch (err) {
245
- bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more tasks.";
246
- }
247
- }
248
- setCheckedUids(new Set());
249
- await reload();
250
- setError(bulkError);
251
- }
252
-
253
- async function handleBulkDelete() {
254
- let bulkError: string | null = null;
255
- for (const task of checkedTasks) {
256
- try {
257
- await deleteTask(task.uid, task.version);
258
- } catch (err) {
259
- bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more tasks.";
260
- }
261
- }
262
- setCheckedUids(new Set());
263
- await reload();
264
- setError(bulkError);
265
- }
266
-
267
- const grouped = useMemo(() => {
268
- const now = new Date();
269
- const map = new Map<Bucket, Task[]>(BUCKET_ORDER.map((b) => [b, []]));
270
- for (const task of viewFiltered) {
271
- if (task.completed) {
272
- continue;
273
- }
274
- map.get(bucketFor(task, now))!.push(task);
275
- }
276
- return map;
277
- }, [viewFiltered]);
278
-
279
- const completedTasks = viewFiltered.filter((t) => t.completed);
280
-
281
- return (
282
- <div className="flex-1 flex min-h-0">
283
- <TasksSidebar mailboxUid={mailboxUid} tasks={tasks} userUid={userUid} active={view} onSelect={handleSelectView} />
284
- <div className="flex-1 min-w-0 flex flex-col overflow-y-auto">
285
- <TasksToolbar
286
- viewMode={viewMode}
287
- onViewModeChange={setViewMode}
288
- selectedCount={checkedTasks.length}
289
- onComplete={handleBulkComplete}
290
- onDelete={handleBulkDelete}
291
- onAddToMyDay={handleBulkAddToMyDay}
292
- />
293
- <div role="region" aria-label="Tasks list" className="max-w-2xl mx-auto w-full flex flex-col gap-6 p-6">
294
- <h1 className="text-xl font-bold uppercase tracking-wide">Tasks</h1>
295
-
296
- {error && <Alert>{error}</Alert>}
297
-
298
- {view.type === "flagged" ? (
299
- <FlaggedEmailList loading={flaggedLoading} error={flaggedError} messages={flaggedMessages} />
300
- ) : (
301
- <>
302
- <form onSubmit={handleCreate} className="flex items-center gap-2 bg-surface border border-border rounded-md p-2">
303
- <input
304
- type="text"
305
- aria-label="Add a task"
306
- className={`${INPUT_CLASS} flex-1`}
307
- value={title}
308
- onChange={(e) => setTitle(e.target.value)}
309
- placeholder="Add a task"
310
- />
311
- <input
312
- type="date"
313
- aria-label="Due date"
314
- className={INPUT_CLASS}
315
- value={dueDate}
316
- onChange={(e) => setDueDate(e.target.value)}
317
- />
318
- <select
319
- aria-label="Priority"
320
- className={INPUT_CLASS}
321
- value={priority}
322
- onChange={(e) => setPriority(e.target.value as TaskPriority)}
323
- >
324
- <option value="low">Low</option>
325
- <option value="normal">Normal</option>
326
- <option value="high">High</option>
327
- </select>
328
- {mailboxes.length > 1 && (
329
- <select
330
- aria-label="Task mailbox"
331
- className={`${INPUT_CLASS} max-w-40`}
332
- value={targetMailboxUid ?? mailboxUid ?? ""}
333
- onChange={(e) => setTargetMailboxUid(e.target.value)}
334
- >
335
- {mailboxes.map((mb) => (
336
- <option key={mb.uid} value={mb.uid}>
337
- {mb.displayName}
338
- {mb.ownerUserUid === userUid ? "" : " (shared)"}
339
- </option>
340
- ))}
341
- </select>
342
- )}
343
- <Button type="submit" loading={creating} disabled={creating} className="!w-auto shrink-0">
344
- Add
345
- </Button>
346
- </form>
347
- {createError && <Alert>{createError}</Alert>}
348
- {addedElsewhere && (
349
- <p role="status" className="text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
350
- &ldquo;{addedElsewhere.title}&rdquo; was added to{" "}
351
- {mailboxes.find((mb) => mb.uid === addedElsewhere.mailboxUid)?.displayName ?? "another mailbox"}.{" "}
352
- <a
353
- href={`/tasks?mailboxUid=${encodeURIComponent(addedElsewhere.mailboxUid)}`}
354
- className="font-medium text-primary-dark hover:underline"
355
- >
356
- View that mailbox&rsquo;s tasks
357
- </a>
358
- </p>
359
- )}
360
-
361
- {loading ? (
362
- <p className="text-sm text-text-muted">Loading&hellip;</p>
363
- ) : viewFiltered.length === 0 ? (
364
- <p className="text-sm text-text-muted">No tasks yet.</p>
365
- ) : viewMode === "grid" ? (
366
- <TaskTable
367
- tasks={viewFiltered}
368
- checkedUids={checkedUids}
369
- onToggleChecked={toggleChecked}
370
- onToggle={handleToggle}
371
- onDelete={handleDelete}
372
- />
373
- ) : (
374
- <>
375
- {BUCKET_ORDER.map((bucket) => {
376
- const items = grouped.get(bucket)!;
377
- if (items.length === 0) {
378
- return null;
379
- }
380
- return (
381
- <TaskGroup
382
- key={bucket}
383
- label={bucket}
384
- tasks={items}
385
- checkedUids={checkedUids}
386
- onToggleChecked={toggleChecked}
387
- onToggle={handleToggle}
388
- onDelete={handleDelete}
389
- />
390
- );
391
- })}
392
- {completedTasks.length > 0 && (
393
- <TaskGroup
394
- label="Completed"
395
- tasks={completedTasks}
396
- checkedUids={checkedUids}
397
- onToggleChecked={toggleChecked}
398
- onToggle={handleToggle}
399
- onDelete={handleDelete}
400
- />
401
- )}
402
- </>
403
- )}
404
- </>
405
- )}
406
- </div>
407
- </div>
408
- </div>
409
- );
410
- }
411
-
412
- function FlaggedEmailList({ loading, error, messages }: { loading: boolean; error: string | null; messages: Message[] }) {
413
- if (error) {
414
- return <Alert>{error}</Alert>;
415
- }
416
- if (loading) {
417
- return <p className="text-sm text-text-muted">Loading&hellip;</p>;
418
- }
419
- if (messages.length === 0) {
420
- return <p className="text-sm text-text-muted">No flagged email.</p>;
421
- }
422
- return (
423
- <ul className="bg-surface border border-border rounded-md divide-y divide-border">
424
- {messages.map((m) => (
425
- <li key={m.uid} className="flex items-center gap-3 py-2.5 px-3 text-sm">
426
- <span className="flex-1 truncate">{m.subject || "(no subject)"}</span>
427
- <span className="text-xs text-text-muted shrink-0">{m.from.address}</span>
428
- <span className="text-xs text-text-muted shrink-0">{new Date(m.receivedDate).toLocaleDateString()}</span>
429
- </li>
430
- ))}
431
- </ul>
432
- );
433
- }
434
-
435
- /** A circular completion-toggle control matching Outlook's visual language (not a native checkbox), while
436
- * staying a real accessible toggle. */
437
- function CompletionToggle({ task, onToggle }: { task: Task; onToggle: (task: Task) => void }) {
438
- return (
439
- <button
440
- type="button"
441
- role="checkbox"
442
- aria-checked={task.completed}
443
- aria-label={`Mark "${task.title}" as ${task.completed ? "not complete" : "complete"}`}
444
- onClick={() => onToggle(task)}
445
- className={[
446
- "w-4 h-4 rounded-full border shrink-0 flex items-center justify-center text-[10px]",
447
- task.completed ? "bg-primary border-primary text-white" : "border-border text-transparent",
448
- ].join(" ")}
449
- >
450
- &#10003;
451
- </button>
452
- );
453
- }
454
-
455
- interface TaskGroupProps {
456
- label: string;
457
- tasks: Task[];
458
- checkedUids: Set<string>;
459
- onToggleChecked: (uid: string) => void;
460
- onToggle: (task: Task) => void;
461
- onDelete: (task: Task) => void;
462
- }
463
-
464
- function TaskGroup({ label, tasks, checkedUids, onToggleChecked, onToggle, onDelete }: TaskGroupProps) {
465
- return (
466
- <div>
467
- <h2 className="text-xs font-bold uppercase tracking-wide text-text-muted mb-2">{label}</h2>
468
- <ul className="bg-surface border border-border rounded-md divide-y divide-border">
469
- {tasks.map((task) => (
470
- <li key={task.uid} className="flex items-center gap-3 py-2.5 px-3">
471
- <input
472
- type="checkbox"
473
- aria-label={`Select ${task.title}`}
474
- checked={checkedUids.has(task.uid)}
475
- onChange={() => onToggleChecked(task.uid)}
476
- />
477
- <CompletionToggle task={task} onToggle={onToggle} />
478
- <span className={["flex-1 text-sm", task.completed ? "line-through text-text-muted" : ""].join(" ")}>
479
- {task.title}
480
- </span>
481
- {task.priority !== "normal" && (
482
- <span className={["text-xs font-medium", PRIORITY_CLASS[task.priority]].join(" ")}>
483
- {PRIORITY_LABEL[task.priority]}
484
- </span>
485
- )}
486
- {task.dueDate && (
487
- <span className="text-xs text-text-muted shrink-0">{new Date(task.dueDate).toLocaleDateString()}</span>
488
- )}
489
- <button
490
- type="button"
491
- onClick={() => onDelete(task)}
492
- aria-label={`Delete "${task.title}"`}
493
- className="text-text-muted hover:text-danger text-sm px-1"
494
- >
495
- &times;
496
- </button>
497
- </li>
498
- ))}
499
- </ul>
500
- </div>
501
- );
502
- }
503
-
504
- interface TaskTableProps {
505
- tasks: Task[];
506
- checkedUids: Set<string>;
507
- onToggleChecked: (uid: string) => void;
508
- onToggle: (task: Task) => void;
509
- onDelete: (task: Task) => void;
510
- }
511
-
512
- function TaskTable({ tasks, checkedUids, onToggleChecked, onToggle, onDelete }: TaskTableProps) {
513
- const allChecked = tasks.length > 0 && tasks.every((t) => checkedUids.has(t.uid));
514
-
515
- function toggleAll() {
516
- if (allChecked) {
517
- // Every task is already checked when this branch runs (that's what `allChecked` means) — no
518
- // per-task guard needed, unlike the `else` branch below where only *some* tasks may be checked.
519
- for (const t of tasks) {
520
- onToggleChecked(t.uid);
521
- }
522
- } else {
523
- for (const t of tasks) {
524
- if (!checkedUids.has(t.uid)) {
525
- onToggleChecked(t.uid);
526
- }
527
- }
528
- }
529
- }
530
-
531
- return (
532
- <div className="overflow-x-auto">
533
- <table className="w-full text-sm bg-surface border border-border rounded-md">
534
- <thead>
535
- <tr className="border-b border-border text-left">
536
- <th className="w-8 px-3 py-2">
537
- <input type="checkbox" aria-label="Select all tasks" checked={allChecked} onChange={toggleAll} />
538
- </th>
539
- <th className="w-8 px-3 py-2" />
540
- <th className="px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted">Title</th>
541
- <th className="px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted">Due Date</th>
542
- <th className="px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted">Importance</th>
543
- <th className="w-8 px-3 py-2" />
544
- </tr>
545
- </thead>
546
- <tbody>
547
- {tasks.map((task) => (
548
- <tr key={task.uid} className="border-b border-border last:border-0">
549
- <td className="px-3 py-2">
550
- <input
551
- type="checkbox"
552
- aria-label={`Select ${task.title}`}
553
- checked={checkedUids.has(task.uid)}
554
- onChange={() => onToggleChecked(task.uid)}
555
- />
556
- </td>
557
- <td className="px-3 py-2">
558
- <CompletionToggle task={task} onToggle={onToggle} />
559
- </td>
560
- <td className={["px-3 py-2", task.completed ? "line-through text-text-muted" : ""].join(" ")}>{task.title}</td>
561
- <td className="px-3 py-2 text-text-muted">{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : ""}</td>
562
- <td className={["px-3 py-2", PRIORITY_CLASS[task.priority]].join(" ")}>{PRIORITY_LABEL[task.priority]}</td>
563
- <td className="px-3 py-2">
564
- <button
565
- type="button"
566
- onClick={() => onDelete(task)}
567
- aria-label={`Delete "${task.title}"`}
568
- className="text-text-muted hover:text-danger text-sm px-1"
569
- >
570
- &times;
571
- </button>
572
- </td>
573
- </tr>
574
- ))}
575
- </tbody>
576
- </table>
577
- </div>
578
- );
579
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, useMemo, useState } from "react";
6
+ import { endOfWeek, isAfter, isBefore, isToday, parseISO, startOfDay } from "date-fns";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import {
9
+ Task,
10
+ TaskPriority,
11
+ createTask,
12
+ deleteTask,
13
+ listTasks,
14
+ setTaskCompleted,
15
+ setTaskMyDay,
16
+ updateTask,
17
+ } from "@rapidmx/react-shared/tasks/tasksApi.js";
18
+ import { listFlaggedMessages } from "@rapidmx/react-shared/mail/flaggedMessages.js";
19
+ import { Message } from "@rapidmx/react-shared/mail/mailApi.js";
20
+ import TasksShell, { TasksShellProps, useTasksShell } from "../../shared/components/tasks/layout/TasksShell.js";
21
+ import TasksSidebar, { TasksView } from "../../shared/components/tasks/TasksSidebar.js";
22
+ import TasksToolbar, { TasksViewMode } from "../../shared/components/tasks/TasksToolbar.js";
23
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
24
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
25
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
26
+ import { findWellKnownFolderUid } from "../../shared/mail/findWellKnownFolderUid.js";
27
+ import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
28
+ import { LIST_PAGE_SIZE, MAX_LIST_PAGES, listAllPages } from "../../shared/mail/listAllPages.js";
29
+
30
+ const INPUT_CLASS =
31
+ "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
32
+
33
+ export default function TasksPage(props: TasksShellProps) {
34
+ return (
35
+ <TasksShell {...props}>
36
+ <TasksContent />
37
+ </TasksShell>
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Parses a task's `dueDate` - `parseISO()` rather than `new Date()`, since `new Date("2026-06-16")` reads a
43
+ * date-only value as UTC midnight, which is the *previous* local day anywhere west of UTC. A full
44
+ * timestamp parses identically either way.
45
+ */
46
+ function parseDueDate(value: string): Date {
47
+ return parseISO(value);
48
+ }
49
+
50
+ type Bucket = "Overdue" | "Today" | "This Week" | "Later" | "No due date";
51
+ const BUCKET_ORDER: Bucket[] = ["Overdue", "Today", "This Week", "Later", "No due date"];
52
+
53
+ /** Buckets a task by its due date relative to `now`, matching the Outlook/To-Do convention this list mirrors. */
54
+ function bucketFor(task: Task, now: Date): Bucket {
55
+ if (!task.dueDate) {
56
+ return "No due date";
57
+ }
58
+ const due = parseDueDate(task.dueDate);
59
+ if (isBefore(due, startOfDay(now))) {
60
+ return "Overdue";
61
+ }
62
+ if (isToday(due)) {
63
+ return "Today";
64
+ }
65
+ if (!isAfter(due, endOfWeek(now, { weekStartsOn: 1 }))) {
66
+ return "This Week";
67
+ }
68
+ return "Later";
69
+ }
70
+
71
+ const PRIORITY_LABEL: Record<TaskPriority, string> = { low: "Low", normal: "Normal", high: "High" };
72
+ const PRIORITY_CLASS: Record<TaskPriority, string> = {
73
+ low: "text-text-muted",
74
+ normal: "text-text-muted",
75
+ high: "text-danger",
76
+ };
77
+
78
+ function TasksContent() {
79
+ const { folderUid, mailboxUid, userUid, mailboxes } = useTasksShell();
80
+ // The quick-add form's Mailbox choices - view-only shares are left out (see writableMailboxes.ts).
81
+ const writableMailboxes = useWritableMailboxes(mailboxes, userUid, mailboxUid);
82
+ // The quick-add form's chosen mailbox - `undefined` means "the mailbox being viewed".
83
+ const [targetMailboxUid, setTargetMailboxUid] = useState<string | undefined>(undefined);
84
+ // Set after a task is added to a different mailbox than the one this list shows.
85
+ const [addedElsewhere, setAddedElsewhere] = useState<{ mailboxUid: string; title: string } | null>(null);
86
+ const [tasks, setTasks] = useState<Task[]>([]);
87
+ const [loading, setLoading] = useState(true);
88
+ const [error, setError] = useState<string | null>(null);
89
+ // Set when `listAllPages()` stopped at its page cap - the list shown isn't every task.
90
+ const [truncated, setTruncated] = useState(false);
91
+ const [title, setTitle] = useState("");
92
+ const [dueDate, setDueDate] = useState("");
93
+ const [priority, setPriority] = useState<TaskPriority>("normal");
94
+ const [createError, setCreateError] = useState<string | null>(null);
95
+ const [creating, setCreating] = useState(false);
96
+ const [view, setView] = useState<TasksView>({ type: "all" });
97
+ const [viewMode, setViewMode] = useState<TasksViewMode>("list");
98
+ const [checkedUids, setCheckedUids] = useState<Set<string>>(new Set());
99
+ const [flaggedMessages, setFlaggedMessages] = useState<Message[]>([]);
100
+ const [flaggedLoading, setFlaggedLoading] = useState(false);
101
+ const [flaggedError, setFlaggedError] = useState<string | null>(null);
102
+ // What the delete confirmation dialog is asking about - one task (a row's delete button), or every
103
+ // checked task (the toolbar's Delete).
104
+ const [pendingDelete, setPendingDelete] = useState<{ type: "single"; task: Task } | { type: "bulk" } | null>(null);
105
+
106
+ function reload(): Promise<void> {
107
+ if (!folderUid) {
108
+ setTasks([]);
109
+ setTruncated(false);
110
+ setLoading(false);
111
+ return Promise.resolve();
112
+ }
113
+ setLoading(true);
114
+ setError(null);
115
+ return listAllPages((page) => listTasks(folderUid, { limit: LIST_PAGE_SIZE, page }))
116
+ .then((result) => {
117
+ setTasks(result.items);
118
+ setTruncated(result.truncated);
119
+ })
120
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load tasks."))
121
+ .finally(() => setLoading(false));
122
+ }
123
+
124
+ useEffect(() => {
125
+ void reload();
126
+ }, [folderUid]);
127
+
128
+ useEffect(() => {
129
+ setTargetMailboxUid(undefined);
130
+ setAddedElsewhere(null);
131
+ }, [mailboxUid]);
132
+
133
+ useEffect(() => {
134
+ if (view.type !== "flagged" || !mailboxUid) {
135
+ return;
136
+ }
137
+ setFlaggedLoading(true);
138
+ setFlaggedError(null);
139
+ listFlaggedMessages(mailboxUid)
140
+ .then(setFlaggedMessages)
141
+ .catch((err) => setFlaggedError(err instanceof ApiRequestError ? err.message : "Could not load flagged email."))
142
+ .finally(() => setFlaggedLoading(false));
143
+ }, [view, mailboxUid]);
144
+
145
+ async function handleCreate(e: FormEvent) {
146
+ e.preventDefault();
147
+ setCreateError(null);
148
+ if (!title.trim()) {
149
+ setCreateError("A title is required.");
150
+ return;
151
+ }
152
+ if (!mailboxUid || !folderUid) {
153
+ return;
154
+ }
155
+ const target = targetMailboxUid ?? mailboxUid;
156
+ setCreating(true);
157
+ try {
158
+ const targetFolderUid = target === mailboxUid ? folderUid : await findWellKnownFolderUid(target, "tasks");
159
+ if (!targetFolderUid) {
160
+ setCreateError("That mailbox has no Tasks folder.");
161
+ return;
162
+ }
163
+ const created = await createTask({
164
+ mailboxUid: target,
165
+ folderUid: targetFolderUid,
166
+ title: title.trim(),
167
+ // The date input's "yyyy-MM-dd" is the user's *local* day - see `parseDueDate()`.
168
+ dueDate: dueDate ? parseDueDate(dueDate).toISOString() : undefined,
169
+ priority,
170
+ });
171
+ if (target === mailboxUid) {
172
+ setAddedElsewhere(null);
173
+ setTasks((prev) => [...prev, created]);
174
+ } else {
175
+ // Created in another mailbox - it doesn't belong in this mailbox's list.
176
+ setAddedElsewhere({ mailboxUid: target, title: created.title });
177
+ }
178
+ setTitle("");
179
+ setDueDate("");
180
+ setPriority("normal");
181
+ } catch (err) {
182
+ setCreateError(err instanceof ApiRequestError ? err.message : "Could not create this task.");
183
+ } finally {
184
+ setCreating(false);
185
+ }
186
+ }
187
+
188
+ async function handleToggle(task: Task) {
189
+ setError(null);
190
+ const nextCompleted = !task.completed;
191
+ setTasks((prev) => prev.map((t) => (t.uid === task.uid ? { ...t, completed: nextCompleted } : t)));
192
+ try {
193
+ const updated = await setTaskCompleted(task, nextCompleted);
194
+ setTasks((prev) => prev.map((t) => (t.uid === task.uid ? updated : t)));
195
+ } catch (err) {
196
+ setTasks((prev) => prev.map((t) => (t.uid === task.uid ? task : t)));
197
+ setError(err instanceof ApiRequestError ? err.message : "Could not update this task.");
198
+ }
199
+ }
200
+
201
+ async function handleDelete(task: Task) {
202
+ setError(null);
203
+ try {
204
+ await deleteTask(task.uid, task.version);
205
+ setTasks((prev) => prev.filter((t) => t.uid !== task.uid));
206
+ } catch (err) {
207
+ setError(err instanceof ApiRequestError ? err.message : "Could not delete this task.");
208
+ }
209
+ }
210
+
211
+ function handleSelectView(next: TasksView) {
212
+ setView(next);
213
+ setCheckedUids(new Set());
214
+ }
215
+
216
+ function toggleChecked(uid: string) {
217
+ setCheckedUids((prev) => {
218
+ const next = new Set(prev);
219
+ if (next.has(uid)) {
220
+ next.delete(uid);
221
+ } else {
222
+ next.add(uid);
223
+ }
224
+ return next;
225
+ });
226
+ }
227
+
228
+ const viewFiltered = useMemo(() => {
229
+ switch (view.type) {
230
+ case "myDay":
231
+ return tasks.filter((t) => t.myDay);
232
+ case "important":
233
+ return tasks.filter((t) => t.priority === "high");
234
+ case "planned":
235
+ return tasks.filter((t) => t.dueDate != null);
236
+ case "assignedToMe":
237
+ return tasks.filter((t) => t.assignedTo === userUid);
238
+ case "list":
239
+ return tasks.filter((t) => t.taskListUid === view.uid);
240
+ case "flagged":
241
+ return [];
242
+ case "all":
243
+ return tasks;
244
+ }
245
+ }, [tasks, view, userUid]);
246
+
247
+ const checkedTasks = viewFiltered.filter((t) => checkedUids.has(t.uid));
248
+
249
+ async function handleBulkComplete() {
250
+ let bulkError: string | null = null;
251
+ for (const task of checkedTasks) {
252
+ try {
253
+ await setTaskCompleted(task, true);
254
+ } catch (err) {
255
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more tasks.";
256
+ }
257
+ }
258
+ setCheckedUids(new Set());
259
+ await reload();
260
+ setError(bulkError);
261
+ }
262
+
263
+ async function handleBulkAddToMyDay() {
264
+ let bulkError: string | null = null;
265
+ for (const task of checkedTasks) {
266
+ try {
267
+ await setTaskMyDay(task, true);
268
+ } catch (err) {
269
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more tasks.";
270
+ }
271
+ }
272
+ setCheckedUids(new Set());
273
+ await reload();
274
+ setError(bulkError);
275
+ }
276
+
277
+ async function handleBulkDelete() {
278
+ let bulkError: string | null = null;
279
+ for (const task of checkedTasks) {
280
+ try {
281
+ await deleteTask(task.uid, task.version);
282
+ } catch (err) {
283
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more tasks.";
284
+ }
285
+ }
286
+ setCheckedUids(new Set());
287
+ await reload();
288
+ setError(bulkError);
289
+ }
290
+
291
+ function requestDelete(task: Task) {
292
+ setPendingDelete({ type: "single", task });
293
+ }
294
+
295
+ function handleConfirmDelete() {
296
+ const pending = pendingDelete!;
297
+ setPendingDelete(null);
298
+ if (pending.type === "single") {
299
+ void handleDelete(pending.task);
300
+ } else {
301
+ void handleBulkDelete();
302
+ }
303
+ }
304
+
305
+ const grouped = useMemo(() => {
306
+ const now = new Date();
307
+ const map = new Map<Bucket, Task[]>(BUCKET_ORDER.map((b) => [b, []]));
308
+ for (const task of viewFiltered) {
309
+ if (task.completed) {
310
+ continue;
311
+ }
312
+ map.get(bucketFor(task, now))!.push(task);
313
+ }
314
+ return map;
315
+ }, [viewFiltered]);
316
+
317
+ const completedTasks = viewFiltered.filter((t) => t.completed);
318
+
319
+ return (
320
+ <div className="flex-1 flex min-h-0">
321
+ <TasksSidebar mailboxUid={mailboxUid} tasks={tasks} userUid={userUid} active={view} onSelect={handleSelectView} />
322
+ <div className="flex-1 min-w-0 flex flex-col overflow-y-auto">
323
+ <TasksToolbar
324
+ viewMode={viewMode}
325
+ onViewModeChange={setViewMode}
326
+ selectedCount={checkedTasks.length}
327
+ onComplete={handleBulkComplete}
328
+ onDelete={() => setPendingDelete({ type: "bulk" })}
329
+ onAddToMyDay={handleBulkAddToMyDay}
330
+ />
331
+ <div role="region" aria-label="Tasks list" className="max-w-2xl mx-auto w-full flex flex-col gap-6 p-6">
332
+ <h1 className="text-xl font-bold uppercase tracking-wide">Tasks</h1>
333
+
334
+ {error && <Alert>{error}</Alert>}
335
+ {truncated && view.type !== "flagged" && (
336
+ <Alert>
337
+ This folder has more tasks than can be shown at once - only the first {LIST_PAGE_SIZE * MAX_LIST_PAGES} are listed.
338
+ </Alert>
339
+ )}
340
+
341
+ {view.type === "flagged" ? (
342
+ <FlaggedEmailList loading={flaggedLoading} error={flaggedError} messages={flaggedMessages} />
343
+ ) : (
344
+ <>
345
+ <form onSubmit={handleCreate} className="flex items-center gap-2 bg-surface border border-border rounded-md p-2">
346
+ <input
347
+ type="text"
348
+ aria-label="Add a task"
349
+ className={`${INPUT_CLASS} flex-1`}
350
+ value={title}
351
+ onChange={(e) => setTitle(e.target.value)}
352
+ placeholder="Add a task"
353
+ />
354
+ <input
355
+ type="date"
356
+ aria-label="Due date"
357
+ className={INPUT_CLASS}
358
+ value={dueDate}
359
+ onChange={(e) => setDueDate(e.target.value)}
360
+ />
361
+ <select
362
+ aria-label="Priority"
363
+ className={INPUT_CLASS}
364
+ value={priority}
365
+ onChange={(e) => setPriority(e.target.value as TaskPriority)}
366
+ >
367
+ <option value="low">Low</option>
368
+ <option value="normal">Normal</option>
369
+ <option value="high">High</option>
370
+ </select>
371
+ {writableMailboxes.length > 1 && (
372
+ <select
373
+ aria-label="Task mailbox"
374
+ className={`${INPUT_CLASS} max-w-40`}
375
+ // `writableMailboxes` only has entries once `mailboxUid` has resolved.
376
+ value={targetMailboxUid ?? mailboxUid}
377
+ onChange={(e) => setTargetMailboxUid(e.target.value)}
378
+ >
379
+ {writableMailboxes.map((mb) => (
380
+ <option key={mb.uid} value={mb.uid}>
381
+ {mb.displayName}
382
+ {mb.ownerUserUid === userUid ? "" : " (shared)"}
383
+ </option>
384
+ ))}
385
+ </select>
386
+ )}
387
+ <Button type="submit" loading={creating} disabled={creating} className="!w-auto shrink-0">
388
+ Add
389
+ </Button>
390
+ </form>
391
+ {createError && <Alert>{createError}</Alert>}
392
+ {addedElsewhere && (
393
+ <p role="status" className="text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
394
+ &ldquo;{addedElsewhere.title}&rdquo; was added to{" "}
395
+ {mailboxes.find((mb) => mb.uid === addedElsewhere.mailboxUid)?.displayName ?? "another mailbox"}.{" "}
396
+ <a
397
+ href={`/tasks?mailboxUid=${encodeURIComponent(addedElsewhere.mailboxUid)}`}
398
+ className="font-medium text-primary-dark hover:underline"
399
+ >
400
+ View that mailbox&rsquo;s tasks
401
+ </a>
402
+ </p>
403
+ )}
404
+
405
+ {loading ? (
406
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
407
+ ) : viewFiltered.length === 0 ? (
408
+ <p className="text-sm text-text-muted">No tasks yet.</p>
409
+ ) : viewMode === "grid" ? (
410
+ <TaskTable
411
+ tasks={viewFiltered}
412
+ checkedUids={checkedUids}
413
+ onToggleChecked={toggleChecked}
414
+ onToggle={handleToggle}
415
+ onDelete={requestDelete}
416
+ />
417
+ ) : (
418
+ <>
419
+ {BUCKET_ORDER.map((bucket) => {
420
+ const items = grouped.get(bucket)!;
421
+ if (items.length === 0) {
422
+ return null;
423
+ }
424
+ return (
425
+ <TaskGroup
426
+ key={bucket}
427
+ label={bucket}
428
+ tasks={items}
429
+ checkedUids={checkedUids}
430
+ onToggleChecked={toggleChecked}
431
+ onToggle={handleToggle}
432
+ onDelete={requestDelete}
433
+ />
434
+ );
435
+ })}
436
+ {completedTasks.length > 0 && (
437
+ <TaskGroup
438
+ label="Completed"
439
+ tasks={completedTasks}
440
+ checkedUids={checkedUids}
441
+ onToggleChecked={toggleChecked}
442
+ onToggle={handleToggle}
443
+ onDelete={requestDelete}
444
+ />
445
+ )}
446
+ </>
447
+ )}
448
+ </>
449
+ )}
450
+ </div>
451
+ </div>
452
+ <Modal open={pendingDelete !== null} onClose={() => setPendingDelete(null)} title="Delete tasks">
453
+ <p className="text-sm mb-5">
454
+ {pendingDelete?.type === "single"
455
+ ? `Delete "${pendingDelete.task.title}"?`
456
+ : `Delete ${checkedTasks.length} selected ${checkedTasks.length === 1 ? "task" : "tasks"}?`}
457
+ </p>
458
+ <div className="flex gap-3 justify-end">
459
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setPendingDelete(null)}>
460
+ Cancel
461
+ </Button>
462
+ <Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={handleConfirmDelete}>
463
+ Delete
464
+ </Button>
465
+ </div>
466
+ </Modal>
467
+ </div>
468
+ );
469
+ }
470
+
471
+ function FlaggedEmailList({ loading, error, messages }: { loading: boolean; error: string | null; messages: Message[] }) {
472
+ if (error) {
473
+ return <Alert>{error}</Alert>;
474
+ }
475
+ if (loading) {
476
+ return <p className="text-sm text-text-muted">Loading&hellip;</p>;
477
+ }
478
+ if (messages.length === 0) {
479
+ return <p className="text-sm text-text-muted">No flagged email.</p>;
480
+ }
481
+ return (
482
+ <ul className="bg-surface border border-border rounded-md divide-y divide-border">
483
+ {messages.map((m) => (
484
+ <li key={m.uid} className="flex items-center gap-3 py-2.5 px-3 text-sm">
485
+ <span className="flex-1 truncate">{m.subject || "(no subject)"}</span>
486
+ <span className="text-xs text-text-muted shrink-0">{m.from.address}</span>
487
+ <span className="text-xs text-text-muted shrink-0">{new Date(m.receivedDate).toLocaleDateString()}</span>
488
+ </li>
489
+ ))}
490
+ </ul>
491
+ );
492
+ }
493
+
494
+ /** A circular completion-toggle control matching Outlook's visual language (not a native checkbox), while
495
+ * staying a real accessible toggle. */
496
+ function CompletionToggle({ task, onToggle }: { task: Task; onToggle: (task: Task) => void }) {
497
+ return (
498
+ <button
499
+ type="button"
500
+ role="checkbox"
501
+ aria-checked={task.completed}
502
+ aria-label={`Mark "${task.title}" as ${task.completed ? "not complete" : "complete"}`}
503
+ onClick={() => onToggle(task)}
504
+ className={[
505
+ "w-4 h-4 rounded-full border shrink-0 flex items-center justify-center text-[10px]",
506
+ task.completed ? "bg-primary border-primary text-white" : "border-border text-transparent",
507
+ ].join(" ")}
508
+ >
509
+ &#10003;
510
+ </button>
511
+ );
512
+ }
513
+
514
+ interface TaskGroupProps {
515
+ label: string;
516
+ tasks: Task[];
517
+ checkedUids: Set<string>;
518
+ onToggleChecked: (uid: string) => void;
519
+ onToggle: (task: Task) => void;
520
+ onDelete: (task: Task) => void;
521
+ }
522
+
523
+ function TaskGroup({ label, tasks, checkedUids, onToggleChecked, onToggle, onDelete }: TaskGroupProps) {
524
+ return (
525
+ <div>
526
+ <h2 className="text-xs font-bold uppercase tracking-wide text-text-muted mb-2">{label}</h2>
527
+ <ul className="bg-surface border border-border rounded-md divide-y divide-border">
528
+ {tasks.map((task) => (
529
+ <li key={task.uid} className="flex items-center gap-3 py-2.5 px-3">
530
+ <input
531
+ type="checkbox"
532
+ aria-label={`Select ${task.title}`}
533
+ checked={checkedUids.has(task.uid)}
534
+ onChange={() => onToggleChecked(task.uid)}
535
+ />
536
+ <CompletionToggle task={task} onToggle={onToggle} />
537
+ <span className={["flex-1 text-sm", task.completed ? "line-through text-text-muted" : ""].join(" ")}>
538
+ {task.title}
539
+ </span>
540
+ {task.priority !== "normal" && (
541
+ <span className={["text-xs font-medium", PRIORITY_CLASS[task.priority]].join(" ")}>
542
+ {PRIORITY_LABEL[task.priority]}
543
+ </span>
544
+ )}
545
+ {task.dueDate && (
546
+ <span className="text-xs text-text-muted shrink-0">{parseDueDate(task.dueDate).toLocaleDateString()}</span>
547
+ )}
548
+ <button
549
+ type="button"
550
+ onClick={() => onDelete(task)}
551
+ aria-label={`Delete "${task.title}"`}
552
+ className="text-text-muted hover:text-danger text-sm px-1"
553
+ >
554
+ &times;
555
+ </button>
556
+ </li>
557
+ ))}
558
+ </ul>
559
+ </div>
560
+ );
561
+ }
562
+
563
+ interface TaskTableProps {
564
+ tasks: Task[];
565
+ checkedUids: Set<string>;
566
+ onToggleChecked: (uid: string) => void;
567
+ onToggle: (task: Task) => void;
568
+ onDelete: (task: Task) => void;
569
+ }
570
+
571
+ function TaskTable({ tasks, checkedUids, onToggleChecked, onToggle, onDelete }: TaskTableProps) {
572
+ const allChecked = tasks.length > 0 && tasks.every((t) => checkedUids.has(t.uid));
573
+
574
+ function toggleAll() {
575
+ if (allChecked) {
576
+ // Every task is already checked when this branch runs (that's what `allChecked` means) — no
577
+ // per-task guard needed, unlike the `else` branch below where only *some* tasks may be checked.
578
+ for (const t of tasks) {
579
+ onToggleChecked(t.uid);
580
+ }
581
+ } else {
582
+ for (const t of tasks) {
583
+ if (!checkedUids.has(t.uid)) {
584
+ onToggleChecked(t.uid);
585
+ }
586
+ }
587
+ }
588
+ }
589
+
590
+ return (
591
+ <div className="overflow-x-auto">
592
+ <table className="w-full text-sm bg-surface border border-border rounded-md">
593
+ <thead>
594
+ <tr className="border-b border-border text-left">
595
+ <th className="w-8 px-3 py-2">
596
+ <input type="checkbox" aria-label="Select all tasks" checked={allChecked} onChange={toggleAll} />
597
+ </th>
598
+ <th className="w-8 px-3 py-2" />
599
+ <th className="px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted">Title</th>
600
+ <th className="px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted">Due Date</th>
601
+ <th className="px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted">Importance</th>
602
+ <th className="w-8 px-3 py-2" />
603
+ </tr>
604
+ </thead>
605
+ <tbody>
606
+ {tasks.map((task) => (
607
+ <tr key={task.uid} className="border-b border-border last:border-0">
608
+ <td className="px-3 py-2">
609
+ <input
610
+ type="checkbox"
611
+ aria-label={`Select ${task.title}`}
612
+ checked={checkedUids.has(task.uid)}
613
+ onChange={() => onToggleChecked(task.uid)}
614
+ />
615
+ </td>
616
+ <td className="px-3 py-2">
617
+ <CompletionToggle task={task} onToggle={onToggle} />
618
+ </td>
619
+ <td className={["px-3 py-2", task.completed ? "line-through text-text-muted" : ""].join(" ")}>{task.title}</td>
620
+ <td className="px-3 py-2 text-text-muted">{task.dueDate ? parseDueDate(task.dueDate).toLocaleDateString() : ""}</td>
621
+ <td className={["px-3 py-2", PRIORITY_CLASS[task.priority]].join(" ")}>{PRIORITY_LABEL[task.priority]}</td>
622
+ <td className="px-3 py-2">
623
+ <button
624
+ type="button"
625
+ onClick={() => onDelete(task)}
626
+ aria-label={`Delete "${task.title}"`}
627
+ className="text-text-muted hover:text-danger text-sm px-1"
628
+ >
629
+ &times;
630
+ </button>
631
+ </td>
632
+ </tr>
633
+ ))}
634
+ </tbody>
635
+ </table>
636
+ </div>
637
+ );
638
+ }