The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add network attention display spec for #10

+279
+279
docs/superpowers/specs/2026-04-10-network-attention-display.md
··· 1 + # Network Attention Display Spec 2 + 3 + **Date:** 2026-04-10 4 + **Issue:** Chainlink #10 5 + **Status:** Approved (pending review) 6 + **Depends on:** PR #9 (scoring algorithm — merged), PR #11 (Railway deploy — merged) 7 + **Unblocks:** #25/#26 (coverage map), #20 (slider UI), talk page scoring (future follow-up) 8 + 9 + --- 10 + 11 + ## 1. Goal 12 + 13 + Wire the scoring engine into the `/talks` grid so authenticated users see bioluminescent glow on talks their network missed. Hover/tap reveals score details ("97% of your network missed this"). Unauthenticated users see the grid without glow, unchanged from today. 14 + 15 + --- 16 + 17 + ## 2. Background 18 + 19 + The scoring engine (`src/lib/scoring/`) is fully implemented with 40 unit tests. It takes `TalkMentions` from the crawler and produces `TalkScore[]` with a 0–1 `intensity` value, a three-state classifier (`engaged | missed | unknown`), and a `layer1` object with the raw counts. 20 + 21 + The `/talks` page currently renders a grid of `LumeCard` components with `glowIntensity={0}` (hardcoded). `LumeCard` already supports `glowIntensity` (maps to CSS glow classes and breathing animation), `tileIndex` (staggers breathing), and `interestMatch` (blue dot for Layer 2, not used yet). 22 + 23 + The missing piece: a client-side hook that fetches `/api/crawl`, pipes the result through `rankTalks`, and passes scores to the cards. 24 + 25 + --- 26 + 27 + ## 3. New Files 28 + 29 + ### 3.1 `src/hooks/useCrawlData.ts` — crawl data fetcher 30 + 31 + A React hook that: 32 + 1. Calls `GET /api/crawl` on mount 33 + 2. Returns `{ mentions: TalkMentions | null, followCount: number, loading: boolean, error: string | null }` 34 + 3. Only fetches if the user is authenticated (check is implicit — `/api/crawl` returns 401 if not, which the hook treats as "no data") 35 + 4. Caches the result for the component lifecycle (no re-fetch on re-render) 36 + 37 + ```ts 38 + import { useState, useEffect } from "react"; 39 + import type { TalkMentions } from "@/lib/scoring"; 40 + 41 + interface CrawlData { 42 + mentions: TalkMentions | null; 43 + followCount: number; 44 + loading: boolean; 45 + error: string | null; 46 + } 47 + 48 + export function useCrawlData(): CrawlData { 49 + const [data, setData] = useState<CrawlData>({ 50 + mentions: null, 51 + followCount: 0, 52 + loading: true, 53 + error: null, 54 + }); 55 + 56 + useEffect(() => { 57 + let cancelled = false; 58 + 59 + async function fetchCrawl() { 60 + try { 61 + const res = await fetch("/api/crawl"); 62 + if (!res.ok) { 63 + // 401 = not authenticated — not an error, just no data 64 + setData({ mentions: null, followCount: 0, loading: false, error: null }); 65 + return; 66 + } 67 + const json = await res.json(); 68 + if (!cancelled) { 69 + setData({ 70 + mentions: json.talkMentions, 71 + followCount: json.followCount, 72 + loading: false, 73 + error: null, 74 + }); 75 + } 76 + } catch (err) { 77 + if (!cancelled) { 78 + setData({ 79 + mentions: null, 80 + followCount: 0, 81 + loading: false, 82 + error: err instanceof Error ? err.message : "Crawl failed", 83 + }); 84 + } 85 + } 86 + } 87 + 88 + fetchCrawl(); 89 + return () => { cancelled = true; }; 90 + }, []); 91 + 92 + return data; 93 + } 94 + ``` 95 + 96 + The `cancelled` flag prevents state updates after unmount. The hook doesn't retry on failure — the crawl endpoint already has its own caching and timeout logic. 97 + 98 + ### 3.2 `src/components/scored-talks-grid.tsx` — scored grid wrapper 99 + 100 + A `"use client"` component that: 101 + 1. Receives `talks: TalkEntry[]` from the server component 102 + 2. Calls `useCrawlData()` to get crawl results 103 + 3. When data arrives, calls `rankTalks({ talks, mentions, followCount })` to produce scored + sorted talks 104 + 4. Renders a `LumeCard` for each talk, passing `glowIntensity={score.intensity}`, `tileIndex`, and the `score` prop for the hover detail 105 + 5. When no crawl data: renders talks in original order with `glowIntensity={0}` 106 + 107 + ```tsx 108 + "use client"; 109 + 110 + import { useMemo } from "react"; 111 + import Link from "next/link"; 112 + import { useCrawlData } from "@/hooks/useCrawlData"; 113 + import { rankTalks, type TalkScore } from "@/lib/scoring"; 114 + import { LumeCard } from "@/components/ui/lume-card"; 115 + import { formatDuration } from "@/lib/format"; 116 + import type { TalkEntry } from "@/lib/types"; 117 + 118 + interface ScoredTalksGridProps { 119 + talks: TalkEntry[]; 120 + } 121 + 122 + export function ScoredTalksGrid({ talks }: ScoredTalksGridProps) { 123 + const { mentions, followCount, loading } = useCrawlData(); 124 + 125 + const scoredTalks: { talk: TalkEntry; score: TalkScore | null }[] = useMemo(() => { 126 + if (!mentions) { 127 + // Not authenticated or crawl not loaded — render unsorted, no scores 128 + return talks.map((talk) => ({ talk, score: null })); 129 + } 130 + const scores = rankTalks({ talks, mentions, followCount }); 131 + // Match scores back to talks by rkey 132 + return scores.map((score) => ({ 133 + talk: talks.find((t) => t.rkey === score.rkey)!, 134 + score, 135 + })); 136 + }, [talks, mentions, followCount]); 137 + 138 + return ( 139 + <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> 140 + {scoredTalks.map(({ talk, score }, index) => ( 141 + <Link key={talk.rkey} href={`/talk/${talk.rkey}`}> 142 + <LumeCard 143 + glowIntensity={score?.intensity ?? 0} 144 + tileIndex={index} 145 + score={score} 146 + > 147 + {/* Card content: speakers, title, chips — same as current */} 148 + </LumeCard> 149 + </Link> 150 + ))} 151 + </div> 152 + ); 153 + } 154 + ``` 155 + 156 + The `useMemo` ensures `rankTalks` only re-runs when the inputs change (not on every render). The `loading` state is available but not used for a spinner — the grid renders immediately and glow appears when data arrives. 157 + 158 + --- 159 + 160 + ## 4. Modified Files 161 + 162 + ### 4.1 `src/app/talks/page.tsx` — use `ScoredTalksGrid` 163 + 164 + Replace the inline `<div className="grid ...">` that maps over talks with: 165 + 166 + ```tsx 167 + <ScoredTalksGrid talks={talks} /> 168 + ``` 169 + 170 + The server component still loads `data/talks.json`, filters by `transcriptFile`, and sorts by `startsAt`. It passes the full talks array to the client component, which re-sorts by score when crawl data arrives. 171 + 172 + The `getAuthUser()` call at the top of the page (for the Nav) is unchanged — it's a server-side call that doesn't affect the scoring flow. 173 + 174 + ### 4.2 `src/components/ui/lume-card.tsx` — add hover/tap score detail 175 + 176 + Add an optional `score: TalkScore | null` prop. When present and the card is hovered (desktop) or tapped (mobile), render a detail strip at the bottom of the card. 177 + 178 + **Detail strip content by state:** 179 + 180 + | `score.state` | Text | Color | 181 + |---|---|---| 182 + | `"missed"` | `"{X}% of your network missed this"` | `text-primary-fixed` (mint) | 183 + | `"engaged"` | `"Discussed by {N} of {T} follows"` | `text-on-surface-variant` (muted) | 184 + | `"unknown"` | (no detail strip) | — | 185 + | `null` | (no detail strip) | — | 186 + 187 + Where: 188 + - `X` = `Math.round(score.layer1.attentionInverse * 100)` — e.g., 97 189 + - `N` = `score.layer1.uniqueFollows` — e.g., 3 190 + - `T` = `score.layer1.totalFollows` — e.g., 423 191 + 192 + **Interaction:** 193 + - Desktop: detail appears on `:hover` via CSS transition (opacity 0 → 1, translateY(4px) → 0). No JavaScript state needed. 194 + - Mobile: detail is always visible when `score` is present (since there's no hover). This is acceptable because mobile cards are larger (single column) and the detail text is small. 195 + 196 + **Implementation note:** The detail strip is inside the card but below the existing content. It uses `absolute` positioning at the bottom, with a subtle gradient fade from transparent to `surface-container-low` so it doesn't clip the card content. 197 + 198 + --- 199 + 200 + ## 5. Data Flow 201 + 202 + ``` 203 + Server: talks/page.tsx 204 + → reads data/talks.json 205 + → filters + sorts by startsAt 206 + → renders <Nav user={authUser} /> 207 + → renders <ScoredTalksGrid talks={talks} /> 208 + 209 + Client: ScoredTalksGrid mounts 210 + → useCrawlData() fires fetch("/api/crawl") 211 + → 401 (not auth) → mentions=null → grid renders with no glow 212 + → 200 (auth) → { talkMentions, followCount } → rankTalks() → TalkScore[] 213 + → grid re-renders with glow intensities + re-sorted by score 214 + → each LumeCard receives intensity + score for hover detail 215 + ``` 216 + 217 + --- 218 + 219 + ## 6. Loading Behavior 220 + 221 + **Phase 1 (immediate, server-rendered):** Talk grid appears with all cards at `glowIntensity=0`. Page is fully interactive. If not authenticated, this is the final state. 222 + 223 + **Phase 2 (~2-5s after mount, client-side):** Crawl data arrives. Cards animate to their scored glow intensities. Grid re-sorts to put missed talks first. The visual effect is the "forest waking up" — cards that your network missed light up with bioluminescent glow. 224 + 225 + No loading spinners, no skeleton screens, no loading text. The transition IS the feedback. The existing `LumeCard` glow CSS already uses transitions, so the intensity change animates smoothly. 226 + 227 + **If crawl fails:** Grid stays in Phase 1 (no glow, original sort). The error is logged to console but not shown to the user — the page is still fully functional for browsing talks. 228 + 229 + --- 230 + 231 + ## 7. Sort Behavior 232 + 233 + | State | Sort Order | 234 + |---|---| 235 + | Not authenticated | Original: by `startsAt` date (server-side) | 236 + | Authenticated, crawl loading | Original: by `startsAt` date | 237 + | Authenticated, crawl loaded | By score: `missed` first (intensity desc) → `engaged` (intensity desc) → `unknown` (rkey asc) | 238 + 239 + The re-sort happens when crawl data arrives. Cards shift positions as glow appears. If this transition feels jarring, a CSS `transition` on grid item `order` can smooth it — but this is a polish item, not a blocker. 240 + 241 + --- 242 + 243 + ## 8. Edge Cases 244 + 245 + - **Not authenticated:** Grid renders without scores. No `/api/crawl` call is made (the hook fetches, gets 401, sets `mentions=null`). Same as today. 246 + - **Zero follows:** `/api/crawl` returns `followCount: 0`. `rankTalks` produces all `unknown` states. Grid renders without glow. Acceptable — the user needs follows for the scoring to be meaningful. 247 + - **Crawl timeout (30s):** `/api/crawl` returns 504. Hook receives error. Grid stays in Phase 1. 248 + - **Partial crawl data:** Some talks have mentions, some don't (out-of-scope talks). `rankTalks` classifies absent talks as `unknown`. They sort to the bottom. 249 + - **Empty talks array:** Grid renders empty (same as today). 250 + 251 + --- 252 + 253 + ## 9. Non-goals 254 + 255 + - **Sliders** (#20) — scoring weights are fixed at `DEFAULT_WEIGHTS` for this issue 256 + - **Coverage map** (#25, #26) — separate visualization, different layout 257 + - **Talk page scoring** — deferred to a follow-up (option C: "who discussed this" with profile resolution) 258 + - **Interest match badges** — Layer 2 not live 259 + - **Friend recommendation badges** — Layer 3 not live 260 + - **Grid re-sort animation** — polish follow-up if the abrupt re-sort feels jarring 261 + - **Error UI for crawl failure** — the page works without scores; no user-visible error needed 262 + 263 + --- 264 + 265 + ## 10. Acceptance Criteria 266 + 267 + - [ ] `src/hooks/useCrawlData.ts` exists and exports `useCrawlData` 268 + - [ ] `src/components/scored-talks-grid.tsx` exists as a `"use client"` component 269 + - [ ] `/talks` page uses `ScoredTalksGrid` instead of inline grid 270 + - [ ] Authenticated users see glow on missed talks after crawl data loads 271 + - [ ] Hover (desktop) or tap (mobile) shows score detail: "X% of your network missed this" or "Discussed by N of T follows" 272 + - [ ] Unauthenticated users see the grid without glow (same as today) 273 + - [ ] Grid re-sorts by score when authenticated + crawl loaded 274 + - [ ] `LumeCard` accepts `score: TalkScore | null` prop for the detail strip 275 + - [ ] `npx tsc --noEmit` clean 276 + - [ ] `npx eslint src/` clean 277 + - [ ] `npm test` passes (existing 40 scoring tests) 278 + - [ ] `npm run build` succeeds 279 + - [ ] Manual test on staging: log in, see glow appear, hover for detail