---
author: architect
responsible: architect
phase: 34
phase_type: implementation
status: Ready for execution
hard_bar: false
security_relevant: false
design_fork: false
wave: 1
depends_on: []
files_modified:
  - internal/server/handlers_tags_admin.go
  - internal/server/handlers_tags_admin_test.go
  - internal/server/router.go
  - web/src/api/client.ts
  - web/src/components/admin/TagManagement.tsx
  - web/src/components/admin/TagManagement.css
requirements: [TF-04]
autonomous: true
---

# Phase 34: Tag Usage Analytics - Plan

**Goal:** An admin analytics view over `graph.Store.TagPageCounts` — per-tag page
counts, most/least used, orphans, totals. Read-only, no new scan.

**Requirements:** TF-04

## must_haves

```yaml
truths:
  - id: TF-04-1
    statement: "An admin analytics view surfaces per-tag page counts (sortable), most-used and unused/orphan tags, and total tag/tagged-page counts, sourced from graph.Store.TagPageCounts — no new heavy scan"
    verification: "go test ./internal/server/ -run TestTagAnalytics -count=1 -v; grep 'TagPageCounts' internal/server/handlers_tags_admin.go"
  - id: TF-04-2
    statement: "The view states its scope (frontmatter page_tags cache; body-only #tags counted after materialization)"
    verification: "grep 'frontmatter\|page_tags\|body.*materialization' web/src/components/admin/TagManagement.tsx"
  - id: TF-04-3
    statement: "Admin-only, read-only (analytics never mutates), efficient at ~5-user scale"
    verification: "grep 'Get.*analytics' internal/server/router.go; no Write/Post in analytics handler"
```

## Tasks

### Task 1: Backend — analytics handler + route (Wave 1)

<read_first>
- internal/graph/query.go:449-455 (TagPageCounts — returns map[string]int)
- internal/server/handlers_tags_admin.go:507-529 (handleTagList — the existing tag list)
- internal/server/router.go:321-343 (admin tag route subgroup)
</read_first>

<action>
**1. Add `handleTagAnalytics`** in `handlers_tags_admin.go` — `GET /admin/tags/analytics`:
- Call `h.graph.TagPageCounts(ctx)` → `map[string]int` (tag → count).
- Compute:
  - `total_tags`: len(counts).
  - `tagged_pages`: count of distinct pages (sum of pages with ≥1 tag — or compute from the tag list: sum of counts is total tag-page edges, not distinct pages; for distinct tagged pages, use `SELECT COUNT(DISTINCT page_path) FROM page_tags` via the store, or accept the approximation).
  - `total_pages`: from `h.pages.Tree()` (count of live `.md` pages) or from the graph's live-page set.
  - `untagged_pages`: total_pages - tagged_pages.
  - `most_used`: top 5 tags by count (descending).
  - `least_used`: bottom 5 tags by count (ascending, excluding 0-count if orphans are separate).
  - `orphans`: tags with count == 0 (in the vocabulary from `handleTagList` but not in `TagPageCounts`, or count == 0).
- Return a JSON response: `{total_tags, total_pages, tagged_pages, untagged_pages, most_used: [{tag, count}], least_used: [{tag, count}], orphans: [{tag, count: 0}], tags: [{tag, count}]}`.

**2. Register the route** in `router.go`:
```go
admin.Get("/admin/tags/analytics", h.handleTagAnalytics)
```

**3. Write tests** in `handlers_tags_admin_test.go`:
- `TestTagAnalyticsRBAC` — editor gets 403; admin gets 200.
- `TestTagAnalytics` — seed pages with tags, call GET /admin/tags/analytics → verify total_tags, most_used, orphans, totals.
- `TestTagAnalyticsEmpty` — no pages/tags → all zeros, empty arrays.
</action>

<acceptance_criteria>
- `grep 'func (h \*authHandlers) handleTagAnalytics' internal/server/handlers_tags_admin.go` returns a match
- `grep 'admin.Get("/admin/tags/analytics"' internal/server/router.go` returns a match
- `grep 'TagPageCounts' internal/server/handlers_tags_admin.go` returns a match (reuses the existing query)
- `go test ./internal/server/ -run TestTagAnalytics -count=1 -v` — all 3 tests pass
- `go build ./...` exits 0
</acceptance_criteria>

### Task 2: Frontend — analytics panel (Wave 2)

<read_first>
- web/src/api/client.ts (listTags, TagEntry)
- web/src/components/admin/TagManagement.tsx (the tag table + tabs/dialogs)
- web/src/components/admin/TagManagement.css
</read_first>

<action>
**1. Add `getTagAnalytics` API function** in `client.ts`:
```typescript
export interface TagAnalyticsSummary {
  total_tags: number;
  total_pages: number;
  tagged_pages: number;
  untagged_pages: number;
  most_used: { tag: string; count: number }[];
  least_used: { tag: string; count: number }[];
  orphans: { tag: string; count: number }[];
  tags: { tag: string; count: number }[];
}
export async function getTagAnalytics(): Promise<TagAnalyticsSummary> {
  const res = await fetch("/api/v1/admin/tags/analytics", { credentials: "same-origin" });
  if (!res.ok) throw new Error("Couldn't load analytics.");
  return (await res.json()) as TagAnalyticsSummary;
}
```

**2. Add an "Analytics" tab/panel to `TagManagement.tsx`**:
- A tab toggle between "Tags" (the existing table) and "Analytics" (the new view).
- Analytics view shows:
  - Summary cards: total tags, tagged pages, untagged pages.
  - Per-tag counts table (sortable by count or name — client-side sort).
  - Most-used + least-used sections (top 5 each).
  - Orphan tags section.
  - Scope note: "Counts reflect frontmatter tags (the page_tags cache). Body-only #tags are counted only after materialization."
- Data from `getTagAnalytics()`, fetched via `useQuery`.

**3. Add CSS** for the analytics cards + layout in `TagManagement.css`.
</action>

<acceptance_criteria>
- `grep 'export async function getTagAnalytics' web/src/api/client.ts` returns a match
- `grep 'Analytics\|analytics' web/src/components/admin/TagManagement.tsx` returns a match
- `grep 'frontmatter\|page_tags\|body.*materialization' web/src/components/admin/TagManagement.tsx` returns a match (scope note)
- `cd web && npx tsc --noEmit` exits 0
- `cd web && npx vite build` exits 0
</acceptance_criteria>

## Verification

- **Goal-backward (qa):** An admin sees per-tag page counts, most/least used, orphans, totals — sourced from `TagPageCounts`, read-only, admin-only. The scope is stated.
- **Read-only:** verified by the handler using only GET + `TagPageCounts` (no writes).
- **No new scan:** verified by grep showing `TagPageCounts` is the data source.