feat: deterministic scheduler → real Now/Next/Later (P2 thin slice)
CommiTea now recommends its own next unit of work from the live backlog. - @commitea/core: `schedule()` — dependency topo-sort with priority + estimate tie-breaks, single serial capacity, cycle detection, and critical-path marking; `selectFocus()` takes the top three. Pure, deterministic; the LLM does none of this. +11 tests (39 in core). Client gains `getIssueDependencies`. - main: reconcile also fetches native issue dependencies for the open scope and returns edges. - renderer: `scheduleFocus()` maps real issues+deps→Now/Next/Later; Focus renders scheduler output (fixture fallback when unconfigured). v0 scope (each a later slice): single serial worker (per-person capacity #8), point durations (Monte Carlo cone #10), estimate-only (calibration #5). Verified: 14 e2e green (fixtures) + gated live spec — the board shows the real 25 open + 9 closed, and Focus picks #2 ChangeSource (critical path) as Now. Screenshots confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,23 +7,26 @@ const here = dirname(fileURLToPath(import.meta.url))
|
||||
const MAIN = join(here, '..', 'out', 'main', 'index.js')
|
||||
|
||||
// Opt-in only (GITEA_LIVE=1). Launches WITHOUT COMMITEA_E2E so the main process
|
||||
// reconciles the real repo via .env.local, and asserts real issues render.
|
||||
// reconciles the real repo via .env.local, and asserts real data renders.
|
||||
test.describe('live backlog', () => {
|
||||
test('The pot shows real gitea issues', async () => {
|
||||
test('The pot + Focus render real gitea data', async () => {
|
||||
test.skip(!process.env.GITEA_LIVE, 'GITEA_LIVE not set — opt-in live test')
|
||||
const app = await electron.launch({ args: [MAIN], env: { ...process.env } })
|
||||
const win = await app.firstWindow()
|
||||
await win.waitForLoadState('domcontentloaded')
|
||||
const rail = win.getByRole('navigation', { name: 'Primary' })
|
||||
|
||||
await win.getByRole('navigation', { name: 'Primary' }).getByRole('button', { name: 'The pot' }).click()
|
||||
// our filed dogfood issue #1 (now closed → Done column) — real gitea data
|
||||
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 15000 })
|
||||
// The pot — waiting here also lets the reconcile (issues + deps) complete
|
||||
await rail.getByRole('button', { name: 'The pot' }).click()
|
||||
await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 })
|
||||
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-board.png'), fullPage: true, animations: 'disabled' })
|
||||
|
||||
// Back to Focus — the deterministic scheduler's real Now/Next/Later. These
|
||||
// rationale phrases are emitted only by the scheduler, never by the demo fixture.
|
||||
await rail.getByRole('button', { name: 'Morning service' }).click()
|
||||
await expect(win.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible()
|
||||
await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' })
|
||||
|
||||
await win.screenshot({
|
||||
path: join(here, '.artifacts', 'screens', 'live-board.png'),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,9 +51,15 @@ export function registerGiteaIpc(): void {
|
||||
ipcMain.handle('gitea:status', () => ({ configured: !!config, repo }))
|
||||
|
||||
ipcMain.handle('gitea:reconcile', async () => {
|
||||
if (!client) return { configured: false, issues: [], milestones: [] }
|
||||
if (!client) return { configured: false, issues: [], milestones: [], deps: [] }
|
||||
const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()])
|
||||
return { configured: true, issues, milestones }
|
||||
// dependency edges among the open scope (the scheduler only plans what's left)
|
||||
const open = issues.filter((i) => i.state === 'open')
|
||||
const perIssue = await Promise.all(
|
||||
open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })),
|
||||
)
|
||||
const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d })))
|
||||
return { configured: true, issues, milestones, deps }
|
||||
})
|
||||
|
||||
ipcMain.handle('gitea:getIssue', async (_event, index: number) => {
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import React from 'react'
|
||||
|
||||
import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
|
||||
import { type FocusView } from '../../lib/backlog.js'
|
||||
import { BurnUpCone } from '../charts/chart.js'
|
||||
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
|
||||
|
||||
/**
|
||||
* Morning service — the Now/Next/Later focus cards + the milestone burn-up cone.
|
||||
* Data is fixture (data.js) until P2's scheduler + Monte Carlo feed it.
|
||||
* `focus` (real scheduler output) overrides the demo fixture when gitea is
|
||||
* configured; the burn-up cone stays fixture until Monte Carlo (P2 next slice).
|
||||
*/
|
||||
export function FocusScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) => void }) {
|
||||
export function FocusScreen({
|
||||
onOpenIssue,
|
||||
focus,
|
||||
}: {
|
||||
onOpenIssue: (issue: IssueRef) => void
|
||||
focus?: FocusView
|
||||
}) {
|
||||
const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later }
|
||||
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
|
||||
<Card
|
||||
overline={slot}
|
||||
@@ -79,9 +88,9 @@ export function FocusScreen({ onOpenIssue }: { onOpenIssue: (issue: IssueRef) =>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr 1fr', gap: 14, alignItems: 'start' }}>
|
||||
<FocusRow slot="Now" issue={FOCUS.now} jade />
|
||||
<FocusRow slot="Next" issue={FOCUS.next} />
|
||||
<FocusRow slot="Later" issue={FOCUS.later} />
|
||||
{view.now ? <FocusRow slot="Now" issue={view.now} jade /> : null}
|
||||
{view.next ? <FocusRow slot="Next" issue={view.next} /> : null}
|
||||
{view.later ? <FocusRow slot="Later" issue={view.later} /> : null}
|
||||
</div>
|
||||
|
||||
<Card
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
|
||||
import logoIcon from '../../design/assets/logo-icon.png'
|
||||
import type { IssueRef } from '../../data/fixtures.js'
|
||||
import { issuesToBoardColumns } from '../../lib/backlog.js'
|
||||
import { issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js'
|
||||
import { useBacklog } from '../../lib/use-backlog.js'
|
||||
import { PrimitivesGallery } from '../gallery.js'
|
||||
import { BoardScreen } from '../screens/board-screen.js'
|
||||
@@ -87,6 +87,7 @@ export function AppShell() {
|
||||
const [readIds, setReadIds] = useState<number[]>([])
|
||||
const backlog = useBacklog()
|
||||
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined
|
||||
const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : undefined
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||
@@ -157,7 +158,7 @@ export function AppShell() {
|
||||
const renderScreen = () => {
|
||||
switch (view) {
|
||||
case 'focus':
|
||||
return <FocusScreen onOpenIssue={openIssue} />
|
||||
return <FocusScreen onOpenIssue={openIssue} focus={focus} />
|
||||
case 'standup':
|
||||
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
|
||||
case 'board':
|
||||
|
||||
9
apps/desktop/src/renderer/src/global.d.ts
vendored
9
apps/desktop/src/renderer/src/global.d.ts
vendored
@@ -1,9 +1,14 @@
|
||||
import type { GiteaIssue, GiteaMilestone } from '@commitea/core'
|
||||
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core'
|
||||
|
||||
/** The gitea bridge exposed by the preload over IPC (main-process backed). */
|
||||
export interface GiteaBridge {
|
||||
status(): Promise<{ configured: boolean; repo: string | null }>
|
||||
reconcile(): Promise<{ configured: boolean; issues: GiteaIssue[]; milestones: GiteaMilestone[] }>
|
||||
reconcile(): Promise<{
|
||||
configured: boolean
|
||||
issues: GiteaIssue[]
|
||||
milestones: GiteaMilestone[]
|
||||
deps: DependencyEdge[]
|
||||
}>
|
||||
getIssue(index: number): Promise<GiteaIssue | null>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { type GiteaIssue, inferColumnV0, type LifecycleColumn } from '@commitea/core'
|
||||
import {
|
||||
type DependencyEdge,
|
||||
type GiteaIssue,
|
||||
inferColumnV0,
|
||||
type LifecycleColumn,
|
||||
schedule,
|
||||
type ScheduledItem,
|
||||
selectFocus,
|
||||
} from '@commitea/core'
|
||||
|
||||
import { type BoardColumn, type BoardIssue } from '../data/fixtures.js'
|
||||
import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js'
|
||||
|
||||
const COLUMN_LABELS: Record<LifecycleColumn, string> = {
|
||||
diagnosis: 'Diagnosis',
|
||||
@@ -37,3 +45,35 @@ export function issuesToBoardColumns(issues: GiteaIssue[]): BoardColumn[] {
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
export interface FocusView {
|
||||
now: FocusIssue | null
|
||||
next: FocusIssue | null
|
||||
later: FocusIssue | null
|
||||
}
|
||||
|
||||
function toFocusIssue(item: ScheduledItem | null): FocusIssue | null {
|
||||
if (!item) return null
|
||||
return { id: item.number, title: item.title, labels: item.labels, rationale: item.rationale }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the deterministic scheduler over the open backlog and take the top three
|
||||
* as Now/Next/Later. Estimates + priority come from label facts; dependency
|
||||
* edges come from gitea's native issue dependencies.
|
||||
*/
|
||||
export function scheduleFocus(issues: GiteaIssue[], deps: DependencyEdge[]): FocusView {
|
||||
const open = issues.filter((i) => i.state === 'open')
|
||||
const plan = schedule(
|
||||
open.map((i) => ({
|
||||
number: i.number,
|
||||
title: i.title,
|
||||
labels: i.labels,
|
||||
estimateDays: i.facts.estimateDays,
|
||||
priority: i.facts.priority,
|
||||
})),
|
||||
deps,
|
||||
)
|
||||
const f = selectFocus(plan)
|
||||
return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { GiteaIssue, GiteaMilestone } from '@commitea/core'
|
||||
import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core'
|
||||
|
||||
export type BacklogState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'unconfigured' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[] }
|
||||
| { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[]; deps: DependencyEdge[] }
|
||||
|
||||
/**
|
||||
* Reconcile the managed repo once on mount, through the main-process bridge.
|
||||
@@ -24,7 +24,7 @@ export function useBacklog(): BacklogState {
|
||||
if (!alive) return
|
||||
setState(
|
||||
r.configured
|
||||
? { status: 'ready', issues: r.issues, milestones: r.milestones }
|
||||
? { status: 'ready', issues: r.issues, milestones: r.milestones, deps: r.deps }
|
||||
: { status: 'unconfigured' },
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user