Fix board stage columns + add board view to Deliverables

Two related bugs + one feature the user flagged:

1. **dominantStage picked Canceled for every fresh project**
   The old logic included BLOCKED in "in-flight" and picked the
   highest-order match. Dow's pipeline puts "On Hold" (order 10)
   and "Canceled" (order 11) as parking stages with NO prereqs —
   so on fresh deliverables they start as NOT_STARTED, which made
   "Canceled" the highest-order in-flight stage for every deliverable.
   Result: board view only rendered a Canceled column.

   Fix — same two-step pick in both project-service.ts and
   deliverables/page.tsx currentStage():
     1) highest-order ACTIVE stage (IN_PROGRESS/IN_REVIEW/CHANGES_REQUESTED)
     2) else lowest-order NOT_STARTED (next-up)
   BLOCKED is skipped entirely — it means "prereqs not done", not
   "where work is". The lowest-order NOT_STARTED rule naturally
   keeps parking stages out of the dominant pick unless they're
   actually being worked.

2. **Board hid empty stage columns**
   In stage-grouped mode the board only rendered columns for
   stages seen in the data, so when the dominantStage bug bucketed
   everything into Canceled, all other columns disappeared.
   ProjectBoard + the new DeliverableBoard now accept a
   pipelineStages prop (from the default pipeline template) and
   render every stage as a column in canonical order, empty or not.

3. **Deliverables page: Board view**
   New component src/components/deliverables/deliverable-board.tsx.
   Grid/Board toggle in the header, group-by selector (Stage/Status)
   next to the filters. Cards show OMG #, priority dot, team, name,
   project, primary assignee, deadline. Clicking a card navigates
   to the deliverable detail page.
This commit is contained in:
DJP 2026-04-21 12:28:11 -04:00
parent 958de5f3a9
commit e9f8fffdcc
5 changed files with 496 additions and 57 deletions

View file

