Skip to content

feat(session): Add session detail page#256

Draft
Amund211 wants to merge 7 commits into
mainfrom
session-detail-page
Draft

feat(session): Add session detail page#256
Amund211 wants to merge 7 commits into
mainfrom
session-detail-page

Conversation

@Amund211

@Amund211 Amund211 commented May 14, 2026

Copy link
Copy Markdown
Owner

Summary

A shareable, read-only session detail page at /session/$uuid/detail?date=<ISO>, plus the stats-layer groundwork it needs. It loads a single session from the new flashlight POST /v1/session-at endpoint and renders a full breakdown of that one session, adapted from the design handoff (design/project/Current Session.html) but mapped onto MUI while keeping the design's accent colours / rainbow ribbon.

What's on the page

  • Player banner — face avatar, prestige star chip, live/ended badge, started/ended/duration, games count, Share button.
  • Tags row — auto-derived from the aggregate: Flawless, Perfect run, On fire, Marathon.
  • KPI row — Games (W/L), Win rate (Δ vs lifetime), Session FKDR (Δ vs lifetime), Stars gained.
  • Momentum strip — a tile per game with finals, mode dot, minutes, and a W/L/D badge; click to expand a Finals/Beds/Kills/XP detail row with status/accolade chips (Perfect game / Carried / Clutch, Survived / Final death, Bed kept / Bed lost). A trailing-streak indicator sits in the header.
  • FKDR trajectory — Recharts line: cumulative session FKDR vs a live lifetime baseline after each game.
  • Totals — started/ended/avg game, finals, beds, XP.
  • Mode breakdown — solo/doubles/threes/fours/4v4 cards with games, wins, a win-rate bar, and FKDR; click to expand. The 4v4 card and the "Other" bucket (Dreams modes folded into overall) render only when games were played in them.
  • Milestones — next stars / next wins / next FKDR, each with "X sessions like this" and projected playtime.
  • Highlights — best game, fastest win, session-vs-lifetime FKDR (beating / on par / below), and pace (fk/hr · games/hr).

Data model & derivation

  • src/queries/sessionAt.ts — typed client for POST /v1/session-at, returning { session, games }. Each entry in games is a GameSegment { start, end, game } where game is null for a no-game window or an unsplittable multi-game gap. Unknown gamemodes/outcomes are Sentry-reported and dropped.
  • src/helpers/sessionDetail.ts — pure derivation (all unit-tested): the session aggregate, per-mode breakdown (including the synthetic "Other" bucket = overall − core modes), the FKDR trajectory, milestones, best/fastest game, and the trailing streak. Per-game data is derived on the client from the consecutive history[] snapshots — the gamemode whose gamesPlayed moved names the game and its stat deltas become the per-game stats.

Stats-layer groundwork (enables the page)

  • Split the canonical gamemode list in src/stats/keys.ts into REAL_GAMEMODE_KEYS (solo/doubles/threes/fours/4v4), with ALL_GAMEMODE_KEYS = [...REAL_GAMEMODE_KEYS, "overall"], so the page's mode list has a single source of truth (GAMEMODES reuses it) and adding a mode is a one-line change.
  • nextCloseMilestone progression strategy — tighter targets than the regular page (next 10 stars, next 0.5 for ratios, next "half-decade" for counters) so the page's goals feel a session or two out. Milestones reuse computeStatProgression, so targets and ETAs stay in agreement with the regular session page.
  • The FKDR trajectory and session aggregate route their ratio stats through computeStat rather than re-implementing ratio math.

Backend contract (matches the flashlight PR)

POST /v1/session-at
{ "uuid": "...", "time": "<ISO>" }

-> {
     "session": { "start": PIT, "end": PIT, "consecutive": bool, "ongoing": bool } | null,
     "games":   [ { "start": PIT, "end": PIT, "game": {...} | null }, ... ]
   }

session === null renders a "No session yet" panel — the empty / 404-ish state for a player who hasn't played a game with Prism running.

URL handling

  • Route /session/$uuid/detail with a date search param (Zod z.coerce.date()), following the existing routes' validateSearch pattern so we don't have to URL-encode colons/Z in the path.
  • On the first successful response an effect rewrites date to the session's start.queriedAt via navigate({ replace: true }), pre-seeding the cache for the canonical key so there's no refetch. After that the URL is canonical and safe to share.
  • A missing or malformed date is coerced to a safe default (degrades to the "No session yet" view) rather than tripping the router's error boundary.
  • Share copies globalThis.location.href and shows a "Link copied!" Snackbar (MUI Alert).

