From e92178d618924147ae9d162fdbf720d6e6e97397 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sat, 24 Oct 2026 22:56:19 +1100 Subject: [PATCH 1/4] feat(planner): render the Gantt chart with @tasknotes/calendar's timeline Replace the hand-built Gantt surface with the timeline view of the vendored @tasknotes/calendar 0.1.0-dev.0. Rows, grouping, bars, milestones, dependency arrows, drag/resize writes, link creation, scales, the today line and selection now come from the library; Planner keeps its ledger (rendered into the row labels), domain mapping and write handling. - gantt.ts: map tasks to timeline items (exclusive end = due + 1 day) and map drops/resizes/placements back to scheduled and inclusive due values. - Ctrl/Cmd+wheel zoom stays in the chart and keeps the pointer date anchored. - Theme the calendar through --tnc-theme-* tokens from Planner's palette. --- README.md | 12 +- e2e/phone-planner.spec.ts | 41 +- package.json | 1 + pnpm-lock.yaml | 16 + src/components/gantt-chart.test.tsx | 604 +++----- src/components/gantt-chart.tsx | 1605 +++++++-------------- src/domain/gantt.test.ts | 60 + src/domain/gantt.ts | 96 ++ src/styles.css | 447 +----- vendor/tasknotes-calendar-0.1.0-dev.0.tgz | Bin 0 -> 131188 bytes 10 files changed, 947 insertions(+), 1935 deletions(-) create mode 100644 vendor/tasknotes-calendar-0.1.0-dev.0.tgz diff --git a/README.md b/README.md index ae662e2..4fa85fa 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,13 @@ staging, or production app; TaskNotes still owns its own sign-in and approval. zooms around the pointer. - Drag a task bar to move its whole schedule. Drag either end to change its scheduled or due date. Drag an unscheduled row into the timeline to place it. -- Focus a bar and use `Alt` + Left/Right Arrow for one-day keyboard moves. The - resize grips support Left/Right Arrow directly. -- Drag from the small handle below either end of one bar to an end of another - bar to create the corresponding start/finish relationship. Cycles and - self-links are rejected. +- Focus a bar and use `Alt` + Left/Right Arrow for one-day keyboard moves, or + `Alt` + `Shift` + Left/Right Arrow to change its end. +- Drag from the handle at the end of one bar onto another bar to make the + second task blocked by the first (finish-to-start). Set other relationship + types in the inspector. Cycles and self-links are rejected. +- On touch screens, press and hold a bar or an unscheduled title before + dragging it. - Select a task to edit dates, status, priority, ordered project memberships, dependencies, or relationship types in the inspector. diff --git a/e2e/phone-planner.spec.ts b/e2e/phone-planner.spec.ts index 4e98df1..302f034 100644 --- a/e2e/phone-planner.spec.ts +++ b/e2e/phone-planner.spec.ts @@ -83,7 +83,7 @@ test("schedule dates and times have full-width rows on phones", async ({ } }); -test("an unscheduled title can drop onto a date header and still opens the editor on click", async ({ +test("an unscheduled title can drop onto the timeline and still opens the editor on click", async ({ page, }) => { await page.goto("/?demo=1"); @@ -94,10 +94,8 @@ test("an unscheduled title can drop onto a date header and still opens the edito const name = await title.locator("strong").innerText(); const ledger = page.locator(".task-ledger-main").filter({ hasText: name }); const source = await title.boundingBox(); - const header = await page.locator(".timeline-header").boundingBox(); - const corner = await page.locator(".gantt-corner").boundingBox(); - if (!source || !header || !corner) - throw new Error("Drag geometry unavailable"); + const corner = await page.locator(".tnc-tl-corner").boundingBox(); + if (!source || !corner) throw new Error("Drag geometry unavailable"); await page.mouse.move( source.x + source.width / 2, source.y + source.height / 2, @@ -105,10 +103,8 @@ test("an unscheduled title can drop onto a date header and still opens the edito await page.mouse.down(); await page.mouse.move( corner.x + corner.width + 200, - header.y + header.height / 2, - { - steps: 12, - }, + source.y + source.height / 2, + { steps: 12 }, ); await page.mouse.up(); await expect(page.locator(".gantt-interaction-message")).toHaveText( @@ -129,7 +125,8 @@ for (const width of [320, 390]) { await page.setViewportSize({ width, height: 844 }); await page.goto("/e2e/fixtures/phone-ledger.html"); await page.getByLabel(/^Search tasks/).fill(phonePrefix); - await expect(page.locator(".task-row")).toHaveCount(2); + const rows = page.locator(".tnc-tl-row:not(.is-group)"); + await expect(rows).toHaveCount(2); await page.evaluate(() => document.fonts.ready); for (const suffix of ["alpha", "beta"]) { const title = page @@ -155,26 +152,14 @@ for (const width of [320, 390]) { }, suffix), ).toBe(true); } - for (const row of await page.locator(".task-row").all()) { - expect((await row.boundingBox())!.height).toBe(65); - expect((await row.locator(".task-ledger").boundingBox())!.height).toBe( - 65, - ); - expect((await row.locator(".timeline-row").boundingBox())!.height).toBe( - 65, - ); + for (const row of await rows.all()) { const bounds = (await row.boundingBox())!; - const action = (await row - .locator(".unscheduled-row-action") - .boundingBox())!; - expect( - Math.abs(action.y + action.height / 2 - bounds.y - bounds.height / 2), - ).toBeLessThanOrEqual(1); + expect(bounds.height).toBe(65); + const ledger = (await row.locator(".task-ledger").boundingBox())!; + const track = (await row.locator(".tnc-tl-track").boundingBox())!; + expect(ledger.y).toBe(track.y); + expect(ledger.height).toBe(track.height); } - await expect(page.locator(".dependency-layer")).toHaveAttribute( - "height", - "165", - ); expect( await page.evaluate(() => document.documentElement.scrollWidth), ).toBeLessThanOrEqual(width); diff --git a/package.json b/package.json index da2b374..ee8ff7b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.96-56ed32ffde05.tgz", "@mdbase-dev/sdk": "file:vendor/mdbase-dev-sdk-dev-0ecbef41.tgz", "@mdbase-dev/ui": "file:vendor/ui-dev-9553ad66.tgz", + "@tasknotes/calendar": "file:vendor/tasknotes-calendar-0.1.0-dev.0.tgz", "@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.15.tgz", "lucide-react": "1.25.0", "react": "19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9059f4f..0d68ae7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: "@mdbase-dev/ui": specifier: file:vendor/ui-dev-9553ad66.tgz version: file:vendor/ui-dev-9553ad66.tgz(@mdbase-dev/sdk@file:vendor/mdbase-dev-sdk-dev-0ecbef41.tgz)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + "@tasknotes/calendar": + specifier: file:vendor/tasknotes-calendar-0.1.0-dev.0.tgz + version: file:vendor/tasknotes-calendar-0.1.0-dev.0.tgz(react@19.2.8) "@tasknotes/model": specifier: file:vendor/tasknotes-model-0.3.0-rc.15.tgz version: file:vendor/tasknotes-model-0.3.0-rc.15.tgz @@ -732,6 +735,15 @@ packages: integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, } + '@tasknotes/calendar@file:vendor/tasknotes-calendar-0.1.0-dev.0.tgz': + resolution: {integrity: sha512-CJhkbTyJpbDbfA5Xubp/LrBUa+6xKiHxULFy3SHvVFjuud0y8zcPh5sfeNHlZPE26/0kAlFrb9Sw1HtDBhBWkA==, tarball: file:vendor/tasknotes-calendar-0.1.0-dev.0.tgz} + version: 0.1.0-dev.0 + peerDependencies: + react: '>=18' + peerDependenciesMeta: + react: + optional: true + "@tasknotes/model@file:vendor/tasknotes-model-0.3.0-rc.15.tgz": resolution: { @@ -2694,6 +2706,10 @@ snapshots: "@standard-schema/spec@1.1.0": {} + '@tasknotes/calendar@file:vendor/tasknotes-calendar-0.1.0-dev.0.tgz(react@19.2.8)': + optionalDependencies: + react: 19.2.8 + "@tasknotes/model@file:vendor/tasknotes-model-0.3.0-rc.15.tgz": dependencies: rrule: 2.8.1 diff --git a/src/components/gantt-chart.test.tsx b/src/components/gantt-chart.test.tsx index cb4cf88..5f42373 100644 --- a/src/components/gantt-chart.test.tsx +++ b/src/components/gantt-chart.test.tsx @@ -30,61 +30,92 @@ beforeEach(() => { HTMLElement.prototype.scrollTo = vi.fn(); }); +function renderChart( + tasks: PlannerTask[], + overrides: Partial[0]> = {}, +) { + const props = { + allTasks: tasks, + tasks, + selectedId: null, + todayRequest: 0, + zoom: 3, + onSelect: vi.fn(), + onZoom: vi.fn(), + onScheduleChange: vi.fn().mockResolvedValue(undefined), + onDependenciesChange: vi.fn().mockResolvedValue(undefined), + onCompletionChange: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; + const view = render(); + return { ...props, view }; +} + +/** The calendar rows start at y = 0 in jsdom; row 0 is the project group. */ +const TASK_ROW_Y = 49 + 10; + +function bar(id: string) { + return document.querySelector(`.tnc-item[data-id="${id}"]`)!; +} + +function drag( + element: Element, + from: { x: number; y: number }, + to: { x: number; y: number }, +) { + fireEvent.pointerDown(element, { + button: 0, + clientX: from.x, + clientY: from.y, + pointerId: 1, + }); + for (const step of [0.5, 1]) + fireEvent.pointerMove(document, { + clientX: from.x + (to.x - from.x) * step, + clientY: from.y + (to.y - from.y) * step, + pointerId: 1, + }); + fireEvent.pointerUp(document, { + clientX: to.x, + clientY: to.y, + pointerId: 1, + }); +} + describe("GanttChart interactions", () => { - it("uses the responsive row height for ledger rows, timeline body and dependency endpoints", () => { - const readStyle = getComputedStyle; - const styleSpy = vi - .spyOn(globalThis, "getComputedStyle") - .mockImplementation((element) => { - const style = readStyle(element); - const readProperty = style.getPropertyValue.bind(style); - style.getPropertyValue = (name) => - name === "--task-row-height" ? "65px" : readProperty(name); - return style; - }); - try { - const first = makeTask({ - id: "first", - scheduled: "2026-08-17", - due: "2026-08-18", - }); - const second = makeTask({ - id: "second", - scheduled: "2026-08-19", - due: "2026-08-20", - blockedBy: [{ uid: "first", reltype: "FINISHTOSTART" }], - }); - const tasks = [first, second]; - const view = render( - undefined} - onZoom={() => undefined} - onScheduleChange={vi.fn().mockResolvedValue(undefined)} - onDependenciesChange={vi.fn().mockResolvedValue(undefined)} - onCompletionChange={vi.fn().mockResolvedValue(undefined)} - />, - ); - for (const row of view.container.querySelectorAll(".task-row")) { - expect(row).toHaveStyle({ height: "65px" }); - } - expect(view.container.querySelector(".gantt-body")).toHaveStyle({ - height: "165px", - }); - expect(view.container.querySelector(".dependency-layer")).toHaveAttribute( - "height", - "165", - ); - const path = view.container.querySelector(".dependency-layer > path"); - expect(path?.getAttribute("d")).toContain("67.5"); - expect(path?.getAttribute("d")).toContain("132.5"); - } finally { - styleSpy.mockRestore(); - } + it("draws grouped rows, inclusive due bars, milestones and dependency arrows", () => { + const first = makeTask({ + id: "first", + title: "First", + projects: ["[[Launch]]"], + scheduled: "2026-08-17", + due: "2026-08-18", + }); + const second = makeTask({ + id: "second", + title: "Second", + projects: ["[[Launch]]"], + due: "2026-08-20", + blockedBy: [{ uid: "tasks/first.md", reltype: "FINISHTOSTART" }], + }); + renderChart([first, second]); + + const rows = [...document.querySelectorAll(".tnc-tl-row")]; + expect(rows.map((row) => row.getAttribute("data-row"))).toEqual([ + "project:launch", + "first", + "second", + ]); + expect(rows[0]).toHaveClass("is-group"); + expect(rows[0]).toHaveTextContent("Launch2"); + expect(bar("first")).toHaveAccessibleName(/^First, Aug 17 — Aug 18\./); + expect(bar("first").style.width).toBe("30px"); + expect(bar("second")).toHaveClass("is-milestone"); + expect( + document.querySelector( + '.tnc-tl-deps path[data-from="first"][data-to="second"]', + ), + ).not.toBeNull(); }); it("flags imported reversed dates without presenting them as a valid range", () => { @@ -95,200 +126,164 @@ describe("GanttChart interactions", () => { due: "2026-10-29", }), ); - const onSelect = vi.fn(); - const onScheduleChange = vi.fn(); - const view = render( - , - ); + const { onSelect, onScheduleChange } = renderChart([task]); const warning = screen.getByText("Scheduled after due"); expect(warning).toHaveAttribute( "title", "Scheduled Nov 2; due Oct 29. Scheduled is after the due date", ); expect(screen.queryByText("Nov 2 — Oct 29")).not.toBeInTheDocument(); - const ledger = view.container.querySelector(".task-ledger-main")!; + const ledger = document.querySelector(".task-ledger-main")!; expect(ledger).toHaveAccessibleName( /Imported inversion, Scheduled Nov 2; due Oct 29.*Scheduled is after the due date/, ); - expect(view.container.querySelector(".task-bar-main")).toHaveAccessibleName( - /Scheduled is after the due date/, - ); + expect(bar("task")).toHaveAccessibleName(/Scheduled is after the due date/); fireEvent.click(ledger); expect(onSelect).toHaveBeenCalledWith(task); expect(onScheduleChange).not.toHaveBeenCalled(); - expect(task.scheduled).toBe("2026-11-02"); - expect(task.due).toBe("2026-10-29"); }); - it("gates timeline writes while keeping task selection available", async () => { + it("gates timeline writes while keeping task selection available", () => { const task = makeTask({ id: "blocked", title: "Blocked task", scheduled: "2026-08-17", due: "2026-08-18", }); - const onScheduleChange = vi.fn().mockResolvedValue(undefined); - const onDependenciesChange = vi.fn().mockResolvedValue(undefined); - const onCompletionChange = vi.fn().mockResolvedValue(undefined); - const onSelect = vi.fn(); - render( - undefined} - />, + const { onSelect, onScheduleChange, onCompletionChange } = renderChart( + [task], + { mutationsDisabled: true }, ); expect( screen.getByRole("button", { name: "Complete Blocked task" }), ).toBeDisabled(); - expect( - screen.getByRole("button", { - name: "Change Blocked task scheduled date", - }), - ).toBeDisabled(); - expect( - screen.getByRole("button", { - name: "Finish relationship handle for Blocked task", - }), - ).toBeDisabled(); - - fireEvent.click(document.querySelector(".task-ledger-main")!); - fireEvent.keyDown(document.querySelector(".task-bar-main")!, { - altKey: true, - key: "ArrowRight", - }); + expect(bar("blocked")).not.toHaveClass("is-draggable"); + expect(bar("blocked").querySelector(".tnc-port, .tnc-resize")).toBeNull(); + fireEvent.keyDown(bar("blocked"), { altKey: true, key: "ArrowRight" }); + fireEvent.click(bar("blocked")); expect(onSelect).toHaveBeenCalledWith(task); expect(onScheduleChange).not.toHaveBeenCalled(); - expect(onDependenciesChange).not.toHaveBeenCalled(); expect(onCompletionChange).not.toHaveBeenCalled(); }); - it("uses Ctrl+wheel for chart zoom without allowing Chrome page zoom", () => { - const task = makeTask({ - id: "zoomable", - title: "Zoomable task", - scheduled: "2026-08-17", - due: "2026-08-18", - }); - const onZoom = vi.fn(); - const addEventListener = vi.spyOn( - HTMLElement.prototype, - "addEventListener", - ); - render( - undefined} - onZoom={onZoom} - />, - ); + it("zooms with Ctrl+wheel inside the chart and leaves plain wheel scrolling alone", () => { + const task = makeTask({ id: "zoomable", scheduled: "2026-08-17" }); + const { onZoom } = renderChart([task]); + const surface = document.querySelector(".gantt-scroll")!; - expect(addEventListener).toHaveBeenCalledWith( - "wheel", - expect.any(Function), - { - passive: false, - }, - ); + const plain = new WheelEvent("wheel", { + bubbles: true, + cancelable: true, + deltaY: 100, + }); + surface.querySelector(".tnc-tl-scroll")!.dispatchEvent(plain); + expect(plain.defaultPrevented).toBe(false); + expect(onZoom).not.toHaveBeenCalled(); - const surface = document.querySelector(".gantt-scroll")!; - const event = new WheelEvent("wheel", { + const zoom = new WheelEvent("wheel", { bubbles: true, cancelable: true, clientX: 600, ctrlKey: true, deltaY: -100, }); - surface.dispatchEvent(event); - - expect(event.defaultPrevented).toBe(true); + surface.querySelector(".tnc-tl-scroll")!.dispatchEvent(zoom); + expect(zoom.defaultPrevented).toBe(true); expect(onZoom).toHaveBeenCalledWith(1); }); - it("leaves ordinary chart scrolling to the browser", () => { - const task = makeTask({ id: "scrollable", title: "Scrollable task" }); - const onZoom = vi.fn(); - render( - undefined} - onZoom={onZoom} - />, + it("moves timed tasks in quarter-hour increments and keeps their stored form", async () => { + const task = makeTask({ + id: "timed", + title: "Timed task", + scheduled: "2026-08-17T09:30:00", + due: "2026-08-17T10:30:00Z", + }); + const { onScheduleChange } = renderChart([task], { zoom: 7 }); + + // 576px per day: 24px is one hour. + drag(bar("timed"), { x: 600, y: TASK_ROW_Y }, { x: 624, y: TASK_ROW_Y }); + + await waitFor(() => + expect(onScheduleChange).toHaveBeenCalledWith(task, { + scheduled: "2026-08-17T10:30:00", + due: "2026-08-17T11:30:00Z", + }), ); + }); - const surface = document.querySelector(".gantt-scroll")!; - const event = new WheelEvent("wheel", { - bubbles: true, - cancelable: true, - deltaY: 100, + it("resizes an all-day finish to an inclusive due date", async () => { + const task = makeTask({ + id: "span", + scheduled: "2026-08-17", + due: "2026-08-18", }); - surface.dispatchEvent(event); + const { onScheduleChange } = renderChart([task], { zoom: 5 }); + + // 34px per day: drag the finish grip two days later. + const finish = + 350 + + parseFloat(bar("span").style.left) + + parseFloat(bar("span").style.width); + drag( + bar("span").querySelector(".tnc-resize-end")!, + { x: finish, y: TASK_ROW_Y }, + { x: finish + 68, y: TASK_ROW_Y }, + ); - expect(event.defaultPrevented).toBe(false); - expect(onZoom).not.toHaveBeenCalled(); + await waitFor(() => + expect(onScheduleChange).toHaveBeenCalledWith(task, { + scheduled: "2026-08-17", + due: "2026-08-20", + }), + ); + }); + + it("creates a finish-to-start relationship by linking two bars", async () => { + const source = makeTask({ + id: "source", + title: "Source", + scheduled: "2026-08-17", + due: "2026-08-19", + }); + const target = makeTask({ + id: "target", + title: "Target", + scheduled: "2026-08-20", + due: "2026-08-22", + }); + const { onDependenciesChange } = renderChart([source, target]); + Object.defineProperty(document, "elementFromPoint", { + configurable: true, + value: vi.fn(() => bar("target")), + }); + + drag( + bar("source").querySelector(".tnc-port")!, + { x: 600, y: TASK_ROW_Y }, + { x: 650, y: TASK_ROW_Y + 49 }, + ); + + await waitFor(() => expect(onDependenciesChange).toHaveBeenCalledOnce()); + expect(onDependenciesChange).toHaveBeenCalledWith(target, [ + { uid: "source", reltype: "FINISHTOSTART" }, + ]); }); it.each([ - { target: "date header", clientX: 420, clientY: 50, saves: true }, - { target: "task ledger", clientX: 120, clientY: 250, saves: false }, - { target: "toolbar", clientX: 420, clientY: -10, saves: false }, + { target: "timeline", clientX: 420, saves: true }, + { target: "task ledger", clientX: 120, saves: false }, ])( "handles unscheduled title drops over the $target", - async ({ clientX, clientY, saves }) => { + async ({ clientX, saves }) => { const task = makeTask({ id: "title-drag", title: "Unscheduled title" }); - const onScheduleChange = vi.fn().mockResolvedValue(undefined); - const onSelect = vi.fn(); - render( - undefined} - />, - ); - const surface = document.querySelector(".gantt-scroll")!; - vi.spyOn(surface, "getBoundingClientRect").mockReturnValue({ + const { onScheduleChange, onSelect } = renderChart([task]); + vi.spyOn( + document.querySelector(".gantt-scroll")!, + "getBoundingClientRect", + ).mockReturnValue({ left: 0, top: 0, right: 1200, @@ -296,32 +291,18 @@ describe("GanttChart interactions", () => { width: 1200, height: 900, } as DOMRect); - vi.spyOn( - document.querySelector(".timeline-row")!, - "getBoundingClientRect", - ).mockReturnValue({ left: 350 } as DOMRect); const title = screen.getByRole("button", { name: /Unscheduled title, not scheduled. Select to edit or drag/, }); - fireEvent.pointerDown(title, { - button: 0, - clientX: 160, - clientY: 250, - pointerId: 1, - }); - fireEvent.pointerMove(window, { clientX, clientY, pointerId: 1 }); - fireEvent.pointerUp(window, { clientX, clientY, pointerId: 1 }); + drag(title, { x: 160, y: TASK_ROW_Y }, { x: clientX, y: TASK_ROW_Y }); fireEvent.click(title); expect(onSelect).not.toHaveBeenCalled(); if (saves) { await waitFor(() => expect(onScheduleChange).toHaveBeenCalledOnce()); - expect(onScheduleChange.mock.calls[0]).toEqual([ - task, - { - due: undefined, - scheduled: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), - }, - ]); + expect(onScheduleChange).toHaveBeenCalledWith(task, { + due: undefined, + scheduled: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + }); } else { expect(onScheduleChange).not.toHaveBeenCalled(); expect(screen.getByRole("status")).toHaveTextContent( @@ -333,206 +314,17 @@ describe("GanttChart interactions", () => { it("keeps an unscheduled title click as task selection", () => { const task = makeTask({ id: "title-click", title: "Unscheduled title" }); - const onScheduleChange = vi.fn(); - const onSelect = vi.fn(); - render( - undefined} - />, - ); + const { onSelect, onScheduleChange } = renderChart([task]); const title = screen.getByRole("button", { name: /Unscheduled title, not scheduled/, }); fireEvent.pointerDown(title, { button: 0, clientX: 160, pointerId: 1 }); - fireEvent.pointerUp(window, { clientX: 160, pointerId: 1 }); + fireEvent.pointerUp(document, { clientX: 160, pointerId: 1 }); fireEvent.click(title); expect(onSelect).toHaveBeenCalledWith(task); expect(onScheduleChange).not.toHaveBeenCalled(); }); - it("places an unscheduled task on the timeline by dragging", async () => { - const task = makeTask({ id: "unscheduled", title: "Unscheduled" }); - const onScheduleChange = vi.fn().mockResolvedValue(undefined); - render( - undefined} - onZoom={() => undefined} - />, - ); - - const handle = screen.getByRole("button", { name: "Drag to schedule" }); - fireEvent.pointerDown(handle, { - button: 0, - clientX: 360, - pointerId: 1, - }); - fireEvent.pointerMove(handle, { clientX: 420, pointerId: 1 }); - fireEvent.pointerUp(handle, { clientX: 420, pointerId: 1 }); - - await waitFor(() => expect(onScheduleChange).toHaveBeenCalledOnce()); - expect(onScheduleChange.mock.calls[0][0]).toBe(task); - expect(onScheduleChange.mock.calls[0][1]).toEqual({ - due: undefined, - scheduled: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), - }); - }); - - it("places unscheduled work at an explicit intraday time", async () => { - const task = makeTask({ id: "intraday", title: "Intraday" }); - const onScheduleChange = vi.fn().mockResolvedValue(undefined); - render( - undefined} - onZoom={() => undefined} - />, - ); - - const handle = screen.getByRole("button", { name: "Drag to schedule" }); - fireEvent.pointerDown(handle, { - button: 0, - clientX: 360, - pointerId: 4, - }); - fireEvent.pointerMove(handle, { clientX: 420, pointerId: 4 }); - fireEvent.pointerUp(handle, { clientX: 420, pointerId: 4 }); - - await waitFor(() => expect(onScheduleChange).toHaveBeenCalledOnce()); - expect(onScheduleChange.mock.calls[0][1]).toEqual({ - due: undefined, - scheduled: expect.stringMatching( - /^\d{4}-\d{2}-\d{2}T\d{2}:(00|15|30|45):00$/, - ), - }); - }); - - it("draws an edge relationship and stores it on the target", async () => { - const source = makeTask({ - id: "source", - title: "Source", - scheduled: "2026-08-17", - due: "2026-08-19", - }); - const target = makeTask({ - id: "target", - title: "Target", - scheduled: "2026-08-20", - due: "2026-08-22", - }); - const onDependenciesChange = vi.fn().mockResolvedValue(undefined); - render( - undefined} - onZoom={() => undefined} - />, - ); - - const sourceHandle = screen.getByRole("button", { - name: "Finish relationship handle for Source", - }); - const targetHandle = screen.getByRole("button", { - name: "Start relationship handle for Target", - }); - Object.defineProperty(document, "elementFromPoint", { - configurable: true, - value: vi.fn(() => targetHandle), - }); - - fireEvent.pointerDown(sourceHandle, { - button: 0, - clientX: 600, - clientY: 100, - pointerId: 2, - }); - fireEvent.pointerMove(sourceHandle, { - clientX: 650, - clientY: 150, - pointerId: 2, - }); - fireEvent.pointerUp(sourceHandle, { - clientX: 650, - clientY: 150, - pointerId: 2, - }); - - await waitFor(() => expect(onDependenciesChange).toHaveBeenCalledOnce()); - expect(onDependenciesChange).toHaveBeenCalledWith(target, [ - { uid: "source", reltype: "FINISHTOSTART" }, - ]); - }); - - it("moves timed tasks in quarter-hour increments at the closest scale", async () => { - const task = makeTask({ - id: "timed", - title: "Timed task", - scheduled: "2026-08-17T09:30:00", - due: "2026-08-17T10:30:00", - }); - const onScheduleChange = vi.fn().mockResolvedValue(undefined); - render( - undefined} - onZoom={() => undefined} - />, - ); - - const bar = screen.getByRole("button", { name: /Timed task, .*09:30/ }); - fireEvent.pointerDown(bar, { - button: 0, - clientX: 600, - pointerId: 3, - }); - fireEvent.pointerMove(bar, { clientX: 624, pointerId: 3 }); - fireEvent.pointerUp(bar, { clientX: 624, pointerId: 3 }); - - await waitFor(() => - expect(onScheduleChange).toHaveBeenCalledWith(task, { - scheduled: "2026-08-17T10:30:00", - due: "2026-08-17T11:30:00", - }), - ); - }); - it("completes a task without opening its inspector", async () => { const task = makeTask({ id: "complete-me", title: "Complete me" }); const onCompletionChange = vi.fn().mockResolvedValue(undefined); diff --git a/src/components/gantt-chart.tsx b/src/components/gantt-chart.tsx index 4cbe751..9158fc3 100644 --- a/src/components/gantt-chart.tsx +++ b/src/components/gantt-chart.tsx @@ -1,19 +1,25 @@ -import { Check, GitBranch, Minus } from "lucide-react"; +import { Calendar } from "@tasknotes/calendar"; +import "@tasknotes/calendar/calendar.css"; +import { Check, GitBranch } from "lucide-react"; import { - useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, + useSyncExternalStore, } from "react"; +import { createPortal } from "react-dom"; import type { - CSSProperties, - KeyboardEvent as ReactKeyboardEvent, - MutableRefObject, - PointerEvent as ReactPointerEvent, -} from "react"; + CalendarItem, + CalendarOptions, + ExternalDragSource, + ItemChangeArg, + TimelineRow, + TimelineScale as CalendarTimelineScale, +} from "@tasknotes/calendar"; +import type { PointerEvent as ReactPointerEvent } from "react"; import { addOrReplaceDependency, @@ -22,19 +28,19 @@ import { dateFromDate, dateLabel, dependencyRelationship, + dependencyTaskIds, groupTasks, hasInvertedSchedule, - minuteToPlanningValue, + placedSchedule, planningMinute, - resolveDependencyTask, - resizedScheduleAt, - shiftedScheduleByMinutes, + scheduleFromTimelineChange, taskHasTime, taskSpan, - taskTimelineSpan, + taskTimelineItem, timelineScale, - withSchedule, type GanttRow, + type TimelineChangeKind, + type TimelineScale, type TimelineZoom, } from "../domain/gantt"; import { errorMessage, isOperationOutcomeUnknown } from "../data/outcome"; @@ -45,57 +51,30 @@ import type { TaskDependency, } from "../domain/task"; -const TASK_COLUMN = 350; -const GROUP_HEIGHT = 35; -const TASK_HEIGHT = 49; +/** Task id carried by an unscheduled task while it is dragged onto the timeline. */ +type PlacementData = string; -type ScheduleDragKind = "move" | "resize-start" | "resize-finish" | "place"; +const PHONE_QUERY = "(max-width: 560px)"; +const LEDGER = { + wide: { labelWidth: 350, rowHeight: 49 }, + phone: { labelWidth: 280, rowHeight: 65 }, +} as const; -interface ScheduleDrag { - task: PlannerTask; - kind: ScheduleDragKind; - originClientX: number; - timelineLeft: number; - update: ScheduleUpdate; - changed: boolean; - validPlacement: boolean; -} - -interface SchedulePreview { - taskId: string; - update: ScheduleUpdate; -} - -interface LinkDrag { - sourceId: string; - sourceEdge: "start" | "finish"; - startX: number; - startY: number; - currentX: number; - currentY: number; - targetId?: string; - targetEdge?: "start" | "finish"; - invalidReason?: string; -} +/** Days per scale unit, as the calendar's timeline measures them. */ +const UNIT_DAYS: Record = { + hour: 1 / 24, + day: 1, + week: 7, + month: 30.44, + quarter: 91.31, +}; interface InteractionMessage { tone: "neutral" | "success" | "danger"; text: string; } -export function GanttChart({ - tasks, - allTasks, - zoom, - selectedId, - todayRequest, - mutationsDisabled = false, - onSelect, - onZoom, - onScheduleChange, - onDependenciesChange, - onCompletionChange, -}: { +interface GanttChartProps { tasks: readonly PlannerTask[]; allTasks: readonly PlannerTask[]; zoom: TimelineZoom; @@ -110,118 +89,101 @@ export function GanttChart({ task: PlannerTask, dependencies: readonly TaskDependency[], ): Promise; -}) { - const scroller = useRef(null); - const body = useRef(null); - const scheduleDrag = useRef(null); - const activeLink = useRef(null); - const suppressClick = useRef(false); +} + +export function GanttChart(props: GanttChartProps) { + if (!props.tasks.length) + return ( +
+

No tasks match these controls

+

Change the saved view, search, or task filters.

+
+ ); + return ; +} + +function GanttTimeline({ + tasks, + allTasks, + zoom, + selectedId, + todayRequest, + mutationsDisabled = false, + onSelect, + onZoom, + onScheduleChange, + onDependenciesChange, + onCompletionChange, +}: GanttChartProps) { + const host = useRef(null); + const calendarRef = useRef | null>(null); + const wheelHandler = useRef<((event: WheelEvent) => void) | null>(null); + const previousScale = useRef<{ scale: TimelineScale; labelWidth: number }>( + null, + ); const pendingZoomAnchor = useRef<{ minute: number; viewportX: number; } | null>(null); - const statusTimer = useRef(null); const lastTodayRequest = useRef(null); - const [schedulePreview, setSchedulePreview] = - useState(null); - const [linkDrag, setLinkDrag] = useState(null); + const statusTimer = useRef(null); + // Ledger labels are React portals, so their clicks reach React before the + // calendar can suppress the click that ends a placement drag. + const placementClick = useRef(false); + const [labelHosts, setLabelHosts] = useState< + ReadonlyMap + >(() => new Map()); const [interactionMessage, setInteractionMessage] = useState(null); const [completionPending, setCompletionPending] = useState>( () => new Set(), ); const [timelineViewportWidth, setTimelineViewportWidth] = useState(0); - const [taskHeight, setTaskHeight] = useState(TASK_HEIGHT); + const { labelWidth, rowHeight } = useMediaQuery(PHONE_QUERY) + ? LEDGER.phone + : LEDGER.wide; const today = dateFromDate(new Date()); - const now = new Date(); const scale = useMemo( () => timelineScale(tasks, zoom, today, timelineViewportWidth), [tasks, timelineViewportWidth, today, zoom], ); - const previousScale = useRef(scale); const rows = useMemo(() => groupTasks(tasks), [tasks]); - const timelineWidth = scale.days.length * scale.cellWidth; - const bodyHeight = rows.reduce( - (height, row) => - height + (row.kind === "group" ? GROUP_HEIGHT : taskHeight), - 0, + const timelineRows = useMemo( + () => + rows.map((row): TimelineRow => + row.kind === "group" + ? { id: row.id, title: row.label, group: true } + : { id: row.id, title: row.task.title }, + ), + [rows], ); - const scaleStartMinute = planningMinute(scale.start)!; - const currentMinute = planningMinute( - `${today}T${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:00`, - )!; - const todayX = minuteX( - scaleStartMinute, - scale.intraday ? currentMinute : planningMinute(today)!, - scale.cellWidth, + const tasksById = useMemo( + () => new Map(tasks.map((task) => [task.id, task])), + [tasks], ); - - useLayoutEffect(() => { - const surface = scroller.current; - if (!surface) return; - const updateWidth = () => { - const width = Math.max(0, surface.clientWidth - taskColumnWidth(surface)); - setTaskHeight(taskRowHeight(surface)); - setTimelineViewportWidth((current) => - Math.abs(current - width) < 1 ? current : width, - ); - }; - updateWidth(); - if (typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(updateWidth); - observer.observe(surface); - return () => observer.disconnect(); - }, [rows.length]); - - useLayoutEffect(() => { - const surface = scroller.current; - const previous = previousScale.current; - if (!surface) return; - if ( - previous.cellWidth !== scale.cellWidth || - previous.start !== scale.start - ) { - const taskColumn = taskColumnWidth(surface); - const pending = pendingZoomAnchor.current; - const viewportX = - pending?.viewportX ?? - taskColumn + (surface.clientWidth - taskColumn) / 2; - const anchorMinute = - pending?.minute ?? - planningMinute(previous.start)! + - ((surface.scrollLeft + viewportX - taskColumn) / previous.cellWidth) * - DAY_MINUTES; - surface.scrollLeft = Math.max( - 0, - taskColumn + - minuteX(planningMinute(scale.start)!, anchorMinute, scale.cellWidth) - - viewportX, - ); - } - pendingZoomAnchor.current = null; - previousScale.current = scale; - }, [scale]); - - useEffect(() => { - if (lastTodayRequest.current === todayRequest) return; - lastTodayRequest.current = todayRequest; - const surface = scroller.current; - if (!surface) return; - const taskColumn = taskColumnWidth(surface); - const targetX = taskColumn + (surface.clientWidth - taskColumn) / 2; - const left = Math.max(0, taskColumn + todayX - targetX); - surface.scrollTo({ - left, - behavior: todayRequest ? "smooth" : "auto", - }); - }, [todayRequest, todayX]); - - useEffect( - () => () => { - if (statusTimer.current !== null) - window.clearTimeout(statusTimer.current); - }, - [], + const items = useMemo( + () => + tasks.flatMap((task): CalendarItem[] => { + const schedule = taskTimelineItem(task, scale.snapMinutes); + if (!schedule) return []; + const color = task.completed ? "var(--success)" : "var(--accent)"; + return [ + { + id: task.id, + title: task.title, + start: schedule.start, + end: schedule.end, + allDay: schedule.allDay, + kind: schedule.milestone ? "milestone" : "task", + completed: task.completed, + color, + backgroundColor: color, + textColor: "var(--paper)", + dependsOn: dependencyTaskIds(task, allTasks), + }, + ]; + }), + [allTasks, scale.snapMinutes, tasks], ); function showMessage(message: InteractionMessage, duration = 2_200) { @@ -233,12 +195,16 @@ export function GanttChart({ ); } - async function commitSchedule(task: PlannerTask, update: ScheduleUpdate) { - if (mutationsDisabled) return; + async function commitSchedule( + task: PlannerTask, + update: ScheduleUpdate, + ): Promise { + if (mutationsDisabled) return false; showMessage({ tone: "neutral", text: "Saving schedule…" }, 10_000); try { await onScheduleChange(task, update); showMessage({ tone: "success", text: "Schedule saved" }); + return true; } catch (reason) { showMessage( { @@ -249,6 +215,7 @@ export function GanttChart({ }, 5_000, ); + return false; } } @@ -319,120 +286,115 @@ export function GanttChart({ } } - function beginScheduleDrag( - event: ReactPointerEvent, - task: PlannerTask, - kind: ScheduleDragKind, + /** Rejects when the write fails so the calendar restores the bar. */ + async function writeTimelineChange( + arg: ItemChangeArg, + kind: TimelineChangeKind, ) { - if (event.button !== 0) return; - const timeline = event.currentTarget - .closest(".gantt-row") - ?.querySelector(".timeline-row"); - if (!timeline) return; - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - scheduleDrag.current = { + const task = tasksById.get(arg.item.id); + if (!task) throw new Error("The task is no longer visible."); + const update = scheduleFromTimelineChange( task, kind, - originClientX: event.clientX, - timelineLeft: timeline.getBoundingClientRect().left, - update: { scheduled: task.scheduled, due: task.due }, - changed: false, - validPlacement: false, - }; - window.addEventListener("pointermove", moveWindowScheduleDrag); - window.addEventListener("pointerup", endWindowScheduleDrag); - window.addEventListener("pointercancel", cancelWindowScheduleDrag); + { start: arg.startStr, end: arg.endStr, allDay: arg.allDay }, + scale.snapMinutes, + ); + if (!(await commitSchedule(task, update))) + throw new Error("Schedule not saved."); } - function moveScheduleDrag(event: { clientX: number; clientY?: number }) { - const current = scheduleDrag.current; - if (!current) return; - const rawDelta = - ((event.clientX - current.originClientX) / scale.cellWidth) * DAY_MINUTES; - const step = - scale.intraday && taskHasTime(current.task) - ? scale.snapMinutes - : DAY_MINUTES; - const delta = Math.round(rawDelta / step) * step; - const moved = Math.abs(event.clientX - current.originClientX) >= 4; - let update = current.update; - let validPlacement = current.validPlacement; - if (current.kind === "move") - update = shiftedScheduleByMinutes(current.task, delta); - else if (current.kind === "place") { - const surface = scroller.current; - const bounds = surface?.getBoundingClientRect(); - const left = - bounds?.width && surface - ? Math.max( - current.timelineLeft, - bounds.left + taskColumnWidth(surface), - ) - : current.timelineLeft; - const right = bounds?.width - ? Math.min(current.timelineLeft + timelineWidth, bounds.right) - : current.timelineLeft + timelineWidth; - validPlacement = - event.clientX >= left && - event.clientX < right && - (event.clientY === undefined || - !bounds?.height || - (event.clientY >= bounds.top && event.clientY <= bounds.bottom)); - const offset = Math.max( - 0, - Math.min( - scale.days.length * DAY_MINUTES - scale.snapMinutes, - ((event.clientX - current.timelineLeft) / scale.cellWidth) * - DAY_MINUTES, - ), - ); - const snapped = - Math.floor(offset / scale.snapMinutes) * scale.snapMinutes; - update = { - scheduled: minuteToPlanningValue( - planningMinute(scale.start)! + snapped, - undefined, - scale.intraday, - ), - due: undefined, - }; - } else { - const span = taskTimelineSpan(current.task, step); - if (span) { - const edge = current.kind === "resize-start" ? "start" : "finish"; - const origin = edge === "start" ? span.startMinute : span.endMinute; - update = resizedScheduleAt(current.task, edge, origin + delta, step); - } + function linkTasks(fromId: string, toId: string) { + const allowed = canAddDependency(allTasks, fromId, toId); + if (!allowed.allowed) { + showMessage({ tone: "danger", text: allowed.reason }, 3_500); + return; } - scheduleDrag.current = { - ...current, - update, - changed: moved, - validPlacement, + const target = allTasks.find((task) => task.id === toId); + if (!target) return; + void commitDependencies( + target, + addOrReplaceDependency( + target.blockedBy, + fromId, + dependencyRelationship("finish", "start"), + allTasks, + ), + ); + } + + function beginPlacement( + event: ReactPointerEvent, + task: PlannerTask, + ) { + const calendar = calendarRef.current; + if (mutationsDisabled || event.button !== 0 || !calendar) return; + const origin = event.nativeEvent; + const doc = event.currentTarget.ownerDocument; + const touch = origin.pointerType === "touch"; + const source: ExternalDragSource = { + title: task.title, + durationMinutes: scale.intraday ? scale.snapMinutes : undefined, + color: "var(--accent)", + kind: "task", + data: task.id, + }; + let timer: number | null = null; + const stop = () => { + if (timer !== null) window.clearTimeout(timer); + doc.removeEventListener("pointermove", move); + doc.removeEventListener("pointerup", stop); + doc.removeEventListener("pointercancel", stop); + }; + const start = (from: PointerEvent) => { + stop(); + placementClick.current = true; + doc.addEventListener( + "pointerup", + () => window.setTimeout(() => (placementClick.current = false), 0), + { once: true }, + ); + calendar.beginExternalDrag(from, source); }; - if (moved) { - setSchedulePreview( - current.kind !== "place" || validPlacement - ? { taskId: current.task.id, update } - : null, + // Mouse placement starts after a small movement so a click still selects; + // touch placement needs a long press so the ledger keeps scrolling. + function move(next: PointerEvent) { + if (next.pointerId !== origin.pointerId) return; + const distance = Math.hypot( + next.clientX - origin.clientX, + next.clientY - origin.clientY, ); + if (touch) { + if (distance > 8) stop(); + } else if (distance >= 4) start(next); } + doc.addEventListener("pointermove", move); + doc.addEventListener("pointerup", stop); + doc.addEventListener("pointercancel", stop); + if (touch) + timer = window.setTimeout(() => { + navigator.vibrate?.(10); + start(origin); + }, 350); } - function endScheduleDrag() { - const current = scheduleDrag.current; - if (!current) return; - removeWindowScheduleListeners(); - scheduleDrag.current = null; - setSchedulePreview(null); - if (!current.changed) return; - suppressClick.current = true; - window.setTimeout(() => { - suppressClick.current = false; - }, 0); - if (current.kind === "place" && !current.validPlacement) { + function placeTask( + taskId: string | undefined, + start: string, + allDay: boolean, + pointer: MouseEvent | KeyboardEvent | null, + ) { + const task = taskId ? tasksById.get(taskId) : undefined; + const surface = host.current; + if (!task || !surface) return; + const bounds = surface.getBoundingClientRect(); + const inside = + pointer && + "clientX" in pointer && + pointer.clientX >= bounds.left + labelWidth && + pointer.clientX < bounds.right && + pointer.clientY >= bounds.top && + pointer.clientY < bounds.bottom; + if (!inside) { showMessage( { tone: "neutral", @@ -442,263 +404,218 @@ export function GanttChart({ ); return; } - void commitSchedule(current.task, current.update); - } - - function cancelScheduleDrag() { - removeWindowScheduleListeners(); - scheduleDrag.current = null; - setSchedulePreview(null); - } - - function moveWindowScheduleDrag(event: PointerEvent) { - moveScheduleDrag(event); - } - - function endWindowScheduleDrag() { - endScheduleDrag(); - } - - function cancelWindowScheduleDrag() { - cancelScheduleDrag(); + void commitSchedule(task, placedSchedule(start, allDay)); } - function removeWindowScheduleListeners() { - window.removeEventListener("pointermove", moveWindowScheduleDrag); - window.removeEventListener("pointerup", endWindowScheduleDrag); - window.removeEventListener("pointercancel", cancelWindowScheduleDrag); - } - - function nudgeSchedule( - task: PlannerTask, - kind: Exclude, - direction: number, - ) { + function itemLabel(task: PlannerTask): string { + if (mutationsDisabled) + return `${task.title}, ${scheduleLabel(task)}. Select to review.`; const step = - scale.intraday && taskHasTime(task) ? scale.snapMinutes : DAY_MINUTES; - const span = taskTimelineSpan(task, step); - if (!span) return; - const update = - kind === "move" - ? shiftedScheduleByMinutes(task, direction * step) - : resizedScheduleAt( - task, - kind === "resize-start" ? "start" : "finish", - (kind === "resize-start" ? span.startMinute : span.endMinute) + - direction * step, - step, - ); - void commitSchedule(task, update); + scale.intraday && taskHasTime(task) + ? scale.snapMinutes === 15 + ? "15 minutes" + : "one hour" + : "one day"; + return `${task.title}, ${scheduleLabel(task)}. Drag to move; Alt plus arrow keys move by ${step}; Alt plus Shift plus arrow keys change the end.`; } - function beginLink( - event: ReactPointerEvent, - task: PlannerTask, - edge: "start" | "finish", - ) { - if (event.button !== 0 || !body.current) return; - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - const port = event.currentTarget.getBoundingClientRect(); - const coordinates = timelineCoordinates( - port.left + port.width / 2, - port.top + port.height / 2, - ); - if (!coordinates) return; - const next: LinkDrag = { - sourceId: task.id, - sourceEdge: edge, - startX: coordinates.x, - startY: coordinates.y, - currentX: coordinates.x, - currentY: coordinates.y, - }; - activeLink.current = next; - setLinkDrag(next); - } + const options: Partial> = { + view: "timeline", + date: scale.start, + toolbar: false, + timeZone: "local", + height: "fill", + editable: !mutationsDisabled, + selectable: false, + navLinks: false, + timeline: { + rows: timelineRows, + rowOf: (item) => item.id, + scale: calendarScale(scale), + days: scale.days.length, + leadDays: 0, + labelWidth, + rowHeight, + dependencies: true, + rowContent: (row, element) => { + element.classList.add("gantt-ledger-host"); + setLabelHosts((current) => + current.get(row.id) === element + ? current + : new Map(current).set(row.id, element), + ); + }, + onLink: mutationsDisabled + ? undefined + : ({ from, to }) => linkTasks(from.id, to.id), + }, + onItemClick: ({ item }) => { + const task = tasksById.get(item.id); + if (task) onSelect(task); + }, + onItemDrop: (arg) => writeTimelineChange(arg, "move"), + onItemResize: (arg) => + writeTimelineChange( + arg, + arg.start.getTime() !== arg.oldStart.getTime() + ? "resize-start" + : "resize-finish", + ), + onExternalDrop: (arg) => + placeTask(arg.source?.data, arg.startStr, arg.allDay, arg.jsEvent), + itemDidMount: ({ item, el, isMirror }) => { + const task = tasksById.get(item.id); + if (!task || isMirror) return; + el.setAttribute("aria-label", itemLabel(task)); + el.classList.toggle("is-selected", item.id === selectedId); + }, + }; - function moveLink(event: ReactPointerEvent) { - const current = activeLink.current; - if (!current) return; - const coordinates = timelineCoordinates(event.clientX, event.clientY); - if (!coordinates) return; - const port = document - .elementFromPoint(event.clientX, event.clientY) - ?.closest(".link-port[data-task-id]"); - const targetId = port?.dataset.taskId; - const targetEdge = port?.dataset.edge as "start" | "finish" | undefined; - const allowed = targetId - ? canAddDependency(allTasks, current.sourceId, targetId) - : null; - const next: LinkDrag = { - ...current, - currentX: coordinates.x, - currentY: coordinates.y, - targetId, - targetEdge, - invalidReason: allowed && !allowed.allowed ? allowed.reason : undefined, + useLayoutEffect(() => { + const surface = host.current; + if (!surface) return; + const calendar = new Calendar(surface, { + view: "timeline", + toolbar: false, + }); + calendarRef.current = calendar; + const wheel = (event: WheelEvent) => wheelHandler.current?.(event); + surface.addEventListener("wheel", wheel, { passive: false }); + const updateWidth = () => { + const width = Math.max(0, surface.clientWidth - labelWidthOf(surface)); + setTimelineViewportWidth((current) => + Math.abs(current - width) < 1 ? current : width, + ); }; - activeLink.current = next; - setLinkDrag(next); - } + updateWidth(); + const observer = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(updateWidth); + observer?.observe(surface); + return () => { + observer?.disconnect(); + surface.removeEventListener("wheel", wheel); + calendar.destroy(); + calendarRef.current = null; + previousScale.current = null; + lastTodayRequest.current = null; + }; + }, []); - function endLink(event: ReactPointerEvent) { - const current = activeLink.current; - if (event.currentTarget.hasPointerCapture(event.pointerId)) - event.currentTarget.releasePointerCapture(event.pointerId); - activeLink.current = null; - setLinkDrag(null); - if (!current?.targetId || !current.targetEdge || current.invalidReason) { - if (current?.invalidReason) - showMessage({ tone: "danger", text: current.invalidReason }, 3_500); - return; + useLayoutEffect(() => { + const calendar = calendarRef.current; + const surface = host.current; + if (!calendar || !surface) return; + const scroll = calendar.getScroll(); + const previous = previousScale.current; + let anchor = pendingZoomAnchor.current; + pendingZoomAnchor.current = null; + const rescaled = + previous && + (previous.scale.start !== scale.start || + previous.scale.cellWidth !== scale.cellWidth || + previous.labelWidth !== labelWidth); + if (rescaled && !anchor) { + const viewportX = + previous.labelWidth + (surface.clientWidth - previous.labelWidth) / 2; + anchor = { + viewportX, + minute: + planningMinute(previous.scale.start)! + + ((scroll.left + viewportX - previous.labelWidth) / + previous.scale.cellWidth) * + DAY_MINUTES, + }; } - const target = allTasks.find((task) => task.id === current.targetId); - if (!target) return; - void commitDependencies( - target, - addOrReplaceDependency( - target.blockedBy, - current.sourceId, - dependencyRelationship(current.sourceEdge, current.targetEdge), - allTasks, - ), - ); - } - - function cancelLink() { - activeLink.current = null; - setLinkDrag(null); - } - - function timelineCoordinates(clientX: number, clientY: number) { - const bodyElement = body.current; - const surface = scroller.current; - if (!bodyElement || !surface) return null; - const bounds = bodyElement.getBoundingClientRect(); - return { - x: Math.max( - 0, - Math.min( - timelineWidth, - clientX - bounds.left - taskColumnWidth(surface), + previousScale.current = { scale, labelWidth }; + + calendar.setOptions(options); + calendar.setItems(items); + calendar.flush(); + + const startMinute = planningMinute(scale.start)!; + const xOf = (minute: number) => + labelWidth + ((minute - startMinute) / DAY_MINUTES) * scale.cellWidth; + if (rescaled && anchor) + calendar.setScroll({ + top: scroll.top, + left: Math.max(0, xOf(anchor.minute) - anchor.viewportX), + }); + if (lastTodayRequest.current !== todayRequest) { + lastTodayRequest.current = todayRequest; + const now = new Date(); + const todayMinute = scale.intraday + ? planningMinute(today)! + now.getHours() * 60 + now.getMinutes() + : planningMinute(today)!; + calendar.setScroll({ + top: calendar.getScroll().top, + left: Math.max( + 0, + xOf(todayMinute) - + (labelWidth + (surface.clientWidth - labelWidth) / 2), ), - ), - y: Math.max(0, Math.min(bodyHeight, clientY - bounds.top)), - }; - } + }); + } + + for (const element of surface.querySelectorAll(".tnc-item.is-selected")) + element.classList.remove("is-selected"); + if (selectedId) + for (const element of calendar.elementsFor(selectedId)) + element.classList.add("is-selected"); - const handleWheel = useCallback( - (event: WheelEvent) => { + wheelHandler.current = (event) => { if (!event.ctrlKey && !event.metaKey) return; event.preventDefault(); - const surface = scroller.current; - if (!surface || event.deltaY === 0) return; - const taskColumn = taskColumnWidth(surface); + if (event.deltaY === 0) return; const bounds = surface.getBoundingClientRect(); - const viewportX = Math.max(taskColumn, event.clientX - bounds.left); - const timelineX = surface.scrollLeft + viewportX - taskColumn; + const viewportX = Math.max(labelWidth, event.clientX - bounds.left); pendingZoomAnchor.current = { - minute: - planningMinute(scale.start)! + - (timelineX / scale.cellWidth) * DAY_MINUTES, viewportX, + minute: + startMinute + + ((calendar.getScroll().left + viewportX - labelWidth) / + scale.cellWidth) * + DAY_MINUTES, }; onZoom(event.deltaY < 0 ? 1 : -1); + }; + }); + + useEffect( + () => () => { + if (statusTimer.current !== null) + window.clearTimeout(statusTimer.current); }, - [onZoom, scale.cellWidth, scale.start], + [], ); - useEffect(() => { - const surface = scroller.current; - if (!surface) return; - surface.addEventListener("wheel", handleWheel, { passive: false }); - return () => surface.removeEventListener("wheel", handleWheel); - }, [handleWheel, rows.length]); - - if (!rows.length) - return ( -
-

No tasks match these controls

-

Change the saved view, search, or task filters.

-
- ); - return ( -
-
-
-
- Task - Schedule -
- -
- {rows.map((row) => - row.kind === "group" ? ( - - ) : ( - - ), - )} - +
+ {rows.map((row) => { + const element = labelHosts.get(row.id); + if (!element) return null; + return createPortal( + row.kind === "group" ? ( + + ) : ( + { + if (!placementClick.current) onSelect(task); + }} /> - {todayX >= 0 && todayX <= timelineWidth ? ( -
- {scale.intraday ? "Now" : "Today"} -
- ) : null} -
-
-
+ ), + element, + row.id, + ); + })} {interactionMessage ? (

{interactionMessage.text}

- ) : linkDrag ? ( -

- {linkDrag.invalidReason ?? - "Drop on a task edge to set the relationship"} -

) : null}
); } -function TimelineHeader({ - scale, - width, -}: { - scale: ReturnType; - width: number; -}) { - if (scale.intraday) { - const labelHours = scale.cellWidth >= 500 ? 3 : 6; - return ( - - ); - } - const months = segmentDates(scale.days, (date) => date.slice(0, 7)); - const weekLabels = scale.days.flatMap((date, index) => - new Date(`${date}T00:00:00Z`).getUTCDay() === 1 ? [{ date, index }] : [], - ); +function GroupLedger({ row }: { row: Extract }) { return ( -