makersclaw 0.4.0 → 0.5.1

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 (102) hide show
  1. package/package.json +1 -1
  2. package/r/annotate.json +3 -3
  3. package/r/approval-request.json +4 -4
  4. package/r/audio-player.json +2 -2
  5. package/r/auth-form.json +2 -2
  6. package/r/board.json +2 -2
  7. package/r/calendar-view.json +9 -9
  8. package/r/cart.json +2 -2
  9. package/r/chart.json +7 -7
  10. package/r/chat.json +3 -3
  11. package/r/code-editor.json +2 -2
  12. package/r/comment-box.json +5 -5
  13. package/r/contact-section.json +2 -2
  14. package/r/core.json +5 -5
  15. package/r/csv-import.json +3 -3
  16. package/r/data-table.json +4 -4
  17. package/r/diagram.json +2 -2
  18. package/r/doc-editor.json +2 -2
  19. package/r/doc-page.json +3 -3
  20. package/r/email-render.json +2 -2
  21. package/r/faq.json +2 -2
  22. package/r/file-upload.json +2 -2
  23. package/r/flow-canvas.json +4 -4
  24. package/r/footer.json +2 -2
  25. package/r/gantt.json +3 -3
  26. package/r/job-panel.json +2 -2
  27. package/r/json-editor.json +3 -3
  28. package/r/map.json +2 -2
  29. package/r/menubar.json +2 -2
  30. package/r/mind-map.json +6 -6
  31. package/r/navigation-menu.json +2 -2
  32. package/r/order-detail.json +2 -2
  33. package/r/org-chart.json +4 -4
  34. package/r/page-builder.json +2 -2
  35. package/r/payment-methods.json +2 -2
  36. package/r/pdf-document.json +2 -2
  37. package/r/pdf-viewer.json +3 -3
  38. package/r/product-detail.json +2 -2
  39. package/r/product-grid.json +3 -3
  40. package/r/record-form.json +2 -2
  41. package/r/record-notes.json +6 -6
  42. package/r/registry.json +121 -121
  43. package/r/search-dialog.json +2 -2
  44. package/r/settings-list.json +2 -2
  45. package/r/setup-checklist.json +2 -2
  46. package/r/share-dialog.json +2 -2
  47. package/r/sidebar.json +4 -4
  48. package/r/signature-pad.json +3 -3
  49. package/r/sketch-canvas.json +2 -2
  50. package/r/spreadsheet.json +3 -3
  51. package/r/stat-cards.json +3 -3
  52. package/r/task-list.json +3 -3
  53. package/r/task-model.json +2 -2
  54. package/r/testimonials.json +2 -2
  55. package/r/transaction-list.json +2 -2
  56. package/r/whiteboard.json +3 -3
  57. package/registry/blocks/approval-request/README.md +2 -1
  58. package/registry/blocks/approval-request/approval-request.jsx +5 -3
  59. package/registry/blocks/calendar-view/README.md +8 -0
  60. package/registry/blocks/calendar-view/agenda.jsx +5 -4
  61. package/registry/blocks/calendar-view/calendar.jsx +23 -8
  62. package/registry/blocks/calendar-view/demo.jsx +2 -0
  63. package/registry/blocks/calendar-view/month.jsx +6 -5
  64. package/registry/blocks/calendar-view/time-grid.jsx +9 -7
  65. package/registry/blocks/calendar-view/tray.jsx +5 -4
  66. package/registry/blocks/chart/README.md +9 -4
  67. package/registry/blocks/chart/cartesian.jsx +31 -24
  68. package/registry/blocks/chart/core.jsx +21 -0
  69. package/registry/blocks/chart/pie.jsx +15 -10
  70. package/registry/blocks/chart/sparkline.jsx +32 -14
  71. package/registry/blocks/chat/message.jsx +4 -4
  72. package/registry/blocks/comment-box/README.md +1 -1
  73. package/registry/blocks/comment-box/comment-time.jsx +4 -6
  74. package/registry/blocks/data-table/README.md +4 -0
  75. package/registry/blocks/data-table/data-table.jsx +20 -6
  76. package/registry/blocks/flow-canvas/edges.jsx +4 -2
  77. package/registry/blocks/flow-canvas/theme.js +59 -48
  78. package/registry/blocks/mind-map/README.md +9 -2
  79. package/registry/blocks/mind-map/mind-map.jsx +51 -12
  80. package/registry/blocks/mind-map/tree.js +33 -0
  81. package/registry/blocks/org-chart/org-chart.jsx +1 -1
  82. package/registry/blocks/pdf-viewer/notes.jsx +4 -3
  83. package/registry/blocks/record-notes/README.md +1 -1
  84. package/registry/blocks/record-notes/record-notes.jsx +4 -5
  85. package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
  86. package/registry/blocks/sidebar/sidebar.jsx +13 -13
  87. package/registry/blocks/signature-pad/signature.js +8 -1
  88. package/registry/blocks/spreadsheet/cells.jsx +2 -2
  89. package/templates/next/files/README.md +77 -19
  90. package/templates/next/files/app/auto-refresh.jsx +39 -74
  91. package/templates/next/files/app/frame.js +197 -6
  92. package/templates/next/files/check/run.mjs +26 -10
  93. package/templates/next/files/components/ui/calendar.jsx +24 -0
  94. package/templates/next/files/components/ui/field.jsx +12 -3
  95. package/templates/next/files/components/ui/index.js +1 -0
  96. package/templates/next/files/components/ui/sheet.jsx +38 -2
  97. package/templates/next/files/components/ui/toggle.jsx +20 -2
  98. package/templates/next/files/lib/actions.js +63 -5
  99. package/templates/next/files/lib/data.js +4 -1
  100. package/templates/next/files/lib/db.js +18 -1
  101. package/templates/next/files/lib/hooks.js +52 -17
  102. package/templates/next/template.json +2 -2
