@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
@@ -1,174 +1,124 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
- import { View, ScrollView, StyleSheet, Text as RNText, type StyleProp, type ViewStyle } from "react-native";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { View, ScrollView, StyleSheet, Text as RNText } from "react-native";
3
3
  import { Text } from "../text";
4
4
  import { colors } from "../colors";
5
5
  import { FocusRingPressable } from "../focus_ring_pressable";
6
-
7
- function TimeGridPressable(props: {
8
- onPress?: () => void;
9
- accessibilityLabel?: string;
10
- style: StyleProp<ViewStyle>;
11
- children: ReactNode;
12
- }) {
13
- return (
14
- <FocusRingPressable
15
- onPress={props.onPress}
16
- accessibilityRole={props.onPress ? "button" : undefined}
17
- accessibilityLabel={props.accessibilityLabel}
18
- style={props.style}
19
- >
20
- {props.children}
21
- </FocusRingPressable>
22
- );
23
- }
24
- import { layoutDayColumns, packEventLanes } from "./layout";
25
- import {
26
- daysInView,
27
- formatTime,
28
- hourLabel,
29
- isSameDay,
30
- isToday,
31
- minutesSinceMidnight,
32
- startOfDay,
33
- weekdayShort,
34
- } from "./dates";
35
- import type { CalendarEvent, Weekday } from "./types";
6
+ import { useCalendar } from "./context";
7
+ import { EventChip } from "./event_chip";
8
+ import { initialScrollMinutes, isBanner, layoutDayColumns, packEventLanes } from "./layout";
9
+ import { daysInView, formatTime, hourLabel, isToday, minutesSinceMidnight, startOfDay } from "./dates";
10
+ import type { CalendarEvent } from "./types";
36
11
 
37
12
  const HOUR_HEIGHT = 44;
38
13
  const PX_PER_MIN = HOUR_HEIGHT / 60;
39
- const GRID_HEIGHT = 24 * HOUR_HEIGHT;
40
- const GUTTER = 56;
41
- const ALLDAY_LANE_H = 24;
42
- const HOURS = Array.from({ length: 24 }, (_, i) => i);
14
+ const GUTTER = 52;
15
+ const ALLDAY_LANE_H = 22;
43
16
  const COL_GAP = 2;
44
17
 
