Skip to content

feat(web): records widget — biggest-trade + longest-survival-streak#48

Merged
ntatschner merged 2 commits into
mainfrom
feat/records-widget-expansion
May 20, 2026
Merged

feat(web): records widget — biggest-trade + longest-survival-streak#48
ntatschner merged 2 commits into
mainfrom
feat/records-widget-expansion

Conversation

@ntatschner
Copy link
Copy Markdown
Collaborator

Plan-3b follow-up #2 — Records widget expansion (2 of 3 records added).

The original Records widget shipped with longest-session and busiest-
session-by-event-count and explicitly noted "More records (deadliest,
biggest trade, longest streak) ship in Plan 3b". This PR lands 2 of
those 3. Deadliest session is deferred — it needs per-session
death aggregation on the server, which doesn't exist yet.

What the user sees

Compact (single line)

Before: `Longest session 4h 12m · Busiest 23,418 events`

After: `Longest session 4h 12m · Survival 12d 6h`
(falls back to `Busiest …` when the user has < 2 deaths in the
recent window, so the original behaviour is preserved when survival
streak isn't computable)

Expanded (full list)

Before: 2 rows + a "more records ship in Plan 3b" note.

After: 4 rows + a more honest "Deadliest session pending — needs
per-session death aggregation on the server" footnote.

Record Source
Longest session `getSessions` (max `ended_at - started_at`)
Busiest session `getSessions` (max `event_count`)
Longest survival streak `listEvents({ event_type: 'player_death', limit: 500 })` — max gap between consecutive timestamps
Biggest trade `getCommerceRecent(token, 500)` — max `quantity` of any `status === 'confirmed'` row

Caveats documented in code

  • `fmtTimeSpan` switches to days+hours for spans ≥ 1 day. Without it,
    a 60-day survival streak would render as `1442h 30m` which is
    unreadable.
  • "Biggest trade" is by `quantity`, not aUEC, because
    CommerceTransactionDto doesn't expose a typed currency field.
    Comment in the file flags this.
  • 500-event limit on the deaths fetch — adequate for "longest gap"
    computation in any realistic player history. Server-side per-session
    death aggregation would be more accurate but requires backend work.

Invariants honoured

  • 3 parallel fetches via `Promise.allSettled` — a single endpoint
    hiccup doesn't blank the widget. Per-source rejections log with
    `call=widget.records.` for diagnostics.
  • Returns `null` when all 4 records are zero — WidgetFrame supplies
    the empty placeholder.
  • Owner-only via `isAvailable = () => ctx.isOwner`. Visitor widening
    follows the Plan-3b sharing-toggle work (separate PR).

Files

  • `apps/web/src/app/_components/widgets/records.tsx` — rewritten;
    imports `getCommerceRecent` + `listEvents` (already in api.ts).

Test plan

  • CI `Build & Test` workflow passes on ubuntu + windows
  • `pnpm --filter web typecheck` clean (verified locally — exit 0)
  • Manual smoke (owner, flag ON, widget enabled): 4 records
    visible in expanded view; compact view shows longest + survival
    (or fallback to busiest)
  • No-data fallback: widget returns `null` when the user has no
    sessions, no transactions, and no deaths

Notes

  • Draft because this is one of several follow-ups in flight.
  • No backend changes. Rule 1 small-task scope.
  • "Deadliest session" can become its own follow-up when there's
    appetite to add the server endpoint.

ntatschner pushed a commit that referenced this pull request May 20, 2026
PR #45 inverted the default of NEXT_PUBLIC_PROFILE_WIDGETS to ON,
and PR #44 widened the framework to visitors. Together these mean
the test's premise ("no widget cards because flag is off and visitor
is not owner") no longer holds — the flag is on by default and
visitors render through the framework now.

The whole suite is skipped with an explanation. Re-enabling it
correctly would require launching the dev-server with
NEXT_PUBLIC_PROFILE_WIDGETS=0 via a separate playwright project
(future test-infra task). After Phase 6 cleanup deletes the env-var
check entirely, this suite should be deleted, not re-enabled.

Unblocks the in-flight follow-up PRs whose CI was failing on this
test (#47 economy paired-tx, and likely #48, #50 as well).
Plan-3b follow-up: expand the Records widget from 2 records (longest
session, busiest session) to 4. The original widget explicitly noted
"More records (deadliest, biggest trade, longest streak) ship in
Plan 3b" — this lands 2 of those 3.

Added:
- **Longest survival streak** — gap (ms) between the user's two
  most-spaced consecutive `player_death` events. Pulled via
  `listEvents({ event_type: 'player_death', limit: 500 })`.
  Rendered in days+hours form when ≥ 1 day (otherwise the existing
  fmtDuration would emit unreadable strings like "1442h 30m").
- **Biggest trade** — largest confirmed transaction by `quantity`
  from `getCommerceRecent(token, 500)`. Includes `item` when the DTO
  carries it. Documented in a comment: not strictly "biggest by aUEC"
  because CommerceTransactionDto doesn't expose a typed currency
  field; quantity is the closest available signal without parsing
  raw payloads.

Still deferred:
- **Deadliest session** — most player-deaths in one session. Needs
  per-session death aggregation on the server (SessionSummary only
  exposes `event_count`, not per-type breakdowns). The expanded view
  now carries an inline note saying this is "pending — needs
  per-session death aggregation on the server" instead of the
  generic "Plan 3b" pointer.

Architecture:

- Three parallel fetches via `Promise.allSettled` so a single
  endpoint hiccup doesn't blank the whole widget (mirrors the
  page-level invariant for multi-endpoint dashboards).
- Per-source failures log with `call=widget.records.<source>` so the
  failing branch is named in logs.
- Compact view leads with longest-session and switches its second
  slot to survival-streak when present, falling back to busiest-by-
  events when the user has < 2 deaths in window.

No backend, schema, or OpenAPI changes. Single-file web edit.

Verified: `pnpm --filter web typecheck` exits 0.
@ntatschner ntatschner marked this pull request as ready for review May 20, 2026 13:20
@ntatschner ntatschner merged commit 9801efa into main May 20, 2026
8 checks passed
@ntatschner ntatschner deleted the feat/records-widget-expansion branch May 20, 2026 13:20
ntatschner pushed a commit that referenced this pull request May 20, 2026
Plan-3b follow-up — completes the records widget by adding the third
deferred record (deadliest session). Removes the "Deadliest session
pending — needs per-session death aggregation on the server"
footnote left by PR #48.

Approach: pure client-side bucketing. Reuses the sessions + deaths
fetches already happening for longest-session / busiest-session /
survival-streak — no new endpoint, no schema change, no migration.

For each session with valid started_at/ended_at, count how many
`player_death` events fall inside the session's bounds; track the
max. Done in a single third pass after both fetches resolve.

Complexity: O(sessions × deaths) per render. With both arrays
capped at ~500 rows per fetch, that's ~250k comparisons worst-case
— sub-millisecond on any modern device. Binary-searching the sorted
death array would only pay off at orders of magnitude more rows;
clarity wins here.

Display: new "Deadliest session: N deaths" row in the expanded
view, slotted between "Busiest" and "Longest survival streak" so
combat-related records cluster together. Compact view unchanged —
already curates to 2 highlights and adding a third would crowd the
line.

The empty-state check now includes `deadliestSessionDeaths` so a
user with deaths-but-no-other-records still gets a non-null render
(rather than falsely returning null and showing the empty placeholder).

Verified: `pnpm --filter web typecheck` exits 0.
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