@@ -5,7 +5,7 @@
5
5
  "title": "Testimonials",
6
6
  "description": "A marketing section of customer quotes: name, role, company, face or logo, the quote, an optional star rating, a verified mark and a \"view on X\" source, and optional headline numbers (stats). Layouts: a grid of cards (plain, card or raised; hover can light one and dim the rest; a \"write a review\" tile), one large quote, a switcher of faces, and a carousel, optionally over a picture.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.1/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -45,7 +45,7 @@
45
45
  "docs": "Installed to components/makersclaw/blocks/testimonials. Import: import { Testimonials } from \"@/components/makersclaw/blocks/testimonials\". Props and an example: components/makersclaw/blocks/testimonials/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
46
46
  "meta": {
47
47
  "makersclaw": {
48
- "version": "0.4.0",
48
+ "version": "0.5.1",
49
49
  "useWhen": "Social proof on a public page: a landing page, a pricing page, a product page, a case-study teaser. Not for reviews a signed-in user writes and manages (a record list or form), or an app's own activity feed."
50
50
  }
51
51
  }
@@ -5,7 +5,7 @@
5
5
  "title": "TransactionList",
6
6
  "description": "Money records as a plain list, in two variants: history (payments in and out, grouped by month, a received / paid / pending / failed mark and a signed amount) and invoices (number, date, a Paid / Due in N days / N days overdue badge, amount, download). Optional date-range filter, Export and per-row download. Empty, loading and error states built in.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.1/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -46,7 +46,7 @@
46
46
  "docs": "Installed to components/makersclaw/blocks/transaction-list. Import: import { TransactionList } from \"@/components/makersclaw/blocks/transaction-list\". Props and an example: components/makersclaw/blocks/transaction-list/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
47
47
  "meta": {
48
48
  "makersclaw": {
49
- "version": "0.4.0",
49
+ "version": "0.5.1",
50
50
  "useWhen": "A billing page's invoices, an account's payment history, a wallet's recent activity: records read top to bottom, newest first. Not for records the founder sorts, filters by column or totals (data-table), or for one order (order-detail)."
51
51
  }
52
52
  }
package/r/whiteboard.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "Whiteboard",
6
6
  "description": "A full-page freeform whiteboard on sketch-canvas: a top bar with the board's title (click to rename), the save state (Saving…, Saved, or the error with Retry), a Board / Text view switch, Export PNG and an optional Share button; under it the Miro-style canvas (tool rail, floating toolbar, zoom, undo). An empty board offers starter templates — blank, a grid of titled areas, a 2×2 matrix — and the Text view lists every word on the board for a screen reader or an agent. With onCreateTask, a selected note or text becomes a task from the top bar (\"Create task\", editable title; several notes make one task each), and a small badge marks it on the board from then on. The sketch is plain JSON; saves are async callbacks answering { ok }.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/sketch-canvas.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.1/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.1/r/sketch-canvas.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -59,7 +59,7 @@
59
59
  "docs": "Installed to components/makersclaw/blocks/whiteboard. Import: import { Whiteboard, WHITEBOARD_TEMPLATES, gridSketch, matrixSketch } from \"@/components/makersclaw/blocks/whiteboard\". Props and an example: components/makersclaw/blocks/whiteboard/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
60
60
  "meta": {
61
61
  "makersclaw": {
62
- "version": "0.4.0",
62
+ "version": "0.5.1",
63
63
  "useWhen": "A screen is one board to think on: a team whiteboard, a planning or prioritisation board, a place to sketch an idea and keep it with a record. Not for marking up a screenshot (annotate), sketching a UI to build (sketch-to-app), a structured board of cards and connectors (flow-canvas), or a kanban (board)."
64
64
  }
65
65
  }
@@ -44,7 +44,8 @@ and pass its fields in.
44
44
  - `decidedBy` · `decidedAt` · `decisionNote` — for a request decided
45
45
  before: who, when, and the reason, shown under the outcome.
46
46
  - `kind` — a small badge beside the title ("Per seat", "Refund").
47
- - `requestedAt` — an ISO date or a `Date`.
47
+ - `requestedAt` — an ISO time, a `Date`, or a day (`"2026-09-28"`, a
48
+ `date` column), which reads as that day wherever the viewer is.
48
49
  - `justification` — the requester's reason, as a paragraph.
49
50
  - `reasonRequired` — default `true`: Reject opens a reason field and
50
51
  sends only when it is filled. `false`: Reject sends at once.
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useId, useState } from "react";
4
- import { Avatar, Badge, Button, Empty, Field, Icon, Notice, Separator, Skeleton, Textarea, cx } from "../../ui";
4
+ import { Avatar, Badge, Button, Empty, Field, Icon, Notice, Separator, Skeleton, Textarea, cx, toLocalDate } from "../../ui";
5
5
 
6
6
  // ApprovalRequest — one request waiting on a decision: what is asked for