@ -13,7 +13,7 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import { format, parseISO } from "date-fns";
import { ClipboardList, Search, X } from "lucide-react";
import { ClipboardList, Search, X, LayoutGrid, Columns3 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
@ -27,18 +27,23 @@ import {
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useAllDeliverables, type AllDeliverableRow } from "@/hooks/use-deliverables";
import {
DeliverableBoard,
type DeliverableBoardGroupBy,
} from "@/components/deliverables/deliverable-board";
import { usePipelineTemplates } from "@/hooks/use-pipelines";
type SortKey = "name" | "project" | "stage" | "dueDate" | "priority" | "status";
type SortDir = "asc" | "desc";
// Stages considered "still in flight" — mirrors listProjects pipelineProgress
// logic so "Current stage" in this view matches the Projects grid.
const IN_FLIGHT = new Set([
"NOT_STARTED",
// "Actively worked" = stages that actually have work happening on them.
// BLOCKED is *not* here: a BLOCKED stage is waiting for prereqs, not
// being worked. NOT_STARTED is handled separately — it's the next thing
// queued to start, not current work.
const ACTIVE_STATUSES = new Set([
"IN_PROGRESS",
"IN_REVIEW",
"CHANGES_REQUESTED",
"BLOCKED",
]);
const DELIVERABLE_STATUSES = [
@ -58,30 +63,40 @@ const PRIORITY_COLORS: Record<string, string> = {
LOW: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700",
};
function currentStage(row: AllDeliverableRow) {
let best: { order: number; name: string; slug: string } | null = null;
export function currentStage(row: AllDeliverableRow) {
// Same two-step pick as project-service.ts dominantStage:
// 1. Highest-order ACTIVE stage (IN_PROGRESS/IN_REVIEW/CHANGES_REQUESTED)
// 2. Fallback to lowest-order NOT_STARTED (next queued)
// BLOCKED is skipped — not reached yet. Parking stages (On Hold /
// Canceled in Dow's template) have no prereqs so they start as
// NOT_STARTED, but their high order means the LOWEST-order NOT_STARTED
// rule naturally picks the real first stage (Pipeline) instead.
let bestActive: { order: number; name: string; slug: string } | null = null;
let bestUpcoming: { order: number; name: string; slug: string } | null = null;
for (const s of row.stages) {
if (!IN_FLIGHT.has(s.status)) continue;
const order = s.stageDefinition?.order ?? s.template?.order ?? -1;
if (!best || order > best.order) {
best = {
order,
name: s.stageDefinition?.name ?? "—",
slug: s.stageDefinition?.slug ?? "",
};
const meta = {
order,
name: s.stageDefinition?.name ?? "—",
slug: s.stageDefinition?.slug ?? "",
};
if (ACTIVE_STATUSES.has(s.status)) {
if (!bestActive || order > bestActive.order) bestActive = meta;
} else if (s.status === "NOT_STARTED") {
if (!bestUpcoming || order < bestUpcoming.order) bestUpcoming = meta;
}
}
return best;
return bestActive ?? bestUpcoming;
}
function primaryAssignee(row: AllDeliverableRow): string | null {
// First assignment on the current (in-flight) stage if any, else first
// assignment on any stage. Keeps the column legible when stages have
// different assignees across the pipeline.
const inFlight = row.stages.find(
(s) => IN_FLIGHT.has(s.status) && s.assignments.length > 0
// Prefer an assignee on the actively-worked stage; fall back to any
// assignment so the column still has something meaningful.
const active = row.stages.find(
(s) => ACTIVE_STATUSES.has(s.status) && s.assignments.length > 0
);
if (inFlight) return inFlight.assignments[0].user.name ?? inFlight.assignments[0].user.email;
if (active) return active.assignments[0].user.name ?? active.assignments[0].user.email;
for (const s of row.stages) {
if (s.assignments.length > 0) {
@ -102,6 +117,31 @@ export default function DeliverablesPage() {
const [teamFilter, setTeamFilter] = useState<string>("__all__");
const [sortKey, setSortKey] = useState<SortKey>("dueDate");
const [sortDir, setSortDir] = useState<SortDir>("asc");
// Grid/Board toggle — board groups by stage by default (the most
// useful lens for deliverables, since each has its own stage).
const [view, setView] = useState<"grid" | "board">("grid");
const [boardGroupBy, setBoardGroupBy] = useState<DeliverableBoardGroupBy>("stage");
// Pipeline stages drive board columns so the full workflow shows even
// when a column has no deliverables in it yet.
const { data: pipelineTemplates } = usePipelineTemplates() as {
data: Array<{
id: string;
name: string;
isDefault: boolean;
stages: Array<{ slug: string; name: string; order: number; color?: string | null }>;
}> | undefined;
};
const defaultPipelineStages = useMemo(() => {
if (!pipelineTemplates || pipelineTemplates.length === 0) return [];
const def = pipelineTemplates.find((t) => t.isDefault) ?? pipelineTemplates[0];
return (def?.stages ?? []).map((s) => ({
slug: s.slug,
name: s.name,
order: s.order,
color: s.color ?? null,
}));
}, [pipelineTemplates]);
const rows = data ?? [];
@ -220,13 +260,39 @@ export default function DeliverablesPage() {
return (
<div className="flex h-full flex-col gap-4">
{/* Header */}
<div className="flex items-center gap-3">
<ClipboardList className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">Deliverables</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Every deliverable across every project you have access to.
</p>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<ClipboardList className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">Deliverables</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Every deliverable across every project you have access to.
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Grid Board. Board shows a grouping selector in the filter
bar (by stage or by status). */}
<div className="flex rounded-md border bg-[var(--card)] p-0.5">
<Button
size="sm"
variant={view === "grid" ? "default" : "ghost"}
className="h-7 px-2"
onClick={() => setView("grid")}
aria-label="Grid view"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant={view === "board" ? "default" : "ghost"}
className="h-7 px-2"
onClick={() => setView("board")}
aria-label="Board view"
>
<Columns3 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
@ -273,6 +339,21 @@ export default function DeliverablesPage() {
options={PRIORITIES.map((p) => ({ value: p, label: p }))}
/>
{view === "board" && (
<Select
value={boardGroupBy}
onValueChange={(v) => setBoardGroupBy(v as DeliverableBoardGroupBy)}
>
<SelectTrigger className="h-9 w-[170px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stage">Group by: Stage</SelectItem>
<SelectItem value="status">Group by: Status</SelectItem>
</SelectContent>
</Select>
)}
{anyFilterActive && (
<Button size="sm" variant="ghost" onClick={clearFilters} className="h-9">
<X className="mr-1 h-3 w-3" />
@ -287,7 +368,17 @@ export default function DeliverablesPage() {
</div>
</div>
{/* Board view */}
{view === "board" && (
<DeliverableBoard
deliverables={sorted}
groupBy={boardGroupBy}
pipelineStages={defaultPipelineStages}
/>
)}
{/* Table */}
{view === "grid" && (
<div className="flex-1 overflow-auto rounded-lg border bg-[var(--card)]">
<table className="w-full min-w-[1100px] border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-[var(--muted)]/60">
@ -392,6 +483,7 @@ export default function DeliverablesPage() {
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -30,6 +30,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { useProjects, useCreateProject, useDeleteProject } from "@/hooks/use-projects";
import { useClientTeams } from "@/hooks/use-client-teams";
import { usePipelineTemplates } from "@/hooks/use-pipelines";
import { ProjectFormDialog } from "@/components/projects/project-form-dialog";
import { ProjectBoard, type BoardGroupBy } from "@/components/projects/project-board";
import { BulkImportDialog } from "@/components/excel/bulk-import-dialog";
@ -116,6 +117,22 @@ export default function ProjectsPage() {
isLoading: boolean;
};
const { data: clientTeams } = useClientTeams();
// Pipeline templates — used to drive board columns in stage-grouped view
// so the full workflow is visible (not just stages that happen to have
// projects in them right now).
const { data: pipelineTemplates } = usePipelineTemplates() as {
data: Array<{ id: string; name: string; isDefault: boolean; stages: Array<{ slug: string; name: string; order: number; color?: string | null }> }> | undefined;
};
const defaultPipelineStages = useMemo(() => {
if (!pipelineTemplates || pipelineTemplates.length === 0) return [];
const def = pipelineTemplates.find((t) => t.isDefault) ?? pipelineTemplates[0];
return (def?.stages ?? []).map((s) => ({
slug: s.slug,
name: s.name,
order: s.order,
color: s.color ?? null,
}));
}, [pipelineTemplates]);
const createProject = useCreateProject();
const deleteProject = useDeleteProject();
@ -339,7 +356,11 @@ export default function ProjectsPage() {
{/* Board view (Kanban columns) */}
{view === "board" && (
<ProjectBoard projects={filtered} groupBy={boardGroupBy} />
<ProjectBoard
projects={filtered}
groupBy={boardGroupBy}
pipelineStages={defaultPipelineStages}
/>
)}
{/* Excel-grid table */}

View file

@ -0,0 +1,260 @@
"use client";
/**
* Kanban board for deliverables.
*
* Unlike the Projects board (which groups projects by a *derived* dominant
* stage), a deliverable has its OWN stage so the board truly reflects
* where each deliverable is in the pipeline.
*
* Two grouping modes:
* - `stage` columns = pipeline stages, deliverables bucketed by
* their "current stage" (see currentStage() helper in
* the deliverables page)
* - `status` columns = DeliverableStatus (NOT_STARTED / IN_PROGRESS
* / IN_REVIEW / APPROVED / ON_HOLD)
*
* Read-only for now. Dragging a deliverable to a new column on the stage
* lens would require deciding which stage transition to fire (it could
* be "advance to next stage" or "jump to stage X"); we'll wire that up
* when the UX is nailed down.
*/
import Link from "next/link";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import type { AllDeliverableRow } from "@/hooks/use-deliverables";
import { currentStage } from "@/app/(app)/deliverables/page";
export type DeliverableBoardGroupBy = "stage" | "status";
export interface BoardPipelineStage {
slug: string;
name: string;
order: number;
color?: string | null;
}
interface DeliverableBoardProps {
deliverables: AllDeliverableRow[];
groupBy: DeliverableBoardGroupBy;
pipelineStages?: BoardPipelineStage[];
}
const STATUS_COLUMNS: Array<{ key: string; label: string; accent: string }> = [
{ key: "NOT_STARTED", label: "Not Started", accent: "#6B7280" },
{ key: "IN_PROGRESS", label: "In Progress", accent: "#2563EB" },
{ key: "IN_REVIEW", label: "In Review", accent: "#D97706" },
{ key: "APPROVED", label: "Approved", accent: "#16A34A" },
{ key: "ON_HOLD", label: "On Hold", accent: "#9CA3AF" },
];
const PRIORITY_DOT: Record<string, string> = {
URGENT: "bg-red-500",
HIGH: "bg-orange-500",
MEDIUM: "bg-blue-500",
LOW: "bg-gray-400",
};
export function DeliverableBoard({
deliverables,
groupBy,
pipelineStages,
}: DeliverableBoardProps) {
const columns = computeColumns(deliverables, groupBy, pipelineStages);
if (columns.length === 0) {
return (
<div className="rounded-lg border bg-[var(--card)] p-12 text-center text-sm text-[var(--muted-foreground)]">
No deliverables to display.
</div>
);
}
return (
<div className="flex gap-3 overflow-x-auto pb-2">
{columns.map((col) => (
<div
key={col.key}
className="flex w-[280px] shrink-0 flex-col rounded-lg border bg-[var(--muted)]/30"
>
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ background: col.accent }}
/>
<span className="text-[11px] font-bold uppercase tracking-wider">
{col.label}
</span>
</div>
<span className="rounded-full bg-[var(--card)] px-2 py-0.5 text-[10px] font-semibold tabular-nums text-[var(--muted-foreground)]">
{col.deliverables.length}
</span>
</div>
<div className="flex flex-1 flex-col gap-2 p-2">
{col.deliverables.length === 0 ? (
<div className="py-6 text-center text-[10px] text-[var(--muted-foreground)]/60">
</div>
) : (
col.deliverables.map((d) => (
<DeliverableCard key={d.id} deliverable={d} />
))
)}
</div>
</div>
))}
</div>
);
}
// ─── Helpers ──────────────────────────────────────────────────
function computeColumns(
deliverables: AllDeliverableRow[],
groupBy: DeliverableBoardGroupBy,
pipelineStages?: BoardPipelineStage[]
): Array<{ key: string; label: string; accent: string; deliverables: AllDeliverableRow[] }> {
if (groupBy === "status") {
return STATUS_COLUMNS.map((c) => ({
...c,
deliverables: deliverables.filter((d) => d.status === c.key),
}));
}
// Stage grouping — prefer the canonical pipeline list so empty stages
// still render. Fall back to "stages observed in the data".
if (pipelineStages && pipelineStages.length > 0) {
const cols = pipelineStages.map((s) => ({
key: s.slug,
label: s.name,
accent: s.color || "#2563EB",
deliverables: [] as AllDeliverableRow[],
}));
const noStage = {
key: "__none__",
label: "No active stage",
accent: "#9CA3AF",
deliverables: [] as AllDeliverableRow[],
};
const bySlug = new Map(cols.map((c) => [c.key, c]));
for (const d of deliverables) {
const cs = currentStage(d);
if (!cs) {
noStage.deliverables.push(d);
continue;
}
const col = bySlug.get(cs.slug);
if (col) col.deliverables.push(d);
else noStage.deliverables.push(d);
}
const result = [...cols];
if (noStage.deliverables.length > 0) result.push(noStage);
return result;
}
// Data-derived columns.
const byKey = new Map<
string,
{ key: string; label: string; accent: string; order: number; deliverables: AllDeliverableRow[] }
>();
const NO_STAGE = {
key: "__none__",
label: "No active stage",
accent: "#9CA3AF",
order: 9999,
};
for (const d of deliverables) {
const cs = currentStage(d);
if (!cs) {
if (!byKey.has(NO_STAGE.key)) {
byKey.set(NO_STAGE.key, { ...NO_STAGE, deliverables: [] });
}
byKey.get(NO_STAGE.key)!.deliverables.push(d);
continue;
}
if (!byKey.has(cs.slug)) {
byKey.set(cs.slug, {
key: cs.slug,
label: cs.name,
accent: "#2563EB",
order: cs.order,
deliverables: [],
});
}
byKey.get(cs.slug)!.deliverables.push(d);
}
return Array.from(byKey.values()).sort((a, b) => a.order - b.order);
}
function DeliverableCard({ deliverable: d }: { deliverable: AllDeliverableRow }) {
const due = d.dueDate ? parseISO(d.dueDate) : null;
const overdue = due && due.getTime() < Date.now() && d.status !== "APPROVED";
// Primary assignee — same pick rule as the table view
const primary = (() => {
const active = d.stages.find(
(s) =>
["IN_PROGRESS", "IN_REVIEW", "CHANGES_REQUESTED"].includes(s.status) &&
s.assignments.length > 0
);
if (active) return active.assignments[0].user.name ?? active.assignments[0].user.email;
for (const s of d.stages) {
if (s.assignments.length > 0) {
return s.assignments[0].user.name ?? s.assignments[0].user.email;
}
}
return null;
})();
return (
<Link
href={`/projects/${d.project.id}/deliverables/${d.id}`}
className="block rounded-md border bg-[var(--card)] p-2.5 text-left shadow-[var(--shadow-xs)] transition-all hover:border-[var(--primary)] hover:shadow-[var(--shadow-sm)]"
>
{/* Top row: OMG #, priority dot, team */}
<div className="mb-1.5 flex items-center gap-1.5 text-[10px]">
{d.project.omgJobNumber && (
<span className="font-mono tabular-nums text-[var(--muted-foreground)]">
#{d.project.omgJobNumber}
</span>
)}
<span
className={cn("h-1.5 w-1.5 shrink-0 rounded-full", PRIORITY_DOT[d.priority])}
aria-label={d.priority}
/>
{d.project.clientTeam && (
<Badge
variant="outline"
className="ml-auto h-4 px-1 text-[9px] uppercase tracking-wider"
>
{d.project.clientTeam.name}
</Badge>
)}
</div>
{/* Deliverable name */}
<div className="mb-1 line-clamp-2 text-xs font-semibold leading-snug">{d.name}</div>
{/* Project name (smaller, muted) */}
<div className="mb-1.5 line-clamp-1 text-[10px] text-[var(--muted-foreground)]">
{d.project.name}
</div>
{/* Assignee + deadline */}
<div className="flex items-center justify-between text-[10px] text-[var(--muted-foreground)]">
<span className="truncate">
{primary ?? <span className="italic">Unassigned</span>}
</span>
{due && (
<span className={cn("shrink-0 tabular-nums", overdue && "font-semibold text-red-600")}>
{format(due, "MMM d")}
</span>
)}
</div>
</Link>
);
}

View file

@ -49,9 +49,22 @@ interface BoardProject {
export type BoardGroupBy = "status" | "stage";
export interface BoardPipelineStage {
slug: string;
name: string;
order: number;
color?: string | null;
}
interface ProjectBoardProps {
projects: BoardProject[];
groupBy: BoardGroupBy;
/**
* Canonical list of pipeline stages drives column order + ensures
* empty-stage columns render in stage-grouped view. When absent, the
* board falls back to "only stages that appear in the data".
*/
pipelineStages?: BoardPipelineStage[];
}
// Canonical column order for the status lens — matches ProjectStatus enum
@ -73,8 +86,8 @@ const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-gray-400",
};
export function ProjectBoard({ projects, groupBy }: ProjectBoardProps) {
const columns = computeColumns(projects, groupBy);
export function ProjectBoard({ projects, groupBy, pipelineStages }: ProjectBoardProps) {
const columns = computeColumns(projects, groupBy, pipelineStages);
if (columns.length === 0) {
return (
@ -127,7 +140,8 @@ export function ProjectBoard({ projects, groupBy }: ProjectBoardProps) {
function computeColumns(
projects: BoardProject[],
groupBy: BoardGroupBy
groupBy: BoardGroupBy,
pipelineStages?: BoardPipelineStage[]
): Array<{ key: string; label: string; accent: string; projects: BoardProject[] }> {
if (groupBy === "status") {
// Fixed canonical columns — always show all status buckets, even empty
@ -138,15 +152,46 @@ function computeColumns(
}));
}
// Pipeline-stage columns are derived from whatever stages actually appear
// in the data. Order by dominantStage.order so columns flow Pipeline →
// Completed even with a custom stage set.
// Stage-grouped view.
// If the caller provided the canonical stage list (pipeline template),
// render every stage as a column in pipeline order — even empty ones —
// so the full workflow is always visible. Otherwise fall back to
// "stages seen in the data".
if (pipelineStages && pipelineStages.length > 0) {
const cols = pipelineStages.map((s) => ({
key: s.slug,
label: s.name,
accent: s.color || "#2563EB",
projects: [] as BoardProject[],
}));
const noStageCol = {
key: "__none__",
label: "No active stage",
accent: "#9CA3AF",
projects: [] as BoardProject[],
};
const bySlug = new Map(cols.map((c) => [c.key, c]));
for (const p of projects) {
const ds = p.pipelineProgress?.dominantStage;
if (!ds) {
noStageCol.projects.push(p);
continue;
}
const col = bySlug.get(ds.slug);
if (col) col.projects.push(p);
else noStageCol.projects.push(p);
}
const result = [...cols];
if (noStageCol.projects.length > 0) result.push(noStageCol);
return result;
}
// No template provided — derive columns from the data. Stage columns
// ordered by dominantStage.order.
const byKey = new Map<
string,
{ key: string; label: string; accent: string; order: number; projects: BoardProject[] }
>();
// Bucket for projects with no active deliverables / no dominant stage
const NO_STAGE = { key: "__none__", label: "No active stage", accent: "#9CA3AF", order: 9999 };
for (const p of projects) {

View file

@ -44,15 +44,27 @@ export async function listProjects(organizationId: string, ctx: VisibilityContex
const deliverables = p.deliverables ?? [];
const stages = p.pipelineTemplate?.stages ?? [];
// Count deliverables whose FURTHEST IN-FLIGHT stage is each stage.
// For each deliverable, find the highest-order stage that isn't yet
// APPROVED/DELIVERED/SKIPPED — that's where that deliverable "lives".
const inFlight = new Set([
"NOT_STARTED",
// Where does this deliverable actually "live" right now?
//
// Two-step pick:
// 1. If any stage is actively being worked (IN_PROGRESS, IN_REVIEW,
// CHANGES_REQUESTED), take the highest-order one — that's the
// furthest-along live stage.
// 2. Otherwise fall back to the LOWEST-order NOT_STARTED stage —
// the next stage queued to start.
//
// BLOCKED is explicitly excluded because a BLOCKED stage hasn't been
// reached yet (its prereqs aren't done). That was the old bug: Dow's
// On Hold / Canceled stages have no prereqs and therefore start as
// NOT_STARTED (not BLOCKED) on fresh deliverables, so the old
// "highest-order in-flight wins" logic always picked Canceled (order
// 11) for fresh work. Preferring ACTIVE over NOT_STARTED and, for
// NOT_STARTED, the lowest order, keeps parking stages from hijacking
// the calc unless they're actually being worked.
const ACTIVE_STATUSES = new Set([
"IN_PROGRESS",
"IN_REVIEW",
"CHANGES_REQUESTED",
"BLOCKED",
]);
const stageOrder = new Map(stages.map((s) => [s.id, s.order] as const));
const stageMeta = new Map(stages.map((s) => [s.id, s] as const));
@ -60,27 +72,36 @@ export async function listProjects(organizationId: string, ctx: VisibilityContex
let completed = 0;
for (const d of deliverables) {
// Highest-order stage still in flight
let bestStageId: string | null = null;
let bestOrder = -1;
let anyInFlight = false;
let bestActiveId: string | null = null;
let bestActiveOrder = -1;
let bestUpcomingId: string | null = null;
let bestUpcomingOrder = Number.POSITIVE_INFINITY;
for (const s of d.stages) {
if (!s.stageDefinitionId) continue;
if (!inFlight.has(s.status)) continue;
anyInFlight = true;
const order = stageOrder.get(s.stageDefinitionId) ?? -1;
if (order > bestOrder) {
bestOrder = order;
bestStageId = s.stageDefinitionId;
if (ACTIVE_STATUSES.has(s.status)) {
// Highest-order actively-worked stage = furthest-along progress.
if (order > bestActiveOrder) {
bestActiveOrder = order;
bestActiveId = s.stageDefinitionId;
}
} else if (s.status === "NOT_STARTED") {
// Lowest-order NOT_STARTED = next in the queue.
if (order < bestUpcomingOrder) {
bestUpcomingOrder = order;
bestUpcomingId = s.stageDefinitionId;
}
}
}
if (!anyInFlight) {
const pickedId = bestActiveId ?? bestUpcomingId;
if (!pickedId) {
completed++;
continue;
}
if (bestStageId) {
counts.set(bestStageId, (counts.get(bestStageId) ?? 0) + 1);
}
counts.set(pickedId, (counts.get(pickedId) ?? 0) + 1);
}
// Pick the dominant stage (most deliverables at that stage)