45
- export interface TimeGridViewProps<T = unknown> {
46
- mode: "week" | "day";
47
- date: Date;
48
- events: CalendarEvent<T>[];
49
- weekStartsOn?: Weekday;
50
- locale?: string;
51
- /** Hour to scroll to on mount. Default: an hour before now (clamped). */
52
- scrollToHour?: number;
53
- /** All-day lane label; defaults to English ("all-day"). */
54
- allDayLabel?: string;
55
- onEventPress?: (event: CalendarEvent<T>) => void;
18
+ /**
19
+ * The hour window a grid actually draws.
20
+ *
21
+ * `dayStartMinutes`/`dayEndMinutes` are a VIEWPORT, never a filter: a calendar
22
+ * that quietly refuses to draw a 06:00 delivery because the working day was
23
+ * declared as 08:00–18:00 is a calendar that lies. The configured window is the
24
+ * floor; anything earlier or later widens it to fit.
25
+ */
26
+ export function gridWindow(
27
+ events: { start: Date; end?: Date | null }[],
28
+ dayStartMinutes: number,
29
+ dayEndMinutes: number,
30
+ ): { startMinutes: number; endMinutes: number } {
31
+ let lo = dayStartMinutes;
32
+ let hi = dayEndMinutes;
33
+ for (const e of events) {
34
+ lo = Math.min(lo, Math.floor(minutesSinceMidnight(e.start) / 60) * 60);
35
+ if (e.end) {
36
+ const endMin = minutesSinceMidnight(e.end);
37
+ // An end at exactly midnight belongs to the day that just closed.
38
+ hi = Math.max(hi, endMin === 0 ? 1440 : Math.ceil(endMin / 60) * 60);
39
+ }
40
+ }
41
+ return { startMinutes: Math.max(0, Math.min(lo, hi - 60)), endMinutes: Math.min(1440, Math.max(hi, lo + 60)) };
56
42
  }
57
43
 
58
- function EventBlock<T>({
59
- event,
60
- top,
61
- height,
62
- leftPct,
63
- widthPct,
64
- onPress,
65
- locale,
66
- }: {
67
- event: CalendarEvent<T>;
68
- top: number;
69
- height: number;
70
- leftPct: number;
71
- widthPct: number;
72
- onPress?: (e: CalendarEvent<T>) => void;
73
- locale?: string;
74
- }) {
75
- const accent = event.color || colors.teal[600];
76
- const compact = height < 34;
77
- return (
78
- <FocusRingPressable
79
- onPress={onPress ? () => onPress(event) : undefined}
80
- accessibilityRole={onPress ? "button" : undefined}
81
- accessibilityLabel={event.title}
82
- style={{
83
- position: "absolute",
84
- top,
85
- height: Math.max(height - 1, 14),
86
- left: `${leftPct}%`,
87
- width: `${widthPct}%`,
88
- paddingHorizontal: COL_GAP,
89
- }}
90
- >
91
- <View
92
- style={{
93
- flex: 1,
94
- borderRadius: 6,
95
- backgroundColor: accent + "1f",
96
- borderLeftWidth: 3,
97
- borderLeftColor: accent,
98
- paddingHorizontal: 6,
99
- paddingVertical: compact ? 1 : 3,
100
- overflow: "hidden",
101
- }}
102
- >
103
- <Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.zinc[800] }}>
104
- {event.title}
105
- </Text>
106
- {!compact ? (
107
- <Text size="xs" numberOfLines={1} style={{ color: colors.zinc[500] }}>
108
- {formatTime(event.start, locale)}
109
- </Text>
110
- ) : null}
111
- </View>
112
- </FocusRingPressable>
113
- );
44
+ export interface TimeGridProps {
45
+ mode: "week" | "day";
114
46
  }
115
47
 
116
- export function TimeGridView<T = unknown>(props: TimeGridViewProps<T>) {
117
- const { mode, date, events, weekStartsOn = 1, locale, onEventPress } = props;
118
- const days = useMemo(
119
- () => daysInView(mode, date, weekStartsOn),
120
- [mode, date, weekStartsOn],
121
- );
48
+ export function CalendarTimeGrid(props: TimeGridProps) {
49
+ const { mode } = props;
50
+ const {
51
+ events, date, weekStartsOn, locale, labels, now: referenceNow, dayStartMinutes, dayEndMinutes,
52
+ onEventPress, onSlotPress, renderEvent,
53
+ } = useCalendar();
54
+
55
+ const days = useMemo(() => daysInView(mode, date, weekStartsOn), [mode, date, weekStartsOn]);
122
56
 
123
- // Live now-indicator: tick each minute.
124
- const [now, setNow] = useState(() => new Date());
57
+ // The now-line has to move, but it must move from the reference the rest of
58
+ // the tree agrees on so it advances by REAL elapsed time from that anchor
59
+ // rather than re-reading a clock the caller may have deliberately overridden.
60
+ const anchor = useRef({ reference: referenceNow.getTime(), mountedAt: Date.now() });
61
+ const [tick, setTick] = useState(0);
125
62
  useEffect(() => {
126
- const id = setInterval(() => setNow(new Date()), 60_000);
63
+ const id = setInterval(() => setTick((t) => t + 1), 60_000);
127
64
  return () => clearInterval(id);
128
65
  }, []);
129
-
130
- const scrollRef = useRef<ScrollView>(null);
131
66
  useEffect(() => {
132
- const hour = props.scrollToHour ?? Math.max(0, now.getHours() - 1);
133
- const id = setTimeout(() => scrollRef.current?.scrollTo({ y: hour * HOUR_HEIGHT, animated: false }), 0);
134
- return () => clearTimeout(id);
135
- // Only on mount / mode change — not every minute.
67
+ anchor.current = { reference: referenceNow.getTime(), mountedAt: Date.now() };
68
+ }, [referenceNow]);
69
+ const now = useMemo(
70
+ () => new Date(anchor.current.reference + (Date.now() - anchor.current.mountedAt)),
71
+ // `tick` is the whole point — it is what re-reads the elapsed time.
136
72
  // eslint-disable-next-line react-hooks/exhaustive-deps
137
- }, [mode]);
73
+ [tick, referenceNow],
74
+ );
138
75
 
