diff --git a/src/components/Analytics/AnalyticsTab.tsx b/src/components/Analytics/AnalyticsTab.tsx index 26f11c9..6a7e68c 100644 --- a/src/components/Analytics/AnalyticsTab.tsx +++ b/src/components/Analytics/AnalyticsTab.tsx @@ -1,41 +1,24 @@ -import { useState, useEffect, useCallback } from "react"; -import { invoke } from "@tauri-apps/api/core"; +import { useState, useEffect } from "react"; +import type { ResponseQualityThresholds } from "../../features/analytics/qualityThresholds"; import { - useAnalytics, - AnalyticsSummary, - ArticleUsage, - LowRatingAnalysis, - ResponseQualityDrilldownExamples, - ResponseQualitySummary, -} from "../../hooks/useAnalytics"; -import { useInsightsOps } from "../../hooks/useInsightsOps"; -import { buildResponseQualityCoaching } from "../../features/analytics/qualityCoaching"; -import { buildOperatorScorecard } from "../../features/analytics/operatorScorecard"; -import { loadQueueHandoffSnapshot } from "../../features/inbox/queueModel"; -import { - getResponseQualityThresholds, - RESPONSE_QUALITY_THRESHOLDS_UPDATED_EVENT, - ResponseQualityThresholds, -} from "../../features/analytics/qualityThresholds"; -import type { KbGapCandidate } from "../../types/insights"; + readCurrentThresholds, + subscribeToQualityThresholds, +} from "./qualityThresholdsState"; import { ArticleDetailPanel } from "./ArticleDetailPanel"; -import { PilotDashboard, PilotQueryTester } from "../Pilot"; +import { PilotDiagnosticsSection } from "./PilotDiagnosticsSection"; +import { RatingDistribution } from "./RatingDistribution"; +import { KbUsageTable } from "./KbUsageTable"; +import { ResponseQualityPanel } from "./ResponseQualityPanel"; +import { useAnalyticsLoader, type AnalyticsPeriod } from "./useAnalyticsLoader"; import "./AnalyticsTab.css"; -type Period = 7 | 30 | 90 | null; // null = all time type AnalyticsSection = "overview" | "pilot"; interface AnalyticsTabProps { initialSection?: AnalyticsSection; } -interface PilotLoggingPolicy { - enabled: boolean; - retention_days: number; - max_rows: number; -} - -const PERIODS: { label: string; value: Period }[] = [ +const PERIODS: { label: string; value: AnalyticsPeriod }[] = [ { label: "7 days", value: 7 }, { label: "30 days", value: 30 }, { label: "90 days", value: 90 }, @@ -46,30 +29,22 @@ export function AnalyticsTab({ initialSection = "overview", }: AnalyticsTabProps) { const { - getSummary, - getKbUsage, - getLowRatingAnalysis, - getResponseQualitySummary, - getResponseQualityDrilldownExamples, - } = useAnalytics(); - const { getKbGapCandidates, updateKbGapStatus } = useInsightsOps(); + summary, + qualitySummary, + qualityDrilldown, + kbUsage, + lowRatingData, + gapCandidates, + loading, + error, + period, + setPeriod, + updateGapStatus, + } = useAnalyticsLoader(); const [activeSection, setActiveSection] = useState(initialSection); - const [period, setPeriod] = useState(30); - const [summary, setSummary] = useState(null); - const [qualitySummary, setQualitySummary] = - useState(null); - const [qualityDrilldown, setQualityDrilldown] = - useState(null); const [qualityThresholds, setQualityThresholds] = - useState(() => getResponseQualityThresholds()); - const [kbUsage, setKbUsage] = useState([]); - const [lowRatingData, setLowRatingData] = useState( - null, - ); - const [gapCandidates, setGapCandidates] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + useState(() => readCurrentThresholds()); const [selectedArticleId, setSelectedArticleId] = useState( null, ); @@ -78,76 +53,9 @@ export function AnalyticsTab({ setActiveSection(initialSection); }, [initialSection]); - const loadData = useCallback(async () => { - setLoading(true); - setError(null); - try { - const [ - summaryData, - kbData, - lowRating, - qualityData, - qualityDrilldownData, - ] = await Promise.all([ - getSummary(period ?? undefined), - getKbUsage(period ?? undefined), - getLowRatingAnalysis(period ?? undefined).catch(() => null), - getResponseQualitySummary(period ?? undefined).catch(() => null), - getResponseQualityDrilldownExamples(period ?? undefined, 6).catch( - () => null, - ), - ]); - const gaps = await getKbGapCandidates(12, "open").catch(() => []); - setSummary(summaryData); - setQualitySummary(qualityData); - setQualityDrilldown(qualityDrilldownData); - setKbUsage(kbData); - setLowRatingData(lowRating); - setGapCandidates(gaps); - } catch (err) { - console.error("Failed to load analytics:", err); - setError(typeof err === "string" ? err : "Failed to load analytics data"); - } finally { - setLoading(false); - } - }, [ - period, - getSummary, - getKbUsage, - getLowRatingAnalysis, - getResponseQualitySummary, - getResponseQualityDrilldownExamples, - getKbGapCandidates, - ]); - - const handleGapStatus = useCallback( - async (id: string, status: "accepted" | "resolved" | "ignored") => { - await updateKbGapStatus(id, status); - setGapCandidates((prev) => prev.filter((g) => g.id !== id)); - }, - [updateKbGapStatus], - ); - - useEffect(() => { - loadData(); - }, [loadData]); - useEffect(() => { - const syncThresholds = () => - setQualityThresholds(getResponseQualityThresholds()); - syncThresholds(); - window.addEventListener( - RESPONSE_QUALITY_THRESHOLDS_UPDATED_EVENT, - syncThresholds, - ); - window.addEventListener("storage", syncThresholds); - return () => { - window.removeEventListener( - RESPONSE_QUALITY_THRESHOLDS_UPDATED_EVENT, - syncThresholds, - ); - window.removeEventListener("storage", syncThresholds); - }; + setQualityThresholds(readCurrentThresholds()); + return subscribeToQualityThresholds(setQualityThresholds); }, []); const overviewContent = (() => { @@ -359,19 +267,19 @@ export function AnalyticsTab({
@@ -442,382 +350,6 @@ export function AnalyticsTab({ ); } -function PilotDiagnosticsSection() { - const [refreshKey, setRefreshKey] = useState(0); - const [policy, setPolicy] = useState(null); - const pilotLoggingEnabled = policy?.enabled ?? false; - - useEffect(() => { - invoke("get_pilot_logging_policy") - .then(setPolicy) - .catch(() => - setPolicy({ enabled: false, retention_days: 14, max_rows: 500 }), - ); - }, []); - - const handleQueryLogged = useCallback(() => { - setRefreshKey((current) => current + 1); - }, []); - - return ( -
-
-
-
-
Pilot Diagnostics
-

- Validate query quality, pilot logging posture, and raw-log - evidence without a separate Pilot tab. -

-
-
- -
- -
- -
-
- ); -} - -function ResponseQualityPanel({ - summary, - thresholds, - drilldown, -}: { - summary: ResponseQualitySummary | null; - thresholds: ResponseQualityThresholds; - drilldown: ResponseQualityDrilldownExamples | null; -}) { - if (!summary || summary.snapshots_count === 0) { - return ( -
-
Response Quality Signals
-
-
- No response quality snapshots captured yet -
-
-
- ); - } - - const avgTimeSeconds = - summary.avg_time_to_draft_ms != null - ? (summary.avg_time_to_draft_ms / 1000).toFixed(1) - : "--"; - const medianTimeSeconds = - summary.median_time_to_draft_ms != null - ? (summary.median_time_to_draft_ms / 1000).toFixed(1) - : "--"; - const coaching = buildResponseQualityCoaching(summary, thresholds); - const scorecard = buildOperatorScorecard( - coaching, - loadQueueHandoffSnapshot(), - ); - - return ( -
-
-
Response Quality Signals
- {coaching && ( - - {coaching.overallSeverity === "healthy" && "Healthy"} - {coaching.overallSeverity === "watch" && "Watch"} - {coaching.overallSeverity === "action" && "Action"} - - )} -
-
-
- Snapshots - - {summary.snapshots_count} - -
-
- Avg Words - - {Math.round(summary.avg_word_count)} - -
-
- Avg Edit Ratio - - {(summary.avg_edit_ratio * 100).toFixed(1)}% - -
-
- Edited Save Rate - - {(summary.edited_save_rate * 100).toFixed(1)}% - -
-
- Avg Time to Draft - {avgTimeSeconds}s -
-
- Median Time to Draft - - {medianTimeSeconds}s - -
-
- Copy per Save - - {(summary.copy_per_saved_ratio * 100).toFixed(1)}% - -
-
- Save Events - - {summary.saved_count} - -
-
- {scorecard && ( -
-
-
-
Operator Scorecard
-

{scorecard.summary}

-
-
- {scorecard.score} - /100 -
-
- {scorecard.prioritySignals.length > 0 ? ( -
    - {scorecard.prioritySignals.map((signal) => ( -
  • - {signal.label}: {signal.guidance} -
  • - ))} -
- ) : ( -
- No urgent actions this period. Keep current runbooks and monitor - trend drift. -
- )} -
- )} - {coaching && ( -
-
- Coaching thresholds -
-
    - {coaching.signals.map((signal) => ( -
  • -
    - {signal.label} - {signal.value} -
    -

    {signal.guidance}

    -

    - {signal.drilldownHint} -

    - {signal.threshold} - {signal.severity !== "healthy" && ( - - )} -
  • - ))} -
-
- )} -
- ); -} - -function QualityDrilldownExamples({ - signalId, - drilldown, -}: { - signalId: - | "edit_ratio" - | "time_to_draft" - | "copy_per_save" - | "edited_save_rate"; - drilldown: ResponseQualityDrilldownExamples | null; -}) { - if (!drilldown) { - return null; - } - const sourceItems = Array.isArray(drilldown[signalId]) - ? drilldown[signalId] - : []; - const items = sourceItems.slice(0, 3); - if (items.length === 0) { - return ( -
- No matching draft examples captured yet for this period. -
- ); - } - - return ( -
-
Draft examples to review
-
    - {items.map((item) => ( -
  • -
    - {item.draft_id} - {formatDrilldownMetric(signalId, item.metric_value)} -
    - {item.draft_excerpt &&

    {item.draft_excerpt}

    } -
  • - ))} -
-
- ); -} - -function formatDrilldownMetric( - signalId: - | "edit_ratio" - | "time_to_draft" - | "copy_per_save" - | "edited_save_rate", - metricValue: number, -): string { - switch (signalId) { - case "edit_ratio": - return `${(metricValue * 100).toFixed(1)}% edit ratio`; - case "time_to_draft": - return `${(metricValue / 1000).toFixed(1)}s to draft`; - case "copy_per_save": - return "Saved without copy"; - case "edited_save_rate": - return `${(metricValue * 100).toFixed(1)}% edit ratio`; - default: - return String(metricValue); - } -} - -function RatingDistribution({ summary }: { summary: AnalyticsSummary }) { - // Derive rating distribution from summary data. - // The backend provides average_rating and total_ratings. - // We display a placeholder distribution based on available data. - const totalRatings = summary.total_ratings; - - // If we have no ratings, show empty state - if (totalRatings === 0) { - return ( -
-
Rating Distribution
-
-
No ratings yet
-
-
- ); - } - - // Use real per-star counts from the backend - const ratingDistribution = Array.isArray(summary.rating_distribution) - ? summary.rating_distribution - : []; - const distribution = [5, 4, 3, 2, 1].map((stars) => ({ - stars, - count: ratingDistribution[stars - 1] ?? 0, - })); - - const maxCount = Math.max(...distribution.map((d) => d.count), 1); - - return ( -
-
Rating Distribution
- {distribution.map(({ stars, count }) => ( -
-
- {stars} star{stars !== 1 ? "s" : ""} -
-
-
-
-
{count}
-
- ))} -
- ); -} - -function KbUsageTable({ - articles, - onArticleClick, -}: { - articles: ArticleUsage[]; - onArticleClick?: (id: string) => void; -}) { - if (articles.length === 0) { - return ( -
-
-
Article
-
Uses
-
-
-
- No article usage data yet -
-
-
- ); - } - - return ( -
-
-
Article
-
Uses
-
- {articles.map((article) => ( -
onArticleClick?.(article.document_id)} - role={onArticleClick ? "button" : undefined} - tabIndex={onArticleClick ? 0 : undefined} - > -
- {article.title} -
-
{article.usage_count}
-
- ))} -
- ); -} - /** Format a date string (YYYY-MM-DD) into a short label (e.g., "Jan 5") */ function formatDateLabel(dateStr: string): string { try { diff --git a/src/components/Analytics/KbUsageTable.test.tsx b/src/components/Analytics/KbUsageTable.test.tsx new file mode 100644 index 0000000..f13ebea --- /dev/null +++ b/src/components/Analytics/KbUsageTable.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { KbUsageTable } from "./KbUsageTable"; + +const articles = [ + { document_id: "doc-1", title: "Remote Work Policy", usage_count: 12 }, + { document_id: "doc-2", title: "VPN Setup Guide", usage_count: 7 }, +]; + +describe("KbUsageTable", () => { + afterEach(() => cleanup()); + + it("renders an empty state when there are no articles", () => { + render(); + expect(screen.getByText("No article usage data yet")).toBeTruthy(); + }); + + it("fires onArticleClick with the document_id when a row is clicked", () => { + const onArticleClick = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText("VPN Setup Guide")); + expect(onArticleClick).toHaveBeenCalledWith("doc-2"); + }); +}); diff --git a/src/components/Analytics/KbUsageTable.tsx b/src/components/Analytics/KbUsageTable.tsx new file mode 100644 index 0000000..1f698c3 --- /dev/null +++ b/src/components/Analytics/KbUsageTable.tsx @@ -0,0 +1,48 @@ +import type { ArticleUsage } from "../../hooks/useAnalytics"; + +export function KbUsageTable({ + articles, + onArticleClick, +}: { + articles: ArticleUsage[]; + onArticleClick?: (id: string) => void; +}) { + if (articles.length === 0) { + return ( +
+
+
Article
+
Uses
+
+
+
+ No article usage data yet +
+
+
+ ); + } + + return ( +
+
+
Article
+
Uses
+
+ {articles.map((article) => ( +
onArticleClick?.(article.document_id)} + role={onArticleClick ? "button" : undefined} + tabIndex={onArticleClick ? 0 : undefined} + > +
+ {article.title} +
+
{article.usage_count}
+
+ ))} +
+ ); +} diff --git a/src/components/Analytics/PilotDiagnosticsSection.test.tsx b/src/components/Analytics/PilotDiagnosticsSection.test.tsx new file mode 100644 index 0000000..3988481 --- /dev/null +++ b/src/components/Analytics/PilotDiagnosticsSection.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PilotDiagnosticsSection } from "./PilotDiagnosticsSection"; + +const invokeMock = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +vi.mock("../Pilot", () => ({ + PilotDashboard: ({ + pilotLoggingEnabled, + }: { + pilotLoggingEnabled: boolean; + }) => ( +
+ enabled:{String(pilotLoggingEnabled)} +
+ ), + PilotQueryTester: ({ + pilotLoggingEnabled, + }: { + pilotLoggingEnabled: boolean; + }) => ( +
+ enabled:{String(pilotLoggingEnabled)} +
+ ), +})); + +describe("PilotDiagnosticsSection", () => { + beforeEach(() => { + invokeMock.mockReset(); + }); + afterEach(() => cleanup()); + + it("loads the pilot logging policy and passes enabled=true to children", async () => { + invokeMock.mockResolvedValue({ + enabled: true, + retention_days: 14, + max_rows: 500, + }); + + render(); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith("get_pilot_logging_policy"); + }); + + await waitFor(() => { + expect(screen.getByTestId("pilot-query-tester").textContent).toContain( + "enabled:true", + ); + }); + }); + + it("falls back to a disabled policy when the invoke call rejects", async () => { + invokeMock.mockRejectedValue(new Error("backend down")); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("pilot-dashboard").textContent).toContain( + "enabled:false", + ); + }); + }); +}); diff --git a/src/components/Analytics/PilotDiagnosticsSection.tsx b/src/components/Analytics/PilotDiagnosticsSection.tsx new file mode 100644 index 0000000..7a4dbfa --- /dev/null +++ b/src/components/Analytics/PilotDiagnosticsSection.tsx @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { PilotDashboard, PilotQueryTester } from "../Pilot"; + +interface PilotLoggingPolicy { + enabled: boolean; + retention_days: number; + max_rows: number; +} + +export function PilotDiagnosticsSection() { + const [refreshKey, setRefreshKey] = useState(0); + const [policy, setPolicy] = useState(null); + const pilotLoggingEnabled = policy?.enabled ?? false; + + useEffect(() => { + invoke("get_pilot_logging_policy") + .then(setPolicy) + .catch(() => + setPolicy({ enabled: false, retention_days: 14, max_rows: 500 }), + ); + }, []); + + const handleQueryLogged = useCallback(() => { + setRefreshKey((current) => current + 1); + }, []); + + return ( +
+
+
+
+
Pilot Diagnostics
+

+ Validate query quality, pilot logging posture, and raw-log + evidence without a separate Pilot tab. +

+
+
+ +
+ +
+ +
+
+ ); +} diff --git a/src/components/Analytics/QualityDrilldownExamples.test.tsx b/src/components/Analytics/QualityDrilldownExamples.test.tsx new file mode 100644 index 0000000..090bb9b --- /dev/null +++ b/src/components/Analytics/QualityDrilldownExamples.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + QualityDrilldownExamples, + formatDrilldownMetric, +} from "./QualityDrilldownExamples"; + +describe("formatDrilldownMetric", () => { + it("formats each signal's metric string", () => { + expect(formatDrilldownMetric("edit_ratio", 0.42)).toBe("42.0% edit ratio"); + expect(formatDrilldownMetric("time_to_draft", 12000)).toBe( + "12.0s to draft", + ); + expect(formatDrilldownMetric("copy_per_save", 99)).toBe( + "Saved without copy", + ); + expect(formatDrilldownMetric("edited_save_rate", 0.8)).toBe( + "80.0% edit ratio", + ); + }); +}); + +describe("QualityDrilldownExamples", () => { + afterEach(() => cleanup()); + + it("renders null when there is no drilldown payload", () => { + const { container } = render( + , + ); + expect(container.innerHTML).toBe(""); + }); + + it("renders up to three draft examples for the given signal", () => { + render( + , + ); + expect(screen.getByText("d1")).toBeTruthy(); + expect(screen.getByText("d2")).toBeTruthy(); + expect(screen.getByText("Drift example one")).toBeTruthy(); + }); +}); diff --git a/src/components/Analytics/QualityDrilldownExamples.tsx b/src/components/Analytics/QualityDrilldownExamples.tsx new file mode 100644 index 0000000..d23a53c --- /dev/null +++ b/src/components/Analytics/QualityDrilldownExamples.tsx @@ -0,0 +1,65 @@ +import type { ResponseQualityDrilldownExamples as Drilldown } from "../../hooks/useAnalytics"; + +export type QualityDrilldownSignalId = + | "edit_ratio" + | "time_to_draft" + | "copy_per_save" + | "edited_save_rate"; + +export function formatDrilldownMetric( + signalId: QualityDrilldownSignalId, + metricValue: number, +): string { + switch (signalId) { + case "edit_ratio": + return `${(metricValue * 100).toFixed(1)}% edit ratio`; + case "time_to_draft": + return `${(metricValue / 1000).toFixed(1)}s to draft`; + case "copy_per_save": + return "Saved without copy"; + case "edited_save_rate": + return `${(metricValue * 100).toFixed(1)}% edit ratio`; + default: + return String(metricValue); + } +} + +export function QualityDrilldownExamples({ + signalId, + drilldown, +}: { + signalId: QualityDrilldownSignalId; + drilldown: Drilldown | null; +}) { + if (!drilldown) { + return null; + } + const sourceItems = Array.isArray(drilldown[signalId]) + ? drilldown[signalId] + : []; + const items = sourceItems.slice(0, 3); + if (items.length === 0) { + return ( +
+ No matching draft examples captured yet for this period. +
+ ); + } + + return ( +
+
Draft examples to review
+
    + {items.map((item) => ( +
  • +
    + {item.draft_id} + {formatDrilldownMetric(signalId, item.metric_value)} +
    + {item.draft_excerpt &&

    {item.draft_excerpt}

    } +
  • + ))} +
+
+ ); +} diff --git a/src/components/Analytics/RatingDistribution.test.tsx b/src/components/Analytics/RatingDistribution.test.tsx new file mode 100644 index 0000000..58df3ba --- /dev/null +++ b/src/components/Analytics/RatingDistribution.test.tsx @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AnalyticsSummary } from "../../hooks/useAnalytics"; +import { RatingDistribution } from "./RatingDistribution"; + +function makeSummary( + overrides: Partial = {}, +): AnalyticsSummary { + return { + responses_generated: 0, + searches_performed: 0, + drafts_saved: 0, + average_rating: 0, + total_ratings: 0, + rating_distribution: [0, 0, 0, 0, 0], + daily_counts: [], + ...overrides, + } as AnalyticsSummary; +} + +describe("RatingDistribution", () => { + afterEach(() => cleanup()); + + it("shows an empty state when there are zero ratings", () => { + render(); + expect(screen.getByText("No ratings yet")).toBeTruthy(); + }); + + it("renders five rows with the real per-star counts", () => { + render( + , + ); + + // 5 stars + expect(screen.getByText("5 stars")).toBeTruthy(); + // 1 star (singular) + expect(screen.getByText("1 star")).toBeTruthy(); + // Counts present (4 for 5-star, 1 for 1-star) + const counts = screen.getAllByText(/^[0-9]+$/); + expect(counts.length).toBeGreaterThanOrEqual(5); + }); +}); diff --git a/src/components/Analytics/RatingDistribution.tsx b/src/components/Analytics/RatingDistribution.tsx new file mode 100644 index 0000000..c692256 --- /dev/null +++ b/src/components/Analytics/RatingDistribution.tsx @@ -0,0 +1,46 @@ +import type { AnalyticsSummary } from "../../hooks/useAnalytics"; + +export function RatingDistribution({ summary }: { summary: AnalyticsSummary }) { + const totalRatings = summary.total_ratings; + + if (totalRatings === 0) { + return ( +
+
Rating Distribution
+
+
No ratings yet
+
+
+ ); + } + + const ratingDistribution = Array.isArray(summary.rating_distribution) + ? summary.rating_distribution + : []; + const distribution = [5, 4, 3, 2, 1].map((stars) => ({ + stars, + count: ratingDistribution[stars - 1] ?? 0, + })); + + const maxCount = Math.max(...distribution.map((d) => d.count), 1); + + return ( +
+
Rating Distribution
+ {distribution.map(({ stars, count }) => ( +
+
+ {stars} star{stars !== 1 ? "s" : ""} +
+
+
+
+
{count}
+
+ ))} +
+ ); +} diff --git a/src/components/Analytics/ResponseQualityPanel.test.tsx b/src/components/Analytics/ResponseQualityPanel.test.tsx new file mode 100644 index 0000000..12626ae --- /dev/null +++ b/src/components/Analytics/ResponseQualityPanel.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ResponseQualityPanel } from "./ResponseQualityPanel"; +import type { ResponseQualityThresholds } from "../../features/analytics/qualityThresholds"; + +vi.mock("../../features/analytics/qualityCoaching", () => ({ + buildResponseQualityCoaching: () => null, +})); +vi.mock("../../features/analytics/operatorScorecard", () => ({ + buildOperatorScorecard: () => null, +})); +vi.mock("../../features/inbox/queueModel", () => ({ + loadQueueHandoffSnapshot: () => null, +})); + +const thresholds: ResponseQualityThresholds = { + edit_ratio_watch: 0.3, + edit_ratio_action: 0.5, + time_to_draft_watch_ms: 30000, + time_to_draft_action_ms: 60000, + copy_per_save_watch: 0.5, + copy_per_save_action: 0.3, + edited_save_rate_watch: 0.5, + edited_save_rate_action: 0.7, +}; + +describe("ResponseQualityPanel", () => { + afterEach(() => cleanup()); + + it("shows the empty state when there are no snapshots", () => { + render( + , + ); + expect( + screen.getByText("No response quality snapshots captured yet"), + ).toBeTruthy(); + }); + + it("renders the metric grid when a snapshot summary is provided", () => { + render( + , + ); + expect(screen.getByText("Snapshots")).toBeTruthy(); + expect(screen.getByText("4")).toBeTruthy(); + expect(screen.getByText("Avg Words")).toBeTruthy(); + expect(screen.getByText("82")).toBeTruthy(); + }); +}); diff --git a/src/components/Analytics/ResponseQualityPanel.tsx b/src/components/Analytics/ResponseQualityPanel.tsx new file mode 100644 index 0000000..fe182ae --- /dev/null +++ b/src/components/Analytics/ResponseQualityPanel.tsx @@ -0,0 +1,172 @@ +import type { + ResponseQualityDrilldownExamples, + ResponseQualitySummary, +} from "../../hooks/useAnalytics"; +import type { ResponseQualityThresholds } from "../../features/analytics/qualityThresholds"; +import { buildResponseQualityCoaching } from "../../features/analytics/qualityCoaching"; +import { buildOperatorScorecard } from "../../features/analytics/operatorScorecard"; +import { loadQueueHandoffSnapshot } from "../../features/inbox/queueModel"; +import { QualityDrilldownExamples } from "./QualityDrilldownExamples"; + +export function ResponseQualityPanel({ + summary, + thresholds, + drilldown, +}: { + summary: ResponseQualitySummary | null; + thresholds: ResponseQualityThresholds; + drilldown: ResponseQualityDrilldownExamples | null; +}) { + if (!summary || summary.snapshots_count === 0) { + return ( +
+
Response Quality Signals
+
+
+ No response quality snapshots captured yet +
+
+
+ ); + } + + const avgTimeSeconds = + summary.avg_time_to_draft_ms != null + ? (summary.avg_time_to_draft_ms / 1000).toFixed(1) + : "--"; + const medianTimeSeconds = + summary.median_time_to_draft_ms != null + ? (summary.median_time_to_draft_ms / 1000).toFixed(1) + : "--"; + const coaching = buildResponseQualityCoaching(summary, thresholds); + const scorecard = buildOperatorScorecard( + coaching, + loadQueueHandoffSnapshot(), + ); + + return ( +
+
+
Response Quality Signals
+ {coaching && ( + + {coaching.overallSeverity === "healthy" && "Healthy"} + {coaching.overallSeverity === "watch" && "Watch"} + {coaching.overallSeverity === "action" && "Action"} + + )} +
+
+
+ Snapshots + + {summary.snapshots_count} + +
+
+ Avg Words + + {Math.round(summary.avg_word_count)} + +
+
+ Avg Edit Ratio + + {(summary.avg_edit_ratio * 100).toFixed(1)}% + +
+
+ Edited Save Rate + + {(summary.edited_save_rate * 100).toFixed(1)}% + +
+
+ Avg Time to Draft + {avgTimeSeconds}s +
+
+ Median Time to Draft + + {medianTimeSeconds}s + +
+
+ Copy per Save + + {(summary.copy_per_saved_ratio * 100).toFixed(1)}% + +
+
+ Save Events + + {summary.saved_count} + +
+
+ {scorecard && ( +
+
+
+
Operator Scorecard
+

{scorecard.summary}

+
+
+ {scorecard.score} + /100 +
+
+ {scorecard.prioritySignals.length > 0 ? ( +
    + {scorecard.prioritySignals.map((signal) => ( +
  • + {signal.label}: {signal.guidance} +
  • + ))} +
+ ) : ( +
+ No urgent actions this period. Keep current runbooks and monitor + trend drift. +
+ )} +
+ )} + {coaching && ( +
+
+ Coaching thresholds +
+
    + {coaching.signals.map((signal) => ( +
  • +
    + {signal.label} + {signal.value} +
    +

    {signal.guidance}

    +

    + {signal.drilldownHint} +

    + {signal.threshold} + {signal.severity !== "healthy" && ( + + )} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/Analytics/qualityThresholdsState.ts b/src/components/Analytics/qualityThresholdsState.ts new file mode 100644 index 0000000..0fa73a7 --- /dev/null +++ b/src/components/Analytics/qualityThresholdsState.ts @@ -0,0 +1,27 @@ +import { + getResponseQualityThresholds, + RESPONSE_QUALITY_THRESHOLDS_UPDATED_EVENT, + type ResponseQualityThresholds, +} from "../../features/analytics/qualityThresholds"; + +export function readCurrentThresholds(): ResponseQualityThresholds { + return getResponseQualityThresholds(); +} + +export function subscribeToQualityThresholds( + onChange: (next: ResponseQualityThresholds) => void, +): () => void { + const handler = () => onChange(getResponseQualityThresholds()); + if (typeof window === "undefined") { + return () => undefined; + } + window.addEventListener(RESPONSE_QUALITY_THRESHOLDS_UPDATED_EVENT, handler); + window.addEventListener("storage", handler); + return () => { + window.removeEventListener( + RESPONSE_QUALITY_THRESHOLDS_UPDATED_EVENT, + handler, + ); + window.removeEventListener("storage", handler); + }; +} diff --git a/src/components/Analytics/useAnalyticsLoader.ts b/src/components/Analytics/useAnalyticsLoader.ts new file mode 100644 index 0000000..440138b --- /dev/null +++ b/src/components/Analytics/useAnalyticsLoader.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useState } from "react"; +import { + useAnalytics, + type AnalyticsSummary, + type ArticleUsage, + type LowRatingAnalysis, + type ResponseQualityDrilldownExamples, + type ResponseQualitySummary, +} from "../../hooks/useAnalytics"; +import { useInsightsOps } from "../../hooks/useInsightsOps"; +import type { KbGapCandidate } from "../../types/insights"; + +export type AnalyticsPeriod = 7 | 30 | 90 | null; + +export interface UseAnalyticsLoaderResult { + summary: AnalyticsSummary | null; + kbUsage: ArticleUsage[]; + qualitySummary: ResponseQualitySummary | null; + qualityDrilldown: ResponseQualityDrilldownExamples | null; + lowRatingData: LowRatingAnalysis | null; + gapCandidates: KbGapCandidate[]; + loading: boolean; + error: string | null; + period: AnalyticsPeriod; + setPeriod: (p: AnalyticsPeriod) => void; + reload: () => Promise; + updateGapStatus: ( + id: string, + status: "accepted" | "resolved" | "ignored", + ) => Promise; +} + +export function useAnalyticsLoader(): UseAnalyticsLoaderResult { + const { + getSummary, + getKbUsage, + getLowRatingAnalysis, + getResponseQualitySummary, + getResponseQualityDrilldownExamples, + } = useAnalytics(); + const { getKbGapCandidates, updateKbGapStatus } = useInsightsOps(); + + const [period, setPeriod] = useState(30); + const [summary, setSummary] = useState(null); + const [qualitySummary, setQualitySummary] = + useState(null); + const [qualityDrilldown, setQualityDrilldown] = + useState(null); + const [kbUsage, setKbUsage] = useState([]); + const [lowRatingData, setLowRatingData] = useState( + null, + ); + const [gapCandidates, setGapCandidates] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [ + summaryData, + kbData, + lowRating, + qualityData, + qualityDrilldownData, + ] = await Promise.all([ + getSummary(period ?? undefined), + getKbUsage(period ?? undefined), + getLowRatingAnalysis(period ?? undefined).catch(() => null), + getResponseQualitySummary(period ?? undefined).catch(() => null), + getResponseQualityDrilldownExamples(period ?? undefined, 6).catch( + () => null, + ), + ]); + const gaps = await getKbGapCandidates(12, "open").catch(() => []); + setSummary(summaryData); + setQualitySummary(qualityData); + setQualityDrilldown(qualityDrilldownData); + setKbUsage(kbData); + setLowRatingData(lowRating); + setGapCandidates(gaps); + } catch (err) { + console.error("Failed to load analytics:", err); + setError(typeof err === "string" ? err : "Failed to load analytics data"); + } finally { + setLoading(false); + } + }, [ + period, + getSummary, + getKbUsage, + getLowRatingAnalysis, + getResponseQualitySummary, + getResponseQualityDrilldownExamples, + getKbGapCandidates, + ]); + + useEffect(() => { + reload(); + }, [reload]); + + const updateGapStatus = useCallback( + async (id: string, status: "accepted" | "resolved" | "ignored") => { + await updateKbGapStatus(id, status); + setGapCandidates((prev) => prev.filter((g) => g.id !== id)); + }, + [updateKbGapStatus], + ); + + return { + summary, + kbUsage, + qualitySummary, + qualityDrilldown, + lowRatingData, + gapCandidates, + loading, + error, + period, + setPeriod, + reload, + updateGapStatus, + }; +}