makersclaw 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/package.json +1 -1
  2. package/r/annotate.json +3 -3
  3. package/r/approval-request.json +4 -4
  4. package/r/audio-player.json +2 -2
  5. package/r/auth-form.json +2 -2
  6. package/r/board.json +2 -2
  7. package/r/calendar-view.json +9 -9
  8. package/r/cart.json +2 -2
  9. package/r/chart.json +7 -7
  10. package/r/chat.json +3 -3
  11. package/r/code-editor.json +2 -2
  12. package/r/comment-box.json +5 -5
  13. package/r/contact-section.json +2 -2
  14. package/r/core.json +5 -5
  15. package/r/csv-import.json +3 -3
  16. package/r/data-table.json +4 -4
  17. package/r/diagram.json +2 -2
  18. package/r/doc-editor.json +2 -2
  19. package/r/doc-page.json +3 -3
  20. package/r/email-render.json +2 -2
  21. package/r/faq.json +2 -2
  22. package/r/file-upload.json +2 -2
  23. package/r/flow-canvas.json +4 -4
  24. package/r/footer.json +2 -2
  25. package/r/gantt.json +3 -3
  26. package/r/job-panel.json +2 -2
  27. package/r/json-editor.json +3 -3
  28. package/r/map.json +2 -2
  29. package/r/menubar.json +2 -2
  30. package/r/mind-map.json +6 -6
  31. package/r/navigation-menu.json +2 -2
  32. package/r/order-detail.json +2 -2
  33. package/r/org-chart.json +4 -4
  34. package/r/page-builder.json +2 -2
  35. package/r/payment-methods.json +2 -2
  36. package/r/pdf-document.json +2 -2
  37. package/r/pdf-viewer.json +3 -3
  38. package/r/product-detail.json +2 -2
  39. package/r/product-grid.json +3 -3
  40. package/r/record-form.json +2 -2
  41. package/r/record-notes.json +6 -6
  42. package/r/registry.json +121 -121
  43. package/r/search-dialog.json +2 -2
  44. package/r/settings-list.json +2 -2
  45. package/r/setup-checklist.json +2 -2
  46. package/r/share-dialog.json +2 -2
  47. package/r/sidebar.json +4 -4
  48. package/r/signature-pad.json +3 -3
  49. package/r/sketch-canvas.json +2 -2
  50. package/r/spreadsheet.json +3 -3
  51. package/r/stat-cards.json +3 -3
  52. package/r/task-list.json +3 -3
  53. package/r/task-model.json +2 -2
  54. package/r/testimonials.json +2 -2
  55. package/r/transaction-list.json +2 -2
  56. package/r/whiteboard.json +3 -3
  57. package/registry/blocks/approval-request/README.md +2 -1
  58. package/registry/blocks/approval-request/approval-request.jsx +5 -3
  59. package/registry/blocks/calendar-view/README.md +8 -0
  60. package/registry/blocks/calendar-view/agenda.jsx +5 -4
  61. package/registry/blocks/calendar-view/calendar.jsx +23 -8
  62. package/registry/blocks/calendar-view/demo.jsx +2 -0
  63. package/registry/blocks/calendar-view/month.jsx +6 -5
  64. package/registry/blocks/calendar-view/time-grid.jsx +9 -7
  65. package/registry/blocks/calendar-view/tray.jsx +5 -4
  66. package/registry/blocks/chart/README.md +9 -4
  67. package/registry/blocks/chart/cartesian.jsx +31 -24
  68. package/registry/blocks/chart/core.jsx +21 -0
  69. package/registry/blocks/chart/pie.jsx +15 -10
  70. package/registry/blocks/chart/sparkline.jsx +32 -14
  71. package/registry/blocks/chat/message.jsx +4 -4
  72. package/registry/blocks/comment-box/README.md +1 -1
  73. package/registry/blocks/comment-box/comment-time.jsx +4 -6
  74. package/registry/blocks/data-table/README.md +4 -0
  75. package/registry/blocks/data-table/data-table.jsx +20 -6
  76. package/registry/blocks/flow-canvas/edges.jsx +4 -2
  77. package/registry/blocks/flow-canvas/theme.js +59 -48
  78. package/registry/blocks/mind-map/README.md +9 -2
  79. package/registry/blocks/mind-map/mind-map.jsx +51 -12
  80. package/registry/blocks/mind-map/tree.js +33 -0
  81. package/registry/blocks/org-chart/org-chart.jsx +1 -1
  82. package/registry/blocks/pdf-viewer/notes.jsx +4 -3
  83. package/registry/blocks/record-notes/README.md +1 -1
  84. package/registry/blocks/record-notes/record-notes.jsx +4 -5
  85. package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
  86. package/registry/blocks/sidebar/sidebar.jsx +13 -13
  87. package/registry/blocks/signature-pad/signature.js +8 -1
  88. package/registry/blocks/spreadsheet/cells.jsx +2 -2
  89. package/templates/next/files/README.md +77 -19
  90. package/templates/next/files/app/auto-refresh.jsx +39 -74
  91. package/templates/next/files/app/frame.js +197 -6
  92. package/templates/next/files/check/run.mjs +26 -10
  93. package/templates/next/files/components/ui/calendar.jsx +24 -0
  94. package/templates/next/files/components/ui/field.jsx +12 -3
  95. package/templates/next/files/components/ui/index.js +1 -0
  96. package/templates/next/files/components/ui/sheet.jsx +38 -2
  97. package/templates/next/files/components/ui/toggle.jsx +20 -2
  98. package/templates/next/files/lib/actions.js +63 -5
  99. package/templates/next/files/lib/data.js +4 -1
  100. package/templates/next/files/lib/db.js +18 -1
  101. package/templates/next/files/lib/hooks.js +52 -17
  102. package/templates/next/template.json +2 -2
@@ -5,8 +5,8 @@
5
5
  "title": "Calendar",
6
6
  "description": "Tasks on a calendar, like Google Calendar, Cron or Notion Calendar: month, week, day and agenda views over task-model's work items. Month shows all-day tasks and spans as tinted bars across their days (stacked in lanes, \"2 more\" past three) and timed ones as a dot and a time; week and day show an all-day strip over an hour grid where timed tasks are blocks placed by their times, overlapping ones side by side, with a line at now. Drag an item to another day or hour (it snaps to the quarter hour and the day columns as it moves), drag its bottom or end to change how long it runs, click an empty day or hour (or drag across hours) to type a new task in place, drag tasks without a date in from an unscheduled tray (or drop one there to take its date off). Arrow keys move a focused block, Shift+arrows resize it, Delete removes with Undo; T, J/K, M/W/D/A and C for today, next/previous, the views and a new task. Search and filters from the shared ViewToolbar, a colour per project or status, a real phone layout (a dotted month with the tapped day listed, a three-day week, a sheet for the tray). An available-time layer (closed hours muted, open slots as boxes you pick, from `availability`, `slots` or an async `getSlots`, with pure `freeSlots` / `availabilityOn` helpers) is what a booking page builds on. One async onChange saves every change; failures roll back with a notice.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/task-model.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.1/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.1/r/task-model.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -20,7 +20,7 @@
20
20
  "path": "registry/blocks/calendar-view/calendar.jsx",
21
21
  "type": "registry:component",
22
22
  "target": "@components/makersclaw/blocks/calendar-view/calendar.jsx",
