makersclaw 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/package.json +1 -1
- package/r/activity-log.json +52 -0
- package/r/agent-changes.json +51 -0
- package/r/agent-chat.json +53 -0
- package/r/agent-column.json +85 -0
- package/r/agent-dock.json +57 -0
- package/r/agent-kit.json +49 -0
- package/r/agent-marks.json +49 -0
- package/r/agent-pane.json +93 -0
- package/r/ai-action.json +56 -0
- package/r/annotate.json +3 -3
- package/r/approval-request.json +2 -2
- package/r/approval.json +54 -0
- package/r/ask-bar.json +50 -0
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/automation-editor.json +66 -0
- package/r/automation-list.json +57 -0
- package/r/automation-model.json +118 -0
- package/r/automations-screen.json +71 -0
- 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 +84 -0
- 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 +51 -0
- package/r/contact-section.json +2 -2
- package/r/core.json +13 -7
- package/r/cost.json +51 -0
- package/r/csv-import.json +3 -3
- package/r/data-picker.json +60 -0
- 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 +63 -0
- package/r/fix-note.json +57 -0
- package/r/flow-canvas.json +2 -2
- package/r/flow-editor.json +79 -0
- 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 +56 -0
- package/r/map.json +2 -2
- package/r/margin-notes.json +67 -0
- 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 +55 -0
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +3 -3
- package/r/pace-bar.json +69 -0
- 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 +52 -0
- package/r/record-notes.json +4 -4
- package/r/registry.json +4086 -1512
- package/r/run-history.json +52 -0
- package/r/run-view.json +80 -0
- package/r/schedule-card.json +54 -0
- package/r/schedule-engine.json +79 -0
- package/r/schedule-gantt.json +83 -0
- package/r/schedule-ruler.json +64 -0
- 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 +52 -0
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +3 -3
- package/r/step-picker.json +53 -0
- package/r/step-settings.json +72 -0
- package/r/suggestion.json +58 -0
- package/r/sum-bar.json +64 -0
- 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 +67 -0
- package/r/time-grid.json +73 -0
- package/r/time-strip.json +64 -0
- package/r/top-bar.json +66 -0
- package/r/transaction-list.json +2 -2
- package/r/trigger-picker.json +61 -0
- package/r/whiteboard.json +3 -3
- package/r/workflow-view.json +55 -0
- package/registry/blocks/activity-log/README.md +112 -0
- package/registry/blocks/activity-log/activity-log.jsx +351 -0
- package/registry/blocks/activity-log/block.json +33 -0
- package/registry/blocks/activity-log/demo.jsx +48 -0
- package/registry/blocks/activity-log/index.js +4 -0
- package/registry/blocks/agent-changes/README.md +94 -0
- package/registry/blocks/agent-changes/agent-changes.jsx +118 -0
- package/registry/blocks/agent-changes/block.json +28 -0
- package/registry/blocks/agent-changes/demo.jsx +39 -0
- package/registry/blocks/agent-changes/index.js +3 -0
- package/registry/blocks/agent-chat/README.md +145 -0
- package/registry/blocks/agent-chat/agent-chat.jsx +415 -0
- package/registry/blocks/agent-chat/block.json +30 -0
- package/registry/blocks/agent-chat/demo.jsx +165 -0
- package/registry/blocks/agent-chat/index.js +3 -0
- package/registry/blocks/agent-column/README.md +197 -0
- package/registry/blocks/agent-column/agent-column.jsx +492 -0
- package/registry/blocks/agent-column/block.json +67 -0
- package/registry/blocks/agent-column/column-picker.jsx +163 -0
- package/registry/blocks/agent-column/demo.jsx +50 -0
- package/registry/blocks/agent-column/index.js +7 -0
- package/registry/blocks/agent-column/model.js +164 -0
- package/registry/blocks/agent-column/receipt.jsx +483 -0
- package/registry/blocks/agent-column/spreadsheet-type.jsx +24 -0
- package/registry/blocks/agent-dock/README.md +210 -0
- package/registry/blocks/agent-dock/agent-dock.jsx +356 -0
- package/registry/blocks/agent-dock/block.json +47 -0
- package/registry/blocks/agent-dock/demo.jsx +153 -0
- package/registry/blocks/agent-dock/dock-model.js +118 -0
- package/registry/blocks/agent-dock/index.js +5 -0
- package/registry/blocks/agent-kit/README.md +73 -0
- package/registry/blocks/agent-kit/agent-kit.jsx +86 -0
- package/registry/blocks/agent-kit/block.json +10 -0
- package/registry/blocks/agent-kit/demo.jsx +35 -0
- package/registry/blocks/agent-kit/index.js +3 -0
- package/registry/blocks/agent-marks/README.md +80 -0
- package/registry/blocks/agent-marks/agent-marks.jsx +133 -0
- package/registry/blocks/agent-marks/block.json +27 -0
- package/registry/blocks/agent-marks/demo.jsx +49 -0
- package/registry/blocks/agent-marks/index.js +3 -0
- package/registry/blocks/agent-pane/README.md +225 -0
- package/registry/blocks/agent-pane/agent-pane.jsx +244 -0
- package/registry/blocks/agent-pane/block.json +41 -0
- package/registry/blocks/agent-pane/composer.jsx +6 -0
- package/registry/blocks/agent-pane/demo.jsx +233 -0
- package/registry/blocks/agent-pane/dock-context.js +10 -0
- package/registry/blocks/agent-pane/index.js +4 -0
- package/registry/blocks/agent-pane/prose.jsx +84 -0
- package/registry/blocks/agent-pane/questions.jsx +134 -0
- package/registry/blocks/agent-pane/transcript.jsx +268 -0
- package/registry/blocks/agent-pane/work.jsx +368 -0
- package/registry/blocks/ai-action/README.md +94 -0
- package/registry/blocks/ai-action/ai-action.jsx +188 -0
- package/registry/blocks/ai-action/block.json +34 -0
- package/registry/blocks/ai-action/demo.jsx +93 -0
- package/registry/blocks/ai-action/index.js +3 -0
- package/registry/blocks/approval/README.md +145 -0
- package/registry/blocks/approval/approval.jsx +163 -0
- package/registry/blocks/approval/block.json +31 -0
- package/registry/blocks/approval/demo.jsx +121 -0
- package/registry/blocks/approval/index.js +3 -0
- package/registry/blocks/ask-bar/README.md +105 -0
- package/registry/blocks/ask-bar/ask-bar.jsx +166 -0
- package/registry/blocks/ask-bar/block.json +26 -0
- package/registry/blocks/ask-bar/demo.jsx +78 -0
- package/registry/blocks/ask-bar/index.js +3 -0
- package/registry/blocks/automation-editor/README.md +291 -0
- package/registry/blocks/automation-editor/automation-editor.jsx +656 -0
- package/registry/blocks/automation-editor/block.json +39 -0
- package/registry/blocks/automation-editor/demo.jsx +284 -0
- package/registry/blocks/automation-editor/index.js +4 -0
- package/registry/blocks/automation-editor/run-inputs.jsx +82 -0
- package/registry/blocks/automation-list/README.md +129 -0
- package/registry/blocks/automation-list/automation-list.jsx +476 -0
- package/registry/blocks/automation-list/block.json +35 -0
- package/registry/blocks/automation-list/demo.jsx +67 -0
- package/registry/blocks/automation-list/index.js +3 -0
- package/registry/blocks/automation-model/README.md +109 -0
- package/registry/blocks/automation-model/block.json +135 -0
- package/registry/blocks/automation-model/catalog.js +256 -0
- package/registry/blocks/automation-model/check.js +161 -0
- package/registry/blocks/automation-model/demo.jsx +42 -0
- package/registry/blocks/automation-model/edit.js +262 -0
- package/registry/blocks/automation-model/fixes.js +287 -0
- package/registry/blocks/automation-model/glyph.jsx +13 -0
- package/registry/blocks/automation-model/graph.js +410 -0
- package/registry/blocks/automation-model/index.js +33 -0
- package/registry/blocks/automation-model/overlay.js +175 -0
- package/registry/blocks/automation-model/parts.jsx +94 -0
- package/registry/blocks/automation-model/schedule.js +53 -0
- package/registry/blocks/automation-model/templates.js +128 -0
- package/registry/blocks/automation-model/values.js +29 -0
- package/registry/blocks/automation-model/words.js +439 -0
- package/registry/blocks/automations-screen/README.md +207 -0
- package/registry/blocks/automations-screen/automations-screen.jsx +452 -0
- package/registry/blocks/automations-screen/block.json +47 -0
- package/registry/blocks/automations-screen/demo.jsx +364 -0
- package/registry/blocks/automations-screen/index.js +4 -0
- package/registry/blocks/automations-screen/screen-model.js +129 -0
- package/registry/blocks/chat-pane/README.md +143 -0
- package/registry/blocks/chat-pane/block.json +48 -0
- package/registry/blocks/chat-pane/chat-attach.js +166 -0
- package/registry/blocks/chat-pane/chat-composer.jsx +195 -0
- package/registry/blocks/chat-pane/chat-context.js +17 -0
- package/registry/blocks/chat-pane/chat-model.js +43 -0
- package/registry/blocks/chat-pane/chat-pane.jsx +398 -0
- package/registry/blocks/chat-pane/chats-list.jsx +52 -0
- package/registry/blocks/chat-pane/demo.jsx +103 -0
- package/registry/blocks/chat-pane/index.js +9 -0
- package/registry/blocks/connect-card/README.md +99 -0
- package/registry/blocks/connect-card/block.json +10 -0
- package/registry/blocks/connect-card/connect-card.jsx +103 -0
- package/registry/blocks/connect-card/demo.jsx +49 -0
- package/registry/blocks/connect-card/index.js +3 -0
- package/registry/blocks/cost/README.md +115 -0
- package/registry/blocks/cost/block.json +10 -0
- package/registry/blocks/cost/cost.jsx +176 -0
- package/registry/blocks/cost/demo.jsx +66 -0
- package/registry/blocks/cost/index.js +3 -0
- package/registry/blocks/data-picker/README.md +81 -0
- package/registry/blocks/data-picker/block.json +38 -0
- package/registry/blocks/data-picker/data-picker.jsx +210 -0
- package/registry/blocks/data-picker/demo.jsx +34 -0
- package/registry/blocks/data-picker/fields.js +89 -0
- package/registry/blocks/data-picker/index.js +4 -0
- package/registry/blocks/data-table/demo.jsx +1 -1
- package/registry/blocks/first-run/README.md +118 -0
- package/registry/blocks/first-run/block.json +11 -0
- package/registry/blocks/first-run/demo.jsx +88 -0
- package/registry/blocks/first-run/first-run-model.js +51 -0
- package/registry/blocks/first-run/first-run.jsx +156 -0
- package/registry/blocks/first-run/index.js +5 -0
- package/registry/blocks/first-run/probe-rules.js +26 -0
- package/registry/blocks/fix-note/README.md +79 -0
- package/registry/blocks/fix-note/block.json +35 -0
- package/registry/blocks/fix-note/demo.jsx +30 -0
- package/registry/blocks/fix-note/fix-note.jsx +235 -0
- package/registry/blocks/fix-note/index.js +3 -0
- package/registry/blocks/flow-editor/README.md +108 -0
- package/registry/blocks/flow-editor/block.json +44 -0
- package/registry/blocks/flow-editor/demo.jsx +115 -0
- package/registry/blocks/flow-editor/flow-canvas.jsx +568 -0
- package/registry/blocks/flow-editor/flow-editor.jsx +154 -0
- package/registry/blocks/flow-editor/index.js +6 -0
- package/registry/blocks/flow-editor/minimap.jsx +78 -0
- package/registry/blocks/flow-editor/outline.jsx +134 -0
- package/registry/blocks/library-shelf/README.md +117 -0
- package/registry/blocks/library-shelf/block.json +28 -0
- package/registry/blocks/library-shelf/demo.jsx +68 -0
- package/registry/blocks/library-shelf/index.js +3 -0
- package/registry/blocks/library-shelf/library-shelf.jsx +211 -0
- package/registry/blocks/library-shelf/probe-rules.js +15 -0
- package/registry/blocks/margin-notes/README.md +222 -0
- package/registry/blocks/margin-notes/block.json +10 -0
- package/registry/blocks/margin-notes/demo.jsx +144 -0
- package/registry/blocks/margin-notes/index.js +20 -0
- package/registry/blocks/margin-notes/margin-model.js +224 -0
- package/registry/blocks/margin-notes/margin-notes.jsx +465 -0
- package/registry/blocks/needs-you/README.md +177 -0
- package/registry/blocks/needs-you/block.json +34 -0
- package/registry/blocks/needs-you/demo.jsx +143 -0
- package/registry/blocks/needs-you/index.js +3 -0
- package/registry/blocks/needs-you/needs-you.jsx +441 -0
- package/registry/blocks/pace-bar/README.md +230 -0
- package/registry/blocks/pace-bar/block.json +10 -0
- package/registry/blocks/pace-bar/demo.jsx +255 -0
- package/registry/blocks/pace-bar/index.js +22 -0
- package/registry/blocks/pace-bar/pace-bar.jsx +461 -0
- package/registry/blocks/pace-bar/pace-model.js +288 -0
- package/registry/blocks/record-grid/README.md +100 -0
- package/registry/blocks/record-grid/block.json +28 -0
- package/registry/blocks/record-grid/demo.jsx +43 -0
- package/registry/blocks/record-grid/index.js +3 -0
- package/registry/blocks/record-grid/record-grid.jsx +251 -0
- package/registry/blocks/run-history/README.md +138 -0
- package/registry/blocks/run-history/block.json +10 -0
- package/registry/blocks/run-history/demo.jsx +74 -0
- package/registry/blocks/run-history/index.js +3 -0
- package/registry/blocks/run-history/run-history.jsx +218 -0
- package/registry/blocks/run-view/README.md +174 -0
- package/registry/blocks/run-view/block.json +50 -0
- package/registry/blocks/run-view/demo.jsx +286 -0
- package/registry/blocks/run-view/index.js +5 -0
- package/registry/blocks/run-view/run-controls.jsx +266 -0
- package/registry/blocks/run-view/run-view.jsx +236 -0
- package/registry/blocks/run-view/runs-list.jsx +99 -0
- package/registry/blocks/schedule-card/README.md +121 -0
- package/registry/blocks/schedule-card/block.json +10 -0
- package/registry/blocks/schedule-card/demo.jsx +82 -0
- package/registry/blocks/schedule-card/index.js +3 -0
- package/registry/blocks/schedule-card/schedule-card.jsx +136 -0
- package/registry/blocks/schedule-engine/README.md +137 -0
- package/registry/blocks/schedule-engine/block.json +10 -0
- package/registry/blocks/schedule-engine/demo.jsx +82 -0
- package/registry/blocks/schedule-engine/engine.js +769 -0
- package/registry/blocks/schedule-engine/index.js +9 -0
- package/registry/blocks/schedule-engine/ops.js +71 -0
- package/registry/blocks/schedule-engine/place.js +294 -0
- package/registry/blocks/schedule-engine/tree.js +79 -0
- package/registry/blocks/schedule-engine/waits.js +80 -0
- package/registry/blocks/schedule-gantt/README.md +159 -0
- package/registry/blocks/schedule-gantt/block.json +10 -0
- package/registry/blocks/schedule-gantt/demo.jsx +223 -0
- package/registry/blocks/schedule-gantt/gantt-model.js +219 -0
- package/registry/blocks/schedule-gantt/geometry.js +176 -0
- package/registry/blocks/schedule-gantt/index.js +26 -0
- package/registry/blocks/schedule-gantt/schedule-gantt.jsx +882 -0
- package/registry/blocks/schedule-gantt/sheet.jsx +372 -0
- package/registry/blocks/schedule-gantt/track.jsx +190 -0
- package/registry/blocks/schedule-ruler/README.md +148 -0
- package/registry/blocks/schedule-ruler/block.json +10 -0
- package/registry/blocks/schedule-ruler/demo.jsx +181 -0
- package/registry/blocks/schedule-ruler/index.js +21 -0
- package/registry/blocks/schedule-ruler/ruler-model.js +239 -0
- package/registry/blocks/schedule-ruler/schedule-ruler.jsx +548 -0
- package/registry/blocks/source-receipt/README.md +97 -0
- package/registry/blocks/source-receipt/block.json +30 -0
- package/registry/blocks/source-receipt/demo.jsx +71 -0
- package/registry/blocks/source-receipt/index.js +3 -0
- package/registry/blocks/source-receipt/source-receipt.jsx +107 -0
- package/registry/blocks/spreadsheet/spreadsheet.jsx +15 -1
- package/registry/blocks/step-picker/README.md +78 -0
- package/registry/blocks/step-picker/block.json +31 -0
- package/registry/blocks/step-picker/demo.jsx +23 -0
- package/registry/blocks/step-picker/index.js +3 -0
- package/registry/blocks/step-picker/step-picker.jsx +144 -0
- package/registry/blocks/step-settings/README.md +100 -0
- package/registry/blocks/step-settings/block.json +44 -0
- package/registry/blocks/step-settings/demo.jsx +93 -0
- package/registry/blocks/step-settings/fields.jsx +58 -0
- package/registry/blocks/step-settings/flow-settings.jsx +115 -0
- package/registry/blocks/step-settings/index.js +5 -0
- package/registry/blocks/step-settings/step-settings.jsx +290 -0
- package/registry/blocks/suggestion/README.md +171 -0
- package/registry/blocks/suggestion/block.json +40 -0
- package/registry/blocks/suggestion/demo.jsx +91 -0
- package/registry/blocks/suggestion/index.js +3 -0
- package/registry/blocks/suggestion/suggestion.jsx +436 -0
- package/registry/blocks/sum-bar/README.md +122 -0
- package/registry/blocks/sum-bar/block.json +10 -0
- package/registry/blocks/sum-bar/demo.jsx +110 -0
- package/registry/blocks/sum-bar/index.js +5 -0
- package/registry/blocks/sum-bar/sum-bar.jsx +209 -0
- package/registry/blocks/sum-bar/sum-model.js +126 -0
- package/registry/blocks/tier-map/README.md +217 -0
- package/registry/blocks/tier-map/block.json +10 -0
- package/registry/blocks/tier-map/demo.jsx +222 -0
- package/registry/blocks/tier-map/index.js +24 -0
- package/registry/blocks/tier-map/tier-map.jsx +574 -0
- package/registry/blocks/tier-map/tier-model.js +318 -0
- package/registry/blocks/time-grid/README.md +305 -0
- package/registry/blocks/time-grid/block.json +10 -0
- package/registry/blocks/time-grid/demo.jsx +279 -0
- package/registry/blocks/time-grid/grid-model.js +322 -0
- package/registry/blocks/time-grid/index.js +25 -0
- package/registry/blocks/time-grid/time-grid.jsx +1325 -0
- package/registry/blocks/time-strip/README.md +141 -0
- package/registry/blocks/time-strip/block.json +10 -0
- package/registry/blocks/time-strip/demo.jsx +129 -0
- package/registry/blocks/time-strip/index.js +19 -0
- package/registry/blocks/time-strip/strip-model.js +178 -0
- package/registry/blocks/time-strip/time-strip.jsx +260 -0
- package/registry/blocks/top-bar/README.md +149 -0
- package/registry/blocks/top-bar/block.json +35 -0
- package/registry/blocks/top-bar/demo.jsx +78 -0
- package/registry/blocks/top-bar/index.js +5 -0
- package/registry/blocks/top-bar/probe-rules.js +74 -0
- package/registry/blocks/top-bar/top-bar-model.js +152 -0
- package/registry/blocks/top-bar/top-bar.jsx +244 -0
- package/registry/blocks/trigger-picker/README.md +98 -0
- package/registry/blocks/trigger-picker/block.json +38 -0
- package/registry/blocks/trigger-picker/demo.jsx +59 -0
- package/registry/blocks/trigger-picker/index.js +3 -0
- package/registry/blocks/trigger-picker/trigger-picker.jsx +374 -0
- package/registry/blocks/workflow-view/README.md +123 -0
- package/registry/blocks/workflow-view/block.json +10 -0
- package/registry/blocks/workflow-view/demo.jsx +90 -0
- package/registry/blocks/workflow-view/index.js +3 -0
- package/registry/blocks/workflow-view/workflow-view.jsx +203 -0
- package/src/install.mjs +77 -16
- package/src/registry.mjs +3 -0
- package/templates/next/browser/lib/data.js +6 -1
- package/templates/next/files/README.md +627 -24
- package/templates/next/files/check/automation-format.ts +2103 -0
- package/templates/next/files/check/automation-package.ts +142 -0
- package/templates/next/files/check/run.mjs +968 -9
- package/templates/next/files/components/ui/field.jsx +17 -4
- package/templates/next/files/components/ui/icon.jsx +13 -0
- package/templates/next/files/components/ui/index.js +2 -1
- package/templates/next/files/components/ui/layout.jsx +77 -2
- package/templates/next/files/components/ui/notice.jsx +70 -0
- package/templates/next/files/components/ui/page.jsx +124 -0
- package/templates/next/files/components/ui/stat.jsx +22 -7
- package/templates/next/files/components/ui/toast.jsx +6 -3
- package/templates/next/files/lib/automations.js +660 -0
- package/templates/next/files/lib/claw.js +241 -5
- package/templates/next/files/lib/door.js +92 -0
- package/templates/next/files/lib/hooks.js +1627 -8
- package/templates/next/files/lib/platform.js +529 -21
- package/templates/next/template.json +13 -3
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Days side by side on an hour grid, with the agent's proposals in pencil.
|
|
4
|
+
// What is already there is ink: events in their kind's colour, a lock on the
|
|
5
|
+
// ones another calendar owns. What the agent suggests is pencil: a hatched
|
|
6
|
+
// card on a dashed edge, on its hour, with a tag hanging off it that quotes
|
|
7
|
+
// the words it came from. Keep inks a block in (the same block, so its
|
|
8
|
+
// colours fade from pencil to ink; kept together they ink one by one), Keep
|
|
9
|
+
// all says what it will do before it does it, a clash is hatched where two
|
|
10
|
+
// things overlap, and a strip of goals can sit above the days.
|
|
11
|
+
//
|
|
12
|
+
// Props in, callbacks out: the block never reads or writes data. Moving a
|
|
13
|
+
// block, making one by dragging across free hours, dropping an item in from
|
|
14
|
+
// a list, Keep, Dismiss and Keep all each go out through one callback and
|
|
15
|
+
// come back as new props; a save that fails puts the block back and says so.
|
|
16
|
+
// README.md beside this file is the contract.
|
|
17
|
+
//
|
|
18
|
+
// Geometry never depends on data, so nothing shifts when rows arrive: the
|
|
19
|
+
// header, the hour rows and the columns are fixed; blocks are absolute.
|
|
20
|
+
// Narrow containers (a container query, no JS measuring) show one day at a
|
|
21
|
+
// time under a strip of the days.
|
|
22
|
+
|
|
23
|
+
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
24
|
+
import { Button, Icon, Notice, Skeleton, cx } from "../../ui";
|
|
25
|
+
import {
|
|
26
|
+
clamp,
|
|
27
|
+
clashesOf,
|
|
28
|
+
clock,
|
|
29
|
+
dateNum,
|
|
30
|
+
durationWords,
|
|
31
|
+
gridLanes,
|
|
32
|
+
keyChange,
|
|
33
|
+
lookOf,
|
|
34
|
+
namesOf,
|
|
35
|
+
newlyInked,
|
|
36
|
+
planKeepAll,
|
|
37
|
+
previewOf,
|
|
38
|
+
readItem,
|
|
39
|
+
snapFloor,
|
|
40
|
+
snapRound,
|
|
41
|
+
span,
|
|
42
|
+
tagPlacement,
|
|
43
|
+
toHHMM,
|
|
44
|
+
toMin,
|
|
45
|
+
walkProposal,
|
|
46
|
+
weekdayIndex,
|
|
47
|
+
} from "./grid-model";
|
|
48
|
+
|
|
49
|
+
export const TIME_GRID_MIME = "application/x-time-grid-item";
|
|
50
|
+
|
|
51
|
+
// The item being dragged in from outside, in this window. dragover cannot
|
|
52
|
+
// read dataTransfer (the browser protects it until drop), so the preview
|
|
53
|
+
// reads this instead.
|
|
54
|
+
let carried = null;
|
|
55
|
+
|
|
56
|
+
/** Spread onto a list item to make it droppable on a TimeGrid:
|
|
57
|
+
* <li {...timeGridDragProps({ id, title, minutes: 60 })}>… */
|
|
58
|
+
export function timeGridDragProps(item) {
|
|
59
|
+
return {
|
|
60
|
+
draggable: true,
|
|
61
|
+
onDragStart(e) {
|
|
62
|
+
carried = item;
|
|
63
|
+
try {
|
|
64
|
+
e.dataTransfer.setData(TIME_GRID_MIME, JSON.stringify(item));
|
|
65
|
+
e.dataTransfer.setData("text/plain", String(item?.title ?? ""));
|
|
66
|
+
e.dataTransfer.effectAllowed = "copyMove";
|
|
67
|
+
} catch {
|
|
68
|
+
// some browsers refuse custom types; `carried` still works
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
onDragEnd() {
|
|
72
|
+
carried = null;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- looks: tokens only, as literal class names so Tailwind sees each ----
|
|
78
|
+
|
|
79
|
+
const WASH = {
|
|
80
|
+
neutral: "border-border bg-muted",
|
|
81
|
+
primary: "border-primary bg-primary/15",
|
|
82
|
+
info: "border-info bg-info/15",
|
|
83
|
+
success: "border-success bg-success/15",
|
|
84
|
+
warning: "border-warning bg-warning/15",
|
|
85
|
+
danger: "border-destructive bg-destructive/15",
|
|
86
|
+
};
|
|
87
|
+
const WASH_HUE = { 1: "border-chart-1 bg-chart-1/15", 2: "border-chart-2 bg-chart-2/15", 3: "border-chart-3 bg-chart-3/15", 4: "border-chart-4 bg-chart-4/15", 5: "border-chart-5 bg-chart-5/15" };
|
|
88
|
+
const SOLID = "border-primary bg-primary text-primary-foreground";
|
|
89
|
+
|
|
90
|
+
const EDGE = {
|
|
91
|
+
neutral: "border-muted-foreground",
|
|
92
|
+
primary: "border-primary",
|
|
93
|
+
info: "border-info",
|
|
94
|
+
success: "border-success",
|
|
95
|
+
warning: "border-warning",
|
|
96
|
+
danger: "border-destructive",
|
|
97
|
+
};
|
|
98
|
+
const EDGE_HUE = { 1: "border-chart-1", 2: "border-chart-2", 3: "border-chart-3", 4: "border-chart-4", 5: "border-chart-5" };
|
|
99
|
+
|
|
100
|
+
const INK_TEXT = {
|
|
101
|
+
neutral: "text-muted-foreground",
|
|
102
|
+
primary: "text-primary",
|
|
103
|
+
info: "text-info",
|
|
104
|
+
success: "text-success",
|
|
105
|
+
warning: "text-warning",
|
|
106
|
+
danger: "text-destructive",
|
|
107
|
+
};
|
|
108
|
+
const INK_TEXT_HUE = { 1: "text-chart-1", 2: "text-chart-2", 3: "text-chart-3", 4: "text-chart-4", 5: "text-chart-5" };
|
|
109
|
+
|
|
110
|
+
const NOTE_TONE = {
|
|
111
|
+
neutral: "text-muted-foreground",
|
|
112
|
+
primary: "text-primary",
|
|
113
|
+
info: "text-info",
|
|
114
|
+
success: "text-success",
|
|
115
|
+
warning: "text-warning",
|
|
116
|
+
danger: "text-destructive",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const HATCH = "bg-[image:repeating-linear-gradient(135deg,transparent_0_5px,var(--color-border)_5px_6px)]";
|
|
120
|
+
const OFF_HATCH = "bg-[image:repeating-linear-gradient(135deg,transparent_0_6px,var(--color-border)_6px_7px)] bg-muted/40";
|
|
121
|
+
const CLASH_HATCH = "bg-[image:repeating-linear-gradient(135deg,var(--color-warning)_0_3px,transparent_3px_7px)]";
|
|
122
|
+
|
|
123
|
+
// Column templates, written out whole so Tailwind sees each class.
|
|
124
|
+
const WIDE_COLS = {
|
|
125
|
+
1: "@min-[36rem]:grid-cols-[3rem_repeat(1,minmax(0,1fr))]",
|
|
126
|
+
2: "@min-[36rem]:grid-cols-[3rem_repeat(2,minmax(0,1fr))]",
|
|
127
|
+
3: "@min-[36rem]:grid-cols-[3rem_repeat(3,minmax(0,1fr))]",
|
|
128
|
+
4: "@min-[36rem]:grid-cols-[3rem_repeat(4,minmax(0,1fr))]",
|
|
129
|
+
5: "@min-[36rem]:grid-cols-[3rem_repeat(5,minmax(0,1fr))]",
|
|
130
|
+
6: "@min-[36rem]:grid-cols-[3rem_repeat(6,minmax(0,1fr))]",
|
|
131
|
+
7: "@min-[36rem]:grid-cols-[3rem_repeat(7,minmax(0,1fr))]",
|
|
132
|
+
};
|
|
133
|
+
const STRIP_COLS = { 1: "grid-cols-1", 2: "grid-cols-2", 3: "grid-cols-3", 4: "grid-cols-4", 5: "grid-cols-5", 6: "grid-cols-6", 7: "grid-cols-7" };
|
|
134
|
+
|
|
135
|
+
const WORDS = {
|
|
136
|
+
allDay: "all day",
|
|
137
|
+
keep: "Keep",
|
|
138
|
+
dismiss: "Dismiss",
|
|
139
|
+
unkeep: "Back to pencil",
|
|
140
|
+
keepAll: "Keep all",
|
|
141
|
+
pencilled: "pencilled in, waiting for you",
|
|
142
|
+
unsure: "Unsure",
|
|
143
|
+
failed: "Didn’t add",
|
|
144
|
+
replaced: "Replaced",
|
|
145
|
+
was: "Was",
|
|
146
|
+
clash: "Clash",
|
|
147
|
+
locked: "locked",
|
|
148
|
+
drafting: "Pencilling in…",
|
|
149
|
+
newBlock: "New block",
|
|
150
|
+
empty: "Nothing on these days yet.",
|
|
151
|
+
retry: "Try again",
|
|
152
|
+
back: "went back: the change was not saved.",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const reducedMotion = () => typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
156
|
+
|
|
157
|
+
/** Words typed into, or a key meant for something else: J and K are left alone. */
|
|
158
|
+
function isTyping(el) {
|
|
159
|
+
if (!el || el.nodeType !== 1) return false;
|
|
160
|
+
return Boolean(el.closest?.("input, textarea, select, [contenteditable=''], [contenteditable='true']"));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const cut = (s) => String(s ?? "");
|
|
164
|
+
|
|
165
|
+
/** One look for one item: the classes of its shell. */
|
|
166
|
+
function shellOf(look, kind, pencilColor) {
|
|
167
|
+
const tone = kind.tone ?? "neutral";
|
|
168
|
+
const hue = kind.hue;
|
|
169
|
+
const edge = hue ? EDGE_HUE[hue] : (EDGE[tone] ?? EDGE.neutral);
|
|
170
|
+
if (look === "ink") {
|
|
171
|
+
if (kind.solid) return cx("border text-primary-foreground", SOLID);
|
|
172
|
+
return cx("border text-foreground", hue ? WASH_HUE[hue] : (WASH[tone] ?? WASH.neutral));
|
|
173
|
+
}
|
|
174
|
+
const pencil = cx("bg-card text-foreground", HATCH);
|
|
175
|
+
if (look === "pencil") return cx(pencil, "border-[1.5px] border-dashed", pencilColor === "kind" ? edge : EDGE.neutral);
|
|
176
|
+
if (look === "unsure") return cx(pencil, "border-2 border-dotted", pencilColor === "kind" ? edge : EDGE.neutral);
|
|
177
|
+
if (look === "failed") return "border-[1.5px] border-solid border-destructive bg-card text-foreground";
|
|
178
|
+
if (look === "replaced") return cx(pencil, "border-[1.5px] border-dashed border-muted-foreground opacity-60");
|
|
179
|
+
return "border-[1.5px] border-dotted border-input bg-transparent text-muted-foreground"; // was
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function labelToneOf(look, kind, clashed) {
|
|
183
|
+
if (clashed) return "text-warning";
|
|
184
|
+
if (look === "failed") return "text-destructive";
|
|
185
|
+
if (look === "ink") return "text-inherit";
|
|
186
|
+
return kind.hue ? INK_TEXT_HUE[kind.hue] : (INK_TEXT[kind.tone ?? "neutral"] ?? INK_TEXT.neutral);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- the inside of a block ------------------------------------------------
|
|
190
|
+
|
|
191
|
+
function Face({ item, h, kind, look, label, fmt, clashed, pencilClassName, words }) {
|
|
192
|
+
const compact = h < 26;
|
|
193
|
+
const pencilish = look !== "ink";
|
|
194
|
+
const struck = look === "replaced" || item.struck;
|
|
195
|
+
const chip = label ? (
|
|
196
|
+
<span className={cx("min-w-0 shrink-0 whitespace-nowrap text-2xs font-semibold leading-tight", labelToneOf(look, kind, clashed))}>{label}</span>
|
|
197
|
+
) : null;
|
|
198
|
+
const icon = item.locked || kind.locked ? "lock" : item.done ? "check" : (item.icon ?? kind.icon);
|
|
199
|
+
return (
|
|
200
|
+
<span className={cx("flex h-full min-w-0 flex-col overflow-hidden text-left", compact ? "justify-center px-1.5 py-0" : "gap-0.5 px-2 py-1")}>
|
|
201
|
+
{!compact && chip}
|
|
202
|
+
<span className={cx("flex min-w-0 gap-1", compact ? "items-center" : "items-start")}>
|
|
203
|
+
{compact ? chip : null}
|
|
204
|
+
{icon ? <Icon name={icon} size={12} className={cx("shrink-0", !compact && "mt-0.5")} /> : null}
|
|
205
|
+
<span
|
|
206
|
+
className={cx(
|
|
207
|
+
"min-w-0",
|
|
208
|
+
compact ? "truncate" : "line-clamp-3",
|
|
209
|
+
pencilish && pencilClassName,
|
|
210
|
+
"text-xs font-medium leading-tight",
|
|
211
|
+
struck && "line-through decoration-1",
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
{item.title}
|
|
215
|
+
</span>
|
|
216
|
+
</span>
|
|
217
|
+
{!compact && h >= 38 ? (
|
|
218
|
+
<span data-nums className={cx("truncate font-mono text-2xs leading-tight", look === "ink" && kind.solid ? "" : "text-muted-foreground")}>
|
|
219
|
+
{span(item.s, item.e, fmt)}
|
|
220
|
+
{item.caption ? `, ${item.caption}` : ""}
|
|
221
|
+
</span>
|
|
222
|
+
) : null}
|
|
223
|
+
{!compact && h >= 62 && item.sub ? <span className="line-clamp-2 text-2xs leading-tight text-muted-foreground">{item.sub}</span> : null}
|
|
224
|
+
{!compact && h >= 62 && item.note && pencilish ? (
|
|
225
|
+
<span className="mt-auto flex min-w-0 items-center gap-1 text-2xs leading-tight text-muted-foreground">
|
|
226
|
+
<Icon name="pencil" size={10} className="shrink-0" />
|
|
227
|
+
<span className="truncate">{item.note}</span>
|
|
228
|
+
</span>
|
|
229
|
+
) : null}
|
|
230
|
+
<span className="sr-only">{look === "pencil" || look === "unsure" ? words.pencilled : ""}</span>
|
|
231
|
+
</span>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Keep and Dismiss (or Back to pencil), small enough for a block. */
|
|
236
|
+
function MiniActions({ item, look, onAction, busy, words, tall, show }) {
|
|
237
|
+
const run = (e, id) => {
|
|
238
|
+
e.stopPropagation();
|
|
239
|
+
onAction?.(item, id);
|
|
240
|
+
};
|
|
241
|
+
const stop = (e) => e.stopPropagation();
|
|
242
|
+
const base =
|
|
243
|
+
"inline-flex h-5 items-center justify-center gap-1 rounded-[4px] border px-1.5 text-2xs font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
|
|
244
|
+
if (look === "ink") {
|
|
245
|
+
return (
|
|
246
|
+
<span className={cx("absolute right-1 top-1 z-10 flex gap-0.5", !show && "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100")}>
|
|
247
|
+
<button type="button" disabled={busy} onPointerDown={stop} onClick={(e) => run(e, "unkeep")} className={cx(base, "border-border bg-card text-foreground")}>
|
|
248
|
+
{words.unkeep}
|
|
249
|
+
</button>
|
|
250
|
+
</span>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return (
|
|
254
|
+
<span className={cx("absolute z-10 flex gap-0.5", tall ? "inset-x-1.5 bottom-1" : "right-0.5 top-0.5", !tall && !show && "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100")}>
|
|
255
|
+
<button type="button" disabled={busy} onPointerDown={stop} onClick={(e) => run(e, "keep")} className={cx(base, "border-primary bg-primary text-primary-foreground")}>
|
|
256
|
+
<Icon name="check" size={10} />
|
|
257
|
+
<span className={tall ? "" : "sr-only"}>{words.keep}</span>
|
|
258
|
+
</button>
|
|
259
|
+
<button type="button" disabled={busy} onPointerDown={stop} onClick={(e) => run(e, "dismiss")} aria-label={words.dismiss} title={words.dismiss} className={cx(base, "border-border bg-card px-1 text-foreground")}>
|
|
260
|
+
<Icon name="x" size={10} />
|
|
261
|
+
</button>
|
|
262
|
+
</span>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- the grid ---------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
export function TimeGrid({
|
|
269
|
+
days = [],
|
|
270
|
+
events = [],
|
|
271
|
+
proposals = [],
|
|
272
|
+
kinds = {},
|
|
273
|
+
today,
|
|
274
|
+
now,
|
|
275
|
+
startHour = 5,
|
|
276
|
+
endHour = 21,
|
|
277
|
+
snap = 15,
|
|
278
|
+
hourHeight = 40,
|
|
279
|
+
workStart,
|
|
280
|
+
workEnd,
|
|
281
|
+
scrollTo,
|
|
282
|
+
height,
|
|
283
|
+
locale = "en-GB",
|
|
284
|
+
formatTime = clock,
|
|
285
|
+
corner,
|
|
286
|
+
above,
|
|
287
|
+
dayNotes,
|
|
288
|
+
renderDayHeader,
|
|
289
|
+
pencilColor = "neutral",
|
|
290
|
+
pencilClassName,
|
|
291
|
+
tags = "selected",
|
|
292
|
+
clashAmong = false,
|
|
293
|
+
keepAll = false,
|
|
294
|
+
selectedId,
|
|
295
|
+
defaultSelectedId = null,
|
|
296
|
+
onSelect,
|
|
297
|
+
onOpen,
|
|
298
|
+
onAction,
|
|
299
|
+
onKeepAll,
|
|
300
|
+
onChange,
|
|
301
|
+
onCompose,
|
|
302
|
+
onDrop,
|
|
303
|
+
explainBlock,
|
|
304
|
+
explainOffHours,
|
|
305
|
+
draft,
|
|
306
|
+
drafting,
|
|
307
|
+
status = "ready",
|
|
308
|
+
error,
|
|
309
|
+
onRetry,
|
|
310
|
+
readOnly = false,
|
|
311
|
+
keys = true,
|
|
312
|
+
words: wordsIn,
|
|
313
|
+
emptyText,
|
|
314
|
+
label = "Week",
|
|
315
|
+
className,
|
|
316
|
+
}) {
|
|
317
|
+
const words = useMemo(() => ({ ...WORDS, ...(wordsIn ?? {}) }), [wordsIn]);
|
|
318
|
+
const keepWordsId = useId();
|
|
319
|
+
const list = days;
|
|
320
|
+
const lo = startHour * 60;
|
|
321
|
+
const hi = endHour * 60;
|
|
322
|
+
const ppm = hourHeight / 60;
|
|
323
|
+
const bodyHeight = (endHour - startHour) * hourHeight;
|
|
324
|
+
const ws = toMin(workStart);
|
|
325
|
+
const we = toMin(workEnd);
|
|
326
|
+
const box = { lo, hi, ws, we, snap };
|
|
327
|
+
const editable = !readOnly && Boolean(onChange);
|
|
328
|
+
|
|
329
|
+
const [innerSel, setInnerSel] = useState(defaultSelectedId);
|
|
330
|
+
const sel = selectedId !== undefined ? selectedId : innerSel;
|
|
331
|
+
const select = (id) => {
|
|
332
|
+
if (selectedId === undefined) setInnerSel(id);
|
|
333
|
+
onSelect?.(id);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const [day, setDay] = useState(() => (list.includes(today) ? today : list[0]));
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (!list.includes(day)) setDay(list.includes(today) ? today : list[0]);
|
|
339
|
+
}, [list.join(","), today]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
340
|
+
|
|
341
|
+
const root = useRef(null);
|
|
342
|
+
const scroller = useRef(null);
|
|
343
|
+
const cols = useRef(new Map()); // date → the day's body element
|
|
344
|
+
const gesture = useRef(null);
|
|
345
|
+
const suppressClick = useRef(false);
|
|
346
|
+
const latest = useRef({});
|
|
347
|
+
const [preview, setPreview] = useState(null);
|
|
348
|
+
const [overrides, setOverrides] = useState(() => new Map());
|
|
349
|
+
const [pending, setPending] = useState([]); // drops waiting for their row
|
|
350
|
+
const [dropHint, setDropHint] = useState(null);
|
|
351
|
+
const [note, setNote] = useState(null); // { date, minute, node }
|
|
352
|
+
const [problem, setProblem] = useState(null); // { message, retry }
|
|
353
|
+
const [busy, setBusy] = useState(() => new Set());
|
|
354
|
+
const [keepingAll, setKeepingAll] = useState(false);
|
|
355
|
+
const [clockMin, setClockMin] = useState(null);
|
|
356
|
+
const [, tick] = useState(0);
|
|
357
|
+
|
|
358
|
+
// The now line: the viewer's clock after mount (the server's is not
|
|
359
|
+
// theirs), unless the page says what time it is.
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (now != null) return undefined;
|
|
362
|
+
const t = () => {
|
|
363
|
+
const d = new Date();
|
|
364
|
+
setClockMin(d.getHours() * 60 + d.getMinutes());
|
|
365
|
+
};
|
|
366
|
+
t();
|
|
367
|
+
const id = setInterval(t, 60_000);
|
|
368
|
+
return () => clearInterval(id);
|
|
369
|
+
}, [now]);
|
|
370
|
+
const nowMin = now != null ? toMin(now) : clockMin;
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
if (!note) return undefined;
|
|
374
|
+
const id = setTimeout(() => setNote(null), 9000);
|
|
375
|
+
return () => clearTimeout(id);
|
|
376
|
+
}, [note]);
|
|
377
|
+
|
|
378
|
+
// Open on the working day when the hours scroll (a grid with a height).
|
|
379
|
+
const scrolledByHand = useRef(false);
|
|
380
|
+
useLayoutEffect(() => {
|
|
381
|
+
const el = scroller.current;
|
|
382
|
+
if (!el || !height) return undefined;
|
|
383
|
+
const target = Math.max(0, ((toMin(scrollTo) ?? ws ?? 8 * 60) - lo) * ppm - 12);
|
|
384
|
+
const apply = () => {
|
|
385
|
+
if (!scrolledByHand.current && scroller.current) scroller.current.scrollTop = target;
|
|
386
|
+
};
|
|
387
|
+
apply();
|
|
388
|
+
const id = setTimeout(apply, 120);
|
|
389
|
+
const mark = () => {
|
|
390
|
+
if (Math.abs((scroller.current?.scrollTop ?? target) - target) > 2) scrolledByHand.current = true;
|
|
391
|
+
};
|
|
392
|
+
el.addEventListener("scroll", mark, { passive: true });
|
|
393
|
+
return () => {
|
|
394
|
+
clearTimeout(id);
|
|
395
|
+
el.removeEventListener("scroll", mark);
|
|
396
|
+
};
|
|
397
|
+
}, [status]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
398
|
+
|
|
399
|
+
// ---- what is drawn: ink and pencil in one list, so a block that is kept
|
|
400
|
+
// stays the same element and its colours fade from pencil to ink.
|
|
401
|
+
const items = useMemo(() => {
|
|
402
|
+
const out = [];
|
|
403
|
+
const seen = new Set();
|
|
404
|
+
const put = (raw, pencil) => {
|
|
405
|
+
const it = readItem(raw, { days: list, lo, hi, snap, pencil });
|
|
406
|
+
if (!it || seen.has(it.id)) return;
|
|
407
|
+
seen.add(it.id);
|
|
408
|
+
const o = overrides.get(it.id);
|
|
409
|
+
if (o && !it.allDay) {
|
|
410
|
+
out.push({ ...it, date: o.date, s: toMin(o.start), e: toMin(o.end) });
|
|
411
|
+
} else out.push(it);
|
|
412
|
+
};
|
|
413
|
+
for (const r of events ?? []) put(r, false);
|
|
414
|
+
for (const r of proposals ?? []) put(r, true);
|
|
415
|
+
return out;
|
|
416
|
+
}, [events, proposals, overrides, list.join(","), lo, hi, snap]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
417
|
+
|
|
418
|
+
const looks = useMemo(() => new Map(items.map((i) => [i.id, lookOf(i)])), [items]);
|
|
419
|
+
const clashes = useMemo(() => clashesOf(items, { among: clashAmong }), [items, clashAmong]);
|
|
420
|
+
const plan = useMemo(() => planKeepAll(items, { clashes, kinds, skip: keepAll && keepAll.skip ? keepAll.skip : ["clash"], fmt: formatTime }), [items, clashes, kinds, keepAll, formatTime]);
|
|
421
|
+
|
|
422
|
+
// Blocks that were pencil and are ink now, in reading order: they ink in one by one.
|
|
423
|
+
const before = useRef(new Map());
|
|
424
|
+
const fresh = newlyInked(before.current, items, list);
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
before.current = new Map(looks);
|
|
427
|
+
if (fresh.length < 2) return undefined;
|
|
428
|
+
const id = setTimeout(() => tick((n) => n + 1), 220 * fresh.length + 900);
|
|
429
|
+
return () => clearTimeout(id);
|
|
430
|
+
}); // runs after every render on purpose: `before` must always be the render just drawn
|
|
431
|
+
|
|
432
|
+
// A saved change stays on screen until the rows carry it.
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
latest.current.items = items;
|
|
435
|
+
setOverrides((cur) => {
|
|
436
|
+
if (!cur.size) return cur;
|
|
437
|
+
const next = new Map(cur);
|
|
438
|
+
const rows = [...(events ?? []), ...(proposals ?? [])];
|
|
439
|
+
for (const [id, o] of cur) {
|
|
440
|
+
const b = rows.find((x) => String(x.id) === id);
|
|
441
|
+
if (!b || (b.date === o.date && toMin(b.start) === toMin(o.start) && toMin(b.end) === toMin(o.end))) next.delete(id);
|
|
442
|
+
}
|
|
443
|
+
return next.size === cur.size ? cur : next;
|
|
444
|
+
});
|
|
445
|
+
setPending((cur) => (cur.length ? cur.filter((p) => !(p.done || matches(p, [...(events ?? []), ...(proposals ?? [])]))) : cur));
|
|
446
|
+
}, [events, proposals]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
447
|
+
|
|
448
|
+
function matches(p, rows) {
|
|
449
|
+
return rows.some((b) => b.date === p.date && toMin(b.start) === p.start && toMin(b.end) === p.end);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const byDay = useMemo(() => {
|
|
453
|
+
const map = new Map(list.map((d) => [d, { timed: [], allDay: [], was: [] }]));
|
|
454
|
+
for (const it of items) {
|
|
455
|
+
const slot = map.get(it.date);
|
|
456
|
+
if (!slot) continue;
|
|
457
|
+
if (it.allDay) slot.allDay.push(it);
|
|
458
|
+
else if (looks.get(it.id) === "was") slot.was.push(it);
|
|
459
|
+
else slot.timed.push(it);
|
|
460
|
+
}
|
|
461
|
+
for (const slot of map.values()) slot.lanes = gridLanes(slot.timed.map((b) => ({ id: b.id, s: b.s, e: b.e })));
|
|
462
|
+
return map;
|
|
463
|
+
}, [items, looks, list.join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
464
|
+
|
|
465
|
+
const kindOf = (it) => kinds[it.kind] ?? {};
|
|
466
|
+
const waitingCount = plan.total;
|
|
467
|
+
|
|
468
|
+
// ---- geometry: pointer → (day, minute)
|
|
469
|
+
function slotAt(x, y, fixedDate) {
|
|
470
|
+
let date = fixedDate ?? null;
|
|
471
|
+
let rect = null;
|
|
472
|
+
if (date) rect = cols.current.get(date)?.getBoundingClientRect() ?? null;
|
|
473
|
+
else {
|
|
474
|
+
let best = null;
|
|
475
|
+
for (const d of list) {
|
|
476
|
+
const r = cols.current.get(d)?.getBoundingClientRect();
|
|
477
|
+
if (!r || r.width === 0) continue;
|
|
478
|
+
const dist = x < r.left ? r.left - x : x > r.right ? x - r.right : 0;
|
|
479
|
+
if (!best || dist < best.dist) best = { d, r, dist };
|
|
480
|
+
}
|
|
481
|
+
if (best) {
|
|
482
|
+
date = best.d;
|
|
483
|
+
rect = best.r;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (!date || !rect) return null;
|
|
487
|
+
return { date, minute: lo + (y - rect.top) / ppm };
|
|
488
|
+
}
|
|
489
|
+
const offHours = (m) => (ws != null && m < ws) || (we != null && m >= we);
|
|
490
|
+
|
|
491
|
+
// ---- gestures: window listeners, so a drag that leaves the grid (or a
|
|
492
|
+
// pointer event dispatched from a script) still lands.
|
|
493
|
+
const listeners = useRef(null);
|
|
494
|
+
if (!listeners.current) {
|
|
495
|
+
listeners.current = {
|
|
496
|
+
move: (e) => latest.current.onMove?.(e),
|
|
497
|
+
up: (e) => latest.current.onUp?.(e),
|
|
498
|
+
cancel: () => latest.current.cancel?.(),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function attach() {
|
|
502
|
+
window.addEventListener("pointermove", listeners.current.move);
|
|
503
|
+
window.addEventListener("pointerup", listeners.current.up);
|
|
504
|
+
window.addEventListener("pointercancel", listeners.current.cancel);
|
|
505
|
+
window.addEventListener("blur", listeners.current.cancel);
|
|
506
|
+
}
|
|
507
|
+
function detach() {
|
|
508
|
+
window.removeEventListener("pointermove", listeners.current.move);
|
|
509
|
+
window.removeEventListener("pointerup", listeners.current.up);
|
|
510
|
+
window.removeEventListener("pointercancel", listeners.current.cancel);
|
|
511
|
+
window.removeEventListener("blur", listeners.current.cancel);
|
|
512
|
+
stopAutoScroll();
|
|
513
|
+
}
|
|
514
|
+
useEffect(() => () => detach(), []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
515
|
+
|
|
516
|
+
// Auto-scroll near the scroller's edges while dragging. An interval, not
|
|
517
|
+
// animation frames: a hidden tab never runs those.
|
|
518
|
+
const autoScroll = useRef(null);
|
|
519
|
+
function stopAutoScroll() {
|
|
520
|
+
if (autoScroll.current) clearInterval(autoScroll.current.id);
|
|
521
|
+
autoScroll.current = null;
|
|
522
|
+
}
|
|
523
|
+
function maybeAutoScroll(y) {
|
|
524
|
+
const el = scroller.current;
|
|
525
|
+
if (!el || !height) return;
|
|
526
|
+
const r = el.getBoundingClientRect();
|
|
527
|
+
const dir = y < r.top + 36 ? -1 : y > r.bottom - 36 ? 1 : 0;
|
|
528
|
+
if (!dir) return stopAutoScroll();
|
|
529
|
+
if (autoScroll.current?.dir === dir) return;
|
|
530
|
+
stopAutoScroll();
|
|
531
|
+
autoScroll.current = {
|
|
532
|
+
dir,
|
|
533
|
+
id: setInterval(() => {
|
|
534
|
+
el.scrollTop += dir * 14;
|
|
535
|
+
const g = gesture.current;
|
|
536
|
+
if (g?.active && g.last) {
|
|
537
|
+
const p = previewOf(g, slotAt(g.last.x, g.last.y, g.kind === "move" ? undefined : g.date), box);
|
|
538
|
+
if (p) {
|
|
539
|
+
g.preview = p;
|
|
540
|
+
setPreview(p);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}, 30),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const pointerPreview = (g, x, y) => previewOf(g, slotAt(x, y, g.kind === "move" ? undefined : g.date), box);
|
|
548
|
+
|
|
549
|
+
latest.current.onMove = (e) => {
|
|
550
|
+
const g = gesture.current;
|
|
551
|
+
if (!g || (e.pointerId != null && g.pointerId != null && e.pointerId !== g.pointerId)) return;
|
|
552
|
+
// Released outside the window: the page never saw the pointerup.
|
|
553
|
+
if (e.pointerType === "mouse" && e.buttons === 0) return latest.current.cancel();
|
|
554
|
+
g.last = { x: e.clientX, y: e.clientY };
|
|
555
|
+
if (!g.active) {
|
|
556
|
+
if (Math.hypot(e.clientX - g.x0, e.clientY - g.y0) < 4) return;
|
|
557
|
+
g.active = true;
|
|
558
|
+
if (g.kind === "held") {
|
|
559
|
+
// A block that cannot move: say why, once, where it is.
|
|
560
|
+
gesture.current = null;
|
|
561
|
+
detach();
|
|
562
|
+
const node = explainBlock?.(g.item);
|
|
563
|
+
if (node) setNote({ date: g.item.date, minute: g.item.s, node });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const p = pointerPreview(g, e.clientX, e.clientY);
|
|
568
|
+
if (p) {
|
|
569
|
+
g.preview = p;
|
|
570
|
+
setPreview(p);
|
|
571
|
+
}
|
|
572
|
+
maybeAutoScroll(e.clientY);
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
latest.current.onUp = (e) => {
|
|
576
|
+
const g = gesture.current;
|
|
577
|
+
if (!g || (e.pointerId != null && g.pointerId != null && e.pointerId !== g.pointerId)) return;
|
|
578
|
+
gesture.current = null;
|
|
579
|
+
detach();
|
|
580
|
+
if (g.kind === "held") return;
|
|
581
|
+
const p = g.active ? (pointerPreview(g, e.clientX, e.clientY) ?? g.preview) : null;
|
|
582
|
+
setPreview(null);
|
|
583
|
+
if (g.kind === "create") {
|
|
584
|
+
const range = p ?? {
|
|
585
|
+
date: g.date,
|
|
586
|
+
start: clamp(g.anchor, ws ?? lo, (we ?? hi) - 60),
|
|
587
|
+
end: clamp(g.anchor + 60, (ws ?? lo) + snap, we ?? hi),
|
|
588
|
+
};
|
|
589
|
+
onCompose?.({ date: range.date, start: toHHMM(range.start), end: toHHMM(range.end) });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (!g.active || !p) return;
|
|
593
|
+
suppressClick.current = true;
|
|
594
|
+
setTimeout(() => {
|
|
595
|
+
suppressClick.current = false;
|
|
596
|
+
}, 0);
|
|
597
|
+
if (p.date === g.item.date && p.start === g.s0 && p.end === g.e0) return;
|
|
598
|
+
commitChange(g.item, p);
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
latest.current.cancel = () => {
|
|
602
|
+
if (!gesture.current) return;
|
|
603
|
+
gesture.current = null;
|
|
604
|
+
detach();
|
|
605
|
+
setPreview(null);
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const fail = (message, retry) => setProblem({ message, retry });
|
|
609
|
+
|
|
610
|
+
async function commitChange(item, p) {
|
|
611
|
+
const id = item.id;
|
|
612
|
+
const next = { date: p.date, start: toHHMM(p.start), end: toHHMM(p.end) };
|
|
613
|
+
setProblem(null);
|
|
614
|
+
setOverrides((m) => new Map(m).set(id, next));
|
|
615
|
+
let result;
|
|
616
|
+
try {
|
|
617
|
+
result = await onChange?.(item, { ...next, mode: p.mode, from: { date: item.date, start: toHHMM(item.s), end: toHHMM(item.e) } }, { proposal: item.pencil });
|
|
618
|
+
} catch (err) {
|
|
619
|
+
result = { ok: false, error: err?.message ?? String(err) };
|
|
620
|
+
}
|
|
621
|
+
if (result && result.ok === false) {
|
|
622
|
+
setOverrides((m) => {
|
|
623
|
+
const n = new Map(m);
|
|
624
|
+
n.delete(id);
|
|
625
|
+
return n;
|
|
626
|
+
});
|
|
627
|
+
fail(`“${item.title}” ${words.back}${result.error ? ` ${result.error}` : ""}`, () => commitChange(item, p));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function runAction(item, id) {
|
|
632
|
+
if (!onAction || readOnly) return;
|
|
633
|
+
setProblem(null);
|
|
634
|
+
setBusy((s) => new Set(s).add(item.id));
|
|
635
|
+
let result;
|
|
636
|
+
try {
|
|
637
|
+
result = await onAction(item, id);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
result = { ok: false, error: err?.message ?? String(err) };
|
|
640
|
+
}
|
|
641
|
+
setBusy((s) => {
|
|
642
|
+
const n = new Set(s);
|
|
643
|
+
n.delete(item.id);
|
|
644
|
+
return n;
|
|
645
|
+
});
|
|
646
|
+
if (result && result.ok === false) fail(`“${item.title}”: ${result.error ?? "that did not save."}`, () => runAction(item, id));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function runKeepAll() {
|
|
650
|
+
if (!onKeepAll || readOnly || !plan.keep.length) return;
|
|
651
|
+
setProblem(null);
|
|
652
|
+
setKeepingAll(true);
|
|
653
|
+
let result;
|
|
654
|
+
try {
|
|
655
|
+
result = await onKeepAll(plan);
|
|
656
|
+
} catch (err) {
|
|
657
|
+
result = { ok: false, error: err?.message ?? String(err) };
|
|
658
|
+
}
|
|
659
|
+
setKeepingAll(false);
|
|
660
|
+
if (result && result.ok === false) fail(result.error ?? "None of it was kept.", runKeepAll);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function onBlockPointerDown(e, it) {
|
|
664
|
+
if (gesture.current) latest.current.cancel();
|
|
665
|
+
if (it.allDay) return;
|
|
666
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
667
|
+
const held = !editable || it.draggable === false || it.locked || kindOf(it).locked || looks.get(it.id) === "failed" || looks.get(it.id) === "replaced";
|
|
668
|
+
if (held) {
|
|
669
|
+
gesture.current = { kind: "held", item: it, x0: e.clientX, y0: e.clientY, pointerId: e.pointerId ?? null, active: false };
|
|
670
|
+
attach();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const edge = e.target.closest?.("[data-edge]")?.getAttribute("data-edge");
|
|
674
|
+
const at = slotAt(e.clientX, e.clientY, it.date);
|
|
675
|
+
gesture.current = {
|
|
676
|
+
kind: edge === "start" ? "start" : edge === "end" ? "end" : "move",
|
|
677
|
+
id: it.id,
|
|
678
|
+
item: it,
|
|
679
|
+
date: it.date,
|
|
680
|
+
s0: it.s,
|
|
681
|
+
e0: it.e,
|
|
682
|
+
offset: at ? at.minute - it.s : 0,
|
|
683
|
+
x0: e.clientX,
|
|
684
|
+
y0: e.clientY,
|
|
685
|
+
pointerId: e.pointerId ?? null,
|
|
686
|
+
active: false,
|
|
687
|
+
};
|
|
688
|
+
attach();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function onColumnPointerDown(e, date) {
|
|
692
|
+
if (e.target !== e.currentTarget) return;
|
|
693
|
+
if (gesture.current) latest.current.cancel();
|
|
694
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
695
|
+
select(null);
|
|
696
|
+
const at = slotAt(e.clientX, e.clientY, date);
|
|
697
|
+
if (!at) return;
|
|
698
|
+
setNote(null);
|
|
699
|
+
if (offHours(at.minute) && explainOffHours) {
|
|
700
|
+
const node = explainOffHours({ date, minute: at.minute, before: ws != null && at.minute < ws });
|
|
701
|
+
if (node) setNote({ date, minute: Math.round(at.minute), node });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (!onCompose || readOnly) return;
|
|
705
|
+
e.preventDefault();
|
|
706
|
+
gesture.current = {
|
|
707
|
+
kind: "create",
|
|
708
|
+
date,
|
|
709
|
+
anchor: clamp(snapFloor(at.minute, snap), ws ?? lo, (we ?? hi) - snap),
|
|
710
|
+
x0: e.clientX,
|
|
711
|
+
y0: e.clientY,
|
|
712
|
+
pointerId: e.pointerId ?? null,
|
|
713
|
+
active: false,
|
|
714
|
+
};
|
|
715
|
+
attach();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Esc cancels a drag or a note, and clears the selection.
|
|
719
|
+
useEffect(() => {
|
|
720
|
+
const onKey = (e) => {
|
|
721
|
+
if (e.key !== "Escape") return;
|
|
722
|
+
if (gesture.current) latest.current.cancel();
|
|
723
|
+
else setNote(null);
|
|
724
|
+
};
|
|
725
|
+
window.addEventListener("keydown", onKey);
|
|
726
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
727
|
+
}, []);
|
|
728
|
+
|
|
729
|
+
// ---- drops from outside (a task list beside the grid)
|
|
730
|
+
function onDragOver(e) {
|
|
731
|
+
if (!onDrop || readOnly) return;
|
|
732
|
+
const ours = carried || [...(e.dataTransfer?.types ?? [])].includes(TIME_GRID_MIME);
|
|
733
|
+
if (!ours) return;
|
|
734
|
+
e.preventDefault();
|
|
735
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
|
736
|
+
const at = slotAt(e.clientX, e.clientY);
|
|
737
|
+
if (!at) return;
|
|
738
|
+
const minutes = clamp(Number(carried?.minutes) || 60, snap, hi - lo);
|
|
739
|
+
const s = clamp(snapFloor(at.minute, snap), lo, hi - minutes);
|
|
740
|
+
setDropHint({ date: at.date, start: s, end: s + minutes, title: carried?.title ?? "" });
|
|
741
|
+
}
|
|
742
|
+
function onDragLeave(e) {
|
|
743
|
+
if (!e.currentTarget.contains(e.relatedTarget)) setDropHint(null);
|
|
744
|
+
}
|
|
745
|
+
async function onDropHere(e) {
|
|
746
|
+
if (!onDrop || readOnly) return;
|
|
747
|
+
let item = carried;
|
|
748
|
+
try {
|
|
749
|
+
const raw = e.dataTransfer?.getData(TIME_GRID_MIME);
|
|
750
|
+
if (raw) item = JSON.parse(raw);
|
|
751
|
+
} catch {
|
|
752
|
+
// keep `carried`
|
|
753
|
+
}
|
|
754
|
+
const hint = dropHint;
|
|
755
|
+
setDropHint(null);
|
|
756
|
+
carried = null;
|
|
757
|
+
if (!item) return;
|
|
758
|
+
e.preventDefault();
|
|
759
|
+
const at = slotAt(e.clientX, e.clientY);
|
|
760
|
+
const minutes = clamp(Number(item.minutes) || 60, snap, hi - lo);
|
|
761
|
+
const s = at ? clamp(snapFloor(at.minute, snap), lo, hi - minutes) : hint?.start;
|
|
762
|
+
const date = at?.date ?? hint?.date;
|
|
763
|
+
if (date == null || s == null) return;
|
|
764
|
+
const key = `drop-${item.id}-${Date.now()}`;
|
|
765
|
+
const ghost = { key, date, start: s, end: s + minutes, title: item.title ?? "", done: false };
|
|
766
|
+
setPending((cur) => [...cur, ghost]);
|
|
767
|
+
let result;
|
|
768
|
+
try {
|
|
769
|
+
result = await onDrop(item, { date, start: toHHMM(s), end: toHHMM(s + minutes) });
|
|
770
|
+
} catch (err) {
|
|
771
|
+
result = { ok: false, error: err?.message ?? String(err) };
|
|
772
|
+
}
|
|
773
|
+
if (result && result.ok === false) {
|
|
774
|
+
setPending((cur) => cur.filter((p) => p.key !== key));
|
|
775
|
+
fail(`“${item.title}” was not placed.${result.error ? ` ${result.error}` : ""}`);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (matches(ghost, [...(events ?? []), ...(proposals ?? [])])) setPending((cur) => cur.filter((p) => p.key !== key));
|
|
779
|
+
else setPending((cur) => cur.map((p) => (p.key === key ? { ...p, done: true } : p)));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ---- keyboard: a focused block moves by the snap, a day, or grows
|
|
783
|
+
function onBlockKeyDown(e, it) {
|
|
784
|
+
if (!editable || it.draggable === false || it.locked || kindOf(it).locked || it.allDay) return;
|
|
785
|
+
const p = keyChange(e.key, e.shiftKey, it, { days: list, lo, hi, snap });
|
|
786
|
+
if (!p) return;
|
|
787
|
+
e.preventDefault();
|
|
788
|
+
if (p.date === it.date && p.start === it.s && p.end === it.e) return;
|
|
789
|
+
commitChange(it, p);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// J and K walk the proposals that are waiting.
|
|
793
|
+
function onRootKeyDown(e) {
|
|
794
|
+
if (!keys || e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || isTyping(e.target)) return;
|
|
795
|
+
if (e.key === "Escape") {
|
|
796
|
+
if (sel != null) select(null);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const dir = e.key === "j" || e.key === "J" ? 1 : e.key === "k" || e.key === "K" ? -1 : 0;
|
|
800
|
+
if (!dir) return;
|
|
801
|
+
const id = walkProposal(items, list, sel, dir);
|
|
802
|
+
if (id == null) return;
|
|
803
|
+
e.preventDefault();
|
|
804
|
+
select(id);
|
|
805
|
+
const it = items.find((i) => i.id === id);
|
|
806
|
+
if (it && list.includes(it.date)) setDay(it.date);
|
|
807
|
+
requestAnimationFrame(() => {
|
|
808
|
+
const el = root.current?.querySelector(`[data-block="${CSS.escape(id)}"]`);
|
|
809
|
+
el?.scrollIntoView?.({ block: "nearest", behavior: reducedMotion() ? "auto" : "smooth" });
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ---- drawing
|
|
814
|
+
const isToday = (d) => d === today;
|
|
815
|
+
const top = (min) => (min - lo) * ppm;
|
|
816
|
+
const fmtDay = useMemo(() => {
|
|
817
|
+
try {
|
|
818
|
+
return new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" });
|
|
819
|
+
} catch {
|
|
820
|
+
return new Intl.DateTimeFormat("en-GB", { weekday: "short", timeZone: "UTC" });
|
|
821
|
+
}
|
|
822
|
+
}, [locale]);
|
|
823
|
+
const fmtLong = useMemo(() => {
|
|
824
|
+
try {
|
|
825
|
+
return new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" });
|
|
826
|
+
} catch {
|
|
827
|
+
return new Intl.DateTimeFormat("en-GB", { weekday: "long", timeZone: "UTC" });
|
|
828
|
+
}
|
|
829
|
+
}, [locale]);
|
|
830
|
+
const weekday = (d, long) => {
|
|
831
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d);
|
|
832
|
+
if (!m) return "";
|
|
833
|
+
return (long ? fmtLong : fmtDay).format(new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]))));
|
|
834
|
+
};
|
|
835
|
+
const hours = [];
|
|
836
|
+
for (let h = startHour + 1; h < endHour; h += 1) hours.push(h);
|
|
837
|
+
|
|
838
|
+
const headerFor = (d, i, compact) => {
|
|
839
|
+
if (renderDayHeader) return renderDayHeader(d, { isToday: isToday(d), index: i, compact, selected: d === day });
|
|
840
|
+
const n = dayNotes?.[d];
|
|
841
|
+
return (
|
|
842
|
+
<span className={cx("flex min-w-0 gap-x-1.5", compact ? "flex-col items-center" : "items-baseline")}>
|
|
843
|
+
<span className={cx("text-xs font-medium", isToday(d) ? "text-primary" : "text-muted-foreground")}>{weekday(d)}</span>
|
|
844
|
+
<span data-nums className={cx("text-sm font-semibold", isToday(d) && "rounded-full bg-primary px-1.5 text-primary-foreground")}>
|
|
845
|
+
{dateNum(d)}
|
|
846
|
+
</span>
|
|
847
|
+
{n?.text ? (
|
|
848
|
+
<span data-nums className={cx("min-w-0 truncate text-2xs font-medium", compact ? "" : "ml-auto", NOTE_TONE[n.tone ?? "neutral"] ?? NOTE_TONE.neutral)}>
|
|
849
|
+
{n.text}
|
|
850
|
+
</span>
|
|
851
|
+
) : null}
|
|
852
|
+
</span>
|
|
853
|
+
);
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
const floating = (b, h, look = "ink", kind = {}) => (
|
|
857
|
+
<div
|
|
858
|
+
aria-hidden="true"
|
|
859
|
+
className={cx("pointer-events-none absolute inset-x-[3px] z-40 rounded-[4px] shadow-lg transition-[top,height] duration-75 ease-out", shellOf(look, kind, pencilColor))}
|
|
860
|
+
style={{ top: top(b.s) + 1, height: h }}
|
|
861
|
+
>
|
|
862
|
+
<Face item={b} h={h} kind={kind} look={look} fmt={formatTime} words={words} pencilClassName={pencilClassName} />
|
|
863
|
+
</div>
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
const labelOf = (it, look, clashed) => {
|
|
867
|
+
if (it.label != null) return it.label;
|
|
868
|
+
if (clashed) return `${words.clash} · ${namesOf(clashed, formatTime, 1)}`;
|
|
869
|
+
if (look === "unsure") return `${words.unsure}${kindOf(it).label ? ` · ${kindOf(it).label}` : ""}`;
|
|
870
|
+
if (look === "failed") return words.failed;
|
|
871
|
+
if (look === "replaced") return words.replaced;
|
|
872
|
+
if (look === "was") return words.was;
|
|
873
|
+
if (look === "pencil") return kindOf(it).label ?? "";
|
|
874
|
+
return null;
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
const showKeepAll = Boolean(keepAll) && !readOnly && waitingCount > 0;
|
|
878
|
+
const selectedItem = sel != null ? items.find((i) => i.id === String(sel)) : null;
|
|
879
|
+
|
|
880
|
+
// ---- loading / failed
|
|
881
|
+
if (status === "failed") {
|
|
882
|
+
return (
|
|
883
|
+
<div className={cx("@container", className)} role="group" aria-label={label}>
|
|
884
|
+
<Notice tone="danger" title={words.failedTitle ?? "The week did not load"} action={onRetry ? <Button size="sm" onClick={onRetry}>{words.retry}</Button> : null}>
|
|
885
|
+
{error ?? "Something went wrong reading the week."}
|
|
886
|
+
</Notice>
|
|
887
|
+
</div>
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
const loading = status === "loading";
|
|
891
|
+
|
|
892
|
+
return (
|
|
893
|
+
<div
|
|
894
|
+
ref={root}
|
|
895
|
+
className={cx("@container relative select-none outline-none focus-visible:ring-2 focus-visible:ring-ring", height === "fill" && "flex min-h-0 flex-1 flex-col", className)}
|
|
896
|
+
aria-label={label}
|
|
897
|
+
role="group"
|
|
898
|
+
tabIndex={keys ? 0 : undefined}
|
|
899
|
+
aria-busy={loading || undefined}
|
|
900
|
+
onKeyDown={onRootKeyDown}
|
|
901
|
+
>
|
|
902
|
+
{above ? <div className="mb-2">{above}</div> : null}
|
|
903
|
+
|
|
904
|
+
{showKeepAll ? (
|
|
905
|
+
<div className="grid items-center gap-2 border-b border-border px-2 py-2 @min-[36rem]:grid-cols-[minmax(0,1fr)_auto]">
|
|
906
|
+
<p className="m-0 min-w-0 text-xs text-muted-foreground" id={keepWordsId}>
|
|
907
|
+
{plan.words}
|
|
908
|
+
</p>
|
|
909
|
+
<Button
|
|
910
|
+
size="sm"
|
|
911
|
+
variant={keepAll.primary ? "primary" : "secondary"}
|
|
912
|
+
loading={keepingAll}
|
|
913
|
+
disabled={!plan.keep.length || !onKeepAll}
|
|
914
|
+
onClick={runKeepAll}
|
|
915
|
+
aria-describedby={keepWordsId}
|
|
916
|
+
>
|
|
917
|
+
{`${keepAll.label ?? words.keepAll} (${plan.keep.length})`}
|
|
918
|
+
</Button>
|
|
919
|
+
</div>
|
|
920
|
+
) : null}
|
|
921
|
+
|
|
922
|
+
{problem ? (
|
|
923
|
+
<Notice
|
|
924
|
+
tone="danger"
|
|
925
|
+
className="m-2"
|
|
926
|
+
onDismiss={() => setProblem(null)}
|
|
927
|
+
action={problem.retry ? <Button size="sm" onClick={problem.retry}>{words.retry}</Button> : null}
|
|
928
|
+
>
|
|
929
|
+
{problem.message}
|
|
930
|
+
</Notice>
|
|
931
|
+
) : null}
|
|
932
|
+
|
|
933
|
+
{/* one day at a time on a narrow window: the strip picks it */}
|
|
934
|
+
<div className={cx("grid gap-1 border-b border-border p-2 @min-[36rem]:hidden", STRIP_COLS[list.length] ?? "grid-cols-5")}>
|
|
935
|
+
{list.map((d, i) => (
|
|
936
|
+
<button
|
|
937
|
+
key={d}
|
|
938
|
+
type="button"
|
|
939
|
+
aria-pressed={d === day}
|
|
940
|
+
onClick={() => setDay(d)}
|
|
941
|
+
className={cx("flex min-h-11 flex-col items-center justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring", d === day ? "bg-accent" : "hover:bg-accent/50")}
|
|
942
|
+
>
|
|
943
|
+
{headerFor(d, i, true)}
|
|
944
|
+
</button>
|
|
945
|
+
))}
|
|
946
|
+
</div>
|
|
947
|
+
|
|
948
|
+
<div
|
|
949
|
+
ref={scroller}
|
|
950
|
+
className={cx("relative overflow-x-clip", height && "overflow-y-auto overscroll-contain", height === "fill" && "min-h-0 flex-1")}
|
|
951
|
+
style={height && height !== "fill" ? { height } : undefined}
|
|
952
|
+
>
|
|
953
|
+
{/* the date row and the all-day row stay on top while the hours scroll */}
|
|
954
|
+
<div className="sticky top-0 z-30 bg-card">
|
|
955
|
+
<div className={cx("hidden @min-[36rem]:grid", WIDE_COLS[list.length])}>
|
|
956
|
+
<div aria-hidden={corner ? undefined : "true"} className="flex items-end justify-end border-b border-border px-1.5 pb-1 text-2xs text-muted-foreground">
|
|
957
|
+
{corner}
|
|
958
|
+
</div>
|
|
959
|
+
{list.map((d, i) => (
|
|
960
|
+
<div key={d} className={cx("relative min-w-0 border-l border-border px-2.5 pb-1.5 pt-2", isToday(d) ? "border-b-2 border-b-primary bg-primary/5" : "border-b border-b-border")}>
|
|
961
|
+
{headerFor(d, i, false)}
|
|
962
|
+
</div>
|
|
963
|
+
))}
|
|
964
|
+
</div>
|
|
965
|
+
<div className={cx("grid grid-cols-[3rem_minmax(0,1fr)] border-b border-border", WIDE_COLS[list.length])}>
|
|
966
|
+
<div className="flex items-center justify-end pr-1.5 text-2xs text-muted-foreground">{words.allDay}</div>
|
|
967
|
+
{list.map((d) => (
|
|
968
|
+
<div key={d} className={cx("min-h-7 min-w-0 space-y-0.5 border-l border-border p-[3px]", d !== day && "hidden @min-[36rem]:block", isToday(d) && "bg-primary/5")}>
|
|
969
|
+
{(byDay.get(d)?.allDay ?? []).map((it) => {
|
|
970
|
+
const look = looks.get(it.id);
|
|
971
|
+
const kind = kindOf(it);
|
|
972
|
+
const waiting = look !== "ink" && look !== "failed" && look !== "replaced" && look !== "was";
|
|
973
|
+
return (
|
|
974
|
+
<div key={it.id} className={cx("group relative flex min-h-5 min-w-0 items-center rounded-[4px]", shellOf(look, kind, pencilColor), String(sel) === it.id && "ring-2 ring-primary")}>
|
|
975
|
+
<button
|
|
976
|
+
type="button"
|
|
977
|
+
data-block={it.id}
|
|
978
|
+
title={it.title}
|
|
979
|
+
onClick={(e) => {
|
|
980
|
+
select(it.id);
|
|
981
|
+
onOpen?.(it, e.currentTarget);
|
|
982
|
+
}}
|
|
983
|
+
className="flex min-h-5 min-w-0 flex-1 items-center gap-1 rounded-[4px] px-1.5 text-left text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
984
|
+
>
|
|
985
|
+
<Icon name={it.locked || kind.locked ? "lock" : (it.icon ?? kind.icon ?? "calendar")} size={11} className="shrink-0" />
|
|
986
|
+
<span className="min-w-0 truncate">{it.title}</span>
|
|
987
|
+
</button>
|
|
988
|
+
{waiting && onAction && !readOnly && String(sel) === it.id ? (
|
|
989
|
+
<span className="flex shrink-0 gap-0.5 pr-0.5">
|
|
990
|
+
<button type="button" disabled={busy.has(it.id)} onClick={() => runAction(it, "keep")} aria-label={words.keep} title={words.keep} className="inline-flex size-5 items-center justify-center rounded-[4px] bg-primary text-primary-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
991
|
+
<Icon name="check" size={10} />
|
|
992
|
+
</button>
|
|
993
|
+
<button type="button" disabled={busy.has(it.id)} onClick={() => runAction(it, "dismiss")} aria-label={words.dismiss} title={words.dismiss} className="inline-flex size-5 items-center justify-center rounded-[4px] border border-border bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
994
|
+
<Icon name="x" size={10} />
|
|
995
|
+
</button>
|
|
996
|
+
</span>
|
|
997
|
+
) : null}
|
|
998
|
+
</div>
|
|
999
|
+
);
|
|
1000
|
+
})}
|
|
1001
|
+
</div>
|
|
1002
|
+
))}
|
|
1003
|
+
</div>
|
|
1004
|
+
</div>
|
|
1005
|
+
|
|
1006
|
+
<div className={cx("relative grid grid-cols-[3rem_minmax(0,1fr)]", WIDE_COLS[list.length])} style={{ height: bodyHeight }}>
|
|
1007
|
+
{/* the hour gutter: the working day's ends in weight */}
|
|
1008
|
+
<div aria-hidden="true" className="relative">
|
|
1009
|
+
{hours.map((h) =>
|
|
1010
|
+
(ws != null && Math.abs(h * 60 - ws) < 30) || (we != null && Math.abs(h * 60 - we) < 30) ? null : (
|
|
1011
|
+
<span key={h} data-nums className="absolute right-1.5 -translate-y-1/2 font-mono text-2xs text-muted-foreground" style={{ top: top(h * 60) }}>
|
|
1012
|
+
{formatTime(h * 60)}
|
|
1013
|
+
</span>
|
|
1014
|
+
),
|
|
1015
|
+
)}
|
|
1016
|
+
{[ws, we].map((m, i) =>
|
|
1017
|
+
m != null && m > lo && m < hi ? (
|
|
1018
|
+
<span key={i} data-nums className="absolute right-1.5 -translate-y-1/2 font-mono text-2xs font-semibold text-foreground" style={{ top: top(m) }}>
|
|
1019
|
+
{formatTime(m)}
|
|
1020
|
+
</span>
|
|
1021
|
+
) : null,
|
|
1022
|
+
)}
|
|
1023
|
+
</div>
|
|
1024
|
+
|
|
1025
|
+
{list.map((d, di) => {
|
|
1026
|
+
const slot = byDay.get(d) ?? { timed: [], was: [], lanes: new Map() };
|
|
1027
|
+
return (
|
|
1028
|
+
<div
|
|
1029
|
+
key={d}
|
|
1030
|
+
ref={(el) => {
|
|
1031
|
+
if (el) cols.current.set(d, el);
|
|
1032
|
+
else cols.current.delete(d);
|
|
1033
|
+
}}
|
|
1034
|
+
data-day={d}
|
|
1035
|
+
onPointerDown={(e) => onColumnPointerDown(e, d)}
|
|
1036
|
+
onDragOver={onDragOver}
|
|
1037
|
+
onDragLeave={onDragLeave}
|
|
1038
|
+
onDrop={onDropHere}
|
|
1039
|
+
className={cx("relative min-w-0 touch-pan-y border-l border-border", d !== day && "hidden @min-[36rem]:block", isToday(d) && "bg-primary/5", onCompose && !readOnly && "cursor-cell")}
|
|
1040
|
+
>
|
|
1041
|
+
{/* ruled hours, and the hatched time outside the working day */}
|
|
1042
|
+
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
|
1043
|
+
{ws != null && ws > lo ? <div className={cx("absolute inset-x-0 top-0 border-b border-border", OFF_HATCH)} style={{ height: top(ws) }} /> : null}
|
|
1044
|
+
{we != null && we < hi ? <div className={cx("absolute inset-x-0 bottom-0 border-t border-border", OFF_HATCH)} style={{ top: top(we) }} /> : null}
|
|
1045
|
+
{hours.map((h) => (
|
|
1046
|
+
<div key={h} className="absolute inset-x-0 border-t border-border/60" style={{ top: top(h * 60) }} />
|
|
1047
|
+
))}
|
|
1048
|
+
</div>
|
|
1049
|
+
|
|
1050
|
+
{loading ? (
|
|
1051
|
+
<div aria-hidden="true" className="pointer-events-none absolute inset-x-[3px] top-0 space-y-6 pt-10">
|
|
1052
|
+
{[0, 1, 2].map((n) => (
|
|
1053
|
+
<Skeleton key={n} shape="rect" className={cx("w-full", n === 1 ? "h-16" : "h-10")} />
|
|
1054
|
+
))}
|
|
1055
|
+
</div>
|
|
1056
|
+
) : null}
|
|
1057
|
+
|
|
1058
|
+
{/* where it used to be: a dotted marker under everything */}
|
|
1059
|
+
{slot.was.map((it) => {
|
|
1060
|
+
const h = Math.max(14, (it.e - it.s) * ppm - 2);
|
|
1061
|
+
return (
|
|
1062
|
+
<div key={it.id} aria-hidden="true" className={cx("pointer-events-none absolute inset-x-[3px] z-[1] rounded-[4px]", shellOf("was", {}, pencilColor))} style={{ top: top(it.s) + 1, height: h }}>
|
|
1063
|
+
<Face item={it} h={h} kind={{}} look="was" label={it.label ?? words.was} fmt={formatTime} words={words} pencilClassName={pencilClassName} />
|
|
1064
|
+
</div>
|
|
1065
|
+
);
|
|
1066
|
+
})}
|
|
1067
|
+
|
|
1068
|
+
{!loading
|
|
1069
|
+
? slot.timed.map((it) => {
|
|
1070
|
+
const lane = slot.lanes.get(it.id) ?? { lane: 0, lanes: 1 };
|
|
1071
|
+
const h = Math.max(14, (it.e - it.s) * ppm - 2);
|
|
1072
|
+
const look = looks.get(it.id);
|
|
1073
|
+
const kind = kindOf(it);
|
|
1074
|
+
const clashed = clashes.get(it.id);
|
|
1075
|
+
const waiting = it.pencil && (look === "pencil" || look === "unsure");
|
|
1076
|
+
const dragging = preview?.id === it.id;
|
|
1077
|
+
const movable = editable && it.draggable !== false && !it.locked && !kind.locked && look !== "failed" && look !== "replaced";
|
|
1078
|
+
const selected = String(sel) === it.id;
|
|
1079
|
+
const delay = fresh.indexOf(it.id);
|
|
1080
|
+
const left = `calc(${(lane.lane / lane.lanes) * 100}% + 3px)`;
|
|
1081
|
+
const width = `calc(${100 / lane.lanes}% - 6px)`;
|
|
1082
|
+
const tall = h >= 54;
|
|
1083
|
+
const showActions = Boolean(onAction) && !readOnly && it.pencil && look !== "failed" && look !== "replaced" && look !== "was";
|
|
1084
|
+
const dayName = weekday(it.date, true);
|
|
1085
|
+
return (
|
|
1086
|
+
<div
|
|
1087
|
+
key={it.id}
|
|
1088
|
+
data-id={it.id}
|
|
1089
|
+
data-look={look}
|
|
1090
|
+
onPointerDown={(e) => onBlockPointerDown(e, it)}
|
|
1091
|
+
className={cx(
|
|
1092
|
+
"group absolute z-10 overflow-hidden rounded-[4px] outline-none motion-safe:transition-[background-color,border-color,color,box-shadow,opacity] motion-safe:duration-700",
|
|
1093
|
+
shellOf(look, kind, pencilColor),
|
|
1094
|
+
it.pencil && "z-20",
|
|
1095
|
+
movable ? "cursor-grab touch-none" : "cursor-pointer",
|
|
1096
|
+
dragging && "opacity-30",
|
|
1097
|
+
busy.has(it.id) && "opacity-70",
|
|
1098
|
+
selected && "z-30 shadow-md ring-2 ring-primary",
|
|
1099
|
+
)}
|
|
1100
|
+
style={{ top: top(it.s) + 1, height: h, left, width, transitionDelay: delay > 0 ? `${delay * 160}ms` : undefined }}
|
|
1101
|
+
>
|
|
1102
|
+
<button
|
|
1103
|
+
type="button"
|
|
1104
|
+
data-block={it.id}
|
|
1105
|
+
title={`${it.title}, ${span(it.s, it.e, formatTime)}`}
|
|
1106
|
+
aria-label={`${it.title}, ${dayName} ${span(it.s, it.e, formatTime)}${waiting ? `, ${words.pencilled}` : ""}${it.locked || kind.locked ? `, ${words.locked}` : ""}`}
|
|
1107
|
+
aria-pressed={it.pencil ? selected : undefined}
|
|
1108
|
+
onKeyDown={(e) => onBlockKeyDown(e, it)}
|
|
1109
|
+
onClick={(e) => {
|
|
1110
|
+
if (suppressClick.current) return;
|
|
1111
|
+
select(it.id);
|
|
1112
|
+
onOpen?.(it, e.currentTarget);
|
|
1113
|
+
}}
|
|
1114
|
+
className="absolute inset-0 block rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
1115
|
+
>
|
|
1116
|
+
<Face item={it} h={h} kind={kind} look={look} label={labelOf(it, look, clashed)} fmt={formatTime} clashed={Boolean(clashed)} words={words} pencilClassName={pencilClassName} />
|
|
1117
|
+
</button>
|
|
1118
|
+
{showActions && (waiting || it.state === "kept") ? (
|
|
1119
|
+
<MiniActions item={it} look={look} onAction={runAction} busy={busy.has(it.id)} words={words} tall={tall && look !== "ink"} show={selected} />
|
|
1120
|
+
) : null}
|
|
1121
|
+
{movable && h >= 22 ? (
|
|
1122
|
+
<>
|
|
1123
|
+
<span data-edge="start" aria-hidden="true" className="absolute inset-x-0 top-0 h-1.5 cursor-ns-resize" />
|
|
1124
|
+
<span data-edge="end" aria-hidden="true" className="absolute inset-x-0 bottom-0 flex h-2 cursor-ns-resize items-end justify-center pb-0.5">
|
|
1125
|
+
<span className="h-0.5 w-6 rounded-full bg-current opacity-0 transition-opacity group-hover:opacity-40" />
|
|
1126
|
+
</span>
|
|
1127
|
+
</>
|
|
1128
|
+
) : null}
|
|
1129
|
+
</div>
|
|
1130
|
+
);
|
|
1131
|
+
})
|
|
1132
|
+
: null}
|
|
1133
|
+
|
|
1134
|
+
{/* where two things overlap: hatched across the day, before anything is kept */}
|
|
1135
|
+
{slot.timed.flatMap((it) =>
|
|
1136
|
+
(clashes.get(it.id) ?? []).map((c) => (
|
|
1137
|
+
<div
|
|
1138
|
+
key={`${it.id}-${c.withId}`}
|
|
1139
|
+
data-clash={it.id}
|
|
1140
|
+
aria-hidden="true"
|
|
1141
|
+
className={cx("pointer-events-none absolute inset-x-0 z-[25] border-y border-warning opacity-60", CLASH_HATCH)}
|
|
1142
|
+
style={{ top: top(c.s), height: Math.max(2, (c.e - c.s) * ppm) }}
|
|
1143
|
+
/>
|
|
1144
|
+
)),
|
|
1145
|
+
)}
|
|
1146
|
+
|
|
1147
|
+
{/* the tag: the words the proposal came from, hanging off its block */}
|
|
1148
|
+
{slot.timed.map((it) => {
|
|
1149
|
+
if (!it.pencil || !it.tag) return null;
|
|
1150
|
+
if (!(tags === "all" || (tags === "selected" && String(sel) === it.id))) return null;
|
|
1151
|
+
const lane = slot.lanes.get(it.id) ?? { lane: 0, lanes: 1 };
|
|
1152
|
+
const tg = typeof it.tag === "string" ? { quote: it.tag } : it.tag;
|
|
1153
|
+
const blockTop = top(it.s) + 1;
|
|
1154
|
+
const blockBottom = top(it.e) - 1;
|
|
1155
|
+
const place = tagPlacement({ dayIndex: di, dayCount: list.length, bottom: blockBottom, top: blockTop, bodyHeight });
|
|
1156
|
+
const style = {};
|
|
1157
|
+
if (place.above) style.bottom = bodyHeight - blockTop + 6;
|
|
1158
|
+
else style.top = blockBottom + 6;
|
|
1159
|
+
if (place.side === "left") style.left = `calc(${(lane.lane / lane.lanes) * 100}% + 3px)`;
|
|
1160
|
+
else style.right = `calc(${(1 - (lane.lane + 1) / lane.lanes) * 100}% + 3px)`;
|
|
1161
|
+
return (
|
|
1162
|
+
<div key={`tag-${it.id}`} role="note" className="pointer-events-none absolute z-[45] w-60 max-w-[calc(100cqw-4rem)] rounded-md bg-primary px-2 py-1.5 text-xs leading-snug text-primary-foreground shadow-md" style={style}>
|
|
1163
|
+
<span aria-hidden="true" className={cx("absolute size-2 rotate-45 bg-primary", place.above ? "-bottom-1" : "-top-1", place.side === "left" ? "left-4" : "right-4")} />
|
|
1164
|
+
{tg.quote ? <q className="font-medium italic">{tg.quote}</q> : null}
|
|
1165
|
+
{tg.source ? <span className="mt-0.5 block text-2xs opacity-90">{tg.source}</span> : null}
|
|
1166
|
+
</div>
|
|
1167
|
+
);
|
|
1168
|
+
})}
|
|
1169
|
+
|
|
1170
|
+
{/* the agent still pencilling this day in */}
|
|
1171
|
+
{(drafting ?? [])
|
|
1172
|
+
.filter((g) => g.date === d)
|
|
1173
|
+
.map((g, i) => (
|
|
1174
|
+
<div
|
|
1175
|
+
key={i}
|
|
1176
|
+
aria-hidden="true"
|
|
1177
|
+
className={cx("pointer-events-none absolute inset-x-[3px] z-10 flex items-center justify-center rounded-[4px] border-[1.5px] border-dashed border-border text-muted-foreground motion-safe:animate-pulse", HATCH, pencilClassName, "text-xs italic")}
|
|
1178
|
+
style={{ top: top(toMin(g.start)) + 1, height: Math.max(14, (toMin(g.end) - toMin(g.start)) * ppm - 2) }}
|
|
1179
|
+
>
|
|
1180
|
+
{g.label ?? words.drafting}
|
|
1181
|
+
</div>
|
|
1182
|
+
))}
|
|
1183
|
+
|
|
1184
|
+
{/* the block being made, while its editor is open */}
|
|
1185
|
+
{draft && draft.date === d ? (
|
|
1186
|
+
<div data-draft aria-hidden="true" className="pointer-events-none absolute inset-x-[3px] z-30 rounded-[4px] shadow-lg ring-2 ring-primary">
|
|
1187
|
+
{(() => {
|
|
1188
|
+
const s = toMin(draft.start);
|
|
1189
|
+
const e = toMin(draft.end);
|
|
1190
|
+
const h = Math.max(14, (e - s) * ppm - 2);
|
|
1191
|
+
return (
|
|
1192
|
+
<div className={cx("absolute inset-x-0 rounded-[4px]", shellOf("ink", { solid: true }, pencilColor))} style={{ top: top(s) + 1, height: h }}>
|
|
1193
|
+
<Face item={{ title: draft.title || words.newBlock, s, e }} h={h} kind={{ solid: true }} look="ink" fmt={formatTime} words={words} />
|
|
1194
|
+
</div>
|
|
1195
|
+
);
|
|
1196
|
+
})()}
|
|
1197
|
+
</div>
|
|
1198
|
+
) : null}
|
|
1199
|
+
|
|
1200
|
+
{/* the block in the hand: lifted, snapping */}
|
|
1201
|
+
{preview && preview.date === d
|
|
1202
|
+
? (() => {
|
|
1203
|
+
const src = preview.id ? items.find((x) => x.id === preview.id) : null;
|
|
1204
|
+
const h = Math.max(14, (preview.end - preview.start) * ppm - 2);
|
|
1205
|
+
const b = src ? { ...src, s: preview.start, e: preview.end } : { title: words.newBlock, s: preview.start, e: preview.end };
|
|
1206
|
+
return floating(b, h, src ? looks.get(src.id) : "ink", src ? kindOf(src) : { solid: true });
|
|
1207
|
+
})()
|
|
1208
|
+
: null}
|
|
1209
|
+
|
|
1210
|
+
{/* a drop from the list, before it lands */}
|
|
1211
|
+
{dropHint && dropHint.date === d ? floating({ title: dropHint.title, s: dropHint.start, e: dropHint.end }, Math.max(14, (dropHint.end - dropHint.start) * ppm - 2), "pencil") : null}
|
|
1212
|
+
|
|
1213
|
+
{/* dropped and waiting for its row */}
|
|
1214
|
+
{pending
|
|
1215
|
+
.filter((p) => p.date === d)
|
|
1216
|
+
.map((p) => (
|
|
1217
|
+
<div key={p.key}>{floating({ title: p.title, s: p.start, e: p.end }, Math.max(14, (p.end - p.start) * ppm - 2), "ink", { solid: true })}</div>
|
|
1218
|
+
))}
|
|
1219
|
+
|
|
1220
|
+
{/* why this time cannot be used */}
|
|
1221
|
+
{note && note.date === d ? (
|
|
1222
|
+
<div role="note" className="absolute inset-x-1 z-50 rounded-lg bg-foreground px-3 py-2.5 text-sm leading-snug text-background shadow-lg" style={{ top: Math.min(bodyHeight - 110, Math.max(4, top(note.minute) - 8)) }}>
|
|
1223
|
+
{note.node}
|
|
1224
|
+
</div>
|
|
1225
|
+
) : null}
|
|
1226
|
+
|
|
1227
|
+
{/* now */}
|
|
1228
|
+
{isToday(d) && nowMin != null && nowMin >= lo && nowMin <= hi ? (
|
|
1229
|
+
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 z-[35]" style={{ top: top(nowMin) }}>
|
|
1230
|
+
<div className="relative h-0.5 bg-primary">
|
|
1231
|
+
<span className="absolute -left-[5px] -top-1 size-2.5 rounded-full bg-primary" />
|
|
1232
|
+
</div>
|
|
1233
|
+
</div>
|
|
1234
|
+
) : null}
|
|
1235
|
+
</div>
|
|
1236
|
+
);
|
|
1237
|
+
})}
|
|
1238
|
+
|
|
1239
|
+
{!loading && emptyText !== false && !items.length ? (
|
|
1240
|
+
<div className="pointer-events-none absolute inset-0 grid place-items-center p-6 text-center text-sm text-muted-foreground">
|
|
1241
|
+
<p className="m-0 max-w-xs">{emptyText ?? words.empty}</p>
|
|
1242
|
+
</div>
|
|
1243
|
+
) : null}
|
|
1244
|
+
</div>
|
|
1245
|
+
</div>
|
|
1246
|
+
|
|
1247
|
+
<p aria-live="polite" className="sr-only">
|
|
1248
|
+
{selectedItem ? `${selectedItem.title}, ${weekday(selectedItem.date, true)} ${span(selectedItem.s ?? 0, selectedItem.e ?? 0, formatTime)}${selectedItem.s == null ? "" : `, ${durationWords(selectedItem.e - selectedItem.s)}`}` : ""}
|
|
1249
|
+
</p>
|
|
1250
|
+
</div>
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// ---- the goals strip ------------------------------------------------------
|
|
1255
|
+
|
|
1256
|
+
const GOAL_LOOK = {
|
|
1257
|
+
ink: "border-border bg-card",
|
|
1258
|
+
pencil: cx("border-dashed border-muted-foreground bg-card", HATCH),
|
|
1259
|
+
done: "border-success bg-success/10",
|
|
1260
|
+
empty: "border-dashed border-border bg-transparent text-muted-foreground",
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* A strip of goals to sit above the days, and a summary cell at its end (a
|
|
1265
|
+
* total against a target, red once it is over). Goals are pencil until kept,
|
|
1266
|
+
* like the blocks beside them.
|
|
1267
|
+
* items [{ id, title, number?, state: "ink" | "pencil" | "done" | "empty", meta?, mark? }]
|
|
1268
|
+
* summary { label, value, of?, unit?, over?, note? } value / of are numbers or strings
|
|
1269
|
+
* empty what to show with no goals
|
|
1270
|
+
*/
|
|
1271
|
+
export function GoalStrip({ items = [], summary, selectedId, onSelect, empty, label = "Goals", className }) {
|
|
1272
|
+
if (!items.length && !summary && !empty) return null;
|
|
1273
|
+
return (
|
|
1274
|
+
<div role="group" aria-label={label} className={cx("grid grid-cols-[repeat(auto-fit,minmax(9.5rem,1fr))] gap-2", className)}>
|
|
1275
|
+
{items.length ? (
|
|
1276
|
+
items.map((g, i) => {
|
|
1277
|
+
const state = GOAL_LOOK[g.state] ? g.state : "ink";
|
|
1278
|
+
const on = selectedId != null && String(selectedId) === String(g.id);
|
|
1279
|
+
const body = (
|
|
1280
|
+
<>
|
|
1281
|
+
<span className="flex min-w-0 items-start gap-2">
|
|
1282
|
+
<span data-nums className={cx("inline-flex size-5 shrink-0 items-center justify-center rounded-full border text-2xs font-semibold", state === "pencil" || state === "empty" ? "border-dashed border-muted-foreground" : "border-border", state === "done" && "border-success text-success")}>
|
|
1283
|
+
{state === "done" ? <Icon name="check" size={10} /> : (g.number ?? i + 1)}
|
|
1284
|
+
</span>
|
|
1285
|
+
<span className={cx("min-w-0 text-xs font-medium leading-snug", state === "done" ? "text-muted-foreground" : "")}>{cut(g.title)}</span>
|
|
1286
|
+
</span>
|
|
1287
|
+
{g.meta || g.mark ? (
|
|
1288
|
+
<span className="mt-1 flex min-w-0 items-center gap-1 pl-7 text-2xs text-muted-foreground">
|
|
1289
|
+
{state === "pencil" ? <Icon name="pencil" size={10} className="shrink-0" /> : null}
|
|
1290
|
+
<span className="truncate">{g.mark ?? g.meta}</span>
|
|
1291
|
+
</span>
|
|
1292
|
+
) : null}
|
|
1293
|
+
</>
|
|
1294
|
+
);
|
|
1295
|
+
const cls = cx("block min-w-0 rounded-md border px-2 py-1.5 text-left motion-safe:transition-[background-color,border-color] motion-safe:duration-700", GOAL_LOOK[state], on && "ring-2 ring-primary");
|
|
1296
|
+
return onSelect ? (
|
|
1297
|
+
<button key={g.id} type="button" aria-pressed={on} onClick={() => onSelect(g.id)} className={cx(cls, "outline-none focus-visible:ring-2 focus-visible:ring-ring")}>
|
|
1298
|
+
{body}
|
|
1299
|
+
</button>
|
|
1300
|
+
) : (
|
|
1301
|
+
<div key={g.id} className={cls}>
|
|
1302
|
+
{body}
|
|
1303
|
+
</div>
|
|
1304
|
+
);
|
|
1305
|
+
})
|
|
1306
|
+
) : empty ? (
|
|
1307
|
+
<div className={cx("min-w-0 rounded-md border px-2 py-1.5 text-xs", GOAL_LOOK.empty)} style={{ gridColumn: "1 / -1" }}>
|
|
1308
|
+
{empty}
|
|
1309
|
+
</div>
|
|
1310
|
+
) : null}
|
|
1311
|
+
{summary ? (
|
|
1312
|
+
<div className="min-w-0 rounded-md border border-border bg-card px-2 py-1.5" data-summary>
|
|
1313
|
+
<span className="block text-2xs font-medium uppercase tracking-wide text-muted-foreground">{summary.label}</span>
|
|
1314
|
+
<span data-nums className={cx("block text-sm font-semibold", summary.over ? "text-destructive" : "")}>
|
|
1315
|
+
{summary.value}
|
|
1316
|
+
{summary.of != null ? <span className="font-normal text-muted-foreground">{` of ${summary.of}`}</span> : null}
|
|
1317
|
+
{summary.unit ? ` ${summary.unit}` : ""}
|
|
1318
|
+
{summary.over ? <span className="ml-1 text-2xs font-semibold">over</span> : null}
|
|
1319
|
+
</span>
|
|
1320
|
+
{summary.note ? <span className="block truncate text-2xs text-muted-foreground">{summary.note}</span> : null}
|
|
1321
|
+
</div>
|
|
1322
|
+
) : null}
|
|
1323
|
+
</div>
|
|
1324
|
+
);
|
|
1325
|
+
}
|