Extends the in-memory cache into a durable mirror. The reconcile snapshot is written to disk on every successful reconcile; on boot the app shows it instantly (stale-while-revalidate) instead of a blank board, and if gitea is unreachable, reads fall back to it (offline). Rebuildable — the durable truth stays in gitea. - snapshot-store.ts: load/save the snapshot as JSON in app userData (never throws; corrupt/absent → "no cache"). At this scale (~34 issues, 37KB) the whole snapshot fits in memory, so a JSON file beats indexed SQL — no query benefit yet, no native-module (better-sqlite3/electron-rebuild) or WASM dependency. That's the next step if the mirror ever needs indexed queries over larger data. - gitea.ts: getSnapshot persists on a fresh pull; bootSnapshot() returns the persisted snapshot (without seeding the cache — agents still reconcile fresh); gitea:boot serves it; gitea:reconcile falls back to it on failure (stale:true). - useBacklog: stale-while-revalidate — boot instantly, then a fresh reconcile supersedes; a reconcile error keeps the shown snapshot instead of erroring. Verified: desktop typecheck clean, 14 fixture e2e green. Live: the snapshot persists (34 issues / 44 deps / 34 timelines / 5 milestones written to disk); a second launch with gitea unreachable renders the full real board — NOW/NEXT/LATER + the Monte Carlo cone — entirely from the cache (new live-persistence e2e). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
2.9 KiB
TypeScript
92 lines
2.9 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
|
|
import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core'
|
|
|
|
export type BacklogState =
|
|
| { status: 'loading' }
|
|
| { status: 'unconfigured' }
|
|
| { status: 'error'; message: string }
|
|
| {
|
|
status: 'ready'
|
|
issues: GiteaIssue[]
|
|
milestones: GiteaMilestone[]
|
|
deps: DependencyEdge[]
|
|
timelines: Record<number, LifecycleEvent[]>
|
|
/** true while showing the persisted snapshot (instant boot / offline). */
|
|
stale: boolean
|
|
/** ISO time the shown snapshot was reconciled, when stale. */
|
|
savedAt?: string
|
|
}
|
|
|
|
/**
|
|
* Reconcile the managed repo through the main-process bridge, stale-while-
|
|
* revalidate: on mount it shows the persisted snapshot instantly (marked stale),
|
|
* then a fresh reconcile supersedes it. If gitea is unreachable, the fresh
|
|
* reconcile falls back to the persisted snapshot (offline). `refetch` re-syncs
|
|
* after a write. `unconfigured` means no token — the UI uses demo fixtures.
|
|
*/
|
|
export function useBacklog(): [BacklogState, () => void] {
|
|
const [state, setState] = useState<BacklogState>({ status: 'loading' })
|
|
const [nonce, setNonce] = useState(0)
|
|
const refetch = useCallback(() => setNonce((n) => n + 1), [])
|
|
|
|
useEffect(() => {
|
|
let alive = true
|
|
|
|
// instant boot from the persisted snapshot (only on first mount, not refetch)
|
|
if (nonce === 0) {
|
|
window.commitea.gitea
|
|
.boot()
|
|
.then((b) => {
|
|
if (!alive || !('cached' in b) || !b.cached) return
|
|
setState((prev) =>
|
|
prev.status === 'ready' && !prev.stale
|
|
? prev // a fresh reconcile already won the race
|
|
: {
|
|
status: 'ready',
|
|
issues: b.issues,
|
|
milestones: b.milestones,
|
|
deps: b.deps,
|
|
timelines: b.timelines,
|
|
stale: true,
|
|
savedAt: b.savedAt,
|
|
},
|
|
)
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
window.commitea.gitea
|
|
.reconcile()
|
|
.then((r) => {
|
|
if (!alive) return
|
|
setState(
|
|
r.configured
|
|
? {
|
|
status: 'ready',
|
|
issues: r.issues,
|
|
milestones: r.milestones,
|
|
deps: r.deps,
|
|
timelines: r.timelines,
|
|
stale: r.stale ?? false,
|
|
savedAt: r.savedAt,
|
|
}
|
|
: { status: 'unconfigured' },
|
|
)
|
|
})
|
|
.catch((e: unknown) => {
|
|
if (alive) {
|
|
// keep a shown boot snapshot rather than clobbering it with an error
|
|
setState((prev) =>
|
|
prev.status === 'ready' ? prev : { status: 'error', message: e instanceof Error ? e.message : String(e) },
|
|
)
|
|
}
|
|
})
|
|
return () => {
|
|
alive = false
|
|
}
|
|
}, [nonce])
|
|
|
|
return [state, refetch]
|
|
}
|