Skip to content
526 changes: 29 additions & 497 deletions src/components/Analytics/AnalyticsTab.tsx

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions src/components/Analytics/KbUsageTable.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<KbUsageTable articles={[]} />);
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(
<KbUsageTable articles={articles} onArticleClick={onArticleClick} />,
);

fireEvent.click(screen.getByText("VPN Setup Guide"));
expect(onArticleClick).toHaveBeenCalledWith("doc-2");
});
});
48 changes: 48 additions & 0 deletions src/components/Analytics/KbUsageTable.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="kb-usage-table">
<div className="kb-usage-header">
<div>Article</div>
<div style={{ textAlign: "right" }}>Uses</div>
</div>
<div className="analytics-empty">
<div className="analytics-empty-description">
No article usage data yet
</div>
</div>
</div>
);
}

return (
<div className="kb-usage-table">
<div className="kb-usage-header">
<div>Article</div>
<div style={{ textAlign: "right" }}>Uses</div>
</div>
{articles.map((article) => (
<div
key={article.document_id}
className={`kb-usage-row ${onArticleClick ? "kb-usage-row-clickable" : ""}`}
onClick={() => onArticleClick?.(article.document_id)}
role={onArticleClick ? "button" : undefined}
tabIndex={onArticleClick ? 0 : undefined}
>
<div className="kb-usage-title" title={article.title}>
{article.title}
</div>
<div className="kb-usage-count">{article.usage_count}</div>
</div>
))}
</div>
);
}
70 changes: 70 additions & 0 deletions src/components/Analytics/PilotDiagnosticsSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<div data-testid="pilot-dashboard">
enabled:{String(pilotLoggingEnabled)}
</div>
),
PilotQueryTester: ({
pilotLoggingEnabled,
}: {
pilotLoggingEnabled: boolean;
}) => (
<div data-testid="pilot-query-tester">
enabled:{String(pilotLoggingEnabled)}
</div>
),
}));

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(<PilotDiagnosticsSection />);

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(<PilotDiagnosticsSection />);

await waitFor(() => {
expect(screen.getByTestId("pilot-dashboard").textContent).toContain(
"enabled:false",
);
});
});
});
59 changes: 59 additions & 0 deletions src/components/Analytics/PilotDiagnosticsSection.tsx
Original file line number Diff line number Diff line change
@@ -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<PilotLoggingPolicy | null>(null);
const pilotLoggingEnabled = policy?.enabled ?? false;

useEffect(() => {
invoke<PilotLoggingPolicy>("get_pilot_logging_policy")
.then(setPolicy)
.catch(() =>
setPolicy({ enabled: false, retention_days: 14, max_rows: 500 }),
);
}, []);

const handleQueryLogged = useCallback(() => {
setRefreshKey((current) => current + 1);
}, []);

return (
<section
className="analytics-section-surface analytics-section-surface-pilot"
aria-label="Pilot diagnostics"
>
<div className="analytics-panel-card">
<div className="analytics-panel-header">
<div>
<div className="section-title">Pilot Diagnostics</div>
<p className="analytics-panel-subtitle">
Validate query quality, pilot logging posture, and raw-log
evidence without a separate Pilot tab.
</p>
</div>
</div>
<PilotQueryTester
pilotLoggingEnabled={pilotLoggingEnabled}
policy={policy}
onQueryLogged={handleQueryLogged}
/>
</div>

<div className="analytics-panel-card">
<PilotDashboard
key={refreshKey}
pilotLoggingEnabled={pilotLoggingEnabled}
policy={policy}
/>
</div>
</section>
);
}
63 changes: 63 additions & 0 deletions src/components/Analytics/QualityDrilldownExamples.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QualityDrilldownExamples signalId="edit_ratio" drilldown={null} />,
);
expect(container.innerHTML).toBe("");
});

it("renders up to three draft examples for the given signal", () => {
render(
<QualityDrilldownExamples
signalId="edit_ratio"
drilldown={{
edit_ratio: [
{
draft_id: "d1",
created_at: "2026-04-01",
metric_value: 0.5,
draft_excerpt: "Drift example one",
},
{
draft_id: "d2",
created_at: "2026-04-02",
metric_value: 0.7,
draft_excerpt: "Drift example two",
},
],
time_to_draft: [],
copy_per_save: [],
edited_save_rate: [],
}}
/>,
);
expect(screen.getByText("d1")).toBeTruthy();
expect(screen.getByText("d2")).toBeTruthy();
expect(screen.getByText("Drift example one")).toBeTruthy();
});
});
65 changes: 65 additions & 0 deletions src/components/Analytics/QualityDrilldownExamples.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="quality-drilldown-empty">
No matching draft examples captured yet for this period.
</div>
);
}

return (
<div className="quality-drilldown">
<div className="quality-drilldown-title">Draft examples to review</div>
<ul className="quality-drilldown-list">
{items.map((item) => (
<li key={`${signalId}-${item.draft_id}-${item.created_at}`}>
<div className="quality-drilldown-head">
<code>{item.draft_id}</code>
<span>{formatDrilldownMetric(signalId, item.metric_value)}</span>
</div>
{item.draft_excerpt && <p>{item.draft_excerpt}</p>}
</li>
))}
</ul>
</div>
);
}
48 changes: 48 additions & 0 deletions src/components/Analytics/RatingDistribution.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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(<RatingDistribution summary={makeSummary()} />);
expect(screen.getByText("No ratings yet")).toBeTruthy();
});

it("renders five rows with the real per-star counts", () => {
render(
<RatingDistribution
summary={makeSummary({
total_ratings: 10,
rating_distribution: [1, 2, 0, 3, 4],
})}
/>,
);

// 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);
});
});
Loading
Loading