@lotics/ui 46.14.1 → 47.1.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 (49) hide show
  1. package/AGENTS.md +20 -0
  2. package/MIGRATION.md +58 -0
  3. package/docs/catalog.md +72 -13
  4. package/docs/composition.md +94 -6
  5. package/docs/reviewing.md +56 -5
  6. package/docs/templates.md +8 -2
  7. package/examples/tpl_calendar.tsx +22 -29
  8. package/examples/tpl_item_list.tsx +7 -2
  9. package/package.json +1 -1
  10. package/src/accordion.tsx +11 -5
  11. package/src/bar_chart.tsx +3 -1
  12. package/src/board.tsx +1 -2
  13. package/src/breakdown.tsx +3 -1
  14. package/src/calendar/agenda_view.tsx +136 -0
  15. package/src/calendar/calendar_toolbar.tsx +84 -0
  16. package/src/calendar/calendar_view.tsx +196 -103
  17. package/src/calendar/context.ts +47 -0
  18. package/src/calendar/dates.ts +36 -6
  19. package/src/calendar/event_chip.tsx +141 -0
  20. package/src/calendar/index.ts +31 -8
  21. package/src/calendar/layout.ts +113 -11
  22. package/src/calendar/month_view.tsx +194 -150
  23. package/src/calendar/repeat.ts +174 -0
  24. package/src/calendar/time_grid_view.tsx +182 -202
  25. package/src/calendar/types.ts +37 -11
  26. package/src/charge_lines.tsx +24 -5
  27. package/src/control_surface.ts +28 -0
  28. package/src/deadline.ts +10 -0
  29. package/src/file_row.tsx +26 -3
  30. package/src/finding.tsx +1 -1
  31. package/src/form_text_input.tsx +9 -2
  32. package/src/gantt/gantt_view.tsx +212 -119
  33. package/src/gantt/index.ts +2 -2
  34. package/src/gantt/scale.ts +38 -2
  35. package/src/gantt/types.ts +34 -7
  36. package/src/inline_slot.tsx +16 -2
  37. package/src/inline_static.tsx +18 -4
  38. package/src/legend_item.tsx +14 -1
  39. package/src/locale.tsx +30 -0
  40. package/src/matrix.tsx +19 -5
  41. package/src/member_chip.tsx +18 -1
  42. package/src/menu_button.tsx +12 -2
  43. package/src/option_list.tsx +11 -1
  44. package/src/progress_bar.tsx +14 -1
  45. package/src/record_summary.tsx +5 -0
  46. package/src/stacked_bar_chart.tsx +4 -1
  47. package/src/table_fit.ts +18 -1
  48. package/src/thumbnail_stack.tsx +18 -3
  49. package/src/use_option_list.ts +13 -1
