diff --git a/lib/data/task.ts b/lib/data/task.ts index 7d97f74..f2e8835 100644 --- a/lib/data/task.ts +++ b/lib/data/task.ts @@ -1341,6 +1341,7 @@ export async function listTasksForGraph(projectId: string, conn: Conn) { status: tasks.status, sequenceNumber: tasks.sequenceNumber, tags: tasks.tags, + priority: tasks.priority, }) .from(tasks) .where(eq(tasks.projectId, projectId)); diff --git a/lib/data/traversal.ts b/lib/data/traversal.ts index a2772c6..604df8a 100644 --- a/lib/data/traversal.ts +++ b/lib/data/traversal.ts @@ -6,14 +6,46 @@ import { withUserContext, type Tx } from "@/lib/db/rls"; import { tasks, projects, taskEdges } from "@/lib/db/schema"; import { fetchDownstream } from "@/lib/db/raw/fetch-downstream"; import { asIdentifier, composeTaskRef } from "@/lib/graph/identifier"; -import { buildEffectiveDepGraph } from "@/lib/graph/effective-deps"; +import { + buildEffectiveDepGraph, + type ActiveTaskInfo, +} from "@/lib/graph/effective-deps"; import { hasCriteriaExpr, deriveTaskStatesSlim } from "@/lib/data/task"; import type { AuthContext } from "@/lib/auth/context"; +import type { Priority } from "@/lib/types"; import { assertProjectAccessTx, assertTaskAccessTx, } from "@/lib/auth/authorization"; +// --------------------------------------------------------------------------- +// Priority weighting for the critical-path DP +// --------------------------------------------------------------------------- + +/** + * Doubling ladder so a single `urgent` node (8) dominates a chain of three + * `normal` nodes (6); a 2-chain of two `urgent` (16) outranks a 3-chain of + * three `backlog` (3). See MYMR-208. + */ +const PRIORITY_WEIGHTS = { + urgent: 8, + core: 4, + normal: 2, + backlog: 1, +} as const satisfies Record; + +const DEFAULT_PRIORITY_WEIGHT = PRIORITY_WEIGHTS.normal; + +/** + * Lookup the DP weight for a task's priority. Null or any string outside the + * recognized alphabet falls back to the `normal` weight (2) so the DP never + * sees `undefined`/`NaN`. + */ +function priorityWeight(p: ActiveTaskInfo["priority"]): number { + if (p === null) return DEFAULT_PRIORITY_WEIGHT; + return PRIORITY_WEIGHTS[p] ?? DEFAULT_PRIORITY_WEIGHT; +} + // --------------------------------------------------------------------------- // Ancestor traversal — internal helper // --------------------------------------------------------------------------- @@ -426,22 +458,30 @@ export type CriticalPathTask = { }; /** - * Find the longest chain of effective `depends_on` edges across active tasks. + * Find the most important remaining chain of effective `depends_on` edges + * across active, non-done tasks. * - * Operates on the effective dependency graph — cancelled tasks are transparent, - * so a chain `A → B → C` where B is cancelled is treated as the active chain - * `A → C` (and contributes length 2, not 3). This avoids the orphan-bug where - * tasks above a cancelled middle would be excluded from the chain entirely. + * Operates on the effective dependency graph: cancelled tasks are transparent + * (passed through inside the shared graph substrate) and done tasks are + * locally transparent here (filtered out of the DP node set), so a chain + * `A(done) → B(planned) → C(draft)` reports as `B → C`. Each DP node + * contributes weight by its `priority` (urgent=8, core=4, normal=2, + * backlog=1; null or unrecognized → 2) so a chain's score reflects priority + * mass, not raw length. A single `urgent` task (8) outranks a chain of three + * `normal` tasks (6); a 2-chain of two `urgent` tasks (16) outranks a + * 3-chain of three `backlog` tasks (3). * - * Algorithm: Kahn's topological sort over active tasks (deps first) followed - * by DP `longest[node] = 1 + max(longest[dep])`, then backtrack from the - * highest-`longest` node to recover the chain in root-first order. + * Algorithm: Kahn's topological sort over not-done active tasks (deps first) + * followed by DP `longest[node] = max(longest[dep]) + priorityWeight(node)`, + * then backtrack from the highest-`longest` node to recover the chain in + * root-first order. Returns empty when no not-done active tasks exist or a + * cycle is detected. * * @param ctx - Resolved auth context. * @param projectId - UUID of the project. - * @returns Ordered array of active tasks forming the longest effective chain - * (foundational task first, topmost dependent last). Empty when no active - * tasks exist or a cycle is detected. + * @returns Ordered array of active, not-done tasks forming the highest-weight + * effective chain (foundational task first, topmost dependent last). Empty + * when no not-done active tasks exist or a cycle is detected. */ export async function getCriticalPath( ctx: AuthContext, @@ -454,9 +494,39 @@ export async function getCriticalPath( const graph = await buildEffectiveDepGraph(projectId, tx); if (graph.activeTasks.size === 0) return []; + // Done-transparency, scoped locally: build a DP node set that excludes + // done tasks, then rebuild deps/dependents adjacency over that set. The + // shared graph substrate keeps done tasks (other analyzers depend on + // them); the filter happens here only. + const dpNodes = new Map(); + for (const info of graph.activeTasks.values()) { + if (info.status === "done") continue; + dpNodes.set(info.id, info); + } + if (dpNodes.size === 0) return []; + + const dpDeps = new Map>(); + for (const id of dpNodes.keys()) { + const fullDeps = graph.effectiveDeps.get(id) ?? new Set(); + const filtered = new Set(); + for (const dep of fullDeps) { + if (dpNodes.has(dep)) filtered.add(dep); + } + dpDeps.set(id, filtered); + } + + const dpDependents = new Map>(); + for (const [src, deps] of dpDeps) { + for (const dep of deps) { + const set = dpDependents.get(dep) ?? new Set(); + set.add(src); + dpDependents.set(dep, set); + } + } + const remaining = new Map(); - for (const id of graph.activeTasks.keys()) { - remaining.set(id, graph.effectiveDeps.get(id)?.size ?? 0); + for (const id of dpNodes.keys()) { + remaining.set(id, dpDeps.get(id)?.size ?? 0); } const topoOrder: string[] = []; @@ -467,8 +537,7 @@ export async function getCriticalPath( while (queue.length > 0) { const cur = queue.shift()!; topoOrder.push(cur); - const dependents = - graph.effectiveDependents.get(cur) ?? new Set(); + const dependents = dpDependents.get(cur) ?? new Set(); for (const dependent of dependents) { const newCount = (remaining.get(dependent) ?? 0) - 1; remaining.set(dependent, newCount); @@ -476,12 +545,12 @@ export async function getCriticalPath( } } - if (topoOrder.length < graph.activeTasks.size) return []; + if (topoOrder.length < dpNodes.size) return []; const longestTo = new Map(); const parent = new Map(); for (const node of topoOrder) { - const deps = graph.effectiveDeps.get(node) ?? new Set(); + const deps = dpDeps.get(node) ?? new Set(); let bestParent: string | null = null; let bestParentLen = 0; for (const dep of deps) { @@ -491,7 +560,8 @@ export async function getCriticalPath( bestParent = dep; } } - longestTo.set(node, bestParentLen + 1); + const info = dpNodes.get(node)!; + longestTo.set(node, bestParentLen + priorityWeight(info.priority)); parent.set(node, bestParent); } @@ -514,7 +584,7 @@ export async function getCriticalPath( chain.reverse(); return chain.map((id) => { - const info = graph.activeTasks.get(id)!; + const info = dpNodes.get(id)!; return { id: info.id, taskRef: composeTaskRef(identifier, info.sequenceNumber), diff --git a/lib/db/raw/fetch-effective-dep-chain.ts b/lib/db/raw/fetch-effective-dep-chain.ts new file mode 100644 index 0000000..1d95463 --- /dev/null +++ b/lib/db/raw/fetch-effective-dep-chain.ts @@ -0,0 +1,70 @@ +import { sql } from "drizzle-orm"; +import { tasks, taskEdges } from "@/lib/db/schema"; +import { executeRaw, type Conn } from "@/lib/db/raw"; + +/** A task in an effective `depends_on` chain with its effective depth. */ +export type EffectiveDepRow = { id: string; depth: number }; + +/** + * Walk forward `depends_on` edges from `taskId`, treating cancelled tasks + * as transparent: a chain `A → B(cancelled) → C(active)` returns C at + * effective depth 1. Cancelled middles do not consume a depth slot. + * + * Recursive CTE bounded by `effective_depth < maxDepth` on the active + * wall. The `CYCLE` clause terminates recursion on cycles, including + * cancelled-only loops. Joins `tasks` at every step and filters on + * `projectId` so a stale or hand-crafted cross-project edge cannot leak + * into the result. The source task is excluded from the result. + * + * @param conn - Drizzle client or transaction handle. + * @param taskId - UUID of the starting task (excluded from the result). + * @param projectId - UUID of the project the starting task belongs to. + * @param maxDepth - Maximum effective hops to include. + * @returns Distinct active task ids reachable from `taskId` within + * `maxDepth` effective hops, ordered by minimum effective depth ascending. + */ +export async function fetchEffectiveDepChain( + conn: Conn, + taskId: string, + projectId: string, + maxDepth: number, +): Promise { + const rows = await executeRaw<{ id: string; depth: number | string }>( + conn, + sql` + WITH RECURSIVE walk AS ( + SELECT + e.target_task_id AS id, + t.status AS status, + CASE WHEN t.status = 'cancelled' THEN 0 ELSE 1 END AS effective_depth + FROM ${taskEdges} e + INNER JOIN ${tasks} t ON t.id = e.target_task_id + WHERE e.source_task_id = ${taskId} + AND e.edge_type = 'depends_on' + AND t.project_id = ${projectId} + + UNION ALL + + SELECT + e.target_task_id, + t.status, + w.effective_depth + CASE WHEN t.status = 'cancelled' THEN 0 ELSE 1 END + FROM ${taskEdges} e + INNER JOIN walk w ON e.source_task_id = w.id + INNER JOIN ${tasks} t ON t.id = e.target_task_id + WHERE e.edge_type = 'depends_on' + AND t.project_id = ${projectId} + AND w.effective_depth < ${maxDepth} + ) CYCLE id SET is_cycle USING path + SELECT id, MIN(effective_depth) AS depth + FROM walk + WHERE NOT is_cycle + AND status <> 'cancelled' + AND effective_depth <= ${maxDepth} + AND id <> ${taskId} + GROUP BY id + ORDER BY depth ASC + `, + ); + return rows.map((r) => ({ id: r.id, depth: Number(r.depth) })); +} diff --git a/lib/db/raw/fetch-effective-downstream.ts b/lib/db/raw/fetch-effective-downstream.ts new file mode 100644 index 0000000..6e969e3 --- /dev/null +++ b/lib/db/raw/fetch-effective-downstream.ts @@ -0,0 +1,70 @@ +import { sql } from "drizzle-orm"; +import { tasks, taskEdges } from "@/lib/db/schema"; +import { executeRaw, type Conn } from "@/lib/db/raw"; + +/** A task in an effective downstream-dependents chain with its effective depth. */ +export type EffectiveDownstreamRow = { id: string; depth: number }; + +/** + * Walk backward `depends_on` edges from `taskId` (find tasks that depend + * on it), treating cancelled tasks as transparent: a chain + * `C(active) → B(cancelled) → A` returns A at effective depth 1 from C's + * perspective. + * + * Mirror of {@link fetchEffectiveDepChain} with source / target swapped + * on the join. Same `CYCLE`, `projectId` filter, and source-exclusion + * semantics. + * + * @param conn - Drizzle client or transaction handle. + * @param taskId - UUID of the starting task (excluded from the result). + * @param projectId - UUID of the project the starting task belongs to. + * @param maxDepth - Maximum effective hops to include. + * @returns Distinct active task ids that effectively depend on `taskId` + * within `maxDepth` effective hops, ordered by minimum effective depth + * ascending. + */ +export async function fetchEffectiveDownstream( + conn: Conn, + taskId: string, + projectId: string, + maxDepth: number, +): Promise { + const rows = await executeRaw<{ id: string; depth: number | string }>( + conn, + sql` + WITH RECURSIVE walk AS ( + SELECT + e.source_task_id AS id, + t.status AS status, + CASE WHEN t.status = 'cancelled' THEN 0 ELSE 1 END AS effective_depth + FROM ${taskEdges} e + INNER JOIN ${tasks} t ON t.id = e.source_task_id + WHERE e.target_task_id = ${taskId} + AND e.edge_type = 'depends_on' + AND t.project_id = ${projectId} + + UNION ALL + + SELECT + e.source_task_id, + t.status, + w.effective_depth + CASE WHEN t.status = 'cancelled' THEN 0 ELSE 1 END + FROM ${taskEdges} e + INNER JOIN walk w ON e.target_task_id = w.id + INNER JOIN ${tasks} t ON t.id = e.source_task_id + WHERE e.edge_type = 'depends_on' + AND t.project_id = ${projectId} + AND w.effective_depth < ${maxDepth} + ) CYCLE id SET is_cycle USING path + SELECT id, MIN(effective_depth) AS depth + FROM walk + WHERE NOT is_cycle + AND status <> 'cancelled' + AND effective_depth <= ${maxDepth} + AND id <> ${taskId} + GROUP BY id + ORDER BY depth ASC + `, + ); + return rows.map((r) => ({ id: r.id, depth: Number(r.depth) })); +} diff --git a/lib/graph/effective-deps.ts b/lib/graph/effective-deps.ts index 66fd448..798d7dc 100644 --- a/lib/graph/effective-deps.ts +++ b/lib/graph/effective-deps.ts @@ -3,6 +3,9 @@ import "server-only"; import type { Conn } from "@/lib/db/raw"; import { listTasksForGraph } from "@/lib/data/task"; import { listDependsOnEdges } from "@/lib/data/edge"; +import { fetchEffectiveDepChain } from "@/lib/db/raw/fetch-effective-dep-chain"; +import { fetchEffectiveDownstream } from "@/lib/db/raw/fetch-effective-downstream"; +import type { Priority } from "@/lib/types"; /** Slim active-task info used by graph analyzers. */ export type ActiveTaskInfo = { @@ -11,6 +14,7 @@ export type ActiveTaskInfo = { status: string; sequenceNumber: number; tags: string[]; + priority: Priority | null; }; /** @@ -38,10 +42,10 @@ export type EffectiveDepGraph = { * cancelled included so the walks can pass through them), and the * active-only task info map (cancelled excluded). * - * This is the exact substrate `buildEffectiveDepGraph` needs and the - * depth-bounded bundle walks (`walkEffectiveDepsBounded`) also need, kept - * as one source of truth so the analyze tools and the context bundles - * derive their dependency graphs from identical data. + * This is the exact substrate `buildEffectiveDepGraph` needs, kept here + * so every analyzer that derives a dependency graph from the project as + * a whole (`getBlockedTasks`, `getCriticalPath`, `deriveTaskStatesSlim`) + * draws from identical data. * * @param projectId - UUID of the project. * @param conn - Drizzle client or transaction handle. Callers running under @@ -71,6 +75,7 @@ export async function buildDepAdjacency( status: t.status, sequenceNumber: t.sequenceNumber, tags: t.tags, + priority: t.priority as Priority | null, }); } } @@ -184,72 +189,6 @@ function walkEffectiveDeps( return result; } -/** - * Walk forward from an active source over the effective dependency graph, - * stopping at maxDepth active "walls". Cancelled tasks are transparent and - * depth-free: the walk passes through them without consuming a depth slot, - * so A -> B(cancelled) -> C(active) yields C at effective depth 1. - * - * BFS by effective depth: the first time an active task is seen is at its - * minimum effective depth, matching the shallowest-depth behaviour of the - * old recursive CTE. The cancelled-frontier is drained at the current - * active depth before BFS advances, so a chain of any number of cancelled - * middles stays depth-free. - * - * @param source - Starting active task id (not included in the result). - * @param adj - source -> targets adjacency map for depends_on edges. - * @param taskStatus - task id -> status map for ALL project tasks. - * @param maxDepth - Maximum number of active hops to include (e.g. 2). - * @returns Map of active-task-id -> effective depth (1..maxDepth). - */ -export function walkEffectiveDepsBounded( - source: string, - adj: Map, - taskStatus: Map, - maxDepth: number, -): Map { - const result = new Map(); - // Seed `visited` with `source` so a cycle back to it never records the - // source itself (the result is the set of deps, source-exclusive). - const visited = new Set([source]); - const visitedCancelled = new Set(); - let queue: { id: string; depth: number }[] = [{ id: source, depth: 0 }]; - - while (queue.length > 0) { - const next: { id: string; depth: number }[] = []; - - for (const node of queue) { - // Drain the cancelled frontier at this active depth before advancing, - // so passing through cancelled middles never consumes a depth slot. - const cancelledFrontier: string[] = [node.id]; - while (cancelledFrontier.length > 0) { - const cur = cancelledFrontier.pop()!; - for (const target of adj.get(cur) ?? []) { - const status = taskStatus.get(target); - if (status === undefined) continue; - if (status === "cancelled") { - if (visitedCancelled.has(target)) continue; - visitedCancelled.add(target); - cancelledFrontier.push(target); - continue; - } - // Active wall at active depth node.depth + 1. - const wallDepth = node.depth + 1; - if (wallDepth > maxDepth) continue; - if (visited.has(target)) continue; - visited.add(target); - result.set(target, wallDepth); - next.push({ id: target, depth: wallDepth }); - } - } - } - - queue = next; - } - - return result; -} - /** * Walk the effective dependency graph from one source task and return its * depth-bounded forward (prerequisites) and reverse (downstream) closures. @@ -257,13 +196,19 @@ export function walkEffectiveDepsBounded( * Cancelled tasks are transparent and depth-free; both result lists contain * only active task ids and never the source itself. * + * Delegates to two recursive CTEs ({@link fetchEffectiveDepChain} and + * {@link fetchEffectiveDownstream}) so the per-call data load stays + * proportional to the bounded result set rather than the whole project + * graph. + * * @param projectId - UUID of the project the task belongs to. * @param taskId - UUID of the source task; excluded from both results. * @param maxDepth - Maximum active hops to include in each direction. * @param conn - Drizzle client or transaction handle. Callers inside a * `withUserContext` transaction must pass the active `tx` so the reads * participate in the same RLS-scoped frame. - * @returns `deps` — active prerequisites; `downstream` — active dependents. + * @returns `deps` for active prerequisites and `downstream` for active + * dependents. */ export async function loadBundleDeps( projectId: string, @@ -271,28 +216,12 @@ export async function loadBundleDeps( maxDepth: number, conn: Conn, ): Promise<{ deps: { id: string }[]; downstream: { id: string }[] }> { - const { adj, taskStatus } = await buildDepAdjacency(projectId, conn); - - const reverseAdj = new Map(); - for (const [src, targets] of adj) { - for (const t of targets) { - const list = reverseAdj.get(t) ?? []; - list.push(src); - reverseAdj.set(t, list); - } - } - - const deps = [ - ...walkEffectiveDepsBounded(taskId, adj, taskStatus, maxDepth).keys(), - ].map((id) => ({ id })); - const downstream = [ - ...walkEffectiveDepsBounded( - taskId, - reverseAdj, - taskStatus, - maxDepth, - ).keys(), - ].map((id) => ({ id })); - - return { deps, downstream }; + const [depRows, downstreamRows] = await Promise.all([ + fetchEffectiveDepChain(conn, taskId, projectId, maxDepth), + fetchEffectiveDownstream(conn, taskId, projectId, maxDepth), + ]); + return { + deps: depRows.map((r) => ({ id: r.id })), + downstream: downstreamRows.map((r) => ({ id: r.id })), + }; } diff --git a/package.json b/package.json index a2d9786..c81b2a2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "AGPL-3.0-or-later", "type": "module", "scripts": { - "dev": "next dev", + "dev": "next dev --webpack", "build": "next build --webpack", "postbuild": "bun run scripts/postbuild.ts", "start": "node --env-file-if-exists=.env.local scripts/start.mjs", diff --git a/tests/data/traversal.test.ts b/tests/data/traversal.test.ts new file mode 100644 index 0000000..968e052 --- /dev/null +++ b/tests/data/traversal.test.ts @@ -0,0 +1,369 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { truncateAll } from "@/tests/setup/schema"; +import { seedUserOrgProject, serviceRoleConnect } from "@/tests/setup/seed"; +import { makeAuthContext } from "@/lib/auth/context"; +import { createEdge } from "@/lib/data/edge"; +import { getCriticalPath } from "@/lib/data/traversal"; + +/** + * Coverage for the MYMR-208 fix: `getCriticalPath` filters done tasks + * locally and weights each DP node by priority instead of `+1`. + * + * Tests seed tasks via the service-role connection so they can set + * `status` and `priority` directly (including the null and unrecognized + * cases AC 4 requires), then stitch `depends_on` edges via `createEdge` + * so the production edge-creation path participates in the fixture. + */ + +afterEach(async () => { + await truncateAll(); +}); + +type SeedInput = { + projectId: string; + title: string; + sequenceNumber: number; + status?: string; + priority?: string | null; +}; + +async function insertTask( + sr: ReturnType, + input: SeedInput, +): Promise { + const status = input.status ?? "planned"; + // Postgres-js parameter binding handles `null` natively; we use the + // service-role pool so a non-union priority value bypasses any + // application-layer validation (the column is plain nullable text). + const [row] = await sr<{ id: string }[]>` + INSERT INTO tasks (project_id, title, sequence_number, status, priority) + VALUES ( + ${input.projectId}, + ${input.title}, + ${input.sequenceNumber}, + ${status}, + ${input.priority === undefined ? "normal" : input.priority} + ) + RETURNING id + `; + return row.id; +} + +describe("getCriticalPath: done-transparency and priority weighting", () => { + test("AC 1: done head of a chain is transparent — A(done) → B → C reports B → C", async () => { + const fx = await seedUserOrgProject("trav-done-head"); + const sr = serviceRoleConnect(); + const aId = await insertTask(sr, { + projectId: fx.projectId, + title: "A", + sequenceNumber: 1, + status: "done", + priority: "normal", + }); + const bId = await insertTask(sr, { + projectId: fx.projectId, + title: "B", + sequenceNumber: 2, + status: "planned", + priority: "normal", + }); + const cId = await insertTask(sr, { + projectId: fx.projectId, + title: "C", + sequenceNumber: 3, + status: "draft", + priority: "normal", + }); + + const ctx = makeAuthContext(fx.userId); + await createEdge(ctx, { + sourceTaskId: bId, + targetTaskId: aId, + edgeType: "depends_on", + note: "", + }); + await createEdge(ctx, { + sourceTaskId: cId, + targetTaskId: bId, + edgeType: "depends_on", + note: "", + }); + + const chain = await getCriticalPath(ctx, fx.projectId); + expect(chain.map((t) => t.id)).toEqual([bId, cId]); + expect(chain.some((t) => t.id === aId)).toBe(false); + expect(chain[0].status).toBe("planned"); + expect(chain[1].status).toBe("draft"); + }); + + test("AC 2: 2-chain of urgent (16) outranks 3-chain of backlog (3)", async () => { + const fx = await seedUserOrgProject("trav-priority-2v3"); + const sr = serviceRoleConnect(); + const u1 = await insertTask(sr, { + projectId: fx.projectId, + title: "U1", + sequenceNumber: 1, + priority: "urgent", + }); + const u2 = await insertTask(sr, { + projectId: fx.projectId, + title: "U2", + sequenceNumber: 2, + priority: "urgent", + }); + const b1 = await insertTask(sr, { + projectId: fx.projectId, + title: "B1", + sequenceNumber: 3, + priority: "backlog", + }); + const b2 = await insertTask(sr, { + projectId: fx.projectId, + title: "B2", + sequenceNumber: 4, + priority: "backlog", + }); + const b3 = await insertTask(sr, { + projectId: fx.projectId, + title: "B3", + sequenceNumber: 5, + priority: "backlog", + }); + + const ctx = makeAuthContext(fx.userId); + await createEdge(ctx, { + sourceTaskId: u2, + targetTaskId: u1, + edgeType: "depends_on", + note: "", + }); + await createEdge(ctx, { + sourceTaskId: b2, + targetTaskId: b1, + edgeType: "depends_on", + note: "", + }); + await createEdge(ctx, { + sourceTaskId: b3, + targetTaskId: b2, + edgeType: "depends_on", + note: "", + }); + + const chain = await getCriticalPath(ctx, fx.projectId); + expect(chain.map((t) => t.id)).toEqual([u1, u2]); + }); + + test("Single urgent (8) outranks 3-chain of normal (6)", async () => { + const fx = await seedUserOrgProject("trav-urgent-vs-normals"); + const sr = serviceRoleConnect(); + const u = await insertTask(sr, { + projectId: fx.projectId, + title: "U", + sequenceNumber: 1, + priority: "urgent", + }); + const n1 = await insertTask(sr, { + projectId: fx.projectId, + title: "N1", + sequenceNumber: 2, + priority: "normal", + }); + const n2 = await insertTask(sr, { + projectId: fx.projectId, + title: "N2", + sequenceNumber: 3, + priority: "normal", + }); + const n3 = await insertTask(sr, { + projectId: fx.projectId, + title: "N3", + sequenceNumber: 4, + priority: "normal", + }); + + const ctx = makeAuthContext(fx.userId); + await createEdge(ctx, { + sourceTaskId: n2, + targetTaskId: n1, + edgeType: "depends_on", + note: "", + }); + await createEdge(ctx, { + sourceTaskId: n3, + targetTaskId: n2, + edgeType: "depends_on", + note: "", + }); + + const chain = await getCriticalPath(ctx, fx.projectId); + expect(chain.map((t) => t.id)).toEqual([u]); + }); + + test("AC 4: null priority defaults to normal weight (2)", async () => { + const fx = await seedUserOrgProject("trav-null-priority"); + const sr = serviceRoleConnect(); + // 2-chain `Nl(null) → Nm(normal)`: 2 + 2 = 4. A single urgent task + // (weight 8) standalone must outrank the chain — proves the null + // default is the `normal` weight (2), not e.g. 0 or NaN. + const nl = await insertTask(sr, { + projectId: fx.projectId, + title: "Nl", + sequenceNumber: 1, + priority: null, + }); + const nm = await insertTask(sr, { + projectId: fx.projectId, + title: "Nm", + sequenceNumber: 2, + priority: "normal", + }); + const u = await insertTask(sr, { + projectId: fx.projectId, + title: "U", + sequenceNumber: 3, + priority: "urgent", + }); + + const ctx = makeAuthContext(fx.userId); + await createEdge(ctx, { + sourceTaskId: nm, + targetTaskId: nl, + edgeType: "depends_on", + note: "", + }); + + const chain = await getCriticalPath(ctx, fx.projectId); + expect(chain.map((t) => t.id)).toEqual([u]); + }); + + test("AC 4: unrecognized priority string defaults to normal weight (2)", async () => { + const fx = await seedUserOrgProject("trav-weird-priority"); + const sr = serviceRoleConnect(); + // `priority` column is plain nullable text (no CHECK constraint), + // so a non-union value persists. Same expected behavior as null. + const w = await insertTask(sr, { + projectId: fx.projectId, + title: "W", + sequenceNumber: 1, + priority: "weird", + }); + const wm = await insertTask(sr, { + projectId: fx.projectId, + title: "Wm", + sequenceNumber: 2, + priority: "normal", + }); + const u = await insertTask(sr, { + projectId: fx.projectId, + title: "U", + sequenceNumber: 3, + priority: "urgent", + }); + + const ctx = makeAuthContext(fx.userId); + await createEdge(ctx, { + sourceTaskId: wm, + targetTaskId: w, + edgeType: "depends_on", + note: "", + }); + + const chain = await getCriticalPath(ctx, fx.projectId); + expect(chain.map((t) => t.id)).toEqual([u]); + }); + + test("regression: cancelled-transparency still works inside the priority-weighted DP", async () => { + const fx = await seedUserOrgProject("trav-cancelled-mid"); + const sr = serviceRoleConnect(); + const a = await insertTask(sr, { + projectId: fx.projectId, + title: "A", + sequenceNumber: 1, + priority: "normal", + }); + const m = await insertTask(sr, { + projectId: fx.projectId, + title: "M", + sequenceNumber: 2, + status: "cancelled", + priority: "normal", + }); + const c = await insertTask(sr, { + projectId: fx.projectId, + title: "C", + sequenceNumber: 3, + priority: "normal", + }); + + const ctx = makeAuthContext(fx.userId); + await createEdge(ctx, { + sourceTaskId: m, + targetTaskId: a, + edgeType: "depends_on", + note: "", + }); + await createEdge(ctx, { + sourceTaskId: c, + targetTaskId: m, + edgeType: "depends_on", + note: "", + }); + + const chain = await getCriticalPath(ctx, fx.projectId); + expect(chain.map((t) => t.id)).toEqual([a, c]); + expect(chain.some((t) => t.id === m)).toBe(false); + expect(chain[0].status).toBe("planned"); + expect(chain[1].status).toBe("planned"); + }); + + test("done middle fragments the chain: A → B(done) → C does not connect A and C", async () => { + // depends_on chain `C → B → A` with B done. Done-transparency is FILTER + // (drop B from the DP node set), NOT WALK (pass through B to connect A + // and C as a length-2 chain). Locks the semantic in: a future refactor + // that "makes done transparent like cancelled" would fail this test + // loudly. Both remaining single-node chains have equal priority mass + // (normal=2 each), so which fragment is picked is implementation- + // defined; assert only the fragmentation property. + const fx = await seedUserOrgProject("trav-done-middle"); + const sr = serviceRoleConnect(); + const a = await insertTask(sr, { + projectId: fx.projectId, + title: "A", + sequenceNumber: 1, + priority: "normal", + }); + const b = await insertTask(sr, { + projectId: fx.projectId, + title: "B", + sequenceNumber: 2, + status: "done", + priority: "normal", + }); + const c = await insertTask(sr, { + projectId: fx.projectId, + title: "C", + sequenceNumber: 3, + priority: "normal", + }); + + const ctx = makeAuthContext(fx.userId); + await createEdge(ctx, { + sourceTaskId: b, + targetTaskId: a, + edgeType: "depends_on", + note: "", + }); + await createEdge(ctx, { + sourceTaskId: c, + targetTaskId: b, + edgeType: "depends_on", + note: "", + }); + + const chain = await getCriticalPath(ctx, fx.projectId); + expect(chain.length).toBe(1); + expect(chain.some((t) => t.id === b)).toBe(false); + expect([a, c]).toContain(chain[0].id); + }); +});