@lotics/ui 46.14.1 → 47.0.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.
@@ -0,0 +1,84 @@
1
+ import type { ReactNode } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Text } from "../text";
4
+ import { Button } from "../button";
5
+ import { IconButton } from "../icon_button";
6
+ import { SegmentedControl, type SegmentOption } from "../segmented_control";
7
+ import { useCalendar } from "./context";
8
+ import { addDays, addMonths, startOfDay, viewTitle } from "./dates";
9
+ import type { CalendarViewMode } from "./types";
10
+
11
+ /**
12
+ * The toolbar's height when it does not wrap — what the root has to subtract
13
+ * before asking whether the GRID still fits. It wraps only at widths that are
14
+ * already compact, where the answer is the agenda either way.
15
+ */
16
+ export const CALENDAR_TOOLBAR_HEIGHT = 57;
17
+
18
+ export interface CalendarToolbarProps {
19
+ /** Extra controls, rendered after the view switch — filters, an add action. */
20
+ children?: ReactNode;
21
+ }
22
+
23
+ /**
24
+ * Prev / today / next, the range title, and the view switch.
25
+ *
26
+ * **Optional on purpose.** Mounting `CalendarView` with children means the app
27
+ * supplies its own chrome, because a Lotics screen already has a header band and
28
+ * a calendar that welds one on produces two stacked bands. The switch is the
29
+ * kit's `SegmentedControl` rather than three hand-rolled pills — a mutually
30
+ * exclusive choice among a few peers is exactly what that control is.
31
+ */
32
+ export function CalendarToolbar(props: CalendarToolbarProps) {
33
+ const { date, setDate, view, setView, views, weekStartsOn, locale, labels } = useCalendar();
34
+
35
+ const step = (dir: number) => {
36
+ if (view === "month" || view === "agenda") setDate(addMonths(date, dir));
37
+ else setDate(addDays(date, dir * (view === "week" ? 7 : 1)));
38
+ };
39
+
40
+ const options: SegmentOption<CalendarViewMode>[] = views.map((v) => ({ label: labels[v], value: v }));
41
+
42
+ return (
43
+ <View style={styles.toolbar}>
44
+ <View style={styles.navGroup}>
45
+ <IconButton icon="chevron-left" size="md" accessibilityLabel={labels.previous} onPress={() => step(-1)} />
46
+ <Button color="secondary" title={labels.today} onPress={() => setDate(startOfDay(new Date()))} />
47
+ <IconButton icon="chevron-right" size="md" accessibilityLabel={labels.next} onPress={() => step(1)} />
48
+ </View>
49
+
50
+ <Text size="lg" weight="semibold" numberOfLines={1} style={styles.title}>
51
+ {viewTitle(view, date, weekStartsOn, locale)}
52
+ </Text>
53
+
54
+ <View style={styles.trailing}>
55
+ {props.children}
56
+ {options.length > 1 ? (
57
+ <SegmentedControl<CalendarViewMode>
58
+ accessibilityLabel={labels.month}
59
+ options={options}
60
+ value={view}
61
+ onValueChange={setView}
62
+ />
63
+ ) : null}
64
+ </View>
65
+ </View>
66
+ );
67
+ }
68
+
69
+ const styles = StyleSheet.create({
70
+ toolbar: {
71
+ flexDirection: "row",
72
+ alignItems: "center",
73
+ justifyContent: "space-between",
74
+ paddingHorizontal: 12,
75
+ paddingVertical: 10,
76
+ gap: 12,
77
+ flexWrap: "wrap",
78
+ },
79
+ navGroup: { flexDirection: "row", alignItems: "center", gap: 6 },
80
+ // The title takes the slack and ellipses; the two control groups hold their
81
+ // size, so a long month name never squeezes the buttons out of the band.
82
+ title: { flexGrow: 1, flexShrink: 1, minWidth: 0, textAlign: "center" },
83
+ trailing: { flexDirection: "row", alignItems: "center", gap: 8, flexShrink: 0 },
84
+ });
@@ -1,131 +1,224 @@
1
- import { useState } from "react";
2
- import { View, StyleSheet } from "react-native";
3
- import { Text } from "../text";
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { View, StyleSheet, type LayoutChangeEvent } from "react-native";
4
3
  import { colors } from "../colors";
