From 7c5dae0298cfe161ec3b599043c4b3a58c774d95 Mon Sep 17 00:00:00 2001 From: NilsR0711 <115017963+NilsR0711@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:09:55 +0100 Subject: [PATCH] fix(web): fall back to project totals when dashboard metrics are zero (#2847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard reads elapsed time, total cost, and tokens used exclusively from AutoDashboardData. When auto-mode is not active (e.g. manual /gsd next), auto is null and all three metrics show 0 — even though the status bar displays real values via /api/visualizer. Add the same projectTotals polling pattern (30s interval via /api/visualizer) that status-bar.tsx already uses, and wire it into the fallback chain: projectTotals ?? auto ?? 0. Closes #2709 --- web/components/gsd/dashboard.tsx | 40 ++++++++++- .../dashboard-metrics-fallback.test.ts | 72 +++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 web/lib/__tests__/dashboard-metrics-fallback.test.ts diff --git a/web/components/gsd/dashboard.tsx b/web/components/gsd/dashboard.tsx index 69787e012..6b8017aff 100644 --- a/web/components/gsd/dashboard.tsx +++ b/web/components/gsd/dashboard.tsx @@ -1,5 +1,6 @@ "use client" +import { useEffect, useState, useCallback } from "react" import { Activity, Clock, @@ -16,6 +17,7 @@ import { useGSDWorkspaceState, useGSDWorkspaceActions, buildPromptCommand, + buildProjectUrl, formatDuration, formatCost, formatTokens, @@ -37,6 +39,8 @@ import { } from "@/components/gsd/loading-skeletons" import { ScopeBadge } from "@/components/gsd/scope-badge" import { ProjectWelcome } from "@/components/gsd/project-welcome" +import { authFetch } from "@/lib/auth" +import { type ProjectTotals } from "@/lib/visualizer-types" /** Interpolate progress bar color from red (0%) through yellow (50%) to green (100%) using oklch. */ function getProgressColor(percent: number): string { @@ -114,10 +118,40 @@ export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = { const auto = getLiveAutoDashboard(state) const bridge = boot?.bridge ?? null const freshness = state.live.freshness + const projectCwd = boot?.project.cwd - const elapsed = auto?.elapsed ?? 0 - const totalCost = auto?.totalCost ?? 0 - const totalTokens = auto?.totalTokens ?? 0 + // ── Project-level totals from visualizer API ── + // Provides fallback metrics when auto-mode is not active (#2709). + // Same polling pattern as status-bar.tsx. + const [projectTotals, setProjectTotals] = useState(null) + + const fetchProjectTotals = useCallback(async () => { + try { + const resp = await authFetch(buildProjectUrl("/api/visualizer", projectCwd)) + if (!resp.ok) return + const json = await resp.json() + if (json.totals) setProjectTotals(json.totals) + } catch { + // Silently ignore — dashboard metrics are non-critical + } + }, [projectCwd]) + + useEffect(() => { + const timeout = window.setTimeout(() => { + void fetchProjectTotals() + }, 0) + const interval = window.setInterval(() => { + void fetchProjectTotals() + }, 30_000) + return () => { + window.clearTimeout(timeout) + window.clearInterval(interval) + } + }, [fetchProjectTotals]) + + const elapsed = projectTotals?.duration ?? auto?.elapsed ?? 0 + const totalCost = projectTotals?.cost ?? auto?.totalCost ?? 0 + const totalTokens = projectTotals?.tokens.total ?? auto?.totalTokens ?? 0 const rtkSavings = auto?.rtkSavings ?? null const rtkEnabled = auto?.rtkEnabled === true diff --git a/web/lib/__tests__/dashboard-metrics-fallback.test.ts b/web/lib/__tests__/dashboard-metrics-fallback.test.ts new file mode 100644 index 000000000..626e68a36 --- /dev/null +++ b/web/lib/__tests__/dashboard-metrics-fallback.test.ts @@ -0,0 +1,72 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +/** + * Regression tests for dashboard metric fallback chain. + * + * The dashboard reads metrics from two sources: + * 1. projectTotals (polled from /api/visualizer — always available) + * 2. auto (live auto-mode data — null when auto is not active) + * + * Fallback chain: projectTotals?.X ?? auto?.X ?? 0 + * + * See: https://github.com/gsd-build/gsd-2/issues/2709 + */ + +interface ProjectTotals { + duration: number; + cost: number; + tokens: { total: number }; +} + +interface AutoDashboard { + elapsed: number; + totalCost: number; + totalTokens: number; +} + +/** Mirrors the fallback logic in dashboard.tsx */ +function deriveMetrics( + projectTotals: ProjectTotals | null, + auto: AutoDashboard | null, +) { + return { + elapsed: projectTotals?.duration ?? auto?.elapsed ?? 0, + totalCost: projectTotals?.cost ?? auto?.totalCost ?? 0, + totalTokens: projectTotals?.tokens.total ?? auto?.totalTokens ?? 0, + }; +} + +describe("dashboard metric fallback (#2709 regression)", () => { + test("returns zero when both sources are null", () => { + const result = deriveMetrics(null, null); + assert.equal(result.elapsed, 0); + assert.equal(result.totalCost, 0); + assert.equal(result.totalTokens, 0); + }); + + test("uses auto data when projectTotals is null", () => { + const auto: AutoDashboard = { elapsed: 5000, totalCost: 1.5, totalTokens: 10000 }; + const result = deriveMetrics(null, auto); + assert.equal(result.elapsed, 5000); + assert.equal(result.totalCost, 1.5); + assert.equal(result.totalTokens, 10000); + }); + + test("uses projectTotals when auto is null (manual mode)", () => { + const totals: ProjectTotals = { duration: 60000, cost: 3.2, tokens: { total: 50000 } }; + const result = deriveMetrics(totals, null); + assert.equal(result.elapsed, 60000); + assert.equal(result.totalCost, 3.2); + assert.equal(result.totalTokens, 50000); + }); + + test("projectTotals takes precedence over auto when both present", () => { + const totals: ProjectTotals = { duration: 120000, cost: 5.0, tokens: { total: 80000 } }; + const auto: AutoDashboard = { elapsed: 10000, totalCost: 0.5, totalTokens: 5000 }; + const result = deriveMetrics(totals, auto); + assert.equal(result.elapsed, 120000, "projectTotals duration should take precedence"); + assert.equal(result.totalCost, 5.0, "projectTotals cost should take precedence"); + assert.equal(result.totalTokens, 80000, "projectTotals tokens should take precedence"); + }); +});