fix(#74): address PR #81 review blockers for live handoff

Distinguish FIA feed drops from true session end via terminal SessionStatus,
consume canonical /api/v1/weekend-context (post-#72 rebase), poll until
analysis-ready, and restore missing live-state styles plus transition/E2E/visual coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 18:50:50 -04:00
parent 1543a09ff1
commit dbe3ac644d
14 changed files with 1323 additions and 152 deletions

View File

@@ -11,14 +11,23 @@
// `deriveLivePhase` collapses the first three inputs into a single UI phase so
// the page never, for example, calls a finished session "offline" or lets a
// dropped socket masquerade as "archive mode".
//
// Critical distinction (issue #74 P0): the server's `deactivate()` path moves a
// STILL-ACTIVE snapshot into `last_snapshot` whenever the upstream FIA SignalR
// connection ends or idles — WITHOUT changing SessionStatus. So `is_live=false`
// alone is ambiguous. We rely on the retained snapshot's terminal SessionStatus
// (Finished/Ended/…) as the only trustworthy "the session really ended" signal.
// A non-terminal retained snapshot means the feed dropped, not that the session
// finished, and must render as `disconnected` (retain snapshot, warn, recover),
// never `settling`/archive.
export type TransportHealth = 'connecting' | 'connected' | 'disconnected' | 'error'
export type LivePhase =
| 'connecting' // cold start: no snapshot yet, still opening the feed
| 'live' // a session is running and streaming
| 'disconnected' // was live, transport dropped — keep the last snapshot, warn
| 'settling' // session ended; a final snapshot is retained, analysis pending
| 'disconnected' // was live, transport/feed dropped — keep the last snapshot, warn
| 'settling' // session truly ended; a final snapshot is retained, analysis pending
| 'archive' // user opened the retained snapshot as an explicit read-only view
| 'inactive' // no session and nothing retained — show weekend context instead
@@ -27,10 +36,39 @@ export interface LiveStateInputs {
/** The feed reports an active session AND carries a snapshot for it. */
isLive: boolean
hasActiveSnapshot: boolean
/** A final snapshot from the last session is retained. */
/** A retained snapshot from the last stream is held (may be a dropped feed). */
hasArchive: boolean
/** The user explicitly opened the archive as a read-only timing view. */
archiveMode: boolean
/**
* The retained snapshot ended on a *terminal* FIA SessionStatus. Only true
* here means the session genuinely finished (vs. the transport dropping).
*/
sessionEndedCleanly: boolean
/** We have observed this session live at least once in this page session. */
wasLive: boolean
/**
* The initial `/api/v1/live/state` query has resolved. `connecting` is a
* cold-start-only phase: once we know the current state (even if the SSE
* transport handshake is still pending), an idle feed is `inactive`, not a
* perpetual "connecting…" spinner.
*/
stateLoaded: boolean
}
// Raw FIA SessionStatus values that mean the session is genuinely over. Mirrors
// the backend `terminalSessionStatus` used by the weekend-context resolver so
// the client and server agree on "finished".
const TERMINAL_STATUSES = new Set(['finished', 'finalised', 'finalized', 'ended', 'aborted'])
function normalizeStatus(status: string | undefined | null): string {
if (!status) return ''
return status.replace(/[^a-zA-Z]/g, '').toLowerCase()
}
/** Whether a raw FIA SessionStatus represents a genuinely completed session. */
export function terminalSessionStatus(status: string | undefined | null): boolean {
return TERMINAL_STATUSES.has(normalizeStatus(status))
}
export function transportDown(transport: TransportHealth): boolean {
@@ -40,10 +78,19 @@ export function transportDown(transport: TransportHealth): boolean {
/**
* Map transport + session + archive inputs to one UI phase. Session lifecycle
* and transport health are deliberately orthogonal: a live session with a
* dropped socket is `disconnected` (snapshot retained), never `archive`.
* dropped socket is `disconnected` (snapshot retained), never `archive` or
* `settling`.
*/
export function deriveLivePhase(input: LiveStateInputs): LivePhase {
const { transport, isLive, hasActiveSnapshot, hasArchive, archiveMode } = input
const {
transport,
isLive,
hasActiveSnapshot,
hasArchive,
archiveMode,
sessionEndedCleanly,
stateLoaded,
} = input
// Explicit read-only archive wins — it is a user-chosen mode.
if (archiveMode && hasArchive) return 'archive'
@@ -54,11 +101,20 @@ export function deriveLivePhase(input: LiveStateInputs): LivePhase {
return transportDown(transport) ? 'disconnected' : 'live'
}
// No active session but we still hold the final snapshot -> settling handoff.
if (hasArchive) return 'settling'
// Feed reports no active session but a snapshot is retained. Distinguish a
// genuine session end from an upstream feed drop using the *only* trustworthy
// signal: whether the retained snapshot carries a terminal SessionStatus.
if (hasArchive) {
if (sessionEndedCleanly) return 'settling'
// Non-terminal retained snapshot means the upstream feed dropped, not that
// the session finished. Hold the last frame and warn — never settle/archive.
return 'disconnected'
}
// Nothing yet and the feed is still opening.
if (transport === 'connecting') return 'connecting'
// Cold start only: still opening the feed and we have not yet learned the
// current state. Once the initial state query resolves, an idle feed is
// `inactive`, not a perpetual "connecting…".
if (transport === 'connecting' && !stateLoaded) return 'connecting'
return 'inactive'
}