Adjacent changes

  • Sessions list link — each non-extrapolated row gets a magnifying-glass link to its detail page. Extrapolated rows have no real session start time on the backend (they'd 404), so they render as plain text.
  • Layout.tsx — the new /session/$uuid_/detail route is added to useShownPlayer so the shown-player context resolves on the page.

Choices & assumptions

  • Search param, not path segment, matching the existing session/history routes.
  • Per-game data is derived on the client from consecutive snapshots; a missed snapshot collapses two games into one or surfaces a labelled gap — accepted over a much chattier backend response.
  • Lifetime FKDR in the trajectory updates as the session progresses (session vs all-time after each game), matching the design intent rather than a static baseline. The KPI-row "Δ vs lifetime" instead compares against the pre-session lifetime (lifetime at session.start).
  • FKDR trajectory numbering — points are numbered by running game count: a real game is G{n}, a multi-game gap spans its range (G5-7), and a no-game window is dropped (it moves no finals) rather than drawn as a flat, mislabelled point.
  • MUI primitives over a new design system; MUI Snackbar/Alert instead of adding a toast library.

Tests

  • pnpm tsc, pnpm oxlint:check, pnpm oxfmt:check clean.
  • UnitsessionDetail (aggregate, modeBreakdown incl. the "Other" bucket, fkdrTrajectory numbering/labels, computeMilestones incl. blocked/off-pace branches, best/fastest game, trailing streak, inferredGameCount), detailSearch, and nextCloseMilestone (+ strict-monotonic and closer-than-natural properties).
  • UI (MSW) — the detail page renders the banner/KPIs/section cards, expands a game tile, normalizes the URL to the session start, and shows the no-session empty state; a mocked POST /v1/session-at handler was added to the shared handlers.

Manual verification (staging)

  • Real player with sessions today.
  • "No session yet" empty state for a long-idle player.
  • Share button copies the canonical (post-normalization) URL.

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 16, 2026

Copy link
Copy Markdown

Deploying rainbow with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3db89b7
Status: ✅  Deploy successful!
Preview URL: https://1afb7729.rainbow-ctx.pages.dev
Branch Preview URL: https://session-detail-page.rainbow-ctx.pages.dev

View logs

Amund211 and others added 5 commits July 11, 2026 14:32
A per-session permalink view at /session/$uuid/detail?date=<ISO>. Loads a
single session from the flashlight POST /v1/session-at endpoint and renders
the Current Session layout from the design handoff: player banner with star
chip and LIVE/ENDED badge, auto-derived tags, KPI row, game-by-game momentum
strip (with clutch/carried/perfect-game badges and a focusable per-game
detail), an inline FKDR trajectory chart, totals card, per-mode breakdown,
milestones and highlights. Each non-extrapolated row in the existing sessions
list now links here via a magnifying-glass icon.

The URL date is normalised to the session's start time on first load
(navigate replace, with the cache pre-seeded) so the link is canonical and
shareable; the Share button copies it and shows a snackbar. session === null
renders a "no session yet" empty state.

All colours come from the MUI theme rather than a bespoke palette: gamemode
identity colours for mode dots/bars, the rainbow accent for the banner ribbon
and the heater tag, and semantic tokens (success/error/warning/info/secondary)
for everything else — no hand-rolled hex. The trajectory chart reads its
strokes/gridlines from the theme like the history charts do.

Rebased onto main: the session `ongoing` flag and the `Box` bottom-padding
layout change have landed separately, so neither is reintroduced here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "perfect" session badge fired on `losses === 0`, which treats a draw
as a win. A session with a draw and no losses would show the badge and its
"Won every game this session" tooltip even though not every game was won.

Check `wins === games` instead so the badge matches its claim. `flawless`
keeps using `losses === 0` — its tooltip only promises "no losses", which a
draw doesn't break.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MomentumStrip keeps its place in the component tree when the user navigates
to a different session, so its `focused` tile index carried over. With a new,
longer segment set that stale index pointed the expanded panel at the wrong
game (or a shorter set silently hid it).

Track the segment identity and clear `focused` whenever it changes, so the
expanded panel always matches the session on screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A gap segment can cover a single game (e.g. one Dreams-mode game that
can't be split into a core mode), which rendered as "1 games" in the tile
label, the tile tooltip and the expanded detail context.

Singularize each of these when the count is one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`repeat(${segments.length}, …)` produces the invalid `repeat(0, …)` when a
session has no game segments. The call site currently guards on games, but
clamp the count to at least one so the grid template stays valid regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Amund211 Amund211 force-pushed the session-detail-page branch from f97195a to fb00a1a Compare July 11, 2026 12:33
Amund211 and others added 2 commits July 11, 2026 15:15
Assign the mocked `POST /v1/session-at` response to a typed
`APISessionAtResponse` variable before returning it, so TypeScript
structurally checks the whole literal — including each inline `game`
object — against the real wire contract instead of letting the mock
drift from the client's `APIGameResult`/`APIGameSegment` shapes.

Export `APISessionAtResponse` so the mock can name it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant