Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions e2e/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1623,10 +1623,8 @@ test("a faulted character's remedy renders in a sub-row under that character", a
// have.
await expect(subRows.locator("td")).toHaveAttribute("colspan", "4");

// The footnote copy is gone, not merely duplicated. Scoped to the remedy's
// own id rather than to `.table-notes` as a whole: that container survives
// for the "make main" consequence notes, which did not move.
await expect(page.locator('.table-notes [id^="contact-remedy-"]')).toHaveCount(0);
// The footnote copy is gone, not merely duplicated: exactly one remedy
// element exists for the one faulted character, and it is the sub-row.
await expect(page.locator('[id^="contact-remedy-"]')).toHaveCount(1);
});

Expand Down
118 changes: 14 additions & 104 deletions src/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,8 @@ import { ACCOUNT_ERRORS, loginErrorUrl, lookupErrorMessage } from "@/lib/error-r
import { getAccountView, type PushStatus } from "@/services/account-view";
import { canReadPayouts } from "@/services/payouts";
import { listAccountPayouts } from "@/services/payout-view";
import { getMainChangeContext } from "@/services/accounts";
import { getSessionAccount } from "@/services/session";
import { classifyCharacter, computeAccountHealth } from "@/core/account-health";
import { previewMainChange } from "@/core/tier";
import { tierLabel } from "@/app/_components/labels";
import { navFor } from "@/app/_components/nav-items";
import { Notice, RuleHead, Scroller, SiteHeader, Status } from "@/app/_components/ui";
import { brandProps } from "@/app/_components/brand-server";
Expand Down Expand Up @@ -59,11 +56,6 @@ const manifestColumns = (showStatus: boolean) => (showStatus ? 4 : 3);
* never dangle. */
const contactRemedyId = (characterId: number) => `contact-remedy-${characterId}`;

/** Same dangling-id guarantee as `contactRemedyId` above, for the "make main"
* consequence sentence — the id is only ever referenced from the button for a
* character `mainChangeNotes` actually produced a note for. */
const mainChangeNoteId = (characterId: number) => `main-change-note-${characterId}`;

