@lotics/ui 1.9.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 CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
7
7
  "./colors": "./src/colors.ts",
8
8
  "./mime": "./src/mime.ts",
9
+ "./download": "./src/download.ts",
9
10
  "./file_badge": "./src/file_badge.tsx",
10
11
  "./file_thumbnail": "./src/file_thumbnail.tsx",
11
12
  "./file_gallery_modal": "./src/file_gallery_modal.tsx",
@@ -18,6 +19,10 @@
18
19
  "./trend_chip": "./src/trend_chip.tsx",
19
20
  "./section_card": "./src/section_card.tsx",
20
21
  "./kpi_card": "./src/kpi_card.tsx",
22
+ "./kanban": "./src/kanban/index.ts",
23
+ "./calendar": "./src/calendar/index.ts",
24
+ "./gantt": "./src/gantt/index.ts",
25
+ "./ring_gauge": "./src/ring_gauge.tsx",
21
26
  "./alert_row": "./src/alert_row.tsx",
22
27
  "./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
23
28
  "./legend_item": "./src/legend_item.tsx",
@@ -0,0 +1,122 @@
1
+ import { useState } from "react";
2
+ import { View, Pressable, StyleSheet } from "react-native";
3
+ import { Text } from "../text";
4
+ import { colors } from "../colors";
5
+ import { MonthView } from "./month_view";
6
+ import { TimeGridView } from "./time_grid_view";
7
+ import { addDays, addMonths, viewTitle } from "./dates";
8
+ import type { CalendarEvent, CalendarViewMode, Weekday } from "./types";
9
+
10
+ const VIEW_LABELS: Record<CalendarViewMode, string> = { month: "Tháng", week: "Tuần", day: "Ngày" };
11
+ const VIEW_ORDER: CalendarViewMode[] = ["month", "week", "day"];
12
+
13
+ export interface CalendarViewProps<T = unknown> {
14
+ events: CalendarEvent<T>[];
15
+ defaultView?: CalendarViewMode;
16
+ defaultDate?: Date;
17
+ weekStartsOn?: Weekday;
18
+ locale?: string;
19
+ onEventPress?: (event: CalendarEvent<T>) => void;
20
+ onDayPress?: (day: Date) => void;
21
+ }
22
+
23
+ /**
24
+ * Full calendar: a toolbar (prev / today / next + month·week·day switch) over
25
+ * the month grid or the week/day time grid. Date + view are owned here so the
26
+ * primitive is drop-in; the consumer only supplies events + callbacks.
27
+ */
28
+ export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
29
+ const { events, defaultView = "month", defaultDate, weekStartsOn = 1, locale, onEventPress, onDayPress } = props;
30
+ const [view, setView] = useState<CalendarViewMode>(defaultView);
31
+ const [date, setDate] = useState<Date>(defaultDate ?? new Date());
32
+
33
+ const step = (dir: number) =>
34
+ setDate((d) => (view === "month" ? addMonths(d, dir) : addDays(d, dir * (view === "week" ? 7 : 1))));
35
+
36
+ // Drilling into a day (month "+N more" or a day cell) opens the day grid.
37
+ const drillToDay = (day: Date) => {
38
+ setDate(day);
39
+ setView("day");
40
+ onDayPress?.(day);
41
+ };
42
+
43
+ return (
44
+ <View style={styles.root}>
45
+ <View style={styles.toolbar}>
46
+ <View style={styles.navGroup}>
47
+ <Pressable onPress={() => step(-1)} accessibilityRole="button" accessibilityLabel="Trước" style={styles.iconBtn}>
48
+ <Text size="lg" color="muted">‹</Text>
49
+ </Pressable>
50
+ <Pressable onPress={() => setDate(new Date())} accessibilityRole="button" style={styles.todayBtn}>
51
+ <Text size="sm" weight="medium">Hôm nay</Text>
52
+ </Pressable>
53
+ <Pressable onPress={() => step(1)} accessibilityRole="button" accessibilityLabel="Sau" style={styles.iconBtn}>
54
+ <Text size="lg" color="muted">›</Text>
55
+ </Pressable>
56
+ </View>
57
+
58
+ <Text size="lg" weight="semibold" style={styles.title} numberOfLines={1}>
59
+ {viewTitle(view, date, weekStartsOn, locale)}
60
+ </Text>
61
+
62
+ <View style={styles.viewSwitch}>
63
+ {VIEW_ORDER.map((v) => (
64
+ <Pressable
65
+ key={v}
66
+ onPress={() => setView(v)}
67
+ accessibilityRole="button"
68
+ style={[styles.viewBtn, view === v && styles.viewBtnActive]}
69
+ >
70
+ <Text size="sm" weight={view === v ? "medium" : "regular"} color={view === v ? "default" : "muted"}>
71
+ {VIEW_LABELS[v]}
72
+ </Text>
73
+ </Pressable>
74
+ ))}
75
+ </View>
76
+ </View>
77
+
78
+ <View style={{ flex: 1 }}>
79
+ {view === "month" ? (
80
+ <MonthView
81
+ date={date}
82
+ events={events}
83
+ weekStartsOn={weekStartsOn}
84
+ locale={locale}
85
+ onEventPress={onEventPress}
86
+ onDayPress={drillToDay}
87
+ />
88
+ ) : (
89
+ <TimeGridView
90
+ mode={view}
91
+ date={date}
92
+ events={events}
93
+ weekStartsOn={weekStartsOn}
94
+ locale={locale}
95
+ onEventPress={onEventPress}
96
+ />
97
+ )}
98
+ </View>
99
+ </View>
100
+ );
101
+ }
102
+
103
+ const styles = StyleSheet.create({
104
+ root: { flex: 1, backgroundColor: colors.white },
105
+ toolbar: {
106
+ flexDirection: "row",
107
+ alignItems: "center",
108
+ justifyContent: "space-between",
109
+ paddingHorizontal: 14,
110
+ paddingVertical: 10,
111
+ gap: 12,
112
+ borderBottomWidth: 1,
113
+ borderBottomColor: colors.border,
114
+ },
115
+ navGroup: { flexDirection: "row", alignItems: "center", gap: 4 },
116
+ iconBtn: { width: 30, height: 30, borderRadius: 6, alignItems: "center", justifyContent: "center" },
117
+ todayBtn: { paddingHorizontal: 12, height: 30, borderRadius: 6, borderWidth: 1, borderColor: colors.border, alignItems: "center", justifyContent: "center" },
118
+ title: { flex: 1, textAlign: "center" },
119
+ viewSwitch: { flexDirection: "row", backgroundColor: colors.zinc[100], borderRadius: 8, padding: 2 },
120
+ viewBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 6 },
121
+ viewBtnActive: { backgroundColor: colors.white },
122
+ });
@@ -0,0 +1,102 @@
1
+ import type { CalendarViewMode, Weekday } from "./types";
2
+
3
+ /**
4
+ * Self-contained date helpers (native `Date` + `Intl`) — no date library, so the
5
+ * calendar primitive adds no peer dependency to apps. All math is local-time, as
6
+ * a calendar displays; we never cross into UTC arithmetic.
7
+ */
8
+
9
+ export const MS_PER_DAY = 86_400_000;
10
+
11
+ export function startOfDay(d: Date): Date {
12
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
13
+ }
14
+
15
+ export function addDays(d: Date, n: number): Date {
16
+ // Construct via Y/M/D so DST day-boundary shifts can't drift the calendar day.
17
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
18
+ }
19
+
20
+ export function startOfWeek(d: Date, weekStartsOn: Weekday = 1): Date {
21
+ const day = d.getDay();
22
+ const diff = (day - weekStartsOn + 7) % 7;
23
+ return addDays(startOfDay(d), -diff);
24
+ }
25
+
26
+ export function startOfMonth(d: Date): Date {
27
+ return new Date(d.getFullYear(), d.getMonth(), 1);
28
+ }
29
+
30
+ export function endOfMonth(d: Date): Date {
31
+ return new Date(d.getFullYear(), d.getMonth() + 1, 0);
32
+ }
33
+
34
+ export function addMonths(d: Date, n: number): Date {
35
+ return new Date(d.getFullYear(), d.getMonth() + n, d.getDate());
36
+ }
37
+
38
+ export function isSameDay(a: Date, b: Date): boolean {
39
+ return (
40
+ a.getFullYear() === b.getFullYear() &&
41
+ a.getMonth() === b.getMonth() &&
42
+ a.getDate() === b.getDate()
43
+ );
44
+ }
45
+
46
+ export function isToday(d: Date, now: Date = new Date()): boolean {
47
+ return isSameDay(d, now);
48
+ }
49
+
50
+ export function isSameMonth(a: Date, b: Date): boolean {
51
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
52
+ }
53
+
54
+ /** Whole calendar days from `a`'s day to `b`'s day (b - a), DST-safe. */
55
+ export function dayDiff(a: Date, b: Date): number {
56
+ return Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / MS_PER_DAY);
57
+ }
58
+
59
+ export function minutesSinceMidnight(d: Date): number {
60
+ return d.getHours() * 60 + d.getMinutes();
61
+ }
62
+
63
+ /** The day columns a view spans: 7 for week, 1 for day, the 6-week grid for month. */
64
+ export function daysInView(mode: CalendarViewMode, date: Date, weekStartsOn: Weekday = 1): Date[] {
65
+ if (mode === "day") return [startOfDay(date)];
66
+ if (mode === "week") {
67
+ const start = startOfWeek(date, weekStartsOn);
68
+ return Array.from({ length: 7 }, (_, i) => addDays(start, i));
69
+ }
70
+ // month: full weeks covering the month → always 6 rows for a stable grid height.
71
+ const gridStart = startOfWeek(startOfMonth(date), weekStartsOn);
72
+ return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
73
+ }
74
+
75
+ // ─── Localized labels (Intl, browser/workspace locale) ──────────────────────
76
+
77
+ export function weekdayShort(d: Date, locale?: string): string {
78
+ return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(d);
79
+ }
80
+
81
+ export function formatTime(d: Date, locale?: string): string {
82
+ return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", hour12: false }).format(d);
83
+ }
84
+
85
+ /** "0:00".."23:00" rail labels — 24-hour, unambiguous across locales. */
86
+ export function hourLabel(hour: number): string {
87
+ return `${String(hour).padStart(2, "0")}:00`;
88
+ }
89
+
90
+ /** Header title: "March 2026" (month) or the week/day range. */
91
+ export function viewTitle(mode: CalendarViewMode, date: Date, weekStartsOn: Weekday, locale?: string): string {
92
+ if (mode === "month") {
93
+ return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
94
+ }
95
+ if (mode === "day") {
96
+ return new Intl.DateTimeFormat(locale, { weekday: "long", day: "numeric", month: "long", year: "numeric" }).format(date);
97
+ }
98
+ const start = startOfWeek(date, weekStartsOn);
99
+ const end = addDays(start, 6);
100
+ const md = (x: Date) => new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).format(x);
101
+ return `${md(start)} – ${md(end)}, ${end.getFullYear()}`;
102
+ }
@@ -0,0 +1,19 @@
1
+ export { CalendarView } from "./calendar_view";
2
+ export type { CalendarViewProps } from "./calendar_view";
3
+ export { TimeGridView } from "./time_grid_view";
4
+ export type { TimeGridViewProps } from "./time_grid_view";
5
+ export { MonthView } from "./month_view";
6
+ export type { MonthViewProps } from "./month_view";
7
+ export { layoutDayColumns, packEventLanes } from "./layout";
8
+ export type { LaneBar } from "./layout";
9
+ export type { CalendarEvent, CalendarViewMode, Weekday, EventColumn } from "./types";
10
+ export {
11
+ addDays,
12
+ addMonths,
13
+ startOfWeek,
14
+ startOfMonth,
15
+ daysInView,
16
+ isSameDay,
17
+ isToday,
18
+ viewTitle,
19
+ } from "./dates";
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { layoutDayColumns, packEventLanes } from "./layout";
3
+ import type { CalendarEvent } from "./types";
4
+
5
+ const DAY = new Date(2026, 0, 15);
6
+ const at = (h: number, m = 0) => new Date(2026, 0, 15, h, m);
7
+ const ev = (id: string, sh: number, sm: number, eh: number, em: number): CalendarEvent => ({
8
+ id,
9
+ title: id,
10
+ start: at(sh, sm),
11
+ end: at(eh, em),
12
+ });
13
+
14
+ /** column/columns keyed by event id, for order-independent assertions. */
15
+ function geom(events: CalendarEvent[]) {
16
+ return Object.fromEntries(
17
+ layoutDayColumns(DAY, events).map((c) => [c.event.id, { column: c.column, columns: c.columns }]),
18
+ );
19
+ }
20
+
21
+ describe("layoutDayColumns", () => {
22
+ it("returns nothing for an empty day", () => {
23
+ expect(layoutDayColumns(DAY, [])).toEqual([]);
24
+ });
25
+
26
+ it("stacks non-overlapping events in a single column", () => {
27
+ const g = geom([ev("a", 9, 0, 10, 0), ev("b", 11, 0, 12, 0)]);
28
+ expect(g.a).toEqual({ column: 0, columns: 1 });
29
+ expect(g.b).toEqual({ column: 0, columns: 1 });
30
+ });
31
+
32
+ it("splits two fully overlapping events into two columns", () => {
33
+ const g = geom([ev("a", 9, 0, 10, 0), ev("b", 9, 0, 10, 0)]);
34
+ expect(g.a.columns).toBe(2);
35
+ expect(g.b.columns).toBe(2);
36
+ expect(new Set([g.a.column, g.b.column])).toEqual(new Set([0, 1]));
37
+ });
38
+
39
+ it("gives three concurrent events three columns", () => {
40
+ const g = geom([ev("a", 9, 0, 12, 0), ev("b", 9, 0, 12, 0), ev("c", 9, 0, 12, 0)]);
41
+ expect(new Set([g.a.column, g.b.column, g.c.column])).toEqual(new Set([0, 1, 2]));
42
+ for (const k of ["a", "b", "c"]) expect(g[k].columns).toBe(3);
43
+ });
44
+
45
+ it("reuses a freed column within a transitive cluster (A→B→C chain)", () => {
46
+ // A 9-10, B 9:30-11 (overlaps A), C 10:30-12 (overlaps B, not A).
47
+ // One cluster via B; 2 columns; C reuses A's column once A ends.
48
+ const g = geom([ev("a", 9, 0, 10, 0), ev("b", 9, 30, 11, 0), ev("c", 10, 30, 12, 0)]);
49
+ expect(g.a.columns).toBe(2);
50
+ expect(g.a.column).toBe(0);
51
+ expect(g.b.column).toBe(1);
52
+ expect(g.c.column).toBe(0); // freed by A
53
+ });
54
+
55
+ it("treats time-separated events as independent clusters", () => {
56
+ const g = geom([ev("morn1", 9, 0, 10, 0), ev("morn2", 9, 0, 10, 0), ev("aft", 14, 0, 15, 0)]);
57
+ expect(g.morn1.columns).toBe(2);
58
+ expect(g.morn2.columns).toBe(2);
59
+ expect(g.aft).toEqual({ column: 0, columns: 1 }); // own cluster
60
+ });
61
+
62
+ it("positions and floors height; clamps to the day window", () => {
63
+ const [c] = layoutDayColumns(DAY, [ev("x", 9, 0, 9, 5)]);
64
+ expect(c.topMinutes).toBe(540); // 9:00
65
+ expect(c.heightMinutes).toBeGreaterThanOrEqual(20); // 5-min event floored
66
+ const spill = layoutDayColumns(DAY, [
67
+ { id: "y", title: "y", start: at(23, 0), end: new Date(2026, 0, 16, 2, 0) },
68
+ ])[0];
69
+ expect(spill.topMinutes).toBe(1380); // 23:00
70
+ expect(spill.topMinutes + spill.heightMinutes).toBeLessThanOrEqual(1440); // clamped
71
+ });
72
+ });
73
+
74
+ describe("packEventLanes", () => {
75
+ const WEEK = new Date(2026, 0, 12); // Mon
76
+ const allDay = (id: string, startDay: number, endDay: number): CalendarEvent => ({
77
+ id,
78
+ title: id,
79
+ start: new Date(2026, 0, 12 + startDay),
80
+ end: new Date(2026, 0, 12 + endDay),
81
+ allDay: true,
82
+ });
83
+
84
+ it("places non-overlapping spans in one lane", () => {
85
+ const { bars, lanes } = packEventLanes([allDay("a", 0, 1), allDay("b", 3, 4)], WEEK, 7);
86
+ expect(lanes).toBe(1);
87
+ expect(bars.find((b) => b.event.id === "a")).toMatchObject({ startCol: 0, span: 2, lane: 0 });
88
+ expect(bars.find((b) => b.event.id === "b")).toMatchObject({ startCol: 3, span: 2, lane: 0 });
89
+ });
90
+
91
+ it("stacks overlapping multi-day spans into separate lanes", () => {
92
+ const { lanes } = packEventLanes([allDay("a", 0, 3), allDay("b", 2, 5)], WEEK, 7);
93
+ expect(lanes).toBe(2);
94
+ });
95
+
96
+ it("clamps a span that starts before the window and filters out-of-range", () => {
97
+ const before: CalendarEvent = { id: "x", title: "x", start: new Date(2026, 0, 9), end: new Date(2026, 0, 13), allDay: true };
98
+ const after = allDay("y", 9, 10); // entirely after a 7-day window
99
+ const { bars } = packEventLanes([before, after], WEEK, 7);
100
+ expect(bars).toHaveLength(1);
101
+ expect(bars[0]).toMatchObject({ event: { id: "x" }, startCol: 0, span: 2 }); // clamped to days 0..1
102
+ });
103
+ });
@@ -0,0 +1,142 @@
1
+ import { dayDiff } from "./dates";
2
+ import type { CalendarEvent, EventColumn } from "./types";
3
+
4
+ /** A horizontal banner placement: which day column it starts on, how many days
5
+ * it spans, and which lane (row) it sits in. Used by the month grid and the
6
+ * week's all-day row. */
7
+ export interface LaneBar<T = unknown> {
8
+ event: CalendarEvent<T>;
9
+ startCol: number;
10
+ span: number;
11
+ lane: number;
12
+ }
13
+
14
+ /**
15
+ * Pack day-spanning events into horizontal lanes across a `numDays` window
16
+ * starting at `rangeStart`. Greedy first-fit: an event takes the topmost lane
17
+ * whose last bar ends before this event starts, else a new lane. Multi-day
18
+ * events become wide bars; single-day events are span-1 bars. Deterministic.
19
+ */
20
+ export function packEventLanes<T>(
21
+ events: CalendarEvent<T>[],
22
+ rangeStart: Date,
23
+ numDays: number,
24
+ ): { bars: LaneBar<T>[]; lanes: number } {
25
+ const items = events
26
+ .map((event) => ({
27
+ event,
28
+ startCol: Math.max(0, dayDiff(rangeStart, event.start)),
29
+ endCol: Math.min(numDays - 1, dayDiff(rangeStart, event.end ?? event.start)),
30
+ }))
31
+ .filter((it) => it.endCol >= 0 && it.startCol <= numDays - 1 && it.endCol >= it.startCol)
32
+ .sort((a, b) => a.startCol - b.startCol || b.endCol - a.endCol);
33
+
34
+ const laneEnds: number[] = [];
35
+ const bars = items.map((it) => {
36
+ let lane = laneEnds.findIndex((end) => end < it.startCol);
37
+ if (lane === -1) {
38
+ lane = laneEnds.length;
39
+ laneEnds.push(it.endCol);
40
+ } else {
41
+ laneEnds[lane] = it.endCol;
42
+ }
43
+ return { event: it.event, startCol: it.startCol, span: it.endCol - it.startCol + 1, lane };
44
+ });
45
+ return { bars, lanes: laneEnds.length };
46
+ }
47
+
48
+ const MINUTES_PER_DAY = 1440;
49
+ /** Short events still need a tappable height; floor the rendered span. */
50
+ const MIN_EVENT_MINUTES = 20;
51
+
52
+ function minutesInto(day: Date, t: Date): number {
53
+ const ms = t.getTime() - day.getTime();
54
+ return ms / 60000;
55
+ }
56
+
57
+ /**
58
+ * Position timed events for a single grid day into side-by-side columns.
59
+ *
60
+ * The defining piece of a real calendar: when events overlap in time they must
61
+ * share the day's width rather than stack on top of each other. We
62
+ * 1. clamp each event to the [0, 1440] minute window of `day`,
63
+ * 2. split events into overlap *clusters* (a maximal run where each event
64
+ * overlaps at least one earlier event in the run — transitive),
65
+ * 3. greedily first-fit each cluster's events into columns (a column is free
66
+ * once its last event ends at/before the next one starts),
67
+ * 4. give every event in a cluster the same `columns` count (the cluster's
68
+ * column total) so widths line up: width = 1/columns, left = column/columns.
69
+ *
70
+ * Pure and deterministic — input order doesn't matter (we sort). All-day events
71
+ * are the caller's concern (the all-day row); pass only timed events here.
72
+ */
73
+ export function layoutDayColumns<T>(
74
+ day: Date,
75
+ events: CalendarEvent<T>[],
76
+ ): EventColumn<T>[] {
77
+ const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate());
78
+
79
+ const placed = events
80
+ .map((event) => {
81
+ const rawTop = minutesInto(dayStart, event.start);
82
+ const rawEnd = event.end ? minutesInto(dayStart, event.end) : rawTop + MIN_EVENT_MINUTES;
83
+ const topMinutes = Math.max(0, Math.min(MINUTES_PER_DAY, rawTop));
84
+ const endMinutes = Math.max(topMinutes, Math.min(MINUTES_PER_DAY, rawEnd));
85
+ return {
86
+ event,
87
+ topMinutes,
88
+ endMinutes,
89
+ heightMinutes: Math.max(MIN_EVENT_MINUTES, endMinutes - topMinutes),
90
+ };
91
+ })
92
+ // Earliest first; on a tie the longer event leads so it anchors column 0.
93
+ .sort((a, b) => a.topMinutes - b.topMinutes || b.heightMinutes - a.heightMinutes);
94
+
95
+ const result: EventColumn<T>[] = [];
96
+ let cluster: typeof placed = [];
97
+ let clusterEnd = -1;
98
+
99
+ const flush = () => {
100
+ if (cluster.length === 0) return;
101
+ // First-fit column assignment within the cluster.
102
+ const columnEnds: number[] = []; // running end-minute per column
103
+ const assigned = cluster.map((item) => {
104
+ // The visual span (incl. the min-height floor) is what must not collide,
105
+ // otherwise a short event floored to 20min could overlap the next chip.
106
+ const itemVisualEnd = item.topMinutes + item.heightMinutes;
107
+ let col = columnEnds.findIndex((end) => end <= item.topMinutes);
108
+ if (col === -1) {
109
+ col = columnEnds.length;
110
+ columnEnds.push(itemVisualEnd);
111
+ } else {
112
+ columnEnds[col] = itemVisualEnd;
113
+ }
114
+ return { item, col };
115
+ });
116
+ const columns = columnEnds.length;
117
+ for (const { item, col } of assigned) {
118
+ result.push({
119
+ event: item.event,
120
+ topMinutes: item.topMinutes,
121
+ heightMinutes: item.heightMinutes,
122
+ column: col,
123
+ columns,
124
+ });
125
+ }
126
+ cluster = [];
127
+ clusterEnd = -1;
128
+ };
129
+
130
+ for (const item of placed) {
131
+ const visualEnd = item.topMinutes + item.heightMinutes;
132
+ if (cluster.length > 0 && item.topMinutes >= clusterEnd) {
133
+ // No overlap with anything still open → previous cluster is closed.
134
+ flush();
135
+ }
136
+ cluster.push(item);
137
+ clusterEnd = Math.max(clusterEnd, visualEnd);
138
+ }
139
+ flush();
140
+
141
+ return result;
142
+ }
@@ -0,0 +1,157 @@
1
+ import { useMemo } from "react";
2
+ import { View, Pressable, StyleSheet } from "react-native";
3
+ import { Text } from "../text";
4
+ import { colors } from "../colors";
5
+ import { packEventLanes } from "./layout";
6
+ import { daysInView, isSameMonth, isToday, weekdayShort } from "./dates";
7
+ import type { CalendarEvent, Weekday } from "./types";
8
+
9
+ const LANE_H = 19;
10
+ const LANE_GAP = 2;
11
+ const MAX_LANES = 3; // visible event lanes per week before "+N more"
12
+
13
+ export interface MonthViewProps<T = unknown> {
14
+ date: Date;
15
+ events: CalendarEvent<T>[];
16
+ weekStartsOn?: Weekday;
17
+ locale?: string;
18
+ onEventPress?: (event: CalendarEvent<T>) => void;
19
+ onDayPress?: (day: Date) => void;
20
+ }
21
+
22
+ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
23
+ const { date, events, weekStartsOn = 1, locale, onEventPress, onDayPress } = props;
24
+ const days = useMemo(() => daysInView("month", date, weekStartsOn), [date, weekStartsOn]);
25
+ const weeks = useMemo(() => Array.from({ length: 6 }, (_, w) => days.slice(w * 7, w * 7 + 7)), [days]);
26
+ const weekdayLabels = useMemo(() => days.slice(0, 7).map((d) => weekdayShort(d, locale)), [days, locale]);
27
+ const now = new Date();
28
+
29
+ return (
30
+ <View style={styles.root}>
31
+ <View style={styles.weekdayHeader}>
32
+ {weekdayLabels.map((label, i) => (
33
+ <View key={i} style={styles.weekdayCell}>
34
+ <Text size="xs" color="muted">{label.toUpperCase()}</Text>
35
+ </View>
36
+ ))}
37
+ </View>
38
+
39
+ {weeks.map((weekDays, w) => {
40
+ const { bars } = packEventLanes(events, weekDays[0], 7);
41
+ const visible = bars.filter((b) => b.lane < MAX_LANES);
42
+ const overflow = Array.from({ length: 7 }, () => 0);
43
+ for (const b of bars) {
44
+ if (b.lane >= MAX_LANES) {
45
+ for (let c = b.startCol; c < b.startCol + b.span && c < 7; c++) overflow[c]++;
46
+ }
47
+ }
48
+
49
+ return (
50
+ <View key={w} style={styles.weekRow}>
51
+ {/* Date numbers */}
52
+ <View style={styles.dateRow}>
53
+ {weekDays.map((day) => {
54
+ const inMonth = isSameMonth(day, date);
55
+ const today = isToday(day, now);
56
+ return (
57
+ <Pressable
58
+ key={day.toISOString()}
59
+ onPress={onDayPress ? () => onDayPress(day) : undefined}
60
+ accessibilityRole={onDayPress ? "button" : undefined}
61
+ style={styles.dateCell}
62
+ >
63
+ <View style={[styles.dateBadge, today && { backgroundColor: colors.teal[600] }]}>
64
+ <Text
65
+ size="xs"
66
+ weight={today ? "semibold" : "regular"}
67
+ style={{ color: today ? colors.white : inMonth ? colors.zinc[800] : colors.zinc[400] }}
68
+ >
69
+ {day.getDate()}
70
+ </Text>
71
+ </View>
72
+ </Pressable>
73
+ );
74
+ })}
75
+ </View>
76
+
77
+ {/* Event lanes */}
78
+ <View style={styles.laneArea}>
79
+ {weekDays.map((_, i) =>
80
+ i === 0 ? null : (
81
+ <View
82
+ key={i}
83
+ style={{ position: "absolute", left: `${(i / 7) * 100}%`, top: -2, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] }}
84
+ />
85
+ ),
86
+ )}
87
+ {visible.map((bar) => {
88
+ const accent = bar.event.color || colors.teal[600];
89
+ const banner = bar.span > 1 || !!bar.event.allDay;
90
+ return (
91
+ <Pressable
92
+ key={bar.event.id}
93
+ onPress={onEventPress ? () => onEventPress(bar.event) : undefined}
94
+ accessibilityRole={onEventPress ? "button" : undefined}
95
+ accessibilityLabel={bar.event.title}
96
+ style={{
97
+ position: "absolute",
98
+ top: bar.lane * (LANE_H + LANE_GAP),
99
+ height: LANE_H,
100
+ left: `${(bar.startCol / 7) * 100}%`,
101
+ width: `${(bar.span / 7) * 100}%`,
102
+ paddingHorizontal: 2,
103
+ }}
104
+ >
105
+ <View
106
+ style={{
107
+ flex: 1,
108
+ borderRadius: 4,
109
+ backgroundColor: banner ? accent : "transparent",
110
+ flexDirection: "row",
111
+ alignItems: "center",
112
+ paddingHorizontal: 5,
113
+ gap: 5,
114
+ }}
115
+ >
116
+ {!banner ? <View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: accent }} /> : null}
117
+ <Text
118
+ size="xs"
119
+ weight={banner ? "medium" : "regular"}
120
+ numberOfLines={1}
121
+ style={{ color: banner ? colors.white : colors.zinc[700], flex: 1 }}
122
+ >
123
+ {bar.event.title}
124
+ </Text>
125
+ </View>
126
+ </Pressable>
127
+ );
128
+ })}
129
+ {weekDays.map((day, col) =>
130
+ overflow[col] > 0 ? (
131
+ <Pressable
132
+ key={`o${col}`}
133
+ onPress={onDayPress ? () => onDayPress(day) : undefined}
134
+ style={{ position: "absolute", top: MAX_LANES * (LANE_H + LANE_GAP), left: `${(col / 7) * 100}%`, width: `${100 / 7}%`, paddingHorizontal: 6 }}
135
+ >
136
+ <Text size="xs" color="muted">+{overflow[col]} nữa</Text>
137
+ </Pressable>
138
+ ) : null,
139
+ )}
140
+ </View>
141
+ </View>
142
+ );
143
+ })}
144
+ </View>
145
+ );
146
+ }
147
+
148
+ const styles = StyleSheet.create({
149
+ root: { flex: 1, backgroundColor: colors.white },
150
+ weekdayHeader: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, paddingVertical: 6 },
151
+ weekdayCell: { flex: 1, alignItems: "center" },
152
+ weekRow: { flex: 1, borderBottomWidth: 1, borderBottomColor: colors.zinc[100] },
153
+ dateRow: { flexDirection: "row", paddingTop: 4 },
154
+ dateCell: { flex: 1, alignItems: "center" },
155
+ dateBadge: { minWidth: 22, height: 22, borderRadius: 11, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
156
+ laneArea: { flex: 1, position: "relative", marginTop: 2 },
157
+ });