diff --git a/README.md b/README.md index ae662e2..a7be51d 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. @@ -166,5 +168,5 @@ src/components React application and Gantt surface src/data mdbase connection and repository adapters src/domain task and timeline semantics public web and mdbase application manifests -vendor pinned TaskNotes model and mdbase Connect packages +vendor pinned development mdbase packages and contract packs ``` 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..4c2554e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "@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/model": "file:vendor/tasknotes-model-0.3.0-rc.15.tgz", + "@tasknotes/calendar": "0.1.0", + "@tasknotes/model": "0.3.0-rc.17", "lucide-react": "1.25.0", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9059f4f..11054e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,9 +26,12 @@ 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: 0.1.0 + version: 0.1.0(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 + specifier: 0.3.0-rc.17 + version: 0.3.0-rc.17 lucide-react: specifier: 1.25.0 version: 1.25.0(react@19.2.8) @@ -732,13 +735,23 @@ packages: integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, } - "@tasknotes/model@file:vendor/tasknotes-model-0.3.0-rc.15.tgz": + "@tasknotes/calendar@0.1.0": resolution: { - integrity: sha512-tsQ/Gmhi12QfKlrgJG5UEVQhbDAJz2Q1tu+UzVlsE3hDCEenZ09ByehbKjmFa75Epvr0bMOhzc8POa6NULhFgw==, - tarball: file:vendor/tasknotes-model-0.3.0-rc.15.tgz, + integrity: sha512-XT/V+eyfQ3ULun1LhcwRho33xRhg8QYrybzQuQWH9Csw5ZBH8OG3nADXk3WPMG07zrFPTTBPlE/xX5JZ9ibdbw==, + } + engines: { node: ">=22" } + peerDependencies: + react: ">=18" + peerDependenciesMeta: + react: + optional: true + + "@tasknotes/model@0.3.0-rc.17": + resolution: + { + integrity: sha512-yg5lLo/5/OO270cIMd2uyOcSVad1/WKBO4WFG53TFFaQfKltxb3D05dy2u88D7ePbn1bclYbDRTQNqZWqT5qDA==, } - version: 0.3.0-rc.15 "@testing-library/dom@10.4.1": resolution: @@ -2694,7 +2707,11 @@ snapshots: "@standard-schema/spec@1.1.0": {} - "@tasknotes/model@file:vendor/tasknotes-model-0.3.0-rc.15.tgz": + "@tasknotes/calendar@0.1.0(react@19.2.8)": + optionalDependencies: + react: 19.2.8 + + "@tasknotes/model@0.3.0-rc.17": dependencies: rrule: 2.8.1 yaml: 2.8.2 diff --git a/src/components/gantt-chart.test.tsx b/src/components/gantt-chart.test.tsx index cb4cf88..b1e3cf9 100644 --- a/src/components/gantt-chart.test.tsx +++ b/src/components/gantt-chart.test.tsx @@ -28,63 +28,111 @@ beforeEach(() => { HTMLElement.prototype.hasPointerCapture = vi.fn(() => true); HTMLElement.prototype.releasePointerCapture = vi.fn(); HTMLElement.prototype.scrollTo = vi.fn(); + // jsdom has no layout; give the calendar's scroller a viewport so its hit + // testing (which ignores points outside the chart) has bounds. + const rect = Element.prototype.getBoundingClientRect; + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation( + function (this: Element) { + return this.classList.contains("tnc-tl-scroll") + ? ({ + left: 0, + top: 0, + right: 1200, + bottom: 900, + width: 1200, + height: 900, + } as DOMRect) + : rect.call(this); + }, + ); }); +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,342 +143,122 @@ 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} - />, - ); - - const surface = document.querySelector(".gantt-scroll")!; - const event = new WheelEvent("wheel", { - bubbles: true, - cancelable: true, - deltaY: 100, + 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", }); - surface.dispatchEvent(event); - - expect(event.defaultPrevented).toBe(false); - expect(onZoom).not.toHaveBeenCalled(); - }); + const { onScheduleChange } = renderChart([task], { zoom: 7 }); - 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 }, - ])( - "handles unscheduled title drops over the $target", - async ({ clientX, clientY, 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({ - left: 0, - top: 0, - right: 1200, - bottom: 900, - 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 }); - 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}$/), - }, - ]); - } else { - expect(onScheduleChange).not.toHaveBeenCalled(); - expect(screen.getByRole("status")).toHaveTextContent( - "Schedule unchanged. Drop over a date in the timeline to schedule this task.", - ); - } - }, - ); + // 576px per day: 24px is one hour. + drag(bar("timed"), { x: 600, y: TASK_ROW_Y }, { x: 624, y: TASK_ROW_Y }); - 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} - />, + await waitFor(() => + expect(onScheduleChange).toHaveBeenCalledWith(task, { + scheduled: "2026-08-17T10:30:00", + due: "2026-08-17T11:30:00Z", + }), ); - 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.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("resizes an all-day finish to an inclusive due date", async () => { + const task = makeTask({ + id: "span", + scheduled: "2026-08-17", + due: "2026-08-18", }); - }); - - 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 { 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 }, ); - 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$/, - ), - }); + await waitFor(() => + expect(onScheduleChange).toHaveBeenCalledWith(task, { + scheduled: "2026-08-17", + due: "2026-08-20", + }), + ); }); - it("draws an edge relationship and stores it on the target", async () => { + it("creates a finish-to-start relationship by linking two bars", async () => { const source = makeTask({ id: "source", title: "Source", @@ -443,49 +271,17 @@ describe("GanttChart interactions", () => { 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", - }); + const { onDependenciesChange } = renderChart([source, target]); Object.defineProperty(document, "elementFromPoint", { configurable: true, - value: vi.fn(() => targetHandle), + value: vi.fn(() => bar("target").querySelector(".tnc-port-start")), }); - 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, - }); + drag( + bar("source").querySelector(".tnc-port-finish")!, + { x: 600, y: TASK_ROW_Y }, + { x: 650, y: TASK_ROW_Y + 49 }, + ); await waitFor(() => expect(onDependenciesChange).toHaveBeenCalledOnce()); expect(onDependenciesChange).toHaveBeenCalledWith(target, [ @@ -493,44 +289,46 @@ describe("GanttChart interactions", () => { ]); }); - 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} - />, - ); + it.each([ + { target: "timeline", clientX: 420, saves: true }, + { target: "task ledger", clientX: 120, saves: false }, + ])( + "handles unscheduled title drops over the $target", + async ({ clientX, saves }) => { + const task = makeTask({ id: "title-drag", title: "Unscheduled title" }); + const { onScheduleChange, onSelect } = renderChart([task]); + const title = screen.getByRole("button", { + name: /Unscheduled title, not scheduled. Select to edit or drag/, + }); + 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).toHaveBeenCalledWith(task, { + due: undefined, + scheduled: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + }); + } else { + expect(onScheduleChange).not.toHaveBeenCalled(); + expect(screen.getByRole("status")).toHaveTextContent( + "Schedule unchanged. Drop over a date in the timeline to schedule this task.", + ); + } + }, + ); - const bar = screen.getByRole("button", { name: /Timed task, .*09:30/ }); - fireEvent.pointerDown(bar, { - button: 0, - clientX: 600, - pointerId: 3, + it("keeps an unscheduled title click as task selection", () => { + const task = makeTask({ id: "title-click", title: "Unscheduled title" }); + const { onSelect, onScheduleChange } = renderChart([task]); + const title = screen.getByRole("button", { + name: /Unscheduled title, not scheduled/, }); - 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", - }), - ); + fireEvent.pointerDown(title, { button: 0, clientX: 160, pointerId: 1 }); + fireEvent.pointerUp(document, { clientX: 160, pointerId: 1 }); + fireEvent.click(title); + expect(onSelect).toHaveBeenCalledWith(task); + expect(onScheduleChange).not.toHaveBeenCalled(); }); it("completes a task without opening its inspector", async () => { diff --git a/src/components/gantt-chart.tsx b/src/components/gantt-chart.tsx index 4cbe751..5277c3a 100644 --- a/src/components/gantt-chart.tsx +++ b/src/components/gantt-chart.tsx @@ -1,40 +1,46 @@ -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, + LinkDragArg, + TimelineAnchor, + TimelineRow, + TimelineScale as CalendarTimelineScale, +} from "@tasknotes/calendar"; +import type { PointerEvent as ReactPointerEvent } from "react"; import { addOrReplaceDependency, canAddDependency, - DAY_MINUTES, dateFromDate, dateLabel, dependencyRelationship, groupTasks, hasInvertedSchedule, - minuteToPlanningValue, - planningMinute, - resolveDependencyTask, - resizedScheduleAt, - shiftedScheduleByMinutes, + placedSchedule, + scheduleFromTimelineChange, taskHasTime, taskSpan, - taskTimelineSpan, + taskTimelineItem, + timelineDependencies, timelineScale, - withSchedule, type GanttRow, + type TimelineChangeKind, + type TimelineScale, type TimelineZoom, } from "../domain/gantt"; import { errorMessage, isOperationOutcomeUnknown } from "../data/outcome"; @@ -45,57 +51,21 @@ import type { TaskDependency, } from "../domain/task"; -const TASK_COLUMN = 350; -const GROUP_HEIGHT = 35; -const TASK_HEIGHT = 49; - -type ScheduleDragKind = "move" | "resize-start" | "resize-finish" | "place"; - -interface ScheduleDrag { - task: PlannerTask; - kind: ScheduleDragKind; - originClientX: number; - timelineLeft: number; - update: ScheduleUpdate; - changed: boolean; - validPlacement: boolean; -} - -interface SchedulePreview { - taskId: string; - update: ScheduleUpdate; -} +/** Task id carried by an unscheduled task while it is dragged onto the timeline. */ +type PlacementData = string; -interface LinkDrag { - sourceId: string; - sourceEdge: "start" | "finish"; - startX: number; - startY: number; - currentX: number; - currentY: number; - targetId?: string; - targetEdge?: "start" | "finish"; - invalidReason?: string; -} +const PHONE_QUERY = "(max-width: 560px)"; +const LEDGER = { + wide: { labelWidth: 350, rowHeight: 49 }, + phone: { labelWidth: 280, rowHeight: 65 }, +} as const; 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 +80,93 @@ 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); - const pendingZoomAnchor = useRef<{ - minute: number; - viewportX: number; - } | null>(null); - const statusTimer = useRef(null); +} + +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); + // The pointer position of a Ctrl/Cmd+wheel zoom, kept in place on rescale. + const pendingZoomAnchor = useRef(null); const lastTodayRequest = useRef(null); - const [schedulePreview, setSchedulePreview] = - useState(null); - const [linkDrag, setLinkDrag] = useState(null); + const statusTimer = useRef(null); + 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: timelineDependencies(task, allTasks), + }, + ]; + }), + [allTasks, scale.snapMinutes, tasks], ); function showMessage(message: InteractionMessage, duration = 2_200) { @@ -233,12 +178,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 +198,7 @@ export function GanttChart({ }, 5_000, ); + return false; } } @@ -319,386 +269,308 @@ 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); - } - } - scheduleDrag.current = { - ...current, - update, - changed: moved, - validPlacement, - }; - if (moved) { - setSchedulePreview( - current.kind !== "place" || validPlacement - ? { taskId: current.task.id, update } - : null, - ); - } + function linkTasks( + fromId: string, + toId: string, + fromEdge: "start" | "finish", + toEdge: "start" | "finish", + ) { + const target = allTasks.find((task) => task.id === toId); + if (!target) return; + void commitDependencies( + target, + addOrReplaceDependency( + target.blockedBy, + fromId, + dependencyRelationship(fromEdge, toEdge), + allTasks, + ), + ); } - 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) { - showMessage( - { - tone: "neutral", - text: "Schedule unchanged. Drop over a date in the timeline to schedule this task.", - }, - 4_500, - ); + /** Live guidance while a relationship handle is dragged. */ + function showLinkDrag(drag: LinkDragArg) { + if (drag.phase === "end") { + if (drag.to && !drag.valid && drag.reason) + showMessage({ tone: "danger", text: drag.reason }, 3_500); + else if (!drag.linked) setInteractionMessage(null); 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(); - } - - function removeWindowScheduleListeners() { - window.removeEventListener("pointermove", moveWindowScheduleDrag); - window.removeEventListener("pointerup", endWindowScheduleDrag); - window.removeEventListener("pointercancel", cancelWindowScheduleDrag); - } - - function nudgeSchedule( - task: PlannerTask, - kind: Exclude, - direction: number, - ) { - 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); + if (statusTimer.current !== null) window.clearTimeout(statusTimer.current); + setInteractionMessage( + drag.to && !drag.valid && drag.reason + ? { tone: "danger", text: drag.reason } + : { + tone: "neutral", + text: "Drop on a task edge to set the relationship", + }, + ); } - function beginLink( - event: ReactPointerEvent, + function beginPlacement( + 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, + 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, }; - activeLink.current = next; - setLinkDrag(next); - } - - 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, + 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(); + calendar.beginExternalDrag(from, source); }; - activeLink.current = next; - setLinkDrag(next); + // 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 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; - } - 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, - ), - ); + /** The calendar only drops on task rows, headings and the date header. */ + function placeTask( + taskId: string | undefined, + start: string, + allDay: boolean, + ) { + const task = taskId ? tasksById.get(taskId) : undefined; + if (task) void commitSchedule(task, placedSchedule(start, allDay)); } - function cancelLink() { - activeLink.current = null; - setLinkDrag(null); + function itemLabel(task: PlannerTask): string { + if (mutationsDisabled) + return `${task.title}, ${scheduleLabel(task)}. Select to review.`; + const step = + 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}.`; } - 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), - ), + const options: Partial> = { + view: "timeline", + date: scale.start, + toolbar: false, + timeZone: "local", + height: "fill", + editable: !mutationsDisabled, + selectable: false, + navLinks: false, + selected: selectedId, + timeline: { + rows: timelineRows, + rowOf: (item) => item.id, + scale: calendarScale(scale), + days: scale.days.length, + leadDays: 0, + labelWidth, + rowHeight, + dependencies: true, + todayLabel: scale.intraday ? "Now" : "Today", + rowLabelDidMount: ({ row, el }) => { + el.classList.add("gantt-ledger-host"); + setLabelHosts((current) => + current.get(row.id) === el + ? current + : new Map(current).set(row.id, el), + ); + }, + rowLabelWillUnmount: ({ row, el }) => + setLabelHosts((current) => { + if (current.get(row.id) !== el) return current; + const next = new Map(current); + next.delete(row.id); + return next; + }), + resizeHandleLabel: mutationsDisabled + ? undefined + : ({ item, edge }) => + `Change ${item.title} ${edge === "start" ? "scheduled" : "due"} date`, + onLink: mutationsDisabled + ? undefined + : ({ from, to, fromEdge, toEdge }) => + linkTasks(from.id, to.id, fromEdge, toEdge), + canLink: ({ from, to }) => { + const allowed = canAddDependency(allTasks, from.id, to.id); + return allowed.allowed || allowed.reason; + }, + onLinkDrag: showLinkDrag, + }, + 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", ), - y: Math.max(0, Math.min(bodyHeight, clientY - bounds.top)), + onExternalDrop: (arg) => + placeTask(arg.source?.data, arg.startStr, arg.allDay), + onExternalDragEnd: ({ dropped }) => { + if (!dropped) + showMessage( + { + tone: "neutral", + text: "Schedule unchanged. Drop over a date in the timeline to schedule this task.", + }, + 4_500, + ); + }, + itemDidMount: ({ item, el, isMirror }) => { + const task = tasksById.get(item.id); + if (!task || isMirror) return; + el.setAttribute("aria-label", itemLabel(task)); + }, + }; + + 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, + ); }; - } + 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; + lastTodayRequest.current = null; + }; + }, []); - const handleWheel = useCallback( - (event: WheelEvent) => { + useLayoutEffect(() => { + const calendar = calendarRef.current; + const surface = host.current; + if (!calendar || !surface) return; + // The calendar keeps the visible dates when the scale or range changes; + // a wheel zoom keeps the date under the pointer instead. + const anchor = pendingZoomAnchor.current; + pendingZoomAnchor.current = null; + calendar.setOptions(options, anchor ? { anchor } : undefined); + calendar.setItems(items); + calendar.flush(); + + if (lastTodayRequest.current !== todayRequest) { + const first = lastTodayRequest.current === null; + lastTodayRequest.current = todayRequest; + calendar.scrollToDate(scale.intraday ? new Date() : today, { + align: "center", + smooth: !first && todayRequest > 0, + }); + } + + 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; pendingZoomAnchor.current = { - minute: - planningMinute(scale.start)! + - (timelineX / scale.cellWidth) * DAY_MINUTES, - viewportX, + clientX: Math.max(bounds.left + labelWidth, event.clientX), }; 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" ? ( + + ) : ( + - {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 }] : [], - ); - return ( - - ); -} - -function GroupRow({ - row, - timelineWidth, - scale, -}: { - row: Extract; - timelineWidth: number; - scale: ReturnType; -}) { +function GroupLedger({ row }: { row: Extract }) { return ( -
-
- {row.label} - {row.count} -
-
+
+ {row.label} + {row.count}
); } -function TaskRow({ - row, - timelineWidth, - taskHeight, - scale, +function TaskLedger({ + task, selected, completionPending, - preview, - linkDrag, mutationsDisabled, - suppressClickRef, onSelect, onCompletionChange, - onBeginScheduleDrag, - onMoveScheduleDrag, - onEndScheduleDrag, - onCancelScheduleDrag, - onNudge, - onBeginLink, - onMoveLink, - onEndLink, - onCancelLink, + onBeginPlacement, }: { - row: Extract; - timelineWidth: number; - taskHeight: number; - scale: ReturnType; + task: PlannerTask; selected: boolean; completionPending: boolean; - preview?: ScheduleUpdate; - linkDrag: LinkDrag | null; mutationsDisabled: boolean; - suppressClickRef: MutableRefObject; onSelect(task: PlannerTask): void; - onCompletionChange(task: PlannerTask): void; - onBeginScheduleDrag( + onCompletionChange(task: PlannerTask): Promise; + onBeginPlacement( event: ReactPointerEvent, task: PlannerTask, - kind: ScheduleDragKind, - ): void; - onMoveScheduleDrag(event: { clientX: number }): void; - onEndScheduleDrag(): void; - onCancelScheduleDrag(): void; - onNudge( - task: PlannerTask, - kind: Exclude, - direction: number, - ): void; - onBeginLink( - event: ReactPointerEvent, - task: PlannerTask, - edge: "start" | "finish", ): void; - onMoveLink(event: ReactPointerEvent): void; - onEndLink(event: ReactPointerEvent): void; - onCancelLink(): void; }) { - const task = preview ? withSchedule(row.task, preview) : row.task; const inverted = hasInvertedSchedule(task); - const existingSpan = taskTimelineSpan(row.task, scale.snapMinutes); - const span = taskTimelineSpan(task, scale.snapMinutes); - const scaleStartMinute = planningMinute(scale.start)!; - const startX = span - ? minuteX(scaleStartMinute, span.startMinute, scale.cellWidth) - : 0; - const width = span - ? Math.max( - (scale.snapMinutes / DAY_MINUTES) * scale.cellWidth, - minuteX(span.startMinute, span.endMinute, scale.cellWidth), - ) - : 0; - - function select() { - if (suppressClickRef.current) { - suppressClickRef.current = false; - return; - } - onSelect(row.task); - } - - function keyboardMove( - event: ReactKeyboardEvent, - kind: Exclude, - ) { - if (mutationsDisabled) return; - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; - if (kind === "move" && !event.altKey) return; - event.preventDefault(); - onNudge(row.task, kind, event.key === "ArrowLeft" ? -1 : 1); - } - + const placeable = !taskSpan(task) && !mutationsDisabled; return ( -
-
- - + -
-
onSelect(task)} + onPointerDown={ + placeable ? (event) => onBeginPlacement(event, task) : undefined + } > - {existingSpan && span ? ( -
+ {task.title} + - - {!span.milestone ? ( - <> -
- ) : ( - <> - - {span ? ( - + {inverted ? "Scheduled after due" : scheduleLabel(task)} + + + + + + + {task.blockedBy.length ? ( + + ) : null} +
); } -function LinkPort({ - task, - edge, - linkDrag, - mutationsDisabled, - onBegin, - onMove, - onEnd, - onCancel, -}: { - task: PlannerTask; - edge: "start" | "finish"; - linkDrag: LinkDrag | null; - mutationsDisabled: boolean; - onBegin( - event: ReactPointerEvent, - task: PlannerTask, - edge: "start" | "finish", - ): void; - onMove(event: ReactPointerEvent): void; - onEnd(event: ReactPointerEvent): void; - onCancel(): void; -}) { - const targeted = - linkDrag?.targetId === task.id && linkDrag.targetEdge === edge; - return ( -