Skip to content

feat: show each character's current location on the account and members pages - #162

Merged
guarzo merged 19 commits into
mainfrom
worktree-character-location
Aug 6, 2026
Merged

feat: show each character's current location on the account and members pages#162
guarzo merged 19 commits into
mainfrom
worktree-character-location

Conversation

@guarzo

@guarzo guarzo commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Show each character's current location on the account page and in the admin members drawer, as a second line under the character's name: J123456 — Home Astrahus.

Also carries two smaller fixes the same brainstorm turned up: the members-drawer scroll defect, and the account manifest's column crowding at 10+ characters.

What this adds

A location job on a ~15-minute cron reads each character's position from ESI and writes five nullable columns on character. A universe_name cache table resolves system/station/structure ids to names so the job doesn't re-ask ESI for facts that never change. The view layer formats one line per character; a single CharacterLocation component renders it on both pages, so they cannot drift into two spellings of the same fact.

Three new ESI scopes. esi-location.read_location.v1 is required; esi-universe.read_structures.v1 and esi-location.read_online.v1 are optional — a character missing an optional scope still gets a location line with that detail omitted. Scopes are env-driven via EVE_SSO_SCOPES, so the change lands in .env.example, playwright.config.ts and docs/ops.md rather than in application source.

Decisions a reviewer should know

locationAsOf is the OLDEST reading, not the newest — deliberately unlike mapObservedAt sitting right beside it on the admin page. mapObservedAt reduces newest-wins because the ACL observation is one job run; location has a per-character clock, and a failed read does not advance locationCheckedAt. Understating freshness is the safe direction. The two labels look adjacent and derive oppositely; that's intentional.

On any read failure the job writes nothing for that character — not even locationCheckedAt. Keeping the last known values and the last successful timestamp is what makes the "as of" label honest about staleness instead of the row silently blanking.

A needs_reauth verdict can only follow from the REQUIRED scope's own failure. Both optional reads degrade to null exactly as if the scope were absent. This was a deliberate deviation from the plan's reference implementation, which would have let an optional-scope 403 write a persisted needs_reauth.

Location data is current-value-only. No history table, no per-reading rows.

The needs_reauth write is audited, and only on the transition. The CAS uses .returning(); a zero-row update means the row rotated underneath, so the stale decision is dropped and counted transient rather than written. The audit row fires only when the character was not already needs_reauth — without that guard a ~15-minute job writes ~96 identical rows a day per broken character. The payload is { scope, detectedBy }, deliberately not token-health's missingScopes: nothing was computed against config here, the stored scopes column still claims the grant, and ESI refused the read anyway. token.needs_reauth therefore has two writers with two payload shapes, so summarize.ts declares both — an undeclared key renders as +N more, which would tell an admin two fields were withheld when nothing was.

Account manifest: 6 columns → 4

Token, standings and map state merge into one STATUS cell. Three accessibility contracts moved with the merge and are worth checking: each of the three merged entries keeps a visible label (without the column headers, two of them read literally "ok"); aria-describedby moved onto the merged <td> and appears exactly once; and the re-authorize control stays an <a> styled as a button, since it navigates.

Members drawer scroll

scrollbar-gutter: stable on the tall drawer region. This fix is not verified by any automated test in this repo — headless Chromium renders scrollbars as zero-width overlays, so the original horizontal-overflow symptom cannot be reproduced in CI. It was applied on the instrument-independent measurement that many-character rows cross the vertical-overflow threshold (956 > 718) while few-character rows do not (530 == 530). Needs one confirmation in a real classic-scrollbar browser.

Deploy note

For one window after deploy, most characters will read needs_reauth and the new job will read missing on the admin sync page. Both self-resolve — the first is members re-consenting to the new scopes, the second is the job's first tick. SYNC_MODE=live remains a required secret.

Migration 0009 is purely additive: one CREATE TYPE, one CREATE TABLE, five nullable ADD COLUMNs. No already-applied migration is edited. Nothing touches TOKEN_ENCRYPTION_KEY handling or the OAuth state flow.

Verification

  • Unit suite, typecheck, lint, format:check, and the full Playwright e2e suite.
  • The whole-branch review found zero Critical.
  • Manual citadel check against live ESI: pending. The 403 path on /universe/structures/{id}/ cannot be exercised faithfully against a mock, so the structure resolver needs one live check: a character docked in a player structure should render the real citadel name, and one docked where the corp has no access should increment namesUnresolved while leaving the job status ok — a name failure must never turn the job amber.

