Skip to content

Latest commit

 

History

History
518 lines (380 loc) · 38.8 KB

File metadata and controls

518 lines (380 loc) · 38.8 KB

My Activity v2 — Live, Explainable, Production-Shaped (12-Hour Execution Plan)

Companion to:

Primary target: Turn the shipped v1 demo into a small-but-real rewards platform — event-sourced state, partner webhooks, SSE fan-out, and a Claude-powered investigation agent that can answer "why is this offer stuck?" with streamed reasoning and tool calls. One 12-hour grind. Same deploy URL.

Stretch: Optimistic mutations + conflict reconciliation, a live-ops console with p50/p95 + Web Vitals, and a time-travel scrubber that replays the event log.

What I am NOT doing: re-theming, re-scoping the four-status model, rewriting v1 cards, or leaving the tRPC + snake_case contract. v1 is load-bearing — v2 sits on top of it.


0. The Deal

  • ~12 hours, solo. This plan gets there with a ~45 min buffer.
  • v1 is already shipped and deployed. v2 is additive — nothing in v1 is rewritten.
  • Every phase ships a demoable moment. Even if the clock bites at phase 5, the build is still ahead of any "polish the existing demo" plan.
  • One PR per pillar, four PRs total: (1) event log + SSE, (2) webhook simulator + motion, (3) Investigation Agent, (4) CI + polish + demo.
  • This plan is the one I'd show the hiring panel. It's not trying to be exhaustive — it's trying to be the most Super-shaped thing I could build in a day.

1. Why this plan specifically (the four pillars)

The v1 demo is a static snapshot of a rewards UX. Every additive-scope item in the v1 plan is real but incremental: one more test, one more token, one more fixture file. None of them change the shape of the demo.

A rewards platform has four real-world properties that the v1 demo only hints at. Ship all four and the demo stops being a mock and starts reading like a reference architecture:

Pillar Real-world property it mimics What the v1 demo does today
Event-sourced ledger Every offer state change (from partner callback, user action, ops override) is an auditable event. Projections are rebuildable. Fixture resets on cold start; mutations overwrite state in place.
Partner webhook → SSE fan-out Attribution confirmations land as partner webhooks; users see cards update without reloading. Client-triggered refetch only; no push channel.
AI Investigation Agent Ops, support, and users all want "why is this stuck?" answered in natural language. Future of rewards support. Static copy on Needs Attention cards.
Live-ops console A rewards team lives in a dashboard: latency, error rates, in-flight events. No observability surface at all.

Each pillar is individually impressive. Together they form the exact shape of the backend Super actually runs — attribution, fraud, support, ops. Finishing all four in a day is the signal.


2. Stack additions (locked)

Additions to the v1 stack in CLAUDE.md §3. Everything else stays put.

Addition Choice Why
Real-time transport Server-Sent Events over a Next.js App Router route handler One-way server→client push; works cleanly on Vercel Functions (Fluid Compute). No socket infra, no protocol dance. Already called out as the v2 path in CLAUDE.md §4.5.
Pub/sub (server) In-process EventTarget or mitt-style emitter Lives next to the in-memory store. No external broker. Mirrors the demo's intentional single-process boundary.
Tab coordination (client) BroadcastChannel Multi-tab sync from a single SSE connection. One tab is the leader, others subscribe.
LLM @anthropic-ai/sdk with prompt caching + extended thinking + tool use + streaming The Investigation Agent is the centerpiece. Anthropic SDK is production-grade, supports every primitive I want to demo.
Model claude-sonnet-4-6 (streaming + extended thinking) Supports extended thinking and streaming; ~5x cheaper than Opus 4.7, sufficient for this use case. Upgrade to claude-opus-4-7 locally for maximum reasoning quality — both work identically in the UI.
Motion Framer Motion (motion v11) Variants on state change sell "this just happened" in ≤300ms. Nothing else in the repo needs this.
Metrics web-vitals (v4) + an in-memory latency histogram Client-side LCP/INP/CLS piped to the console panel; server-side timing via performance.now() around every procedure. No vendor beacon — honest and self-contained.
CI GitHub Actions: typecheck → test → build → playwright Gate every PR. Green CI badge is part of the demo.
Env ANTHROPIC_API_KEY required for Investigation Agent; demo fallback mode if absent Fail closed, not open. The fallback is a pre-recorded streaming response so the demo never dies.