5
- import { FocusRingPressable } from "../focus_ring_pressable";
6
- import { MonthView } from "./month_view";
7
- import { TimeGridView } from "./time_grid_view";
8
- import { addDays, addMonths, viewTitle } from "./dates";
9
- import { DEFAULT_CALENDAR_LABELS } from "./types";
10
- import type { CalendarEvent, CalendarLabels, CalendarViewMode, Weekday } from "./types";
4
+ import { useLoticsLocale } from "../locale";
5
+ import { CalendarProvider, useCalendar, type CalendarContextValue } from "./context";
6
+ import { CalendarToolbar, CALENDAR_TOOLBAR_HEIGHT } from "./calendar_toolbar";
7
+ import { CalendarMonth, MIN_MONTH_HEIGHT } from "./month_view";
8
+ import { CalendarWeek, CalendarDay } from "./time_grid_view";
9
+ import { CalendarAgenda } from "./agenda_view";
10
+ import { daysInView, startOfDay } from "./dates";
11
+ import { expandRepeats, sourceEventId } from "./repeat";
12
+ import type { CalendarEvent, CalendarViewLabels, CalendarViewMode, Weekday } from "./types";
11
13
 
12
- const VIEW_ORDER: CalendarViewMode[] = ["month", "week", "day"];
14
+ /** Below this width the month/week grids stop being readable and the agenda
15
+ * takes over. Seven columns need ~110px each to hold a real title; under that
16
+ * the grid can only say WHICH days have something. */
17
+ const DEFAULT_COMPACT_BELOW = 720;
18
+
19
+ const ALL_VIEWS: CalendarViewMode[] = ["month", "week", "day", "agenda"];
20
+ /** What the switcher offers once the surface is too narrow for a grid. */
21
+ const COMPACT_VIEWS: CalendarViewMode[] = ["agenda", "day"];
13
22
 
