@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.
- package/AGENTS.md +20 -0
- package/MIGRATION.md +58 -0
- package/docs/catalog.md +38 -9
- package/docs/composition.md +52 -0
- package/docs/reviewing.md +25 -7
- package/docs/templates.md +8 -2
- package/examples/tpl_calendar.tsx +22 -29
- package/package.json +1 -1
- package/src/board.tsx +1 -2
- package/src/calendar/agenda_view.tsx +136 -0
- package/src/calendar/calendar_toolbar.tsx +84 -0
- package/src/calendar/calendar_view.tsx +196 -103
- package/src/calendar/context.ts +47 -0
- package/src/calendar/dates.ts +36 -6
- package/src/calendar/event_chip.tsx +141 -0
- package/src/calendar/index.ts +31 -8
- package/src/calendar/layout.ts +113 -11
- package/src/calendar/month_view.tsx +194 -150
- package/src/calendar/repeat.ts +174 -0
- package/src/calendar/time_grid_view.tsx +182 -202
- package/src/calendar/types.ts +37 -11
- package/src/control_surface.ts +28 -0
- package/src/deadline.ts +10 -0
- package/src/file_row.tsx +26 -3
- package/src/gantt/gantt_view.tsx +212 -119
- package/src/gantt/index.ts +2 -2
- package/src/gantt/scale.ts +38 -2
- package/src/gantt/types.ts +34 -7
- package/src/legend_item.tsx +14 -1
- package/src/locale.tsx +30 -0
- package/src/matrix.tsx +19 -5
- package/src/option_list.tsx +11 -1
- package/src/use_option_list.ts +13 -1
package/src/gantt/gantt_view.tsx
CHANGED
|
@@ -1,176 +1,160 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState
|
|
2
|
-
import { View, ScrollView, StyleSheet
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
62
|
-
const
|
|
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
|
|
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(
|
|
77
|
-
|
|
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(
|
|
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
|
-
<
|
|
95
|
-
{
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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={
|
|
108
|
-
<View style={
|
|
109
|
-
<Text size="xs" color="muted" style={
|
|
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
|
-
{
|
|
112
|
-
|
|
113
|
-
<View
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
{
|
|
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={{
|
|
129
|
+
style={[styles.tick, { left: tk.left, borderLeftWidth: tk.monthStart ? 1 : 0 }]}
|
|
128
130
|
>
|
|
129
|
-
<Text
|
|
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
|
-
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
});
|
package/src/gantt/index.ts
CHANGED
|
@@ -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";
|
package/src/gantt/scale.ts
CHANGED
|
@@ -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
|
package/src/gantt/types.ts
CHANGED
|
@@ -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
|
|
12
|
+
/** Inclusive end day. Null/undefined → a single-day bar. */
|
|
11
13
|
end?: Date | null;
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
23
|
-
* the
|
|
24
|
-
*
|
|
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 = {
|
|
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 };
|
package/src/legend_item.tsx
CHANGED
|
@@ -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
|
-
|
|
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" },
|