7
7
  // (label/value facts), who asked and when, why, and Approve / Reject. The
@@ -15,10 +15,12 @@ const STATUS = {
15
15
  rejected: { tone: "danger", label: "Rejected" },
16
16
  };
17
17
 
18
+ // A day alone ("2026-09-28", a `date` column) reads as that day wherever
19
+ // the viewer is: toLocalDate makes it local midnight, never UTC's.
18
20
  function defaultFormat(value) {
19
21
  if (!value) return "";
20
- const d = value instanceof Date ? value : new Date(value);
21
- if (Number.isNaN(d.getTime())) return String(value);
22
+ const d = toLocalDate(value);
23
+ if (!d) return String(value);
22
24
  return new Intl.DateTimeFormat(undefined, { dateStyle: "long" }).format(d);
23
25
  }
24
26
 
@@ -54,6 +54,11 @@ times; dropping a timed block on the all-day strip saves `start: null`;
54
54
  dropping on the tray saves both as `null`). A new task from a click is
55
55
  `draftTask(title, ctx, { due })` or `{ start, due }`.
56
56
 
57
+ A task with `locked: true` stays where it is: no drag, resize, arrow
58
+ keys, Delete or done tick for it, and it still opens (`onOpen`). For
59
+ what the app shows but did not make — events mirrored from the person's
60
+ calendar — beside what it may move.
61
+
57
62
  ## Props
58
63
 
59
64
  - `tasks` — the tasks. Pass `useFixture(rows, SAMPLE).rows` and the