23
- "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { Button, IconButton, Notice, Sheet, Skeleton, ToggleGroup, cx } from \"../../ui\";\nimport {\n DEFAULT_VIEW,\n LIFTED,\n TaskChip,\n TaskDragProvider,\n ViewToolbar,\n addDays,\n applyView,\n createTaskContext,\n dayOf,\n deleteChange,\n draftTask,\n formatDay,\n isDone,\n minutesOf,\n startOfMonth,\n useTaskChanges,\n withTime,\n} from \"../task-model\";\nimport {\n CALENDAR_MODES,\n DAY_MINUTES,\n MODE_LABELS,\n UNSCHEDULE,\n allDayPatch,\n closedOn,\n dayPatch,\n durationOf,\n eventsOf,\n extendPatch,\n minutesLabel,\n moveEventPatch,\n normalizeSlots,\n placeEventPatch,\n rangeFor,\n resizeEventPatch,\n snapMinutes,\n stepAnchor,\n timedPatch,\n titleFor,\n unscheduledOf,\n} from \"./calendar-model\";\nimport { AgendaView } from \"./agenda\";\nimport { MonthView } from \"./month\";\nimport { TimeGrid } from \"./time-grid\";\nimport { UnscheduledTray } from \"./tray\";\nimport { isTyping } from \"./paint\";\n\n// CalendarView — tasks on a calendar: month, week, day and agenda. Drag an\n// item to another day or hour, drag its end to change how long it runs,\n// click (or drag across) an empty stretch to add one there, and drag\n// tasks without a date in from the unscheduled tray. A line marks now.\n// Search and filters come from task-model's ViewToolbar, so a task\n// filtered out of the list is filtered out here too.\n//\n// Everything saves through one async `onChange(change)` answering { ok }\n// (task-model's change shapes); the calendar shows each change at once\n// and takes it back, with a notice, when the save fails.\n//\n// The available-time layer (closed hours muted, open slots as boxes you\n// can pick) is what booking-page builds on: `availability`, `slots` or\n// `getSlots`, `renderSlot`, `onSlotClick`. README.md lists every prop.\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\nconst clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));\nconst UNIT = { month: \"month\", week: \"week\", day: \"day\", agenda: \"stretch\" };\nconst quote = (t) => `“${t?.title || \"Untitled\"}”`;\n\nfunction useControlled(value, initial, onChange) {\n const [own, setOwn] = useState(initial);\n const controlled = value !== undefined;\n const set = useCallback(\n (next) => {\n if (!controlled) setOwn(next);\n onChange?.(next);\n },\n [controlled, onChange],\n );\n return [controlled ? value : own, set];\n}\n\n// The wall clock, a minute at a time — after mount only, so the server\n// and the first client render agree. A fixed `now` wins (a demo).\nfunction useNow(fixed) {\n const [clock, setClock] = useState(null);\n useEffect(() => {\n if (fixed) return undefined;\n const tick = () => {\n const d = new Date();\n setClock(withTime(dayOf(d), d.getHours() * 60 + d.getMinutes()));\n };\n tick();\n const t = setInterval(tick, 60000);\n return () => clearInterval(t);\n }, [fixed]);\n return fixed ?? clock;\n}\n\nexport function CalendarView({\n tasks,\n statuses,\n people,\n projects,\n fields,\n today,\n weekStartsOn,\n weekend,\n holidays,\n now: nowProp,\n locale,\n onChange,\n onOpen,\n mode: modeProp,\n defaultMode = \"month\",\n onModeChange,\n modes = CALENDAR_MODES,\n date: dateProp,\n defaultDate,\n onDateChange,\n view: viewProp,\n defaultView,\n onViewChange,\n header = true,\n toolbar = true,\n toolbarExtra,\n tray = true,\n colorBy = \"project\",\n availability,\n slots,\n getSlots,\n renderSlot,\n onSlotClick,\n selectedSlot,\n slotLabel = \"Open\",\n hourHeight = 48,\n height = \"40rem\",\n snap = 15,\n defaultDuration = 60,\n agendaDays = 14,\n addLabel = \"Add task\",\n loading = false,\n error: readError,\n label = \"Calendar\",\n className,\n}) {\n const ctx = useMemo(\n () => createTaskContext({ statuses, people, projects, fields, today, weekStartsOn, weekend, holidays }),\n [statuses, people, projects, fields, today, weekStartsOn, weekend, holidays],\n );\n const editable = typeof onChange === \"function\";\n const { tasks: all, commit, error, clearError } = useTaskChanges(tasks, onChange);\n const now = useNow(nowProp);\n\n const [modeRaw, setModeRaw] = useControlled(modeProp, defaultMode, onModeChange);\n const mode = modes.includes(modeRaw) ? modeRaw : modes[0] ?? \"month\";\n const [anchorRaw, setAnchor] = useControlled(dateProp, dayOf(defaultDate) ?? ctx.today, onDateChange);\n const anchor = dayOf(anchorRaw) ?? ctx.today;\n const [viewRaw, setView] = useControlled(viewProp, { ...DEFAULT_VIEW, groupBy: \"none\", ...defaultView }, onViewChange);\n const view = useMemo(() => ({ ...DEFAULT_VIEW, groupBy: \"none\", ...viewRaw }), [viewRaw]);\n\n // How wide we are, not the window: a calendar in a side panel is narrow.\n const rootRef = useRef(null);\n const [width, setWidth] = useState(null);\n useIsoLayoutEffect(() => {\n const el = rootRef.current;\n if (!el) return undefined;\n setWidth(el.getBoundingClientRect().width);\n if (typeof ResizeObserver === \"undefined\") return undefined;\n const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n const narrow = width !== null && width < 640;\n const sidePanel = width === null || width >= 960;\n const count = mode === \"week\" && narrow ? 3 : 7;\n\n const range = useMemo(() => rangeFor(mode, anchor, { weekStartsOn: ctx.weekStartsOn, count, agendaDays }), [mode, anchor, ctx.weekStartsOn, count, agendaDays]);\n const shown = useMemo(() => applyView(all, view, ctx).tasks, [all, view, ctx]);\n const events = useMemo(() => eventsOf(shown, { defaultMinutes: defaultDuration }), [shown, defaultDuration]);\n const unscheduled = useMemo(() => unscheduledOf(shown).filter((t) => !isDone(t, ctx)), [shown, ctx]);\n\n const [announce, setAnnounce] = useState(\"\");\n const say = (text) => setAnnounce(text);\n const [draft, setDraft] = useState(null); // { day, allDay } | { day, from, to }\n const [selected, setSelected] = useState(null); // a phone's month: the tapped day\n const [trayOpen, setTrayOpen] = useState(true);\n const [traySheet, setTraySheet] = useState(false);\n useEffect(() => setDraft(null), [mode, anchor]);\n\n // ---- saving ----------------------------------------------------------\n\n function update(task, patch, words) {\n if (!patch) return Promise.resolve({ ok: false });\n return commit({ type: \"update\", id: task.id, patch }, { label: quote(task) }).then((r) => {\n if (r.ok !== false && words) say(words);\n return r;\n });\n }\n\n function remove(task) {\n return commit(deleteChange(all, [task.id]), { label: quote(task), undo: `Deleted ${quote(task)}` });\n }\n\n function saveDraft(title) {\n const d = draft;\n setDraft(null);\n if (!d || !editable) return undefined;\n const patch = d.allDay ? { due: d.day } : { start: withTime(d.day, d.from), due: withTime(d.day, d.to) };\n const task = draftTask(title, ctx, patch);\n return commit({ type: \"create\", task }, { label: quote(task) }).then((r) => {\n if (r.ok !== false) say(`Added ${quote(task)} on ${formatDay(d.day, ctx.today, { locale })}${d.allDay ? \"\" : `, ${minutesLabel(d.from, { locale })}`}`);\n return r;\n });\n }\n\n function extend(event, dir) {\n const end = addDays(event.end, dir);\n if (end < event.start) return;\n update(event.task, extendPatch(event, end), `${quote(event.task)} now ends ${formatDay(end, ctx.today, { locale })}`);\n }\n\n // Arrow keys on a timed block: previewed at once, saved when the keys\n // rest, so ten presses are one save.\n const [nudge, setNudge] = useState(null);\n const nudgeRef = useRef(null);\n const nudgeTimer = useRef(null);\n const focusRef = useRef(null);\n useEffect(() => () => clearTimeout(nudgeTimer.current), []);\n\n function flushNudge() {\n clearTimeout(nudgeTimer.current);\n const n = nudgeRef.current;\n nudgeRef.current = null;\n setNudge(null);\n if (!n || (!n.dm && !n.dd && !n.dr)) return;\n const e = n.event;\n const len = Math.max(15, e.to - e.from + n.dr);\n const from = clamp(e.from + n.dm, 0, DAY_MINUTES - len);\n const day = addDays(e.start, n.dd);\n update(e.task, placeEventPatch(e, day, from, from + len), `${quote(e.task)} is now ${formatDay(day, ctx.today, { locale })}, ${minutesLabel(from, { locale })} to ${minutesLabel(from + len, { locale })}`);\n }\n\n function nudgeBy(event, { dm = 0, dd = 0, dr = 0 }) {\n if (nudgeRef.current && nudgeRef.current.id !== event.id) flushNudge();\n focusRef.current = event.id;\n const cur = nudgeRef.current ?? { id: event.id, event, dm: 0, dd: 0, dr: 0 };\n const next = { ...cur, dm: cur.dm + dm, dd: cur.dd + dd, dr: cur.dr + dr };\n nudgeRef.current = next;\n setNudge(next);\n clearTimeout(nudgeTimer.current);\n nudgeTimer.current = setTimeout(flushNudge, 700);\n }\n\n const shownEvents = useMemo(() => {\n if (!nudge) return events;\n return events.map((e) => {\n if (e.id !== nudge.id || !e.timed) return e;\n const day = addDays(e.start, nudge.dd);\n const len = Math.max(15, e.to - e.from + nudge.dr);\n const from = clamp(e.from + nudge.dm, 0, DAY_MINUTES - len);\n return { ...e, start: day, end: day, from, to: from + len, preview: true };\n });\n }, [events, nudge]);\n\n // A block moved by keys to another column is a new element: keep focus.\n useEffect(() => {\n if (!focusRef.current || !rootRef.current) return;\n const el = rootRef.current.querySelector(`[data-cal-id=\"${CSS.escape(focusRef.current)}\"]`);\n if (!el) return;\n if (document.activeElement !== el) el.focus({ preventScroll: false });\n if (!nudgeRef.current) focusRef.current = null;\n });\n\n // ---- navigation --------------------------------------------------------\n\n function setMode(next) {\n setModeRaw(next);\n setDraft(null);\n }\n\n function go(dir) {\n const next = stepAnchor(mode, anchor, dir, { count, agendaDays });\n setAnchor(next);\n if (mode === \"month\") setSelected(next.slice(0, 7) === ctx.today.slice(0, 7) ? ctx.today : startOfMonth(next));\n }\n\n function goToday() {\n setAnchor(ctx.today);\n setSelected(ctx.today);\n }\n\n function openDay(day) {\n if (modes.includes(\"day\")) {\n setAnchor(day);\n setModeRaw(\"day\");\n } else setAnchor(day);\n }\n\n function addNew() {\n if (!editable) return;\n const inView = mode === \"month\" ? ctx.today.slice(0, 7) === anchor.slice(0, 7) : range.days.includes(ctx.today);\n const day = inView ? ctx.today : mode === \"month\" ? startOfMonth(anchor) : range.days[0];\n if (mode === \"week\" || mode === \"day\") {\n const nowMin = dayOf(now) === day ? minutesOf(now) : null;\n const from = nowMin !== null ? Math.min(Math.ceil(nowMin / 60) * 60, DAY_MINUTES - defaultDuration) : 9 * 60;\n setDraft({ day, from, to: from + defaultDuration });\n } else {\n setDraft({ day, allDay: true });\n if (narrow) setSelected(day);\n }\n }\n\n function onKeyDown(e) {\n if (e.defaultPrevented || isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return;\n if (e.target.closest?.(\"[popover], dialog\")) return;\n const k = e.key.toLowerCase();\n const byKey = { m: \"month\", w: \"week\", d: \"day\", a: \"agenda\" };\n if (k === \"t\") goToday();\n else if (k === \"j\" || k === \"n\") go(1);\n else if (k === \"k\" || k === \"p\") go(-1);\n else if (k === \"c\" && editable) addNew();\n else if (byKey[k] && modes.includes(byKey[k])) setMode(byKey[k]);\n else return;\n e.preventDefault();\n }\n\n // ---- dragging ------------------------------------------------------------\n\n const ppm = hourHeight / 60;\n const pointer = useRef(null); // where the last press started\n const lastMove = useRef(null); // where the pointer is during a drag\n const grab = useRef(null); // the day of a span that was grabbed\n const [dragging, setDragging] = useState(false);\n useEffect(() => {\n if (!dragging) return undefined;\n const move = (e) => {\n lastMove.current = { x: e.clientX, y: e.clientY };\n };\n window.addEventListener(\"pointermove\", move, { passive: true });\n return () => window.removeEventListener(\"pointermove\", move);\n }, [dragging]);\n\n function pointerMinute(over, delta) {\n const p = lastMove.current ?? (pointer.current ? { y: pointer.current.y + delta.y } : null);\n if (!p || typeof over.top !== \"function\") return 9 * 60;\n return clamp(Math.floor((p.y - over.top()) / ppm / 15) * 15, 0, DAY_MINUTES - 15);\n }\n\n function onDragEnd({ active, over, delta }) {\n setDragging(false);\n const a = active?.data ?? {};\n const o = over?.data ?? null;\n const task = a.task;\n const grabbed = grab.current;\n grab.current = null;\n if (!task) return;\n const name = quote(task);\n const on = (day) => formatDay(day, ctx.today, { locale });\n if (a.kind === \"resize-y\") {\n const dm = snapMinutes(delta.y / ppm, snap);\n if (!dm) return;\n const to = clamp(a.event.to + dm, a.event.from + 15, DAY_MINUTES);\n update(task, resizeEventPatch(a.event, to), `${name} now ends at ${minutesLabel(to, { locale })}`);\n return;\n }\n if (a.kind === \"resize-x\") {\n const dd = Math.round(delta.x / a.cellWidth());\n if (!dd) return;\n const end = addDays(a.event.end, dd) < a.event.start ? a.event.start : addDays(a.event.end, dd);\n update(task, extendPatch(a.event, end), `${name} now ends ${on(end)}`);\n return;\n }\n if (o?.kind === \"tray\") {\n if (a.kind !== \"tray\") update(task, UNSCHEDULE, `${name} has no date now`);\n return;\n }\n if (a.kind === \"timed\") {\n if (o?.kind === \"cell\") {\n update(task, allDayPatch(o.day), `${name} is all day on ${on(o.day)}`);\n return;\n }\n const days = range.days;\n const k = clamp(Math.round(delta.x / a.colWidth()), -a.colIndex, days.length - 1 - a.colIndex);\n const dm = snapMinutes(delta.y / ppm, snap);\n if (!k && !dm) return;\n const day = days[a.colIndex + k];\n const from = clamp(a.event.from + dm, 0, DAY_MINUTES - (a.event.to - a.event.from));\n update(task, moveEventPatch(a.event, day, from), `${name} is now ${on(day)}, ${minutesLabel(from, { locale })}`);\n return;\n }\n if (!o) return;\n if (o.kind === \"column\") {\n const from = pointerMinute(o, delta);\n const len = a.kind === \"tray\" ? durationOf(task, defaultDuration) : defaultDuration;\n update(task, timedPatch(o.day, from, len), `${name} is now ${on(o.day)}, ${minutesLabel(from, { locale })}`);\n return;\n }\n if (o.kind === \"cell\") {\n const patch = a.kind === \"tray\" ? { due: o.day } : dayPatch(task, o.day, { grabbed });\n if (patch) update(task, patch, `${name} is now on ${on(o.day)}`);\n }\n }\n\n // ---- the available-time layer -------------------------------------------\n\n const slotKey = `${range.from}:${range.to}`;\n const [fetched, setFetched] = useState({ key: null, slots: [] });\n const timeMode = mode === \"week\" || mode === \"day\";\n useEffect(() => {\n if (!getSlots || !timeMode) return undefined;\n let alive = true;\n Promise.resolve()\n .then(() => getSlots({ from: range.from, to: range.to }))\n .then(\n (r) => alive && setFetched({ key: slotKey, slots: normalizeSlots(r) }),\n () => alive && setFetched({ key: slotKey, slots: [] }),\n );\n return () => {\n alive = false;\n };\n // range.from / range.to are in slotKey\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [getSlots, slotKey, timeMode]);\n const gridSlots = useMemo(() => [...normalizeSlots(slots), ...(fetched.key === slotKey ? fetched.slots : [])], [slots, fetched, slotKey]);\n const closed = useMemo(() => (availability ? (day) => closedOn(day, availability) : null), [availability]);\n\n const api = {\n ctx,\n editable,\n locale,\n colorBy,\n addLabel,\n snap,\n defaultDuration,\n open: onOpen ?? null,\n openDay,\n draft,\n startDraft: (d) => editable && setDraft(d),\n cancelDraft: () => setDraft(null),\n saveDraft,\n commit,\n update,\n remove,\n extend,\n nudge: nudgeBy,\n setGrab: (day) => {\n grab.current = day;\n },\n };\n\n // ---- drawing -----------------------------------------------------------------\n\n const title = titleFor(mode, range, anchor, { today: ctx.today, locale });\n const trayShown = tray && (sidePanel ? trayOpen : traySheet);\n const empty = editable && !loading && all.length === 0;\n\n let body;\n if (loading) body = <CalendarSkeleton />;\n else if (mode === \"month\")\n body = <MonthView weeks={range.weeks} month={anchor.slice(0, 7)} events={shownEvents} api={api} narrow={narrow} selected={selected} onSelect={setSelected} />;\n else if (mode === \"agenda\") body = <AgendaView days={range.days} events={shownEvents} api={api} />;\n else\n body = (\n <TimeGrid\n days={range.days}\n events={shownEvents}\n api={api}\n hourHeight={hourHeight}\n height={height}\n closed={closed}\n slots={gridSlots}\n renderSlot={renderSlot}\n onSlotClick={onSlotClick}\n selectedSlot={selectedSlot}\n slotLabel={slotLabel}\n now={now}\n />\n );\n\n return (\n <section\n ref={rootRef}\n aria-label={label}\n className={cx(\"grid min-w-0 gap-3\", className)}\n onKeyDown={onKeyDown}\n onPointerDownCapture={(e) => {\n pointer.current = { x: e.clientX, y: e.clientY };\n focusRef.current = null;\n }}\n onKeyDownCapture={(e) => {\n if (e.key === \" \" || e.key === \"Enter\") {\n pointer.current = null;\n grab.current = null;\n }\n }}\n >\n {header ? (\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2\">\n <h2 className=\"min-w-0 flex-1 truncate text-lg font-semibold tracking-tight\" aria-live=\"polite\">\n {title}\n </h2>\n <div className=\"flex items-center gap-1\">\n <Button size=\"sm\" variant=\"secondary\" onClick={goToday} title=\"Today (T)\">\n Today\n </Button>\n <IconButton size=\"sm\" icon=\"chevron-left\" label={`Previous ${UNIT[mode]}`} onClick={() => go(-1)} />\n <IconButton size=\"sm\" icon=\"chevron-right\" label={`Next ${UNIT[mode]}`} onClick={() => go(1)} />\n </div>\n {modes.length > 1 ? (\n <ToggleGroup\n size=\"sm\"\n aria-label=\"Calendar view\"\n value={mode}\n onChange={setMode}\n items={modes.map((m) => ({ value: m, label: MODE_LABELS[m] ?? m }))}\n className={narrow ? \"w-full\" : undefined}\n />\n ) : null}\n </div>\n ) : null}\n {toolbar ? (\n <ViewToolbar\n view={view}\n onViewChange={setView}\n ctx={ctx}\n tasks={all}\n controls={[\"search\", \"filter\"]}\n count={shown.length}\n extra={\n <>\n {tray ? (\n <Button\n size=\"sm\"\n variant={trayShown ? \"secondary\" : \"quiet\"}\n icon=\"inbox\"\n aria-pressed={sidePanel ? trayOpen : undefined}\n onClick={() => (sidePanel ? setTrayOpen((v) => !v) : setTraySheet(true))}\n >\n {unscheduled.length ? `Unscheduled · ${unscheduled.length}` : \"Unscheduled\"}\n </Button>\n ) : null}\n {editable ? (\n <Button size=\"sm\" variant=\"primary\" icon=\"plus\" onClick={addNew} title={`${addLabel} (C)`}>\n {addLabel}\n </Button>\n ) : null}\n {toolbarExtra}\n </>\n }\n />\n ) : null}\n {readError ? <Notice tone=\"danger\">{readError}</Notice> : null}\n {error ? (\n <Notice tone=\"danger\" onDismiss={clearError}>\n {error}\n </Notice>\n ) : null}\n {empty ? (\n <p className=\"text-sm text-muted-foreground\">\n {timeMode ? \"Nothing on the calendar yet. Click an hour, or drag across a few, to add a task.\" : \"Nothing on the calendar yet. Click any day to add a task there.\"}\n </p>\n ) : null}\n <TaskDragProvider\n disabled={!editable}\n onDragStart={() => {\n lastMove.current = null;\n setDragging(true);\n }}\n onDragOver={({ over }) => {\n if (grab.current == null && over?.data?.kind === \"cell\") grab.current = over.data.day;\n }}\n onDragEnd={onDragEnd}\n onDragCancel={() => {\n setDragging(false);\n grab.current = null;\n }}\n overlay={(a) =>\n a.data.kind === \"span\" || a.data.kind === \"tray\" ? (\n <div className={cx(LIFTED, \"w-52 max-w-[70vw]\")}>\n <TaskChip task={a.data.task} ctx={ctx} />\n </div>\n ) : null\n }\n >\n <div className=\"flex min-w-0 items-start gap-4\">\n <div className=\"min-w-0 flex-1\">{body}</div>\n {tray && sidePanel && trayOpen ? <UnscheduledTray tasks={unscheduled} api={api} className=\"sticky top-4 max-h-[40rem] w-60 shrink-0 overflow-y-auto\" /> : null}\n </div>\n </TaskDragProvider>\n {tray && !sidePanel ? (\n <Sheet open={traySheet} onClose={() => setTraySheet(false)} title=\"Unscheduled\" description=\"Tasks without a date yet. Give each one a day.\">\n <UnscheduledTray tasks={unscheduled} api={api} mode=\"sheet\" />\n </Sheet>\n ) : null}\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n </section>\n );\n}\n\nfunction CalendarSkeleton() {\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\" aria-busy=\"true\" aria-label=\"Loading the calendar\">\n <div className=\"grid grid-cols-7 gap-px border-b border-border p-2\">\n {Array.from({ length: 7 }, (_, i) => (\n <Skeleton key={i} className=\"h-3 w-10\" />\n ))}\n </div>\n {Array.from({ length: 5 }, (_, w) => (\n <div key={w} className=\"grid grid-cols-7 border-b border-border last:border-b-0\">\n {Array.from({ length: 7 }, (_, d) => (\n <div key={d} className=\"grid min-h-28 content-start gap-1.5 border-e border-border p-2 last:border-e-0\">\n <Skeleton className=\"h-3 w-4\" />\n {(w + d) % 3 === 0 ? <Skeleton className=\"h-4 w-full\" /> : null}\n {(w * d) % 4 === 1 ? <Skeleton className=\"h-4 w-2/3\" /> : null}\n </div>\n ))}\n </div>\n ))}\n </div>\n );\n}\n"
23
+ "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { Button, IconButton, Notice, Sheet, Skeleton, ToggleGroup, cx } from \"../../ui\";\nimport {\n DEFAULT_VIEW,\n LIFTED,\n TaskChip,\n TaskDragProvider,\n ViewToolbar,\n addDays,\n applyView,\n createTaskContext,\n dayOf,\n deleteChange,\n draftTask,\n formatDay,\n isDone,\n minutesOf,\n startOfMonth,\n useTaskChanges,\n withTime,\n} from \"../task-model\";\nimport {\n CALENDAR_MODES,\n DAY_MINUTES,\n MODE_LABELS,\n UNSCHEDULE,\n allDayPatch,\n closedOn,\n dayPatch,\n durationOf,\n eventsOf,\n extendPatch,\n minutesLabel,\n moveEventPatch,\n normalizeSlots,\n placeEventPatch,\n rangeFor,\n resizeEventPatch,\n snapMinutes,\n stepAnchor,\n timedPatch,\n titleFor,\n unscheduledOf,\n} from \"./calendar-model\";\nimport { AgendaView } from \"./agenda\";\nimport { MonthView } from \"./month\";\nimport { TimeGrid } from \"./time-grid\";\nimport { UnscheduledTray } from \"./tray\";\nimport { isTyping } from \"./paint\";\n\n// CalendarView — tasks on a calendar: month, week, day and agenda. Drag an\n// item to another day or hour, drag its end to change how long it runs,\n// click (or drag across) an empty stretch to add one there, and drag\n// tasks without a date in from the unscheduled tray. A line marks now.\n// Search and filters come from task-model's ViewToolbar, so a task\n// filtered out of the list is filtered out here too.\n//\n// Everything saves through one async `onChange(change)` answering { ok }\n// (task-model's change shapes); the calendar shows each change at once\n// and takes it back, with a notice, when the save fails.\n//\n// Editing is per item, not all or nothing: `canCreate={false}` keeps the\n// dragging and takes away every way to add (a click or a drag on an empty\n// day or hour, C, the Add buttons), and a task with `locked: true` stays\n// where it is (no drag, resize, keys, delete or done) but still opens.\n// Until 0.5.0 an onChange that let items move also let a click create\n// one, and moved every item: an app that shows events it did not make (a\n// mirror of the person's calendar) beside proposals it may move could not\n// have one without the other (inbox-to-calendar 0.3.0).\n//\n// The available-time layer (closed hours muted, open slots as boxes you\n// can pick) is what booking-page builds on: `availability`, `slots` or\n// `getSlots`, `renderSlot`, `onSlotClick`. README.md lists every prop.\n\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\nconst clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));\nconst UNIT = { month: \"month\", week: \"week\", day: \"day\", agenda: \"stretch\" };\nconst quote = (t) => `“${t?.title || \"Untitled\"}”`;\n\nfunction useControlled(value, initial, onChange) {\n const [own, setOwn] = useState(initial);\n const controlled = value !== undefined;\n const set = useCallback(\n (next) => {\n if (!controlled) setOwn(next);\n onChange?.(next);\n },\n [controlled, onChange],\n );\n return [controlled ? value : own, set];\n}\n\n// The wall clock, a minute at a time — after mount only, so the server\n// and the first client render agree. A fixed `now` wins (a demo).\nfunction useNow(fixed) {\n const [clock, setClock] = useState(null);\n useEffect(() => {\n if (fixed) return undefined;\n const tick = () => {\n const d = new Date();\n setClock(withTime(dayOf(d), d.getHours() * 60 + d.getMinutes()));\n };\n tick();\n const t = setInterval(tick, 60000);\n return () => clearInterval(t);\n }, [fixed]);\n return fixed ?? clock;\n}\n\nexport function CalendarView({\n tasks,\n statuses,\n people,\n projects,\n fields,\n today,\n weekStartsOn,\n weekend,\n holidays,\n now: nowProp,\n locale,\n onChange,\n canCreate = true,\n onOpen,\n mode: modeProp,\n defaultMode = \"month\",\n onModeChange,\n modes = CALENDAR_MODES,\n date: dateProp,\n defaultDate,\n onDateChange,\n view: viewProp,\n defaultView,\n onViewChange,\n header = true,\n toolbar = true,\n toolbarExtra,\n tray = true,\n colorBy = \"project\",\n availability,\n slots,\n getSlots,\n renderSlot,\n onSlotClick,\n selectedSlot,\n slotLabel = \"Open\",\n hourHeight = 48,\n height = \"40rem\",\n snap = 15,\n defaultDuration = 60,\n agendaDays = 14,\n addLabel = \"Add task\",\n loading = false,\n error: readError,\n label = \"Calendar\",\n className,\n}) {\n const ctx = useMemo(\n () => createTaskContext({ statuses, people, projects, fields, today, weekStartsOn, weekend, holidays }),\n [statuses, people, projects, fields, today, weekStartsOn, weekend, holidays],\n );\n const editable = typeof onChange === \"function\";\n const creatable = editable && canCreate !== false;\n const canEdit = (task) => editable && !task?.locked;\n const { tasks: all, commit, error, clearError } = useTaskChanges(tasks, onChange);\n const now = useNow(nowProp);\n\n const [modeRaw, setModeRaw] = useControlled(modeProp, defaultMode, onModeChange);\n const mode = modes.includes(modeRaw) ? modeRaw : modes[0] ?? \"month\";\n const [anchorRaw, setAnchor] = useControlled(dateProp, dayOf(defaultDate) ?? ctx.today, onDateChange);\n const anchor = dayOf(anchorRaw) ?? ctx.today;\n const [viewRaw, setView] = useControlled(viewProp, { ...DEFAULT_VIEW, groupBy: \"none\", ...defaultView }, onViewChange);\n const view = useMemo(() => ({ ...DEFAULT_VIEW, groupBy: \"none\", ...viewRaw }), [viewRaw]);\n\n // How wide we are, not the window: a calendar in a side panel is narrow.\n const rootRef = useRef(null);\n const [width, setWidth] = useState(null);\n useIsoLayoutEffect(() => {\n const el = rootRef.current;\n if (!el) return undefined;\n setWidth(el.getBoundingClientRect().width);\n if (typeof ResizeObserver === \"undefined\") return undefined;\n const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n const narrow = width !== null && width < 640;\n const sidePanel = width === null || width >= 960;\n const count = mode === \"week\" && narrow ? 3 : 7;\n\n const range = useMemo(() => rangeFor(mode, anchor, { weekStartsOn: ctx.weekStartsOn, count, agendaDays }), [mode, anchor, ctx.weekStartsOn, count, agendaDays]);\n const shown = useMemo(() => applyView(all, view, ctx).tasks, [all, view, ctx]);\n const events = useMemo(() => eventsOf(shown, { defaultMinutes: defaultDuration }), [shown, defaultDuration]);\n const unscheduled = useMemo(() => unscheduledOf(shown).filter((t) => !isDone(t, ctx)), [shown, ctx]);\n\n const [announce, setAnnounce] = useState(\"\");\n const say = (text) => setAnnounce(text);\n const [draft, setDraft] = useState(null); // { day, allDay } | { day, from, to }\n const [selected, setSelected] = useState(null); // a phone's month: the tapped day\n const [trayOpen, setTrayOpen] = useState(true);\n const [traySheet, setTraySheet] = useState(false);\n useEffect(() => setDraft(null), [mode, anchor]);\n\n // ---- saving ----------------------------------------------------------\n\n function update(task, patch, words) {\n if (!patch || !canEdit(task)) return Promise.resolve({ ok: false });\n return commit({ type: \"update\", id: task.id, patch }, { label: quote(task) }).then((r) => {\n if (r.ok !== false && words) say(words);\n return r;\n });\n }\n\n function remove(task) {\n if (!canEdit(task)) return Promise.resolve({ ok: false });\n return commit(deleteChange(all, [task.id]), { label: quote(task), undo: `Deleted ${quote(task)}` });\n }\n\n function saveDraft(title) {\n const d = draft;\n setDraft(null);\n if (!d || !creatable) return undefined;\n const patch = d.allDay ? { due: d.day } : { start: withTime(d.day, d.from), due: withTime(d.day, d.to) };\n const task = draftTask(title, ctx, patch);\n return commit({ type: \"create\", task }, { label: quote(task) }).then((r) => {\n if (r.ok !== false) say(`Added ${quote(task)} on ${formatDay(d.day, ctx.today, { locale })}${d.allDay ? \"\" : `, ${minutesLabel(d.from, { locale })}`}`);\n return r;\n });\n }\n\n function extend(event, dir) {\n const end = addDays(event.end, dir);\n if (end < event.start) return;\n update(event.task, extendPatch(event, end), `${quote(event.task)} now ends ${formatDay(end, ctx.today, { locale })}`);\n }\n\n // Arrow keys on a timed block: previewed at once, saved when the keys\n // rest, so ten presses are one save.\n const [nudge, setNudge] = useState(null);\n const nudgeRef = useRef(null);\n const nudgeTimer = useRef(null);\n const focusRef = useRef(null);\n useEffect(() => () => clearTimeout(nudgeTimer.current), []);\n\n function flushNudge() {\n clearTimeout(nudgeTimer.current);\n const n = nudgeRef.current;\n nudgeRef.current = null;\n setNudge(null);\n if (!n || (!n.dm && !n.dd && !n.dr)) return;\n const e = n.event;\n const len = Math.max(15, e.to - e.from + n.dr);\n const from = clamp(e.from + n.dm, 0, DAY_MINUTES - len);\n const day = addDays(e.start, n.dd);\n update(e.task, placeEventPatch(e, day, from, from + len), `${quote(e.task)} is now ${formatDay(day, ctx.today, { locale })}, ${minutesLabel(from, { locale })} to ${minutesLabel(from + len, { locale })}`);\n }\n\n function nudgeBy(event, { dm = 0, dd = 0, dr = 0 }) {\n if (nudgeRef.current && nudgeRef.current.id !== event.id) flushNudge();\n focusRef.current = event.id;\n const cur = nudgeRef.current ?? { id: event.id, event, dm: 0, dd: 0, dr: 0 };\n const next = { ...cur, dm: cur.dm + dm, dd: cur.dd + dd, dr: cur.dr + dr };\n nudgeRef.current = next;\n setNudge(next);\n clearTimeout(nudgeTimer.current);\n nudgeTimer.current = setTimeout(flushNudge, 700);\n }\n\n const shownEvents = useMemo(() => {\n if (!nudge) return events;\n return events.map((e) => {\n if (e.id !== nudge.id || !e.timed) return e;\n const day = addDays(e.start, nudge.dd);\n const len = Math.max(15, e.to - e.from + nudge.dr);\n const from = clamp(e.from + nudge.dm, 0, DAY_MINUTES - len);\n return { ...e, start: day, end: day, from, to: from + len, preview: true };\n });\n }, [events, nudge]);\n\n // A block moved by keys to another column is a new element: keep focus.\n useEffect(() => {\n if (!focusRef.current || !rootRef.current) return;\n const el = rootRef.current.querySelector(`[data-cal-id=\"${CSS.escape(focusRef.current)}\"]`);\n if (!el) return;\n if (document.activeElement !== el) el.focus({ preventScroll: false });\n if (!nudgeRef.current) focusRef.current = null;\n });\n\n // ---- navigation --------------------------------------------------------\n\n function setMode(next) {\n setModeRaw(next);\n setDraft(null);\n }\n\n function go(dir) {\n const next = stepAnchor(mode, anchor, dir, { count, agendaDays });\n setAnchor(next);\n if (mode === \"month\") setSelected(next.slice(0, 7) === ctx.today.slice(0, 7) ? ctx.today : startOfMonth(next));\n }\n\n function goToday() {\n setAnchor(ctx.today);\n setSelected(ctx.today);\n }\n\n function openDay(day) {\n if (modes.includes(\"day\")) {\n setAnchor(day);\n setModeRaw(\"day\");\n } else setAnchor(day);\n }\n\n function addNew() {\n if (!creatable) return;\n const inView = mode === \"month\" ? ctx.today.slice(0, 7) === anchor.slice(0, 7) : range.days.includes(ctx.today);\n const day = inView ? ctx.today : mode === \"month\" ? startOfMonth(anchor) : range.days[0];\n if (mode === \"week\" || mode === \"day\") {\n const nowMin = dayOf(now) === day ? minutesOf(now) : null;\n const from = nowMin !== null ? Math.min(Math.ceil(nowMin / 60) * 60, DAY_MINUTES - defaultDuration) : 9 * 60;\n setDraft({ day, from, to: from + defaultDuration });\n } else {\n setDraft({ day, allDay: true });\n if (narrow) setSelected(day);\n }\n }\n\n function onKeyDown(e) {\n if (e.defaultPrevented || isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return;\n if (e.target.closest?.(\"[popover], dialog\")) return;\n const k = e.key.toLowerCase();\n const byKey = { m: \"month\", w: \"week\", d: \"day\", a: \"agenda\" };\n if (k === \"t\") goToday();\n else if (k === \"j\" || k === \"n\") go(1);\n else if (k === \"k\" || k === \"p\") go(-1);\n else if (k === \"c\" && creatable) addNew();\n else if (byKey[k] && modes.includes(byKey[k])) setMode(byKey[k]);\n else return;\n e.preventDefault();\n }\n\n // ---- dragging ------------------------------------------------------------\n\n const ppm = hourHeight / 60;\n const pointer = useRef(null); // where the last press started\n const lastMove = useRef(null); // where the pointer is during a drag\n const grab = useRef(null); // the day of a span that was grabbed\n const [dragging, setDragging] = useState(false);\n useEffect(() => {\n if (!dragging) return undefined;\n const move = (e) => {\n lastMove.current = { x: e.clientX, y: e.clientY };\n };\n window.addEventListener(\"pointermove\", move, { passive: true });\n return () => window.removeEventListener(\"pointermove\", move);\n }, [dragging]);\n\n function pointerMinute(over, delta) {\n const p = lastMove.current ?? (pointer.current ? { y: pointer.current.y + delta.y } : null);\n if (!p || typeof over.top !== \"function\") return 9 * 60;\n return clamp(Math.floor((p.y - over.top()) / ppm / 15) * 15, 0, DAY_MINUTES - 15);\n }\n\n function onDragEnd({ active, over, delta }) {\n setDragging(false);\n const a = active?.data ?? {};\n const o = over?.data ?? null;\n const task = a.task;\n const grabbed = grab.current;\n grab.current = null;\n if (!task || !canEdit(task)) return;\n const name = quote(task);\n const on = (day) => formatDay(day, ctx.today, { locale });\n if (a.kind === \"resize-y\") {\n const dm = snapMinutes(delta.y / ppm, snap);\n if (!dm) return;\n const to = clamp(a.event.to + dm, a.event.from + 15, DAY_MINUTES);\n update(task, resizeEventPatch(a.event, to), `${name} now ends at ${minutesLabel(to, { locale })}`);\n return;\n }\n if (a.kind === \"resize-x\") {\n const dd = Math.round(delta.x / a.cellWidth());\n if (!dd) return;\n const end = addDays(a.event.end, dd) < a.event.start ? a.event.start : addDays(a.event.end, dd);\n update(task, extendPatch(a.event, end), `${name} now ends ${on(end)}`);\n return;\n }\n if (o?.kind === \"tray\") {\n if (a.kind !== \"tray\") update(task, UNSCHEDULE, `${name} has no date now`);\n return;\n }\n if (a.kind === \"timed\") {\n if (o?.kind === \"cell\") {\n update(task, allDayPatch(o.day), `${name} is all day on ${on(o.day)}`);\n return;\n }\n const days = range.days;\n const k = clamp(Math.round(delta.x / a.colWidth()), -a.colIndex, days.length - 1 - a.colIndex);\n const dm = snapMinutes(delta.y / ppm, snap);\n if (!k && !dm) return;\n const day = days[a.colIndex + k];\n const from = clamp(a.event.from + dm, 0, DAY_MINUTES - (a.event.to - a.event.from));\n update(task, moveEventPatch(a.event, day, from), `${name} is now ${on(day)}, ${minutesLabel(from, { locale })}`);\n return;\n }\n if (!o) return;\n if (o.kind === \"column\") {\n const from = pointerMinute(o, delta);\n const len = a.kind === \"tray\" ? durationOf(task, defaultDuration) : defaultDuration;\n update(task, timedPatch(o.day, from, len), `${name} is now ${on(o.day)}, ${minutesLabel(from, { locale })}`);\n return;\n }\n if (o.kind === \"cell\") {\n const patch = a.kind === \"tray\" ? { due: o.day } : dayPatch(task, o.day, { grabbed });\n if (patch) update(task, patch, `${name} is now on ${on(o.day)}`);\n }\n }\n\n // ---- the available-time layer -------------------------------------------\n\n const slotKey = `${range.from}:${range.to}`;\n const [fetched, setFetched] = useState({ key: null, slots: [] });\n const timeMode = mode === \"week\" || mode === \"day\";\n useEffect(() => {\n if (!getSlots || !timeMode) return undefined;\n let alive = true;\n Promise.resolve()\n .then(() => getSlots({ from: range.from, to: range.to }))\n .then(\n (r) => alive && setFetched({ key: slotKey, slots: normalizeSlots(r) }),\n () => alive && setFetched({ key: slotKey, slots: [] }),\n );\n return () => {\n alive = false;\n };\n // range.from / range.to are in slotKey\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [getSlots, slotKey, timeMode]);\n const gridSlots = useMemo(() => [...normalizeSlots(slots), ...(fetched.key === slotKey ? fetched.slots : [])], [slots, fetched, slotKey]);\n const closed = useMemo(() => (availability ? (day) => closedOn(day, availability) : null), [availability]);\n\n const api = {\n ctx,\n editable,\n creatable,\n canEdit,\n locale,\n colorBy,\n addLabel,\n snap,\n defaultDuration,\n open: onOpen ?? null,\n openDay,\n draft,\n startDraft: (d) => creatable && setDraft(d),\n cancelDraft: () => setDraft(null),\n saveDraft,\n commit,\n update,\n remove,\n extend,\n nudge: nudgeBy,\n setGrab: (day) => {\n grab.current = day;\n },\n };\n\n // ---- drawing -----------------------------------------------------------------\n\n const title = titleFor(mode, range, anchor, { today: ctx.today, locale });\n const trayShown = tray && (sidePanel ? trayOpen : traySheet);\n const empty = creatable && !loading && all.length === 0;\n\n let body;\n if (loading) body = <CalendarSkeleton />;\n else if (mode === \"month\")\n body = <MonthView weeks={range.weeks} month={anchor.slice(0, 7)} events={shownEvents} api={api} narrow={narrow} selected={selected} onSelect={setSelected} />;\n else if (mode === \"agenda\") body = <AgendaView days={range.days} events={shownEvents} api={api} />;\n else\n body = (\n <TimeGrid\n days={range.days}\n events={shownEvents}\n api={api}\n hourHeight={hourHeight}\n height={height}\n closed={closed}\n slots={gridSlots}\n renderSlot={renderSlot}\n onSlotClick={onSlotClick}\n selectedSlot={selectedSlot}\n slotLabel={slotLabel}\n now={now}\n />\n );\n\n return (\n <section\n ref={rootRef}\n aria-label={label}\n className={cx(\"grid min-w-0 gap-3\", className)}\n onKeyDown={onKeyDown}\n onPointerDownCapture={(e) => {\n pointer.current = { x: e.clientX, y: e.clientY };\n focusRef.current = null;\n }}\n onKeyDownCapture={(e) => {\n if (e.key === \" \" || e.key === \"Enter\") {\n pointer.current = null;\n grab.current = null;\n }\n }}\n >\n {header ? (\n <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2\">\n <h2 className=\"min-w-0 flex-1 truncate text-lg font-semibold tracking-tight\" aria-live=\"polite\">\n {title}\n </h2>\n <div className=\"flex items-center gap-1\">\n <Button size=\"sm\" variant=\"secondary\" onClick={goToday} title=\"Today (T)\">\n Today\n </Button>\n <IconButton size=\"sm\" icon=\"chevron-left\" label={`Previous ${UNIT[mode]}`} onClick={() => go(-1)} />\n <IconButton size=\"sm\" icon=\"chevron-right\" label={`Next ${UNIT[mode]}`} onClick={() => go(1)} />\n </div>\n {modes.length > 1 ? (\n <ToggleGroup\n size=\"sm\"\n aria-label=\"Calendar view\"\n value={mode}\n onChange={setMode}\n items={modes.map((m) => ({ value: m, label: MODE_LABELS[m] ?? m }))}\n className={narrow ? \"w-full\" : undefined}\n />\n ) : null}\n </div>\n ) : null}\n {toolbar ? (\n <ViewToolbar\n view={view}\n onViewChange={setView}\n ctx={ctx}\n tasks={all}\n controls={[\"search\", \"filter\"]}\n count={shown.length}\n extra={\n <>\n {tray ? (\n <Button\n size=\"sm\"\n variant={trayShown ? \"secondary\" : \"quiet\"}\n icon=\"inbox\"\n aria-pressed={sidePanel ? trayOpen : undefined}\n onClick={() => (sidePanel ? setTrayOpen((v) => !v) : setTraySheet(true))}\n >\n {unscheduled.length ? `Unscheduled · ${unscheduled.length}` : \"Unscheduled\"}\n </Button>\n ) : null}\n {creatable ? (\n <Button size=\"sm\" variant=\"primary\" icon=\"plus\" onClick={addNew} title={`${addLabel} (C)`}>\n {addLabel}\n </Button>\n ) : null}\n {toolbarExtra}\n </>\n }\n />\n ) : null}\n {readError ? <Notice tone=\"danger\">{readError}</Notice> : null}\n {error ? (\n <Notice tone=\"danger\" onDismiss={clearError}>\n {error}\n </Notice>\n ) : null}\n {empty ? (\n <p className=\"text-sm text-muted-foreground\">\n {timeMode ? \"Nothing on the calendar yet. Click an hour, or drag across a few, to add a task.\" : \"Nothing on the calendar yet. Click any day to add a task there.\"}\n </p>\n ) : null}\n <TaskDragProvider\n disabled={!editable}\n onDragStart={() => {\n lastMove.current = null;\n setDragging(true);\n }}\n onDragOver={({ over }) => {\n if (grab.current == null && over?.data?.kind === \"cell\") grab.current = over.data.day;\n }}\n onDragEnd={onDragEnd}\n onDragCancel={() => {\n setDragging(false);\n grab.current = null;\n }}\n overlay={(a) =>\n a.data.kind === \"span\" || a.data.kind === \"tray\" ? (\n <div className={cx(LIFTED, \"w-52 max-w-[70vw]\")}>\n <TaskChip task={a.data.task} ctx={ctx} />\n </div>\n ) : null\n }\n >\n <div className=\"flex min-w-0 items-start gap-4\">\n <div className=\"min-w-0 flex-1\">{body}</div>\n {tray && sidePanel && trayOpen ? <UnscheduledTray tasks={unscheduled} api={api} className=\"sticky top-4 max-h-[40rem] w-60 shrink-0 overflow-y-auto\" /> : null}\n </div>\n </TaskDragProvider>\n {tray && !sidePanel ? (\n <Sheet open={traySheet} onClose={() => setTraySheet(false)} title=\"Unscheduled\" description=\"Tasks without a date yet. Give each one a day.\">\n <UnscheduledTray tasks={unscheduled} api={api} mode=\"sheet\" />\n </Sheet>\n ) : null}\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n </section>\n );\n}\n\nfunction CalendarSkeleton() {\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\" aria-busy=\"true\" aria-label=\"Loading the calendar\">\n <div className=\"grid grid-cols-7 gap-px border-b border-border p-2\">\n {Array.from({ length: 7 }, (_, i) => (\n <Skeleton key={i} className=\"h-3 w-10\" />\n ))}\n </div>\n {Array.from({ length: 5 }, (_, w) => (\n <div key={w} className=\"grid grid-cols-7 border-b border-border last:border-b-0\">\n {Array.from({ length: 7 }, (_, d) => (\n <div key={d} className=\"grid min-h-28 content-start gap-1.5 border-e border-border p-2 last:border-e-0\">\n <Skeleton className=\"h-3 w-4\" />\n {(w + d) % 3 === 0 ? <Skeleton className=\"h-4 w-full\" /> : null}\n {(w * d) % 4 === 1 ? <Skeleton className=\"h-4 w-2/3\" /> : null}\n </div>\n ))}\n </div>\n ))}\n </div>\n );\n}\n"
24
24
  },
