diff --git a/package.json b/package.json index 8f4d259..9b83674 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dependencies": { "@fontsource/atkinson-hyperlegible": "5.3.0", "@fontsource/azeret-mono": "5.3.0", - "@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.69-90334b9c4f6d.tgz", + "@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz", "@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.11.tgz", "lucide-react": "1.25.0", "react": "19.2.8", @@ -55,7 +55,7 @@ }, "pnpm": { "overrides": { - "@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.69-90334b9c4f6d.tgz" + "@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e6f88a..e0c6137 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.69-90334b9c4f6d.tgz + "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz importers: .: @@ -17,8 +17,8 @@ importers: specifier: 5.3.0 version: 5.3.0 "@mdbase-dev/connect": - specifier: file:vendor/mdbase-dev-connect-0.1.0-beta.69-90334b9c4f6d.tgz - version: file:vendor/mdbase-dev-connect-0.1.0-beta.69-90334b9c4f6d.tgz + specifier: file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz + version: file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz "@tasknotes/model": specifier: file:vendor/tasknotes-model-0.3.0-rc.11.tgz version: file:vendor/tasknotes-model-0.3.0-rc.11.tgz @@ -478,21 +478,21 @@ packages: integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, } - "@mdbase-dev/connect-protocol@file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.69-90334b9c4f6d.tgz": + "@mdbase-dev/connect-protocol@file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz": resolution: { - integrity: sha512-BDs378h5o7+bFcMc+u3cLAuk8zfjF333ZX9NsjSmZ39EasQQ3AqjG0Hgf79MezuSHczqskXUhgL5hqHZQ9ivsw==, - tarball: file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.69-90334b9c4f6d.tgz, + integrity: sha512-+lXIWlLhItrHXiVHUfs4DIuo7U0h1Z7GByha9lyAoNDe+TDi9RY0x+koUTTkwKvn7AqjWRVAxfLqpdetiK7Thg==, + tarball: file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz, } - version: 0.1.0-beta.69 + version: 0.1.0-beta.85 - "@mdbase-dev/connect@file:vendor/mdbase-dev-connect-0.1.0-beta.69-90334b9c4f6d.tgz": + "@mdbase-dev/connect@file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz": resolution: { - integrity: sha512-eodTZfuRsLuwRVfTT4/kSwJr1jWxKoY/7cBEKwoM9kIrI8/sGh+f15Fb1aFB8suCV11qVybJobl1EznvS/9/GQ==, - tarball: file:vendor/mdbase-dev-connect-0.1.0-beta.69-90334b9c4f6d.tgz, + integrity: sha512-4KvCC9UGfAdfOKFFKIrQPFNq5w/14bktuPbGB3EJlKnYsP1V+qVxpyHt9SzpgqSyTaRLpigCTxTixZrT/aW0Yg==, + tarball: file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz, } - version: 0.1.0-beta.69 + version: 0.1.0-beta.85 "@napi-rs/wasm-runtime@1.2.3": resolution: @@ -2514,14 +2514,14 @@ snapshots: "@jridgewell/resolve-uri": 3.1.2 "@jridgewell/sourcemap-codec": 1.5.5 - "@mdbase-dev/connect-protocol@file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.69-90334b9c4f6d.tgz": + "@mdbase-dev/connect-protocol@file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz": dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) - "@mdbase-dev/connect@file:vendor/mdbase-dev-connect-0.1.0-beta.69-90334b9c4f6d.tgz": + "@mdbase-dev/connect@file:vendor/mdbase-dev-connect-0.1.0-beta.85-61daa25dfbb6.tgz": dependencies: - "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.69-90334b9c4f6d.tgz + "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.85-61daa25dfbb6.tgz "@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)": dependencies: diff --git a/src/components/connection-gate.test.tsx b/src/components/connection-gate.test.tsx index 14a3774..f5f40fa 100644 --- a/src/components/connection-gate.test.tsx +++ b/src/components/connection-gate.test.tsx @@ -1,9 +1,267 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { MdbaseApplicationSessionSnapshot } from "@mdbase-dev/connect"; +const connectMock = vi.hoisted(() => { + const ok = (value: Value) => ({ + ok: true as const, + value, + diagnostics: [], + }); + let snapshot: MdbaseApplicationSessionSnapshot = { + status: "unselected", + connections: [], + }; + const listeners = new Set<() => void>(); + const isAuthorizationCallback = vi.fn(() => false); + const session = { + applyCollectionSetup: vi.fn(), + authorize: vi.fn(), + clearSelection: vi.fn(), + connection: vi.fn(() => null), + getSnapshot: vi.fn(() => snapshot), + handleAuthorizationCallback: vi.fn(), + select: vi.fn(), + start: vi.fn(), + subscribe: vi.fn((listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + }; + return { + session, + isAuthorizationCallback, + reset() { + snapshot = { status: "unselected", connections: [] }; + listeners.clear(); + for (const mock of Object.values(session)) { + if ("mockReset" in mock) mock.mockReset(); + } + session.connection.mockReturnValue(null); + session.start.mockResolvedValue(ok(snapshot)); + session.authorize.mockResolvedValue(ok({ kind: "redirected" })); + session.applyCollectionSetup.mockResolvedValue(ok(snapshot)); + session.handleAuthorizationCallback.mockResolvedValue(ok({})); + session.select.mockReturnValue(ok({})); + session.clearSelection.mockReturnValue(ok(undefined)); + isAuthorizationCallback.mockReset(); + isAuthorizationCallback.mockReturnValue(false); + }, + setSnapshot(next: MdbaseApplicationSessionSnapshot) { + snapshot = next; + for (const listener of listeners) listener(); + }, + }; +}); + +vi.mock("../data/connect", () => ({ + isAuthorizationCallback: connectMock.isAuthorizationCallback, + plannerSession: connectMock.session, +})); + import { SetupReview, WelcomePreview } from "./connection-gate"; +beforeEach(() => connectMock.reset()); + +describe("ConnectionGate lifecycle", () => { + it("retries a failed explicit start and gates collection actions", async () => { + connectMock.session.start + .mockImplementationOnce(async () => { + const problem = { + code: "temporarily_unavailable", + message: "Connect is temporarily unavailable.", + }; + connectMock.setSnapshot({ + status: "start_failed", + problem, + connections: [], + } as unknown as MdbaseApplicationSessionSnapshot); + return { ok: false as const, problem, diagnostics: [] }; + }) + .mockImplementationOnce(async () => { + const snapshot: MdbaseApplicationSessionSnapshot = { + status: "unselected", + connections: [], + }; + connectMock.setSnapshot(snapshot); + return { ok: true as const, value: snapshot, diagnostics: [] }; + }); + + const { ConnectionGate } = await import("./connection-gate"); + render(); + + expect( + await screen.findByRole("button", { name: "Retry opening Planner" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Open collection" }), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: "Retry opening Planner" }), + ); + + await screen.findByRole("button", { name: "Open collection" }); + expect(connectMock.session.start).toHaveBeenCalledTimes(2); + }); + + it("makes an abandoned startup timeout retryable from not_started", async () => { + connectMock.session.start + .mockImplementationOnce(async () => { + connectMock.setSnapshot({ status: "starting", connections: [] }); + await Promise.resolve(); + connectMock.setSnapshot({ status: "not_started", connections: [] }); + return { + ok: false as const, + problem: { code: "timeout", message: "Startup timed out." }, + }; + }) + .mockImplementationOnce(async () => { + const snapshot: MdbaseApplicationSessionSnapshot = { + status: "unselected", + connections: [], + }; + connectMock.setSnapshot(snapshot); + return { ok: true as const, value: snapshot, diagnostics: [] }; + }); + + const { ConnectionGate } = await import("./connection-gate"); + render(); + + fireEvent.click( + await screen.findByRole("button", { name: "Retry opening Planner" }), + ); + await screen.findByRole("button", { name: "Open collection" }); + + expect(connectMock.session.start).toHaveBeenNthCalledWith(1, { + timeoutMs: 20_000, + }); + expect(connectMock.session.start).toHaveBeenCalledTimes(2); + }); + + it("lets startup own callback completion with the callback budget", async () => { + connectMock.isAuthorizationCallback.mockReturnValue(true); + const { ConnectionGate } = await import("./connection-gate"); + render(); + + await waitFor(() => + expect(connectMock.session.start).toHaveBeenCalledWith({ + timeoutMs: 60_000, + }), + ); + expect( + connectMock.session.handleAuthorizationCallback, + ).not.toHaveBeenCalled(); + }); + + it("reviews declaration changes through explicit selected authorization", async () => { + connectMock.setSnapshot(authorizationRequiredSnapshot()); + const { ConnectionGate } = await import("./connection-gate"); + render(); + + fireEvent.click( + await screen.findByRole("button", { name: "Review updated access" }), + ); + + await waitFor(() => + expect(connectMock.session.authorize).toHaveBeenCalledWith("selected", { + timeoutMs: 60_000, + }), + ); + }); + + it("omits the selected unusable collection from alternatives", async () => { + const selected = authorizationRequiredSnapshot(); + connectMock.setSnapshot({ + ...selected, + connections: [ + selected.info, + { ...selected.info, collectionId: "collection-2", displayName: "Home" }, + ], + }); + const { ConnectionGate } = await import("./connection-gate"); + render(); + + expect( + await screen.findByRole("button", { name: "Open Home" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Open Product" }), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Open Home" })); + expect(connectMock.session.select).toHaveBeenCalledWith("collection-2", { + history: "replace", + }); + }); + + it("reauthorizes an unavailable selected collection in place", async () => { + connectMock.setSnapshot({ + status: "unavailable", + collectionId: "collection-1", + reason: "invalid_stored_grant", + connections: [], + }); + const { ConnectionGate } = await import("./connection-gate"); + render(); + + fireEvent.click( + await screen.findByRole("button", { name: "Review updated access" }), + ); + + await waitFor(() => + expect(connectMock.session.authorize).toHaveBeenCalledWith("selected", { + timeoutMs: 60_000, + }), + ); + }); + + it("keeps destroyed sessions terminal", async () => { + connectMock.setSnapshot({ status: "destroyed", connections: [] }); + const { ConnectionGate } = await import("./connection-gate"); + render(); + + expect( + await screen.findByText( + "This collection session has closed. Reload Planner to reconnect.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /open collection|retry opening/i }), + ).not.toBeInTheDocument(); + expect(connectMock.session.start).not.toHaveBeenCalled(); + }); +}); + +function authorizationRequiredSnapshot(): Extract< + MdbaseApplicationSessionSnapshot, + { status: "authorization_required" } +> { + return { + status: "authorization_required", + collectionId: "collection-1", + info: { + collectionId: "collection-1", + displayName: "Product", + operations: [], + scope: { kind: "collection" }, + authority: { kind: "connector", durability: "computer" }, + route: "relay", + directAccess: "unavailable", + }, + capabilities: { + requiredAvailable: false, + optionalAvailable: false, + available: [], + unavailable: [], + }, + connections: [], + } as unknown as Extract< + MdbaseApplicationSessionSnapshot, + { status: "authorization_required" } + >; +} + describe("WelcomePreview", () => { it("moves example tasks with pointer dragging", () => { render(); diff --git a/src/components/connection-gate.tsx b/src/components/connection-gate.tsx index a5a0365..deea834 100644 --- a/src/components/connection-gate.tsx +++ b/src/components/connection-gate.tsx @@ -20,6 +20,9 @@ import { MdbasePlannerRepository } from "../data/mdbase-repository"; import { errorMessage, requireOutcome } from "../data/outcome"; import { Planner } from "./planner"; +const START_TIMEOUT_MS = 20_000; +const CALLBACK_START_TIMEOUT_MS = 60_000; + export function ConnectionGate({ onDemo }: { onDemo(): void }) { const snapshot = useSyncExternalStore( (listener) => plannerSession.subscribe(listener), @@ -27,35 +30,36 @@ export function ConnectionGate({ onDemo }: { onDemo(): void }) { () => plannerSession.getSnapshot(), ); const [error, setError] = useState(""); + const [startError, setStartError] = useState(""); const [opening, setOpening] = useState(false); - const handledCallback = useRef(false); + const mounted = useRef(true); + + const startSession = useCallback(async () => { + setStartError(""); + try { + const timeoutMs = isAuthorizationCallback(location.href) + ? CALLBACK_START_TIMEOUT_MS + : START_TIMEOUT_MS; + requireOutcome(await plannerSession.start({ timeoutMs })); + } catch (reason) { + if (mounted.current) setStartError(connectionErrorMessage(reason)); + } + }, []); useEffect(() => { let active = true; - void plannerSession - .start({ timeoutMs: 20_000 }) - .then(requireOutcome) - .then(async () => { - if ( - active && - !handledCallback.current && - isAuthorizationCallback(location.href) - ) { - handledCallback.current = true; - requireOutcome( - await plannerSession.handleAuthorizationCallback(location.href, { - timeoutMs: 60_000, - }), - ); - } - }) - .catch( - (reason: unknown) => active && setError(connectionErrorMessage(reason)), - ); + mounted.current = true; + const status = plannerSession.getSnapshot().status; + if (status !== "start_failed" && status !== "destroyed") { + queueMicrotask(() => { + if (active) void startSession(); + }); + } return () => { active = false; + mounted.current = false; }; - }, []); + }, [startSession]); const connection = snapshot.status === "ready" ? plannerSession.connection() : null; @@ -106,23 +110,59 @@ export function ConnectionGate({ onDemo }: { onDemo(): void }) { } }, []); + const selectCollection = useCallback((collectionId: string) => { + setError(""); + try { + requireOutcome( + plannerSession.select(collectionId, { history: "replace" }), + ); + } catch (reason) { + setError(connectionErrorMessage(reason)); + } + }, []); + + const clearSelection = useCallback(() => { + requireOutcome(plannerSession.clearSelection()); + }, []); + if (repository) return ( - plannerSession.clearSelection()} - /> + ); const stateError = - snapshot.status === "blocked" + startError || + (snapshot.status === "start_failed" ? snapshot.problem.message - : snapshot.status === "unavailable" - ? unavailableMessage(snapshot.reason) - : ""; + : snapshot.status === "destroyed" + ? "This collection session has closed. Reload Planner to reconnect." + : snapshot.status === "blocked" + ? snapshot.problem.message + : snapshot.status === "unavailable" + ? unavailableMessage(snapshot.reason) + : ""); + + const retryStartup = + snapshot.status === "start_failed" || + (snapshot.status === "not_started" && Boolean(startError)); const isWaiting = - snapshot.status === "opening" || snapshot.status === "checking_setup"; + snapshot.status === "not_started" || + snapshot.status === "starting" || + snapshot.status === "checking_setup"; + const selectedCollectionId = + "collectionId" in snapshot ? snapshot.collectionId : null; + const sessionStarted = ![ + "not_started", + "starting", + "start_failed", + "destroyed", + ].includes(snapshot.status); + const alternativeConnections = sessionStarted + ? snapshot.connections.filter( + ({ collectionId }) => collectionId !== selectedCollectionId, + ) + : []; return (
)}
- {snapshot.status === "setup_review_required" ? ( + {retryStartup ? ( + + ) : snapshot.status === "destroyed" ? null : snapshot.status === + "setup_review_required" ? ( ) : ( ) : null} + {alternativeConnections.map((connection) => ( + + ))}
{error || stateError ? (

diff --git a/src/components/gantt-chart.test.tsx b/src/components/gantt-chart.test.tsx index 56b5d9e..2df87c3 100644 --- a/src/components/gantt-chart.test.tsx +++ b/src/components/gantt-chart.test.tsx @@ -31,6 +31,59 @@ beforeEach(() => { }); describe("GanttChart interactions", () => { + it("gates timeline writes while keeping task selection available", async () => { + 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} + />, + ); + + 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(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", diff --git a/src/components/gantt-chart.tsx b/src/components/gantt-chart.tsx index cf8d734..3f948f7 100644 --- a/src/components/gantt-chart.tsx +++ b/src/components/gantt-chart.tsx @@ -36,7 +36,7 @@ import { type GanttRow, type TimelineZoom, } from "../domain/gantt"; -import { errorMessage } from "../data/outcome"; +import { errorMessage, isOperationOutcomeUnknown } from "../data/outcome"; import type { PlannerTask, @@ -87,6 +87,7 @@ export function GanttChart({ zoom, selectedId, todayRequest, + mutationsDisabled = false, onSelect, onZoom, onScheduleChange, @@ -98,6 +99,7 @@ export function GanttChart({ zoom: TimelineZoom; selectedId: string | null; todayRequest: number; + mutationsDisabled?: boolean; onSelect(task: PlannerTask): void; onZoom(direction: -1 | 1): void; onScheduleChange(task: PlannerTask, update: ScheduleUpdate): Promise; @@ -228,6 +230,7 @@ export function GanttChart({ } async function commitSchedule(task: PlannerTask, update: ScheduleUpdate) { + if (mutationsDisabled) return; showMessage({ tone: "neutral", text: "Saving schedule…" }, 10_000); try { await onScheduleChange(task, update); @@ -236,7 +239,9 @@ export function GanttChart({ showMessage( { tone: "danger", - text: `Schedule not saved. ${errorMessage(reason)}`, + text: isOperationOutcomeUnknown(reason) + ? `Schedule outcome is unknown. ${errorMessage(reason)}` + : `Schedule not saved. ${errorMessage(reason)}`, }, 5_000, ); @@ -247,6 +252,7 @@ export function GanttChart({ task: PlannerTask, dependencies: readonly TaskDependency[], ) { + if (mutationsDisabled) return; showMessage({ tone: "neutral", text: "Saving relationship…" }, 10_000); try { await onDependenciesChange(task, dependencies); @@ -255,7 +261,9 @@ export function GanttChart({ showMessage( { tone: "danger", - text: `Relationship not saved. ${errorMessage(reason)}`, + text: isOperationOutcomeUnknown(reason) + ? `Relationship outcome is unknown. ${errorMessage(reason)}` + : `Relationship not saved. ${errorMessage(reason)}`, }, 5_000, ); @@ -263,6 +271,7 @@ export function GanttChart({ } async function commitCompletion(task: PlannerTask) { + if (mutationsDisabled) return; if (task.recurrence) { showMessage( { @@ -291,7 +300,9 @@ export function GanttChart({ showMessage( { tone: "danger", - text: `Task not changed. ${errorMessage(reason)}`, + text: isOperationOutcomeUnknown(reason) + ? `Task outcome is unknown. ${errorMessage(reason)}` + : `Task not changed. ${errorMessage(reason)}`, }, 5_000, ); @@ -595,6 +606,7 @@ export function GanttChart({ ; onSelect(task: PlannerTask): void; onCompletionChange(task: PlannerTask): void; @@ -872,6 +886,7 @@ function TaskRow({ event: ReactKeyboardEvent, kind: Exclude, ) { + if (mutationsDisabled) return; if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; if (kind === "move" && !event.altKey) return; event.preventDefault(); @@ -893,7 +908,7 @@ function TaskRow({ : `${row.task.completed ? "Reopen" : "Complete"} ${row.task.title}` } className="completion-control" - disabled={completionPending} + disabled={completionPending || mutationsDisabled} title={ row.task.recurrence ? "Complete recurring tasks in TaskNotes, where you can choose an occurrence." @@ -945,17 +960,29 @@ function TaskRow({ } > {span ? (

, task: PlannerTask, @@ -1069,6 +1110,7 @@ function LinkPort({ className={`link-port is-${edge}${targeted ? " is-targeted" : ""}${targeted && linkDrag?.invalidReason ? " is-invalid" : ""}`} data-edge={edge} data-task-id={task.id} + disabled={mutationsDisabled} type="button" onPointerCancel={onCancel} onPointerDown={(event) => onBegin(event, task, edge)} diff --git a/src/components/planner.test.tsx b/src/components/planner.test.tsx new file mode 100644 index 0000000..535285c --- /dev/null +++ b/src/components/planner.test.tsx @@ -0,0 +1,378 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MdbaseConnectError, type ConnectProblem } from "@mdbase-dev/connect"; + +import { Planner } from "./planner"; + +import type { + PendingPlannerMutation, + PlannerCollection, + PlannerRepository, + PlannerTask, + ScheduleUpdate, +} from "../domain/task"; + +vi.mock("./gantt-chart", () => ({ + GanttChart: ({ + tasks, + mutationsDisabled, + onCompletionChange, + onDependenciesChange, + onScheduleChange, + onSelect, + }: { + tasks: readonly PlannerTask[]; + mutationsDisabled?: boolean; + onCompletionChange(task: PlannerTask): Promise; + onDependenciesChange( + task: PlannerTask, + dependencies: PlannerTask["blockedBy"], + ): Promise; + onScheduleChange(task: PlannerTask, update: ScheduleUpdate): Promise; + onSelect(task: PlannerTask): void; + }) => ( +
+ {tasks[0]?.scheduled} + {tasks[0] ? ( + <> + + + + + + ) : null} +
+ ), +})); + +const collection: PlannerCollection = { + id: "collection-1", + name: "Work", + tasks: [], + views: [], + statuses: [], + priorities: [], +}; + +beforeEach(() => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: false })), + ); + HTMLElement.prototype.scrollTo = vi.fn(); +}); + +describe("Planner connection recovery", () => { + it("surfaces clearSelection failures while the collection remains ready", async () => { + const repository = repositoryFixture(); + const onChangeCollection = vi.fn(() => { + throw new Error("The session is no longer available."); + }); + render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: /Work/ })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "The session is no longer available.", + ); + }); + + it("recovers a durable pending handle explicitly and reconciles by loading", async () => { + let completeRecovery = () => undefined; + let pending: readonly PendingPlannerMutation[] = [ + { + requestId: "request-1", + operation: "update", + createdAt: "2026-08-23T12:00:00.000Z", + }, + ]; + const repository = repositoryFixture({ + pendingMutations: () => pending, + recoverPendingMutation: vi.fn(() => { + return new Promise((resolve) => { + completeRecovery = () => { + pending = []; + resolve(); + }; + }); + }), + }); + render( + undefined} />, + ); + + fireEvent.click( + await screen.findByRole("button", { name: "Recover pending change" }), + ); + + await waitFor(() => + expect(repository.recoverPendingMutation).toHaveBeenCalledWith( + "request-1", + ), + ); + expect(screen.getByRole("button", { name: "Recovering…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Save view" })).toBeDisabled(); + + completeRecovery(); + await waitFor(() => expect(repository.load).toHaveBeenCalledTimes(2)); + expect( + screen.queryByRole("button", { name: "Recover pending change" }), + ).not.toBeInTheDocument(); + }); + + it("reconciles a consumed recovery rejection before releasing writes", async () => { + let pending: readonly PendingPlannerMutation[] = [pendingMutation()]; + let finishReload: () => void = () => undefined; + let loads = 0; + const load = vi.fn(() => { + loads += 1; + if (loads === 1) return Promise.resolve(structuredClone(collection)); + return new Promise((resolve) => { + finishReload = () => resolve(structuredClone(collection)); + }); + }); + const repository = repositoryFixture({ + load, + pendingMutations: () => pending, + recoverPendingMutation: vi.fn(async () => { + pending = []; + throw new Error("The original update was rejected."); + }), + }); + render( + undefined} />, + ); + + fireEvent.click( + await screen.findByRole("button", { name: "Recover pending change" }), + ); + + await waitFor(() => expect(load).toHaveBeenCalledTimes(2)); + expect(screen.getByRole("button", { name: "Recovering…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Save view" })).toBeDisabled(); + + finishReload(); + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Recover pending change" }), + ).not.toBeInTheDocument(), + ); + expect(screen.getByRole("button", { name: "Save view" })).toBeEnabled(); + }); + + it("retains the write gate without reloading when recovery remains retryable", async () => { + const pending = pendingMutation(); + const repository = repositoryFixture({ + pendingMutations: () => [pending], + recoverPendingMutation: vi.fn(async () => { + throw new Error("The connector is temporarily unavailable."); + }), + }); + render( + undefined} />, + ); + + fireEvent.click( + await screen.findByRole("button", { name: "Recover pending change" }), + ); + + await waitFor(() => + expect(repository.recoverPendingMutation).toHaveBeenCalledTimes(1), + ); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Recover pending change" }), + ).toBeEnabled(), + ); + expect(repository.load).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Save view" })).toBeDisabled(); + }); + + it("keeps an uncertain optimistic schedule until explicit recovery", async () => { + const task = plannerTask(); + let pending: readonly PendingPlannerMutation[] = []; + const problem = { + problem_version: 1, + code: "operation_outcome_unknown", + category: "transport", + recovery: "recover_mutation", + message: "The schedule may already have changed.", + operation_outcome: "unknown", + } as unknown as ConnectProblem; + const updateSchedule = vi.fn(async () => { + pending = [ + { + requestId: "request-1", + operation: "update", + createdAt: "2026-08-23T12:00:00.000Z", + }, + ]; + throw new MdbaseConnectError(problem); + }); + const repository = repositoryFixture({ + load: vi.fn(async () => ({ + ...structuredClone(collection), + tasks: [task], + })), + updateSchedule, + pendingMutations: () => pending, + recoverPendingMutation: vi.fn(), + }); + render( + undefined} />, + ); + + fireEvent.click( + await screen.findByRole("button", { name: "Move test task" }), + ); + + await waitFor(() => + expect(screen.getByTestId("scheduled-value")).toHaveTextContent( + "2026-08-24", + ), + ); + expect( + await screen.findByRole("button", { name: "Recover pending change" }), + ).toBeInTheDocument(); + expect(updateSchedule).toHaveBeenCalledTimes(1); + }); + + it("blocks every subsequent write while a mutation awaits recovery", async () => { + const task = plannerTask(); + const repository = repositoryFixture({ + load: vi.fn(async () => ({ + ...structuredClone(collection), + tasks: [task], + })), + pendingMutations: () => [ + { + requestId: "request-1", + operation: "update", + createdAt: "2026-08-23T12:00:00.000Z", + }, + ], + recoverPendingMutation: vi.fn(), + }); + render( + undefined} />, + ); + + const ganttWrites = [ + await screen.findByRole("button", { name: "Move test task" }), + screen.getByRole("button", { name: "Change test dependencies" }), + screen.getByRole("button", { name: "Complete test task" }), + ]; + for (const button of ganttWrites) { + expect(button).toBeDisabled(); + fireEvent.click(button); + } + + expect( + screen.getByRole("button", { name: "Recover pending change" }), + ).toBeEnabled(); + expect(screen.getByRole("button", { name: "Save view" })).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Select test task" })); + expect( + document.querySelector( + ".task-inspector .planning-field select", + ), + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Save schedule" }), + ).toBeDisabled(); + + expect(repository.updateSchedule).not.toHaveBeenCalled(); + expect(repository.updateDependencies).not.toHaveBeenCalled(); + expect(repository.updateProperties).not.toHaveBeenCalled(); + expect(repository.toggleCompletion).not.toHaveBeenCalled(); + expect(repository.saveView).not.toHaveBeenCalled(); + }); +}); + +function repositoryFixture( + overrides: Partial = {}, +): PlannerRepository { + return { + load: vi.fn(async () => structuredClone(collection)), + updateSchedule: vi.fn(), + updateDependencies: vi.fn(), + updateProperties: vi.fn(), + toggleCompletion: vi.fn(), + saveView: vi.fn(), + pendingMutations: () => [], + ...overrides, + }; +} + +function plannerTask(): PlannerTask { + return { + id: "task-1", + path: "tasks/task-1.md", + title: "Plan release", + status: "open", + priority: "normal", + scheduled: "2026-08-23", + due: "2026-08-25", + projects: [], + blockedBy: [{ uid: "research", reltype: "FINISHTOSTART" }], + completed: false, + statusLabel: "Open", + statusColor: "#64748b", + priorityLabel: "Normal", + priorityColor: "#64748b", + statusOptions: [ + { + value: "open", + label: "Open", + color: "#64748b", + order: 1, + isCompleted: false, + isSkipped: false, + }, + ], + priorityOptions: [ + { value: "normal", label: "Normal", color: "#64748b", weight: 1 }, + ], + }; +} + +function pendingMutation(): PendingPlannerMutation { + return { + requestId: "request-1", + operation: "update", + createdAt: "2026-08-23T12:00:00.000Z", + }; +} diff --git a/src/components/planner.tsx b/src/components/planner.tsx index 78433de..b1a7710 100644 --- a/src/components/planner.tsx +++ b/src/components/planner.tsx @@ -33,10 +33,11 @@ import { } from "../domain/gantt"; import { GanttChart } from "./gantt-chart"; import { TaskInspector } from "./task-inspector"; -import { errorMessage } from "../data/outcome"; +import { errorMessage, isOperationOutcomeUnknown } from "../data/outcome"; import type { PlannerCollection, + PendingPlannerMutation, PlannerRepository, PlannerTask, ScheduleUpdate, @@ -70,7 +71,19 @@ export function Planner({ const [saveError, setSaveError] = useState(""); const [selectedId, setSelectedId] = useState(null); const [todayRequest, setTodayRequest] = useState(0); + const [pendingMutations, setPendingMutations] = useState< + readonly PendingPlannerMutation[] + >([]); + const [recoveringMutation, setRecoveringMutation] = useState(""); + const [recoveryError, setRecoveryError] = useState(""); + const [collectionActionError, setCollectionActionError] = useState(""); const refreshTimer = useRef(null); + const mutationsDisabled = + pendingMutations.length > 0 || Boolean(recoveringMutation); + + const refreshPendingMutations = useCallback(() => { + setPendingMutations(repository.pendingMutations?.() ?? []); + }, [repository]); const load = useCallback( async (quiet = false) => { @@ -87,6 +100,7 @@ export function Planner({ setShowCompleted(options?.showCompleted ?? false); if (options?.zoom !== undefined) setZoom(clampZoom(Math.round(options.zoom))); + refreshPendingMutations(); } catch (reason) { setError(errorMessage(reason)); } finally { @@ -94,7 +108,7 @@ export function Planner({ setRefreshing(false); } }, - [activeViewKey, repository], + [activeViewKey, refreshPendingMutations, repository], ); useEffect(() => { @@ -157,6 +171,7 @@ export function Planner({ const unscheduled = visibleTasks.filter((task) => !taskSpan(task)).length; async function saveSchedule(task: PlannerTask, update: ScheduleUpdate) { + requireMutationsAvailable(mutationsDisabled); const previous = task; setCollection((current) => replaceCollectionTask(current, withSchedule(task, update)), @@ -165,8 +180,11 @@ export function Planner({ const saved = await repository.updateSchedule(task, update); setCollection((current) => replaceCollectionTask(current, saved)); } catch (reason) { - setCollection((current) => replaceCollectionTask(current, previous)); + if (!isOperationOutcomeUnknown(reason)) + setCollection((current) => replaceCollectionTask(current, previous)); throw reason; + } finally { + refreshPendingMutations(); } } @@ -174,6 +192,7 @@ export function Planner({ task: PlannerTask, dependencies: readonly TaskDependency[], ) { + requireMutationsAvailable(mutationsDisabled); const previous = task; setCollection((current) => replaceCollectionTask(current, { ...task, blockedBy: [...dependencies] }), @@ -182,19 +201,63 @@ export function Planner({ const saved = await repository.updateDependencies(task, dependencies); setCollection((current) => replaceCollectionTask(current, saved)); } catch (reason) { - setCollection((current) => replaceCollectionTask(current, previous)); + if (!isOperationOutcomeUnknown(reason)) + setCollection((current) => replaceCollectionTask(current, previous)); throw reason; + } finally { + refreshPendingMutations(); } } async function saveProperties(task: PlannerTask, update: TaskPropertyUpdate) { - const saved = await repository.updateProperties(task, update); - setCollection((current) => replaceCollectionTask(current, saved)); + requireMutationsAvailable(mutationsDisabled); + try { + const saved = await repository.updateProperties(task, update); + setCollection((current) => replaceCollectionTask(current, saved)); + } finally { + refreshPendingMutations(); + } } async function toggleCompletion(task: PlannerTask) { - const saved = await repository.toggleCompletion(task); - setCollection((current) => replaceCollectionTask(current, saved)); + requireMutationsAvailable(mutationsDisabled); + try { + const saved = await repository.toggleCompletion(task); + setCollection((current) => replaceCollectionTask(current, saved)); + } finally { + refreshPendingMutations(); + } + } + + async function recoverMutation(requestId: string) { + if (!repository.recoverPendingMutation) return; + setRecoveringMutation(requestId); + setRecoveryError(""); + try { + await repository.recoverPendingMutation(requestId); + await load(true); + } catch (reason) { + setRecoveryError(errorMessage(reason)); + const remaining = repository.pendingMutations?.() ?? []; + if (remaining.some((pending) => pending.requestId === requestId)) { + setPendingMutations(remaining); + } else { + // A consumed handle is definitive even when recovery returns a rejection. + // Reconcile before releasing the write gate. + await load(true); + } + } finally { + setRecoveringMutation(""); + } + } + + function changeCollection() { + setCollectionActionError(""); + try { + onChangeCollection(); + } catch (reason) { + setCollectionActionError(errorMessage(reason)); + } } function chooseView(key: string) { @@ -215,6 +278,10 @@ export function Planner({ async function saveView(event: FormEvent) { event.preventDefault(); + if (mutationsDisabled) { + setSaveError(pendingMutationMessage); + return; + } if (!collection || !saveName.trim()) return; setSavingView(true); setSaveError(""); @@ -236,6 +303,7 @@ export function Planner({ } catch (reason) { setSaveError(errorMessage(reason)); } finally { + refreshPendingMutations(); setSavingView(false); } } @@ -251,7 +319,7 @@ export function Planner({ -
@@ -265,7 +333,7 @@ export function Planner({
TaskNotes Planner -
+ {collectionActionError ? ( +

+ {collectionActionError} +

+ ) : null} + {pendingMutations.map((pending) => ( + + ))} + {recoveryError ? ( +

+ {recoveryError} +

+ ) : null} {error ? (

Tasks may be out of date. {error} @@ -425,6 +519,7 @@ export function Planner({ ) : null} setSelectedId(null)} onSaveDependencies={saveDependencies} @@ -499,7 +595,7 @@ export function Planner({