@@ -64,6 +69,9 @@ dropping on the tray saves both as `null`). A new task from a click is
64
69
  `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app,
65
70
  loop over `changeWrites(change)` (task-model README, "A change").
66
71
  Without it the calendar only shows: no drag, no quick-add, no checks.
72
+ - `canCreate` — `false` keeps every change to the tasks there are and
73
+ takes away every way to add one: a click or a drag across an empty
74
+ day or hour, C, the Add buttons (default `true`; with `onChange`).
67
75
  - `onOpen(task)` — clicking an item (or Enter on it) opens your `Sheet`.
68
76
  - `mode` · `defaultMode` (`"month"`) · `onModeChange(mode)` — month ·
69
77
  week · day · agenda. `modes` — which ones the switcher offers (default
@@ -28,7 +28,7 @@ export function AgendaView({ days, events, api }) {
28
28
  title="Nothing scheduled in these days"
29
29
  body="Tasks with a date show up here, day by day. Give one a date, or add a new one."
30
30
  action={
31
- api.editable ? (
31
+ api.creatable ? (
32
32
  <Button variant="primary" size="sm" icon="plus" onClick={() => api.startDraft({ day: days[0], allDay: true })}>
33
33
  {api.addLabel}
34
34
  </Button>
@@ -81,14 +81,14 @@ export function AgendaDay({ day, events, api, standalone = false }) {
81
81
  {!items.length && !drafting ? (
82
82
  <li className="flex min-h-11 items-center justify-between gap-2 px-2 text-sm text-muted-foreground">
83
83
  <span>{today ? "Nothing scheduled today" : "Nothing scheduled"}</span>
84
- {api.editable ? (
84
+ {api.creatable ? (
85
85
  <Button size="sm" variant="quiet" icon="plus" onClick={() => api.startDraft({ day, allDay: true })}>
86
86
  {api.addLabel}
87
87
  </Button>
88
88
  ) : null}
89
89
  </li>
90
90
  ) : null}
91
- {items.length > 0 && standalone && api.editable && !drafting ? (
91
+ {items.length > 0 && standalone && api.creatable && !drafting ? (
92
92
  <li className="pt-1">
93
93
  <Button size="sm" variant="quiet" icon="plus" align="start" onClick={() => api.startDraft({ day, allDay: true })}>
94
94
  {api.addLabel}
@@ -101,8 +101,9 @@ export function AgendaDay({ day, events, api, standalone = false }) {
101
101
  }
102
102
 
103
103
  function AgendaRow({ event, day, api }) {
104
- const { ctx, locale, editable } = api;
104
+ const { ctx, locale } = api;
105
105
  const task = event.task;
106
+ const editable = api.canEdit(task);
106
107
  const done = isDone(task, ctx);
107
108
  const title = task.title || "Untitled";
108
109
  const paint = paintOf(task, ctx, api.colorBy);
@@ -61,6 +61,15 @@ import { isTyping } from "./paint";
61
61
  // (task-model's change shapes); the calendar shows each change at once
62
62
  // and takes it back, with a notice, when the save fails.
63
63
  //
64
+ // Editing is per item, not all or nothing: `canCreate={false}` keeps the
65
+ // dragging and takes away every way to add (a click or a drag on an empty
66
+ // day or hour, C, the Add buttons), and a task with `locked: true` stays
67
+ // where it is (no drag, resize, keys, delete or done) but still opens.
68
+ // Until 0.5.0 an onChange that let items move also let a click create
69
+ // one, and moved every item: an app that shows events it did not make (a
70
+ // mirror of the person's calendar) beside proposals it may move could not
71
+ // have one without the other (inbox-to-calendar 0.3.0).
72
+ //
64
73
  // The available-time layer (closed hours muted, open slots as boxes you
65
74
  // can pick) is what booking-page builds on: `availability`, `slots` or
66
75
  // `getSlots`, `renderSlot`, `onSlotClick`. README.md lists every prop.
@@ -113,6 +122,7 @@ export function CalendarView({
113
122
  now: nowProp,
114
123
  locale,
115
124
  onChange,
125
+ canCreate = true,
116
126
  onOpen,
117
127
  mode: modeProp,
118
128
  defaultMode = "month",
@@ -152,6 +162,8 @@ export function CalendarView({
152
162
  [statuses, people, projects, fields, today, weekStartsOn, weekend, holidays],
153
163
  );
154
164
  const editable = typeof onChange === "function";
165
+ const creatable = editable && canCreate !== false;
166
+ const canEdit = (task) => editable && !task?.locked;
155
167
  const { tasks: all, commit, error, clearError } = useTaskChanges(tasks, onChange);
156
168
  const now = useNow(nowProp);
157
169
 
@@ -194,7 +206,7 @@ export function CalendarView({
194
206
  // ---- saving ----------------------------------------------------------
195
207
 
196
208
  function update(task, patch, words) {
197
- if (!patch) return Promise.resolve({ ok: false });
209
+ if (!patch || !canEdit(task)) return Promise.resolve({ ok: false });
198
210
  return commit({ type: "update", id: task.id, patch }, { label: quote(task) }).then((r) => {
199
211
  if (r.ok !== false && words) say(words);
200
212
  return r;
@@ -202,13 +214,14 @@ export function CalendarView({
202
214
  }
203
215
 
204
216
  function remove(task) {
217
+ if (!canEdit(task)) return Promise.resolve({ ok: false });
205
218
  return commit(deleteChange(all, [task.id]), { label: quote(task), undo: `Deleted ${quote(task)}` });
206
219
  }
207
220
 
208
221
  function saveDraft(title) {
209
222
  const d = draft;
210
223
  setDraft(null);
211
- if (!d || !editable) return undefined;
224
+ if (!d || !creatable) return undefined;
212
225
  const patch = d.allDay ? { due: d.day } : { start: withTime(d.day, d.from), due: withTime(d.day, d.to) };
213
226
  const task = draftTask(title, ctx, patch);
214
227
  return commit({ type: "create", task }, { label: quote(task) }).then((r) => {
@@ -301,7 +314,7 @@ export function CalendarView({
301
314
  }
302
315
 
303
316
  function addNew() {
304
- if (!editable) return;
317
+ if (!creatable) return;
305
318
  const inView = mode === "month" ? ctx.today.slice(0, 7) === anchor.slice(0, 7) : range.days.includes(ctx.today);
306
319
  const day = inView ? ctx.today : mode === "month" ? startOfMonth(anchor) : range.days[0];
307
320
  if (mode === "week" || mode === "day") {
@@ -322,7 +335,7 @@ export function CalendarView({
322
335
  if (k === "t") goToday();
323
336
  else if (k === "j" || k === "n") go(1);
324
337
  else if (k === "k" || k === "p") go(-1);
325
- else if (k === "c" && editable) addNew();
338
+ else if (k === "c" && creatable) addNew();
326
339
  else if (byKey[k] && modes.includes(byKey[k])) setMode(byKey[k]);
327
340
  else return;
328
341
  e.preventDefault();
@@ -357,7 +370,7 @@ export function CalendarView({
357
370
  const task = a.task;
358
371
  const grabbed = grab.current;
359
372
  grab.current = null;
360
- if (!task) return;
373
+ if (!task || !canEdit(task)) return;
361
374
  const name = quote(task);
362
375
  const on = (day) => formatDay(day, ctx.today, { locale });
363
376
  if (a.kind === "resize-y") {
@@ -431,6 +444,8 @@ export function CalendarView({
431
444
  const api = {
432
445
  ctx,
433
446
  editable,
447
+ creatable,
448
+ canEdit,
434
449
  locale,
435
450
  colorBy,
436
451
  addLabel,
@@ -439,7 +454,7 @@ export function CalendarView({
439
454
  open: onOpen ?? null,
440
455
  openDay,
441
456
  draft,
442
- startDraft: (d) => editable && setDraft(d),
457
+ startDraft: (d) => creatable && setDraft(d),
443
458
  cancelDraft: () => setDraft(null),
444
459
  saveDraft,
445
460
  commit,
@@ -456,7 +471,7 @@ export function CalendarView({
456
471
 
457
472
  const title = titleFor(mode, range, anchor, { today: ctx.today, locale });
458
473
  const trayShown = tray && (sidePanel ? trayOpen : traySheet);
459
- const empty = editable && !loading && all.length === 0;
474
+ const empty = creatable && !loading && all.length === 0;
460
475
 
461
476
  let body;
462
477
  if (loading) body = <CalendarSkeleton />;
@@ -543,7 +558,7 @@ export function CalendarView({
543
558
  {unscheduled.length ? `Unscheduled · ${unscheduled.length}` : "Unscheduled"}
544
559
  </Button>
545
560
  ) : null}
546
- {editable ? (
561
+ {creatable ? (
547
562
  <Button size="sm" variant="primary" icon="plus" onClick={addNew} title={`${addLabel} (C)`}>
548
563
  {addLabel}
549
564
  </Button>
@@ -56,6 +56,8 @@ const TEAM_TASKS = [
56
56
  { id: "c16", title: "Choose the analytics events", status: "done", assignees: ["sam"], due: "2026-09-18", project: "site" },
57
57
  { id: "c17", title: "Brief the paid social agency", status: "done", assignees: ["maya"], start: "2026-09-17T15:00", due: "2026-09-17T16:00", project: "q4" },
58
58
  { id: "c18", title: "Accessibility pass on the new nav", status: "todo", assignees: ["priya"], due: "2026-09-29", project: "site" },
59
+ // Mirrored from someone's calendar: shown, opened, never moved here.
60
+ { id: "c19", title: "Investor call (from your calendar)", status: "todo", assignees: ["maya"], start: "2026-09-24T14:00", due: "2026-09-24T15:00", project: "team", locked: true },
59
61
  { id: "c19", title: "Draft the customer story", status: "todo", assignees: [], project: "q4", estimate: 3 },
60
62
  { id: "c20", title: "Refresh the pitch deck", status: "todo", assignees: ["maya"], project: "team", estimate: 2 },
61
63
  { id: "c21", title: "Shortlist offsite venues", status: "todo", assignees: ["priya"], project: "team" },
@@ -75,7 +75,7 @@ function WeekRow({ week, month, events, api }) {
75
75
  }
76
76
 
77
77
  function DayCell({ day, inMonth, api, reserve, hidden, dayItems, overflow }) {
78
- const { ctx, editable, locale } = api;
78
+ const { ctx, creatable, locale } = api;
79
79
  const today = day === ctx.today;
80
80
  const label = dayName(day, locale);
81
81
  const drop = useDropTarget(`cell:${day}`, { kind: "cell", day, label });
@@ -84,7 +84,7 @@ function DayCell({ day, inMonth, api, reserve, hidden, dayItems, overflow }) {
84
84
  const onBackground = (e) => {
85
85
  // Only the cell's own empty space: not its buttons, not the "more"
86
86
  // list (a popover inside the cell, whose clicks bubble here).
87
- if (!editable || e.target.dataset?.calBg === undefined) return;
87
+ if (!creatable || e.target.dataset?.calBg === undefined) return;
88
88
  api.startDraft({ day, allDay: true });
89
89
  };
90
90
  return (
@@ -96,13 +96,13 @@ function DayCell({ day, inMonth, api, reserve, hidden, dayItems, overflow }) {
96
96
  "group/cell relative flex min-h-32 min-w-0 flex-col border-e border-border p-1 transition-colors duration-150 last:border-e-0",
97
97
  !inMonth && "bg-muted/40",
98
98
  inMonth && isWeekend(day, ctx.weekend) && "bg-muted/20",
99
- editable && "cursor-default",
99
+ creatable && "cursor-default",
100
100
  drop.isOver && "bg-accent",
101
101
  )}
102
102
  >
103
103
  <div className="flex h-9 items-center justify-between gap-1">
104
104
  <DayNumber day={day} today={today} muted={!inMonth} label={firstOfMonth ? formatDate(day, { month: "short", day: "numeric" }, { locale }) : null} api={api} count={dayItems.length} />
105
- {editable ? (
105
+ {creatable ? (
106
106
  <IconButton
107
107
  size="sm"
108
108
  icon="plus"
@@ -189,8 +189,9 @@ function MoreItems({ day, label, items, count, api }) {
189
189
 
190
190
  function MonthItem({ item, week, api, cellWidth }) {
191
191
  const { event, col, span, lane, clippedStart, clippedEnd } = item;
192
- const { ctx, editable } = api;
192
+ const { ctx } = api;
193
193
  const task = event.task;
194
+ const editable = api.canEdit(task);
194
195
  const done = isDone(task, ctx);
195
196
  const paint = paintOf(task, ctx, api.colorBy);
196
197
  const title = task.title || "Untitled";
@@ -185,7 +185,7 @@ function StripCell({ day, api }) {
185
185
  <div
186
186
  ref={drop.setNodeRef}
187
187
  onClick={(e) => {
188
- if (api.editable && e.target === e.currentTarget) api.startDraft({ day, allDay: true });
188
+ if (api.creatable && e.target === e.currentTarget) api.startDraft({ day, allDay: true });
189
189
  }}
190
190
  className={cx("border-s border-border transition-colors duration-150 first:border-s-0", drop.isOver && "bg-accent")}
191
191
  />
@@ -194,8 +194,9 @@ function StripCell({ day, api }) {
194
194
 
195
195
  function StripItem({ item, days, api, cellWidth }) {
196
196
  const { event, col, span, lane, clippedStart, clippedEnd } = item;
197
- const { ctx, editable } = api;
197
+ const { ctx } = api;
198
198
  const task = event.task;
199
+ const editable = api.canEdit(task);
199
200
  const done = isDone(task, ctx);
200
201
  const paint = paintOf(task, ctx, api.colorBy);
201
202
  const title = task.title || "Untitled";
@@ -267,7 +268,7 @@ function StripItem({ item, days, api, cellWidth }) {
267
268
  // ---- one day's column of hours ------------------------------------------
268
269
 
269
270
  function DayColumn({ day, index, days, events, api, hourHeight, colWidth, closed, slots, renderSlot, onSlotClick, selectedSlot, slotLabel, nowMin, weekend }) {
270
- const { editable, locale } = api;
271
+ const { creatable, locale } = api;
271
272
  const ppm = hourHeight / 60;
272
273
  const ref = useRef(null);
273
274
  const label = formatDate(day, { weekday: "long", month: "long", day: "numeric" }, { locale });
@@ -298,7 +299,7 @@ function DayColumn({ day, index, days, events, api, hourHeight, colWidth, closed
298
299
  )}
299
300
  onPointerDown={(e) => {
300
301
  lastPointer.current = e.pointerType;
301
- if (!editable || e.pointerType !== "mouse" || e.button !== 0 || !onBg(e)) return;
302
+ if (!creatable || e.pointerType !== "mouse" || e.button !== 0 || !onBg(e)) return;
302
303
  const m = snapMinutes(minuteAt(e.clientY) - 7, 15);
303
304
  setMaking({ a: m, b: m + 15 });
304
305
  e.currentTarget.setPointerCapture?.(e.pointerId);
@@ -309,7 +310,7 @@ function DayColumn({ day, index, days, events, api, hourHeight, colWidth, closed
309
310
  if (making) {
310
311
  const b = snapMinutes(m, 15);
311
312
  setMaking((x) => (x && (b !== x.b ? { ...x, b: Math.max(b, x.a + 15) } : x)));
312
- } else if (editable && onBg(e)) {
313
+ } else if (creatable && onBg(e)) {
313
314
  const h = Math.floor(m / 30) * 30;
314
315
  if (h !== hover) setHover(h);
315
316
  } else if (hover !== null) setHover(null);
@@ -329,7 +330,7 @@ function DayColumn({ day, index, days, events, api, hourHeight, colWidth, closed
329
330
  }}
330
331
  onClick={(e) => {
331
332
  // A tap on a touch screen: an hour from the half hour tapped.
332
- if (!editable || lastPointer.current === "mouse" || !onBg(e)) return;
333
+ if (!creatable || lastPointer.current === "mouse" || !onBg(e)) return;
333
334
  const from = Math.floor(minuteAt(e.clientY) / 30) * 30;
334
335
  api.startDraft({ day, from, to: Math.min(DAY_MINUTES, from + api.defaultDuration) });
335
336
  }}
@@ -420,8 +421,9 @@ function Slot({ slot, ppm, renderSlot, onSlotClick, selected, label = "Open", lo
420
421
  }
421
422
 
422
423
  function EventBlock({ event, pos, ppm, api, index, days, colWidth }) {
423
- const { ctx, editable, locale } = api;
424
+ const { ctx, locale } = api;
424
425
  const task = event.task;
426
+ const editable = api.canEdit(task);
425
427
  const done = isDone(task, ctx);
426
428
  const paint = paintOf(task, ctx, api.colorBy);
427
429
  const title = task.title || "Untitled";
@@ -53,7 +53,8 @@ export function UnscheduledTray({ tasks, api, mode = "panel", className }) {
53
53
  }
54
54
 
55
55
  function TrayItem({ task, api }) {
56
- const d = useDraggableTask(`tray:${task.id}`, { kind: "tray", task }, { disabled: !api.editable });
56
+ const movable = api.canEdit(task);
57
+ const d = useDraggableTask(`tray:${task.id}`, { kind: "tray", task }, { disabled: !movable });
57
58
  const keys = withKeys(d.handle, (e) => e.key === "Enter");
58
59
  return (
59
60
  <li ref={d.setNodeRef} data-cal-id={task.id}>
@@ -62,8 +63,8 @@ function TrayItem({ task, api }) {
62
63
  ctx={api.ctx}
63
64
  onOpen={api.open ?? undefined}
64
65
  dragging={d.isDragging}
65
- dragProps={api.editable ? keys : undefined}
66
- className={cx("min-h-9", api.editable && "cursor-grab active:cursor-grabbing")}
66
+ dragProps={movable ? keys : undefined}
67
+ className={cx("min-h-9", movable && "cursor-grab active:cursor-grabbing")}
67
68
  />
68
69
  </li>
69
70
  );
@@ -73,7 +74,7 @@ function SheetItem({ task, api }) {
73
74
  return (
74
75
  <li className="group/row flex min-h-11 items-center gap-2">
75
76
  <TaskChip task={task} ctx={api.ctx} onOpen={api.open ?? undefined} className="min-h-9 flex-1" />
76
- {api.editable ? (
77
+ {api.canEdit(task) ? (
77
78
  <DueLabel
78
79
  value={null}
79
80
  ctx={api.ctx}
@@ -24,8 +24,11 @@ and load the rows in the server surface (README §4). Put a chart in a
24
24
 
25
25
  Rows, as `loadRows` returns them. One key holds the x (a month, a stage,
26
26
  a week) and each series is another key holding a number, in the order the
27
- x should read — sort in `loadRows`. A value that is missing or not a number
28
- is a gap in a line and nothing in a bar or an area.
27
+ x should read — sort in `loadRows`. A value that is missing (`null`,
28
+ `undefined`, `""`) or not a number is a gap, never a zero: a line, an area
29
+ and a sparkline break there (a value alone between two gaps is a dot), a
30
+ bar or a slice is not drawn, a heatmap cell is blank, and the tooltip and
31
+ the table say —. A real zero is `0`.
29
32
 
30
33
  ```js