25
25
  {
26
26
  "path": "registry/blocks/calendar-view/calendar-model.js",
@@ -32,25 +32,25 @@
32
32
  "path": "registry/blocks/calendar-view/month.jsx",
33
33
  "type": "registry:component",
34
34
  "target": "@components/makersclaw/blocks/calendar-view/month.jsx",
35
- "content": "\"use client\";\n\nimport { useMemo, useRef, useState } from \"react\";\nimport { IconButton, Popover, cx } from \"../../ui\";\nimport { TaskChip, formatDate, isDone, isWeekend, useDraggableTask, useDropTarget } from \"../task-model\";\nimport { layoutLanes, minutesLabel } from \"./calendar-model\";\nimport { DraftInput } from \"./draft\";\nimport { paintOf, withKeys } from \"./paint\";\nimport { AgendaDay } from \"./agenda\";\n\n// The month: weeks of seven days. All-day tasks and spans are tinted bars\n// that run across the days they cover (stacked in lanes, never over each\n// other); a timed task is a dot, its time and its title. Three lanes show;\n// past that a day says \"2 more\" and opens its full list. Drag a bar to\n// another day, drag its end to make it longer, click an empty day to add\n// a task there. On a phone the days show dots, and tapping one lists it\n// under the grid.\n\nconst LANES = 3;\nconst LANE_PX = 24;\n\nconst dayName = (day, locale) => formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale });\n\nexport function MonthView({ weeks, month, events, api, narrow, selected, onSelect }) {\n const locale = api.locale;\n const heads = weeks[0] ?? [];\n if (narrow) return <NarrowMonth weeks={weeks} month={month} events={events} api={api} selected={selected} onSelect={onSelect} />;\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n <div className=\"grid grid-cols-7 border-b border-border\">\n {heads.map((d) => (\n <div key={d} className=\"px-2 py-2 text-xs font-medium text-muted-foreground\">\n {formatDate(d, { weekday: \"short\" }, { locale })}\n </div>\n ))}\n </div>\n {weeks.map((week) => (\n <WeekRow key={week[0]} week={week} month={month} events={events} api={api} />\n ))}\n </div>\n );\n}\n\nfunction WeekRow({ week, month, events, api }) {\n const rowRef = useRef(null);\n const layout = useMemo(() => layoutLanes(events, week), [events, week]);\n const overflow = layout.lanes > LANES;\n const shownLanes = overflow ? LANES - 1 : layout.lanes;\n const visible = layout.items.filter((it) => it.lane < shownLanes);\n // What each day cannot show: its items in the lanes that did not fit.\n const hidden = week.map((_, i) => layout.items.filter((it) => it.lane >= shownLanes && it.col <= i && i < it.col + it.span));\n const cellWidth = () => (rowRef.current?.getBoundingClientRect().width ?? 700) / 7;\n const reserve = Math.max(shownLanes + (overflow ? 1 : 0), 1) * LANE_PX;\n return (\n <div ref={rowRef} className=\"relative grid grid-cols-7 border-b border-border last:border-b-0\">\n {week.map((day, i) => (\n <DayCell\n key={day}\n day={day}\n inMonth={day.slice(0, 7) === month}\n api={api}\n reserve={reserve}\n hidden={hidden[i]}\n dayItems={layout.items.filter((it) => it.col <= i && i < it.col + it.span)}\n overflow={overflow}\n />\n ))}\n <div className=\"pointer-events-none absolute inset-x-0 top-10 grid grid-cols-7 gap-y-0.5\" style={{ gridAutoRows: `${LANE_PX - 2}px` }}>\n {visible.map((it) => (\n <MonthItem key={`${it.event.id}:${week[0]}`} item={it} week={week} api={api} cellWidth={cellWidth} />\n ))}\n </div>\n </div>\n );\n}\n\nfunction DayCell({ day, inMonth, api, reserve, hidden, dayItems, overflow }) {\n const { ctx, editable, locale } = api;\n const today = day === ctx.today;\n const label = dayName(day, locale);\n const drop = useDropTarget(`cell:${day}`, { kind: \"cell\", day, label });\n const drafting = api.draft && api.draft.allDay && api.draft.day === day;\n const firstOfMonth = day.endsWith(\"-01\");\n const onBackground = (e) => {\n // Only the cell's own empty space: not its buttons, not the \"more\"\n // list (a popover inside the cell, whose clicks bubble here).\n if (!editable || e.target.dataset?.calBg === undefined) return;\n api.startDraft({ day, allDay: true });\n };\n return (\n <div\n ref={drop.setNodeRef}\n data-cal-bg=\"\"\n onClick={onBackground}\n className={cx(\n \"group/cell relative flex min-h-32 min-w-0 flex-col border-e border-border p-1 transition-colors duration-150 last:border-e-0\",\n !inMonth && \"bg-muted/40\",\n inMonth && isWeekend(day, ctx.weekend) && \"bg-muted/20\",\n editable && \"cursor-default\",\n drop.isOver && \"bg-accent\",\n )}\n >\n <div className=\"flex h-9 items-center justify-between gap-1\">\n <DayNumber day={day} today={today} muted={!inMonth} label={firstOfMonth ? formatDate(day, { month: \"short\", day: \"numeric\" }, { locale }) : null} api={api} count={dayItems.length} />\n {editable ? (\n <IconButton\n size=\"sm\"\n icon=\"plus\"\n label={`${api.addLabel} on ${label}`}\n onClick={(e) => {\n e.stopPropagation();\n api.startDraft({ day, allDay: true });\n }}\n className=\"opacity-0 transition-opacity duration-150 group-hover/cell:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:hidden\"\n />\n ) : null}\n </div>\n <div data-cal-bg=\"\" aria-hidden=\"true\" style={{ height: reserve - (overflow ? LANE_PX : 0) }} />\n {overflow && hidden.length ? <MoreItems day={day} label={label} items={dayItems} count={hidden.length} api={api} /> : overflow ? <div aria-hidden=\"true\" style={{ height: LANE_PX }} /> : null}\n {drafting ? (\n <div className=\"mt-0.5 flex h-6 items-center rounded-sm bg-primary/10 px-1.5 ring-1 ring-primary\">\n <DraftInput label={`New task on ${label}`} onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n </div>\n ) : null}\n <div data-cal-bg=\"\" className=\"min-h-2 flex-1\" />\n </div>\n );\n}\n\nfunction DayNumber({ day, today, muted, label, api, count }) {\n const n = Number(day.slice(8, 10));\n const words = `${dayName(day, api.locale)}${count ? `, ${count} ${count === 1 ? \"item\" : \"items\"}` : \"\"}. Open the day`;\n return (\n // raw: the date is a small round target in the corner of the cell\n // (the day view opens from it); a kit Button is a 36px padded action.\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n api.openDay(day);\n }}\n aria-label={words}\n aria-current={today ? \"date\" : undefined}\n className={cx(\n \"inline-flex h-7 min-w-7 items-center justify-center rounded-full px-1.5 text-xs tabular-nums outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n today ? \"bg-primary font-semibold text-primary-foreground\" : \"hover:bg-accent\",\n !today && (muted ? \"text-muted-foreground\" : \"font-medium text-foreground\"),\n )}\n >\n {label ?? n}\n </button>\n );\n}\n\nfunction MoreItems({ day, label, items, count, api }) {\n const [open, setOpen] = useState(false);\n return (\n <Popover\n label={`Everything on ${label}`}\n open={open}\n onOpenChange={setOpen}\n width=\"md\"\n align=\"start\"\n className=\"p-2\"\n trigger={(props) => (\n // raw: a line of small text inside the day cell, sized to a lane;\n // Popover hands its trigger props to a button of ours.\n <button\n type=\"button\"\n {...props}\n onClick={(e) => e.stopPropagation()}\n className=\"flex h-6 items-center rounded-sm px-1.5 text-left text-xs font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n {count} more\n </button>\n )}\n >\n <p className=\"px-1 pb-2 text-sm font-semibold\">{label}</p>\n <ul className=\"grid gap-1\">\n {items.map((it) => (\n <li key={it.event.id}>\n <TaskChip task={it.event.task} ctx={api.ctx} onOpen={api.open ? (t) => (setOpen(false), api.open(t)) : undefined} />\n </li>\n ))}\n </ul>\n </Popover>\n );\n}\n\nfunction MonthItem({ item, week, api, cellWidth }) {\n const { event, col, span, lane, clippedStart, clippedEnd } = item;\n const { ctx, editable } = api;\n const task = event.task;\n const done = isDone(task, ctx);\n const paint = paintOf(task, ctx, api.colorBy);\n const title = task.title || \"Untitled\";\n const bar = !event.timed;\n const d = useDraggableTask(`bar:${event.id}:${week[0]}`, { kind: \"span\", task, event }, { disabled: !editable });\n const canResize = editable && bar && !clippedEnd;\n const r = useDraggableTask(`rx:${event.id}:${week[0]}`, { kind: \"resize-x\", task, event, cellWidth }, { disabled: !canResize });\n let grow = 0;\n if (r.isDragging && r.transform) grow = Math.round(r.transform.x / cellWidth());\n const shownSpan = Math.max(1, Math.min(7 - col, span + Math.max(grow, 1 - span)));\n const when = event.timed ? minutesLabel(event.from, { locale: api.locale }) : \"\";\n const words = `${title}, ${event.timed ? (event.deadline ? `due ${when}` : when) : event.start === event.end ? \"all day\" : `${formatDate(event.start, { month: \"short\", day: \"numeric\" })} to ${formatDate(event.end, { month: \"short\", day: \"numeric\" })}`}${done ? \", done\" : \"\"}`;\n const keys = withKeys(d.handle, (e) => {\n if (e.key === \"Enter\") return true;\n if ((e.key === \"Delete\" || e.key === \"Backspace\") && editable) {\n e.preventDefault();\n api.remove(task);\n return true;\n }\n if (bar && editable && e.shiftKey && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\")) {\n e.preventDefault();\n api.extend(event, e.key === \"ArrowRight\" ? 1 : -1);\n return true;\n }\n return false;\n });\n return (\n // raw: an item on the month is a drag source that also opens; a kit\n // Button would centre and pad it as an action.\n <button\n type=\"button\"\n ref={d.setNodeRef}\n data-cal-id={event.id}\n {...(editable ? keys : {})}\n onPointerDown={(e) => {\n if (editable) {\n // Which of its days was grabbed: a span moves by the days\n // between that one and the one it is dropped on.\n const rect = e.currentTarget.getBoundingClientRect();\n const w = rect.width / shownSpan;\n api.setGrab(week[col + Math.max(0, Math.min(shownSpan - 1, Math.floor((e.clientX - rect.left) / w)))]);\n keys.onPointerDown?.(e);\n }\n }}\n onClick={api.open ? () => api.open(task) : undefined}\n aria-label={words}\n title={title}\n style={{ gridColumn: `${col + 1} / span ${shownSpan}`, gridRow: lane + 1 }}\n className={cx(\n \"pointer-events-auto relative flex min-w-0 items-center gap-1.5 text-left text-xs outline-none transition-[opacity,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n clippedStart ? \"ms-0 rounded-s-none\" : \"ms-1\",\n clippedEnd ? \"me-0 rounded-e-none\" : \"me-1\",\n bar ? cx(\"rounded-sm border-s-[3px] px-1.5 font-medium text-foreground\", paint.tint, clippedStart && \"border-s-0 ps-2\") : \"rounded-sm px-1.5 hover:bg-accent\",\n editable ? \"cursor-grab active:cursor-grabbing\" : api.open ? \"cursor-pointer\" : \"cursor-default\",\n d.isDragging && \"opacity-40\",\n r.isDragging && \"ring-2 ring-primary\",\n done && \"text-muted-foreground\",\n )}\n >\n {bar ? null : <span aria-hidden=\"true\" className={cx(\"size-2 shrink-0 rounded-full\", paint.dot, done && \"opacity-50\")} />}\n {when ? (\n <span data-nums className=\"shrink-0 text-muted-foreground\">\n {when}\n </span>\n ) : null}\n <span className={cx(\"min-w-0 flex-1 truncate\", done && \"line-through\")}>{title}</span>\n {canResize ? (\n <span\n ref={r.setNodeRef}\n {...r.handle}\n role=\"presentation\"\n tabIndex={-1}\n aria-hidden=\"true\"\n onClick={(e) => e.stopPropagation()}\n className=\"absolute inset-y-0 end-0 w-2 cursor-ew-resize rounded-e-sm hover:bg-primary/30\"\n />\n ) : null}\n </button>\n );\n}\n\n// ---- a phone's month: dots, and the tapped day listed under it ----------\n\nfunction NarrowMonth({ weeks, month, events, api, selected, onSelect }) {\n const { ctx, locale } = api;\n const byDay = useMemo(() => {\n const m = new Map();\n for (const e of events) {\n for (const week of weeks) {\n for (const day of week) if (e.start <= day && day <= e.end) m.set(day, [...(m.get(day) ?? []), e]);\n }\n }\n return m;\n }, [events, weeks]);\n const day = selected ?? ctx.today;\n return (\n <div className=\"grid gap-4\">\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n <div className=\"grid grid-cols-7 border-b border-border\">\n {weeks[0].map((d) => (\n <div key={d} className=\"py-2 text-center text-2xs font-medium text-muted-foreground\">\n {formatDate(d, { weekday: \"narrow\" }, { locale })}\n </div>\n ))}\n </div>\n {weeks.map((week) => (\n <div key={week[0]} className=\"grid grid-cols-7\">\n {week.map((d) => {\n const items = byDay.get(d) ?? [];\n const isSel = d === day;\n const isToday = d === ctx.today;\n return (\n // raw: a day on a phone's month is one tap target — a\n // number and its dots — that lists the day below.\n <button\n key={d}\n type=\"button\"\n onClick={() => onSelect(d)}\n aria-pressed={isSel}\n aria-label={`${dayName(d, locale)}${items.length ? `, ${items.length} ${items.length === 1 ? \"item\" : \"items\"}` : \"\"}`}\n className={cx(\n \"flex min-h-12 flex-col items-center justify-start gap-1 py-1.5 outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n d.slice(0, 7) !== month && \"text-muted-foreground\",\n )}\n >\n <span\n className={cx(\n \"inline-flex size-7 items-center justify-center rounded-full text-sm tabular-nums\",\n isSel ? \"bg-foreground font-semibold text-background\" : isToday ? \"font-semibold text-primary\" : undefined,\n )}\n >\n {Number(d.slice(8, 10))}\n </span>\n <span className=\"flex h-1.5 items-center gap-0.5\" aria-hidden=\"true\">\n {items.slice(0, 3).map((e) => (\n <span key={e.id} className={cx(\"size-1.5 rounded-full\", paintOf(e.task, ctx, api.colorBy).dot)} />\n ))}\n </span>\n </button>\n );\n })}\n </div>\n ))}\n </div>\n <AgendaDay day={day} events={byDay.get(day) ?? eventsOn(events, day)} api={api} standalone />\n </div>\n );\n}\n\nfunction eventsOn(events, day) {\n return events.filter((e) => e.start <= day && day <= e.end);\n}\n\n"
35
+ "content": "\"use client\";\n\nimport { useMemo, useRef, useState } from \"react\";\nimport { IconButton, Popover, cx } from \"../../ui\";\nimport { TaskChip, formatDate, isDone, isWeekend, useDraggableTask, useDropTarget } from \"../task-model\";\nimport { layoutLanes, minutesLabel } from \"./calendar-model\";\nimport { DraftInput } from \"./draft\";\nimport { paintOf, withKeys } from \"./paint\";\nimport { AgendaDay } from \"./agenda\";\n\n// The month: weeks of seven days. All-day tasks and spans are tinted bars\n// that run across the days they cover (stacked in lanes, never over each\n// other); a timed task is a dot, its time and its title. Three lanes show;\n// past that a day says \"2 more\" and opens its full list. Drag a bar to\n// another day, drag its end to make it longer, click an empty day to add\n// a task there. On a phone the days show dots, and tapping one lists it\n// under the grid.\n\nconst LANES = 3;\nconst LANE_PX = 24;\n\nconst dayName = (day, locale) => formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale });\n\nexport function MonthView({ weeks, month, events, api, narrow, selected, onSelect }) {\n const locale = api.locale;\n const heads = weeks[0] ?? [];\n if (narrow) return <NarrowMonth weeks={weeks} month={month} events={events} api={api} selected={selected} onSelect={onSelect} />;\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n <div className=\"grid grid-cols-7 border-b border-border\">\n {heads.map((d) => (\n <div key={d} className=\"px-2 py-2 text-xs font-medium text-muted-foreground\">\n {formatDate(d, { weekday: \"short\" }, { locale })}\n </div>\n ))}\n </div>\n {weeks.map((week) => (\n <WeekRow key={week[0]} week={week} month={month} events={events} api={api} />\n ))}\n </div>\n );\n}\n\nfunction WeekRow({ week, month, events, api }) {\n const rowRef = useRef(null);\n const layout = useMemo(() => layoutLanes(events, week), [events, week]);\n const overflow = layout.lanes > LANES;\n const shownLanes = overflow ? LANES - 1 : layout.lanes;\n const visible = layout.items.filter((it) => it.lane < shownLanes);\n // What each day cannot show: its items in the lanes that did not fit.\n const hidden = week.map((_, i) => layout.items.filter((it) => it.lane >= shownLanes && it.col <= i && i < it.col + it.span));\n const cellWidth = () => (rowRef.current?.getBoundingClientRect().width ?? 700) / 7;\n const reserve = Math.max(shownLanes + (overflow ? 1 : 0), 1) * LANE_PX;\n return (\n <div ref={rowRef} className=\"relative grid grid-cols-7 border-b border-border last:border-b-0\">\n {week.map((day, i) => (\n <DayCell\n key={day}\n day={day}\n inMonth={day.slice(0, 7) === month}\n api={api}\n reserve={reserve}\n hidden={hidden[i]}\n dayItems={layout.items.filter((it) => it.col <= i && i < it.col + it.span)}\n overflow={overflow}\n />\n ))}\n <div className=\"pointer-events-none absolute inset-x-0 top-10 grid grid-cols-7 gap-y-0.5\" style={{ gridAutoRows: `${LANE_PX - 2}px` }}>\n {visible.map((it) => (\n <MonthItem key={`${it.event.id}:${week[0]}`} item={it} week={week} api={api} cellWidth={cellWidth} />\n ))}\n </div>\n </div>\n );\n}\n\nfunction DayCell({ day, inMonth, api, reserve, hidden, dayItems, overflow }) {\n const { ctx, creatable, locale } = api;\n const today = day === ctx.today;\n const label = dayName(day, locale);\n const drop = useDropTarget(`cell:${day}`, { kind: \"cell\", day, label });\n const drafting = api.draft && api.draft.allDay && api.draft.day === day;\n const firstOfMonth = day.endsWith(\"-01\");\n const onBackground = (e) => {\n // Only the cell's own empty space: not its buttons, not the \"more\"\n // list (a popover inside the cell, whose clicks bubble here).\n if (!creatable || e.target.dataset?.calBg === undefined) return;\n api.startDraft({ day, allDay: true });\n };\n return (\n <div\n ref={drop.setNodeRef}\n data-cal-bg=\"\"\n onClick={onBackground}\n className={cx(\n \"group/cell relative flex min-h-32 min-w-0 flex-col border-e border-border p-1 transition-colors duration-150 last:border-e-0\",\n !inMonth && \"bg-muted/40\",\n inMonth && isWeekend(day, ctx.weekend) && \"bg-muted/20\",\n creatable && \"cursor-default\",\n drop.isOver && \"bg-accent\",\n )}\n >\n <div className=\"flex h-9 items-center justify-between gap-1\">\n <DayNumber day={day} today={today} muted={!inMonth} label={firstOfMonth ? formatDate(day, { month: \"short\", day: \"numeric\" }, { locale }) : null} api={api} count={dayItems.length} />\n {creatable ? (\n <IconButton\n size=\"sm\"\n icon=\"plus\"\n label={`${api.addLabel} on ${label}`}\n onClick={(e) => {\n e.stopPropagation();\n api.startDraft({ day, allDay: true });\n }}\n className=\"opacity-0 transition-opacity duration-150 group-hover/cell:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:hidden\"\n />\n ) : null}\n </div>\n <div data-cal-bg=\"\" aria-hidden=\"true\" style={{ height: reserve - (overflow ? LANE_PX : 0) }} />\n {overflow && hidden.length ? <MoreItems day={day} label={label} items={dayItems} count={hidden.length} api={api} /> : overflow ? <div aria-hidden=\"true\" style={{ height: LANE_PX }} /> : null}\n {drafting ? (\n <div className=\"mt-0.5 flex h-6 items-center rounded-sm bg-primary/10 px-1.5 ring-1 ring-primary\">\n <DraftInput label={`New task on ${label}`} onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n </div>\n ) : null}\n <div data-cal-bg=\"\" className=\"min-h-2 flex-1\" />\n </div>\n );\n}\n\nfunction DayNumber({ day, today, muted, label, api, count }) {\n const n = Number(day.slice(8, 10));\n const words = `${dayName(day, api.locale)}${count ? `, ${count} ${count === 1 ? \"item\" : \"items\"}` : \"\"}. Open the day`;\n return (\n // raw: the date is a small round target in the corner of the cell\n // (the day view opens from it); a kit Button is a 36px padded action.\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n api.openDay(day);\n }}\n aria-label={words}\n aria-current={today ? \"date\" : undefined}\n className={cx(\n \"inline-flex h-7 min-w-7 items-center justify-center rounded-full px-1.5 text-xs tabular-nums outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n today ? \"bg-primary font-semibold text-primary-foreground\" : \"hover:bg-accent\",\n !today && (muted ? \"text-muted-foreground\" : \"font-medium text-foreground\"),\n )}\n >\n {label ?? n}\n </button>\n );\n}\n\nfunction MoreItems({ day, label, items, count, api }) {\n const [open, setOpen] = useState(false);\n return (\n <Popover\n label={`Everything on ${label}`}\n open={open}\n onOpenChange={setOpen}\n width=\"md\"\n align=\"start\"\n className=\"p-2\"\n trigger={(props) => (\n // raw: a line of small text inside the day cell, sized to a lane;\n // Popover hands its trigger props to a button of ours.\n <button\n type=\"button\"\n {...props}\n onClick={(e) => e.stopPropagation()}\n className=\"flex h-6 items-center rounded-sm px-1.5 text-left text-xs font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n {count} more\n </button>\n )}\n >\n <p className=\"px-1 pb-2 text-sm font-semibold\">{label}</p>\n <ul className=\"grid gap-1\">\n {items.map((it) => (\n <li key={it.event.id}>\n <TaskChip task={it.event.task} ctx={api.ctx} onOpen={api.open ? (t) => (setOpen(false), api.open(t)) : undefined} />\n </li>\n ))}\n </ul>\n </Popover>\n );\n}\n\nfunction MonthItem({ item, week, api, cellWidth }) {\n const { event, col, span, lane, clippedStart, clippedEnd } = item;\n const { ctx } = api;\n const task = event.task;\n const editable = api.canEdit(task);\n const done = isDone(task, ctx);\n const paint = paintOf(task, ctx, api.colorBy);\n const title = task.title || \"Untitled\";\n const bar = !event.timed;\n const d = useDraggableTask(`bar:${event.id}:${week[0]}`, { kind: \"span\", task, event }, { disabled: !editable });\n const canResize = editable && bar && !clippedEnd;\n const r = useDraggableTask(`rx:${event.id}:${week[0]}`, { kind: \"resize-x\", task, event, cellWidth }, { disabled: !canResize });\n let grow = 0;\n if (r.isDragging && r.transform) grow = Math.round(r.transform.x / cellWidth());\n const shownSpan = Math.max(1, Math.min(7 - col, span + Math.max(grow, 1 - span)));\n const when = event.timed ? minutesLabel(event.from, { locale: api.locale }) : \"\";\n const words = `${title}, ${event.timed ? (event.deadline ? `due ${when}` : when) : event.start === event.end ? \"all day\" : `${formatDate(event.start, { month: \"short\", day: \"numeric\" })} to ${formatDate(event.end, { month: \"short\", day: \"numeric\" })}`}${done ? \", done\" : \"\"}`;\n const keys = withKeys(d.handle, (e) => {\n if (e.key === \"Enter\") return true;\n if ((e.key === \"Delete\" || e.key === \"Backspace\") && editable) {\n e.preventDefault();\n api.remove(task);\n return true;\n }\n if (bar && editable && e.shiftKey && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\")) {\n e.preventDefault();\n api.extend(event, e.key === \"ArrowRight\" ? 1 : -1);\n return true;\n }\n return false;\n });\n return (\n // raw: an item on the month is a drag source that also opens; a kit\n // Button would centre and pad it as an action.\n <button\n type=\"button\"\n ref={d.setNodeRef}\n data-cal-id={event.id}\n {...(editable ? keys : {})}\n onPointerDown={(e) => {\n if (editable) {\n // Which of its days was grabbed: a span moves by the days\n // between that one and the one it is dropped on.\n const rect = e.currentTarget.getBoundingClientRect();\n const w = rect.width / shownSpan;\n api.setGrab(week[col + Math.max(0, Math.min(shownSpan - 1, Math.floor((e.clientX - rect.left) / w)))]);\n keys.onPointerDown?.(e);\n }\n }}\n onClick={api.open ? () => api.open(task) : undefined}\n aria-label={words}\n title={title}\n style={{ gridColumn: `${col + 1} / span ${shownSpan}`, gridRow: lane + 1 }}\n className={cx(\n \"pointer-events-auto relative flex min-w-0 items-center gap-1.5 text-left text-xs outline-none transition-[opacity,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n clippedStart ? \"ms-0 rounded-s-none\" : \"ms-1\",\n clippedEnd ? \"me-0 rounded-e-none\" : \"me-1\",\n bar ? cx(\"rounded-sm border-s-[3px] px-1.5 font-medium text-foreground\", paint.tint, clippedStart && \"border-s-0 ps-2\") : \"rounded-sm px-1.5 hover:bg-accent\",\n editable ? \"cursor-grab active:cursor-grabbing\" : api.open ? \"cursor-pointer\" : \"cursor-default\",\n d.isDragging && \"opacity-40\",\n r.isDragging && \"ring-2 ring-primary\",\n done && \"text-muted-foreground\",\n )}\n >\n {bar ? null : <span aria-hidden=\"true\" className={cx(\"size-2 shrink-0 rounded-full\", paint.dot, done && \"opacity-50\")} />}\n {when ? (\n <span data-nums className=\"shrink-0 text-muted-foreground\">\n {when}\n </span>\n ) : null}\n <span className={cx(\"min-w-0 flex-1 truncate\", done && \"line-through\")}>{title}</span>\n {canResize ? (\n <span\n ref={r.setNodeRef}\n {...r.handle}\n role=\"presentation\"\n tabIndex={-1}\n aria-hidden=\"true\"\n onClick={(e) => e.stopPropagation()}\n className=\"absolute inset-y-0 end-0 w-2 cursor-ew-resize rounded-e-sm hover:bg-primary/30\"\n />\n ) : null}\n </button>\n );\n}\n\n// ---- a phone's month: dots, and the tapped day listed under it ----------\n\nfunction NarrowMonth({ weeks, month, events, api, selected, onSelect }) {\n const { ctx, locale } = api;\n const byDay = useMemo(() => {\n const m = new Map();\n for (const e of events) {\n for (const week of weeks) {\n for (const day of week) if (e.start <= day && day <= e.end) m.set(day, [...(m.get(day) ?? []), e]);\n }\n }\n return m;\n }, [events, weeks]);\n const day = selected ?? ctx.today;\n return (\n <div className=\"grid gap-4\">\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n <div className=\"grid grid-cols-7 border-b border-border\">\n {weeks[0].map((d) => (\n <div key={d} className=\"py-2 text-center text-2xs font-medium text-muted-foreground\">\n {formatDate(d, { weekday: \"narrow\" }, { locale })}\n </div>\n ))}\n </div>\n {weeks.map((week) => (\n <div key={week[0]} className=\"grid grid-cols-7\">\n {week.map((d) => {\n const items = byDay.get(d) ?? [];\n const isSel = d === day;\n const isToday = d === ctx.today;\n return (\n // raw: a day on a phone's month is one tap target — a\n // number and its dots — that lists the day below.\n <button\n key={d}\n type=\"button\"\n onClick={() => onSelect(d)}\n aria-pressed={isSel}\n aria-label={`${dayName(d, locale)}${items.length ? `, ${items.length} ${items.length === 1 ? \"item\" : \"items\"}` : \"\"}`}\n className={cx(\n \"flex min-h-12 flex-col items-center justify-start gap-1 py-1.5 outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n d.slice(0, 7) !== month && \"text-muted-foreground\",\n )}\n >\n <span\n className={cx(\n \"inline-flex size-7 items-center justify-center rounded-full text-sm tabular-nums\",\n isSel ? \"bg-foreground font-semibold text-background\" : isToday ? \"font-semibold text-primary\" : undefined,\n )}\n >\n {Number(d.slice(8, 10))}\n </span>\n <span className=\"flex h-1.5 items-center gap-0.5\" aria-hidden=\"true\">\n {items.slice(0, 3).map((e) => (\n <span key={e.id} className={cx(\"size-1.5 rounded-full\", paintOf(e.task, ctx, api.colorBy).dot)} />\n ))}\n </span>\n </button>\n );\n })}\n </div>\n ))}\n </div>\n <AgendaDay day={day} events={byDay.get(day) ?? eventsOn(events, day)} api={api} standalone />\n </div>\n );\n}\n\nfunction eventsOn(events, day) {\n return events.filter((e) => e.start <= day && day <= e.end);\n}\n\n"
36
36
  },
