@lotics/ui 46.14.0 → 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.
@@ -1,176 +1,160 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
- import { View, ScrollView, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { View, ScrollView, StyleSheet } from "react-native";
3
3
  import { Text } from "../text";
4
- import { colors } from "../colors";
4
+ import { colors, solid } from "../colors";
5
+ import { proportionalRadius } from "../control_surface";
5
6
  import { FocusRingPressable } from "../focus_ring_pressable";
6
-
7
- function GanttButton(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 { addDays, dayDiff } from "../calendar/dates";
25
- import { usePointerDrag } from "../use_pointer_drag";
26
- import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
27
- import { DEFAULT_GANTT_LABELS } from "./types";
7
+ import { SegmentedControl, type SegmentOption } from "../segmented_control";
8
+ import { useLoticsLocale } from "../locale";
9
+ import { dayDiff } from "../calendar/dates";
10
+ import { axisRange, barGeometry, buildRows, buildTicks, pxPerDay } from "./scale";
28
11
  import type { GanttLabels, GanttScale, GanttTask } from "./types";
29
12
 
30
13
  const LABEL_W = 188;
31
14
  const HEADER_H = 38;
32
15
  const ROW_H = 40;
16
+ const GROUP_H = 30;
33
17
  const BAR_H = 22;
34
- const HANDLE_W = 8;
35
- const MIN_BAR = 8;
18
+ const MILESTONE = 14;
36
19
  const SCALES: GanttScale[] = ["day", "week", "month"];
37
20
 
38
21
  export interface GanttViewProps<T = unknown> {
39
22
  tasks: GanttTask<T>[];
40
23
  defaultScale?: GanttScale;
24
+ /** What counts as today for the marker and the opening scroll position. */
41
25
  today?: Date;
42
26
  locale?: string;
43
27
  /** Optional toolbar caption shown left of the zoom switch. */
44
28
  title?: string;
45
- /** User-facing chrome strings; defaults to English. */
29
+ /** Per-instance overrides; the rest resolve from `LoticsLocale.gantt`. */
46
30
  labels?: Partial<GanttLabels>;
47
31
  onTaskPress?: (task: GanttTask<T>) => void;
48
- /** When set, each bar gets a right-edge resize handle; dragging it adjusts the
49
- * task's end. Receives the task + the new inclusive end day (clamped ≥ start). */
50
- onTaskResize?: (task: GanttTask<T>, newEnd: Date) => void;
51
32
  }
52
33
 
53
34
  /**
54
- * Timeline / gantt: a frozen task-label column beside a horizontally-scrollable
55
- * zoomable axis. Bars are positioned by {@link barGeometry}; month boundaries get
56
- * a heavier gridline; a red line marks today. The axis opens scrolled to today
57
- * so a long history doesn't bury the active range. Renders at its natural height
58
- * wrap in a ScrollView for very long task lists.
35
+ * A Gantt chart: a frozen label column beside a zoomable, horizontally
36
+ * scrollable axis, with tasks in lanes and dependency arrows between them.
37
+ *
38
+ * **Reach for this over `CalendarView` when the ROWS are the subject** a
39
+ * phase, an owner, a vehicle, a channel and dates are the horizontal axis. A
40
+ * calendar answers "what happens on the 14th"; this answers "what is this team
41
+ * doing, and what is waiting on what".
42
+ *
43
+ * **Lanes** come from `group`, **milestones** render as diamonds, and `progress`
44
+ * fills a portion of the bar. There are deliberately no dependency connectors:
45
+ * lane order and bar position already carry the sequence, and elbow arrows over
46
+ * a ruled grid add line noise to say it a second time.
47
+ *
48
+ * Renders at its natural height — wrap in a `ScrollView` for very long lists.
59
49
  */
60
50
  export function GanttView<T = unknown>(props: GanttViewProps<T>) {
61
- const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress, onTaskResize } = props;
62
- const L = { ...DEFAULT_GANTT_LABELS, ...props.labels };
51
+ const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress } = props;
52
+ const pack = useLoticsLocale();
53
+ const L: GanttLabels = { ...pack.gantt, ...props.labels };
63
54
  const [scale, setScale] = useState<GanttScale>(defaultScale);
64
55
 
65
- const taskById = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
66
- const { live, bind } = usePointerDrag((id, _pointer, delta) => {
67
- const t = taskById.get(id);
68
- if (!t || !onTaskResize) return;
69
- const days = Math.round(delta.dx / pxPerDay(scale));
70
- if (days === 0) return;
71
- const newEnd = addDays(t.end ?? t.start, days);
72
- onTaskResize(t, newEnd < t.start ? t.start : newEnd);
73
- });
74
-
56
+ const rows = useMemo(() => buildRows(tasks), [tasks]);
75
57
  const { start: axisStart, end: axisEnd } = useMemo(() => axisRange(tasks, today), [tasks, today]);
76
- const ticks = useMemo(() => buildTicks(axisStart, axisEnd, scale, locale), [axisStart, axisEnd, scale, locale]);
77
- const axisWidth = useMemo(() => (dayDiff(axisStart, axisEnd) + 1) * pxPerDay(scale), [axisStart, axisEnd, scale]);
58
+ const ticks = useMemo(
59
+ () => buildTicks(axisStart, axisEnd, scale, locale),
60
+ [axisStart, axisEnd, scale, locale],
61
+ );
62
+ const axisWidth = useMemo(
63
+ () => (dayDiff(axisStart, axisEnd) + 1) * pxPerDay(scale),
64
+ [axisStart, axisEnd, scale],
65
+ );
78
66
  const todayLeft = dayDiff(axisStart, today) * pxPerDay(scale);
79
67
  const todayInRange = today >= axisStart && today <= axisEnd;
80
68
 
81
- // Open scrolled to today (a margin of recent past visible), like the time-grid
82
- // scrolls to "now" — keeps the active range in view when history is long.
83
69
  const scrollRef = useRef<ScrollView>(null);
84
70
  useEffect(() => {
85
- const id = setTimeout(() => scrollRef.current?.scrollTo({ x: Math.max(0, todayLeft - 96), animated: false }), 0);
71
+ const id = setTimeout(
72
+ () => scrollRef.current?.scrollTo({ x: Math.max(0, todayLeft - 96), animated: false }),
73
+ 0,
74
+ );
86
75
  return () => clearTimeout(id);
87
76
  }, [scale, todayLeft]);
88
77
 
78
+ const options: SegmentOption<GanttScale>[] = SCALES.map((s) => ({ label: L[s], value: s }));
79
+
80
+ if (tasks.length === 0) {
81
+ return (
82
+ <View style={styles.empty}>
83
+ <Text size="sm" color="muted">{L.empty}</Text>
84
+ </View>
85
+ );
86
+ }
87
+
89
88
  return (
90
89
  <View style={styles.root}>
91
- {/* Toolbar: zoom */}
92
90
  <View style={styles.toolbar}>
93
91
  {title ? <Text size="sm" color="muted">{title}</Text> : <View />}
94
- <View style={styles.zoomSwitch}>
95
- {SCALES.map((s) => (
96
- <GanttButton key={s} onPress={() => setScale(s)} style={[styles.zoomBtn, scale === s && styles.zoomBtnActive]}>
97
- <Text size="sm" weight={scale === s ? "medium" : "regular"} color={scale === s ? "default" : "muted"}>
98
- {L[s]}
99
- </Text>
100
- </GanttButton>
101
- ))}
102
- </View>
92
+ <SegmentedControl<GanttScale>
93
+ accessibilityLabel={L.task}
94
+ options={options}
95
+ value={scale}
96
+ onValueChange={setScale}
97
+ />
103
98
  </View>
104
99
 
105
100
  <View style={{ flexDirection: "row", flex: 1 }}>
106
101
  {/* Frozen label column */}
107
- <View style={{ width: LABEL_W, borderRightWidth: 1, borderRightColor: colors.border }}>
108
- <View style={[styles.headerCell, { height: HEADER_H, paddingLeft: 12, justifyContent: "center" }]}>
109
- <Text size="xs" color="muted" style={{ textTransform: "uppercase" }}>{L.task}</Text>
102
+ <View style={styles.labelColumn}>
103
+ <View style={styles.labelHeader}>
104
+ <Text size="xs" color="muted" style={styles.uppercase}>{L.task}</Text>
110
105
  </View>
111
- {tasks.map((t) => (
112
- <View key={t.id} style={[styles.labelRow, { height: ROW_H }]}>
113
- <View style={{ width: 8, height: 8, borderRadius: 2, backgroundColor: t.color || colors.teal[600] }} />
114
- <Text size="sm" numberOfLines={1} style={{ flex: 1 }}>{t.label}</Text>
115
- </View>
116
- ))}
106
+ {rows.map((row) =>
107
+ row.kind === "group" ? (
108
+ <View key={`g${row.group}`} style={styles.groupLabel}>
109
+ <Text size="xs" weight="semibold" color="muted" numberOfLines={1} style={styles.uppercase}>
110
+ {row.group}
111
+ </Text>
112
+ </View>
113
+ ) : (
114
+ <View key={row.task.id} style={styles.labelRow}>
115
+ <View style={[styles.swatch, { backgroundColor: solid(row.task.color ?? "teal") }]} />
116
+ <Text size="sm" numberOfLines={1} style={styles.labelText}>{row.task.label}</Text>
117
+ </View>
118
+ ),
119
+ )}
117
120
  </View>
118
121
 
119
122
  {/* Scrollable timeline */}
120
123
  <ScrollView ref={scrollRef} horizontal style={{ flex: 1 }} showsHorizontalScrollIndicator>
121
124
  <View style={{ width: axisWidth }}>
122
- {/* Header ticks */}
123
- <View style={{ height: HEADER_H, flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border }}>
125
+ <View style={styles.tickHeader}>
124
126
  {ticks.map((tk) => (
125
127
  <View
126
128
  key={tk.date.toISOString()}
127
- style={{ position: "absolute", left: tk.left, top: 0, bottom: 0, justifyContent: "center", paddingLeft: 4, borderLeftWidth: tk.monthStart ? 1 : 0, borderLeftColor: colors.zinc[300] }}
129
+ style={[styles.tick, { left: tk.left, borderLeftWidth: tk.monthStart ? 1 : 0 }]}
128
130
  >
129
- <Text size="xs" weight={tk.monthStart ? "medium" : "regular"} color={tk.monthStart ? "default" : "muted"}>
131
+ <Text
132
+ size="xs"
133
+ weight={tk.monthStart ? "medium" : "regular"}
134
+ color={tk.monthStart ? "default" : "muted"}
135
+ >
130
136
  {tk.label}
131
137
  </Text>
132
138
  </View>
133
139
  ))}
134
140
  </View>
135
141
 
136
- {/* Task rows */}
137
- {tasks.map((t) => {
138
- const base = barGeometry(t, axisStart, scale);
139
- const dragging = live && live.id === t.id ? live : null;
140
- const width = dragging ? Math.max(MIN_BAR, base.width + dragging.dx) : base.width;
141
- const accent = t.color || colors.teal[600];
142
- return (
143
- <View key={t.id} style={{ height: ROW_H, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] }}>
144
- {ticks.map((tk) =>
145
- tk.monthStart ? (
146
- <View key={tk.date.toISOString()} style={{ position: "absolute", left: tk.left, top: 0, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[200] }} />
147
- ) : null,
148
- )}
149
- <GanttButton
150
- onPress={onTaskPress ? () => onTaskPress(t) : undefined}
151
- accessibilityLabel={t.label}
152
- style={{ position: "absolute", left: base.left, width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
153
- >
154
- <View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8, opacity: dragging ? 0.85 : 1 }}>
155
- <Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>{t.label}</Text>
156
- </View>
157
- </GanttButton>
158
- {onTaskResize ? (
159
- <View
160
- ref={bind(t.id, "ew-resize")}
161
- style={{ position: "absolute", left: base.left + width - HANDLE_W, width: HANDLE_W + 6, top: (ROW_H - BAR_H) / 2, height: BAR_H, alignItems: "center", justifyContent: "center", zIndex: 2 }}
162
- >
163
- <View style={{ width: 3, height: 11, borderRadius: 2, backgroundColor: "rgba(255,255,255,0.75)" }} />
164
- </View>
165
- ) : null}
166
- </View>
167
- );
168
- })}
142
+ {rows.map((row) =>
143
+ row.kind === "group" ? (
144
+ <View key={`g${row.group}`} style={styles.groupBand} />
145
+ ) : (
146
+ <TaskRow
147
+ key={row.task.id}
148
+ task={row.task}
149
+ axisStart={axisStart}
150
+ scale={scale}
151
+ monthTicks={ticks}
152
+ onPress={onTaskPress ? () => onTaskPress(row.task) : undefined}
153
+ />
154
+ ),
155
+ )}
169
156
 
170
- {/* Today marker */}
171
- {todayInRange ? (
172
- <View style={{ position: "absolute", left: todayLeft, top: 0, bottom: 0, width: 2, backgroundColor: colors.red[500] }} />
173
- ) : null}
157
+ {todayInRange ? <View style={[styles.todayLine, { left: todayLeft }]} /> : null}
174
158
  </View>
175
159
  </ScrollView>
176
160
  </View>
@@ -178,12 +162,121 @@ export function GanttView<T = unknown>(props: GanttViewProps<T>) {
178
162
  );
179
163
  }
