Skip to content

feat(payouts): keep the operator's place while paying a roster - #145

Merged
guarzo merged 9 commits into
mainfrom
worktree-payouts-pay-flow-focus
Aug 6, 2026
Merged

feat(payouts): keep the operator's place while paying a roster#145
guarzo merged 9 commits into
mainfrom
worktree-payouts-pay-flow-focus

Conversation

@guarzo

@guarzo guarzo commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Pull request

What changed and why

Paying out a fleet on /payouts/[id] costs more than it should, and the cost is
not the clicking. mark paid renders only while a row is unpaid, so it unmounts
itself on success; focus falls to <body>; and the operator re-scans a 5-15 row
table for the next unpaid name after every ISK transfer. Nothing tracks
position.

So: keep the operator's place.

A new PayFlow provider wraps the roster and hosts one effect. When a payment
settles it announces progress and the next recipient into a live region, and
moves focus to that row's copy amount button — the one control in the action
cell that renders regardless of paid state, and therefore the only stable target
across both transitions. On the last row it announces "All N paid" and focuses
the roster heading instead. A revert announces too, but holds focus on its own
row rather than advancing.

The effect lives above the table because both mark paid and revert unmount
themselves on success; an effect hosted in either button would race its own
unmount. (InlineEdit can use useActionState precisely because it never
unmounts across its own call. That pattern does not transfer here.)

The load-bearing property: no optimistic UI. Every announcement derives from
server-rendered rows, never from the action promise. The live region can only
say "Paid Ada Relay" on a render where the server says Ada is paid. A failed
action throws to error.tsx and the component unmounts with its watch still
armed, announcing nothing. On a money ledger that is worth the extra round trip.

Also here: the first payment on an operation freezes it permanently — pools,
roster and shares can no longer be reopened with Unlock — and had no confirm
step. It now gets ConfirmSubmit, with the consequence sentence in
aria-describedby and permanently .visually-hidden (#112: revealing inside a
td widens the cell, moves the armed button out from under a stationary mouse,
and disarms the control).

Deliberately not here, after checking how the corp actually pays a fleet:
no "mark all paid" (EVE has no multi-recipient transfer, so bulk would decouple
the ledger from reality), no reordering or filtering (rosters are 5-15 people),
and no change to recordPayment, markPaidAction, revertPaymentAction, or
anything under src/services/, src/core/, src/db/, drizzle/.

page.tsx shows ~379 changed lines; git diff -w puts the real edit at 45/28.
The rest is re-indentation from wrapping the table in the provider.

Why pay-flow-ids.ts is its own module

copyAmountId is called by page.tsx (a Server Component, at render time) and
by pay-flow.tsx (a Client Component, inside an effect). Every export of a
"use client" file becomes a client reference, and a Server Component cannot
invoke a client reference as a plain function. The 18-line directive-free module
is what lets both sides share one definition of the id format.

What CI cannot check

No manual screen-reader pass. The announcement text and focus destinations
are asserted in Playwright (six new tests), but nobody has heard NVDA or
VoiceOver read them. There is no jsdom in this repo, so client behaviour is
Playwright-only by necessity.

Everything else is CI's. Locally, on d8ff8a6:

npm test           -> Test Files  76 passed (76)
                      Tests  1083 passed (1083)
npm run test:e2e   -> 204 passed (3.2m)
npm run typecheck  -> clean
npm run lint       -> clean
npm run format:check -> All matched files use Prettier code style!

Deploy notes

None. No migration, no new secret, no fly.toml change.

Flags

Known residual: stale attribution, not stale state. If this tab's props say
"unpaid" because another operator paid the row after the render, recordPayment
returns early (payouts.ts:818), markPaidAction revalidates regardless
(actions.ts:609), and the effect announces "Paid X" for a transfer this
operator never made. Generally: any path where the submit lands but the row's
state doesn't move leaves the watch armed, and the next render that flips that
row announces as if this click caused it. The bound holds either way — the
announced state is true, and the audit log names the real payer. Only
attribution is wrong. Closing it needs a return value from the action saying
whether this call was the one that wrote, which is a service-layer change this
pass deliberately does not make.

Follow-up, out of scope: ConfirmSubmit -> Submit occupy the same JSX slot
with different component types, so a fast click during the remount can be lost.
Pre-existing — the identical ternary sat inline before this branch — but this
change increases exposure, because focus now lands on the next row immediately
and invites the fast follow-up click the old re-scan pause used to prevent. It
belongs in its own change against those two components.