31
34
  [{ month: "Jan", newBiz: 24600, expansion: 8100 }, { month: "Feb", newBiz: 26900, expansion: 8800 }]
@@ -117,7 +120,8 @@ instead: `"success"`, `"warning"`, `"danger"`, `"info"`, `"primary"`,
117
120
  - `data` — one row per slice.
118
121
  - `name` — the key holding a slice's name, default `"name"`.
119
122
  - `value` — the key holding its number, default `"value"`. Zero and
120
- negative values draw nothing.
123
+ negative values draw nothing; a missing one draws nothing and reads —
124
+ in the legend and the table.
121
125
  - `donut` — open the middle. It shows the total, and the picked slice's
122
126
  value and share while one is picked. Default `false`.
123
127
  - `centerLabel` — a donut's middle instead of the total: a string, or
@@ -304,7 +308,8 @@ export default function SalesCharts({ months, stages, visits, error }) {
304
308
  - Sort, group or sum: the rows come shaped. Totals by month or by stage
305
309
  are a query (a view, or `loadRows` over one).
306
310
  - A time scale: x is a list of labels, evenly spaced, in row order. Give
307
- every period a row (a month with no sales is a row with 0).
311
+ every period a row (a month with no sales is a row with 0; a day with
312
+ no data yet, a row with null).
308
313
  - Two value axes, scatter, radar, maps, zoom or brushing.
309
314
  - A colour outside the theme: series take the chart tones and the theme's
310
315
  status tones only. Colour by value is the heatmap's alone, on one chart
@@ -12,6 +12,7 @@ import {
12
12
  idFrom,
13
13
  niceTicks,
14
14
  num,
15
+ runs,
15
16
  textWidth,
16
17
  tone,
17
18
  toneOf,
@@ -265,6 +266,7 @@ function Cartesian({
265
266
  const parts = live.map((s) => {
266
267
  const vals = rows.map((r) => num(r?.[s.key]));
267
268
  const idx = vals.reduce((best, v, i) => (v != null && (best < 0 || v > vals[best]) ? i : best), -1);
269
+ if (idx < 0) return `${s.label}: no data`;
268
270
  const first = vals.find((v) => v != null);
269
271
  const last = [...vals].reverse().find((v) => v != null);
270
272
  return `${s.label}: ${kind === "bar" ? "" : `from ${fmt(first)} to ${fmt(last)}, `}highest ${fmt(vals[idx])} at ${xNames[idx]}`;
@@ -276,7 +278,14 @@ function Cartesian({
276
278
  active == null
277
279
  ? []
278
280
  : live.map((s) => ({ key: s.key, label: s.label, tone: s.tone, dashed: s.dashed, value: fmt(num(rows[active]?.[s.key])) }));
279
- const total = active == null ? null : live.reduce((sum, s) => sum + (num(rows[active]?.[s.key]) ?? 0), 0);
281
+ // The stack's total: what there is, or none (—) when every value is missing.
282
+ const total =
283
+ active == null
284
+ ? null
285
+ : live.reduce((sum, s) => {
286
+ const v = num(rows[active]?.[s.key]);
287
+ return v == null ? sum : (sum ?? 0) + v;
288
+ }, null);
280
289
  const announce = active == null ? "" : `${xNames[active]}: ${tipRows.map((r) => `${r.label} ${r.value}`).join(", ")}`;
281
290
 
282
291
  const legendNode = showLegend ? (
@@ -439,17 +448,20 @@ function Cartesian({
439
448
  const s = live[j];
440
449
  // A dashed series is a line only: a comparison has no fill.
441
450
  if (s.dashed && !stack) return null;
442
- const top = [];
443
- const base = [];
444
- spans[j].forEach((p, i) => {
445
- top.push([cPos(i), vPos(p ? p[1] : stack ? 0 : 0)]);
446
- base.push([cPos(i), vPos(p ? p[0] : 0)]);
447
- });
448
- const back = curvePath([...base].reverse(), curve).replace(/^M/, "L");
451
+ // One band per run of values: an area breaks where a value
452
+ // is missing, as its line does — never down to zero.
453
+ const d = runs(spans[j].map((p, i) => (p ? { x: cPos(i), top: vPos(p[1]), base: vPos(p[0]) } : null)))
454
+ .filter((run) => run.length > 1)
455
+ .map((run) => {
456
+ const back = curvePath(run.map((q) => [q.x, q.base]).reverse(), curve).replace(/^M/, "L");
457
+ return `${curvePath(run.map((q) => [q.x, q.top]), curve)}${back}Z`;
458
+ })
459
+ .join("");
460
+ if (!d) return null;
449
461
  return (
450
462
  <path
451
463
  key={s.key}
452
- d={`${curvePath(top, curve)}${back}Z`}
464
+ d={d}
453
465
  className={tone(s.tone, "fill")}
454
466
  fillOpacity={stack ? 0.4 : s.muted ? 0.2 : 0.45}
455
467
  mask={stack ? undefined : `url(#${fade})`}
@@ -459,18 +471,9 @@ function Cartesian({
459
471
  : null}
460
472
  {order.map((j) => {
461
473
  const s = live[j];
462
- // A line breaks where a value is missing.
463
- const segs = [];
464
- let cur = [];
465
- spans[j].forEach((p, i) => {
466
- if (!p && kind === "line") {
467
- if (cur.length) segs.push(cur);
468
- cur = [];
469
- return;
470
- }
471
- cur.push([cPos(i), vPos(p ? p[1] : 0)]);
472
- });
473
- if (cur.length) segs.push(cur);
474
+ // A line (and an area's edge) breaks where a value is missing;
475
+ // a value alone between two gaps is a dot, so it still shows.
476
+ const segs = runs(spans[j].map((p, i) => (p ? [cPos(i), vPos(p[1])] : null)));
474
477
  return (
475
478
  <g
476
479
  key={s.key}
@@ -481,9 +484,13 @@ function Cartesian({
481
484
  strokeLinejoin="round"
482
485
  strokeLinecap={s.dashed ? "butt" : "round"}
483
486
  >
484
- {segs.map((seg, k) => (
485
- <path key={k} d={curvePath(seg, curve)} />
486
- ))}
487
+ {segs.map((seg, k) =>
488
+ seg.length > 1 ? (
489
+ <path key={k} d={curvePath(seg, curve)} />
490
+ ) : dots ? null : (
491
+ <circle key={k} cx={seg[0][0]} cy={seg[0][1]} r={3} className={cx(tone(s.tone, "fill"), "stroke-card")} strokeWidth={1.5} strokeDasharray="none" />
492
+ ),
493
+ )}
487
494
  {dots
488
495
  ? spans[j].map((p, i) =>
489
496
  p ? <circle key={i} cx={cPos(i)} cy={vPos(p[1])} r={3} className={cx(tone(s.tone, "fill"), "stroke-card")} strokeWidth={1.5} strokeDasharray="none" /> : null,
@@ -48,11 +48,32 @@ export function defaultFormat(v) {
48
48
  return Math.abs(v) >= 10000 ? COMPACT.format(v) : PLAIN.format(v);
49
49
  }
50
50
 
51
+ /** A value as a number, or null when there is none to draw: null,
52
+ * undefined, an empty or blank string, or anything not a finite number.
53
+ * null is a gap, never a zero — Number(null) and Number("") are 0, and
54
+ * until 0.5.1 a day with no data plotted as a real zero. */
51
55
  export function num(v) {
56
+ if (v == null || (typeof v === "string" && !v.trim())) return null;
52
57
  const n = typeof v === "number" ? v : Number(v);
53
58
  return Number.isFinite(n) ? n : null;
54
59
  }
55
60
 
61
+ /** Points split into runs wherever one is missing (null): a line or an
62
+ * area breaks at a gap. [p | null, …] → [[p, …], …] */
63
+ export function runs(points) {
64
+ const out = [];
65
+ let cur = [];
66
+ for (const p of points) {
67
+ if (p) cur.push(p);
68
+ else if (cur.length) {
69
+ out.push(cur);
70
+ cur = [];
71
+ }
72
+ }
73
+ if (cur.length) out.push(cur);
74
+ return out;
75
+ }
76
+
56
77
  /** Round numbers that cover [min, max] in about `count` steps. */
57
78
  export function niceTicks(min, max, count = 5) {
58
79
  if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
@@ -61,18 +61,23 @@ export function PieChart({
61
61
  const rows = Array.isArray(data) ? data : [];
62
62
  const slices = useMemo(
63
63
  () =>
64
- rows.map((r, i) => ({
65
- row: r,
66
- key: String(r?.[name] ?? i),
67
- label: formatName(r?.[name], r) || humanize(r?.[name]),
68
- tone: toneOf(r, i),
69
- value: Math.max(0, num(r?.[value]) ?? 0),
70
- })),
64
+ rows.map((r, i) => {
65
+ const v = num(r?.[value]);
66
+ return {
67
+ row: r,
68
+ key: String(r?.[name] ?? i),
69
+ label: formatName(r?.[name], r) || humanize(r?.[name]),
70
+ tone: toneOf(r, i),
71
+ // A missing value is no slice and reads —, never 0 or 0%.
72
+ value: v == null ? null : Math.max(0, v),
73
+ };
74
+ }),
71
75
  [rows, name, value], // eslint-disable-line react-hooks/exhaustive-deps
72
76
  );
73
77
  const live = slices.filter((s) => !hidden.has(s.key) && s.value > 0);
74
78
  const total = live.reduce((sum, s) => sum + s.value, 0);
75
- const pct = (v) => (total ? `${Math.round((v / total) * 1000) / 10}%` : "0%");
79
+ const pct = (v) => (v == null ? "—" : total ? `${Math.round((v / total) * 1000) / 10}%` : "0%");
80
+ const fmt = (v) => (v == null ? "—" : format(v));
76
81
 
77
82
  const size = Math.max(0, Math.min(width, height));
78
83
  const c = { x: width / 2, y: height / 2 };
@@ -123,7 +128,7 @@ export function PieChart({
123
128
  key: s.key,
124
129
  label: s.label,
125
130
  tone: s.tone,
126
- value: hidden.has(s.key) ? null : legendValues === "value" ? format(s.value) : legendValues === "percent" ? pct(s.value) : null,
131
+ value: hidden.has(s.key) ? null : legendValues === "value" ? fmt(s.value) : legendValues === "percent" ? pct(s.value) : null,
127
132
  }))}
128
133
  hidden={hidden}
129
134
  onToggle={(key) => {
@@ -238,7 +243,7 @@ export function PieChart({
238
243
  <DataTable
239
244
  caption={label}
240
245
  head={["", "Value", "Share"]}
241
- body={slices.map((s) => [s.label, format(s.value), hidden.has(s.key) ? "hidden" : pct(s.value)])}
246
+ body={slices.map((s) => [s.label, fmt(s.value), hidden.has(s.key) ? "hidden" : pct(s.value)])}
242
247
  />
243
248
  </div>
244
249
  );