180
164
 
165
+ function TaskRow<T>(props: {
166
+ task: GanttTask<T>;
167
+ axisStart: Date;
168
+ scale: GanttScale;
169
+ monthTicks: { date: Date; left: number; monthStart: boolean }[];
170
+ onPress?: () => void;
171
+ }) {
172
+ const { task, axisStart, scale, monthTicks, onPress } = props;
173
+ const bar = barGeometry(task, axisStart, scale);
174
+ const hue = colors[task.color ?? "teal"];
175
+ // The same wash-and-ink discipline the calendar's chips use: the track is the
176
+ // family's lightest ground and the progress a step up from it, never the
177
+ // saturated 500 — a row of solid bars is the paint chart this moved away from.
178
+ // The milestone keeps the solid shade: it is 14px and carries identity.
179
+ const accent = solid(task.color ?? "teal");
180
+ // Same rule as the calendar's chips: the corner is a proportion of the box,
181
+ // so a 22px bar is not rendered as a pill.
182
+ const radius = proportionalRadius(BAR_H);
183
+
184
+ return (
185
+ <View style={styles.taskRow}>
186
+ {monthTicks.map((tk) =>
187
+ tk.monthStart ? (
188
+ <View key={tk.date.toISOString()} style={[styles.monthRule, { left: tk.left }]} />
189
+ ) : null,
190
+ )}
191
+ {task.milestone ? (
192
+ <FocusRingPressable
193
+ onPress={onPress}
194
+ accessibilityRole={onPress ? "button" : undefined}
195
+ accessibilityLabel={task.label}
196
+ style={[styles.milestoneHit, { left: bar.left - MILESTONE / 2 }]}
197
+ >
198
+ {/* A rotated square: a moment has no duration, and drawing it as a
199
+ one-day bar reads as a day of work that was never scheduled. */}
200
+ <View style={[styles.milestone, { backgroundColor: accent }]} />
201
+ </FocusRingPressable>
202
+ ) : (
203
+ <FocusRingPressable
204
+ onPress={onPress}
205
+ accessibilityRole={onPress ? "button" : undefined}
206
+ accessibilityLabel={task.label}
207
+ style={[styles.barHit, { left: bar.left, width: bar.width }]}
208
+ >
209
+ {({ hovered }) => (
210
+ <View style={[styles.bar, { backgroundColor: onPress && hovered ? hue[200] : hue[100], borderRadius: radius }]}>
211
+ {task.progress != null ? (
212
+ <View
213
+ style={[
214
+ styles.progress,
215
+ { backgroundColor: hue[400], width: `${Math.max(0, Math.min(1, task.progress)) * 100}%` },
216
+ ]}
217
+ />
218
+ ) : (
219
+ <View style={[styles.progress, { backgroundColor: hue[300], width: "100%" }]} />
220
+ )}
221
+ </View>
222
+ )}
223
+ </FocusRingPressable>
224
+ )}
225
+ </View>
226
+ );
227
+ }
228
+
181
229
  const styles = StyleSheet.create({
182
230
  root: { flex: 1, backgroundColor: colors.white },
183
- toolbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: colors.border },
184
- zoomSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 10, padding: 2 },
185
- zoomBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 8 },
186
- zoomBtnActive: { backgroundColor: colors.white },
187
- headerCell: { borderBottomWidth: 1, borderBottomColor: colors.border },
188
- labelRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] },
231
+ empty: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.white },
232
+ toolbar: {
233
+ flexDirection: "row",
234
+ alignItems: "center",
235
+ justifyContent: "space-between",
236
+ paddingHorizontal: 14,
237
+ paddingVertical: 10,
238
+ gap: 12,
239
+ },
240
+ // No rule down the column: the frozen half is set off by the axis content
241
+ // beginning, and a border here is one more line on a surface already ruled
242
+ // horizontally by every row.
243
+ labelColumn: { width: LABEL_W },
244
+ labelHeader: { height: HEADER_H, paddingLeft: 12, justifyContent: "center" },
245
+ uppercase: { textTransform: "uppercase" },
246
+ groupLabel: {
247
+ height: GROUP_H,
248
+ justifyContent: "flex-end",
249
+ paddingHorizontal: 12,
250
+ paddingBottom: 6,
251
+ },
252
+ labelRow: { height: ROW_H, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12 },
253
+ swatch: { width: 8, height: 8, borderRadius: 2, flexShrink: 0 },
254
+ labelText: { flexShrink: 1, minWidth: 0 },
255
+ tickHeader: { height: HEADER_H, flexDirection: "row" },
256
+ tick: {
257
+ position: "absolute",
258
+ top: 0,
259
+ bottom: 0,
260
+ justifyContent: "center",
261
+ paddingLeft: 4,
262
+ borderLeftColor: colors.zinc[300],
263
+ },
264
+ groupBand: { height: GROUP_H },
265
+ taskRow: { height: ROW_H },
266
+ monthRule: { position: "absolute", top: 0, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[200] },
267
+ // The bar carries no label: the frozen column beside it already names the
268
+ // task, and repeating it inside every bar is the same string twice on one line.
269
+ barHit: { position: "absolute", top: (ROW_H - BAR_H) / 2, height: BAR_H },
270
+ bar: { flex: 1, overflow: "hidden" },
271
+ progress: { position: "absolute", left: 0, top: 0, bottom: 0 },
272
+ milestoneHit: {
273
+ position: "absolute",
274
+ top: (ROW_H - MILESTONE) / 2 - 3,
275
+ width: MILESTONE + 6,
276
+ height: MILESTONE + 6,
277
+ alignItems: "center",
278
+ justifyContent: "center",
279
+ },
280
+ milestone: { width: MILESTONE, height: MILESTONE, transform: [{ rotate: "45deg" }], borderRadius: 4 },
281
+ todayLine: { position: "absolute", top: 0, bottom: 0, width: 2, backgroundColor: colors.red[500] },
189
282
  });