14
23
  export interface CalendarViewProps<T = unknown> {
15
24
  events: CalendarEvent<T>[];
16
- defaultView?: CalendarViewMode;
25
+ /** Controlled anchor date. Omit and pass `defaultDate` for the drop-in form. */
26
+ date?: Date;
17
27
  defaultDate?: Date;
28
+ onDateChange?: (date: Date) => void;
29
+ /** Controlled view. Omit and pass `defaultView` for the drop-in form. */
30
+ view?: CalendarViewMode;
31
+ defaultView?: CalendarViewMode;
32
+ onViewChange?: (view: CalendarViewMode) => void;
33
+ /** Which modes the built-in toolbar offers. Default: all four. */
34
+ views?: CalendarViewMode[];
18
35
  weekStartsOn?: Weekday;
19
36
  locale?: string;
20
- /** User-facing chrome strings; defaults to English. */
21
- labels?: Partial<CalendarLabels>;
37
+ /** Per-instance overrides; the rest resolve from `LoticsLocale.calendarView`. */
38
+ labels?: Partial<CalendarViewLabels>;
39
+ /** Default visible hours of a day in the time grid. A viewport, not a filter —
40
+ * an event outside it still widens the grid rather than disappearing. */
41
+ dayStartHour?: number;
42
+ dayEndHour?: number;
43
+ /** Width below which month/week fall back to the agenda. */
44
+ compactBelow?: number;
45
+ /**
46
+ * What counts as "now" — the today badge, the now line, the hour a time grid
47
+ * opens at. Defaults to the browser's clock.
48
+ *
49
+ * **This is not a timezone conversion, and the calendar deliberately has no
50
+ * `timeZone` prop.** Lotics stores a datetime as a naive WALL CLOCK, so
51
+ * "14:30" means 14:30 to the business and there is nothing to convert; a
52
+ * calendar that re-projected it into the viewer's zone would move every
53
+ * event. What genuinely does depend on where the reader is sitting is the
54
+ * answer to "what day is it", so that is the only thing taken as a prop.
55
+ */
56
+ now?: Date;
22
57
  onEventPress?: (event: CalendarEvent<T>) => void;
23
- onDayPress?: (day: Date) => void;
24
- /** When set, month-view event chips become draggable; dropping on another day
25
- * reschedules the event (duration preserved). Receives event + new start/end. */
26
- onEventDrop?: (event: CalendarEvent<T>, newStart: Date, newEnd: Date | null) => void;
58
+ /** An empty day cell or hour slot was pressed. The calendar never writes —
59
+ * this hands you a range so your own form or drawer can. */
60
+ onSlotPress?: (start: Date, end: Date) => void;
61
+ /** Replace the contents of an event chip; the press target, the lane and the
62
+ * geometry stay the calendar's. One slot rather than a prop per decoration.
63
+ * It is called for banners and timed chips alike — branch on the exported
64
+ * `isBanner(event)` when a multi-day bar should render differently. */
65
+ renderEvent?: (event: CalendarEvent<T>) => ReactNode;
66
+ /** Compose the parts yourself — `CalendarToolbar`, `CalendarMonth`,
67
+ * `CalendarWeek`, `CalendarDay`, `CalendarAgenda`. Omit for toolbar + view. */
68
+ children?: ReactNode;
27
69
  }
28
70
 
29
71
  /**
30
- * Full calendar: a toolbar (prev / today / next + month/week/day switch) over
31
- * the month grid or the week/day time grid. Date + view are owned here so the
32
- * primitive is drop-in; the consumer only supplies events + callbacks.
72
+ * A calendar of events month grid, week/day time grid, or agenda.
73
+ *
74
+ * **Drop-in or composed.** `<CalendarView events={…} />` renders the toolbar and
75
+ * the current view. Passing children replaces that with whatever you mount,
76
+ * against the same state: an app screen already has a header band, and a
77
+ * calendar that welds its own on stacks two.
78
+ *
79
+ * **Controlled or not.** `date`/`view` with their `on*Change` callbacks let the
80
+ * surrounding screen drive — a filter chip, a route, a "jump to today" that
81
+ * lives in the app's own header. Omit them and the calendar owns its state.
82
+ *
83
+ * **It never writes.** Press an event or an empty slot and you get a callback;
84
+ * every mutation is the app's, through whatever form or workflow it uses.
85
+ *
86
+ * For rows that are RESOURCES rather than days — a channel, an owner, a vehicle
87
+ * — reach for `GanttView` instead. Both pack bars with the same layout core.
33
88
  */
34
89
  export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
35
- const { events, defaultView = "month", defaultDate, weekStartsOn = 1, locale, onEventPress, onDayPress, onEventDrop } = props;
36
- const L = { ...DEFAULT_CALENDAR_LABELS, ...props.labels };
37
- const [view, setView] = useState<CalendarViewMode>(defaultView);
38
- const [date, setDate] = useState<Date>(defaultDate ?? new Date());
90
+ const {
91
+ events, weekStartsOn = 1, locale, views = ALL_VIEWS,
92
+ dayStartHour = 0, dayEndHour = 24, compactBelow = DEFAULT_COMPACT_BELOW, now,
93
+ onEventPress, onSlotPress, renderEvent, children,
94
+ } = props;
39
95
 
40
- const step = (dir: number) =>
41
- setDate((d) => (view === "month" ? addMonths(d, dir) : addDays(d, dir * (view === "week" ? 7 : 1))));
96
+ const pack = useLoticsLocale();
97
+ const labels: CalendarViewLabels = { ...pack.calendarView, ...props.labels };
42
98
 
