@spatika/react 1.1.0 → 1.2.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.
Files changed (311) hide show
  1. package/AGENTS.md +71 -0
  2. package/README.md +4 -3
  3. package/dist/composites/Card.d.ts +7 -1
  4. package/dist/composites/Card.d.ts.map +1 -1
  5. package/dist/composites/Card.js +10 -0
  6. package/dist/composites/ChartDataGrid.d.ts +33 -0
  7. package/dist/composites/ChartDataGrid.d.ts.map +1 -0
  8. package/dist/composites/ChartDataGrid.js +77 -0
  9. package/dist/composites/CoverHero.d.ts +1 -1
  10. package/dist/composites/CoverHero.d.ts.map +1 -1
  11. package/dist/composites/CoverHero.js +2 -2
  12. package/dist/composites/EntityMediaCard.d.ts +1 -1
  13. package/dist/composites/EntityMediaCard.d.ts.map +1 -1
  14. package/dist/composites/EntityMediaCard.js +12 -4
  15. package/dist/composites/EventCalendar.d.ts +35 -0
  16. package/dist/composites/EventCalendar.d.ts.map +1 -0
  17. package/dist/composites/EventCalendar.js +339 -0
  18. package/dist/composites/EventEditor.d.ts +22 -0
  19. package/dist/composites/EventEditor.d.ts.map +1 -0
  20. package/dist/composites/EventEditor.js +86 -0
  21. package/dist/composites/EventTimeline.d.ts +24 -0
  22. package/dist/composites/EventTimeline.d.ts.map +1 -0
  23. package/dist/composites/EventTimeline.js +184 -0
  24. package/dist/composites/MapChart.d.ts +24 -0
  25. package/dist/composites/MapChart.d.ts.map +1 -0
  26. package/dist/composites/MapChart.js +58 -0
  27. package/dist/composites/ProfileHero.d.ts +6 -4
  28. package/dist/composites/ProfileHero.d.ts.map +1 -1
  29. package/dist/composites/ProfileHero.js +8 -8
  30. package/dist/composites/ScatterWebGL.d.ts +22 -0
  31. package/dist/composites/ScatterWebGL.d.ts.map +1 -0
  32. package/dist/composites/ScatterWebGL.js +45 -0
  33. package/dist/composites/SiteFooter.d.ts +1 -1
  34. package/dist/composites/SiteFooter.js +1 -1
  35. package/dist/composites/SiteNav.d.ts +7 -2
  36. package/dist/composites/SiteNav.d.ts.map +1 -1
  37. package/dist/composites/SiteNav.js +9 -5
  38. package/dist/composites/StatCard.d.ts +2 -1
  39. package/dist/composites/StatCard.d.ts.map +1 -1
  40. package/dist/composites/StatCard.js +3 -3
  41. package/dist/composites/StatusDot.d.ts +1 -1
  42. package/dist/composites/Wordmark.d.ts +2 -2
  43. package/dist/composites/Wordmark.d.ts.map +1 -1
  44. package/dist/composites/cartesian-charts.d.ts +138 -0
  45. package/dist/composites/cartesian-charts.d.ts.map +1 -0
  46. package/dist/composites/cartesian-charts.js +401 -0
  47. package/dist/composites/chart-composition.d.ts +139 -0
  48. package/dist/composites/chart-composition.d.ts.map +1 -0
  49. package/dist/composites/chart-composition.js +486 -0
  50. package/dist/composites/chart-ui.d.ts +83 -0
  51. package/dist/composites/chart-ui.d.ts.map +1 -0
  52. package/dist/composites/chart-ui.js +108 -0
  53. package/dist/composites/charts-3d.d.ts +37 -0
  54. package/dist/composites/charts-3d.d.ts.map +1 -0
  55. package/dist/composites/charts-3d.js +121 -0
  56. package/dist/composites/flow-charts.d.ts +89 -0
  57. package/dist/composites/flow-charts.d.ts.map +1 -0
  58. package/dist/composites/flow-charts.js +186 -0
  59. package/dist/composites/radial-charts.d.ts +109 -0
  60. package/dist/composites/radial-charts.d.ts.map +1 -0
  61. package/dist/composites/radial-charts.js +176 -0
  62. package/dist/composites/scheduler-ui.d.ts +43 -0
  63. package/dist/composites/scheduler-ui.d.ts.map +1 -0
  64. package/dist/composites/scheduler-ui.js +71 -0
  65. package/dist/index.d.ts +67 -7
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +57 -7
  68. package/dist/lib/chart-export.d.ts +10 -0
  69. package/dist/lib/chart-export.d.ts.map +1 -0
  70. package/dist/lib/chart-export.js +102 -0
  71. package/dist/lib/charts.d.ts +235 -0
  72. package/dist/lib/charts.d.ts.map +1 -0
  73. package/dist/lib/charts.js +744 -0
  74. package/dist/lib/geo.d.ts +39 -0
  75. package/dist/lib/geo.d.ts.map +1 -0
  76. package/dist/lib/geo.js +101 -0
  77. package/dist/lib/iso.d.ts +21 -0
  78. package/dist/lib/iso.d.ts.map +1 -0
  79. package/dist/lib/iso.js +56 -0
  80. package/dist/lib/rrule.d.ts +20 -0
  81. package/dist/lib/rrule.d.ts.map +1 -0
  82. package/dist/lib/rrule.js +236 -0
  83. package/dist/lib/scheduler.d.ts +152 -0
  84. package/dist/lib/scheduler.d.ts.map +1 -0
  85. package/dist/lib/scheduler.js +501 -0
  86. package/dist/lib/webgl-scatter.d.ts +44 -0
  87. package/dist/lib/webgl-scatter.d.ts.map +1 -0
  88. package/dist/lib/webgl-scatter.js +185 -0
  89. package/dist/primitives/Accordion.d.ts +44 -20
  90. package/dist/primitives/Accordion.d.ts.map +1 -1
  91. package/dist/primitives/Accordion.js +82 -22
  92. package/dist/primitives/AppBar.d.ts +13 -0
  93. package/dist/primitives/AppBar.d.ts.map +1 -0
  94. package/dist/primitives/AppBar.js +27 -0
  95. package/dist/primitives/Autocomplete.d.ts +28 -0
  96. package/dist/primitives/Autocomplete.d.ts.map +1 -0
  97. package/dist/primitives/Autocomplete.js +118 -0
  98. package/dist/primitives/Avatar.d.ts +13 -1
  99. package/dist/primitives/Avatar.d.ts.map +1 -1
  100. package/dist/primitives/Avatar.js +17 -2
  101. package/dist/primitives/Backdrop.d.ts +17 -0
  102. package/dist/primitives/Backdrop.d.ts.map +1 -0
  103. package/dist/primitives/Backdrop.js +17 -0
  104. package/dist/primitives/BottomNavigation.d.ts +23 -0
  105. package/dist/primitives/BottomNavigation.d.ts.map +1 -0
  106. package/dist/primitives/BottomNavigation.js +30 -0
  107. package/dist/primitives/ButtonBase.d.ts +14 -0
  108. package/dist/primitives/ButtonBase.d.ts.map +1 -0
  109. package/dist/primitives/ButtonBase.js +13 -0
  110. package/dist/primitives/ButtonGroup.d.ts +25 -0
  111. package/dist/primitives/ButtonGroup.d.ts.map +1 -0
  112. package/dist/primitives/ButtonGroup.js +44 -0
  113. package/dist/primitives/CircularProgress.d.ts +16 -0
  114. package/dist/primitives/CircularProgress.d.ts.map +1 -0
  115. package/dist/primitives/CircularProgress.js +17 -0
  116. package/dist/primitives/ClickAwayListener.d.ts +16 -0
  117. package/dist/primitives/ClickAwayListener.d.ts.map +1 -0
  118. package/dist/primitives/ClickAwayListener.js +33 -0
  119. package/dist/primitives/Collapse.d.ts +19 -0
  120. package/dist/primitives/Collapse.d.ts.map +1 -0
  121. package/dist/primitives/Collapse.js +14 -0
  122. package/dist/primitives/Container.d.ts +13 -0
  123. package/dist/primitives/Container.d.ts.map +1 -0
  124. package/dist/primitives/Container.js +26 -0
  125. package/dist/primitives/Fab.d.ts +22 -0
  126. package/dist/primitives/Fab.d.ts.map +1 -0
  127. package/dist/primitives/Fab.js +42 -0
  128. package/dist/primitives/FormControl.d.ts +27 -0
  129. package/dist/primitives/FormControl.d.ts.map +1 -0
  130. package/dist/primitives/FormControl.js +24 -0
  131. package/dist/primitives/FormControlLabel.d.ts +18 -0
  132. package/dist/primitives/FormControlLabel.d.ts.map +1 -0
  133. package/dist/primitives/FormControlLabel.js +19 -0
  134. package/dist/primitives/Grid.d.ts +32 -0
  135. package/dist/primitives/Grid.d.ts.map +1 -0
  136. package/dist/primitives/Grid.js +50 -0
  137. package/dist/primitives/IconButton.d.ts +22 -0
  138. package/dist/primitives/IconButton.d.ts.map +1 -0
  139. package/dist/primitives/IconButton.js +39 -0
  140. package/dist/primitives/ImageList.d.ts +31 -0
  141. package/dist/primitives/ImageList.d.ts.map +1 -0
  142. package/dist/primitives/ImageList.js +33 -0
  143. package/dist/primitives/InputAdornment.d.ts +28 -0
  144. package/dist/primitives/InputAdornment.d.ts.map +1 -0
  145. package/dist/primitives/InputAdornment.js +13 -0
  146. package/dist/primitives/Link.d.ts +17 -0
  147. package/dist/primitives/Link.d.ts.map +1 -0
  148. package/dist/primitives/Link.js +29 -0
  149. package/dist/primitives/List.d.ts +41 -0
  150. package/dist/primitives/List.d.ts.map +1 -0
  151. package/dist/primitives/List.js +22 -0
  152. package/dist/primitives/Masonry.d.ts +14 -0
  153. package/dist/primitives/Masonry.d.ts.map +1 -0
  154. package/dist/primitives/Masonry.js +15 -0
  155. package/dist/primitives/Menu.d.ts +28 -0
  156. package/dist/primitives/Menu.d.ts.map +1 -0
  157. package/dist/primitives/Menu.js +54 -0
  158. package/dist/primitives/MobileStepper.d.ts +15 -0
  159. package/dist/primitives/MobileStepper.d.ts.map +1 -0
  160. package/dist/primitives/MobileStepper.js +8 -0
  161. package/dist/primitives/Modal.d.ts +18 -0
  162. package/dist/primitives/Modal.d.ts.map +1 -0
  163. package/dist/primitives/Modal.js +29 -0
  164. package/dist/primitives/NoSsr.d.ts +15 -0
  165. package/dist/primitives/NoSsr.d.ts.map +1 -0
  166. package/dist/primitives/NoSsr.js +19 -0
  167. package/dist/primitives/Paper.d.ts +22 -0
  168. package/dist/primitives/Paper.d.ts.map +1 -0
  169. package/dist/primitives/Paper.js +33 -0
  170. package/dist/primitives/Progress.d.ts +6 -2
  171. package/dist/primitives/Progress.d.ts.map +1 -1
  172. package/dist/primitives/Progress.js +8 -3
  173. package/dist/primitives/Snackbar.d.ts +23 -0
  174. package/dist/primitives/Snackbar.d.ts.map +1 -0
  175. package/dist/primitives/Snackbar.js +33 -0
  176. package/dist/primitives/SpeedDial.d.ts +26 -0
  177. package/dist/primitives/SpeedDial.d.ts.map +1 -0
  178. package/dist/primitives/SpeedDial.js +26 -0
  179. package/dist/primitives/Stack.d.ts +15 -0
  180. package/dist/primitives/Stack.d.ts.map +1 -0
  181. package/dist/primitives/Stack.js +45 -0
  182. package/dist/primitives/StarRating.d.ts +19 -1
  183. package/dist/primitives/StarRating.d.ts.map +1 -1
  184. package/dist/primitives/StarRating.js +36 -6
  185. package/dist/primitives/Stepper.d.ts +36 -0
  186. package/dist/primitives/Stepper.d.ts.map +1 -0
  187. package/dist/primitives/Stepper.js +53 -0
  188. package/dist/primitives/TablePagination.d.ts +16 -0
  189. package/dist/primitives/TablePagination.d.ts.map +1 -0
  190. package/dist/primitives/TablePagination.js +12 -0
  191. package/dist/primitives/TextField.d.ts +24 -0
  192. package/dist/primitives/TextField.d.ts.map +1 -0
  193. package/dist/primitives/TextField.js +19 -0
  194. package/dist/primitives/TextareaAutosize.d.ts +11 -0
  195. package/dist/primitives/TextareaAutosize.d.ts.map +1 -0
  196. package/dist/primitives/TextareaAutosize.js +26 -0
  197. package/dist/primitives/ToggleButton.d.ts +28 -0
  198. package/dist/primitives/ToggleButton.d.ts.map +1 -0
  199. package/dist/primitives/ToggleButton.js +56 -0
  200. package/dist/primitives/TransferList.d.ts +21 -0
  201. package/dist/primitives/TransferList.d.ts.map +1 -0
  202. package/dist/primitives/TransferList.js +40 -0
  203. package/dist/primitives/Typography.d.ts +23 -0
  204. package/dist/primitives/Typography.d.ts.map +1 -0
  205. package/dist/primitives/Typography.js +69 -0
  206. package/dist/primitives/transitions.d.ts +29 -0
  207. package/dist/primitives/transitions.d.ts.map +1 -0
  208. package/dist/primitives/transitions.js +52 -0
  209. package/package.json +28 -4
  210. package/skills/spatika-ui/SKILL.md +96 -0
  211. package/src/composites/Card.tsx +51 -1
  212. package/src/composites/ChartDataGrid.test.tsx +57 -0
  213. package/src/composites/ChartDataGrid.tsx +196 -0
  214. package/src/composites/CoverHero.test.tsx +38 -0
  215. package/src/composites/CoverHero.tsx +29 -22
  216. package/src/composites/EntityMediaCard.test.tsx +58 -0
  217. package/src/composites/EntityMediaCard.tsx +31 -13
  218. package/src/composites/EventCalendar.tsx +928 -0
  219. package/src/composites/EventEditor.tsx +268 -0
  220. package/src/composites/EventTimeline.tsx +357 -0
  221. package/src/composites/MapChart.test.tsx +39 -0
  222. package/src/composites/MapChart.tsx +130 -0
  223. package/src/composites/ProfileHero.test.tsx +33 -0
  224. package/src/composites/ProfileHero.tsx +96 -79
  225. package/src/composites/ScatterWebGL.test.tsx +25 -0
  226. package/src/composites/ScatterWebGL.tsx +79 -0
  227. package/src/composites/SiteFooter.test.tsx +37 -0
  228. package/src/composites/SiteFooter.tsx +1 -1
  229. package/src/composites/SiteNav.test.tsx +46 -0
  230. package/src/composites/SiteNav.tsx +14 -4
  231. package/src/composites/StatCard.test.tsx +30 -0
  232. package/src/composites/StatCard.tsx +4 -0
  233. package/src/composites/Wordmark.test.tsx +23 -0
  234. package/src/composites/Wordmark.tsx +2 -2
  235. package/src/composites/cartesian-charts.test.tsx +52 -0
  236. package/src/composites/cartesian-charts.tsx +1287 -0
  237. package/src/composites/chart-composition.test.tsx +53 -0
  238. package/src/composites/chart-composition.tsx +898 -0
  239. package/src/composites/chart-ui.tsx +328 -0
  240. package/src/composites/charts-3d.test.tsx +25 -0
  241. package/src/composites/charts-3d.tsx +318 -0
  242. package/src/composites/charts.test.tsx +254 -0
  243. package/src/composites/chrome.test.tsx +29 -0
  244. package/src/composites/flow-charts.test.tsx +79 -0
  245. package/src/composites/flow-charts.tsx +607 -0
  246. package/src/composites/marketing.test.tsx +38 -34
  247. package/src/composites/radial-charts.test.tsx +74 -0
  248. package/src/composites/radial-charts.tsx +617 -0
  249. package/src/composites/scheduler-ui.tsx +304 -0
  250. package/src/composites/scheduler.test.tsx +194 -0
  251. package/src/index.test.ts +22 -0
  252. package/src/index.ts +277 -5
  253. package/src/lib/chart-export.test.ts +20 -0
  254. package/src/lib/chart-export.ts +99 -0
  255. package/src/lib/charts-platform.test.ts +93 -0
  256. package/src/lib/charts.test.ts +132 -0
  257. package/src/lib/charts.ts +1021 -0
  258. package/src/lib/geo.test.ts +42 -0
  259. package/src/lib/geo.ts +138 -0
  260. package/src/lib/iso.test.ts +22 -0
  261. package/src/lib/iso.ts +102 -0
  262. package/src/lib/rrule.test.ts +53 -0
  263. package/src/lib/rrule.ts +281 -0
  264. package/src/lib/scheduler.test.ts +186 -0
  265. package/src/lib/scheduler.ts +688 -0
  266. package/src/lib/tokens.test.ts +2 -2
  267. package/src/lib/webgl-scatter.test.ts +36 -0
  268. package/src/lib/webgl-scatter.ts +230 -0
  269. package/src/primitives/Accordion.tsx +218 -63
  270. package/src/primitives/AppBar.tsx +39 -0
  271. package/src/primitives/Autocomplete.tsx +236 -0
  272. package/src/primitives/Avatar.tsx +52 -1
  273. package/src/primitives/Backdrop.tsx +48 -0
  274. package/src/primitives/BottomNavigation.tsx +102 -0
  275. package/src/primitives/ButtonBase.tsx +34 -0
  276. package/src/primitives/ButtonGroup.tsx +90 -0
  277. package/src/primitives/CircularProgress.tsx +104 -0
  278. package/src/primitives/ClickAwayListener.tsx +46 -0
  279. package/src/primitives/Collapse.tsx +39 -0
  280. package/src/primitives/Container.tsx +40 -0
  281. package/src/primitives/Fab.tsx +62 -0
  282. package/src/primitives/FormControl.tsx +90 -0
  283. package/src/primitives/FormControlLabel.tsx +51 -0
  284. package/src/primitives/Grid.tsx +101 -0
  285. package/src/primitives/IconButton.tsx +59 -0
  286. package/src/primitives/ImageList.test.tsx +48 -0
  287. package/src/primitives/ImageList.tsx +112 -0
  288. package/src/primitives/InputAdornment.tsx +86 -0
  289. package/src/primitives/Link.tsx +48 -0
  290. package/src/primitives/List.tsx +121 -0
  291. package/src/primitives/Masonry.tsx +32 -0
  292. package/src/primitives/Menu.tsx +112 -0
  293. package/src/primitives/MobileStepper.tsx +64 -0
  294. package/src/primitives/Modal.tsx +70 -0
  295. package/src/primitives/NoSsr.tsx +28 -0
  296. package/src/primitives/Paper.tsx +55 -0
  297. package/src/primitives/Progress.tsx +25 -7
  298. package/src/primitives/Snackbar.tsx +83 -0
  299. package/src/primitives/SpeedDial.tsx +110 -0
  300. package/src/primitives/Stack.tsx +88 -0
  301. package/src/primitives/StarRating.tsx +87 -28
  302. package/src/primitives/Stepper.tsx +184 -0
  303. package/src/primitives/TablePagination.tsx +58 -0
  304. package/src/primitives/TextField.tsx +105 -0
  305. package/src/primitives/TextareaAutosize.tsx +49 -0
  306. package/src/primitives/ToggleButton.tsx +148 -0
  307. package/src/primitives/TransferList.tsx +110 -0
  308. package/src/primitives/Typography.tsx +90 -0
  309. package/src/primitives/mui-more.test.tsx +76 -0
  310. package/src/primitives/mui.test.tsx +134 -0
  311. package/src/primitives/transitions.tsx +120 -0