@@ -1,5 +1,5 @@
1
1
  export { GanttView } from "./gantt_view";
2
2
  export type { GanttViewProps } from "./gantt_view";
3
- export { barGeometry, axisRange, buildTicks, pxPerDay } from "./scale";
3
+ export { barGeometry, axisRange, buildTicks, buildRows, pxPerDay } from "./scale";
4
4
  export { DEFAULT_GANTT_LABELS } from "./types";
5
- export type { GanttTask, GanttScale, GanttTick, GanttBar, GanttLabels } from "./types";
5
+ export type { GanttTask, GanttScale, GanttTick, GanttBar, GanttRow, GanttLabels } from "./types";
@@ -1,6 +1,6 @@
1
1
  import { addDays, dayDiff, startOfDay, startOfMonth, startOfWeek } from "../calendar/dates";
2
2
  import type { Weekday } from "../calendar/types";
3
- import type { GanttBar, GanttScale, GanttTask, GanttTick } from "./types";
3
+ import type { GanttBar, GanttRow, GanttScale, GanttTask, GanttTick } from "./types";
4
4
 
5
5
  const MIN_BAR_PX = 8;
6
6
 
@@ -32,15 +32,51 @@ export function axisRange(tasks: GanttTask[], today: Date = new Date(), padDays
32
32
  return { start: addDays(startOfDay(min), -padDays), end: addDays(startOfDay(max), padDays) };
33
33
  }