Known follow-up

src/jobs/contacts.ts writes the same needs_reauth CAS without an audit row. This PR fixes that in the location job; contacts is left alone deliberately, as a separate change.

guarzo added 19 commits August 6, 2026 09:53
A many-character members drawer pushes .scroller--tall past its height
cap and gains a vertical scrollbar; on a classic-scrollbar platform that
takes width out of the region mid-render, tipping an already near-full
table into horizontal scroll as a side effect of nothing the user did.
scrollbar-gutter: stable reserves that width unconditionally, so
crossing the vertical-overflow threshold no longer changes the content
box width.

This container's headless Chromium renders scrollbars as zero-width
overlays regardless of host OS or --disable-features=OverlayScrollbar
(confirmed against both the drawer and a trivial always-overflowing
probe element), so the horizontal-overflow fix itself is not verified
by automated test here and needs confirmation in a real
classic-scrollbar browser. What drawer-scroll.spec.ts does verify: a
many-character drawer crosses the region's vertical-overflow threshold
and a few-character one doesn't (real geometry, independent of
scrollbar rendering), and that scrollbar-gutter: stable is the computed
value on the scroll container.

Applying the gutter to .scroller--tall shifted the true rightmost
scrollLeft on that class's other users (admin accounts, audit) by a
consistent, measured 10px versus the naive scrollWidth - clientWidth
figure those tests' pin assertions relied on -- accounted for via
TALL_SCROLLER_GUTTER_SLOP in e2e/geometry.ts rather than loosened
silently.
The hardcoded 10px constant contradicted its own doc comment (which wrongly
described scrollbar-gutter's reservation as inflating scrollWidth rather than
shrinking clientWidth) and encoded this container's incidental scrollbar
width rather than anything platform-invariant.

pinGeometry now measures offsetWidth - clientWidth on the scroll region in
the same evaluate() call and returns it as gutterWidth; admin.spec.ts and
audit.spec.ts derive their tolerance from that measured value instead. The
real figure (12px: ~10px from .scroller's `scrollbar-width: thin` plus 2px of
border) closes the assertions cleanly with no residual slop.

drawer-scroll.spec.ts's disclaimer no longer overstates what this
environment can't see: scrollbar-gutter's reservation is genuinely verified
here (offsetWidth - clientWidth > 0, now asserted directly); only the
original bug's own mechanism -- an unreserved, classically-rendered
scrollbar narrowing a box -- can't be reproduced under headless Chromium's
overlay scrollbars.
esi-location.read_location.v1 (required), esi-universe.read_structures.v1 and
esi-location.read_online.v1 (both optional) ship in one re-auth event, because
the re-auth cost is per rollout and not per scope.

