diff --git a/apps/desktop/e2e/live-onboarding.spec.ts b/apps/desktop/e2e/live-onboarding.spec.ts index 410c1c2..98d9973 100644 --- a/apps/desktop/e2e/live-onboarding.spec.ts +++ b/apps/desktop/e2e/live-onboarding.spec.ts @@ -38,6 +38,11 @@ test.describe('live onboarding', () => { const win = await app.firstWindow() await win.waitForLoadState('domcontentloaded') + // Any uncaught render error in a real-data view builder must fail the test — + // this is the "it actually works on real data" guarantee. + const pageErrors: string[] = [] + win.on('pageerror', (e) => pageErrors.push(e.message)) + // the connection gate, not the app await expect(win.getByText(/Connect your Gitea/)).toBeVisible({ timeout: 15000 }) await win.getByPlaceholder('your-org').fill('christian') @@ -45,11 +50,35 @@ test.describe('live onboarding', () => { await win.getByPlaceholder(/gitea PAT/).fill(token!) await win.getByRole('button', { name: 'Connect' }).click() - // it validated + saved + reconciled into the real board — real issue titles prove it const rail = win.getByRole('navigation', { name: 'Primary' }) + const realTitle = /Model router|SQLite cache bootstrap|Purity\/rebuild/ + + // Board (real reconciled columns) — real issue titles prove the sync landed. await rail.getByRole('button', { name: 'The pot' }).click() - await expect(win.getByText(/Model router|SQLite cache bootstrap|Purity\/rebuild/).first()).toBeVisible({ timeout: 30000 }) + await expect(win.getByText(realTitle).first()).toBeVisible({ timeout: 30000 }) + + // Gantt tab — scheduler-derived bars over the real open backlog. + await win.getByRole('tab', { name: 'Gantt' }).click() + await expect(win.getByText(realTitle).first()).toBeVisible() + + // Dependencies tab — real dep graph. Just needs to render without throwing. + await win.getByRole('tab', { name: 'Dependencies' }).click() + await win.waitForTimeout(300) + + // Issue drill-in — the machine-derived sidecar (lifecycle/forecast/deps) on a real issue. + await win.getByRole('tab', { name: 'Board' }).click() + await win.getByText(realTitle).first().click() + await expect(win.getByText('Machine-derived')).toBeVisible() + await win.getByRole('button', { name: 'Back' }).click() + + // Standup + Inbox — scheduler plan / nag / inbox feed over real data. + await rail.getByRole('button', { name: 'Standup' }).click() + await expect(win.getByRole('heading', { name: 'Morning standup' })).toBeVisible() + await rail.getByRole('button', { name: 'Inbox' }).click() + await expect(win.getByRole('heading', { name: 'Inbox' })).toBeVisible() + await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-onboarding.png'), fullPage: true, animations: 'disabled' }) + expect(pageErrors, `uncaught render errors: ${pageErrors.join(' · ')}`).toEqual([]) await app.close() }) diff --git a/apps/desktop/src/renderer/src/components/screens/board-screen.tsx b/apps/desktop/src/renderer/src/components/screens/board-screen.tsx index a51fcb9..da3fe31 100644 --- a/apps/desktop/src/renderer/src/components/screens/board-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/board-screen.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { COLUMNS, type BoardColumn, type BoardIssue, type IssueRef } from '../../data/fixtures.js' +import { COLUMNS, type BoardColumn, type BoardIssue, type DepsData, type GanttData, type IssueRef } from '../../data/fixtures.js' import { Card, Tag, Badge, Tabs, IconButton, Input, Icon } from '../ui/index.js' import { EmptyState } from '../shell/states.js' import { GanttView } from './gantt-view.js' @@ -12,10 +12,14 @@ import { DepsGraph } from './deps-graph.js' export function BoardScreen({ onOpenIssue, columns = COLUMNS, + gantt, + deps, loading = false, }: { onOpenIssue: (issue: IssueRef) => void columns?: BoardColumn[] + gantt?: GanttData + deps?: DepsData loading?: boolean }) { const [tab, setTab] = React.useState('board'); @@ -90,9 +94,9 @@ export function BoardScreen({ ) ) : tab === 'deps' ? ( - + ) : ( - + )} ); diff --git a/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx b/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx index ca108ac..e7d7ce9 100644 --- a/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx +++ b/apps/desktop/src/renderer/src/components/screens/deps-graph.tsx @@ -1,11 +1,11 @@ import React from 'react' -import { DEPS, type IssueRef } from '../../data/fixtures.js' +import { DEPS, type DepsData, type IssueRef } from '../../data/fixtures.js' import { Tag, Icon } from '../ui/index.js' // Dependency graph drill-in — layered DAG, critical path in spruce -export function DepsGraph({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) { - const g = DEPS +export function DepsGraph({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: DepsData }) { + const g = data ?? DEPS const PAD = 14, COLW = 206, ROWH = 106, NW = 176, NH = 84 const X = (c: number) => PAD + c * COLW diff --git a/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx b/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx index 14b2a68..52908cc 100644 --- a/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx +++ b/apps/desktop/src/renderer/src/components/screens/gantt-view.tsx @@ -1,11 +1,11 @@ import React from 'react' -import { GANTT, type IssueRef } from '../../data/fixtures.js' +import { GANTT, type GanttData, type IssueRef } from '../../data/fixtures.js' import { Icon } from '../ui/index.js' // Gantt drill-in — scheduler-derived bars, critical chain, 80% forecast tails -export function GanttView({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) { - const g = GANTT; +export function GanttView({ onOpenIssue, data }: { onOpenIssue: (issue: IssueRef) => void; data?: GanttData }) { + const g = data ?? GANTT; const LABELW = 232, DAYW = 21, ROWH = 36, HEADH = 30; const chartW = g.days * DAYW; diff --git a/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx b/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx index 46f0519..7079862 100644 --- a/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/inbox-screen.tsx @@ -9,13 +9,15 @@ export function InboxScreen({ onOpenDirectives, readIds, setReadIds, + data, }: { onOpenIssue: (issue: IssueRef) => void onOpenDirectives: () => void readIds: number[] setReadIds: React.Dispatch> + data?: InboxItem[] }) { - const all = INBOX + const all = data ?? INBOX const [tab, setTab] = React.useState('all') const isRead = (n: InboxItem) => !n.unread || readIds.includes(n.id) diff --git a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx index fb2f76d..7ab09b6 100644 --- a/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/issue-screen.tsx @@ -18,18 +18,20 @@ const NONE = '—' export function IssueScreen({ issue, + detail, onBack, onOpenIssue, canWrite = false, onApplyChange, }: { issue: IssueRef + detail?: IssueDetail onBack: () => void onOpenIssue: (issue: IssueRef) => void canWrite?: boolean onApplyChange?: (change: IssueChange) => Promise<{ ok: boolean }> }) { - const det: IssueDetail = ISSUE_DETAIL[issue.id] || { + const det: IssueDetail = detail ?? ISSUE_DETAIL[issue.id] ?? { state: 'triage', assignee: 'Stephen', milestone: 'Beta', @@ -267,7 +269,7 @@ export function IssueScreen({ blocks {det.blocks.map((b) => ( diff --git a/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx b/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx index 51e2e0a..ff43818 100644 --- a/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/standup-screen.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { type IssueRef, STANDUP } from '../../data/fixtures.js' +import { type IssueRef, STANDUP, type StandupData } from '../../data/fixtures.js' import { Badge, Button, Icon } from '../ui/index.js' const standupCSS = ` @@ -36,15 +36,15 @@ const toneColor: Record = { export function StandupScreen({ onBegin, onOpenIssue, + data, }: { onBegin: () => void onOpenIssue: (issue: IssueRef) => void + data?: StandupData }) { - const s = STANDUP + const s = data ?? STANDUP const NAG_REF: IssueRef = { id: s.nag.id, - title: 'Fix lifecycle inference on merge events', - labels: ['est/2d', 'p/1'], days: s.nag.days, } diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index c7e8187..1506d6e 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -17,6 +17,12 @@ import { } from '../../lib/backlog.js' import { useBacklog } from '../../lib/use-backlog.js' import { useCapacity } from '../../lib/use-capacity.js' +import { depsGraphView } from '../../lib/views/deps-graph.js' +import { ganttView } from '../../lib/views/gantt-view.js' +import { inboxView } from '../../lib/views/inbox-view.js' +import { issueDetailView } from '../../lib/views/issue-detail.js' +import type { ProjectData } from '../../lib/views/project-data.js' +import { standupView } from '../../lib/views/standup-view.js' import { PrimitivesGallery } from '../gallery.js' import { BoardScreen } from '../screens/board-screen.js' import { CalibrationScreen } from '../screens/calibration-screen.js' @@ -100,6 +106,7 @@ export function AppShell() { const [readIds, setReadIds] = useState([]) const [milestoneId, setMilestoneId] = useState(null) const [gate, setGate] = useState<'checking' | 'connect' | 'ready'>('checking') + const [demo, setDemo] = useState(false) const [pubConfig, setPubConfig] = useState(null) const [backlog, refetchBacklog] = useBacklog() const capacityMembers = useCapacity() @@ -132,6 +139,41 @@ export function AppShell() { ) ?? undefined) : undefined + // The uniform input every real-data view builder consumes. Assembled once from + // the reconciled backlog + capacity; undefined until the first reconcile lands. + const projectData: ProjectData | undefined = + backlog.status === 'ready' + ? { + issues: backlog.issues, + milestones: backlog.milestones, + deps: backlog.deps, + timelines: backlog.timelines, + calibration: calibration?.model, + workers, + today: new Date(), + } + : undefined + const issueDetail = + projectData && issue ? (issueDetailView(issue.id, projectData) ?? undefined) : undefined + const standup = projectData ? standupView(projectData) : undefined + const inbox = projectData ? inboxView(projectData) : undefined + const gantt = projectData ? ganttView(projectData) : undefined + const depsGraph = projectData ? depsGraphView(projectData) : undefined + // The rail's unread badge tracks the real inbox once reconciled (still-unread + // minus what's been opened); demo shows the fixture count. + const inboxUnread = inbox + ? inbox.filter((n) => n.unread && !readIds.includes(n.id)).length + : INBOX_UNREAD + + // The real connected host (from the saved config), for the rail's status line. + const hostLabel = (() => { + try { + return pubConfig?.baseUrl ? new URL(pubConfig.baseUrl).host : 'gitea' + } catch { + return pubConfig?.baseUrl ?? 'gitea' + } + })() + useEffect(() => { document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') }, [dark]) @@ -141,14 +183,25 @@ export function AppShell() { useEffect(() => { window.commitea.gitea .status() - .then((s) => setGate(s.demo || s.configured ? 'ready' : 'connect')) + .then((s) => { + setDemo(s.demo) + setGate(s.demo || s.configured ? 'ready' : 'connect') + }) .catch(() => setGate('connect')) window.commitea.config.get().then(setPubConfig).catch(() => {}) }, []) const openIssue = (ref: IssueRef) => { if (view !== 'issue') setPrevView(view) - setIssue(ref) + // Callers into the sidecar (e.g. a "blocks #N" chip) may know only the id. + // Backfill the real title + labels from the reconciled backlog so the header + // isn't blank; fall back to whatever the caller passed (demo fixtures). + let full = ref + if (backlog.status === 'ready' && (!ref.title || !ref.labels)) { + const found = backlog.issues.find((i) => i.number === ref.id) + if (found) full = { ...ref, id: found.number, title: found.title, labels: found.labels } + } + setIssue(full) setView('issue') } @@ -166,7 +219,7 @@ export function AppShell() { const NAV: NavEntry[] = [ { id: 'standup', label: 'Standup', icon: 'sun' }, { id: 'focus', label: 'Morning service', icon: 'coffee' }, - { id: 'inbox', label: 'Inbox', icon: 'bell', count: INBOX_UNREAD || null }, + { id: 'inbox', label: 'Inbox', icon: 'bell', count: inboxUnread || null }, { id: 'capture', label: 'Capture', icon: 'plus' }, { id: 'board', label: 'The pot', icon: 'square-kanban' }, { id: 'runway', label: 'Runway', icon: 'chart-line' }, @@ -224,12 +277,14 @@ export function AppShell() { case 'focus': return case 'standup': - return setView('focus')} onOpenIssue={openIssue} /> + return setView('focus')} onOpenIssue={openIssue} data={standup} /> case 'board': return ( ) @@ -257,6 +312,7 @@ export function AppShell() { onOpenDirectives={() => setView('directives')} readIds={readIds} setReadIds={setReadIds} + data={inbox} /> ) case 'capture': @@ -282,6 +338,7 @@ export function AppShell() { return issue ? ( 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}
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), + } +}