@lotics/ui 1.10.0 → 1.11.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/package.json +4 -1
- package/src/calendar/calendar_view.tsx +122 -0
- package/src/calendar/dates.ts +102 -0
- package/src/calendar/index.ts +19 -0
- package/src/calendar/layout.test.ts +103 -0
- package/src/calendar/layout.ts +142 -0
- package/src/calendar/month_view.tsx +157 -0
- package/src/calendar/time_grid_view.tsx +261 -0
- package/src/calendar/types.ts +38 -0
- package/src/download.ts +14 -2
- package/src/gantt/gantt_view.tsx +131 -0
- package/src/gantt/index.ts +4 -0
- package/src/gantt/scale.test.ts +47 -0
- package/src/gantt/scale.ts +92 -0
- package/src/gantt/types.ts +36 -0
- package/src/kanban/constants.ts +18 -0
- package/src/kanban/default_renderers.tsx +160 -0
- package/src/kanban/drag_preview.tsx +157 -0
- package/src/kanban/index.ts +13 -0
- package/src/kanban/insert_card_zone.tsx +135 -0
- package/src/kanban/kanban_board.tsx +616 -0
- package/src/kanban/kanban_card.tsx +312 -0
- package/src/kanban/kanban_column.tsx +487 -0
- package/src/kanban/placeholders.tsx +54 -0
- package/src/kanban/types.ts +116 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { View, ScrollView, Pressable, StyleSheet, Text as RNText } from "react-native";
|
|
3
|
+
import { Text } from "../text";
|
|
4
|
+
import { colors } from "../colors";
|
|
5
|
+
import { layoutDayColumns, packEventLanes } from "./layout";
|
|
6
|
+
import {
|
|
7
|
+
daysInView,
|
|
8
|
+
formatTime,
|
|
9
|
+
hourLabel,
|
|
10
|
+
isSameDay,
|
|
11
|
+
isToday,
|
|
12
|
+
minutesSinceMidnight,
|
|
13
|
+
startOfDay,
|
|
14
|
+
weekdayShort,
|
|
15
|
+
} from "./dates";
|
|
16
|
+
import type { CalendarEvent, Weekday } from "./types";
|
|
17
|
+
|
|
18
|
+
const HOUR_HEIGHT = 44;
|
|
19
|
+
const PX_PER_MIN = HOUR_HEIGHT / 60;
|
|
20
|
+
const GRID_HEIGHT = 24 * HOUR_HEIGHT;
|
|
21
|
+
const GUTTER = 56;
|
|
22
|
+
const ALLDAY_LANE_H = 24;
|
|
23
|
+
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
|
24
|
+
const COL_GAP = 2;
|
|
25
|
+
|
|
26
|
+
export interface TimeGridViewProps<T = unknown> {
|
|
27
|
+
mode: "week" | "day";
|
|
28
|
+
date: Date;
|
|
29
|
+
events: CalendarEvent<T>[];
|
|
30
|
+
weekStartsOn?: Weekday;
|
|
31
|
+
locale?: string;
|
|
32
|
+
/** Hour to scroll to on mount. Default: an hour before now (clamped). */
|
|
33
|
+
scrollToHour?: number;
|
|
34
|
+
onEventPress?: (event: CalendarEvent<T>) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function EventBlock<T>({
|
|
38
|
+
event,
|
|
39
|
+
top,
|
|
40
|
+
height,
|
|
41
|
+
leftPct,
|
|
42
|
+
widthPct,
|
|
43
|
+
onPress,
|
|
44
|
+
locale,
|
|
45
|
+
}: {
|
|
46
|
+
event: CalendarEvent<T>;
|
|
47
|
+
top: number;
|
|
48
|
+
height: number;
|
|
49
|
+
leftPct: number;
|
|
50
|
+
widthPct: number;
|
|
51
|
+
onPress?: (e: CalendarEvent<T>) => void;
|
|
52
|
+
locale?: string;
|
|
53
|
+
}) {
|
|
54
|
+
const accent = event.color || colors.teal[600];
|
|
55
|
+
const compact = height < 34;
|
|
56
|
+
return (
|
|
57
|
+
<Pressable
|
|
58
|
+
onPress={onPress ? () => onPress(event) : undefined}
|
|
59
|
+
accessibilityRole={onPress ? "button" : undefined}
|
|
60
|
+
accessibilityLabel={event.title}
|
|
61
|
+
style={{
|
|
62
|
+
position: "absolute",
|
|
63
|
+
top,
|
|
64
|
+
height: Math.max(height - 1, 14),
|
|
65
|
+
left: `${leftPct}%`,
|
|
66
|
+
width: `${widthPct}%`,
|
|
67
|
+
paddingHorizontal: COL_GAP,
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<View
|
|
71
|
+
style={{
|
|
72
|
+
flex: 1,
|
|
73
|
+
borderRadius: 6,
|
|
74
|
+
backgroundColor: accent + "1f",
|
|
75
|
+
borderLeftWidth: 3,
|
|
76
|
+
borderLeftColor: accent,
|
|
77
|
+
paddingHorizontal: 6,
|
|
78
|
+
paddingVertical: compact ? 1 : 3,
|
|
79
|
+
overflow: "hidden",
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.zinc[800] }}>
|
|
83
|
+
{event.title}
|
|
84
|
+
</Text>
|
|
85
|
+
{!compact ? (
|
|
86
|
+
<Text size="xs" numberOfLines={1} style={{ color: colors.zinc[500] }}>
|
|
87
|
+
{formatTime(event.start, locale)}
|
|
88
|
+
</Text>
|
|
89
|
+
) : null}
|
|
90
|
+
</View>
|
|
91
|
+
</Pressable>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function TimeGridView<T = unknown>(props: TimeGridViewProps<T>) {
|
|
96
|
+
const { mode, date, events, weekStartsOn = 1, locale, onEventPress } = props;
|
|
97
|
+
const days = useMemo(
|
|
98
|
+
() => daysInView(mode, date, weekStartsOn),
|
|
99
|
+
[mode, date, weekStartsOn],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Live now-indicator: tick each minute.
|
|
103
|
+
const [now, setNow] = useState(() => new Date());
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const id = setInterval(() => setNow(new Date()), 60_000);
|
|
106
|
+
return () => clearInterval(id);
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const hour = props.scrollToHour ?? Math.max(0, now.getHours() - 1);
|
|
112
|
+
const id = setTimeout(() => scrollRef.current?.scrollTo({ y: hour * HOUR_HEIGHT, animated: false }), 0);
|
|
113
|
+
return () => clearTimeout(id);
|
|
114
|
+
// Only on mount / mode change — not every minute.
|
|
115
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
116
|
+
}, [mode]);
|
|
117
|
+
|
|
118
|
+
const { timedByDay, allDay } = useMemo(() => {
|
|
119
|
+
const timed: CalendarEvent<T>[] = [];
|
|
120
|
+
const banner: CalendarEvent<T>[] = [];
|
|
121
|
+
for (const e of events) {
|
|
122
|
+
const multiDay = e.end ? !isSameDay(e.start, e.end) : false;
|
|
123
|
+
if (e.allDay || multiDay) banner.push(e);
|
|
124
|
+
else timed.push(e);
|
|
125
|
+
}
|
|
126
|
+
const byDay = days.map((day) =>
|
|
127
|
+
timed.filter((e) => {
|
|
128
|
+
const s = startOfDay(e.start);
|
|
129
|
+
return s.getTime() === day.getTime();
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
return { timedByDay: byDay, allDay: banner };
|
|
133
|
+
}, [events, days]);
|
|
134
|
+
|
|
135
|
+
const allDayPacked = useMemo(() => packEventLanes(allDay, days[0], days.length), [allDay, days]);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<View style={styles.root}>
|
|
139
|
+
{/* Header: day columns */}
|
|
140
|
+
<View style={styles.headerRow}>
|
|
141
|
+
<View style={{ width: GUTTER }} />
|
|
142
|
+
{days.map((day) => {
|
|
143
|
+
const today = isToday(day, now);
|
|
144
|
+
return (
|
|
145
|
+
<View key={day.toISOString()} style={styles.headerCell}>
|
|
146
|
+
<Text size="xs" style={{ color: today ? colors.teal[600] : colors.zinc[500] }}>
|
|
147
|
+
{weekdayShort(day, locale).toUpperCase()}
|
|
148
|
+
</Text>
|
|
149
|
+
<View style={[styles.dateBadge, today && { backgroundColor: colors.teal[600] }]}>
|
|
150
|
+
<Text size="sm" weight={today ? "semibold" : "medium"} style={{ color: today ? colors.white : colors.zinc[800] }}>
|
|
151
|
+
{day.getDate()}
|
|
152
|
+
</Text>
|
|
153
|
+
</View>
|
|
154
|
+
</View>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</View>
|
|
158
|
+
|
|
159
|
+
{/* All-day banner row */}
|
|
160
|
+
{allDayPacked.lanes > 0 ? (
|
|
161
|
+
<View style={[styles.allDayRow, { height: allDayPacked.lanes * (ALLDAY_LANE_H + 2) + 6 }]}>
|
|
162
|
+
<View style={{ width: GUTTER, justifyContent: "center" }}>
|
|
163
|
+
<Text size="xs" style={{ color: colors.zinc[400], textAlign: "right", paddingRight: 6 }}>
|
|
164
|
+
cả ngày
|
|
165
|
+
</Text>
|
|
166
|
+
</View>
|
|
167
|
+
<View style={{ flex: 1 }}>
|
|
168
|
+
{allDayPacked.bars.map((bar) => {
|
|
169
|
+
const accent = bar.event.color || colors.teal[600];
|
|
170
|
+
return (
|
|
171
|
+
<Pressable
|
|
172
|
+
key={bar.event.id}
|
|
173
|
+
onPress={onEventPress ? () => onEventPress(bar.event) : undefined}
|
|
174
|
+
accessibilityRole={onEventPress ? "button" : undefined}
|
|
175
|
+
accessibilityLabel={bar.event.title}
|
|
176
|
+
style={{
|
|
177
|
+
position: "absolute",
|
|
178
|
+
top: bar.lane * (ALLDAY_LANE_H + 2) + 3,
|
|
179
|
+
height: ALLDAY_LANE_H,
|
|
180
|
+
left: `${(bar.startCol / days.length) * 100}%`,
|
|
181
|
+
width: `${(bar.span / days.length) * 100}%`,
|
|
182
|
+
paddingHorizontal: COL_GAP,
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
<View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8 }}>
|
|
186
|
+
<Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>
|
|
187
|
+
{bar.event.title}
|
|
188
|
+
</Text>
|
|
189
|
+
</View>
|
|
190
|
+
</Pressable>
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
</View>
|
|
194
|
+
</View>
|
|
195
|
+
) : null}
|
|
196
|
+
|
|
197
|
+
{/* Scrollable time grid */}
|
|
198
|
+
<ScrollView ref={scrollRef} style={{ flex: 1 }} showsVerticalScrollIndicator>
|
|
199
|
+
<View style={{ flexDirection: "row", height: GRID_HEIGHT }}>
|
|
200
|
+
{/* Hour gutter */}
|
|
201
|
+
<View style={{ width: GUTTER }}>
|
|
202
|
+
{HOURS.map((h) =>
|
|
203
|
+
h === 0 ? null : (
|
|
204
|
+
<RNText
|
|
205
|
+
key={h}
|
|
206
|
+
style={{
|
|
207
|
+
position: "absolute",
|
|
208
|
+
top: h * HOUR_HEIGHT - 7,
|
|
209
|
+
right: 6,
|
|
210
|
+
fontSize: 11,
|
|
211
|
+
color: colors.zinc[400],
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{hourLabel(h)}
|
|
215
|
+
</RNText>
|
|
216
|
+
),
|
|
217
|
+
)}
|
|
218
|
+
</View>
|
|
219
|
+
{/* Day columns */}
|
|
220
|
+
{days.map((day, dayIdx) => {
|
|
221
|
+
const placed = layoutDayColumns(day, timedByDay[dayIdx]);
|
|
222
|
+
const today = isToday(day, now);
|
|
223
|
+
return (
|
|
224
|
+
<View key={day.toISOString()} style={styles.dayColumn}>
|
|
225
|
+
{HOURS.map((h) => (
|
|
226
|
+
<View key={h} style={{ position: "absolute", top: h * HOUR_HEIGHT, left: 0, right: 0, borderTopWidth: 1, borderTopColor: colors.zinc[100] }} />
|
|
227
|
+
))}
|
|
228
|
+
{placed.map((c) => (
|
|
229
|
+
<EventBlock
|
|
230
|
+
key={c.event.id}
|
|
231
|
+
event={c.event}
|
|
232
|
+
top={c.topMinutes * PX_PER_MIN}
|
|
233
|
+
height={c.heightMinutes * PX_PER_MIN}
|
|
234
|
+
leftPct={(c.column / c.columns) * 100}
|
|
235
|
+
widthPct={(1 / c.columns) * 100}
|
|
236
|
+
onPress={onEventPress}
|
|
237
|
+
locale={locale}
|
|
238
|
+
/>
|
|
239
|
+
))}
|
|
240
|
+
{today ? (
|
|
241
|
+
<View style={{ position: "absolute", top: minutesSinceMidnight(now) * PX_PER_MIN, left: 0, right: 0, height: 2, backgroundColor: colors.red[500] }}>
|
|
242
|
+
<View style={{ position: "absolute", left: -4, top: -3, width: 8, height: 8, borderRadius: 4, backgroundColor: colors.red[500] }} />
|
|
243
|
+
</View>
|
|
244
|
+
) : null}
|
|
245
|
+
</View>
|
|
246
|
+
);
|
|
247
|
+
})}
|
|
248
|
+
</View>
|
|
249
|
+
</ScrollView>
|
|
250
|
+
</View>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const styles = StyleSheet.create({
|
|
255
|
+
root: { flex: 1, backgroundColor: colors.white },
|
|
256
|
+
headerRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 6 },
|
|
257
|
+
headerCell: { flex: 1, alignItems: "center", gap: 3 },
|
|
258
|
+
dateBadge: { minWidth: 26, height: 26, borderRadius: 13, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
|
|
259
|
+
allDayRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 3 },
|
|
260
|
+
dayColumn: { flex: 1, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] },
|
|
261
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar primitive — data model. Mirrors the minimum shape every calendar API
|
|
3
|
+
* converges on ({title, start, end}) plus the layered extras (all-day, color).
|
|
4
|
+
* `data` carries the consumer's row so render/press callbacks stay typed.
|
|
5
|
+
*/
|
|
6
|
+
export interface CalendarEvent<T = unknown> {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
/** Event start (local time). For an all-day event, the day it begins. */
|
|
10
|
+
start: Date;
|
|
11
|
+
/** Exclusive end. Null/undefined → a zero-length point (treated as 30 min in the time grid). */
|
|
12
|
+
end?: Date | null;
|
|
13
|
+
/** All-day / multi-day banner event (rendered in the all-day row, not the time grid). */
|
|
14
|
+
allDay?: boolean;
|
|
15
|
+
/** Hex or design-token color for the event chip. */
|
|
16
|
+
color?: string | null;
|
|
17
|
+
data?: T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CalendarViewMode = "month" | "week" | "day";
|
|
21
|
+
|
|
22
|
+
/** 0 = Sunday … 6 = Saturday. */
|
|
23
|
+
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Geometry produced by the overlap layout for one timed event: which column it
|
|
27
|
+
* occupies and how many columns its overlap cluster spans. width = 1/columns,
|
|
28
|
+
* left = column/columns — concurrent events render side-by-side, FullCalendar-style.
|
|
29
|
+
*/
|
|
30
|
+
export interface EventColumn<T = unknown> {
|
|
31
|
+
event: CalendarEvent<T>;
|
|
32
|
+
/** Minutes from midnight of the grid day (clamped to [0, 1440]). */
|
|
33
|
+
topMinutes: number;
|
|
34
|
+
/** Height in minutes (>= a floor so very short events stay tappable). */
|
|
35
|
+
heightMinutes: number;
|
|
36
|
+
column: number;
|
|
37
|
+
columns: number;
|
|
38
|
+
}
|
package/src/download.ts
CHANGED
|
@@ -12,11 +12,23 @@
|
|
|
12
12
|
// No React Native / @lotics/shared imports — kept pure so both the host
|
|
13
13
|
// frontend and sandboxed custom-code apps can consume via the per-file
|
|
14
14
|
// export without dragging the wider UI surface.
|
|
15
|
+
//
|
|
16
|
+
// `credentials` defaults to `same-origin`: custom-code apps download presigned
|
|
17
|
+
// R2 URLs (cross-site, no cookie — sending one would trip R2's CORS). The host
|
|
18
|
+
// frontend downloads auth-gated proxy URLs on a sibling subdomain and passes
|
|
19
|
+
// `credentials: "include"` so the session cookie rides the cross-origin fetch.
|
|
15
20
|
|
|
16
|
-
export async function downloadFileFromUrl(
|
|
21
|
+
export async function downloadFileFromUrl(
|
|
22
|
+
url: string,
|
|
23
|
+
filename: string,
|
|
24
|
+
opts?: { credentials?: RequestCredentials },
|
|
25
|
+
): Promise<void> {
|
|
17
26
|
if (!url) throw new Error("downloadFileFromUrl: empty url");
|
|
18
27
|
|
|
19
|
-
const response = await fetch(url, {
|
|
28
|
+
const response = await fetch(url, {
|
|
29
|
+
cache: "no-store",
|
|
30
|
+
credentials: opts?.credentials ?? "same-origin",
|
|
31
|
+
});
|
|
20
32
|
if (!response.ok) {
|
|
21
33
|
throw new Error(`File download failed: ${response.status} ${response.statusText}`);
|
|
22
34
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { View, ScrollView, Pressable, StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "../text";
|
|
4
|
+
import { colors } from "../colors";
|
|
5
|
+
import { dayDiff } from "../calendar/dates";
|
|
6
|
+
import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
|
|
7
|
+
import type { GanttScale, GanttTask } from "./types";
|
|
8
|
+
|
|
9
|
+
const LABEL_W = 188;
|
|
10
|
+
const HEADER_H = 38;
|
|
11
|
+
const ROW_H = 40;
|
|
12
|
+
const BAR_H = 22;
|
|
13
|
+
const SCALE_LABELS: Record<GanttScale, string> = { day: "Ngày", week: "Tuần", month: "Tháng" };
|
|
14
|
+
const SCALES: GanttScale[] = ["day", "week", "month"];
|
|
15
|
+
|
|
16
|
+
export interface GanttViewProps<T = unknown> {
|
|
17
|
+
tasks: GanttTask<T>[];
|
|
18
|
+
defaultScale?: GanttScale;
|
|
19
|
+
today?: Date;
|
|
20
|
+
locale?: string;
|
|
21
|
+
onTaskPress?: (task: GanttTask<T>) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Timeline / gantt: a frozen task-label column beside a horizontally-scrollable
|
|
26
|
+
* zoomable axis. Bars are positioned by {@link barGeometry}; month boundaries get
|
|
27
|
+
* a heavier gridline; a red line marks today. Renders at its natural height — wrap
|
|
28
|
+
* in a ScrollView for very long task lists.
|
|
29
|
+
*/
|
|
30
|
+
export function GanttView<T = unknown>(props: GanttViewProps<T>) {
|
|
31
|
+
const { tasks, defaultScale = "week", today = new Date(), locale, onTaskPress } = props;
|
|
32
|
+
const [scale, setScale] = useState<GanttScale>(defaultScale);
|
|
33
|
+
|
|
34
|
+
const { start: axisStart, end: axisEnd } = useMemo(() => axisRange(tasks, today), [tasks, today]);
|
|
35
|
+
const ticks = useMemo(() => buildTicks(axisStart, axisEnd, scale, locale), [axisStart, axisEnd, scale, locale]);
|
|
36
|
+
const axisWidth = useMemo(() => (dayDiff(axisStart, axisEnd) + 1) * pxPerDay(scale), [axisStart, axisEnd, scale]);
|
|
37
|
+
const todayLeft = dayDiff(axisStart, today) * pxPerDay(scale);
|
|
38
|
+
const todayInRange = today >= axisStart && today <= axisEnd;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<View style={styles.root}>
|
|
42
|
+
{/* Toolbar: zoom */}
|
|
43
|
+
<View style={styles.toolbar}>
|
|
44
|
+
<Text size="sm" color="muted">Tiến độ dự án</Text>
|
|
45
|
+
<View style={styles.zoomSwitch}>
|
|
46
|
+
{SCALES.map((s) => (
|
|
47
|
+
<Pressable key={s} onPress={() => setScale(s)} accessibilityRole="button" style={[styles.zoomBtn, scale === s && styles.zoomBtnActive]}>
|
|
48
|
+
<Text size="sm" weight={scale === s ? "medium" : "regular"} color={scale === s ? "default" : "muted"}>
|
|
49
|
+
{SCALE_LABELS[s]}
|
|
50
|
+
</Text>
|
|
51
|
+
</Pressable>
|
|
52
|
+
))}
|
|
53
|
+
</View>
|
|
54
|
+
</View>
|
|
55
|
+
|
|
56
|
+
<View style={{ flexDirection: "row", flex: 1 }}>
|
|
57
|
+
{/* Frozen label column */}
|
|
58
|
+
<View style={{ width: LABEL_W, borderRightWidth: 1, borderRightColor: colors.border }}>
|
|
59
|
+
<View style={[styles.headerCell, { height: HEADER_H, paddingLeft: 12, justifyContent: "center" }]}>
|
|
60
|
+
<Text size="xs" color="muted">CÔNG VIỆC</Text>
|
|
61
|
+
</View>
|
|
62
|
+
{tasks.map((t) => (
|
|
63
|
+
<View key={t.id} style={[styles.labelRow, { height: ROW_H }]}>
|
|
64
|
+
<View style={{ width: 8, height: 8, borderRadius: 2, backgroundColor: t.color || colors.teal[600] }} />
|
|
65
|
+
<Text size="sm" numberOfLines={1} style={{ flex: 1 }}>{t.label}</Text>
|
|
66
|
+
</View>
|
|
67
|
+
))}
|
|
68
|
+
</View>
|
|
69
|
+
|
|
70
|
+
{/* Scrollable timeline */}
|
|
71
|
+
<ScrollView horizontal style={{ flex: 1 }} showsHorizontalScrollIndicator>
|
|
72
|
+
<View style={{ width: axisWidth }}>
|
|
73
|
+
{/* Header ticks */}
|
|
74
|
+
<View style={{ height: HEADER_H, flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
|
75
|
+
{ticks.map((tk) => (
|
|
76
|
+
<View
|
|
77
|
+
key={tk.date.toISOString()}
|
|
78
|
+
style={{ position: "absolute", left: tk.left, top: 0, bottom: 0, justifyContent: "center", paddingLeft: 4, borderLeftWidth: tk.monthStart ? 1 : 0, borderLeftColor: colors.zinc[300] }}
|
|
79
|
+
>
|
|
80
|
+
<Text size="xs" weight={tk.monthStart ? "medium" : "regular"} color={tk.monthStart ? "default" : "muted"}>
|
|
81
|
+
{tk.label}
|
|
82
|
+
</Text>
|
|
83
|
+
</View>
|
|
84
|
+
))}
|
|
85
|
+
</View>
|
|
86
|
+
|
|
87
|
+
{/* Task rows */}
|
|
88
|
+
{tasks.map((t) => {
|
|
89
|
+
const bar = barGeometry(t, axisStart, scale);
|
|
90
|
+
const accent = t.color || colors.teal[600];
|
|
91
|
+
return (
|
|
92
|
+
<View key={t.id} style={{ height: ROW_H, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] }}>
|
|
93
|
+
{ticks.map((tk) =>
|
|
94
|
+
tk.monthStart ? (
|
|
95
|
+
<View key={tk.date.toISOString()} style={{ position: "absolute", left: tk.left, top: 0, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[200] }} />
|
|
96
|
+
) : null,
|
|
97
|
+
)}
|
|
98
|
+
<Pressable
|
|
99
|
+
onPress={onTaskPress ? () => onTaskPress(t) : undefined}
|
|
100
|
+
accessibilityRole={onTaskPress ? "button" : undefined}
|
|
101
|
+
accessibilityLabel={t.label}
|
|
102
|
+
style={{ position: "absolute", left: bar.left, width: bar.width, top: (ROW_H - BAR_H) / 2, height: BAR_H }}
|
|
103
|
+
>
|
|
104
|
+
<View style={{ flex: 1, borderRadius: 5, backgroundColor: accent, justifyContent: "center", paddingHorizontal: 8 }}>
|
|
105
|
+
<Text size="xs" weight="medium" numberOfLines={1} style={{ color: colors.white }}>{t.label}</Text>
|
|
106
|
+
</View>
|
|
107
|
+
</Pressable>
|
|
108
|
+
</View>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
|
|
112
|
+
{/* Today marker */}
|
|
113
|
+
{todayInRange ? (
|
|
114
|
+
<View style={{ position: "absolute", left: todayLeft, top: 0, bottom: 0, width: 2, backgroundColor: colors.red[500] }} />
|
|
115
|
+
) : null}
|
|
116
|
+
</View>
|
|
117
|
+
</ScrollView>
|
|
118
|
+
</View>
|
|
119
|
+
</View>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const styles = StyleSheet.create({
|
|
124
|
+
root: { flex: 1, backgroundColor: colors.white },
|
|
125
|
+
toolbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: colors.border },
|
|
126
|
+
zoomSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 8, padding: 2 },
|
|
127
|
+
zoomBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 6 },
|
|
128
|
+
zoomBtnActive: { backgroundColor: colors.white },
|
|
129
|
+
headerCell: { borderBottomWidth: 1, borderBottomColor: colors.border },
|
|
130
|
+
labelRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] },
|
|
131
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
|
|
3
|
+
import type { GanttTask } from "./types";
|
|
4
|
+
|
|
5
|
+
const AXIS = new Date(2026, 0, 1); // Jan 1 2026
|
|
6
|
+
const task = (id: string, s: [number, number], e?: [number, number]): GanttTask => ({
|
|
7
|
+
id,
|
|
8
|
+
label: id,
|
|
9
|
+
start: new Date(2026, s[0], s[1]),
|
|
10
|
+
end: e ? new Date(2026, e[0], e[1]) : null,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("gantt scale", () => {
|
|
14
|
+
it("zoom widths shrink as the scale widens", () => {
|
|
15
|
+
expect(pxPerDay("day")).toBeGreaterThan(pxPerDay("week"));
|
|
16
|
+
expect(pxPerDay("week")).toBeGreaterThan(pxPerDay("month"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("positions a bar by day offset and inclusive span", () => {
|
|
20
|
+
// Jan 3 → Jan 5 inclusive = 3 days, 2 days after the axis start.
|
|
21
|
+
const b = barGeometry(task("a", [0, 3], [0, 5]), AXIS, "day");
|
|
22
|
+
expect(b.left).toBe(2 * 40);
|
|
23
|
+
expect(b.width).toBe(3 * 40);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("gives a single-day task one day of width (floored)", () => {
|
|
27
|
+
const b = barGeometry(task("m", [0, 10]), AXIS, "day");
|
|
28
|
+
expect(b.width).toBe(40);
|
|
29
|
+
const tiny = barGeometry(task("m", [0, 10]), AXIS, "month"); // 1 * 5px → floored to 8
|
|
30
|
+
expect(tiny.width).toBe(8);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("derives a padded axis window from the tasks", () => {
|
|
34
|
+
const { start, end } = axisRange([task("a", [0, 5], [0, 8]), task("b", [0, 3], [0, 20])], new Date(2026, 0, 1), 3);
|
|
35
|
+
expect(start).toEqual(new Date(2026, 0, 0)); // Jan 3 - 3 = Dec 31 (= Jan 0)
|
|
36
|
+
expect(end).toEqual(new Date(2026, 0, 23)); // Jan 20 + 3
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("emits one day-tick per day and flags month starts", () => {
|
|
40
|
+
const ticks = buildTicks(new Date(2026, 0, 30), new Date(2026, 1, 2), "day");
|
|
41
|
+
expect(ticks.map((t) => t.label)).toEqual(["30", "31", "1", "2"]);
|
|
42
|
+
expect(ticks.find((t) => t.label === "1")?.monthStart).toBe(true);
|
|
43
|
+
expect(ticks.find((t) => t.label === "30")?.monthStart).toBe(false);
|
|
44
|
+
expect(ticks[0].left).toBe(0);
|
|
45
|
+
expect(ticks[1].left).toBe(40); // one day across at day scale
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { addDays, dayDiff, startOfDay, startOfMonth, startOfWeek } from "../calendar/dates";
|
|
2
|
+
import type { Weekday } from "../calendar/types";
|
|
3
|
+
import type { GanttBar, GanttScale, GanttTask, GanttTick } from "./types";
|
|
4
|
+
|
|
5
|
+
const MIN_BAR_PX = 8;
|
|
6
|
+
|
|
7
|
+
/** Pixels per day at each zoom. Day = roomy single days; month = dense quarters. */
|
|
8
|
+
export function pxPerDay(scale: GanttScale): number {
|
|
9
|
+
switch (scale) {
|
|
10
|
+
case "day":
|
|
11
|
+
return 40;
|
|
12
|
+
case "week":
|
|
13
|
+
return 16;
|
|
14
|
+
case "month":
|
|
15
|
+
return 5;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The axis window: from the earliest task start to the latest end, padded so
|
|
20
|
+
* bars never hug the edges. Falls back to a month around `today` when empty. */
|
|
21
|
+
export function axisRange(tasks: GanttTask[], today: Date = new Date(), padDays = 3): { start: Date; end: Date } {
|
|
22
|
+
if (tasks.length === 0) {
|
|
23
|
+
return { start: addDays(today, -padDays), end: addDays(today, 30) };
|
|
24
|
+
}
|
|
25
|
+
let min = tasks[0].start;
|
|
26
|
+
let max = tasks[0].end ?? tasks[0].start;
|
|
27
|
+
for (const t of tasks) {
|
|
28
|
+
if (t.start < min) min = t.start;
|
|
29
|
+
const e = t.end ?? t.start;
|
|
30
|
+
if (e > max) max = e;
|
|
31
|
+
}
|
|
32
|
+
return { start: addDays(startOfDay(min), -padDays), end: addDays(startOfDay(max), padDays) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Bar geometry: left = days from axis start; width spans the inclusive end. */
|
|
36
|
+
export function barGeometry<T>(task: GanttTask<T>, axisStart: Date, scale: GanttScale): GanttBar<T> {
|
|
37
|
+
const ppd = pxPerDay(scale);
|
|
38
|
+
const left = dayDiff(axisStart, task.start) * ppd;
|
|
39
|
+
const endDay = task.end ?? task.start;
|
|
40
|
+
const days = Math.max(1, dayDiff(task.start, endDay) + 1); // inclusive
|
|
41
|
+
return { task, left, width: Math.max(MIN_BAR_PX, days * ppd) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Header ticks across [start, end]. Granularity follows the zoom: a tick per day
|
|
46
|
+
* (day), per week (week), or per month (month). Each tick flags a month start so
|
|
47
|
+
* the view can draw a heavier divider / month band — the monday.com two-level feel.
|
|
48
|
+
*/
|
|
49
|
+
export function buildTicks(
|
|
50
|
+
axisStart: Date,
|
|
51
|
+
axisEnd: Date,
|
|
52
|
+
scale: GanttScale,
|
|
53
|
+
locale?: string,
|
|
54
|
+
weekStartsOn: Weekday = 1,
|
|
55
|
+
): GanttTick[] {
|
|
56
|
+
const ppd = pxPerDay(scale);
|
|
57
|
+
const ticks: GanttTick[] = [];
|
|
58
|
+
const at = (date: Date, label: string): GanttTick => ({
|
|
59
|
+
date,
|
|
60
|
+
left: dayDiff(axisStart, date) * ppd,
|
|
61
|
+
label,
|
|
62
|
+
monthStart: date.getDate() === 1,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (scale === "month") {
|
|
66
|
+
let cursor = startOfMonth(axisStart);
|
|
67
|
+
const fmt = new Intl.DateTimeFormat(locale, { month: "short", year: "2-digit" });
|
|
68
|
+
while (cursor <= axisEnd) {
|
|
69
|
+
ticks.push(at(cursor, fmt.format(cursor)));
|
|
70
|
+
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
|
|
71
|
+
}
|
|
72
|
+
return ticks;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (scale === "week") {
|
|
76
|
+
let cursor = startOfWeek(axisStart, weekStartsOn);
|
|
77
|
+
const fmt = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" });
|
|
78
|
+
while (cursor <= axisEnd) {
|
|
79
|
+
ticks.push(at(cursor, fmt.format(cursor)));
|
|
80
|
+
cursor = addDays(cursor, 7);
|
|
81
|
+
}
|
|
82
|
+
return ticks;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// day
|
|
86
|
+
let cursor = startOfDay(axisStart);
|
|
87
|
+
while (cursor <= axisEnd) {
|
|
88
|
+
ticks.push(at(cursor, String(cursor.getDate())));
|
|
89
|
+
cursor = addDays(cursor, 1);
|
|
90
|
+
}
|
|
91
|
+
return ticks;
|
|
92
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gantt / timeline primitive — data model. A task is a labelled bar spanning
|
|
3
|
+
* [start, end] (end inclusive — a one-day task has start === end's day). `data`
|
|
4
|
+
* carries the consumer's row for render/press callbacks.
|
|
5
|
+
*/
|
|
6
|
+
export interface GanttTask<T = unknown> {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
start: Date;
|
|
10
|
+
/** Inclusive end day. Null/undefined → a single-day (milestone-ish) bar. */
|
|
11
|
+
end?: Date | null;
|
|
12
|
+
color?: string | null;
|
|
13
|
+
/** Optional grouping lane label (e.g. project / assignee). */
|
|
14
|
+
group?: string;
|
|
15
|
+
data?: T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Zoom level of the horizontal time axis. */
|
|
19
|
+
export type GanttScale = "day" | "week" | "month";
|
|
20
|
+
|
|
21
|
+
/** A header tick on the time axis. */
|
|
22
|
+
export interface GanttTick {
|
|
23
|
+
date: Date;
|
|
24
|
+
/** px offset from the axis start. */
|
|
25
|
+
left: number;
|
|
26
|
+
label: string;
|
|
27
|
+
/** Start of a new month — used to draw the heavier divider + month band. */
|
|
28
|
+
monthStart: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pixel geometry of one task bar on the axis. */
|
|
32
|
+
export interface GanttBar<T = unknown> {
|
|
33
|
+
task: GanttTask<T>;
|
|
34
|
+
left: number;
|
|
35
|
+
width: number;
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Column layout
|
|
2
|
+
export const DEFAULT_COLUMN_WIDTH = 280;
|
|
3
|
+
export const MINIMIZED_COLUMN_WIDTH = 44;
|
|
4
|
+
export const DEFAULT_COLUMN_GAP = 16;
|
|
5
|
+
export const DEFAULT_ITEM_GAP = 8;
|
|
6
|
+
export const COLUMN_CONTENT_PADDING = 12;
|
|
7
|
+
|
|
8
|
+
// Auto-scroll behavior
|
|
9
|
+
export const AUTO_SCROLL_THRESHOLD = 60;
|
|
10
|
+
export const AUTO_SCROLL_SPEED = 8;
|
|
11
|
+
|
|
12
|
+
// Drag detection
|
|
13
|
+
export const DRAG_THRESHOLD = 5;
|
|
14
|
+
|
|
15
|
+
// Touch-specific drag activation (like dnd-kit's delay activation)
|
|
16
|
+
// On touch devices, require a hold before drag can start to distinguish from tap
|
|
17
|
+
export const TOUCH_DRAG_DELAY = 200; // ms - hold time before drag activates on touch
|
|
18
|
+
export const TOUCH_TOLERANCE = 10; // px - max movement allowed during delay period
|