The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

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

Merge pull request #15 from musicjunkieg/feat/network-attention-display

feat: network attention display (#10)

authored by

chaos gremlin and committed by
GitHub
cada7e73 46f36193

+1022 -29
+1
CHANGELOG.md
··· 12 12 - Fix talk-to-schedule matching for ATScience VODs without vodAtUri (#34) 13 13 14 14 ### Changed 15 + - Deploy to Railway (#31) 15 16 - Scoring algorithm (#19) 16 17 - Post-to-talk matching (#17) 17 18 - Social graph crawler (#16)
+538
docs/superpowers/plans/2026-04-10-network-attention-display.md
··· 1 + # Network Attention Display Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** Wire the scoring engine into the `/talks` grid so authenticated users see bioluminescent glow on missed talks, with hover/tap detail showing the score. 6 + 7 + **Architecture:** Server component loads talks from disk (unchanged). New client component (`ScoredTalksGrid`) fetches `/api/crawl` via a `useCrawlData` hook, runs `rankTalks`, and passes scores to `LumeCard`. `LumeCard` gains a `score` prop for hover/tap detail strips. Unauthenticated users see the grid without glow. 8 + 9 + **Tech Stack:** Next.js 16 (App Router, React 19), existing scoring module (`@/lib/scoring`), existing bioluminescent design system (Tailwind v4 CSS-first theme) 10 + 11 + **Spec:** `docs/superpowers/specs/2026-04-10-network-attention-display.md` 12 + 13 + **Chainlink Issue:** #10 14 + 15 + --- 16 + 17 + ## File Structure 18 + 19 + | File | Action | Responsibility | 20 + |------|--------|----------------| 21 + | `src/hooks/useCrawlData.ts` | Create | React hook: fetches `/api/crawl`, returns `{ mentions, followCount, loading, error }` | 22 + | `src/components/scored-talks-grid.tsx` | Create | Client component: calls `useCrawlData` + `rankTalks`, renders scored `LumeCard` grid with card content | 23 + | `src/components/ui/lume-card.tsx` | Modify | Add optional `score: TalkScore | null` prop for hover/tap detail strip | 24 + | `src/app/talks/page.tsx` | Modify | Replace inline grid with `<ScoredTalksGrid talks={talks} />` | 25 + 26 + --- 27 + 28 + ## Chunk 1: Hook + LumeCard enhancement 29 + 30 + ### Task 1: Create `useCrawlData` hook 31 + 32 + **Files:** 33 + - Create: `src/hooks/useCrawlData.ts` 34 + 35 + - [ ] **Step 1: Create the hooks directory** 36 + 37 + Run: `mkdir -p src/hooks` 38 + 39 + - [ ] **Step 2: Write the hook** 40 + 41 + Create `src/hooks/useCrawlData.ts`: 42 + 43 + ```ts 44 + "use client"; 45 + 46 + import { useState, useEffect } from "react"; 47 + import type { TalkMentions } from "@/lib/scoring"; 48 + 49 + export interface CrawlData { 50 + mentions: TalkMentions | null; 51 + followCount: number; 52 + loading: boolean; 53 + error: string | null; 54 + } 55 + 56 + /** 57 + * Fetches crawl data from `/api/crawl` on mount. 58 + * 59 + * - If authenticated: returns `{ mentions, followCount }` from the crawler. 60 + * - If not authenticated (401): returns `mentions: null` — not an error. 61 + * - If the crawl fails or times out: returns `error` string. 62 + * 63 + * The hook fires one fetch on mount and does not retry. The crawl endpoint 64 + * has its own caching (30-minute TTL) and concurrent-request coalescing. 65 + */ 66 + export function useCrawlData(): CrawlData { 67 + const [data, setData] = useState<CrawlData>({ 68 + mentions: null, 69 + followCount: 0, 70 + loading: true, 71 + error: null, 72 + }); 73 + 74 + useEffect(() => { 75 + let cancelled = false; 76 + 77 + async function fetchCrawl() { 78 + try { 79 + const res = await fetch("/api/crawl"); 80 + if (!res.ok) { 81 + // 401 = not authenticated, 504 = timeout — treat as "no data" 82 + if (!cancelled) { 83 + setData({ 84 + mentions: null, 85 + followCount: 0, 86 + loading: false, 87 + error: null, 88 + }); 89 + } 90 + return; 91 + } 92 + const json = await res.json(); 93 + if (!cancelled) { 94 + setData({ 95 + mentions: json.talkMentions, 96 + followCount: json.followCount, 97 + loading: false, 98 + error: null, 99 + }); 100 + } 101 + } catch (err) { 102 + if (!cancelled) { 103 + setData({ 104 + mentions: null, 105 + followCount: 0, 106 + loading: false, 107 + error: err instanceof Error ? err.message : "Crawl failed", 108 + }); 109 + } 110 + } 111 + } 112 + 113 + fetchCrawl(); 114 + return () => { 115 + cancelled = true; 116 + }; 117 + }, []); 118 + 119 + return data; 120 + } 121 + ``` 122 + 123 + - [ ] **Step 3: Verify tsc is clean** 124 + 125 + Run: `npx tsc --noEmit` 126 + Expected: clean. 127 + 128 + - [ ] **Step 4: Commit** 129 + 130 + ```bash 131 + git add src/hooks/useCrawlData.ts 132 + git commit -m "feat: add useCrawlData hook for fetching crawl results" 133 + ``` 134 + 135 + --- 136 + 137 + ### Task 2: Add score detail strip to `LumeCard` 138 + 139 + **Files:** 140 + - Modify: `src/components/ui/lume-card.tsx` 141 + 142 + - [ ] **Step 1: Read the current `LumeCard`** 143 + 144 + Read `src/components/ui/lume-card.tsx` and confirm the current interface: 145 + - Props: `glowIntensity`, `tileIndex`, `interestMatch`, plus `HTMLAttributes<HTMLDivElement>` 146 + - No `score` prop exists yet 147 + 148 + - [ ] **Step 2: Add the `score` prop and detail strip** 149 + 150 + Replace the entire file with: 151 + 152 + ```tsx 153 + import { type HTMLAttributes } from "react"; 154 + import type { TalkScore } from "@/lib/scoring"; 155 + 156 + interface LumeCardProps extends HTMLAttributes<HTMLDivElement> { 157 + /** Understory score 0-1. Higher = more undiscovered = brighter glow. */ 158 + glowIntensity?: number; 159 + /** Index for staggered breathing animation. */ 160 + tileIndex?: number; 161 + /** Whether to show the interest match indicator. */ 162 + interestMatch?: boolean; 163 + /** Score data for hover/tap detail strip. null = no detail. */ 164 + score?: TalkScore | null; 165 + } 166 + 167 + function glowStyle(intensity: number): string { 168 + if (intensity > 0.7) return "biolume-glow-strong"; 169 + if (intensity > 0.3) return "biolume-glow"; 170 + return ""; 171 + } 172 + 173 + function ScoreDetail({ score }: { score: TalkScore }) { 174 + if (score.state === "unknown") return null; 175 + 176 + if (score.state === "missed") { 177 + return ( 178 + <div className="text-label-sm text-primary-fixed"> 179 + Your network missed this 180 + </div> 181 + ); 182 + } 183 + 184 + // engaged — show percentage 185 + const pct = Math.round(score.layer1.attentionInverse * 100); 186 + return ( 187 + <div className="text-label-sm text-on-surface-variant"> 188 + {pct}% of your network missed this 189 + </div> 190 + ); 191 + } 192 + 193 + function LumeCard({ 194 + glowIntensity = 0, 195 + tileIndex, 196 + interestMatch = false, 197 + score, 198 + className = "", 199 + children, 200 + ...props 201 + }: LumeCardProps) { 202 + const isUnderstory = glowIntensity > 0.3; 203 + const hasDetail = score && score.state !== "unknown"; 204 + 205 + return ( 206 + <div 207 + className={[ 208 + "group relative rounded-lg", 209 + "bg-surface-container-low/60 backdrop-blur-[20px]", 210 + "border-t-2", 211 + glowIntensity > 0.3 212 + ? "border-primary-fixed-dim" 213 + : glowIntensity > 0 214 + ? "border-primary-fixed-dim/50" 215 + : "border-primary-fixed-dim/20", 216 + "transition-all duration-500", 217 + "hover:biolume-glow-strong", 218 + "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-fixed", 219 + glowStyle(glowIntensity), 220 + isUnderstory ? "animate-breathe" : "", 221 + className, 222 + ].join(" ")} 223 + style={ 224 + tileIndex !== undefined 225 + ? ({ "--tile-index": tileIndex } as React.CSSProperties) 226 + : undefined 227 + } 228 + {...props} 229 + > 230 + {interestMatch && ( 231 + <span 232 + className="absolute top-3 right-3 h-2.5 w-2.5 rounded-full bg-interest-match" 233 + aria-label="Matches your interests" 234 + /> 235 + )} 236 + {children} 237 + 238 + {hasDetail && ( 239 + <div 240 + className={[ 241 + "px-5 pb-3 pt-0", 242 + // Mobile: always visible (no hover capability) 243 + "max-h-12 opacity-100", 244 + // Desktop (sm+): hidden by default, revealed on hover via group-hover 245 + "sm:max-h-0 sm:overflow-hidden sm:opacity-0", 246 + "sm:transition-all sm:duration-300", 247 + "sm:group-hover:max-h-12 sm:group-hover:opacity-100", 248 + ].join(" ")} 249 + > 250 + <div className="border-t border-primary-fixed-dim/20 pt-2"> 251 + <ScoreDetail score={score} /> 252 + </div> 253 + </div> 254 + )} 255 + </div> 256 + ); 257 + } 258 + 259 + export { LumeCard, type LumeCardProps }; 260 + ``` 261 + 262 + **Key changes from the original:** 263 + - Added `score?: TalkScore | null` prop 264 + - Added `ScoreDetail` component (renders "Your network missed this" or "X% of your network missed this") 265 + - Added detail strip: **always visible on mobile** (where there's no hover), **hidden → revealed on hover on desktop** (`sm:group-hover:`). No JavaScript state needed — pure CSS. 266 + - Changed `transition-shadow` to `transition-all duration-500` for smoother glow transitions 267 + - Added `group` class for group-hover targeting 268 + - Border top opacity now varies with glow intensity for more visual gradient 269 + - No `"use client"` needed — no `useState`, no event handlers. The component is rendered inside the client `ScoredTalksGrid` but doesn't need its own client boundary. 270 + 271 + - [ ] **Step 3: Verify tsc and eslint are clean** 272 + 273 + Run in parallel: 274 + - `npx tsc --noEmit` — Expected: clean 275 + - `npx eslint src/` — Expected: clean 276 + 277 + > **Note:** If eslint flags the `useState` import or the `"use client"` directive, ensure the file starts with `"use client";` on its own line (React 19 / Next.js 16 requirement for client components). 278 + 279 + - [ ] **Step 4: Verify existing tests still pass** 280 + 281 + Run: `npm test` 282 + Expected: 40/40 pass (scoring tests are unrelated but confirms nothing broke). 283 + 284 + - [ ] **Step 5: Commit** 285 + 286 + ```bash 287 + git add src/components/ui/lume-card.tsx 288 + git commit -m "feat: add score detail strip to LumeCard (hover/tap) 289 + 290 + Shows 'Your network missed this' (mint) for missed talks and 291 + 'X% of your network missed this' (muted) for engaged talks. 292 + Detail strip revealed on hover (desktop) or tap (mobile). 293 + Border-top opacity now varies with glow intensity." 294 + ``` 295 + 296 + --- 297 + 298 + ## Chunk 2: Scored grid + page integration 299 + 300 + ### Task 3: Create `ScoredTalksGrid` client component 301 + 302 + **Files:** 303 + - Create: `src/components/scored-talks-grid.tsx` 304 + 305 + - [ ] **Step 1: Write the component** 306 + 307 + Create `src/components/scored-talks-grid.tsx`: 308 + 309 + ```tsx 310 + "use client"; 311 + 312 + import { useMemo } from "react"; 313 + import Link from "next/link"; 314 + import { useCrawlData } from "@/hooks/useCrawlData"; 315 + import { rankTalks, type TalkScore } from "@/lib/scoring"; 316 + import { LumeCard } from "@/components/ui/lume-card"; 317 + import { Chip } from "@/components/ui/chip"; 318 + import { formatDuration } from "@/lib/format"; 319 + import type { TalkEntry } from "@/lib/types"; 320 + 321 + interface ScoredTalksGridProps { 322 + talks: TalkEntry[]; 323 + } 324 + 325 + export function ScoredTalksGrid({ talks }: ScoredTalksGridProps) { 326 + const { mentions, followCount } = useCrawlData(); 327 + 328 + const scoredTalks: { talk: TalkEntry; score: TalkScore | null }[] = 329 + useMemo(() => { 330 + if (!mentions) { 331 + // Not authenticated or crawl not loaded — unsorted, no scores 332 + return talks.map((talk) => ({ talk, score: null })); 333 + } 334 + const scores = rankTalks({ talks, mentions, followCount }); 335 + return scores.map((score) => ({ 336 + talk: talks.find((t) => t.rkey === score.rkey)!, 337 + score, 338 + })); 339 + }, [talks, mentions, followCount]); 340 + 341 + return ( 342 + <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> 343 + {scoredTalks.map(({ talk, score }, index) => ( 344 + <Link key={talk.rkey} href={`/talk/${talk.rkey}`}> 345 + <LumeCard 346 + className="h-full" 347 + glowIntensity={score?.intensity ?? 0} 348 + tileIndex={index} 349 + score={score} 350 + > 351 + <div className="p-5"> 352 + {talk.speakers.length > 0 && ( 353 + <p className="text-label-md text-primary-fixed-dim mb-2"> 354 + {talk.speakers.map((s) => s.name).join(", ")} 355 + </p> 356 + )} 357 + <h2 className="text-headline-sm text-on-surface mb-3"> 358 + {talk.title} 359 + </h2> 360 + <div className="flex flex-wrap gap-2"> 361 + {talk.room && <Chip>{talk.room}</Chip>} 362 + <Chip>{formatDuration(talk.durationMs)}</Chip> 363 + </div> 364 + </div> 365 + </LumeCard> 366 + </Link> 367 + ))} 368 + </div> 369 + ); 370 + } 371 + ``` 372 + 373 + The card content JSX is copied verbatim from the current `talks/page.tsx` (lines 50–64). The `useMemo` ensures `rankTalks` only re-runs when inputs change. 374 + 375 + - [ ] **Step 2: Verify tsc and eslint** 376 + 377 + Run in parallel: 378 + - `npx tsc --noEmit` — Expected: clean 379 + - `npx eslint src/` — Expected: clean 380 + 381 + - [ ] **Step 3: Commit** 382 + 383 + ```bash 384 + git add src/components/scored-talks-grid.tsx 385 + git commit -m "feat: add ScoredTalksGrid client component 386 + 387 + Fetches crawl data via useCrawlData hook, runs rankTalks to score 388 + and sort talks, renders LumeCard grid with glow intensities and 389 + score detail. Falls back to unsorted grid with no glow when 390 + unauthenticated." 391 + ``` 392 + 393 + --- 394 + 395 + ### Task 4: Wire `ScoredTalksGrid` into the talks page 396 + 397 + **Files:** 398 + - Modify: `src/app/talks/page.tsx` 399 + 400 + - [ ] **Step 1: Read the current page** 401 + 402 + Read `src/app/talks/page.tsx` and confirm the current structure (server component that loads talks and renders the grid inline). 403 + 404 + - [ ] **Step 2: Replace the inline grid with `ScoredTalksGrid`** 405 + 406 + Replace the entire file with: 407 + 408 + ```tsx 409 + import * as fs from "fs"; 410 + import * as path from "path"; 411 + import { Nav } from "@/components/ui/nav"; 412 + import { ScoredTalksGrid } from "@/components/scored-talks-grid"; 413 + import { getAuthUser } from "@/lib/auth/user"; 414 + import type { TalkEntry } from "@/lib/types"; 415 + 416 + const DATA_DIR = path.resolve(process.cwd(), "data"); 417 + 418 + function loadTalks(): TalkEntry[] { 419 + const raw = fs.readFileSync(path.join(DATA_DIR, "talks.json"), "utf-8"); 420 + return JSON.parse(raw); 421 + } 422 + 423 + export const metadata = { 424 + title: "All Talks — Understory", 425 + description: "Browse all ATmosphereConf 2026 talks with transcripts.", 426 + }; 427 + 428 + export default async function TalksPage() { 429 + const user = await getAuthUser(); 430 + const talks = loadTalks() 431 + .filter((t) => t.transcriptFile) 432 + .sort((a, b) => { 433 + if (a.startsAt && b.startsAt) 434 + return a.startsAt.localeCompare(b.startsAt); 435 + if (a.startsAt) return -1; 436 + if (b.startsAt) return 1; 437 + return a.createdAt.localeCompare(b.createdAt); 438 + }); 439 + 440 + return ( 441 + <> 442 + <Nav minimal user={user} /> 443 + <main className="mx-auto max-w-7xl px-6 pt-24 pb-16"> 444 + <header className="mb-8"> 445 + <h1 className="text-headline-md text-on-surface mb-2">All Talks</h1> 446 + <p className="text-label-md text-on-surface-variant"> 447 + {talks.length} talks with transcripts 448 + </p> 449 + </header> 450 + 451 + <ScoredTalksGrid talks={talks} /> 452 + </main> 453 + </> 454 + ); 455 + } 456 + ``` 457 + 458 + **Changes from the original:** 459 + - Removed imports: `Link`, `Chip`, `LumeCard`, `formatDuration` (now used inside `ScoredTalksGrid`) 460 + - Added import: `ScoredTalksGrid` 461 + - Replaced the `<div className="grid ...">` block (lines 46–67) with `<ScoredTalksGrid talks={talks} />` 462 + - The server component still does: load talks → filter by transcriptFile → sort by startsAt. The client component handles scoring + re-sorting. 463 + 464 + - [ ] **Step 3: Verify tsc, eslint, and tests** 465 + 466 + Run in parallel: 467 + - `npx tsc --noEmit` — Expected: clean 468 + - `npx eslint src/` — Expected: clean 469 + - `npm test` — Expected: 40/40 pass 470 + 471 + - [ ] **Step 4: Run a production build** 472 + 473 + Run: `npm run build` 474 + 475 + Expected: 476 + - Build succeeds 477 + - Route list unchanged (same routes as before) 478 + - No new warnings 479 + 480 + - [ ] **Step 5: Test locally with the dev server** 481 + 482 + Start the dev server: `npm run dev` 483 + 484 + **Unauthenticated test:** 485 + 1. Open `http://127.0.0.1:3000/talks` in an incognito window 486 + 2. Expected: talk grid loads immediately, no glow on any card, same sort as before 487 + 3. Hover over a card: no detail strip (no score data) 488 + 489 + **Authenticated test (if possible locally):** 490 + 1. Open `http://127.0.0.1:3000/talks` while logged in 491 + 2. Expected: talk grid loads immediately without glow 492 + 3. After ~2-5 seconds: glow appears on missed talks, grid re-sorts by score 493 + 4. Hover over a glowing card: detail strip shows "Your network missed this" (mint) 494 + 5. Hover over a dimmer card: detail strip shows "X% of your network missed this" (muted) 495 + 496 + Stop the dev server. 497 + 498 + - [ ] **Step 6: Commit** 499 + 500 + ```bash 501 + git add src/app/talks/page.tsx 502 + git commit -m "feat: wire ScoredTalksGrid into /talks page 503 + 504 + Server component loads talks and passes to client component. 505 + Scoring + glow + hover detail are progressive enhancement — 506 + unauthenticated users see the same grid as before." 507 + ``` 508 + 509 + --- 510 + 511 + ## Chunk 3: Final verification 512 + 513 + ### Task 5: Verify everything and wrap up 514 + 515 + - [ ] **Step 1: Run all checks** 516 + 517 + Run in parallel: 518 + - `npm test` — Expected: 40/40 pass 519 + - `npx tsc --noEmit` — Expected: clean 520 + - `npx eslint src/` — Expected: clean 521 + - `npm run build` — Expected: succeeds 522 + 523 + - [ ] **Step 2: Verify file inventory** 524 + 525 + The feature branch should have 4 changed/new files: 526 + 527 + ``` 528 + src/hooks/useCrawlData.ts (new) 529 + src/components/scored-talks-grid.tsx (new) 530 + src/components/ui/lume-card.tsx (modified) 531 + src/app/talks/page.tsx (modified) 532 + ``` 533 + 534 + Run: `git diff --stat main` to confirm. 535 + 536 + - [ ] **Step 3: Use the finishing-a-development-branch skill** 537 + 538 + Invoke `superpowers:finishing-a-development-branch` to present merge/PR options and execute the chosen workflow. The work should be pushed to `staging` first for Railway validation, then promoted to `main` via PR.
+282
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 + className="h-full" 144 + glowIntensity={score?.intensity ?? 0} 145 + tileIndex={index} 146 + score={score} 147 + > 148 + {/* Card content: speakers, title, room chip, duration chip. 149 + Copy the existing JSX from talks/page.tsx (the <div className="p-5"> 150 + block with speakers, h2 title, and metadata chips). */} 151 + </LumeCard> 152 + </Link> 153 + ))} 154 + </div> 155 + ); 156 + } 157 + ``` 158 + 159 + 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. 160 + 161 + --- 162 + 163 + ## 4. Modified Files 164 + 165 + ### 4.1 `src/app/talks/page.tsx` — use `ScoredTalksGrid` 166 + 167 + Replace the inline `<div className="grid ...">` that maps over talks with: 168 + 169 + ```tsx 170 + <ScoredTalksGrid talks={talks} /> 171 + ``` 172 + 173 + 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. 174 + 175 + 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. 176 + 177 + ### 4.2 `src/components/ui/lume-card.tsx` — add hover/tap score detail 178 + 179 + 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. 180 + 181 + **Detail strip content by state:** 182 + 183 + | `score.state` | Text | Color | 184 + |---|---|---| 185 + | `"missed"` | `"Your network missed this"` | `text-primary-fixed` (mint) | 186 + | `"engaged"` | `"{X}% of your network missed this"` | `text-on-surface-variant` (muted) | 187 + | `"unknown"` | (no detail strip) | — | 188 + | `null` | (no detail strip) | — | 189 + 190 + Where: 191 + - `X` = `Math.round(score.layer1.attentionInverse * 100)` — e.g., 83 192 + 193 + **Why no percentage for "missed":** The `missed` state means `uniqueFollows === 0`, so `attentionInverse` is always exactly `1.0` — the percentage would always read "100%". A static message is clearer and avoids pointless math. For `engaged` talks, the percentage IS meaningful: "83% of your network missed this" conveys the gradient between "almost nobody talked about it" and "most of your follows discussed it." 194 + 195 + **Interaction:** 196 + - Desktop (`sm:` and above): detail is hidden by default, revealed on `:hover` via CSS transition (`max-height` + `opacity`). Uses Tailwind `group`/`group-hover:` — no JavaScript state needed. 197 + - Mobile (below `sm:`): detail is always visible when `score` is present (since there's no hover). Acceptable because mobile cards are full-width and the detail text is small. 198 + 199 + **Implementation note:** The detail strip uses flow-based expansion (`max-h-0`/`max-h-12` + `overflow-hidden`) rather than absolute positioning. This avoids layout complexity and works naturally with the card's existing padding. The `transition-all duration-300` on `sm:` breakpoint smoothly animates the reveal on desktop hover. 200 + 201 + --- 202 + 203 + ## 5. Data Flow 204 + 205 + ``` 206 + Server: talks/page.tsx 207 + → reads data/talks.json 208 + → filters + sorts by startsAt 209 + → renders <Nav user={authUser} /> 210 + → renders <ScoredTalksGrid talks={talks} /> 211 + 212 + Client: ScoredTalksGrid mounts 213 + → useCrawlData() fires fetch("/api/crawl") 214 + → 401 (not auth) → mentions=null → grid renders with no glow 215 + → 200 (auth) → { talkMentions, followCount } → rankTalks() → TalkScore[] 216 + → grid re-renders with glow intensities + re-sorted by score 217 + → each LumeCard receives intensity + score for hover detail 218 + ``` 219 + 220 + --- 221 + 222 + ## 6. Loading Behavior 223 + 224 + **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. 225 + 226 + **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. 227 + 228 + 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. 229 + 230 + **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. 231 + 232 + --- 233 + 234 + ## 7. Sort Behavior 235 + 236 + | State | Sort Order | 237 + |---|---| 238 + | Not authenticated | Original: by `startsAt` date (server-side) | 239 + | Authenticated, crawl loading | Original: by `startsAt` date | 240 + | Authenticated, crawl loaded | By score: `missed` first (intensity desc) → `engaged` (intensity desc) → `unknown` (rkey asc) | 241 + 242 + 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. 243 + 244 + --- 245 + 246 + ## 8. Edge Cases 247 + 248 + - **Not authenticated:** Grid renders without scores. The hook fires `fetch("/api/crawl")` which returns 401; the hook treats this as "no data" and sets `mentions=null`. Same visual as today. 249 + - **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. 250 + - **Crawl timeout (30s):** `/api/crawl` returns 504. Hook receives error. Grid stays in Phase 1. 251 + - **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. 252 + - **Empty talks array:** Grid renders empty (same as today). 253 + 254 + --- 255 + 256 + ## 9. Non-goals 257 + 258 + - **Sliders** (#20) — scoring weights are fixed at `DEFAULT_WEIGHTS` for this issue 259 + - **Coverage map** (#25, #26) — separate visualization, different layout 260 + - **Talk page scoring** — deferred to a follow-up (option C: "who discussed this" with profile resolution) 261 + - **Interest match badges** — Layer 2 not live 262 + - **Friend recommendation badges** — Layer 3 not live 263 + - **Grid re-sort animation** — polish follow-up if the abrupt re-sort feels jarring 264 + - **Error UI for crawl failure** — the page works without scores; no user-visible error needed 265 + 266 + --- 267 + 268 + ## 10. Acceptance Criteria 269 + 270 + - [ ] `src/hooks/useCrawlData.ts` exists and exports `useCrawlData` 271 + - [ ] `src/components/scored-talks-grid.tsx` exists as a `"use client"` component 272 + - [ ] `/talks` page uses `ScoredTalksGrid` instead of inline grid 273 + - [ ] Authenticated users see glow on missed talks after crawl data loads 274 + - [ ] Hover (desktop) or tap (mobile) shows score detail: "X% of your network missed this" or "Discussed by N of T follows" 275 + - [ ] Unauthenticated users see the grid without glow (same as today) 276 + - [ ] Grid re-sorts by score when authenticated + crawl loaded 277 + - [ ] `LumeCard` accepts `score: TalkScore | null` prop for the detail strip 278 + - [ ] `npx tsc --noEmit` clean 279 + - [ ] `npx eslint src/` clean 280 + - [ ] `npm test` passes (existing 40 scoring tests) 281 + - [ ] `npm run build` succeeds 282 + - [ ] Manual test on staging: log in, see glow appear, hover for detail
+2 -26
src/app/talks/page.tsx
··· 1 1 import * as fs from "fs"; 2 2 import * as path from "path"; 3 - import Link from "next/link"; 4 3 import { Nav } from "@/components/ui/nav"; 5 - import { Chip } from "@/components/ui/chip"; 6 - import { LumeCard } from "@/components/ui/lume-card"; 7 - import { formatDuration } from "@/lib/format"; 4 + import { ScoredTalksGrid } from "@/components/scored-talks-grid"; 8 5 import { getAuthUser } from "@/lib/auth/user"; 9 6 import type { TalkEntry } from "@/lib/types"; 10 7 ··· 43 40 </p> 44 41 </header> 45 42 46 - <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> 47 - {talks.map((talk) => ( 48 - <Link key={talk.rkey} href={`/talk/${talk.rkey}`}> 49 - <LumeCard className="h-full"> 50 - <div className="p-5"> 51 - {talk.speakers.length > 0 && ( 52 - <p className="text-label-md text-primary-fixed-dim mb-2"> 53 - {talk.speakers.map((s) => s.name).join(", ")} 54 - </p> 55 - )} 56 - <h2 className="text-headline-sm text-on-surface mb-3"> 57 - {talk.title} 58 - </h2> 59 - <div className="flex flex-wrap gap-2"> 60 - {talk.room && <Chip>{talk.room}</Chip>} 61 - <Chip>{formatDuration(talk.durationMs)}</Chip> 62 - </div> 63 - </div> 64 - </LumeCard> 65 - </Link> 66 - ))} 67 - </div> 43 + <ScoredTalksGrid talks={talks} /> 68 44 </main> 69 45 </> 70 46 );
+62
src/components/scored-talks-grid.tsx
··· 1 + "use client"; 2 + 3 + import { useMemo } from "react"; 4 + import Link from "next/link"; 5 + import { useCrawlData } from "@/hooks/useCrawlData"; 6 + import { rankTalks, type TalkScore } from "@/lib/scoring"; 7 + import { LumeCard } from "@/components/ui/lume-card"; 8 + import { Chip } from "@/components/ui/chip"; 9 + import { formatDuration } from "@/lib/format"; 10 + import type { TalkEntry } from "@/lib/types"; 11 + 12 + interface ScoredTalksGridProps { 13 + talks: TalkEntry[]; 14 + } 15 + 16 + export function ScoredTalksGrid({ talks }: ScoredTalksGridProps) { 17 + const { mentions, followCount } = useCrawlData(); 18 + 19 + const scoredTalks: { talk: TalkEntry; score: TalkScore | null }[] = 20 + useMemo(() => { 21 + if (!mentions) { 22 + // Not authenticated or crawl not loaded — unsorted, no scores 23 + return talks.map((talk) => ({ talk, score: null })); 24 + } 25 + const scores = rankTalks({ talks, mentions, followCount }); 26 + const talksByRkey = new Map(talks.map((t) => [t.rkey, t])); 27 + return scores.map((score) => ({ 28 + talk: talksByRkey.get(score.rkey)!, 29 + score, 30 + })); 31 + }, [talks, mentions, followCount]); 32 + 33 + return ( 34 + <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> 35 + {scoredTalks.map(({ talk, score }, index) => ( 36 + <Link key={talk.rkey} href={`/talk/${talk.rkey}`}> 37 + <LumeCard 38 + className="h-full" 39 + glowIntensity={score?.intensity ?? 0} 40 + tileIndex={index} 41 + score={score} 42 + > 43 + <div className="p-5"> 44 + {talk.speakers.length > 0 && ( 45 + <p className="text-label-md text-primary-fixed-dim mb-2"> 46 + {talk.speakers.map((s) => s.name).join(", ")} 47 + </p> 48 + )} 49 + <h2 className="text-headline-sm text-on-surface mb-3"> 50 + {talk.title} 51 + </h2> 52 + <div className="flex flex-wrap gap-2"> 53 + {talk.room && <Chip>{talk.room}</Chip>} 54 + <Chip>{formatDuration(talk.durationMs)}</Chip> 55 + </div> 56 + </div> 57 + </LumeCard> 58 + </Link> 59 + ))} 60 + </div> 61 + ); 62 + }
+51 -3
src/components/ui/lume-card.tsx
··· 1 1 import { type HTMLAttributes } from "react"; 2 + import type { TalkScore } from "@/lib/scoring"; 2 3 3 4 interface LumeCardProps extends HTMLAttributes<HTMLDivElement> { 4 5 /** Understory score 0-1. Higher = more undiscovered = brighter glow. */ ··· 7 8 tileIndex?: number; 8 9 /** Whether to show the interest match indicator. */ 9 10 interestMatch?: boolean; 11 + /** Score data for hover/tap detail strip. null = no detail. */ 12 + score?: TalkScore | null; 10 13 } 11 14 12 15 function glowStyle(intensity: number): string { ··· 15 18 return ""; 16 19 } 17 20 21 + function ScoreDetail({ score }: { score: TalkScore }) { 22 + if (score.state === "unknown") return null; 23 + 24 + if (score.state === "missed") { 25 + return ( 26 + <div className="text-label-sm text-primary-fixed"> 27 + Your network missed this 28 + </div> 29 + ); 30 + } 31 + 32 + // engaged — show percentage 33 + const pct = Math.min(99, Math.round(score.layer1.attentionInverse * 100)); 34 + return ( 35 + <div className="text-label-sm text-on-surface-variant"> 36 + {pct}% of your network missed this 37 + </div> 38 + ); 39 + } 40 + 18 41 function LumeCard({ 19 42 glowIntensity = 0, 20 43 tileIndex, 21 44 interestMatch = false, 45 + score, 22 46 className = "", 23 47 children, 24 48 ...props 25 49 }: LumeCardProps) { 26 50 const isUnderstory = glowIntensity > 0.3; 51 + const hasDetail = score && score.state !== "unknown"; 27 52 28 53 return ( 29 54 <div 30 55 className={[ 31 - "relative rounded-lg", 56 + "group relative rounded-lg", 32 57 "bg-surface-container-low/60 backdrop-blur-[20px]", 33 - "border-t-2 border-primary-fixed-dim", 34 - "transition-shadow duration-200", 58 + "border-t-2", 59 + glowIntensity > 0.3 60 + ? "border-primary-fixed-dim" 61 + : glowIntensity > 0 62 + ? "border-primary-fixed-dim/50" 63 + : "border-primary-fixed-dim/20", 64 + "transition-all duration-500", 35 65 "hover:biolume-glow-strong", 36 66 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-fixed", 37 67 glowStyle(glowIntensity), ··· 52 82 /> 53 83 )} 54 84 {children} 85 + 86 + {hasDetail && ( 87 + <div 88 + className={[ 89 + "px-5 pb-3 pt-0", 90 + // Mobile: always visible (no hover capability) 91 + "max-h-12 opacity-100", 92 + // Desktop (sm+): hidden by default, revealed on hover via group-hover 93 + "sm:max-h-0 sm:overflow-hidden sm:opacity-0", 94 + "sm:transition-all sm:duration-300", 95 + "sm:group-hover:max-h-12 sm:group-hover:opacity-100", 96 + ].join(" ")} 97 + > 98 + <div className="border-t border-primary-fixed-dim/20 pt-2"> 99 + <ScoreDetail score={score} /> 100 + </div> 101 + </div> 102 + )} 55 103 </div> 56 104 ); 57 105 }
+86
src/hooks/useCrawlData.ts
··· 1 + "use client"; 2 + 3 + import { useState, useEffect } from "react"; 4 + import type { TalkMentions } from "@/lib/scoring"; 5 + 6 + export interface CrawlData { 7 + mentions: TalkMentions | null; 8 + followCount: number; 9 + loading: boolean; 10 + error: string | null; 11 + } 12 + 13 + /** 14 + * Fetches crawl data from `/api/crawl` on mount. 15 + * 16 + * - If authenticated: returns `{ mentions, followCount }` from the crawler. 17 + * - If not authenticated (401): returns `mentions: null` — not an error. 18 + * - If the crawl fails or times out: returns `error` string. 19 + * 20 + * The hook fires one fetch on mount and does not retry. The crawl endpoint 21 + * has its own caching (30-minute TTL) and concurrent-request coalescing. 22 + */ 23 + export function useCrawlData(): CrawlData { 24 + const [data, setData] = useState<CrawlData>({ 25 + mentions: null, 26 + followCount: 0, 27 + loading: true, 28 + error: null, 29 + }); 30 + 31 + useEffect(() => { 32 + let cancelled = false; 33 + 34 + async function fetchCrawl() { 35 + try { 36 + const res = await fetch("/api/crawl"); 37 + if (!res.ok) { 38 + if (!cancelled) { 39 + if (res.status === 401 || res.status === 504) { 40 + // Not authenticated or timeout — treat as "no data" 41 + setData({ 42 + mentions: null, 43 + followCount: 0, 44 + loading: false, 45 + error: null, 46 + }); 47 + } else { 48 + setData({ 49 + mentions: null, 50 + followCount: 0, 51 + loading: false, 52 + error: `Crawl failed: ${res.status} ${res.statusText}`, 53 + }); 54 + } 55 + } 56 + return; 57 + } 58 + const json = await res.json(); 59 + if (!cancelled) { 60 + setData({ 61 + mentions: json.talkMentions, 62 + followCount: json.followCount, 63 + loading: false, 64 + error: null, 65 + }); 66 + } 67 + } catch (err) { 68 + if (!cancelled) { 69 + setData({ 70 + mentions: null, 71 + followCount: 0, 72 + loading: false, 73 + error: err instanceof Error ? err.message : "Crawl failed", 74 + }); 75 + } 76 + } 77 + } 78 + 79 + fetchCrawl(); 80 + return () => { 81 + cancelled = true; 82 + }; 83 + }, []); 84 + 85 + return data; 86 + }