Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions client/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
127 changes: 110 additions & 17 deletions client/lib/planningGroups.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<PlanningPriority, string> = {
Expand All @@ -23,7 +45,9 @@ const PRIORITY_LABELS: Record<PlanningPriority, string> = {
low: "priority:low",
};

const SECTION_DEFINITIONS: Array<Omit<PlanningSection, "groups" | "count">> = [
const PRIORITY_ORDER: PlanningPriority[] = ["high", "medium", "low"];

const SECTION_DEFINITIONS: Array<Omit<PlanningSection, "groups" | "count" | "epicCount">> = [
{ 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 },
Expand All @@ -37,32 +61,96 @@ 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<number>();
const childrenByParent = new Map<number, PlanningItem[]>();
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:"))
.sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" }))[0]
?? "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<PlanningPriority>(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<number, PlanningNode>();

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<string, PlanningItem[]>();
for (const item of sectionItems) {
const tag = primaryTag(item);
grouped.set(tag, [...(grouped.get(tag) ?? []), item]);
const grouped = new Map<string, PlanningNode[]>();
for (const node of sectionNodes) {
const tag = primaryTag(node.item);
grouped.set(tag, [...(grouped.get(tag) ?? []), node]);
}

const groups = [...grouped.entries()]
Expand All @@ -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,
}];
});
}
66 changes: 66 additions & 0 deletions client/lib/planningView.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) : {};
const candidates = source.expandedEpics;
if (!Array.isArray(candidates)) return { expandedEpics: [] };

const expandedEpics: number[] = [];
const seen = new Set<number>();
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<Storage, "getItem">): 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<Storage, "setItem">,
): 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;
}
Loading
Loading