// Reads the session cookie and hits the DB on every request; getConfig() also
// requires env vars that aren't present at build time, so this route must
// never be statically prerendered.
Expand Down Expand Up @@ -162,29 +154,24 @@ export default async function AccountPage({
// their sum. `searchParams` is a Next-supplied promise with no DB dependency
// of its own, so it joins the same `Promise.all` instead of a separate await.
//
// Four concurrent queries against a pool of `max = 5` (src/db/index.ts).
// Total connection-milliseconds are unchanged — the same four reads, just
// Three concurrent queries against a pool of `max = 5` (src/db/index.ts).
// Total connection-milliseconds are unchanged — the same three reads, just
// overlapped — so average pool occupancy is what it was; only the per-request
// burst grew from 1 slot to 4. That fits, but it is nearly all the headroom:
// burst grew from 1 slot to 3. That fits, but it is nearly all the headroom:
// anything added to this array should be weighed against that 5, whose
// `connectionTimeoutMillis` turns a long wait for a free client into a
// thrown error rather than a slow page.
const [view, { error, done, name, at }, showPayoutsLink, payouts, mainChangeContext] =
await Promise.all([
getAccountView(getDb(), cfg, sess.accountId),
searchParams,
// Same tier-only gate the payouts pages themselves re-check — this only
// decides whether the link appears, never whether the route is reachable.
canReadPayouts(getDb(), sess.accountId),
// Finalized operations only, and only rows whose participant resolved to
// this account — see listAccountPayouts for both, including what the second
// one cannot show.
listAccountPayouts(getDb(), sess.accountId),
// Backs the honest "make main" consequence below — see
// `previewMainChange`'s own doc for why this is a separate query rather
// than a field on `AccountView`.
getMainChangeContext(getDb(), sess.accountId),
]);
const [view, { error, done, name, at }, showPayoutsLink, payouts] = await Promise.all([
getAccountView(getDb(), cfg, sess.accountId),
searchParams,
// Same tier-only gate the payouts pages themselves re-check — this only
// decides whether the link appears, never whether the route is reachable.
canReadPayouts(getDb(), sess.accountId),
// Finalized operations only, and only rows whose participant resolved to
// this account — see listAccountPayouts for both, including what the second
// one cannot show.
listAccountPayouts(getDb(), sess.accountId),
]);
const message = lookupErrorMessage(ACCOUNT_ERRORS, error);
const confirmation = accountConfirmation(done, name);
const now = Date.now();
Expand Down Expand Up @@ -220,43 +207,6 @@ export default async function AccountPage({
now: new Date(now),
});

// The honest "make main" consequence per non-main character — sweep item
// #6. Computed once here, not per-row where the button lives, because the
// sentence is prose too long for that cell (the same reason the remedy
// prose spans a sub-row rather than sitting in one) and because "none" of
// the three outcomes is the common case: a member's alts are usually in the
// same corp as their main, and a blanket warning on every row would be noise
// on exactly the rows where pressing the button changes nothing (PRODUCT.md
// principle 4).
//
// `text` never repeats `c.name` — the rendered block below prefixes
// `<strong>{name}:</strong>` itself, which it still needs because these notes
// sit under the table rather than under the row they describe — and never
// guesses a pronoun for the character's owner; each sentence reads as a
// standalone clause after that prefix instead.
const mainChangeNotes = view.characters
.filter((c) => !c.isMain)
.flatMap((c) => {
const preview = previewMainChange({
tier: mainChangeContext.tier,
tierLocked: mainChangeContext.tierLocked,
allianceId: mainChangeContext.allianceIdByCharacter.get(c.id) ?? null,
configuredAllianceId: cfg.allianceId,
});
if (preview.kind === "none") return [];
const text =
preview.kind === "unknown"
? "Alliance standing not checked yet — setting as main means your tier follows whatever the next check finds."
: preview.nextTier === "alumni"
? `Not in the alliance right now — setting as main moves your tier to ${tierLabel(
"alumni",
)} once the next check runs, and standings and map access follow it.`
: `In the alliance — setting as main moves your tier to ${tierLabel(
"member",
)} once the next check runs.`;
return [{ id: c.id, name: c.name, text }];
});

return (
<>
<SiteHeader items={nav} current="/account" {...brandProps()} />
Expand Down Expand Up @@ -762,23 +712,6 @@ export default async function AccountPage({
// "main"s would otherwise announce a noun with
// no object nine times.
aria-label={`make ${c.name} main`}
// Points at the prose below the Scroller only when
// `mainChangeNotes` produced one for this character
// — most rows change nothing (an alt in the same
// corp as the main), and this button carries no
// `aria-describedby` at all on those, matching the
// STATUS cell's `contactRemedyId` wiring above.
//
// Complements the label above rather than competing
// with it: `aria-label` replaces the name a screen
// reader announces, `aria-describedby` is read after
// it, so the row says what the press does and then
// what it will change.
aria-describedby={
mainChangeNotes.some((n) => n.id === c.id)
? mainChangeNoteId(c.id)
: undefined
}
>
main
</Submit>
Expand Down Expand Up @@ -884,29 +817,6 @@ export default async function AccountPage({
</table>
</Scroller>

{/* State before action (PRODUCT.md principle 2), read by anyone before
they press "make main", not surfaced as a tooltip or screen-reader-
only aside. Only the characters where pressing it actually moves the
tier get a sentence — an alt already in the same corp as the main
gets none, so this never reads as a warning on a press that changes
nothing.

Stays below the table, unlike the remediation prose that moved into
a sub-row: a remedy marks a character that is currently broken, and
adjacency to that row is the whole point. This note describes what a
control *would* do on a row that is fine, and up to nine of them can
apply at once — a sub-row each would spend the fold budget this
round exists to reclaim, on rows where nothing is wrong. */}
{mainChangeNotes.length > 0 && (
<div className="table-notes">
{mainChangeNotes.map((n) => (
<p key={n.id} id={mainChangeNoteId(n.id)} className="table-note">
<strong>{n.name}:</strong> {n.text}
</p>
))}
</div>
)}

<p className="btn-row pager">
{/* Demoted to the default grade whenever the page is reporting
anything: DESIGN.md rations gold to one primary action per view,
Expand Down
23 changes: 3 additions & 20 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -3718,26 +3718,9 @@ h3.rule-head__label {
color: var(--ink-faint);
}

/* A run of the same notes placed *after* the table instead of before it. The
caption position needs no top margin, so a .table-note following a table's
last row butts straight against the border; this restores the gap without
changing the caption case. The last note's own bottom margin is dropped so
the group spaces against what follows it, not doubly.

One consumer left: the "make main" consequence notes. The remediation prose
that was the other moved into a spanning sub-row inside the table. */
.table-notes {
margin-top: var(--s-4);
}

.table-notes .table-note:last-child {
margin-bottom: 0;
}

/* The container rule above only reaches a note inside `.table-notes`, and the
drawer row's note is not in one. A `<td>` does not collapse a child's margin
out, so without this the drawer row would carry `--s-4` of dead space under
prose whose whole point is a minimal row height. */
/* A `<td>` does not collapse a child's margin out, so without this the drawer
row would carry `--s-4` of dead space under prose whose whole point is a
minimal row height. */
.drawer-row .table-note {
margin-bottom: 0;
}
Expand Down
46 changes: 0 additions & 46 deletions src/core/tier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,49 +23,3 @@ export function decideTier(input: {
const desired = input.mainInAlliance ? "member" : "alumni";
return input.tier === desired ? null : desired;
}

/**
* What pressing "make main" for one candidate character would do to the
* account's tier, stated honestly rather than as a blanket warning —
* `/account`'s sweep item #6. Reuses `decideTier` rather than re-deriving the
* state machine: the membership job (`jobs/membership.ts`) is the only other
* caller of that rule, and a second copy here would drift the moment either
* changed.
*
* `allianceId` is the candidate's own LAST-CACHED affiliation
* (`character.allianceId`), written by the membership job, not a live ESI
* read — the web tier never calls ESI directly (see the repo's web-tier
* guardrail). `null` means the job has never resolved this character at all,
* which is exactly the freshest-alt case the sweep item describes: a member
* links an alt and presses "make main" before any job has run against it. That
* is reported as `"unknown"` rather than folded into "no change", because
* `decideTier`'s own `!mainConfirmed` branch answers "not confirmed THIS RUN"
* — true of every account between job runs — and would silently claim "no
* consequence" for the one candidate this preview most needs to flag.
*
* An already-locked account never returns `"change"`: `decideTier` would
* refuse the same account for the same reason (`tierLocked` short-circuits
* it), so nothing "make main" does here can move an account the membership
* job has already stopped touching.
*/
export type MainChangePreview =
| { kind: "none" }
| { kind: "unknown" }
| { kind: "change"; nextTier: "member" | "alumni" };

export function previewMainChange(input: {
tier: Tier;
tierLocked: boolean;
allianceId: number | null;
configuredAllianceId: number;
}): MainChangePreview {
if (input.tierLocked) return { kind: "none" };
if (input.allianceId === null) return { kind: "unknown" };
const next = decideTier({
tier: input.tier,
tierLocked: false,
mainConfirmed: true,
mainInAlliance: input.allianceId === input.configuredAllianceId,
});
return next === null ? { kind: "none" } : { kind: "change", nextTier: next };
}
38 changes: 1 addition & 37 deletions src/services/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import type { Config } from "@/config";
import { TOKEN_FAULT_RESULTS } from "@/core/contact-result";
import type { Tier } from "@/core/tier";
import type { Dbx, DbTx } from "@/db";
import type { DbTx } from "@/db";
import {
account,
bootstrapAdminGrant,
Expand Down Expand Up @@ -528,41 +527,6 @@ export async function unlinkCharacter(
return { ok: true };
}

/**
* Read-only context for previewing what "make main" would do to the
* account's tier — `core/tier.ts`'s `previewMainChange`, called once per
* candidate on `/account`. Deliberately its own query rather than a field
* added to `AccountView` (`services/account-view.ts`): that module's shape is
* being edited by another change in this same pass, and this is a page-scoped
* concern (the consequence of one specific action) rather than a fact about
* the account worth carrying on every read of it.
*
* No `.for("update")` — this backs a page render, not a mutation, and the
* account/character rows it reads are re-locked and re-checked for real
* inside `setMainCharacter` and the membership job at the point either of
* them actually acts.
*/
export async function getMainChangeContext(
dbx: Dbx,
accountId: string,
): Promise<{
tier: Tier;
tierLocked: boolean;
allianceIdByCharacter: Map<number, number | null>;
}> {
const [acc] = await dbx.select().from(account).where(eq(account.id, accountId));
if (!acc) throw new Error("account not found");
const chars = await dbx
.select({ id: character.id, allianceId: character.allianceId })
.from(character)
.where(eq(character.accountId, accountId));
return {
tier: acc.tier,
tierLocked: acc.tierLocked,
allianceIdByCharacter: new Map(chars.map((c) => [c.id, c.allianceId])),
};
}

export async function setMainCharacter(
dbx: DbTx,
accountId: string,
Expand Down
29 changes: 0 additions & 29 deletions tests/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
} from "@/db/schema";
import {
demoteAdmin,
getMainChangeContext,
handleEveLogin,
linkCharacter,
maybeGrantBootstrapAdmin,
Expand Down Expand Up @@ -584,34 +583,6 @@ describe("setMainCharacter", () => {
});
});

// The read backing `/account`'s "make main" consequence preview
// (`core/tier.ts`'s `previewMainChange`) — sweep item #6.
describe("getMainChangeContext", () => {
it("carries the account's tier, lock state, and each character's cached allianceId", async () => {
const a = await login(ch());
await link(a.accountId, ch({ characterId: 90000003, characterName: "Alt" }));
// The membership job is the only writer of `character.allianceId`
// (jobs/membership.ts) — never set at link time — so this simulates a
// character the job HAS resolved, alongside the freshly-linked one it
// hasn't touched yet (still `null` from `link` above).
await ctx.db
.update(character)
.set({ allianceId: 99000001 })
.where(eq(character.id, 90000001));
const context = await getMainChangeContext(ctx.db, a.accountId);
expect(context.tier).toBe("pending");
expect(context.tierLocked).toBe(false);
expect(context.allianceIdByCharacter.get(90000001)).toBe(99000001);
expect(context.allianceIdByCharacter.get(90000003)).toBeNull();
});

it("throws for an account that no longer exists", async () => {
await expect(
getMainChangeContext(ctx.db, "00000000-0000-0000-0000-000000000000"),
).rejects.toThrow();
});
});

describe("re-auth side effects", () => {
it("audits, enqueues, and downgrades status when scopes shrink", async () => {
await login(ch());
Expand Down
Loading