43
- // Drilling into a day (month "+N more" or a day cell) opens the day grid.
44
- const drillToDay = (day: Date) => {
45
- setDate(day);
46
- setView("day");
47
- onDayPress?.(day);
99
+ // One reading of the clock for the whole tree, so the badge, the now line and
100
+ // the scroll target cannot disagree across a midnight boundary mid-render.
101
+ const referenceNow = now ?? new Date();
102
+ const [ownDate, setOwnDate] = useState<Date>(() => startOfDay(props.defaultDate ?? now ?? new Date()));
103
+ const [ownView, setOwnView] = useState<CalendarViewMode>(props.defaultView ?? "month");
104
+ const date = props.date ?? ownDate;
105
+ const view = props.view ?? ownView;
106
+
107
+ const setDate = (next: Date) => {
108
+ if (props.date === undefined) setOwnDate(next);
109
+ props.onDateChange?.(next);
110
+ };
111
+ const setView = (next: CalendarViewMode) => {
112
+ if (props.view === undefined) setOwnView(next);
113
+ props.onViewChange?.(next);
48
114
  };
49
115
 
50
- return (
51
- <View style={styles.root}>
52
- <View style={styles.toolbar}>
53
- <View style={styles.navGroup}>
54
- <FocusRingPressable accessibilityRole="button" onPress={() => step(-1)} accessibilityLabel={L.previous} style={styles.iconBtn}>
55
- <Text size="lg" color="muted">‹</Text>
56
- </FocusRingPressable>
57
- <FocusRingPressable accessibilityRole="button" onPress={() => setDate(new Date())} style={styles.todayBtn}>
58
- <Text size="sm" weight="medium">{L.today}</Text>
59
- </FocusRingPressable>
60
- <FocusRingPressable accessibilityRole="button" onPress={() => step(1)} accessibilityLabel={L.next} style={styles.iconBtn}>
61
- <Text size="lg" color="muted">›</Text>
62
- </FocusRingPressable>
63
- </View>
116
+ const [size, setSize] = useState({ width: 0, height: 0 });
117
+ const onLayout = (e: LayoutChangeEvent) => {
118
+ const { width: w, height: h } = e.nativeEvent.layout;
119
+ const next = { width: Math.round(w), height: Math.round(h) };
120
+ setSize((prev) =>
121
+ Math.abs(prev.width - next.width) > 1 || Math.abs(prev.height - next.height) > 1 ? next : prev,
122
+ );
123
+ };
124
+ // 0 means "not measured yet" — assume roomy, so the first paint of a normal
125
+ // desktop calendar is not an agenda that flips to a grid a frame later.
126
+ const tooNarrow = size.width > 0 && size.width < compactBelow;
127
+ // A grid is unreadable in TWO directions, and only width was ever checked. A
128
+ // 900px-wide, 360px-tall month gave every row 20px of lane area and rendered
129
+ // nothing but "+N more" on all thirty-one days.
130
+ //
131
+ // The measurement is of the WHOLE calendar, so the chrome comes off before
132
+ // the grid is asked whether it fits — comparing the total against a grid-only
133
+ // floor let a 420px calendar through and it rendered exactly the wall of
134
+ // "+N more" this check exists to prevent.
135
+ const chromeHeight = children ? 0 : CALENDAR_TOOLBAR_HEIGHT;
136
+ const tooShort = size.height > 0 && size.height - chromeHeight < MIN_MONTH_HEIGHT;
137
+ const compact = tooNarrow || tooShort;
138
+ const effectiveView: CalendarViewMode =
139
+ compact && (view === "month" || view === "week") ? "agenda" : view;
64
140
 
65
- <Text size="lg" weight="semibold" style={styles.title} numberOfLines={1}>
66
- {viewTitle(view, date, weekStartsOn, locale)}
67
- </Text>
141
+ // Repeating events are expanded to the VIEW's own range, so a daily timetable
142
+ // costs one event rather than a year of them, and navigating a month back
143
+ // does not have to have been materialised in advance.
144
+ const days = useMemo(
145
+ () => daysInView(effectiveView, date, weekStartsOn),
146
+ [effectiveView, date, weekStartsOn],
147
+ );
148
+ const shown = useMemo(
149
+ () => expandRepeats(events, days[0], days[days.length - 1]),
150
+ [events, days],
151
+ );
68
152
 
69
- <View style={styles.viewSwitch}>
70
- {VIEW_ORDER.map((v) => (
71
- <FocusRingPressable accessibilityRole="button"
72
- key={v}
73
- onPress={() => setView(v)}
74
- style={[styles.viewBtn, view === v && styles.viewBtnActive]}
75
- >
76
- <Text size="sm" weight={view === v ? "medium" : "regular"} color={view === v ? "default" : "muted"}>
77
- {L[v]}
78
- </Text>
79
- </FocusRingPressable>
80
- ))}
81
- </View>
82
- </View>
153
+ // Resolved against the typed array, so no part ever handles a type it doesn't
154
+ // know and nothing has to be cast back on the way out. An occurrence resolves
155
+ // through its source id, so pressing the third Tuesday of a series still
156
+ // hands the caller the row the series came from.
157
+ const byId = useMemo(() => new Map(events.map((e) => [e.id, e])), [events]);
158
+ const resolve = (id: string) => byId.get(id) ?? byId.get(sourceEventId(id));
83
159
 
84
- <View style={{ flex: 1 }}>
85
- {view === "month" ? (
86
- <MonthView
87
- date={date}
88
- events={events}
89
- weekStartsOn={weekStartsOn}
90
- locale={locale}
91
- moreLabel={L.more}
92
- onEventPress={onEventPress}
93
- onDayPress={drillToDay}
94
- onEventDrop={onEventDrop}
95
- />
96
- ) : (
97
- <TimeGridView
98
- mode={view}
99
- date={date}
100
- events={events}
101
- weekStartsOn={weekStartsOn}
102
- locale={locale}
103
- allDayLabel={L.allDay}
104
- onEventPress={onEventPress}
105
- />
160
+ const value: CalendarContextValue = {
161
+ events: shown,
162
+ date,
163
+ setDate,
164
+ view: effectiveView,
165
+ setView,
166
+ views: compact ? COMPACT_VIEWS.filter((v) => views.includes(v)) : views,
167
+ weekStartsOn,
168
+ locale,
169
+ labels,
170
+ now: referenceNow,
171
+ dayStartMinutes: Math.max(0, Math.min(23, dayStartHour)) * 60,
172
+ dayEndMinutes: Math.max(1, Math.min(24, dayEndHour)) * 60,
173
+ onEventPress: onEventPress
174
+ ? (id) => {
175
+ const event = resolve(id);
176
+ if (event) onEventPress(event);
177
+ }
178
+ : undefined,
179
+ onSlotPress,
180
+ renderEvent: renderEvent
181
+ ? (event) => {
182
+ const typed = resolve(event.id);
183
+ return typed ? renderEvent(typed) : null;
184
+ }
185
+ : undefined,
186
+ };
187
+
188
+ return (
189
+ <View style={styles.root} onLayout={onLayout}>
190
+ <CalendarProvider value={value}>
191
+ {children ?? (
192
+ <>
193
+ <CalendarToolbar />
194
+ <CalendarBody />
195
+ </>
106
196
  )}
107
- </View>
197
+ </CalendarProvider>
198
+ </View>
199
+ );
200
+ }
201
+
202
+ /** The current view's grid, without the toolbar — for composing your own chrome
203
+ * over the calendar's own view state. */
204
+ export function CalendarBody() {
205
+ const { view } = useCalendar();
206
+ return (
207
+ <View style={styles.body}>
208
+ {view === "month" ? (
209
+ <CalendarMonth />
210
+ ) : view === "week" ? (
211
+ <CalendarWeek />
212
+ ) : view === "day" ? (
213
+ <CalendarDay />
214
+ ) : (
215
+ <CalendarAgenda />
216
+ )}
108
217
  </View>
109
218
  );
110
219
  }
111
220
 
112
221
  const styles = StyleSheet.create({
113
- root: { flex: 1, backgroundColor: colors.white },
114
- toolbar: {
115
- flexDirection: "row",
116
- alignItems: "center",
117
- justifyContent: "space-between",
118
- paddingHorizontal: 14,
119
- paddingVertical: 10,
120
- gap: 12,
121
- borderBottomWidth: 1,
122
- borderBottomColor: colors.border,
123
- },
124
- navGroup: { flexDirection: "row", alignItems: "center", gap: 4 },
125
- iconBtn: { width: 30, height: 30, borderRadius: 6, alignItems: "center", justifyContent: "center" },
126
- todayBtn: { paddingHorizontal: 12, height: 30, borderRadius: 6, borderWidth: 1, borderColor: colors.border, alignItems: "center", justifyContent: "center" },
127
- title: { flex: 1, textAlign: "center" },
128
- viewSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 10, padding: 2 },
129
- viewBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 8 },
130
- viewBtnActive: { backgroundColor: colors.white },
222
+ root: { flex: 1, backgroundColor: colors.white, minWidth: 0 },
223
+ body: { flex: 1, minWidth: 0 },
131
224
  });
@@ -0,0 +1,47 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import type { CalendarEvent, CalendarViewLabels, CalendarViewMode, Weekday } from "./types";
3
+
4
+ /**
5
+ * What every calendar part reads.
6
+ *
7
+ * Events are held as `CalendarEvent<unknown>` and the press/render callbacks are
8
+ * keyed by event **id**, not by the typed event. That is what lets the root be
9
+ * generic over the consumer's row type while the parts stay plain: the root
10
+ * closes over its own typed array and resolves the id back before calling out,
11
+ * so no part ever has to launder a type it does not know.
12
+ */
13
+ export interface CalendarContextValue {
14
+ events: CalendarEvent<unknown>[];
15
+ /** The anchor date; which days are in view is each part's own business. */
16
+ date: Date;
17
+ setDate: (date: Date) => void;
18
+ view: CalendarViewMode;
19
+ setView: (view: CalendarViewMode) => void;
20
+ /** Which modes the toolbar offers. */
21
+ views: CalendarViewMode[];
22
+ weekStartsOn: Weekday;
23
+ locale?: string;
24
+ labels: CalendarViewLabels;
25
+ /** What the calendar treats as "now" — the today badge, the red line, and the
26
+ * hour a grid opens at. Supplied rather than read from the clock so a
27
+ * workspace in another zone can hand over ITS wall clock, and so a test can
28
+ * pin a day. */
29
+ now: Date;
30
+ /** Default visible window of a day in the time grid, in minutes from midnight.
31
+ * A viewport, never a filter — an event outside it still widens the grid. */
32
+ dayStartMinutes: number;
33
+ dayEndMinutes: number;
34
+ onEventPress?: (id: string) => void;
35
+ onSlotPress?: (start: Date, end: Date) => void;
36
+ renderEvent?: (event: CalendarEvent<unknown>) => ReactNode;
37
+ }
38
+
39
+ const CalendarContext = createContext<CalendarContextValue | null>(null);
40
+
41
+ export const CalendarProvider = CalendarContext.Provider;
42
+
43
+ export function useCalendar(): CalendarContextValue {
44
+ const ctx = useContext(CalendarContext);
45
+ if (!ctx) throw new Error("Calendar parts must be used within a CalendarView");
46
+ return ctx;
47
+ }
@@ -32,7 +32,11 @@ export function endOfMonth(d: Date): Date {
32
32
  }
33
33
 
34
34
  export function addMonths(d: Date, n: number): Date {
35
- return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());
35
+ // Clamp the day so 31 Jan + 1 month is 28/29 Feb, not 2/3 March. Stepping a
36
+ // month at a time through a 31-day month otherwise skips February entirely.
37
+ const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
38
+ const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
39
+ return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), lastDay));
36
40
  }