139
- const { timedByDay, allDay } = useMemo(() => {
140
- const timed: CalendarEvent<T>[] = [];
141
- const banner: CalendarEvent<T>[] = [];
142
- for (const e of events) {
143
- const multiDay = e.end ? !isSameDay(e.start, e.end) : false;
144
- if (e.allDay || multiDay) banner.push(e);
145
- else timed.push(e);
146
- }
76
+ const { timedByDay, banners, inView } = useMemo(() => {
77
+ const timed: CalendarEvent<unknown>[] = [];
78
+ const bar: CalendarEvent<unknown>[] = [];
79
+ for (const e of events) (isBanner(e) ? bar : timed).push(e);
147
80
  const byDay = days.map((day) =>
148
- timed.filter((e) => {
149
- const s = startOfDay(e.start);
150
- return s.getTime() === day.getTime();
151
- }),
81
+ timed.filter((e) => startOfDay(e.start).getTime() === day.getTime()),
152
82
  );
153
- return { timedByDay: byDay, allDay: banner };
83
+ return { timedByDay: byDay, banners: bar, inView: byDay.flat() };
154
84
  }, [events, days]);
155
85
 
156
- const allDayPacked = useMemo(() => packEventLanes(allDay, days[0], days.length), [allDay, days]);
86
+ const win = useMemo(
87
+ () => gridWindow(inView, dayStartMinutes, dayEndMinutes),
88
+ [inView, dayStartMinutes, dayEndMinutes],
89
+ );
90
+ const gridHeight = ((win.endMinutes - win.startMinutes) / 60) * HOUR_HEIGHT;
91
+ const hours = useMemo(
92
+ () => Array.from({ length: Math.ceil((win.endMinutes - win.startMinutes) / 60) }, (_, i) => win.startMinutes / 60 + i),
93
+ [win],
94
+ );
95
+
96
+ const scrollRef = useRef<ScrollView>(null);
97
+ const target = initialScrollMinutes(inView, days, now, win.startMinutes);
98
+ useEffect(() => {
99
+ const y = Math.max(0, (target - win.startMinutes) * PX_PER_MIN);
100
+ const id = setTimeout(() => scrollRef.current?.scrollTo({ y, animated: false }), 0);
101
+ return () => clearTimeout(id);
102
+ // Re-aimed whenever the range changes — navigating to another week must
103
+ // land on THAT week's content, which is the bug the old "an hour before
104
+ // now" rule had: any week without today in it opened on empty grid.
105
+ }, [target, win.startMinutes]);
106
+
107
+ const allDayPacked = useMemo(() => packEventLanes(banners, days[0], days.length), [banners, days]);
157
108
 
158
109
  return (
159
110
  <View style={styles.root}>
160
- {/* Header: day columns */}
161
111
  <View style={styles.headerRow}>
162
112
  <View style={{ width: GUTTER }} />
163
113
  {days.map((day) => {
164
114
  const today = isToday(day, now);
165
115
  return (
166
116
  <View key={day.toISOString()} style={styles.headerCell}>
167
- <Text size="xs" style={{ color: today ? colors.teal[600] : colors.zinc[500] }}>
168
- {weekdayShort(day, locale).toUpperCase()}
117
+ <Text size="xs" style={today ? styles.todayInk : styles.mutedInk}>
118
+ {new Intl.DateTimeFormat(locale, { weekday: "short" }).format(day).toUpperCase()}
169
119
  </Text>
170
- <View style={[styles.dateBadge, today && { backgroundColor: colors.teal[600] }]}>
171
- <Text size="sm" weight={today ? "semibold" : "medium"} style={{ color: today ? colors.white : colors.zinc[800] }}>
120
+ <View style={[styles.dateBadge, today && styles.dateBadgeToday]}>
121
+ <Text size="sm" weight={today ? "semibold" : "medium"} style={today ? styles.onAccent : styles.dateInk}>
172
122
  {day.getDate()}
173
123
  </Text>
174
124
  </View>
@@ -177,89 +127,99 @@ export function TimeGridView<T = unknown>(props: TimeGridViewProps<T>) {
177
127
  })}
178
128
  </View>
179
129
 
180
- {/* All-day banner row */}
181
130
  {allDayPacked.lanes > 0 ? (
182
131
  <View style={[styles.allDayRow, { height: allDayPacked.lanes * (ALLDAY_LANE_H + 2) + 6 }]}>
183
- <View style={{ width: GUTTER, justifyContent: "center" }}>
184
- <Text size="xs" style={{ color: colors.zinc[400], textAlign: "right", paddingRight: 6 }}>
185
- {props.allDayLabel ?? "all-day"}
186
- </Text>
132
+ <View style={styles.allDayGutter}>
133
+ <Text size="xs" style={styles.allDayLabel}>{labels.allDay}</Text>
187
134
  </View>
188
135
  <View style={{ flex: 1 }}>
189
- {allDayPacked.bars.map((bar) => {
190
- const accent = bar.event.color || colors.teal[600];
191
- return (
192
- <TimeGridPressable
193
- key={bar.event.id}
194
- onPress={onEventPress ? () => onEventPress(bar.event) : undefined}
195
- accessibilityLabel={bar.event.title}
196
- style={{
197
- position: "absolute",
198
- top: bar.lane * (ALLDAY_LANE_H + 2) + 3,
199
- height: ALLDAY_LANE_H,
200
- left: `${(bar.startCol / days.length) * 100}%`,
201
- width: `${(bar.span / days.length) * 100}%`,
202
- paddingHorizontal: COL_GAP,
203
- }}
204
- >
205
- <View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8 }}>
206
- <Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>
207
- {bar.event.title}
208
- </Text>
209
- </View>
210
- </TimeGridPressable>
211
- );
212
- })}
136
+ {allDayPacked.bars.map((bar) => (
137
+ <EventChip
138
+ key={bar.event.id}
139
+ event={bar.event}
140
+ variant="banner"
141
+ height={ALLDAY_LANE_H}
142
+ locale={locale}
143
+ render={renderEvent}
144
+ onPress={onEventPress ? () => onEventPress(bar.event.id) : undefined}
145
+ style={{
146
+ position: "absolute",
147
+ top: bar.lane * (ALLDAY_LANE_H + 2) + 3,
148
+ height: ALLDAY_LANE_H,
149
+ left: `${(bar.startCol / days.length) * 100}%`,
150
+ width: `${(bar.span / days.length) * 100}%`,
151
+ paddingHorizontal: COL_GAP,
152
+ }}
153
+ />
154
+ ))}
213
155
  </View>
214
156
  </View>
215
157
  ) : null}
216
158
 
217
- {/* Scrollable time grid */}
218
159
  <ScrollView ref={scrollRef} style={{ flex: 1 }} showsVerticalScrollIndicator>
219
- <View style={{ flexDirection: "row", height: GRID_HEIGHT }}>
220
- {/* Hour gutter */}
160
+ <View style={{ flexDirection: "row", height: gridHeight }}>
221
161
  <View style={{ width: GUTTER }}>
222
- {HOURS.map((h) =>
223
- h === 0 ? null : (
224
- <RNText
225
- key={h}
226
- style={{
227
- position: "absolute",
228
- top: h * HOUR_HEIGHT - 7,
229
- right: 6,
230
- fontSize: 11,
231
- color: colors.zinc[400],
232
- }}
233
- >
162
+ {hours.map((h, i) =>
163
+ i === 0 ? null : (
164
+ <RNText key={h} style={[styles.hourLabel, { top: i * HOUR_HEIGHT - 7 }]}>
234
165
  {hourLabel(h)}
235
166
  </RNText>
236
167
  ),
237
168
  )}
238
169
  </View>
239
- {/* Day columns */}
240
170
  {days.map((day, dayIdx) => {
241
171
  const placed = layoutDayColumns(day, timedByDay[dayIdx]);
242
172
  const today = isToday(day, now);
173
+ const nowOffset = (minutesSinceMidnight(now) - win.startMinutes) * PX_PER_MIN;
243
174
  return (
244
175
  <View key={day.toISOString()} style={styles.dayColumn}>
245
- {HOURS.map((h) => (
246
- <View key={h} style={{ position: "absolute", top: h * HOUR_HEIGHT, left: 0, right: 0, borderTopWidth: 1, borderTopColor: colors.zinc[100] }} />
176
+ {hours.map((h, i) => (
177
+ <View key={h} style={[styles.hourRule, { top: i * HOUR_HEIGHT }]} />
247
178
  ))}
248
- {placed.map((c) => (
249
- <EventBlock
250
- key={c.event.id}
251
- event={c.event}
252
- top={c.topMinutes * PX_PER_MIN}
253
- height={c.heightMinutes * PX_PER_MIN}
254
- leftPct={(c.column / c.columns) * 100}
255
- widthPct={(1 / c.columns) * 100}
256
- onPress={onEventPress}
257
- locale={locale}
258
- />
259
- ))}
260
- {today ? (
261
- <View style={{ position: "absolute", top: minutesSinceMidnight(now) * PX_PER_MIN, left: 0, right: 0, height: 2, backgroundColor: colors.red[500] }}>
262
- <View style={{ position: "absolute", left: -4, top: -3, width: 8, height: 8, borderRadius: 4, backgroundColor: colors.red[500] }} />
179
+ {/* One press target per hour. Fine-grained enough to mean
180
+ something, coarse enough not to put 300 Tab stops in a week. */}
181
+ {onSlotPress
182
+ ? hours.map((h, i) => (
183
+ <FocusRingPressable
184
+ key={`s${h}`}
185
+ accessibilityRole="button"
186
+ accessibilityLabel={labels.addAt(
187
+ `${new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long" }).format(day)} ${hourLabel(h)}`,
188
+ )}
189
+ onPress={() => {
190
+ const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, 0);
191
+ onSlotPress(start, new Date(start.getTime() + 3_600_000));
192
+ }}
193
+ style={[styles.slot, { top: i * HOUR_HEIGHT }]}
194
+ />
195
+ ))
196
+ : null}
197
+ {placed.map((c) => {
198
+ const height = c.heightMinutes * PX_PER_MIN;
199
+ return (
200
+ <EventChip
201
+ key={c.event.id}
202
+ event={c.event}
203
+ variant="block"
204
+ compact={height < 34}
205
+ height={Math.max(height - 1, 14)}
206
+ locale={locale}
207
+ render={renderEvent}
208
+ onPress={onEventPress ? () => onEventPress(c.event.id) : undefined}
209
+ style={{
210
+ position: "absolute",
211
+ top: (c.topMinutes - win.startMinutes) * PX_PER_MIN,
212
+ height: Math.max(height - 1, 14),
213
+ left: `${(c.column / c.columns) * 100}%`,
214
+ width: `${(1 / c.columns) * 100}%`,
215
+ paddingHorizontal: COL_GAP,
216
+ }}
217
+ />
218
+ );
219
+ })}
220
+ {today && nowOffset >= 0 && nowOffset <= gridHeight ? (
221
+ <View style={[styles.nowLine, { top: nowOffset }]} accessibilityLabel={formatTime(now, locale)}>
222
+ <View style={styles.nowDot} />
263
223
  </View>
264
224
  ) : null}
265
225
  </View>
@@ -271,11 +231,31 @@ export function TimeGridView<T = unknown>(props: TimeGridViewProps<T>) {
271
231
  );
272
232
  }
273
233
 
234
+ export function CalendarWeek() {
235
+ return <CalendarTimeGrid mode="week" />;
236
+ }
237
+
238
+ export function CalendarDay() {
239
+ return <CalendarTimeGrid mode="day" />;
240
+ }
241
+
274
242
  const styles = StyleSheet.create({
275
243
  root: { flex: 1, backgroundColor: colors.white },
276
- headerRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 6 },
244
+ headerRow: { flexDirection: "row", paddingTop: 2, paddingBottom: 8 },
277
245
  headerCell: { flex: 1, alignItems: "center", gap: 3 },
278
246
  dateBadge: { minWidth: 26, height: 26, borderRadius: 13, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
279
- allDayRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 3 },
247
+ dateBadgeToday: { backgroundColor: colors.teal[600] },
248
+ todayInk: { color: colors.teal[600] },
249
+ mutedInk: { color: colors.zinc[500] },
250
+ dateInk: { color: colors.zinc[800] },
251
+ onAccent: { color: colors.white },
252
+ allDayRow: { flexDirection: "row", borderTopWidth: 1, borderTopColor: colors.zinc[100], paddingVertical: 4 },
253
+ allDayGutter: { width: GUTTER, justifyContent: "center" },
254
+ allDayLabel: { color: colors.zinc[400], textAlign: "right", paddingRight: 6 },
255
+ hourLabel: { position: "absolute", right: 6, fontSize: 11, color: colors.zinc[400] },
256
+ hourRule: { position: "absolute", left: 0, right: 0, borderTopWidth: 1, borderTopColor: colors.zinc[100] },
257
+ slot: { position: "absolute", left: 0, right: 0, height: HOUR_HEIGHT },
280
258
  dayColumn: { flex: 1, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] },
259
+ nowLine: { position: "absolute", left: 0, right: 0, height: 2, backgroundColor: colors.red[500] },
260
+ nowDot: { position: "absolute", left: -4, top: -3, width: 8, height: 8, borderRadius: 4, backgroundColor: colors.red[500] },
281
261
  });
@@ -1,3 +1,6 @@
1
+ import type { ColorName } from "../color_tokens";
2
+ import type { RepeatRule } from "./repeat";
3
+
1
4
  /**
2
5
  * Calendar primitive — data model. Mirrors the minimum shape every calendar API
3
6
  * converges on ({title, start, end}) plus the layered extras (all-day, color).
@@ -8,44 +11,67 @@ export interface CalendarEvent<T = unknown> {
8
11
  title: string;
9
12
  /** Event start (local time). For an all-day event, the day it begins. */
10
13
  start: Date;
11
- /** Exclusive end. Null/undefined a zero-length point (treated as 30 min in the time grid). */
14
+ /** INCLUSIVE end day for an all-day event; exclusive instant for a timed one.
15
+ * Null/undefined → a zero-length point (floored in the time grid). */
12
16
  end?: Date | null;
13
17
  /** All-day / multi-day banner event (rendered in the all-day row, not the time grid). */
14
18
  allDay?: boolean;
15
- /** Hex or design-token color for the event chip. */
16
- color?: string | null;
19
+ /**
20
+ * Design-token colour for the event. A TOKEN, not a hex string: the views
21
+ * derive a fill, a tint and a border from it, and a raw hex cannot be tinted
22
+ * without string surgery — the previous version concatenated `"1f"` onto the
23
+ * value, which silently produced garbage for any colour that was not a
24
+ * 6-digit hex.
25
+ */
26
+ color?: ColorName;
27
+ /** Secondary line — a room, an owner, a status. Shown where there is room. */
28
+ meta?: string;
29
+ /**
30
+ * Repeat this event. The calendar expands it to the occurrences that fall in
31
+ * the view's own range, so a daily timetable costs one event, not a year of
32
+ * them. An occurrence's `id` encodes its source (see `sourceEventId`), so
33
+ * `onEventPress` still hands you the row the series came from.
34
+ */
35
+ repeat?: RepeatRule;
17
36
  data?: T;
18
37
  }
19
38
 
20
- export type CalendarViewMode = "month" | "week" | "day";
39
+ export type CalendarViewMode = "month" | "week" | "day" | "agenda";
21
40
 
22
41
  /**
23
- * User-facing chrome strings. Primitives default to English and take these as a
24
- * prop i18n is the consumer's responsibility (see the @lotics/ui convention in
25
- * grid/data_grid.tsx). Dates/weekday/month names come from `locale` via Intl and
26
- * are not part of this set.
42
+ * User-facing chrome strings. Resolution is **prop `LoticsLocale.calendarView`
43
+ * English default**, the kit-wide rule; `labels` overrides one instance.
44
+ * Dates/weekday/month names come from `locale` via Intl and are not part of this set.
27
45
  */
28
- export interface CalendarLabels {
46
+ export interface CalendarViewLabels {
29
47
  today: string;
30
48
  month: string;
31
49
  week: string;
32
50
  day: string;
51
+ agenda: string;
33
52
  previous: string;
34
53
  next: string;
35
54
  allDay: string;
36
- /** Month-cell overflow chip, e.g. (3) => "+3 more". */
55
+ /** Day-cell overflow chip, e.g. (3) => "+3 more". */
37
56
  more: (count: number) => string;
57
+ /** Agenda / empty range. */
58
+ noEvents: string;
59
+ /** Accessible name of an empty time slot's press target. */
60
+ addAt: (when: string) => string;
38
61
  }
39
62
 
40
- export const DEFAULT_CALENDAR_LABELS: CalendarLabels = {
63
+ export const DEFAULT_CALENDAR_VIEW_LABELS: CalendarViewLabels = {
41
64
  today: "Today",
42
65
  month: "Month",
43
66
  week: "Week",
44
67
  day: "Day",
68
+ agenda: "Agenda",
45
69
  previous: "Previous",
46
70
  next: "Next",
47
71
  allDay: "all-day",
48
72
  more: (n) => `+${n} more`,
73
+ noEvents: "Nothing scheduled",
74
+ addAt: (when) => `Add at ${when}`,
49
75
  };
50
76
 
51
77
  /** 0 = Sunday … 6 = Saturday. */
@@ -120,6 +120,11 @@ export interface ChargeLinesProps {
120
120
  * not the sum of something visible is the one figure a reader cannot check. */
121
121
  total: number;
122
122
  formatMoney: (n: number) => string;
123
+ /** Format a QUANTITY. Band-level for the same reason `formatMoney` is: a
124
+ * locale that writes `28,9` must not have the one figure the kit renders
125
+ * itself print `28.9` while every number the app formats beside it reads
126
+ * correctly. Defaults to the platform's own `String`. */
127
+ formatQuantity?: (n: number) => string;
123
128
  /** Sits in the closing row beside the total — the issue/collect action. */
124
129
  action?: ReactNode;
125
130
  /** Shown in place of the lines when there are none. */
@@ -129,7 +134,12 @@ export interface ChargeLinesProps {
129
134
  const QTY_W = 76;
130
135
  const OP_W = 14;
131
136
  const PRICE_W = 132;
132
- const QTY_W_NARROW = 48;
137
+ // 48 fitted an integer and clipped `28,9` — 33px of text in the 30 left after the
138
+ // inset and border, on 4 of 7 lines of a real record. A quantity with one decimal
139
+ // is the ordinary case wherever goods are sold by weight, and `formatQuantity`
140
+ // now makes the separator a comma in the locales that use one, which is no wider
141
+ // but no narrower either. 60 seats four glyphs and a unit's worth of slack.
142
+ const QTY_W_NARROW = 60;
133
143
  const PRICE_W_NARROW = 92;
134
144
  const AMOUNT_W = 108;
135
145
  const ACTION_W = 32;
@@ -138,7 +148,7 @@ const ACTION_W = 32;
138
148
  const FORK_AT = 520;
139
149
 
140
150
  export function ChargeLines(props: ChargeLinesProps) {
141
- const { children, totalLabel, total, formatMoney, action, empty } = props;
151
+ const { children, totalLabel, total, formatMoney, formatQuantity, action, empty } = props;
142
152
  const [width, setWidth] = useState(0);
143
153
  const narrow = width > 0 && width < FORK_AT;
144
154
  const onLayout = useCallback((e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width), []);
@@ -146,7 +156,7 @@ export function ChargeLines(props: ChargeLinesProps) {
146
156
  const isEmpty = Array.isArray(rows) ? rows.length === 0 : !rows;
147
157
 
148
158
  return (
149
- <ChargeLinesContext.Provider value={{ narrow, formatMoney }}>
159
+ <ChargeLinesContext.Provider value={{ narrow, formatMoney, formatQuantity }}>
150
160
  <View onLayout={onLayout} style={styles.band}>
151
161
  {isEmpty ? (
152
162
  <View style={styles.empty}>{empty}</View>
@@ -188,6 +198,10 @@ export function ChargeLine(props: ChargeLineProps) {
188
198
  const band = useChargeLines();
189
199
  const words = useLoticsLocale().chargeLines;
190
200
  const money = props.formatMoney ?? band.formatMoney;
201
+ const qty = band.formatQuantity ?? ((n: number) => String(n));
202
+ const fmtQty = band.formatQuantity
203
+ ? (v: number | null) => (v == null ? "" : qty(v))
204
+ : undefined;
191
205
  const priced = quantity !== undefined || unitPrice !== undefined;
192
206
  const amount = priced ? (quantity ?? 0) * (unitPrice ?? 0) : (props.amount ?? 0);
193
207
  /** The row's money verb — the unit price's on a priced line, the amount's on a
@@ -212,12 +226,13 @@ export function ChargeLine(props: ChargeLineProps) {
212
226
  <View style={{ width: band.narrow ? QTY_W_NARROW : QTY_W }}>
213
227
  {locked || !onQuantityChange ? (
214
228
  <Text size="sm" tabular style={styles.inset}>
215
- {quantity ?? "—"}
229
+ {quantity == null ? "—" : qty(quantity)}
216
230
  </Text>
217
231
  ) : (
218
232
  <InlineNumberInput
219
233
  value={quantity ?? null}
220
234
  onSave={onQuantityChange}
235
+ format={fmtQty}
221
236
  min={0}
222
237
  placeholder="1"
223
238
  align="right"
@@ -340,7 +355,11 @@ export function ChargeLine(props: ChargeLineProps) {
340
355
  );
341
356
  }
342
357
 
343
- const ChargeLinesContext = createContext<{ narrow: boolean; formatMoney: (n: number) => string }>({
358
+ const ChargeLinesContext = createContext<{
359
+ narrow: boolean;
360
+ formatMoney: (n: number) => string;
361
+ formatQuantity?: (n: number) => string;
362
+ }>({
344
363
  narrow: false,
345
364
  formatMoney: (n) => String(n),
346
365
  });
@@ -69,6 +69,34 @@ export const MIN_VALUE_WIDTH = 200;
69
69
  * (avatars, dots, switches, sliders, progress, icon buttons) stay full. */
70
70
  export const CONTROL_RADIUS = 10;
71
71
 
72
+ /**
73
+ * The radius ladder's container rung — a surface that HOLDS content rather than
74
+ * one you press: a board card, a calendar's event chip, a panel.
75
+ *
76
+ * Two rungs, and the question that picks one is what the thing IS, never how
77
+ * big it is: `CONTROL_RADIUS` for something you press directly, this for
78
+ * something with content inside it. A whole card being pressable does not make
79
+ * it a control.
80
+ */
81
+ export const CARD_RADIUS = 16;
82
+
83
+ /**
84
+ * The radius a SHORT surface should take — proportionate to its height, capped
85
+ * at the control rung.
86
+ *
87
+ * A fixed radius does not survive a component whose height is data: at 22px,
88
+ * `CONTROL_RADIUS` is half the box and the thing renders as a pill, while the
89
+ * same 10 on a two-hour calendar block is barely a corner. Two boxes of the
90
+ * same family then look like two different vocabularies. Scaling by height and
91
+ * capping keeps one corner treatment across a chip, a bar and a block.
92
+ *
93
+ * The ratio is under a third, so the straight edge always dominates — a corner
94
+ * that eats half the side has stopped being a rounded rectangle.
95
+ */
96
+ export function proportionalRadius(height: number): number {
97
+ return Math.round(Math.min(CARD_RADIUS, Math.max(0, height) * 0.3));
98
+ }
99
+
72
100
  /**
73
101
  * HOW FAR A ROW'S WASH EXTENDS PAST ITS CONTENT — and the invariant that goes
74
102
  * with it: **a row that paints a hover/selection wash BLEEDS it outward; it