@@ -0,0 +1,268 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ createEventId,
4
+ fromDatetimeLocalValue,
5
+ SCHEDULER_COLOR_TONES,
6
+ toDatetimeLocalValue,
7
+ toDate,
8
+ type SchedulerColorTone,
9
+ type SchedulerEvent,
10
+ type SchedulerResource,
11
+ } from "../lib/scheduler";
12
+ import { Button } from "../primitives/Button";
13
+ import { Checkbox } from "../primitives/Checkbox";
14
+ import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogDescription,
18
+ DialogFooter,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ } from "../primitives/Dialog";
22
+ import { Input } from "../primitives/Input";
23
+ import { NativeSelect } from "../primitives/NativeSelect";
24
+ import { Textarea } from "../primitives/Textarea";
25
+ import { FormField } from "./FormField";
26
+
27
+ export type EventEditorDraft = {
28
+ id?: string;
29
+ title?: string;
30
+ start: Date;
31
+ end: Date;
32
+ allDay?: boolean;
33
+ resourceId?: string;
34
+ color?: SchedulerColorTone;
35
+ description?: string;
36
+ rrule?: string;
37
+ };
38
+
39
+ const RRULE_PRESETS: { value: string; label: string }[] = [
40
+ { value: "", label: "Does not repeat" },
41
+ { value: "FREQ=DAILY", label: "Daily" },
42
+ { value: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", label: "Every weekday" },
43
+ { value: "FREQ=WEEKLY", label: "Weekly" },
44
+ { value: "FREQ=MONTHLY", label: "Monthly" },
45
+ { value: "FREQ=YEARLY", label: "Yearly" },
46
+ ];
47
+
48
+ export function EventEditor({
49
+ open,
50
+ onOpenChange,
51
+ draft,
52
+ resources = [],
53
+ readOnly = false,
54
+ onSave,
55
+ onDelete,
56
+ }: {
57
+ open: boolean;
58
+ onOpenChange: (open: boolean) => void;
59
+ draft: EventEditorDraft | null;
60
+ resources?: SchedulerResource[];
61
+ readOnly?: boolean;
62
+ onSave: (event: SchedulerEvent) => void;
63
+ onDelete?: (id: string) => void;
64
+ }) {
65
+ const [title, setTitle] = useState("");
66
+ const [start, setStart] = useState("");
67
+ const [end, setEnd] = useState("");
68
+ const [allDay, setAllDay] = useState(false);
69
+ const [resourceId, setResourceId] = useState("");
70
+ const [color, setColor] = useState<SchedulerColorTone | "">("");
71
+ const [description, setDescription] = useState("");
72
+ const [rrule, setRrule] = useState("");
73
+
74
+ useEffect(() => {
75
+ if (!draft || !open) return;
76
+ setTitle(draft.title ?? "");
77
+ setStart(
78
+ draft.allDay
79
+ ? toDatetimeLocalValue(toDate(draft.start)).slice(0, 10)
80
+ : toDatetimeLocalValue(toDate(draft.start)),
81
+ );
82
+ setEnd(
83
+ draft.allDay
84
+ ? toDatetimeLocalValue(toDate(draft.end)).slice(0, 10)
85
+ : toDatetimeLocalValue(toDate(draft.end)),
86
+ );
87
+ setAllDay(Boolean(draft.allDay));
88
+ setResourceId(draft.resourceId ?? "");
89
+ setColor(draft.color ?? "");
90
+ setDescription(draft.description ?? "");
91
+ setRrule(draft.rrule ?? "");
92
+ }, [draft, open]);
93
+
94
+ const locked = readOnly;
95
+ const isEdit = Boolean(draft?.id);
96
+
97
+ function submit() {
98
+ if (!draft || locked) return;
99
+ const nextStart = allDay ? fromDatetimeLocalValue(`${start}T00:00`) : fromDatetimeLocalValue(start);
100
+ const nextEnd = allDay
101
+ ? fromDatetimeLocalValue(`${end || start}T00:00`)
102
+ : fromDatetimeLocalValue(end || start);
103
+ onSave({
104
+ id: draft.id ?? createEventId(),
105
+ title: title.trim() || "Untitled",
106
+ start: nextStart,
107
+ end: nextEnd.getTime() <= nextStart.getTime()
108
+ ? new Date(nextStart.getTime() + (allDay ? 86_400_000 : 3_600_000))
109
+ : nextEnd,
110
+ allDay,
111
+ resourceId: resourceId || undefined,
112
+ color: color || undefined,
113
+ description: description.trim() || undefined,
114
+ rrule: rrule || undefined,
115
+ });
116
+ onOpenChange(false);
117
+ }
118
+
119
+ return (
120
+ <Dialog open={open} onOpenChange={onOpenChange}>
121
+ <DialogContent className="max-w-md">
122
+ <DialogHeader>
123
+ <DialogTitle>{locked ? "Event" : isEdit ? "Edit event" : "New event"}</DialogTitle>
124
+ <DialogDescription>
125
+ {locked ? "This calendar is read-only." : "Title, time, resource, and recurrence."}
126
+ </DialogDescription>
127
+ </DialogHeader>
128
+ {draft ? (
129
+ <form
130
+ className="grid gap-3"
131
+ onSubmit={(event) => {
132
+ event.preventDefault();
133
+ submit();
134
+ }}
135
+ >
136
+ <FormField id="spk-event-title" label="Title" required>
137
+ <Input
138
+ id="spk-event-title"
139
+ value={title}
140
+ onChange={(event) => setTitle(event.target.value)}
141
+ disabled={locked}
142
+ autoFocus={!locked}
143
+ />
144
+ </FormField>
145
+ <label className="flex items-center gap-2 text-sm font-medium">
146
+ <Checkbox
147
+ checked={allDay}
148
+ onCheckedChange={(value) => {
149
+ const next = value === true;
150
+ setAllDay(next);
151
+ if (next) {
152
+ setStart(start.slice(0, 10));
153
+ setEnd((end || start).slice(0, 10));
154
+ } else {
155
+ setStart(start.length <= 10 ? `${start}T09:00` : start);
156
+ setEnd((end || start).length <= 10 ? `${end || start}T10:00` : end);
157
+ }
158
+ }}
159
+ disabled={locked}
160
+ />
161
+ All day
162
+ </label>
163
+ <div className="grid gap-3 sm:grid-cols-2">
164
+ <FormField id="spk-event-start" label="Start">
165
+ <Input
166
+ id="spk-event-start"
167
+ type={allDay ? "date" : "datetime-local"}
168
+ value={start}
169
+ onChange={(event) => setStart(event.target.value)}
170
+ disabled={locked}
171
+ />
172
+ </FormField>
173
+ <FormField id="spk-event-end" label="End">
174
+ <Input
175
+ id="spk-event-end"
176
+ type={allDay ? "date" : "datetime-local"}
177
+ value={end}
178
+ onChange={(event) => setEnd(event.target.value)}
179
+ disabled={locked}
180
+ />
181
+ </FormField>
182
+ </div>
183
+ {resources.length > 0 ? (
184
+ <FormField id="spk-event-resource" label="Resource">
185
+ <NativeSelect
186
+ id="spk-event-resource"
187
+ value={resourceId}
188
+ onChange={(event) => setResourceId(event.target.value)}
189
+ disabled={locked}
190
+ className="h-9 min-h-9"
191
+ >
192
+ <option value="">None</option>
193
+ {resources.map((resource) => (
194
+ <option key={resource.id} value={resource.id}>
195
+ {resource.title}
196
+ </option>
197
+ ))}
198
+ </NativeSelect>
199
+ </FormField>
200
+ ) : null}
201
+ <FormField id="spk-event-color" label="Color">
202
+ <NativeSelect
203
+ id="spk-event-color"
204
+ value={color}
205
+ onChange={(event) => setColor(event.target.value as SchedulerColorTone | "")}
206
+ disabled={locked}
207
+ className="h-9 min-h-9"
208
+ >
209
+ <option value="">From resource</option>
210
+ {SCHEDULER_COLOR_TONES.map((tone) => (
211
+ <option key={tone} value={tone}>
212
+ {tone}
213
+ </option>
214
+ ))}
215
+ </NativeSelect>
216
+ </FormField>
217
+ <FormField id="spk-event-repeat" label="Repeat">
218
+ <NativeSelect
219
+ id="spk-event-repeat"
220
+ value={rrule}
221
+ onChange={(event) => setRrule(event.target.value)}
222
+ disabled={locked}
223
+ className="h-9 min-h-9"
224
+ >
225
+ {RRULE_PRESETS.map((preset) => (
226
+ <option key={preset.value || "none"} value={preset.value}>
227
+ {preset.label}
228
+ </option>
229
+ ))}
230
+ {rrule && !RRULE_PRESETS.some((preset) => preset.value === rrule) ? (
231
+ <option value={rrule}>Custom</option>
232
+ ) : null}
233
+ </NativeSelect>
234
+ </FormField>
235
+ <FormField id="spk-event-notes" label="Description">
236
+ <Textarea
237
+ id="spk-event-notes"
238
+ value={description}
239
+ onChange={(event) => setDescription(event.target.value)}
240
+ disabled={locked}
241
+ rows={3}
242
+ />
243
+ </FormField>
244
+ <DialogFooter>
245
+ {isEdit && onDelete && !locked ? (
246
+ <Button
247
+ type="button"
248
+ variant="destructive"
249
+ className="sm:mr-auto"
250
+ onClick={() => {
251
+ onDelete(draft.id!);
252
+ onOpenChange(false);
253
+ }}
254
+ >
255
+ Delete
256
+ </Button>
257
+ ) : null}
258
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
259
+ {locked ? "Close" : "Cancel"}
260
+ </Button>
261
+ {!locked ? <Button type="submit">Save</Button> : null}
262
+ </DialogFooter>
263
+ </form>
264
+ ) : null}
265
+ </DialogContent>
266
+ </Dialog>
267
+ );
268
+ }
@@ -0,0 +1,357 @@
1
+ import { useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
2
+ import { expandVisibleEvents } from "../lib/rrule";
3
+ import { useControllableState } from "../lib/use-controllable-state";
4
+ import {
5
+ eventCanDrag,
6
+ eventCanResize,
7
+ eventColorStyle,
8
+ formatMonthYear,
9
+ getTimelineRange,
10
+ layoutTimelineBars,
11
+ normalizeEvents,
12
+ occurrenceKey,
13
+ resizeEvent,
14
+ shiftByMs,
15
+ shiftTimelineDate,
16
+ toDate,
17
+ visibleEvents,
18
+ type NormalizedEvent,
19
+ type SchedulerEvent,
20
+ type SchedulerResource,
21
+ type TimelineScale,
22
+ } from "../lib/scheduler";
23
+ import { EventEditor, type EventEditorDraft } from "./EventEditor";
24
+ import {
25
+ EventResizeHandle,
26
+ ResourceLegend,
27
+ SchedulerShell,
28
+ SchedulerToolbar,
29
+ TIMELINE_SCALE_LABEL,
30
+ } from "./scheduler-ui";
31
+ import { DropdownMenuItem } from "../primitives/DropdownMenu";
32
+
33
+ export type EventTimelineProps = {
34
+ events?: SchedulerEvent[];
35
+ resources?: SchedulerResource[];
36
+ scale?: TimelineScale;
37
+ defaultScale?: TimelineScale;
38
+ onScaleChange?: (scale: TimelineScale) => void;
39
+ date?: Date;
40
+ defaultDate?: Date;
41
+ onDateChange?: (date: Date) => void;
42
+ scales?: TimelineScale[];
43
+ locale?: string;
44
+ readOnly?: boolean;
45
+ areEventsDraggable?: boolean;
46
+ areEventsResizable?: boolean;
47
+ onEventClick?: (event: SchedulerEvent) => void;
48
+ onEventChange?: (event: SchedulerEvent, next: Partial<SchedulerEvent>) => void;
49
+ onEventCreate?: (event: SchedulerEvent) => void;
50
+ onEventDelete?: (event: SchedulerEvent) => void;
51
+ className?: string;
52
+ };
53
+
54
+ const LANE_HEIGHT = 28;
55
+ const LANE_GAP = 4;
56
+ const ROW_PAD = 10;
57
+
58
+ function sourceEvent(events: SchedulerEvent[], id: string): SchedulerEvent | undefined {
59
+ return events.find((event) => event.id === id);
60
+ }
61
+
62
+ function draftFromSource(source: SchedulerEvent): EventEditorDraft {
63
+ return {
64
+ id: source.id,
65
+ title: source.title,
66
+ start: toDate(source.start),
67
+ end: toDate(source.end),
68
+ allDay: source.allDay,
69
+ resourceId: source.resourceId,
70
+ color: source.color,
71
+ description: source.description,
72
+ rrule: source.rrule,
73
+ };
74
+ }
75
+
76
+ function timelineTitle(scale: TimelineScale, date: Date, rangeStart: Date, rangeEnd: Date, locale?: string): string {
77
+ if (scale === "hours") {
78
+ return new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric" }).format(date);
79
+ }
80
+ if (scale === "years") {
81
+ return `${rangeStart.getFullYear()}–${rangeEnd.getFullYear() - 1}`;
82
+ }
83
+ if (scale === "months") return String(date.getFullYear());
84
+ return formatMonthYear(date, locale);
85
+ }
86
+
87
+ /** Horizontal resource timeline — rows are resources, bars are allocations across time. */
88
+ export function EventTimeline({
89
+ events = [],
90
+ resources = [],
91
+ scale: scaleProp,
92
+ defaultScale = "months",
93
+ onScaleChange,
94
+ date: dateProp,
95
+ defaultDate,
96
+ onDateChange,
97
+ scales = ["hours", "days", "weeks", "months", "years"],
98
+ locale,
99
+ readOnly = false,
100
+ areEventsDraggable,
101
+ areEventsResizable,
102
+ onEventClick,
103
+ onEventChange,
104
+ onEventCreate,
105
+ onEventDelete,
106
+ className,
107
+ }: EventTimelineProps) {
108
+ const [scale, setScale] = useControllableState<TimelineScale>({
109
+ prop: scaleProp,
110
+ defaultProp: defaultScale,
111
+ onChange: onScaleChange,
112
+ });
113
+ const [date, setDate] = useControllableState<Date>({
114
+ prop: dateProp,
115
+ defaultProp: defaultDate ?? new Date(),
116
+ onChange: onDateChange,
117
+ });
118
+ const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
119
+ const [draft, setDraft] = useState<EventEditorDraft | null>(null);
120
+ const dragRef = useRef<{ id: string; x: number; active: boolean } | null>(null);
121
+
122
+ const resolvedScale = scale ?? "months";
123
+ const resolvedDate = date ?? new Date();
124
+ const range = useMemo(
125
+ () => getTimelineRange(resolvedDate, resolvedScale, locale),
126
+ [resolvedDate, resolvedScale, locale],
127
+ );
128
+ const canDrag = !readOnly && (areEventsDraggable ?? Boolean(onEventChange));
129
+ const canResize = !readOnly && (areEventsResizable ?? Boolean(onEventChange));
130
+ const editorLocked = readOnly || Boolean(draft?.id && sourceEvent(events, draft.id)?.readOnly);
131
+
132
+ const normalized = useMemo(() => {
133
+ const visible = visibleEvents(normalizeEvents(events, resources), hiddenIds);
134
+ return expandVisibleEvents(visible, range.start, range.end, events);
135
+ }, [events, resources, hiddenIds, range.start, range.end]);
136
+
137
+ const visibleResources = resources.filter((resource) => !hiddenIds.has(resource.id));
138
+ const rows = visibleResources.length > 0
139
+ ? visibleResources
140
+ : [{ id: "__all", title: "Events", color: "blue" as const }];
141
+
142
+ const title = timelineTitle(resolvedScale, resolvedDate, range.start, range.end, locale);
143
+
144
+ function toggleResource(id: string) {
145
+ setHiddenIds((prev) => {
146
+ const next = new Set(prev);
147
+ if (next.has(id)) next.delete(id);
148
+ else next.add(id);
149
+ return next;
150
+ });
151
+ }
152
+
153
+ function openEvent(event: NormalizedEvent) {
154
+ const source = sourceEvent(events, event.id);
155
+ if (source) onEventClick?.(source);
156
+ setDraft(source ? draftFromSource(source) : {
157
+ id: event.id,
158
+ title: event.title,
159
+ start: event.start,
160
+ end: event.end,
161
+ allDay: event.allDay,
162
+ resourceId: event.resourceId,
163
+ color: event.color,
164
+ description: event.description,
165
+ rrule: event.rrule,
166
+ });
167
+ }
168
+
169
+ function handlePointerDown(event: NormalizedEvent, pointer: ReactPointerEvent) {
170
+ if (!onEventChange || !eventCanDrag(event, { readOnly, enabled: canDrag })) return;
171
+ (pointer.currentTarget as HTMLElement).setPointerCapture?.(pointer.pointerId);
172
+ dragRef.current = { id: event.id, x: pointer.clientX, active: false };
173
+ }
174
+
175
+ function handlePointerMove(pointer: ReactPointerEvent) {
176
+ const drag = dragRef.current;
177
+ if (!drag) return;
178
+ if (Math.abs(pointer.clientX - drag.x) > 6) drag.active = true;
179
+ }
180
+
181
+ function handlePointerUp(event: NormalizedEvent, pointer: ReactPointerEvent) {
182
+ const drag = dragRef.current;
183
+ dragRef.current = null;
184
+ if (!drag?.active || !onEventChange) return;
185
+ const grid = (pointer.currentTarget as HTMLElement).closest("[data-scheduler-grid]") as HTMLElement | null;
186
+ if (!grid) return;
187
+ const rect = grid.getBoundingClientRect();
188
+ const rangeMs = range.end.getTime() - range.start.getTime();
189
+ const deltaPx = pointer.clientX - drag.x;
190
+ const deltaMs = (deltaPx / rect.width) * rangeMs;
191
+ const source = sourceEvent(events, event.id);
192
+ if (!source) return;
193
+ const master = normalizeEvents([source], resources)[0];
194
+ onEventChange(source, shiftByMs(master, deltaMs));
195
+ }
196
+
197
+ function commitResize(event: NormalizedEvent, edge: "start" | "end", instant: Date) {
198
+ if (!onEventChange) return;
199
+ const next = resizeEvent(event, edge, instant);
200
+ const source = sourceEvent(events, event.id);
201
+ if (!source) return;
202
+ const master = normalizeEvents([source], resources)[0];
203
+ onEventChange(source, {
204
+ start: new Date(master.start.getTime() + (next.start.getTime() - event.start.getTime())),
205
+ end: new Date(master.end.getTime() + (next.end.getTime() - event.end.getTime())),
206
+ });
207
+ }
208
+
209
+ function renderBar(event: NormalizedEvent): ReactNode {
210
+ const resizable = eventCanResize(event, { readOnly, enabled: canResize }) && Boolean(onEventChange);
211
+ const button = (
212
+ <button
213
+ type="button"
214
+ className="flex h-6 w-full items-center truncate rounded-md px-2 text-left text-[11px] font-semibold shadow-sm"
215
+ style={eventColorStyle(event.color, "bar")}
216
+ onClick={() => {
217
+ if (dragRef.current?.active) return;
218
+ openEvent(event);
219
+ }}
220
+ onPointerDown={(pointer) => handlePointerDown(event, pointer)}
221
+ onPointerMove={handlePointerMove}
222
+ onPointerUp={(pointer) => handlePointerUp(event, pointer)}
223
+ >
224
+ {event.title}
225
+ </button>
226
+ );
227
+
228
+ if (!resizable) return button;
229
+
230
+ return (
231
+ <div className="relative h-full">
232
+ {button}
233
+ <EventResizeHandle event={event} edge="start" axis="ew" onCommit={commitResize} />
234
+ <EventResizeHandle event={event} edge="end" axis="ew" onCommit={commitResize} />
235
+ </div>
236
+ );
237
+ }
238
+
239
+ return (
240
+ <SchedulerShell slot="event-timeline" className={className} aria-label="Event timeline">
241
+ <SchedulerToolbar
242
+ title={title}
243
+ onPrev={() => setDate(shiftTimelineDate(resolvedDate, resolvedScale, -1))}
244
+ onNext={() => setDate(shiftTimelineDate(resolvedDate, resolvedScale, 1))}
245
+ onToday={() => setDate(new Date())}
246
+ menuLabel={TIMELINE_SCALE_LABEL[resolvedScale]}
247
+ menu={scales.map((item) => (
248
+ <DropdownMenuItem key={item} onClick={() => setScale(item)}>
249
+ {TIMELINE_SCALE_LABEL[item]}
250
+ </DropdownMenuItem>
251
+ ))}
252
+ />
253
+
254
+ <div className="overflow-x-auto">
255
+ <div className="min-w-[720px]">
256
+ <div className="grid" style={{ gridTemplateColumns: "11rem minmax(0, 1fr)" }}>
257
+ <div className="border-b border-r border-border/50 px-3 py-2 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
258
+ Resource
259
+ </div>
260
+ <div
261
+ className="grid border-b border-border/50"
262
+ style={{ gridTemplateColumns: `repeat(${range.ticks.length}, minmax(0, 1fr))` }}
263
+ >
264
+ {range.ticks.map((tick) => (
265
+ <div
266
+ key={tick.date.toISOString()}
267
+ className="border-l border-border/40 px-2 py-2 text-center text-xs font-bold text-foreground first:border-l-0"
268
+ >
269
+ {tick.label}
270
+ </div>
271
+ ))}
272
+ </div>
273
+
274
+ {rows.map((resource) => {
275
+ const rowEvents =
276
+ resource.id === "__all"
277
+ ? normalized
278
+ : normalized.filter((event) => event.resourceId === resource.id);
279
+ const bars = layoutTimelineBars(rowEvents, range.start, range.end);
280
+ const laneCount = Math.max(1, ...bars.map((bar) => bar.lane + 1), 1);
281
+ const height = ROW_PAD * 2 + laneCount * LANE_HEIGHT + (laneCount - 1) * LANE_GAP;
282
+ const tone = resource.color ?? (rowEvents[0]?.color ?? "blue");
283
+
284
+ return (
285
+ <div key={resource.id} className="contents">
286
+ <div
287
+ className="flex items-center gap-2 border-b border-r border-border/40 px-3 py-2"
288
+ style={{ minHeight: height }}
289
+ >
290
+ <span className="size-2.5 shrink-0 rounded-full" style={eventColorStyle(tone, "dot")} />
291
+ <span className="truncate text-sm font-semibold text-foreground">{resource.title}</span>
292
+ </div>
293
+ <div
294
+ data-scheduler-grid
295
+ data-range-start={range.start.getTime()}
296
+ data-range-end={range.end.getTime()}
297
+ className="relative border-b border-border/40"
298
+ style={{
299
+ minHeight: height,
300
+ backgroundImage: `repeating-linear-gradient(90deg, transparent, transparent calc(${100 / range.ticks.length}% - 1px), color-mix(in srgb, var(--border) 40%, transparent) calc(${100 / range.ticks.length}% - 1px), color-mix(in srgb, var(--border) 40%, transparent) ${100 / range.ticks.length}%)`,
301
+ }}
302
+ >
303
+ {bars.map((bar) => (
304
+ <div
305
+ key={occurrenceKey(bar.event)}
306
+ className="absolute"
307
+ style={{
308
+ top: ROW_PAD + bar.lane * (LANE_HEIGHT + LANE_GAP),
309
+ left: `${bar.left}%`,
310
+ width: `${bar.width}%`,
311
+ height: LANE_HEIGHT,
312
+ }}
313
+ >
314
+ {renderBar(bar.event)}
315
+ </div>
316
+ ))}
317
+ </div>
318
+ </div>
319
+ );
320
+ })}
321
+ </div>
322
+ </div>
323
+ </div>
324
+
325
+ <ResourceLegend
326
+ resources={resources}
327
+ hiddenIds={hiddenIds}
328
+ onToggle={toggleResource}
329
+ />
330
+
331
+ <EventEditor
332
+ open={draft != null}
333
+ onOpenChange={(open) => {
334
+ if (!open) setDraft(null);
335
+ }}
336
+ draft={draft}
337
+ resources={resources}
338
+ readOnly={editorLocked}
339
+ onSave={(event) => {
340
+ if (event.id && sourceEvent(events, event.id)) {
341
+ onEventChange?.(sourceEvent(events, event.id)!, event);
342
+ } else {
343
+ onEventCreate?.(event);
344
+ }
345
+ }}
346
+ onDelete={
347
+ onEventDelete
348
+ ? (id) => {
349
+ const source = sourceEvent(events, id);
350
+ if (source) onEventDelete(source);
351
+ }
352
+ : undefined
353
+ }
354
+ />
355
+ </SchedulerShell>
356
+ );
357
+ }
@@ -0,0 +1,39 @@
1
+ import { render } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { MapChart } from "./MapChart";
4
+
5
+ describe("MapChart", () => {
6
+ it("draws choropleth paths from GeoJSON", () => {
7
+ const { container } = render(
8
+ <MapChart
9
+ width={240}
10
+ height={160}
11
+ geoData={{
12
+ type: "FeatureCollection",
13
+ features: [
14
+ {
15
+ type: "Feature",
16
+ id: "a",
17
+ properties: { name: "A" },
18
+ geometry: {
19
+ type: "Polygon",
20
+ coordinates: [
21
+ [
22
+ [0, 0],
23
+ [4, 0],
24
+ [4, 3],
25
+ [0, 3],
26
+ [0, 0],
27
+ ],
28
+ ],
29
+ },
30
+ },
31
+ ],
32
+ }}
33
+ series={[{ data: [{ id: "a", value: 12, label: "A" }] }]}
34
+ />,
35
+ );
36
+ expect(container.querySelector('[data-slot="map-chart"]')).toBeTruthy();
37
+ expect(container.querySelector('[data-slot="map-chart"] path')).toBeTruthy();
38
+ });
39
+ });