Skip to content

Commit 27ecfe3

Browse files
committed
fix(admin): open the date column newest-first
An admin coming back to /admin/accounts asks one question of "Tier changed": what moved while I was away. The column opened ascending, so the top of the table filled with the most settled accounts on the roster — the ones nothing has happened to since last spring — and the answer sat at the bottom, behind a second click, every visit. On a page whose own lede says the sync jobs change things without telling anyone, that is the wrong end of the column to land on. `SORT_OPENS` gives each key the direction it opens on its FIRST press. Name, Tier and Cryo keep ascending, which is right for a value you already have in mind and are scanning for. Only `tierChangedAt` opens descending. Once a column is active it toggles exactly as before — this changes the opening direction and nothing else. The spec asserts the URL, `aria-sort`, and the rendered row order, with the two seeded accounts named so alphabetical order and recency order disagree — otherwise a sort that did nothing at all would pass. The tier-change instants are stamped with a direct update rather than by widening `seedMember`, which five other spec files share. Verified to fail with the fix reverted (`Received: …dir=asc`). typecheck, eslint, prettier --check .: clean. npm test: 77 files, 1134 tests passed. npx playwright test: 233 passed, 233 ✓ lines counted.
1 parent 38bb6bd commit 27ecfe3

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

e2e/admin.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,64 @@ test("sortable headers say so at rest, without changing their accessible names",
399399
await expect(page.getByRole("link", { name: "Tier", exact: true })).toBeVisible();
400400
});
401401

402+
/**
403+
* The direction a column opens on its first click.
404+
*
405+
* Every header opened ascending, which is right for Name, Tier and Cryo — a
406+
* value you already have in mind, found from the top. It is wrong for "Tier
407+
* changed": nobody opens that column to find the account nothing has happened
408+
* to since last spring. They open it to answer "what moved while I was away",
409+
* and ascending answers the opposite question, putting the answer at the
410+
* bottom of the roster behind a second click.
411+
*
412+
* Asserted through the URL and through the rendered order, because the URL
413+
* alone would pass if `dir` stopped reaching the query.
414+
*/
415+
test("the date column opens newest-first; the others still open ascending", async ({
416+
page,
417+
context,
418+
}) => {
419+
const admin = await seedMember(db, { name: "Boss", tier: "member", isAdmin: true });
420+
await context.addCookies([await sessionCookieFor(db, admin.id)]);
421+
// Two accounts whose tier moved a year apart, named so that alphabetical
422+
// order and recency order disagree — otherwise a sort that did nothing at
423+
// all would pass this test.
424+
const ancient = await seedMember(db, { name: "Ancient", tier: "member" });
425+
const zeta = await seedMember(db, { name: "Zeta", tier: "member" });
426+
// `seedMember` does not take a tier-change instant — stamped here rather
427+
// than by widening a helper five other spec files share.
428+
await db
429+
.update(account)
430+
.set({ tierChangedAt: new Date("2024-01-01T00:00:00Z") })
431+
.where(eq(account.id, ancient.id));
432+
await db
433+
.update(account)
434+
.set({ tierChangedAt: new Date("2026-01-01T00:00:00Z") })
435+
.where(eq(account.id, zeta.id));
436+
await page.goto("/admin/accounts");
437+
438+
await page.getByRole("link", { name: "Tier changed", exact: true }).click();
439+
await expect(page).toHaveURL(/[?&]sort=tierChangedAt(&|$)/);
440+
await expect(page).toHaveURL(/[?&]dir=desc(&|$)/);
441+
await expect(
442+
page.getByRole("columnheader", { name: "Tier changed", exact: true }),
443+
).toHaveAttribute("aria-sort", "descending");
444+
// Zeta moved last, so it is first. Boss and Ancient are older.
445+
await expect(page.locator(".log--dense tbody tr").first()).toContainText("Zeta");
446+
447+
// Once active the column toggles as it always did — this change is about
448+
// the first press only.
449+
await page.getByRole("link", { name: /^Tier changed/ }).click();
450+
await expect(page).toHaveURL(/[?&]dir=asc(&|$)/);
451+
await expect(page.locator(".log--dense tbody tr").first()).toContainText("Ancient");
452+
453+
// A non-date column is untouched: still ascending on its first press.
454+
await page.goto("/admin/accounts");
455+
await page.getByRole("link", { name: "Tier", exact: true }).click();
456+
await expect(page).toHaveURL(/[?&]sort=tier(&|$)/);
457+
await expect(page).toHaveURL(/[?&]dir=asc(&|$)/);
458+
});
459+
402460
test("tier controls: manual set locks; return-to-auto unlocks", async ({
403461
page,
404462
context,

src/app/admin/accounts/page.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ const SORTS: Array<{ key: AdminListSort; label: string }> = [
5454
{ key: "status", label: "Cryo" },
5555
{ key: "tierChangedAt", label: "Tier changed" },
5656
];
57+
58+
/**
59+
* Which way a column opens on the FIRST click, before it has a direction of
60+
* its own. Only the direction of that first press; once a column is active it
61+
* toggles as it always did.
62+
*
63+
* `name`, `tier` and `status` open ascending because A-first and
64+
* `member`-first are how someone looks for a value they already have in mind.
65+
* `tierChangedAt` is not that kind of column. Nobody clicks "Tier changed"
66+
* to find the account nothing has happened to since last spring; they click it
67+
* to answer "what moved while I was away", and ascending answers the opposite
68+
* question — the top of the table fills with the most settled accounts on the
69+
* roster and the answer sits at the bottom, one more click away, on a page
70+
* whose whole reason to exist is that the sync jobs change things without
71+
* telling anyone.
72+
*/
73+
const SORT_OPENS: Record<AdminListSort, "asc" | "desc"> = {
74+
name: "asc",
75+
tier: "asc",
76+
status: "asc",
77+
tierChangedAt: "desc",
78+
};
5779
// What an admin may manually assign. Pending is deliberately absent: it is a
5880
// state accounts are born in, and setTierManual locks whatever it sets.
5981
const TIERS = ["member", "associate", "alumni"] as const;
@@ -420,7 +442,17 @@ export default async function AdminAccountsPage({
420442
<a
421443
href={qs({
422444
sort: s.key,
423-
dir: sort === s.key && dir === "asc" ? "desc" : "asc",
445+
// Active column: toggle. Inactive: open the way the
446+
// column is worth reading (`SORT_OPENS`), rather than
447+
// ascending for everything — which cost a second click
448+
// on the one column where ascending is never the
449+
// question being asked.
450+
dir:
451+
sort === s.key
452+
? dir === "asc"
453+
? "desc"
454+
: "asc"
455+
: SORT_OPENS[s.key],
424456
})}
425457
>
426458
{s.label}

0 commit comments

Comments
 (0)