34
34
 
35
- /** Bar geometry: left = days from axis start; width spans the inclusive end. */
35
+ /** Bar geometry: left = days from axis start; width spans the inclusive end.
36
+ * A milestone has no span — it is a marker centred on its day. */
36
37
  export function barGeometry<T>(task: GanttTask<T>, axisStart: Date, scale: GanttScale): GanttBar<T> {
37
38
  const ppd = pxPerDay(scale);
38
39
  const left = dayDiff(axisStart, task.start) * ppd;
40
+ if (task.milestone) return { task, left, width: 0 };
39
41
  const endDay = task.end ?? task.start;
40
42
  const days = Math.max(1, dayDiff(task.start, endDay) + 1); // inclusive
41
43
  return { task, left, width: Math.max(MIN_BAR_PX, days * ppd) };
42
44
  }
43
45
 
46
+ /**
47
+ * The chart's lines, in render order: each lane's heading followed by its tasks.
48
+ *
49
+ * `group` was declared on `GanttTask` and read by NOTHING — the type documented
50
+ * lanes, the gallery passed them, and every task rendered in one flat list. This
51
+ * is what makes the promise real. Ungrouped tasks lead, so a chart that uses no
52
+ * groups is unchanged; lanes then appear in the order they are first seen,
53
+ * which keeps the order the caller's own array states rather than sorting
54
+ * alphabetically behind their back.
55
+ */
56
+ export function buildRows<T>(tasks: GanttTask<T>[]): GanttRow<T>[] {
57
+ const ungrouped = tasks.filter((t) => !t.group);
58
+ const order: string[] = [];
59
+ const byGroup = new Map<string, GanttTask<T>[]>();
60
+ for (const t of tasks) {
61
+ if (!t.group) continue;
62
+ const existing = byGroup.get(t.group);
63
+ if (existing) existing.push(t);
64
+ else {
65
+ order.push(t.group);
66
+ byGroup.set(t.group, [t]);
67
+ }
68
+ }
69
+
70
+ const rows: GanttRow<T>[] = [];
71
+ let index = 0;
72
+ for (const task of ungrouped) rows.push({ kind: "task", task, index: index++ });
73
+ for (const group of order) {
74
+ rows.push({ kind: "group", group, index: index++ });
75
+ for (const task of byGroup.get(group) ?? []) rows.push({ kind: "task", task, index: index++ });
76
+ }
77
+ return rows;
78
+ }
79
+
44
80
  /**
45
81
  * Header ticks across [start, end]. Granularity follows the zoom: a tick per day
46
82
  * (day), per week (week), or per month (month). Each tick flags a month start so
@@ -1,3 +1,5 @@
1
+ import type { ColorName } from "../color_tokens";
2
+
1
3
  /**
2
4
  * Gantt / timeline primitive — data model. A task is a labelled bar spanning
3
5
  * [start, end] (end inclusive — a one-day task has start === end's day). `data`
@@ -7,11 +9,23 @@ export interface GanttTask<T = unknown> {
7
9
  id: string;
8
10
  label: string;
9
11
  start: Date;
10
- /** Inclusive end day. Null/undefined → a single-day (milestone-ish) bar. */
12
+ /** Inclusive end day. Null/undefined → a single-day bar. */
11
13
  end?: Date | null;
