Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/feed/feed-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getLatestNodes } from "@/lib/graph-api"
import { schemaTypeComparator } from "@/lib/node-schema-utils"
import { FeedCard } from "./feed-card"
import { HotTakes } from "./hot-takes"
import { GraphStats } from "./graph-stats"
import { cn } from "@/lib/utils"

export function FeedView() {
Expand Down Expand Up @@ -91,6 +92,7 @@ export function FeedView() {

return (
<div className="h-full w-full overflow-y-auto overflow-x-hidden">
{showHotTakes && <GraphStats />}
{showHotTakes && <HotTakes />}

{(showHotTakes || typeCounts.length > 1) && (
Expand Down
58 changes: 58 additions & 0 deletions src/components/feed/graph-stats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use client"

import { useEffect, useState } from "react"
import { getStats } from "@/lib/graph-api"
import { isMocksEnabled } from "@/lib/mock-data"
import { formatStatNumber, getDisplayStats, type GraphStat } from "@/lib/graph-stats"

const MOCK_STATS: GraphStat[] = [{ label: "Nodes", value: 1234 }]

export function GraphStats() {
const mocks = isMocksEnabled()
const [stats, setStats] = useState<GraphStat[] | null>(mocks ? MOCK_STATS : null)
const [error, setError] = useState(false)

useEffect(() => {
if (mocks) return
const controller = new AbortController()
getStats(controller.signal)
.then((res) => setStats(getDisplayStats(res)))
.catch((err) => {
if (controller.signal.aborted) return
console.error("[graph-stats] getStats failed:", err)
setError(true)
})
return () => controller.abort()
}, [mocks])

if (error) {
return (
<div className="max-w-3xl mx-auto px-3 sm:px-6 pt-4 text-xs text-muted-foreground/60">
Stats unavailable
</div>
)
}

if (stats === null) {
return (
<div className="max-w-3xl mx-auto px-3 sm:px-6 pt-4" data-testid="graph-stats-loading">
<div className="h-10 w-40 rounded-md bg-muted/40 animate-pulse" />
</div>
)
}

if (stats.length === 0) return null

return (
<div className="max-w-3xl mx-auto px-3 sm:px-6 pt-4 flex flex-wrap gap-2">
{stats.map((s) => (
<div key={s.label} className="rounded-md border border-border/40 bg-card/50 px-3 py-1.5">
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted-foreground/60">
{s.label}
</div>
<div className="text-sm font-semibold text-foreground">{formatStatNumber(s.value)}</div>
</div>
))}
</div>
)
}
25 changes: 25 additions & 0 deletions src/lib/__tests__/graph-stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest"
import { getDisplayStats, formatStatNumber } from "../graph-stats"

describe("getDisplayStats", () => {
it("returns nodes and CVEs when cve_count present", () => {
expect(getDisplayStats({ node_count: 10, cve_count: 3 })).toEqual([
{ label: "Nodes", value: 10 },
{ label: "CVEs", value: 3 },
])
})
it("returns only nodes when cve_count absent or 0", () => {
expect(getDisplayStats({ node_count: 10 })).toEqual([{ label: "Nodes", value: 10 }])
expect(getDisplayStats({ node_count: 10, cve_count: 0 })).toEqual([{ label: "Nodes", value: 10 }])
})
it("uses node_count, not num_nodes", () => {
expect(getDisplayStats({ node_count: 5, num_nodes: 0 })[0].value).toBe(5)
})
it("returns nothing without node_count", () => {
expect(getDisplayStats({ num_nodes: 7 })).toEqual([])
expect(getDisplayStats(null)).toEqual([])
})
it("formats with grouping", () => {
expect(formatStatNumber(1776798)).toBe("1,776,798")
})
})
3 changes: 2 additions & 1 deletion src/lib/graph-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ interface NodesListResponse {
totalCount?: number
}

interface StatsResponse {
export interface StatsResponse {
node_count: number
num_nodes: number
num_episodes: number
num_people: number
Expand Down
26 changes: 26 additions & 0 deletions src/lib/graph-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { StatsResponse } from "@/lib/graph-api"

export interface GraphStat {
label: string
value: number
}

// Per-type counts shown only when the graph reports a positive count.
// Add entries here to surface more node types.
export const TYPE_STATS: readonly { key: string; label: string }[] = [
{ key: "cve_count", label: "CVEs" },
]

export function getDisplayStats(stats: Partial<StatsResponse> | null | undefined): GraphStat[] {
if (!stats || typeof stats.node_count !== "number") return []
const result: GraphStat[] = [{ label: "Nodes", value: stats.node_count }]
for (const { key, label } of TYPE_STATS) {
const value = (stats as Record<string, unknown>)[key]
if (typeof value === "number" && value > 0) result.push({ label, value })
}
return result
}

export function formatStatNumber(n: number): string {
return n.toLocaleString("en-US")
}
Loading