Accepted edge: paying out of roster order announces a "Next:" pointing at a
row above the one just paid. It matches the specified algorithm (next unpaid in
roster order, not next after this row) and the roster is short enough that this
is a curiosity rather than a problem.

Fixed along the way: four cross-file file.tsx:NNN comment references had
drifted stale as the files grew during the branch (d8ff8a6, comments only), and
a dead export { copyAmountId } re-export was re-publishing the id helper through
the very client boundary the module split exists to avoid (e8f6c08).

Summary by CodeRabbit

  • New Features

    • Improved payout workflows with clearer payment progress announcements and automatic focus movement.
    • Added focused navigation to roster headings, copy-amount controls, and the next unpaid participant.
    • Added support for excluding participants from payment actions.
    • Added accessible descriptions for permanent freezes and payment confirmations.
    • Improved payment reversal behavior, including updated focus and payment counts.
  • Tests

    • Added end-to-end coverage for payout accessibility, payment progression, exclusions, completion, and reversals.

guarzo added 6 commits August 6, 2026 00:00
…nounced ISK amount

pay-flow.tsx re-exported copyAmountId back through the client boundary that
pay-flow-ids.ts exists to avoid, with no importer to justify it. And every
"Next:" assertion in payouts.spec.ts truncated before the ISK amount, so a
wrong or unformatted figure in the announcement would never fail a test.
… mean

Four cross-file references drifted as the surrounding files grew during the
branch. Each one makes a checkable claim, so a wrong number is worse than no
number — it sends the next reader to unrelated code and quietly discredits the
comment around it.

  pay-flow.tsx       page.tsx:866/891 -> 888/901 (MarkPaidForm/RevertForm)
  pay-flow.tsx       copy-amount-button.tsx:81 -> 88 (its role="status")
  pay-flow.tsx       page.tsx:211 -> 224 (firstPayment)
  copy-amount-button page.tsx:841 -> 861-868 (the render, not the comment
                     above it; 861-862 is where the condition shows the
                     button does not depend on paid state)

Comments only. No behaviour change.
@coderabbitai

coderabbitai Bot commented Aug 6, 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: 50 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: 47f19e91-2112-4425-a4c3-71fe8807005e

📥 Commits

Reviewing files that changed from the base of the PR and between 7047ad6 and 21ff008.

📒 Files selected for processing (3)
  • src/app/payouts/[id]/page.tsx
  • src/app/payouts/[id]/pay-flow-ids.ts
  • src/app/payouts/[id]/pay-flow.tsx
📝 Walkthrough

Walkthrough

The payout roster now uses shared payment-flow components. It derives active payment rows, assigns stable focus targets, announces server-confirmed state changes, and manages focus after payment or reversion. End-to-end tests cover finalized rosters and accessibility behavior.

Changes

Payout accessibility flow

Layer / File(s) Summary
Flow contracts and focus targets
src/app/payouts/[id]/pay-flow.tsx, src/app/payouts/[id]/pay-flow-ids.ts, src/app/_components/ui.tsx, src/app/payouts/[id]/copy-amount-button.tsx
The shared flow defines payment rows and pay/revert events. RuleHead and CopyAmountButton accept stable focus IDs.
Payout flow wiring
src/app/payouts/[id]/page.tsx, src/app/payouts/[id]/pay-flow.tsx
The payout page excludes excluded participants from payment rows and connects roster actions to PayFlow, MarkPaidForm, and RevertForm. The flow announces confirmed changes and moves focus to the next control or roster heading.
End-to-end payout validation
e2e/payouts.spec.ts
Tests seed finalized rosters and cover payment progression, announcements, freeze messaging, completion focus, excluded-row skipping, and reversion behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant PayoutPage
  participant PayFlow
  participant Server
  Operator->>PayoutPage: submit payment or reversion
  PayoutPage->>PayFlow: dispatch action tracking
  PayFlow->>Server: wait for confirmed state
  Server-->>PayoutPage: render updated roster
  PayFlow->>Operator: announce result and move focus
Loading

Possibly related PRs

  • guarzo/authGD#65: Both modify the payout detail page, CopyAmountButton, and payout end-to-end tests.
  • guarzo/authGD#83: Both modify payout payment and reversion flows and their end-to-end coverage.
  • guarzo/authGD#91: This flow uses shared form primitives and fmtIsk while also modifying the payout page and ui.tsx.

Poem

