Skip to content

fix(account): tell a member at a glance whether their access is healthy - #71

Merged
guarzo merged 7 commits into
mainfrom
worktree-account-verdict-line
Aug 4, 2026
Merged

fix(account): tell a member at a glance whether their access is healthy#71
guarzo merged 7 commits into
mainfrom
worktree-account-verdict-line

Conversation

@guarzo

@guarzo guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner

What this is

An $impeccable critique of /account found that a member could not tell at a glance whether their access was healthy. The page listed facts (token state, contacts state, map state, tier, Discord) but never drew a conclusion from them, so answering "is anything wrong with me?" meant reading a six-column table and knowing which values were bad. This implements the five fixes that critique recommended.

What changed

A derived verdict, one line under the <h1>. New pure module src/core/account-health.ts computes it. One derivation now feeds three surfaces that previously each decided for themselves: the verdict line, the first-sync notice, and the colour grade on Add character (gold only when there is nothing else to do first).

The verdict splits two states that look alike and are not:

  • needs attention — the member can clear it: a dead token, a missing scope, a missing or misnamed contact label.
  • not syncing — nobody on this account can clear it: token_refresh_failed and sync_failed retry themselves, dry_run is SYNC_MODE, an unrecognized code goes to an admin.

They are counted separately because ContactRemedy tells the member "nothing to do here" for the second group. A headline demanding attention above copy saying the opposite teaches members that the headline is noise.

Map ACL state is deliberately not an input. wanderer_acl_observation is a delete-and-replace snapshot, so onMapAcl === false cannot distinguish "legitimately off the map" from "the job has not run" — a verdict counting it would alarm every member who is correctly off the map. The reasoning is recorded in the module so nobody "fixes" it later.

Remediation prose moved out of the table cell. At 320px the manifest lives in a horizontally-scrolling Scroller; prose inside a <td> drove row height to ~340px while staying off-screen and unreachable. It now renders as a keyed list below the table at all widths, wired back to its row with aria-describedby.

Copy for the six result codes that had none. They previously fell through to result.replace(/_/g, " ") — a bare, alarm-red code with zero guidance. Each now gets one of three treatments matching who owns the fix, and unrecognized codes get a sentence instead of a raw string.

A re-authorize control where re-authorizing is the fix, gated behind showReauth which defaults to off. /auth/eve/link links a character to the clicking user's account. The admin accounts table renders this same component for other members' characters, so an ungated control there would have started a link flow against the admin's own account instead of fixing the row it sat under. Found during review.

A real empty state for the zero-character account, and the verdict suppressed there entirely — "nominal" would be true and useless.