Explicitly not adding:

  • WebSockets (SSE is the right primitive for one-way push; WS adds protocol cost we don't need).
  • A database (the in-memory event log is deliberate, per CLAUDE.md §4.6).
  • A real queue (Vercel Queues is the right production answer; scripted HTTP is fine for a 12-hour demo).
  • State management libraries (React Query + EventTarget is all the state we need).

3. What I'm NOT building (and why)

Every one of these is defensible work. None of them land the "holy shit" moment in 12 hours.

Cut Why it's safe
Real Amplitude wire-up + flag gating The console-log stub already carries the right event shape. Replacing it buys zero demo minutes.
Storybook + per-variant stories Scenario switcher on the real page is still a better review tool for a single engineer.
Design token export + /theme-preview route MUI theme is the spec. One consumer; no second caller.
Separate tRPC routers for rewardsArbiter.* / credits.* / user.* The demo is on deals.*. Every other router observed in the HAR is scope creep.
Full-fidelity auth (JWT, cookies, Ada JWT mint) Out of scope by design (CLAUDE.md §5). Public procedure still fine.
Real Neon / Upstash integration Adds infra setup, loses the "one pnpm install, everything works" property. Call it out in the README as the deliberate next step.
Dark mode Zero demo value in a daylight pitch; 90 min of fiddling.
i18n / localized relative timestamps en-US is enough; noted as additive scope.

4. The 12-hour sequence

Ten phases. Seven are must-ship (the core pillars). Three are stretch (drop in order if the clock bites). Every phase names what "done" looks like so you can cut early without drift.

Must-ship:  P1 → P2 → P3 → P4 → P5 → P6 → P7       (≈9h, with 45min buffer)
Stretch:             P8 → P9 → P10                 (+≈3h if pace holds)

Phase 1 — Event-sourced ledger (60 min)

Why it's first. Everything else (SSE, webhooks, the AI agent, time travel) reads the event log. Ship the spine before the limbs.

Goal. Every state change — create, update, delete, webhook-driven re-verification, ops override — is an append-only ActivityEvent. The current deals.getActivityOfferPlacements response becomes a projection of the event log. Existing v1 tests still pass.

Deliverables:

  • src/server/events/types.ts — discriminated union ActivityEvent:
    type ActivityEvent =
      | { kind: 'offer_created'; at: string; actor: Actor; offer_id: string; payload: OfferSnapshot }
      | { kind: 'offer_updated'; at: string; actor: Actor; offer_id: string; patch: OfferPatch }
      | { kind: 'offer_deleted'; at: string; actor: Actor; offer_id: string }
      | { kind: 'activity_status_changed'; at: string; actor: Actor; offer_id: string;
          from: string | null; to: string | null; reason: string | null }
      | { kind: 'partner_webhook_received'; at: string; actor: Actor; offer_id: string;
          event_name: string; idempotency_key: string }
      | { kind: 'event_milestone_completed'; at: string; actor: Actor; offer_id: string;
          event_id: string; payout: string | null };
    type Actor = { kind: 'user'; id: string } | { kind: 'partner'; slug: string } | { kind: 'ops'; id: string } | { kind: 'system' };
  • src/server/events/log.ts — in-memory append-only array keyed by offer_id, plus a monotonically increasing seq. Two exports: appendEvent, getEventsSince(seq). Both are pure functions of the module-level ref. __resetActivityEventLog() for tests.
  • src/server/events/projection.tsprojectOfferFromEvents(events): OfferSnapshot | null. Deterministic fold over the event stream; returns null if the offer was deleted. Covered by 6–8 Vitest cases.
  • deals.ts refactor — every mutation writes an event, then rebuilds the projection; read path still returns the same envelope. __resetActivityStore() seeds the event log from fixtures rather than cloning a record. Existing deals.test.ts stays green without change.
  • New query: deals.getActivityEvents with { since_seq: number } — returns { events: ActivityEvent[]; latest_seq: number }. Used by time-travel (Phase 10) and the Investigation Agent (Phase 5).

Skip: persistence, snapshots every-N-events, CDC-style compaction, Kafka semantics. In-memory and deterministic is the point.

Done when:

  • pnpm test green; all v1 tests untouched.
  • deals.getActivityEvents returns a seeded event per fixture offer (an offer_created event per scenario entry).
  • Creating an offer via createActivityOfferPlacement appends one offer_created event; updating appends one offer_updated; deleting appends one offer_deleted. Read path is unchanged.

Phase 2 — SSE broadcast layer (75 min)

Why it's second. The event log is useless until the client can subscribe to it. Ship the transport now so every subsequent phase can push events for free.

Goal. The browser subscribes once to /api/events/activity, receives ActivityEvents in real time, and the deals.getActivityOfferPlacements React Query cache updates without a refetch. Multi-tab sync works via BroadcastChannel. Connection is self-healing.

Deliverables:

  • src/server/events/broadcaster.ts — tiny EventTarget-backed pub/sub: broadcast(event), subscribe(handler): unsubscribe. Wrap appendEvent so every mutation fans out automatically.
  • app/api/events/activity/route.ts — SSE endpoint:
    • Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no.
    • Sends a retry: 3000 directive + an initial event: snapshot with latest_seq.
    • Respects Last-Event-ID header — replays missed events from the log on reconnect.
    • 30-second heartbeat (comment :hb frames) so proxies don't kill the connection.
    • Cleans up subscribers on request.signal.aborted (Fluid Compute graceful shutdown).
  • src/lib/realtime/useActivitySubscription.ts — React hook:
    • Opens an EventSource lazily on mount; closes on unmount.
    • Exponential backoff on onerror (1s → 2s → 5s → 10s, capped).
    • Elects a leader tab via BroadcastChannel('activity') + localStorage lock. Only the leader holds the SSE; followers receive events over the channel.
    • Exposes { status: 'connecting' | 'open' | 'reconnecting' | 'offline', lastEventAt, eventsReceived }.
  • src/lib/realtime/applyEvent.ts — small reducer that takes an ActivityEvent and mutates the React Query cache via queryClient.setQueryData(['deals.getActivityOfferPlacements', ...], updater). No refetch, no round trip.
  • MyActivityPage wires the hook. Header gains a subtle connection pill — green dot + "Live" when open, amber + "Reconnecting…" on backoff, grey + "Offline — last update 12s ago" when the browser reports !navigator.onLine. No fake "Live" claims: the pill's text is always literal (§4.5 of CLAUDE.md).

Skip: actual distributed leader election, a serialized resume-token protocol beyond Last-Event-ID, multi-region fan-out. Single process is the contract.

Done when:

  • Open two tabs on ?scenario=mixed. Call createActivityOfferPlacement via a curl in a third terminal. Both tabs show the new card within 1s, without a network refetch.
  • Kill the dev server, wait 3s, restart. The pill goes amber then green; a createActivityOfferPlacement fired while down arrives after reconnect via Last-Event-ID replay.
  • Only one tab holds an open EventSource at a time — verifiable via DevTools Network.

Phase 3 — Partner webhook simulator (60 min)

Why it's third. SSE is push without a reason to push. Webhooks are the reason. This phase turns the demo into a two-actor system: "something happened at the partner → the user sees it."

Goal. A partner sends a webhook → the server verifies + applies it → the event log grows → SSE fans out → cards animate to their new state. A demo-only admin panel lets you trigger any of the canonical partner events on any offer, live.

Deliverables:

  • app/api/webhooks/partner/route.ts — POST endpoint accepting:
    {
      "partner_slug": "gokart",
      "event_name": "milestone_completed",       // or "attribution_verified" | "attribution_failed" | "reward_paid"
      "offer_id": "...",
      "event_id": "...",                         // for milestone_completed, the events[].id
      "idempotency_key": "evt_ab12cd34",
      "signature": "sha256=..."                  // HMAC of body with a per-partner secret from env
    }
    • Signature verification: HMAC-SHA256, constant-time compare. Secret from PARTNER_HMAC_SECRET env var (a demo default is checked in).
    • Idempotency: dedupes on (partner_slug, idempotency_key), scoped in-memory. Duplicate POST is a 200 no-op, never a 500.
    • Returns 202 with { event_seq: number } so the partner could reconcile.
  • src/server/webhooks/translate.ts — maps partner events to ActivityEvents:
    • attribution_verifiedactivity_status_changed from RETRY or INITIATED to IN_PROGRESS
    • milestone_completedevent_milestone_completed + a derived offer_updated patch marking the event complete
    • attribution_failedactivity_status_changed to RETRY
    • reward_paidactivity_status_changed to COMPLETED
  • src/features/earn/activity/admin/PartnerSimulator.tsx — demo-only floating control (gated behind ?admin=1):
    • Pick an offer from the current scenario.
    • Pick a partner event (dropdown).
    • "Fire" button posts to /api/webhooks/partner with a correct signature.
    • History log of fired events in-panel, so you can demo a sequence.
  • tRPC: deals.simulatePartnerWebhook (mutation) for Playwright to drive without posting raw HTTP.

Skip: persistent idempotency store, real HMAC secret rotation, replay-attack prevention beyond idempotency, a per-partner allow-list. Scripted demo, not pentest.

Done when:

  • ?scenario=needs-attention&admin=1 → fire attribution_verified on a RETRY offer → the card morphs from Needs Attention to In Progress within 500ms, progress bar animates in.
  • Firing the same idempotency_key twice emits exactly one event in the log.
  • Invalid signature → 401, no event written.

Phase 4 — Live card motion (45 min)

Why it's fourth. SSE + webhooks already update state. Motion turns state change into perceived causality — the user sees something happen. No motion and the cards feel like they're merely refreshing.

Goal. Status transitions animate. Progress bars tween. "Last updated" ticks visibly. The Live pill pulses briefly on each received event. Nothing janky, nothing distracting.

Deliverables:

  • motion v11 installed. One AnimatePresence at the OfferActivityCard root; layout on the status pill to auto-morph between variants.
  • StatusPill becomes a motion.span with spring transitions on color + text.
  • MilestoneProgressLinearProgress wrapped with a tween from old percent to new on data change. 400ms ease-out.
  • RelativeTimestamp — a single shared 1s tick (Phase 2 already centralizes the refresh interval); text fades in/out on cross-bracket changes ("Just now" → "1 min ago").
  • Header connection pill — on each received event, a subtle background pulse (150ms, via controls.start + reset). Never more than 4 Hz even under load.
  • prefers-reduced-motion respected globally — all transitions become instant.

Skip: card flip animations, entry stagger, confetti on reward_paid. Tasteful > flashy.

Done when:

  • Firing attribution_verified on a Needs Attention card: the pill morphs amber → green, the body content cross-fades, the progress bar tweens from 0 to 10% — all within 500ms, smooth on 60Hz.
  • prefers-reduced-motion: reduce in DevTools → every transition collapses to instant.

Phase 5 — The Investigation Agent (180 min)

Why it's fifth. This is the centerpiece. Everything before this phase exists so this phase has something real to investigate. Every rewards/fintech platform is going to build this in the next 18 months; ship it in 180 minutes.

Goal. Clicking "Why is this stuck?" on a Needs Attention card opens a drawer. An agent powered by claude-sonnet-4-6 streams its reasoning (extended thinking), calls tools to query the event log, the partner attribution status, and a user history, and produces a human-readable explanation plus a recommended next step. The final answer is stored against the offer's event log as an investigation_completed event, so the drawer is re-openable without re-running the model.

Deliverables:

Server:

  • src/server/ai/anthropic.ts — lazy singleton Anthropic client. Reads ANTHROPIC_API_KEY; in its absence, switches to a demoMode client that streams a pre-recorded response (stored as JSONL). The demo script uses real mode; the fallback means the preview URL never errors even if the key expires.
  • src/server/ai/tools.ts — three tool definitions:
    queryOfferEvents({ offer_id, since_iso? })  ActivityEvent[]
    queryPartnerStatus({ partner_slug, offer_id })  { attribution_last_checked, last_known_state, known_issues[] }
    queryUserOfferHistory({ user_id, partner_slug })  { total_offers, completion_rate, typical_time_to_verify_minutes }
    Each tool has a Zod input schema + a pure server function. The partner and user history are scripted — they return deterministic synthetic data from the fixture so the demo is repeatable.
  • src/server/ai/system-prompt.ts — system prompt with prompt caching breakpoint (stable block). Sets role, tone (calm, specific, no apologies), output contract (markdown with a final ## Recommended next step section).
  • New tRPC procedure deals.investigateOffer — uses trpc-subscriptions-style or a custom SSE route under the same endpoint (/api/events/investigation/[offer_id]). Streams three event kinds to the client:
    • thinking — extended-thinking deltas (stream.on('thinking', ...))
    • tool_use — the name + args of each tool call, then the result
    • text — final answer deltas
    • done — final investigation record (stored as investigation_completed in the event log)
  • Server uses max_tokens: 1024, thinking: { type: 'enabled', budget_tokens: 2000 }, prompt caching on the system prompt + tool definitions, streaming = true. Covers all four headline features of the Claude API at once.

Client:

  • src/features/earn/activity/components/InvestigationDrawer.tsx — MUI Drawer anchored right, 520px wide:
    • Header: offer icon, title, status pill.
    • Reasoning panel (collapsed by default, expands while thinking streams): scrollable monospace list of tool calls + thinking tokens. Every tool call renders as a card: queryOfferEvents(offer_id="…", since_iso="…") → JSON result pretty-printed. Thinking blocks appear as italic grey text.
    • Answer panel: rendered markdown, streaming in.
    • Footer: "Recommended next step" pull-quote card with an action button (e.g. Re-check attribution → fires a partner attribution_verified webhook for the demo).
  • NeedsAttentionCard gains a secondary link Why is this stuck? (primary stays Get support). Clicking opens the drawer.
  • Drawer streaming UI uses Framer Motion AnimatePresence on the message list; new tool calls slide in.
  • Investigation results are memoized per offer_id + latest_seq — re-opening the drawer on unchanged state rehydrates from the event log; changed state prompts a "Re-investigate" button.

Demo seams:

  • The three tools are scripted. For the demo, queryPartnerStatus returns { known_issues: ['ios_attribution_lag_24h'] } for one specific offer, so the agent reliably reasons about it. The other offers return clean data and the agent handles that too.
  • Concurrency-safe: if two tabs click Investigate at the same time, the server dedupes on offer_id + latest_seq and streams to both.

Skip: retrieval over a doc store, embeddings, multi-agent orchestration, function-calling with arbitrary user prompts. This is a specific tool with a specific goal.

Done when:

  • Clicking "Why is this stuck?" on the seeded Needs Attention offer → drawer opens → thinking streams ("The offer is in RETRY state with created_at 2h 13m ago…") → queryOfferEvents is called visibly with correct args → queryPartnerStatus returns the iOS lag → final answer appears, paragraph-by-paragraph, naming the partner issue and recommending a re-check → action button fires the webhook → drawer closes → card morphs to In Progress.
  • Deleting ANTHROPIC_API_KEY from the env → the feature still works end-to-end in demoMode with a recorded streaming response.
  • Re-opening the drawer on an unchanged offer rehydrates instantly (no API call).
  • All streaming survives a tab blur/focus cycle.

This is the demo beat the hiring panel will remember. Three Claude API features (extended thinking, tool use, streaming) stacked on top of the event log and the webhook system, all wired together in a UX that would be at home on super.com tomorrow.


Phase 6 — CI/CD with Playwright gates (45 min)

Why it's sixth. Green CI on the PR is part of the pitch.

Goal. Every PR runs typecheck → vitest → next build → playwright. Main-branch builds deploy to Vercel preview automatically (already live; just confirm). A new Playwright spec exercises the Investigation Agent with the SSE flow end-to-end in demoMode.

Deliverables:

  • .github/workflows/ci.yml — single job on push + pull_request:
    • Node 20, pnpm 10 via corepack, cache ~/.pnpm-store
    • pnpm install --frozen-lockfile
    • pnpm typecheck
    • pnpm test
    • pnpm build
    • pnpm exec playwright install --with-deps chromium
    • pnpm exec playwright test
  • playwright/my-activity-v2.spec.ts — three new specs in addition to the v1 smokes:
    1. SSE: fire a partner webhook via trpc.deals.simulatePartnerWebhook, assert the card transitions within 2s without a full-page reload.
    2. Investigation: open the drawer on a Needs Attention offer in demoMode, assert the queryPartnerStatus tool call appears, assert the final answer contains the seeded issue id.
    3. Resume: start an SSE subscription, disconnect + reconnect the fetch mock, assert missed events replay via Last-Event-ID.
  • Playwright tests start pnpm dev with ANTHROPIC_API_KEY="" so CI always uses demoMode — deterministic and free.
  • README gains a CI badge + a 30-second "what CI runs" line.

Skip: visual-regression diffing, matrix OS tests, a separate lint job, GitHub Actions caching beyond pnpm. One gate, one machine.

Done when:

  • Open a PR, CI turns green in ≤4 minutes.
  • Deliberately break the event-log projection → CI fails with a readable assertion from the Playwright SSE spec. Revert, green again.

Phase 7 — Polish + Demo Script (60 min)

Why it's seventh. The difference between a working demo and a memorable one is ten minutes of polish and a script.

Goal. The preview URL is demoable cold to a stranger. A dedicated docs/demo-script.md walks through the four pillars as a seven-minute story. README has a "watch this" section pointing to the live URL with the exact scenario links in the right order.

Deliverables:

  • docs/demo-script.md:
    • Act 1: The baseline (60s). Open /earn/activity?scenario=mixed. Point out the four states, the "Live" pill, the freshness label. "Everything you saw in v1 still works."
    • Act 2: Partner push (90s). Open the admin panel. Fire attribution_verified on the Needs Attention card. The audience watches it morph live. "In production that POST comes from the partner. Here I'm faking it in front of you."
    • Act 3: Multi-tab sync (30s). Open a second tab. Fire another event. Both tabs update. "One SSE, many tabs, via BroadcastChannel. Follower tabs don't hold connections."
    • Act 4: Investigation (3m). Pick the iOS-lag seeded offer. Click "Why is this stuck?". Narrate the thinking as it streams, call out each tool call, read the final answer, click the action button, watch the card resolve.
    • Act 5: Under the hood (60s). Open the Live Ops console (if Phase 9 shipped) or DevTools Network to show the SSE connection. Point at the event log. "Everything you just saw is a linear replay of 11 events."
    • Act 6: What would change in prod (30s). Name the three things I'd swap: in-memory log → Postgres (or Vercel Queues); demo admin panel → real partner HMAC; scripted tools → real attribution-service clients. "Everything else is the same shape."
  • README.md updates:
    • New "v2" section with an animated GIF (or Loom) of the Investigation Agent streaming.
    • Scenario URLs listed in demo order.
    • One-line mention of each of the four pillars with a link to the relevant source file.
  • Motion pass: review Phase 4's timings with fresh eyes, trim any >500ms transition.
  • Error states: SSE offline → pill + a quiet banner "Working offline — changes will sync when you reconnect." Investigation demoMode → a small subtitle "(Running in demo mode — real investigations require ANTHROPIC_API_KEY.)"
  • Empty fixtures: ?scenario=rewarded already shows only Rewarded; confirm the connection pill and Investigation button don't appear when they'd be meaningless.

Skip: Loom, OG images, a custom domain, a marketing landing. The URL + the demo script is the deliverable.

Done when:

  • A stranger can open the preview URL + scroll docs/demo-script.md and reach the end in 7 minutes without a guide.
  • Each act's demo moment works on the first try, 5 times in a row.

Phase 8 — STRETCH: Optimistic + conflict reconciliation (75 min)

Goal. The existing createActivityOfferPlacement / updateActivityOfferPlacement / deleteActivityOfferPlacement mutations become optimistic. When a concurrent SSE event arrives with a conflicting change, the UI detects the conflict and surfaces a merge affordance.

Deliverables:

  • src/lib/realtime/optimistic.ts — thin wrapper over React Query's onMutate / onError that tags optimistic diffs with a client-side mutation_id.
  • applyEvent gains conflict detection: if an incoming offer_updated event touches a field currently optimistic on the client, mark it conflicted: true.
  • ConflictToast — MUI Snackbar, stacked: "Someone else updated this offer. [View diff] [Keep my change] [Use theirs]."
  • Playwright spec: two concurrent mutations from two tabs → exactly one toast in the losing tab.

Skip: CRDT, operational transforms, three-way merge. Last-writer-wins with a user-visible choice is enough.

Done when:

  • Two tabs, same offer. Tab A clicks the "Re-check" action; tab B fires a conflicting partner webhook. Tab A sees a toast within 1s. Clicking "Keep my change" re-submits; "Use theirs" discards.

Phase 9 — STRETCH: Live-ops console (90 min)

Goal. Cmd+Shift+D opens a floating panel at the bottom of the viewport with:

  1. SSE connection state: status, reconnect count, total events received, last event id/time.
  2. Per-procedure latency: in-memory histogram (count + p50 + p95) for getActivityOfferPlacements, createActivityOfferPlacement, updateActivityOfferPlacement, deleteActivityOfferPlacement, investigateOffer, getActivityEvents. Sampled from a client-side interceptor around every tRPC call.
  3. Web Vitals: LCP, INP, CLS — live values, coloured by the official thresholds.
  4. Event log tail: last 20 ActivityEvents with kind + actor + offer title.

Deliverables:

  • src/features/ops/LiveOpsConsole.tsx — MUI Drawer anchored bottom; four tabs; keyboard shortcut via a global effect.
  • src/lib/telemetry/histogram.ts — minimal in-memory TDigest-alike; returns { n, p50, p95 }.
  • tRPC link: createTRPCNext gets a custom link that wraps every call with a performance.now() pair and feeds the histogram.
  • src/lib/telemetry/webVitals.ts — mounts web-vitals observers, pushes values into a context.
  • Console collapses to a 40px dockbar when closed; remembers state in sessionStorage.

Skip: exporting to a real APM, multi-tab aggregation, sampling / sampling rates. One tab, right now, good enough.

Done when:

  • Cmd+Shift+D opens the console. Fire 5 partner webhooks in quick succession — the SSE tab shows 5 events arriving, the latency tab shows a histogram with visible p50/p95 bars, Web Vitals shows a stable LCP and an INP that spikes on the click.

Phase 10 — STRETCH: Time-travel scrubber (90 min)

Goal. A scrubber at the top of the Live-Ops Console lets you slide through the event log. The activity page re-renders the state at that point in time. A "Copy replay URL" button deep-links to that timestamp.

Deliverables:

  • src/features/ops/TimeTravelScrubber.tsx — MUI Slider bound to seq. Dragging pauses SSE application; releasing resumes from live.
  • deals.getActivityOfferPlacements gains an optional at_seq input. When present, it projects the state from the event log up to seq = at_seq rather than the latest.
  • React Query cache key includes at_seq so scrubbing is free on revisits.
  • ?at=<seq> URL param makes the scrubbed state shareable.

Skip: scrubbing into the future (obviously), editing events, branching timelines.

Done when:

  • Fire a sequence of webhooks. Open the scrubber. Slide back to before the webhook. Card states revert. "Copy replay URL" produces a URL that, opened in an incognito window, shows the same historical state.

5. Running time sheet

Phase Work Budget Cumulative
1 Event-sourced ledger 1.00h 1.00h
2 SSE broadcast layer + multi-tab 1.25h 2.25h
3 Partner webhook simulator 1.00h 3.25h
4 Live card motion 0.75h 4.00h
5 Investigation Agent (Claude) 3.00h 7.00h
6 CI/CD + new Playwright specs 0.75h 7.75h
7 Polish + demo script 1.00h 8.75h
Core buffer 0.25h 9.00h
8 Stretch: Optimistic + conflict 1.25h 10.25h
9 Stretch: Live-Ops Console 1.50h 11.75h
10 Stretch: Time-travel scrubber 1.50h 13.25h

The must-ship set fits in 9h with a 25 min core buffer. Stretch phases go in priority order: Live-Ops Console first (biggest wow-per-hour after Phase 5), then Time-travel, then Optimistic last (most fragile, easiest to ship a broken version of).

Expected landing zone: Phases 1–7 + Phase 9 = 10.5h, leaves 1.5h for demo rehearsal and preview-URL smoke testing. That's the target.


6. Hard off-ramps (when the clock bites)

Apply in order. Each recovers 45–90 min without collapsing the story.

  1. Hour 8, behind → Phase 5 drops extended thinking. Keep streaming + tool use; drop thinking: { enabled }. Saves ~20 min of UI + testing. The drawer still looks like magic.
  2. Hour 9, behind → Phase 5 drops the queryUserOfferHistory tool. Two tools is still "tool use." Saves ~25 min.
  3. Hour 10, behind → skip Phase 9 (Live-Ops Console) entirely. The demo carries itself without it. The connection pill in the header is enough "observability" for the pitch.
  4. Hour 11, behind → Phase 6 reduces to typecheck + vitest only. Drop the Playwright specs from CI; keep them local. Saves ~20 min of debugging GHA.
  5. Hour 11.5, behind → skip motion polish pass (tail of Phase 4). Motion still works; just not hand-tuned.
  6. Hour 11.75, behind → demo script becomes bullet points in README. Skip the standalone markdown file.

Do not cut in this order — these stay no matter what:

  • SSE + webhook → event → card transition (Phase 2 + 3 together)
  • Investigation Agent answering at least one offer in demoMode (Phase 5 core)
  • Green CI badge on the PR (Phase 6 reduced)
  • A README that tells a stranger what to click (Phase 7 reduced)

If any of those four are in doubt, stop new work and consolidate.


7. Definition of done (12h v2 demo)

  • Preview URL is green on a merged main branch. CI is green.
  • Opening ?scenario=mixed&admin=1 in two tabs + firing a partner webhook shows both cards animating to their new state within 1s, without a page reload.
  • Clicking "Why is this stuck?" on a seeded Needs Attention offer streams visible thinking, at least two tool calls with visible args + results, and a final markdown answer with a recommended next step.
  • Clicking the recommended-next-step button fires a real webhook and resolves the card.
  • Killing the server and bringing it back reconnects the SSE and replays missed events via Last-Event-ID.
  • Every event the UI shows is also visible in deals.getActivityEvents — the audit log is the source of truth.
  • pnpm test passes (all v1 tests + ≥12 new v2 tests). pnpm exec playwright test passes.
  • docs/demo-script.md exists and walks the four acts in order.
  • README has a v2 section linking Phase 2 / 3 / 5 source files.

If Phase 9 or 10 lands, their own "Done when" criteria apply as additive.


8. The demo (the "holy shit" script)

Seven minutes, one take. Every beat here is an acceptance criterion from the phases above.

0:00 — Land on /earn/activity?scenario=mixed. "This is v1. Four states, manual refresh."

0:30 — Point at the header. "See the green 'Live' pill? That's an SSE connection. One connection per browser, shared across tabs via BroadcastChannel."

1:00 — Open the admin panel (?admin=1). Fire attribution_verified on the Needs Attention card. The card morphs. The pill pulses. "No refresh, no refetch. That request went to /api/webhooks/partner — HMAC-signed, idempotent — became an event in the log, fanned out via SSE, and React Query applied it in place."

1:45 — Open a second tab with the same URL. "Both tabs are looking at the same log. Only one of them is holding the SSE connection."

2:15 — Click 'Why is this stuck?' on the seeded offer.

  • Drawer opens. "This is powered by claude-sonnet-4-6 with extended thinking, tool use, and streaming."
  • Thinking streams. "You're watching the model reason — that's thinking blocks."
  • queryOfferEvents tool call appears. "It's querying our event log. Same procedure you'd call from a production service."
  • queryPartnerStatus returns the iOS-lag issue. "The partner status check surfaces a known issue."
  • Final answer streams. Point at the "Recommended next step" card.

5:00 — Click the action button. The webhook fires; the card resolves to In Progress; the drawer closes. "End to end — AI recommendation, user action, partner webhook, event log, SSE fan-out, UI update."

5:30 — Open Live Ops console (Cmd+Shift+D). (If Phase 9 shipped.) "p50, p95, Web Vitals, every event that's crossed the wire. Nothing is sampled — this is every call I've made."

6:00 — Show docs/demo-script.md + the PR description. "Everything you saw is in four PRs, all green CI, 12 hours start to finish."

6:45 — The one-liner. "The shape is a miniature version of what a rewards platform actually runs. Swap the in-memory log for Postgres or Vercel Queues, swap the admin panel for real partner HMACs, and you're at production."


9. Risks & mitigations

Risk Mitigation
Anthropic API rate limits or key revocation mid-demo demoMode fallback with a pre-recorded streaming response. The demo never hits a dead end; the README flags the fallback honestly.
SSE disconnects under Fluid Compute cold starts or proxy idle timeouts 30s heartbeat + Last-Event-ID replay. Acceptance criterion in Phase 2 exercises a restart.
Event log grows unboundedly over a long demo Capped at 1,000 events per offer in-memory; older events are dropped with a warning log (not relevant to the demo, but keeps the server honest).
Investigation drawer feels slow because streaming is visibly token-by-token Extended thinking is budget-capped at 2000 tokens; final answer is capped at 1024. First visible token lands ≤2s in practice with claude-opus-4-7.
Optimistic mutations (Phase 8) introduce visible flicker on conflict Out of scope for core demo; Phase 8 is stretch. Core demo uses pessimistic mutations + SSE state.
Admin panel leaks into prod and a real user fires a webhook Panel is gated on ?admin=1 AND NODE_ENV !== 'production' OR an allowlisted IP. Belt + suspenders.
Playwright SSE spec is flaky on GHA Use trpc.deals.simulatePartnerWebhook (tRPC), not a raw fetch. Assert via locator.waitFor with a generous 5s timeout.
Scope creep into real analytics or real auth Every time it tempts you, note it as additive scope. The demo is the scope.

10. Why this plan reads as "holy shit, we should hire this guy" (and not as "one more polish PR")

Three signals the panel will pick up:

1. It takes the shape of the real platform. Event-sourced state + partner webhooks + real-time fan-out + AI explainability is literally the backend Super runs. The demo isn't a mock of the UI; it's a miniature of the system. In a day, by one person.

2. It combines four hard skills without dropping any. Distributed state (event log + replay), transport (SSE + backoff + multi-tab), LLM product thinking (tool use + streaming + extended thinking + prompt caching), and platform engineering (CI + e2e + structured telemetry). Any one of them is a full engineer's job. Shipping all four, wired together, on a deadline, is the signal that everyone on the panel is actually looking for.

3. It respects the constraints. No stack swaps. No new routers that weren't in the HAR. No vendor lock-in the team didn't choose. No "while I'm here" refactors of v1. It reads as someone who can ship into an unfamiliar codebase without blowing up the work that came before.

That's the pitch. Now go build it.


11. Companion docs


End of v2 execution plan.