setView(prevView)}
onOpenIssue={openIssue}
canWrite={backlog.status === 'ready'}
@@ -356,9 +413,15 @@ export function AppShell() {
-
-
-
+ {/* Dev-only surfaces (fixture galleries / onboarding preview) — shown in dev
+ and in demo/e2e mode; hidden in a real configured, packaged app. */}
+ {import.meta.env.DEV || demo ? (
+ <>
+
+
+
+ >
+ ) : null}
- gitea.stephenmann.io
+ {hostLabel}
Evening service}
diff --git a/apps/desktop/src/renderer/src/lib/views/deps-graph.ts b/apps/desktop/src/renderer/src/lib/views/deps-graph.ts
new file mode 100644
index 0000000..ccb77f4
--- /dev/null
+++ b/apps/desktop/src/renderer/src/lib/views/deps-graph.ts
@@ -0,0 +1,195 @@
+import { inferLifecycle, schedule } from '@commitea/core'
+
+import type { DepEdge, DepNode, DepsData } from '../../data/fixtures.js'
+import { formatShort } from '../dates.js'
+import type { ProjectData } from './project-data.js'
+
+/** Copy of backlog.ts's private toSchedulable — open issues → scheduler input. Not exported there. */
+function toSchedulable(issues: ProjectData['issues']) {
+ return issues
+ .filter((i) => i.state === 'open')
+ .map((i) => ({
+ number: i.number,
+ title: i.title,
+ labels: i.labels,
+ estimateDays: i.facts.estimateDays,
+ priority: i.facts.priority,
+ assignee: i.assignee,
+ }))
+}
+
+function median(values: number[]): number {
+ if (values.length === 0) return 0
+ const sorted = [...values].sort((a, b) => a - b)
+ const mid = Math.floor(sorted.length / 2)
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
+}
+
+/**
+ * Real Deps-graph view: dependency topology of every issue that appears in a
+ * real gitea dependency edge, laid out into columns (longest dependency depth)
+ * and rows (index within its column, ordered by issue number). Critical path
+ * comes from the deterministic scheduler over the open backlog — closed issues
+ * never schedule, so they can't be critical, which is honest (they're done).
+ * The milestone node is the open milestone with the most open in-scope issues;
+ * when nothing links to a real milestone we fall back to the first open one,
+ * then to a bare '—' placeholder — never a fabricated name/date.
+ */
+export function depsGraphView(d: ProjectData): DepsData {
+ const byNumber = new Map(d.issues.map((i) => [i.number, i]))
+
+ // Participating set: anything that shows up as either end of a real edge,
+ // restricted to issues we actually have (dangling refs to unknown issues
+ // are dropped — nothing honest to draw for them).
+ const participating = new Set()
+ for (const e of d.deps) {
+ if (byNumber.has(e.issue)) participating.add(e.issue)
+ if (byNumber.has(e.dependsOn)) participating.add(e.dependsOn)
+ }
+
+ if (participating.size === 0) {
+ const fallback = d.milestones.find((m) => m.state === 'open')
+ return {
+ nodes: [],
+ milestone: fallback
+ ? { name: fallback.title, due: fallback.dueOn ? formatShort(new Date(fallback.dueOn)) : 'no date', col: 0, row: 0 }
+ : { name: '—', due: 'no date', col: 0, row: 0 },
+ edges: [],
+ critical: [],
+ unattached: [],
+ }
+ }
+
+ // In-scope deps: both endpoints participating.
+ const depsMap = new Map() // issue -> its dependencies
+ const dependentsMap = new Map() // issue -> who depends on it
+ for (const id of participating) {
+ depsMap.set(id, [])
+ dependentsMap.set(id, [])
+ }
+ for (const e of d.deps) {
+ if (participating.has(e.issue) && participating.has(e.dependsOn)) {
+ depsMap.get(e.issue)!.push(e.dependsOn)
+ dependentsMap.get(e.dependsOn)!.push(e.issue)
+ }
+ }
+
+ // Column = longest dependency depth. Cycle-guarded (shouldn't happen — real
+ // gitea deps are DAG-shaped in practice — but recursion must not hang).
+ const colMemo = new Map()
+ function colOf(id: number, visiting: Set): number {
+ const cached = colMemo.get(id)
+ if (cached != null) return cached
+ if (visiting.has(id)) return 0 // cycle guard: treat as a root
+ visiting.add(id)
+ const deps = depsMap.get(id) ?? []
+ const col = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((dep) => colOf(dep, visiting)))
+ visiting.delete(id)
+ colMemo.set(id, col)
+ return col
+ }
+ for (const id of participating) colOf(id, new Set())
+
+ // Row = index within column, ordered deterministically by issue number.
+ const byCol = new Map()
+ for (const id of participating) {
+ const col = colMemo.get(id)!
+ if (!byCol.has(col)) byCol.set(col, [])
+ byCol.get(col)!.push(id)
+ }
+ const rowOf = new Map()
+ for (const ids of byCol.values()) {
+ ids.sort((a, b) => a - b)
+ ids.forEach((id, i) => rowOf.set(id, i))
+ }
+
+ const maxCol = Math.max(...[...colMemo.values()])
+
+ // Critical path from the real scheduler over the open backlog.
+ const plan = schedule(toSchedulable(d.issues), d.deps)
+ const critical = new Set(plan.items.filter((i) => i.critical).map((i) => i.number))
+ const scheduledByNumber = new Map(plan.items.map((i) => [i.number, i]))
+
+ const nodes: DepNode[] = [...participating].sort((a, b) => a - b).map((id) => {
+ const issue = byNumber.get(id)!
+ const inf = inferLifecycle(issue, d.timelines[id] ?? [], d.today)
+ const scheduled = scheduledByNumber.get(id)
+ return {
+ id,
+ title: issue.title,
+ tags: issue.labels,
+ state: inf.column,
+ days: inf.steepingDays != null ? `${inf.steepingDays}d` : undefined,
+ col: colMemo.get(id)!,
+ row: rowOf.get(id)!,
+ // Reuse the scheduler's own rationale where one exists (open issues
+ // only) — never invented commentary for closed/unscheduled nodes.
+ rationale: scheduled?.rationale,
+ }
+ })
+
+ // Nearest open milestone with the most open in-scope issues.
+ const openMilestones = d.milestones.filter((m) => m.state === 'open')
+ let chosenMilestoneId: number | null = null
+ let milestone: DepsData['milestone']
+ const medianRow = median(nodes.map((n) => n.row))
+ if (openMilestones.length === 0) {
+ milestone = { name: '—', due: 'no date', col: maxCol + 1, row: medianRow }
+ } else {
+ let best: { id: number; title: string; dueOn: string | null | undefined; count: number } | null = null
+ for (const m of openMilestones) {
+ const count = [...participating].filter((id) => {
+ const issue = byNumber.get(id)!
+ return issue.milestone?.id === m.id && issue.state === 'open'
+ }).length
+ if (
+ best === null ||
+ count > best.count ||
+ (count === best.count && dueRank(m.dueOn) < dueRank(best.dueOn))
+ ) {
+ best = { id: m.id, title: m.title, dueOn: m.dueOn, count }
+ }
+ }
+ // best is non-null since openMilestones.length > 0
+ chosenMilestoneId = best!.count > 0 ? best!.id : null
+ milestone = {
+ name: best!.title,
+ due: best!.dueOn ? formatShort(new Date(best!.dueOn)) : 'no date',
+ col: maxCol + 1,
+ row: medianRow,
+ }
+ }
+
+ const edges: DepEdge[] = []
+ for (const e of d.deps) {
+ if (!participating.has(e.issue) || !participating.has(e.dependsOn)) continue
+ const crit = critical.has(e.dependsOn) && critical.has(e.issue)
+ edges.push(crit ? { from: e.dependsOn, to: e.issue, crit: true } : { from: e.dependsOn, to: e.issue })
+ }
+
+ // Terminal nodes (nothing in scope depends on them) that belong to the
+ // chosen milestone's scope get an edge into it.
+ if (chosenMilestoneId != null) {
+ for (const id of participating) {
+ const dependents = dependentsMap.get(id) ?? []
+ if (dependents.length > 0) continue
+ const issue = byNumber.get(id)!
+ if (issue.milestone?.id !== chosenMilestoneId) continue
+ edges.push(critical.has(id) ? { from: id, to: 'ms', crit: true } : { from: id, to: 'ms' })
+ }
+ }
+
+ const linked = new Set()
+ for (const e of edges) {
+ linked.add(e.from)
+ if (e.to !== 'ms') linked.add(e.to)
+ }
+ const unattached = [...participating].filter((id) => !linked.has(id)).sort((a, b) => a - b)
+
+ return { nodes, milestone, edges, critical: [...critical], unattached }
+}
+
+/** Rank a due date for "nearest" comparisons; undated milestones sort last. */
+function dueRank(dueOn: string | null | undefined): number {
+ return dueOn ? new Date(dueOn).getTime() : Number.POSITIVE_INFINITY
+}
diff --git a/apps/desktop/src/renderer/src/lib/views/gantt-view.ts b/apps/desktop/src/renderer/src/lib/views/gantt-view.ts
new file mode 100644
index 0000000..0a6e5f6
--- /dev/null
+++ b/apps/desktop/src/renderer/src/lib/views/gantt-view.ts
@@ -0,0 +1,127 @@
+import { forecast, inferLifecycle, schedule, workingDaysBetween } from '@commitea/core'
+
+import type { GanttData, GanttRow, GanttWeek } from '../../data/fixtures.js'
+import { addWorkingDays, formatShort } from '../dates.js'
+import type { ProjectData } from './project-data.js'
+
+/** Same 2-char scheme as backlog.ts's `initials()`; copied inline (not exported there). */
+function initials(login: string): string {
+ return login.slice(0, 2).toUpperCase()
+}
+
+/** Copy of backlog.ts's private `toSchedulable` — not exported, so mirrored here. */
+function toSchedulable(issues: ProjectData['issues']) {
+ return issues
+ .filter((i) => i.state === 'open')
+ .map((i) => ({
+ number: i.number,
+ title: i.title,
+ labels: i.labels,
+ estimateDays: i.facts.estimateDays,
+ priority: i.facts.priority,
+ assignee: i.assignee,
+ }))
+}
+
+const roundUpToWeek = (n: number): number => Math.ceil(Math.max(n, 1) / 7) * 7
+
+/**
+ * Real Gantt: the deterministic scheduler's serial layout over the open
+ * backlog (startDay/endDay/critical), plus a handful of recently-closed
+ * issues shown as already-done bars. No per-issue Monte Carlo exists at this
+ * grain, so `p80` is a rough single-issue buffer (endDay padded by half its
+ * own duration) rather than a simulated percentile — flagged below.
+ */
+export function ganttView(d: ProjectData): GanttData {
+ const plan = schedule(toSchedulable(d.issues), d.deps)
+ const issueByNumber = new Map(d.issues.map((i) => [i.number, i]))
+
+ const scheduledRows: GanttRow[] = plan.items.map((item) => {
+ const issue = issueByNumber.get(item.number)
+ const inf = issue ? inferLifecycle(issue, d.timelines[item.number] ?? [], d.today) : null
+ const state: GanttRow['state'] =
+ inf?.column === 'done' ? 'done' : inf?.column === 'steeping' ? 'steeping' : inf?.column === 'review' ? 'review' : 'scheduled'
+ return {
+ id: item.number,
+ title: item.title,
+ who: initials(issue?.assignee ?? ''),
+ start: item.startDay,
+ end: item.endDay,
+ crit: item.critical,
+ state,
+ // Rough single-issue buffer (no Monte Carlo at this grain): pad the end
+ // by half its own duration as a stand-in p80.
+ p80: Math.ceil(item.endDay + (item.endDay - item.startDay) * 0.5),
+ }
+ })
+
+ // Recently-closed issues (closed within ~14 calendar days) as already-done bars.
+ const doneRows: GanttRow[] = d.issues
+ .filter((i) => {
+ if (i.state !== 'closed' || !i.closedAt) return false
+ const ageDays = (d.today.getTime() - new Date(i.closedAt).getTime()) / (24 * 60 * 60 * 1000)
+ return ageDays >= 0 && ageDays <= 14
+ })
+ .map((i) => ({
+ id: i.number,
+ title: i.title,
+ who: initials(i.assignee ?? ''),
+ start: 0,
+ end: 1,
+ state: 'done' as const,
+ }))
+
+ const rows = [...doneRows, ...scheduledRows]
+
+ // Nearest open milestone with a due date, for the horizon + `due` marker.
+ const dueCandidates = d.milestones
+ .filter((m) => m.state === 'open' && m.dueOn)
+ .map((m) => ({ m, day: Math.max(0, workingDaysBetween(d.today, new Date(m.dueOn as string))) }))
+ .sort((a, b) => a.day - b.day)
+ const nearestDue = dueCandidates[0] ?? null
+
+ const days = roundUpToWeek(Math.max(7, ...rows.map((r) => r.end), nearestDue?.day ?? 0))
+
+ const weeks: GanttWeek[] = []
+ for (let at = 0; at <= days; at += 7) {
+ weeks.push({ at, label: formatShort(addWorkingDays(d.today, at)) })
+ }
+
+ const due = nearestDue
+ ? { at: nearestDue.day, label: `${nearestDue.m.title} due` }
+ : { at: days, label: 'horizon' }
+
+ // 80% window: forecast the nearest open milestone's open scope, if any has
+ // one; otherwise fall back to a generic band over the last third of the
+ // horizon (no milestone scope to anchor it to).
+ const open = d.issues.filter((i) => i.state === 'open')
+ const milestoneWithScope = d.milestones
+ .filter((m) => m.state === 'open')
+ .map((m) => ({ m, scope: open.filter((i) => i.milestone?.id === m.id) }))
+ .filter((x) => x.scope.length > 0)
+ .sort((a, b) => {
+ const da = a.m.dueOn ? workingDaysBetween(d.today, new Date(a.m.dueOn)) : Number.POSITIVE_INFINITY
+ const db = b.m.dueOn ? workingDaysBetween(d.today, new Date(b.m.dueOn)) : Number.POSITIVE_INFINITY
+ return da - db
+ })[0]
+
+ let band: GanttData['band']
+ if (milestoneWithScope) {
+ const f = forecast(toSchedulable(milestoneWithScope.scope), d.deps, { workers: d.workers })
+ const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
+ band = { from: Math.round(f.p50Day), to: Math.round(p90), label: '80% window' }
+ } else {
+ // No milestone with open scope to anchor a real forecast to — degrade to
+ // a generic band over the last third of the horizon.
+ band = { from: Math.round((days * 2) / 3), to: days, label: '80% window' }
+ }
+
+ return {
+ days,
+ weeks,
+ today: 0,
+ band,
+ due,
+ rows,
+ }
+}
diff --git a/apps/desktop/src/renderer/src/lib/views/inbox-view.ts b/apps/desktop/src/renderer/src/lib/views/inbox-view.ts
new file mode 100644
index 0000000..cf845be
--- /dev/null
+++ b/apps/desktop/src/renderer/src/lib/views/inbox-view.ts
@@ -0,0 +1,112 @@
+import { inferLifecycle } from '@commitea/core'
+
+import type { InboxItem } from '../../data/fixtures.js'
+import type { ProjectData } from './project-data.js'
+
+const CLOSED_CAP = 5
+const TOTAL_CAP = 12
+
+/** "HH:MM" from an ISO timestamp; '—' when absent (never fabricated). */
+function hhmm(iso: string | null | undefined): string {
+ if (!iso) return '—'
+ const d = new Date(iso)
+ const hh = String(d.getHours()).padStart(2, '0')
+ const mm = String(d.getMinutes()).padStart(2, '0')
+ return `${hh}:${mm}`
+}
+
+/**
+ * Real Inbox feed: steeping nags, review-waiting issues, recently-closed
+ * issues, and closed milestones — all derived straight from the reconciled
+ * backlog + inferred lifecycle. No mentions, no outages, no people-activity:
+ * ProjectData carries none of that, so those fixture item `type`s never
+ * appear here (an honest degrade, not an oversight).
+ */
+export function inboxView(d: ProjectData): InboxItem[] {
+ const items: InboxItem[] = []
+ let id = 1
+
+ const open = d.issues.filter((i) => i.state === 'open')
+
+ // 1. Steeping nags — open issues that have sat past first-commit for 2+ working days.
+ for (const issue of open) {
+ const inf = inferLifecycle(issue, d.timelines[issue.number] ?? [], d.today)
+ if (inf.steepingDays == null || inf.steepingDays < 2) continue
+ const blocks = d.deps.filter((e) => e.dependsOn === issue.number).map((e) => e.issue)
+ items.push({
+ id: id++,
+ day: 'Today',
+ type: 'nag',
+ icon: 'clock',
+ tone: 'warn',
+ text: `#${issue.number} is steeping · ${inf.steepingDays}d`,
+ detail: blocks.length ? `blocks ${blocks.map((b) => `#${b}`).join(' and ')}` : 'worth a look',
+ time: hhmm(inf.stages.steeping),
+ unread: true,
+ issue: { id: issue.number, title: issue.title, labels: issue.labels, days: `${inf.steepingDays}d` },
+ })
+ }
+
+ // 2. Review-waiting — open issues whose lifecycle has reached the review column.
+ for (const issue of open) {
+ const inf = inferLifecycle(issue, d.timelines[issue.number] ?? [], d.today)
+ if (inf.column !== 'review') continue
+ items.push({
+ id: id++,
+ day: 'Today',
+ type: 'review',
+ icon: 'git-pull-request',
+ tone: 'info',
+ text: `#${issue.number} awaits review`,
+ detail: issue.title,
+ time: hhmm(inf.stages.review),
+ unread: true,
+ issue: { id: issue.number, title: issue.title, labels: issue.labels },
+ })
+ }
+
+ // 3. Recently shipped — closed issues, newest close first, capped.
+ const closed = d.issues
+ .filter((i) => i.state === 'closed')
+ .slice()
+ .sort((a, b) => {
+ const at = a.closedAt ? new Date(a.closedAt).getTime() : -Infinity
+ const bt = b.closedAt ? new Date(b.closedAt).getTime() : -Infinity
+ return bt - at
+ })
+ .slice(0, CLOSED_CAP)
+ for (const issue of closed) {
+ items.push({
+ id: id++,
+ day: 'Yesterday',
+ type: 'milestone',
+ icon: 'circle-check',
+ tone: 'ok',
+ text: `#${issue.number} shipped`,
+ detail: issue.title,
+ time: hhmm(issue.closedAt),
+ unread: false,
+ issue: { id: issue.number, title: issue.title, labels: issue.labels },
+ })
+ }
+
+ // 4. Milestone completion — milestones gitea reports as closed.
+ for (const m of d.milestones) {
+ if (m.state !== 'closed') continue
+ items.push({
+ id: id++,
+ day: 'Earlier',
+ type: 'milestone',
+ icon: 'milestone',
+ tone: 'ok',
+ // No milestone-close timestamp is available on GiteaMilestone, so time is
+ // left at the honest placeholder rather than borrowed from an issue.
+ text: `${m.title} closed`,
+ detail: 'milestone complete',
+ time: '—',
+ unread: false,
+ })
+ }
+
+ return items.slice(0, TOTAL_CAP)
+}
diff --git a/apps/desktop/src/renderer/src/lib/views/issue-detail.ts b/apps/desktop/src/renderer/src/lib/views/issue-detail.ts
new file mode 100644
index 0000000..5c31e44
--- /dev/null
+++ b/apps/desktop/src/renderer/src/lib/views/issue-detail.ts
@@ -0,0 +1,72 @@
+import {
+ forecast,
+ inferLifecycle,
+ type LifecycleStages,
+ toDurationModel,
+} from '@commitea/core'
+
+import type { IssueDetail } from '../../data/fixtures.js'
+import { addWorkingDays, formatRange, formatShort } from '../dates.js'
+import type { ProjectData } from './project-data.js'
+
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
+
+/** "Feb 6 · 11:47" from an ISO timestamp; "pending" when absent. */
+function when(iso: string | undefined): string {
+ if (!iso) return 'pending'
+ const d = new Date(iso)
+ const hh = String(d.getHours()).padStart(2, '0')
+ const mm = String(d.getMinutes()).padStart(2, '0')
+ return `${MONTHS[d.getMonth()]} ${d.getDate()} · ${hh}:${mm}`
+}
+
+function lifecycle(stages: LifecycleStages): IssueDetail['lifecycle'] {
+ return [
+ { stage: 'Diagnosis', event: 'issue opened', when: when(stages.opened), icon: 'circle-dot', done: true },
+ { stage: 'Triage', event: 'labeled / milestoned', when: when(stages.triaged), icon: 'tag', done: !!stages.triaged },
+ { stage: 'Work start', event: 'first commit ref', when: when(stages.steeping), icon: 'git-commit-horizontal', done: !!stages.steeping },
+ { stage: 'In review', event: 'PR referenced', when: when(stages.review), icon: 'git-merge', done: !!stages.review },
+ { stage: 'Complete', event: 'issue closed', when: when(stages.done), icon: 'circle-check', done: !!stages.done },
+ ]
+}
+
+/**
+ * Real Issue-detail sidecar: lifecycle timeline (from event inference), a
+ * single-issue forecast range, and the real dependency edges. Returns null for
+ * an unknown id → the screen shows the demo fixture. Comments aren't reconciled,
+ * so they stay empty (the screen degrades gracefully).
+ */
+export function issueDetailView(id: number, d: ProjectData): IssueDetail | null {
+ const issue = d.issues.find((i) => i.number === id)
+ if (!issue) return null
+ const inf = inferLifecycle(issue, d.timelines[id] ?? [], d.today)
+ const blockedBy = d.deps.filter((e) => e.issue === id).map((e) => e.dependsOn)
+ const blocks = d.deps.filter((e) => e.dependsOn === id).map((e) => e.issue)
+
+ let forecastLine = { p80: 'closed', note: 'shipped' }
+ if (issue.state === 'open') {
+ const f = forecast(
+ [{ number: id, title: issue.title, labels: issue.labels, estimateDays: issue.facts.estimateDays, priority: issue.facts.priority, assignee: issue.assignee }],
+ [],
+ { ...(d.calibration ? { model: toDurationModel(d.calibration) } : {}), workers: d.workers },
+ )
+ const p90 = f.curve.length ? f.curve[f.curve.length - 1].p90Day : f.p95Day
+ forecastLine = {
+ p80: `done ${formatRange(addWorkingDays(d.today, f.p50Day), addWorkingDays(d.today, p90))}`,
+ note: d.calibration && !d.calibration.coldStart ? `from your history · n=${d.calibration.n}` : 'cold-start priors',
+ }
+ }
+
+ return {
+ state: inf.column === 'review' ? 'review' : inf.column,
+ assignee: issue.assignee ?? 'unassigned',
+ milestone: issue.milestone?.title ?? '—',
+ body: issue.body,
+ comments: [],
+ lifecycle: lifecycle(inf.stages),
+ forecast: forecastLine,
+ blocks,
+ blockedBy,
+ note: inf.steepingDays != null ? `Steeping ${inf.steepingDays}d.${blocks.length ? ` Blocks ${blocks.map((b) => `#${b}`).join(', ')}.` : ''}` : '',
+ }
+}
diff --git a/apps/desktop/src/renderer/src/lib/views/project-data.ts b/apps/desktop/src/renderer/src/lib/views/project-data.ts
new file mode 100644
index 0000000..4436009
--- /dev/null
+++ b/apps/desktop/src/renderer/src/lib/views/project-data.ts
@@ -0,0 +1,17 @@
+import type { CalibrationModel, DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent, Worker } from '@commitea/core'
+
+/**
+ * The reconciled backlog + derived config that every real-data view builder
+ * consumes. AppShell assembles it once from the backlog + capacity, so each
+ * screen's builder gets a uniform input. `calibration`/`workers` are optional —
+ * views degrade to cold-start / single-worker when absent.
+ */
+export interface ProjectData {
+ issues: GiteaIssue[]
+ milestones: GiteaMilestone[]
+ deps: DependencyEdge[]
+ timelines: Record
+ calibration?: CalibrationModel
+ workers: Worker[]
+ today: Date
+}
diff --git a/apps/desktop/src/renderer/src/lib/views/standup-view.ts b/apps/desktop/src/renderer/src/lib/views/standup-view.ts
new file mode 100644
index 0000000..7821a73
--- /dev/null
+++ b/apps/desktop/src/renderer/src/lib/views/standup-view.ts
@@ -0,0 +1,157 @@
+import {
+ type DependencyEdge,
+ type GiteaIssue,
+ inferLifecycle,
+ schedule,
+ type ScheduledItem,
+} from '@commitea/core'
+
+import type { DriftItem, Nag, PlanItem, StandupData } from '../../data/fixtures.js'
+import type { ProjectData } from './project-data.js'
+
+/** Same shape as backlog.ts's private toSchedulable — not exported there, so copied inline. */
+function toSchedulable(issues: GiteaIssue[]) {
+ return issues
+ .filter((i) => i.state === 'open')
+ .map((i) => ({
+ number: i.number,
+ title: i.title,
+ labels: i.labels,
+ estimateDays: i.facts.estimateDays,
+ priority: i.facts.priority,
+ assignee: i.assignee,
+ }))
+}
+
+function whyFor(item: ScheduledItem): string {
+ if (item.critical) return 'On the critical path.'
+ if (item.blocks.length) return `Blocks ${item.blocks.map((b) => `#${b}`).join(', ')}.`
+ return 'Next by dependency + priority order.'
+}
+
+/**
+ * Top scheduled pick per distinct assignee. Iterates the (already dependency-
+ * + priority-ordered) plan once, keeping the first item seen per assignee —
+ * that's their earliest-starting pick since the v0 scheduler lays out a single
+ * serial lane (order ascending ⇒ startDay ascending). Ordered by `d.workers`
+ * when present (assignees absent from the roster are appended after the named
+ * workers; unassigned work last), else by discovery order. Capped at 4 people.
+ */
+function buildPlan(d: ProjectData, open: GiteaIssue[], items: ScheduledItem[]): PlanItem[] {
+ const assigneeByNumber = new Map(open.map((i) => [i.number, i.assignee]))
+ const firstPickByPerson = new Map()
+ for (const item of items) {
+ const key = assigneeByNumber.get(item.number) ?? 'Unassigned'
+ if (!firstPickByPerson.has(key)) firstPickByPerson.set(key, item)
+ }
+
+ let order: string[]
+ if (d.workers.length) {
+ const workerOrder = d.workers.map((w) => w.person)
+ const known = workerOrder.filter((p) => firstPickByPerson.has(p))
+ const extra = [...firstPickByPerson.keys()].filter((k) => k !== 'Unassigned' && !workerOrder.includes(k))
+ order = [...known, ...extra]
+ if (firstPickByPerson.has('Unassigned')) order.push('Unassigned')
+ } else {
+ order = [...firstPickByPerson.keys()]
+ }
+
+ return order.slice(0, 4).map((who) => {
+ const item = firstPickByPerson.get(who)!
+ return { who, pick: `#${item.number}`, title: item.title, why: whyFor(item) }
+ })
+}
+
+/**
+ * The single longest-steeping open issue (inferLifecycle's steepingDays, whole
+ * working days since first commit ref). `blocks` comes from the scheduler's
+ * in-scope dependents of that same issue. Falls back to a calm, honest nag
+ * when nothing is currently steeping.
+ */
+function buildNag(d: ProjectData, open: GiteaIssue[], items: ScheduledItem[]): Nag {
+ let worst: { issue: GiteaIssue; days: number } | null = null
+ for (const issue of open) {
+ const inf = inferLifecycle(issue, d.timelines[issue.number] ?? [], d.today)
+ if (inf.steepingDays != null && (worst == null || inf.steepingDays > worst.days)) {
+ worst = { issue, days: inf.steepingDays }
+ }
+ }
+ if (!worst) {
+ return { id: 0, days: '0d', blocks: [], text: 'Nothing is over-steeping. The pot is calm.' }
+ }
+
+ const scheduled = items.find((it) => it.number === worst!.issue.number)
+ const blocks = (scheduled?.blocks ?? []).map((n) => `#${n}`)
+ const days = `${worst.days}d`
+ return {
+ id: worst.issue.number,
+ days,
+ blocks,
+ text: `${days} steeping. ${blocks.length ? `Blocks ${blocks.join(', ')}.` : 'Worth a look.'}`,
+ }
+}
+
+/**
+ * Honest drift signals only — ProjectData carries no notification/webhook
+ * stream, so drift is limited to what lifecycle inference can actually see:
+ * open issues sitting in review (age isn't measurable from the data we have,
+ * so every review-column issue is flagged rather than fabricating a duration)
+ * and open issues steeping past their own estimate. Sorted most-severe first
+ * (steeping overage ranks by days over estimate; review-sitting is a flat,
+ * lower severity since it has no measured age) and capped at 4.
+ */
+function buildDrift(d: ProjectData, open: GiteaIssue[]): DriftItem[] {
+ const candidates: (DriftItem & { severity: number })[] = []
+ for (const issue of open) {
+ const inf = inferLifecycle(issue, d.timelines[issue.number] ?? [], d.today)
+ if (inf.column === 'review') {
+ candidates.push({
+ tone: 'warn',
+ text: `#${issue.number} ${issue.title} is sitting in review.`,
+ delta: 'idle in review',
+ severity: 1,
+ })
+ }
+ if (inf.steepingDays != null) {
+ const est = issue.facts.estimateDays ?? 2
+ if (inf.steepingDays > est) {
+ candidates.push({
+ tone: 'warn',
+ text: `#${issue.number} ${issue.title} has steeped past its estimate.`,
+ delta: `${inf.steepingDays}d vs ${est}d est`,
+ severity: inf.steepingDays - est + 1,
+ })
+ }
+ }
+ }
+ return candidates
+ .sort((a, b) => b.severity - a.severity)
+ .slice(0, 4)
+ .map(({ tone, text, delta }) => ({ tone, text, delta }))
+}
+
+/**
+ * Morning-standup sidecar: per-person top pick from the deterministic
+ * scheduler, the single longest-steeping issue as the day's nag, and drift
+ * limited to real lifecycle anomalies (review-sitting, steeping-past-estimate).
+ * Never throws — an empty/cold backlog degrades to an empty plan, empty drift,
+ * and a calm nag.
+ */
+export function standupView(d: ProjectData): StandupData {
+ const date = d.today.toLocaleDateString('en-GB', {
+ weekday: 'long',
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ })
+ const open = d.issues.filter((i) => i.state === 'open')
+ const deps: DependencyEdge[] = d.deps
+ const { items } = schedule(toSchedulable(open), deps)
+
+ return {
+ date,
+ plan: buildPlan(d, open, items),
+ nag: buildNag(d, open, items),
+ drift: buildDrift(d, open),
+ }
+}