diff --git a/AGENTS.md b/AGENTS.md index df13e531..451c567d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,6 +269,17 @@ several decisions below. are a device-local display preference in `localStorage`, not repository planning data; the densest treatment is the first-visit default, and row labels share the status line rather than consuming another vertical band. +17b. **Planning epic hierarchy is bounded, read-only and device-local.** GitHub's + issue list exposes child counts but not parent links, so the BFF fans out only + across a bounded, concurrency-limited set of candidate parents and applies + results afterward in deterministic parent order. The UI promotes an epic to + the highest priority found on its parent or visible children, nests children + only beneath that parent, and shows closed children as progress evidence. It + never edits hierarchy. Expanded epic numbers persist in localStorage and + malformed or blocked storage falls back to all epics collapsed. If filtering + removes a parent while retaining its child, the child remains top-level with + a parent breadcrumb rather than disappearing; unresolved or truncated edges + are reported honestly instead of blanking the feed. 18. **PWA push supplements rather than replaces ntfy.** Web Push is a third independent delivery channel with its own server-backed enabled flag and event matrix. Device subscriptions are persisted server-side with mode `0600`; VAPID private material is diff --git a/client/lib/api.ts b/client/lib/api.ts index d3f1a0be..715be0d4 100644 --- a/client/lib/api.ts +++ b/client/lib/api.ts @@ -381,12 +381,24 @@ export interface PlanningItem { createdAt: string; updatedAt: string; commentCount: number; + /** Sub-issue count reported by GitHub; 0 for anything that is not an epic. */ + childCount: number; + /** Completed sub-issues, never greater than `childCount`. */ + completedChildCount: number; + /** + * The epic this item belongs to, resolved by the BFF's bounded fan-out. + * `null` means top-level *or* an edge nobody spent a request to discover, so + * it is never evidence that an item has no parent. + */ + parentNumber: number | null; } export interface PlanningSnapshot { repository: { owner: string; repo: string; url: string }; items: PlanningItem[]; truncated: boolean; + /** True when more epics existed than the BFF resolved parent links for. */ + epicsTruncated: boolean; fetchedAt: string; } diff --git a/client/lib/planningGroups.ts b/client/lib/planningGroups.ts index 6be29407..b428fd35 100644 --- a/client/lib/planningGroups.ts +++ b/client/lib/planningGroups.ts @@ -1,11 +1,30 @@ -import type { PlanningItem } from "./api.js"; +import type { PlanningItem, PlanningItemState, PlanningItemType } from "./api.js"; export type PlanningPriority = "high" | "medium" | "low"; export type PlanningSectionId = "conflict" | PlanningPriority | "none"; +/** + * One top-level row and the epic children folded underneath it. + * + * A child is never *also* a top-level row: the whole point of the hierarchy is + * that an epic collapses to one line. But a child whose parent is missing from + * the filtered input — closed, filtered out, or beyond the BFF's 500-record + * window — still gets its own row, with `orphanedParentNumber` set so the UI + * can say where it belongs instead of the row silently disappearing. + */ +export interface PlanningNode { + item: PlanningItem; + /** Children present in this input, in input order. */ + children: PlanningItem[]; + /** Children GitHub counted that this input does not contain. */ + unresolvedChildCount: number; + /** Set when this item names a parent that is not in the input set. */ + orphanedParentNumber: number | null; +} + export interface PlanningTagGroup { label: string; - items: PlanningItem[]; + nodes: PlanningNode[]; } export interface PlanningSection { @@ -14,7 +33,10 @@ export interface PlanningSection { subtitle: string; defaultOpen: boolean; groups: PlanningTagGroup[]; + /** Every item in the section, parents and children alike. */ count: number; + /** Nodes that actually fold at least one child. */ + epicCount: number; } const PRIORITY_LABELS: Record = { @@ -23,7 +45,9 @@ const PRIORITY_LABELS: Record = { low: "priority:low", }; -const SECTION_DEFINITIONS: Array> = [ +const PRIORITY_ORDER: PlanningPriority[] = ["high", "medium", "low"]; + +const SECTION_DEFINITIONS: Array> = [ { id: "conflict", title: "Needs triage", subtitle: "Conflicting priority labels", defaultOpen: true }, { id: "high", title: "Work now", subtitle: PRIORITY_LABELS.high, defaultOpen: true }, { id: "medium", title: "Plan next", subtitle: PRIORITY_LABELS.medium, defaultOpen: false }, @@ -37,10 +61,38 @@ function normalizedLabel(label: string): string { export function planningPriorities(item: PlanningItem): PlanningPriority[] { const labels = new Set(item.labels.map(normalizedLabel)); - return (Object.keys(PRIORITY_LABELS) as PlanningPriority[]) - .filter((priority) => labels.has(PRIORITY_LABELS[priority])); + return PRIORITY_ORDER.filter((priority) => labels.has(PRIORITY_LABELS[priority])); +} + +/** + * Applies the page filters without destroying a visible epic's progress evidence. + * Children of a matching parent stay in the input regardless of their own state; + * a matching child with a filtered-out parent stays alone and becomes a breadcrumb + * node in `groupPlanningItems`. + */ +export function filterPlanningItems( + items: PlanningItem[], + type: "all" | PlanningItemType, + state: "all" | PlanningItemState, +): PlanningItem[] { + const included = new Set(); + const childrenByParent = new Map(); + for (const item of items) { + if (item.parentNumber !== null) { + childrenByParent.set(item.parentNumber, [...(childrenByParent.get(item.parentNumber) ?? []), item]); + } + if ((type === "all" || item.type === type) && (state === "all" || item.state === state)) { + included.add(item.number); + } + } + for (const item of items) { + if (!included.has(item.number) || item.childCount === 0) continue; + for (const child of childrenByParent.get(item.number) ?? []) included.add(child.number); + } + return items.filter((item) => included.has(item.number)); } +/** Computed from the parent alone — children never rename the group. */ function primaryTag(item: PlanningItem): string { return item.labels .filter((label) => !normalizedLabel(label).startsWith("priority:")) @@ -48,21 +100,57 @@ function primaryTag(item: PlanningItem): string { ?? "Untagged"; } -function itemSection(item: PlanningItem): PlanningSectionId { - const priorities = planningPriorities(item); - if (priorities.length > 1) return "conflict"; - return priorities[0] ?? "none"; +/** + * A node's own conflict always wins: it needs triage whatever its children say. + * Otherwise the node sits at the highest priority anywhere in the epic, so a + * `priority:high` child cannot be buried inside an unlabelled parent. This is + * the deliberate exception to "the exact label selects the section". + */ +function nodeSection(node: PlanningNode): PlanningSectionId { + const own = planningPriorities(node.item); + if (own.length > 1) return "conflict"; + const present = new Set(own); + for (const child of node.children) { + for (const priority of planningPriorities(child)) present.add(priority); + } + return PRIORITY_ORDER.find((priority) => present.has(priority)) ?? "none"; } export function groupPlanningItems(items: PlanningItem[]): PlanningSection[] { + const numbers = new Set(items.map((item) => item.number)); + const nodes: PlanningNode[] = []; + const byNumber = new Map(); + + for (const item of items) { + const parentNumber = item.parentNumber !== null && item.parentNumber !== item.number ? item.parentNumber : null; + if (parentNumber !== null && numbers.has(parentNumber)) continue; + const node: PlanningNode = { + item, + children: [], + unresolvedChildCount: Math.max(0, item.childCount), + orphanedParentNumber: parentNumber, + }; + nodes.push(node); + if (!byNumber.has(item.number)) byNumber.set(item.number, node); + } + + for (const item of items) { + const parentNumber = item.parentNumber; + if (parentNumber === null || parentNumber === item.number || !numbers.has(parentNumber)) continue; + const parent = byNumber.get(parentNumber); + if (!parent) continue; + parent.children.push(item); + parent.unresolvedChildCount = Math.max(0, parent.item.childCount - parent.children.length); + } + return SECTION_DEFINITIONS.flatMap((definition) => { - const sectionItems = items.filter((item) => itemSection(item) === definition.id); - if (sectionItems.length === 0) return []; + const sectionNodes = nodes.filter((node) => nodeSection(node) === definition.id); + if (sectionNodes.length === 0) return []; - const grouped = new Map(); - for (const item of sectionItems) { - const tag = primaryTag(item); - grouped.set(tag, [...(grouped.get(tag) ?? []), item]); + const grouped = new Map(); + for (const node of sectionNodes) { + const tag = primaryTag(node.item); + grouped.set(tag, [...(grouped.get(tag) ?? []), node]); } const groups = [...grouped.entries()] @@ -71,8 +159,13 @@ export function groupPlanningItems(items: PlanningItem[]): PlanningSection[] { if (right === "Untagged") return -1; return left.localeCompare(right, undefined, { sensitivity: "base" }); }) - .map(([label, groupedItems]) => ({ label, items: groupedItems })); + .map(([label, groupedNodes]) => ({ label, nodes: groupedNodes })); - return [{ ...definition, groups, count: sectionItems.length }]; + return [{ + ...definition, + groups, + count: sectionNodes.reduce((total, node) => total + 1 + node.children.length, 0), + epicCount: sectionNodes.filter((node) => node.children.length > 0).length, + }]; }); } diff --git a/client/lib/planningView.ts b/client/lib/planningView.ts new file mode 100644 index 00000000..e1c45086 --- /dev/null +++ b/client/lib/planningView.ts @@ -0,0 +1,66 @@ +// client/lib/planningView.ts +// +// Device-local expand state for the planning page's epics, following the same +// pattern as notificationView: an injectable Storage so this is unit-testable, +// an exported normalizer, and storage failures that degrade to defaults rather +// than throwing on a render path. +// +// This is a display preference, not planning data. Which epics a phone has +// open says nothing about the repository, so it must not become server state. + +export const PLANNING_VIEW_STORAGE_KEY = "opencode.planning.view"; + +/** Bounded so a long session of clicking cannot grow localStorage without limit. */ +export const PLANNING_VIEW_LIMITS = { expandedEpics: 200 } as const; + +export interface PlanningViewState { + /** Issue numbers of the epics this device has expanded. */ + expandedEpics: number[]; +} + +/** + * Epics start COLLAPSED. Folding a backlog down to its parents is the whole + * point of the hierarchy, so an empty list is the correct first-visit state. + */ +export const DEFAULT_PLANNING_VIEW: PlanningViewState = { expandedEpics: [] }; + +export function normalizePlanningView(raw: unknown): PlanningViewState { + const source = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; + const candidates = source.expandedEpics; + if (!Array.isArray(candidates)) return { expandedEpics: [] }; + + const expandedEpics: number[] = []; + const seen = new Set(); + for (const entry of candidates) { + const number = typeof entry === "number" ? entry : Number.NaN; + if (!Number.isSafeInteger(number) || number < 0 || seen.has(number)) continue; + seen.add(number); + expandedEpics.push(number); + if (expandedEpics.length >= PLANNING_VIEW_LIMITS.expandedEpics) break; + } + return { expandedEpics }; +} + +/** Corrupt or blocked storage falls back to the default rather than throwing. */ +export function loadPlanningView(storage?: Pick): PlanningViewState { + try { + const raw = (storage ?? localStorage).getItem(PLANNING_VIEW_STORAGE_KEY); + if (raw === null) return { expandedEpics: [] }; + return normalizePlanningView(JSON.parse(raw)); + } catch { + return { expandedEpics: [] }; + } +} + +export function savePlanningView( + state: PlanningViewState, + storage?: Pick, +): PlanningViewState { + const normalized = normalizePlanningView(state); + try { + (storage ?? localStorage).setItem(PLANNING_VIEW_STORAGE_KEY, JSON.stringify(normalized)); + } catch { + // Storage may be blocked by browser privacy settings; the in-memory view still works. + } + return normalized; +} diff --git a/client/pages/Planning.tsx b/client/pages/Planning.tsx index debc2a55..4f7aac74 100644 --- a/client/pages/Planning.tsx +++ b/client/pages/Planning.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { AlertTriangle, ChevronRight, ExternalLink, GitPullRequest, MessageSquare, Plus, RefreshCw } from "lucide-react"; +import { Fragment, useEffect, useState } from "react"; +import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Circle, ExternalLink, GitPullRequest, Layers3, MessageSquare, Plus, RefreshCw } from "lucide-react"; import { useSearchParams } from "react-router-dom"; import { Alert } from "../ds/alert.js"; @@ -15,7 +15,8 @@ import { type PlanningItemType, type PlanningSnapshot, } from "../lib/api.js"; -import { groupPlanningItems, type PlanningSection } from "../lib/planningGroups.js"; +import { filterPlanningItems, groupPlanningItems, type PlanningNode, type PlanningSection } from "../lib/planningGroups.js"; +import { loadPlanningView, savePlanningView } from "../lib/planningView.js"; type TypeFilter = "all" | PlanningItemType; type StateFilter = "all" | PlanningItemState; @@ -126,30 +127,51 @@ function labelBadge(label: string): BadgeVariant { } } -function PlanningRow({ item, density, conflict, onOpen }: { - item: PlanningItem; +function PlanningRow({ node, density, conflict, expanded, onOpen, onToggle }: { + node: PlanningNode; density: PlanningDensity; conflict: boolean; + expanded: boolean; onOpen: (item: PlanningItem) => void; + onToggle: (number: number) => void; }) { + const item = node.item; const status = stateBadge(item); const classes = DENSITY_CLASSES[density]; + const expandable = node.children.length > 0; + const epic = item.childCount > 0; const badgeClass = density === "densest" ? "px-1.5 py-0 text-[10px]" : density === "denser" ? "px-2 py-0 text-[10px]" : ""; return ( -
  • +
  • - + {expandable ? ( + + ) : ( + + )}
    + {epic && ( + + + )} {item.type === "pull_request" ? "Pull request" : "Issue"} @@ -193,6 +215,9 @@ function PlanningRow({ item, density, conflict, onOpen }: {
    + {node.orphanedParentNumber !== null && ( + Child of #{node.orphanedParentNumber} + )} Created {formatDate(item.createdAt)} Last activity {formatDate(item.updatedAt)} {item.author && by {item.author}} @@ -203,16 +228,90 @@ function PlanningRow({ item, density, conflict, onOpen }: { )}
    + {epic && ( +
    + {item.completedChildCount}/{item.childCount} closed + + + + {node.unresolvedChildCount > 0 && {node.unresolvedChildCount} not loaded} +
    + )} +
    +
    +
  • + ); +} + +function PlanningChildRow({ item, density, onOpen }: { + item: PlanningItem; + density: PlanningDensity; + onOpen: (item: PlanningItem) => void; +}) { + const badgeClass = density === "densest" || density === "denser" ? "px-1.5 py-0 text-[10px]" : ""; + const spacing = density === "comfortable" ? "py-3" : density === "compact" ? "py-2.5" : "py-2"; + return ( +
  • +
    + + {item.state === "closed" ? +
    +
    + #{item.number} + + + +
    +
    + {item.state === "closed" ? "Closed" : "Open"} + {item.labels.map((label) => ( + {label} + ))} +
  • ); } -function PlanningGroupSection({ section, density, onOpen }: { +function PlanningGroupSection({ section, density, expandedEpics, onOpen, onToggleEpic }: { section: PlanningSection; density: PlanningDensity; + expandedEpics: Set; onOpen: (item: PlanningItem) => void; + onToggleEpic: (number: number) => void; }) { const [open, setOpen] = useState(section.defaultOpen); const isConflict = section.id === "conflict"; @@ -237,31 +336,46 @@ function PlanningGroupSection({ section, density, onOpen }: {

    {section.subtitle}

    - - {section.count} {section.count === 1 ? "item" : "items"} - +
    + {section.epicCount > 0 && {section.epicCount} {section.epicCount === 1 ? "epic" : "epics"}} + + {section.count} {section.count === 1 ? "item" : "items"} + +
    - {section.groups.map((tagGroup) => ( + {section.groups.map((tagGroup) => { + const count = tagGroup.nodes.reduce((total, node) => total + 1 + node.children.length, 0); + return (

    {tagGroup.label}

    - {tagGroup.items.length} + {count}
      - {tagGroup.items.map((item) => ( - - ))} + {tagGroup.nodes.map((node) => { + const expanded = expandedEpics.has(node.item.number); + return ( + + + {expanded && node.children.map((child) => ( + + ))} + + ); + })}
    - ))} + ); + })}
    ); @@ -275,6 +389,7 @@ export function PlanningPage() { const [typeFilter, setTypeFilter] = useState("all"); const [stateFilter, setStateFilter] = useState("open"); const [density, setDensity] = useState(initialDensity); + const [expandedEpicNumbers, setExpandedEpicNumbers] = useState(() => loadPlanningView().expandedEpics); const [createOpen, setCreateOpen] = useState(() => params.get("create") === "1"); const [created, setCreated] = useState(null); @@ -306,11 +421,13 @@ export function PlanningPage() { }; }, []); - const items = (snapshot?.items ?? []).filter((item) => - (typeFilter === "all" || item.type === typeFilter) - && (stateFilter === "all" || item.state === stateFilter), - ); + const items = filterPlanningItems(snapshot?.items ?? [], typeFilter, stateFilter); const sections = groupPlanningItems(items); + const expandedEpics = new Set(expandedEpicNumbers); + const visibleEpicNumbers = sections.flatMap((section) => section.groups.flatMap((group) => + group.nodes.filter((node) => node.children.length > 0).map((node) => node.item.number))); + const everyVisibleEpicExpanded = visibleEpicNumbers.length > 0 + && visibleEpicNumbers.every((number) => expandedEpics.has(number)); const itemParam = params.get("item"); const selectedItemNumber = itemParam && /^[1-9]\d*$/u.test(itemParam) && Number.isSafeInteger(Number(itemParam)) ? Number(itemParam) @@ -325,6 +442,18 @@ export function PlanningPage() { } }; + const updateExpandedEpics = (next: number[]) => { + const saved = savePlanningView({ expandedEpics: next }); + setExpandedEpicNumbers(saved.expandedEpics); + }; + + const toggleEpic = (number: number) => { + const next = new Set(expandedEpicNumbers); + if (next.has(number)) next.delete(number); + else next.add(number); + updateExpandedEpics([...next]); + }; + return (
    @@ -439,6 +568,24 @@ export function PlanningPage() { ))} + {visibleEpicNumbers.length > 0 && ( + + )} @@ -454,6 +601,11 @@ export function PlanningPage() { GitHub returned more than 500 items. This list shows the 500 most recently active records. )} + {snapshot?.epicsTruncated && ( + + Some epic relationships were not loaded because the repository has more parent issues than this view resolves at once. Unresolved items remain visible at top level. + + )} {loading && !snapshot ? (
    @@ -476,8 +628,10 @@ export function PlanningPage() { {sections.map((section) => ( setParams({ item: String(item.number) })} + onToggleEpic={toggleEpic} section={section} /> ))} diff --git a/client/simulator/publicSimulator.ts b/client/simulator/publicSimulator.ts index aa0eacf2..042c4951 100644 --- a/client/simulator/publicSimulator.ts +++ b/client/simulator/publicSimulator.ts @@ -61,10 +61,10 @@ const baseMessages: RawMessage[] = [ ]; const planningItems: PlanningItem[] = [ - { id: "112", number: 112, type: "issue", title: "CICD. add a preview deployed pr step", state: "open", merged: false, labels: ["priority:medium", "deployment"], author: "leoncheng57", url: "https://github.com/leoncheng57/custom-dca-opencode/issues/112", createdAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-25T18:00:00Z", commentCount: 1 }, - { id: "153", number: 153, type: "issue", title: "Use GitHub's deployment infra to build a public simulator", state: "open", merged: false, labels: ["priority:high", "deployment"], author: "leoncheng57", url: "https://github.com/leoncheng57/custom-dca-opencode/issues/153", createdAt: "2026-08-24T09:00:00Z", updatedAt: "2026-08-25T19:00:00Z", commentCount: 2 }, - { id: "178", number: 178, type: "pull_request", title: "Show native task child model provenance", state: "open", merged: false, labels: ["priority:medium", "frontend"], author: "contributor", url: "https://github.com/leoncheng57/custom-dca-opencode/pull/178", createdAt: "2026-08-25T13:00:00Z", updatedAt: "2026-08-25T20:00:00Z", commentCount: 3 }, - { id: "109", number: 109, type: "issue", title: "Publish the migrated agent-skills catalog", state: "closed", merged: false, labels: ["priority:low", "documentation"], author: "leoncheng57", url: "https://github.com/leoncheng57/custom-dca-opencode/issues/109", createdAt: "2026-08-20T09:00:00Z", updatedAt: "2026-08-23T19:00:00Z", commentCount: 4 }, + { id: "112", number: 112, type: "issue", title: "CICD. add a preview deployed pr step", state: "open", merged: false, labels: ["priority:medium", "deployment"], author: "leoncheng57", url: "https://github.com/leoncheng57/custom-dca-opencode/issues/112", createdAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-25T18:00:00Z", commentCount: 1, childCount: 0, completedChildCount: 0, parentNumber: 153 }, + { id: "153", number: 153, type: "issue", title: "Use GitHub's deployment infra to build a public simulator", state: "open", merged: false, labels: ["priority:high", "deployment"], author: "leoncheng57", url: "https://github.com/leoncheng57/custom-dca-opencode/issues/153", createdAt: "2026-08-24T09:00:00Z", updatedAt: "2026-08-25T19:00:00Z", commentCount: 2, childCount: 2, completedChildCount: 1, parentNumber: null }, + { id: "178", number: 178, type: "pull_request", title: "Show native task child model provenance", state: "open", merged: false, labels: ["priority:medium", "frontend"], author: "contributor", url: "https://github.com/leoncheng57/custom-dca-opencode/pull/178", createdAt: "2026-08-25T13:00:00Z", updatedAt: "2026-08-25T20:00:00Z", commentCount: 3, childCount: 0, completedChildCount: 0, parentNumber: null }, + { id: "109", number: 109, type: "issue", title: "Publish the migrated agent-skills catalog", state: "closed", merged: false, labels: ["priority:low", "documentation"], author: "leoncheng57", url: "https://github.com/leoncheng57/custom-dca-opencode/issues/109", createdAt: "2026-08-20T09:00:00Z", updatedAt: "2026-08-23T19:00:00Z", commentCount: 4, childCount: 0, completedChildCount: 0, parentNumber: 153 }, ]; const defaultPreferences: NotificationPreferences = { @@ -249,10 +249,10 @@ export function createPublicSimulator(): typeof fetch { if (path === "/api/workspace/commits") return response({ commits: [{ sha: "abc123456789", shortSha: "abc1234", subject: "Add PR preview deployment", author: "Preview Contributor", authoredAt: "2026-08-25T18:30:00Z" }, { sha: "def456789012", shortSha: "def4567", subject: "Add deterministic simulator fixtures", author: "Preview Contributor", authoredAt: "2026-08-25T18:00:00Z" }] }); if (path === "/api/worktrees") return response({ worktrees: [{ name: "preview-pipeline", branch: "feat/pr-preview-pipeline", directory: `${SIMULATOR_DIRECTORY}.worktrees/preview-pipeline` }] }); - if (path === "/api/planning/items") return response({ repository: { owner: "leoncheng57", repo: "custom-dca-opencode", url: "https://github.com/leoncheng57/custom-dca-opencode" }, items: planningItems, truncated: false, fetchedAt: new Date().toISOString() }); + if (path === "/api/planning/items") return response({ repository: { owner: "leoncheng57", repo: "custom-dca-opencode", url: "https://github.com/leoncheng57/custom-dca-opencode" }, items: planningItems, truncated: false, epicsTruncated: false, fetchedAt: new Date().toISOString() }); if (path === "/api/planning/labels") return response({ labels: ["deployment", "documentation", "frontend", "priority:high", "priority:medium", "priority:low"].map((name) => ({ name, description: `${name} work` })), truncated: false }); if (path === "/api/planning/issues" && method === "POST") { - const issue: PlanningItem = { id: `sim-${planningItems.length}`, number: 900 + planningItems.length, type: "issue", title: String(body.title), state: "open", merged: false, labels: body.labels || [], author: "preview-user", url: "https://github.com/leoncheng57/custom-dca-opencode/issues", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), commentCount: 0 }; + const issue: PlanningItem = { id: `sim-${planningItems.length}`, number: 900 + planningItems.length, type: "issue", title: String(body.title), state: "open", merged: false, labels: body.labels || [], author: "preview-user", url: "https://github.com/leoncheng57/custom-dca-opencode/issues", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), commentCount: 0, childCount: 0, completedChildCount: 0, parentNumber: null }; planningItems.push(issue); return response({ issue }, 201); } const planningRoute = routeMatch(path, /^\/api\/planning\/items\/(\d+)(\/labels)?$/u); diff --git a/server/github-planning.ts b/server/github-planning.ts index d803d135..5d2e5153 100644 --- a/server/github-planning.ts +++ b/server/github-planning.ts @@ -32,6 +32,16 @@ export const PLANNING_LIMITS = { detailBodyCharacters: 20_000, commentBodyCharacters: 8_000, comments: 50, + /** + * Parent/child ("epic") edges cost one extra request per parent, because the + * list endpoint reports a child *count* but never a parent link. These three + * bound that fan-out: how many epics we will resolve at all, how many of + * those requests may be open at once, and how many children we read from any + * single epic. + */ + epics: 25, + epicConcurrency: 4, + epicChildren: 100, } as const; export type PlanningItemType = "issue" | "pull_request"; @@ -66,6 +76,16 @@ export interface PlanningItem { createdAt: string; updatedAt: string; commentCount: number; + /** `sub_issues_summary.total`; 0 for anything that is not an epic. */ + childCount: number; + /** `sub_issues_summary.completed`, clamped to at most `childCount`. */ + completedChildCount: number; + /** + * Resolved by the snapshot's bounded `/sub_issues` fan-out, never by the + * list endpoint, which carries no parent link at all. `null` means either + * top-level or an edge we did not spend a request to discover. + */ + parentNumber: number | null; } export interface PlanningSnapshot { @@ -73,6 +93,8 @@ export interface PlanningSnapshot { items: PlanningItem[]; /** True when more records exist than PLANNING_LIMITS allows us to fetch. */ truncated: boolean; + /** True when more epics were discovered than PLANNING_LIMITS.epics resolves. */ + epicsTruncated: boolean; fetchedAt: string; } @@ -153,6 +175,17 @@ function commentsUrl(number: number): URL { return url; } +/** + * The only endpoint that names an epic's children. The list endpoint carries + * `sub_issues_summary` but no parent link, and the single-issue endpoint's + * `parent_issue_url` would cost one request *per item* rather than per parent. + */ +function subIssuesUrl(number: number): URL { + const url = new URL(`${itemUrl(number).pathname}/sub_issues`, githubApi()); + url.searchParams.set("per_page", String(PLANNING_LIMITS.epicChildren)); + return url; +} + /** * GitHub signals an exhausted rate limit with 403 plus a zero remaining header * far more often than with 429, so both have to map to "Rate limited" or the UI @@ -305,6 +338,12 @@ function webUrl(value: unknown): string { } } +/** Absent, malformed, fractional and negative counters all read as zero. */ +function counter(value: unknown): number { + const number = Number(value); + return Number.isSafeInteger(number) && number >= 0 ? number : 0; +} + export function normalizePlanningItem(raw: Record): PlanningItem | null { const number = Number(raw.number); if (!Number.isSafeInteger(number) || number <= 0) return null; @@ -314,6 +353,12 @@ export function normalizePlanningItem(raw: Record): PlanningIte const mergedAt = isPull ? (pull as { merged_at?: unknown }).merged_at : undefined; const title = String(raw.title ?? ""); + const rawSummary = raw.sub_issues_summary; + const summary = rawSummary && typeof rawSummary === "object" && !Array.isArray(rawSummary) + ? (rawSummary as Record) + : {}; + const childCount = counter(summary.total); + return { id: String(raw.id ?? `${isPull ? "pull" : "issue"}-${number}`), number, @@ -329,6 +374,10 @@ export function normalizePlanningItem(raw: Record): PlanningIte createdAt: String(raw.created_at ?? ""), updatedAt: String(raw.updated_at ?? ""), commentCount: Number.isFinite(Number(raw.comments)) ? Math.max(0, Math.trunc(Number(raw.comments))) : 0, + childCount, + completedChildCount: Math.min(childCount, counter(summary.completed)), + // Only the snapshot fan-out may set this; a single record never knows. + parentNumber: null, }; } @@ -421,6 +470,68 @@ export async function getPlanningItemDetails(value: unknown): Promise Promise): Promise { + if (count <= 0) return; + let cursor = 0; + const lanes = Array.from({ length: Math.max(1, Math.min(limit, count)) }, async () => { + while (cursor < count) { + const index = cursor; + cursor += 1; + await worker(index); + } + }); + await Promise.all(lanes); +} + +/** + * Resolves parent/child edges for the snapshot and reports whether more epics + * existed than we were willing to spend requests on. + * + * Fails open per parent, exactly like the comments fetch in + * getPlanningItemDetails: an epic whose `/sub_issues` rejects or answers with + * something other than an array simply contributes no edges. One unlucky epic + * must never blank a backlog that is mostly about other work. + * + * Requests run concurrently but assignments are applied afterwards in parent + * order, so a child claimed by two parents always lands on the same one. + */ +async function resolveEpicEdges(items: PlanningItem[]): Promise { + const byNumber = new Map(items.map((item) => [item.number, item])); + // Pull requests never have sub-issues, so they are never candidate parents. + const candidates = items + .filter((item) => item.type === "issue" && item.childCount > 0) + .sort((left, right) => right.number - left.number); + const epicsTruncated = candidates.length > PLANNING_LIMITS.epics; + const parents = epicsTruncated ? candidates.slice(0, PLANNING_LIMITS.epics) : candidates; + if (parents.length === 0) return false; + + const responses: unknown[] = new Array(parents.length).fill(null); + await withConcurrency(parents.length, PLANNING_LIMITS.epicConcurrency, async (index) => { + try { + responses[index] = await planningRequest(subIssuesUrl(parents[index].number)); + } catch { + responses[index] = null; + } + }); + + parents.forEach((parent, index) => { + const body = responses[index]; + if (!Array.isArray(body)) return; + for (const entry of body.slice(0, PLANNING_LIMITS.epicChildren)) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const number = Number((entry as { number?: unknown }).number); + if (!Number.isSafeInteger(number) || number === parent.number) continue; + const child = byNumber.get(number); + // Unknown numbers are children outside the fetched window; keep the edge unresolved. + if (!child || child.parentNumber !== null) continue; + child.parentNumber = parent.number; + } + }); + + return epicsTruncated; +} + async function loadSnapshot(): Promise { const items: PlanningItem[] = []; let truncated = false; @@ -433,7 +544,8 @@ async function loadSnapshot(): Promise { if (!result.hasNext) break; if (page === PLANNING_LIMITS.pages) truncated = true; } - return { repository: { ...PLANNING_REPOSITORY }, items, truncated, fetchedAt: new Date().toISOString() }; + const epicsTruncated = await resolveEpicEdges(items); + return { repository: { ...PLANNING_REPOSITORY }, items, truncated, epicsTruncated, fetchedAt: new Date().toISOString() }; } let cached: { snapshot: PlanningSnapshot; expiresAt: number } | null = null; diff --git a/tests/e2e/mock-preview.ts b/tests/e2e/mock-preview.ts index 86bc4aae..8afc9b06 100644 --- a/tests/e2e/mock-preview.ts +++ b/tests/e2e/mock-preview.ts @@ -15,8 +15,14 @@ createServer((req, res) => { res.end(JSON.stringify({ detailRequests, mergeBody })); return; } - const planningDetail = req.url?.match(/^\/repos\/leoncheng57\/custom-dca-opencode\/issues\/(101|102)$/u); - const planningComments = req.url?.match(/^\/repos\/leoncheng57\/custom-dca-opencode\/issues\/(101|102)\/comments\?/u); + const planningDetail = req.url?.match(/^\/repos\/leoncheng57\/custom-dca-opencode\/issues\/(101|102|106|107)$/u); + const planningComments = req.url?.match(/^\/repos\/leoncheng57\/custom-dca-opencode\/issues\/(101|102|106|107)\/comments\?/u); + const planningSubIssues = req.url?.match(/^\/repos\/leoncheng57\/custom-dca-opencode\/issues\/(101)\/sub_issues\?/u); + if (planningSubIssues && req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify([{ number: 106 }, { number: 107 }])); + return; + } if (planningComments && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(planningComments[1] === "101" ? [ @@ -32,16 +38,22 @@ createServer((req, res) => { user: { login: "maintainer" }, created_at: "2026-08-23T11:00:00Z", }, - ] : [{ id: 2001, body: "PR conversation comment.", user: { login: "reviewer" }, created_at: "2026-08-23T12:00:00Z" }])); + ] : planningComments[1] === "102" ? [{ id: 2001, body: "PR conversation comment.", user: { login: "reviewer" }, created_at: "2026-08-23T12:00:00Z" }] : [])); return; } if (planningDetail && req.method === "GET") { - const isPull = planningDetail[1] === "102"; + const number = Number(planningDetail[1]); + const isPull = number === 102; + const title = number === 106 + ? "Polish compact planning controls" + : number === 107 + ? "Document the mobile planning layout" + : isPull ? "Add the project planning feed" : "Improve the mobile planning view"; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ - id: Number(planningDetail[1]), - number: Number(planningDetail[1]), - title: isPull ? "Add the project planning feed" : "Improve the mobile planning view", + id: number, + number, + title, body: isPull ? "## Pull request description\n\nReady for review." : "## Planning context\n\nMake the roadmap easier to scan.\n\n
    unsafe
    ", state: "open", labels: isPull @@ -52,6 +64,7 @@ createServer((req, res) => { created_at: isPull ? "2026-08-15T10:00:00Z" : "2026-08-12T09:00:00Z", updated_at: isPull ? "2026-08-22T08:15:00Z" : "2026-08-21T16:30:00Z", comments: isPull ? 1 : 4, + ...(number === 101 ? { sub_issues_summary: { total: 2, completed: 1, percent_completed: 50 } } : {}), ...(isPull ? { pull_request: { merged_at: null } } : {}), })); return; @@ -61,11 +74,12 @@ createServer((req, res) => { req.on("data", (chunk) => (raw += chunk)); req.on("end", () => { const input = JSON.parse(raw) as { labels: string[] }; - const isPull = planningDetail[1] === "102"; + const number = Number(planningDetail[1]); + const isPull = number === 102; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ - id: Number(planningDetail[1]), - number: Number(planningDetail[1]), + id: number, + number, title: isPull ? "Add the project planning feed" : "Improve the mobile planning view", state: "open", labels: input.labels.map((name) => ({ name })), @@ -74,6 +88,7 @@ createServer((req, res) => { created_at: isPull ? "2026-08-15T10:00:00Z" : "2026-08-12T09:00:00Z", updated_at: "2026-08-25T12:00:00Z", comments: isPull ? 1 : 4, + ...(number === 101 ? { sub_issues_summary: { total: 2, completed: 1, percent_completed: 50 } } : {}), ...(isPull ? { pull_request: { merged_at: null } } : {}), })); }); @@ -94,6 +109,31 @@ createServer((req, res) => { created_at: "2026-08-12T09:00:00Z", updated_at: "2026-08-21T16:30:00Z", comments: 4, + sub_issues_summary: { total: 2, completed: 1, percent_completed: 50 }, + }, + { + id: 106, + number: 106, + title: "Polish compact planning controls", + state: "open", + labels: [{ name: "priority:high", color: "ff0000" }, { name: "frontend", color: "123456" }], + user: { login: "contributor" }, + html_url: "https://github.com/leoncheng57/custom-dca-opencode/issues/106", + created_at: "2026-08-20T09:00:00Z", + updated_at: "2026-08-24T14:00:00Z", + comments: 0, + }, + { + id: 107, + number: 107, + title: "Document the mobile planning layout", + state: "closed", + labels: [{ name: "priority:low", color: "cccccc" }, { name: "documentation", color: "123456" }], + user: { login: "maintainer" }, + html_url: "https://github.com/leoncheng57/custom-dca-opencode/issues/107", + created_at: "2026-08-20T10:00:00Z", + updated_at: "2026-08-24T15:00:00Z", + comments: 1, }, { id: 102, diff --git a/tests/e2e/planning.ui.spec.ts b/tests/e2e/planning.ui.spec.ts index dcca1ab5..e0cd761a 100644 --- a/tests/e2e/planning.ui.spec.ts +++ b/tests/e2e/planning.ui.spec.ts @@ -34,6 +34,60 @@ test.describe("project planning", () => { await expect(externalLink).toHaveAttribute("rel", "noopener noreferrer"); }); + test("collapses epics, persists keyboard expansion, and keeps closed child progress", async ({ page }) => { + await page.goto("/planning"); + + const toggle = page.getByTestId("opencode-planning-epic-101-toggle"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(page.getByTestId("opencode-planning-child-row")).toHaveCount(0); + await expect(page.getByText("Polish compact planning controls")).toHaveCount(0); + await expect(page.getByTestId("opencode-planning-epic-101-progress")).toHaveAttribute("aria-valuenow", "1"); + await expect(page.getByTestId("opencode-planning-epic-101-progress")).toHaveAttribute("aria-valuemax", "2"); + await expect(page.getByTestId("opencode-planning-section-high")).toContainText("1 epic"); + + await toggle.focus(); + await toggle.press("Enter"); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByTestId("opencode-planning-child-row")).toHaveCount(2); + await expect(page.getByTestId("opencode-planning-child-row").filter({ hasText: "Document the mobile planning layout" })).toContainText("Closed"); + + await page.reload(); + await expect(page.getByTestId("opencode-planning-epic-101-toggle")).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByText("Polish compact planning controls")).toBeVisible(); + + await page.getByTestId("opencode-planning-item-106").click(); + await expect(page.getByTestId("opencode-planning-item-dialog")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Polish compact planning controls" })).toBeVisible(); + await page.getByTestId("opencode-planning-item-close").click(); + + await page.getByTestId("opencode-planning-epics-toggle-all").click(); + await expect(page.getByTestId("opencode-planning-epic-101-toggle")).toHaveAttribute("aria-expanded", "false"); + await expect(page.getByTestId("opencode-planning-child-row")).toHaveCount(0); + }); + + test("falls a child back to a parent breadcrumb when filters remove its epic", async ({ page }) => { + await page.goto("/planning"); + await page.getByTestId("opencode-planning-state-closed").click(); + await page.getByTestId("opencode-planning-section-low-toggle").click(); + + const child = page.getByTestId("opencode-planning-row").filter({ hasText: "Document the mobile planning layout" }); + await expect(child).toBeVisible(); + await expect(child.getByTestId("opencode-planning-item-107-parent")).toHaveText("Child of #101"); + }); + + test("warns without blanking the feed when epic discovery is truncated", async ({ page }) => { + await page.route("**/api/planning/items", async (route) => { + const response = await route.fetch(); + const snapshot = await response.json(); + await route.fulfill({ response, json: { ...snapshot, epicsTruncated: true } }); + }); + await page.goto("/planning"); + + await expect(page.getByTestId("opencode-planning-epics-truncated")).toContainText("Some epic relationships were not loaded"); + await expect(page.getByTestId("opencode-planning-list")).toBeVisible(); + await expect(page.getByText("Improve the mobile planning view")).toBeVisible(); + }); + test("opens deep-linked issue and pull request details with safe Markdown comments", async ({ page }) => { await page.goto("/planning"); const issueTrigger = page.getByTestId("opencode-planning-item-101"); @@ -73,12 +127,12 @@ test.describe("project planning", () => { await expect(page.getByTestId("opencode-planning-item-save-success")).toHaveText("Labels updated."); await expect(page.getByTestId("opencode-planning-item-dialog")).toBeVisible(); await page.getByTestId("opencode-planning-item-close").click(); - await expect(page.getByTestId("opencode-planning-list")).toBeFocused(); + await expect(page.getByTestId("opencode-planning-item-101")).toBeFocused(); - await page.getByTestId("opencode-planning-section-medium-toggle").click(); const updatedRow = page.getByTestId("opencode-planning-row").filter({ hasText: "Improve the mobile planning view" }); await expect(updatedRow).toBeVisible(); await expect(updatedRow.getByText("priority:medium")).toBeVisible(); + await expect(page.getByTestId("opencode-planning-section-high")).toContainText("Improve the mobile planning view"); }); test("keeps a failed label edit selected and retryable", async ({ page }) => { @@ -141,6 +195,13 @@ test.describe("project planning", () => { expect(metrics.body).toBeLessThanOrEqual(metrics.viewport); await expect(page.getByText("Created Aug 12, 2026")).toBeVisible(); await expect(page.getByText("Last activity Aug 21, 2026")).toBeVisible(); + await page.getByTestId("opencode-planning-epic-101-toggle").click(); + await expect(page.getByTestId("opencode-planning-child-row")).toHaveCount(2); + const expandedMetrics = await page.evaluate(() => ({ + body: document.body.scrollWidth, + viewport: document.documentElement.clientWidth, + })); + expect(expandedMetrics.body).toBeLessThanOrEqual(expandedMetrics.viewport); }); test("changes density and persists it across reloads", async ({ page }) => { @@ -148,9 +209,11 @@ test.describe("project planning", () => { const list = page.getByTestId("opencode-planning-list"); await expect(list).toHaveAttribute("data-density", "densest"); - await page.getByTestId("opencode-planning-density-comfortable").click(); - await expect(list).toHaveAttribute("data-density", "comfortable"); - await expect(page.getByTestId("opencode-planning-density-comfortable")).toHaveAttribute("aria-pressed", "true"); + for (const density of ["comfortable", "compact", "dense", "denser", "densest", "comfortable"]) { + await page.getByTestId(`opencode-planning-density-${density}`).click(); + await expect(list).toHaveAttribute("data-density", density); + await expect(page.getByTestId(`opencode-planning-density-${density}`)).toHaveAttribute("aria-pressed", "true"); + } await page.reload(); await expect(page.getByTestId("opencode-planning-list")).toHaveAttribute("data-density", "comfortable"); diff --git a/tests/github-planning.test.ts b/tests/github-planning.test.ts index b7eb33d9..a0acdfd6 100644 --- a/tests/github-planning.test.ts +++ b/tests/github-planning.test.ts @@ -35,10 +35,21 @@ function rawItem(number: number, extra: Record = {}): Record { + return rawItem(number, { sub_issues_summary: { total, completed, percent_completed: 0 } }); +} + +function subIssuesPath(input: URL | RequestInfo): number | null { + const match = String(input).match(/\/issues\/(\d+)\/sub_issues/u); + return match ? Number(match[1]) : null; +} + afterEach(() => { resetPlanningCache(); vi.unstubAllGlobals(); @@ -80,6 +91,43 @@ describe("GitHub planning normalization", () => { expect(normalized?.labels).toHaveLength(PLANNING_LIMITS.labels); expect(normalized?.url).toBe(""); }); + + it("reads the sub-issue summary defensively and never resolves a parent on its own", () => { + expect(normalizePlanningItem(rawEpic(1, 4, 1))).toMatchObject({ + childCount: 4, + completedChildCount: 1, + parentNumber: null, + }); + + // A parent link only ever exists on the single-issue endpoint, and the + // snapshot fan-out is the only thing allowed to fill it in. + expect(normalizePlanningItem(rawItem(2, { + sub_issues_summary: { total: 3, completed: 1 }, + parent_issue_url: "https://api.github.com/repos/leoncheng57/custom-dca-opencode/issues/9", + }))?.parentNumber).toBeNull(); + + for (const summary of [ + undefined, + null, + "3", + [], + {}, + { total: -2, completed: -1 }, + { total: 1.5, completed: 0.5 }, + { total: "many", completed: "some" }, + { total: Number.NaN, completed: Number.POSITIVE_INFINITY }, + { total: Number.MAX_SAFE_INTEGER + 1, completed: Number.MAX_SAFE_INTEGER + 1 }, + ]) { + expect(normalizePlanningItem(rawItem(3, { sub_issues_summary: summary }))) + .toMatchObject({ childCount: 0, completedChildCount: 0 }); + } + }); + + it("clamps a completed count that exceeds the total", () => { + expect(normalizePlanningItem(rawEpic(1, 2, 9))).toMatchObject({ childCount: 2, completedChildCount: 2 }); + expect(normalizePlanningItem(rawItem(2, { sub_issues_summary: { total: 0, completed: 5 } }))) + .toMatchObject({ childCount: 0, completedChildCount: 0 }); + }); }); describe("GitHub planning fetch", () => { @@ -151,6 +199,153 @@ describe("GitHub planning fetch", () => { }); }); +describe("GitHub planning epic fan-out", () => { + it("makes no extra request when nothing reports sub-issues", async () => { + const fetchMock = vi.fn(async () => response([rawItem(1), rawItem(2, { pull_request: { merged_at: null } })])); + vi.stubGlobal("fetch", fetchMock); + + const snapshot = await getPlanningSnapshot(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(snapshot.epicsTruncated).toBe(false); + expect(snapshot.items.map((item) => item.parentNumber)).toEqual([null, null]); + }); + + it("resolves parent numbers from the sub_issues endpoint of every epic", async () => { + const fetchMock = vi.fn(async (input: URL | RequestInfo) => { + const parent = subIssuesPath(input); + if (parent === 30) return response([rawItem(11), rawItem(12)]); + if (parent === 20) return response([rawItem(13)]); + if (parent !== null) return response([]); + return response([rawEpic(30, 2), rawEpic(20, 1), rawItem(11), rawItem(12), rawItem(13)]); + }); + vi.stubGlobal("fetch", fetchMock); + + const snapshot = await getPlanningSnapshot(); + + expect(Object.fromEntries(snapshot.items.map((item) => [item.number, item.parentNumber]))).toEqual({ + 30: null, + 20: null, + 11: 30, + 12: 30, + 13: 20, + }); + // One list page plus exactly one request per epic. + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(String(fetchMock.mock.calls[1][0])) + .toBe(`https://api.github.com/repos/leoncheng57/custom-dca-opencode/issues/30/sub_issues?per_page=${PLANNING_LIMITS.epicChildren}`); + expect(snapshot.epicsTruncated).toBe(false); + }); + + it("never treats a pull request as an epic even when it reports sub-issues", async () => { + const fetchMock = vi.fn(async (input: URL | RequestInfo) => { + if (subIssuesPath(input) !== null) throw new Error("pull requests must not be probed"); + return response([ + rawItem(50, { pull_request: { merged_at: null }, sub_issues_summary: { total: 4, completed: 1 } }), + rawItem(11), + ]); + }); + vi.stubGlobal("fetch", fetchMock); + + const snapshot = await getPlanningSnapshot(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(snapshot.items[0]).toMatchObject({ type: "pull_request", childCount: 4 }); + expect(snapshot.items[1].parentNumber).toBeNull(); + }); + + it("fails open per epic: one rejected or malformed response leaves the others resolved", async () => { + const fetchMock = vi.fn(async (input: URL | RequestInfo) => { + const parent = subIssuesPath(input); + if (parent === 40) return response({ message: "upstream-secret-detail" }, 500); + if (parent === 30) return response({ not: "an array" }); + if (parent === 20) return response([rawItem(13)]); + return response([rawEpic(40, 1), rawEpic(30, 1), rawEpic(20, 1), rawItem(11), rawItem(12), rawItem(13)]); + }); + vi.stubGlobal("fetch", fetchMock); + + const snapshot = await getPlanningSnapshot(); + + expect(Object.fromEntries(snapshot.items.map((item) => [item.number, item.parentNumber]))).toEqual({ + 40: null, + 30: null, + 20: null, + 11: null, + 12: null, + 13: 20, + }); + expect(snapshot.epicsTruncated).toBe(false); + }); + + it("reports truncation and stops probing once the epic cap is reached", async () => { + const epics = Array.from({ length: PLANNING_LIMITS.epics + 3 }, (_, index) => rawEpic(index + 1, 1)); + const probed: number[] = []; + const fetchMock = vi.fn(async (input: URL | RequestInfo) => { + const parent = subIssuesPath(input); + if (parent !== null) { + probed.push(parent); + return response([]); + } + return response(epics); + }); + vi.stubGlobal("fetch", fetchMock); + + const snapshot = await getPlanningSnapshot(); + + expect(snapshot.epicsTruncated).toBe(true); + expect(probed).toHaveLength(PLANNING_LIMITS.epics); + // Highest numbers first, so the cap is deterministic rather than page-order luck. + expect([...probed].sort((left, right) => right - left)).toEqual(probed); + expect(probed[0]).toBe(PLANNING_LIMITS.epics + 3); + }); + + it("reads at most epicChildren entries and ignores numbers outside the snapshot", async () => { + const children = Array.from({ length: PLANNING_LIMITS.epicChildren + 5 }, (_, index) => rawItem(index + 1)); + const fetchMock = vi.fn(async (input: URL | RequestInfo) => { + if (subIssuesPath(input) !== null) { + // A self-reference and an out-of-window child must both be ignored. + return response([rawItem(9000), { number: "not-a-number" }, null, rawEpic(500, 1), ...children]); + } + return response([rawEpic(500, PLANNING_LIMITS.epicChildren + 5), ...children]); + }); + vi.stubGlobal("fetch", fetchMock); + + const snapshot = await getPlanningSnapshot(); + + expect(snapshot.items.some((item) => item.number === 9000)).toBe(false); + expect(snapshot.items.find((item) => item.number === 500)?.parentNumber).toBeNull(); + const resolved = snapshot.items.filter((item) => item.parentNumber === 500); + // Four non-child entries consume slots in the capped window before the children do. + expect(resolved).toHaveLength(PLANNING_LIMITS.epicChildren - 4); + expect(snapshot.items.find((item) => item.number === PLANNING_LIMITS.epicChildren + 5)?.parentNumber).toBeNull(); + }); + + it("gives a child claimed by two epics the higher-numbered parent, deterministically", async () => { + const fetchMock = vi.fn(async (input: URL | RequestInfo) => { + if (subIssuesPath(input) !== null) return response([rawItem(11)]); + return response([rawEpic(20, 1), rawEpic(30, 1), rawItem(11)]); + }); + vi.stubGlobal("fetch", fetchMock); + + const snapshot = await getPlanningSnapshot(); + + expect(snapshot.items.find((item) => item.number === 11)?.parentNumber).toBe(30); + }); + + it("keeps the fan-out inside the existing snapshot cache", async () => { + const fetchMock = vi.fn(async (input: URL | RequestInfo) => (subIssuesPath(input) !== null + ? response([rawItem(11)]) + : response([rawEpic(30, 1), rawItem(11)]))); + vi.stubGlobal("fetch", fetchMock); + + const first = await getPlanningSnapshot(); + const second = await getPlanningSnapshot(); + + expect(second).toBe(first); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + describe("GitHub planning issue creation", () => { it("strictly validates and normalizes create input", () => { expect(validateCreatePlanningIssue({ title: " Ship it ", body: "## Why", labels: ["frontend", "frontend", "mobile"] })) diff --git a/tests/planning-groups.test.ts b/tests/planning-groups.test.ts index b9096192..c6b0a6e8 100644 --- a/tests/planning-groups.test.ts +++ b/tests/planning-groups.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import type { PlanningItem } from "../client/lib/api.js"; -import { groupPlanningItems, planningPriorities } from "../client/lib/planningGroups.js"; +import { filterPlanningItems, groupPlanningItems, planningPriorities } from "../client/lib/planningGroups.js"; -function item(number: number, labels: string[]): PlanningItem { +function item(number: number, labels: string[], extra: Partial = {}): PlanningItem { return { id: String(number), number, @@ -17,9 +17,26 @@ function item(number: number, labels: string[]): PlanningItem { createdAt: "2026-08-01T00:00:00Z", updatedAt: "2026-08-02T00:00:00Z", commentCount: 0, + childCount: 0, + completedChildCount: 0, + parentNumber: null, + ...extra, }; } +function epic(number: number, labels: string[], childCount: number): PlanningItem { + return item(number, labels, { childCount }); +} + +function child(number: number, labels: string[], parentNumber: number): PlanningItem { + return item(number, labels, { parentNumber }); +} + +function numbersIn(sections: ReturnType): number[] { + return sections.flatMap((section) => + section.groups.flatMap((group) => group.nodes.map((node) => node.item.number))); +} + describe("planning groups", () => { it("orders every priority bucket and puts conflicts in triage once", () => { const sections = groupPlanningItems([ @@ -31,8 +48,7 @@ describe("planning groups", () => { ]); expect(sections.map((section) => section.id)).toEqual(["conflict", "high", "medium", "low", "none"]); - expect(sections.flatMap((section) => section.groups.flatMap((group) => group.items.map((entry) => entry.number)))) - .toEqual([2, 5, 4, 1, 3]); + expect(numbersIn(sections)).toEqual([2, 5, 4, 1, 3]); }); it("does not treat duplicate spellings of one priority as a conflict", () => { @@ -49,6 +65,165 @@ describe("planning groups", () => { ]); expect(sections[0].groups.map((group) => group.label)).toEqual(["frontend", "Untagged"]); - expect(sections[0].groups.flatMap((group) => group.items.map((entry) => entry.number))).toEqual([1, 3, 2]); + expect(numbersIn(sections)).toEqual([1, 3, 2]); + }); +}); + +describe("planning epic hierarchy", () => { + it("keeps every child as progress evidence when its parent matches the filters", () => { + const parent = epic(10, ["priority:high"], 2); + const openChild = child(11, [], 10); + const closedChild = child(12, [], 10); + closedChild.state = "closed"; + + expect(filterPlanningItems([parent, openChild, closedChild], "all", "open").map((entry) => entry.number)) + .toEqual([10, 11, 12]); + }); + + it("keeps a matching child without its filtered parent so grouping can show a breadcrumb", () => { + const parent = epic(10, [], 1); + const closedChild = child(11, ["priority:low"], 10); + closedChild.state = "closed"; + + const filtered = filterPlanningItems([parent, closedChild], "all", "closed"); + expect(filtered.map((entry) => entry.number)).toEqual([11]); + expect(groupPlanningItems(filtered)[0].groups[0].nodes[0].orphanedParentNumber).toBe(10); + }); + + it("folds a child into its parent and never gives it a top-level row", () => { + const sections = groupPlanningItems([ + epic(10, ["priority:high"], 2), + child(11, ["priority:high"], 10), + child(12, [], 10), + ]); + + expect(numbersIn(sections)).toEqual([10]); + const node = sections[0].groups[0].nodes[0]; + expect(node.children.map((entry) => entry.number)).toEqual([11, 12]); + expect(node.orphanedParentNumber).toBeNull(); + }); + + it("preserves input order among children", () => { + const sections = groupPlanningItems([ + epic(10, [], 3), + child(30, [], 10), + child(12, [], 10), + child(21, [], 10), + ]); + + expect(sections[0].groups[0].nodes[0].children.map((entry) => entry.number)).toEqual([30, 12, 21]); + }); + + it("renders a child whose parent is absent at top level with a breadcrumb number", () => { + const sections = groupPlanningItems([child(11, ["priority:medium"], 10)]); + + expect(numbersIn(sections)).toEqual([11]); + const node = sections[0].groups[0].nodes[0]; + expect(node.orphanedParentNumber).toBe(10); + expect(node.children).toEqual([]); + }); + + it("promotes an unlabelled epic to its highest child priority", () => { + const sections = groupPlanningItems([ + epic(10, [], 2), + child(11, ["priority:low"], 10), + child(12, ["priority:high"], 10), + ]); + + expect(sections.map((section) => section.id)).toEqual(["high"]); + expect(numbersIn(sections)).toEqual([10]); + }); + + it("counts every priority of a conflicting child toward the parent's promotion", () => { + const sections = groupPlanningItems([ + epic(10, [], 1), + child(11, ["priority:high", "priority:low"], 10), + ]); + + expect(sections.map((section) => section.id)).toEqual(["high"]); + }); + + it("keeps a parent's own priority conflict in triage regardless of its children", () => { + const sections = groupPlanningItems([ + epic(10, ["priority:medium", "priority:low"], 1), + child(11, ["priority:high"], 10), + ]); + + expect(sections.map((section) => section.id)).toEqual(["conflict"]); + expect(sections[0].groups[0].nodes[0].children.map((entry) => entry.number)).toEqual([11]); + }); + + it("leaves an epic without any priority anywhere in the no-priority section", () => { + const sections = groupPlanningItems([epic(10, [], 1), child(11, [], 10)]); + expect(sections.map((section) => section.id)).toEqual(["none"]); + }); + + it("counts children in the section total and epics only when a child resolved", () => { + const sections = groupPlanningItems([ + epic(10, ["priority:high"], 2), + child(11, [], 10), + child(12, [], 10), + epic(20, ["priority:high"], 3), + item(30, ["priority:high"]), + ]); + + expect(sections[0].count).toBe(5); + expect(sections[0].epicCount).toBe(1); + }); + + it("reports the children GitHub counted but this input does not contain", () => { + const sections = groupPlanningItems([ + epic(10, [], 5), + child(11, [], 10), + child(12, [], 10), + epic(20, [], 1), + epic(30, [], 0), + ]); + + const nodes = sections[0].groups[0].nodes; + expect(nodes.map((node) => [node.item.number, node.unresolvedChildCount])).toEqual([ + [10, 3], + [20, 1], + [30, 0], + ]); + }); + + it("never reports a negative unresolved count when more children resolve than GitHub counted", () => { + const sections = groupPlanningItems([ + epic(10, [], 1), + child(11, [], 10), + child(12, [], 10), + ]); + + expect(sections[0].groups[0].nodes[0].unresolvedChildCount).toBe(0); + }); + + it("takes the tag from the parent and ignores the children's labels", () => { + const sections = groupPlanningItems([ + epic(10, ["priority:high", "server"], 1), + child(11, ["mobile", "aaa-first-alphabetically"], 10), + ]); + + expect(sections[0].groups.map((group) => group.label)).toEqual(["server"]); + }); + + it("keeps an unlabelled epic untagged even when its children carry tags", () => { + const sections = groupPlanningItems([ + epic(10, [], 1), + child(11, ["frontend"], 10), + item(20, ["frontend"]), + ]); + + expect(sections[0].groups.map((group) => group.label)).toEqual(["frontend", "Untagged"]); + expect(numbersIn(sections)).toEqual([20, 10]); + }); + + it("treats a self-referencing parent as top-level rather than nesting it in itself", () => { + const sections = groupPlanningItems([item(10, [], { parentNumber: 10 })]); + + const node = sections[0].groups[0].nodes[0]; + expect(node.item.number).toBe(10); + expect(node.children).toEqual([]); + expect(node.orphanedParentNumber).toBeNull(); }); }); diff --git a/tests/planning-view.test.ts b/tests/planning-view.test.ts new file mode 100644 index 00000000..7f8a1c1e --- /dev/null +++ b/tests/planning-view.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + DEFAULT_PLANNING_VIEW, + loadPlanningView, + normalizePlanningView, + PLANNING_VIEW_LIMITS, + PLANNING_VIEW_STORAGE_KEY, + savePlanningView, +} from "../client/lib/planningView.js"; + +function fakeStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + values, + getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + values.set(key, value); + }), + }; +} + +describe("planning view normalization", () => { + it("collapses every epic by default", () => { + expect(DEFAULT_PLANNING_VIEW).toEqual({ expandedEpics: [] }); + }); + + it("rejects anything that is not an object with an array of epics", () => { + for (const invalid of [null, undefined, 7, "expanded", [], [1, 2], { expandedEpics: null }, { expandedEpics: "1" }, { expandedEpics: 5 }]) { + expect(normalizePlanningView(invalid)).toEqual({ expandedEpics: [] }); + } + }); + + it("drops entries that are not non-negative safe integers", () => { + expect(normalizePlanningView({ + expandedEpics: [10, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1, "12", null, {}, [], 0, 20], + })).toEqual({ expandedEpics: [10, 0, 20] }); + }); + + it("dedupes while preserving first-seen order", () => { + expect(normalizePlanningView({ expandedEpics: [30, 10, 30, 20, 10] })).toEqual({ expandedEpics: [30, 10, 20] }); + }); + + it("caps the list and ignores unknown fields", () => { + const normalized = normalizePlanningView({ + expandedEpics: Array.from({ length: PLANNING_VIEW_LIMITS.expandedEpics + 50 }, (_, index) => index + 1), + density: "compact", + }); + expect(normalized.expandedEpics).toHaveLength(PLANNING_VIEW_LIMITS.expandedEpics); + expect(normalized.expandedEpics.at(-1)).toBe(PLANNING_VIEW_LIMITS.expandedEpics); + expect(Object.keys(normalized)).toEqual(["expandedEpics"]); + }); +}); + +describe("planning view storage", () => { + it("round-trips through an injected storage under the documented key", () => { + const storage = fakeStorage(); + + expect(savePlanningView({ expandedEpics: [12, 12, -3, 7] }, storage)).toEqual({ expandedEpics: [12, 7] }); + expect(storage.setItem).toHaveBeenCalledWith(PLANNING_VIEW_STORAGE_KEY, JSON.stringify({ expandedEpics: [12, 7] })); + expect(loadPlanningView(storage)).toEqual({ expandedEpics: [12, 7] }); + }); + + it("returns the default for absent and corrupt entries", () => { + expect(loadPlanningView(fakeStorage())).toEqual({ expandedEpics: [] }); + expect(loadPlanningView(fakeStorage({ [PLANNING_VIEW_STORAGE_KEY]: "{not json" }))).toEqual({ expandedEpics: [] }); + expect(loadPlanningView(fakeStorage({ [PLANNING_VIEW_STORAGE_KEY]: "[1,2,3]" }))).toEqual({ expandedEpics: [] }); + }); + + it("returns the default when storage throws on read", () => { + const storage = { + getItem: vi.fn(() => { + throw new Error("blocked by privacy settings"); + }), + }; + expect(loadPlanningView(storage)).toEqual({ expandedEpics: [] }); + }); + + it("still returns the normalized state when storage throws on write", () => { + const storage = { + setItem: vi.fn(() => { + throw new Error("quota exceeded"); + }), + }; + expect(savePlanningView({ expandedEpics: [4, 4, 5] }, storage)).toEqual({ expandedEpics: [4, 5] }); + expect(storage.setItem).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/public-simulator.test.ts b/tests/public-simulator.test.ts new file mode 100644 index 00000000..46e19b03 --- /dev/null +++ b/tests/public-simulator.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import type { PlanningSnapshot } from "../client/lib/api.js"; +import { createPublicSimulator } from "../client/simulator/publicSimulator.js"; + +describe("public simulator planning fixtures", () => { + it("exposes a complete deterministic epic hierarchy", async () => { + const response = await createPublicSimulator()("https://preview.invalid/api/planning/items"); + const snapshot = await response.json() as PlanningSnapshot; + + expect(snapshot.epicsTruncated).toBe(false); + expect(snapshot.items.every((item) => Number.isInteger(item.childCount) + && Number.isInteger(item.completedChildCount) + && (item.parentNumber === null || Number.isInteger(item.parentNumber)))).toBe(true); + expect(snapshot.items.find((item) => item.number === 153)).toMatchObject({ childCount: 2, completedChildCount: 1 }); + expect(snapshot.items.filter((item) => item.parentNumber === 153).map((item) => item.number)).toEqual([112, 109]); + }); +});