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.
- package/package.json +1 -1
- package/r/annotate.json +3 -3
- package/r/approval-request.json +4 -4
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/board.json +2 -2
- package/r/calendar-view.json +9 -9
- package/r/cart.json +2 -2
- package/r/chart.json +7 -7
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +5 -5
- package/r/contact-section.json +2 -2
- package/r/core.json +5 -5
- package/r/csv-import.json +3 -3
- package/r/data-table.json +4 -4
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/flow-canvas.json +4 -4
- package/r/footer.json +2 -2
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/map.json +2 -2
- package/r/menubar.json +2 -2
- package/r/mind-map.json +6 -6
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/page-builder.json +2 -2
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +3 -3
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-notes.json +6 -6
- package/r/registry.json +121 -121
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +4 -4
- package/r/signature-pad.json +3 -3
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +3 -3
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/whiteboard.json +3 -3
- package/registry/blocks/approval-request/README.md +2 -1
- package/registry/blocks/approval-request/approval-request.jsx +5 -3
- package/registry/blocks/calendar-view/README.md +8 -0
- package/registry/blocks/calendar-view/agenda.jsx +5 -4
- package/registry/blocks/calendar-view/calendar.jsx +23 -8
- package/registry/blocks/calendar-view/demo.jsx +2 -0
- package/registry/blocks/calendar-view/month.jsx +6 -5
- package/registry/blocks/calendar-view/time-grid.jsx +9 -7
- package/registry/blocks/calendar-view/tray.jsx +5 -4
- package/registry/blocks/chart/README.md +9 -4
- package/registry/blocks/chart/cartesian.jsx +31 -24
- package/registry/blocks/chart/core.jsx +21 -0
- package/registry/blocks/chart/pie.jsx +15 -10
- package/registry/blocks/chart/sparkline.jsx +32 -14
- package/registry/blocks/chat/message.jsx +4 -4
- package/registry/blocks/comment-box/README.md +1 -1
- package/registry/blocks/comment-box/comment-time.jsx +4 -6
- package/registry/blocks/data-table/README.md +4 -0
- package/registry/blocks/data-table/data-table.jsx +20 -6
- package/registry/blocks/flow-canvas/edges.jsx +4 -2
- package/registry/blocks/flow-canvas/theme.js +59 -48
- package/registry/blocks/mind-map/README.md +9 -2
- package/registry/blocks/mind-map/mind-map.jsx +51 -12
- package/registry/blocks/mind-map/tree.js +33 -0
- package/registry/blocks/org-chart/org-chart.jsx +1 -1
- package/registry/blocks/pdf-viewer/notes.jsx +4 -3
- package/registry/blocks/record-notes/README.md +1 -1
- package/registry/blocks/record-notes/record-notes.jsx +4 -5
- package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
- package/registry/blocks/sidebar/sidebar.jsx +13 -13
- package/registry/blocks/signature-pad/signature.js +8 -1
- package/registry/blocks/spreadsheet/cells.jsx +2 -2
- package/templates/next/files/README.md +77 -19
- package/templates/next/files/app/auto-refresh.jsx +39 -74
- package/templates/next/files/app/frame.js +197 -6
- package/templates/next/files/check/run.mjs +26 -10
- package/templates/next/files/components/ui/calendar.jsx +24 -0
- package/templates/next/files/components/ui/field.jsx +12 -3
- package/templates/next/files/components/ui/index.js +1 -0
- package/templates/next/files/components/ui/sheet.jsx +38 -2
- package/templates/next/files/components/ui/toggle.jsx +20 -2
- package/templates/next/files/lib/actions.js +63 -5
- package/templates/next/files/lib/data.js +4 -1
- package/templates/next/files/lib/db.js +18 -1
- package/templates/next/files/lib/hooks.js +52 -17
- package/templates/next/template.json +2 -2
package/r/testimonials.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
}
|
package/r/transaction-list.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
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.
|
|
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
|
|
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 =
|
|
21
|
-
if (
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 || !
|
|
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 (!
|
|
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" &&
|
|
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) =>
|
|
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 =
|
|
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
|
-
{
|
|
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,
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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.
|
|
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
|
|
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 {
|
|
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 (!
|
|
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 (
|
|
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 (!
|
|
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,
|
|
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
|
|
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={
|
|
66
|
-
className={cx("min-h-9",
|
|
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.
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
spans[j].
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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={
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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" ?
|
|
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,
|
|
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
|
);
|