@@ -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") {
@@ -0,0 +1,141 @@
1
+ import type { ReactNode } from "react";
2
+ import { View, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
3
+ import { Text } from "../text";
4
+ import { colors, type ColorName } from "../colors";
5
+ import { proportionalRadius } from "../control_surface";
6
+ import { FocusRingPressable } from "../focus_ring_pressable";
7
+ import { formatTime } from "./dates";
8
+ import type { CalendarEvent } from "./types";
9
+
10
+ const DEFAULT_COLOR: ColorName = "teal";
11
+
12
+ /**
13
+ * The ground and ink one event colour resolves to.
14
+ *
15
+ * **A wash with matching ink, never a saturated fill with white on it.** The
16
+ * pairing is `Badge`'s tonal one — the palette's own light ground and dark ink
17
+ * of a single family — so a calendar reads as the same product as everything
18
+ * beside it, and a month with a dozen colours in it stays a surface you can
19
+ * look at. A `solid()` bar per event turns the grid into a paint chart, which
20
+ * is what "the colours are ugly" actually names.
21
+ *
22
+ * The dot is the one place the solid shade still belongs: it is 6px, it carries
23
+ * identity rather than area, and it needs the contrast.
24
+ */
25
+ function chipColors(name: ColorName): { ground: string; hovered: string; ink: string; dot: string } {
26
+ const hue = colors[name];
27
+ // `hovered` is the SAME ground one rung deeper, never a different colour:
28
+ // what has to change is only "this responds to me", and the chip must still
29
+ // read as the event it was a moment ago.
30
+ return { ground: hue[100], hovered: hue[200], ink: hue[900], dot: hue[500] };
31
+ }
32
+
33
+ /**
34
+ * The three ways an event is drawn.
35
+ *
36
+ * - `banner` — a bar. A thing that occupies whole DAYS: an all-day marker, a
37
+ * multi-day campaign. Reads as continuous across day boundaries.
38
+ * - `dot` — a coloured dot beside plain text. A timed event inside a month
39
+ * cell, where a filled bar per meeting turns the month into a colour chart
40
+ * and the dates stop being findable.
41
+ * - `block` — a body sized to its duration, for the time grid, where vertical
42
+ * extent carries the meaning.
43
+ *
44
+ * **None of them draws a border.** A washed body already states its own edge;
45
+ * a rule on top of it is the same edge asserted twice, on a surface that is
46
+ * already full of grid lines. (`board.tsx` states the same law for its cards.)
47
+ */
48
+ export type EventChipVariant = "banner" | "dot" | "block";
49
+
50
+ export interface EventChipProps {
51
+ event: CalendarEvent<unknown>;
52
+ variant: EventChipVariant;
53
+ onPress?: () => void;
54
+ /** Replaces the chip's contents; the press target and geometry stay ours. */
55
+ render?: (event: CalendarEvent<unknown>) => ReactNode;
56
+ /** Show the start time before the title (month cells, where there is no rail). */
57
+ showTime?: boolean;
58
+ locale?: string;
59
+ style?: StyleProp<ViewStyle>;
60
+ /** Below this the block drops its second line rather than clipping it. */
61
+ compact?: boolean;
62
+ /** The chip's rendered height, so its corners stay proportionate to it — a
63
+ * fixed radius turns a 20px lane chip into a pill and barely marks a tall
64
+ * block. */
65
+ height: number;
66
+ }
67
+
68
+ export function EventChip(props: EventChipProps) {
69
+ const { event, variant, onPress, render, showTime, locale, style, compact, height } = props;
70
+ const { ground, hovered, ink, dot } = chipColors(event.color ?? DEFAULT_COLOR);
71
+ const radius = proportionalRadius(height);
72
+
73
+ const label = showTime ? `${formatTime(event.start, locale)} ${event.title}` : event.title;
74
+
75
+ return (
76
+ <FocusRingPressable
77
+ onPress={onPress}
78
+ accessibilityRole={onPress ? "button" : undefined}
79
+ accessibilityLabel={accessibleName(event, locale)}
80
+ style={[styles.press, style]}
81
+ >
82
+ {({ hovered: over }) => {
83
+ // Nothing lights up without a handler, so a read-only calendar never
84
+ // advertises a press that does nothing.
85
+ const lit = !!onPress && over;
86
+ return render ? (
87
+ render(event)
88
+ ) : variant === "banner" ? (
89
+ <View style={[styles.banner, { backgroundColor: lit ? hovered : ground, borderRadius: radius }]}>
90
+ <Text size="xs" weight="medium" numberOfLines={1} style={{ color: ink }}>
91
+ {event.title}
92
+ </Text>
93
+ </View>
94
+ ) : variant === "dot" ? (
95
+ // The dot row has no ground of its own, so its hover is a NEUTRAL
96
+ // wash: the row is mostly plain text, and tinting it with the event's
97
+ // own hue would read as the event changing state rather than as the
98
+ // pointer being over it.
99
+ <View style={[styles.dotRow, lit ? { backgroundColor: colors.zinc[100], borderRadius: radius } : null]}>
100
+ <View style={[styles.dot, { backgroundColor: dot }]} />
101
+ <Text size="xs" numberOfLines={1} style={styles.dotLabel}>
102
+ {label}
103
+ </Text>
104
+ </View>
105
+ ) : (
106
+ <View style={[styles.block, { backgroundColor: lit ? hovered : ground, borderRadius: radius }]}>
107
+ <Text size="xs" weight="medium" numberOfLines={1} style={{ color: ink }}>
108
+ {event.title}
109
+ </Text>
110
+ {!compact ? (
111
+ <Text size="xs" numberOfLines={1} style={{ color: ink, opacity: 0.7 }}>
112
+ {event.meta ?? formatTime(event.start, locale)}
113
+ </Text>
114
+ ) : null}
115
+ </View>
116
+ );
117
+ }}
118
+ </FocusRingPressable>
119
+ );
120
+ }
121
+
122
+ /** What a screen reader hears: the title, then when it is — never a bare title,
123
+ * which in a grid of forty of them says nothing about which one this is. */
124
+ export function accessibleName(event: CalendarEvent<unknown>, locale?: string): string {
125
+ if (event.allDay) return event.title;
126
+ const start = formatTime(event.start, locale);
127
+ const end = event.end ? formatTime(event.end, locale) : null;
128
+ return end ? `${event.title}, ${start}–${end}` : `${event.title}, ${start}`;
129
+ }
130
+
131
+ const styles = StyleSheet.create({
132
+ press: { minWidth: 0 },
133
+ banner: { flex: 1, justifyContent: "center", paddingHorizontal: 8, minWidth: 0 },
134
+ dotRow: { flex: 1, flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: 4, minWidth: 0 },
135
+ dot: { width: 6, height: 6, borderRadius: 3, flexShrink: 0 },
136
+ // `flexShrink: 1` + `minWidth: 0` on the label, not just the row: React Native
137
+ // defaults flexShrink to 0, so without it the title holds its full width and
138
+ // pushes the dot out of the cell instead of ellipsing.
139
+ dotLabel: { color: colors.zinc[700], flexShrink: 1, minWidth: 0 },
140
+ block: { flex: 1, paddingHorizontal: 8, paddingVertical: 2, overflow: "hidden", minWidth: 0 },
141
+ });
@@ -1,19 +1,42 @@
1
- export { CalendarView } from "./calendar_view";
1
+ export { CalendarView, CalendarBody } from "./calendar_view";
2
2
  export type { CalendarViewProps } from "./calendar_view";
3
- export { TimeGridView } from "./time_grid_view";
4
- export type { TimeGridViewProps } from "./time_grid_view";
5
- export { MonthView } from "./month_view";
6
- export type { MonthViewProps } from "./month_view";
7
- export { layoutDayColumns, packEventLanes } from "./layout";
3
+ export { CalendarToolbar } from "./calendar_toolbar";
4
+ export type { CalendarToolbarProps } from "./calendar_toolbar";
5
+ export { CalendarMonth } from "./month_view";
6
+ export { CalendarWeek, CalendarDay, gridWindow } from "./time_grid_view";
7
+ export { CalendarAgenda } from "./agenda_view";
8
+ export { EventChip } from "./event_chip";
9
+ export type { EventChipProps, EventChipVariant } from "./event_chip";
10
+ export { useCalendar } from "./context";
11
+ export type { CalendarContextValue } from "./context";
12
+ export {
13
+ layoutDayColumns,
14
+ packEventLanes,
15
+ compareEvents,
16
+ fitLanes,
17
+ initialScrollMinutes,
18
+ isBanner,
19
+ } from "./layout";
8
20
  export type { LaneBar } from "./layout";
9
- export { DEFAULT_CALENDAR_LABELS } from "./types";
10
- export type { CalendarEvent, CalendarViewMode, Weekday, EventColumn, CalendarLabels } from "./types";
21
+ export { expandRepeats, occurrenceDays, occurrenceId, sourceEventId } from "./repeat";
22
+ export type { RepeatRule } from "./repeat";
23
+ export { DEFAULT_CALENDAR_VIEW_LABELS } from "./types";
24
+ export type {
25
+ CalendarEvent,
26
+ CalendarViewMode,
27
+ Weekday,
28
+ EventColumn,
29
+ CalendarViewLabels,
30
+ } from "./types";
11
31
  export {
12
32
  addDays,
13
33
  addMonths,
14
34
  startOfWeek,
15
35
  startOfMonth,
36
+ endOfMonth,
16
37
  daysInView,
38
+ weekRowsInMonth,
39
+ dayHeading,
17
40
  isSameDay,
18
41
  isToday,
19
42
  viewTitle,
@@ -1,4 +1,4 @@
1
- import { dayDiff } from "./dates";
1
+ import { dayDiff, minutesSinceMidnight, startOfDay } from "./dates";
2
2
  import type { CalendarEvent, EventColumn } from "./types";
3
3
 
4
4
  /** A horizontal banner placement: which day column it starts on, how many days
@@ -11,38 +11,112 @@ export interface LaneBar<T = unknown> {
11
11
  lane: number;
12
12
  }
13
13
 
14
+ /** True for an event that occupies whole days rather than a slot in one. */
15
+ export function isBanner<T>(event: CalendarEvent<T>): boolean {
16
+ if (event.allDay) return true;
17
+ return event.end ? dayDiff(event.start, event.end) > 0 : false;
18
+ }
19
+
20
+ /**
21
+ * The order events take within a day, and the order lanes are handed out.
22
+ *
23
+ * Banners lead — a bar that runs Monday to Friday must sit ABOVE the meetings
24
+ * inside those days, or it gets a different lane every week row and stops
25
+ * reading as one continuous thing. Longest first among them, so the widest bar
26
+ * anchors the top lane. Timed events then follow in clock order, which is the
27
+ * rule the previous version was missing entirely: it sorted by day only, so a
28
+ * day's events appeared in whatever order the consumer's array happened to be
29
+ * in — 16:00 above 09:00 as often as not.
30
+ */
31
+ export function compareEvents<T>(a: CalendarEvent<T>, b: CalendarEvent<T>): number {
32
+ const ab = isBanner(a);
33
+ const bb = isBanner(b);
34
+ if (ab !== bb) return ab ? -1 : 1;
35
+ const byDay = startOfDay(a.start).getTime() - startOfDay(b.start).getTime();
36
+ if (byDay !== 0) return byDay;
37
+ if (ab) {
38
+ const len = (e: CalendarEvent<T>) => (e.end ? dayDiff(e.start, e.end) : 0);
39
+ const byLen = len(b) - len(a);
40
+ if (byLen !== 0) return byLen;
41
+ } else {
42
+ const byTime = minutesSinceMidnight(a.start) - minutesSinceMidnight(b.start);
43
+ if (byTime !== 0) return byTime;
44
+ }
45
+ // Ties break on id so the layout is deterministic across renders.
46
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
47
+ }
48
+
14
49
  /**
15
50
  * Pack day-spanning events into horizontal lanes across a `numDays` window
16
51
  * starting at `rangeStart`. Greedy first-fit: an event takes the topmost lane
17
52
  * whose last bar ends before this event starts, else a new lane. Multi-day
18
53
  * events become wide bars; single-day events are span-1 bars. Deterministic.
54
+ *
55
+ * Pure over the list it is handed, which is what keeps a per-resource split
56
+ * (one call per group, rendered as sub-columns) a caller-side concern rather
57
+ * than a second code path in here.
19
58
  */
20
59
  export function packEventLanes<T>(
21
60
  events: CalendarEvent<T>[],
22
61
  rangeStart: Date,
23
62
  numDays: number,
24
63
  ): { bars: LaneBar<T>[]; lanes: number } {
25
- const items = events
64
+ const items = [...events]
65
+ .sort(compareEvents)
26
66
  .map((event) => ({
27
67
  event,
28
68
  startCol: Math.max(0, dayDiff(rangeStart, event.start)),
29
69
  endCol: Math.min(numDays - 1, dayDiff(rangeStart, event.end ?? event.start)),
30
70
  }))
31
- .filter((it) => it.endCol >= 0 && it.startCol <= numDays - 1 && it.endCol >= it.startCol)
32
- .sort((a, b) => a.startCol - b.startCol || b.endCol - a.endCol);
71
+ .filter((it) => it.endCol >= 0 && it.startCol <= numDays - 1 && it.endCol >= it.startCol);
33
72
 
34
- const laneEnds: number[] = [];
73
+ // Occupancy per lane is a set of COLUMNS, not a single "last end column".
74
+ // With one end per lane, a bar late in the week blocks that lane for every
75
+ // day BEFORE it too — a Saturday holiday banner took lane 0 across the whole
76
+ // row and pushed Monday's first meeting down a lane, which on a short row is
77
+ // the difference between seeing it and reading "+2 more".
78
+ const laneCols: boolean[][] = [];
79
+ const free = (lane: boolean[], from: number, to: number) => {
80
+ for (let c = from; c <= to; c++) if (lane[c]) return false;
81
+ return true;
82
+ };
35
83
  const bars = items.map((it) => {
36
- let lane = laneEnds.findIndex((end) => end < it.startCol);
84
+ let lane = laneCols.findIndex((cols) => free(cols, it.startCol, it.endCol));
37
85
  if (lane === -1) {
38
- lane = laneEnds.length;
39
- laneEnds.push(it.endCol);
40
- } else {
41
- laneEnds[lane] = it.endCol;
86
+ lane = laneCols.length;
87
+ laneCols.push(Array.from({ length: numDays }, () => false));
42
88
  }
89
+ for (let c = it.startCol; c <= it.endCol; c++) laneCols[lane][c] = true;
43
90
  return { event: it.event, startCol: it.startCol, span: it.endCol - it.startCol + 1, lane };
44
91
  });
45
- return { bars, lanes: laneEnds.length };
92
+ return { bars, lanes: laneCols.length };
93
+ }
94
+
95
+ /**
96
+ * How many event lanes fit in a measured week row, and whether the row must
97
+ * give one back to the "+N more" chip.
98
+ *
99
+ * The lane cap used to be the constant 3 while the row itself was `flex: 1` —
100
+ * so a tall calendar wasted the space it had and a short one drew three lanes
101
+ * into a 20px row, straight over the next week's dates. A cap has to be a
102
+ * function of the height actually measured.
103
+ */
104
+ export function fitLanes(
105
+ availableHeight: number,
106
+ laneHeight: number,
107
+ laneGap: number,
108
+ neededLanes: number,
109
+ overflowChipHeight: number,
110
+ ): { visibleLanes: number; overflows: boolean } {
111
+ const per = laneHeight + laneGap;
112
+ // The last lane needs no trailing gap, so the gap is added back before
113
+ // dividing — without it a row that fits exactly N lanes reports N-1.
114
+ const fit = (h: number) => Math.max(0, Math.floor((h + laneGap) / per));
115
+ if (neededLanes <= fit(availableHeight)) return { visibleLanes: neededLanes, overflows: false };
116
+ // Overflowing costs the row the chip's height, NOT a whole lane: the chip is
117
+ // one line of small text, and charging it a full lane threw away an event
118
+ // that had room to be shown.
119
+ return { visibleLanes: fit(availableHeight - overflowChipHeight), overflows: true };
46
120
  }
47
121
 
48
122
  const MINUTES_PER_DAY = 1440;
@@ -54,6 +128,34 @@ function minutesInto(day: Date, t: Date): number {
54
128
  return ms / 60000;
55
129
  }
56
130
 
131
+ /**
132
+ * The minute window a time grid should open on.
133
+ *
134
+ * The previous rule was "an hour before now", unconditionally — so navigating to
135
+ * any week whose events sit outside the current wall-clock hour opened on an
136
+ * empty grid. A week of 09:00 meetings, viewed at 13:00, looked like a week with
137
+ * nothing in it. What a reader wants is the content: the earliest event in view,
138
+ * and only when there is none does the clock (or the configured day start) decide.
139
+ */
140
+ export function initialScrollMinutes(
141
+ events: { start: Date }[],
142
+ daysInRange: Date[],
143
+ now: Date,
144
+ dayStartMinutes: number,
145
+ ): number {
146
+ let earliest: number | null = null;
147
+ for (const e of events) {
148
+ const inRange = daysInRange.some((d) => startOfDay(e.start).getTime() === d.getTime());
149
+ if (!inRange) continue;
150
+ const m = minutesSinceMidnight(e.start);
151
+ if (earliest === null || m < earliest) earliest = m;
152
+ }
153
+ if (earliest !== null) return Math.max(0, earliest - 30);
154
+ const todayInRange = daysInRange.some((d) => startOfDay(now).getTime() === d.getTime());
155
+ if (todayInRange) return Math.max(0, minutesSinceMidnight(now) - 60);
156
+ return dayStartMinutes;
157
+ }
158
+
57
159
  /**
58
160
  * Position timed events for a single grid day into side-by-side columns.
59
161
  *