makersclaw 0.8.3 → 0.8.5
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/activity-log.json +3 -3
- package/r/agent-changes.json +3 -3
- package/r/agent-chat.json +3 -3
- package/r/agent-column.json +3 -3
- package/r/agent-dock.json +3 -3
- package/r/agent-kit.json +2 -2
- package/r/agent-marks.json +2 -2
- package/r/agent-pane.json +4 -4
- package/r/ai-action.json +4 -4
- package/r/annotate.json +3 -3
- package/r/approval-request.json +2 -2
- package/r/approval.json +3 -3
- package/r/ask-bar.json +2 -2
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/automation-editor.json +7 -7
- package/r/automation-list.json +3 -3
- package/r/automation-model.json +2 -2
- package/r/automations-screen.json +7 -7
- package/r/board.json +2 -2
- package/r/calendar-view.json +3 -3
- package/r/cart.json +2 -2
- package/r/chart.json +2 -2
- package/r/chat-pane.json +4 -4
- package/r/chat.json +2 -2
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +3 -3
- package/r/connect-card.json +2 -2
- package/r/contact-section.json +2 -2
- package/r/cost.json +2 -2
- package/r/csv-import.json +3 -3
- package/r/data-picker.json +3 -3
- package/r/data-table.json +2 -2
- 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/first-run.json +2 -2
- package/r/fix-note.json +4 -4
- package/r/flow-canvas.json +2 -2
- package/r/flow-editor.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/library-shelf.json +2 -2
- package/r/map.json +2 -2
- package/r/margin-notes.json +3 -3
- package/r/menubar.json +2 -2
- package/r/mind-map.json +3 -3
- package/r/navigation-menu.json +2 -2
- package/r/needs-you.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +3 -3
- package/r/pace-bar.json +3 -3
- 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 +2 -2
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-grid.json +2 -2
- package/r/record-notes.json +4 -4
- package/r/registry.json +290 -249
- package/r/run-history.json +2 -2
- package/r/run-view.json +6 -6
- package/r/schedule-card.json +2 -2
- package/r/schedule-engine.json +2 -2
- package/r/schedule-gantt.json +6 -6
- package/r/schedule-ruler.json +2 -2
- 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 +2 -2
- package/r/signature-pad.json +2 -2
- package/r/sketch-canvas.json +2 -2
- package/r/source-receipt.json +3 -3
- package/r/spreadsheet.json +2 -2
- package/r/stat-cards.json +3 -3
- package/r/step-picker.json +3 -3
- package/r/step-settings.json +5 -5
- package/r/suggestion.json +2 -2
- package/r/sum-bar.json +2 -2
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/tier-map.json +2 -2
- package/r/time-grid.json +2 -2
- package/r/time-strip.json +5 -5
- package/r/top-bar.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/trigger-picker.json +3 -3
- package/r/whiteboard.json +3 -3
- package/r/workflow-view.json +3 -3
- package/r/workspace-objects.json +45 -0
- package/registry/blocks/chat-pane/README.md +4 -0
- package/registry/blocks/chat-pane/chat-attach.js +6 -1
- package/registry/blocks/schedule-gantt/schedule-gantt.jsx +15 -10
- package/registry/blocks/schedule-gantt/sheet.jsx +8 -6
- package/registry/blocks/schedule-gantt/track.jsx +1 -1
- package/registry/blocks/time-strip/README.md +5 -2
- package/registry/blocks/time-strip/strip-model.js +6 -0
- package/registry/blocks/time-strip/time-strip.jsx +41 -4
- package/registry/blocks/workspace-objects/README.md +23 -0
- package/registry/blocks/workspace-objects/block.json +10 -0
- package/registry/blocks/workspace-objects/demo.jsx +3 -0
- package/registry/blocks/workspace-objects/index.js +1 -0
- package/registry/blocks/workspace-objects/objects.js +147 -0
package/r/run-history.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "RunHistory",
|
|
6
6
|
"description": "A job's runs and the account of one, drawn inside the app. RunHistory is the list, newest first: each run's outcome as a mark and a word (completed, failed, running, needs you, stopped, skipped), its title, one line of what it did, when and what it cost. RunAccount is one run: its steps in order with how each ended, a row of figures and the account itself, one line a heading. RunOverview is the summary: completed, failed, in progress, the average run time, and the credits used against the week's cap as a bar. They draw what useRuns() and useRun() return (lib/hooks.js, GET _claw/runs and _claw/runs/<id>). Their own width decides their layout.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"docs": "Installed to components/makersclaw/blocks/run-history. Import: import { RunHistory, RunAccount, RunOverview } from \"@/components/makersclaw/blocks/run-history\". Props and an example: components/makersclaw/blocks/run-history/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
46
46
|
"meta": {
|
|
47
47
|
"makersclaw": {
|
|
48
|
-
"version": "0.8.
|
|
48
|
+
"version": "0.8.5",
|
|
49
49
|
"useWhen": "Superseded by run-view for an app whose jobs run on the automations engine; still the block for an app that has not moved (its jobs are the agent's wakes). An app whose agent runs on a schedule, or does work on asks, accounts for itself inside the app: a morning read's page, a nightly import's log, the runs beside a job's card. For the job drawn as a graph with its runs beside it use workflow-view (it uses these); for who did what to the rows use activity-log; for one ask in progress use job-panel."
|
|
50
50
|
}
|
|
51
51
|
}
|
package/r/run-view.json
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
"title": "RunView",
|
|
6
6
|
"description": "One run of an automation on the same canvas as the builder: every card wears how it went, the path the run took is lit and the paths it did not take are dimmed, a looped step says how many times, a failed step says why, and a step the agent fixed says so on its card and in its panel. A side panel gives the run in a few lines (who started it, how long, what it cost of what it may, the version) and, for a card pressed, how that step went and what went in and out for those who may read it. RunsList is the automation's runs, newest first, with an overview (done, failed, in progress, spent) and a filter. For a run that is not over it offers what the run allows (RunControls): pause, resume, stop; when it is stuck on a step, try it again, skip it, change the data it was given for this run, or raise the run's limit and go on. On a step of a run that is over (done, failed, stopped) or waiting on its owner, a quiet \"Retry from this step\" starts the run over there, asking first when a step from there on already sent something. A step the agent fixed shows what changed, what it cost, whether a write can be taken back, and keeps or drops the change the agent drafted for next time. A test run (a dry run) draws a strip over the canvas, with what each step would have done and what could not be checked, and its steps as dashed pencil cards.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
10
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/automation-model.json",
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/flow-editor.json",
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/step-settings.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/fix-note.json"
|
|
13
13
|
],
|
|
14
14
|
"dependencies": [],
|
|
15
15
|
"files": [
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"docs": "Installed to components/makersclaw/blocks/run-view. Import: import { RunView, RunsList, RunControls, RetryFromStep } from \"@/components/makersclaw/blocks/run-view\". Props and an example: components/makersclaw/blocks/run-view/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
74
74
|
"meta": {
|
|
75
75
|
"makersclaw": {
|
|
76
|
-
"version": "0.8.
|
|
76
|
+
"version": "0.8.5",
|
|
77
77
|
"useWhen": "A member looks at what an automation did: the page behind a run row, and the Runs tab beside the builder. Fed by useAutomationRun and useAutomation (lib/hooks.js). For a job's run drawn top to bottom in the older kit use workflow-view."
|
|
78
78
|
}
|
|
79
79
|
}
|
package/r/schedule-card.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "ScheduleCard",
|
|
6
6
|
"description": "One scheduled job of the app's agent on a card: its name, how its last run went as a mark and words (Ran 07:02, Didn't run, Reading now, Off, Not set up), one line of what it did, this run's cost and the week's credits against the allowance as a bar, when it runs next, Run now with its price on the button, and an on/off switch. It draws what useJobs() returns (lib/hooks.js, GET _claw/jobs); Run now and the switch show only where the door says the member may press them. Its own width decides its layout.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"docs": "Installed to components/makersclaw/blocks/schedule-card. Import: import { ScheduleCard } from \"@/components/makersclaw/blocks/schedule-card\". Props and an example: components/makersclaw/blocks/schedule-card/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
48
48
|
"meta": {
|
|
49
49
|
"makersclaw": {
|
|
50
|
-
"version": "0.8.
|
|
50
|
+
"version": "0.8.5",
|
|
51
51
|
"useWhen": "Superseded by automation-list for an app whose jobs run on the automations engine; still the block for an app that has not moved (its jobs are the agent's wakes). An app whose agent runs on a schedule shows that job beside the day's work: a CRM's morning read on Today, a nightly import, a weekly report, an inbox checked every 15 minutes. For the runs themselves use run-history; for the job drawn as a graph with its runs use workflow-view; for one ask in progress use job-panel."
|
|
52
52
|
}
|
|
53
53
|
}
|
package/r/schedule-engine.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "Schedule engine",
|
|
6
6
|
"description": "The rules a dated plan follows, as pure functions over plain rows: working days (Monday to Friday), the four link kinds (finish to start, start to start, finish to finish, start to finish) with lag and lead, the forward and backward pass, slack, the critical path and the long pole, shifting every dependent when a task moves, re-planning unfinished work from today, mending links the dates ignore, summaries that span their sub-tasks, finished work pinned in place, and the outline tree with its 1, 1.1, 1.2 numbers. Also the plans for a write: where a new task goes and with what dates, what an indent, outdent, move or reorder changes, a task's length, who is on two things at once, the rows a baseline keeps, and the short form of a link (2.4FS+2d) both ways. One entry, operate(), answers all of it as JSON so an agent's script and a screen get the same dates.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"docs": "Installed to components/makersclaw/blocks/schedule-engine. Import: import { dayOf, isoOf, weekdayOf, makeCalendar, LINK_KINDS, linkKind, spanOf, datesOf, workdaysBetween, addWorkdays, durationOf, buildNetwork, forwardPass, backwardPass, criticalPath, schedule, shiftDependents, replanFromToday, alignConflicts, linkProblem, longPole, taskTree, descendantsOf, positionBetween, siblingOrder, KIND_WORDS, lagWords, shortForm, waitsWords, parseWaits, nextWorkday, summaryChanges, placeNew, familyLinkProblem, outlineChange, lengthChange, overlapsOf, snapshotOf, operate, plain, OPERATIONS } from \"@/components/makersclaw/blocks/schedule-engine\". Props and an example: components/makersclaw/blocks/schedule-engine/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
73
73
|
"meta": {
|
|
74
74
|
"makersclaw": {
|
|
75
|
-
"version": "0.8.
|
|
75
|
+
"version": "0.8.5",
|
|
76
76
|
"useWhen": "A plan with tasks that wait on each other and dates that must follow: a project schedule, a launch plan, a roadmap whose slips ripple, an agent that re-plans or answers 'when do we finish and what decides it'. The engine under the schedule-gantt block, usable alone with no chart. Not for undated to-dos (task-list) or stages (board)."
|
|
77
77
|
}
|
|
78
78
|
}
|
package/r/schedule-gantt.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"title": "ScheduleGantt",
|
|
6
6
|
"description": "A Gantt for dated work with dependencies: the task sheet beside a working-day time scale, one picture row for row. Outline numbers, phases that fold into a bracket over their work, milestones as diamonds, bars in steel grey with their progress, links of all four kinds with lag drawn as elbows (a broken one dashed with a cross), weekends shaded, today a line, the target a dashed line and the finish a solid one with the gap between them counted in working days in the header. The critical path turns red when switched on; slack, the start and finish, the length in working days, the owner, what a task waits on in the short form (2.4FS+2d) and its distance from a baseline are columns the page picks, and the divider between sheet and bars drags. Drag a bar to move it (several selected move together), an end to change its length, the dot past its end onto another bar to link them: every task that waits on it moves while you drag, the finish in the header slides off the target and a note by the bar says what it costs. Dashed outlines show where bars were and where a suggestion would put them; a thin bar under each shows a baseline; lanes under the rows show who is on what, on the same scale. Type into the sheet: names, lengths, percentages, what a task waits on, and a last row that adds a task (Enter) or a milestone (Shift+Enter). Built on schedule-engine.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/schedule-engine.json"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": [],
|
|
12
12
|
"files": [
|
|
@@ -20,19 +20,19 @@
|
|
|
20
20
|
"path": "registry/blocks/schedule-gantt/schedule-gantt.jsx",
|
|
21
21
|
"type": "registry:component",
|
|
22
22
|
"target": "@components/makersclaw/blocks/schedule-gantt/schedule-gantt.jsx",
|
|
23
|
-
"content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { Icon, cx } from \"../../ui\";\nimport { datesOf, dayOf, isoOf, linkProblem, makeCalendar, schedule, shiftDependents, spanOf, taskTree, waitsWords } from \"../schedule-engine\";\nimport { ZOOMS, axisFor, rangeFor, routeLink } from \"./geometry\";\nimport { baselineDelta, barLabel, columnsFor, dayWords, describeDrag, dragLines, fixedWidth, gapOf, linkLines, selectionMoves, slackWords } from \"./gantt-model\";\nimport { AddRow, ROW, SheetHead, SheetRow } from \"./sheet\";\nimport { HEAD, Ghosts, LaneLabels, LaneTrack, Links, Paper, TrackHead } from \"./track\";\n\n// ScheduleGantt: the task sheet beside a working-day time scale, one picture\n// row for row. Bars are steel grey with their progress inside; the critical\n// path turns red when the page switches it on; a phase is a bracket over its\n// work; a milestone a diamond. The target is a dashed line, the finish a solid\n// one, and the gap between them is measured in working days in the header.\n// Dashed outlines show where bars were (`ghosts`) and where a suggestion\n// would put them (`pencil`).\n//\n// Drag a bar to move it, an end to change its length, the dot at its end onto\n// another bar to link them: everything that waits on it moves while you drag,\n// and a note by the bar says what it costs. README.md lists every prop.\n// Built from divs and one svg for the links, tokens only.\n\nconst BAR = 14;\nconst CAL = makeCalendar(\"weekdays\");\nconst EMPTY = [];\nconst NONE = new Set();\nconst KIND_OF = { \"finish:start\": \"finish_to_start\", \"start:start\": \"start_to_start\", \"finish:finish\": \"finish_to_finish\", \"start:finish\": \"start_to_finish\" };\nconst NUM = \"tabular-nums\";\n\nconst firstName = (name) => String(name ?? \"\").trim().split(/\\s+/)[0] ?? \"\";\n\nexport function ScheduleGantt({\n tasks: tasksProp,\n links: linksProp,\n people,\n today,\n showToday = true,\n target,\n zoom = \"week\",\n fitWidth,\n columns,\n vsLabel = \"vs baseline\",\n rowH = ROW,\n gridWidth = 470,\n onGridWidth,\n height = \"fill\",\n critical = false,\n selectedId,\n selectedIds,\n onSelect,\n collapsed,\n onToggle,\n onOpen,\n onMove,\n onLink,\n onEdit,\n onAdd,\n onPreview,\n onOpenLink,\n highlight,\n addUnder,\n editRequest,\n jump,\n ghosts,\n pencil,\n baseline,\n guesses,\n notes,\n tags,\n moving,\n rowMenu,\n lanes,\n lanesTitle,\n loading = false,\n error = null,\n emptyText = \"No tasks yet. Type the first one in the last row.\",\n label = \"Gantt chart\",\n className,\n}) {\n const tasks = tasksProp ?? EMPTY;\n const links = linksProp ?? EMPTY;\n const scroller = useRef(null);\n const track = useRef(null);\n const dragRef = useRef(null);\n const linkRef = useRef(null);\n const suppress = useRef(false);\n const addRef = useRef(null);\n const [boxW, setBoxW] = useState(null);\n const [preview, setPreview] = useState(null);\n const [linking, setLinking] = useState(null);\n const [editing, setEditing] = useState(null);\n const [draft, setDraft] = useState({ title: \"\", days: \"\", milestone: false, parentId: null, afterId: null });\n const [adding, setAdding] = useState(false);\n const [hovered, setHovered] = useState(null);\n const [busy, setBusy] = useState(false);\n const [note, setNote] = useState(null);\n const [gridState, setGridState] = useState(gridWidth);\n const selected = selectedId ? String(selectedId) : null;\n const picked = selectedIds ?? NONE;\n\n // Measured in the callback, applied in the next frame (an observer that sets what it watches loops).\n useLayoutEffect(() => {\n const el = scroller.current;\n if (!el) return undefined;\n let raf = 0;\n const apply = (w) => {\n cancelAnimationFrame(raf);\n raf = requestAnimationFrame(() => setBoxW(w));\n };\n setBoxW(el.clientWidth);\n const onResize = () => apply(el.clientWidth);\n window.addEventListener(\"resize\", onResize);\n const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(() => apply(el.clientWidth));\n ro?.observe(el);\n return () => {\n cancelAnimationFrame(raf);\n window.removeEventListener(\"resize\", onResize);\n ro?.disconnect();\n };\n }, []);\n\n // The page opens the add row: { id (a parent) | null, afterId, milestone, nonce }.\n useEffect(() => {\n if (!addUnder) return;\n setAdding(true);\n setDraft({ title: \"\", days: \"\", milestone: Boolean(addUnder.milestone), parentId: addUnder.id ? String(addUnder.id) : null, afterId: addUnder.afterId ? String(addUnder.afterId) : null });\n const t = setTimeout(() => {\n addRef.current?.focus();\n addRef.current?.scrollIntoView?.({ block: \"nearest\" });\n }, 20);\n return () => clearTimeout(t);\n }, [addUnder]);\n\n // The page opens a cell: { id, field, nonce } (the row menu's Rename).\n useEffect(() => {\n if (editRequest?.id) setEditing({ id: String(editRequest.id), field: editRequest.field ?? \"title\" });\n }, [editRequest]);\n\n useEffect(() => {\n if (jump?.date) scrollToDay(jump.date, true);\n }, [jump]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- the model ----------------------------------------------------------\n const plan = useMemo(() => schedule(tasks, links), [tasks, links]);\n const live = preview?.plan ?? plan;\n const tree = useMemo(() => taskTree(tasks), [tasks]);\n const byId = useMemo(() => new Map(tasks.map((t) => [String(t.id), t])), [tasks]);\n const closed = collapsed ?? NONE;\n const visible = useMemo(() => {\n const out = [];\n for (const e of tree.order) {\n let hidden = false;\n for (let p = e.parentId; p; p = tree.byId.get(p)?.parentId) if (closed.has(p)) hidden = true;\n if (!hidden) out.push(e);\n }\n return out;\n }, [tree, closed]);\n const rowOf = useMemo(() => new Map(visible.map((e, i) => [e.id, i])), [visible]);\n const outlineOf = (id) => tree.byId.get(String(id))?.outline;\n // The add row sits under the row it adds after; everything below it moves down by its height.\n const ADD_H = ROW + 6;\n const addIndex = adding ? (draft.afterId && rowOf.has(draft.afterId) ? rowOf.get(draft.afterId) : draft.parentId && rowOf.has(draft.parentId) ? rowOf.get(draft.parentId) : visible.length - 1) : null;\n const rowTop = (i) => i * rowH + (addIndex != null && i > addIndex ? ADD_H : 0);\n const rowAt = (y) => {\n for (let i = 0; i < visible.length; i += 1) if (y >= rowTop(i) && y < rowTop(i) + rowH) return i;\n return -1;\n };\n const shownRow = (id) => {\n for (let cur = String(id); cur; cur = tree.byId.get(cur)?.parentId) if (rowOf.has(cur)) return rowOf.get(cur);\n return null;\n };\n\n const shown = useMemo(() => {\n const out = new Map();\n for (const t of tasks) {\n const id = String(t.id);\n const r = live.rows.get(id);\n if (!r || !r.dated || r.summary) continue;\n const p = preview?.dates?.get(id);\n out.set(id, { start: p?.start ?? r.start, due: p?.due ?? r.due });\n }\n for (const r of live.rows.values()) {\n if (!r.summary) continue;\n let s = null;\n let d = null;\n for (const leaf of r.leafIds) {\n const x = out.get(leaf);\n if (!x || !x.start) continue;\n if (!s || x.start < s) s = x.start;\n if (!d || x.due > d) d = x.due;\n }\n if (s) out.set(r.id, { start: s, due: d });\n }\n return out;\n }, [tasks, live, preview]);\n\n // ---- the sheet ----------------------------------------------------------\n const narrow = boxW != null && boxW < 760;\n const cols = useMemo(() => columnsFor(columns, { vsLabel, narrow, outlineWidth: tree.order.some((e) => e.depth >= 3) ? 78 : undefined }), [columns, vsLabel, narrow, tree]);\n const gridW = narrow ? Math.max(170, Math.min(260, Math.round((boxW ?? 400) * 0.46))) : Math.max(fixedWidth(cols) + 160, gridState);\n const cellsOf = useMemo(() => {\n const byTask = new Map();\n for (const l of links) {\n const k = String(l.task_id);\n if (!byTask.has(k)) byTask.set(k, []);\n byTask.get(k).push(l);\n }\n return (id) => {\n const t = byId.get(id);\n const r = live.rows.get(id);\n const mine = byTask.get(id) ?? [];\n const person = people?.get?.(String(t?.assignee_id ?? \"\"));\n const name = person?.name ?? t?.assignee_name ?? \"\";\n const out = {\n owner: { name: firstName(name), title: name, left: Boolean(person?.left) },\n waits: waitsWords(mine, id, outlineOf),\n waitsLinks: mine.map((l) => live.links.get(String(l.id))).filter(Boolean),\n waitsBroken: mine.some((l) => live.links.get(String(l.id))?.violated) || Boolean(r?.stuck),\n slack: slackWords(r),\n guessed: Boolean(guesses?.has?.(id)),\n };\n if (baseline) out.delta = baselineDelta(r, baseline.get?.(id));\n return out;\n };\n }, [links, byId, live, people, baseline, guesses, tree]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- the axis -----------------------------------------------------------\n const z = ZOOMS[zoom] ? zoom : \"week\";\n const range = useMemo(() => {\n const dates = [];\n for (const r of plan.rows.values()) if (r.dated) dates.push(r.start, r.due, r.planDue);\n if (target) dates.push(target);\n if (ghosts) for (const g of ghosts.values()) dates.push(g.start, g.due);\n if (pencil) for (const g of pencil.values()) dates.push(g.start, g.due);\n if (baseline) for (const g of baseline.values()) dates.push(g.start, g.due);\n return rangeFor(z, dates, today);\n }, [plan, target, ghosts, pencil, baseline, z, today]);\n // Whole project: the span fits the width, with room on the right for the notes after the bars.\n const ppd = z === \"fit\" ? Math.max(2, ((fitWidth ?? boxW ?? 1200) - gridW - (notes?.size ? 200 : 16)) / (range.to - range.from + 1)) : ZOOMS[z].ppd;\n const axis = useMemo(() => axisFor(z, range.from, range.to), [z, range.from, range.to]);\n const trackW = Math.round((range.to - range.from + 1) * ppd);\n const X = (iso) => (dayOf(iso) - range.from) * ppd;\n const inRange = (iso) => iso && dayOf(iso) >= range.from && dayOf(iso) <= range.to;\n\n const boxOf = (id) => {\n const row = shownRow(id);\n const d = shown.get(String(id));\n if (row == null || !d) return null;\n const r = live.rows.get(String(id));\n const y = rowTop(row) + rowH / 2;\n const own = rowOf.get(String(id)) === row;\n if (r?.milestone) {\n const cx0 = X(d.due) + ppd / 2;\n return { x0: cx0 - 7, x1: cx0 + 7, y, top: y - 7, bottom: y + 7, milestone: true, row, own };\n }\n const h = r?.summary || !own ? 6 : BAR;\n return { x0: X(d.start), x1: X(d.due) + ppd, y, top: y - h / 2, bottom: y + h / 2, milestone: false, row, own };\n };\n\n const lines = useMemo(() => {\n const out = [];\n const moved = new Set((preview?.changes ?? []).map((c) => String(c.id)));\n for (const l of live.links.values()) {\n if (!l.valid) continue;\n const a = boxOf(l.dependsOnId);\n const b = boxOf(l.taskId);\n if (!a || !b || a.row === b.row || !a.own || !b.own) continue;\n const kind = { finish_to_start: \"FS\", start_to_start: \"SS\", finish_to_finish: \"FF\", start_to_finish: \"SF\" }[l.kind] ?? l.kind;\n const route = routeLink(a, b, kind, { rowH });\n out.push({ id: l.id, link: l, critical: l.critical, lag: l.lag, route, touches: selected === l.taskId || selected === l.dependsOnId, moving: moved.has(l.taskId) && moved.has(l.dependsOnId) });\n }\n return out;\n }, [live, shown, visible, ppd, range.from, selected, rowH, preview]); // eslint-disable-line react-hooks/exhaustive-deps\n\n function scrollToDay(iso, smooth) {\n const el = scroller.current;\n if (!el || !iso) return;\n const left = Math.max(0, X(iso) - (el.clientWidth - gridW) * 0.3);\n el.scrollTo({ left, behavior: smooth ? \"smooth\" : \"auto\" });\n }\n useLayoutEffect(() => {\n if (z !== \"fit\") scrollToDay(today, false);\n }, [z, range.from]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- dragging bars ----------------------------------------------------------\n function movesFor(d, delta) {\n const t = byId.get(d.id);\n const r = plan.rows.get(d.id);\n if (!t || !r) return [];\n if (r.summary) return [{ id: d.id, start: isoOf(dayOf(d.start) + delta) }];\n const span = spanOf(t);\n if (d.mode === \"move\") {\n if (r.milestone) {\n const m = datesOf(CAL.lastIndex(dayOf(r.due) + delta) + 1, 0, true);\n return [{ id: d.id, start: m.start, due: m.due }];\n }\n const m = datesOf(CAL.startIndex(dayOf(r.start) + delta), span.d, false);\n return [{ id: d.id, start: m.start, due: m.due }];\n }\n if (d.mode === \"end\") return [{ id: d.id, start: r.start, due: isoOf(Math.max(dayOf(r.start), dayOf(r.due) + delta)) }];\n return [{ id: d.id, start: isoOf(Math.min(dayOf(r.due), dayOf(r.start) + delta)), due: r.due }];\n }\n\n function previewFor(moves, extra = {}) {\n const res = shiftDependents(tasks, links, moves, extra);\n const dates = new Map(res.changes.map((c) => [String(c.id), { start: c.start_date, due: c.due_date }]));\n const from = new Map(res.changes.filter((c) => c.from?.start_date).map((c) => [String(c.id), { start: c.from.start_date, due: c.from.due_date }]));\n return { dates, from, plan: res.after, finishBefore: res.finishBefore, finishAfter: res.finishAfter, clamped: res.clamped, moves, changes: res.changes };\n }\n\n const describe = (p, id) => describeDrag({ before: plan, after: p.plan, changes: p.changes, clamped: p.clamped, id, titleOf: (x) => byId.get(x)?.title ?? \"\" });\n\n function startDrag(e, id, modeName) {\n if (!onMove) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n const r = plan.rows.get(String(id));\n if (!r?.dated || r.pinned) return;\n if (e.pointerType !== \"mouse\" && modeName === \"move\" && !r.summary && !r.milestone) return;\n e.stopPropagation();\n const together = modeName === \"move\" && picked.size > 1 && picked.has(String(id)) ? [...picked].map(String) : null;\n dragRef.current = { id: String(id), mode: modeName, x0: e.clientX, start: shown.get(String(id))?.start, delta: 0, moved: false, pointerId: e.pointerId, together };\n try {\n track.current?.setPointerCapture(e.pointerId);\n } catch {\n // the pointer is gone already\n }\n }\n\n function movesOfDrag(d, delta) {\n if (!d.together) return movesFor(d, delta);\n return selectionMoves(d.together, (id) => {\n const r = plan.rows.get(id);\n if (!r?.dated || r.pinned) return [];\n return movesFor({ id, mode: \"move\", start: shown.get(id)?.start ?? r.start }, delta);\n });\n }\n\n function onTrackMove(e) {\n if (linkRef.current) return moveLink(e);\n const d = dragRef.current;\n if (!d || e.pointerId !== d.pointerId) return;\n const dx = e.clientX - d.x0;\n if (!d.moved && Math.abs(dx) < 4) return;\n d.moved = true;\n const delta = Math.round(dx / ppd);\n if (delta === d.delta && preview) return;\n d.delta = delta;\n const p = previewFor(movesOfDrag(d, delta));\n p.info = describe(p, d.id);\n d.preview = p;\n setPreview(p);\n onPreview?.({ mode: d.mode === \"end\" ? \"stretch\" : \"move\", task: byId.get(d.id), ...p.info });\n }\n\n async function onTrackUp(e) {\n if (linkRef.current) return finishLink(e);\n const d = dragRef.current;\n if (!d || e.pointerId !== d.pointerId) return;\n dragRef.current = null;\n if (!d.moved) return;\n suppress.current = true;\n setTimeout(() => {\n suppress.current = false;\n }, 0);\n const p = d.preview;\n setPreview(null);\n onPreview?.(null);\n if (!p || d.delta === 0) return;\n const res = await onMove?.(p.moves, p);\n if (res && res.ok === false) setNote(res.error ?? \"That move was not saved.\");\n }\n\n function cancelAll() {\n dragRef.current = null;\n linkRef.current = null;\n setPreview(null);\n setLinking(null);\n onPreview?.(null);\n }\n\n // ---- drawing links ----------------------------------------------------------\n function trackPoint(e) {\n const rect = track.current?.getBoundingClientRect();\n return rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: 0, y: 0 };\n }\n\n function startLink(e, id, end) {\n if (!onLink) return;\n e.stopPropagation();\n e.preventDefault();\n const b = boxOf(id);\n if (!b) return;\n const from = { x: end === \"start\" ? b.x0 : b.x1, y: b.y };\n const l = { fromId: String(id), fromEnd: end, from, to: from, targetId: null, targetEnd: null, problem: null, pointerId: e.pointerId, preview: null };\n linkRef.current = l;\n setLinking(l);\n try {\n track.current?.setPointerCapture(e.pointerId);\n } catch {\n // the pointer is gone already\n }\n }\n\n function moveLink(e) {\n const cur = linkRef.current;\n if (!cur || e.pointerId !== cur.pointerId) return;\n const pt = trackPoint(e);\n const entry = visible[rowAt(pt.y)];\n let targetId = null;\n let targetEnd = null;\n let problem = null;\n let p = null;\n if (entry && entry.id !== cur.fromId) {\n const b = boxOf(entry.id);\n if (b && pt.x >= b.x0 - 12 && pt.x <= b.x1 + 12) {\n targetId = entry.id;\n targetEnd = b.milestone ? \"start\" : pt.x < (b.x0 + b.x1) / 2 ? \"start\" : \"finish\";\n problem = linkProblem(tasks, links, targetId, cur.fromId);\n if (!problem) {\n p = previewFor([], { nextLinks: [...links, { id: \"__new\", task_id: targetId, depends_on_id: cur.fromId, kind: KIND_OF[`${cur.fromEnd}:${targetEnd}`], lag_days: 0 }] });\n p.info = describe(p, targetId);\n }\n }\n }\n linkRef.current = { ...cur, to: pt, targetId, targetEnd, problem, preview: p };\n setLinking(linkRef.current);\n setPreview(p);\n onPreview?.(p ? { mode: \"link\", task: byId.get(targetId), ...p.info } : null);\n }\n\n async function finishLink(e) {\n const l = linkRef.current;\n if (!l || e.pointerId !== l.pointerId) return;\n linkRef.current = null;\n setLinking(null);\n const p = l.preview;\n setPreview(null);\n onPreview?.(null);\n if (!l.targetId) return;\n if (l.problem) return setNote(l.problem);\n const res = await onLink?.({ taskId: l.targetId, dependsOnId: l.fromId, kind: KIND_OF[`${l.fromEnd}:${l.targetEnd}`] }, p);\n if (res && res.ok === false) setNote(res.error ?? \"That link was not saved.\");\n }\n\n useEffect(() => {\n if (!preview && !linking) return undefined;\n const onKey = (e) => {\n if (e.key === \"Escape\") cancelAll();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [preview, linking]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- keyboard on the chart ----------------------------------------------------\n function onKeyDown(e) {\n if (editing || e.target?.tagName === \"INPUT\") return;\n const i = selected ? rowOf.get(selected) : null;\n const task = selected ? byId.get(selected) : null;\n if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n const next = i == null ? 0 : Math.max(0, Math.min(visible.length - 1, i + (e.key === \"ArrowDown\" ? 1 : -1)));\n if (visible[next]) onSelect?.(visible[next].id);\n return;\n }\n if (!task) return;\n if (e.altKey && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") && onMove && !e.shiftKey) {\n e.preventDefault();\n const r = plan.rows.get(String(task.id));\n if (!r?.dated || r.pinned) return;\n const step = e.key === \"ArrowRight\" ? 1 : -1;\n const from = dayOf(r.milestone ? r.due : r.start);\n const to = CAL.dayAt((r.milestone ? CAL.lastIndex(from) : CAL.startIndex(from)) + step);\n const p = previewFor(movesFor({ id: String(task.id), mode: \"move\", start: r.start }, to - from));\n onMove(p.moves, p).then((res) => res && res.ok === false && setNote(res.error ?? \"That move was not saved.\"));\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n onOpen?.(task);\n } else if (e.key === \"F2\") {\n e.preventDefault();\n setEditing({ id: String(task.id), field: \"title\" });\n } else if (e.key === \"Escape\") onSelect?.(null);\n }\n\n async function commitEdit(task, field, value) {\n setEditing(null);\n const res = await onEdit?.(task, field, value);\n if (res && res.ok === false) setNote(res.error ?? \"That change was not saved.\");\n }\n\n async function submitDraft(milestoneKey = false) {\n const title = draft.title.trim();\n if (!title || !onAdd || busy) return;\n setBusy(true);\n const milestone = draft.milestone || milestoneKey;\n const res = await onAdd({ title, days: Number(draft.days) || null, milestone, parentId: draft.parentId, afterId: draft.afterId });\n setBusy(false);\n if (res && res.ok === false) setNote(res.error ?? \"That task was not added.\");\n else setDraft((d) => ({ ...d, title: \"\", days: \"\", milestone: false, afterId: res?.id ? String(res.id) : d.afterId }));\n addRef.current?.focus();\n }\n\n // ---- the sheet's divider --------------------------------------------------------\n function startDivider(e) {\n e.preventDefault();\n const x0 = e.clientX;\n const w0 = gridW;\n let last = w0;\n const move = (ev) => {\n last = Math.max(fixedWidth(cols) + 140, Math.min(760, w0 + ev.clientX - x0));\n setGridState(last);\n };\n const up = () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n onGridWidth?.(last);\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n }\n const divider = narrow ? null : (\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label=\"Resize the task sheet\"\n aria-valuenow={gridW}\n tabIndex={0}\n onPointerDown={startDivider}\n onKeyDown={(e) => {\n if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n e.preventDefault();\n setGridState(Math.max(fixedWidth(cols) + 140, Math.min(760, gridW + (e.key === \"ArrowRight\" ? 16 : -16))));\n }\n }}\n className=\"absolute inset-y-0 -right-[3px] z-50 w-[6px] cursor-col-resize outline-none hover:bg-ring/30 focus-visible:bg-ring/40\"\n />\n );\n\n // ---- render -------------------------------------------------------------------\n const finish = preview?.finishAfter ?? plan.finish;\n const gap = gapOf(finish, target);\n const marks = {\n today: showToday && inRange(today) ? X(today) + ppd / 2 : null,\n target: target && inRange(target) ? X(target) + ppd : null,\n targetIso: target,\n finish: finish && inRange(finish) ? X(finish) + ppd : null,\n finishFrom: preview && plan.finish && inRange(plan.finish) ? X(plan.finish) + ppd : null,\n moving: Boolean(preview && !linking),\n gap,\n };\n const bodyH = Math.max(visible.length, 1) * rowH + (addIndex != null ? ADD_H : 0);\n const addAfter = addIndex != null && addIndex < visible.length - 1 ? addIndex : null;\n const addParent = draft.parentId ? byId.get(String(draft.parentId)) : null;\n const ghostMap = preview?.from?.size ? preview.from : ghosts;\n const dragInfo = preview?.info ?? null;\n const laneH = 20;\n const laneHead = 28;\n const hasLanes = Boolean(lanes?.length);\n const lanesH = hasLanes ? laneHead + lanes.length * laneH : 0;\n const rowTops = visible.map((_, i) => rowTop(i));\n const selectedRows = new Set([...(selected ? [selected] : []), ...picked].map((id) => rowOf.get(String(id))).filter((i) => i != null));\n const addAt = addIndex != null && addIndex < visible.length ? rowTop(addIndex) + rowH : bodyH;\n\n const addRow = onAdd ? (\n <AddRow\n open={adding}\n onOpen={() => {\n setAdding(true);\n setDraft({ title: \"\", days: \"\", milestone: false, parentId: null, afterId: null });\n setTimeout(() => addRef.current?.focus(), 20);\n }}\n draft={draft}\n setDraft={setDraft}\n onSubmit={submitDraft}\n busy={busy}\n addRef={addRef}\n parentTitle={addParent?.title}\n aboveTitle={draft.afterId ? byId.get(draft.afterId)?.title : null}\n onIndent={() => setDraft((d) => ({ ...d, parentId: d.afterId, afterId: null }))}\n height={rowH}\n />\n ) : null;\n const addExtra = onAdd && !adding ? rowH : 0;\n\n const rootStyle = height === \"fill\" ? undefined : { height };\n\n return (\n <div className={cx(\"flex min-h-0 flex-col\", height === \"fill\" && \"h-full\", className)} style={rootStyle}>\n {error ? (\n <p role=\"alert\" className=\"shrink-0 border-b border-destructive px-3 py-2 text-[13px] text-destructive\">\n {typeof error === \"string\" ? error : \"The plan could not be read.\"}\n </p>\n ) : null}\n {note ? (\n <p role=\"alert\" className=\"flex shrink-0 items-start justify-between gap-3 border-b border-border bg-card px-3 py-2 text-[13px]\">\n <span>{note}</span>\n <button type=\"button\" onClick={() => setNote(null)} className=\"shrink-0 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\" aria-label=\"Dismiss the message\">\n <Icon name=\"x\" size={16} />\n </button>\n </p>\n ) : null}\n <div\n ref={scroller}\n role=\"region\"\n aria-label={label}\n aria-busy={loading || undefined}\n tabIndex={0}\n onKeyDown={onKeyDown}\n className=\"relative min-h-0 flex-1 overflow-auto bg-card outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\"\n >\n <div className=\"relative\" style={{ width: gridW + trackW, minHeight: HEAD + bodyH + addExtra + lanesH }}>\n <div className=\"sticky top-0 z-30 flex border-b border-border bg-background\" style={{ height: HEAD }}>\n <div className=\"sticky left-0 z-40 shrink-0 border-r border-border bg-background\" style={{ width: gridW }}>\n <SheetHead cols={cols} />\n {divider}\n </div>\n <div className=\"relative\" style={{ width: trackW }}>\n <TrackHead axis={axis} range={range} ppd={ppd} z={z} gridW={gridW} marks={marks} />\n </div>\n </div>\n\n <div className=\"flex\">\n <div className=\"sticky left-0 z-20 shrink-0 border-r border-border bg-card\" style={{ width: gridW }}>\n {loading\n ? Array.from({ length: 8 }, (_, i) => (\n <div key={i} className=\"flex items-center gap-2 border-b border-border px-3\" style={{ height: rowH }}>\n <span className=\"h-2.5 w-8 animate-pulse rounded-[2px] bg-muted\" />\n <span className=\"h-2.5 flex-1 animate-pulse rounded-[2px] bg-muted\" style={{ maxWidth: `${40 + ((i * 17) % 50)}%` }} />\n </div>\n ))\n : visible.map((e, i) => {\n const t = byId.get(e.id);\n const r = live.rows.get(e.id);\n const d = shown.get(e.id);\n const late = r && !r.summary && !r.pinned && r.dated && !r.milestone && d?.due && d.due < today && r.progress < 100;\n const lateMs = r?.milestone && !r.pinned && d?.due && d.due < today;\n return (\n <div key={e.id}>\n <SheetRow\n e={e}\n t={t}\n r={r}\n cols={cols}\n rowH={rowH}\n critical={critical}\n selected={selected === e.id || picked.has(e.id)}\n closed={closed.has(e.id)}\n cells={cellsOf(e.id)}\n foldedCount={r?.summary ? `${r.leafIds.length} ${r.leafIds.length === 1 ? \"task\" : \"tasks\"}` : null}\n tags={tags?.get?.(e.id)}\n late={late || lateMs}\n dim={highlight && !highlight.has(e.id) && !r?.summary}\n moving={moving}\n editing={editing?.id === e.id ? editing : null}\n onSelect={onSelect}\n onOpen={onOpen}\n onToggle={onToggle}\n onOpenLink={onOpenLink}\n onStartEdit={(field) => {\n onSelect?.(e.id);\n setEditing({ id: e.id, field });\n }}\n onCommit={(field, v) => commitEdit(t, field, v)}\n onCancel={() => setEditing(null)}\n onHover={() => setHovered(e.id)}\n menu={rowMenu && (hovered === e.id || selected === e.id) ? rowMenu(t, { row: e, plan: r }) : null}\n />\n {addAfter === i && adding ? addRow : null}\n </div>\n );\n })}\n {addAfter == null ? addRow : null}\n {hasLanes ? (\n <div>\n <div className=\"flex items-center gap-2 border-b border-t border-border bg-muted px-3 text-[12px] font-semibold\" style={{ height: laneHead }}>\n {lanesTitle}\n </div>\n <LaneLabels lanes={lanes} laneH={laneH} width={gridW - 1} />\n </div>\n ) : null}\n </div>\n\n <div\n ref={track}\n className={cx(\"relative\", linking && \"cursor-crosshair\")}\n style={{ width: trackW, height: bodyH + addExtra + lanesH, touchAction: dragRef.current || linking ? \"none\" : \"pan-x pan-y\" }}\n onPointerMove={onTrackMove}\n onPointerUp={onTrackUp}\n onPointerCancel={cancelAll}\n >\n <Paper axis={axis} range={range} ppd={ppd} z={z} marks={marks} rowTops={rowTops} rowH={rowH} selectedRows={selectedRows} />\n <Links lines={lines} width={trackW} height={bodyH} critical={critical} onOpenLink={onOpenLink} />\n <Ghosts ghosts={ghostMap} visible={visible} X={X} ppd={ppd} rowH={rowH} rowTop={rowTop} live={live} kind=\"was\" bar={BAR} />\n <Ghosts ghosts={pencil} visible={visible} X={X} ppd={ppd} rowH={rowH} rowTop={rowTop} live={live} kind=\"pencil\" bar={BAR} />\n {addIndex != null && !draft.milestone ? (\n <span aria-hidden=\"true\" className=\"absolute h-3.5 rounded-[2px] border border-dashed border-info bg-info/15\" style={{ left: today ? X(today) : 0, width: Math.max(1, Number(draft.days) || 3) * ppd * 1.4, top: rowTop(addIndex) + rowH + (ADD_H - 14) / 2 }} />\n ) : null}\n\n {visible.map((e, i) => {\n const t = byId.get(e.id);\n const r = live.rows.get(e.id);\n const d = shown.get(e.id);\n const y = rowTop(i) + rowH / 2;\n const n = notes?.get?.(e.id);\n if (!r || !d) {\n return n ? (\n <span key={e.id} className={cx(\"absolute -translate-y-1/2 whitespace-nowrap text-[12px]\", n.tone === \"problem\" ? \"font-medium text-destructive\" : \"text-muted-foreground\")} style={{ left: 12, top: y }}>\n {n.text}\n </span>\n ) : null;\n }\n const isSel = selected === e.id || picked.has(e.id);\n const dim = highlight && !highlight.has(e.id) && !r.summary;\n const done = r.pinned;\n const late = !r.summary && !done && !r.milestone && d.due < today && r.progress < 100;\n const title = barLabel(t, r);\n const common = {\n tabIndex: -1,\n onClick: (ev) => {\n ev.stopPropagation();\n if (suppress.current) return;\n onSelect?.(e.id, { additive: ev.metaKey || ev.ctrlKey || ev.shiftKey });\n },\n onDoubleClick: () => onOpen?.(t),\n title,\n \"aria-label\": title,\n };\n const crit = critical && r.critical && !done;\n const labelEl = (x, extra = 0) => {\n const person = people?.get?.(String(t.assignee_id ?? \"\"));\n const who = firstName(person?.name ?? t.assignee_name);\n const text = n?.text ?? who;\n return text || late ? (\n <span className=\"pointer-events-none absolute top-1/2 flex -translate-y-1/2 items-center gap-1.5 whitespace-nowrap text-[11px]\" style={{ left: x + 6 + extra }}>\n {late ? <span className=\"rounded-[3px] border border-warning px-1 text-[10px] font-semibold leading-4 text-warning\">Late</span> : null}\n {text ? <span className={n?.tone === \"problem\" ? \"font-medium text-destructive\" : n?.tone === \"crit\" ? \"font-medium text-destructive\" : \"text-muted-foreground\"}>{text}</span> : null}\n </span>\n ) : null;\n };\n const handles =\n onLink && !r.summary && !done ? (\n <>\n <button type=\"button\" tabIndex={-1} aria-label={`Link from the start of ${t.title}`} onPointerDown={(ev) => startLink(ev, e.id, \"start\")} className={cx(\"absolute top-1/2 z-20 size-2 -translate-y-1/2 rounded-full border-2 border-ring bg-background opacity-0 outline-none group-hover:opacity-100\", isSel && \"opacity-100\")} style={{ left: -11, touchAction: \"none\" }} />\n <button type=\"button\" tabIndex={-1} aria-label={`Link from the end of ${t.title}`} onPointerDown={(ev) => startLink(ev, e.id, \"finish\")} className={cx(\"absolute top-1/2 z-20 size-2 -translate-y-1/2 rounded-full border-2 border-ring bg-background opacity-0 outline-none group-hover:opacity-100\", isSel && \"opacity-100\")} style={{ right: -11, touchAction: \"none\" }} />\n </>\n ) : null;\n const target = linking?.targetId === e.id;\n\n if (r.milestone) {\n const cx0 = X(d.due) + ppd / 2;\n return (\n <div key={e.id} className={cx(\"group absolute z-10\", dim && \"opacity-40\")} style={{ left: cx0 - 9, top: y - 9, width: 18, height: 18 }}>\n <button type=\"button\" {...common} onPointerDown={(ev) => startDrag(ev, e.id, \"move\")} className={cx(\"absolute inset-0 grid place-items-center rounded-[2px] outline-none focus-visible:ring-2 focus-visible:ring-ring\", onMove && !done && \"cursor-grab\", target && (linking.problem ? \"ring-2 ring-destructive\" : \"ring-2 ring-ring\"))}>\n <span className={cx(\"block size-3 rotate-45\", done ? \"bg-chart-2\" : crit ? \"bg-destructive\" : \"bg-foreground\", isSel && \"ring-2 ring-ring ring-offset-1 ring-offset-card\")} />\n </button>\n {handles}\n <span className={cx(NUM, \"pointer-events-none absolute left-full top-1/2 ml-2 -translate-y-1/2 whitespace-nowrap text-[11px]\", crit ? \"font-semibold text-destructive\" : \"text-muted-foreground\")}>{n?.text ?? dayWords(d.due)}</span>\n </div>\n );\n }\n\n const x0 = X(d.start);\n const w = Math.max(ppd, X(d.due) + ppd - x0);\n if (r.summary) {\n const folded = closed.has(e.id);\n const kids = folded ? r.leafIds.map((id) => ({ id, row: live.rows.get(id), d: shown.get(id) })).filter((k) => k.row?.milestone && k.d) : [];\n return (\n <div key={e.id} className=\"group absolute z-10\" style={{ left: x0, top: y - 4, width: w, height: 12 }}>\n <button type=\"button\" {...common} onPointerDown={(ev) => startDrag(ev, e.id, \"move\")} className={cx(\"absolute inset-x-0 top-0 h-[6px] outline-none focus-visible:ring-2 focus-visible:ring-ring\", done ? \"bg-chart-2\" : \"bg-foreground\", onMove && !done && \"cursor-grab\")}>\n <span className={cx(\"absolute left-0 top-[6px] size-0 border-r-[5px] border-t-[5px] border-r-transparent\", done ? \"border-t-chart-2\" : \"border-t-foreground\")} />\n <span className={cx(\"absolute right-0 top-[6px] size-0 border-l-[5px] border-t-[5px] border-l-transparent\", done ? \"border-t-chart-2\" : \"border-t-foreground\")} />\n </button>\n {kids.map((k) => (\n <span key={k.id} title={byId.get(k.id)?.title} className={cx(\"absolute -top-[10px] size-[10px] -translate-x-1/2 rotate-45 border-[1.5px] border-card\", k.row.pinned ? \"bg-chart-2\" : critical && k.row.critical ? \"bg-destructive\" : \"bg-foreground\")} style={{ left: X(k.d.due) + ppd / 2 - x0 }} />\n ))}\n {n ? (\n <span className=\"pointer-events-none absolute -top-[4px] whitespace-nowrap text-[11px] text-muted-foreground\" style={{ left: w + 8 }}>\n {n.text}\n </span>\n ) : null}\n </div>\n );\n }\n const loop = r.stuck;\n const guess = guesses?.has?.(e.id);\n const barTop = y - BAR / 2 - (baseline ? 2 : 0);\n const base = baseline?.get?.(e.id);\n const dragging = preview && dragRef.current?.id === e.id;\n const movedByDrag = preview?.dates?.has(e.id);\n const trackClass = loop ? \"border border-dashed border-destructive bg-destructive/15\" : guess ? \"border border-dashed border-chart-2 bg-transparent\" : crit ? \"bg-destructive\" : done ? \"bg-chart-2\" : \"bg-chart-1\";\n const fill = crit ? \"bg-foreground/35\" : \"bg-chart-2\";\n const mark = tags?.get?.(e.id)?.some((g) => g.kind === \"unsaved\");\n return (\n <div key={e.id} className={cx(\"group absolute z-10\", dim && \"opacity-40\")} style={{ left: x0, top: barTop, width: w, height: BAR }}>\n <button\n type=\"button\"\n {...common}\n onPointerDown={(ev) => startDrag(ev, e.id, \"move\")}\n className={cx(\n \"absolute inset-0 overflow-hidden rounded-[2px] outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n trackClass,\n isSel && \"ring-2 ring-ring\",\n (dragging || movedByDrag) && !isSel && \"ring-[1.5px] ring-ring\",\n mark && \"ring-2 ring-destructive\",\n target && (linking.problem ? \"ring-2 ring-destructive\" : \"ring-2 ring-ring\"),\n onMove && !done && \"cursor-grab\",\n )}\n >\n {loop ? <span className=\"absolute inset-0 opacity-40\" style={{ backgroundImage: \"repeating-linear-gradient(135deg, currentColor 0 2px, transparent 2px 6px)\" }} /> : null}\n {!loop && !guess && !done && r.progress > 0 ? <span className={cx(\"absolute inset-y-0 left-0\", fill)} style={{ width: `${r.progress}%` }} /> : null}\n </button>\n {base ? <span aria-hidden=\"true\" className=\"pointer-events-none absolute h-1 rounded-[1px] bg-chart-3\" style={{ left: X(base.start) - x0, width: Math.max(ppd, X(base.due) + ppd - X(base.start)), top: BAR + 2 }} /> : null}\n {onMove && !done ? (\n <>\n <span onPointerDown={(ev) => startDrag(ev, e.id, \"start\")} aria-hidden=\"true\" className={cx(\"absolute z-10 cursor-ew-resize\", dragging || isSel ? \"-inset-y-[2px] -left-[2px] w-1.5 rounded-[2px] bg-ring\" : \"inset-y-0 left-0 w-2\")} style={{ touchAction: \"none\" }} />\n <span onPointerDown={(ev) => startDrag(ev, e.id, \"end\")} aria-hidden=\"true\" className={cx(\"absolute z-10 cursor-ew-resize\", dragging || isSel ? \"-inset-y-[2px] -right-[2px] w-1.5 rounded-[2px] bg-ring\" : \"inset-y-0 right-0 w-2\")} style={{ touchAction: \"none\" }} />\n </>\n ) : null}\n {handles}\n {labelEl(w, isSel ? 8 : 0)}\n </div>\n );\n })}\n\n {dragInfo && dragRef.current ? <DragTip info={dragInfo} task={byId.get(dragInfo.id)} box={boxOf(dragInfo.id)} tasks={tasks} plan={plan} /> : null}\n\n {linking ? (\n (() => {\n const dx = linking.to.x - linking.from.x;\n const dy = linking.to.y - linking.from.y;\n const lines2 = linking.targetId\n ? linkLines({ task: byId.get(linking.targetId), predecessor: byId.get(linking.fromId), kind: KIND_OF[`${linking.fromEnd}:${linking.targetEnd}`], info: linking.preview?.info, problem: linking.problem })\n : null;\n return (\n <>\n <span aria-hidden=\"true\" className={cx(\"pointer-events-none absolute z-30 h-0 origin-left border-t-[1.5px] border-dashed\", linking.problem ? \"border-destructive\" : \"border-ring\")} style={{ left: linking.from.x, top: linking.from.y, width: Math.sqrt(dx * dx + dy * dy), transform: `rotate(${(Math.atan2(dy, dx) * 180) / Math.PI}deg)` }} />\n {lines2 ? (\n <div role=\"status\" className=\"pointer-events-none absolute z-40 max-w-80 rounded-[4px] border border-border bg-popover px-2.5 py-2 text-[12px] leading-snug text-popover-foreground shadow-lg\" style={{ left: linking.to.x + 12, top: linking.to.y + 10 }}>\n <div className={lines2.tone === \"problem\" ? \"font-medium text-destructive\" : undefined}>{lines2.text}</div>\n {lines2.tail ? <div className={lines2.tone === \"late\" ? \"font-semibold text-warning\" : \"text-muted-foreground\"}>{lines2.tail}</div> : null}\n </div>\n ) : null}\n </>\n );\n })()\n ) : null}\n\n {visible.length === 0 && !loading ? (\n <div className=\"absolute inset-x-0 top-6 grid place-items-center px-6 text-center\">\n <p className=\"max-w-sm text-[13px] text-muted-foreground\">{emptyText}</p>\n </div>\n ) : null}\n\n {hasLanes ? <LaneTrack lanes={lanes} X={X} ppd={ppd} laneH={laneH} top={bodyH + addExtra + laneHead} /> : null}\n </div>\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n/** The note by a bar while it moves: what it is, where it ends, what it costs, in the canvas's words. */\nfunction DragTip({ info, task, box, tasks, plan }) {\n if (!box || !task) return null;\n const next = (() => {\n const l = [...plan.links.values()].find((x) => x.valid && x.dependsOnId === info.id);\n return l ? tasks.find((t) => String(t.id) === l.taskId)?.title : null;\n })();\n const lines = dragLines(info, task, { nextTitle: next });\n return (\n <div role=\"status\" className=\"pointer-events-none absolute z-40 grid w-max max-w-80 gap-0.5 rounded-[4px] border border-border bg-popover px-2.5 py-2 text-[12px] leading-snug text-popover-foreground shadow-lg\" style={{ left: box.x1 + 14, top: Math.max(4, box.y - 64) }}>\n <span className=\"text-[13px] font-semibold\">{lines.title}</span>\n <span className={NUM}>{lines.first}</span>\n <span>{lines.second}</span>\n {lines.slack ? <span className=\"text-muted-foreground\">{lines.slack}</span> : null}\n <span className={cx(NUM, lines.third.tone === \"late\" ? \"font-semibold text-warning\" : lines.third.tone === \"early\" ? \"font-semibold text-success\" : \"text-muted-foreground\")}>{lines.third.text}</span>\n <span className=\"text-muted-foreground\">{lines.last}</span>\n </div>\n );\n}\n\n"
|
|
23
|
+
"content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { Icon, cx } from \"../../ui\";\nimport { datesOf, dayOf, isoOf, linkProblem, makeCalendar, schedule, shiftDependents, spanOf, taskTree, waitsWords } from \"../schedule-engine\";\nimport { ZOOMS, axisFor, rangeFor, routeLink } from \"./geometry\";\nimport { baselineDelta, barLabel, columnsFor, dayWords, describeDrag, dragLines, fixedWidth, gapOf, linkLines, selectionMoves, slackWords } from \"./gantt-model\";\nimport { AddRow, ROW, SheetHead, SheetRow } from \"./sheet\";\nimport { HEAD, Ghosts, LaneLabels, LaneTrack, Links, Paper, TrackHead } from \"./track\";\n\n// ScheduleGantt: the task sheet beside a working-day time scale, one picture\n// row for row. Bars are steel grey with their progress inside; the critical\n// path turns red when the page switches it on; a phase is a bracket over its\n// work; a milestone a diamond. The target is a dashed line, the finish a solid\n// one, and the gap between them is measured in working days in the header.\n// Dashed outlines show where bars were (`ghosts`) and where a suggestion\n// would put them (`pencil`).\n//\n// Drag a bar to move it, an end to change its length, the dot at its end onto\n// another bar to link them: everything that waits on it moves while you drag,\n// and a note by the bar says what it costs. README.md lists every prop.\n// Built from divs and one svg for the links, tokens only.\n\nconst BAR = 14;\nconst CAL = makeCalendar(\"weekdays\");\nconst EMPTY = [];\nconst NONE = new Set();\nconst KIND_OF = { \"finish:start\": \"finish_to_start\", \"start:start\": \"start_to_start\", \"finish:finish\": \"finish_to_finish\", \"start:finish\": \"start_to_finish\" };\nconst NUM = \"tabular-nums\";\n\nconst firstName = (name) => String(name ?? \"\").trim().split(/\\s+/)[0] ?? \"\";\n\nexport function ScheduleGantt({\n tasks: tasksProp,\n links: linksProp,\n people,\n today,\n showToday = true,\n target,\n zoom = \"week\",\n fitWidth,\n columns,\n vsLabel = \"vs baseline\",\n rowH = ROW,\n gridWidth = 470,\n onGridWidth,\n height = \"fill\",\n critical = false,\n selectedId,\n selectedIds,\n onSelect,\n collapsed,\n onToggle,\n onOpen,\n onMove,\n onLink,\n onEdit,\n onAdd,\n onPreview,\n onOpenLink,\n highlight,\n addUnder,\n editRequest,\n jump,\n ghosts,\n pencil,\n baseline,\n guesses,\n notes,\n tags,\n moving,\n rowMenu,\n lanes,\n lanesTitle,\n loading = false,\n error = null,\n emptyText = \"No tasks yet. Type the first one in the last row.\",\n label = \"Gantt chart\",\n className,\n}) {\n const tasks = tasksProp ?? EMPTY;\n const links = linksProp ?? EMPTY;\n const scroller = useRef(null);\n const track = useRef(null);\n const dragRef = useRef(null);\n const linkRef = useRef(null);\n const suppress = useRef(false);\n const addRef = useRef(null);\n const [boxW, setBoxW] = useState(null);\n const [preview, setPreview] = useState(null);\n const [linking, setLinking] = useState(null);\n const [editing, setEditing] = useState(null);\n const [draft, setDraft] = useState({ title: \"\", days: \"\", milestone: false, parentId: null, afterId: null });\n const [adding, setAdding] = useState(false);\n const [hovered, setHovered] = useState(null);\n const [busy, setBusy] = useState(false);\n const [note, setNote] = useState(null);\n const [gridState, setGridState] = useState(gridWidth);\n const selected = selectedId ? String(selectedId) : null;\n const picked = selectedIds ?? NONE;\n\n // Measured in the callback, applied in the next frame (an observer that sets what it watches loops).\n useLayoutEffect(() => {\n const el = scroller.current;\n if (!el) return undefined;\n let raf = 0;\n const apply = (w) => {\n cancelAnimationFrame(raf);\n raf = requestAnimationFrame(() => setBoxW(w));\n };\n setBoxW(el.clientWidth);\n const onResize = () => apply(el.clientWidth);\n window.addEventListener(\"resize\", onResize);\n const ro = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(() => apply(el.clientWidth));\n ro?.observe(el);\n return () => {\n cancelAnimationFrame(raf);\n window.removeEventListener(\"resize\", onResize);\n ro?.disconnect();\n };\n }, []);\n\n // The page opens the add row: { id (a parent) | null, afterId, milestone, nonce }.\n useEffect(() => {\n if (!addUnder) return;\n setAdding(true);\n setDraft({ title: \"\", days: \"\", milestone: Boolean(addUnder.milestone), parentId: addUnder.id ? String(addUnder.id) : null, afterId: addUnder.afterId ? String(addUnder.afterId) : null });\n const t = setTimeout(() => {\n addRef.current?.focus();\n addRef.current?.scrollIntoView?.({ block: \"nearest\" });\n }, 20);\n return () => clearTimeout(t);\n }, [addUnder]);\n\n // The page opens a cell: { id, field, nonce } (the row menu's Rename).\n useEffect(() => {\n if (editRequest?.id) setEditing({ id: String(editRequest.id), field: editRequest.field ?? \"title\" });\n }, [editRequest]);\n\n useEffect(() => {\n if (jump?.date) scrollToDay(jump.date, true);\n }, [jump]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- the model ----------------------------------------------------------\n const plan = useMemo(() => schedule(tasks, links), [tasks, links]);\n const live = preview?.plan ?? plan;\n const tree = useMemo(() => taskTree(tasks), [tasks]);\n const byId = useMemo(() => new Map(tasks.map((t) => [String(t.id), t])), [tasks]);\n const closed = collapsed ?? NONE;\n const visible = useMemo(() => {\n const out = [];\n for (const e of tree.order) {\n let hidden = false;\n for (let p = e.parentId; p; p = tree.byId.get(p)?.parentId) if (closed.has(p)) hidden = true;\n if (!hidden) out.push(e);\n }\n return out;\n }, [tree, closed]);\n const rowOf = useMemo(() => new Map(visible.map((e, i) => [e.id, i])), [visible]);\n const outlineOf = (id) => tree.byId.get(String(id))?.outline;\n // The add row sits under the row it adds after; everything below it moves down by its height.\n const ADD_H = ROW + 6;\n const addIndex = adding ? (draft.afterId && rowOf.has(draft.afterId) ? rowOf.get(draft.afterId) : draft.parentId && rowOf.has(draft.parentId) ? rowOf.get(draft.parentId) : visible.length - 1) : null;\n const rowTop = (i) => i * rowH + (addIndex != null && i > addIndex ? ADD_H : 0);\n const rowAt = (y) => {\n for (let i = 0; i < visible.length; i += 1) if (y >= rowTop(i) && y < rowTop(i) + rowH) return i;\n return -1;\n };\n const shownRow = (id) => {\n for (let cur = String(id); cur; cur = tree.byId.get(cur)?.parentId) if (rowOf.has(cur)) return rowOf.get(cur);\n return null;\n };\n\n const shown = useMemo(() => {\n const out = new Map();\n for (const t of tasks) {\n const id = String(t.id);\n const r = live.rows.get(id);\n if (!r || !r.dated || r.summary) continue;\n const p = preview?.dates?.get(id);\n out.set(id, { start: p?.start ?? r.start, due: p?.due ?? r.due });\n }\n for (const r of live.rows.values()) {\n if (!r.summary) continue;\n let s = null;\n let d = null;\n for (const leaf of r.leafIds) {\n const x = out.get(leaf);\n if (!x || !x.start) continue;\n if (!s || x.start < s) s = x.start;\n if (!d || x.due > d) d = x.due;\n }\n if (s) out.set(r.id, { start: s, due: d });\n }\n return out;\n }, [tasks, live, preview]);\n\n // ---- the sheet ----------------------------------------------------------\n const narrow = boxW != null && boxW < 760;\n const cols = useMemo(() => columnsFor(columns, { vsLabel, narrow, outlineWidth: tree.order.some((e) => e.depth >= 3) ? 78 : undefined }), [columns, vsLabel, narrow, tree]);\n const gridW = narrow ? Math.max(170, Math.min(260, Math.round((boxW ?? 400) * 0.46))) : Math.max(fixedWidth(cols) + 160, gridState);\n const cellsOf = useMemo(() => {\n const byTask = new Map();\n for (const l of links) {\n const k = String(l.task_id);\n if (!byTask.has(k)) byTask.set(k, []);\n byTask.get(k).push(l);\n }\n return (id) => {\n const t = byId.get(id);\n const r = live.rows.get(id);\n const mine = byTask.get(id) ?? [];\n const person = people?.get?.(String(t?.assignee_id ?? \"\"));\n const name = person?.name ?? t?.assignee_name ?? \"\";\n const out = {\n owner: { name: firstName(name), title: name, left: Boolean(person?.left) },\n waits: waitsWords(mine, id, outlineOf),\n waitsLinks: mine.map((l) => live.links.get(String(l.id))).filter(Boolean),\n waitsBroken: mine.some((l) => live.links.get(String(l.id))?.violated) || Boolean(r?.stuck),\n slack: slackWords(r),\n guessed: Boolean(guesses?.has?.(id)),\n };\n if (baseline) out.delta = baselineDelta(r, baseline.get?.(id));\n return out;\n };\n }, [links, byId, live, people, baseline, guesses, tree]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- the axis -----------------------------------------------------------\n const z = ZOOMS[zoom] ? zoom : \"week\";\n const range = useMemo(() => {\n const dates = [];\n for (const r of plan.rows.values()) if (r.dated) dates.push(r.start, r.due, r.planDue);\n if (target) dates.push(target);\n if (ghosts) for (const g of ghosts.values()) dates.push(g.start, g.due);\n if (pencil) for (const g of pencil.values()) dates.push(g.start, g.due);\n if (baseline) for (const g of baseline.values()) dates.push(g.start, g.due);\n return rangeFor(z, dates, today);\n }, [plan, target, ghosts, pencil, baseline, z, today]);\n // Whole project: the span fits the width, with room on the right for the notes after the bars.\n const ppd = z === \"fit\" ? Math.max(2, ((fitWidth ?? boxW ?? 1200) - gridW - (notes?.size ? 200 : 16)) / (range.to - range.from + 1)) : ZOOMS[z].ppd;\n const axis = useMemo(() => axisFor(z, range.from, range.to), [z, range.from, range.to]);\n const trackW = Math.round((range.to - range.from + 1) * ppd);\n const X = (iso) => (dayOf(iso) - range.from) * ppd;\n const inRange = (iso) => iso && dayOf(iso) >= range.from && dayOf(iso) <= range.to;\n\n const boxOf = (id) => {\n const row = shownRow(id);\n const d = shown.get(String(id));\n if (row == null || !d) return null;\n const r = live.rows.get(String(id));\n const y = rowTop(row) + rowH / 2;\n const own = rowOf.get(String(id)) === row;\n if (r?.milestone) {\n const cx0 = X(d.due) + ppd / 2;\n return { x0: cx0 - 7, x1: cx0 + 7, y, top: y - 7, bottom: y + 7, milestone: true, row, own };\n }\n const h = r?.summary || !own ? 6 : BAR;\n return { x0: X(d.start), x1: X(d.due) + ppd, y, top: y - h / 2, bottom: y + h / 2, milestone: false, row, own };\n };\n\n const lines = useMemo(() => {\n const out = [];\n const moved = new Set((preview?.changes ?? []).map((c) => String(c.id)));\n for (const l of live.links.values()) {\n if (!l.valid) continue;\n const a = boxOf(l.dependsOnId);\n const b = boxOf(l.taskId);\n if (!a || !b || a.row === b.row || !a.own || !b.own) continue;\n const kind = { finish_to_start: \"FS\", start_to_start: \"SS\", finish_to_finish: \"FF\", start_to_finish: \"SF\" }[l.kind] ?? l.kind;\n const route = routeLink(a, b, kind, { rowH });\n out.push({ id: l.id, link: l, critical: l.critical, lag: l.lag, route, touches: selected === l.taskId || selected === l.dependsOnId, moving: moved.has(l.taskId) && moved.has(l.dependsOnId) });\n }\n return out;\n }, [live, shown, visible, ppd, range.from, selected, rowH, preview]); // eslint-disable-line react-hooks/exhaustive-deps\n\n function scrollToDay(iso, smooth) {\n const el = scroller.current;\n if (!el || !iso) return;\n const left = Math.max(0, X(iso) - (el.clientWidth - gridW) * 0.3);\n el.scrollTo({ left, behavior: smooth ? \"smooth\" : \"auto\" });\n }\n useLayoutEffect(() => {\n if (z !== \"fit\") scrollToDay(today, false);\n }, [z, range.from]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- dragging bars ----------------------------------------------------------\n function movesFor(d, delta) {\n const t = byId.get(d.id);\n const r = plan.rows.get(d.id);\n if (!t || !r) return [];\n if (r.summary) return [{ id: d.id, start: isoOf(dayOf(d.start) + delta) }];\n const span = spanOf(t);\n if (d.mode === \"move\") {\n if (r.milestone) {\n const m = datesOf(CAL.lastIndex(dayOf(r.due) + delta) + 1, 0, true);\n return [{ id: d.id, start: m.start, due: m.due }];\n }\n const m = datesOf(CAL.startIndex(dayOf(r.start) + delta), span.d, false);\n return [{ id: d.id, start: m.start, due: m.due }];\n }\n if (d.mode === \"end\") return [{ id: d.id, start: r.start, due: isoOf(Math.max(dayOf(r.start), dayOf(r.due) + delta)) }];\n return [{ id: d.id, start: isoOf(Math.min(dayOf(r.due), dayOf(r.start) + delta)), due: r.due }];\n }\n\n function previewFor(moves, extra = {}) {\n const res = shiftDependents(tasks, links, moves, extra);\n const dates = new Map(res.changes.map((c) => [String(c.id), { start: c.start_date, due: c.due_date }]));\n const from = new Map(res.changes.filter((c) => c.from?.start_date).map((c) => [String(c.id), { start: c.from.start_date, due: c.from.due_date }]));\n return { dates, from, plan: res.after, finishBefore: res.finishBefore, finishAfter: res.finishAfter, clamped: res.clamped, moves, changes: res.changes };\n }\n\n const describe = (p, id) => describeDrag({ before: plan, after: p.plan, changes: p.changes, clamped: p.clamped, id, titleOf: (x) => byId.get(x)?.title ?? \"\" });\n\n function startDrag(e, id, modeName) {\n if (!onMove) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n const r = plan.rows.get(String(id));\n if (!r?.dated || r.pinned) return;\n if (e.pointerType !== \"mouse\" && modeName === \"move\" && !r.summary && !r.milestone) return;\n e.stopPropagation();\n const together = modeName === \"move\" && picked.size > 1 && picked.has(String(id)) ? [...picked].map(String) : null;\n dragRef.current = { id: String(id), mode: modeName, x0: e.clientX, start: shown.get(String(id))?.start, delta: 0, moved: false, pointerId: e.pointerId, together };\n try {\n // Keep an ordinary click/double-click on its bar; capture on the track\n // retargeted that click and skipped the bar's selection/open callbacks.\n e.currentTarget?.setPointerCapture(e.pointerId);\n } catch {\n // the pointer is gone already\n }\n }\n\n function movesOfDrag(d, delta) {\n if (!d.together) return movesFor(d, delta);\n return selectionMoves(d.together, (id) => {\n const r = plan.rows.get(id);\n if (!r?.dated || r.pinned) return [];\n return movesFor({ id, mode: \"move\", start: shown.get(id)?.start ?? r.start }, delta);\n });\n }\n\n function onTrackMove(e) {\n if (linkRef.current) return moveLink(e);\n const d = dragRef.current;\n if (!d || e.pointerId !== d.pointerId) return;\n const dx = e.clientX - d.x0;\n if (!d.moved && Math.abs(dx) < 4) return;\n d.moved = true;\n const delta = Math.round(dx / ppd);\n if (delta === d.delta && preview) return;\n d.delta = delta;\n const p = previewFor(movesOfDrag(d, delta));\n p.info = describe(p, d.id);\n d.preview = p;\n setPreview(p);\n onPreview?.({ mode: d.mode === \"end\" ? \"stretch\" : \"move\", task: byId.get(d.id), ...p.info });\n }\n\n async function onTrackUp(e) {\n if (linkRef.current) return finishLink(e);\n const d = dragRef.current;\n if (!d || e.pointerId !== d.pointerId) return;\n dragRef.current = null;\n if (!d.moved) return;\n suppress.current = true;\n setTimeout(() => {\n suppress.current = false;\n }, 0);\n const p = d.preview;\n setPreview(null);\n onPreview?.(null);\n if (!p || d.delta === 0) return;\n const res = await onMove?.(p.moves, p);\n if (res && res.ok === false) setNote(res.error ?? \"That move was not saved.\");\n }\n\n function cancelAll() {\n dragRef.current = null;\n linkRef.current = null;\n setPreview(null);\n setLinking(null);\n onPreview?.(null);\n }\n\n // ---- drawing links ----------------------------------------------------------\n function trackPoint(e) {\n const rect = track.current?.getBoundingClientRect();\n return rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : { x: 0, y: 0 };\n }\n\n function startLink(e, id, end) {\n if (!onLink) return;\n e.stopPropagation();\n e.preventDefault();\n const b = boxOf(id);\n if (!b) return;\n const from = { x: end === \"start\" ? b.x0 : b.x1, y: b.y };\n const l = { fromId: String(id), fromEnd: end, from, to: from, targetId: null, targetEnd: null, problem: null, pointerId: e.pointerId, preview: null };\n linkRef.current = l;\n setLinking(l);\n try {\n track.current?.setPointerCapture(e.pointerId);\n } catch {\n // the pointer is gone already\n }\n }\n\n function moveLink(e) {\n const cur = linkRef.current;\n if (!cur || e.pointerId !== cur.pointerId) return;\n const pt = trackPoint(e);\n const entry = visible[rowAt(pt.y)];\n let targetId = null;\n let targetEnd = null;\n let problem = null;\n let p = null;\n if (entry && entry.id !== cur.fromId) {\n const b = boxOf(entry.id);\n if (b && pt.x >= b.x0 - 12 && pt.x <= b.x1 + 12) {\n targetId = entry.id;\n targetEnd = b.milestone ? \"start\" : pt.x < (b.x0 + b.x1) / 2 ? \"start\" : \"finish\";\n problem = linkProblem(tasks, links, targetId, cur.fromId);\n if (!problem) {\n p = previewFor([], { nextLinks: [...links, { id: \"__new\", task_id: targetId, depends_on_id: cur.fromId, kind: KIND_OF[`${cur.fromEnd}:${targetEnd}`], lag_days: 0 }] });\n p.info = describe(p, targetId);\n }\n }\n }\n linkRef.current = { ...cur, to: pt, targetId, targetEnd, problem, preview: p };\n setLinking(linkRef.current);\n setPreview(p);\n onPreview?.(p ? { mode: \"link\", task: byId.get(targetId), ...p.info } : null);\n }\n\n async function finishLink(e) {\n const l = linkRef.current;\n if (!l || e.pointerId !== l.pointerId) return;\n linkRef.current = null;\n setLinking(null);\n const p = l.preview;\n setPreview(null);\n onPreview?.(null);\n if (!l.targetId) return;\n if (l.problem) return setNote(l.problem);\n const res = await onLink?.({ taskId: l.targetId, dependsOnId: l.fromId, kind: KIND_OF[`${l.fromEnd}:${l.targetEnd}`] }, p);\n if (res && res.ok === false) setNote(res.error ?? \"That link was not saved.\");\n }\n\n useEffect(() => {\n if (!preview && !linking) return undefined;\n const onKey = (e) => {\n if (e.key === \"Escape\") cancelAll();\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [preview, linking]); // eslint-disable-line react-hooks/exhaustive-deps\n\n // ---- keyboard on the chart ----------------------------------------------------\n function onKeyDown(e) {\n if (editing || e.target?.tagName === \"INPUT\") return;\n const i = selected ? rowOf.get(selected) : null;\n const task = selected ? byId.get(selected) : null;\n if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n e.preventDefault();\n const next = i == null ? 0 : Math.max(0, Math.min(visible.length - 1, i + (e.key === \"ArrowDown\" ? 1 : -1)));\n if (visible[next]) onSelect?.(visible[next].id);\n return;\n }\n if (!task) return;\n if (e.altKey && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") && onMove && !e.shiftKey) {\n e.preventDefault();\n const r = plan.rows.get(String(task.id));\n if (!r?.dated || r.pinned) return;\n const step = e.key === \"ArrowRight\" ? 1 : -1;\n const from = dayOf(r.milestone ? r.due : r.start);\n const to = CAL.dayAt((r.milestone ? CAL.lastIndex(from) : CAL.startIndex(from)) + step);\n const p = previewFor(movesFor({ id: String(task.id), mode: \"move\", start: r.start }, to - from));\n onMove(p.moves, p).then((res) => res && res.ok === false && setNote(res.error ?? \"That move was not saved.\"));\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n onOpen?.(task);\n } else if (e.key === \"F2\") {\n e.preventDefault();\n setEditing({ id: String(task.id), field: \"title\" });\n } else if (e.key === \"Escape\") onSelect?.(null);\n }\n\n async function commitEdit(task, field, value) {\n setEditing(null);\n const res = await onEdit?.(task, field, value);\n if (res && res.ok === false) setNote(res.error ?? \"That change was not saved.\");\n }\n\n async function submitDraft(milestoneKey = false) {\n const title = draft.title.trim();\n if (!title || !onAdd || busy) return;\n setBusy(true);\n const milestone = draft.milestone || milestoneKey;\n const res = await onAdd({ title, days: Number(draft.days) || null, milestone, parentId: draft.parentId, afterId: draft.afterId });\n setBusy(false);\n if (res && res.ok === false) setNote(res.error ?? \"That task was not added.\");\n else setDraft((d) => ({ ...d, title: \"\", days: \"\", milestone: false, afterId: res?.id ? String(res.id) : d.afterId }));\n addRef.current?.focus();\n }\n\n // ---- the sheet's divider --------------------------------------------------------\n function startDivider(e) {\n e.preventDefault();\n const x0 = e.clientX;\n const w0 = gridW;\n let last = w0;\n const move = (ev) => {\n last = Math.max(fixedWidth(cols) + 140, Math.min(760, w0 + ev.clientX - x0));\n setGridState(last);\n };\n const up = () => {\n window.removeEventListener(\"pointermove\", move);\n window.removeEventListener(\"pointerup\", up);\n onGridWidth?.(last);\n };\n window.addEventListener(\"pointermove\", move);\n window.addEventListener(\"pointerup\", up);\n }\n const divider = narrow ? null : (\n <div\n role=\"separator\"\n aria-orientation=\"vertical\"\n aria-label=\"Resize the task sheet\"\n aria-valuenow={gridW}\n tabIndex={0}\n onPointerDown={startDivider}\n onKeyDown={(e) => {\n if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") {\n e.preventDefault();\n setGridState(Math.max(fixedWidth(cols) + 140, Math.min(760, gridW + (e.key === \"ArrowRight\" ? 16 : -16))));\n }\n }}\n className=\"absolute inset-y-0 -right-[3px] z-50 w-[6px] cursor-col-resize outline-none hover:bg-ring/30 focus-visible:bg-ring/40\"\n />\n );\n\n // ---- render -------------------------------------------------------------------\n const finish = preview?.finishAfter ?? plan.finish;\n const gap = gapOf(finish, target);\n const marks = {\n today: showToday && inRange(today) ? X(today) + ppd / 2 : null,\n target: target && inRange(target) ? X(target) + ppd : null,\n targetIso: target,\n finish: finish && inRange(finish) ? X(finish) + ppd : null,\n finishFrom: preview && plan.finish && inRange(plan.finish) ? X(plan.finish) + ppd : null,\n moving: Boolean(preview && !linking),\n gap,\n };\n const bodyH = Math.max(visible.length, 1) * rowH + (addIndex != null ? ADD_H : 0);\n const addAfter = addIndex != null && addIndex < visible.length - 1 ? addIndex : null;\n const addParent = draft.parentId ? byId.get(String(draft.parentId)) : null;\n const ghostMap = preview?.from?.size ? preview.from : ghosts;\n const dragInfo = preview?.info ?? null;\n const laneH = 20;\n const laneHead = 28;\n const hasLanes = Boolean(lanes?.length);\n const lanesH = hasLanes ? laneHead + lanes.length * laneH : 0;\n const rowTops = visible.map((_, i) => rowTop(i));\n const selectedRows = new Set([...(selected ? [selected] : []), ...picked].map((id) => rowOf.get(String(id))).filter((i) => i != null));\n const addAt = addIndex != null && addIndex < visible.length ? rowTop(addIndex) + rowH : bodyH;\n\n const addRow = onAdd ? (\n <AddRow\n open={adding}\n onOpen={() => {\n setAdding(true);\n setDraft({ title: \"\", days: \"\", milestone: false, parentId: null, afterId: null });\n setTimeout(() => addRef.current?.focus(), 20);\n }}\n draft={draft}\n setDraft={setDraft}\n onSubmit={submitDraft}\n busy={busy}\n addRef={addRef}\n parentTitle={addParent?.title}\n aboveTitle={draft.afterId ? byId.get(draft.afterId)?.title : null}\n onIndent={() => setDraft((d) => ({ ...d, parentId: d.afterId, afterId: null }))}\n height={rowH}\n />\n ) : null;\n const addExtra = onAdd && !adding ? rowH : 0;\n\n const rootStyle = height === \"fill\" ? undefined : { height };\n\n return (\n <div className={cx(\"flex min-h-0 flex-col\", height === \"fill\" && \"h-full\", className)} style={rootStyle}>\n {error ? (\n <p role=\"alert\" className=\"shrink-0 border-b border-destructive px-3 py-2 text-[13px] text-destructive\">\n {typeof error === \"string\" ? error : \"The plan could not be read.\"}\n </p>\n ) : null}\n {note ? (\n <p role=\"alert\" className=\"flex shrink-0 items-start justify-between gap-3 border-b border-border bg-card px-3 py-2 text-[13px]\">\n <span>{note}</span>\n <button type=\"button\" onClick={() => setNote(null)} className=\"shrink-0 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\" aria-label=\"Dismiss the message\">\n <Icon name=\"x\" size={16} />\n </button>\n </p>\n ) : null}\n <div\n ref={scroller}\n role=\"region\"\n aria-label={label}\n aria-busy={loading || undefined}\n tabIndex={0}\n onKeyDown={onKeyDown}\n className=\"relative min-h-0 flex-1 overflow-auto bg-card outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\"\n >\n <div className=\"relative\" style={{ width: gridW + trackW, minHeight: HEAD + bodyH + addExtra + lanesH }}>\n <div className=\"sticky top-0 z-30 flex border-b border-border bg-background\" style={{ height: HEAD }}>\n <div className=\"sticky left-0 z-40 shrink-0 border-r border-border bg-background\" style={{ width: gridW }}>\n <SheetHead cols={cols} />\n {divider}\n </div>\n <div className=\"relative\" style={{ width: trackW }}>\n <TrackHead axis={axis} range={range} ppd={ppd} z={z} gridW={gridW} marks={marks} />\n </div>\n </div>\n\n <div className=\"flex\">\n <div className=\"sticky left-0 z-20 shrink-0 border-r border-border bg-card\" style={{ width: gridW }}>\n {loading\n ? Array.from({ length: 8 }, (_, i) => (\n <div key={i} className=\"flex items-center gap-2 border-b border-border px-3\" style={{ height: rowH }}>\n <span className=\"h-2.5 w-8 animate-pulse rounded-[2px] bg-muted\" />\n <span className=\"h-2.5 flex-1 animate-pulse rounded-[2px] bg-muted\" style={{ maxWidth: `${40 + ((i * 17) % 50)}%` }} />\n </div>\n ))\n : visible.map((e, i) => {\n const t = byId.get(e.id);\n const r = live.rows.get(e.id);\n const d = shown.get(e.id);\n const late = r && !r.summary && !r.pinned && r.dated && !r.milestone && d?.due && d.due < today && r.progress < 100;\n const lateMs = r?.milestone && !r.pinned && d?.due && d.due < today;\n return (\n <div key={e.id}>\n <SheetRow\n e={e}\n t={t}\n r={r}\n cols={cols}\n rowH={rowH}\n critical={critical}\n selected={selected === e.id || picked.has(e.id)}\n closed={closed.has(e.id)}\n cells={cellsOf(e.id)}\n foldedCount={r?.summary ? `${r.leafIds.length} ${r.leafIds.length === 1 ? \"task\" : \"tasks\"}` : null}\n tags={tags?.get?.(e.id)}\n late={late || lateMs}\n dim={highlight && !highlight.has(e.id) && !r?.summary}\n moving={moving}\n editing={editing?.id === e.id ? editing : null}\n onSelect={onSelect}\n onOpen={onOpen}\n onToggle={onToggle}\n onOpenLink={onOpenLink}\n onStartEdit={(field) => {\n onSelect?.(e.id);\n setEditing({ id: e.id, field });\n }}\n onCommit={(field, v) => commitEdit(t, field, v)}\n onCancel={() => setEditing(null)}\n onHover={() => setHovered(e.id)}\n menu={rowMenu && (hovered === e.id || selected === e.id) ? rowMenu(t, { row: e, plan: r }) : null}\n />\n {addAfter === i && adding ? addRow : null}\n </div>\n );\n })}\n {addAfter == null ? addRow : null}\n {hasLanes ? (\n <div>\n <div className=\"flex items-center gap-2 border-b border-t border-border bg-muted px-3 text-[12px] font-semibold\" style={{ height: laneHead }}>\n {lanesTitle}\n </div>\n <LaneLabels lanes={lanes} laneH={laneH} width={gridW - 1} />\n </div>\n ) : null}\n </div>\n\n <div\n ref={track}\n className={cx(\"relative\", linking && \"cursor-crosshair\")}\n style={{ width: trackW, height: bodyH + addExtra + lanesH, touchAction: dragRef.current || linking ? \"none\" : \"pan-x pan-y\" }}\n onPointerMove={onTrackMove}\n onPointerUp={onTrackUp}\n onPointerCancel={cancelAll}\n >\n <Paper axis={axis} range={range} ppd={ppd} z={z} marks={marks} rowTops={rowTops} rowH={rowH} selectedRows={selectedRows} />\n <Links lines={lines} width={trackW} height={bodyH} critical={critical} onOpenLink={onOpenLink} />\n <Ghosts ghosts={ghostMap} visible={visible} X={X} ppd={ppd} rowH={rowH} rowTop={rowTop} live={live} kind=\"was\" bar={BAR} />\n <Ghosts ghosts={pencil} visible={visible} X={X} ppd={ppd} rowH={rowH} rowTop={rowTop} live={live} kind=\"pencil\" bar={BAR} />\n {addIndex != null && !draft.milestone ? (\n <span aria-hidden=\"true\" className=\"absolute h-3.5 rounded-[2px] border border-dashed border-info bg-info/15\" style={{ left: today ? X(today) : 0, width: Math.max(1, Number(draft.days) || 3) * ppd * 1.4, top: rowTop(addIndex) + rowH + (ADD_H - 14) / 2 }} />\n ) : null}\n\n {visible.map((e, i) => {\n const t = byId.get(e.id);\n const r = live.rows.get(e.id);\n const d = shown.get(e.id);\n const y = rowTop(i) + rowH / 2;\n const n = notes?.get?.(e.id);\n if (!r || !d) {\n return n ? (\n <span key={e.id} className={cx(\"absolute -translate-y-1/2 whitespace-nowrap text-[12px]\", n.tone === \"problem\" ? \"font-medium text-destructive\" : \"text-muted-foreground\")} style={{ left: 12, top: y }}>\n {n.text}\n </span>\n ) : null;\n }\n const isSel = selected === e.id || picked.has(e.id);\n const dim = highlight && !highlight.has(e.id) && !r.summary;\n const done = r.pinned;\n const late = !r.summary && !done && !r.milestone && d.due < today && r.progress < 100;\n const title = barLabel(t, r);\n const common = {\n tabIndex: -1,\n onClick: (ev) => {\n ev.stopPropagation();\n if (suppress.current) return;\n onSelect?.(e.id, { additive: ev.metaKey || ev.ctrlKey || ev.shiftKey });\n },\n onDoubleClick: () => onOpen?.(t),\n title,\n \"aria-label\": title,\n };\n const crit = critical && r.critical && !done;\n const labelEl = (x, extra = 0) => {\n const person = people?.get?.(String(t.assignee_id ?? \"\"));\n const who = firstName(person?.name ?? t.assignee_name);\n const text = n?.text ?? who;\n return text || late ? (\n <span className=\"pointer-events-none absolute top-1/2 flex -translate-y-1/2 items-center gap-1.5 whitespace-nowrap text-[11px]\" style={{ left: x + 6 + extra }}>\n {late ? <span className=\"rounded-[3px] border border-warning px-1 text-[10px] font-semibold leading-4 text-warning\">Late</span> : null}\n {text ? <span className={n?.tone === \"problem\" ? \"font-medium text-destructive\" : n?.tone === \"crit\" ? \"font-medium text-destructive\" : \"text-muted-foreground\"}>{text}</span> : null}\n </span>\n ) : null;\n };\n const handles =\n onLink && !r.summary && !done ? (\n <>\n <button type=\"button\" tabIndex={-1} aria-label={`Link from the start of ${t.title}`} onPointerDown={(ev) => startLink(ev, e.id, \"start\")} className={cx(\"absolute top-1/2 z-20 size-2 -translate-y-1/2 rounded-full border-2 border-ring bg-background opacity-0 outline-none group-hover:opacity-100\", isSel && \"opacity-100\")} style={{ left: -11, touchAction: \"none\" }} />\n <button type=\"button\" tabIndex={-1} aria-label={`Link from the end of ${t.title}`} onPointerDown={(ev) => startLink(ev, e.id, \"finish\")} className={cx(\"absolute top-1/2 z-20 size-2 -translate-y-1/2 rounded-full border-2 border-ring bg-background opacity-0 outline-none group-hover:opacity-100\", isSel && \"opacity-100\")} style={{ right: -11, touchAction: \"none\" }} />\n </>\n ) : null;\n const target = linking?.targetId === e.id;\n\n if (r.milestone) {\n const cx0 = X(d.due) + ppd / 2;\n return (\n <div key={e.id} className={cx(\"group absolute z-10\", dim && \"opacity-40\")} style={{ left: cx0 - 9, top: y - 9, width: 18, height: 18 }}>\n <button type=\"button\" {...common} onPointerDown={(ev) => startDrag(ev, e.id, \"move\")} className={cx(\"absolute inset-0 grid place-items-center rounded-[2px] outline-none focus-visible:ring-2 focus-visible:ring-ring\", onMove && !done && \"cursor-grab\", target && (linking.problem ? \"ring-2 ring-destructive\" : \"ring-2 ring-ring\"))}>\n <span className={cx(\"block size-3 rotate-45\", done ? \"bg-chart-2\" : crit ? \"bg-destructive\" : \"bg-foreground\", isSel && \"ring-2 ring-ring ring-offset-1 ring-offset-card\")} />\n </button>\n {handles}\n <span className={cx(NUM, \"pointer-events-none absolute left-full top-1/2 ml-2 -translate-y-1/2 whitespace-nowrap text-[11px]\", crit ? \"font-semibold text-destructive\" : \"text-muted-foreground\")}>{n?.text ?? dayWords(d.due)}</span>\n </div>\n );\n }\n\n const x0 = X(d.start);\n const w = Math.max(ppd, X(d.due) + ppd - x0);\n if (r.summary) {\n const folded = closed.has(e.id);\n const kids = folded ? r.leafIds.map((id) => ({ id, row: live.rows.get(id), d: shown.get(id) })).filter((k) => k.row?.milestone && k.d) : [];\n return (\n <div key={e.id} className=\"group absolute z-10\" style={{ left: x0, top: y - 4, width: w, height: 12 }}>\n <button type=\"button\" {...common} onPointerDown={(ev) => startDrag(ev, e.id, \"move\")} className={cx(\"absolute inset-x-0 top-0 h-[6px] outline-none focus-visible:ring-2 focus-visible:ring-ring\", done ? \"bg-chart-2\" : \"bg-foreground\", onMove && !done && \"cursor-grab\")}>\n <span className={cx(\"absolute left-0 top-[6px] size-0 border-r-[5px] border-t-[5px] border-r-transparent\", done ? \"border-t-chart-2\" : \"border-t-foreground\")} />\n <span className={cx(\"absolute right-0 top-[6px] size-0 border-l-[5px] border-t-[5px] border-l-transparent\", done ? \"border-t-chart-2\" : \"border-t-foreground\")} />\n </button>\n {kids.map((k) => (\n <span key={k.id} title={byId.get(k.id)?.title} className={cx(\"absolute -top-[10px] size-[10px] -translate-x-1/2 rotate-45 border-[1.5px] border-card\", k.row.pinned ? \"bg-chart-2\" : critical && k.row.critical ? \"bg-destructive\" : \"bg-foreground\")} style={{ left: X(k.d.due) + ppd / 2 - x0 }} />\n ))}\n {n ? (\n <span className=\"pointer-events-none absolute -top-[4px] whitespace-nowrap text-[11px] text-muted-foreground\" style={{ left: w + 8 }}>\n {n.text}\n </span>\n ) : null}\n </div>\n );\n }\n const shortBar = w <= 16;\n const loop = r.stuck;\n const guess = guesses?.has?.(e.id);\n const barTop = y - BAR / 2 - (baseline ? 2 : 0);\n const base = baseline?.get?.(e.id);\n const dragging = preview && dragRef.current?.id === e.id;\n const movedByDrag = preview?.dates?.has(e.id);\n const trackClass = loop ? \"border border-dashed border-destructive bg-destructive/15\" : guess ? \"border border-dashed border-chart-2 bg-transparent\" : crit ? \"bg-destructive\" : done ? \"bg-chart-2\" : \"bg-chart-1\";\n const fill = crit ? \"bg-foreground/35\" : \"bg-chart-2\";\n const mark = tags?.get?.(e.id)?.some((g) => g.kind === \"unsaved\");\n return (\n <div key={e.id} className={cx(\"group absolute z-10\", dim && \"opacity-40\")} style={{ left: x0, top: barTop, width: w, height: BAR }}>\n <button\n type=\"button\"\n {...common}\n onPointerDown={(ev) => startDrag(ev, e.id, \"move\")}\n className={cx(\n \"absolute inset-0 rounded-[2px] outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n trackClass,\n isSel && \"ring-2 ring-ring\",\n (dragging || movedByDrag) && !isSel && \"ring-[1.5px] ring-ring\",\n mark && \"ring-2 ring-destructive\",\n target && (linking.problem ? \"ring-2 ring-destructive\" : \"ring-2 ring-ring\"),\n onMove && !done && \"cursor-grab\",\n )}\n >\n <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[2px]\">\n {loop ? <span className=\"absolute inset-0 opacity-40\" style={{ backgroundImage: \"repeating-linear-gradient(135deg, currentColor 0 2px, transparent 2px 6px)\" }} /> : null}\n {!loop && !guess && !done && r.progress > 0 ? <span className={cx(\"absolute inset-y-0 left-0\", fill)} style={{ width: `${r.progress}%` }} /> : null}\n </span>\n {/* A press on an end is still a press on this task. With no\n drag, its click/open callbacks must survive clipping. */}\n {onMove && !done ? <>\n <span onPointerDown={(ev) => startDrag(ev, e.id, \"start\")} aria-hidden=\"true\" className={cx(\"absolute z-10 cursor-ew-resize\", shortBar ? (dragging || isSel ? \"-inset-y-[2px] -left-1.5 w-1.5 rounded-[2px] bg-ring\" : \"inset-y-0 -left-1.5 w-1.5\") : dragging || isSel ? \"-inset-y-[2px] -left-[2px] w-1.5 rounded-[2px] bg-ring\" : \"inset-y-0 left-0 w-2\")} style={{ touchAction: \"none\" }} />\n <span onPointerDown={(ev) => startDrag(ev, e.id, \"end\")} aria-hidden=\"true\" className={cx(\"absolute z-10 cursor-ew-resize\", shortBar ? (dragging || isSel ? \"-inset-y-[2px] -right-1.5 w-1.5 rounded-[2px] bg-ring\" : \"inset-y-0 -right-1.5 w-1.5\") : dragging || isSel ? \"-inset-y-[2px] -right-[2px] w-1.5 rounded-[2px] bg-ring\" : \"inset-y-0 right-0 w-2\")} style={{ touchAction: \"none\" }} />\n </> : null}\n </button>\n {base ? <span aria-hidden=\"true\" className=\"pointer-events-none absolute h-1 rounded-[1px] bg-chart-3\" style={{ left: X(base.start) - x0, width: Math.max(ppd, X(base.due) + ppd - X(base.start)), top: BAR + 2 }} /> : null}\n {handles}\n {labelEl(w, isSel ? 8 : 0)}\n </div>\n );\n })}\n\n {dragInfo && dragRef.current ? <DragTip info={dragInfo} task={byId.get(dragInfo.id)} box={boxOf(dragInfo.id)} tasks={tasks} plan={plan} /> : null}\n\n {linking ? (\n (() => {\n const dx = linking.to.x - linking.from.x;\n const dy = linking.to.y - linking.from.y;\n const lines2 = linking.targetId\n ? linkLines({ task: byId.get(linking.targetId), predecessor: byId.get(linking.fromId), kind: KIND_OF[`${linking.fromEnd}:${linking.targetEnd}`], info: linking.preview?.info, problem: linking.problem })\n : null;\n return (\n <>\n <span aria-hidden=\"true\" className={cx(\"pointer-events-none absolute z-30 h-0 origin-left border-t-[1.5px] border-dashed\", linking.problem ? \"border-destructive\" : \"border-ring\")} style={{ left: linking.from.x, top: linking.from.y, width: Math.sqrt(dx * dx + dy * dy), transform: `rotate(${(Math.atan2(dy, dx) * 180) / Math.PI}deg)` }} />\n {lines2 ? (\n <div role=\"status\" className=\"pointer-events-none absolute z-40 max-w-80 rounded-[4px] border border-border bg-popover px-2.5 py-2 text-[12px] leading-snug text-popover-foreground shadow-lg\" style={{ left: linking.to.x + 12, top: linking.to.y + 10 }}>\n <div className={lines2.tone === \"problem\" ? \"font-medium text-destructive\" : undefined}>{lines2.text}</div>\n {lines2.tail ? <div className={lines2.tone === \"late\" ? \"font-semibold text-warning\" : \"text-muted-foreground\"}>{lines2.tail}</div> : null}\n </div>\n ) : null}\n </>\n );\n })()\n ) : null}\n\n {visible.length === 0 && !loading ? (\n <div className=\"absolute inset-x-0 top-6 grid place-items-center px-6 text-center\">\n <p className=\"max-w-sm text-[13px] text-muted-foreground\">{emptyText}</p>\n </div>\n ) : null}\n\n {hasLanes ? <LaneTrack lanes={lanes} X={X} ppd={ppd} laneH={laneH} top={bodyH + addExtra + laneHead} /> : null}\n </div>\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n/** The note by a bar while it moves: what it is, where it ends, what it costs, in the canvas's words. */\nfunction DragTip({ info, task, box, tasks, plan }) {\n if (!box || !task) return null;\n const next = (() => {\n const l = [...plan.links.values()].find((x) => x.valid && x.dependsOnId === info.id);\n return l ? tasks.find((t) => String(t.id) === l.taskId)?.title : null;\n })();\n const lines = dragLines(info, task, { nextTitle: next });\n return (\n <div role=\"status\" className=\"pointer-events-none absolute z-40 grid w-max max-w-80 gap-0.5 rounded-[4px] border border-border bg-popover px-2.5 py-2 text-[12px] leading-snug text-popover-foreground shadow-lg\" style={{ left: box.x1 + 14, top: Math.max(4, box.y - 64) }}>\n <span className=\"text-[13px] font-semibold\">{lines.title}</span>\n <span className={NUM}>{lines.first}</span>\n <span>{lines.second}</span>\n {lines.slack ? <span className=\"text-muted-foreground\">{lines.slack}</span> : null}\n <span className={cx(NUM, lines.third.tone === \"late\" ? \"font-semibold text-warning\" : lines.third.tone === \"early\" ? \"font-semibold text-success\" : \"text-muted-foreground\")}>{lines.third.text}</span>\n <span className=\"text-muted-foreground\">{lines.last}</span>\n </div>\n );\n}\n\n"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"path": "registry/blocks/schedule-gantt/sheet.jsx",
|
|
27
27
|
"type": "registry:component",
|
|
28
28
|
"target": "@components/makersclaw/blocks/schedule-gantt/sheet.jsx",
|
|
29
|
-
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Icon, Menu, cx } from \"../../ui\";\nimport { monthDay } from \"./gantt-model\";\n\n// The task sheet on the chart's left, one row for one row of bars: the\n// outline number, the task (a phase folds, a milestone is a diamond, its\n// tags in words), and whichever columns the page asked for: start, finish,\n// length in working days, how much is done, the owner, what it waits on,\n// slack, and how far it sits from a baseline. A row's \"…\" holds every\n// change; double-click a name, a length, a % or a \"waits on\" to type it.\n// The last row adds a task in place.\n\nexport const ROW = 30;\n\nconst NUM = \"tabular-nums\";\n\nfunction Chevron({ open }) {\n return <Icon name={open ? \"chevron-down\" : \"chevron-right\"} size={14} />;\n}\n\nconst TAG = {\n late: \"border border-warning text-warning\",\n crit: \"border border-destructive text-destructive\",\n pole: \"border border-destructive text-destructive\",\n unsaved: \"border border-destructive text-destructive\",\n blue: \"border border-info text-info\",\n ink: \"bg-foreground text-background\",\n quiet: \"text-muted-foreground\",\n};\n\n/** A tag in a row: words in a small outlined pill; the agent's is a filled badge with its reason on hover. */\nexport function TagChip({ tag }) {\n if (tag.kind === \"agent\")\n return (\n <span title={tag.title ?? tag.label ?? \"The agent did this\"} className=\"inline-flex size-[14px] shrink-0 items-center justify-center rounded-[3px] bg-info text-background\">\n <Icon name=\"sparkles\" size={9} label={tag.title ?? \"The agent did this\"} />\n </span>\n );\n if (tag.kind === \"dot\") return <span title={tag.title ?? tag.label} aria-label={tag.title ?? tag.label} className=\"size-1.5 shrink-0 rounded-full bg-warning\" />;\n return <span className={cx(\"inline-flex h-4 shrink-0 items-center whitespace-nowrap rounded-[3px] px-1 text-[10px] font-semibold\", tag.kind === \"quiet\" ? \"font-normal\" : \"\", TAG[tag.kind] ?? TAG.quiet)}>{tag.label}</span>;\n}\n\n/** A dense cell editor: a 30 px row needs its own. */\nexport function CellInput({ type = \"text\", value, onCommit, onCancel, label, align = \"left\", min, max, className, placeholder, quiet = false, autoFocus = true }) {\n const ref = useRef(null);\n const [v, setV] = useState(value ?? \"\");\n const settled = useRef(false);\n useEffect(() => {\n if (!autoFocus) return;\n ref.current?.focus();\n ref.current?.select?.();\n }, []); // eslint-disable-line react-hooks/exhaustive-deps\n const commit = () => {\n if (settled.current) return;\n settled.current = true;\n onCommit(v);\n };\n return (\n <input\n ref={ref}\n type={type}\n aria-label={label}\n value={v}\n min={min}\n max={max}\n placeholder={placeholder}\n onChange={(e) => setV(e.target.value)}\n onBlur={commit}\n onKeyDown={(e) => {\n e.stopPropagation();\n if (e.key === \"Enter\") {\n e.preventDefault();\n commit();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n settled.current = true;\n onCancel();\n }\n }}\n className={cx(\"h-6 w-full min-w-0 rounded-[3px] bg-background px-1.5 text-[12px] text-foreground outline-none\", quiet ? \"border border-border focus-visible:border-ring\" : \"border border-ring ring-1 ring-ring\", align === \"right\" && \"text-right\", type !== \"text\" && NUM, className)}\n />\n );\n}\n\nexport function SheetHead({ cols }) {\n return (\n <div className=\"flex h-full items-end px-0 pb-1.5\">\n {cols.map((c) => (\n <div key={c.key} className={cx(\"shrink-0 truncate px-1.5 text-[11px] font-semibold text-muted-foreground\", c.key === \"title\" && \"min-w-0 flex-1\", c.right && \"text-right\")} style={c.key === \"title\" ? undefined : { width: c.w }}>\n {c.label}\n </div>\n ))}\n </div>\n );\n}\n\nconst tone = { late: \"text-warning\", early: \"text-success\", same: \"text-muted-foreground\", new: \"text-info\" };\n\n/**\n * One row of the sheet. `r` is the engine's row; `cells` carries what the\n * page worked out (owner words, the waits-on text, the baseline delta).\n * `editing` { field } when a cell is open.\n */\nexport function SheetRow({ e, t, r, cols, selected, closed, cells, tags, late, editing, rowH, critical, onSelect, onOpen, onToggle, onStartEdit, onCommit, onCancel, onOpenLink, menu, dim, onHover, foldedCount, moving }) {\n const summary = r?.summary;\n const done = r?.pinned;\n const crit = critical && r?.critical && !done;\n const editableLength = !summary && !r?.milestone && !done;\n return (\n <div\n data-row={e.id}\n onMouseEnter={onHover}\n onClick={(ev) => onSelect?.(e.id, { additive: ev.metaKey || ev.ctrlKey || ev.shiftKey })}\n onDoubleClick={() => onOpen?.(t)}\n className={cx(\"group/row relative flex items-center border-b border-border text-[13px]\", selected ? \"bg-accent\" : \"hover:bg-muted/50\", dim && \"opacity-50\")}\n style={{ height: rowH }}\n >\n {cols.map((c) => {\n if (c.key === \"outline\")\n return (\n <div key=\"outline\" className={cx(NUM, \"shrink-0 truncate px-1.5 text-[12px]\", crit ? \"text-destructive\" : \"text-muted-foreground\")} style={{ width: c.w }}>\n {e.outline}\n </div>\n );\n if (c.key === \"title\") {\n const isEditing = editing?.field === \"title\";\n return (\n <div key=\"title\" className=\"flex min-w-0 flex-1 items-center gap-1 px-1.5\" style={{ paddingLeft: 6 + e.depth * 14 }}>\n {summary ? (\n <button\n type=\"button\"\n aria-label={closed ? `Open ${t.title}` : `Fold ${t.title}`}\n aria-expanded={!closed}\n onClick={(ev) => {\n ev.stopPropagation();\n onToggle?.(e.id);\n }}\n className=\"grid size-5 shrink-0 place-items-center rounded-[3px] text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Chevron open={!closed} />\n </button>\n ) : r?.milestone ? (\n <span className=\"grid size-5 shrink-0 place-items-center\">\n <span aria-hidden=\"true\" className={cx(\"size-[9px] rotate-45 bg-foreground\", crit && \"bg-destructive\")} />\n </span>\n ) : (\n <span className=\"size-5 shrink-0\" aria-hidden=\"true\" />\n )}\n {isEditing ? (\n <CellInput value={t.title} label={`Name of ${t.title}`} onCancel={onCancel} onCommit={(v) => onCommit(\"title\", v)} />\n ) : (\n <span\n onDoubleClick={(ev) => {\n ev.stopPropagation();\n onStartEdit?.(\"title\");\n }}\n title={t.title}\n className={cx(\"min-w-0 truncate\", summary ? \"font-semibold\" : done ? \"text-muted-foreground\" : \"text-foreground\")}\n >\n {t.title}\n </span>\n )}\n {closed && summary && foldedCount ? <span className=\"shrink-0 text-[11px] text-muted-foreground\">{foldedCount}</span> : null}\n {late ? <TagChip tag={{ label: \"Late\", kind: \"late\" }} /> : null}\n {(tags ?? []).map((g, i) => (\n <TagChip key={`${g.kind}${g.label ?? i}`} tag={g} />\n ))}\n {menu ? (\n <span className={cx(\"ml-auto shrink-0 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100\", selected && \"opacity-100\")} onClickCapture={() => !selected && onSelect?.(e.id)} onClick={(ev) => ev.stopPropagation()}>\n <Menu label={`${t.title}: change it`} items={menu} />\n </span>\n ) : null}\n </div>\n );\n }\n if (c.key === \"start\" || c.key === \"due\") {\n const iso = c.key === \"start\" ? r?.start : r?.due;\n const shown = c.key === \"due\" && r?.milestone ? \"\" : iso ? monthDay(iso) : null;\n return (\n <div key={c.key} className={cx(NUM, \"shrink-0 truncate px-1.5 text-[12px]\", moving?.has?.(e.id) && \"text-ring\")} style={{ width: c.w }}>\n {shown === null ? <span className=\"text-muted-foreground\">no date</span> : shown}\n </div>\n );\n }\n if (c.key === \"who\") {\n const o = cells?.owner;\n return (\n <div key=\"who\" className=\"shrink-0 truncate px-1.5 text-[12px]\" style={{ width: c.w }} title={o?.title}>\n {summary || r?.milestone ? null : o?.name ? (\n <span className={o.left ? \"text-muted-foreground\" : undefined}>{o.name}{o.left ? \" (left)\" : \"\"}</span>\n ) : (\n <span className=\"text-warning\">no owner</span>\n )}\n </div>\n );\n }\n if (c.key === \"days\") {\n const undated = !r?.dated;\n if (editing?.field === \"days\" || (undated && !summary && editableLength))\n return (\n <div key=\"days\" className=\"shrink-0 px-1\" style={{ width: c.w }} onClick={(ev) => ev.stopPropagation()}>\n <CellInput\n type=\"number\"\n min={1}\n align=\"right\"\n placeholder=\"none\"\n value={cells?.lengthGuess != null ? String(cells.lengthGuess) : undated ? \"\" : String(r?.duration ?? \"\")}\n label={`Length of ${t.title} in working days`}\n onCancel={onCancel}\n onCommit={(v) => (String(v) === String(r?.duration ?? \"\") || v === \"\" ? onCancel() : onCommit(\"days\", v))}\n quiet={undated && editing?.field !== \"days\"}\n autoFocus={!undated || editing?.field === \"days\"}\n />\n </div>\n );\n const text = summary ? (r?.dated && r.duration != null ? `${r.duration} wd` : \"\") : r?.milestone ? \"0 wd\" : r?.duration != null ? `${r.duration} wd${cells?.guessed ? \"?\" : \"\"}` : \"\";\n return (\n <div\n key=\"days\"\n onDoubleClick={(ev) => {\n if (!editableLength) return;\n ev.stopPropagation();\n onStartEdit?.(\"days\");\n }}\n className={cx(NUM, \"shrink-0 truncate px-1.5 text-right text-[12px]\", cells?.guessed ? \"text-info\" : summary || r?.milestone ? \"text-muted-foreground\" : \"text-foreground\")}\n style={{ width: c.w }}\n title={cells?.guessed ? \"A guess from the title\" : undefined}\n >\n {text}\n </div>\n );\n }\n if (c.key === \"done\") {\n if (editing?.field === \"progress\")\n return (\n <div key=\"done\" className=\"shrink-0 px-1\" style={{ width: c.w }} onClick={(ev) => ev.stopPropagation()}>\n <CellInput type=\"number\" min={0} max={100} align=\"right\" value={String(r?.progress ?? 0)} label={`Done, in per cent, of ${t.title}`} onCancel={onCancel} onCommit={(v) => onCommit(\"progress\", v)} />\n </div>\n );\n return (\n <div\n key=\"done\"\n onDoubleClick={(ev) => {\n if (summary || r?.milestone || done) return;\n ev.stopPropagation();\n onStartEdit?.(\"progress\");\n }}\n className={cx(NUM, \"flex shrink-0 justify-end px-1.5 text-[12px] text-muted-foreground\")}\n style={{ width: c.w }}\n >\n {done && !summary ? <Icon name=\"check\" size={14} label=\"Done\" /> : r?.milestone || !r ? \"\" : `${r.progress}%`}\n </div>\n );\n }\n if (c.key === \"waits\") {\n const text = cells?.waits ?? \"\";\n if (editing?.field === \"waits\")\n return (\n <div key=\"waits\" className=\"shrink-0 px-1\" style={{ width: c.w }} onClick={(ev) => ev.stopPropagation()}>\n <CellInput value={text} label={`What ${t.title} waits on`} placeholder=\"2.2FS+2d\" onCancel={onCancel} onCommit={(v) => (v === text ? onCancel() : onCommit(\"waits\", v))} />\n </div>\n );\n return (\n <div\n key=\"waits\"\n onDoubleClick={(ev) => {\n ev.stopPropagation();\n onStartEdit?.(\"waits\");\n }}\n onClick={(ev) => {\n if (cells?.waitsLinks?.length === 1 && onOpenLink && (ev.metaKey || ev.ctrlKey)) {\n ev.stopPropagation();\n onOpenLink(cells.waitsLinks[0]);\n }\n }}\n title={text ? `${text} (double-click to change)` : \"Double-click to say what it waits on\"}\n className={cx(NUM, \"shrink-0 truncate px-1.5 text-[12px]\", cells?.waitsBroken ? \"text-destructive\" : \"text-foreground\")}\n style={{ width: c.w }}\n >\n {text}\n </div>\n );\n }\n if (c.key === \"slack\") {\n const text = cells?.slack ?? \"\";\n return (\n <div key=\"slack\" className={cx(NUM, \"shrink-0 truncate px-1.5 text-right text-[12px]\", crit ? \"font-semibold text-destructive\" : \"text-muted-foreground\")} style={{ width: c.w }}>\n {text}\n </div>\n );\n }\n if (c.key === \"vs\") {\n const d = cells?.delta;\n return (\n <div key=\"vs\" className={cx(NUM, \"shrink-0 truncate px-1.5 text-right text-[12px]\", d?.tone ? tone[d.tone] : \"text-muted-foreground\")} style={{ width: c.w }}>\n {d?.text ?? \"\"}\n </div>\n );\n }\n return null;\n })}\n </div>\n );\n}\n\n/**\n * The row that adds in place: a title and a length. Enter adds a task,\n * Shift+Enter a milestone; Tab makes it a sub-task of the row above; Esc\n * cancels. Closed, it is one quiet line that says so.\n */\nexport function AddRow({ open, onOpen, draft, setDraft, onSubmit, busy, addRef, parentTitle, aboveTitle, onIndent, height = ROW }) {\n if (!open)\n return (\n <button type=\"button\" onClick={onOpen} className=\"flex w-full items-center gap-2 border-b border-border px-3 text-left text-[13px] text-muted-foreground outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\" style={{ height }}>\n <Icon name=\"plus\" size={14} />\n <span>Add task</span>\n <kbd className=\"rounded-[3px] border border-border px-1 text-[10px]\">Enter</kbd>\n <span>milestone</span>\n <kbd className=\"rounded-[3px] border border-border px-1 text-[10px]\">Shift+Enter</kbd>\n </button>\n );\n return (\n <div className=\"border-b border-border bg-accent\">\n <div className=\"flex items-center gap-2 px-3\" style={{ height: height + 6 }}>\n <span className=\"w-9 shrink-0\">\n <Icon name={draft.milestone ? \"diamond\" : \"plus\"} size={14} className=\"text-muted-foreground\" />\n </span>\n <input\n ref={addRef}\n value={draft.title}\n onChange={(ev) => setDraft((d) => ({ ...d, title: ev.target.value }))}\n onKeyDown={(ev) => {\n ev.stopPropagation();\n if (ev.key === \"Enter\") {\n ev.preventDefault();\n onSubmit(ev.shiftKey);\n } else if (ev.key === \"Tab\" && !ev.shiftKey && aboveTitle && !draft.milestone) {\n ev.preventDefault();\n onIndent?.();\n } else if (ev.key === \"Escape\") {\n setDraft({ title: \"\", days: \"\", milestone: false, parentId: null, afterId: null });\n ev.currentTarget.blur();\n }\n }}\n placeholder={parentTitle ? `A sub-task of ${parentTitle}` : draft.milestone ? \"Name the milestone\" : \"Add a task\"}\n aria-label={parentTitle ? `Add a sub-task of ${parentTitle}` : draft.milestone ? \"Milestone name\" : \"Task title\"}\n disabled={busy}\n className=\"h-6 min-w-0 flex-1 rounded-[3px] border border-ring bg-background px-1.5 text-[13px] text-foreground outline-none ring-1 ring-ring placeholder:text-muted-foreground\"\n />\n {!draft.milestone ? (\n <input\n value={draft.days}\n onChange={(ev) => setDraft((d) => ({ ...d, days: ev.target.value.replace(/[^0-9]/g, \"\").slice(0, 3) }))}\n onKeyDown={(ev) => {\n ev.stopPropagation();\n if (ev.key === \"Enter\") {\n ev.preventDefault();\n onSubmit(ev.shiftKey);\n }\n }}\n inputMode=\"numeric\"\n placeholder=\"5\"\n aria-label=\"Length in working days\"\n className={cx(NUM, \"h-6 w-10 shrink-0 rounded-[3px] border border-ring bg-background px-1 text-right text-[13px] outline-none ring-1 ring-ring\")}\n />\n ) : null}\n </div>\n </div>\n );\n}\n"
|
|
29
|
+
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Icon, Menu, cx } from \"../../ui\";\nimport { monthDay } from \"./gantt-model\";\n\n// The task sheet on the chart's left, one row for one row of bars: the\n// outline number, the task (a phase folds, a milestone is a diamond, its\n// tags in words), and whichever columns the page asked for: start, finish,\n// length in working days, how much is done, the owner, what it waits on,\n// slack, and how far it sits from a baseline. A row's \"…\" holds every\n// change; double-click a name, a length, a % or a \"waits on\" to type it.\n// The last row adds a task in place.\n\nexport const ROW = 30;\n\nconst NUM = \"tabular-nums\";\n\nfunction Chevron({ open }) {\n return <Icon name={open ? \"chevron-down\" : \"chevron-right\"} size={14} />;\n}\n\nconst TAG = {\n late: \"border border-warning text-warning\",\n crit: \"border border-destructive text-destructive\",\n pole: \"border border-destructive text-destructive\",\n unsaved: \"border border-destructive text-destructive\",\n blue: \"border border-info text-info\",\n ink: \"bg-foreground text-background\",\n quiet: \"text-muted-foreground\",\n};\n\n/** A tag in a row: words in a small outlined pill; the agent's is a filled badge with its reason on hover. */\nexport function TagChip({ tag }) {\n if (tag.kind === \"agent\")\n return (\n <span title={tag.title ?? tag.label ?? \"The agent did this\"} className=\"inline-flex size-[14px] shrink-0 items-center justify-center rounded-[3px] bg-info text-background\">\n <Icon name=\"sparkles\" size={9} label={tag.title ?? \"The agent did this\"} />\n </span>\n );\n if (tag.kind === \"dot\") return <span title={tag.title ?? tag.label} aria-label={tag.title ?? tag.label} className=\"size-1.5 shrink-0 rounded-full bg-warning\" />;\n return <span className={cx(\"inline-flex h-4 shrink-0 items-center whitespace-nowrap rounded-[3px] px-1 text-[10px] font-semibold\", tag.kind === \"quiet\" ? \"font-normal\" : \"\", TAG[tag.kind] ?? TAG.quiet)}>{tag.label}</span>;\n}\n\n/** A dense cell editor: a 30 px row needs its own. */\nexport function CellInput({ type = \"text\", value, onCommit, onCancel, label, align = \"left\", min, max, className, placeholder, quiet = false, autoFocus = true }) {\n const ref = useRef(null);\n const [v, setV] = useState(value ?? \"\");\n const settled = useRef(false);\n useEffect(() => {\n if (!autoFocus) return;\n ref.current?.focus();\n ref.current?.select?.();\n }, []); // eslint-disable-line react-hooks/exhaustive-deps\n const commit = () => {\n if (settled.current) return;\n settled.current = true;\n onCommit(v);\n };\n return (\n <input\n ref={ref}\n type={type}\n aria-label={label}\n value={v}\n min={min}\n max={max}\n placeholder={placeholder}\n onChange={(e) => setV(e.target.value)}\n onBlur={commit}\n onKeyDown={(e) => {\n e.stopPropagation();\n if (e.key === \"Enter\") {\n e.preventDefault();\n commit();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n settled.current = true;\n onCancel();\n }\n }}\n className={cx(\"h-6 w-full min-w-0 rounded-[3px] bg-background px-1.5 text-[12px] text-foreground outline-none\", quiet ? \"border border-border focus-visible:border-ring\" : \"border border-ring ring-1 ring-ring\", align === \"right\" && \"text-right\", type !== \"text\" && NUM, className)}\n />\n );\n}\n\nexport function SheetHead({ cols }) {\n return (\n <div className=\"flex h-full items-end px-0 pb-1.5\">\n {cols.map((c) => (\n <div key={c.key} className={cx(\"shrink-0 truncate px-1.5 text-[11px] font-semibold text-muted-foreground\", c.key === \"title\" && \"min-w-0 flex-1\", c.right && \"text-right\")} style={c.key === \"title\" ? undefined : { width: c.w }}>\n {c.label}\n </div>\n ))}\n </div>\n );\n}\n\nconst tone = { late: \"text-warning\", early: \"text-success\", same: \"text-muted-foreground\", new: \"text-info\" };\n\n/**\n * One row of the sheet. `r` is the engine's row; `cells` carries what the\n * page worked out (owner words, the waits-on text, the baseline delta).\n * `editing` { field } when a cell is open.\n */\nexport function SheetRow({ e, t, r, cols, selected, closed, cells, tags, late, editing, rowH, critical, onSelect, onOpen, onToggle, onStartEdit, onCommit, onCancel, onOpenLink, menu, dim, onHover, foldedCount, moving }) {\n const summary = r?.summary;\n const done = r?.pinned;\n const crit = critical && r?.critical && !done;\n const editableLength = !summary && !r?.milestone && !done;\n return (\n <div\n data-row={e.id}\n onMouseEnter={onHover}\n onClick={(ev) => onSelect?.(e.id, { additive: ev.metaKey || ev.ctrlKey || ev.shiftKey })}\n onDoubleClick={() => onOpen?.(t)}\n className={cx(\"group/row relative flex items-center border-b border-border text-[13px]\", selected ? \"bg-accent\" : \"hover:bg-muted/50\", dim && \"opacity-50\")}\n style={{ height: rowH }}\n >\n {cols.map((c) => {\n if (c.key === \"outline\")\n return (\n <div key=\"outline\" className={cx(NUM, \"shrink-0 truncate px-1.5 text-[12px]\", crit ? \"text-destructive\" : \"text-muted-foreground\")} style={{ width: c.w }}>\n {e.outline}\n </div>\n );\n if (c.key === \"title\") {\n const isEditing = editing?.field === \"title\";\n return (\n <div key=\"title\" className=\"flex min-w-0 flex-1 items-center gap-1 px-1.5\" style={{ paddingLeft: 6 + e.depth * 14 }}>\n {summary ? (\n <button\n type=\"button\"\n aria-label={closed ? `Open ${t.title}` : `Fold ${t.title}`}\n aria-expanded={!closed}\n onClick={(ev) => {\n ev.stopPropagation();\n onToggle?.(e.id);\n }}\n className=\"grid size-5 shrink-0 place-items-center rounded-[3px] text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Chevron open={!closed} />\n </button>\n ) : r?.milestone ? (\n <span className=\"grid size-5 shrink-0 place-items-center\">\n <span aria-hidden=\"true\" className={cx(\"size-[9px] rotate-45 bg-foreground\", crit && \"bg-destructive\")} />\n </span>\n ) : (\n <span className=\"size-5 shrink-0\" aria-hidden=\"true\" />\n )}\n {isEditing ? (\n <CellInput value={t.title} label={`Name of ${t.title}`} onCancel={onCancel} onCommit={(v) => onCommit(\"title\", v)} />\n ) : (\n <span\n onDoubleClick={(ev) => {\n ev.stopPropagation();\n onStartEdit?.(\"title\");\n }}\n title={t.title}\n className={cx(\"min-w-0 truncate\", summary ? \"font-semibold\" : done ? \"text-muted-foreground\" : \"text-foreground\")}\n >\n {t.title}\n </span>\n )}\n {closed && summary && foldedCount ? <span className=\"shrink-0 text-[11px] text-muted-foreground\">{foldedCount}</span> : null}\n {late ? <TagChip tag={{ label: \"Late\", kind: \"late\" }} /> : null}\n {(tags ?? []).map((g, i) => (\n <TagChip key={`${g.kind}${g.label ?? i}`} tag={g} />\n ))}\n {menu ? (\n <span className={cx(\"ml-auto shrink-0 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100\", selected && \"opacity-100\")} onClickCapture={() => !selected && onSelect?.(e.id)} onClick={(ev) => ev.stopPropagation()}>\n <Menu label={`${t.title}: change it`} items={menu} />\n </span>\n ) : null}\n </div>\n );\n }\n if (c.key === \"start\" || c.key === \"due\") {\n const iso = c.key === \"start\" ? r?.start : r?.due;\n const shown = c.key === \"due\" && r?.milestone ? \"\" : iso ? monthDay(iso) : null;\n return (\n <div key={c.key} className={cx(NUM, \"shrink-0 truncate px-1.5 text-[12px]\", moving?.has?.(e.id) && \"text-ring\")} style={{ width: c.w }}>\n {shown === null ? <span className=\"text-muted-foreground\">no date</span> : shown}\n </div>\n );\n }\n if (c.key === \"who\") {\n const o = cells?.owner;\n return (\n <div key=\"who\" className=\"shrink-0 truncate px-1.5 text-[12px]\" style={{ width: c.w }} title={o?.title}>\n {summary || r?.milestone ? null : o?.name ? (\n <span className={o.left ? \"text-muted-foreground\" : undefined}>{o.name}{o.left ? \" (left)\" : \"\"}</span>\n ) : (\n <span className=\"text-warning\">no owner</span>\n )}\n </div>\n );\n }\n if (c.key === \"days\") {\n const undated = !r?.dated;\n if (editing?.field === \"days\" || (undated && !summary && editableLength))\n return (\n <div key=\"days\" className=\"shrink-0 px-1\" style={{ width: c.w }} onClick={(ev) => ev.stopPropagation()}>\n <CellInput\n type=\"number\"\n min={1}\n align=\"right\"\n placeholder=\"none\"\n value={cells?.lengthGuess != null ? String(cells.lengthGuess) : undated ? \"\" : String(r?.duration ?? \"\")}\n label={`Length of ${t.title} in working days`}\n onCancel={onCancel}\n onCommit={(v) => (String(v) === String(r?.duration ?? \"\") || v === \"\" ? onCancel() : onCommit(\"days\", v))}\n quiet={undated && editing?.field !== \"days\"}\n autoFocus={!undated || editing?.field === \"days\"}\n />\n </div>\n );\n const text = summary ? (r?.dated && r.duration != null ? `${r.duration} wd` : \"\") : r?.milestone ? \"0 wd\" : r?.duration != null ? `${r.duration} wd${cells?.guessed ? \"?\" : \"\"}` : \"\";\n return (\n <div\n key=\"days\"\n onDoubleClick={(ev) => {\n if (!editableLength) return;\n ev.stopPropagation();\n onStartEdit?.(\"days\");\n }}\n className={cx(NUM, \"shrink-0 truncate px-1.5 text-right text-[12px]\", cells?.guessed ? \"text-info\" : summary || r?.milestone ? \"text-muted-foreground\" : \"text-foreground\")}\n style={{ width: c.w }}\n title={cells?.guessed ? \"A guess from the title\" : undefined}\n >\n {text}\n </div>\n );\n }\n if (c.key === \"done\") {\n if (editing?.field === \"progress\")\n return (\n <div key=\"done\" className=\"shrink-0 px-1\" style={{ width: c.w }} onClick={(ev) => ev.stopPropagation()}>\n <CellInput type=\"number\" min={0} max={100} align=\"right\" value={String(r?.progress ?? 0)} label={`Done, in per cent, of ${t.title}`} onCancel={onCancel} onCommit={(v) => onCommit(\"progress\", v)} />\n </div>\n );\n return (\n <div\n key=\"done\"\n onDoubleClick={(ev) => {\n if (summary || r?.milestone || done) return;\n ev.stopPropagation();\n onStartEdit?.(\"progress\");\n }}\n className={cx(NUM, \"flex shrink-0 justify-end px-1.5 text-[12px] text-muted-foreground\")}\n style={{ width: c.w }}\n >\n {done && !summary ? <Icon name=\"check\" size={14} label=\"Done\" /> : r?.milestone || !r ? \"\" : `${r.progress}%`}\n </div>\n );\n }\n if (c.key === \"waits\") {\n const text = cells?.waits ?? \"\";\n if (editing?.field === \"waits\")\n return (\n <div key=\"waits\" className=\"shrink-0 px-1\" style={{ width: c.w }} onClick={(ev) => ev.stopPropagation()}>\n <CellInput value={text} label={`What ${t.title} waits on`} placeholder=\"2.2FS+2d\" onCancel={onCancel} onCommit={(v) => (v === text ? onCancel() : onCommit(\"waits\", v))} />\n </div>\n );\n return (\n <div\n key=\"waits\"\n onDoubleClick={(ev) => {\n ev.stopPropagation();\n onStartEdit?.(\"waits\");\n }}\n onClick={(ev) => {\n if (cells?.waitsLinks?.length === 1 && onOpenLink && (ev.metaKey || ev.ctrlKey)) {\n ev.stopPropagation();\n onOpenLink(cells.waitsLinks[0]);\n }\n }}\n title={text ? `${text} (double-click to change)` : \"Double-click to say what it waits on\"}\n className={cx(NUM, \"shrink-0 truncate px-1.5 text-[12px]\", cells?.waitsBroken ? \"text-destructive\" : \"text-foreground\")}\n style={{ width: c.w }}\n >\n {text}\n </div>\n );\n }\n if (c.key === \"slack\") {\n const text = cells?.slack ?? \"\";\n return (\n <div key=\"slack\" className={cx(NUM, \"shrink-0 truncate px-1.5 text-right text-[12px]\", crit ? \"font-semibold text-destructive\" : \"text-muted-foreground\")} style={{ width: c.w }}>\n {text}\n </div>\n );\n }\n if (c.key === \"vs\") {\n const d = cells?.delta;\n return (\n <div key=\"vs\" className={cx(NUM, \"shrink-0 truncate px-1.5 text-right text-[12px]\", d?.tone ? tone[d.tone] : \"text-muted-foreground\")} style={{ width: c.w }}>\n {d?.text ?? \"\"}\n </div>\n );\n }\n return null;\n })}\n </div>\n );\n}\n\n/**\n * The row that adds in place: a title and a length. Enter adds a task,\n * Shift+Enter a milestone; Tab makes it a sub-task of the row above; Esc\n * cancels. Closed, it is one quiet line that says so.\n */\nexport function AddRow({ open, onOpen, draft, setDraft, onSubmit, busy, addRef, parentTitle, aboveTitle, onIndent, height = ROW }) {\n if (!open)\n return (\n <button type=\"button\" onClick={onOpen} title=\"Enter adds a task; Shift+Enter adds a milestone.\" aria-keyshortcuts=\"Enter Shift+Enter\" className=\"@container/gantt-add flex w-full min-w-0 items-center gap-2 border-b border-border px-3 text-left text-[13px] text-muted-foreground outline-none hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\" style={{ height }}>\n <Icon name=\"plus\" size={14} className=\"shrink-0\" />\n <span className=\"min-w-0 flex-1 truncate\">Add task</span>\n <kbd className=\"shrink-0 rounded-[3px] border border-border px-1 text-[10px]\">Enter</kbd>\n <span className=\"hidden shrink-0 items-center gap-2 @[18rem]/gantt-add:flex\">\n <span>milestone</span>\n <kbd className=\"rounded-[3px] border border-border px-1 text-[10px]\">Shift+Enter</kbd>\n </span>\n </button>\n );\n return (\n <div className=\"border-b border-border bg-accent\">\n <div className=\"flex items-center gap-2 px-3\" style={{ height: height + 6 }}>\n <span className=\"w-9 shrink-0\">\n <Icon name={draft.milestone ? \"diamond\" : \"plus\"} size={14} className=\"text-muted-foreground\" />\n </span>\n <input\n ref={addRef}\n value={draft.title}\n onChange={(ev) => setDraft((d) => ({ ...d, title: ev.target.value }))}\n onKeyDown={(ev) => {\n ev.stopPropagation();\n if (ev.key === \"Enter\") {\n ev.preventDefault();\n onSubmit(ev.shiftKey);\n } else if (ev.key === \"Tab\" && !ev.shiftKey && aboveTitle && !draft.milestone) {\n ev.preventDefault();\n onIndent?.();\n } else if (ev.key === \"Escape\") {\n setDraft({ title: \"\", days: \"\", milestone: false, parentId: null, afterId: null });\n ev.currentTarget.blur();\n }\n }}\n placeholder={parentTitle ? `A sub-task of ${parentTitle}` : draft.milestone ? \"Name the milestone\" : \"Add a task\"}\n aria-label={parentTitle ? `Add a sub-task of ${parentTitle}` : draft.milestone ? \"Milestone name\" : \"Task title\"}\n disabled={busy}\n className=\"h-6 min-w-0 flex-1 rounded-[3px] border border-ring bg-background px-1.5 text-[13px] text-foreground outline-none ring-1 ring-ring placeholder:text-muted-foreground\"\n />\n {!draft.milestone ? (\n <input\n value={draft.days}\n onChange={(ev) => setDraft((d) => ({ ...d, days: ev.target.value.replace(/[^0-9]/g, \"\").slice(0, 3) }))}\n onKeyDown={(ev) => {\n ev.stopPropagation();\n if (ev.key === \"Enter\") {\n ev.preventDefault();\n onSubmit(ev.shiftKey);\n }\n }}\n inputMode=\"numeric\"\n placeholder=\"5\"\n aria-label=\"Length in working days\"\n className={cx(NUM, \"h-6 w-10 shrink-0 rounded-[3px] border border-ring bg-background px-1 text-right text-[13px] outline-none ring-1 ring-ring\")}\n />\n ) : null}\n </div>\n </div>\n );\n}\n"
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
"path": "registry/blocks/schedule-gantt/track.jsx",
|
|
33
33
|
"type": "registry:component",
|
|
34
34
|
"target": "@components/makersclaw/blocks/schedule-gantt/track.jsx",
|
|
35
|
-
"content": "\"use client\";\n\nimport { Fragment } from \"react\";\nimport { cx } from \"../../ui\";\nimport { monthDay } from \"./gantt-model\";\n\n// The time scale's paper and lines: the header with its flags (today, the\n// target and the finish, with the gap between them as a line you can read),\n// weekends and the grid, the target as a dashed rule, the finish as a solid\n// one, today in the accent, the link lines (elbows between the bars, the\n// critical ones red when the page asks for them, a broken one dashed with a\n// cross), ghosts of where bars were, pencil for where a suggestion would put\n// them, and the lanes under the rows (who is on what, on the same scale).\n\nconst NUM = \"tabular-nums\";\nexport const HEAD = 56;\n\n/** The header: the axis tiers, then a band with Today, Target and Finish. */\nexport function TrackHead({ axis, range, ppd, z, gridW, marks }) {\n const late = marks.gap?.state === \"late\";\n const early = marks.gap?.state === \"early\";\n const finishText = marks.gap?.text ?? \"\";\n const targetText = marks.target != null ? `Target ${monthDay(marks.targetIso)}` : \"\";\n const same = marks.target != null && marks.finish != null && marks.target === marks.finish;\n return (\n <>\n {axis.top.map((s) => (\n <div key={`t${s.start}`} className=\"absolute top-0 h-[18px] border-l border-border\" style={{ left: (s.start - range.from) * ppd, width: (s.end - s.start + 1) * ppd }}>\n <span className=\"sticky block w-max px-1.5 pt-0.5 text-[11px] font-semibold text-foreground\" style={{ left: gridW }}>\n {s.label}\n </span>\n </div>\n ))}\n {axis.bottom.map((s) => (\n <div key={`b${s.start}`} className={cx(NUM, \"absolute top-[18px] h-[18px] overflow-hidden whitespace-nowrap pt-0.5 text-[11px]\", z === \"day\" ? \"text-center\" : \"pl-1\", s.dim ? \"text-muted-foreground/70\" : \"text-muted-foreground\")} style={{ left: (s.start - range.from) * ppd, width: (s.end - s.start + 1) * ppd }}>\n {s.label}\n </div>\n ))}\n <div className=\"absolute inset-x-0 top-[36px] border-t border-border\" />\n {marks.target != null && marks.finish != null && !same ? (\n <span aria-hidden=\"true\" className={cx(\"absolute top-[45px] h-0 border-t-2\", late ? \"border-warning\" : \"border-success\")} style={{ left: Math.min(marks.target, marks.finish), width: Math.abs(marks.finish - marks.target) }} />\n ) : null}\n {marks.finishFrom != null && marks.finishFrom !== marks.finish ? <span aria-hidden=\"true\" className=\"absolute top-[40px] h-3 w-0.5 bg-chart-3\" style={{ left: marks.finishFrom }} /> : null}\n {marks.today != null ? (\n <span className=\"absolute top-[38px] z-10 h-4 -translate-x-1/2 whitespace-nowrap rounded-[3px] bg-primary px-1.5 text-[11px] font-semibold leading-4 text-primary-foreground\" style={{ left: marks.today }}>\n Today\n </span>\n ) : null}\n {marks.target != null && !same ? (\n <span className={cx(\"absolute top-[38px] z-10 h-4 whitespace-nowrap rounded-[3px] border border-dashed border-foreground bg-background px-1.5 text-[11px] font-semibold leading-[14px] text-foreground\", late ? \"-translate-x-full -ml-px\" : \"ml-px\")} style={{ left: marks.target }}>\n {targetText}\n </span>\n ) : null}\n {marks.finish != null ? (\n <span className={cx(\"absolute top-[38px] z-10 h-4 whitespace-nowrap rounded-[3px] px-1.5 text-[11px] font-semibold leading-4\", marks.moving ? \"bg-ring text-primary-foreground\" : \"bg-foreground text-background\", early ? \"-translate-x-full -ml-px\" : \"ml-px\")} style={{ left: marks.finish }}>\n {finishText}\n </span>\n ) : null}\n </>\n );\n}\n\n/** Weekends, grid lines, the selected rows, target, finish and today, under everything. */\nexport function Paper({ axis, range, ppd, z, marks, rowTops, rowH, selectedRows }) {\n return (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n {z === \"month\" || z === \"fit\" || ppd < 4 ? null : axis.shade.map((s) => <div key={`s${s.start}`} className=\"absolute inset-y-0 bg-muted\" style={{ left: (s.start - range.from) * ppd, width: (s.end - s.start + 1) * ppd }} />)}\n {axis.lines.map((l) => (\n <div key={`l${l.day}${l.strong ? \"s\" : \"\"}`} className={cx(\"absolute inset-y-0 w-px\", l.strong ? \"bg-border\" : \"bg-border/50\")} style={{ left: (l.day - range.from) * ppd }} />\n ))}\n {rowTops.map((top, i) => (\n <div key={i} className={cx(\"absolute inset-x-0 border-b border-border\", selectedRows.has(i) && \"bg-accent\")} style={{ top, height: rowH }} />\n ))}\n {marks.target != null ? <div className=\"absolute inset-y-0 border-l-[1.5px] border-dashed border-foreground\" style={{ left: marks.target }} /> : null}\n {marks.finish != null ? <div className={cx(\"absolute inset-y-0 w-0.5 -translate-x-px\", marks.moving ? \"bg-ring\" : \"bg-foreground\")} style={{ left: marks.finish }} /> : null}\n {marks.today != null ? <div className=\"absolute inset-y-0 w-0.5 -translate-x-px bg-primary\" style={{ left: marks.today }} /> : null}\n </div>\n );\n}\n\nconst HEAD_PATH = {\n down: (x, y) => `${x - 3.5},${y - 5} ${x + 3.5},${y - 5} ${x},${y}`,\n up: (x, y) => `${x - 3.5},${y + 5} ${x + 3.5},${y + 5} ${x},${y}`,\n right: (x, y) => `${x - 5},${y - 3.5} ${x - 5},${y + 3.5} ${x},${y}`,\n left: (x, y) => `${x + 5},${y - 3.5} ${x + 5},${y + 3.5} ${x},${y}`,\n};\n\n/**\n * Link lines on one svg: hairline in muted ink, the critical ones red (when\n * the page switches the critical path on), a broken one dashed with a\n * cross, the ones that move with a drag in the ring colour, the lag beside\n * the first leg. Each is pressable (double-click) when `onOpenLink` is given.\n */\nexport function Links({ lines, width, height, critical, onOpenLink }) {\n return (\n <svg aria-hidden=\"true\" className=\"pointer-events-none absolute left-0 top-0 z-[5] overflow-visible\" width={width} height={height}>\n {lines.map((l) => {\n const tone = l.link.violated ? \"broken\" : l.moving ? \"moving\" : critical && l.critical ? \"crit\" : l.touches ? \"strong\" : \"quiet\";\n const stroke = { broken: \"stroke-destructive\", crit: \"stroke-destructive\", moving: \"stroke-ring\", strong: \"stroke-foreground\", quiet: \"stroke-muted-foreground\" }[tone];\n const fill = { broken: \"fill-destructive\", crit: \"fill-destructive\", moving: \"fill-ring\", strong: \"fill-foreground\", quiet: \"fill-muted-foreground\" }[tone];\n const w = tone === \"crit\" || tone === \"broken\" || tone === \"moving\" ? 1.5 : 1;\n const segs = l.route.segments;\n if (!segs.length) return null;\n const d = `M${segs[0].x1 + 0.5} ${segs[0].y1 + 0.5}${segs.map((s) => ` L${s.x2 + 0.5} ${s.y2 + 0.5}`).join(\"\")}`;\n const first = segs.find((s) => s.x1 === s.x2);\n const mid = segs[Math.floor(segs.length / 2)];\n return (\n <Fragment key={l.id}>\n <path d={d} fill=\"none\" className={stroke} strokeWidth={w} strokeDasharray={tone === \"broken\" ? \"4 3\" : undefined} />\n {l.route.head ? <polygon points={HEAD_PATH[l.route.head.dir](l.route.head.x + 0.5, l.route.head.y + 0.5)} className={fill} /> : null}\n {tone === \"broken\" && mid ? (\n <path d={`M${(mid.x1 + mid.x2) / 2 - 4} ${(mid.y1 + mid.y2) / 2 - 4}l8 8M${(mid.x1 + mid.x2) / 2 + 4} ${(mid.y1 + mid.y2) / 2 - 4}l-8 8`} fill=\"none\" className=\"stroke-destructive\" strokeWidth={2} />\n ) : null}\n {l.lag && first ? (\n <text x={first.x1 + 4} y={(first.y1 + first.y2) / 2} dominantBaseline=\"middle\" className={cx(\"text-[10px]\", tone === \"crit\" ? \"fill-destructive\" : \"fill-muted-foreground\")}>\n {l.lag > 0 ? `+${l.lag}` : l.lag}\n </text>\n ) : null}\n {onOpenLink ? (\n <path d={d} fill=\"none\" stroke=\"transparent\" strokeWidth={10} className=\"pointer-events-auto cursor-pointer\" onDoubleClick={() => onOpenLink(l.link)}>\n <title>{`Double-click to change this link`}</title>\n </path>\n ) : null}\n </Fragment>\n );\n })}\n </svg>\n );\n}\n\n/** Where bars were (dashed grey) or where a suggestion would put them (dashed in the agent's colour). */\nexport function Ghosts({ ghosts, visible, X, ppd, rowH, rowTop, live, kind = \"was\", bar = 14 }) {\n if (!ghosts?.size) return null;\n const ring = kind === \"pencil\" ? \"border-info\" : \"border-chart-3\";\n return (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-[6]\">\n {visible.map((e, i) => {\n const g = ghosts.get(e.id);\n const r = live.rows.get(e.id);\n if (!g || !r || r.summary) return null;\n const y = (rowTop ? rowTop(i) : i * rowH) + rowH / 2;\n if (r.milestone) {\n const cx0 = X(g.due ?? g.start) + ppd / 2;\n return <span key={e.id} className={cx(\"absolute size-3 -translate-x-1/2 -translate-y-1/2 rotate-45 border-[1.5px] border-dashed\", ring)} style={{ left: cx0, top: y }} />;\n }\n const x0 = X(g.start);\n const w = Math.max(ppd, X(g.due) + ppd - x0);\n return <span key={e.id} className={cx(\"absolute -translate-y-1/2 rounded-[2px] border-[1.5px] border-dashed\", ring)} style={{ left: x0, width: w, top: y, height: bar }} />;\n })}\n </div>\n );\n}\n\n/**\n * Lanes under the rows, on the same scale: one thin row per lane (a person)\n * with a bar for each of its items and a box where two or more overlap\n * (the count in it). The label side is drawn by the sheet's twin\n * (LaneLabels); both take the same lane list.\n */\nexport function LaneTrack({ lanes, X, ppd, laneH = 20, top }) {\n return (\n <div className=\"absolute inset-x-0\" style={{ top }}>\n {lanes.map((lane, i) => (\n <div key={lane.id} className=\"absolute inset-x-0 border-b border-border\" style={{ top: i * laneH, height: laneH }}>\n {(lane.items ?? []).map((it, k) => (\n <span key={`${it.id ?? k}`} title={it.title} className=\"absolute h-1.5 rounded-[2px] bg-chart-1\" style={{ left: X(it.start), width: Math.max(ppd, X(it.due) + ppd - X(it.start)), top: 7 }} />\n ))}\n {(lane.marks ?? []).map((m, k) => (\n <span key={`m${k}`} title={m.title ?? `${m.count} at once`} className=\"absolute rounded-[2px] border-[1.5px] border-warning pl-0.5 text-[10px] font-bold leading-[11px] text-warning\" style={{ left: X(m.from), width: Math.max(ppd, X(m.to) + ppd - X(m.from)), top: 3, height: 14 }}>\n {m.count}\n </span>\n ))}\n </div>\n ))}\n </div>\n );\n}\n\nexport function LaneLabels({ lanes, laneH = 20, width }) {\n return (\n <div style={{ width }}>\n {lanes.map((lane) => (\n <div key={lane.id} className=\"flex items-center gap-1.5 border-b border-border px-3 text-[12px]\" style={{ height: laneH }}>\n <span className=\"truncate\">{lane.label}</span>\n <span className=\"ml-auto shrink-0 text-muted-foreground\">{lane.sub}</span>\n </div>\n ))}\n </div>\n );\n}\n"
|
|
35
|
+
"content": "\"use client\";\n\nimport { Fragment } from \"react\";\nimport { cx } from \"../../ui\";\nimport { monthDay } from \"./gantt-model\";\n\n// The time scale's paper and lines: the header with its flags (today, the\n// target and the finish, with the gap between them as a line you can read),\n// weekends and the grid, the target as a dashed rule, the finish as a solid\n// one, today in the accent, the link lines (elbows between the bars, the\n// critical ones red when the page asks for them, a broken one dashed with a\n// cross), ghosts of where bars were, pencil for where a suggestion would put\n// them, and the lanes under the rows (who is on what, on the same scale).\n\nconst NUM = \"tabular-nums\";\nexport const HEAD = 56;\n\n/** The header: the axis tiers, then a band with Today, Target and Finish. */\nexport function TrackHead({ axis, range, ppd, z, gridW, marks }) {\n const late = marks.gap?.state === \"late\";\n const early = marks.gap?.state === \"early\";\n const finishText = marks.gap?.text ?? \"\";\n const targetText = marks.target != null ? `Target ${monthDay(marks.targetIso)}` : \"\";\n const same = marks.target != null && marks.finish != null && marks.target === marks.finish;\n return (\n <>\n {axis.top.map((s) => (\n <div key={`t${s.start}`} className=\"absolute top-0 h-[18px] border-l border-border\" style={{ left: (s.start - range.from) * ppd, width: (s.end - s.start + 1) * ppd }}>\n <span title={s.label} className=\"sticky block w-max max-w-full truncate px-1.5 pt-0.5 text-[11px] font-semibold text-foreground\" style={{ left: gridW }}>\n {s.label}\n </span>\n </div>\n ))}\n {axis.bottom.map((s) => (\n <div key={`b${s.start}`} className={cx(NUM, \"absolute top-[18px] h-[18px] overflow-hidden whitespace-nowrap pt-0.5 text-[11px]\", z === \"day\" ? \"text-center\" : \"pl-1\", s.dim ? \"text-muted-foreground/70\" : \"text-muted-foreground\")} style={{ left: (s.start - range.from) * ppd, width: (s.end - s.start + 1) * ppd }}>\n {s.label}\n </div>\n ))}\n <div className=\"absolute inset-x-0 top-[36px] border-t border-border\" />\n {marks.target != null && marks.finish != null && !same ? (\n <span aria-hidden=\"true\" className={cx(\"absolute top-[45px] h-0 border-t-2\", late ? \"border-warning\" : \"border-success\")} style={{ left: Math.min(marks.target, marks.finish), width: Math.abs(marks.finish - marks.target) }} />\n ) : null}\n {marks.finishFrom != null && marks.finishFrom !== marks.finish ? <span aria-hidden=\"true\" className=\"absolute top-[40px] h-3 w-0.5 bg-chart-3\" style={{ left: marks.finishFrom }} /> : null}\n {marks.today != null ? (\n <span className=\"absolute top-[38px] z-10 h-4 -translate-x-1/2 whitespace-nowrap rounded-[3px] bg-primary px-1.5 text-[11px] font-semibold leading-4 text-primary-foreground\" style={{ left: marks.today }}>\n Today\n </span>\n ) : null}\n {marks.target != null && !same ? (\n <span className={cx(\"absolute top-[38px] z-10 h-4 whitespace-nowrap rounded-[3px] border border-dashed border-foreground bg-background px-1.5 text-[11px] font-semibold leading-[14px] text-foreground\", late ? \"-translate-x-full -ml-px\" : \"ml-px\")} style={{ left: marks.target }}>\n {targetText}\n </span>\n ) : null}\n {marks.finish != null ? (\n <span className={cx(\"absolute top-[38px] z-10 h-4 whitespace-nowrap rounded-[3px] px-1.5 text-[11px] font-semibold leading-4\", marks.moving ? \"bg-ring text-primary-foreground\" : \"bg-foreground text-background\", early ? \"-translate-x-full -ml-px\" : \"ml-px\")} style={{ left: marks.finish }}>\n {finishText}\n </span>\n ) : null}\n </>\n );\n}\n\n/** Weekends, grid lines, the selected rows, target, finish and today, under everything. */\nexport function Paper({ axis, range, ppd, z, marks, rowTops, rowH, selectedRows }) {\n return (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n {z === \"month\" || z === \"fit\" || ppd < 4 ? null : axis.shade.map((s) => <div key={`s${s.start}`} className=\"absolute inset-y-0 bg-muted\" style={{ left: (s.start - range.from) * ppd, width: (s.end - s.start + 1) * ppd }} />)}\n {axis.lines.map((l) => (\n <div key={`l${l.day}${l.strong ? \"s\" : \"\"}`} className={cx(\"absolute inset-y-0 w-px\", l.strong ? \"bg-border\" : \"bg-border/50\")} style={{ left: (l.day - range.from) * ppd }} />\n ))}\n {rowTops.map((top, i) => (\n <div key={i} className={cx(\"absolute inset-x-0 border-b border-border\", selectedRows.has(i) && \"bg-accent\")} style={{ top, height: rowH }} />\n ))}\n {marks.target != null ? <div className=\"absolute inset-y-0 border-l-[1.5px] border-dashed border-foreground\" style={{ left: marks.target }} /> : null}\n {marks.finish != null ? <div className={cx(\"absolute inset-y-0 w-0.5 -translate-x-px\", marks.moving ? \"bg-ring\" : \"bg-foreground\")} style={{ left: marks.finish }} /> : null}\n {marks.today != null ? <div className=\"absolute inset-y-0 w-0.5 -translate-x-px bg-primary\" style={{ left: marks.today }} /> : null}\n </div>\n );\n}\n\nconst HEAD_PATH = {\n down: (x, y) => `${x - 3.5},${y - 5} ${x + 3.5},${y - 5} ${x},${y}`,\n up: (x, y) => `${x - 3.5},${y + 5} ${x + 3.5},${y + 5} ${x},${y}`,\n right: (x, y) => `${x - 5},${y - 3.5} ${x - 5},${y + 3.5} ${x},${y}`,\n left: (x, y) => `${x + 5},${y - 3.5} ${x + 5},${y + 3.5} ${x},${y}`,\n};\n\n/**\n * Link lines on one svg: hairline in muted ink, the critical ones red (when\n * the page switches the critical path on), a broken one dashed with a\n * cross, the ones that move with a drag in the ring colour, the lag beside\n * the first leg. Each is pressable (double-click) when `onOpenLink` is given.\n */\nexport function Links({ lines, width, height, critical, onOpenLink }) {\n return (\n <svg aria-hidden=\"true\" className=\"pointer-events-none absolute left-0 top-0 z-[5] overflow-visible\" width={width} height={height}>\n {lines.map((l) => {\n const tone = l.link.violated ? \"broken\" : l.moving ? \"moving\" : critical && l.critical ? \"crit\" : l.touches ? \"strong\" : \"quiet\";\n const stroke = { broken: \"stroke-destructive\", crit: \"stroke-destructive\", moving: \"stroke-ring\", strong: \"stroke-foreground\", quiet: \"stroke-muted-foreground\" }[tone];\n const fill = { broken: \"fill-destructive\", crit: \"fill-destructive\", moving: \"fill-ring\", strong: \"fill-foreground\", quiet: \"fill-muted-foreground\" }[tone];\n const w = tone === \"crit\" || tone === \"broken\" || tone === \"moving\" ? 1.5 : 1;\n const segs = l.route.segments;\n if (!segs.length) return null;\n const d = `M${segs[0].x1 + 0.5} ${segs[0].y1 + 0.5}${segs.map((s) => ` L${s.x2 + 0.5} ${s.y2 + 0.5}`).join(\"\")}`;\n const first = segs.find((s) => s.x1 === s.x2);\n const mid = segs[Math.floor(segs.length / 2)];\n return (\n <Fragment key={l.id}>\n <path d={d} fill=\"none\" className={stroke} strokeWidth={w} strokeDasharray={tone === \"broken\" ? \"4 3\" : undefined} />\n {l.route.head ? <polygon points={HEAD_PATH[l.route.head.dir](l.route.head.x + 0.5, l.route.head.y + 0.5)} className={fill} /> : null}\n {tone === \"broken\" && mid ? (\n <path d={`M${(mid.x1 + mid.x2) / 2 - 4} ${(mid.y1 + mid.y2) / 2 - 4}l8 8M${(mid.x1 + mid.x2) / 2 + 4} ${(mid.y1 + mid.y2) / 2 - 4}l-8 8`} fill=\"none\" className=\"stroke-destructive\" strokeWidth={2} />\n ) : null}\n {l.lag && first ? (\n <text x={first.x1 + 4} y={(first.y1 + first.y2) / 2} dominantBaseline=\"middle\" className={cx(\"text-[10px]\", tone === \"crit\" ? \"fill-destructive\" : \"fill-muted-foreground\")}>\n {l.lag > 0 ? `+${l.lag}` : l.lag}\n </text>\n ) : null}\n {onOpenLink ? (\n <path d={d} fill=\"none\" stroke=\"transparent\" strokeWidth={10} className=\"pointer-events-auto cursor-pointer\" onDoubleClick={() => onOpenLink(l.link)}>\n <title>{`Double-click to change this link`}</title>\n </path>\n ) : null}\n </Fragment>\n );\n })}\n </svg>\n );\n}\n\n/** Where bars were (dashed grey) or where a suggestion would put them (dashed in the agent's colour). */\nexport function Ghosts({ ghosts, visible, X, ppd, rowH, rowTop, live, kind = \"was\", bar = 14 }) {\n if (!ghosts?.size) return null;\n const ring = kind === \"pencil\" ? \"border-info\" : \"border-chart-3\";\n return (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-[6]\">\n {visible.map((e, i) => {\n const g = ghosts.get(e.id);\n const r = live.rows.get(e.id);\n if (!g || !r || r.summary) return null;\n const y = (rowTop ? rowTop(i) : i * rowH) + rowH / 2;\n if (r.milestone) {\n const cx0 = X(g.due ?? g.start) + ppd / 2;\n return <span key={e.id} className={cx(\"absolute size-3 -translate-x-1/2 -translate-y-1/2 rotate-45 border-[1.5px] border-dashed\", ring)} style={{ left: cx0, top: y }} />;\n }\n const x0 = X(g.start);\n const w = Math.max(ppd, X(g.due) + ppd - x0);\n return <span key={e.id} className={cx(\"absolute -translate-y-1/2 rounded-[2px] border-[1.5px] border-dashed\", ring)} style={{ left: x0, width: w, top: y, height: bar }} />;\n })}\n </div>\n );\n}\n\n/**\n * Lanes under the rows, on the same scale: one thin row per lane (a person)\n * with a bar for each of its items and a box where two or more overlap\n * (the count in it). The label side is drawn by the sheet's twin\n * (LaneLabels); both take the same lane list.\n */\nexport function LaneTrack({ lanes, X, ppd, laneH = 20, top }) {\n return (\n <div className=\"absolute inset-x-0\" style={{ top }}>\n {lanes.map((lane, i) => (\n <div key={lane.id} className=\"absolute inset-x-0 border-b border-border\" style={{ top: i * laneH, height: laneH }}>\n {(lane.items ?? []).map((it, k) => (\n <span key={`${it.id ?? k}`} title={it.title} className=\"absolute h-1.5 rounded-[2px] bg-chart-1\" style={{ left: X(it.start), width: Math.max(ppd, X(it.due) + ppd - X(it.start)), top: 7 }} />\n ))}\n {(lane.marks ?? []).map((m, k) => (\n <span key={`m${k}`} title={m.title ?? `${m.count} at once`} className=\"absolute rounded-[2px] border-[1.5px] border-warning pl-0.5 text-[10px] font-bold leading-[11px] text-warning\" style={{ left: X(m.from), width: Math.max(ppd, X(m.to) + ppd - X(m.from)), top: 3, height: 14 }}>\n {m.count}\n </span>\n ))}\n </div>\n ))}\n </div>\n );\n}\n\nexport function LaneLabels({ lanes, laneH = 20, width }) {\n return (\n <div style={{ width }}>\n {lanes.map((lane) => (\n <div key={lane.id} className=\"flex items-center gap-1.5 border-b border-border px-3 text-[12px]\" style={{ height: laneH }}>\n <span className=\"truncate\">{lane.label}</span>\n <span className=\"ml-auto shrink-0 text-muted-foreground\">{lane.sub}</span>\n </div>\n ))}\n </div>\n );\n}\n"
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
"path": "registry/blocks/schedule-gantt/geometry.js",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"docs": "Installed to components/makersclaw/blocks/schedule-gantt. Import: import { ScheduleGantt, TagChip, CellInput, ROW, ZOOMS, axisFor, rangeFor, routeLink, shortDate, isoToday, COLUMNS, DEFAULT_COLUMNS, MONTHS, WEEKDAYS, baselineDelta, barLabel, columnsFor, describeDrag, dayWords, dragLines, fixedWidth, gapOf, lengthWords, linkLines, loopWords, monthDay, selectionMoves, slackWords } from \"@/components/makersclaw/blocks/schedule-gantt\". Props and an example: components/makersclaw/blocks/schedule-gantt/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
77
77
|
"meta": {
|
|
78
78
|
"makersclaw": {
|
|
79
|
-
"version": "0.8.
|
|
79
|
+
"version": "0.8.5",
|
|
80
80
|
"useWhen": "A project plan where the question is 'when do we finish and what decides it': tasks with lengths in working days, links of four kinds with lag, slack, a critical path, a target date, a saved baseline, and dates that must move when something slips. Not for calendar-day work with finish-to-start links only (gantt), phases against gates (schedule-ruler), stage columns (board) or undated to-dos (task-list)."
|
|
81
81
|
}
|
|
82
82
|
}
|
package/r/schedule-ruler.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "Schedule ruler",
|
|
6
6
|
"description": "A dated chain drawn to scale on a day axis (a tick each Monday, a taller one on the 1st): the segments of the chain as bars with their names and status (done, in progress, planned, late, draft), a gate post at the end of a segment built from its exit criteria as pips (met are filled, with a 2/4 count, and the date turns to the warning tone once it has passed with criteria open), milestone diamonds with their names, a solid target line, a dashed forecast line, today as a line in the accent, and between target and forecast the gap hatched: green slack or a red overrun, counted in working days and labelled \"+3 d\" or \"3 d slack\". Earlier positions can show as dashed ghosts. GapRuler is the target against the forecast alone, for a sentence to sit beside; ScheduleStrip is the chain in one small row for a list. Positions are percentages, so it draws the same on the server; under 36rem of its own width the chain scrolls and is also listed in words. Loading, failed and empty states are drawn.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"docs": "Installed to components/makersclaw/blocks/schedule-ruler. Import: import { ScheduleRuler, GapRuler, ScheduleStrip, axisOf, dayName, dayNumber, daysBetween, frameOf, gapOf, gapWords, gateOf, isoOf, layoutLanes, placeLabel, readMilestones, readSegments, shortDay, summaryOf, WORKWEEK } from \"@/components/makersclaw/blocks/schedule-ruler\". Props and an example: components/makersclaw/blocks/schedule-ruler/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
58
58
|
"meta": {
|
|
59
59
|
"makersclaw": {
|
|
60
|
-
"version": "0.8.
|
|
60
|
+
"version": "0.8.5",
|
|
61
61
|
"useWhen": "A screen that answers 'when does this finish, and against what promise': a project's phases and gates, a launch plan's finish against its target date, a roadmap row's forecast against its deadline, a delivery chain, a renewal or audit timeline. Draws a result the app has already worked out; it does not schedule. Not for editable bars with dependencies (gantt), days on a month grid (calendar-view), or the age of one thing against a limit (day-ruler)."
|
|
62
62
|
}
|
|
63
63
|
}
|
package/r/search-dialog.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "SearchDialog",
|
|
6
6
|
"description": "One ⌘K search over every kind of record — chats, documents, images, projects, people. Before typing it shows your sections (Last opened, Recent); while typing, ranked matches with the typed text marked, a date, image thumbnails, and kind chips (All, Chats, Images…) to narrow them. Searches your list in the browser, or your server through an async onSearch. Built on the kit's Command; SearchPanel is the same box inline.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"docs": "Installed to components/makersclaw/blocks/search-dialog. Import: import { SearchDialog, SearchPanel } from \"@/components/makersclaw/blocks/search-dialog\". Props and an example: components/makersclaw/blocks/search-dialog/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
45
45
|
"meta": {
|
|
46
46
|
"makersclaw": {
|
|
47
|
-
"version": "0.8.
|
|
47
|
+
"version": "0.8.5",
|
|
48
48
|
"useWhen": "An app has more than one kind of record and wants one global search box or ⌘K palette to find any of them, or a search page with type filters."
|
|
49
49
|
}
|
|
50
50
|
}
|
package/r/settings-list.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "SettingsList",
|
|
6
6
|
"description": "A card of settings, one row each — icon, label, description, meta lines, a badge, and its control: a Switch, a Select, a Button, a remove button or a menu of actions, with optional details that open under the row. Every change saves on its own through an async callback; the row shows the new value at once, a spinner while it saves, and on failure the old value back with the reason and Try again. variant=\"matrix\" is the notification grid: events by group down the side, channels (email, push, SMS) across, a switch per cell, stacked on a phone.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.8.5/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"docs": "Installed to components/makersclaw/blocks/settings-list. Import: import { SettingsList } from \"@/components/makersclaw/blocks/settings-list\". Props and an example: components/makersclaw/blocks/settings-list/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
60
60
|
"meta": {
|
|
61
61
|
"makersclaw": {
|
|
62
|
-
"version": "0.8.
|
|
62
|
+
"version": "0.8.5",
|
|
63
63
|
"useWhen": "An account or workspace settings screen where each setting applies the moment it is changed: preferences, two-step sign-in methods, signed-in devices, connected integrations, billing profiles, notification preferences per channel. Not for a form of fields saved together with one button (record-form)."
|
|
64
64
|
}
|
|
65
65
|
}
|