37
41
 
38
42
  export function isSameDay(a: Date, b: Date): boolean {
@@ -60,16 +64,37 @@ export function minutesSinceMidnight(d: Date): number {
60
64
  return d.getHours() * 60 + d.getMinutes();
61
65
  }
62
66
 
63
- /** The day columns a view spans: 7 for week, 1 for day, the 6-week grid for month. */
67
+ /**
68
+ * How many week rows this month actually needs (4–6).
69
+ *
70
+ * The grid used to be a hardcoded 42 days "for a stable grid height", which buys
71
+ * a stable height at the price of a whole empty row in most months — June 2026
72
+ * rendered five weeks of dates and a sixth row of greyed-out July, eating ~17%
73
+ * of the calendar's height to show nothing. A month grid should be as tall as
74
+ * the month is; the row height absorbs the difference.
75
+ */
76
+ export function weekRowsInMonth(date: Date, weekStartsOn: Weekday = 1): number {
77
+ const gridStart = startOfWeek(startOfMonth(date), weekStartsOn);
78
+ const last = endOfMonth(date);
79
+ return Math.ceil((dayDiff(gridStart, last) + 1) / 7);
80
+ }
81
+
82
+ /** The day columns a view spans: 7 for week, 1 for day, whole weeks for month/agenda. */
64
83
  export function daysInView(mode: CalendarViewMode, date: Date, weekStartsOn: Weekday = 1): Date[] {
65
84
  if (mode === "day") return [startOfDay(date)];
66
85
  if (mode === "week") {
67
86
  const start = startOfWeek(date, weekStartsOn);
68
87
  return Array.from({ length: 7 }, (_, i) => addDays(start, i));
69
88
  }
70
- // month: full weeks covering the month → always 6 rows for a stable grid height.
89
+ if (mode === "agenda") {
90
+ // The agenda lists the month it is pointed at, so prev/next mean the same
91
+ // thing it does in month view and the toolbar title stays honest.
92
+ const start = startOfMonth(date);
93
+ const days = endOfMonth(date).getDate();
94
+ return Array.from({ length: days }, (_, i) => addDays(start, i));
95
+ }
71
96
  const gridStart = startOfWeek(startOfMonth(date), weekStartsOn);
72
- return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
97
+ return Array.from({ length: weekRowsInMonth(date, weekStartsOn) * 7 }, (_, i) => addDays(gridStart, i));
73
98
  }
74
99
 
75
100
  // ─── Localized labels (Intl, browser/workspace locale) ──────────────────────
@@ -87,9 +112,14 @@ export function hourLabel(hour: number): string {
87
112
  return `${String(hour).padStart(2, "0")}:00`;
88
113
  }
89
114
 
90
- /** Header title: "March 2026" (month) or the week/day range. */
115
+ /** Agenda day heading: "Thu 12 March". */
116
+ export function dayHeading(d: Date, locale?: string): string {
117
+ return new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "long" }).format(d);
118
+ }
119
+
120
+ /** Header title: "March 2026" (month/agenda) or the week/day range. */
91
121
  export function viewTitle(mode: CalendarViewMode, date: Date, weekStartsOn: Weekday, locale?: string): string {
92
- if (mode === "month") {
122
+ if (mode === "month" || mode === "agenda") {
93
123
  return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
94
124
  }
95
125
  if (mode === "day") {