@rapidmx/web-client 0.28.0 → 0.29.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 (119) hide show
  1. package/README.md +3 -0
  2. package/apps/shared/components/admin/diagnostics/DiagnosticsManager.tsx +22 -10
  3. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +60 -0
  4. package/apps/shared/components/admin/diagnostics/SettingsTable.tsx +108 -0
  5. package/apps/shared/components/admin/diagnostics/diagnosticsApi.ts +25 -1
  6. package/apps/shared/components/calendar/CalendarSearchBox.tsx +83 -0
  7. package/apps/shared/components/calendar/EventMatchNav.tsx +72 -0
  8. package/apps/shared/components/calendar/EventModal.tsx +33 -12
  9. package/apps/shared/components/calendar/ListView.tsx +207 -0
  10. package/apps/shared/components/calendar/MonthView.tsx +14 -2
  11. package/apps/shared/components/calendar/SlideIn.tsx +47 -0
  12. package/apps/shared/components/calendar/SplitDayView.tsx +5 -0
  13. package/apps/shared/components/calendar/TimeGridView.tsx +23 -12
  14. package/apps/shared/components/calendar/activeOccurrence.ts +28 -0
  15. package/apps/shared/components/calendar/allDay.ts +16 -1
  16. package/apps/shared/components/calendar/calendarView.ts +41 -0
  17. package/apps/shared/components/layout/AppShell.tsx +13 -6
  18. package/apps/shared/components/mail/ConversationList.tsx +29 -3
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +15 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +117 -55
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +3 -0
  22. package/apps/shared/components/mail/compose/ComposeContext.tsx +193 -35
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +62 -11
  24. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +21 -10
  25. package/apps/shared/components/mail/compose/RichTextEditor.tsx +2 -1
  26. package/apps/shared/components/mail/compose/quotedBody.ts +45 -2
  27. package/apps/shared/components/mail/labelMenu.tsx +4 -0
  28. package/apps/shared/components/mail/layout/MailShell.tsx +9 -2
  29. package/apps/shared/components/mail/reading/MessageBody.tsx +1 -1
  30. package/apps/shared/components/mail/reading/bodyContent.ts +15 -13
  31. package/apps/shared/components/mail/reading/bodyHtml.ts +12 -7
  32. package/apps/shared/gestures/useEnterSlide.ts +1 -1
  33. package/apps/shared/keyboard/keymap.ts +2 -0
  34. package/apps/shared/mail/rangeSelection.ts +45 -0
  35. package/apps/shared/mail/rowExit.ts +39 -0
  36. package/apps/www/calendar/index.tsx +251 -53
  37. package/apps/www/index.tsx +522 -343
  38. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.d.ts +3 -3
  39. package/dist/apps/shared/components/admin/diagnostics/DiagnosticsManager.js +15 -9
  40. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.d.ts +15 -0
  41. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +16 -0
  42. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.d.ts +20 -0
  43. package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +31 -0
  44. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.d.ts +20 -1
  45. package/dist/apps/shared/components/admin/diagnostics/diagnosticsApi.js +7 -0
  46. package/dist/apps/shared/components/calendar/CalendarSearchBox.d.ts +22 -0
  47. package/dist/apps/shared/components/calendar/CalendarSearchBox.js +24 -0
  48. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +17 -0
  49. package/dist/apps/shared/components/calendar/EventMatchNav.js +37 -0
  50. package/dist/apps/shared/components/calendar/EventModal.d.ts +17 -1
  51. package/dist/apps/shared/components/calendar/EventModal.js +13 -11
  52. package/dist/apps/shared/components/calendar/ListView.d.ts +30 -0
  53. package/dist/apps/shared/components/calendar/ListView.js +111 -0
  54. package/dist/apps/shared/components/calendar/MonthView.js +12 -3
  55. package/dist/apps/shared/components/calendar/SlideIn.d.ts +16 -0
  56. package/dist/apps/shared/components/calendar/SlideIn.js +32 -0
  57. package/dist/apps/shared/components/calendar/SplitDayView.js +5 -1
  58. package/dist/apps/shared/components/calendar/TimeGridView.js +11 -3
  59. package/dist/apps/shared/components/calendar/activeOccurrence.d.ts +15 -0
  60. package/dist/apps/shared/components/calendar/activeOccurrence.js +21 -0
  61. package/dist/apps/shared/components/calendar/allDay.d.ts +4 -0
  62. package/dist/apps/shared/components/calendar/allDay.js +14 -1
  63. package/dist/apps/shared/components/calendar/calendarView.d.ts +16 -0
  64. package/dist/apps/shared/components/calendar/calendarView.js +36 -0
  65. package/dist/apps/shared/components/layout/AppShell.js +8 -4
  66. package/dist/apps/shared/components/mail/ConversationList.d.ts +9 -1
  67. package/dist/apps/shared/components/mail/ConversationList.js +10 -2
  68. package/dist/apps/shared/components/mail/ConversationThreadPane.js +9 -1
  69. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -4
  70. package/dist/apps/shared/components/mail/MessageDetailPane.js +3 -0
  71. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +31 -0
  72. package/dist/apps/shared/components/mail/compose/ComposeContext.js +123 -11
  73. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +6 -1
  74. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +37 -11
  75. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +5 -1
  76. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +10 -6
  77. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +2 -1
  78. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +3 -0
  79. package/dist/apps/shared/components/mail/compose/quotedBody.js +43 -1
  80. package/dist/apps/shared/components/mail/labelMenu.d.ts +3 -1
  81. package/dist/apps/shared/components/mail/labelMenu.js +2 -2
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +7 -1
  83. package/dist/apps/shared/components/mail/reading/MessageBody.js +1 -1
  84. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +5 -1
  85. package/dist/apps/shared/components/mail/reading/bodyContent.js +11 -11
  86. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +3 -2
  87. package/dist/apps/shared/components/mail/reading/bodyHtml.js +8 -4
  88. package/dist/apps/shared/gestures/useEnterSlide.d.ts +2 -0
  89. package/dist/apps/shared/gestures/useEnterSlide.js +1 -1
  90. package/dist/apps/shared/keyboard/keymap.d.ts +12 -0
  91. package/dist/apps/shared/keyboard/keymap.js +2 -0
  92. package/dist/apps/shared/mail/rangeSelection.d.ts +20 -0
  93. package/dist/apps/shared/mail/rangeSelection.js +34 -0
  94. package/dist/apps/shared/mail/rowExit.d.ts +12 -0
  95. package/dist/apps/shared/mail/rowExit.js +33 -0
  96. package/dist/apps/www/calendar/index.js +177 -18
  97. package/dist/apps/www/index.js +220 -65
  98. package/dist/lib/calendar/eventSearch.d.ts +42 -0
  99. package/dist/lib/calendar/eventSearch.js +152 -0
  100. package/dist/lib/components/navigation/BottomTabBar.d.ts +15 -3
  101. package/dist/lib/components/navigation/BottomTabBar.js +52 -15
  102. package/dist/lib/components/navigation/NavBadge.d.ts +13 -0
  103. package/dist/lib/components/navigation/NavBadge.js +18 -0
  104. package/dist/lib/mail/compose/quotedImages.d.ts +21 -0
  105. package/dist/lib/mail/compose/quotedImages.js +41 -0
  106. package/dist/lib/mail/inlineImages.d.ts +38 -0
  107. package/dist/lib/mail/inlineImages.js +90 -0
  108. package/dist/lib/mail/mailApi.d.ts +8 -1
  109. package/dist/lib/mail/mailApi.js +57 -5
  110. package/lib/calendar/eventSearch.ts +172 -0
  111. package/lib/components/navigation/BottomTabBar.tsx +107 -22
  112. package/lib/components/navigation/NavBadge.tsx +33 -0
  113. package/lib/mail/compose/quotedImages.ts +49 -0
  114. package/lib/mail/inlineImages.ts +104 -0
  115. package/lib/mail/mailApi.ts +64 -5
  116. package/package.json +1 -1
  117. package/apps/shared/components/admin/diagnostics/VersionsPanel.tsx +0 -36
  118. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.d.ts +0 -10
  119. package/dist/apps/shared/components/admin/diagnostics/VersionsPanel.js +0 -11
