Skip to content

Commit 8c197ce

Browse files
authored
Merge pull request #49 from saagpatel/codex/refactor/wave5-2-analytics-tab
refactor(components): decompose AnalyticsTab into per-section siblings
2 parents fb681ea + 610bfab commit 8c197ce

13 files changed

Lines changed: 846 additions & 497 deletions

src/components/Analytics/AnalyticsTab.tsx

Lines changed: 29 additions & 497 deletions
Large diffs are not rendered by default.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// @vitest-environment jsdom
2+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { KbUsageTable } from "./KbUsageTable";
5+
6+
const articles = [
7+
{ document_id: "doc-1", title: "Remote Work Policy", usage_count: 12 },
8+
{ document_id: "doc-2", title: "VPN Setup Guide", usage_count: 7 },
9+
];
10+
11+
describe("KbUsageTable", () => {
12+
afterEach(() => cleanup());
13+
14+
it("renders an empty state when there are no articles", () => {
15+
render(<KbUsageTable articles={[]} />);
16+
expect(screen.getByText("No article usage data yet")).toBeTruthy();
17+
});
18+
19+
it("fires onArticleClick with the document_id when a row is clicked", () => {
20+
const onArticleClick = vi.fn();
21+
render(
22+
<KbUsageTable articles={articles} onArticleClick={onArticleClick} />,
23+
);
24+
25+
fireEvent.click(screen.getByText("VPN Setup Guide"));
26+
expect(onArticleClick).toHaveBeenCalledWith("doc-2");
27+
});
28+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { ArticleUsage } from "../../hooks/useAnalytics";
2+
3+
export function KbUsageTable({
4+
articles,
5+
onArticleClick,
6+
}: {
7+
articles: ArticleUsage[];
8+
onArticleClick?: (id: string) => void;
9+
}) {
10+
if (articles.length === 0) {
11+
return (
12+
<div className="kb-usage-table">
13+
<div className="kb-usage-header">
14+
<div>Article</div>
15+
<div style={{ textAlign: "right" }}>Uses</div>
16+
</div>
17+
<div className="analytics-empty">
18+
<div className="analytics-empty-description">
19+
No article usage data yet
20+
</div>
21+
</div>
22+
</div>
23+
);
24+
}
25+
26+
return (
27+
<div className="kb-usage-table">
28+
<div className="kb-usage-header">
29+
<div>Article</div>
30+
<div style={{ textAlign: "right" }}>Uses</div>
31+
</div>
32+
{articles.map((article) => (
33+
<div
34+
key={article.document_id}
35+
className={`kb-usage-row ${onArticleClick ? "kb-usage-row-clickable" : ""}`}
36+
onClick={() => onArticleClick?.(article.document_id)}
37+
role={onArticleClick ? "button" : undefined}
38+
tabIndex={onArticleClick ? 0 : undefined}
39+
>
40+
<div className="kb-usage-title" title={article.title}>
41+
{article.title}
42+
</div>
43+
<div className="kb-usage-count">{article.usage_count}</div>
44+
</div>
45+
))}
46+
</div>
47+
);
48+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// @vitest-environment jsdom
2+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { PilotDiagnosticsSection } from "./PilotDiagnosticsSection";
5+
6+
const invokeMock = vi.fn();
7+
8+
vi.mock("@tauri-apps/api/core", () => ({
9+
invoke: (...args: unknown[]) => invokeMock(...args),
10+
}));
11+
12+
vi.mock("../Pilot", () => ({
13+
PilotDashboard: ({
14+
pilotLoggingEnabled,
15+
}: {
16+
pilotLoggingEnabled: boolean;
17+
}) => (
18+
<div data-testid="pilot-dashboard">
19+
enabled:{String(pilotLoggingEnabled)}
20+
</div>
21+
),
22+
PilotQueryTester: ({
23+
pilotLoggingEnabled,
24+
}: {
25+
pilotLoggingEnabled: boolean;
26+
}) => (
27+
<div data-testid="pilot-query-tester">
28+
enabled:{String(pilotLoggingEnabled)}
29+
</div>
30+
),
31+
}));
32+
33+
describe("PilotDiagnosticsSection", () => {
34+
beforeEach(() => {
35+
invokeMock.mockReset();
36+
});
37+
afterEach(() => cleanup());
38+
39+
it("loads the pilot logging policy and passes enabled=true to children", async () => {
40+
invokeMock.mockResolvedValue({
41+
enabled: true,
42+
retention_days: 14,
43+
max_rows: 500,
44+
});
45+
46+
render(<PilotDiagnosticsSection />);
47+
48+
await waitFor(() => {
49+
expect(invokeMock).toHaveBeenCalledWith("get_pilot_logging_policy");
50+
});
51+
52+
await waitFor(() => {
53+
expect(screen.getByTestId("pilot-query-tester").textContent).toContain(
54+
"enabled:true",
55+
);
56+
});
57+
});
58+
59+
it("falls back to a disabled policy when the invoke call rejects", async () => {
60+
invokeMock.mockRejectedValue(new Error("backend down"));
61+
62+
render(<PilotDiagnosticsSection />);
63+
64+
await waitFor(() => {
65+
expect(screen.getByTestId("pilot-dashboard").textContent).toContain(
66+
"enabled:false",
67+
);
68+
});
69+
});
70+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useCallback, useEffect, useState } from "react";
2+
import { invoke } from "@tauri-apps/api/core";
3+
import { PilotDashboard, PilotQueryTester } from "../Pilot";
4+
5+
interface PilotLoggingPolicy {
6+
enabled: boolean;
7+
retention_days: number;
8+
max_rows: number;
9+
}
10+
11+
export function PilotDiagnosticsSection() {
12+
const [refreshKey, setRefreshKey] = useState(0);
13+
const [policy, setPolicy] = useState<PilotLoggingPolicy | null>(null);
14+
const pilotLoggingEnabled = policy?.enabled ?? false;
15+
16+
useEffect(() => {
17+
invoke<PilotLoggingPolicy>("get_pilot_logging_policy")
18+
.then(setPolicy)
19+
.catch(() =>
20+
setPolicy({ enabled: false, retention_days: 14, max_rows: 500 }),
21+
);
22+
}, []);
23+
24+
const handleQueryLogged = useCallback(() => {
25+
setRefreshKey((current) => current + 1);
26+
}, []);
27+
28+
return (
29+
<section
30+
className="analytics-section-surface analytics-section-surface-pilot"
31+
aria-label="Pilot diagnostics"
32+
>
33+
<div className="analytics-panel-card">
34+
<div className="analytics-panel-header">
35+
<div>
36+
<div className="section-title">Pilot Diagnostics</div>
37+
<p className="analytics-panel-subtitle">
38+
Validate query quality, pilot logging posture, and raw-log
39+
evidence without a separate Pilot tab.
40+
</p>
41+
</div>
42+
</div>
43+
<PilotQueryTester
44+
pilotLoggingEnabled={pilotLoggingEnabled}
45+
policy={policy}
46+
onQueryLogged={handleQueryLogged}
47+
/>
48+
</div>
49+
50+
<div className="analytics-panel-card">
51+
<PilotDashboard
52+
key={refreshKey}
53+
pilotLoggingEnabled={pilotLoggingEnabled}
54+
policy={policy}
55+
/>
56+
</div>
57+
</section>
58+
);
59+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// @vitest-environment jsdom
2+
import { cleanup, render, screen } from "@testing-library/react";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import {
5+
QualityDrilldownExamples,
6+
formatDrilldownMetric,
7+
} from "./QualityDrilldownExamples";
8+
9+
describe("formatDrilldownMetric", () => {
10+
it("formats each signal's metric string", () => {
11+
expect(formatDrilldownMetric("edit_ratio", 0.42)).toBe("42.0% edit ratio");
12+
expect(formatDrilldownMetric("time_to_draft", 12000)).toBe(
13+
"12.0s to draft",
14+
);
15+
expect(formatDrilldownMetric("copy_per_save", 99)).toBe(
16+
"Saved without copy",
17+
);
18+
expect(formatDrilldownMetric("edited_save_rate", 0.8)).toBe(
19+
"80.0% edit ratio",
20+
);
21+
});
22+
});
23+
24+
describe("QualityDrilldownExamples", () => {
25+
afterEach(() => cleanup());
26+
27+
it("renders null when there is no drilldown payload", () => {
28+
const { container } = render(
29+
<QualityDrilldownExamples signalId="edit_ratio" drilldown={null} />,
30+
);
31+
expect(container.innerHTML).toBe("");
32+
});
33+
34+
it("renders up to three draft examples for the given signal", () => {
35+
render(
36+
<QualityDrilldownExamples
37+
signalId="edit_ratio"
38+
drilldown={{
39+
edit_ratio: [
40+
{
41+
draft_id: "d1",
42+
created_at: "2026-04-01",
43+
metric_value: 0.5,
44+
draft_excerpt: "Drift example one",
45+
},
46+
{
47+
draft_id: "d2",
48+
created_at: "2026-04-02",
49+
metric_value: 0.7,
50+
draft_excerpt: "Drift example two",
51+
},
52+
],
53+
time_to_draft: [],
54+
copy_per_save: [],
55+
edited_save_rate: [],
56+
}}
57+
/>,
58+
);
59+
expect(screen.getByText("d1")).toBeTruthy();
60+
expect(screen.getByText("d2")).toBeTruthy();
61+
expect(screen.getByText("Drift example one")).toBeTruthy();
62+
});
63+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { ResponseQualityDrilldownExamples as Drilldown } from "../../hooks/useAnalytics";
2+
3+
export type QualityDrilldownSignalId =
4+
| "edit_ratio"
5+
| "time_to_draft"
6+
| "copy_per_save"
7+
| "edited_save_rate";
8+
9+
export function formatDrilldownMetric(
10+
signalId: QualityDrilldownSignalId,
11+
metricValue: number,
12+
): string {
13+
switch (signalId) {
14+
case "edit_ratio":
15+
return `${(metricValue * 100).toFixed(1)}% edit ratio`;
16+
case "time_to_draft":
17+
return `${(metricValue / 1000).toFixed(1)}s to draft`;
18+
case "copy_per_save":
19+
return "Saved without copy";
20+
case "edited_save_rate":
21+
return `${(metricValue * 100).toFixed(1)}% edit ratio`;
22+
default:
23+
return String(metricValue);
24+
}
25+
}
26+
27+
export function QualityDrilldownExamples({
28+
signalId,
29+
drilldown,
30+
}: {
31+
signalId: QualityDrilldownSignalId;
32+
drilldown: Drilldown | null;
33+
}) {
34+
if (!drilldown) {
35+
return null;
36+
}
37+
const sourceItems = Array.isArray(drilldown[signalId])
38+
? drilldown[signalId]
39+
: [];
40+
const items = sourceItems.slice(0, 3);
41+
if (items.length === 0) {
42+
return (
43+
<div className="quality-drilldown-empty">
44+
No matching draft examples captured yet for this period.
45+
</div>
46+
);
47+
}
48+
49+
return (
50+
<div className="quality-drilldown">
51+
<div className="quality-drilldown-title">Draft examples to review</div>
52+
<ul className="quality-drilldown-list">
53+
{items.map((item) => (
54+
<li key={`${signalId}-${item.draft_id}-${item.created_at}`}>
55+
<div className="quality-drilldown-head">
56+
<code>{item.draft_id}</code>
57+
<span>{formatDrilldownMetric(signalId, item.metric_value)}</span>
58+
</div>
59+
{item.draft_excerpt && <p>{item.draft_excerpt}</p>}
60+
</li>
61+
))}
62+
</ul>
63+
</div>
64+
);
65+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// @vitest-environment jsdom
2+
import { cleanup, render, screen } from "@testing-library/react";
3+
import { afterEach, describe, expect, it } from "vitest";
4+
import type { AnalyticsSummary } from "../../hooks/useAnalytics";
5+
import { RatingDistribution } from "./RatingDistribution";
6+
7+
function makeSummary(
8+
overrides: Partial<AnalyticsSummary> = {},
9+
): AnalyticsSummary {
10+
return {
11+
responses_generated: 0,
12+
searches_performed: 0,
13+
drafts_saved: 0,
14+
average_rating: 0,
15+
total_ratings: 0,
16+
rating_distribution: [0, 0, 0, 0, 0],
17+
daily_counts: [],
18+
...overrides,
19+
} as AnalyticsSummary;
20+
}
21+
22+
describe("RatingDistribution", () => {
23+
afterEach(() => cleanup());
24+
25+
it("shows an empty state when there are zero ratings", () => {
26+
render(<RatingDistribution summary={makeSummary()} />);
27+
expect(screen.getByText("No ratings yet")).toBeTruthy();
28+
});
29+
30+
it("renders five rows with the real per-star counts", () => {
31+
render(
32+
<RatingDistribution
33+
summary={makeSummary({
34+
total_ratings: 10,
35+
rating_distribution: [1, 2, 0, 3, 4],
36+
})}
37+
/>,
38+
);
39+
40+
// 5 stars
41+
expect(screen.getByText("5 stars")).toBeTruthy();
42+
// 1 star (singular)
43+
expect(screen.getByText("1 star")).toBeTruthy();
44+
// Counts present (4 for 5-star, 1 for 1-star)
45+
const counts = screen.getAllByText(/^[0-9]+$/);
46+
expect(counts.length).toBeGreaterThanOrEqual(5);
47+
});
48+
});

0 commit comments

Comments
 (0)