-
Status: Active
-
Authors: Podcast Scraper Team
-
Parent UXS: UXS-001: GI/KG Viewer -- shared tokens, typography, layout, states
-
Normative layout & charts: see ## Dashboard implementation specification below (full product spec merged into this UXS).
-
Related PRDs:
-
Related RFCs:
-
Implementation paths:
web/gi-kg-viewer/src/components/dashboard/DashboardView.vueweb/gi-kg-viewer/src/components/dashboard/BriefingCard.vueweb/gi-kg-viewer/src/components/dashboard/IndexStatusCard.vueweb/gi-kg-viewer/src/components/dashboard/TopicClustersStatusBlock.vueweb/gi-kg-viewer/src/utils/chartRegister.tsweb/gi-kg-viewer/src/stores/indexStats.ts,web/gi-kg-viewer/src/stores/dashboardNav.ts
-
Shell IA: VIEWER_IA.md — canonical shell layout, navigation axes, subject rail, status bar, first-run behavior
For shell layout, the three navigation axes, subject rail persistence and clearing, status bar, and first-run empty corpus behavior, see VIEWER_IA.md. This document specifies the Dashboard main tab only (briefing card, Coverage / Intelligence / Pipeline sub-tabs, charts, and layout below).
The Dashboard main tab is briefing + three tabs only: Coverage (default),
Intelligence, Pipeline. Corpus artifact picking (List, All / None,
Load into graph) lives on the status bar (List opens
data-testid="artifact-list-dialog"). Deep links to Library, Digest, and
Graph use dashboardNav handoffs consumed when those tabs activate. Chart.js uses
shared Tufte-style defaults from chartRegister.ts.
- Briefing card (
data-testid="briefing-card") — last run / health / short actions; always above tabs. - Tablist
aria-label="Dashboard tabs"— Coverage | Intelligence | Pipeline. - Coverage — coverage by month, feed coverage table, artifact activity (from listed artifacts), Index status (
data-testid="index-status-card") — status-only; index facts + rebuild actions moved to the Configuration dialog's Index section, reached via Manage in Configuration (index-status-manage). - Intelligence — digest snapshot, Topic briefings (
topic-briefing-cards, PRD-033 FR6.1 #888 — retrieval-groundedtopic-briefing-cardper top topic, see §5), Topic clusters status (topic-clusters-status-block), topic landscape, top voices (when API available). Topic momentum / emerging connections omitted until RFC-073 data ships (no placeholder UI). Search activity (query-activity-chart, PRD-033 FR6.2 — daily search-volume bar chart, see §5). Topic momentum stays omitted. - Pipeline — run history strip, duration trend, stage timings, numeric outcomes, episodes per run; optional per-feed run heatmap only when server exposes stable per-feed fields.
The legacy CorpusDataWorkspace / Pipeline | Content intelligence split on Dashboard is removed; those components are not part of this surface.
List (when health is ok and a corpus path is set) fetches GET /api/artifacts and opens the Corpus artifacts dialog. Load into graph switches the main tab to Graph when load succeeds (same behavior as the former workspace).
E2E surface map — Dashboard tab,
briefing-card, Dashboard tabs tablist, Index status card, artifact-list-dialog;
openCorpusDataWorkspace only switches to Dashboard and waits for the briefing card.
| Date | Change |
|---|---|
| 2026-04-06 | Initial content (in UXS-001) |
| 2026-04-13 | Extracted from UXS-001 into standalone UXS-006 |
| 2026-04-19 | Corpus workspace on Dashboard; left rail query-only IA |
| 2026-04-19 | Dashboard: briefing + tabs; artifacts via status bar dialog |
| 2026-04-20 | §6.0 jobs: operator --config + optional profile: merge (#593) |
| 2026-04-20 | §6.0 jobs (cont.): feeds via --feeds-spec when feeds.spec.yaml exists |
| 2026-04-21 | §6.0: Operator YAML = Config; shallow PUT validation (feed + secret keys) |
| 2026-06-05 | PRD-033 FR6.1 briefing cards + FR6.2 search-activity chart (#888 + follow-up) |
Status: Ready for implementation
Author: Design session (Marko + Claude), April 2026
Repo: chipi/podcast_scraper
Target area: web/gi-kg-viewer/src/components/dashboard/
Related docs: UXS-006, RFC-071, TUFTE_CHART_CRITIQUE.md, UXS-001
Replaces: Current UXS-006 content (rewrite, not amendment)
The Dashboard is restructured around two principles:
5-second answers. The primary question — "is everything OK, and what should I do?" — is answered by a permanent briefing card above the tabs, before any chart is read.
Actionability. Every data point either has an attached action or is explicitly decorative context. Observations without a "so what?" belong in depth charts, not on the primary surface.
Structure:
├── BRIEFING CARD (permanent, always visible, above tabs)
│ Last run · Corpus health · Action items
│
└── TABS
├── Coverage — what fraction of my corpus has intelligence coverage?
├── Intelligence — what is my corpus telling me?
└── Pipeline — how is my pipeline performing?
```yaml
Three tabs instead of the current two. The old "Status" concept becomes
the briefing card. The old "Content Intelligence" becomes Coverage +
Intelligence split by purpose.
---
### 2. Global Chart Rules (Tufte)
These apply to every chart in the dashboard without exception. They
are not per-chart suggestions — they are design system constraints.
#### 2.1 Non-negotiable rules
**No legends.** Every series is labelled directly — end-of-line for
line charts, end-of-bar for bar charts, positioned annotation for
everything else. No legend box anywhere in the dashboard.
**Title = insight.** Every chart title states the finding, not the
variable name. "Coverage by month" is a label. "N months below average
GI coverage" is a title. Mandatory, not optional.
**No gridlines.** Remove all gridlines from all charts. Reference
lines (average, target) are a single `border`-token hairline annotated
directly — not a grid.
**No dual y-axes.** If two series have different scales, they get
separate charts. No exceptions.
**Remove top and right spines.** Only left and bottom axis lines on
all Chart.js charts.
**Y-axis starts at 0 on all bar charts.** No truncated axes.
**Mandatory insight line.** Remove the word "optional" from UXS-001
and UXS-006. Every chart has an insight line below it. If the data
does not support a clear takeaway, the chart does not belong on the
dashboard.
**Charts earn their place.** Before adding a chart, ask: does this
need visual encoding, or is a number or table sufficient? Three numbers
are not a chart. If the answer is "a table," build a table.
**No chartjunk.** No 3D effects, no gradient fills, no drop shadows
on chart elements, no decorative borders around chart areas.
#### 2.2 Chart.js global config
Apply these defaults in `chartRegister.ts` so every chart component
inherits them without per-component overrides:
```typescript
Chart.defaults.plugins.legend.display = false
Chart.defaults.plugins.tooltip.enabled = true
Chart.defaults.scales.linear = {
...Chart.defaults.scales.linear,
grid: { display: false },
border: { display: false },
ticks: { maxTicksLimit: 5, color: 'var(--ps-muted)' }
}
Chart.defaults.scales.category = {
...Chart.defaults.scales.category,
grid: { display: false },
border: { display: false },
ticks: { color: 'var(--ps-muted)' }
}
A shared Chart.js afterDraw plugin for direct series labelling.
Register once in chartRegister.ts. Used by all multi-series line
charts:
const endLabelPlugin = {
id: 'endLabel',
afterDraw(chart: Chart) {
const ctx = chart.ctx
chart.data.datasets.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
if (!meta.hidden && meta.data.length > 0) {
const lastPoint = meta.data[meta.data.length - 1]
ctx.fillStyle = dataset.borderColor as string
ctx.font = '10px Inter, system-ui'
ctx.textAlign = 'left'
ctx.fillText(
dataset.label ?? '',
lastPoint.x + 6,
lastPoint.y + 3
)
}
})
}
}
Chart.register(endLabelPlugin)
```yaml
---
### 3. Briefing Card
#### 3.1 Purpose
Permanent section at the top of the Dashboard page, above the tab bar.
Always visible regardless of which tab is active. Answers the three
primary questions in under 5 seconds:
- "Did the last run succeed?" (Last run section)
- "Is my corpus healthy?" (Corpus health section)
- "What should I do?" (Action items section)
#### 3.2 Layout
```text
│ CORPUS 213 episodes · 67% GI · 82% indexed · 3 feeds │
│ ────────────────────────────────────────────────────────────── │
│ → 71 episodes have no GI artifacts [View in Library] │
│ → Index last rebuilt 14 days ago [Rebuild now] │
│ → 2 episodes failed in last run [View failures] │
└──────────────────────────────────────────────────────────────────┘-
Background:
elevatedtoken (lifts the card above the page surface) -
Border:
bordertoken,rounded-sm -
Padding:
p-4 -
Divider between health and action items:
border-t border-border mt-3 pt-3 -
Section labels ("LAST RUN", "CORPUS"):
text-[10px] font-semibold tracking-wider mutedsmall caps -
Data values:
text-sm surface-foreground
Sources: GET /api/corpus/runs/summary most recent item.
Format: ● [status] · [N] episodes · [duration] · [age] [Details →]
Status badge:
● Success—successtoken dot● Partial—warningtoken dot (some feeds failed in multi-feed run)● Failed—dangertoken dot
Age: relative time — "2 hours ago", "yesterday", "3 days ago". Not a raw timestamp. Computed client-side from run timestamp.
Duration: human-readable — "3m 24s", not "204s".
Multi-feed: "3 of 4 feeds succeeded" replaces episode count when
multi_feed_summary is present and any feed failed.
"Details →" link: navigates to Pipeline tab.
Empty state (no runs found): muted text — "No pipeline runs
found. Run podcast scrape to begin."
data-testid="briefing-last-run"
Sources: GET /api/corpus/coverage (NEW endpoint — see Section 8) +
GET /api/index/stats.
Format: [total] episodes · [gi]% GI · [indexed]% indexed · [feeds] feeds
Each metric is a link:
- Episode count → Library tab
- GI% → Coverage tab (scrolls to coverage chart)
- Indexed% → Coverage tab (scrolls to index section)
- Feed count → Library tab (feed filter)
Warning threshold: if GI% < 50% or indexed% < 60%, that metric
renders in warning token colour. Immediate visual signal without
reading action items.
Thresholds are tunable parameters (UXS-001 table):
| Parameter | Default | Status |
|---|---|---|
| GI coverage warning threshold | 50% | Open |
| Index coverage warning threshold | 60% | Open |
data-testid="briefing-corpus-health"
Max 3 items. Triage order: pipeline failures first (blocking), coverage gaps second, operational staleness third.
Sources: assembled client-side from coverage data + index stats + run summary.
Item format: → [plain-language finding] [primary action link] [secondary action link]
Action item rules:
-
Plain language, present tense: "71 episodes have no GI artifacts" not "GI coverage: 67%"
-
Primary action link: the most direct next step
-
Max one secondary action link
-
Never says "run enrich" or references RFC-073 enrichers — out of scope
Possible items (ordered by priority):
| Condition | Text | Actions |
|---|---|---|
| Last run has failures | "N episodes failed in last run" | [View failures] → Library |
| Last run failed entirely | "Last run failed — no episodes processed" | [View in Pipeline] |
| GI coverage < 50% | "N episodes have no GI artifacts" | [View in Library] |
| Index not built | "Vector index has not been built" | [Open index controls] |
| Index stale > 7 days | "Index last rebuilt N days ago" | [Open index controls] |
| Feed not indexed | "Feed X is not in the vector index" | [View coverage] |
| No runs in > 7 days | "No pipeline runs in N days" | [View in Pipeline] |
| Topic clusters missing | "Topic clusters not built" | [View in Coverage] |
"Open index controls" routes to the Configuration dialog's Index
section (sources-dialog-index-panel), where the rebuild is triggered —
the rebuild action no longer fires inline from the Briefing card
(config consolidation). All other actions are navigation links.
All-clear state: When no items qualify: a single line —
● Everything looks good — using success token dot. This positive
confirmation is as important as finding items — it tells you to stop
looking.
data-testid="briefing-action-items"
data-testid="briefing-action-item" (per item)
data-testid="briefing-all-clear" (empty state)
Answers: "What fraction of my corpus has intelligence coverage, and where are the gaps?"
Four sections, top to bottom.
Chart type: Single-series bar chart.
What it shows: GI coverage percentage per publish month (0–100%). One bar per month. Bars are the primary story — what fraction of episodes published that month have GI artifacts.
Not a stacked chart. Not episode counts. Coverage rate is the signal, not volume.
Tufte compliance:
-
Single series, no legend needed
-
Y-axis: 0–100%, labelled "Coverage %", max 5 tick labels
-
X-axis: month labels,
text-[10px] -
Reference line: horizontal hairline at corpus average coverage %, annotated directly — "avg 67%" placed at the right end of the line
-
Bars below average:
warningtoken fill -
Bars at or above average:
gitoken fill -
No gridlines, no top/right spines
-
Direct annotation on the lowest bar: "Lowest: [month] — N%"
-
Insight line below chart (mandatory): computed from data, e.g. "3 months below average — [month], [month], [month] need attention"
or "Coverage improving: up [X]pp since [earliest month]"
Interaction: clicking any bar navigates to Library filtered to
episodes from that month without GI artifacts (since + end of
month + topic_cluster_only=false + filter for no-GI).
Source: GET /api/corpus/coverage → by_month array (NEW).
Component: CoverageByMonthChart.vue (new, replaces
VerticalBarChart.vue for this use case)
Empty state: "No episode metadata found. Run the pipeline to generate corpus data."
data-testid="coverage-by-month-chart"
Not a chart — a table with inline data bars.
What it shows: Per-feed coverage summary. Each row = one feed. Columns: Feed name | Episodes | GI coverage | KG coverage | Indexed.
GI coverage and KG coverage columns show a 40px inline progress bar (CSS, not Chart.js) plus the percentage number. This gives visual comparison without a separate chart.
Tufte note: 40px inline bars are proportional (0–100% always), direct labelled (number next to bar), and scannable. No legend, no axis, no gridlines.
Sort order: ascending by GI coverage — worst-covered feeds at top, since those are the action targets.
Row interaction: clicking a row navigates to Library filtered to that feed.
Insight line: "Feed [name] has lowest GI coverage at [N]% — [M] episodes without GI artifacts."
Source: GET /api/corpus/coverage → by_feed array (NEW).
Component: FeedCoverageTable.vue (new)
data-testid="feed-coverage-table"
data-testid="feed-coverage-row" (per row)
Chart type: Grouped bar chart — new artifacts per day, last 30 days.
Replaces: The current cumulative GI+KG line chart.
Why: Cumulative lines always trend upward and look healthy even when work has stopped. A recency chart makes silence visually obvious.
What it shows: For each day in the last 30 days: count of new GI artifacts + count of new KG artifacts written that day.
Tufte compliance:
-
Two series:
gitoken bars andkgtoken bars, grouped per day -
Series labelled directly: "GI" and "KG" as
text-[10px]text positioned above the first bar of each series (not a legend) -
X-axis: day labels, only show every 7th label to avoid clutter
-
Y-axis: starts at 0, labelled "New artifacts"
-
No gridlines, no spines except left + bottom
-
Silence is visible: days with no bars are visually obvious gaps
-
If last 14 consecutive days have no bars: mandatory insight reads "No new artifacts in 14 days — pipeline may not be running"
-
Otherwise insight: "Last GI: [date] · Last KG: [date]"
-
Annotate the most recent active day: small dot + date label
Source: GET /api/artifacts → mtime_utc values per artifact,
bucketed by day client-side. Existing artifactMtimeBuckets.ts —
extend to produce daily counts rather than cumulative.
Component: ArtifactActivityChart.vue (new — replaces
CategoryLineChart.vue for this purpose)
data-testid="artifact-activity-chart"
Not a chart — a card.
The question "is my index current?" doesn't need a chart. It needs three facts:
Feeds in index: 3 of 3
```python
- "⚠ Rebuild recommended" appears when index staleness heuristic
from `index/stats` is true. Uses `warning` token.
- "Last rebuild error: [message]" in `danger` token when
`rebuild_last_error` is present.
- **Status-only.** The index facts + rebuild *action* live in the
Configuration dialog's **Index** section (`sources-dialog-index-panel`) —
the single canonical place per concern (config consolidation). The card has a
**Manage in Configuration →** button (`data-testid="index-status-manage"`)
that opens Configuration → Index rather than triggering a rebuild inline. The
Briefing **Open index controls** action and the status-bar rebuild
bolt route to the same dialog.
No Chart.js involved. Pure key-value status card.
**Source:** `GET /api/index/stats` (existing).
`data-testid="index-status-card"`
---
### 5. Intelligence Tab
Answers: "What is my corpus telling me?"
Five sections. Some require enricher data and degrade gracefully.
Enricher triggers ("run enrich") are out of scope — degraded states
show what's missing, not how to generate it.
#### 5.1 Corpus snapshot
**Always available.**
An expanded version of the digest compact view. Shows the rolling
window summary (default 7d) with a little more breathing room than
the Digest tab's compact layout.
Content:
- Window line: "Last 7 days — 12 new episodes across 3 feeds"
- Top 3 topic bands from the digest, one line each: topic label +
episode count ("AI policy — 4 episodes")
- Each topic band line clickable → Digest tab (which loads that topic)
- "Open Digest →" link at bottom
Not a chart. A structured text summary using the digest API data.
**Source:** `GET /api/corpus/digest` with `window=7d`, `compact=false`,
`max_rows=3`.
`data-testid="intelligence-snapshot"`
#### 5.1a Topic briefings (PRD-033 FR6.1, #888)
**Always available when the digest returns scored topic bands.**
A compact grid of **retrieval-grounded** briefing cards — one per top topic, ranked by
retrieval signal (best hit score lifted by insight density + distinct-show breadth).
Each card is grounded in a *direct query*, **not** a briefing pack (the MCP briefing-pack
form is orthogonal, RFC-093): the digest topic bands are already built by running a
semantic search per topic, so each card surfaces:
- Topic label (a `topic-briefing-card-link` button when the topic maps to a KG node →
opens the **Topic Entity View** rail via `focusTopic`; plain text otherwise) + episode count.
- **Top segment**: the band's best-scored hit (`summary_preview`) with its retrieval
score badge — the "grounded in retrieval" evidence.
- **Cross-show** (`topic-briefing-card-cross-show`, mapped topics only): the top insight
from `cross_show_synthesis` plus the number of distinct shows — the corpus
differentiator, lazily fetched from `GET /api/relational/cross-show`.
**Source:** the already-fetched `GET /api/corpus/digest` bands + `GET /api/relational/cross-show`.
`data-testid="topic-briefing-cards"` (container), `topic-briefing-card` (each).
#### 5.1b Search activity (PRD-033 FR6.2)
**Available once at least one search has been logged.**
A Tufte-simple single-series bar chart of **daily search volume** over the last 30 days
(`query-activity-chart`). The data is the append-only search-activity log
(`<corpus>/search/query_log.jsonl`) that `/api/search` writes — **timestamp + detected
intent only, never the raw query text** (privacy-light), read via
`GET /api/corpus/query-activity`.
**Honest scope:** the PRD's literal "query volume *by topic* over time" is not
achievable — queries are not topic-tagged, and a corpus-coverage-by-topic series would
mislabel corpus volume as query volume. This ships the supported subset: *search volume
over time*. The chart is hidden until the log has entries (`total > 0`).
#### 5.2 Topic landscape
**Available when topic clusters have been built.**
A compact grid of topic clusters. Each cluster card:
- Cluster canonical label (`text-sm font-semibold`)
- Member topic count badge ("4 topics")
- Episode count (summed from `members[].episode_ids`, deduplicated)
- Clicking → Graph tab, focused on that TopicCluster compound node
Grid: `repeat(auto-fit, minmax(min(100%, 12rem), 1fr))` — same card-width logic as Digest topic bands
but cards are smaller and denser — no hit rows, just cluster identity.
**Insight line:** "N topic clusters covering M distinct topics."
**Degraded state** (404 from topic-clusters endpoint): `muted` text —
"Topic clusters not yet built for this corpus." No action prompt.
**Source:** `GET /api/corpus/topic-clusters` (existing).
`data-testid="intelligence-topic-landscape"`
#### 5.3 Top voices
**Available when `GET /api/corpus/persons/top` exists (NEW endpoint —
see Section 8).**
Top 5 persons by insight count. Each person card:
- Display name (`text-sm font-semibold`)
- Episode count badge: "23 episodes"
- Insight count badge: "67 insights" (`gi` token)
- Top 3 topic chips (`kg` token border) — topics from the endpoint
- "View profile →" link → Person Landing (UXS-010) in subject rail
Cards in a horizontal scrollable row on narrow viewports, 2-column
grid on wide viewports.
**Insight line:** "N speakers with grounded insights — [top person]
leads with N insights across M episodes."
**Degraded state** (endpoint not available or 0 persons): `muted` text —
"No speaker intelligence found for this corpus."
**Source:** `GET /api/corpus/persons/top?limit=5` (NEW).
`data-testid="intelligence-top-voices"`
#### 5.4 Topic momentum
**Enricher-gated — degrades gracefully.**
Available when `temporal_velocity` enricher data is present (RFC-073).
Top 5 topics by recent activity. Each row:
- Topic name (`text-sm`)
- Sparkline: 8-point mini line chart, last 8 weeks of mention
frequency. `series-1` token stroke, no fill. No axes, no labels —
the shape is the signal.
- Trend badge: accelerating (`success`) / stable (`muted`) /
declining (`warning`) — same as UXS-007
- Episode count: "N episodes" in `muted`
- Clicking → Topic Entity View (UXS-007) in subject rail
**Tufte note on sparklines:** y-scale normalises to each topic's own
range (0 to its max). This shows *relative* trend shape, not absolute
volume. Volume is shown as the episode count number. This is acceptable
for a "relative momentum" display — the spec must note this so
implementers don't try to share y-scales (which would make
low-volume topics invisible).
**Insight line:** "N topics accelerating in the last 30 days."
**Degraded state:** `muted` text — "Topic momentum data not available
for this corpus." No enricher prompt.
**Source:** RFC-073 enricher output (not yet available).
`data-testid="intelligence-topic-momentum"`
#### 5.5 Emerging connections
**Enricher-gated — degrades gracefully.**
Available when `topic_cooccurrence` enricher data is present (RFC-073).
Top 4 topic pairs by unexpected co-occurrence strength. Each row:
- "Topic A ↔ Topic B" (`text-sm`)
- Episode count: "14 episodes" (`muted`)
- Clicking → Graph tab, both topic nodes highlighted + focused,
subgraph showing their shared connections
**Insight line:** "N topic pairs co-occur more than expected given
their individual frequency."
**Degraded state:** `muted` text — "Topic co-occurrence data not
available for this corpus." No enricher prompt.
**Source:** RFC-073 enricher output (not yet available).
`data-testid="intelligence-emerging-connections"`
---
### 6. Pipeline Tab
Answers: "How is my pipeline performing?"
Now freed from being the default tab (briefing card handles the quick
answer), the Pipeline tab can be dense and detailed.
#### 6.0 HTTP pipeline jobs (RFC-077)
When **`jobs_api`** is true on health, the tab includes **`PipelineJobsCard`**
(`data-testid="pipeline-jobs-card"`): list **HTTP-triggered** pipeline jobs for
the current corpus, **Run** (enqueue), **Reconcile**, **Refresh**, per-row **Cancel**
when status is `queued` or `running`, and inline help when the API is off. This
surface is **in addition to** run-history charts fed from **`GET /api/corpus/runs/summary`**
(see below). Spec: [RFC-077](../rfc/RFC-077-viewer-feeds-and-serve-pipeline-jobs.md).
**Config a job uses:** the subprocess passes **`--config`** to the server-resolved operator YAML path (same file as status bar **Operator YAML** / **Config**). That file may contain **`profile: <preset>`** — effective pipeline settings are **packaged preset defaults merged with explicit keys** in the file ([GitHub #593](https://github.com/chipi/podcast_scraper/issues/593)). **Feeds** for the job come from corpus **`feeds.spec.yaml`** via **`--feeds-spec`** when that file exists, not from duplicate root keys in operator YAML (those keys are rejected on **`PUT /api/operator-config`** — **top-level** denylist only, same as secrets). Any **Run** / help copy in this tab should say **operator file + optional profile**, not “edit feeds here.”
#### 6.1 Run history strip
**Not a chart — a row of status dots.**
Last 10 runs rendered as coloured dots in a horizontal row. No axes,
no labels — maximum information density.
- `●` success token = run succeeded
- `●` warning token = partial success (some feeds failed)
- `●` danger token = run failed
Dots are ordered left-to-right oldest to newest. Most recent dot is
slightly larger (12px vs 8px) to draw the eye to current state.
Native `title` tooltip per dot: "Run [date] · [N] episodes ·
[duration] · [status]"
Clicking a dot: expands that run's detail inline below the strip,
replacing the current "latest run detail" section with the selected
run. Clicking again collapses. Only one run expanded at a time.
**Insight line:** "Last 10 runs: [N] success, [M] partial, [P] failed"
— or simply "All 10 runs succeeded" in `success` token if clean.
**Source:** `GET /api/corpus/runs/summary` (existing).
`data-testid="pipeline-run-history-strip"`
`data-testid="pipeline-run-dot"` (per dot)
#### 6.2 Duration trend
**Chart type:** Bar chart, last 5 runs' durations.
**What it shows:** Run-to-run duration variability. Is the pipeline
getting slower?
**Tufte compliance:**
- 5 bars — simple, no legend needed
- Y-axis: starts at 0, labelled in minutes, max 4 ticks
- X-axis: run dates, `text-[10px]`
- Reference line: horizontal hairline at 5-run average, directly
labelled "avg [X]m [Y]s"
- Latest bar: `primary` fill. Others: `surface-foreground` at 60%
opacity. Draws eye to the current run vs history.
- If latest bar > average + 20%: `warning` fill on latest bar
- No gridlines, no top/right spines
- Direct bar labels: duration value above each bar ("3m 24s") in
`text-[10px] muted`
- **Title = insight** (mandatory): "Latest run [X]% [faster/slower]
than 5-run average" — computed. If within 10% either way: "Run
duration stable — avg [X]m [Y]s"
**Source:** `GET /api/corpus/runs/summary` last 5 items sorted by
timestamp.
**Component:** Reuse/update existing chart component.
`data-testid="pipeline-duration-trend"`
#### 6.3 Latest run breakdown
Two sections side-by-side on wide viewports, stacked on narrow.
**Stage timings (left):**
**Chart type:** Horizontal bar chart, one bar per pipeline stage,
sorted by duration descending.
**What it shows:** Where time was spent in the most recently selected
run (from the history strip — defaults to latest).
**Tufte compliance:**
- Horizontal bars — stage names are text, need space to breathe
- Sorted descending: longest bar at top, shortest at bottom
- Direct value labels: duration ("1m 23s") printed at the right end
of each bar — **no x-axis needed** once values are labelled
- Bar fill: `primary` token for the longest bar (the bottleneck),
`surface-foreground` at 60% opacity for all others. The bottleneck
is visually distinct without any annotation.
- No gridlines at all — bar length communicates value
- No spines — labels make axes redundant
- **Title = insight** (mandatory): "[Stage name]: [X]% of total run
time" — always names the bottleneck
**Episode outcomes (right):**
**Not a chart.** Three numbers are not a chart.
```text
✗ 2 failed [View failures →]
text-sm numbers, intent token colours for each count. "View
failures →" link appears only when failures > 0, navigates to
Library filtered for that run's failed episodes.
Source: GET /api/corpus/runs/summary for the selected run
(or run detail endpoint if stage timings need fuller data — see
open questions in Section 9).
data-testid="pipeline-stage-timings"
data-testid="pipeline-episode-outcomes"
Chart type: Bar chart, all runs in summary (up to 150, capped).
What it shows: Episode count processed per run over time. Shows pipeline activity and cadence.
Tufte compliance:
-
Single series, no legend needed
-
Y-axis: starts at 0, "Episodes" label, max 5 ticks
-
X-axis: run dates (sparse labelling — every Nth label)
-
No gridlines, no top/right spines
-
Total corpus as text annotation below chart: "Total: [N] episodes across [M] runs" — not a cumulative overlay on the same axis
(that was the dual-axis lie)
-
Title = insight: "Average [N] episodes per run" or "Processing volume declining — last 3 runs below average" if trend is negative
Source: GET /api/corpus/runs/summary (existing).
Component: Reuse VerticalBarChart.vue with updated config.
data-testid="pipeline-episodes-per-run"
Chart type: Heatmap grid (small multiples).
Only shown for multi-feed corpora. Hidden for single-feed.
What it shows: Per-feed run success/partial/failure over the last 5 runs. Rows = feeds, columns = runs (left-to-right oldest to newest).
Tufte compliance:
-
Cell colour:
successfill /warningfill /dangerfill -
Cell glyph: ✓ / ⚠ / ✗ inside each cell — colour alone is not sufficient (accessibility + Tufte "don't rely on colour only")
-
Cell size: ~28px × 28px. Gap spacing between cells instead of borders (whitespace is cleaner than lines)
-
Feed names: left-aligned row headers,
text-xs -
Run dates: top-aligned column headers,
text-[10px] -
No legend — ✓/⚠/✗ glyphs are self-explanatory
-
Insight line (mandatory): "Feed [name] failed [N] of last 5 runs" — triggered when any feed has ≥ 2 failures in the window.
If all clean: "All feeds succeeded in last 5 runs" (
successtoken).
Source: GET /api/corpus/runs/summary per-feed breakdown
(verify field availability in CorpusRunSummaryItem — see Section 9).
Component: FeedRunHistoryGrid.vue (new)
data-testid="pipeline-feed-history-grid"
Coverage is the default tab (not Pipeline as today) because it answers the most frequent ongoing question — "how complete is my intelligence coverage?" — whereas Pipeline is consulted reactively after running.
The briefing card's "Details →" link on the Last run section jumps to the Pipeline tab.
Priority: CRITICAL. Unblocks briefing card scorecard + Coverage tab.
Single catalog scan checking GI/KG artifact presence per episode,
grouped by month and feed. Same implementation pattern as
/api/corpus/stats.
Response schema:
class CoverageByMonthItem(BaseModel):
month: str # "YYYY-MM"
total: int
with_gi: int
with_kg: int
with_both: int
class CoverageFeedItem(BaseModel):
feed_id: str
display_title: str
total: int
with_gi: int
with_kg: int
class CorpusCoverageResponse(BaseModel):
total_episodes: int
with_gi: int
with_kg: int
with_both: int
with_neither: int
by_month: List[CoverageByMonthItem]
by_feed: List[CoverageFeedItem]Implementation: One filesystem scan. For each episode metadata
file: check sibling *.gi.json and *.kg.json existence, group by
publish_date month and feed_id. No GI/KG file reading — just
existence checks. Fast.
Test: tests/unit/podcast_scraper/server/test_viewer_corpus_coverage.py
Priority: MEDIUM. Enables Intelligence tab Top Voices section. Intelligence tab degrades gracefully without it.
Partial GI artifact scan: reads Person node counts from loaded GI artifacts, aggregates by person canonical id, returns top N by insight count.
Response schema:
class TopPersonItem(BaseModel):
person_id: str # "person:{slug}"
display_name: str
episode_count: int
insight_count: int
top_topics: List[str] # canonical topic ids, max 3
class CorpusTopPersonsResponse(BaseModel):
persons: List[TopPersonItem]
total_persons: int # total distinct persons in corpus
```yaml
**Implementation:** Requires reading GI artifacts. Two options:
(a) scan all `*.gi.json` files, extract Person node counts — full scan,
slower; (b) use the loaded graph in memory if available — faster but
state-dependent. Prefer (a) for correctness — this is a background
fetch, latency is acceptable.
**Test:** `tests/unit/podcast_scraper/server/test_viewer_corpus_persons.py`
---
### 9. Open Questions
These are not blocking Phase 1–2 but need resolution before the
implementation is complete.
**Stage timings granularity:** The current `CorpusRunSummaryItem`
schema provides "compact metrics per run." Verify whether it includes
per-stage timing breakdown or only total duration. If stage timings
are only in the full `run.json` file and not in the compact summary,
a `GET /api/corpus/runs/{run_id}` detail endpoint may be needed for
the stage timings chart. Check the existing `corpus_run_summary.json`
schema before adding a new endpoint.
**Feed per-run breakdown:** `FeedRunHistoryGrid` requires per-feed
success/failure per run. Verify whether `CorpusRunSummaryItem` includes
per-feed breakdown. If not, this chart silently hides (it is conditional
on multi-feed corpus anyway) and is tracked as a follow-up.
**`GET /api/corpus/coverage` performance:** If the corpus has thousands
of episodes, the existence-check scan may be slow. Add a
`Cache-Control: max-age=60` header and consider a lightweight cache
in the route handler (invalidated on corpus path change). Profile with
a real large corpus during implementation.
---
### 10. Files to Touch
#### New components
```text
web/gi-kg-viewer/src/components/dashboard/ArtifactActivityChart.vue
web/gi-kg-viewer/src/components/dashboard/FeedRunHistoryGrid.vue
web/gi-kg-viewer/src/components/dashboard/IntelligenceSnapshot.vue
web/gi-kg-viewer/src/components/dashboard/TopicLandscape.vue
web/gi-kg-viewer/src/components/dashboard/TopVoices.vue
web/gi-kg-viewer/src/components/dashboard/TopicMomentum.vue
web/gi-kg-viewer/src/components/dashboard/EmergingConnections.vueweb/gi-kg-viewer/src/components/dashboard/DashboardView.vue
— briefing card always rendered above tabs
web/gi-kg-viewer/src/utils/chartRegister.ts
— global Chart.js defaults (no legends, no gridlines)
— endLabelPlugin registered globally
web/gi-kg-viewer/src/utils/artifactMtimeBuckets.ts
— extend to produce daily new-artifact counts (not just cumulative)
web/gi-kg-viewer/src/api/corpusPersonsApi.ts
— fetchTopPersons(limit)
src/podcast_scraper/server/schemas.py
— CorpusCoverageResponse, CoverageByMonthItem, CoverageFeedItem
— CorpusTopPersonsResponse, TopPersonItem
Add coverage and persons/top routes to app.py.
Add or extend integration tests for the new coverage and persons routes (mirror patterns used for existing corpus API tests).
docs/uxs/UXS-006-dashboard.md
— Full rewrite (current file is thin; this spec replaces it)
— Remove API/Data left panel section (moved to status bar)
— Add briefing card spec
— Add three-tab structure
— Add Tufte compliance rules (cross-reference TUFTE_CHART_CRITIQUE.md)
docs/uxs/UXS-001-gi-kg-viewer.md
— Remove "optional" from insight line rule — mandatory
— Add to tunable parameters:
GI coverage warning threshold 50% Open
Index coverage warning threshold 60% Open
Action items max 3 Open
Top voices limit 5 Open
docs/guides/SERVER_GUIDE.md
— Add GET /api/corpus/coverage and GET /api/corpus/persons/top
to the API reference table
— coverage-by-month-chart, feed-coverage-table, artifact-activity-chart
— index-status-card
— intelligence-snapshot, intelligence-topic-landscape
— intelligence-top-voices, intelligence-topic-momentum
— intelligence-emerging-connections
— pipeline-run-history-strip, pipeline-run-dot
— pipeline-duration-trend, pipeline-stage-timings
— pipeline-episode-outcomes, pipeline-episodes-per-run
— pipeline-feed-history-grid
```yaml
---
### 11. Implementation Phases
#### Phase 1 — Briefing card + Coverage tab
**Goal:** The two most impactful surfaces. Requires `GET
/api/corpus/coverage` new endpoint.
Steps:
1. Implement `GET /api/corpus/coverage` server route + tests
2. `BriefingCard.vue` — all three sections wired to coverage + runs/summary
3. Coverage tab: `CoverageByMonthChart.vue`, `FeedCoverageTable.vue`
4. Apply global Chart.js Tufte defaults in `chartRegister.ts`
5. `ArtifactActivityChart.vue` (replaces cumulative line)
6. `IndexStatusCard` section (from existing index/stats data)
**Checkpoint:** Briefing card shows last run + corpus health + action
items. Coverage tab shows month chart, feed table, activity chart,
index card. No regressions in existing Pipeline tab.
#### Phase 2 — Pipeline tab restructure
**Goal:** Improved Pipeline tab with Tufte-compliant charts.
Steps:
1. `RunHistoryStrip.vue` with click-to-expand per run
2. `DurationTrendChart.vue` with insight title + reference line
3. Stage timings chart with direct value labels + bottleneck highlight
4. Episode outcomes → inline numbers in run card (remove chart)
5. `EpisodesPerRunChart.vue` (remove cumulative overlay)
6. `FeedRunHistoryGrid.vue` (multi-feed only)
**Checkpoint:** Pipeline tab fully restructured. All Tufte rules
applied. Episode outcomes is no longer a chart.
#### Phase 3 — Intelligence tab
**Goal:** Intelligence tab with available data + graceful degradation.
Steps:
1. `IntelligenceSnapshot.vue` (digest API, existing data)
2. `TopicLandscape.vue` (topic-clusters API, existing data)
3. Implement `GET /api/corpus/persons/top` + `TopVoices.vue`
4. `TopicMomentum.vue` stub with degraded state (enricher not yet
available)
5. `EmergingConnections.vue` stub with degraded state
**Checkpoint:** Intelligence tab shows snapshot + topic landscape
immediately. Top voices shows when persons/top endpoint ships. Momentum
and connections show degraded states (no enricher prompts).
#### Phase 4 — UXS + E2E updates
1. Rewrite `UXS-006-dashboard.md` to match implementation
2. Update `UXS-001-gi-kg-viewer.md` tunable params + mandatory insight
line rule
3. Update `SERVER_GUIDE.md` API table
4. Update `E2E_SURFACE_MAP.md` with all new testids
5. Run `make test-ui-e2e`
---
### 12. What This Does Not Change
- Chart.js library itself — same version, same registration pattern
- `indexStats.ts` store — unchanged
- Digest and Library tabs — unchanged
- Subject rail — unchanged
- Token system — all chart colours use existing `--ps-*` variables
- `DashboardOverviewSection.vue` is absorbed into `BriefingCard.vue`
and can be removed after migration