37
37
  {
38
38
  "path": "registry/blocks/calendar-view/time-grid.jsx",
39
39
  "type": "registry:component",
40
40
  "target": "@components/makersclaw/blocks/calendar-view/time-grid.jsx",
41
- "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport { dayOf, formatDate, isDone, isWeekend, minutesOf, useDraggableTask, useDropTarget } from \"../task-model\";\nimport { DAY_MINUTES, eventTimeLabel, hourLabel, layoutLanes, layoutTimed, minutesLabel, snapMinutes } from \"./calendar-model\";\nimport { DraftInput } from \"./draft\";\nimport { paintOf, withKeys } from \"./paint\";\n\n// The week and the day: a strip of all-day items on top, then the hours\n// as a scrolling grid with one column per day. Timed tasks are tinted\n// blocks placed by their times; overlapping ones share the column side by\n// side. Drag a block to move it (it snaps to the quarter hour and to the\n// day columns as it goes), drag its bottom edge to change how long it\n// runs, press on an empty stretch and drag to make a new one (a click\n// makes an hour). A line marks now. The available-time layer draws closed\n// hours muted and open slots as dashed boxes.\n//\n// Keyboard: a focused block moves with the arrow keys (up/down 15\n// minutes, left/right a day), Shift+up/down changes its length, Enter\n// opens it, Delete removes it (with Undo).\n\nconst GUTTER = \"w-14 shrink-0\";\n\nexport function TimeGrid({ days, events, api, hourHeight = 48, height = \"40rem\", closed, slots, renderSlot, onSlotClick, selectedSlot, slotLabel, now }) {\n const { ctx, locale } = api;\n const n = days.length;\n const cols = { gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` };\n const scrollRef = useRef(null);\n const colsRef = useRef(null);\n const colWidth = () => (colsRef.current?.getBoundingClientRect().width ?? 700) / n;\n const timed = useMemo(() => events.filter((e) => e.timed), [events]);\n const allDay = useMemo(() => events.filter((e) => !e.timed), [events]);\n const nowDay = dayOf(now);\n const nowMin = now ? minutesOf(now) : null;\n\n // Open the grid near what matters: now, else the day's first item, else 8 AM.\n const first = timed.length ? Math.min(...timed.map((e) => e.from)) : null;\n const focusMin = nowDay && days.includes(nowDay) && nowMin !== null ? nowMin - 90 : first !== null ? first - 30 : 8 * 60;\n const scrollKey = `${days[0]}:${n}`;\n const scrolled = useRef(null);\n useEffect(() => {\n if (scrolled.current === scrollKey || !scrollRef.current) return;\n scrolled.current = scrollKey;\n scrollRef.current.scrollTop = Math.max(0, (focusMin / 60) * hourHeight);\n }, [scrollKey, focusMin, hourHeight]);\n\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n <div className=\"flex overflow-y-hidden border-b border-border [scrollbar-gutter:stable]\">\n <div className={GUTTER} />\n <div className=\"grid flex-1\" style={cols}>\n {days.map((day) => (\n <DayHead key={day} day={day} api={api} single={n === 1} />\n ))}\n </div>\n </div>\n <AllDayStrip days={days} events={allDay} api={api} cols={cols} />\n <div ref={scrollRef} className=\"relative overflow-y-auto overscroll-contain [scrollbar-gutter:stable]\" style={{ height }}>\n <div className=\"relative flex\" style={{ height: 24 * hourHeight }}>\n <div className={cx(GUTTER, \"relative\")} aria-hidden=\"true\">\n {Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (\n <span key={h} data-nums className=\"absolute end-2 -translate-y-1/2 text-2xs text-muted-foreground\" style={{ top: h * hourHeight }}>\n {hourLabel(h, { locale })}\n </span>\n ))}\n </div>\n <div ref={colsRef} className=\"relative grid flex-1\" style={cols}>\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n {Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (\n <div key={h} className=\"absolute inset-x-0 border-t border-border\" style={{ top: h * hourHeight }} />\n ))}\n </div>\n {days.map((day, i) => (\n <DayColumn\n key={day}\n day={day}\n index={i}\n days={days}\n events={timed.filter((e) => e.start === day)}\n api={api}\n hourHeight={hourHeight}\n colWidth={colWidth}\n closed={closed ? closed(day) : null}\n slots={slots?.filter((s) => s.day === day)}\n renderSlot={renderSlot}\n onSlotClick={onSlotClick}\n selectedSlot={selectedSlot}\n slotLabel={slotLabel}\n nowMin={day === nowDay ? nowMin : null}\n weekend={isWeekend(day, ctx.weekend)}\n />\n ))}\n </div>\n </div>\n </div>\n </div>\n );\n}\n\nfunction DayHead({ day, api, single }) {\n const { ctx, locale } = api;\n const today = day === ctx.today;\n return (\n <div className={cx(\"flex min-w-0 items-center justify-center border-s border-border py-2 first:border-s-0\", single && \"justify-start ps-3\")}>\n {/* raw: the heading is the way into that day (a button in the header\n row); a kit Button would pad it as an action. */}\n <button\n type=\"button\"\n onClick={() => api.openDay(day)}\n disabled={single}\n aria-label={`${formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale })}. Open the day`}\n aria-current={today ? \"date\" : undefined}\n className=\"group/head inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:hover:bg-transparent\"\n >\n <span className={cx(\"text-xs\", today ? \"font-medium text-primary\" : \"text-muted-foreground\")}>{formatDate(day, { weekday: \"short\" }, { locale })}</span>\n <span\n data-nums\n className={cx(\"inline-flex size-7 items-center justify-center rounded-full text-sm font-semibold\", today ? \"bg-primary text-primary-foreground\" : \"text-foreground\")}\n >\n {Number(day.slice(8, 10))}\n </span>\n </button>\n </div>\n );\n}\n\n// ---- the all-day strip ----------------------------------------------------\n\nconst STRIP_LANES = 3;\n\nfunction AllDayStrip({ days, events, api, cols }) {\n const [open, setOpen] = useState(false);\n const rowRef = useRef(null);\n const layout = useMemo(() => layoutLanes(events, days), [events, days]);\n const over = layout.lanes > STRIP_LANES && !open;\n const lanes = over ? STRIP_LANES - 1 : layout.lanes;\n const hiddenCount = layout.items.filter((it) => it.lane >= lanes).length;\n const cellWidth = () => (rowRef.current?.getBoundingClientRect().width ?? 700) / days.length;\n const drafting = api.draft?.allDay && days.includes(api.draft.day);\n const rows = Math.max(lanes + (over ? 1 : 0) + (drafting ? 1 : 0), 1);\n return (\n <div className=\"flex overflow-y-hidden border-b border-border [scrollbar-gutter:stable]\">\n <div className={cx(GUTTER, \"flex items-start justify-end pe-2 pt-1.5 text-2xs text-muted-foreground\")}>All day</div>\n <div ref={rowRef} className=\"relative grid flex-1\" style={{ ...cols, minHeight: rows * 24 + 8 }}>\n {days.map((day) => (\n <StripCell key={day} day={day} api={api} />\n ))}\n <div className=\"pointer-events-none absolute inset-x-0 top-1 grid gap-y-0.5\" style={{ ...cols, gridAutoRows: \"22px\" }}>\n {layout.items\n .filter((it) => it.lane < lanes)\n .map((it) => (\n <StripItem key={it.event.id} item={it} days={days} api={api} cellWidth={cellWidth} />\n ))}\n {over ? (\n // raw: a line of small text sized to a lane that opens the strip;\n // a kit Button is a 36px action.\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n style={{ gridColumn: `1 / span ${days.length}`, gridRow: lanes + 1 }}\n className=\"pointer-events-auto mx-1 justify-self-start rounded-sm px-1.5 text-xs font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n {hiddenCount} more\n </button>\n ) : null}\n {drafting ? (\n <div\n style={{ gridColumn: `${days.indexOf(api.draft.day) + 1}`, gridRow: lanes + (over ? 2 : 1) }}\n className=\"pointer-events-auto mx-1 flex items-center rounded-sm bg-primary/10 px-1.5 ring-1 ring-primary\"\n >\n <DraftInput label={`New all-day task on ${formatDate(api.draft.day, { weekday: \"long\", month: \"long\", day: \"numeric\" })}`} onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n </div>\n ) : null}\n </div>\n </div>\n </div>\n );\n}\n\nfunction StripCell({ day, api }) {\n const label = `${formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale: api.locale })}, all day`;\n const drop = useDropTarget(`strip:${day}`, { kind: \"cell\", day, label });\n return (\n <div\n ref={drop.setNodeRef}\n onClick={(e) => {\n if (api.editable && e.target === e.currentTarget) api.startDraft({ day, allDay: true });\n }}\n className={cx(\"border-s border-border transition-colors duration-150 first:border-s-0\", drop.isOver && \"bg-accent\")}\n />\n );\n}\n\nfunction StripItem({ item, days, api, cellWidth }) {\n const { event, col, span, lane, clippedStart, clippedEnd } = item;\n const { ctx, editable } = api;\n const task = event.task;\n const done = isDone(task, ctx);\n const paint = paintOf(task, ctx, api.colorBy);\n const title = task.title || \"Untitled\";\n const d = useDraggableTask(`strip:${event.id}`, { kind: \"span\", task, event }, { disabled: !editable });\n const canResize = editable && !clippedEnd;\n const r = useDraggableTask(`sx:${event.id}`, { kind: \"resize-x\", task, event, cellWidth }, { disabled: !canResize });\n const grow = r.isDragging && r.transform ? Math.round(r.transform.x / cellWidth()) : 0;\n const shownSpan = Math.max(1, Math.min(days.length - col, span + Math.max(grow, 1 - span)));\n const keys = withKeys(d.handle, (e) => {\n if (e.key === \"Enter\") return true;\n if ((e.key === \"Delete\" || e.key === \"Backspace\") && editable) {\n e.preventDefault();\n api.remove(task);\n return true;\n }\n if (editable && e.shiftKey && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\")) {\n e.preventDefault();\n api.extend(event, e.key === \"ArrowRight\" ? 1 : -1);\n return true;\n }\n return false;\n });\n return (\n // raw: an all-day bar is a drag source that also opens; a kit\n // Button would centre and pad it as an action.\n <button\n type=\"button\"\n ref={d.setNodeRef}\n data-cal-id={event.id}\n {...(editable ? keys : {})}\n onPointerDown={(e) => {\n if (!editable) return;\n const rect = e.currentTarget.getBoundingClientRect();\n const w = rect.width / shownSpan;\n api.setGrab(days[col + Math.max(0, Math.min(shownSpan - 1, Math.floor((e.clientX - rect.left) / w)))]);\n keys.onPointerDown?.(e);\n }}\n onClick={api.open ? () => api.open(task) : undefined}\n aria-label={`${title}, ${event.start === event.end ? \"all day\" : `${formatDate(event.start, { month: \"short\", day: \"numeric\" })} to ${formatDate(event.end, { month: \"short\", day: \"numeric\" })}`}${done ? \", done\" : \"\"}`}\n title={title}\n style={{ gridColumn: `${col + 1} / span ${shownSpan}`, gridRow: lane + 1 }}\n className={cx(\n \"pointer-events-auto relative flex min-w-0 items-center rounded-sm border-s-[3px] px-1.5 text-left text-xs font-medium text-foreground outline-none transition-[opacity,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n paint.tint,\n clippedStart ? \"ms-0 rounded-s-none border-s-0 ps-2\" : \"ms-1\",\n clippedEnd ? \"me-0 rounded-e-none\" : \"me-1\",\n editable ? \"cursor-grab active:cursor-grabbing\" : api.open ? \"cursor-pointer\" : \"cursor-default\",\n d.isDragging && \"opacity-40\",\n r.isDragging && \"ring-2 ring-primary\",\n done && \"text-muted-foreground\",\n )}\n >\n <span className={cx(\"min-w-0 flex-1 truncate\", done && \"line-through\")}>{title}</span>\n {canResize ? (\n <span\n ref={r.setNodeRef}\n {...r.handle}\n role=\"presentation\"\n tabIndex={-1}\n aria-hidden=\"true\"\n onClick={(e) => e.stopPropagation()}\n className=\"absolute inset-y-0 end-0 w-2 cursor-ew-resize rounded-e-sm hover:bg-primary/30\"\n />\n ) : null}\n </button>\n );\n}\n\n// ---- one day's column of hours ------------------------------------------\n\nfunction DayColumn({ day, index, days, events, api, hourHeight, colWidth, closed, slots, renderSlot, onSlotClick, selectedSlot, slotLabel, nowMin, weekend }) {\n const { editable, locale } = api;\n const ppm = hourHeight / 60;\n const ref = useRef(null);\n const label = formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale });\n const drop = useDropTarget(`col:${day}`, { kind: \"column\", day, label, top: () => ref.current?.getBoundingClientRect().top ?? 0 });\n const layout = useMemo(() => layoutTimed(events), [events]);\n const [hover, setHover] = useState(null); // minutes under the mouse\n const [making, setMaking] = useState(null); // { a, b } while press-dragging a new block\n const lastPointer = useRef(\"mouse\");\n const drafting = api.draft && !api.draft.allDay && api.draft.day === day ? api.draft : null;\n\n const minuteAt = (clientY) => {\n const top = ref.current?.getBoundingClientRect().top ?? 0;\n return Math.max(0, Math.min(DAY_MINUTES - 15, (clientY - top) / ppm));\n };\n const onBg = (e) => e.target === e.currentTarget || e.target.dataset?.calBg !== undefined;\n\n return (\n <div\n ref={(el) => {\n ref.current = el;\n drop.setNodeRef(el);\n }}\n data-cal-bg=\"\"\n className={cx(\n \"relative border-s border-border transition-colors duration-150 first:border-s-0\",\n weekend && \"bg-muted/20\",\n drop.isOver && \"bg-accent/50\",\n )}\n onPointerDown={(e) => {\n lastPointer.current = e.pointerType;\n if (!editable || e.pointerType !== \"mouse\" || e.button !== 0 || !onBg(e)) return;\n const m = snapMinutes(minuteAt(e.clientY) - 7, 15);\n setMaking({ a: m, b: m + 15 });\n e.currentTarget.setPointerCapture?.(e.pointerId);\n }}\n onPointerMove={(e) => {\n if (e.pointerType !== \"mouse\") return;\n const m = minuteAt(e.clientY);\n if (making) {\n const b = snapMinutes(m, 15);\n setMaking((x) => (x && (b !== x.b ? { ...x, b: Math.max(b, x.a + 15) } : x)));\n } else if (editable && onBg(e)) {\n const h = Math.floor(m / 30) * 30;\n if (h !== hover) setHover(h);\n } else if (hover !== null) setHover(null);\n }}\n onPointerLeave={() => setHover(null)}\n onPointerUp={(e) => {\n if (!making) return;\n e.currentTarget.releasePointerCapture?.(e.pointerId);\n const { a, b } = making;\n setMaking(null);\n setHover(null);\n // A click (no real drag) makes the default length from the half hour.\n if (b - a <= 15) {\n const from = Math.floor(a / 30) * 30;\n api.startDraft({ day, from, to: Math.min(DAY_MINUTES, from + api.defaultDuration) });\n } else api.startDraft({ day, from: a, to: b });\n }}\n onClick={(e) => {\n // A tap on a touch screen: an hour from the half hour tapped.\n if (!editable || lastPointer.current === \"mouse\" || !onBg(e)) return;\n const from = Math.floor(minuteAt(e.clientY) / 30) * 30;\n api.startDraft({ day, from, to: Math.min(DAY_MINUTES, from + api.defaultDuration) });\n }}\n >\n {closed?.map(([a, b]) => (\n <div key={a} data-cal-bg=\"\" aria-hidden=\"true\" className=\"absolute inset-x-0 bg-muted/60\" style={{ top: a * ppm, height: (b - a) * ppm }} />\n ))}\n {slots?.map((s) => (\n <Slot key={s.id} slot={s} ppm={ppm} renderSlot={renderSlot} onSlotClick={onSlotClick} selected={selectedSlot != null && (selectedSlot === s.id || selectedSlot === s.start)} label={slotLabel} locale={locale} />\n ))}\n {hover !== null && !making && !drafting ? (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-x-1 flex items-start rounded-md border border-dashed border-primary/40 px-1.5 pt-0.5 text-2xs font-medium text-primary\"\n style={{ top: hover * ppm + 1, height: 30 * ppm - 2 }}\n >\n + {minutesLabel(hover, { locale })}\n </div>\n ) : null}\n {events.map((e) => (\n <EventBlock key={e.id} event={e} pos={layout.get(e.id) ?? { col: 0, cols: 1, span: 1 }} ppm={ppm} api={api} index={index} days={days} colWidth={colWidth} />\n ))}\n {making ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-1 z-10 rounded-md bg-primary/15 px-1.5 py-0.5 text-2xs font-medium text-primary ring-1 ring-primary\" style={{ top: making.a * ppm, height: (making.b - making.a) * ppm }}>\n {minutesLabel(making.a, { locale })} – {minutesLabel(making.b, { locale })}\n </div>\n ) : null}\n {drafting ? (\n <div\n className=\"absolute inset-x-1 z-20 flex flex-col gap-0.5 rounded-md bg-card px-1.5 py-1 shadow-md ring-1 ring-primary\"\n style={{ top: drafting.from * ppm, height: Math.max((drafting.to - drafting.from) * ppm, 40) }}\n >\n <DraftInput label={`New task on ${label}, ${minutesLabel(drafting.from, { locale })} to ${minutesLabel(drafting.to, { locale })}`} onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n <span data-nums className=\"text-2xs text-muted-foreground\">\n {minutesLabel(drafting.from, { locale })} – {minutesLabel(drafting.to, { locale })}\n </span>\n </div>\n ) : null}\n {nowMin !== null ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 z-10 flex items-center\" style={{ top: nowMin * ppm - 1 }}>\n <span className=\"-ms-1 size-2.5 shrink-0 rounded-full bg-primary ring-2 ring-card\" />\n <span className=\"h-0.5 flex-1 bg-primary\" />\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction Slot({ slot, ppm, renderSlot, onSlotClick, selected, label = \"Open\", locale }) {\n const style = { top: slot.from * ppm + 1, height: Math.max((slot.to - slot.from) * ppm - 2, 16) };\n const words = `${label}, ${minutesLabel(slot.from, { locale })} to ${minutesLabel(slot.to, { locale })}`;\n const face = renderSlot ? (\n renderSlot(slot, { selected })\n ) : (\n <span data-nums className=\"truncate\">\n {minutesLabel(slot.from, { locale })}\n </span>\n );\n const cls = cx(\n \"absolute inset-x-1 flex items-start overflow-hidden rounded-md border border-dashed px-1.5 pt-0.5 text-left text-2xs font-medium transition-colors duration-150\",\n selected ? \"border-solid border-primary bg-primary text-primary-foreground\" : \"border-primary/50 bg-primary/5 text-primary\",\n );\n if (!onSlotClick) {\n return (\n <div className={cx(cls, \"pointer-events-none\")} style={style} aria-label={words}>\n {face}\n </div>\n );\n }\n return (\n // raw: an open slot is a box on the grid placed by its times; a kit\n // Button would pad and centre it as an action.\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n onSlotClick(slot);\n }}\n onPointerDown={(e) => e.stopPropagation()}\n aria-label={words}\n aria-pressed={selected}\n style={style}\n className={cx(cls, \"z-[1] outline-none hover:bg-primary/15 focus-visible:ring-2 focus-visible:ring-ring\", selected && \"hover:bg-primary\")}\n >\n {face}\n </button>\n );\n}\n\nfunction EventBlock({ event, pos, ppm, api, index, days, colWidth }) {\n const { ctx, editable, locale } = api;\n const task = event.task;\n const done = isDone(task, ctx);\n const paint = paintOf(task, ctx, api.colorBy);\n const title = task.title || \"Untitled\";\n const d = useDraggableTask(`ev:${event.id}`, { kind: \"timed\", task, event, colIndex: index, colWidth }, { disabled: !editable });\n const canResize = editable && !event.deadline;\n const r = useDraggableTask(`ry:${event.id}`, { kind: \"resize-y\", task, event }, { disabled: !canResize });\n\n // While dragged, the block itself shows where it will land: snapped to\n // the quarter hour and to a day column.\n let from = event.from;\n let to = event.to;\n let dx = 0;\n if (d.isDragging && d.transform) {\n const w = colWidth();\n const k = Math.max(-index, Math.min(days.length - 1 - index, Math.round(d.transform.x / w)));\n dx = k * w;\n const dm = Math.max(-event.from, Math.min(DAY_MINUTES - event.to, snapMinutes(d.transform.y / ppm, api.snap)));\n from += dm;\n to += dm;\n }\n if (r.isDragging && r.transform) to = Math.max(from + 15, Math.min(DAY_MINUTES, to + snapMinutes(r.transform.y / ppm, api.snap)));\n const h = Math.max((to - from) * ppm, 18);\n const tall = h >= 38;\n const shown = { ...event, from, to };\n const time = eventTimeLabel(shown, { locale });\n const moving = d.isDragging || r.isDragging;\n\n const keys = withKeys(d.handle, (e) => {\n if (e.key === \"Enter\" || e.key === \" \") return true;\n if (!editable) return false;\n if (e.key === \"Delete\" || e.key === \"Backspace\") {\n e.preventDefault();\n api.remove(task);\n return true;\n }\n const step = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -1, ArrowRight: 1 }[e.key];\n if (!step) return false;\n e.preventDefault();\n if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") api.nudge(event, { dd: step });\n else if (e.shiftKey) api.nudge(event, { dr: step * 15 });\n else api.nudge(event, { dm: step * 15 });\n return true;\n });\n\n return (\n // raw: a timed block is a drag source placed by its times that also\n // opens; a kit Button would centre and pad it as an action.\n <button\n type=\"button\"\n ref={d.setNodeRef}\n data-cal-id={event.id}\n {...(editable ? keys : { onKeyDown: keys.onKeyDown })}\n onClick={api.open ? () => api.open(task) : undefined}\n aria-label={`${title}, ${time}${done ? \", done\" : \"\"}`}\n aria-keyshortcuts={editable ? \"ArrowUp ArrowDown ArrowLeft ArrowRight Shift+ArrowUp Shift+ArrowDown Enter Delete\" : undefined}\n title={`${title}\\n${time}`}\n style={{\n top: from * ppm + 1,\n height: h - 2,\n left: `calc(${(pos.col / pos.cols) * 100}% + 2px)`,\n width: `calc(${(pos.span / pos.cols) * 100}% - ${pos.col + pos.span < pos.cols ? 2 : 6}px)`,\n transform: dx ? `translateX(${dx}px)` : undefined,\n }}\n className={cx(\n \"absolute flex flex-col overflow-hidden rounded-md border-s-[3px] px-1.5 text-left text-xs leading-tight text-foreground outline-none transition-[box-shadow,opacity,background-color] duration-150 focus-visible:z-20 focus-visible:ring-2 focus-visible:ring-ring\",\n paint.tint,\n tall ? \"py-1\" : \"justify-center py-0\",\n pos.cols > 1 && !moving && \"ring-1 ring-card\",\n editable ? \"cursor-grab active:cursor-grabbing\" : api.open ? \"cursor-pointer\" : \"cursor-default\",\n moving && \"z-30 shadow-lg ring-1 ring-primary/40\",\n event.preview && \"z-20 ring-2 ring-primary\",\n done && \"text-muted-foreground\",\n )}\n >\n {tall ? (\n <>\n <span className={cx(\"line-clamp-2 font-medium\", done && \"line-through\")}>{title}</span>\n <span data-nums className=\"mt-0.5 truncate text-2xs text-muted-foreground\">\n {time}\n </span>\n </>\n ) : (\n <span className=\"flex min-w-0 items-baseline gap-1.5\">\n <span className={cx(\"truncate font-medium\", done && \"line-through\")}>{title}</span>\n <span data-nums className=\"shrink-0 text-2xs text-muted-foreground\">\n {minutesLabel(from, { locale })}\n </span>\n </span>\n )}\n {canResize ? (\n <span\n ref={r.setNodeRef}\n {...r.handle}\n role=\"presentation\"\n tabIndex={-1}\n aria-hidden=\"true\"\n onClick={(e) => e.stopPropagation()}\n className=\"absolute inset-x-0 bottom-0 h-2 cursor-ns-resize after:absolute after:inset-x-1/3 after:bottom-0.5 after:h-0.5 after:rounded-full after:bg-foreground/30 after:opacity-0 after:transition-opacity hover:after:opacity-100\"\n />\n ) : null}\n </button>\n );\n}\n"
41
+ "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { cx } from \"../../ui\";\nimport { dayOf, formatDate, isDone, isWeekend, minutesOf, useDraggableTask, useDropTarget } from \"../task-model\";\nimport { DAY_MINUTES, eventTimeLabel, hourLabel, layoutLanes, layoutTimed, minutesLabel, snapMinutes } from \"./calendar-model\";\nimport { DraftInput } from \"./draft\";\nimport { paintOf, withKeys } from \"./paint\";\n\n// The week and the day: a strip of all-day items on top, then the hours\n// as a scrolling grid with one column per day. Timed tasks are tinted\n// blocks placed by their times; overlapping ones share the column side by\n// side. Drag a block to move it (it snaps to the quarter hour and to the\n// day columns as it goes), drag its bottom edge to change how long it\n// runs, press on an empty stretch and drag to make a new one (a click\n// makes an hour). A line marks now. The available-time layer draws closed\n// hours muted and open slots as dashed boxes.\n//\n// Keyboard: a focused block moves with the arrow keys (up/down 15\n// minutes, left/right a day), Shift+up/down changes its length, Enter\n// opens it, Delete removes it (with Undo).\n\nconst GUTTER = \"w-14 shrink-0\";\n\nexport function TimeGrid({ days, events, api, hourHeight = 48, height = \"40rem\", closed, slots, renderSlot, onSlotClick, selectedSlot, slotLabel, now }) {\n const { ctx, locale } = api;\n const n = days.length;\n const cols = { gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` };\n const scrollRef = useRef(null);\n const colsRef = useRef(null);\n const colWidth = () => (colsRef.current?.getBoundingClientRect().width ?? 700) / n;\n const timed = useMemo(() => events.filter((e) => e.timed), [events]);\n const allDay = useMemo(() => events.filter((e) => !e.timed), [events]);\n const nowDay = dayOf(now);\n const nowMin = now ? minutesOf(now) : null;\n\n // Open the grid near what matters: now, else the day's first item, else 8 AM.\n const first = timed.length ? Math.min(...timed.map((e) => e.from)) : null;\n const focusMin = nowDay && days.includes(nowDay) && nowMin !== null ? nowMin - 90 : first !== null ? first - 30 : 8 * 60;\n const scrollKey = `${days[0]}:${n}`;\n const scrolled = useRef(null);\n useEffect(() => {\n if (scrolled.current === scrollKey || !scrollRef.current) return;\n scrolled.current = scrollKey;\n scrollRef.current.scrollTop = Math.max(0, (focusMin / 60) * hourHeight);\n }, [scrollKey, focusMin, hourHeight]);\n\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n <div className=\"flex overflow-y-hidden border-b border-border [scrollbar-gutter:stable]\">\n <div className={GUTTER} />\n <div className=\"grid flex-1\" style={cols}>\n {days.map((day) => (\n <DayHead key={day} day={day} api={api} single={n === 1} />\n ))}\n </div>\n </div>\n <AllDayStrip days={days} events={allDay} api={api} cols={cols} />\n <div ref={scrollRef} className=\"relative overflow-y-auto overscroll-contain [scrollbar-gutter:stable]\" style={{ height }}>\n <div className=\"relative flex\" style={{ height: 24 * hourHeight }}>\n <div className={cx(GUTTER, \"relative\")} aria-hidden=\"true\">\n {Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (\n <span key={h} data-nums className=\"absolute end-2 -translate-y-1/2 text-2xs text-muted-foreground\" style={{ top: h * hourHeight }}>\n {hourLabel(h, { locale })}\n </span>\n ))}\n </div>\n <div ref={colsRef} className=\"relative grid flex-1\" style={cols}>\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n {Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (\n <div key={h} className=\"absolute inset-x-0 border-t border-border\" style={{ top: h * hourHeight }} />\n ))}\n </div>\n {days.map((day, i) => (\n <DayColumn\n key={day}\n day={day}\n index={i}\n days={days}\n events={timed.filter((e) => e.start === day)}\n api={api}\n hourHeight={hourHeight}\n colWidth={colWidth}\n closed={closed ? closed(day) : null}\n slots={slots?.filter((s) => s.day === day)}\n renderSlot={renderSlot}\n onSlotClick={onSlotClick}\n selectedSlot={selectedSlot}\n slotLabel={slotLabel}\n nowMin={day === nowDay ? nowMin : null}\n weekend={isWeekend(day, ctx.weekend)}\n />\n ))}\n </div>\n </div>\n </div>\n </div>\n );\n}\n\nfunction DayHead({ day, api, single }) {\n const { ctx, locale } = api;\n const today = day === ctx.today;\n return (\n <div className={cx(\"flex min-w-0 items-center justify-center border-s border-border py-2 first:border-s-0\", single && \"justify-start ps-3\")}>\n {/* raw: the heading is the way into that day (a button in the header\n row); a kit Button would pad it as an action. */}\n <button\n type=\"button\"\n onClick={() => api.openDay(day)}\n disabled={single}\n aria-label={`${formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale })}. Open the day`}\n aria-current={today ? \"date\" : undefined}\n className=\"group/head inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:hover:bg-transparent\"\n >\n <span className={cx(\"text-xs\", today ? \"font-medium text-primary\" : \"text-muted-foreground\")}>{formatDate(day, { weekday: \"short\" }, { locale })}</span>\n <span\n data-nums\n className={cx(\"inline-flex size-7 items-center justify-center rounded-full text-sm font-semibold\", today ? \"bg-primary text-primary-foreground\" : \"text-foreground\")}\n >\n {Number(day.slice(8, 10))}\n </span>\n </button>\n </div>\n );\n}\n\n// ---- the all-day strip ----------------------------------------------------\n\nconst STRIP_LANES = 3;\n\nfunction AllDayStrip({ days, events, api, cols }) {\n const [open, setOpen] = useState(false);\n const rowRef = useRef(null);\n const layout = useMemo(() => layoutLanes(events, days), [events, days]);\n const over = layout.lanes > STRIP_LANES && !open;\n const lanes = over ? STRIP_LANES - 1 : layout.lanes;\n const hiddenCount = layout.items.filter((it) => it.lane >= lanes).length;\n const cellWidth = () => (rowRef.current?.getBoundingClientRect().width ?? 700) / days.length;\n const drafting = api.draft?.allDay && days.includes(api.draft.day);\n const rows = Math.max(lanes + (over ? 1 : 0) + (drafting ? 1 : 0), 1);\n return (\n <div className=\"flex overflow-y-hidden border-b border-border [scrollbar-gutter:stable]\">\n <div className={cx(GUTTER, \"flex items-start justify-end pe-2 pt-1.5 text-2xs text-muted-foreground\")}>All day</div>\n <div ref={rowRef} className=\"relative grid flex-1\" style={{ ...cols, minHeight: rows * 24 + 8 }}>\n {days.map((day) => (\n <StripCell key={day} day={day} api={api} />\n ))}\n <div className=\"pointer-events-none absolute inset-x-0 top-1 grid gap-y-0.5\" style={{ ...cols, gridAutoRows: \"22px\" }}>\n {layout.items\n .filter((it) => it.lane < lanes)\n .map((it) => (\n <StripItem key={it.event.id} item={it} days={days} api={api} cellWidth={cellWidth} />\n ))}\n {over ? (\n // raw: a line of small text sized to a lane that opens the strip;\n // a kit Button is a 36px action.\n <button\n type=\"button\"\n onClick={() => setOpen(true)}\n style={{ gridColumn: `1 / span ${days.length}`, gridRow: lanes + 1 }}\n className=\"pointer-events-auto mx-1 justify-self-start rounded-sm px-1.5 text-xs font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n {hiddenCount} more\n </button>\n ) : null}\n {drafting ? (\n <div\n style={{ gridColumn: `${days.indexOf(api.draft.day) + 1}`, gridRow: lanes + (over ? 2 : 1) }}\n className=\"pointer-events-auto mx-1 flex items-center rounded-sm bg-primary/10 px-1.5 ring-1 ring-primary\"\n >\n <DraftInput label={`New all-day task on ${formatDate(api.draft.day, { weekday: \"long\", month: \"long\", day: \"numeric\" })}`} onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n </div>\n ) : null}\n </div>\n </div>\n </div>\n );\n}\n\nfunction StripCell({ day, api }) {\n const label = `${formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale: api.locale })}, all day`;\n const drop = useDropTarget(`strip:${day}`, { kind: \"cell\", day, label });\n return (\n <div\n ref={drop.setNodeRef}\n onClick={(e) => {\n if (api.creatable && e.target === e.currentTarget) api.startDraft({ day, allDay: true });\n }}\n className={cx(\"border-s border-border transition-colors duration-150 first:border-s-0\", drop.isOver && \"bg-accent\")}\n />\n );\n}\n\nfunction StripItem({ item, days, api, cellWidth }) {\n const { event, col, span, lane, clippedStart, clippedEnd } = item;\n const { ctx } = api;\n const task = event.task;\n const editable = api.canEdit(task);\n const done = isDone(task, ctx);\n const paint = paintOf(task, ctx, api.colorBy);\n const title = task.title || \"Untitled\";\n const d = useDraggableTask(`strip:${event.id}`, { kind: \"span\", task, event }, { disabled: !editable });\n const canResize = editable && !clippedEnd;\n const r = useDraggableTask(`sx:${event.id}`, { kind: \"resize-x\", task, event, cellWidth }, { disabled: !canResize });\n const grow = r.isDragging && r.transform ? Math.round(r.transform.x / cellWidth()) : 0;\n const shownSpan = Math.max(1, Math.min(days.length - col, span + Math.max(grow, 1 - span)));\n const keys = withKeys(d.handle, (e) => {\n if (e.key === \"Enter\") return true;\n if ((e.key === \"Delete\" || e.key === \"Backspace\") && editable) {\n e.preventDefault();\n api.remove(task);\n return true;\n }\n if (editable && e.shiftKey && (e.key === \"ArrowRight\" || e.key === \"ArrowLeft\")) {\n e.preventDefault();\n api.extend(event, e.key === \"ArrowRight\" ? 1 : -1);\n return true;\n }\n return false;\n });\n return (\n // raw: an all-day bar is a drag source that also opens; a kit\n // Button would centre and pad it as an action.\n <button\n type=\"button\"\n ref={d.setNodeRef}\n data-cal-id={event.id}\n {...(editable ? keys : {})}\n onPointerDown={(e) => {\n if (!editable) return;\n const rect = e.currentTarget.getBoundingClientRect();\n const w = rect.width / shownSpan;\n api.setGrab(days[col + Math.max(0, Math.min(shownSpan - 1, Math.floor((e.clientX - rect.left) / w)))]);\n keys.onPointerDown?.(e);\n }}\n onClick={api.open ? () => api.open(task) : undefined}\n aria-label={`${title}, ${event.start === event.end ? \"all day\" : `${formatDate(event.start, { month: \"short\", day: \"numeric\" })} to ${formatDate(event.end, { month: \"short\", day: \"numeric\" })}`}${done ? \", done\" : \"\"}`}\n title={title}\n style={{ gridColumn: `${col + 1} / span ${shownSpan}`, gridRow: lane + 1 }}\n className={cx(\n \"pointer-events-auto relative flex min-w-0 items-center rounded-sm border-s-[3px] px-1.5 text-left text-xs font-medium text-foreground outline-none transition-[opacity,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-ring\",\n paint.tint,\n clippedStart ? \"ms-0 rounded-s-none border-s-0 ps-2\" : \"ms-1\",\n clippedEnd ? \"me-0 rounded-e-none\" : \"me-1\",\n editable ? \"cursor-grab active:cursor-grabbing\" : api.open ? \"cursor-pointer\" : \"cursor-default\",\n d.isDragging && \"opacity-40\",\n r.isDragging && \"ring-2 ring-primary\",\n done && \"text-muted-foreground\",\n )}\n >\n <span className={cx(\"min-w-0 flex-1 truncate\", done && \"line-through\")}>{title}</span>\n {canResize ? (\n <span\n ref={r.setNodeRef}\n {...r.handle}\n role=\"presentation\"\n tabIndex={-1}\n aria-hidden=\"true\"\n onClick={(e) => e.stopPropagation()}\n className=\"absolute inset-y-0 end-0 w-2 cursor-ew-resize rounded-e-sm hover:bg-primary/30\"\n />\n ) : null}\n </button>\n );\n}\n\n// ---- one day's column of hours ------------------------------------------\n\nfunction DayColumn({ day, index, days, events, api, hourHeight, colWidth, closed, slots, renderSlot, onSlotClick, selectedSlot, slotLabel, nowMin, weekend }) {\n const { creatable, locale } = api;\n const ppm = hourHeight / 60;\n const ref = useRef(null);\n const label = formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale });\n const drop = useDropTarget(`col:${day}`, { kind: \"column\", day, label, top: () => ref.current?.getBoundingClientRect().top ?? 0 });\n const layout = useMemo(() => layoutTimed(events), [events]);\n const [hover, setHover] = useState(null); // minutes under the mouse\n const [making, setMaking] = useState(null); // { a, b } while press-dragging a new block\n const lastPointer = useRef(\"mouse\");\n const drafting = api.draft && !api.draft.allDay && api.draft.day === day ? api.draft : null;\n\n const minuteAt = (clientY) => {\n const top = ref.current?.getBoundingClientRect().top ?? 0;\n return Math.max(0, Math.min(DAY_MINUTES - 15, (clientY - top) / ppm));\n };\n const onBg = (e) => e.target === e.currentTarget || e.target.dataset?.calBg !== undefined;\n\n return (\n <div\n ref={(el) => {\n ref.current = el;\n drop.setNodeRef(el);\n }}\n data-cal-bg=\"\"\n className={cx(\n \"relative border-s border-border transition-colors duration-150 first:border-s-0\",\n weekend && \"bg-muted/20\",\n drop.isOver && \"bg-accent/50\",\n )}\n onPointerDown={(e) => {\n lastPointer.current = e.pointerType;\n if (!creatable || e.pointerType !== \"mouse\" || e.button !== 0 || !onBg(e)) return;\n const m = snapMinutes(minuteAt(e.clientY) - 7, 15);\n setMaking({ a: m, b: m + 15 });\n e.currentTarget.setPointerCapture?.(e.pointerId);\n }}\n onPointerMove={(e) => {\n if (e.pointerType !== \"mouse\") return;\n const m = minuteAt(e.clientY);\n if (making) {\n const b = snapMinutes(m, 15);\n setMaking((x) => (x && (b !== x.b ? { ...x, b: Math.max(b, x.a + 15) } : x)));\n } else if (creatable && onBg(e)) {\n const h = Math.floor(m / 30) * 30;\n if (h !== hover) setHover(h);\n } else if (hover !== null) setHover(null);\n }}\n onPointerLeave={() => setHover(null)}\n onPointerUp={(e) => {\n if (!making) return;\n e.currentTarget.releasePointerCapture?.(e.pointerId);\n const { a, b } = making;\n setMaking(null);\n setHover(null);\n // A click (no real drag) makes the default length from the half hour.\n if (b - a <= 15) {\n const from = Math.floor(a / 30) * 30;\n api.startDraft({ day, from, to: Math.min(DAY_MINUTES, from + api.defaultDuration) });\n } else api.startDraft({ day, from: a, to: b });\n }}\n onClick={(e) => {\n // A tap on a touch screen: an hour from the half hour tapped.\n if (!creatable || lastPointer.current === \"mouse\" || !onBg(e)) return;\n const from = Math.floor(minuteAt(e.clientY) / 30) * 30;\n api.startDraft({ day, from, to: Math.min(DAY_MINUTES, from + api.defaultDuration) });\n }}\n >\n {closed?.map(([a, b]) => (\n <div key={a} data-cal-bg=\"\" aria-hidden=\"true\" className=\"absolute inset-x-0 bg-muted/60\" style={{ top: a * ppm, height: (b - a) * ppm }} />\n ))}\n {slots?.map((s) => (\n <Slot key={s.id} slot={s} ppm={ppm} renderSlot={renderSlot} onSlotClick={onSlotClick} selected={selectedSlot != null && (selectedSlot === s.id || selectedSlot === s.start)} label={slotLabel} locale={locale} />\n ))}\n {hover !== null && !making && !drafting ? (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-x-1 flex items-start rounded-md border border-dashed border-primary/40 px-1.5 pt-0.5 text-2xs font-medium text-primary\"\n style={{ top: hover * ppm + 1, height: 30 * ppm - 2 }}\n >\n + {minutesLabel(hover, { locale })}\n </div>\n ) : null}\n {events.map((e) => (\n <EventBlock key={e.id} event={e} pos={layout.get(e.id) ?? { col: 0, cols: 1, span: 1 }} ppm={ppm} api={api} index={index} days={days} colWidth={colWidth} />\n ))}\n {making ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-1 z-10 rounded-md bg-primary/15 px-1.5 py-0.5 text-2xs font-medium text-primary ring-1 ring-primary\" style={{ top: making.a * ppm, height: (making.b - making.a) * ppm }}>\n {minutesLabel(making.a, { locale })} – {minutesLabel(making.b, { locale })}\n </div>\n ) : null}\n {drafting ? (\n <div\n className=\"absolute inset-x-1 z-20 flex flex-col gap-0.5 rounded-md bg-card px-1.5 py-1 shadow-md ring-1 ring-primary\"\n style={{ top: drafting.from * ppm, height: Math.max((drafting.to - drafting.from) * ppm, 40) }}\n >\n <DraftInput label={`New task on ${label}, ${minutesLabel(drafting.from, { locale })} to ${minutesLabel(drafting.to, { locale })}`} onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n <span data-nums className=\"text-2xs text-muted-foreground\">\n {minutesLabel(drafting.from, { locale })} – {minutesLabel(drafting.to, { locale })}\n </span>\n </div>\n ) : null}\n {nowMin !== null ? (\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-x-0 z-10 flex items-center\" style={{ top: nowMin * ppm - 1 }}>\n <span className=\"-ms-1 size-2.5 shrink-0 rounded-full bg-primary ring-2 ring-card\" />\n <span className=\"h-0.5 flex-1 bg-primary\" />\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction Slot({ slot, ppm, renderSlot, onSlotClick, selected, label = \"Open\", locale }) {\n const style = { top: slot.from * ppm + 1, height: Math.max((slot.to - slot.from) * ppm - 2, 16) };\n const words = `${label}, ${minutesLabel(slot.from, { locale })} to ${minutesLabel(slot.to, { locale })}`;\n const face = renderSlot ? (\n renderSlot(slot, { selected })\n ) : (\n <span data-nums className=\"truncate\">\n {minutesLabel(slot.from, { locale })}\n </span>\n );\n const cls = cx(\n \"absolute inset-x-1 flex items-start overflow-hidden rounded-md border border-dashed px-1.5 pt-0.5 text-left text-2xs font-medium transition-colors duration-150\",\n selected ? \"border-solid border-primary bg-primary text-primary-foreground\" : \"border-primary/50 bg-primary/5 text-primary\",\n );\n if (!onSlotClick) {\n return (\n <div className={cx(cls, \"pointer-events-none\")} style={style} aria-label={words}>\n {face}\n </div>\n );\n }\n return (\n // raw: an open slot is a box on the grid placed by its times; a kit\n // Button would pad and centre it as an action.\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n onSlotClick(slot);\n }}\n onPointerDown={(e) => e.stopPropagation()}\n aria-label={words}\n aria-pressed={selected}\n style={style}\n className={cx(cls, \"z-[1] outline-none hover:bg-primary/15 focus-visible:ring-2 focus-visible:ring-ring\", selected && \"hover:bg-primary\")}\n >\n {face}\n </button>\n );\n}\n\nfunction EventBlock({ event, pos, ppm, api, index, days, colWidth }) {\n const { ctx, locale } = api;\n const task = event.task;\n const editable = api.canEdit(task);\n const done = isDone(task, ctx);\n const paint = paintOf(task, ctx, api.colorBy);\n const title = task.title || \"Untitled\";\n const d = useDraggableTask(`ev:${event.id}`, { kind: \"timed\", task, event, colIndex: index, colWidth }, { disabled: !editable });\n const canResize = editable && !event.deadline;\n const r = useDraggableTask(`ry:${event.id}`, { kind: \"resize-y\", task, event }, { disabled: !canResize });\n\n // While dragged, the block itself shows where it will land: snapped to\n // the quarter hour and to a day column.\n let from = event.from;\n let to = event.to;\n let dx = 0;\n if (d.isDragging && d.transform) {\n const w = colWidth();\n const k = Math.max(-index, Math.min(days.length - 1 - index, Math.round(d.transform.x / w)));\n dx = k * w;\n const dm = Math.max(-event.from, Math.min(DAY_MINUTES - event.to, snapMinutes(d.transform.y / ppm, api.snap)));\n from += dm;\n to += dm;\n }\n if (r.isDragging && r.transform) to = Math.max(from + 15, Math.min(DAY_MINUTES, to + snapMinutes(r.transform.y / ppm, api.snap)));\n const h = Math.max((to - from) * ppm, 18);\n const tall = h >= 38;\n const shown = { ...event, from, to };\n const time = eventTimeLabel(shown, { locale });\n const moving = d.isDragging || r.isDragging;\n\n const keys = withKeys(d.handle, (e) => {\n if (e.key === \"Enter\" || e.key === \" \") return true;\n if (!editable) return false;\n if (e.key === \"Delete\" || e.key === \"Backspace\") {\n e.preventDefault();\n api.remove(task);\n return true;\n }\n const step = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -1, ArrowRight: 1 }[e.key];\n if (!step) return false;\n e.preventDefault();\n if (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\") api.nudge(event, { dd: step });\n else if (e.shiftKey) api.nudge(event, { dr: step * 15 });\n else api.nudge(event, { dm: step * 15 });\n return true;\n });\n\n return (\n // raw: a timed block is a drag source placed by its times that also\n // opens; a kit Button would centre and pad it as an action.\n <button\n type=\"button\"\n ref={d.setNodeRef}\n data-cal-id={event.id}\n {...(editable ? keys : { onKeyDown: keys.onKeyDown })}\n onClick={api.open ? () => api.open(task) : undefined}\n aria-label={`${title}, ${time}${done ? \", done\" : \"\"}`}\n aria-keyshortcuts={editable ? \"ArrowUp ArrowDown ArrowLeft ArrowRight Shift+ArrowUp Shift+ArrowDown Enter Delete\" : undefined}\n title={`${title}\\n${time}`}\n style={{\n top: from * ppm + 1,\n height: h - 2,\n left: `calc(${(pos.col / pos.cols) * 100}% + 2px)`,\n width: `calc(${(pos.span / pos.cols) * 100}% - ${pos.col + pos.span < pos.cols ? 2 : 6}px)`,\n transform: dx ? `translateX(${dx}px)` : undefined,\n }}\n className={cx(\n \"absolute flex flex-col overflow-hidden rounded-md border-s-[3px] px-1.5 text-left text-xs leading-tight text-foreground outline-none transition-[box-shadow,opacity,background-color] duration-150 focus-visible:z-20 focus-visible:ring-2 focus-visible:ring-ring\",\n paint.tint,\n tall ? \"py-1\" : \"justify-center py-0\",\n pos.cols > 1 && !moving && \"ring-1 ring-card\",\n editable ? \"cursor-grab active:cursor-grabbing\" : api.open ? \"cursor-pointer\" : \"cursor-default\",\n moving && \"z-30 shadow-lg ring-1 ring-primary/40\",\n event.preview && \"z-20 ring-2 ring-primary\",\n done && \"text-muted-foreground\",\n )}\n >\n {tall ? (\n <>\n <span className={cx(\"line-clamp-2 font-medium\", done && \"line-through\")}>{title}</span>\n <span data-nums className=\"mt-0.5 truncate text-2xs text-muted-foreground\">\n {time}\n </span>\n </>\n ) : (\n <span className=\"flex min-w-0 items-baseline gap-1.5\">\n <span className={cx(\"truncate font-medium\", done && \"line-through\")}>{title}</span>\n <span data-nums className=\"shrink-0 text-2xs text-muted-foreground\">\n {minutesLabel(from, { locale })}\n </span>\n </span>\n )}\n {canResize ? (\n <span\n ref={r.setNodeRef}\n {...r.handle}\n role=\"presentation\"\n tabIndex={-1}\n aria-hidden=\"true\"\n onClick={(e) => e.stopPropagation()}\n className=\"absolute inset-x-0 bottom-0 h-2 cursor-ns-resize after:absolute after:inset-x-1/3 after:bottom-0.5 after:h-0.5 after:rounded-full after:bg-foreground/30 after:opacity-0 after:transition-opacity hover:after:opacity-100\"\n />\n ) : null}\n </button>\n );\n}\n"
42
42
  },
