3.13.0
Released
Features
A recurring Quest is now one card on
/questsinstead of one per cycle — ten days of a daily Quest had been ten near-identical cards ahead of everything else, and the chips counted every cycle. The search, status, ownership and Year Theme filters still look at every cycle, so Completed finds a daily Quest by its completed cycles and a search matches any cycle's title, and each recurring Quest's matching cycles then fold into one card: today's cycle (the/tasksQuest list's rule), else the next to start, else the most recently updated — or, under Completed, Failed or Archived, its latest matching cycle. The chips, "Showing N of M" and Show more count Quests, and every sort places the card by the cycle it shows. The card and the rows layout read "Cycle 11 · 11 cycles", and the count links to/quests?template=<id>, a new index param that lists that Quest's cycles one card each, newest first under the default sort, with the chips, search, sorts, layout and paging working within it, under a chip naming the Quest ("Daily check-in — 11 cycles") that leads back. An id with no cycle the viewer can see lists nothing under the filtered empty state. The quest page's Repeats row gains an All cycles link to the same list, for party members as well as the owner (#976).A recurring Quest is now one row in the
/tasksQuest list instead of one per cycle — ten days of a daily Quest had been ten identical rows. The row shows today's cycle (the one whose window falls on that Quest's own calendar day, as the dashboard's Today list picks it), else the next cycle to start, else the most recently updated, with its title, progress and status, and a second line such as "Cycle 10 · 12 cycles". Its chevron lists the other cycles beneath it, newest first, each a compact, selectable row ("Cycle 9 · Oct 27 · 3/3 · Completed"); a row stays open or closed as you leave it for the rest of the visit, and opens by itself when the quest being shown is one of its earlier cycles, so a link to an earlier cycle —?questId=, or the quest page's "From earlier cycles" — lands on a visible, highlighted row. On a phone the quest picker groups each recurring Quest's cycles under one heading, today's first, each option naming its cycle and day ("Cycle 10 · Oct 28"). Filter quests matches a recurring Quest by its title and keeps it as one row, closed unless the quest being shown is inside it. Each cycle keeps its own live progress, and under the Pinned chip a recurring Quest's badge counts the pinned tasks across all its cycles while each cycle's row shows its own; quests that are not recurring are unchanged (#979).A task now says who last changed it. With
NEXT_PUBLIC_FEATURE_TASK_HISTORYon, each task card on the quest page and on/taskscarries a "Changed by Sam · 2 hours ago" badge — "you" for your own changes, "Questify" for scheduled and system changes, "a former party member" for someone who has left the party, "a deleted account" once their account is gone — for its last creation, status, assignee or due-date change; rewording a task does not move it. The badge's visible words are its accessible name, and it opens the task's Activity panel, which lists its history newest first in plain sentences ("Sam moved this to Done", "You assigned this to Priya", "Questify cleared the due date"), with dates in the viewer's own zone, up to the latest 50 changes, and reads again when the task changes while it is open — bulk completion, assignment and due-date changes included, which fold in the stamp their write made unless a newer one has landed;?panel=activitydeep-links to it. History is visible to the quest's owner and party only, throughGET /api/tasks/[id]/events, and never on a public quest. Tasks changed before this release show no badge, since nothing was recorded for them. The flag is registered in.env.example,lib/env.ts, the webDockerfile,docker-compose.yml,turbo.jsonand the Playwright web server, and the dev TUI lists it (#643).
Fixes
Fixed the hint and summary boxes in the quest forms being hard to read in dark mode. The Year Theme statement in the New / Edit Quest form, the "These replace a Quest Pack" note and the model persona summaries put muted text on a half-strength gold wash, which is a mid-tone over the dark indigo background (2.45:1, and 4.44:1 in light mode); the "Saved" confirmations on the recurring-quest settings and the scheduled-start window used a /60 wash that left even default text at 3.66:1. All of them now share one surface,
NOTE_SURFACE_CLASSinlib/a11y/note-surface.ts— a /15 gold tint with a gold border and full-contrast text, 11.5:1 or better in both themes — which the coach's draft preview already used. The link and attachment placeholder tiles, which drew a muted hostname and file icon on solid gold (1.19:1 in dark mode), now use the neutral muted surface instead (#978).Every date-and-time field now has a visible calendar button at its trailing edge that opens the browser's date picker — the Edit Quest sheet's Schedule › Starts, a recurring Quest's start, a task's due date, the Quest Jam start form and the admin Quest Jam Starts/Ends. The browser's own picker icon had been a sliver clipped by the field's edge in Safari and a black glyph almost invisible on the dark field in Chromium, and it was never a tab stop, so the calendar could only be reached by knowing where to click. The button has a name a screen reader announces ("Choose date and time", or "Choose start date and time" / "Choose end date and time" where a form has two), sits in the tab order and opens the picker on click, Enter or Space; in a browser without
showPicker()it puts the cursor in the field instead. The browser's own icon is hidden so there is one control, not two, and the picker follows the app's light or dark theme rather than the operating system's. Typing a date works as before (#977)./tasksopened with no quest in the link now opens today's cycle of a daily Quest instead of tomorrow's. The recurrence mint writes the next cycle alongside today's, and writes it last, so it was the most recently updated active quest every morning and the page opened its untouched checklist — a task ticked there was ticked for tomorrow. The page now opens the active quest whose scheduled window holds the current time, then one whose window falls on today (so a 07:00–07:30 routine still opens today's cycle that afternoon), then the one starting soonest, and only then the most recently updated active quest as before. A window runs for its length in real time from when it opens, as the dashboard's Today list and the quest page count it, and "today" is the calendar day in each quest's own timezone, so the server's clock never shifts a cycle's day; when two windows are open at once — for an hour after midnight on the day the clocks go forward — the page opens the one that opened later. In the Quest list and the phone's quest picker, a cycle that has not started yet now sits right after its template's current cycle rather than above it. A link to a particular quest, tomorrow's cycle included, still opens that quest (#980).The Quest Chronicle's Browser voice toggle now shows only while a story is being read aloud, beside Pause/Resume and Stop, in the reading view and on a shared Chronicle page — not beside Listen before anything plays, where the choice could not be heard. Pressing it mid-reading switches to the browser's voice and carries on from the start of the sentence being read rather than the title, and pressing it again hands that sentence back to the narrator, whose recording picks up where that sentence begins; the highlight follows whichever voice is reading. If the narrator's reading has gone by then, the browser voice carries on instead. When it is on, the toggle is filled and shows a check in place of the microphone, so its state is visible without hovering, in both themes; its name stays "Browser voice", and focus stays on it as the voice changes and returns to Listen when the reading ends (#984).
Technical changes· 15 entries for database, tooling, CI, tests and docs
Database
20261210120000_tasks_quest_list_schedule.sqlrecreatesget_my_active_quests(), the/tasksQuest list's read, with five more columns:recurrence_template_id,cycle_index,scheduled_start_at,scheduled_durationandschedule_timezone, so the page can tell which cycle of a recurring Quest is today's and #979 can group a template's cycles. It stayssecurity invoker, returns the same rows in the same order, and is re-granted toauthenticatedonly, as before; the quest page already returned the same five columns throughget_quest_with_tasks(), so nothing new is visible. Covered by two new assertions insupabase/tests/tasks_quest_list.sql(#980).20261209120000_revoke_api_table_maintain.sqltakes Postgres 17'sMAINTAINprivilege —VACUUM,ANALYZE,REINDEX,CLUSTER,REFRESH MATERIALIZED VIEWandLOCK TABLE— back fromanonandauthenticatedon every relation inpublic, and removes it from rolepostgres's default privileges inpublicso a table created later never grants it. It came from the same defaultgrant all on tablesasTRUNCATE, and on productionanonheld it on 36 of the 62 tables andauthenticatedon 44; row-level security governs none of these commands, so a future path that ran SQL as the calling role could have taken anACCESS EXCLUSIVElock on a table it may only read, or reindexed it. PostgREST never issues them, so the API could not reach it.SELECT/INSERT/UPDATE/DELETEandservice_roleare unchanged.LOCK TABLEin its stronger modes is also allowed byUPDATEorDELETE, so a table that keeps those for RLS to govern can still be locked by a role holding them; the revoke closes the lock only on tables already narrowed toSELECT. The migration needs no version guard because local and CI now run Postgres 17 too (see Tooling). Proven by therls.sqlsweep andsupabase/tests/table_privileges.sql(#982).20261208120000_revoke_api_table_ddl_privileges.sqltakesTRUNCATE,REFERENCESandTRIGGERback fromanonandauthenticatedon every relation inpublic, and changes rolepostgres's default privileges inpublicso a table created later never grants them. Supabase's defaultgrant all on tableshad given them to both roles, and most migrations never took them back: on productionanonheld all three on 36 of the 62 tables andauthenticatedon 44. Row-level security does not apply toTRUNCATE, and the other two let a role create foreign keys and triggers against a table — a signed-in role could attach a trigger totasks— so any future path that ran SQL as the calling role could have bypassed every policy; PostgREST never issues these commands, so the API could not reach them.SELECT/INSERT/UPDATE/DELETEare unchanged and stay governed by RLS and each table's own grants, andservice_rolekeepsALL.rls.sqlsweeps every relation inpublicfrompg_class, so a table added later, or a migration that re-grantsALL, fails by name; the newsupabase/tests/table_privileges.sqlproves the new-table default with a probe table and showstruncate,create triggerand a foreign key toquestseach refused with 42501. Production's Postgres 17ALLalso includesMAINTAIN, which local and CI (Postgres 15) cannot express; that is #982 (#642).20261207120000_task_change_history.sqlrecords who changes a task and what they changed, written by triggers fromauth.uid()so neither the app nor a direct PostgREST call can skip or forge it. A tracked change is a task being created, or a change of its status, assignee or due date; rewording, positions, places andextra_contentare not tracked.tasksgainsupdated_by_id(→auth.users,on delete set null, with a covering index),updated_by_kind(user/system, null for tasks nothing has been recorded for) andupdated_by_at, stamped bytasks_stamp_change_actor(BEFORE INSERT OR UPDATE, named to fire after the guards), which puts the stored stamp back on any untracked update so a client-sent value never sticks. The new append-onlytask_eventstable (task_iddeliberately not a foreign key,quest_idcascading,actor_id,actor_kind,kindincreated/status/assignee/due_at/deleted, old and new values, the title at the time) is written only bytasks_record_events— on insert acreatedevent plus the task's starting status, assignee and due date, on delete adeletedevent, and on update one event per tracked column changed (that trigger carries aWHENon the tracked columns, so reorders and rewording never reach it); events are stamped withclock_timestamp()so several in one transaction keep their order. A deleted task keeps its history plus itsdeletedevent, deleting the quest removes it, and the writer recognises that cascade by the quest row already being gone. It is readable by the quest's owner and seated party through oneuser_can_access_quest()policy, never by a public quest's visitors, andanonandauthenticatedhold no write privilege, so a client INSERT, UPDATE or DELETE raises 42501. A write with no JWT (pg_cron, the worker, a service-role script) is recorded assystemwith no actor; a definer function's write is credited to its caller.task_change_actor_id()creditsauth.uid()only while that account exists, and an untracked update clears a stamp whose account is gone rather than restoring it, because an account delete clears its references with UPDATEs that fire these triggers inside the delete.tasks_set_updated_atis recreated to leaveupdated_atalone for a write that moves only the stamp, as it already did fortemplate_task_id, so that cleanup moves neither a task'supdated_at(a done task's completion time to the recap, digest and dashboard) nor, through the activity and stall triggers, its quest'slast_activity_ator open stall notices. The stamp columns are left off the #570 client column allowlist (collaborators read them throughget_tasks_with_deadlines()) and added totask_owner_editable_columns(), so working a challenge task stamps it without reading as rewording. Covered bysupabase/tests/task_change_history.sql, a newrls.sqlsection and two new fixtures inaccount_deletion_cascade.sql(#643).
Tooling & Configuration
Local and CI databases now run Postgres 17, the major version production has run on:
supabase/config.toml[db] major_versionmoved from 15 to 17, so every migration and pgTAP run is tested on the version it is deployed to, and Postgres 17-only syntax such asMAINTAINcan be written and proven. The full pgTAP suite was run on 15 and on 17 before the change and gave the same results. A Postgres data directory cannot be opened by another major version, so an existing local Supabase volume has to be thrown away once —pnpm dev:tui -- nuke --yes, orsupabase stop --no-backupthenpnpm supabase:start;docs/local-docker.mdsays so (#982).
Tests
Added
lib/a11y/note-surface.test.ts, which pins the note surface's tint and text colour and scans every component undersrc/componentsfor a gold wash heavier than /20 or muted text sharing a class string with anybg-secondary, and a Year Theme test that the statement renders on the shared surface without the oldbg-secondary/50+text-muted-foregroundpairing (#978).ui-date-time-input.test.tsxcovers the sharedDateTimeInput— the field keepstype="datetime-local", its name and value, a focusable named calendar button,showPicker()called on the field when it exists, focus on the field when it is missing or throws, the button disabled with the field, andaria-invalid,aria-describedby,value,onChange, the ref and layout classes passed through;e2e/quests.spec.tsexpands the Edit Quest sheet's Schedule section and checks the calendar button is visible and reached by Shift+Tab from Duration (#977).queries.test.tscovers the/questscollapse — today's cycle over tomorrow's pre-minted one with and without Active, the latest completed cycle under Completed and no card for a Quest with none, a search matching only an older cycle, chip counts and paging on the collapsed list, the progress sort's task read on representatives only, the uncollapsed?template=list and an unknown template — andpickIndexRepresentative()directly;index-params.test.tsthetemplateparam's parse and canonical round-trip;quest-index-toolbar.test.tsxa search, sort, chip and no-JS submit carryingtemplate;quest-filters.test.tsx,quest-card.test.tsx,quest-row.test.tsx,quest-details-card.test.tsx,quest-detail-view.test.tsxand the/questspage test the chip, the cycle-count link and the All cycles link (#976).task-quest-list.test.tsxcoversgroupTaskQuests()(today's cycle as the representative, the other cycles newest first, a group placed where its first cycle appears, a quest without a template never grouped, two templates kept apart),filterTaskQuestEntries()keeping a group whole, the cycle labels in the cycle's own timezone, and the list's recurring rows: one row with "Cycle N · N cycles", the chevron'saria-expanded/aria-controlsand the other cycles newest first, selecting an earlier cycle, a deep link to one opening its group with that row current, the disclosure staying open when the representative is picked from it and staying closed once closed, a single-cycle Quest with no chevron, the filter, per-cycle live counts, and the Pinned badge summed across cycles (unknown when any cycle's is) and dimmed only while nothing in the group is selected.types.test.tscoverspickRepresentativeCycle()— today's cycle over tomorrow's, a completed or short-window cycle still today's, a draft not, then the soonest upcoming, then the most recently updated — andtask-manager.test.tsxthe phone picker's<optgroup>, with today's cycle first and a standalone quest outside it. Theapps/webcoverage floors rose to 96.3 statements and lines and 96.1 functions (#979).lib/tasks/types.test.tscovers the new/tasksopening rule — today's cycle over tomorrow's pre-minted one; on the 25-hour day in November, a 24-hour window closing after 24 real hours and the day's cycle still opening in the hour before the next one starts; on the 23-hour day in March, the old cycle still open past midnight and the newer of the two winning; "today" judged in Tokyo rather than the server's zone; a short window after it has closed, the soonest upcoming quest, a finished cycle, the old fallbacks and a requested id — and the Quest list's order, including which of two open cycles the next one follows. The DST cases fail if the window is counted on the wall clock or the later-opening window is not preferred.lib/quests/schedule.test.tscoverswallClockMilliseconds()andapp/(app)/tasks/page.test.tsxpins 2026-10-28 and checks the page opens and lists today's cycle first while a link to tomorrow's still opens it (#980).narrative-panel.test.tsxandshared-chronicle-reader.test.tsxcheck that Browser voice is absent before Listen and during the reveal or Replay, present while reading, that its pressed style and check icon followaria-pressed, that a mid-reading switch carries on from the current sentence in both directions with focus kept on the toggle, and that focus returns to Listen when the reading ends with it there;use-chronicle-listen.test.tscovers switching either way mid-reading, the narrator's reading having gone during a switch (the browser voice carries on from the same sentence), a browser reading that gained the toggle when the narrator's reading landed mid-way not restarting, and Stop cancelling a switch still waiting on the route;use-chronicle-narration.test.tsanduse-provider-narration.test.tscover starting from a given sentence, including a seek that waits for the file's duration and one that waits for a fresh URL;narration.test.tscoversnarrationSentenceStartTime()and its round trip throughnarrationSentenceAtTime();e2e/quest-chronicle.spec.tspresses Listen before looking for the toggle and switches voices mid-reading (#984).supabase/tests/table_privileges.sqlnow also proves that a table created after20261209120000grantsMAINTAINto neither API role, thatservice_rolekeeps it on new and existing tables, and thatreindex table public.quests(asauthenticated) andlock table public.achievements in access exclusive mode(asauthenticatedand asanon) raise 42501; each new assertion fails on a database without the migration.rls.sql'spg_classsweep ofpublicaddsMAINTAINto the privileges neither API role may hold (#982).supabase/tests/task_change_history.sql(47 assertions) covers the stamp on insert and on each tracked change; a task's starting assignee, status and due date logged aftercreated, in order; forged stamps discarded on insert and on tracked and untracked updates, trusted roles included; a rewording and a reorder recording nothing; a status and due-date change in one write logging one event each; a definer-function write credited to the caller (removing a party member); a system write; a challenge task's status change; reads by the owner, a member, a stranger, a public quest's visitor and anon; every client write refused with 42501; a deleted task's history surviving its deletion; a refused delete logging nothing; a departed member losing access; the quest cascade with a member still seated and assigned; and deleting the account that last changed a task leaving the task'supdated_atand the quest'slast_activity_atwhere they were.account_deletion_cascade.sqlnow deletes an account that appears in both new actor columns — the case that would otherwise have made such an account impossible to delete. Web:task-events.test.tscovers the row mapping, the read and its limit, and the actor and event sentences;types.test.tsthe mapped last change; the route test 200/401/404/500;task-activity.test.tsxloading, the list, a deadline, empty, truncated, a re-read after an edit that keeps the list on screen, and a failed read with retry;task-manager-bulk.test.tsxa bulk completion updating the badge;types.test.tsnewerLastChange();task-manager-history.test.tsxthe badge's accessible name andaria-controls, opening and closing the panel, the panel's re-read key, "you" and Questify, no badge without a recorded change, and the flag off (#643).
Documentation
docs/schema.mddocuments theMAINTAINrevoke and theLOCK TABLEcaveat;docs/local-docker.mdsays the local database is Postgres 17 and how to replace a Postgres 15 volume; theadd-migrationskill's no-Docker fallback now names Postgres 17 (#982).docs/schema.mddocuments the stamp columns, a "Who changed a task (#643)" section on what is tracked and how the actor is decided, thetask_eventstable, its migration and its test coverage;docs/ops/feature-flags.mdaddsNEXT_PUBLIC_FEATURE_TASK_HISTORYas off (#643).