@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.
Files changed (31) hide show
  1. package/dist/app/current-address.d.ts +4 -3
  2. package/dist/app/current-address.js +1 -0
  3. package/dist/app/displays/calendar-controls.js +17 -49
  4. package/dist/app/displays/calendar-event.js +2 -2
  5. package/dist/app/displays/calendar-range.js +19 -9
  6. package/dist/app/displays/display-controls.js +2 -1
  7. package/dist/app/displays/list-displays.js +8 -1
  8. package/dist/app/displays/period-controls.js +55 -0
  9. package/dist/app/displays/timeline-display.js +225 -0
  10. package/dist/app/displays/timeline-range.js +140 -0
  11. package/dist/app/displays/timeline-row.js +42 -0
  12. package/dist/app/displays/timeline-scale.js +50 -0
  13. package/dist/app/embedded-list/embedded-list.js +7 -0
  14. package/dist/app/index.html +2 -0
  15. package/dist/app/list-page.js +11 -1
  16. package/dist/format/date.js +6 -1
  17. package/dist/package.js +1 -1
  18. package/dist/shell/assets/app-BaOeRmvJ.js +69 -0
  19. package/dist/shell/assets/{index-CY5B9PIg.js → index-BSpJh7ky.js} +1 -1
  20. package/dist/shell/assets/index-Br0ZlvVS.css +2 -0
  21. package/dist/shell/assets/{maplibre-D4ace4wn.js → maplibre--RHVslik.js} +1 -1
  22. package/dist/shell/assets/{markdown-content-JU2jw1Kr.js → markdown-content-CNP__2Lb.js} +1 -1
  23. package/dist/shell/assets/{record-map-B8GB--Ku.js → record-map-CS2kj78D.js} +1 -1
  24. package/dist/shell/assets/{records-map-BUfPiD0T.js → records-map-D66qm5NI.js} +1 -1
  25. package/dist/shell/assets/{shared-protobase-ui-C0Ld98uO.js → shared-protobase-ui-C2BPkI-k.js} +1 -1
  26. package/dist/shell/index.html +7 -4
  27. package/dist/shell/protobase.shell.json +3 -2
  28. package/dist/shell/protobase.sw.js +33 -0
  29. package/package.json +4 -4
  30. package/dist/shell/assets/app-ByxJ0Flo.js +0 -69
  31. 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 { ChevronLeft, ChevronRight } from "lucide-react";
8
- import { jsx, jsxs } from "react/jsx-runtime";
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) => control(scaled(to), segmentedClass.option(view.scale === to), label, label, view.scale === to);
41
- const unit = view.scale === "month" ? "month" : "week";
42
- const square = buttonClass({
43
- variant: "secondary",
44
- size: "sm"
20
+ const scale = (to, label) => ({
21
+ view: scaled(to),
22
+ label,
23
+ current: view.scale === to
45
24
  });
46
- return /* @__PURE__ */ jsxs("div", {
47
- className: "flex flex-wrap items-center justify-between gap-2",
48
- children: [/* @__PURE__ */ jsx("h2", {
49
- className: "text-sm font-medium",
50
- children: periodText(view)
51
- }), /* @__PURE__ */ jsxs("div", {
52
- className: "flex items-center gap-2",
53
- children: [/* @__PURE__ */ jsxs("div", {
54
- className: "flex items-center gap-1",
55
- children: [
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 days of `days` a record is on: from its start's day through its end's, both included, where a timestamp `end`
89
- * at midnight is over before that day starts; its start day alone without an end or with one before its start.
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 eventDays = (record, display, model, days, zone = viewerZone()) => {
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
- const last = ending < first ? first : ending;
100
- return days.filter((day) => day >= first && day <= last);
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 ordered = rows.toSorted((a, b) => (instantOf(a.record[display.start]) ?? 0) - (instantOf(b.record[display.start]) ?? 0));
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 };