Questify

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 turns Title | description lines into new rows at the end of the list; the rows are the only thing saved. /admin/quest-packs gains 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 behind ConfirmAction; 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 — description instead 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/privileges gains 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 at text-sm. The table moved into a new PrivilegeCatalog client 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 from lg; 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 native title tooltip, 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 from lg and 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 of user_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 from NEAR_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 from profiles, roles, privileges and model_personas for 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 the role.delete audit 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_id is on 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 and GET /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, and parseAdminUserFilters() and the page's search read called .trim() on it, throwing a server-side TypeError. Every Users-page parameter — search, paging, sort and the five filters — now goes through singleSearchParam(), 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 lg the interview ran inline in the New Quest page, in a ~600px card whose thread was capped at min(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") below lg now opens the interview as a full-screen panel, sized to window.visualViewport (iOS Safari does not shrink 100dvh for 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 across lg with 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 a SideSheet or a portal, because either would unmount the chat on close or at the lg boundary and lose the conversation a follow-up continues (#764). At lg and 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 exports viewport with interactive-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. useModalFocus now 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.sql and 20261105150000_onboarding_pick_guard.sql add Quest Pack archiving, a guarded delete, and an onboarding read that survives both (#789). quest_packs.archived_at marks a retired pack, and the quest_packs_archived_inactive check 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 returns in_use: a plain delete after reading the counts could race a clone or a pick made in between, and both quests.quest_pack_id and user_onboarding.selected_quest_pack_id are on 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 without quest_packs.write, are closed to anon, 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 ordinary tasks policies. Because that read ignores is_active, the user_onboarding_guard_pick trigger refuses (42501) a client setting selected_quest_pack_id to a pack that is not active, or setting a finished completed_at back to null — the owner policy on user_onboarding would 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 sends completed_at: null on a mid-wizard step, so a step posted from a stale tab cannot reopen a finished onboarding.

  • 20261104120000_admin_user_signups_between.sql adds admin_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 raises 22023 rather 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 to anon, granted to authenticated with is_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 in supabase/tests/admin_user_analytics.sql and the anon privilege check in rls.sql.

  • 20261103120000_admin_user_search.sql adds admin_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 raise 22023. The same migration drops and recreates user_ai_spend_report() with a trailing _user_id that narrows the rows to one account after the totals are taken, so the totals stay those of every account. Both are security definer, refuse anyone is_admin() rejects and are closed to anon; the advisor allowlist names the new signatures. Covered by supabase/tests/admin_user_search.sql (new), user_ai_spend_report.sql and rls.sql.

CI/CD

  • The unit-test, build and CI-pass jobs in ci.yml now 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 own runner.temp directory, 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.ts pins parseAdminUserFilters() dropping every repeated filter, and the Users page test renders a repeated q, role, plan, page and sort and 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 new mobile Playwright 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/interview answered 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 across lg and back keeps focus in the answer field, and reopening shows the same conversation. Vitest covers the panel in CoachingEntry (dialog semantics below lg only, the card left behind it, the persona chip in one place, the return-focus target after a draft), CoachingInterview in panel mode (Escape, the mid-turn refusal, the scroll lock, the viewport variables, focus handed back), the new use-viewport-sheet hooks, and useModalFocus (new test file), including a modal stacked on top keeping Escape and Tab and the new keepFocusWhileShown option 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.read only); the clone count includes other owners' quests in any status; a cloned pack, or one picked in an unfinished onboarding, comes back in_use and keeps its references, a finished onboarding's pick does not block, an unused one is deleted with its tasks, a missing one is not_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) pins get_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 to anon — 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.sql asserts all three functions are closed to anon. 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.tsx pins the tabs and counts, the Clones column, Delete versus Archive, and Delete withheld when counts fail; pack-actions.test.ts, pack-utils.test.ts and packs.test.ts cover 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.tsx pins the described select options and the linked, zero-unlinked user counts; roles.test.ts pins rolesByPrivilege(); the shell contract now checks privilege-catalog.tsx for DataTable (#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.tsx covers the section nav and the sections' scroll margins, the single ten-tile grid, preset and custom ranges read through admin_user_signups_between, the From/To form and its defaults, and a refused range explained with the 30-day fallback. user-analytics.test.ts covers parseSignupRange() (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, and signupMovingAverage(). The Playwright analytics spec now submits a one-day custom range from the section nav.

  • app/admin/page.test.tsx pins 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.ts covers each audit action's sentence and link, the fallbacks for a deleted target or actor, the account-deletion actor wording (kept equal to ACCOUNT_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() and getMonthGrossAiCredits() have unit tests, including the unbudgeted-feature exclusion and a rejected read, and role-actions.test.ts pins the name in the role.delete audit row.

  • supabase/tests/admin_user_search.sql (22 assertions) pins admin_list_users() (#786): refused to a non-admin and to anon; 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; and 22023 for unknown values. user_ai_spend_report.sql gains two assertions for _user_id narrowing rows but not totals. Vitest covers listAdminUsers() against the RPC (arguments, the total re-read past the last page, roles, errors) and parseAdminUserFilters(); 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.