diff --git a/frontend/package.json b/frontend/package.json index fd20133f..153ce531 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,9 +11,11 @@ "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "vitest", - "test:run": "vitest run", - "test:coverage": "vitest run --coverage", + "test": "vitest --project=unit", + "test:run": "vitest run --project=unit", + "test:coverage": "vitest run --project=unit --coverage", + "test:all": "vitest run", + "test:storybook": "vitest run --project=storybook", "storybook": "storybook dev -p 6006 --no-open", "build-storybook": "storybook build" }, diff --git a/frontend/src/app/(home)/_components/ContentFeed/index.stories.tsx b/frontend/src/app/(home)/_components/ContentFeed/index.stories.tsx new file mode 100644 index 00000000..7c882f8a --- /dev/null +++ b/frontend/src/app/(home)/_components/ContentFeed/index.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" +import { createContent } from "@/lib/storybook-fixtures" + +import { ContentFeed } from "." + +const content = createContent({ + is_reference: true, + newsletter_promotion_at: "2026-04-28T11:00:00Z", + newsletter_promotion_theme: 14, +}) + +const meta = { + title: "Pages/Home/Components/ContentFeed", + component: ContentFeed, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + projectId: 1, + filteredContents: [content], + contentClusterLookup: new Map([ + [content.id, { clusterId: 5, label: "Platform Signals", velocityScore: 0.81 }], + ]), + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Empty: Story = { + args: { + filteredContents: [], + contentClusterLookup: new Map(), + }, +} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/ContentFeed/index.test.tsx b/frontend/src/app/(home)/_components/ContentFeed/index.test.tsx new file mode 100644 index 00000000..df5c7d19 --- /dev/null +++ b/frontend/src/app/(home)/_components/ContentFeed/index.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { createContent } from "@/lib/storybook-fixtures" + +import { ContentFeed } from "." + +describe("ContentFeed", () => { + it("renders the empty state when no content matches", () => { + render() + + expect(screen.getByText("No content matched the current filters.")).toBeInTheDocument() + }) + + it("renders content cards, trend badges, and quick actions", () => { + const content = createContent({ + is_reference: true, + newsletter_promotion_at: "2026-04-28T11:00:00Z", + newsletter_promotion_theme: 14, + }) + + render( + , + ) + + expect(screen.getByText(content.title)).toBeInTheDocument() + expect(screen.getByRole("link", { name: /Trend Platform Signals/i })).toHaveAttribute( + "href", + "/trends?project=1&cluster=5", + ) + expect(screen.getByText("Base 84%")).toBeInTheDocument() + expect(screen.getByText("reference")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Upvote" })).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/ContentFeed/index.tsx b/frontend/src/app/(home)/_components/ContentFeed/index.tsx new file mode 100644 index 00000000..926fd6ef --- /dev/null +++ b/frontend/src/app/(home)/_components/ContentFeed/index.tsx @@ -0,0 +1,139 @@ +import Link from "next/link" + +import { StatusBadge } from "@/components/elements/StatusBadge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Button, buttonVariants } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import type { Content } from "@/lib/types" +import { cn } from "@/lib/utils" +import { formatDate, formatPercentScore, truncateText } from "@/lib/view-helpers" + +import type { ContentClusterBadge } from "../shared" + +type ContentFeedProps = { + projectId: number + filteredContents: Content[] + contentClusterLookup: Map +} + +/** Render the surfaced content cards and quick editorial actions. */ +export function ContentFeed({ + projectId, + filteredContents, + contentClusterLookup, +}: ContentFeedProps) { + return ( +
+ {filteredContents.length === 0 ? ( + + No content matched the current filters. + + ) : null} + {filteredContents.map((content) => { + const trendCluster = contentClusterLookup.get(content.id) + + return ( + + +
+
+

{content.title}

+
+ {formatDate(content.published_date)} + {content.author || "Unknown author"} + {content.source_plugin} +
+
+ = 0.7 + ? "positive" + : "warning" + } + > + Adjusted {formatPercentScore(content.authority_adjusted_score ?? content.relevance_score)} + +
+ +
+ {trendCluster ? ( + + Trend {trendCluster.label} ยท {formatPercentScore(trendCluster.velocityScore ?? null)} + + ) : null} + {content.authority_adjusted_score !== null ? ( + + Base {formatPercentScore(content.relevance_score)} + + ) : null} + + {content.content_type || "unclassified"} + + {content.duplicate_signal_count > 0 ? ( + + Also seen in {content.duplicate_signal_count} source + {content.duplicate_signal_count === 1 ? "" : "s"} + + ) : null} + {content.duplicate_of ? ( + + Duplicate of #{content.duplicate_of} + + ) : null} + {content.is_reference ? ( + reference + ) : null} + {!content.is_active ? ( + archived + ) : null} + {content.newsletter_promotion_at ? ( + + Promoted {formatDate(content.newsletter_promotion_at)} + + ) : null} +
+ +

{truncateText(content.content_text)}

+ +
+ + Open detail + +
+ + + + + +
+
+ + + + + +
+
+
+
+ ) + })} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.stories.tsx b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.stories.tsx new file mode 100644 index 00000000..707f8705 --- /dev/null +++ b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" + +import { DashboardFilterToolbar } from "." + +const meta = { + title: "Pages/Home/Components/DashboardFilterToolbar", + component: DashboardFilterToolbar, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + projectId: 1, + view: "content", + contentTypes: ["article", "tutorial"], + contentTypeFilter: "", + sources: ["rss", "reddit"], + sourceFilter: "", + daysFilter: 30, + duplicateStateFilter: "", + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Filtered: Story = { + args: { + contentTypeFilter: "article", + sourceFilter: "rss", + duplicateStateFilter: "duplicate_related", + }, +} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.test.tsx b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.test.tsx new file mode 100644 index 00000000..a524055f --- /dev/null +++ b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { DashboardFilterToolbar } from "." + +describe("DashboardFilterToolbar", () => { + it("renders the dashboard filter form", () => { + const { container } = render( + , + ) + + expect(screen.getByRole("button", { name: "Apply filters" })).toBeInTheDocument() + expect(screen.getByRole("link", { name: "Reset" })).toHaveAttribute("href", "/?project=1") + expect(container.querySelector('input[name="project"]')).toHaveValue("1") + expect(container.querySelector('input[name="contentType"]')).toHaveValue("article") + expect(container.querySelector('input[name="source"]')).toHaveValue("rss") + expect(container.querySelector('input[name="days"]')).toHaveValue("30") + }) +}) \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.tsx b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.tsx new file mode 100644 index 00000000..45f740ed --- /dev/null +++ b/frontend/src/app/(home)/_components/DashboardFilterToolbar/index.tsx @@ -0,0 +1,159 @@ +import Link from "next/link" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import type { DashboardView, DuplicateStateFilter } from "@/lib/dashboard-view" +import { cn } from "@/lib/utils" + +import { + dashboardDayOptions, + dashboardViewOptions, + duplicateStateOptions, +} from "../shared" + +type DashboardFilterToolbarProps = { + projectId: number + view: DashboardView + contentTypes: string[] + contentTypeFilter: string + sources: string[] + sourceFilter: string + daysFilter: number + duplicateStateFilter: DuplicateStateFilter +} + +/** Render dashboard filtering controls for content and review views. */ +export function DashboardFilterToolbar({ + projectId, + view, + contentTypes, + contentTypeFilter, + sources, + sourceFilter, + daysFilter, + duplicateStateFilter, +}: DashboardFilterToolbarProps) { + return ( + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Reset + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/DashboardOverview/index.stories.tsx b/frontend/src/app/(home)/_components/DashboardOverview/index.stories.tsx new file mode 100644 index 00000000..d7e120c7 --- /dev/null +++ b/frontend/src/app/(home)/_components/DashboardOverview/index.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" + +import { DashboardOverview } from "." + +const meta = { + title: "Pages/Home/Components/DashboardOverview", + component: DashboardOverview, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + surfacedCount: 12, + reviewQueueCount: 4, + trackedEntitiesCount: 8, + positiveFeedback: 5, + negativeFeedback: 2, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/DashboardOverview/index.test.tsx b/frontend/src/app/(home)/_components/DashboardOverview/index.test.tsx new file mode 100644 index 00000000..19b24996 --- /dev/null +++ b/frontend/src/app/(home)/_components/DashboardOverview/index.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { DashboardOverview } from "." + +describe("DashboardOverview", () => { + it("renders dashboard summary metrics", () => { + render( + , + ) + + expect(screen.getByText("Surfaced")).toBeInTheDocument() + expect(screen.getByText("Review queue")).toBeInTheDocument() + expect(screen.getByText("Tracked entities")).toBeInTheDocument() + expect(screen.getByText("5/2")).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/DashboardOverview/index.tsx b/frontend/src/app/(home)/_components/DashboardOverview/index.tsx new file mode 100644 index 00000000..9e38b314 --- /dev/null +++ b/frontend/src/app/(home)/_components/DashboardOverview/index.tsx @@ -0,0 +1,58 @@ +import { Card, CardContent } from "@/components/ui/card" + +type DashboardOverviewProps = { + surfacedCount: number + reviewQueueCount: number + trackedEntitiesCount: number + positiveFeedback: number + negativeFeedback: number +} + +/** Render top-level dashboard metrics for the selected project. */ +export function DashboardOverview({ + surfacedCount, + reviewQueueCount, + trackedEntitiesCount, + positiveFeedback, + negativeFeedback, +}: DashboardOverviewProps) { + const items = [ + { + label: "Surfaced", + value: String(surfacedCount), + description: "Active content items in the current filter window.", + }, + { + label: "Review queue", + value: String(reviewQueueCount), + description: "Borderline or low-confidence items waiting on an editor.", + }, + { + label: "Tracked entities", + value: String(trackedEntitiesCount), + description: "People, vendors, and organizations linked to this project.", + }, + { + label: "Signals", + value: `${positiveFeedback}/${negativeFeedback}`, + description: "Upvotes and downvotes captured through the API so far.", + }, + ] + + return ( +
+ {items.map((item) => ( + + +

{item.label}

+

{item.value}

+

{item.description}

+
+
+ ))} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/DashboardSidebar/index.stories.tsx b/frontend/src/app/(home)/_components/DashboardSidebar/index.stories.tsx new file mode 100644 index 00000000..ee31bed2 --- /dev/null +++ b/frontend/src/app/(home)/_components/DashboardSidebar/index.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" +import { createProject, createSourceConfig } from "@/lib/storybook-fixtures" + +import { DashboardSidebar } from "." + +const meta = { + title: "Pages/Home/Components/DashboardSidebar", + component: DashboardSidebar, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + selectedProject: createProject(), + sourceConfigs: [createSourceConfig(), createSourceConfig({ id: 3, is_active: false })], + pendingReviewCount: 4, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/DashboardSidebar/index.test.tsx b/frontend/src/app/(home)/_components/DashboardSidebar/index.test.tsx new file mode 100644 index 00000000..89121731 --- /dev/null +++ b/frontend/src/app/(home)/_components/DashboardSidebar/index.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { createProject, createSourceConfig } from "@/lib/storybook-fixtures" + +import { DashboardSidebar } from "." + +describe("DashboardSidebar", () => { + it("renders project context and source counts", () => { + render( + , + ) + + expect(screen.getByText("Project focus")).toBeInTheDocument() + expect(screen.getByText("AI Weekly")).toBeInTheDocument() + expect(screen.getByText("Active sources")).toBeInTheDocument() + expect(screen.getByText("Editorial queue")).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/DashboardSidebar/index.tsx b/frontend/src/app/(home)/_components/DashboardSidebar/index.tsx new file mode 100644 index 00000000..d79dd0e1 --- /dev/null +++ b/frontend/src/app/(home)/_components/DashboardSidebar/index.tsx @@ -0,0 +1,49 @@ +import { Card, CardContent } from "@/components/ui/card" +import type { Project, SourceConfig } from "@/lib/types" + +type DashboardSidebarProps = { + selectedProject: Project + sourceConfigs: SourceConfig[] + pendingReviewCount: number +} + +/** Render dashboard-side project and workflow summaries. */ +export function DashboardSidebar({ + selectedProject, + sourceConfigs, + pendingReviewCount, +}: DashboardSidebarProps) { + return ( + + ) +} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/HomePageContent/index.stories.tsx b/frontend/src/app/(home)/_components/HomePageContent/index.stories.tsx new file mode 100644 index 00000000..44570e58 --- /dev/null +++ b/frontend/src/app/(home)/_components/HomePageContent/index.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" +import { + createContent, + createEntity, + createProject, + createSourceConfig, +} from "@/lib/storybook-fixtures" +import type { ReviewQueueItem } from "@/lib/types" + +import { HomePageContent } from "." + +const content = createContent({ + is_reference: true, + newsletter_promotion_at: "2026-04-28T11:00:00Z", + newsletter_promotion_theme: 14, +}) +const reviewItem: ReviewQueueItem = { + id: 7, + project: 1, + content: content.id, + reason: "borderline_relevance", + confidence: 0.61, + created_at: "2026-04-28T12:00:00Z", + resolved: false, + resolution: "", +} + +const meta = { + title: "Pages/Home/Components/HomePageContent", + component: HomePageContent, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + projects: [createProject()], + selectedProject: createProject(), + filteredContents: [content], + pendingReviewItems: [reviewItem], + entities: [createEntity()], + positiveFeedback: 1, + negativeFeedback: 1, + contentTypes: ["article"], + contentTypeFilter: "", + sources: ["rss"], + sourceFilter: "", + daysFilter: 30, + duplicateStateFilter: "", + view: "content", + sourceConfigs: [createSourceConfig()], + contentMap: new Map([[content.id, content]]), + contentClusterLookup: new Map([[content.id, { clusterId: 5, label: "Platform Signals", velocityScore: 0.81 }]]), + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const ContentView: Story = {} + +export const ReviewView: Story = { + args: { + view: "review", + }, +} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/HomePageContent/index.test.tsx b/frontend/src/app/(home)/_components/HomePageContent/index.test.tsx new file mode 100644 index 00000000..604fb151 --- /dev/null +++ b/frontend/src/app/(home)/_components/HomePageContent/index.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import { describe, expect, it, vi } from "vitest" + +import { + createContent, + createEntity, + createProject, + createSourceConfig, +} from "@/lib/storybook-fixtures" +import type { ReviewQueueItem } from "@/lib/types" + +vi.mock("@/components/layout/AppShell", () => ({ + AppShell: ({ children, title }: { children: ReactNode; title: string }) => ( +
+

{title}

+ {children} +
+ ), +})) + +import { HomePageContent } from "." + +const content = createContent() +const reviewItem: ReviewQueueItem = { + id: 7, + project: 1, + content: content.id, + reason: "borderline_relevance", + confidence: 0.61, + created_at: "2026-04-28T12:00:00Z", + resolved: false, + resolution: "", +} + +describe("HomePageContent", () => { + it("renders flash messages and the content view", () => { + const project = createProject() + + render( + , + ) + + expect(screen.getByText("AI Weekly dashboard")).toBeInTheDocument() + expect(screen.getByText("Filter failed")).toBeInTheDocument() + expect(screen.getByText("Filters applied")).toBeInTheDocument() + expect(screen.getByText(content.title)).toBeInTheDocument() + }) + + it("renders the review view when selected", () => { + const project = createProject() + + render( + , + ) + + expect(screen.getByText("borderline_relevance")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Approve" })).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/HomePageContent/index.tsx b/frontend/src/app/(home)/_components/HomePageContent/index.tsx new file mode 100644 index 00000000..13b7e016 --- /dev/null +++ b/frontend/src/app/(home)/_components/HomePageContent/index.tsx @@ -0,0 +1,122 @@ +import { AppShell } from "@/components/layout/AppShell" +import { Alert, AlertDescription } from "@/components/ui/alert" +import type { DashboardView, DuplicateStateFilter } from "@/lib/dashboard-view" +import type { + Content, + Entity, + Project, + ReviewQueueItem, + SourceConfig, +} from "@/lib/types" + +import { ContentFeed } from "../ContentFeed" +import { DashboardFilterToolbar } from "../DashboardFilterToolbar" +import { DashboardOverview } from "../DashboardOverview" +import { DashboardSidebar } from "../DashboardSidebar" +import { ReviewQueueTable } from "../ReviewQueueTable" +import type { ContentClusterBadge } from "../shared" + +type HomePageContentProps = { + projects: Project[] + selectedProject: Project + filteredContents: Content[] + pendingReviewItems: ReviewQueueItem[] + entities: Entity[] + positiveFeedback: number + negativeFeedback: number + contentTypes: string[] + contentTypeFilter: string + sources: string[] + sourceFilter: string + daysFilter: number + duplicateStateFilter: DuplicateStateFilter + view: DashboardView + sourceConfigs: SourceConfig[] + contentMap: Map + contentClusterLookup: Map + errorMessage?: string + successMessage?: string +} + +/** Render the dashboard UI for one selected project. */ +export function HomePageContent({ + projects, + selectedProject, + filteredContents, + pendingReviewItems, + entities, + positiveFeedback, + negativeFeedback, + contentTypes, + contentTypeFilter, + sources, + sourceFilter, + daysFilter, + duplicateStateFilter, + view, + sourceConfigs, + contentMap, + contentClusterLookup, + errorMessage = "", + successMessage = "", +}: HomePageContentProps) { + return ( + + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + {successMessage ? ( + + {successMessage} + + ) : null} + + + + {view === "review" ? ( + + ) : ( +
+ + +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/ReviewQueueTable/index.stories.tsx b/frontend/src/app/(home)/_components/ReviewQueueTable/index.stories.tsx new file mode 100644 index 00000000..b414a4e8 --- /dev/null +++ b/frontend/src/app/(home)/_components/ReviewQueueTable/index.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" +import { createContent } from "@/lib/storybook-fixtures" +import type { ReviewQueueItem } from "@/lib/types" + +import { ReviewQueueTable } from "." + +const content = createContent() +const pendingReviewItems: ReviewQueueItem[] = [ + { + id: 7, + project: 1, + content: content.id, + reason: "borderline_relevance", + confidence: 0.61, + created_at: "2026-04-28T12:00:00Z", + resolved: false, + resolution: "", + }, +] + +const meta = { + title: "Pages/Home/Components/ReviewQueueTable", + component: ReviewQueueTable, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + projectId: 1, + pendingReviewItems, + contentMap: new Map([[content.id, content]]), + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Empty: Story = { + args: { + pendingReviewItems: [], + contentMap: new Map(), + }, +} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/ReviewQueueTable/index.test.tsx b/frontend/src/app/(home)/_components/ReviewQueueTable/index.test.tsx new file mode 100644 index 00000000..a254b67f --- /dev/null +++ b/frontend/src/app/(home)/_components/ReviewQueueTable/index.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { createContent } from "@/lib/storybook-fixtures" +import type { Content, ReviewQueueItem } from "@/lib/types" + +import { ReviewQueueTable } from "." + +function createReviewQueueItem(overrides: Partial = {}): ReviewQueueItem { + return { + id: 7, + project: 1, + content: 41, + reason: "borderline_relevance", + confidence: 0.61, + created_at: "2026-04-28T12:00:00Z", + resolved: false, + resolution: "", + ...overrides, + } +} + +describe("ReviewQueueTable", () => { + it("renders the empty state when no review items exist", () => { + render() + + expect( + screen.getByText("No unresolved review items for this project right now."), + ).toBeInTheDocument() + }) + + it("renders queue rows with fallback metadata and actions", () => { + const content = createContent({ duplicate_of: 18, duplicate_signal_count: 2 }) + const contentMap = new Map([[content.id, content]]) + + render( + , + ) + + expect(screen.getByText("Useful AI briefing", { selector: "strong" })).toBeInTheDocument() + expect(screen.getByText("Also seen in 2 sources")).toBeInTheDocument() + expect(screen.getByText("Duplicate of #18")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Approve" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/ReviewQueueTable/index.tsx b/frontend/src/app/(home)/_components/ReviewQueueTable/index.tsx new file mode 100644 index 00000000..f900b285 --- /dev/null +++ b/frontend/src/app/(home)/_components/ReviewQueueTable/index.tsx @@ -0,0 +1,109 @@ +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import type { Content, ReviewQueueItem } from "@/lib/types" +import { formatDate, formatScore } from "@/lib/view-helpers" + +type ReviewQueueTableProps = { + projectId: number + pendingReviewItems: ReviewQueueItem[] + contentMap: Map +} + +/** Render the review queue table and resolution actions. */ +export function ReviewQueueTable({ + projectId, + pendingReviewItems, + contentMap, +}: ReviewQueueTableProps) { + return ( +
+ + + + Content + Reason + Confidence + Queued + Actions + + + + {pendingReviewItems.length === 0 ? ( + + + + + No unresolved review items for this project right now. + + + + + ) : null} + {pendingReviewItems.map((item) => { + const content = contentMap.get(item.content) + + return ( + + + + {content?.title ?? `Content #${item.content}`} + +
+ {content?.source_plugin ?? "unknown source"} + {content?.content_type || "unclassified"} + {content?.duplicate_signal_count ? ( + + Also seen in {content.duplicate_signal_count} source + {content.duplicate_signal_count === 1 ? "" : "s"} + + ) : null} + {content?.duplicate_of ? Duplicate of #{content.duplicate_of} : null} +
+
+ + {item.reason} + + + {formatScore(item.confidence)} + + + {formatDate(item.created_at)} + + +
+
+ + + + + + +
+ + + + + + +
+
+
+ ) + })} +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/shared.test.ts b/frontend/src/app/(home)/_components/shared.test.ts new file mode 100644 index 00000000..1cd10197 --- /dev/null +++ b/frontend/src/app/(home)/_components/shared.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" + +import { createTopicClusterDetail } from "@/lib/storybook-fixtures" + +import { buildContentClusterLookup } from "./shared" + +describe("buildContentClusterLookup", () => { + it("keeps the highest-velocity cluster badge per content item", () => { + const lowVelocityCluster = createTopicClusterDetail({ + id: 5, + label: "Low velocity", + velocity_score: 0.2, + }) + const highVelocityCluster = createTopicClusterDetail({ + id: 6, + label: "High velocity", + velocity_score: 0.8, + memberships: lowVelocityCluster.memberships, + }) + + const lookup = buildContentClusterLookup([lowVelocityCluster, highVelocityCluster]) + + expect(lookup.get(41)).toEqual({ + clusterId: 6, + label: "High velocity", + velocityScore: 0.8, + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/app/(home)/_components/shared.ts b/frontend/src/app/(home)/_components/shared.ts new file mode 100644 index 00000000..fb01172e --- /dev/null +++ b/frontend/src/app/(home)/_components/shared.ts @@ -0,0 +1,47 @@ +import type { DashboardView, DuplicateStateFilter } from "@/lib/dashboard-view" +import type { TopicClusterDetail } from "@/lib/types" + +export type ContentClusterBadge = { + clusterId: number + label: string + velocityScore: number | null +} + +export const dashboardViewOptions: Array<{ value: DashboardView; label: string }> = [ + { value: "content", label: "Surfaced content" }, + { value: "review", label: "Pending review" }, +] + +export const dashboardDayOptions = [ + { value: "7", label: "7 days" }, + { value: "14", label: "14 days" }, + { value: "30", label: "30 days" }, + { value: "90", label: "90 days" }, +] as const + +export const duplicateStateOptions: Array<{ value: DuplicateStateFilter; label: string }> = [ + { value: "", label: "All items" }, + { value: "duplicate_related", label: "Duplicate-related" }, +] + +export function buildContentClusterLookup(clusterDetails: TopicClusterDetail[]) { + const lookup = new Map() + + for (const clusterDetail of clusterDetails) { + for (const membership of clusterDetail.memberships) { + const current = lookup.get(membership.content.id) + const candidateVelocity = clusterDetail.velocity_score ?? 0 + const currentVelocity = current?.velocityScore ?? -1 + + if (!current || candidateVelocity > currentVelocity) { + lookup.set(membership.content.id, { + clusterId: clusterDetail.id, + label: clusterDetail.label || `Cluster ${clusterDetail.id}`, + velocityScore: clusterDetail.velocity_score, + }) + } + } + } + + return lookup +} \ No newline at end of file diff --git a/frontend/src/app/(home)/page.stories.tsx b/frontend/src/app/(home)/page.stories.tsx new file mode 100644 index 00000000..f2cb613d --- /dev/null +++ b/frontend/src/app/(home)/page.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { HomePageContent } from "@/app/(home)/_components/HomePageContent" +import { compactDocsParameters } from "@/lib/storybook-docs" +import { + createContent, + createEntity, + createProject, + createSourceConfig, +} from "@/lib/storybook-fixtures" +import type { ReviewQueueItem } from "@/lib/types" + +const content = createContent({ + is_reference: true, + newsletter_promotion_at: "2026-04-28T11:00:00Z", + newsletter_promotion_theme: 14, +}) +const reviewItem: ReviewQueueItem = { + id: 7, + project: 1, + content: content.id, + reason: "borderline_relevance", + confidence: 0.61, + created_at: "2026-04-28T12:00:00Z", + resolved: false, + resolution: "", +} + +const meta = { + title: "Pages/Home", + component: HomePageContent, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + projects: [createProject()], + selectedProject: createProject(), + filteredContents: [content], + pendingReviewItems: [reviewItem], + entities: [createEntity()], + positiveFeedback: 1, + negativeFeedback: 1, + contentTypes: ["article"], + contentTypeFilter: "", + sources: ["rss"], + sourceFilter: "", + daysFilter: 30, + duplicateStateFilter: "", + view: "content", + sourceConfigs: [createSourceConfig()], + contentMap: new Map([[content.id, content]]), + contentClusterLookup: new Map([[content.id, { clusterId: 5, label: "Platform Signals", velocityScore: 0.81 }]]), + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const ContentView: Story = {} + +export const ReviewView: Story = { + args: { + view: "review", + }, +} + +export const WithFlashMessages: Story = { + args: { + errorMessage: "Filter failed", + successMessage: "Filters applied", + }, +} \ No newline at end of file diff --git a/frontend/src/app/page.test.tsx b/frontend/src/app/(home)/page.test.tsx similarity index 71% rename from frontend/src/app/page.test.tsx rename to frontend/src/app/(home)/page.test.tsx index b7464be4..9647be07 100644 --- a/frontend/src/app/page.test.tsx +++ b/frontend/src/app/(home)/page.test.tsx @@ -19,6 +19,7 @@ const { getProjectEntitiesMock, getProjectFeedbackMock, getProjectsMock, + homePageContentMock, getProjectReviewQueueMock, getProjectSourceConfigsMock, getProjectTopicClusterMock, @@ -30,6 +31,7 @@ const { getProjectEntitiesMock: vi.fn(), getProjectFeedbackMock: vi.fn(), getProjectsMock: vi.fn(), + homePageContentMock: vi.fn(() =>
), getProjectReviewQueueMock: vi.fn(), getProjectSourceConfigsMock: vi.fn(), getProjectTopicClusterMock: vi.fn(), @@ -55,18 +57,8 @@ vi.mock("@/components/layout/AppShell", () => ({ ), })) -vi.mock("@/components/elements/StatusBadge", () => ({ - StatusBadge: ({ - children, - tone, - }: { - children: ReactNode - tone: string - }) => ( - - {children} - - ), +vi.mock("@/app/(home)/_components/HomePageContent", () => ({ + HomePageContent: homePageContentMock, })) vi.mock("@/lib/api", () => ({ @@ -341,11 +333,10 @@ describe("HomePage", () => { expect(getProjectTopicClustersMock).not.toHaveBeenCalled() }) - it("renders the content view with summaries, flash messages, and content cards", async () => { + it("loads the derived dashboard state into HomePageContent", async () => { const content = createContent({ title: "Useful AI briefing", is_reference: true, - is_active: false, relevance_score: 0.84, authority_adjusted_score: 0.88, newsletter_promotion_at: "2026-04-28T11:00:00Z", @@ -427,124 +418,40 @@ describe("HomePage", () => { view: "content", }, }) - expect(screen.getByText("Filter failed")).toBeInTheDocument() - expect(screen.getByText("Filters applied")).toBeInTheDocument() - expect(screen.getByText("Useful AI briefing")).toBeInTheDocument() - expect(screen.getByText("1/1")).toBeInTheDocument() - expect( - screen.getAllByText("1", { selector: "p.mt-1.text-3xl.font-bold" }), - ).toHaveLength(5) - expect(screen.getByText("reference")).toBeInTheDocument() - expect(screen.getByText("archived")).toBeInTheDocument() - expect( - screen.getByRole("link", { name: /Trend Platform Signals/i }), - ).toHaveAttribute("href", "/trends?project=1&cluster=5") - expect( - screen.getByRole("link", { name: /Promoted Apr 28, 2026/i }), - ).toHaveAttribute("href", "/themes?project=1&theme=14") + expect(homePageContentMock).toHaveBeenCalled() + const props = (homePageContentMock.mock.calls[0] as unknown[] | undefined)?.[0] as + | { + positiveFeedback: number + negativeFeedback: number + errorMessage: string + successMessage: string + contentClusterLookup: Map + sourceConfigs: SourceConfig[] + entities: Entity[] + filteredContents: Content[] + pendingReviewItems: ReviewQueueItem[] + } + | undefined + + if (!props) { + throw new Error("Expected HomePageContent props to be captured") + } + + expect(props.errorMessage).toBe("Filter failed") + expect(props.successMessage).toBe("Filters applied") + expect(props.positiveFeedback).toBe(1) + expect(props.negativeFeedback).toBe(1) + expect(props.entities).toHaveLength(1) + expect(props.sourceConfigs).toHaveLength(2) + expect(props.filteredContents).toEqual([content]) + expect(props.pendingReviewItems).toEqual([reviewItem]) + expect(props.contentClusterLookup.get(content.id)).toEqual({ + clusterId: 5, + label: "Platform Signals", + velocityScore: 0.81, + }) + expect(screen.getByTestId("home-page-content")).toBeInTheDocument() expect(getProjectTopicClustersMock).toHaveBeenCalledWith(1) expect(getProjectTopicClusterMock).toHaveBeenCalledWith(1, 5) - - const badges = screen.getAllByTestId("status-badge") - expect(badges).toHaveLength(1) - expect(badges[0]).toHaveAttribute("data-tone", "positive") - expect(badges[0]).toHaveTextContent("Adjusted 88%") - expect(screen.getByText("Base 84%")).toBeInTheDocument() - }) - - it("renders duplicate context inside review rows", async () => { - const content = createContent({ - duplicate_of: 18, - duplicate_signal_count: 2, - }) - const reviewItem = createReviewQueueItem({ content: content.id }) - - buildDashboardViewMock.mockReturnValue( - createDashboardView({ - contentMap: new Map([[content.id, content]]), - pendingReviewItems: [reviewItem], - view: "review", - }), - ) - - await renderHomePage({ project: "1", view: "review" }) - - expect(screen.getByText("Also seen in 2 sources")).toBeInTheDocument() - expect(screen.getByText("Duplicate of #18")).toBeInTheDocument() - }) - - it("renders duplicate badges on content cards", async () => { - const content = createContent({ - duplicate_of: 19, - duplicate_signal_count: 3, - is_active: false, - }) - - getProjectContentsMock.mockResolvedValue([content]) - buildDashboardViewMock.mockReturnValue( - createDashboardView({ - contentMap: new Map([[content.id, content]]), - contentTypes: ["article"], - filteredContents: [content], - pendingReviewItems: [], - sources: ["rss"], - view: "content", - }), - ) - - await renderHomePage({ project: "1", view: "content" }) - - expect(screen.getByText("Also seen in 3 sources")).toBeInTheDocument() - expect(screen.getByText("Duplicate of #19")).toBeInTheDocument() - }) - - it("renders the empty content state when no content matches the current filters", async () => { - buildDashboardViewMock.mockReturnValue( - createDashboardView({ - filteredContents: [], - pendingReviewItems: [], - view: "content", - }), - ) - - await renderHomePage({ project: "1" }) - - expect( - screen.getByText("No content matched the current filters."), - ).toBeInTheDocument() - }) - - it("renders the review view empty state when there are no unresolved items", async () => { - buildDashboardViewMock.mockReturnValue( - createDashboardView({ - pendingReviewItems: [], - view: "review", - }), - ) - - await renderHomePage({ project: "1", view: "review" }) - - expect( - screen.getByText("No unresolved review items for this project right now."), - ).toBeInTheDocument() - }) - - it("renders the review table with fallback content labels when content metadata is missing", async () => { - const reviewItem = createReviewQueueItem({ id: 14, content: 99 }) - buildDashboardViewMock.mockReturnValue( - createDashboardView({ - contentMap: new Map(), - pendingReviewItems: [reviewItem], - view: "review", - }), - ) - - await renderHomePage({ project: "1", view: "review" }) - - expect(screen.getByText("Content #99", { selector: "strong" })).toBeInTheDocument() - expect(screen.getByText("unknown source")).toBeInTheDocument() - expect(screen.getByText("unclassified")).toBeInTheDocument() - expect(screen.getByRole("button", { name: "Approve" })).toBeInTheDocument() - expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument() }) }) diff --git a/frontend/src/app/(home)/page.tsx b/frontend/src/app/(home)/page.tsx new file mode 100644 index 00000000..c0a816af --- /dev/null +++ b/frontend/src/app/(home)/page.tsx @@ -0,0 +1,114 @@ +import { HomePageContent } from "@/app/(home)/_components/HomePageContent" +import { buildContentClusterLookup } from "@/app/(home)/_components/shared" +import { AppShell } from "@/components/layout/AppShell" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { + getProjectContents, + getProjectEntities, + getProjectFeedback, + getProjectReviewQueue, + getProjects, + getProjectSourceConfigs, + getProjectTopicCluster, + getProjectTopicClusters, +} from "@/lib/api" +import { buildDashboardView } from "@/lib/dashboard-view" +import { + getErrorMessage, + getSuccessMessage, + selectProject, +} from "@/lib/view-helpers" + +type HomePageProps = { + /** Search params promise containing the optional dashboard filters and flash messages. */ + searchParams: Promise> +} + +/** + * Render the project dashboard for the selected API-visible project. + * + * The page resolves the active project from the URL, loads the project-scoped content, + * review queue, entity, source, and feedback data, and then delegates filter and summary + * derivation to `buildDashboardView`. When the current API user has no visible projects, + * the page returns a guarded empty state instead of issuing any project-scoped requests. + */ +export default async function HomePage({ searchParams }: HomePageProps) { + const resolvedSearchParams = await searchParams + const projects = await getProjects() + const selectedProject = selectProject(projects, resolvedSearchParams) + + if (!selectedProject) { + return ( + + + No projects are available for the configured API user. + + + ) + } + + const [contents, reviewQueue, entities, sourceConfigs, feedback, topicClusters] = + await Promise.all([ + getProjectContents(selectedProject.id), + getProjectReviewQueue(selectedProject.id), + getProjectEntities(selectedProject.id), + getProjectSourceConfigs(selectedProject.id), + getProjectFeedback(selectedProject.id), + getProjectTopicClusters(selectedProject.id), + ]) + const clusterDetails = await Promise.all( + topicClusters.map((cluster) => getProjectTopicCluster(selectedProject.id, cluster.id)), + ) + const contentClusterLookup = buildContentClusterLookup(clusterDetails) + + const { + contentMap, + contentTypeFilter, + contentTypes, + daysFilter, + duplicateStateFilter, + filteredContents, + negativeFeedback, + pendingReviewItems, + positiveFeedback, + sourceFilter, + sources, + view, + } = buildDashboardView({ + contents, + reviewQueue, + feedback, + searchParams: resolvedSearchParams, + }) + const errorMessage = getErrorMessage(resolvedSearchParams) + const successMessage = getSuccessMessage(resolvedSearchParams) + + return ( + + ) +} diff --git a/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx b/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx new file mode 100644 index 00000000..0cc4fe00 --- /dev/null +++ b/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import type { + SourceDiversityObservabilitySummary, + SourceDiversitySnapshot, +} from "@/lib/types" + +import { SourceDiversityPanel } from "." + +function createSnapshot( + overrides: Partial = {}, +): SourceDiversitySnapshot { + return { + id: 3, + project: 1, + computed_at: "2026-04-28T08:00:00Z", + window_days: 14, + plugin_entropy: 0.65, + source_entropy: 0.72, + author_entropy: 0.48, + cluster_entropy: 0.58, + top_plugin_share: 0.62, + top_source_share: 0.44, + breakdown: { + total_content_count: 12, + plugin_counts: [{ key: "rss", label: "rss", count: 7, share: 0.58 }], + source_counts: [ + { key: "feed:1", label: "Example Feed", count: 5, share: 0.42 }, + ], + author_counts: [], + cluster_counts: [], + alerts: [], + }, + ...overrides, + } +} + +function createSummary( + overrides: Partial = {}, +): SourceDiversityObservabilitySummary { + return { + project: 1, + snapshot_count: 2, + latest_snapshot: createSnapshot(), + ...overrides, + } +} + +describe("SourceDiversityPanel", () => { + it("renders source diversity alerts and trend details", () => { + render( + , + ) + + expect( + screen.getByRole("heading", { level: 2, name: "Source diversity" }), + ).toBeInTheDocument() + expect(screen.getByText("Your stream is 70%+ from RSS this week.")).toBeInTheDocument() + expect(screen.getByLabelText("Source diversity trend")).toBeInTheDocument() + }) + + it("renders the empty state when no snapshots exist", () => { + render( + , + ) + + expect( + screen.getByText("No source-diversity snapshots exist for this project yet."), + ).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.tsx b/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.tsx index 059185bc..ab2b0089 100644 --- a/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.tsx +++ b/frontend/src/app/admin/health/_components/SourceDiversityPanel/index.tsx @@ -1,4 +1,13 @@ +import type { ComponentProps } from "react" + import { StatusBadge } from "@/components/elements/StatusBadge" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card" import type { SourceDiversityObservabilitySummary, SourceDiversitySnapshot, @@ -13,7 +22,7 @@ type SourceDiversityPanelProps = { /** SVG polyline points for the top-plugin-share trend. */ trendPoints: string /** Semantic tone for the summary badge. */ - statusTone: "positive" | "warning" | "negative" | "neutral" + statusTone: ComponentProps["tone"] /** Visible summary label for the status badge. */ statusLabel: string } @@ -50,142 +59,186 @@ export function SourceDiversityPanel({ statusLabel, }: SourceDiversityPanelProps) { return ( -
-
-
-

Source diversity

-

- Entropy, source concentration, and advisory alerts derived from the latest source-diversity snapshot. -

-
- {statusLabel} -
- - {summary.latest_snapshot ? ( - <> -
-
-

Plugin diversity

-

- {formatPercentScore(summary.latest_snapshot.plugin_entropy)} -

-
-
-

Source diversity

-

- {formatPercentScore(summary.latest_snapshot.source_entropy)} -

-
-
-

Author diversity

-

- {formatPercentScore(summary.latest_snapshot.author_entropy)} -

-
-
-

Cluster diversity

-

- {formatPercentScore(summary.latest_snapshot.cluster_entropy)} -

-
-
-

Top plugin share

-

- {formatPercentScore(summary.latest_snapshot.top_plugin_share)} -

-
-
-

Top source share

-

- {formatPercentScore(summary.latest_snapshot.top_source_share)} -

-
-
- - {visibleSnapshots.length > 1 ? ( -
-
- Top plugin share trend - Last {visibleSnapshots.length} snapshots -
- - - -
- ) : null} + + +

+ Source diversity +

+ + Entropy, source concentration, and advisory alerts derived from the latest source-diversity snapshot. + + + {statusLabel} + +
- {(summary.latest_snapshot.breakdown.alerts ?? []).length > 0 ? ( -
- {summary.latest_snapshot.breakdown.alerts.map((alert) => ( -
- {alert.code} -

{alert.message}

-
- ))} -
- ) : ( -
- No source-diversity alerts are active for this project. + + {summary.latest_snapshot ? ( + <> +
+ + +

+ Plugin diversity +

+

+ {formatPercentScore(summary.latest_snapshot.plugin_entropy)} +

+
+
+ + +

+ Source diversity +

+

+ {formatPercentScore(summary.latest_snapshot.source_entropy)} +

+
+
+ + +

+ Author diversity +

+

+ {formatPercentScore(summary.latest_snapshot.author_entropy)} +

+
+
+ + +

+ Cluster diversity +

+

+ {formatPercentScore(summary.latest_snapshot.cluster_entropy)} +

+
+
+ + +

+ Top plugin share +

+

+ {formatPercentScore(summary.latest_snapshot.top_plugin_share)} +

+
+
+ + +

+ Top source share +

+

+ {formatPercentScore(summary.latest_snapshot.top_source_share)} +

+
+
- )} -
-
-

Top plugin buckets

-
- {summary.latest_snapshot.breakdown.plugin_counts.slice(0, 4).map((item) => ( -
-
- {item.label} - {formatPercentScore(item.share)} -
- {renderShareBar(item.share)} + {visibleSnapshots.length > 1 ? ( + + +
+ Top plugin share trend + Last {visibleSnapshots.length} snapshots
+ + + +
+
+ ) : null} + + {(summary.latest_snapshot.breakdown.alerts ?? []).length > 0 ? ( +
+ {summary.latest_snapshot.breakdown.alerts.map((alert) => ( + + + {alert.code} +

{alert.message}

+
+
))}
-
-
-

Top source buckets

-
- {summary.latest_snapshot.breakdown.source_counts.slice(0, 4).map((item) => ( -
-
- {item.label} - {formatPercentScore(item.share)} -
- {renderShareBar(item.share)} + ) : ( + + + No source-diversity alerts are active for this project. + + + )} + +
+ + +

Top plugin buckets

+
+ {summary.latest_snapshot.breakdown.plugin_counts.slice(0, 4).map((item) => ( +
+
+ {item.label} + {formatPercentScore(item.share)} +
+ {renderShareBar(item.share)} +
+ ))}
- ))} -
+ + + + +

Top source buckets

+
+ {summary.latest_snapshot.breakdown.source_counts.slice(0, 4).map((item) => ( +
+
+ {item.label} + {formatPercentScore(item.share)} +
+ {renderShareBar(item.share)} +
+ ))} +
+
+
-
-
- - View raw breakdown JSON - -
-              {JSON.stringify(summary.latest_snapshot.breakdown, null, 2)}
-            
-
- - ) : ( -
- No source-diversity snapshots exist for this project yet. -
- )} -
+
+ + View raw breakdown JSON + +
+                {JSON.stringify(summary.latest_snapshot.breakdown, null, 2)}
+              
+
+ + ) : ( + + + No source-diversity snapshots exist for this project yet. + + + )} + + ) } diff --git a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx new file mode 100644 index 00000000..784dbe2b --- /dev/null +++ b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" +import { + createIngestionRun, + createSourceConfig, +} from "@/lib/storybook-fixtures" + +import { SourceHealthPanel } from "." + +const meta = { + title: "Pages/AdminHealth/Components/SourceHealthPanel", + component: SourceHealthPanel, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + statusLabel: "mixed", + statusTone: "warning", + rows: [ + { + sourceConfig: createSourceConfig(), + latestRun: createIngestionRun(), + status: "healthy", + }, + { + sourceConfig: createSourceConfig({ + id: 8, + plugin_name: "reddit", + last_fetched_at: null, + }), + latestRun: createIngestionRun({ + id: 23, + plugin_name: "reddit", + status: "failed", + error_message: "Rate limit", + }), + status: "failing", + }, + ], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Mixed: Story = {} + +export const Empty: Story = { + args: { + rows: [], + statusLabel: "idle", + statusTone: "neutral", + }, +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx new file mode 100644 index 00000000..f0839761 --- /dev/null +++ b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import type { IngestionRun, SourceConfig } from "@/lib/types" + +import { SourceHealthPanel } from "." + +function createSourceConfig(overrides: Partial = {}): SourceConfig { + return { + id: 7, + project: 1, + plugin_name: "rss", + config: { feed_url: "https://example.com/feed.xml" }, + is_active: true, + last_fetched_at: "2026-04-28T08:00:00Z", + ...overrides, + } +} + +function createIngestionRun(overrides: Partial = {}): IngestionRun { + return { + id: 22, + project: 1, + plugin_name: "rss", + started_at: "2026-04-28T09:00:00Z", + completed_at: "2026-04-28T09:03:00Z", + status: "success", + items_fetched: 12, + items_ingested: 9, + error_message: "", + ...overrides, + } +} + +describe("SourceHealthPanel", () => { + it("renders source health rows", () => { + render( + , + ) + + expect(screen.getByText("Source configuration health")).toBeInTheDocument() + expect(screen.getByText("rss", { selector: "strong" })).toBeInTheDocument() + expect(screen.getByText("9/12")).toBeInTheDocument() + }) + + it("renders the empty state when no source configs exist", () => { + render() + + expect( + screen.getByText("No source configurations exist for this project yet."), + ).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx new file mode 100644 index 00000000..b2428380 --- /dev/null +++ b/frontend/src/app/admin/health/_components/SourceHealthPanel/index.tsx @@ -0,0 +1,119 @@ +import type { ComponentProps } from "react" + +import { StatusBadge } from "@/components/elements/StatusBadge" +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import type { HealthStatus, IngestionRun, SourceConfig } from "@/lib/types" +import { formatDate, healthTone } from "@/lib/view-helpers" + +type SourceHealthRow = { + /** Stored source configuration for one plugin. */ + sourceConfig: SourceConfig + /** Latest ingestion run for that plugin, if any. */ + latestRun: IngestionRun | null + /** Computed health state for the source row. */ + status: HealthStatus +} + +type SourceHealthPanelProps = { + /** Source rows prepared by the page orchestration layer. */ + rows: SourceHealthRow[] + /** Optional section status label. */ + statusLabel?: string + /** Optional section status tone. */ + statusTone?: ComponentProps["tone"] +} + +/** Render the source-by-source ingestion health table for the selected project. */ +export function SourceHealthPanel({ + rows, + statusLabel = "sources", + statusTone = "neutral", +}: SourceHealthPanelProps) { + return ( + + +
+
+

+ Source configuration health +

+ + Per-plugin freshness, latest run outcome, and current source health for the selected project. + +
+ {statusLabel} +
+
+ + + {rows.length === 0 ? ( + + + No source configurations exist for this project yet. + + + ) : ( + + + + Source + Status + Last fetch + Latest run + Items + Errors + + + + {rows.map(({ sourceConfig, latestRun, status }) => ( + + + + {sourceConfig.plugin_name} + +
+ Config #{sourceConfig.id} + {sourceConfig.is_active ? "active" : "disabled"} +
+
+ + {status} + + + {formatDate(sourceConfig.last_fetched_at)} + + + {latestRun + ? `${latestRun.status} at ${formatDate(latestRun.started_at)}` + : "No runs yet"} + + + {latestRun + ? `${latestRun.items_ingested}/${latestRun.items_fetched}` + : "0/0"} + + + {latestRun?.error_message || "-"} + +
+ ))} +
+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx new file mode 100644 index 00000000..f95170e7 --- /dev/null +++ b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" +import { + createTopicCentroidSnapshot, + createTopicCentroidSummary, +} from "@/lib/storybook-fixtures" + +import { TopicCentroidPanel } from "." + +const activeSnapshots = [ + createTopicCentroidSnapshot({ id: 1, computed_at: "2026-04-25T08:00:00Z", drift_from_previous: 0.08 }), + createTopicCentroidSnapshot({ id: 2, computed_at: "2026-04-26T08:00:00Z", drift_from_previous: 0.11 }), + createTopicCentroidSnapshot({ id: 3, computed_at: "2026-04-27T08:00:00Z", drift_from_previous: 0.14 }), +] + +const meta = { + title: "Pages/AdminHealth/Components/TopicCentroidPanel", + component: TopicCentroidPanel, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + summary: createTopicCentroidSummary({ + snapshot_count: 3, + active_snapshot_count: 3, + avg_drift_from_previous: 0.11, + avg_drift_from_week_ago: 0.18, + latest_snapshot: activeSnapshots[2], + }), + visibleSnapshots: activeSnapshots, + trendPoints: "0,66 110,58 220,49", + statusTone: "positive", + statusLabel: "active", + historyHref: "/admin/health?project=1#centroid-snapshot-history", + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Active: Story = {} + +export const NoSnapshots: Story = { + args: { + summary: createTopicCentroidSummary({ + snapshot_count: 0, + active_snapshot_count: 0, + avg_drift_from_previous: null, + avg_drift_from_week_ago: null, + latest_snapshot: null, + }), + visibleSnapshots: [], + trendPoints: "0,36 220,36", + statusTone: "neutral", + statusLabel: "idle", + }, +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx new file mode 100644 index 00000000..0c354500 --- /dev/null +++ b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import type { + TopicCentroidObservabilitySummary, + TopicCentroidSnapshot, +} from "@/lib/types" + +import { TopicCentroidPanel } from "." + +function createSnapshot( + overrides: Partial = {}, +): TopicCentroidSnapshot { + return { + id: 3, + project: 1, + computed_at: "2026-04-28T08:00:00Z", + centroid_active: true, + feedback_count: 14, + upvote_count: 11, + downvote_count: 3, + drift_from_previous: 0.1, + drift_from_week_ago: 0.2, + ...overrides, + } +} + +function createSummary( + overrides: Partial = {}, +): TopicCentroidObservabilitySummary { + return { + project: 1, + snapshot_count: 3, + active_snapshot_count: 2, + avg_drift_from_previous: 0.1, + avg_drift_from_week_ago: 0.2, + latest_snapshot: createSnapshot(), + ...overrides, + } +} + +describe("TopicCentroidPanel", () => { + it("renders centroid summary metrics and history", () => { + render( + , + ) + + expect(screen.getByText("Topic centroid observability")).toBeInTheDocument() + expect(screen.getAllByText("10.0%").length).toBeGreaterThan(0) + expect(screen.getByText("Feedback 14")).toBeInTheDocument() + expect( + screen.getByRole("link", { name: "Open centroid snapshot history" }), + ).toHaveAttribute( + "href", + "/admin/health?project=1#centroid-snapshot-history", + ) + expect(screen.getByText("Centroid snapshot history")).toBeInTheDocument() + }) + + it("renders empty states when no snapshots exist", () => { + render( + , + ) + + expect( + screen.getByText("No centroid snapshots exist for this project yet."), + ).toBeInTheDocument() + expect( + screen.getByText("No centroid snapshot history exists for this project yet."), + ).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx new file mode 100644 index 00000000..4e1af5f9 --- /dev/null +++ b/frontend/src/app/admin/health/_components/TopicCentroidPanel/index.tsx @@ -0,0 +1,231 @@ +import Link from "next/link" +import type { ComponentProps } from "react" + +import { StatusBadge } from "@/components/elements/StatusBadge" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import type { + TopicCentroidObservabilitySummary, + TopicCentroidSnapshot, +} from "@/lib/types" +import { formatDate } from "@/lib/view-helpers" + +function formatDriftPercent(value: number | null) { + if (value === null) { + return "n/a" + } + return `${(value * 100).toFixed(1)}%` +} + +type TopicCentroidPanelProps = { + /** Aggregate centroid summary for the selected project. */ + summary: TopicCentroidObservabilitySummary + /** Recent centroid snapshots shown in the summary sparkline and history table. */ + visibleSnapshots: TopicCentroidSnapshot[] + /** SVG polyline points for the drift trend line. */ + trendPoints: string + /** Semantic tone for the summary badge. */ + statusTone: ComponentProps["tone"] + /** Visible label for the centroid status badge. */ + statusLabel: string + /** Deep link to the snapshot history section. */ + historyHref: string +} + +/** Render centroid observability summary and history for the admin health page. */ +export function TopicCentroidPanel({ + summary, + visibleSnapshots, + trendPoints, + statusTone, + statusLabel, + historyHref, +}: TopicCentroidPanelProps) { + return ( + <> + + +

+ Topic centroid observability +

+ + The latest centroid state for this project, plus average drift across persisted snapshot history. + + + {statusLabel} + +
+ + +
+ + +

+ Centroid state +

+

+ {summary.latest_snapshot + ? summary.latest_snapshot.centroid_active + ? "Active" + : "Inactive" + : "Not computed"} +

+
+
+ + +

+ Avg drift vs previous +

+

+ {formatDriftPercent(summary.avg_drift_from_previous)} +

+
+
+ + +

+ Avg drift vs 7d +

+

+ {formatDriftPercent(summary.avg_drift_from_week_ago)} +

+
+
+ + +

+ Latest snapshot +

+

+ {formatDate(summary.latest_snapshot?.computed_at ?? null)} +

+
+
+
+ + {visibleSnapshots.length > 1 ? ( + +
+ Recent drift trend + Last {visibleSnapshots.length} snapshots +
+ + + + + ) : null} + + {summary.latest_snapshot ? ( +
+ {summary.snapshot_count} snapshots + {summary.active_snapshot_count} active snapshots + Feedback {summary.latest_snapshot.feedback_count} + Upvotes {summary.latest_snapshot.upvote_count} + Downvotes {summary.latest_snapshot.downvote_count} +
+ ) : ( + + + No centroid snapshots exist for this project yet. + + + )} +
+
+ + + +

+ Centroid snapshot history +

+ + Recent centroid recomputations for this project, including feedback volume and drift between snapshots. + + + + Showing {visibleSnapshots.length} of {summary.snapshot_count} snapshots + + +
+ + + {visibleSnapshots.length === 0 ? ( + + + No centroid snapshot history exists for this project yet. + + + ) : ( + + + + Computed + State + Feedback + Drift vs previous + Drift vs 7d + + + + {visibleSnapshots.map((snapshot) => ( + + + {formatDate(snapshot.computed_at)} + + + + {snapshot.centroid_active ? "active" : "inactive"} + + + + {snapshot.feedback_count} total + + + {formatDriftPercent(snapshot.drift_from_previous)} + + + {formatDriftPercent(snapshot.drift_from_week_ago)} + + + ))} + +
+ )} +
+
+ + ) +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx new file mode 100644 index 00000000..c64c46aa --- /dev/null +++ b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" +import type { + TrendTaskRun, + TrendTaskRunObservabilitySummary, +} from "@/lib/types" + +import { TrendTaskRunsPanel } from "." + +function createTrendTaskRun(overrides: Partial = {}): TrendTaskRun { + return { + id: 41, + project: 1, + task_name: "recompute_topic_centroid", + task_run_id: "95ae5b14-5d7d-498e-9adc-1dbaab4dd4b8", + status: "completed", + started_at: "2026-04-28T08:00:00Z", + finished_at: "2026-04-28T08:00:01Z", + latency_ms: 523, + error_message: "", + summary: { + project_id: 1, + feedback_count: 12, + upvote_count: 10, + downvote_count: 2, + }, + ...overrides, + } +} + +function createSummary( + overrides: Partial = {}, +): TrendTaskRunObservabilitySummary { + return { + project: 1, + run_count: 8, + failed_run_count: 0, + latest_runs: [createTrendTaskRun()], + ...overrides, + } +} + +const historyRuns = [ + createTrendTaskRun(), + createTrendTaskRun({ + id: 42, + task_name: "generate_theme_suggestions", + status: "failed", + latency_ms: 1480, + error_message: "OpenRouter timeout", + summary: { project_id: 1, created: 0, updated: 0, skipped: 2 }, + }), +] + +const meta = { + title: "Pages/AdminHealth/Components/TrendTaskRunsPanel", + component: TrendTaskRunsPanel, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, + args: { + historyHref: "/admin/health?project=1#trend-task-run-history", + statusLabel: "healthy", + statusTone: "positive", + summary: createSummary({ latest_runs: historyRuns, failed_run_count: 1 }), + visibleRuns: historyRuns, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Healthy: Story = {} + +export const Failing: Story = { + args: { + statusLabel: "failing", + statusTone: "negative", + }, +} + +export const Empty: Story = { + args: { + statusLabel: "idle", + statusTone: "neutral", + summary: createSummary({ run_count: 0, failed_run_count: 0, latest_runs: [] }), + visibleRuns: [], + }, +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx new file mode 100644 index 00000000..8ad59640 --- /dev/null +++ b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import type { + TrendTaskRun, + TrendTaskRunObservabilitySummary, +} from "@/lib/types" + +import { TrendTaskRunsPanel } from "." + +function createTrendTaskRun(overrides: Partial = {}): TrendTaskRun { + return { + id: 41, + project: 1, + task_name: "recompute_topic_centroid", + task_run_id: "run-41", + status: "completed", + started_at: "2026-04-28T08:00:00Z", + finished_at: "2026-04-28T08:00:01Z", + latency_ms: 523, + error_message: "", + summary: { + project_id: 1, + feedback_count: 12, + upvote_count: 10, + downvote_count: 2, + }, + ...overrides, + } +} + +function createSummary( + overrides: Partial = {}, +): TrendTaskRunObservabilitySummary { + return { + project: 1, + run_count: 2, + failed_run_count: 1, + latest_runs: [createTrendTaskRun()], + ...overrides, + } +} + +describe("TrendTaskRunsPanel", () => { + it("renders trend task summaries and failure details", () => { + const failedRun = createTrendTaskRun({ + id: 42, + task_name: "generate_theme_suggestions", + status: "failed", + latency_ms: 1480, + error_message: "OpenRouter timeout", + summary: { project_id: 1, created: 0, updated: 0, skipped: 2 }, + }) + + render( + , + ) + + expect(screen.getByText("Trend pipeline runs")).toBeInTheDocument() + expect(screen.getAllByText("Theme suggestions").length).toBeGreaterThan(0) + expect(screen.getAllByText("OpenRouter timeout").length).toBeGreaterThan(0) + expect(screen.getAllByText("1.5s").length).toBeGreaterThan(0) + expect(screen.getByText("Trend task run history")).toBeInTheDocument() + }) + + it("renders empty states when no task runs exist", () => { + render( + , + ) + + expect( + screen.getByText("No trend pipeline runs have been persisted for this project yet."), + ).toBeInTheDocument() + expect( + screen.getByText("No trend task run history exists for this project yet."), + ).toBeInTheDocument() + }) +}) \ No newline at end of file diff --git a/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx new file mode 100644 index 00000000..b1448e05 --- /dev/null +++ b/frontend/src/app/admin/health/_components/TrendTaskRunsPanel/index.tsx @@ -0,0 +1,296 @@ +import Link from "next/link" +import type { ComponentProps } from "react" + +import { StatusBadge } from "@/components/elements/StatusBadge" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import type { + TrendTaskRun, + TrendTaskRunObservabilitySummary, +} from "@/lib/types" +import { formatDate } from "@/lib/view-helpers" + +const TREND_TASK_LABELS: Record = { + recompute_topic_centroid: "Topic centroid", + recompute_topic_clusters: "Topic clusters", + recompute_topic_velocity: "Topic velocity", + recompute_source_diversity: "Source diversity", + generate_theme_suggestions: "Theme suggestions", + generate_original_content_ideas: "Original content ideas", +} + +const TREND_TASK_DETAIL_LABELS: Record = { + feedback_count: "feedback", + upvote_count: "upvotes", + downvote_count: "downvotes", + contents_considered: "content", + clusters_updated: "clusters updated", + clusters_evaluated: "clusters evaluated", + snapshots_created: "snapshots", + content_count: "content", + alert_count: "alerts", + created: "created", + updated: "updated", + skipped: "skipped", +} + +function formatLatency(value: number | null) { + if (value === null) { + return "n/a" + } + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}s` + } + return `${value}ms` +} + +function trendTaskRunTone(status: TrendTaskRun["status"]) { + if (status === "failed") { + return "negative" + } + if (status === "started") { + return "warning" + } + if (status === "skipped") { + return "neutral" + } + return "positive" +} + +function formatTrendTaskName(taskName: string) { + return TREND_TASK_LABELS[taskName] ?? taskName.replaceAll("_", " ") +} + +function buildTrendTaskRunSummaryText(taskRun: TrendTaskRun) { + const detailParts = Object.entries(taskRun.summary) + .filter(([key, value]) => key !== "project_id" && key !== "snapshot_id" && value !== null) + .filter(([, value]) => ["string", "number", "boolean"].includes(typeof value)) + .slice(0, 3) + .map(([key, value]) => `${TREND_TASK_DETAIL_LABELS[key] ?? key.replaceAll("_", " ")} ${String(value)}`) + + if (detailParts.length === 0) { + return "No task summary recorded yet." + } + + return detailParts.join(" โ€ข ") +} + +type TrendTaskRunsPanelProps = { + /** Project-level trend task summary. */ + summary: TrendTaskRunObservabilitySummary + /** Visible persisted task runs for the history table. */ + visibleRuns: TrendTaskRun[] + /** Semantic tone for the overall section status. */ + statusTone: ComponentProps["tone"] + /** Visible label for the overall section status. */ + statusLabel: string + /** Deep link to the history section. */ + historyHref: string +} + +/** Render trend pipeline status and recent persisted runs for the health page. */ +export function TrendTaskRunsPanel({ + summary, + visibleRuns, + statusTone, + statusLabel, + historyHref, +}: TrendTaskRunsPanelProps) { + return ( + <> + + +

+ Trend pipeline runs +

+ + The latest persisted run for each tracked trend task, including task outcome, runtime, and any recorded failure message. + + + {statusLabel} + +
+ + +
+ + +

+ Persisted runs +

+

+ {summary.run_count} +

+
+
+ + +

+ Latest task rows +

+

+ {summary.latest_runs.length} +

+
+
+ + +

+ Failed runs +

+

+ {summary.failed_run_count} +

+
+
+
+ + {summary.latest_runs.length === 0 ? ( + + + No trend pipeline runs have been persisted for this project yet. + + + ) : ( + <> + {visibleRuns.length > 0 ? ( + +
+ Recent task history + Last {visibleRuns.length} persisted runs +
+ + ) : null} + + + + + Task + Status + Started + Duration + Summary + + + + {summary.latest_runs.map((taskRun) => ( + + + {formatTrendTaskName(taskRun.task_name)} + + + + {taskRun.status} + + + + {formatDate(taskRun.started_at)} + + + {formatLatency(taskRun.latency_ms)} + + +

{buildTrendTaskRunSummaryText(taskRun)}

+ {taskRun.error_message ? ( +

{taskRun.error_message}

+ ) : null} +
+
+ ))} +
+
+ + )} +
+
+ + + +

+ Trend task run history +

+ + Recent persisted executions across the trend pipeline, including run duration, summary output, and the latest recorded failures. + + + + Showing {visibleRuns.length} of {summary.run_count} runs + + +
+ + + {visibleRuns.length === 0 ? ( + + + No trend task run history exists for this project yet. + + + ) : ( + + + + Started + Task + Status + Finished + Duration + Summary + + + + {visibleRuns.map((taskRun) => ( + + + {formatDate(taskRun.started_at)} + + + {formatTrendTaskName(taskRun.task_name)} + + + + {taskRun.status} + + + + {formatDate(taskRun.finished_at)} + + + {formatLatency(taskRun.latency_ms)} + + +

{buildTrendTaskRunSummaryText(taskRun)}

+ {taskRun.error_message ? ( +

{taskRun.error_message}

+ ) : null} +
+
+ ))} +
+
+ )} +
+
+ + ) +} \ No newline at end of file diff --git a/frontend/src/app/admin/health/page.stories.tsx b/frontend/src/app/admin/health/page.stories.tsx index 8ca1db93..94f97c94 100644 --- a/frontend/src/app/admin/health/page.stories.tsx +++ b/frontend/src/app/admin/health/page.stories.tsx @@ -1,7 +1,9 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite" import { SourceDiversityPanel } from "@/app/admin/health/_components/SourceDiversityPanel" -import { StatusBadge } from "@/components/elements/StatusBadge" +import { SourceHealthPanel } from "@/app/admin/health/_components/SourceHealthPanel" +import { TopicCentroidPanel } from "@/app/admin/health/_components/TopicCentroidPanel" +import { TrendTaskRunsPanel } from "@/app/admin/health/_components/TrendTaskRunsPanel" import { AppShell } from "@/components/layout/AppShell" import { compactDocsParameters } from "@/lib/storybook-docs" import { @@ -10,6 +12,7 @@ import { createSourceConfig, createSourceDiversitySnapshot, createSourceDiversitySummary, + createTopicCentroidSnapshot, createTopicCentroidSummary, } from "@/lib/storybook-fixtures" @@ -48,8 +51,15 @@ export const NoSnapshots: Story = { function HealthPagePreview({ alerting = false, noSnapshots = false }: HealthPreviewProps) { const projects = [createProject()] + const centroidSnapshots = noSnapshots + ? [] + : [ + createTopicCentroidSnapshot({ id: 1, computed_at: "2026-04-25T08:00:00Z", drift_from_previous: 0.08 }), + createTopicCentroidSnapshot({ id: 2, computed_at: "2026-04-26T08:00:00Z", drift_from_previous: 0.12 }), + createTopicCentroidSnapshot({ id: 3, computed_at: "2026-04-27T08:00:00Z", drift_from_previous: 0.18 }), + ] const centroidSummary = createTopicCentroidSummary({ - latest_snapshot: noSnapshots ? null : createTopicCentroidSummary().latest_snapshot, + latest_snapshot: noSnapshots ? null : centroidSnapshots[2], snapshot_count: noSnapshots ? 0 : 4, active_snapshot_count: noSnapshots ? 0 : 4, avg_drift_from_previous: noSnapshots ? null : 0.12, @@ -82,6 +92,47 @@ function HealthPagePreview({ alerting = false, noSnapshots = false }: HealthPrev createIngestionRun(), createIngestionRun({ id: 23, plugin_name: "reddit", status: alerting ? "failed" : "success", error_message: alerting ? "Rate limit" : "" }), ] + const trendRuns = [ + { + id: 41, + project: 1, + task_name: "recompute_topic_centroid", + task_run_id: "run-41", + status: "completed" as const, + started_at: "2026-04-28T08:00:00Z", + finished_at: "2026-04-28T08:00:01Z", + latency_ms: 523, + error_message: "", + summary: { + project_id: 1, + feedback_count: 12, + upvote_count: 10, + downvote_count: 2, + }, + }, + { + id: 42, + project: 1, + task_name: "generate_theme_suggestions", + task_run_id: "run-42", + status: alerting ? ("failed" as const) : ("completed" as const), + started_at: "2026-04-28T08:10:00Z", + finished_at: "2026-04-28T08:10:01Z", + latency_ms: alerting ? 1480 : 910, + error_message: alerting ? "OpenRouter timeout" : "", + summary: { project_id: 1, created: 1, updated: 0, skipped: 2 }, + }, + ] + const sourceRows = sourceConfigs.map((sourceConfig, index) => ({ + sourceConfig, + latestRun: runs[index] ?? null, + status: + index === 1 && alerting + ? ("failing" as const) + : noSnapshots && index === 1 + ? ("degraded" as const) + : ("healthy" as const), + })) return ( -
-
-
-

Topic centroid observability

-

Representative centroid summary for the health page composition story.

-
- {noSnapshots ? "idle" : "active"} -
-
-
-

Centroid state

-

{centroidSummary.latest_snapshot ? "Active" : "Not computed"}

-
-
-

Avg drift vs previous

-

{centroidSummary.avg_drift_from_previous ?? "n/a"}

-
-
-
+ + + -
-
- - - - - - - - - - {sourceConfigs.map((sourceConfig, index) => ( - - - - - - ))} - -
SourceStatusLatest run
{sourceConfig.plugin_name} - - {index === 1 && alerting ? "failing" : "healthy"} - - {runs[index]?.status ?? "No runs yet"}
-
-
+
) } diff --git a/frontend/src/app/admin/health/page.tsx b/frontend/src/app/admin/health/page.tsx index ab6b38f0..b4d16dbd 100644 --- a/frontend/src/app/admin/health/page.tsx +++ b/frontend/src/app/admin/health/page.tsx @@ -1,7 +1,7 @@ -import Link from "next/link" - import { SourceDiversityPanel } from "@/app/admin/health/_components/SourceDiversityPanel" -import { StatusBadge } from "@/components/elements/StatusBadge" +import { SourceHealthPanel } from "@/app/admin/health/_components/SourceHealthPanel" +import { TopicCentroidPanel } from "@/app/admin/health/_components/TopicCentroidPanel" +import { TrendTaskRunsPanel } from "@/app/admin/health/_components/TrendTaskRunsPanel" import { AppShell } from "@/components/layout/AppShell" import { getProjectIngestionRuns, @@ -16,38 +16,15 @@ import { } from "@/lib/api" import type { HealthStatus, + IngestionRun, + SourceConfig, SourceDiversityObservabilitySummary, SourceDiversitySnapshot, TopicCentroidObservabilitySummary, TopicCentroidSnapshot, - TrendTaskRun, TrendTaskRunObservabilitySummary, } from "@/lib/types" -import { formatDate, healthTone, selectProject } from "@/lib/view-helpers" - -const TREND_TASK_LABELS: Record = { - recompute_topic_centroid: "Topic centroid", - recompute_topic_clusters: "Topic clusters", - recompute_topic_velocity: "Topic velocity", - recompute_source_diversity: "Source diversity", - generate_theme_suggestions: "Theme suggestions", - generate_original_content_ideas: "Original content ideas", -} - -const TREND_TASK_DETAIL_LABELS: Record = { - feedback_count: "feedback", - upvote_count: "upvotes", - downvote_count: "downvotes", - contents_considered: "content", - clusters_updated: "clusters updated", - clusters_evaluated: "clusters evaluated", - snapshots_created: "snapshots", - content_count: "content", - alert_count: "alerts", - created: "created", - updated: "updated", - skipped: "skipped", -} +import { healthTone, selectProject } from "@/lib/view-helpers" type HealthPageProps = { /** Search params promise containing the optional `project` selector. */ @@ -159,47 +136,6 @@ export function formatDriftPercent(value: number | null) { return `${(value * 100).toFixed(1)}%` } -function formatLatency(value: number | null) { - if (value === null) { - return "n/a" - } - if (value >= 1000) { - return `${(value / 1000).toFixed(1)}s` - } - return `${value}ms` -} - -function trendTaskRunTone(status: TrendTaskRun["status"]) { - if (status === "failed") { - return "negative" - } - if (status === "started") { - return "warning" - } - if (status === "skipped") { - return "neutral" - } - return "positive" -} - -function formatTrendTaskName(taskName: string) { - return TREND_TASK_LABELS[taskName] ?? taskName.replaceAll("_", " ") -} - -function buildTrendTaskRunSummaryText(taskRun: TrendTaskRun) { - const detailParts = Object.entries(taskRun.summary) - .filter(([key, value]) => key !== "project_id" && key !== "snapshot_id" && value !== null) - .filter(([, value]) => ["string", "number", "boolean"].includes(typeof value)) - .slice(0, 3) - .map(([key, value]) => `${TREND_TASK_DETAIL_LABELS[key] ?? key.replaceAll("_", " ")} ${String(value)}`) - - if (detailParts.length === 0) { - return "No task summary recorded yet." - } - - return detailParts.join(" โ€ข ") -} - /** * Build sparkline points for centroid drift across recent snapshots. * @@ -258,6 +194,25 @@ export function buildSourceDiversityTrendPoints( .join(" ") } +function buildSourceHealthRows( + sourceConfigs: SourceConfig[], + latestRunByPlugin: Map, +) { + return sourceConfigs.map((sourceConfig) => { + const latestRun = latestRunByPlugin.get(sourceConfig.plugin_name) ?? null + + return { + sourceConfig, + latestRun, + status: deriveSourceStatus( + sourceConfig.is_active, + latestRun?.status ?? null, + sourceConfig.last_fetched_at, + ), + } + }) +} + /** * Render the source-by-source ingestion health view for the selected project. * @@ -339,6 +294,16 @@ export default async function HealthPage({ searchParams }: HealthPageProps) { latestRunByPlugin.set(ingestionRun.plugin_name, ingestionRun) } } + const sourceHealthRows = buildSourceHealthRows( + sourceConfigs, + latestRunByPlugin, + ) + const centroidStatusLabel = centroidSummary.latest_snapshot + ? centroidSummary.latest_snapshot.centroid_active + ? "active" + : "inactive" + : "idle" + const trendStatus = deriveTrendTaskRunStatus(trendTaskRunSummary) return ( -
-
-
-

- Topic centroid observability -

-

- The latest centroid state for this project, plus average drift across - persisted snapshot history. -

-
- - {centroidSummary.latest_snapshot - ? centroidSummary.latest_snapshot.centroid_active - ? "active" - : "inactive" - : "idle"} - -
- -
-
-

- Centroid state -

-

- {centroidSummary.latest_snapshot - ? centroidSummary.latest_snapshot.centroid_active - ? "Active" - : "Inactive" - : "Not computed"} -

-
-
-

- Avg drift vs previous -

-

- {formatDriftPercent(centroidSummary.avg_drift_from_previous)} -

-
-
-

- Avg drift vs 7d -

-

- {formatDriftPercent(centroidSummary.avg_drift_from_week_ago)} -

-
-
-

- Latest snapshot -

-

- {formatDate(centroidSummary.latest_snapshot?.computed_at ?? null)} -

-
-
- - {visibleCentroidSnapshots.length > 1 ? ( - -
- Recent drift trend - Last {visibleCentroidSnapshots.length} snapshots -
- - - - - ) : null} - - {centroidSummary.latest_snapshot ? ( -
- {centroidSummary.snapshot_count} snapshots - {centroidSummary.active_snapshot_count} active snapshots - - Feedback {centroidSummary.latest_snapshot.feedback_count} - - Upvotes {centroidSummary.latest_snapshot.upvote_count} - Downvotes {centroidSummary.latest_snapshot.downvote_count} -
- ) : ( -
- No centroid snapshots exist for this project yet. -
- )} -
- -
-
-
-

- Trend pipeline runs -

-

- The latest persisted run for each tracked trend task, including task outcome, runtime, and any recorded failure message. -

-
- - {deriveTrendTaskRunStatus(trendTaskRunSummary)} - -
- -
-
-

- Persisted runs -

-

- {trendTaskRunSummary.run_count} -

-
-
-

- Latest task rows -

-

- {trendTaskRunSummary.latest_runs.length} -

-
-
-

- Failed runs -

-

- {trendTaskRunSummary.failed_run_count} -

-
-
- - {trendTaskRunSummary.latest_runs.length === 0 ? ( -
- No trend pipeline runs have been persisted for this project yet. -
- ) : ( - <> - {visibleTrendTaskRuns.length > 0 ? ( - -
- Recent task history - Last {visibleTrendTaskRuns.length} persisted runs -
- - ) : null} - -
- - - - - - - - - - - - {trendTaskRunSummary.latest_runs.map((taskRun) => ( - - - - - - - - ))} - -
TaskStatusStartedDurationSummary
- {formatTrendTaskName(taskRun.task_name)} - - - {taskRun.status} - - - {formatDate(taskRun.started_at)} - - {formatLatency(taskRun.latency_ms)} - -

{buildTrendTaskRunSummaryText(taskRun)}

- {taskRun.error_message ? ( -

{taskRun.error_message}

- ) : null} -
-
- - )} -
- -
-
-
-

- Trend task run history -

-

- Recent persisted executions across the trend pipeline, including run duration, summary output, and the latest recorded failures. -

-
- - Showing {visibleTrendTaskRuns.length} of {trendTaskRunSummary.run_count} runs - -
- - {visibleTrendTaskRuns.length === 0 ? ( -
- No trend task run history exists for this project yet. -
- ) : ( -
- - - - - - - - - - - - - {visibleTrendTaskRuns.map((taskRun) => ( - - - - - - - - - ))} - -
StartedTaskStatusFinishedDurationSummary
- {formatDate(taskRun.started_at)} - - {formatTrendTaskName(taskRun.task_name)} - - - {taskRun.status} - - - {formatDate(taskRun.finished_at)} - - {formatLatency(taskRun.latency_ms)} - -

{buildTrendTaskRunSummaryText(taskRun)}

- {taskRun.error_message ? ( -

{taskRun.error_message}

- ) : null} -
-
- )} -
- -
-
-
-

- Centroid snapshot history -

-

- Recent centroid recomputations for this project, including feedback volume and drift between snapshots. -

-
- - Showing {visibleCentroidSnapshots.length} of {centroidSummary.snapshot_count} snapshots - -
+ - {visibleCentroidSnapshots.length === 0 ? ( -
- No centroid snapshot history exists for this project yet. -
- ) : ( -
- - - - - - - - - - - - {visibleCentroidSnapshots.map((snapshot) => ( - - - - - - - - ))} - -
ComputedStateFeedbackDrift vs previousDrift vs 7d
- {formatDate(snapshot.computed_at)} - - - {snapshot.centroid_active ? "active" : "inactive"} - - - {snapshot.feedback_count} total - - {formatDriftPercent(snapshot.drift_from_previous)} - - {formatDriftPercent(snapshot.drift_from_week_ago)} -
-
- )} -
+ -
-
- - - - - - - - - - - - - {sourceConfigs.length === 0 ? ( - - - - ) : null} - {sourceConfigs.map((sourceConfig) => { - const latestRun = - latestRunByPlugin.get(sourceConfig.plugin_name) ?? null - const status = deriveSourceStatus( - sourceConfig.is_active, - latestRun?.status ?? null, - sourceConfig.last_fetched_at, - ) - return ( - - - - - - - - - ) - })} - -
SourceStatusLast fetchLatest runItemsErrors
-
- No source configurations exist for this project yet. -
-
- - {sourceConfig.plugin_name} - -
- Config #{sourceConfig.id} - - {sourceConfig.is_active ? "active" : "disabled"} - -
-
- - {status} - - - {formatDate(sourceConfig.last_fetched_at)} - - {latestRun - ? `${latestRun.status} at ${formatDate(latestRun.started_at)}` - : "No runs yet"} - - {latestRun - ? `${latestRun.items_ingested}/${latestRun.items_fetched}` - : "0/0"} - - {latestRun?.error_message || "-"} -
-
-
+
) } diff --git a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx new file mode 100644 index 00000000..b9f15efe --- /dev/null +++ b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite" + +import { compactDocsParameters } from "@/lib/storybook-docs" + +import { NewProjectFormCard } from "." + +const meta = { + title: "Pages/AdminProjects/New/Components/NewProjectFormCard", + component: NewProjectFormCard, + tags: ["autodocs"], + parameters: { + docs: compactDocsParameters, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} \ No newline at end of file diff --git a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx new file mode 100644 index 00000000..df1a909b --- /dev/null +++ b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { NewProjectFormCard } from "." + +describe("NewProjectFormCard", () => { + it("renders the project creation form fields", () => { + render() + + expect(screen.getByRole("heading", { level: 2, name: "New project" })).toBeInTheDocument() + expect(screen.getByLabelText("Name")).toBeRequired() + expect(screen.getByLabelText("Topic description")).toBeRequired() + expect(screen.getByLabelText("Content retention days")).toHaveValue(365) + expect(screen.getByRole("button", { name: "Create project" })).toBeInTheDocument() + }) + + it("posts back to the projects api with the redirect hint", () => { + render() + + const form = screen.getByRole("button", { name: "Create project" }).closest("form") + const redirectInput = screen.getByDisplayValue("/admin/projects/new") + + expect(form).toHaveAttribute("action", "/api/projects") + expect(form).toHaveAttribute("method", "POST") + expect(redirectInput).toHaveAttribute("name", "redirectTo") + expect(redirectInput).toHaveAttribute("type", "hidden") + }) +}) \ No newline at end of file diff --git a/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx new file mode 100644 index 00000000..9602e7af --- /dev/null +++ b/frontend/src/app/admin/projects/new/_components/NewProjectFormCard/index.tsx @@ -0,0 +1,71 @@ +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" + +/** Render the self-service project creation form. */ +export function NewProjectFormCard() { + return ( + + +

+ Provision +

+

+ New project +

+ + Create a project, set its editorial scope, and become the first project admin automatically. + +
+ + +
+ + +
+ + +
+ +
+ +