3.2.0
Released
Features
Quest Pack tasks are edited as rows, and packs can be filtered, counted and retired (#789). The pack drawer's pipe-delimited textarea — where a
|inside a title broke parsing, the only way to reorder was cut and paste, and one error covered every task — is now a list of Title / Description rows with Add task, move up and down, and remove, each row reporting its own error and focus following the row that moved. The old format survives as a collapsed "Paste tasks" box that turnsTitle | descriptionlines into new rows at the end of the list; the rows are the only thing saved./admin/quest-packsgains Active · Inactive · Archived tabs with counts (?status=, kept through the drawer), a Clones column, and a per-row choice: Delete for a pack nothing uses, Archive for one that a quest was started from or that a new user picked during an onboarding still under way (shown as "+1 in onboarding" under the count), both behindConfirmAction; an archived pack can be unarchived back to Inactive, or deleted once it has no clones. The move arrows now swap with the neighbour in the same tab rather than with a pack the admin cannot see. If the clone counts fail to load, the list says so and offers only Archive rather than treating unknown as unused. A pack archived or switched off after a new user picked it in onboarding still seeds their first quest, and the step still shows its name. While the drawer is saving, the task rows cannot be added, moved or removed, so the errors that come back land on the rows that were posted; the result of a delete, archive or unarchive shows in the list's banner even though the row has left the tab. The admin dashboard's Quest Packs tile no longer counts archived packs.The admin Roles and Privileges pages say what a privilege does and who holds it (#788). The "Add a privilege" select in a role's drawer now reads
key — descriptioninstead of the bare key, so assigning one no longer means opening Privileges in another tab, and the privilege chips in the Roles list carry the description as a tooltip. A role's Users count links to/admin/users?role=<id>, the Users list filtered to that role; zero stays plain text, and a count that could not be read is still a dash./admin/privilegesgains a "Roles using this privilege" column whose role chips open that role's edit drawer (/admin/roles?edit=<id>, not the#<role-key>anchor the issue sketched — the list has no per-role anchors since #784), a client-side filter over key, description and granting role, and descriptions attext-sm. The table moved into a newPrivilegeCatalogclient component for the filter; the page still reads both catalogs on the server, and a failed roles read leaves the catalog up with an alert and a dash per row rather than failing the page. The rename half of the issue had already landed with the #784 drawer, which is also where the user counts came from.User Analytics is one screen of headline figures with a way around the rest (#787). A sticky pill nav under the header — below the mobile admin bar on phones, at the top from
md— links Accounts, Signups, Onboarding, Activation and Plans, marking the section in view; the sections already carried ids and a scroll margin for a bar that did not exist. The ten small counts, which sat in three full-width rows across two sections (~340px for ten numbers), are one grid of tighter tiles, five across fromlg; the onboarding totals moved up into it as Could onboard, Started onboarding and Finished onboarding, and the Onboarding section keeps its per-step table. The signup chart can be read without hovering: gridlines labelled 0, half and the busiest day replace the one-line "busiest day N", short ranges (up to 14 days) print each day's count above its bar, and ranges of 14 days or more add a trailing 7-day average line with a legend and a column in the table fallback. The nativetitletooltip, which never appeared on touch, is replaced by a visible one that follows the pointer, stays up after a tap, and steps through the days with the arrow keys from a single tab stop with a polite screen-reader announcement. From/To day inputs beside the 7/30/90 presets submit as a shareable?from=&to=URL (UTC days, inclusive, up to 366); a missing or future end means today, and a range the page cannot show — a day repeated in the URL included, which Next.js passes as an array — says why and shows the last 30 days instead rather than quietly charting something else. A range that crosses New Year shows the year on its axis.The admin dashboard reads at a glance and every part of it goes somewhere (#785). Seven stat tiles in a four-column grid left an empty slot, three of them were Feedback variants with the same icon, and Quests was the only tile that went nowhere. There are now six — Total Users, Active 7d (from
getUserAnalyticsSummary()), Feedback, Quests, Quest Packs and AI spend this month — in two rows of three fromlgand three rows of two below, so neither width has an orphan. Feedback is one tile, "12 open · 40 total · 25 resolved", whose each segment links to its?status=filter; Quests links to Analytics' activation funnel until an admin quest list exists; AI spend is every account's gross credits for the current UTC month in dollars (getMonthGrossAiCredits(), one row ofuser_ai_spend_report) and links to AI Spend. Tile links have a visible keyboard focus ring and the hover border is driven from the link. The "Quick links" row, six destinations already in the side bar beside it, is replaced by three counted shortcuts the side bar does not offer: Open feedback, Never signed in (the Users list filtered to those accounts since #786) and Features near AI ceiling (budgeted platform features past 80% of their monthly ceiling,featuresNearCeiling(), the same line as the amber bar on AI Spend, which now reads it fromNEAR_CEILING_FRACTION). Recent Activity rows are sentences — "Shawn deleted role Moderator", "Shawn gave Ada the Moderator role" — with the actor's display name and target names read fromprofiles,roles,privilegesandmodel_personasfor just the ids the rows mention (lib/admin/audit-activity.ts), and each row is a link to the role or persona drawer or the user, or to the Audit Log filtered to that action when the target no longer exists.deleteRoleAction()now records the role's display name beside its key in therole.deleteaudit metadata, since the row cannot be looked up once it is gone, so "deleted role …" names it as admins saw it; rows written before this fall back to the key. A row whose actor's account has since been deleted (actor_idison delete set null) reads "A deleted account …" rather than being attributed to the system. A failed name lookup falls back to short ids rather than emptying the list, and every count on the page — the tiles, the Feedback segments and the shortcuts — shows "—" when its read fails rather than a zero that would read as a finding ("Open feedback 0" after a failed query looked like an empty queue). The Roles tile is gone; Roles stays in the side bar.The admin Users list answers the questions support starts from (#786). The search box finds an account by a pasted email or full user id as well as by display name, and five filters — Plan, Plan set by, Role, Onboarding and Sign-in — sit under it in the same GET form, so every filtered list is a link (
?plan=,?source=,?role=,?onboarding=,?signed_in=) that keeps the current sort and page size. The count line names what is applied ("120 users · Pro · Never signed in"), a filtered list with no matches offers to clear them, and an unrecognised value in a hand-edited URL is dropped rather than failing the page. Last active and Quests are now sortable column headers. Each row's ⋯ menu links to that account's AI spend (/admin/ai-spend?user=, the account report narrowed to one row with every account's totals kept), feedback (/admin/feedback?user=, a new submitter filter on the list andGET /api/admin/feedback, which 422s an id that is not a UUID) and audit entries, and copies the whole user id. On User Analytics, Never signed in and onboarding Finished link to the matching filtered list, as does each Plans row's account count — manual Pro included — and the admin dashboard's Never signed in shortcut now opens that list rather than the Analytics section.
Fixes
A repeated query parameter on
/admin/users(?role=<id>&role=<id>,?q=a&q=b) no longer takes the page down. Next.js hands a repeated parameter to the page as an array, andparseAdminUserFilters()and the page's search read called.trim()on it, throwing a server-sideTypeError. Every Users-page parameter — search, paging, sort and the five filters — now goes throughsingleSearchParam(), which treats a repeated value as absent, the same way an unrecognised filter value is already dropped, so the link lands on the unfiltered list rather than an error. It does not guess which of the repeated values was meant (#786).The AI Quest Coach works on a phone with the keyboard up (#853). Below
lgthe interview ran inline in the New Quest page, in a ~600px card whose thread was capped atmin(420px, 45vh), so on a phone with Safari's toolbars and the keyboard showing — about 330px of screen — the question being answered and the answer field could not both be on screen. Starting the coach (or "Ask for changes") belowlgnow opens the interview as a full-screen panel, sized towindow.visualViewport(iOS Safari does not shrink100dvhfor the keyboard) and re-pinning the thread to its newest message as the keyboard opens; the answer field's growth is capped at 30% of the visible height so the thread keeps the rest. The panel traps focus, locks the page behind it, and closes on Escape or Cancel — both refused while a turn is in flight, so a paid-for reply is not dropped — handing focus back to the button that opened it; Continue closes it onto the filled form with focus on "Ask for changes". A tablet rotated acrosslgwith the chat open switches between panel and column without closing it, and focus stays in the chat. It is the same interview element restyled, not aSideSheetor a portal, because either would unmount the chat on close or at thelgboundary and lose the conversation a follow-up continues (#764). Atlgand up the coach column is unchanged, except that reopening it now lands on the newest message rather than the top of the thread. The root layout now exportsviewportwithinteractive-widget=resizes-content, so Chrome on Android shrinks the layout viewport for the keyboard app-wide instead of sliding it over fixed and sticky elements.useModalFocusnow leaves the keyboard to another modal opened on top of it — the persona picker's portalled dialog, opened from the chip in the panel's header — where Escape used to close the panel underneath and Tab was dragged back out of the picker.
Technical changes· 11 entries for database, tooling, CI, tests and docs
Database
20261105120000_quest_pack_archive.sql,20261105130000_quest_pack_delete_onboarding_guard.sql,20261105140000_onboarding_selected_pack.sqland20261105150000_onboarding_pick_guard.sqladd Quest Pack archiving, a guarded delete, and an onboarding read that survives both (#789).quest_packs.archived_atmarks a retired pack, and thequest_packs_archived_inactivecheck keeps an archived pack inactive, so the existing public read policy already hides it and nothing can clone it.admin_quest_pack_clone_counts()returns, per pack, how many quests were started from it — any owner, any status — and how many onboardings that have not finished have it selected, counts only, because both tables are owner-only under RLS and an admin's own session would count only their own.admin_delete_quest_pack(_pack_id)locks the pack row before it counts, then deletes the pack only if no quest points at it and no unfinished onboarding has picked it, and otherwise returnsin_use: a plain delete after reading the counts could race a clone or a pick made in between, and bothquests.quest_pack_idanduser_onboarding.selected_quest_pack_idareon delete set null, so that quest would quietly lose its pack, or that new user's first quest would be created without it. Both refuse 42501 withoutquest_packs.write, are closed toanon, and are allowlisted for the signed-in definer advisor like the other admin reads. Archiving and unarchiving are ordinary updates under the existing admin policy.get_onboarding_selected_pack()returns the name and template tasks of the pack the caller picked in their own unfinished onboarding, whether or not it is still active: catalogue reads only see active packs, so a pack archived or switched off between the pick and the first-quest step used to clone nothing. It only reads; the app inserts the tasks under the ordinarytaskspolicies. Because that read ignoresis_active, theuser_onboarding_guard_picktrigger refuses (42501) a client settingselected_quest_pack_idto a pack that is not active, or setting a finishedcompleted_atback to null — the owner policy onuser_onboardingwould otherwise let anyone point their pick at a hidden pack's id and read its tasks. A pick left unchanged is not re-checked, which is what lets a pack retired after it was chosen still seed the first quest. The wizard no longer sendscompleted_at: nullon a mid-wizard step, so a step posted from a stale tab cannot reopen a finished onboarding.20261104120000_admin_user_signups_between.sqladdsadmin_user_signups_between(_from, _to)(#787): new non-system accounts per UTC calendar day over any inclusive range of up to 366 days, quiet days included, so User Analytics can answer "signups since the launch post on the 14th" —admin_user_signups(_days)always ended today and clamped to 7–90 days. A missing end, an end before the start or a longer range raises22023rather than being clamped, since a range nobody asked for would chart as an answer; the admin check runs first, so a non-admin learns nothing from the arguments.security definer, closed toanon, granted toauthenticatedwithis_admin()deciding, and named in the advisor allowlist.admin_user_signups(_days)is left in place but no longer called, so the previous build keeps working while the migration lands ahead of it. Covered by nine new assertions insupabase/tests/admin_user_analytics.sqland the anon privilege check inrls.sql.20261103120000_admin_user_search.sqladdsadmin_list_users()(#786): one page of accounts for/admin/users, searched, filtered and sorted in the database with the number that matched. A whole user id or a whole email address finds that one account — the email match is exact and case-insensitive, so the list cannot be browsed by email fragment — and anything else matches display names, with%and_typed into the box matched literally. Filters cover plan, plan source (billing or manual), role, onboarding stage (including "not finished") and sign-in state; sorts cover signup, name, approximate last activity (never-active accounts count as the oldest) and quests owned. A system account is listed and searchable but matches no plan, onboarding or sign-in filter, so a filtered list counts exactly what the User Analytics figure it was opened from counts. Unknown filter or sort values raise22023. The same migration drops and recreatesuser_ai_spend_report()with a trailing_user_idthat narrows the rows to one account after the totals are taken, so the totals stay those of every account. Both aresecurity definer, refuse anyoneis_admin()rejects and are closed toanon; the advisor allowlist names the new signatures. Covered bysupabase/tests/admin_user_search.sql(new),user_ai_spend_report.sqlandrls.sql.
CI/CD
The unit-test, build and CI-pass jobs in
ci.ymlnow run on the self-hosted macOS runner instead of GitHub-hosted Ubuntu; e2e, CodeQL and the production release jobs stay on GitHub-hosted runners, so pull-request code never runs on the machine that holds the Vercel and Fly deploy tokens. Each self-hosted job installs pnpm into its ownrunner.tempdirectory, because the action's default~/setup-pnpm— which also holds the pnpm store — is shared by every runner instance on the machine, and concurrent jobs deleted each other's store mid-install. (#860)
Tests
users.test.tspinsparseAdminUserFilters()dropping every repeated filter, and the Users page test renders a repeatedq,role,plan,pageandsortand asserts the list is read unsearched, unfiltered and from the first page (#786).e2e/quest-coach.mobile.spec.ts(new) drives the coach on a Pixel 7 in a newmobilePlaywright project (*.mobile.spec.ts, ignored by the desktop project) against a fixture route,/e2e-fixtures/coach, that renders the New Quest page with the coach on for a signed-out visitor, with/api/coach/interviewanswered by the spec — so it runs in CI's smoke mode (#853). It covers open → answer → draft → Continue with the viewport shrunk to stand in for the keyboard, asserting the answer field and the newest message are inside the visual viewport after each step, the panel is full screen and the page locked, Continue lands on the filled form with focus on "Ask for changes", Escape and Cancel return focus to the trigger, rotating acrosslgand back keeps focus in the answer field, and reopening shows the same conversation. Vitest covers the panel inCoachingEntry(dialog semantics belowlgonly, the card left behind it, the persona chip in one place, the return-focus target after a draft),CoachingInterviewin panel mode (Escape, the mid-turn refusal, the scroll lock, the viewport variables, focus handed back), the newuse-viewport-sheethooks, anduseModalFocus(new test file), including a modal stacked on top keeping Escape and Tab and the newkeepFocusWhileShownoption leaving focus in content that is still on screen.supabase/tests/admin_quest_pack_archive.sql(21 assertions) pins the archive and guarded delete (#789): both functions refused to a player and to Support (quest_packs.readonly); the clone count includes other owners' quests in any status; a cloned pack, or one picked in an unfinished onboarding, comes backin_useand keeps its references, a finished onboarding's pick does not block, an unused one is deleted with its tasks, a missing one isnot_found; an archived pack cannot be made active and is invisible to a player; only an admin can archive.supabase/tests/onboarding_selected_pack.sql(13 assertions, new) pinsget_onboarding_selected_pack()— the caller's own unfinished pick, archived included, with tasks in order; nothing for a caller without a pick or after onboarding finishes; closed toanon— and the pick guard: a client cannot pick an archived pack or reopen a finished onboarding, can pick an active pack or clear the pick, and a pick retired after it was made does not block the rest of the wizard.rls.sqlasserts all three functions are closed toanon.components/quest-packs/quest-pack-task-editor.test.tsx(new) pins the row editor's posted fields, add / move / remove with focus, paste-to-append, per-row errors that follow their task when it moves and clear when it is edited, and row keys that match between server renders;quest-pack-admin-panel.test.tsxpins the tabs and counts, the Clones column, Delete versus Archive, and Delete withheld when counts fail;pack-actions.test.ts,pack-utils.test.tsandpacks.test.tscover row parsing with row-indexed errors, the delete, archive and unarchive actions, the tab-aware move and the clone-count read.components/admin/privilege-catalog.test.tsx(new) pins the Privileges table's three columns, role chips linking to their edit drawers, filtering by key, description and role name with a no-match message, the roles-unavailable alert, and no filter over an empty or failed catalog;role-admin-panel.test.tsxpins the described select options and the linked, zero-unlinked user counts;roles.test.tspinsrolesByPrivilege(); the shell contract now checksprivilege-catalog.tsxforDataTable(#788).app/admin/analytics/signup-chart.test.tsx(new) pins the chart's scale (gridline labels, no duplicate line when half rounds onto the top), per-bar counts up to 14 days only, the 7-day average line, legend and table column from 14 days, the tooltip on focus, arrow keys, Home/End, Escape, blur, mouse and tap, the year on a range that crosses New Year, and a 366-bar range (#787).page.test.tsxcovers the section nav and the sections' scroll margins, the single ten-tile grid, preset and custom ranges read throughadmin_user_signups_between, the From/To form and its defaults, and a refused range explained with the 30-day fallback.user-analytics.test.tscoversparseSignupRange()(precedence over?days=, open and future ends, the 366-day boundary, malformed, reversed and repeated ranges), UTC day arithmetic against a local clock already on the next day, andsignupMovingAverage(). The Playwright analytics spec now submits a one-day custom range from the section nav.app/admin/page.test.tsxpins the dashboard (#785): six tiles that all link, the merged Feedback tile's three segment links, shortcuts whose destinations are not side-bar items, an activity row that reads as a sentence and links to its record, and a dash rather than zero when the analytics, AI spend, users, quests, quest packs or feedback reads fail.lib/admin/audit-activity.test.tscovers each audit action's sentence and link, the fallbacks for a deleted target or actor, the account-deletion actor wording (kept equal toACCOUNT_DELETE_AUDIT_ACTION), a deleted role named from its recorded display name with the key as the fallback, an action by a since-deleted admin never labelled as the system, and that names are looked up only for the ids the rows mention and survive a failed lookup.featuresNearCeiling()andgetMonthGrossAiCredits()have unit tests, including the unbudgeted-feature exclusion and a rejected read, androle-actions.test.tspins the name in therole.deleteaudit row.supabase/tests/admin_user_search.sql(22 assertions) pinsadmin_list_users()(#786): refused to a non-admin and toanon; exact, case-insensitive email matching with fragments and bare domains finding nothing; a whole id in either case; literal%; each filter, including the system account excluded from plan and sign-in filters; both directions of the last-active sort with never-active accounts as the oldest; the quests sort; paging with the total over every match; and22023for unknown values.user_ai_spend_report.sqlgains two assertions for_user_idnarrowing rows but not totals. Vitest coverslistAdminUsers()against the RPC (arguments, the total re-read past the last page, roles, errors) andparseAdminUserFilters(); the Users page's filters, count line, hidden sort fields, empty state, sortable activity headers and row-menu links; the feedback API and list?user=filter; AI Spend's one-account view; and the Analytics drill-down links.