@protobase/ui 0.23.6 → 0.23.8
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/dist/app/current-address.d.ts +4 -3
- package/dist/app/current-address.js +1 -0
- package/dist/app/displays/calendar-controls.js +17 -49
- package/dist/app/displays/calendar-event.js +2 -2
- package/dist/app/displays/calendar-range.js +19 -9
- package/dist/app/displays/display-controls.js +2 -1
- package/dist/app/displays/list-displays.js +8 -1
- package/dist/app/displays/period-controls.js +55 -0
- package/dist/app/displays/timeline-display.js +225 -0
- package/dist/app/displays/timeline-range.js +140 -0
- package/dist/app/displays/timeline-row.js +42 -0
- package/dist/app/displays/timeline-scale.js +50 -0
- package/dist/app/embedded-list/embedded-list.js +7 -0
- package/dist/app/index.html +2 -0
- package/dist/app/list-page.js +11 -1
- package/dist/format/date.js +6 -1
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-BaOeRmvJ.js +69 -0
- package/dist/shell/assets/{index-CY5B9PIg.js → index-BSpJh7ky.js} +1 -1
- package/dist/shell/assets/index-Br0ZlvVS.css +2 -0
- package/dist/shell/assets/{maplibre-D4ace4wn.js → maplibre--RHVslik.js} +1 -1
- package/dist/shell/assets/{markdown-content-JU2jw1Kr.js → markdown-content-CNP__2Lb.js} +1 -1
- package/dist/shell/assets/{record-map-B8GB--Ku.js → record-map-CS2kj78D.js} +1 -1
- package/dist/shell/assets/{records-map-BUfPiD0T.js → records-map-D66qm5NI.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-C0Ld98uO.js → shared-protobase-ui-C2BPkI-k.js} +1 -1
- package/dist/shell/index.html +7 -4
- package/dist/shell/protobase.shell.json +3 -2
- package/dist/shell/protobase.sw.js +33 -0
- package/package.json +4 -4
- package/dist/shell/assets/app-ByxJ0Flo.js +0 -69
- package/dist/shell/assets/index-B-IciGPG.css +0 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { AppAddress, CalendarView, FilterLayout, MapView, ResourceModel, ViewModel } from "@protobase/schema";
|
|
1
|
+
import { AppAddress, CalendarView, FilterLayout, MapView, ResourceModel, TimelineView, ViewModel } from "@protobase/schema";
|
|
2
2
|
//#region src/app/current-address.d.ts
|
|
3
3
|
/**
|
|
4
|
-
* A list as the app shows it: every value there, the view's default where the address names none; `map`, `calendar
|
|
5
|
-
* and `savedSearch`, the saved search it was opened from, have none.
|
|
4
|
+
* A list as the app shows it: every value there, the view's default where the address names none; `map`, `calendar`,
|
|
5
|
+
* `timeline` and `savedSearch`, the saved search it was opened from, have none.
|
|
6
6
|
*/
|
|
7
7
|
export type ListAddress = {
|
|
8
8
|
kind: 'list';
|
|
@@ -14,6 +14,7 @@ export type ListAddress = {
|
|
|
14
14
|
filterLayout: FilterLayout;
|
|
15
15
|
map?: MapView;
|
|
16
16
|
calendar?: CalendarView;
|
|
17
|
+
timeline?: TimelineView;
|
|
17
18
|
savedSearch?: string;
|
|
18
19
|
};
|
|
19
20
|
/** The address the app shows, as logical values. A record's key is in the text form the record API takes. */
|
|
@@ -61,6 +61,7 @@ const changedList = (read, change, defaults) => {
|
|
|
61
61
|
...differs("filterLayout") && { filterLayout: next.filterLayout },
|
|
62
62
|
...next.map && { map: next.map },
|
|
63
63
|
...next.calendar && { calendar: next.calendar },
|
|
64
|
+
...next.timeline && { timeline: next.timeline },
|
|
64
65
|
...next.savedSearch && { savedSearch: next.savedSearch }
|
|
65
66
|
};
|
|
66
67
|
};
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import { segmentedClass } from "../../primitives/segmented.js";
|
|
2
1
|
import { dayIn, viewerZone } from "../../format/zone.js";
|
|
3
2
|
import { formatDayRange, formatMonth } from "../../format/date.js";
|
|
4
|
-
import { buttonClass } from "../../primitives/button.js";
|
|
5
|
-
import { AppLink } from "../use-app-address.js";
|
|
6
3
|
import { calendarView, gridDays, stepView, todayView } from "./calendar-range.js";
|
|
7
|
-
import {
|
|
8
|
-
import { jsx
|
|
4
|
+
import { PeriodControls } from "./period-controls.js";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
9
6
|
//#region src/app/displays/calendar-controls.tsx
|
|
10
7
|
/** The heading of the month or week a calendar shows. */
|
|
11
8
|
const periodText = (view) => {
|
|
@@ -13,57 +10,28 @@ const periodText = (view) => {
|
|
|
13
10
|
const ms = (day) => Date.parse(`${day}T00:00:00Z`);
|
|
14
11
|
return view.scale === "month" ? formatMonth(ms(view.day)) : formatDayRange(ms(days[0]), ms(days.at(-1)));
|
|
15
12
|
};
|
|
16
|
-
/**
|
|
17
|
-
* The month or week a calendar shows, with the way to the one before, the one holding today, the one after, and the
|
|
18
|
-
* month or the week: links when the calendar's view is in the address, so each opens in a new tab, else buttons.
|
|
19
|
-
*/
|
|
13
|
+
/** The month or week a calendar shows, with the way to the one before, the one holding today, the one after, and the month or the week. */
|
|
20
14
|
const CalendarControls = ({ view, now, linkTo, onView }) => {
|
|
21
|
-
const control = (to, className, label, children, current) => linkTo ? /* @__PURE__ */ jsx(AppLink, {
|
|
22
|
-
to: linkTo(to),
|
|
23
|
-
className,
|
|
24
|
-
"aria-label": label,
|
|
25
|
-
"aria-current": current ? "page" : void 0,
|
|
26
|
-
children
|
|
27
|
-
}) : /* @__PURE__ */ jsx("button", {
|
|
28
|
-
type: "button",
|
|
29
|
-
className,
|
|
30
|
-
"aria-label": label,
|
|
31
|
-
"aria-pressed": current,
|
|
32
|
-
onClick: () => onView?.(to),
|
|
33
|
-
children
|
|
34
|
-
});
|
|
35
15
|
const holdsToday = view.scale === "month" && dayIn(now, viewerZone()).startsWith(view.day.slice(0, 7));
|
|
36
16
|
const scaled = (to) => to === "week" && holdsToday ? todayView("week", now) : calendarView({
|
|
37
17
|
scale: to,
|
|
38
18
|
day: view.day
|
|
39
19
|
}, now);
|
|
40
|
-
const scale = (to, label) =>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
size: "sm"
|
|
20
|
+
const scale = (to, label) => ({
|
|
21
|
+
view: scaled(to),
|
|
22
|
+
label,
|
|
23
|
+
current: view.scale === to
|
|
45
24
|
});
|
|
46
|
-
return /* @__PURE__ */
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
control(stepView(view, -1), square, `Previous ${unit}`, /* @__PURE__ */ jsx(ChevronLeft, { className: "size-3.5" })),
|
|
57
|
-
control(todayView(view.scale, now), square, "Today", "Today"),
|
|
58
|
-
control(stepView(view, 1), square, `Next ${unit}`, /* @__PURE__ */ jsx(ChevronRight, { className: "size-3.5" }))
|
|
59
|
-
]
|
|
60
|
-
}), /* @__PURE__ */ jsxs("span", {
|
|
61
|
-
role: "group",
|
|
62
|
-
"aria-label": "Calendar scale",
|
|
63
|
-
className: segmentedClass.group(),
|
|
64
|
-
children: [scale("month", "Month"), scale("week", "Week")]
|
|
65
|
-
})]
|
|
66
|
-
})]
|
|
25
|
+
return /* @__PURE__ */ jsx(PeriodControls, {
|
|
26
|
+
heading: periodText(view),
|
|
27
|
+
unit: view.scale === "month" ? "month" : "week",
|
|
28
|
+
previous: stepView(view, -1),
|
|
29
|
+
today: todayView(view.scale, now),
|
|
30
|
+
next: stepView(view, 1),
|
|
31
|
+
scales: [scale("month", "Month"), scale("week", "Week")],
|
|
32
|
+
scalesLabel: "Calendar scale",
|
|
33
|
+
linkTo,
|
|
34
|
+
onView
|
|
67
35
|
});
|
|
68
36
|
};
|
|
69
37
|
//#endregion
|
|
@@ -23,7 +23,7 @@ const startTime = (model, display, row, day) => {
|
|
|
23
23
|
return dayIn(ms, viewerZone()) === day ? formatTime(ms) : void 0;
|
|
24
24
|
};
|
|
25
25
|
/** An event as a one-line link to its record, its time first for a timestamp, styled as a small record card. */
|
|
26
|
-
const EventChip = ({ model, view, display, row, day }) => {
|
|
26
|
+
const EventChip = ({ model, view, display, row, day, className }) => {
|
|
27
27
|
const seedRecord = useSeedRecord(model);
|
|
28
28
|
const time = startTime(model, display, row, day);
|
|
29
29
|
return /* @__PURE__ */ jsxs(AppLink, {
|
|
@@ -33,7 +33,7 @@ const EventChip = ({ model, view, display, row, day }) => {
|
|
|
33
33
|
key: row.key
|
|
34
34
|
},
|
|
35
35
|
onClick: () => seedRecord(row.record),
|
|
36
|
-
className: "flex min-w-0 items-baseline gap-1 rounded-md border border-border bg-background px-1.5 py-0.5 text-xs hover:border-border-strong hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
|
|
36
|
+
className: cn("flex min-w-0 items-baseline gap-1 rounded-md border border-border bg-background px-1.5 py-0.5 text-xs hover:border-border-strong hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30", className),
|
|
37
37
|
children: [time && /* @__PURE__ */ jsx("span", {
|
|
38
38
|
className: "shrink-0 tabular-nums text-muted-foreground",
|
|
39
39
|
children: time
|
|
@@ -6,7 +6,9 @@ const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
|
6
6
|
const startOf = (day) => Date.parse(`${day}T00:00:00Z`);
|
|
7
7
|
/** The day `count` days after `day`, or before it for a negative count. */
|
|
8
8
|
const addDays = (day, count) => dayOf(startOf(day) + count * dayMs);
|
|
9
|
+
/** The Monday on or before `day`. */
|
|
9
10
|
const monday = (day) => addDays(day, -((new Date(startOf(day)).getUTCDay() + 6) % 7));
|
|
11
|
+
/** The 1st of the month of `day`, or of the month `step` months on. */
|
|
10
12
|
const monthOf = (day, step = 0) => {
|
|
11
13
|
const date = new Date(startOf(day));
|
|
12
14
|
return dayOf(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + step, 1));
|
|
@@ -85,25 +87,33 @@ const instantOf = (value) => {
|
|
|
85
87
|
return Number.isNaN(ms) ? void 0 : ms;
|
|
86
88
|
};
|
|
87
89
|
/**
|
|
88
|
-
* The
|
|
89
|
-
*
|
|
90
|
+
* The first and last day a record is on: its start's day and its end's, where a timestamp `end` at midnight is over
|
|
91
|
+
* before that day starts; its start day alone without an end or with one before its start, and none without a start.
|
|
90
92
|
*/
|
|
91
|
-
const
|
|
93
|
+
const recordDays = (record, display, model, zone = viewerZone()) => {
|
|
92
94
|
const start = instantOf(record[display.start]);
|
|
93
|
-
if (start === void 0) return
|
|
95
|
+
if (start === void 0) return void 0;
|
|
94
96
|
const timestamp = isTimestamp(display, model);
|
|
95
97
|
const day = (ms) => timestamp ? dayIn(ms, zone) : dayOf(ms);
|
|
96
98
|
const first = day(start);
|
|
97
99
|
const end = display.end === void 0 ? void 0 : instantOf(record[display.end]);
|
|
98
100
|
const ending = end === void 0 ? first : day(timestamp ? end - 1 : end);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
return {
|
|
102
|
+
first,
|
|
103
|
+
last: ending < first ? first : ending
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
/** The days of `days` a record is on, from its first through its last (`recordDays`). */
|
|
107
|
+
const eventDays = (record, display, model, days, zone = viewerZone()) => {
|
|
108
|
+
const span = recordDays(record, display, model, zone);
|
|
109
|
+
return span ? days.filter((day) => day >= span.first && day <= span.last) : [];
|
|
101
110
|
};
|
|
111
|
+
/** The rows in the order they start, those without a start first. */
|
|
112
|
+
const inStartOrder = (rows, display) => rows.toSorted((a, b) => (instantOf(a.record[display.start]) ?? 0) - (instantOf(b.record[display.start]) ?? 0));
|
|
102
113
|
/** The rows on each day of `days`, each day's in start order. */
|
|
103
114
|
const byDay = (rows, display, model, days, zone = viewerZone()) => {
|
|
104
115
|
const placed = new Map(days.map((day) => [day, []]));
|
|
105
|
-
const
|
|
106
|
-
for (const row of ordered) for (const day of eventDays(row.record, display, model, days, zone)) placed.get(day).push(row);
|
|
116
|
+
for (const row of inStartOrder(rows, display)) for (const day of eventDays(row.record, display, model, days, zone)) placed.get(day).push(row);
|
|
107
117
|
return placed;
|
|
108
118
|
};
|
|
109
119
|
/**
|
|
@@ -126,4 +136,4 @@ const movedPatch = (record, display, model, from, to, zone = viewerZone()) => {
|
|
|
126
136
|
};
|
|
127
137
|
};
|
|
128
138
|
//#endregion
|
|
129
|
-
export { addDays, byDay, calendarView, eventDays, gridDays, movedPatch, rangeFilter, rangeOf, stepView, todayView, withRange };
|
|
139
|
+
export { addDays, byDay, calendarView, eventDays, gridDays, inStartOrder, monday, monthOf, movedPatch, rangeFilter, rangeOf, recordDays, stepView, todayView, withRange };
|
|
@@ -4,7 +4,7 @@ import { humanize } from "../../live/naming.js";
|
|
|
4
4
|
import { displayChoices, sortFields } from "./list-displays.js";
|
|
5
5
|
import { parseSort } from "../list-order.js";
|
|
6
6
|
import { MenuSelect } from "../../primitives/menu-select.js";
|
|
7
|
-
import { Calendar, Columns3, LayoutGrid, List, Map, SquareKanban, Table2 } from "lucide-react";
|
|
7
|
+
import { Calendar, ChartGantt, Columns3, LayoutGrid, List, Map, SquareKanban, Table2 } from "lucide-react";
|
|
8
8
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
9
9
|
//#region src/app/displays/display-controls.tsx
|
|
10
10
|
/** The icons a display's `icon` may name; another name shows the label alone. */
|
|
@@ -15,6 +15,7 @@ const icons = {
|
|
|
15
15
|
board: Columns3,
|
|
16
16
|
list: List,
|
|
17
17
|
calendar: Calendar,
|
|
18
|
+
timeline: ChartGantt,
|
|
18
19
|
map: Map
|
|
19
20
|
};
|
|
20
21
|
/**
|
|
@@ -63,7 +63,8 @@ const defaultIcons = {
|
|
|
63
63
|
cards: "cards",
|
|
64
64
|
map: "map",
|
|
65
65
|
board: "kanban",
|
|
66
|
-
calendar: "calendar"
|
|
66
|
+
calendar: "calendar",
|
|
67
|
+
timeline: "timeline"
|
|
67
68
|
};
|
|
68
69
|
const displayChoices = (view) => [{
|
|
69
70
|
name: tableDisplay,
|
|
@@ -138,6 +139,12 @@ const shownFields = (model, view, display) => {
|
|
|
138
139
|
display.end,
|
|
139
140
|
display.title ?? recordTitleField(model, view)
|
|
140
141
|
].filter((name) => name !== void 0);
|
|
142
|
+
if (display.kind === "timeline") return [
|
|
143
|
+
display.start,
|
|
144
|
+
display.end,
|
|
145
|
+
display.group,
|
|
146
|
+
display.title ?? recordTitleField(model, view)
|
|
147
|
+
].filter((name) => name !== void 0);
|
|
141
148
|
const { image, title, subtitle, amount, badge, meta, footer, flags } = cardFields(model, view, display);
|
|
142
149
|
return [
|
|
143
150
|
image,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { segmentedClass } from "../../primitives/segmented.js";
|
|
2
|
+
import { buttonClass } from "../../primitives/button.js";
|
|
3
|
+
import { AppLink } from "../use-app-address.js";
|
|
4
|
+
import { Fragment } from "react";
|
|
5
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/app/displays/period-controls.tsx
|
|
8
|
+
/**
|
|
9
|
+
* The period a calendar or a timeline shows, with the way to the one before, the one holding today, the one after, and
|
|
10
|
+
* its scales: links when the period is in the address, so each opens in a new tab, else buttons.
|
|
11
|
+
*/
|
|
12
|
+
const PeriodControls = ({ heading, unit, previous, today, next, scales, scalesLabel, linkTo, onView }) => {
|
|
13
|
+
const control = (to, className, label, children, current) => linkTo ? /* @__PURE__ */ jsx(AppLink, {
|
|
14
|
+
to: linkTo(to),
|
|
15
|
+
className,
|
|
16
|
+
"aria-label": label,
|
|
17
|
+
"aria-current": current ? "page" : void 0,
|
|
18
|
+
children
|
|
19
|
+
}) : /* @__PURE__ */ jsx("button", {
|
|
20
|
+
type: "button",
|
|
21
|
+
className,
|
|
22
|
+
"aria-label": label,
|
|
23
|
+
"aria-pressed": current,
|
|
24
|
+
onClick: () => onView?.(to),
|
|
25
|
+
children
|
|
26
|
+
});
|
|
27
|
+
const square = buttonClass({
|
|
28
|
+
variant: "secondary",
|
|
29
|
+
size: "sm"
|
|
30
|
+
});
|
|
31
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
32
|
+
className: "flex flex-wrap items-center justify-between gap-2",
|
|
33
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
34
|
+
className: "text-sm font-medium",
|
|
35
|
+
children: heading
|
|
36
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
37
|
+
className: "flex items-center gap-2",
|
|
38
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
39
|
+
className: "flex items-center gap-1",
|
|
40
|
+
children: [
|
|
41
|
+
control(previous, square, `Previous ${unit}`, /* @__PURE__ */ jsx(ChevronLeft, { className: "size-3.5" })),
|
|
42
|
+
control(today, square, "Today", "Today"),
|
|
43
|
+
control(next, square, `Next ${unit}`, /* @__PURE__ */ jsx(ChevronRight, { className: "size-3.5" }))
|
|
44
|
+
]
|
|
45
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
46
|
+
role: "group",
|
|
47
|
+
"aria-label": scalesLabel,
|
|
48
|
+
className: segmentedClass.group(),
|
|
49
|
+
children: scales.map((scale) => /* @__PURE__ */ jsx(Fragment, { children: control(scale.view, segmentedClass.option(scale.current), scale.label, scale.label, scale.current) }, scale.label))
|
|
50
|
+
})]
|
|
51
|
+
})]
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
export { PeriodControls };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { dayIn, viewerZone } from "../../format/zone.js";
|
|
3
|
+
import { formatDate } from "../../format/date.js";
|
|
4
|
+
import { humanize } from "../../live/naming.js";
|
|
5
|
+
import { usePermissions } from "../meta-gate.js";
|
|
6
|
+
import { ErrorBanner } from "../error-banner.js";
|
|
7
|
+
import { can, rowAllows } from "../permissions.js";
|
|
8
|
+
import { addDays, inStartOrder, recordDays } from "./calendar-range.js";
|
|
9
|
+
import { PeriodControls } from "./period-controls.js";
|
|
10
|
+
import { EventChip, eventTitle } from "./calendar-event.js";
|
|
11
|
+
import { useCalendarRows } from "./use-calendar-rows.js";
|
|
12
|
+
import { movedRows, useCalendarMove } from "./use-calendar-move.js";
|
|
13
|
+
import { scaledTimeline, stepTimeline, timelineDays, timelineHeading, timelineLanes, timelineRange, timelineTicks, timelineView } from "./timeline-range.js";
|
|
14
|
+
import { TimelineLines, TimelineScale } from "./timeline-scale.js";
|
|
15
|
+
import { TimelineRow } from "./timeline-row.js";
|
|
16
|
+
import { useRef, useState } from "react";
|
|
17
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
18
|
+
import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
|
|
19
|
+
//#region src/app/displays/timeline-display.tsx
|
|
20
|
+
const scales = [
|
|
21
|
+
{
|
|
22
|
+
scale: "week",
|
|
23
|
+
label: "Week"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
scale: "month",
|
|
27
|
+
label: "Month"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
scale: "quarter",
|
|
31
|
+
label: "Quarter"
|
|
32
|
+
}
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* The list's records as bars over the days of a week, a month or a quarter, in lanes by the display's `group`, loaded
|
|
36
|
+
* for those days alone through the list API as a calendar's are; a bar dragged along its row, or moved with the
|
|
37
|
+
* keyboard, saves the record with its dates moved by the same days.
|
|
38
|
+
*/
|
|
39
|
+
const TimelineDisplay = ({ model, view, display, fields, filter, at, linkTo, now = Date.now() }) => {
|
|
40
|
+
const [local, setLocal] = useState();
|
|
41
|
+
const shown = timelineView(linkTo ? at : local, now);
|
|
42
|
+
const days = timelineDays(shown);
|
|
43
|
+
const ticks = timelineTicks(shown);
|
|
44
|
+
const list = useCalendarRows({
|
|
45
|
+
model,
|
|
46
|
+
display,
|
|
47
|
+
range: timelineRange(shown),
|
|
48
|
+
fields,
|
|
49
|
+
filter
|
|
50
|
+
});
|
|
51
|
+
const { moves, move } = useCalendarMove(model, display);
|
|
52
|
+
const lanes = timelineLanes(inStartOrder(movedRows(list.rows, moves), display), model, view, display);
|
|
53
|
+
const permissions = usePermissions(model.name);
|
|
54
|
+
const movable = can(permissions, "update") && [display.start, display.end].every((name) => name === void 0 || !model.fields[name]?.readOnly);
|
|
55
|
+
const [active, setActive] = useState();
|
|
56
|
+
const dragged = useRef(false);
|
|
57
|
+
const shift = useRef(0);
|
|
58
|
+
const track = useRef(null);
|
|
59
|
+
const dayWidth = () => (track.current?.getBoundingClientRect().width ?? 0) / days.length;
|
|
60
|
+
const daysAcross = (x) => {
|
|
61
|
+
const width = dayWidth();
|
|
62
|
+
return width > 0 ? Math.round(x / width) : 0;
|
|
63
|
+
};
|
|
64
|
+
const snap = ({ transform }) => ({
|
|
65
|
+
...transform,
|
|
66
|
+
x: daysAcross(transform.x) * dayWidth(),
|
|
67
|
+
y: 0
|
|
68
|
+
});
|
|
69
|
+
const dayKeys = (event, { currentCoordinates }) => {
|
|
70
|
+
const step = event.code === "ArrowLeft" ? -1 : event.code === "ArrowRight" ? 1 : 0;
|
|
71
|
+
if (step === 0) return void 0;
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
return {
|
|
74
|
+
x: currentCoordinates.x + step * dayWidth(),
|
|
75
|
+
y: currentCoordinates.y
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), useSensor(TouchSensor, { activationConstraint: {
|
|
79
|
+
delay: 250,
|
|
80
|
+
tolerance: 5
|
|
81
|
+
} }), useSensor(KeyboardSensor, {
|
|
82
|
+
keyboardCodes: {
|
|
83
|
+
start: ["Space"],
|
|
84
|
+
cancel: ["Escape"],
|
|
85
|
+
end: ["Space", "Enter"]
|
|
86
|
+
},
|
|
87
|
+
coordinateGetter: dayKeys
|
|
88
|
+
}));
|
|
89
|
+
const today = dayIn(now, viewerZone());
|
|
90
|
+
const title = (picked) => eventTitle(model, view, display, picked.row);
|
|
91
|
+
const startText = (picked) => formatDate(Date.parse(`${addDays(picked.day, shift.current)}T00:00:00Z`));
|
|
92
|
+
const announcements = {
|
|
93
|
+
onDragStart: ({ active: picked }) => `Picked up ${title(picked.data.current)}.`,
|
|
94
|
+
onDragMove: ({ active: picked }) => `${title(picked.data.current)} starts on ${startText(picked.data.current)}.`,
|
|
95
|
+
onDragOver: () => void 0,
|
|
96
|
+
onDragEnd: ({ active: picked }) => shift.current === 0 ? `${title(picked.data.current)} was put back.` : `${title(picked.data.current)} was moved to ${startText(picked.data.current)}.`,
|
|
97
|
+
onDragCancel: ({ active: picked }) => `Moving ${title(picked.data.current)} was cancelled.`
|
|
98
|
+
};
|
|
99
|
+
const release = () => {
|
|
100
|
+
setActive(void 0);
|
|
101
|
+
setTimeout(() => dragged.current = false);
|
|
102
|
+
};
|
|
103
|
+
const start = ({ active: picked }) => {
|
|
104
|
+
dragged.current = true;
|
|
105
|
+
shift.current = 0;
|
|
106
|
+
setActive(picked.data.current);
|
|
107
|
+
};
|
|
108
|
+
const moving = ({ delta }) => shift.current = daysAcross(delta.x);
|
|
109
|
+
const end = ({ active: picked, delta }) => {
|
|
110
|
+
release();
|
|
111
|
+
shift.current = daysAcross(delta.x);
|
|
112
|
+
const { row } = picked.data.current;
|
|
113
|
+
const span = recordDays(row.record, display, model);
|
|
114
|
+
if (span && shift.current !== 0) move(row, span.first, addDays(span.first, shift.current));
|
|
115
|
+
};
|
|
116
|
+
const groupLabel = display.group === void 0 ? view?.names?.plural ?? humanize(model.name) : view?.fields[display.group]?.label ?? humanize(display.group);
|
|
117
|
+
const empty = !list.loading && !list.error && list.rows.length === 0;
|
|
118
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
119
|
+
className: "flex min-w-0 flex-1 flex-col gap-3",
|
|
120
|
+
children: [
|
|
121
|
+
/* @__PURE__ */ jsx(PeriodControls, {
|
|
122
|
+
heading: timelineHeading(shown),
|
|
123
|
+
unit: shown.scale,
|
|
124
|
+
previous: stepTimeline(shown, -1),
|
|
125
|
+
today: timelineView({
|
|
126
|
+
scale: shown.scale,
|
|
127
|
+
day: today
|
|
128
|
+
}, now),
|
|
129
|
+
next: stepTimeline(shown, 1),
|
|
130
|
+
scales: scales.map(({ scale, label }) => ({
|
|
131
|
+
view: scaledTimeline(shown, scale, now),
|
|
132
|
+
label,
|
|
133
|
+
current: shown.scale === scale
|
|
134
|
+
})),
|
|
135
|
+
scalesLabel: "Timeline scale",
|
|
136
|
+
linkTo,
|
|
137
|
+
onView: setLocal
|
|
138
|
+
}),
|
|
139
|
+
list.error && /* @__PURE__ */ jsx(ErrorBanner, {
|
|
140
|
+
title: "Could not load rows",
|
|
141
|
+
error: list.error
|
|
142
|
+
}),
|
|
143
|
+
list.capped && /* @__PURE__ */ jsxs("p", {
|
|
144
|
+
className: "text-xs text-muted-foreground",
|
|
145
|
+
children: [
|
|
146
|
+
"Showing the first ",
|
|
147
|
+
500,
|
|
148
|
+
" records of this ",
|
|
149
|
+
shown.scale,
|
|
150
|
+
". Narrow the filter to see the rest."
|
|
151
|
+
]
|
|
152
|
+
}),
|
|
153
|
+
/* @__PURE__ */ jsxs(DndContext, {
|
|
154
|
+
sensors,
|
|
155
|
+
modifiers: [snap],
|
|
156
|
+
onDragStart: start,
|
|
157
|
+
onDragMove: moving,
|
|
158
|
+
onDragEnd: end,
|
|
159
|
+
onDragCancel: release,
|
|
160
|
+
accessibility: {
|
|
161
|
+
announcements,
|
|
162
|
+
screenReaderInstructions: { draggable: "To move a bar to other days, press Space, move it a day with the left and right arrow keys, and press Space again to drop it there. Escape puts it back." }
|
|
163
|
+
},
|
|
164
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
165
|
+
className: "overflow-hidden rounded-lg border border-border",
|
|
166
|
+
children: [/* @__PURE__ */ jsx(TimelineScale, {
|
|
167
|
+
ticks,
|
|
168
|
+
days,
|
|
169
|
+
today,
|
|
170
|
+
label: groupLabel,
|
|
171
|
+
trackRef: track
|
|
172
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
173
|
+
className: "relative",
|
|
174
|
+
children: [
|
|
175
|
+
/* @__PURE__ */ jsx(TimelineLines, {
|
|
176
|
+
ticks,
|
|
177
|
+
days,
|
|
178
|
+
today
|
|
179
|
+
}),
|
|
180
|
+
list.loading && /* @__PURE__ */ jsxs("div", {
|
|
181
|
+
className: "relative flex flex-col gap-2 p-2",
|
|
182
|
+
children: [/* @__PURE__ */ jsx(Skeleton, { className: "h-4" }), /* @__PURE__ */ jsx(Skeleton, { className: "h-4" })]
|
|
183
|
+
}),
|
|
184
|
+
empty && /* @__PURE__ */ jsxs("p", {
|
|
185
|
+
className: "relative px-2 py-3 text-xs text-muted-foreground",
|
|
186
|
+
children: [
|
|
187
|
+
"Nothing in this ",
|
|
188
|
+
shown.scale,
|
|
189
|
+
"."
|
|
190
|
+
]
|
|
191
|
+
}),
|
|
192
|
+
!list.loading && lanes.map((lane) => /* @__PURE__ */ jsxs("section", {
|
|
193
|
+
"aria-label": lane.label,
|
|
194
|
+
className: "relative border-b border-border last:border-b-0",
|
|
195
|
+
children: [lane.label !== void 0 && /* @__PURE__ */ jsxs("h3", {
|
|
196
|
+
className: "flex items-center gap-1.5 border-b border-border bg-surface px-2 py-1 text-xs font-medium",
|
|
197
|
+
children: [lane.label, /* @__PURE__ */ jsx("span", {
|
|
198
|
+
className: "tabular-nums text-muted-foreground",
|
|
199
|
+
children: lane.rows.length
|
|
200
|
+
})]
|
|
201
|
+
}), lane.rows.map((row) => /* @__PURE__ */ jsx(TimelineRow, {
|
|
202
|
+
model,
|
|
203
|
+
view,
|
|
204
|
+
display,
|
|
205
|
+
row,
|
|
206
|
+
days,
|
|
207
|
+
movable: movable && rowAllows(row.record, "update"),
|
|
208
|
+
dragged: () => dragged.current
|
|
209
|
+
}, row.key))]
|
|
210
|
+
}, lane.key))
|
|
211
|
+
]
|
|
212
|
+
})]
|
|
213
|
+
}), /* @__PURE__ */ jsx(DragOverlay, { children: active && /* @__PURE__ */ jsx(EventChip, {
|
|
214
|
+
model,
|
|
215
|
+
view,
|
|
216
|
+
display,
|
|
217
|
+
row: active.row,
|
|
218
|
+
day: active.day
|
|
219
|
+
}) })]
|
|
220
|
+
})
|
|
221
|
+
]
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
//#endregion
|
|
225
|
+
export { TimelineDisplay };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { dayIn, viewerZone } from "../../format/zone.js";
|
|
2
|
+
import { formatDayRange, formatMonth, formatMonthName, formatMonthRange, formatWeekday } from "../../format/date.js";
|
|
3
|
+
import { humanize } from "../../live/naming.js";
|
|
4
|
+
import { displayValue } from "../../live/display-value.js";
|
|
5
|
+
import { addDays, monday, monthOf, recordDays } from "./calendar-range.js";
|
|
6
|
+
//#region src/app/displays/timeline-range.ts
|
|
7
|
+
const dayMs = 864e5;
|
|
8
|
+
const ms = (day) => Date.parse(`${day}T00:00:00Z`);
|
|
9
|
+
const quarterOf = (day, step = 0) => {
|
|
10
|
+
const date = new Date(ms(day));
|
|
11
|
+
return new Date(Date.UTC(date.getUTCFullYear(), Math.floor(date.getUTCMonth() / 3) * 3 + 3 * step, 1)).toISOString().slice(0, 10);
|
|
12
|
+
};
|
|
13
|
+
const normalised = ({ scale, day }) => ({
|
|
14
|
+
scale,
|
|
15
|
+
day: scale === "week" ? monday(day) : scale === "month" ? monthOf(day) : quarterOf(day)
|
|
16
|
+
});
|
|
17
|
+
/** The period a timeline shows: the address's, from the week's Monday, the month's 1st or the quarter's, else the month of `now`. */
|
|
18
|
+
const timelineView = (at, now, zone = viewerZone()) => normalised(at ?? {
|
|
19
|
+
scale: "month",
|
|
20
|
+
day: dayIn(now, zone)
|
|
21
|
+
});
|
|
22
|
+
/** The week before (-1) or after (1) `view`, or the month or quarter. */
|
|
23
|
+
const stepTimeline = (view, step) => {
|
|
24
|
+
const { scale, day } = normalised(view);
|
|
25
|
+
return {
|
|
26
|
+
scale,
|
|
27
|
+
day: scale === "week" ? addDays(day, 7 * step) : scale === "month" ? monthOf(day, step) : quarterOf(day, step)
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
/** The days a timeline shows: the week's seven, the month's, or the quarter's. */
|
|
31
|
+
const timelineDays = (view) => {
|
|
32
|
+
const first = normalised(view).day;
|
|
33
|
+
const count = (ms(stepTimeline(view, 1).day) - ms(first)) / dayMs;
|
|
34
|
+
return Array.from({ length: count }, (_, index) => addDays(first, index));
|
|
35
|
+
};
|
|
36
|
+
/** The days `view` shows, as a range from its first day to the day after its last. */
|
|
37
|
+
const timelineRange = (view) => ({
|
|
38
|
+
from: normalised(view).day,
|
|
39
|
+
to: stepTimeline(view, 1).day
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* The period of `scale` a timeline switches to: the one holding today when the period shown holds it, else the one
|
|
43
|
+
* holding the period's first day.
|
|
44
|
+
*/
|
|
45
|
+
const scaledTimeline = (view, scale, now, zone = viewerZone()) => {
|
|
46
|
+
const today = dayIn(now, zone);
|
|
47
|
+
const days = timelineDays(view);
|
|
48
|
+
return normalised({
|
|
49
|
+
scale,
|
|
50
|
+
day: today >= days[0] && today <= days.at(-1) ? today : days[0]
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
/** The heading of the period a timeline shows: "12–18 Oct 2026", "October 2026", "October – December 2026". */
|
|
54
|
+
const timelineHeading = (view) => {
|
|
55
|
+
const days = timelineDays(view);
|
|
56
|
+
if (view.scale === "week") return formatDayRange(ms(days[0]), ms(days.at(-1)));
|
|
57
|
+
return view.scale === "month" ? formatMonth(ms(days[0])) : formatMonthRange(ms(days[0]), ms(monthOf(days.at(-1))));
|
|
58
|
+
};
|
|
59
|
+
/** The marks of a timeline's scale: a week's days by weekday and number, a month's by number, a quarter's months by name. */
|
|
60
|
+
const timelineTicks = (view) => {
|
|
61
|
+
const days = timelineDays(view);
|
|
62
|
+
if (view.scale === "week") return days.map((day, index) => ({
|
|
63
|
+
index,
|
|
64
|
+
span: 1,
|
|
65
|
+
label: `${formatWeekday(ms(day))} ${Number(day.slice(8))}`
|
|
66
|
+
}));
|
|
67
|
+
if (view.scale === "month") return days.map((day, index) => ({
|
|
68
|
+
index,
|
|
69
|
+
span: 1,
|
|
70
|
+
label: String(Number(day.slice(8))),
|
|
71
|
+
...index % 7 !== 0 && { minor: true }
|
|
72
|
+
}));
|
|
73
|
+
const firsts = days.flatMap((day, index) => day.endsWith("-01") ? [index] : []);
|
|
74
|
+
return firsts.map((index, at) => ({
|
|
75
|
+
index,
|
|
76
|
+
span: (firsts[at + 1] ?? days.length) - index,
|
|
77
|
+
label: formatMonthName(ms(days[index]))
|
|
78
|
+
}));
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Where a record's bar lies on `days`: the index of its first and last day shown, and whether it starts before them or
|
|
82
|
+
* ends after them; none for a record without a start or off them.
|
|
83
|
+
*/
|
|
84
|
+
const barOf = (record, display, model, days, zone = viewerZone()) => {
|
|
85
|
+
const span = recordDays(record, display, model, zone);
|
|
86
|
+
const first = days[0];
|
|
87
|
+
const last = days.at(-1);
|
|
88
|
+
if (!span || first === void 0 || last === void 0 || span.last < first || span.first > last) return void 0;
|
|
89
|
+
return {
|
|
90
|
+
first: span.first < first ? 0 : days.indexOf(span.first),
|
|
91
|
+
last: span.last > last ? days.length - 1 : days.indexOf(span.last),
|
|
92
|
+
before: span.first < first,
|
|
93
|
+
after: span.last > last,
|
|
94
|
+
/** The day the record starts on, shown or not. */
|
|
95
|
+
start: span.first
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
const noValue = "none";
|
|
99
|
+
/**
|
|
100
|
+
* The lanes of a timeline: one per value of its `group` that a record has, each with its records in their order; an
|
|
101
|
+
* enum's in the order the field lists its values, a relation's by the name of the record it points at, others by value,
|
|
102
|
+
* and last the records without a value, headed "No <field>" as a board's column is.
|
|
103
|
+
*/
|
|
104
|
+
const timelineLanes = (rows, model, view, display) => {
|
|
105
|
+
const group = display.group;
|
|
106
|
+
const field = group === void 0 ? void 0 : model.fields[group];
|
|
107
|
+
if (group === void 0 || !field) return [{
|
|
108
|
+
key: "",
|
|
109
|
+
rows
|
|
110
|
+
}];
|
|
111
|
+
const hints = view?.fields[group];
|
|
112
|
+
const lanes = /* @__PURE__ */ new Map();
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
const value = row.record[group];
|
|
115
|
+
const empty = value === null || value === void 0 || value === "";
|
|
116
|
+
const key = empty ? noValue : `value:${String(value)}`;
|
|
117
|
+
const label = empty ? `No ${(hints?.label ?? humanize(group)).toLowerCase()}` : row.labels[group] ?? displayValue(field, value, hints);
|
|
118
|
+
const known = lanes.get(key) ?? {
|
|
119
|
+
value: empty ? void 0 : value,
|
|
120
|
+
lane: {
|
|
121
|
+
key,
|
|
122
|
+
label,
|
|
123
|
+
rows: []
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
known.lane.rows.push(row);
|
|
127
|
+
lanes.set(key, known);
|
|
128
|
+
}
|
|
129
|
+
const order = field.enumValues ?? [];
|
|
130
|
+
const compare = (a, b) => {
|
|
131
|
+
if (a.value === void 0 || b.value === void 0) return Number(a.value === void 0) - Number(b.value === void 0);
|
|
132
|
+
if (field.type === "enum") return order.indexOf(String(a.value)) - order.indexOf(String(b.value));
|
|
133
|
+
if (field.type === "relation") return a.lane.label.localeCompare(b.lane.label);
|
|
134
|
+
if (typeof a.value === "number" && typeof b.value === "number") return a.value - b.value;
|
|
135
|
+
return String(a.value).localeCompare(String(b.value), void 0, { numeric: true });
|
|
136
|
+
};
|
|
137
|
+
return [...lanes.values()].toSorted(compare).map(({ lane }) => lane);
|
|
138
|
+
};
|
|
139
|
+
//#endregion
|
|
140
|
+
export { barOf, scaledTimeline, stepTimeline, timelineDays, timelineHeading, timelineLanes, timelineRange, timelineTicks, timelineView };
|