310-d Frontend — Stale-Stats UX + Pipeline SSE¶
Delivered: PR #1054 (foundation + back-office badge integration) and a follow-up PR (button transformation + popover; this section is being updated as that PR lands).
Decision: contextual recalculation button¶
Status¶
Delivered in PR #1059.
Context¶
The "Recalculate Emissions" button predates the auto-pipeline. After the runner-driven chain shipped (PR #1053 + #1054), the button became redundant in the common case (chain auto-fires on upload), but the question of whether to remove it surfaced these two scenarios where it remains the only operator-facing recovery path:
| Scenario | Auto-pipeline behavior | Recovery |
|---|---|---|
| Chain finished with FINISHED+ERROR | No auto-retry | Button is the only manual retry |
needs_recalculation detected without an active chain (race, missed dispatch, manual Path 1 edit) | Nothing happens until next upload | Button is the only manual trigger |
Removing both the badge AND the button would leave the operator no way out of either state. The "Last recalc failed" red badge would sit there forever.
Decision¶
Transform, don't remove. Keep the button code but tighten its visibility condition to only show when there's something for the operator to do:
| State | Old visibility | New visibility |
|---|---|---|
| Active pipeline running (badge fires) | Visible if needs_recalculation was true | Hidden — badge says it all |
| Chain finished with ERROR (badge: "Last recalc failed") | Visible if needs_recalculation was true | Visible as "Retry recalculation" (new label, same endpoint) |
needs_recalculation = true, no active pipeline, no failed pipeline | Visible | Visible (unchanged — the legitimate manual trigger) |
| All clean | Hidden | Hidden (unchanged) |
Implementation notes¶
- No backend change. Same recalc-trigger endpoint (
POST /v1/sync/recalculate-emissions/{module_type_id}/{data_entry_type_id}). - Button label switches between
data_management_recalculate_emissions(existing) anddata_management_recalculate_retry(new) based onhasRecalcFailure. useRecalculationcomposable is not removed — it's the click handler for both label variants.- The "Last recalc failed" badge stays AS-IS; the button becomes its recovery affordance.
What this means for the consumer¶
A back-office operator who lands on a clean module sees no clutter. A module mid-recalc shows "Recalculating…". A failed recalc shows "Last recalc failed" + "Retry recalculation" together. A needs-recalc with no active chain shows the existing manual trigger.
Decision: pipeline-state popover/tooltip¶
Status¶
Delivered in PR #1059.
Context¶
The badge tells the operator that the pipeline is running or failed, but not what's happening inside. This is a back-office page; operators expect to see the diagnostic detail without opening devtools.
Decision¶
Add a Quasar <q-tooltip> on hover of the badge. Renders:
- Pipeline UUID (with copy-on-click)
- Per-job rows:
{job_type} · {state} · {result}with thestatus_messageunderneath - Timestamps:
started_at/finished_at, formatted relative ("3s ago", "just now") - Failure cases: error
status_messagehighlighted in red
All this data is already in the pipelineStream store (jobsFor(id) returns the array; failedStatusMessagesFor(id) returns just the error messages). Pure rendering work; no new API, no new store plumbing.
Implementation notes¶
- Single new component
PipelineDiagnosticTooltip.vue— reusable for any module card (today: onlyModuleConfig.vue; tomorrow: potentially results-page and module-detail badges). - i18n keys for the static labels ("Pipeline ID", "Started", "Finished", "No active jobs", etc.).
- Relative timestamp via a small inline
formatRelative()helper — no project-wide time utility was available; bespoke helper kept inside the component to avoid premature abstraction. - Keyboard a11y on the badge (fix F-C1, follow-up after PR #1059). Quasar's
<q-tooltip>only registersmouseenter/mouseleaveon the anchor (verified atfrontend/node_modules/quasar/src/components/tooltip/QTooltip.js:247-266), sotabindex="0"alone never opens it for keyboard users.PipelineDiagnosticTooltip.vuere-exposes Quasar'sshow()/hide()viadefineExpose; the parent badge drives them from@focus/@blur. Tabbing onto the badge now opens the diagnostic; tabbing off closes it. The copy-pipeline-id button inside the tooltip portal is still mouse-only —blurfires when focus tries to enter the portal, which collapses the tooltip. Honest partial-a11y; full keyboard reachability requires switching to<q-popup-proxy>/<q-menu>(see Future enhancements).
Future enhancements (out of scope for this PR)¶
- Click-to-stick on mobile / no-mouse devices, and full keyboard reachability of the in-tooltip copy button. Quasar's
<q-tooltip>is hover-only by spec, and the badge-driven@focus/@blurbridge added for F-C1 cannot keep the tooltip open while focus moves into its portal. Switching to<q-popup-proxy>(supports both hover and click, plus focus retention) is more invasive (different anchor model, different escape semantics) and was deferred to keep this PR scoped. Tracked as a separate UX follow-up if mobile support or full in-tooltip keyboard navigation becomes a requirement.
Context¶
Backend half shipped in PR #1052 (SSE endpoint + current_pipeline_id repo helper) and PR #1053 (provider gating + current_pipeline_id field on the carbon-report response). After a bulk CSV upload or factor sync, carbon_reports.stats is stale (reflects pre-chain data) until the runner-driven emission_recalc → aggregation chain finishes. Today the UI shows the stale numbers as if they were fresh — operators have no signal that recalculation is in flight.
This plan ships the frontend half: a "Recalculating..." badge on each module card, a per-pipeline SSE subscription that updates in real time, visual de-emphasis on the stale numbers, and a clear recovery affordance for the failure case.
Backend prerequisites (already shipped)¶
GET /v1/carbon-reports/{id}/modulesreturnsCarbonReportModuleReadwithcurrent_pipeline_id: Optional[UUID].nullwhen no active pipeline → no badge.GET /v1/sync/pipelines/{pipeline_id}returns the pipeline's job list (one-shot read).GET /v1/sync/pipelines/{pipeline_id}/streamServer-Sent Events stream emittingevent: pipeline-updatepayloads on any job's(state, status_message, result, started_at, finished_at)tuple change, plusevent: pingheartbeats every ~15s, terminalstream_closed: trueflag once every job is FINISHED.populate_existing=Trueon the underlying repo query so the long-lived AsyncSession sees out-of-band runner updates.
Spec¶
1. Module card: "Recalculating..." badge¶
When current_pipeline_id != null on a module card:
- Show a badge near the module title (e.g. Quasar
<q-badge color="warning">) with copy "Recalculating..." plus a subtle spinner / pulse animation. - The "last updated through" timestamp on the stats block reads the
finished_atof the most recent FINISHEDaggregationjob in the pipeline (or the previous successful aggregation if none). - Stats numbers themselves are visually de-emphasized — gray text, reduced opacity (e.g.
opacity: 0.6), maybe italic. The numbers are still readable; the de-emphasis just signals "not fresh".
2. Pipeline SSE subscription¶
When the module card mounts (or when current_pipeline_id transitions from null → set):
- Open an
EventSourceagainst/api/v1/sync/pipelines/${current_pipeline_id}/stream. - On each
pipeline-updateevent, update the in-memory pipeline state (Pinia store entry keyed bypipeline_id). - The store should derive
is_finished = jobs.every(j => j.state === 'FINISHED')andhas_error = jobs.some(j => j.result === 'ERROR'). - On
stream_closed: truepayload, close theEventSource— the backend signals end-of-stream. - On any
EventSource.onerror(proxy timeout, network), reopen with simple exponential backoff capped at 30s.
3. Pipeline-finished transition¶
When the pipeline transitions to is_finished (last pipeline-update showed all FINISHED, or the stream_closed marker arrived):
- If
has_error === false: refetch the carbon-report response so the new stats land andcurrent_pipeline_idclears tonull. Badge disappears, stats lose their de-emphasis. - If
has_error === true: surface a recovery affordance — see section 4.
4. Failure recovery affordance (the meta-standpoint pinpoint)¶
A FINISHED+ERROR aggregation (or any FINISHED+ERROR job in the pipeline) means the chain stopped without producing fresh stats. The operator needs:
- A distinguishable badge state when the most-recent pipeline finished with ERROR — copy "Last recalc failed" plus a different color (Quasar
negativeinstead ofwarning). - A "Retry" button that hits the existing recalc-trigger endpoint (
POST /v1/sync/recalculate-emissions/{module_type_id}/{data_entry_type_id}per year, or whatever the documented entry point ends up being). - The error's
status_messagefrom the failed job available in a tooltip / detail dialog so the operator can decide whether to retry blindly or escalate.
This is the meta-pinpoint: chain-dispatch failures already surface through the existing job-state metric layer (status_message + structured logs). Surfacing them in the UX is the missing piece.
5. Multiple modules sharing one pipeline¶
A factor_ingest fans out to N emission_recalc children, each of which chains to one aggregation (deduplicated per (module, year)). Different modules may share a pipeline_id. Implication:
- Subscribe ONCE per unique
pipeline_id(Pinia store keyed by id), not once per module card mount. Multiple cards share the same store entry and re-render on the same SSE update. - The badge clears on each module independently as the per-module aggregation tail finishes.
DAG: who clears the badge¶
factor_ingest (parent, module=A) -- pipeline P
│
├─▶ emission_recalc (det A1) ─┐
├─▶ emission_recalc (det A2) ─┼──▶ aggregation (module=A, year=Y) ← clears A's badge
└─▶ emission_recalc (det A3) ─┘ (deduped to 1)
current_pipeline_id on module A's card returns null once the aggregation row for (A, year=Y) reaches FINISHED — that's the backend's get_current_pipeline_ids_for_modules semantics (active = NOT_STARTED/QUEUED/RUNNING; FINISHED is excluded).
Files (proposed)¶
frontend/src/composables/usePipelineStream.ts(new) — wraps the EventSource + reconnect logic, exposes a Pinia-friendly reactive state.frontend/src/stores/pipelineStream.ts(new) — keyed bypipeline_id, holds{ jobs, is_finished, has_error, status_messages }.frontend/src/components/molecules/data-management/ModuleCard.vue(or wherever module cards live today —UploadCardData.vue,UploadCardFactors.vue,ModuleUploadsSection.vue) — render the badge + de-emphasis.frontend/src/components/molecules/data-management/PipelineRetryButton.vue(new) — error-state retry affordance.frontend/src/types/api.ts(or whereverCarbonReportModuleReadis typed) — addcurrent_pipeline_id: string | nullto the type.
Tests¶
- Vitest unit on
usePipelineStream— opens EventSource with the right URL, parsespipeline-updateevents into store state, closes onstream_closed: true, reopens ononerror. - Vitest component test on the module card —
current_pipeline_id = null→ no badge;= uuid→ "Recalculating..." badge; FINISHED pipeline withhas_error→ "Last recalc failed" badge + retry button. - E2E (Playwright) — full happy-path: trigger a CSV upload, observe badge appear, observe SSE updates, observe badge clear and stats refresh.
Out of scope¶
- Backend changes (everything needed already shipped in #1052/#1053).
- Path 1 (interactive UI edits) — synchronous emission writes there remain the deliberate UX choice; no badge needed.
- Aggregating multiple pipelines per module (the backend returns the most-recent active pipeline only; if a follow-up factor sync chains while an earlier ingest is still running, the badge tracks the newer pipeline only).
Risks¶
- EventSource pooling on report-load. A report with N modules sharing M unique pipelines opens M streams. M is small in practice (one factor upload → one pipeline → potentially every module on the report); shouldn't strain the browser's per-origin connection limit (usually 6). Watch for it on stress test.
- Stale
current_pipeline_idon initial load. The SSE stream picks up updates from the moment we subscribe; if the pipeline finished between the carbon-report request and our subscription, the badge would show forever. Mitigation: also call the one-shotGET /v1/sync/pipelines/{id}once on subscribe and apply that state before the stream takes over. - EventSource doesn't honor cookies cross-origin in some browsers. The frontend is same-origin with the backend in production, so not a concern; flag for review if that ever changes.
Recovery contract pin-down¶
For Karpathy guideline alignment: "defensive mechanisms are copium for bad strategies". The failure-state UX above (section 4) is NOT defensive — it's a real product requirement. The chain CAN fail (DB hiccup, exception in workflow, etc.) and the user needs visible recovery. Backend already pinpoints failures via update_ingestion_job(state=FINISHED, result=ERROR, status_message=str(exc)); the frontend just renders that pinpoint. This is the right place for the failure-state UX to live, not in defensive backend code.