43
43
  {
44
44
  "path": "registry/blocks/calendar-view/agenda.jsx",
45
45
  "type": "registry:component",
46
46
  "target": "@components/makersclaw/blocks/calendar-view/agenda.jsx",
47
- "content": "\"use client\";\n\nimport { Button, Empty, cx } from \"../../ui\";\nimport { AssigneeStack, CompleteToggle, diffDays, formatDate, isDone, toggleDoneChange } from \"../task-model\";\nimport { eventTimeLabel } from \"./calendar-model\";\nimport { DraftInput } from \"./draft\";\nimport { paintOf } from \"./paint\";\n\n// The agenda: the days ahead as a list, each day's items in time order\n// (all-day first), with a round check to complete each. Days with nothing\n// on them are left out — except today, which always shows. On a phone\n// the month's tapped day is drawn by the same AgendaDay.\n\nconst WORDS = \"flex min-w-0 flex-1 flex-col items-start py-1.5 text-left outline-none\";\n\nconst order = (a, b) => Number(a.timed) - Number(b.timed) || (a.from ?? 0) - (b.from ?? 0) || String(a.task.title).localeCompare(String(b.task.title));\n\nexport function AgendaView({ days, events, api }) {\n const { ctx } = api;\n const sections = days\n .map((day) => ({ day, items: events.filter((e) => e.start <= day && day <= e.end).sort(order) }))\n .filter((s) => s.items.length || s.day === ctx.today || (api.draft?.allDay && api.draft.day === s.day));\n const any = sections.some((s) => s.items.length);\n if (!any && !(api.draft && days.includes(api.draft.day))) {\n return (\n <Empty\n icon=\"calendar\"\n title=\"Nothing scheduled in these days\"\n body=\"Tasks with a date show up here, day by day. Give one a date, or add a new one.\"\n action={\n api.editable ? (\n <Button variant=\"primary\" size=\"sm\" icon=\"plus\" onClick={() => api.startDraft({ day: days[0], allDay: true })}>\n {api.addLabel}\n </Button>\n ) : null\n }\n />\n );\n }\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n {sections.map((s) => (\n <AgendaDay key={s.day} day={s.day} events={s.items} api={api} />\n ))}\n </div>\n );\n}\n\n/** One day of the agenda: its date on the left, its items on the right. */\nexport function AgendaDay({ day, events, api, standalone = false }) {\n const { ctx, locale } = api;\n const today = day === ctx.today;\n const items = [...events].sort(order);\n const drafting = api.draft?.allDay && api.draft.day === day;\n const label = formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale });\n return (\n <section\n aria-label={label}\n className={cx(\n \"grid grid-cols-[3.25rem_minmax(0,1fr)] gap-x-3 px-3 py-3 sm:grid-cols-[6.5rem_minmax(0,1fr)] sm:px-4\",\n standalone ? \"rounded-lg border border-border bg-card\" : \"border-b border-border last:border-b-0\",\n )}\n >\n <h3 className=\"flex flex-col items-start pt-1 sm:flex-row sm:items-baseline sm:gap-2\">\n <span data-nums className={cx(\"text-xl font-semibold leading-none\", today ? \"text-primary\" : \"text-foreground\")}>\n {Number(day.slice(8, 10))}\n </span>\n <span className={cx(\"mt-1 text-xs sm:mt-0\", today ? \"font-medium text-primary\" : \"text-muted-foreground\")}>\n {today ? \"Today\" : diffDays(ctx.today, day) === 1 ? \"Tomorrow\" : formatDate(day, { weekday: \"short\" }, { locale })}\n </span>\n </h3>\n <ul className=\"grid min-w-0 gap-0.5\">\n {drafting ? (\n <li className=\"flex min-h-11 items-center gap-2 rounded-md bg-primary/10 px-2 ring-1 ring-primary\">\n <DraftInput className=\"text-sm\" label={`New task on ${label}`} placeholder=\"New task, then Enter\" onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n </li>\n ) : null}\n {items.map((e) => (\n <AgendaRow key={e.id} event={e} day={day} api={api} />\n ))}\n {!items.length && !drafting ? (\n <li className=\"flex min-h-11 items-center justify-between gap-2 px-2 text-sm text-muted-foreground\">\n <span>{today ? \"Nothing scheduled today\" : \"Nothing scheduled\"}</span>\n {api.editable ? (\n <Button size=\"sm\" variant=\"quiet\" icon=\"plus\" onClick={() => api.startDraft({ day, allDay: true })}>\n {api.addLabel}\n </Button>\n ) : null}\n </li>\n ) : null}\n {items.length > 0 && standalone && api.editable && !drafting ? (\n <li className=\"pt-1\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"plus\" align=\"start\" onClick={() => api.startDraft({ day, allDay: true })}>\n {api.addLabel}\n </Button>\n </li>\n ) : null}\n </ul>\n </section>\n );\n}\n\nfunction AgendaRow({ event, day, api }) {\n const { ctx, locale, editable } = api;\n const task = event.task;\n const done = isDone(task, ctx);\n const title = task.title || \"Untitled\";\n const paint = paintOf(task, ctx, api.colorBy);\n const project = ctx.projectById.get(String(task.project));\n const multi = event.start !== event.end;\n const time = multi ? `Day ${diffDays(event.start, day) + 1} of ${diffDays(event.start, event.end) + 1}` : eventTimeLabel(event, { locale });\n const words = (\n <>\n <span className={cx(\"w-full truncate text-sm font-medium\", done && \"text-muted-foreground line-through\")} title={title}>\n {title}\n </span>\n <span className=\"flex w-full min-w-0 items-center gap-1.5 text-xs text-muted-foreground\">\n {editable ? <span aria-hidden=\"true\" className={cx(\"size-1.5 shrink-0 rounded-full\", paint.dot)} /> : null}\n <span data-nums className=\"shrink-0\">\n {time}\n </span>\n {project ? <span className=\"truncate\">· {project.name}</span> : null}\n </span>\n </>\n );\n return (\n <li className=\"group/row flex min-h-11 min-w-0 items-center gap-1 rounded-md pe-2 transition-colors duration-150 hover:bg-accent/60\">\n {editable ? (\n <CompleteToggle size=\"sm\" done={done} label={`“${title}”`} onToggle={() => api.commit(toggleDoneChange(task, ctx), { label: `“${title}”` })} />\n ) : (\n <span aria-hidden=\"true\" className=\"inline-flex size-9 shrink-0 items-center justify-center\">\n <span className={cx(\"size-2 rounded-full\", paint.dot)} />\n </span>\n )}\n {api.open ? (\n // raw: the row's words open the task; a kit Button centres and pads\n // an action, this is two lines of text.\n <button type=\"button\" onClick={() => api.open(task)} className={cx(WORDS, \"rounded-sm focus-visible:ring-2 focus-visible:ring-ring\")}>\n {words}\n </button>\n ) : (\n <span className={WORDS}>{words}</span>\n )}\n {task.assignees?.length ? <AssigneeStack ids={task.assignees} ctx={ctx} max={2} /> : null}\n </li>\n );\n}\n"
47
+ "content": "\"use client\";\n\nimport { Button, Empty, cx } from \"../../ui\";\nimport { AssigneeStack, CompleteToggle, diffDays, formatDate, isDone, toggleDoneChange } from \"../task-model\";\nimport { eventTimeLabel } from \"./calendar-model\";\nimport { DraftInput } from \"./draft\";\nimport { paintOf } from \"./paint\";\n\n// The agenda: the days ahead as a list, each day's items in time order\n// (all-day first), with a round check to complete each. Days with nothing\n// on them are left out — except today, which always shows. On a phone\n// the month's tapped day is drawn by the same AgendaDay.\n\nconst WORDS = \"flex min-w-0 flex-1 flex-col items-start py-1.5 text-left outline-none\";\n\nconst order = (a, b) => Number(a.timed) - Number(b.timed) || (a.from ?? 0) - (b.from ?? 0) || String(a.task.title).localeCompare(String(b.task.title));\n\nexport function AgendaView({ days, events, api }) {\n const { ctx } = api;\n const sections = days\n .map((day) => ({ day, items: events.filter((e) => e.start <= day && day <= e.end).sort(order) }))\n .filter((s) => s.items.length || s.day === ctx.today || (api.draft?.allDay && api.draft.day === s.day));\n const any = sections.some((s) => s.items.length);\n if (!any && !(api.draft && days.includes(api.draft.day))) {\n return (\n <Empty\n icon=\"calendar\"\n title=\"Nothing scheduled in these days\"\n body=\"Tasks with a date show up here, day by day. Give one a date, or add a new one.\"\n action={\n api.creatable ? (\n <Button variant=\"primary\" size=\"sm\" icon=\"plus\" onClick={() => api.startDraft({ day: days[0], allDay: true })}>\n {api.addLabel}\n </Button>\n ) : null\n }\n />\n );\n }\n return (\n <div className=\"overflow-hidden rounded-lg border border-border bg-card\">\n {sections.map((s) => (\n <AgendaDay key={s.day} day={s.day} events={s.items} api={api} />\n ))}\n </div>\n );\n}\n\n/** One day of the agenda: its date on the left, its items on the right. */\nexport function AgendaDay({ day, events, api, standalone = false }) {\n const { ctx, locale } = api;\n const today = day === ctx.today;\n const items = [...events].sort(order);\n const drafting = api.draft?.allDay && api.draft.day === day;\n const label = formatDate(day, { weekday: \"long\", month: \"long\", day: \"numeric\" }, { locale });\n return (\n <section\n aria-label={label}\n className={cx(\n \"grid grid-cols-[3.25rem_minmax(0,1fr)] gap-x-3 px-3 py-3 sm:grid-cols-[6.5rem_minmax(0,1fr)] sm:px-4\",\n standalone ? \"rounded-lg border border-border bg-card\" : \"border-b border-border last:border-b-0\",\n )}\n >\n <h3 className=\"flex flex-col items-start pt-1 sm:flex-row sm:items-baseline sm:gap-2\">\n <span data-nums className={cx(\"text-xl font-semibold leading-none\", today ? \"text-primary\" : \"text-foreground\")}>\n {Number(day.slice(8, 10))}\n </span>\n <span className={cx(\"mt-1 text-xs sm:mt-0\", today ? \"font-medium text-primary\" : \"text-muted-foreground\")}>\n {today ? \"Today\" : diffDays(ctx.today, day) === 1 ? \"Tomorrow\" : formatDate(day, { weekday: \"short\" }, { locale })}\n </span>\n </h3>\n <ul className=\"grid min-w-0 gap-0.5\">\n {drafting ? (\n <li className=\"flex min-h-11 items-center gap-2 rounded-md bg-primary/10 px-2 ring-1 ring-primary\">\n <DraftInput className=\"text-sm\" label={`New task on ${label}`} placeholder=\"New task, then Enter\" onSubmit={api.saveDraft} onCancel={api.cancelDraft} />\n </li>\n ) : null}\n {items.map((e) => (\n <AgendaRow key={e.id} event={e} day={day} api={api} />\n ))}\n {!items.length && !drafting ? (\n <li className=\"flex min-h-11 items-center justify-between gap-2 px-2 text-sm text-muted-foreground\">\n <span>{today ? \"Nothing scheduled today\" : \"Nothing scheduled\"}</span>\n {api.creatable ? (\n <Button size=\"sm\" variant=\"quiet\" icon=\"plus\" onClick={() => api.startDraft({ day, allDay: true })}>\n {api.addLabel}\n </Button>\n ) : null}\n </li>\n ) : null}\n {items.length > 0 && standalone && api.creatable && !drafting ? (\n <li className=\"pt-1\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"plus\" align=\"start\" onClick={() => api.startDraft({ day, allDay: true })}>\n {api.addLabel}\n </Button>\n </li>\n ) : null}\n </ul>\n </section>\n );\n}\n\nfunction AgendaRow({ event, day, api }) {\n const { ctx, locale } = api;\n const task = event.task;\n const editable = api.canEdit(task);\n const done = isDone(task, ctx);\n const title = task.title || \"Untitled\";\n const paint = paintOf(task, ctx, api.colorBy);\n const project = ctx.projectById.get(String(task.project));\n const multi = event.start !== event.end;\n const time = multi ? `Day ${diffDays(event.start, day) + 1} of ${diffDays(event.start, event.end) + 1}` : eventTimeLabel(event, { locale });\n const words = (\n <>\n <span className={cx(\"w-full truncate text-sm font-medium\", done && \"text-muted-foreground line-through\")} title={title}>\n {title}\n </span>\n <span className=\"flex w-full min-w-0 items-center gap-1.5 text-xs text-muted-foreground\">\n {editable ? <span aria-hidden=\"true\" className={cx(\"size-1.5 shrink-0 rounded-full\", paint.dot)} /> : null}\n <span data-nums className=\"shrink-0\">\n {time}\n </span>\n {project ? <span className=\"truncate\">· {project.name}</span> : null}\n </span>\n </>\n );\n return (\n <li className=\"group/row flex min-h-11 min-w-0 items-center gap-1 rounded-md pe-2 transition-colors duration-150 hover:bg-accent/60\">\n {editable ? (\n <CompleteToggle size=\"sm\" done={done} label={`“${title}”`} onToggle={() => api.commit(toggleDoneChange(task, ctx), { label: `“${title}”` })} />\n ) : (\n <span aria-hidden=\"true\" className=\"inline-flex size-9 shrink-0 items-center justify-center\">\n <span className={cx(\"size-2 rounded-full\", paint.dot)} />\n </span>\n )}\n {api.open ? (\n // raw: the row's words open the task; a kit Button centres and pads\n // an action, this is two lines of text.\n <button type=\"button\" onClick={() => api.open(task)} className={cx(WORDS, \"rounded-sm focus-visible:ring-2 focus-visible:ring-ring\")}>\n {words}\n </button>\n ) : (\n <span className={WORDS}>{words}</span>\n )}\n {task.assignees?.length ? <AssigneeStack ids={task.assignees} ctx={ctx} max={2} /> : null}\n </li>\n );\n}\n"
48
48
  },