Rows align, and focus knows
Payments speak as status flows
Excluded names fade from sight
Reverts guide the next step right
The roster stays in keyboard light

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses the required Conventional Commit format and clearly states the user-visible focus-preservation effect.
Description check ✅ Passed The description includes all required sections and clearly documents the change, manual verification limits, deployment notes, and known flags.
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.

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

guarzo added 2 commits August 6, 2026 01:38
…flow-focus

# Conflicts:
#	src/app/payouts/[id]/page.tsx
…merge

Merging main moved page.tsx around, so the same four cross-file references
drifted a second time. Same reasoning as d8ff8a6: a wrong number sends the next
reader to unrelated code.

  pay-flow.tsx       page.tsx:888/901 -> 871/884 (MarkPaidForm/RevertForm)
  pay-flow.tsx       page.tsx:224 -> 201 (firstPayment)
  copy-amount-button page.tsx:861-868 -> 844-851

Twice in one branch is the convention telling on itself; noted on the PR.

@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: 2

🤖 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/payouts/`[id]/page.tsx:
- Line 689: Move the shared "roster-heading" value into pay-flow-ids.ts
alongside copyAmountId, export it, and import that symbol in the page component.
Use the shared identifier for both the roster heading id and the headingId prop
so these values cannot drift.

In `@src/app/payouts/`[id]/pay-flow.tsx:
- Line 151: Memoize the object passed as the value of PayFlowContext.Provider so
its identity remains stable while the memoized dispatch is unchanged. Update the
provider in PayFlow and retain dispatch as the sole dependency, preventing
MarkPaidForm and RevertForm subtrees from re-rendering on unrelated message or
pending changes.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b1394b98-3f69-4d2e-87f7-e65bf83eefa2

📥 Commits

Reviewing files that changed from the base of the PR and between ea65e2c and 7047ad6.

📒 Files selected for processing (6)
  • e2e/payouts.spec.ts
  • src/app/_components/ui.tsx
  • src/app/payouts/[id]/copy-amount-button.tsx
  • src/app/payouts/[id]/page.tsx
  • src/app/payouts/[id]/pay-flow-ids.ts
  • src/app/payouts/[id]/pay-flow.tsx

Comment thread src/app/payouts/[id]/page.tsx Outdated
Comment thread src/app/payouts/[id]/pay-flow.tsx Outdated
…alue churning

Two review findings from #145, both small and both real.

`"roster-heading"` was spelled out twice — once as the `id` on the heading,
once as `PayFlow`'s `headingId` — with nothing checking they match. It moves to
`pay-flow-ids.ts`, which exists for exactly this and is already reachable from
both sides of the client boundary. The e2e selectors keep the literal on
purpose: a test that imports the id it asserts on cannot catch the id changing.

`<PayFlowContext.Provider value={{ dispatch }}>` built a fresh object every
render. `children` is a prop, so React already bails out of re-rendering the
table when only `message` or `pending` moved — but that bailout does not cover
context consumers, and every row's control is one. So each announcement
re-rendered a dozen forms for nothing. Memoized on `dispatch`, which changes
only when `rows` does, which is when those rows should re-render anyway.
@guarzo

guarzo commented Aug 6, 2026

Copy link
Copy Markdown
Owner Author

Both findings applied in 21ff008 — thanks, both were real.

ROSTER_HEADING_ID — agreed, and the timing is pointed: this branch already had to re-point four file.tsx:NNN comment references twice (once during polish, once after merging the design sweep) because nothing checks a cross-file reference. Same class of rot, so the constant is worth it. The e2e selectors deliberately keep the string literal — a test that imports the id it is asserting on cannot catch the id changing.

Context value memo — correct, and worth spelling out why the usual counter-argument does not apply here. children arrives as a prop from the Server Component, so React does bail out of re-rendering the table when only message or pending moved. But that bailout does not cover context consumers, and every row's mark paid / revert is one — so each announcement was re-rendering a dozen forms for nothing. Memoized on dispatch, which changes only when rows does, which is exactly when those rows should re-render.

Also on this branch since the first review: merged main (the design sweep moved page.tsx under me — one import-block conflict, resolved by keeping both sides), and filed the remount-race follow-up flagged in the description as #146.

Verification on 21ff008: typecheck, lint, format:check clean; e2e/payouts.spec.ts 66 passed. Full unit + e2e were green on the merge commit (77/1134 unit, 225 e2e) and this commit touches only the two things above.

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