12
- color?: string | null;
13
- /** Optional grouping lane label (e.g. project / assignee). */
14
+ /**
15
+ * Design-token colour, matching `CalendarEvent.color`. A TOKEN, not a hex:
16
+ * the bar derives a fill, a progress overlay and a swatch from it.
17
+ */
18
+ color?: ColorName;
19
+ /**
20
+ * Lane this task belongs to — a phase, an owner, a vehicle. Tasks sharing a
21
+ * `group` render under one heading, in the order first seen; tasks without
22
+ * one lead, ungrouped.
23
+ */
14
24
  group?: string;
25
+ /** A moment rather than a span — drawn as a diamond, no width. `end` is ignored. */
26
+ milestone?: boolean;
27
+ /** 0–1. Draws a solid portion inside the bar; omit for no progress overlay. */
28
+ progress?: number;
15
29
  data?: T;
16
30
  }
17
31
 
@@ -19,9 +33,9 @@ export interface GanttTask<T = unknown> {
19
33
  export type GanttScale = "day" | "week" | "month";
20
34
 
21
35
  /**
22
- * User-facing chrome strings. Defaults to English and taken as a prop i18n is
23
- * the consumer's responsibility (see the @lotics/ui convention in
24
- * grid/data_grid.tsx). Axis tick labels come from `locale` via Intl.
36
+ * User-facing chrome strings. Resolution is **prop `LoticsLocale.gantt`
37
+ * English default**, the kit-wide rule; `labels` overrides one instance.
38
+ * Axis tick labels come from `locale` via Intl.
25
39
  */
26
40
  export interface GanttLabels {
27
41
  day: string;
@@ -29,9 +43,17 @@ export interface GanttLabels {
29
43
  month: string;
30
44
  /** Frozen task-column header. */
31
45
  task: string;
46
+ /** Shown when there is nothing to chart. */
47
+ empty: string;
32
48
  }
33
49
 
34
- export const DEFAULT_GANTT_LABELS: GanttLabels = { day: "Day", week: "Week", month: "Month", task: "Task" };
50
+ export const DEFAULT_GANTT_LABELS: GanttLabels = {
51
+ day: "Day",
52
+ week: "Week",
53
+ month: "Month",
54
+ task: "Task",
55
+ empty: "Nothing scheduled",
56
+ };
35
57
 
36
58
  /** A header tick on the time axis. */
37
59
  export interface GanttTick {
@@ -49,3 +71,8 @@ export interface GanttBar<T = unknown> {
49
71
  left: number;
50
72
  width: number;
51
73
  }
74
+
75
+ /** One rendered line of the chart: a lane heading, or a task at a row index. */
76
+ export type GanttRow<T = unknown> =
77
+ | { kind: "group"; group: string; index: number }
78
+ | { kind: "task"; task: GanttTask<T>; index: number };
@@ -24,7 +24,10 @@ export function LegendItem(props: LegendItemProps) {
24
24
  return (
25
25
  <View style={styles.row}>
26
26
  <View style={[styles.swatch, { backgroundColor: props.color }]} />
27
- <Text size="sm" color="muted">
27
+ {/* Nhãn phải CO ĐƯỢC. Không có `minWidth: 0` thì một hộp chữ trong hàng
28
+ flex không bao giờ nhỏ hơn nội dung của nó, nên nó đẩy cả hàng rộng ra
29
+ và ở màn hẹp hai mục chú giải chồng lên nhau thay vì xuống dòng. */}
30
+ <Text size="sm" color="muted" style={styles.label}>
28
31
  {props.label}
29
32
  </Text>
30
33
  {props.value !== undefined && <Metric value={props.value} size="sm" />}
@@ -37,7 +40,17 @@ const styles = StyleSheet.create({
37
40
  flexDirection: "row",
38
41
  alignItems: "center",
39
42
  gap: SPACE.sm,
43
+ // 130 là bề ngang MONG MUỐN để vài mục chú giải xếp thành cột đều nhau —
44
+ // không phải một sàn cứng. Thiếu `flexShrink` thì nó thành sàn thật: ở màn
45
+ // hẹp hàng không chịu co, và một chú giải bốn chữ vừa bị cắt vừa đè lên
46
+ // mục bên cạnh. Cùng nhầm lẫn mà `table_fit` đã gỡ một lần — hỏi "còn
47
+ // dùng được không" ở chỗ đáng ra phải hỏi "còn đọc được không".
40
48
  minWidth: 130,
49
+ flexShrink: 1,
50
+ },
51
+ label: {
52
+ flexShrink: 1,
53
+ minWidth: 0,
41
54
  },
42
55
  swatch: {
43
56
  width: 8,
package/src/locale.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createContext, useContext, type ReactNode } from "react";
2
2
  import { type DatePickerLabels } from "./date_picker";
3
3
  import { type CalendarLabels } from "./date_calendar";
4
+ import { type CalendarViewLabels } from "./calendar/types";
5
+ import { type GanttLabels } from "./gantt/types";
4
6
  import { type PaginationLabels } from "./pagination";
5
7
  import { type SortHeaderLabels } from "./sort_header";
6
8
  import { type ConfidenceLabels } from "./confidence";
@@ -55,6 +57,14 @@ export interface LoticsLocale {
55
57
  picker: { emptyOption: string };
56
58
  datePicker: DatePickerLabels;
57
59
  calendar: CalendarLabels;
60
+ /** `CalendarView` and its parts — the SCHEDULING surface. Distinct from
61
+ * `calendar` above, which is the date PICKER's month grid: two different
62
+ * components, two different vocabularies, and the collision is why this one
63
+ * carries the longer name. */
64
+ calendarView: CalendarViewLabels;
65
+ /** `GanttView` — the zoom switch, the frozen column header, dependency
66
+ * announcements and the empty state. */
67
+ gantt: GanttLabels;
58
68
  /** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
59
69
  * a call site doesn't pass a dimension-specific `clearLabel`. */
60
70
  filterChip: { clear: string };
@@ -327,6 +337,16 @@ export const en: LoticsLocale = {
327
337
  picker: { emptyOption: "None" },
328
338
  datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", chooseTime: "Choose a time", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date", invalidTime: "Enter a complete time" },
329
339
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
340
+ calendarView: {
341
+ today: "Today", month: "Month", week: "Week", day: "Day", agenda: "Agenda",
342
+ previous: "Previous", next: "Next", allDay: "all-day",
343
+ more: (n) => `+${n} more`, noEvents: "Nothing scheduled",
344
+ addAt: (when) => `Add at ${when}`,
345
+ },
346
+ gantt: {
347
+ day: "Day", week: "Week", month: "Month", task: "Task",
348
+ empty: "Nothing scheduled",
349
+ },
330
350
  filterChip: { clear: "Clear" },
331
351
  floatingActionBar: { clear: "Clear" },
332
352
  formField: { optional: "Optional" },
@@ -536,6 +556,16 @@ export const vi: LoticsLocale = {
536
556
  picker: { emptyOption: "Không có" },
537
557
  datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", chooseTime: "Chọn giờ", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
538
558
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
559
+ calendarView: {
560
+ today: "Hôm nay", month: "Tháng", week: "Tuần", day: "Ngày", agenda: "Danh sách",
561
+ previous: "Trước", next: "Sau", allDay: "cả ngày",
562
+ more: (n) => `+${n} nữa`, noEvents: "Không có lịch nào",
563
+ addAt: (when) => `Thêm vào ${when}`,
564
+ },
565
+ gantt: {
566
+ day: "Ngày", week: "Tuần", month: "Tháng", task: "Công việc",
567
+ empty: "Chưa có việc nào",
568
+ },
539
569
  filterChip: { clear: "Xóa" },
540
570
  floatingActionBar: { clear: "Bỏ chọn" },
541
571
  formField: { optional: "Tùy chọn" },