49
49
  {
50
50
  "path": "registry/blocks/calendar-view/tray.jsx",
51
51
  "type": "registry:component",
52
52
  "target": "@components/makersclaw/blocks/calendar-view/tray.jsx",
53
- "content": "\"use client\";\n\nimport { Icon, cx } from \"../../ui\";\nimport { DueLabel, TaskChip, useDraggableTask, useDropTarget } from \"../task-model\";\nimport { withKeys } from \"./paint\";\n\n// The unscheduled tray: open tasks with no date yet. Beside a wide\n// calendar it is a column to drag from (onto a day, onto an hour) and to\n// drop on (which takes the date off). On a phone, where there is nothing\n// to drag onto, each task has a date picker instead.\n\nexport function UnscheduledTray({ tasks, api, mode = \"panel\", className }) {\n const drop = useDropTarget(\"tray\", { kind: \"tray\", label: \"Unscheduled\" }, { disabled: !api.editable || mode !== \"panel\" });\n const dragging = drop.active && drop.active.data?.kind !== \"tray\";\n return (\n <section\n ref={drop.setNodeRef}\n aria-label=\"Unscheduled tasks\"\n className={cx(\n \"flex min-h-0 flex-col gap-2 transition-colors duration-150\",\n mode === \"panel\" && \"rounded-lg border border-border bg-card p-3\",\n mode === \"panel\" && dragging && \"border-dashed border-primary\",\n drop.isOver && \"bg-accent\",\n className,\n )}\n >\n {mode === \"panel\" ? (\n <header className=\"flex items-baseline justify-between gap-2\">\n <h3 className=\"text-sm font-semibold\">Unscheduled</h3>\n <span data-nums className=\"text-xs text-muted-foreground\">\n {tasks.length}\n </span>\n </header>\n ) : null}\n {dragging ? (\n <p className=\"text-xs text-muted-foreground\">Drop here to take the date off.</p>\n ) : tasks.length && api.editable && mode === \"panel\" ? (\n <p className=\"text-xs text-muted-foreground\">Drag a task onto a day or an hour to schedule it.</p>\n ) : null}\n {tasks.length ? (\n <ul className=\"grid gap-1.5\">\n {tasks.map((t) => (mode === \"panel\" ? <TrayItem key={t.id} task={t} api={api} /> : <SheetItem key={t.id} task={t} api={api} />))}\n </ul>\n ) : (\n <div className=\"grid place-items-center gap-1.5 rounded-md border border-dashed border-border px-3 py-6 text-center\">\n <Icon name=\"circle-check\" size={18} className=\"text-muted-foreground\" />\n <p className=\"text-sm font-medium\">Everything has a date</p>\n <p className=\"text-xs text-muted-foreground\">Tasks without one wait here.</p>\n </div>\n )}\n </section>\n );\n}\n\nfunction TrayItem({ task, api }) {\n const d = useDraggableTask(`tray:${task.id}`, { kind: \"tray\", task }, { disabled: !api.editable });\n const keys = withKeys(d.handle, (e) => e.key === \"Enter\");\n return (\n <li ref={d.setNodeRef} data-cal-id={task.id}>\n <TaskChip\n task={task}\n ctx={api.ctx}\n onOpen={api.open ?? undefined}\n dragging={d.isDragging}\n dragProps={api.editable ? keys : undefined}\n className={cx(\"min-h-9\", api.editable && \"cursor-grab active:cursor-grabbing\")}\n />\n </li>\n );\n}\n\nfunction SheetItem({ task, api }) {\n return (\n <li className=\"group/row flex min-h-11 items-center gap-2\">\n <TaskChip task={task} ctx={api.ctx} onOpen={api.open ?? undefined} className=\"min-h-9 flex-1\" />\n {api.editable ? (\n <DueLabel\n value={null}\n ctx={api.ctx}\n quiet={false}\n placeholder=\"Schedule\"\n onChange={(day) => day && api.update(task, { due: day }, `Scheduled “${task.title}”`)}\n />\n ) : null}\n </li>\n );\n}\n"
53
+ "content": "\"use client\";\n\nimport { Icon, cx } from \"../../ui\";\nimport { DueLabel, TaskChip, useDraggableTask, useDropTarget } from \"../task-model\";\nimport { withKeys } from \"./paint\";\n\n// The unscheduled tray: open tasks with no date yet. Beside a wide\n// calendar it is a column to drag from (onto a day, onto an hour) and to\n// drop on (which takes the date off). On a phone, where there is nothing\n// to drag onto, each task has a date picker instead.\n\nexport function UnscheduledTray({ tasks, api, mode = \"panel\", className }) {\n const drop = useDropTarget(\"tray\", { kind: \"tray\", label: \"Unscheduled\" }, { disabled: !api.editable || mode !== \"panel\" });\n const dragging = drop.active && drop.active.data?.kind !== \"tray\";\n return (\n <section\n ref={drop.setNodeRef}\n aria-label=\"Unscheduled tasks\"\n className={cx(\n \"flex min-h-0 flex-col gap-2 transition-colors duration-150\",\n mode === \"panel\" && \"rounded-lg border border-border bg-card p-3\",\n mode === \"panel\" && dragging && \"border-dashed border-primary\",\n drop.isOver && \"bg-accent\",\n className,\n )}\n >\n {mode === \"panel\" ? (\n <header className=\"flex items-baseline justify-between gap-2\">\n <h3 className=\"text-sm font-semibold\">Unscheduled</h3>\n <span data-nums className=\"text-xs text-muted-foreground\">\n {tasks.length}\n </span>\n </header>\n ) : null}\n {dragging ? (\n <p className=\"text-xs text-muted-foreground\">Drop here to take the date off.</p>\n ) : tasks.length && api.editable && mode === \"panel\" ? (\n <p className=\"text-xs text-muted-foreground\">Drag a task onto a day or an hour to schedule it.</p>\n ) : null}\n {tasks.length ? (\n <ul className=\"grid gap-1.5\">\n {tasks.map((t) => (mode === \"panel\" ? <TrayItem key={t.id} task={t} api={api} /> : <SheetItem key={t.id} task={t} api={api} />))}\n </ul>\n ) : (\n <div className=\"grid place-items-center gap-1.5 rounded-md border border-dashed border-border px-3 py-6 text-center\">\n <Icon name=\"circle-check\" size={18} className=\"text-muted-foreground\" />\n <p className=\"text-sm font-medium\">Everything has a date</p>\n <p className=\"text-xs text-muted-foreground\">Tasks without one wait here.</p>\n </div>\n )}\n </section>\n );\n}\n\nfunction TrayItem({ task, api }) {\n const movable = api.canEdit(task);\n const d = useDraggableTask(`tray:${task.id}`, { kind: \"tray\", task }, { disabled: !movable });\n const keys = withKeys(d.handle, (e) => e.key === \"Enter\");\n return (\n <li ref={d.setNodeRef} data-cal-id={task.id}>\n <TaskChip\n task={task}\n ctx={api.ctx}\n onOpen={api.open ?? undefined}\n dragging={d.isDragging}\n dragProps={movable ? keys : undefined}\n className={cx(\"min-h-9\", movable && \"cursor-grab active:cursor-grabbing\")}\n />\n </li>\n );\n}\n\nfunction SheetItem({ task, api }) {\n return (\n <li className=\"group/row flex min-h-11 items-center gap-2\">\n <TaskChip task={task} ctx={api.ctx} onOpen={api.open ?? undefined} className=\"min-h-9 flex-1\" />\n {api.canEdit(task) ? (\n <DueLabel\n value={null}\n ctx={api.ctx}\n quiet={false}\n placeholder=\"Schedule\"\n onChange={(day) => day && api.update(task, { due: day }, `Scheduled “${task.title}”`)}\n />\n ) : null}\n </li>\n );\n}\n"
54
54
  },