The consent copy lands with them rather than after: without it every member's
consent screen falls through to describeScope's fallback and says authGD
cannot explain what it is asking for.
Adds five nullable location columns to character (system/station/structure
ids, online flag, checked-at timestamp) and a universe_name cache table for
resolved EVE names, keyed by id with a kind enum for refresh policy. Nothing
reads or writes these yet -- persistence layer only.
resolveUniverseName's initial SELECT was outside the try/catch, so a DB
read failure (connection drop, timeout) rejected instead of falling
through to ESI. Wrap the read so it degrades to "no cached candidate,"
matching the function's never-throws contract. Add a test that forces
the read to reject via a Proxy over the test db, and a comment
recording that isCacheFresh is checked against input.kind rather than
cached.kind because id alone is the primary key and EVE id ranges never
collide across kinds.
Covers the two gaps a reviewer found in the initial location job: a
two-character run where only one fails (proving the per-character
try/catch isn't hoistable without breaking updated-count), and the
needs_reauth branch's CAS on the refresh-token blob, both the match
and the concurrent-rotation-miss cases. Also references
LOCATION_SCOPES_OPTIONAL by index at both scope gates instead of
re-typing the literals, and moves a test fixture's structure id out
of the solar-system id range.
User-approved deviation from the plan's reference implementation:
previously a throwing getOnline/getStructureName reached the outer
catch and could mark a character needs_reauth on the strength of an
optional scope, or abandon the write entirely. Wrap both optional
reads in .catch(() => null) so a failure degrades the corresponding
detail exactly like the scope being absent — the required getLocation
read is untouched and still aborts the write on failure.
getLocatableCharacters has no ORDER BY (by design; adding one is out
of scope for this job), so the two-character isolation test's
teeth depended on which row Postgres happened to return first.
Run the scenario with the failing character seeded first and then
second, so the assertion holds regardless of physical row order —
repo has form on this exact class of bug (3ba6405).
The location job persisted `tokenStatus: "needs_reauth"` under a CAS and
left no durable record of it, unlike the equivalent state change in
`token-health`. Mirror that job's pattern:

- `.returning()` on the CAS, so a zero-row update (the row rotated or was
  reclaimed underneath) drops the stale decision and counts as a transient
  failure rather than being silently swallowed as `failed`.
- Audit only on the TRANSITION into needs_reauth. A ~15-minute job would
  otherwise write ~96 identical rows a day per broken character.

The `details` are deliberately NOT token-health's `missingScopes`: nothing
was computed against config here. The stored `scopes` column still claims
the grant and ESI refused the read anyway, so the payload names the scope
whose read 403'd. That means one action with two payload shapes, so the
audit summariser declares both — an undeclared key renders as `+N more`,
which would report a hidden field that is not actually hidden.

The catch block's "write NO location columns, not even locationCheckedAt"
behaviour is unchanged; `errors.push` moved above the branching so the
early-returning CAS-miss path still contributes to the error summary.
… four comments

No behaviour change.

- `src/jobs/location.ts`: delete the `.catch(() => null)` on the structure
  name lookup and the comment justifying it. `resolveUniverseName` is
  contractually non-throwing and the call already sits inside the
  optional-scope gate, so the guard's only live effect would be laundering a
  genuine TypeError into a silent `namesUnresolved++`. All three
  `resolveUniverseName` call sites are now unwrapped and consistent.

- `e2e/sync.spec.ts`: adding `location` to the housekeeping group made that
  list render three items, but the assertion still said two — the one thing
  the per-task reviews could not see, since each saw only its own diff. Also
  assert `location` is reachable inside its group's list, which is the test's
  own stated purpose.

- Four comments the branch invalidated: the `OPEN_WINDOW_SCOPE` line
  references in `login/page.tsx` (both re-derived), the "most frequent job
  runs every 30 minutes / three missed ticks" premise in `core/health.ts`
  (location now ticks every 15, so 90 minutes is six) and its restatement in
  `docs/ops.md`, and the "CONTACTS column's caption" in `account/page.tsx`,
  which named a column merged into STATUS. `STALE_AFTER_MS` is unchanged —
  it is hand-picked, not derived.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 591507f2-22f2-4894-a090-2edfbe2d3a39

📥 Commits

Reviewing files that changed from the base of the PR and between 55d2a7d and d101183.

⛔ Files ignored due to path filters (2)
  • drizzle/meta/0009_snapshot.json is excluded by !drizzle/meta/**
  • drizzle/meta/_journal.json is excluded by !drizzle/meta/**
📒 Files selected for processing (45)
  • .env.example
  • docs/ops.md
  • drizzle/0009_bright_mauler.sql
  • e2e/account.spec.ts
  • e2e/admin.spec.ts
  • e2e/audit.spec.ts
  • e2e/drawer-scroll.spec.ts
  • e2e/geometry.ts
  • e2e/helpers.ts
  • e2e/location.spec.ts
  • e2e/login.spec.ts
  • e2e/sync.spec.ts
  • playwright.config.ts
  • src/app/_components/character-location.tsx
  • src/app/account/page.tsx
  • src/app/admin/accounts/page.tsx
  • src/app/admin/audit/summarize.ts
  • src/app/globals.css
  • src/app/login/page.tsx
  • src/core/health.ts
  • src/core/location.ts
  • src/core/run-health.ts
  • src/core/schedules.ts
  • src/db/schema.ts
  • src/db/tables.ts
  • src/jobs/location.ts
  • src/lib/esi/client.ts
  • src/services/account-view.ts
  • src/services/desired.ts
  • src/services/sync-status.ts
  • src/services/universe-names.ts
  • src/worker/handlers.ts
  • src/worker/queues.ts
  • tests/account-view.test.ts
  • tests/audit-summarize.test.ts
  • tests/db-schema.test.ts
  • tests/deprovision-flow.test.ts
  • tests/desired.test.ts
  • tests/esi-client.test.ts
  • tests/location-format.test.ts
  • tests/location-job.test.ts
  • tests/schedules.test.ts
  • tests/sync-status.test.ts
  • tests/universe-names.test.ts
  • tests/worker-queues.test.ts

Comment @coderabbitai help to get the list of available commands.

@guarzo
guarzo merged commit 5791dd1 into main Aug 6, 2026
1 check passed
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