Questify

3.13.0

Released

Features

  • A recurring Quest is now one card on /quests instead 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 /tasks Quest 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 /tasks Quest 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_HISTORY on, each task card on the quest page and on /tasks carries 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=activity deep-links to it. History is visible to the quest's owner and party only, through GET /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 web Dockerfile, docker-compose.yml, turbo.json and 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_CLASS in lib/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).

  • /tasks opened 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.sql recreates get_my_active_quests(), the /tasks Quest list's read, with five more columns: recurrence_template_id, cycle_index, scheduled_start_at, scheduled_duration and schedule_timezone, so the page can tell which cycle of a recurring Quest is today's and #979 can group a template's cycles. It stays security invoker, returns the same rows in the same order, and is re-granted to authenticated only, as before; the quest page already returned the same five columns through get_quest_with_tasks(), so nothing new is visible. Covered by two new assertions in supabase/tests/tasks_quest_list.sql (#980).

  • 20261209120000_revoke_api_table_maintain.sql takes Postgres 17's MAINTAIN privilege — VACUUM, ANALYZE, REINDEX, CLUSTER, REFRESH MATERIALIZED VIEW and LOCK TABLE — back from anon and authenticated on every relation in public, and removes it from role postgres's default privileges in public so a table created later never grants it. It came from the same default grant all on tables as TRUNCATE, and on production anon held it on 36 of the 62 tables and authenticated on 44; row-level security governs none of these commands, so a future path that ran SQL as the calling role could have taken an ACCESS EXCLUSIVE lock on a table it may only read, or reindexed it. PostgREST never issues them, so the API could not reach it. SELECT / INSERT / UPDATE / DELETE and service_role are unchanged. LOCK TABLE in its stronger modes is also allowed by UPDATE or DELETE, 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 to SELECT. The migration needs no version guard because local and CI now run Postgres 17 too (see Tooling). Proven by the rls.sql sweep and supabase/tests/table_privileges.sql (#982).

  • 20261208120000_revoke_api_table_ddl_privileges.sql takes TRUNCATE, REFERENCES and TRIGGER back from anon and authenticated on every relation in public, and changes role postgres's default privileges in public so a table created later never grants them. Supabase's default grant all on tables had given them to both roles, and most migrations never took them back: on production anon held all three on 36 of the 62 tables and authenticated on 44. Row-level security does not apply to TRUNCATE, and the other two let a role create foreign keys and triggers against a table — a signed-in role could attach a trigger to tasks — 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 / DELETE are unchanged and stay governed by RLS and each table's own grants, and service_role keeps ALL. rls.sql sweeps every relation in public from pg_class, so a table added later, or a migration that re-grants ALL, fails by name; the new supabase/tests/table_privileges.sql proves the new-table default with a probe table and shows truncate, create trigger and a foreign key to quests each refused with 42501. Production's Postgres 17 ALL also includes MAINTAIN, which local and CI (Postgres 15) cannot express; that is #982 (#642).

  • 20261207120000_task_change_history.sql records who changes a task and what they changed, written by triggers from auth.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 and extra_content are not tracked. tasks gains updated_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) and updated_by_at, stamped by tasks_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-only task_events table (task_id deliberately not a foreign key, quest_id cascading, actor_id, actor_kind, kind in created / status / assignee / due_at / deleted, old and new values, the title at the time) is written only by tasks_record_events — on insert a created event plus the task's starting status, assignee and due date, on delete a deleted event, and on update one event per tracked column changed (that trigger carries a WHEN on the tracked columns, so reorders and rewording never reach it); events are stamped with clock_timestamp() so several in one transaction keep their order. A deleted task keeps its history plus its deleted event, 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 one user_can_access_quest() policy, never by a public quest's visitors, and anon and authenticated hold 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 as system with no actor; a definer function's write is credited to its caller. task_change_actor_id() credits auth.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_at is recreated to leave updated_at alone for a write that moves only the stamp, as it already did for template_task_id, so that cleanup moves neither a task's updated_at (a done task's completion time to the recap, digest and dashboard) nor, through the activity and stall triggers, its quest's last_activity_at or open stall notices. The stamp columns are left off the #570 client column allowlist (collaborators read them through get_tasks_with_deadlines()) and added to task_owner_editable_columns(), so working a challenge task stamps it without reading as rewording. Covered by supabase/tests/task_change_history.sql, a new rls.sql section and two new fixtures in account_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_version moved 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 as MAINTAIN can 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, or supabase stop --no-backup then pnpm supabase:start; docs/local-docker.md says so (#982).

Tests

  • Added lib/a11y/note-surface.test.ts, which pins the note surface's tint and text colour and scans every component under src/components for a gold wash heavier than /20 or muted text sharing a class string with any bg-secondary, and a Year Theme test that the statement renders on the shared surface without the old bg-secondary/50 + text-muted-foreground pairing (#978).

  • ui-date-time-input.test.tsx covers the shared DateTimeInput — the field keeps type="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, and aria-invalid, aria-describedby, value, onChange, the ref and layout classes passed through; e2e/quests.spec.ts expands the Edit Quest sheet's Schedule section and checks the calendar button is visible and reached by Shift+Tab from Duration (#977).

  • queries.test.ts covers the /quests collapse — 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 — and pickIndexRepresentative() directly; index-params.test.ts the template param's parse and canonical round-trip; quest-index-toolbar.test.tsx a search, sort, chip and no-JS submit carrying template; quest-filters.test.tsx, quest-card.test.tsx, quest-row.test.tsx, quest-details-card.test.tsx, quest-detail-view.test.tsx and the /quests page test the chip, the cycle-count link and the All cycles link (#976).

  • task-quest-list.test.tsx covers groupTaskQuests() (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's aria-expanded/aria-controls and 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.ts covers pickRepresentativeCycle() — 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 — and task-manager.test.tsx the phone picker's <optgroup>, with today's cycle first and a standalone quest outside it. The apps/web coverage floors rose to 96.3 statements and lines and 96.1 functions (#979).

  • lib/tasks/types.test.ts covers the new /tasks opening 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.ts covers wallClockMilliseconds() and app/(app)/tasks/page.test.tsx pins 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.tsx and shared-chronicle-reader.test.tsx check that Browser voice is absent before Listen and during the reveal or Replay, present while reading, that its pressed style and check icon follow aria-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.ts covers 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.ts and use-provider-narration.test.ts cover 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.ts covers narrationSentenceStartTime() and its round trip through narrationSentenceAtTime(); e2e/quest-chronicle.spec.ts presses Listen before looking for the toggle and switches voices mid-reading (#984).

  • supabase/tests/table_privileges.sql now also proves that a table created after 20261209120000 grants MAINTAIN to neither API role, that service_role keeps it on new and existing tables, and that reindex table public.quests (as authenticated) and lock table public.achievements in access exclusive mode (as authenticated and as anon) raise 42501; each new assertion fails on a database without the migration. rls.sql's pg_class sweep of public adds MAINTAIN to 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 after created, 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's updated_at and the quest's last_activity_at where they were. account_deletion_cascade.sql now 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.ts covers the row mapping, the read and its limit, and the actor and event sentences; types.test.ts the mapped last change; the route test 200/401/404/500; task-activity.test.tsx loading, 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.tsx a bulk completion updating the badge; types.test.ts newerLastChange(); task-manager-history.test.tsx the badge's accessible name and aria-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.md documents the MAINTAIN revoke and the LOCK TABLE caveat; docs/local-docker.md says the local database is Postgres 17 and how to replace a Postgres 15 volume; the add-migration skill's no-Docker fallback now names Postgres 17 (#982).

  • docs/schema.md documents the stamp columns, a "Who changed a task (#643)" section on what is tracked and how the actor is decided, the task_events table, its migration and its test coverage; docs/ops/feature-flags.md adds NEXT_PUBLIC_FEATURE_TASK_HISTORY as off (#643).