55
55
  {
56
56
  "path": "registry/blocks/calendar-view/draft.jsx",
@@ -68,7 +68,7 @@
68
68
  "path": "registry/blocks/calendar-view/README.md",
69
69
  "type": "registry:file",
70
70
  "target": "@components/makersclaw/blocks/calendar-view/README.md",
71
- "content": "# Calendar\n\nTasks on a calendar, the way Google Calendar, Cron and Notion Calendar\ndraw them. **Month** shows all-day tasks and multi-day spans as tinted bars\nacross their days (stacked, never on top of each other; past three a day\nsays \"2 more\") and timed tasks as a dot, a time and a title. **Week** and\n**Day** show an all-day strip over an hour grid: timed tasks are blocks\nplaced by their times, overlapping ones side by side, and a line marks\nnow. **Agenda** lists the days ahead with a round check to complete each\nitem.\n\nDrag an item to another day or hour — a block snaps to the quarter hour\nand to the day columns as it moves. Drag a block's bottom edge (or a\nbar's end) to change how long it runs. Click an empty day or hour, or drag\nacross a few hours, and type the new task in place. Tasks with no date\nwait in the **unscheduled tray** beside the calendar: drag one onto a day\nor an hour to schedule it, or drop a scheduled one there to take its date\noff. Search and filters come from task-model's toolbar, so the calendar\nfilters exactly like the list.\n\nThe **available-time layer** — closed hours muted, open slots as boxes\nyou can pick — is what a booking page is built on (`availability`,\n`slots` / `getSlots`, `onSlotClick`, and the pure `freeSlots`).\n\nEvery change saves through one async `onChange(change)` and shows at\nonce; when the save fails the calendar puts it back and says why.\n\n```jsx\nimport { CalendarView } from \"../components/blocks/calendar-view\"; // a Makersclaw app (\"./blocks/calendar-view\" from components/)\nimport { CalendarView } from \"@/components/makersclaw/blocks/calendar-view\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the tasks in the server surface (README §4). It is built on\n`task-model` (added with it): the task shape, the change shapes and the\nhelpers are documented there.\n\n## The data\n\nTasks in task-model's shape. Where a task lands is read from `start` and\n`due` (wall-calendar strings, no time zone):\n\n| The task has | It shows as |\n| --- | --- |\n| `due: \"2026-09-24\"` | an all-day item on the 24th |\n| `start: \"2026-09-22\", due: \"2026-09-24\"` | a bar across the 22nd–24th |\n| `start: \"…T09:30\", due: \"…T11:00\"` (same day) | a 9:30–11:00 block |\n| `start: \"…T09:30\"` only | a block running its `estimate` (hours), else `defaultDuration` |\n| `due: \"…T17:00\"` only | a deadline: a half-hour block at 5 PM, \"Due 5:00 PM\" |\n| neither | the unscheduled tray (open tasks only) |\n\nA drag saves only `start` and `due` (a move keeps the length and the\ntimes; dropping a timed block on the all-day strip saves `start: null`;\ndropping on the tray saves both as `null`). A new task from a click is\n`draftTask(title, ctx, { due })` or `{ start, due }`.\n\n## Props\n\n- `tasks` — the tasks. Pass `useFixture(rows, SAMPLE).rows` and the\n examples change on screen and save nothing.\n- `statuses` · `people` · `projects` · `fields` — as task-list and\n task-model take them. A project's `hue` (1–5) colours its items.\n- `onChange(change)` — save a change; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app,\n loop over `changeWrites(change)` (task-model README, \"A change\").\n Without it the calendar only shows: no drag, no quick-add, no checks.\n- `onOpen(task)` — clicking an item (or Enter on it) opens your `Sheet`.\n- `mode` · `defaultMode` (`\"month\"`) · `onModeChange(mode)` — month ·\n week · day · agenda. `modes` — which ones the switcher offers (default\n all four; one hides the switcher).\n- `date` · `defaultDate` (today) · `onDateChange(day)` — the day the\n view is around.\n- `view` · `defaultView` · `onViewChange(view)` — task-model's view\n (search, filters, hideDone); grouping and sort do not apply here.\n- `today` — \"YYYY-MM-DD\"; fix it in a demo. `now` — \"YYYY-MM-DDTHH:mm\"\n for the now line; default the browser's clock (after mount).\n- `weekStartsOn` (1, Monday) · `weekend` (`[0, 6]`, muted) · `holidays`\n · `locale` (the browser's) — dates, hours and headings follow it.\n- `colorBy` — `\"project\"` (default) · `\"status\"` (the status's tone).\n- `tray` (true) — the unscheduled tray: a column beside the calendar\n when it is 960px wide or more (the toolbar's \"Unscheduled\" button\n folds it), a sheet with a date picker per task below that.\n- `header` (true) — the title, Today, arrows and the view switcher.\n `toolbar` (true) — search, filter, \"Unscheduled\", \"Add task\";\n `toolbarExtra` — your buttons at its end. `addLabel` (\"Add task\").\n- `hourHeight` (48) — pixels per hour. `height` (\"40rem\") — the hour\n grid's scrolling height. `snap` (15) — minutes a drag snaps to.\n `defaultDuration` (60) — minutes of a new block. `agendaDays` (14).\n- `loading` — a skeleton month. `error` — a read error, as a Notice.\n- `label` (\"Calendar\") — the region's name.\n\n### The available-time layer (week and day)\n\n- `availability` — the open hours; everything else is drawn muted:\n `{ days: [1,2,3,4,5], from: \"09:00\", to: \"17:00\" }`, or per weekday\n `{ weekly: { 1: [[\"09:00\",\"12:00\"], [\"13:00\",\"17:00\"]], … } }`, either\n with `dates: { \"2026-12-24\": [[\"09:00\",\"12:00\"]], \"2026-12-25\": [] }`,\n or a function `(day) → [[\"09:00\",\"17:00\"]]`.\n- `slots` — open times to show: `[{ start: \"2026-09-24T10:00\", end:\n \"2026-09-24T10:30\", id?, …yours }]`.\n- `getSlots({ from, to })` — the same, fetched for the days on screen\n (may be async; called again when the range changes). Pass a stable\n function (`useCallback`); a new one fetches again.\n- `onSlotClick(slot)` — slots become buttons; `selectedSlot` (a slot's\n `id` or `start`) fills the picked one. `slotLabel` (\"Open\") is what a\n screen reader says before the time. `renderSlot(slot, { selected })`\n draws a slot's inside.\n\nA booking page is the calendar with `modes={[\"week\", \"day\"]}`,\n`toolbar={false}`, `tray={false}`, the host's meetings as `tasks`, and:\n\n```js\nconst getSlots = useCallback(({ from, to }) =>\n freeSlots({ from, to, availability: HOURS, busy: meetings, minutes: 30, buffer: 10, notice: 120, now }), [meetings, now]);\n```\n\n### Helpers (pure, from the same import)\n\n- `freeSlots({ from, to, availability, busy, minutes = 30, step, buffer, notice, now })`\n → `[{ id, day, start, end, from, to }]` — `busy` mixes tasks (their\n timed blocks; all-day items do not take time) and `{ start, end }`\n intervals; `buffer` minutes kept free around each; nothing before\n `now` + `notice` minutes. `availabilityOn(day, availability)` /\n `closedOn(day, availability)` → `[[from, to], …]` minutes.\n- `eventOf(task)` → `{ id, task, timed, start, end, from, to, deadline, startOnly }`\n or null; `eventsOf(tasks)` · `unscheduledOf(tasks)`.\n- `layoutTimed(events)` → Map id → `{ col, cols, span }` (side by side);\n `layoutLanes(events, days)` → `{ items: [{ event, lane, col, span, clippedStart, clippedEnd }], lanes }`.\n- The patches a gesture saves: `moveEventPatch(event, day, from)` ·\n `resizeEventPatch(event, to)` · `placeEventPatch(event, day, from, to)` ·\n `timedPatch(day, from, minutes)` · `dayPatch(task, day, { grabbed })` ·\n `allDayPatch(day)` · `extendPatch(event, endDay)` · `UNSCHEDULE`.\n- `rangeFor(mode, anchor, { weekStartsOn, count, agendaDays })` ·\n `stepAnchor` · `titleFor` · `rangeLabel` · `hourLabel` ·\n `minutesLabel` · `eventTimeLabel` · `snapMinutes` · `durationOf` ·\n `CALENDAR_MODES` · `MODE_LABELS` · `DAY_MINUTES`.\n\n### Keyboard\n\n- A focused block: ↑/↓ moves it 15 minutes, ←/→ a day, Shift+↑/↓\n changes its length (saved once the keys rest); Enter opens it;\n Delete removes it, with Undo in the toast.\n- A focused bar or month item: Space picks it up, the arrows move it\n between days, Space drops it, Escape puts it back; Shift+←/→ changes\n a bar's last day.\n- Anywhere in the calendar: T today · J / K next / previous · M W D A\n the views · C a new task.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { Sheet } from \"./ui\";\nimport { CalendarView } from \"./blocks/calendar-view\";\n\nexport default function Schedule({ tasks, people, projects, onChange }) {\n const [open, setOpen] = useState(null);\n return (\n <>\n <CalendarView tasks={tasks} people={people} projects={projects} onChange={onChange} onOpen={setOpen} defaultMode=\"week\" />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.title}>…</Sheet>\n </>\n );\n}\n```\n\n## What it will not do\n\n- Recurring events, time zones, or events that are not tasks: a repeat\n is the page's to create; a time is wall time.\n- Timed blocks that cross midnight: a task over several days is an\n all-day bar.\n- Fetch or save: tasks come in as props, changes go out through\n `onChange`; slots come from `slots` or your `getSlots`.\n- Take a booking: `onSlotClick` hands you the slot; the form and the\n write are the page's (booking-page).\n\nIts files are fixed: `npm run check` refuses a change, and `makersclaw\nadd calendar-view` restores them.\n"
71
+ "content": "# Calendar\n\nTasks on a calendar, the way Google Calendar, Cron and Notion Calendar\ndraw them. **Month** shows all-day tasks and multi-day spans as tinted bars\nacross their days (stacked, never on top of each other; past three a day\nsays \"2 more\") and timed tasks as a dot, a time and a title. **Week** and\n**Day** show an all-day strip over an hour grid: timed tasks are blocks\nplaced by their times, overlapping ones side by side, and a line marks\nnow. **Agenda** lists the days ahead with a round check to complete each\nitem.\n\nDrag an item to another day or hour — a block snaps to the quarter hour\nand to the day columns as it moves. Drag a block's bottom edge (or a\nbar's end) to change how long it runs. Click an empty day or hour, or drag\nacross a few hours, and type the new task in place. Tasks with no date\nwait in the **unscheduled tray** beside the calendar: drag one onto a day\nor an hour to schedule it, or drop a scheduled one there to take its date\noff. Search and filters come from task-model's toolbar, so the calendar\nfilters exactly like the list.\n\nThe **available-time layer** — closed hours muted, open slots as boxes\nyou can pick — is what a booking page is built on (`availability`,\n`slots` / `getSlots`, `onSlotClick`, and the pure `freeSlots`).\n\nEvery change saves through one async `onChange(change)` and shows at\nonce; when the save fails the calendar puts it back and says why.\n\n```jsx\nimport { CalendarView } from \"../components/blocks/calendar-view\"; // a Makersclaw app (\"./blocks/calendar-view\" from components/)\nimport { CalendarView } from \"@/components/makersclaw/blocks/calendar-view\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the tasks in the server surface (README §4). It is built on\n`task-model` (added with it): the task shape, the change shapes and the\nhelpers are documented there.\n\n## The data\n\nTasks in task-model's shape. Where a task lands is read from `start` and\n`due` (wall-calendar strings, no time zone):\n\n| The task has | It shows as |\n| --- | --- |\n| `due: \"2026-09-24\"` | an all-day item on the 24th |\n| `start: \"2026-09-22\", due: \"2026-09-24\"` | a bar across the 22nd–24th |\n| `start: \"…T09:30\", due: \"…T11:00\"` (same day) | a 9:30–11:00 block |\n| `start: \"…T09:30\"` only | a block running its `estimate` (hours), else `defaultDuration` |\n| `due: \"…T17:00\"` only | a deadline: a half-hour block at 5 PM, \"Due 5:00 PM\" |\n| neither | the unscheduled tray (open tasks only) |\n\nA drag saves only `start` and `due` (a move keeps the length and the\ntimes; dropping a timed block on the all-day strip saves `start: null`;\ndropping on the tray saves both as `null`). A new task from a click is\n`draftTask(title, ctx, { due })` or `{ start, due }`.\n\nA task with `locked: true` stays where it is: no drag, resize, arrow\nkeys, Delete or done tick for it, and it still opens (`onOpen`). For\nwhat the app shows but did not make — events mirrored from the person's\ncalendar — beside what it may move.\n\n## Props\n\n- `tasks` — the tasks. Pass `useFixture(rows, SAMPLE).rows` and the\n examples change on screen and save nothing.\n- `statuses` · `people` · `projects` · `fields` — as task-list and\n task-model take them. A project's `hue` (1–5) colours its items.\n- `onChange(change)` — save a change; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app,\n loop over `changeWrites(change)` (task-model README, \"A change\").\n Without it the calendar only shows: no drag, no quick-add, no checks.\n- `canCreate` — `false` keeps every change to the tasks there are and\n takes away every way to add one: a click or a drag across an empty\n day or hour, C, the Add buttons (default `true`; with `onChange`).\n- `onOpen(task)` — clicking an item (or Enter on it) opens your `Sheet`.\n- `mode` · `defaultMode` (`\"month\"`) · `onModeChange(mode)` — month ·\n week · day · agenda. `modes` — which ones the switcher offers (default\n all four; one hides the switcher).\n- `date` · `defaultDate` (today) · `onDateChange(day)` — the day the\n view is around.\n- `view` · `defaultView` · `onViewChange(view)` — task-model's view\n (search, filters, hideDone); grouping and sort do not apply here.\n- `today` — \"YYYY-MM-DD\"; fix it in a demo. `now` — \"YYYY-MM-DDTHH:mm\"\n for the now line; default the browser's clock (after mount).\n- `weekStartsOn` (1, Monday) · `weekend` (`[0, 6]`, muted) · `holidays`\n · `locale` (the browser's) — dates, hours and headings follow it.\n- `colorBy` — `\"project\"` (default) · `\"status\"` (the status's tone).\n- `tray` (true) — the unscheduled tray: a column beside the calendar\n when it is 960px wide or more (the toolbar's \"Unscheduled\" button\n folds it), a sheet with a date picker per task below that.\n- `header` (true) — the title, Today, arrows and the view switcher.\n `toolbar` (true) — search, filter, \"Unscheduled\", \"Add task\";\n `toolbarExtra` — your buttons at its end. `addLabel` (\"Add task\").\n- `hourHeight` (48) — pixels per hour. `height` (\"40rem\") — the hour\n grid's scrolling height. `snap` (15) — minutes a drag snaps to.\n `defaultDuration` (60) — minutes of a new block. `agendaDays` (14).\n- `loading` — a skeleton month. `error` — a read error, as a Notice.\n- `label` (\"Calendar\") — the region's name.\n\n### The available-time layer (week and day)\n\n- `availability` — the open hours; everything else is drawn muted:\n `{ days: [1,2,3,4,5], from: \"09:00\", to: \"17:00\" }`, or per weekday\n `{ weekly: { 1: [[\"09:00\",\"12:00\"], [\"13:00\",\"17:00\"]], … } }`, either\n with `dates: { \"2026-12-24\": [[\"09:00\",\"12:00\"]], \"2026-12-25\": [] }`,\n or a function `(day) → [[\"09:00\",\"17:00\"]]`.\n- `slots` — open times to show: `[{ start: \"2026-09-24T10:00\", end:\n \"2026-09-24T10:30\", id?, …yours }]`.\n- `getSlots({ from, to })` — the same, fetched for the days on screen\n (may be async; called again when the range changes). Pass a stable\n function (`useCallback`); a new one fetches again.\n- `onSlotClick(slot)` — slots become buttons; `selectedSlot` (a slot's\n `id` or `start`) fills the picked one. `slotLabel` (\"Open\") is what a\n screen reader says before the time. `renderSlot(slot, { selected })`\n draws a slot's inside.\n\nA booking page is the calendar with `modes={[\"week\", \"day\"]}`,\n`toolbar={false}`, `tray={false}`, the host's meetings as `tasks`, and:\n\n```js\nconst getSlots = useCallback(({ from, to }) =>\n freeSlots({ from, to, availability: HOURS, busy: meetings, minutes: 30, buffer: 10, notice: 120, now }), [meetings, now]);\n```\n\n### Helpers (pure, from the same import)\n\n- `freeSlots({ from, to, availability, busy, minutes = 30, step, buffer, notice, now })`\n → `[{ id, day, start, end, from, to }]` — `busy` mixes tasks (their\n timed blocks; all-day items do not take time) and `{ start, end }`\n intervals; `buffer` minutes kept free around each; nothing before\n `now` + `notice` minutes. `availabilityOn(day, availability)` /\n `closedOn(day, availability)` → `[[from, to], …]` minutes.\n- `eventOf(task)` → `{ id, task, timed, start, end, from, to, deadline, startOnly }`\n or null; `eventsOf(tasks)` · `unscheduledOf(tasks)`.\n- `layoutTimed(events)` → Map id → `{ col, cols, span }` (side by side);\n `layoutLanes(events, days)` → `{ items: [{ event, lane, col, span, clippedStart, clippedEnd }], lanes }`.\n- The patches a gesture saves: `moveEventPatch(event, day, from)` ·\n `resizeEventPatch(event, to)` · `placeEventPatch(event, day, from, to)` ·\n `timedPatch(day, from, minutes)` · `dayPatch(task, day, { grabbed })` ·\n `allDayPatch(day)` · `extendPatch(event, endDay)` · `UNSCHEDULE`.\n- `rangeFor(mode, anchor, { weekStartsOn, count, agendaDays })` ·\n `stepAnchor` · `titleFor` · `rangeLabel` · `hourLabel` ·\n `minutesLabel` · `eventTimeLabel` · `snapMinutes` · `durationOf` ·\n `CALENDAR_MODES` · `MODE_LABELS` · `DAY_MINUTES`.\n\n### Keyboard\n\n- A focused block: ↑/↓ moves it 15 minutes, ←/→ a day, Shift+↑/↓\n changes its length (saved once the keys rest); Enter opens it;\n Delete removes it, with Undo in the toast.\n- A focused bar or month item: Space picks it up, the arrows move it\n between days, Space drops it, Escape puts it back; Shift+←/→ changes\n a bar's last day.\n- Anywhere in the calendar: T today · J / K next / previous · M W D A\n the views · C a new task.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { Sheet } from \"./ui\";\nimport { CalendarView } from \"./blocks/calendar-view\";\n\nexport default function Schedule({ tasks, people, projects, onChange }) {\n const [open, setOpen] = useState(null);\n return (\n <>\n <CalendarView tasks={tasks} people={people} projects={projects} onChange={onChange} onOpen={setOpen} defaultMode=\"week\" />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.title}>…</Sheet>\n </>\n );\n}\n```\n\n## What it will not do\n\n- Recurring events, time zones, or events that are not tasks: a repeat\n is the page's to create; a time is wall time.\n- Timed blocks that cross midnight: a task over several days is an\n all-day bar.\n- Fetch or save: tasks come in as props, changes go out through\n `onChange`; slots come from `slots` or your `getSlots`.\n- Take a booking: `onSlotClick` hands you the slot; the form and the\n write are the page's (booking-page).\n\nIts files are fixed: `npm run check` refuses a change, and `makersclaw\nadd calendar-view` restores them.\n"
72
72
  }