Verdict weight. The verdict initially rendered at --t-label (11px) while .tier--lead is 14px inside a gold border and fill, so the loudest thing on a degraded page was the fact that needed no action. Status now takes size="lead" (mirroring Tier's existing size="lead"), applied to the degraded and stalled states only.

Reviewer focus

  • src/core/account-health.ts — the attention / stalled split, and the firstSyncPending flag being deliberately independent of the verdict. A character linked seconds ago has no scopes yet, so it needs attention and is waiting on its first run; collapsing those into one priority-ordered state silently dropped the notice telling the member the wait is minutes rather than broken. tests/account-health.test.ts pins that case.
  • The showReauth default. Off is the safe default precisely because the unsafe caller is the one that would forget to pass it.
  • New copy avoids em dashes per the design rules. Pre-existing label copy still has them; out of scope here.

Verification

All run on the final tree:

npx tsc --noEmit    clean
npx vitest run      53 files, 463 tests passed
npm run lint        0 errors, 3 warnings (all pre-existing on main)
npm run test:e2e    56 passed (1.4m)

Six states were checked in the browser at 1280px and 320px: nominal, degraded, stalled, first-sync-pending, zero characters, and label mismatch.

Not done

  • The 3 no-img-element lint warnings are pre-existing on main.
  • Em dashes and the ambiguous "nothing to do here" phrasing in the pre-existing label copy.
  • Whether the contacts result codes deserve an exported union type. lastResult is bare text() at src/db/schema.ts:142; structural drift is compiler-caught, but a genuinely new code is handled by design rather than by the type, and is pinned by a some_new_code test case.

Summary by CodeRabbit

  • New Features

    • Added account health summaries showing sync status, first-sync guidance, and prioritized attention items.
    • Improved contact-sync status displays with clearer explanations, tailored remediation guidance, and reauthorization options.
    • Added prominent status styling and improved spacing for account-page notes and verdicts.
    • Discord push status is now omitted when Discord is not linked.
  • Bug Fixes

    • Updated reports to omit unlinked Discord entries instead of showing “not linked.”
  • Tests

    • Expanded coverage for account health, contact-sync outcomes, remediation guidance, and account-page behavior.

guarzo added 4 commits August 4, 2026 07:48
The account page stated every fact about a member's access and ranked none
of them, so a healthy account and one with three dead tokens looked
identical above the fold. Adds one derived verdict line under the h1, and
fixes the four states the critique found unreadable behind it.

- computeAccountHealth (src/core/account-health.ts) splits what a member
  must fix from what they can only be told about: token faults, missing
  scopes and the five clearable contacts codes count as attention, while
  the transient retries, dry_run and unrecognized codes count as stalled.
  The remedy copy for the latter says "nothing to do here", and a headline
  demanding action over copy denying it teaches members to ignore headlines.
- Contacts remediation prose moves out of the CONTACTS cell to below the
  table. Inside the horizontally-scrolling Scroller it drove rows to ~340px
  at 320px wide while staying off-screen and unreachable.
- The six result codes that fell through to a bare `result.replace(/_/g," ")`
  get real copy, in the three groups src/jobs/contacts.ts implies.
- ContactRemedy renders the re-authorize control only when the caller opts
  in. /auth/eve/link links a character to the clicking user's account, so on
  the admin table it could not fix the row it sat under.
- Zero-character accounts get a real empty state; the verdict line and the
  "Last pushed" block, which had nothing true to say, are suppressed.
- Discord state stops being stated twice, ~800px apart.
- The stalled verdict said "nothing here is the member's to do", but an
  unrecognized code's remedy tells them to ask an admin. That is an action,
  just not a self-service one. The classification is unchanged; the wording
  is now about who owns the fix.
- contactRemedyId claimed its element "always exists in the DOM", mirroring
  CONTACTS_NOTE_ID. It does not: the <p> only renders when hasContactRemedy
  is true. The behaviour was already correct, since the aria-describedby
  reference is gated on the same predicate, so the id cannot dangle.
onMapAcl is the obvious fourth input and is deliberately excluded.
wanderer_acl_observation is a delete-and-replace snapshot, so a character
legitimately off the ACL has no row and is indistinguishable from a job that
has not run (src/services/account-view.ts:26-29). Counting it would raise an
alarm the data cannot substantiate, for every member who is correctly off the
map. Without this note the omission reads as an oversight and invites a fix
that would make the verdict lie.
The verdict line landed as a plain .st token at --t-label (11px). The
tier badge a block below it is .tier--lead: 14px, 600 weight, inside a
gold border and a 14% fill. So on an account with three broken
characters the loudest thing above the fold was the one fact that
needed no action.

Adds Status size="lead", mirroring the size="lead" the Tier badge
already has in the same module, and applies it to the degraded and
stalled verdicts only. Nominal and first-sync-pending keep the plain
token: a page with nothing wrong should not open with a large line
saying so.

The lead token drops uppercase and the wide tracking. Both are legible
devices at 11px where they mark a value in a table; at body size they
turn a sentence into what reads like a section heading.

Verified at 1280px and 320px. .st is nowrap, so the copy has a width
budget rather than a wrap; recorded in the rule.

npx tsc --noEmit: clean
npx vitest run: 53 files, 463 tests passed
npm run lint: 0 errors, 3 pre-existing warnings
npm run test:e2e: 56 passed
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

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: 22 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 582c1cca-c2d9-4950-af77-030a3a619eac

📥 Commits

Reviewing files that changed from the base of the PR and between d1b53c5 and 6380c6a.

📒 Files selected for processing (4)
  • e2e/account.spec.ts
  • src/app/account/contact-state.tsx
  • src/app/account/page.tsx
  • tests/account-page.test.ts
📝 Walkthrough

Walkthrough

The change centralizes contact-sync result types, adds account-health derivation, separates status and remedy rendering, and updates account and admin pages with new remediation, verdict, layout, and Discord reporting behavior.

Changes

Account health and contact remediation

Layer / File(s) Summary
Contact result contracts and persistence typing
src/core/contact-result.ts, src/db/schema.ts, src/jobs/contacts.ts, src/services/account-view.ts
Contact-sync results use a shared literal union. Database and job types enforce known results, while account views retain support for unknown persisted values.
Account health derivation
src/core/account-health.ts, tests/account-health.test.ts
computeAccountHealth classifies attention, stalled synchronization, first-sync status, counts, and verdict precedence.
Contact status and remedy rendering
src/app/account/contact-state.tsx, src/app/_components/ui.tsx, tests/account-page.test.ts, tests/contact-result.test.ts
ContactState renders compact tokens. ContactRemedy renders result-specific explanations, reauthorization, retry guidance, and unknown-result handling.
Account and admin surface integration
src/app/account/page.tsx, src/app/admin/accounts/page.tsx, src/app/globals.css, e2e/account.spec.ts
Account health and conditional remedies are wired into member and admin pages. The account page updates verdict styling, table notes, images, and unlinked Discord reporting.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant AccountPage
  participant computeAccountHealth
  participant ContactState
  participant ContactRemedy
  AccountPage->>computeAccountHealth: character health inputs
  computeAccountHealth-->>AccountPage: AccountHealth verdict
  AccountPage->>ContactState: contact result and target
  AccountPage->>ContactRemedy: result, detail, and label
Loading

Poem

I’m a rabbit with a tidy report,
Health verdicts now sort every sort.
Tokens stay small, remedies speak,
Discord slips out when links are weak.
Tests hop neatly from dawn to night.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: helping members assess account access health at a glance.
Description check ✅ Passed The description explains the changes, rationale, verification results, reviewer focus, and known exclusions in sufficient detail.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-account-verdict-line
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch worktree-account-verdict-line

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

guarzo added 2 commits August 4, 2026 08:24
`contact_sync_state.last_result` was bare `text()` and `recordResult`
took a bare `string`, so the nine codes the contacts job writes existed
only as scattered literals. Nothing connected the writer to the three
readers that branch on them.

Adds `src/core/contact-result.ts` holding CONTACT_SYNC_RESULTS and the
derived ContactSyncResult union, narrows the column with `$type<>()`,
and types `recordResult`'s parameter.

Not a pgEnum, unlike token_status. That column is a closed set the tier
state machine branches on, where a value outside it has no sensible
handling and the database refusing it is a feature. This vocabulary is
open: ContactRemedy already has designed copy for a code it does not
recognize, and a pgEnum would make every new code a migration and turn
a rolling deploy into a write failure instead of one vague line of UI.
`npm run db:generate` confirms: "No schema changes, nothing to migrate".

Readers keep `string | null`, documented at both service-layer
boundaries. A row can hold a code this build has never heard of, so
handing readers the union would let a switch look exhaustive when it is
not, and would make the deliberate fallback branch read as dead code.

The sharpest catch is MEMBER_FIXABLE in account-health.ts: it was
`Set<string>`, so a typo compiled and its only symptom was a verdict
that under-counted forever while the character's own row still showed
the fault. Now constructed as `Set<ContactSyncResult>` and declared
`ReadonlySet<string>`, so the typo check happens at construction while
`.has()` still takes the widened value a reader holds.

tests/contact-result.test.ts closes the gap the types cannot: readers
take `string`, so no type forces them to grow a branch. It iterates the
vocabulary and demands real UI copy plus a verdict classification for
every code.

Both guards were mutation-tested, not assumed. Adding "quota_exceeded"
to the union failed the ContactRemedy and verdict cases; typo'ing a
write-site literal to "missing_scop" failed tsc with a did-you-mean.
The ContactState case cannot detect drift (its fallback renders the
same text as several real branches) and says so rather than implying
coverage it does not have.

npx tsc --noEmit: clean
npx vitest run: 54 files, 490 tests passed
npm run lint: 0 errors, 3 pre-existing warnings
npm run test:e2e: 56 passed
npm run db:generate: no schema changes
Three files conflicted, all in the blocks main rewrote under me:

- src/app/account/page.tsx — main swapped hand-rolled `.notice`
  paragraphs for the new <Notice> primitive and moved the shell nav to
  href-based matching (current="/account"). Kept both: the first-sync
  notice is now a <Notice>, still gated on health.firstSyncPending so it
  does not restate a verdict that already leads with the fault.
- src/app/admin/accounts/page.tsx — main renamed RowDisclosure to
  Disclosure. Took the rename; the contact-state imports are unchanged.
- src/app/_components/ui.tsx — main added its own contract comment above
  Status. Folded the size="lead" paragraph into it rather than leaving
  two adjacent JSDoc blocks on one function.

Also formatted two files that were already over the 90-column limit
before the merge: my it.each list in tests/account-health.test.ts and
the resolved admin accounts page.

Verified after the merge, not before:
  npx tsc --noEmit         clean
  npx vitest run           57 files, 580 tests passed
  npm run lint             0 errors, 3 pre-existing img warnings
  npm run format:check     clean after the two files above
  npm run test:e2e         74 passed
@guarzo

guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app/account/contact-state.tsx (1)

95-144: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reword the label remedies so they do not end with the transient-failure phrase.

missing_label and label_mismatch are member-fixable. src/core/account-health.ts line 75-81 puts both in MEMBER_FIXABLE, so they raise the attention count and drive the degraded verdict.

Both remedies end with "nothing to do here". Line 180 uses the same phrase for token_refresh_failed and sync_failed, which count as stalled and need no member action at all. The phrase now means two different things.

src/core/account-health.ts lines 38-43 identify this exact combination as the failure to avoid: a headline that demands attention above copy that says the opposite.

The member must rename or create a label in game for these two codes. State that the sync needs no further action, not that the member has none.

✏️ Proposed copy fix
   if (result === "missing_label") {
     return (
       <span className="dim">
         Create a contact label named <code className="literal">{`"${label}"`}</code> in
-        game. The next sync picks it up on its own — nothing to do here.
+        game. The next sync picks it up on its own — no further step after that.
       </span>
     );
   }
             Your label is named <code className="literal">{`"${candidates[0]}"`}</code>.
             It must be exactly <code className="literal">{`"${label}"`}</code> —
             capitalization and spaces both count. Rename it in game. The next sync picks
-            it up on its own — nothing to do here.
+            it up on its own — no further step after that.

Apply the same wording to the remaining two label_mismatch branches at lines 133 and 139.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/account/contact-state.tsx` around lines 95 - 144, Reword the remedies
in the missing_label branch and all three label_mismatch branches to state that
the member must create or rename the label in game, after which the next sync
needs no further action. Replace the misleading “nothing to do here” wording
while preserving the existing label-specific instructions and candidate
rendering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/account/page.tsx`:
- Around line 207-220: The Notice component that renders when
health.firstSyncPending is true unconditionally promises Discord role updates,
but this message is misleading when Discord is not linked. Update the
conditional rendering of the Notice component to additionally check that
view.discordLinked is true, so the Discord role update promise only displays
when Discord is actually linked to the account.

---

Outside diff comments:
In `@src/app/account/contact-state.tsx`:
- Around line 95-144: Reword the remedies in the missing_label branch and all
three label_mismatch branches to state that the member must create or rename the
label in game, after which the next sync needs no further action. Replace the
misleading “nothing to do here” wording while preserving the existing
label-specific instructions and candidate rendering.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: df54826a-8343-4d5b-a26e-0f422b852e97

📥 Commits

Reviewing files that changed from the base of the PR and between b35d6f1 and d1b53c5.

📒 Files selected for processing (14)
  • e2e/account.spec.ts
  • src/app/_components/ui.tsx
  • src/app/account/contact-state.tsx
  • src/app/account/page.tsx
  • src/app/admin/accounts/page.tsx
  • src/app/globals.css
  • src/core/account-health.ts
  • src/core/contact-result.ts
  • src/db/schema.ts
  • src/jobs/contacts.ts
  • src/services/account-view.ts
  • tests/account-health.test.ts
  • tests/account-page.test.ts
  • tests/contact-result.test.ts

Comment thread src/app/account/page.tsx
…ther side

Two findings, both real, but the second one names the wrong file to change.

1. The first-sync notice promised Discord roles on an account with no
   Discord link, rendering directly above the "Link Discord" button it
   was contradicting. Worse than merely false: it told the member the
   one step they still owed was already handled. The clause is now
   conditional, and the unlinked branch spends it pointing at the
   control instead of dropping the subject.

2. "nothing to do here" carried two meanings. CodeRabbit proposed
   rewording the four label branches. Blame says those are shipped copy
   from #53 and #60, where the phrase means "nothing more to do *in
   authGD*" and sits after an explicit "rename it in game". The
   collision came from the transient branch this PR added, which reused
   the phrase for codes that ask nothing of anyone. Reworded that side
   to "No action needed." and left the shipped copy alone.

Guards, both mutation-tested rather than assumed:
- e2e/account.spec.ts gains the linked case and extends the unlinked
  one. Forcing the conditional to always-linked fails the unlinked
  assertion with the old sentence quoted back.
- tests/account-page.test.ts now asserts the transient codes do *not*
  contain "othing to do here", so the phrase cannot drift back.

Verified:
  npx tsc --noEmit         clean
  npx vitest run           57 files, 580 tests passed
  npm run lint             0 errors, 3 pre-existing img warnings
  npm run format:check     clean
  npm run test:e2e         75 passed

Not fixed, out of scope: the label remedies use em dashes in
member-facing copy, which DESIGN.md's copy rules ban. That is shipped
code this PR does not touch.
@guarzo

guarzo commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Both findings taken, but the second one from the other side.

1. Discord promise on an unlinked account — valid, and worse than stated. The notice renders directly above the Link Discord button, so it told a member the one step they still owed was already handled. Fixed, but rather than dropping the clause I spent it on the control:

  • linked: Standings, map access and Discord roles update within a few minutes of linking a character.
  • unlinked: Standings and map access update within a few minutes of linking a character. Discord roles start once you link Discord below.

2. "nothing to do here" — the phrase collision is real, but the direction is backwards. git blame puts the four label branches at #53/#60, where the phrase means "nothing more to do in authGD" and follows an explicit "rename it in game" imperative. The premise that the copy "says the opposite" of the headline does not hold there: every label remedy opens with what to do. The collision came from the transient branch this PR added, which reused a phrase that already had a narrower meaning. So I reworded my side to No action needed. and left the shipped copy alone.

Guards, mutation-tested rather than assumed:

  • e2e/account.spec.ts gains the linked case and extends the unlinked one. Forcing the conditional to always-linked fails the unlinked assertion with the old sentence quoted back.
  • tests/account-page.test.ts asserts the transient codes do not contain othing to do here, so the phrase cannot drift back into the overloaded meaning.

tsc clean, 580 unit tests pass, lint 0 errors, format clean, 75 e2e pass.

Separately, out of scope here: the label remedies use em dashes in member-facing copy, which our copy rules ban. Shipped code this PR does not touch.

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