From 6e4c8f2aa758db256a7da1594719288fb17d7669 Mon Sep 17 00:00:00 2001 From: Jean Clawd Date: Thu, 23 Apr 2026 21:59:13 +0200 Subject: [PATCH] [verified] fix: normalize analytics skill payloads Normalize /api/analytics/usage responses so older backends that omit skill analytics do not crash the dashboard. - add normalizeAnalyticsResponse at the API boundary - type the page state against the normalized payload - cover missing and populated skill payloads with tests --- web/src/lib/api.analytics.test.ts | 74 +++++++++++++++++++++++++++++++ web/src/lib/api.ts | 26 ++++++++++- web/src/pages/AnalyticsPage.tsx | 9 +++- 3 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 web/src/lib/api.analytics.test.ts diff --git a/web/src/lib/api.analytics.test.ts b/web/src/lib/api.analytics.test.ts new file mode 100644 index 000000000..78b5c0e4a --- /dev/null +++ b/web/src/lib/api.analytics.test.ts @@ -0,0 +1,74 @@ +/// + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeAnalyticsResponse, type AnalyticsResponse } from "./api.ts"; + +test("normalizeAnalyticsResponse fills in missing skill analytics for older backends", () => { + const raw = { + daily: [], + by_model: [], + totals: { + total_input: 0, + total_output: 0, + total_cache_read: 0, + total_reasoning: 0, + total_estimated_cost: 0, + total_actual_cost: 0, + total_sessions: 0, + total_api_calls: 0, + }, + } as AnalyticsResponse; + + const normalized = normalizeAnalyticsResponse(raw); + + assert.deepEqual(normalized.skills, { + summary: { + total_skill_loads: 0, + total_skill_edits: 0, + total_skill_actions: 0, + distinct_skills_used: 0, + }, + top_skills: [], + }); +}); + +test("normalizeAnalyticsResponse preserves populated skill analytics", () => { + const raw: AnalyticsResponse = { + daily: [], + by_model: [], + totals: { + total_input: 0, + total_output: 0, + total_cache_read: 0, + total_reasoning: 0, + total_estimated_cost: 0, + total_actual_cost: 0, + total_sessions: 0, + total_api_calls: 0, + }, + skills: { + summary: { + total_skill_loads: 2, + total_skill_edits: 1, + total_skill_actions: 3, + distinct_skills_used: 2, + }, + top_skills: [ + { + skill: "systematic-debugging", + view_count: 2, + manage_count: 1, + total_count: 3, + percentage: 100, + last_used_at: 1713900000, + }, + ], + }, + }; + + const normalized = normalizeAnalyticsResponse(raw); + + assert.deepEqual(normalized.skills, raw.skills); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 04951c02b..c4e711025 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -52,8 +52,8 @@ export const api = { if (params.component && params.component !== "all") qs.set("component", params.component); return fetchJSON(`/api/logs?${qs.toString()}`); }, - getAnalytics: (days: number) => - fetchJSON(`/api/analytics/usage?days=${days}`), + getAnalytics: (days: number): Promise => + fetchJSON(`/api/analytics/usage?days=${days}`).then(normalizeAnalyticsResponse), getConfig: () => fetchJSON>("/api/config"), getDefaults: () => fetchJSON>("/api/config/defaults"), getSchema: () => fetchJSON<{ fields: Record; category_order: string[] }>("/api/config/schema"), @@ -355,10 +355,32 @@ export interface AnalyticsResponse { total_sessions: number; total_api_calls: number; }; + skills?: { + summary?: AnalyticsSkillsSummary; + top_skills?: AnalyticsSkillEntry[]; + }; +} + +export type NormalizedAnalyticsResponse = Omit & { skills: { summary: AnalyticsSkillsSummary; top_skills: AnalyticsSkillEntry[]; }; +}; + +export function normalizeAnalyticsResponse(raw: AnalyticsResponse): NormalizedAnalyticsResponse { + return { + ...raw, + skills: { + summary: { + total_skill_loads: raw.skills?.summary?.total_skill_loads ?? 0, + total_skill_edits: raw.skills?.summary?.total_skill_edits ?? 0, + total_skill_actions: raw.skills?.summary?.total_skill_actions ?? 0, + distinct_skills_used: raw.skills?.summary?.distinct_skills_used ?? 0, + }, + top_skills: Array.isArray(raw.skills?.top_skills) ? raw.skills.top_skills : [], + }, + }; } export interface CronJob { diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index 92384e137..eccc7e769 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -7,7 +7,12 @@ import { TrendingUp, } from "lucide-react"; import { api } from "@/lib/api"; -import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry, AnalyticsSkillEntry } from "@/lib/api"; +import type { + AnalyticsDailyEntry, + AnalyticsModelEntry, + AnalyticsSkillEntry, + NormalizedAnalyticsResponse, +} from "@/lib/api"; import { timeAgo } from "@/lib/utils"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -277,7 +282,7 @@ function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) { export default function AnalyticsPage() { const [days, setDays] = useState(30); - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const { t } = useI18n();