@@ -0,0 +1,207 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useEffect, useMemo, useRef } from "react";
6
+ import { addDays, format, isToday, parseISO, startOfDay } from "date-fns";
7
+ import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
8
+ import { allDayDateKey, localDateKey, startsOnDay } from "./allDay.js";
9
+ import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
10
+
11
+ /** The most days one event is listed on, so an event that runs for years does not become thousands of rows. */
12
+ export const LIST_MAX_DAYS_PER_EVENT = 366;
13
+
14
+ /** A step through the list's months: `nonce` makes a second press in the same direction a new step. */
15
+ export interface ListStep {
16
+ direction: 1 | -1;
17
+ nonce: number;
18
+ }
19
+
20
+ export interface ListViewProps {
21
+ /** Every occurrence to list (or, while a search runs, only its matches) - see `eventSearch.ts`'s `allOccurrences()`. Only today and later is shown. */
22
+ occurrences: CalendarOccurrence[];
23
+ /** Each occurrence's own calendar color, keyed by `folderUid` - see `calendarColors.ts`. */
24
+ folderColors: Record<string, string>;
25
+ onSelectEvent: (occurrence: CalendarOccurrence) => void;
26
+ /** The day the list scrolls to (the first day from it that has events - the top for one in the past -, the last day when none does) on opening and whenever `jumpNonce` changes. */
27
+ focusDate: Date;
28
+ jumpNonce: number;
29
+ /** Scrolls to the first day of the previous or next month (from the one at the top) that has events. */
30
+ step: ListStep | null;
31
+ }
32
+
33
+ /** When an occurrence is on show on `day`: its time range, "All day", or - on a later day of a multi-day event - that it continues. */
34
+ function timeLabel(occurrence: CalendarOccurrence, day: Date): string {
35
+ if (occurrence.allDay) {
36
+ return "All day";
37
+ }
38
+ if (!startsOnDay(occurrence, day)) {
39
+ return "Continues";
40
+ }
41
+ const start = new Date(occurrence.startDate);
42
+ const end = new Date(occurrence.endDate);
43
+ return start.getTime() === end.getTime() ? format(start, "h:mma") : `${format(start, "h:mma")} – ${format(end, "h:mma")}`;
44
+ }
45
+
46
+ /** The days (local midnights) `occurrence` is on show on: from its first to its last, at most `LIST_MAX_DAYS_PER_EVENT`. */
47
+ function daysOf(occurrence: CalendarOccurrence): Date[] {
48
+ let first: Date;
49
+ let last: Date;
50
+ if (occurrence.allDay) {
51
+ // Date-only, the end exclusive (see `allDay.ts`), and at least the start day.
52
+ first = parseISO(allDayDateKey(occurrence.startDate));
53
+ last = addDays(parseISO(allDayDateKey(occurrence.endDate)), -1);
54
+ } else {
55
+ const start = new Date(occurrence.startDate);
56
+ const end = new Date(occurrence.endDate);
57
+ first = startOfDay(start);
58
+ // An event that ends at midnight does not reach into that day.
59
+ last = startOfDay(end > start ? new Date(end.getTime() - 1) : start);
60
+ }
61
+ const days = [first];
62
+ for (let day = addDays(first, 1); day <= last && days.length < LIST_MAX_DAYS_PER_EVENT; day = addDays(day, 1)) {
63
+ days.push(day);
64
+ }
65
+ return days;
66
+ }
67
+
68
+ /** The day's rows in the order they are read: the all-day events and those carried over from an earlier day, then the timed ones as they start. */
69
+ function sortedForDay(occurrences: CalendarOccurrence[], day: Date): CalendarOccurrence[] {
70
+ const rank = (occurrence: CalendarOccurrence) => (occurrence.allDay || !startsOnDay(occurrence, day) ? 0 : 1);
71
+ return occurrences.sort((a, b) => rank(a) - rank(b) || new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
72
+ }
73
+
74
+ /**
75
+ * Every event from today on as one scrollable list, in date order, with a heading and a list for each day that has any (days without events are left out, and
76
+ * nothing at all says so). A multi-day event is listed on every day it covers, as the month grid shows it. Each row is a full-width button that
77
+ * opens the event's card, with the time, title, place (or organizer) and its calendar's colour.
78
+ *
79
+ * Every day is rendered (an event is a cheap row, and a series is bounded to 500 occurrences - see `eventSearch.ts`); the days are grouped once per
80
+ * change of `occurrences`, not per render. The list scrolls itself to `focusDate` and, on a `step`, to the neighbouring month.
81
+ */
82
+ export default function ListView({ occurrences, folderColors, onSelectEvent, focusDate, jumpNonce, step }: ListViewProps) {
83
+ const activeKey = useActiveOccurrenceKey();
84
+ const scrollerRef = useRef<HTMLDivElement>(null);
85
+ const groups = useMemo(() => {
86
+ const byDay = new Map<string, { day: Date; items: CalendarOccurrence[] }>();
87
+ const todayKey = localDateKey(new Date());
88
+ for (const occurrence of occurrences) {
89
+ for (const day of daysOf(occurrence)) {
90
+ const key = localDateKey(day);
91
+ // The days an event spent before today are not listed.
92
+ if (key < todayKey) {
93
+ continue;
94
+ }
95
+ const group = byDay.get(key) ?? { day, items: [] };
96
+ group.items.push(occurrence);
97
+ byDay.set(key, group);
98
+ }
99
+ }
100
+ return Array.from(byDay.entries())
101
+ .sort(([a], [b]) => a.localeCompare(b))
102
+ .map(([key, { day, items }]) => ({ key, day, items: sortedForDay(items, day) }));
103
+ }, [occurrences]);
104
+
105
+ const dayElements = () => Array.from(scrollerRef.current!.querySelectorAll<HTMLElement>("[data-day]"));
106
+
107
+ // On opening and on a jump (Today, a date picked in the mini calendar): the first day from `focusDate` that has events, else the last one.
108
+ useEffect(() => {
109
+ const wanted = localDateKey(focusDate);
110
+ const days = dayElements();
111
+ (days.find((el) => el.dataset.day! >= wanted) ?? days[days.length - 1])?.scrollIntoView({ block: "start" });
112
+ }, [jumpNonce]);
113
+
114
+ // A step: from the month of the day at the top of the list to the first day of the previous or next month that has any.
115
+ useEffect(() => {
116
+ const days = dayElements();
117
+ if (!step || days.length === 0) {
118
+ return;
119
+ }
120
+ const top = scrollerRef.current!.getBoundingClientRect().top;
121
+ const current = days.find((el) => el.getBoundingClientRect().bottom > top) ?? days[days.length - 1];
122
+ const months = Array.from(new Set(days.map((el) => el.dataset.day!.slice(0, 7))));
123
+ const month = months[months.indexOf(current.dataset.day!.slice(0, 7)) + step.direction];
124
+ // Past the first or last month with events there is nowhere to go.
125
+ days.find((el) => el.dataset.day!.startsWith(month))?.scrollIntoView({ block: "start" });
126
+ }, [step]);
127
+
128
+ const thisYear = new Date().getFullYear();
129
+ return (
130
+ <div ref={scrollerRef} role="region" aria-label="List" data-calendar-scroller className="flex-1 min-h-0 overflow-y-auto">
131
+ {groups.length === 0 ? (
132
+ <p className="p-4 text-sm text-text-muted">No upcoming events</p>
133
+ ) : (
134
+ groups.map(({ key, day, items }) => (
135
+ <section key={key} data-day={key} aria-labelledby={`list-day-${key}`}>
136
+ <h3
137
+ id={`list-day-${key}`}
138
+ // Today is marked, not shouted: the same opaque bar as every day, with a primary edge and a small pill.
139
+ aria-current={isToday(day) ? "date" : undefined}
140
+ className={[
141
+ "sticky top-0 z-10 px-4 py-2 text-sm font-semibold border-b border-border bg-surface-alt text-text border-l-4",
142
+ isToday(day) ? "border-l-primary" : "border-l-transparent",
143
+ ].join(" ")}
144
+ >
145
+ {format(day, day.getFullYear() === thisYear ? "EEEE, MMMM d" : "EEEE, MMMM d, yyyy")}
146
+ {isToday(day) && (
147
+ <span aria-hidden="true" className="ml-2 px-1.5 py-0.5 rounded-full bg-primary/15 text-primary-dark text-xs font-medium">
148
+ Today
149
+ </span>
150
+ )}
151
+ </h3>
152
+ <ul>
153
+ {items.map((occurrence) => (
154
+ <ListRow
155
+ key={occurrence.occurrenceKey}
156
+ occurrence={occurrence}
157
+ day={day}
158
+ color={folderColors[occurrence.folderUid]}
159
+ onSelect={onSelectEvent}
160
+ activeKey={activeKey}
161
+ />
162
+ ))}
163
+ </ul>
164
+ </section>
165
+ ))
166
+ )}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ function ListRow({
172
+ occurrence,
173
+ day,
174
+ color,
175
+ onSelect,
176
+ activeKey,
177
+ }: {
178
+ occurrence: CalendarOccurrence;
179
+ day: Date;
180
+ color: string;
181
+ onSelect: (occurrence: CalendarOccurrence) => void;
182
+ activeKey: string | null;
183
+ }) {
184
+ const marker = occurrenceMarker(activeKey, occurrence);
185
+ const place = occurrence.location || occurrence.organizer.displayName || occurrence.organizer.address;
186
+ return (
187
+ <li className="border-b border-border/50">
188
+ <button
189
+ type="button"
190
+ {...marker.attrs}
191
+ onClick={() => onSelect(occurrence)}
192
+ className={["w-full min-h-14 px-4 py-2 flex items-center gap-3 text-left hover:bg-surface-alt", marker.className].join(" ")}
193
+ >
194
+ <span className="w-28 shrink-0 text-xs text-text-muted">{timeLabel(occurrence, day)}</span>
195
+ <span
196
+ aria-hidden="true"
197
+ className={["w-3 h-3 rounded-full shrink-0", occurrence.busyStatus === "free" ? "border-2" : ""].join(" ")}
198
+ style={occurrence.busyStatus === "free" ? { borderColor: color } : { backgroundColor: color }}
199
+ />
200
+ <span className="flex-1 min-w-0">
201
+ <span className="block text-sm font-medium truncate">{occurrence.title}</span>
202
+ <span className="block text-xs text-text-muted truncate">{place}</span>
203
+ </span>
204
+ </button>
205
+ </li>
206
+ );
207
+ }
@@ -8,6 +8,7 @@ import { addDays, eachDayOfInterval, endOfMonth, endOfWeek, format, isSameMonth,
8
8
  import { dayDropId, eventDragId } from "../../../../lib/calendar/calendarDragIds.js";
9
9
  import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
10
10
  import { occursOnDay, startsOnDay } from "./allDay.js";
11
+ import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
11
12
  import { EventAnchor, anchorOf } from "./EventShell.js";
12
13
 
13
14
  const MAX_CHIPS_PER_DAY = 3;
@@ -63,7 +64,13 @@ interface DayCellProps {
63
64
 
64
65
  function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }: DayCellProps) {
65
66
  const { setNodeRef, isOver } = useDroppable({ id: dayDropId(day) });
66
- const visible = occurrences.slice(0, MAX_CHIPS_PER_DAY);
67
+ const activeKey = useActiveOccurrenceKey();
68
+ // The search's current result is always shown, even when it would be one of the "+N more".
69
+ const activeIndex = occurrences.findIndex((occ) => occ.occurrenceKey === activeKey);
70
+ const visible =
71
+ activeIndex >= MAX_CHIPS_PER_DAY
72
+ ? [...occurrences.slice(0, MAX_CHIPS_PER_DAY - 1), occurrences[activeIndex]]
73
+ : occurrences.slice(0, MAX_CHIPS_PER_DAY);
67
74
  const overflowCount = occurrences.length - visible.length;
68
75
 
69
76
  return (
@@ -136,10 +143,12 @@ function EventChip({
136
143
  }) {
137
144
  const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
138
145
  const isFree = occurrence.busyStatus === "free";
146
+ const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
139
147
 
140
148
  return (
141
149
  <button
142
150
  ref={setNodeRef}
151
+ {...marker.attrs}
143
152
  type="button"
144
153
  onClick={() => onSelect(occurrence)}
145
154
  style={{
@@ -150,6 +159,7 @@ function EventChip({
150
159
  "text-xs text-left truncate rounded-sm px-1.5 py-0.5 shrink-0",
151
160
  isFree ? "bg-surface-alt text-text-muted" : "",
152
161
  isDragging ? "opacity-50" : "",
162
+ marker.className,
153
163
  ].join(" ")}
154
164
  {...listeners}
155
165
  {...attributes}
@@ -172,12 +182,14 @@ function ContinuationChip({
172
182
  onSelect: (occurrence: CalendarOccurrence) => void;
173
183
  }) {
174
184
  const isFree = occurrence.busyStatus === "free";
185
+ const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
175
186
  return (
176
187
  <button
177
188
  type="button"
189
+ {...marker.attrs}
178
190
  onClick={() => onSelect(occurrence)}
179
191
  style={isFree ? undefined : { backgroundColor: color, color: "#fff" }}
180
- className={["text-xs text-left truncate rounded-sm px-1.5 py-0.5 shrink-0", isFree ? "bg-surface-alt text-text-muted" : ""].join(" ")}
192
+ className={["text-xs text-left truncate rounded-sm px-1.5 py-0.5 shrink-0", isFree ? "bg-surface-alt text-text-muted" : "", marker.className].join(" ")}
181
193
  >
182
194
  {occurrence.title}
183
195
  </button>
@@ -0,0 +1,47 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode, useLayoutEffect, useRef } from "react";
6
+ import { prefersReducedMotion } from "../../gestures/useEnterSlide.js";
7
+
8
+ /** The side a card slides in from: the Previous match's comes from the left, the Next match's from the right. */
9
+ export type SlideFrom = "left" | "right";
10
+
11
+ /** How long the card takes to settle, and how far off it starts, in px. */
12
+ export const SLIDE_IN_MS = 220;
13
+ const SLIDE_IN_OFFSET_PX = 48;
14
+
15
+ /**
16
+ * Slides what it holds in from `from` (and fades it in) as it mounts - mount it afresh (a `key`) for each card. Without `from` (the card was just
17
+ * opened, not stepped to) nothing moves. The animation is the Web Animations API's, so nothing is left behind in the styles, and it is skipped
18
+ * for someone who asked for less motion and where the browser has no `Element.animate()`. `data-slide-from` says which way it came, for tests and
19
+ * styling. Its own clip (`overflow-x-clip`, which makes no scroller) keeps the sliding card inside the dialog's edges; the dialog itself, the
20
+ * backdrop and the buttons beside the card are not moved.
21
+ */
22
+ export default function SlideIn({ from, children }: { from?: SlideFrom; children: ReactNode }) {
23
+ const ref = useRef<HTMLDivElement>(null);
24
+
25
+ useLayoutEffect(() => {
26
+ const element = ref.current!;
27
+ if (!from || typeof element.animate !== "function" || prefersReducedMotion()) {
28
+ return;
29
+ }
30
+ const offset = from === "left" ? -SLIDE_IN_OFFSET_PX : SLIDE_IN_OFFSET_PX;
31
+ element.animate(
32
+ [
33
+ { transform: `translateX(${offset}px)`, opacity: 0 },
34
+ { transform: "translateX(0)", opacity: 1 },
35
+ ],
36
+ { duration: SLIDE_IN_MS, easing: "ease-out" },
37
+ );
38
+ }, []);
39
+
40
+ return (
41
+ <div className="overflow-x-clip">
42
+ <div ref={ref} data-slide-from={from}>
43
+ {children}
44
+ </div>
45
+ </div>
46
+ );
47
+ }
@@ -6,6 +6,7 @@ import React from "react";
6
6
  import { addDays, addMinutes, format, startOfDay } from "date-fns";
7
7
  import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
8
8
  import { occursOnDay } from "./allDay.js";
9
+ import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
9
10
  import { EventAnchor, anchorOf } from "./EventShell.js";
10
11
 
11
12
  const HOUR_HEIGHT_PX = 48;
@@ -91,6 +92,7 @@ function SplitColumn({
91
92
  onSelectEvent: (occurrence: CalendarOccurrence) => void;
92
93
  onSelectSlot: (start: Date, end: Date, folderUid: string, anchor: EventAnchor) => void;
93
94
  }) {
95
+ const activeKey = useActiveOccurrenceKey();
94
96
  return (
95
97
  <div className="flex-1 min-w-0 relative border-l border-border">
96
98
  {Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
@@ -117,9 +119,11 @@ function SplitColumn({
117
119
  const top = ((visibleStart - dayStart.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX;
118
120
  const height = Math.max(((visibleEnd - visibleStart) / 60_000 / 60) * HOUR_HEIGHT_PX, 16);
119
121
  const isFree = occurrence.busyStatus === "free";
122
+ const marker = occurrenceMarker(activeKey, occurrence);
120
123
  return (
121
124
  <div
122
125
  key={occurrence.occurrenceKey}
126
+ {...marker.attrs}
123
127
  onClick={() => onSelectEvent(occurrence)}
124
128
  style={{
125
129
  position: "absolute",
@@ -132,6 +136,7 @@ function SplitColumn({
132
136
  className={[
133
137
  "rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
134
138
  isFree ? "bg-surface-alt text-text-muted" : "",
139
+ marker.className,
135
140
  ].join(" ")}
136
141
  >
137
142
  <div className="font-medium truncate">{occurrence.title}</div>
@@ -8,6 +8,7 @@ import { addDays, addMinutes, format, isToday, startOfDay } from "date-fns";
8
8
  import { dayDropId, eventDragId, resizeDragId, slotDropId } from "../../../../lib/calendar/calendarDragIds.js";
9
9
  import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
10
10
  import { occursOnDay, startsOnDay } from "./allDay.js";
11
+ import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
11
12
  import { EventAnchor, anchorOf } from "./EventShell.js";
12
13
 
13
14
  const HOUR_HEIGHT_PX = 48;
@@ -34,6 +35,7 @@ export interface TimeGridViewProps {
34
35
  export default function TimeGridView({ days, occurrences, folderColors, onSelectEvent, onSelectSlot }: TimeGridViewProps) {
35
36
  const allDayEvents = occurrences.filter((occ) => occ.allDay);
36
37
  const timedEvents = occurrences.filter((occ) => !occ.allDay);
38
+ const activeKey = useActiveOccurrenceKey();
37
39
 
38
40
  return (
39
41
  <div data-calendar-scroller className="flex-1 flex flex-col min-h-0 overflow-y-auto">
@@ -55,17 +57,21 @@ export default function TimeGridView({ days, occurrences, folderColors, onSelect
55
57
  <div key={day.toISOString()} className="flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border">
56
58
  {allDayEvents
57
59
  .filter((occ) => occursOnDay(occ, day))
58
- .map((occ) => (
59
- <button
60
- key={occ.occurrenceKey}
61
- type="button"
62
- onClick={() => onSelectEvent(occ)}
63
- style={{ backgroundColor: folderColors[occ.folderUid], color: "#fff" }}
64
- className="text-xs text-left truncate rounded-sm px-1.5 py-0.5"
65
- >
66
- {occ.title}
67
- </button>
68
- ))}
60
+ .map((occ) => {
61
+ const marker = occurrenceMarker(activeKey, occ);
62
+ return (
63
+ <button
64
+ key={occ.occurrenceKey}
65
+ type="button"
66
+ {...marker.attrs}
67
+ onClick={() => onSelectEvent(occ)}
68
+ style={{ backgroundColor: folderColors[occ.folderUid], color: "#fff" }}
69
+ className={["text-xs text-left truncate rounded-sm px-1.5 py-0.5", marker.className].join(" ")}
70
+ >
71
+ {occ.title}
72
+ </button>
73
+ );
74
+ })}
69
75
  </div>
70
76
  ))}
71
77
  </div>
@@ -175,11 +181,13 @@ function ContinuationBlock({
175
181
  }) {
176
182
  const isFree = occurrence.busyStatus === "free";
177
183
  const { top, height } = blockGeometry(occurrence, dayStart);
184
+ const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
178
185
  return (
179
186
  <div
187
+ {...marker.attrs}
180
188
  onClick={() => onSelect(occurrence)}
181
189
  style={{ position: "absolute", top, height, left: 2, right: 2, ...(isFree ? undefined : { backgroundColor: color, color: "#fff" }) }}
182
- className={["rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer", isFree ? "bg-surface-alt text-text-muted" : ""].join(" ")}
190
+ className={["rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer", isFree ? "bg-surface-alt text-text-muted" : "", marker.className].join(" ")}
183
191
  >
184
192
  <div className="font-medium truncate">{occurrence.title}</div>
185
193
  </div>
@@ -207,6 +215,7 @@ function EventBlock({
207
215
 
208
216
  const start = new Date(occurrence.startDate);
209
217
  const { top, height } = blockGeometry(occurrence, dayStart);
218
+ const marker = occurrenceMarker(useActiveOccurrenceKey(), occurrence);
210
219
  // The resize handle's own drag position has no live pixel preview (it only applies its ns-resize
211
220
  // affordance) — the actual new end time is computed from whichever slot it's dropped onto (see
212
221
  // `resolveDragAction`'s "resize" case), snapped to the grid rather than following the pointer
@@ -215,6 +224,7 @@ function EventBlock({
215
224
  return (
216
225
  <div
217
226
  ref={setNodeRef}
227
+ {...marker.attrs}
218
228
  onClick={() => onSelect(occurrence)}
219
229
  style={{
220
230
  position: "absolute",
@@ -230,6 +240,7 @@ function EventBlock({
230
240
  "rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
231
241
  isFree ? "bg-surface-alt text-text-muted" : "",
232
242
  isDragging ? "opacity-50" : "",
243
+ marker.className,
233
244
  ].join(" ")}
234
245
  {...listeners}
235
246
  {...attributes}
@@ -0,0 +1,28 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { createContext, useContext } from "react";
6
+ import { CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
7
+
8
+ /** The `occurrenceKey` of the search result the calendar is pointing at (`null`: none), for the views to mark - a context, so the month, week and split
9
+ * views and their many kinds of chip need no prop threaded through each. */
10
+ export const ActiveOccurrenceContext = createContext<string | null>(null);
11
+
12
+ /** The key of the occurrence the calendar's search is on. */
13
+ export function useActiveOccurrenceKey(): string | null {
14
+ return useContext(ActiveOccurrenceContext);
15
+ }
16
+
17
+ /** What an occurrence's chip or block spreads on itself: `data-occurrence-key` (how the page finds it to scroll to) and, when it is the
18
+ * `activeKey` one, a ring and `data-active-match`. */
19
+ export function occurrenceMarker(
20
+ activeKey: string | null,
21
+ occurrence: CalendarOccurrence,
22
+ ): { attrs: { "data-occurrence-key": string; "data-active-match"?: "true" }; className: string } {
23
+ const active = activeKey === occurrence.occurrenceKey;
24
+ return {
25
+ attrs: { "data-occurrence-key": occurrence.occurrenceKey, ...(active ? { "data-active-match": "true" as const } : undefined) },
26
+ className: active ? "ring-2 ring-text ring-offset-1 ring-offset-surface" : "",
27
+ };
28
+ }
@@ -2,7 +2,7 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { format } from "date-fns";
5
+ import { format, parseISO, startOfDay } from "date-fns";
6
6
  import { WeekdayCode } from "../../../../lib/calendar/calendarApi.js";
7
7
  import { CalendarOccurrence, toEventWallClock } from "../../../../lib/calendar/recurrence.js";
8
8
 
@@ -112,6 +112,21 @@ export function occursOnDay(occurrence: CalendarOccurrence, day: Date): boolean
112
112
  return start < dayEnd && end > dayStart;
113
113
  }
114
114
 
115
+ /** Whether `occurrence` is on show on `today` (a local day) or later: it ends after that day begins, so one still running today counts and one that ended is not. */
116
+ export function isUpcoming(occurrence: CalendarOccurrence, today: Date): boolean {
117
+ if (occurrence.allDay) {
118
+ const key = localDateKey(today);
119
+ return allDayDateKey(occurrence.endDate) > key || allDayDateKey(occurrence.startDate) >= key;
120
+ }
121
+ const dayStart = startOfDay(today);
122
+ return new Date(occurrence.endDate) > dayStart || new Date(occurrence.startDate) >= dayStart;
123
+ }
124
+
125
+ /** The local calendar day (midnight) `occurrence` starts on - the day a view shows to bring it into sight. */
126
+ export function occurrenceDay(occurrence: CalendarOccurrence): Date {
127
+ return occurrence.allDay ? parseISO(allDayDateKey(occurrence.startDate)) : startOfDay(new Date(occurrence.startDate));
128
+ }
129
+
115
130
  /**
116
131
  * Whether `day` is the first day `occurrence` covers. Grid views only make that day's chip/block
117
132
  * draggable — a drag id must be unique, so the continuation copies on later days are display-only.
@@ -0,0 +1,41 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The calendar's views, and the one the reader last chose - kept in `localStorage` so coming back to the Calendar from elsewhere in the app (or
7
+ * reopening it) lands on the same view, the way the mail list remembers its own arrangement (see `listPreferences.ts`). Per device, not synced.
8
+ * A read that finds nothing, something this client doesn't know or a blocked store gives `null`, and a write that fails is swallowed: the
9
+ * choice then only doesn't outlive the page.
10
+ */
11
+
12
+ /** The views, in the order the switcher lists them: the List sits left of the Month. */
13
+ export type CalendarView = "list" | "month" | "week" | "workWeek" | "day" | "split";
14
+ export const CALENDAR_VIEWS: CalendarView[] = ["list", "month", "week", "workWeek", "day", "split"];
15
+
16
+ const STORAGE_KEY = "rapidmx:calendar-view";
17
+
18
+ /** Whether `value` names a view. */
19
+ export function isCalendarView(value: unknown): value is CalendarView {
20
+ return (CALENDAR_VIEWS as unknown[]).includes(value);
21
+ }
22
+
23
+ /** The view last chosen, or `null` when none was (or it cannot be read). The calendar page does not restore a stored `"list"`: that view is the phone's
24
+ * default and is never written by a desktop. */
25
+ export function getStoredCalendarView(): CalendarView | null {
26
+ try {
27
+ const stored = localStorage.getItem(STORAGE_KEY);
28
+ return isCalendarView(stored) ? stored : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /** Remembers `view` as the one to open the Calendar on. */
35
+ export function storeCalendarView(view: CalendarView): void {
36
+ try {
37
+ localStorage.setItem(STORAGE_KEY, view);
38
+ } catch {
39
+ // A blocked store only means the choice does not outlive the page.
40
+ }
41
+ }
@@ -22,6 +22,7 @@ import ComposeProvider from "../mail/compose/ComposeContext.js";
22
22
  import ContactCardProvider from "../contacts/ContactCardProvider.js";
23
23
  import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
24
24
  import BottomTabBar, { NavItem } from "../../../../lib/components/navigation/BottomTabBar.js";
25
+ import NavBadge, { navItemLabel } from "../../../../lib/components/navigation/NavBadge.js";
25
26
  import { FrameBrandingFooter, FrameBrandingHeader, useBrandingHtml } from "./BrandingChrome.js";
26
27
  import RailIcon from "./RailIcon.js";
27
28
  import AppearanceProvider from "../../appearance/AppearanceProvider.js";
@@ -323,7 +324,10 @@ export function AppChrome({
323
324
  }, [fetchedBranding?.title, inFrame]);
324
325
  // The router sets the document's title to the page's own whenever the page changes, which drops the unread count this puts in front
325
326
  // of it, so a new pathname is what puts the count back. (A folder change is shallow and keeps the title.)
326
- useUnreadTitle(inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts), { enabled: inFrame, resetKey: pathname });
327
+ // The same count is the chip on the Mail icon (rail and bottom tab bar). It is zero - no chip - until the folders are listed, with no
328
+ // mailbox, and wherever the connection is off, so nothing shows when mail isn't available.
329
+ const unreadTotal = inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts);
330
+ useUnreadTitle(unreadTotal, { enabled: inFrame, resetKey: pathname });
327
331
 
328
332
  // Plugin rail entries that depend on the user (the meet plugin's personal room) are asked for once they are signed in.
329
333
  const resolvedRail = useResolvedRailItems(pluginNav?.appRail, userUid);
@@ -332,7 +336,7 @@ export function AppChrome({
332
336
  return <div className="min-h-screen" />;
333
337
  }
334
338
 
335
- const apps = appRailItems(pluginNav, resolvedRail);
339
+ const apps = appRailItems(pluginNav, resolvedRail).map((app) => (app.id === "mail" ? { ...app, badge: unreadTotal } : app));
336
340
  // The header title: "settings" has no rail item, everything else is labelled by its own rail item.
337
341
  const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
338
342
  // A custom header (`Branding.headerHtml`) replaces the app's own title bar and the icon at the top of the rail: it is the top of the app, and the
@@ -414,13 +418,13 @@ export function AppChrome({
414
418
  {!customHeader && (
415
419
  <RailIcon src={iconSrc} />
416
420
  )}
417
- {apps.map(({ id, href, label, icon: Icon }) => (
421
+ {apps.map(({ id, href, label, icon: Icon, badge }) => (
418
422
  <a
419
423
  key={id}
420
424
  href={href}
421
- aria-label={label}
425
+ aria-label={navItemLabel(label, badge)}
422
426
  aria-current={id === active ? "page" : undefined}
423
- title={label}
427
+ title={navItemLabel(label, badge)}
424
428
  className={[
425
429
  "w-10 h-10 flex items-center justify-center rounded-sm",
426
430
  id === active
@@ -428,7 +432,10 @@ export function AppChrome({
428
432
  : "text-text-muted hover:bg-surface-alt hover:text-text",
429
433
  ].join(" ")}
430
434
  >
431
- <Icon size={20} aria-hidden="true" />
435
+ <span className="relative inline-flex">
436
+ <Icon size={20} aria-hidden="true" />
437
+ <NavBadge count={badge} />
438
+ </span>
432
439
  </a>
433
440
  ))}
434
441
  </nav>