73
73
  ],
74
74
  "categories": [
@@ -98,7 +98,7 @@
98
98
  "docs": "Installed to components/makersclaw/blocks/calendar-view. Import: import { CalendarView, CALENDAR_MODES, MODE_LABELS, DAY_MINUTES, UNSCHEDULE, snapMinutes, durationOf, eventOf, eventsOf, unscheduledOf, layoutTimed, layoutLanes, moveEventPatch, resizeEventPatch, placeEventPatch, timedPatch, dayPatch, allDayPatch, extendPatch, rangeFor, stepAnchor, rangeLabel, titleFor, hourLabel, minutesLabel, eventTimeLabel, availabilityOn, closedOn, freeSlots, normalizeSlots } from \"@/components/makersclaw/blocks/calendar-view\". Props and an example: components/makersclaw/blocks/calendar-view/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
99
99
  "meta": {
100
100
  "makersclaw": {
101
- "version": "0.4.0",
101
+ "version": "0.5.1",
102
102
  "useWhen": "A screen where dated work or events are seen by day and moved around: a team's deadlines, a content calendar, a launch plan, shifts, a personal schedule, meetings on tasks, or the slot picker of a booking page (modes week/day, slots from getSlots). Not for dependencies over a long timeline (gantt), for undated work (task-list, board), or for a single date field (the kit's DatePicker)."
103
103
  }
104
104
  }
package/r/cart.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Cart",
6
6
  "description": "A shopping cart: line items with picture, option, quantity and remove, a summary (subtotal, discount code, shipping, tax, total) and Checkout. step=\"checkout\" is the checkout page: contact and delivery details beside the order summary, ending at onPay, which hands off to Paddle. No card fields. Empty and loading states built in.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.1/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -55,7 +55,7 @@
55
55
  "docs": "Installed to components/makersclaw/blocks/cart. Import: import { Cart } from \"@/components/makersclaw/blocks/cart\". Props and an example: components/makersclaw/blocks/cart/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
56
56
  "meta": {
57
57
  "makersclaw": {
58
- "version": "0.4.0",
58
+ "version": "0.5.1",
59
59
  "useWhen": "An app that sells things: the basket before paying, and the page that collects the buyer's email and address before the payment provider takes over. Not for a subscription plan picker (a pricing table) or for an order already placed (order-detail)."
60
60
  }
61
61
  }