Questify

2.5.0

Released

Features

  • Finishing a quest's last task completes the quest, on every quest rather than only Party Quests. The task section asks the server for fresh data the moment it can tell the quest must have closed, so the header, the lifecycle buttons and the frozen-tasks notice update without a navigation (#477).

  • Marking a quest complete with work outstanding asks what to do with it — "7 tasks still to do", with Mark all done and Complete anyway. A quest whose tasks are already done, or which has no tasks at all, completes on the first press as before (#477).

  • A quest with a fixed task list hides the add-a-task form instead of showing an empty one with a dead Add button. The reason moves to a one-line notice beside the other task locks (#477).

  • A Random Quest Challenge now actually constrains its quest. Its checklist arrives locked: the tasks can be completed, reordered, assigned and pinned to a place, but not renamed, rewritten or removed, and nothing can be added alongside them. Previously the checklist was copied into an ordinary quest and could be emptied or replaced wholesale, and completing whatever was left still counted as completing the challenge. Locked tasks are marked "From the challenge" and lose their Edit and Delete buttons; the add-a-task form explains why it is closed (#477).

  • A challenge card names the reward the product actually grants — the Challenge Runner achievement — instead of an XP bonus that nothing anywhere awarded, read or displayed a total for. The same wording went from the dashboard strip, the quest banner, the challenge history and the marketing hero (#477).

  • An investigation comes back as one short paragraph of plain prose. It used to ask for four markdown sections — Summary, Findings, Risks, Next steps — of which only the first line was ever displayed, so the structure was generated and then discarded. Asking a small free-tier model for four headings is also a lot of output to fund before it says anything useful (#476).

  • Investigating a task asks what you want to know first. The button opens a dialog with a free-text question and four starters — "How do I complete this task?", "What should I do first?", "Break this into smaller steps.", "What might go wrong here?" — that fill the field rather than firing straight away, so the question stays editable and you can see what is about to be asked. Leaving it blank still runs the general analysis it always did (#476).

  • An investigation shows the question you asked instead of the whole prompt that was sent. The prompt column holds the entire assembled instruction — persona, quest, task, output format — which is what the model needs and several paragraphs nobody wanted to scroll past. The question is stored alongside it and is the only part now displayed (#476).

  • The task section no longer narrates itself. "Task added.", "Task updated.", "Investigation queued." and the rest were banners announcing changes visible in the list directly below them. Errors still appear inline, because nothing else reports those (#476).

  • An action that happens to unlock an achievement no longer reports it twice, and no longer swallows the confirmation of what was actually done. The unlock used to be written into the inline notice as well as the toast, and callers treated that as having said enough — so queueing an investigation that earned a badge said "Achievement unlocked: AI Scout." and never said "Investigation queued." The toast is the notification; the inline notice reports the action (#476).

  • The location consent prompt sits above the map again. Leaflet stacks its own furniture high — map panes at z-index 400, zoom controls at 1000 — and with no stacking context of its own that competed at the page root, where it painted over the z-50 dialog that the map itself had raised. The map container is now isolated, which fixes it for every dialog rather than for one (#476).

  • A task's location is set by dropping a pin on a map. It previously required typing a latitude and a longitude — the user had to know where "the gym" is to six decimal places before they could attach it to anything. Click the map or drag the pin to move it, "Use my current location" places it from the browser's own fix, and the check-in radius is drawn as a circle that resizes with the slider, so 100 m and 5 km are things you can see rather than infer (#476).

  • The coordinate fields are still there, collapsed behind "Enter coordinates" and bound to the marker in both directions. A map cannot be used with a keyboard or a screen reader, so removing them would have removed the only accessible way to set a location; pasting an exact pair is also sometimes faster than hunting for it (#476).

  • Opting in to location from the picker no longer switches on the "Near me" filter as a side effect. The consent prompt now records what it was raised for, and only the filter's own request turns the filter on (#476).

  • The location check-in permission prompt is a modal. It used to render inline in the task section, so pressing "Near me" reserved a card's worth of height in the middle of the page and left the prompt sitting beside a large empty gap. Escape and a click outside both count as "Not now" — dismissing a permission prompt is a refusal, never a grant (#476).

  • The Add Task form no longer strands its button. Title and Details share a row, the location panel is full-width beneath them, and "Add" has a row of its own; it used to be the last cell of a single grid row, so it sat off at the right edge and drifted further down whenever the location panel expanded inside the Details column (#476).

  • The task section on a quest page stopped repeating the quest. It used to open with an eyebrow reading "QUEST TASKS", a second <h1> reading "Task management", and the quest's own title again as a subtitle — all directly beneath the quest heading that already said both. It is now a single "Tasks" heading with the progress bar under it (#476).

  • The Compact/Expanded toggle is gone; task details and investigations always render, which is what Expanded — the default nobody had reason to change — already did (#476).

  • The model a quest runs on is stated once, on the quest's meta line beside the owner, the date and the year theme, instead of as a chip over its task list. It is a property of the quest, not of the task section. The standalone /tasks workspace keeps its own model control: it has no quest header to carry it, and its picker is the only way to choose the model an investigation runs on while the model-personas flag is off (#476).

  • The remaining task controls are all one size, so "Complete all" and "Near me" sit on the same baseline instead of one being a head taller than the other (#476).

  • Every owner action on a quest now sits in one aligned row directly beneath the quest's goal. They used to be three stacked groups at the foot of the page — the lifecycle transitions, a lone "Edit Quest" button, then a row holding Share, Archive and Delete — reachable only after scrolling past the entire task list. Each of those buttons also wrapped itself in a container that held its own error text, and in a flex row those containers stretch to the row's height while the fixed-height button inside them does not, so the buttons sat at visibly different heights the moment any one of them had something to say. Every action now renders bare and reports through one message region below the row. Archive and Delete moved behind an overflow menu: still one click away, no longer competing with the transition that matters (#476).

  • "Share Quest" no longer moves. It used to render in the page header for a public quest and at the foot of the page for a private one; it is now always in the same place, and telling the reader a private quest must be made public first is a note under the toolbar rather than a control that appears somewhere else (#476).

  • Deleting a quest confirms in a dialog instead of expanding an inline card between the buttons, and dismisses on Escape or a click outside (#476).

  • The quest detail page leads with the quest instead of burying it. Context and constraints are collapsible sections showing their first line when closed, rather than two full cards of reference material stacked above the tasks; the quest's images are a scrollable strip of thumbnails rather than a two-column grid of aspect-video panels that pushed the task list most of a screen down; and the daily/weekly challenge banner is a single line rather than a card. The goal, the tasks and the owner's controls are what remains at full weight (#476).

  • Challenge closing times are formatted in the viewer's timezone only after the page loads, on both the dashboard and /challenges. Intl.DateTimeFormat resolves to whatever timezone the runtime defaults to — UTC on the server, the viewer's own in the browser — and challenge periods close at midnight UTC, so anyone west of it was served a different day than their browser would have rendered, a mismatch React resolves by leaving the stale one on screen. Both pages now share one ChallengeDeadline component rather than holding a copy of the formatter each (#468).

  • The dashboard hero greets by time of day — "Good morning", "Good afternoon", "Good evening", and "Burning the midnight oil" past 10 p.m. — each with a line of its own beneath it. The hour comes from the viewer's own clock rather than the server's: the server renders a greeting that is true at any hour and the time-aware one replaces it on hydration, so a reader four hours off UTC is never told good morning at nine at night (#468).

  • Removed the "New Quest" button from the dashboard hero and the desktop side bar. The app shell's top bar has carried the primary call to action at every width since #461, so a signed-in user was looking at three of the same button on one screen; the top bar's is unchanged and remains the one on phones (#468).

  • The dashboard opens on the numbers that answer "where am I": active quests, tasks done, the current run of consecutive days something was finished, and how far along the active quests are. The streak counts the same UTC days compute_well_played_recap does, so it agrees with the Well Played recap, and it survives a day with nothing finished yet rather than resetting at midnight. The row is owner-scoped — party quests belong to someone else's history. A dashboard that cannot read your statistics says so rather than reporting an empty account above a quest list that loaded fine, and the completion-rate caption tells having no active quests apart from having active quests with nothing in them yet (#468).

  • The dashboard's Random Challenges section is now a single card listing one row per live challenge — title, prompt, cadence, XP, closing time, and one button. The two full-height cards it replaces carried the constraints block and the numbered task list as well, which is the bulk of what pushed the quest list off the first screen. The full cards are unchanged on /challenges (#468).

  • Tightened the dashboard's vertical rhythm: a smaller welcome panel, the year theme and the challenge card sharing a row when both are present, and the Skill Tracks block reading as a section rather than a second page heading. The quest preview keeps the page's one large heading (#468).

  • Added a Quests section to the navigation. /quests is now an index of every quest the user owns or shares as a party member, filterable by status, and the task workspace that used to answer that URL moved to /tasks — the nav item pointing at it was labelled "Tasks" while its address said quests, so there was no way to reach a quest list from the nav at all. Old /quests?questId=… links redirect to the workspace, an unrecognised ?status= is normalised away instead of listing everything under an inactive filter bar, and a quest's breadcrumb now returns to the Quests index instead of the dashboard (#469).

  • Both signed-in surfaces are gated in the middleware: anonymous visitors to /quests or /tasks get the usual login bounce, and a user with onboarding still pending is routed to the wizard from either. /quests/[id] and /quests/public keep rendering for logged-out visitors (#469).

  • A failed quest read now says so. The Quests index and the dashboard preview show "Unable to load your quests" when the query or its policies fail, instead of rendering an empty account under an invitation to create a first quest (#469).

  • The dashboard now previews the four most recently updated quests with an "All quests" link instead of carrying the full filterable list; the status filters moved to the Quests page with the list, and an old /dashboard?status=… link forwards there. listQuests takes a limit that is applied to the quests query itself, so the preview no longer fetches task counts, party-member counts, personas and themes for the user's entire quest history (#469).

  • Skill Tracks are their own section. They used to be listed among Quest Packs with a badge, and a track's stage map was only reachable at /quest-packs/[slug]/track — a multi-stage learning path presented as a variant of a one-off template. /skill-tracks is now an index in the nav showing every published track with the stage you reached, /skill-tracks/[slug] is the stage map, and the old track URL redirects permanently (query string, and so ?stage=, preserved). The Quest Packs browser lists templates only and points at the new section (#470).

  • The Skill Tracks index and the dashboard's resume widget report the same three states for a track — not started, in progress at stage N, and complete — and a failed read says so instead of claiming nothing is published (#470).

  • Skill tracks no longer appear anywhere a Quest Pack is chosen. The New Quest pack picker, the onboarding wizard's starting points, and GET /api/quest-packs all list templates only — a track shares the quest_packs table but keeps its tasks in skill_track_stage_tasks, so picking one previewed an empty task list and cloned nothing into the quest. Arriving at /quests/new from a track stage still pre-fills the stage title and offers the way back to the track map; the track is resolved against the track tables rather than through the picker (#470).

  • The AI Coach interview now ends on a decision instead of a countdown. When the draft is ready the answer field gives way to Continue and Start over, and the summary stays on screen until one is chosen — it previously flashed up for under a second before the card replaced itself with the pre-filled form. Start over begins a fresh interview in place; Cancel still leaves the coach entirely (#471).

  • The AI Coach's closing turn now shows the quest it drafted — title, goal, and the numbered tasks it is about to create — in place of the JSON payload it used to print (#471).

  • The AI Coach interview now runs on the model persona the user picked, and the picker sits above the coach card so the choice is made before the interview rather than after it. /api/coach/interview re-checks the key against the account's entitled personas and only uses it to REORDER the fallback chain — a key the plan does not cover is refused with 402, and a provider outage on the chosen model still degrades to the next entitled one instead of failing the turn (#471).

  • The AI Coach's suggested tasks are now created with the quest instead of being listed for the user to copy out by hand. They are shown as "Tasks the AI Coach will create", and because a Quest Pack would clone a second unrelated task list on top of them, the pack picker is disabled and says so while a coach draft is active. createQuestSchema rejects the pair outright, and a failed task insert rolls the quest back — the same contract the pack path already had (#471).

  • Reworked the New Quest page around progressive disclosure. Title and Goal are the only text fields on screen when the page opens; Context & constraints, Reference images, Quest Pack, Year Theme, and Model persona each moved into a collapsible section that stays closed until it is wanted and names what it holds when it is closed. Model persona leads rather than trails, so the model choice sits with the AI Coach card instead of being made after the coach has already drafted the quest. A section opens on load when it already carries content, so an AI Coach draft, a ?pack= deep link, and every quest being edited still land on a filled-in form. Collapsed sections stay mounted, so nothing needs to be opened for its value to be saved (#471).

  • Replaced the New Quest reference-image textarea. Image URLs are added one at a time with a thumbnail preview, a remove control, and a running n of 8 count, and an invalid, duplicate, or over-limit URL is refused with an inline message instead of failing the whole save on submit. The submitted format is unchanged (#471).

  • Moved the New Quest status select and public-quest checkbox into a compact publish row beside the submit button, and dropped the Dashboard and Quest Packs links from the page header — both have lived in the app shell's navigation since #461. The contextual “Track map” link back into a Skill Track is kept, because it has no shell entry of its own. The same changes apply to the inline quest editor, which shares the form (#471).

  • Added a unified navigation shell for the signed-in app: a sticky top bar with the Questify wordmark, a New Quest call to action, and an account menu, plus a persistent side bar listing Dashboard, Tasks, Public Quests, Quest Jams, Quest Packs, and Challenges. Below md the side bar collapses into a drawer opened from the top bar. The current section is marked with aria-current="page" (#461).

  • The account menu carries the signed-in identity (avatar or initials, name, email), links to Profile, links to Admin for users who pass the is_admin() check, and holds the Sign out control that every page used to render for itself (#461).

  • Moved /dashboard, /quests, /quest-jams, /quest-packs, /challenges, and /profile into an (app) route group so the shell is attached in one layout. Every URL is unchanged. Pages in the group that are reachable while logged out — a shared public quest, the public quest index, the Quest Jam list, the Quest Pack catalogue — render without the shell and keep their Sign in / Create account entry points (#461).

  • Removed the per-page navigation button rows from the dashboard, challenges, quest packs, profile, Quest Jams, and public quests pages. Those links are now in the navigation bars, so signed-in pages no longer each ship their own ad-hoc set (#461).

  • Removed LogoutButton; the account menu now holds the app's only sign-out control. The E2E logout helper opens the account menu before clicking it, so it no longer silently no-ops (#461).

  • Added a free AI tier. OpenRouter is now a supported provider in both the worker and the web app, and OPENROUTER_API_KEY is the only provider key a working install needs — investigations, quest narratives, and the AI coach all run at no cost without an OpenAI, Anthropic, or DeepSeek account.

  • The seeded free default is openrouter/free, a router rather than a fixed model: OpenRouter selects an available zero-cost model per request and filters for the capabilities it needs. Individual :free slugs are retired without notice, so both provider clients retry a 400/404 from a pinned free slug against the router before failing, and investigation details record the model that actually served the request.

  • Paid models are now behind a subscription wall. The model persona picker locks Pro personas for free accounts, labels free personas as Free rather than by cost band, and refuses to submit a locked persona. Selecting a Pro model explicitly returns 402 with an upgrade message; a Pro persona merely inherited from an existing quest is downgraded silently so generation still succeeds.

  • The AI coaching interview now walks the personas the account is entitled to instead of a hard-coded Anthropic → OpenAI → DeepSeek chain, which meant a free account's coaching turn silently reached a metered provider. Provider-outage fallback is preserved, capped at three attempts to stay inside the route's time budget.

  • Admins can set a persona's required plan from the persona admin panel (Required Plan), defaulting to Pro.

  • Added the first-run onboarding wizard at /onboarding, behind NEXT_PUBLIC_FEATURE_ONBOARDING_WIZARD (default off). A newly signed-up user is walked through four steps — profile basics (display name, avatar, theme), an optional Quest Pack or Skill Track, their first quest, and a Year Theme — with a progress indicator and resumable state. Choosing a pack in step 2 clones its tasks into the quest created in step 3, and the quest is created active so the user lands on the dashboard with work in progress (#460).

  • Every onboarding step is skippable and the whole wizard can be dismissed with "Skip setup"; skipped steps are recorded separately from completed ones, and the step the user was on when they dismissed is preserved so drop-off is attributable (#460).

  • Signed-in users with unfinished onboarding are routed to /onboarding from protected paths by the middleware. The check is gated on the feature flag first, scoped to protected paths only, runs as a single-column primary-key lookup, and fails open on error so a database problem cannot trap a user in a redirect loop. A user with no onboarding row at all counts as pending, so an account created out of band is routed to the wizard and its row repaired rather than silently skipping onboarding forever (#460).

  • Signup, login, and the /auth/callback handler now resolve the onboarding destination themselves instead of relying on the middleware bounce, so a new account lands on /onboarding with the address bar to match. Next renders a middleware redirect issued during a Server Action redirect but leaves the URL on the original target, which meant a user finishing signup read the wizard at a /dashboard URL. A deliberate next deep link is preserved, and a password-recovery session is never diverted (#460).

  • The first-quest step is idempotent: revisiting it via Back, or retrying after a failed write, updates the quest already created instead of leaving a trail of duplicate active quests. A step revisited with a different outcome moves between completed_steps and skipped_steps rather than appearing in both, and skipping the pack step clears any previously chosen pack so the first quest does not clone its tasks anyway (#460).

Fixes

  • A model's reasoning is no longer passed off as its answer. Reading message.reasoning when content came back empty was added on the theory that partial thinking beats nothing; what it produced was the model's monologue under a green Completed tick — "We need to respond with concise, actionable markdown. The persona: … So we need to answer with sections: …". A model that reasoned and never committed to an answer has not answered, so the investigation now fails and says exactly that, naming AI_MAX_RESPONSE_TOKENS as the lever (#476).

  • A finished investigation shows its first sentence rather than a markdown heading. The one-line summary is the first line that is not a "Summary" label, but the test for that only matched a bare ### Summary — so a model that wrapped its answer in a blockquote produced the summary > ### Summary. Blockquote markers are stripped before anything is judged, and the label is now recognised as a heading, in bold or underscore emphasis, with or without a colon (#476).

  • An investigation that came back empty is no longer recorded as a success. It used to be marked completed with "Investigation completed without a text response." where the analysis should be — a green tick over an empty result and no clue why. It now fails with the reason: the token limit was reached before the model answered (naming AI_MAX_RESPONSE_TOKENS as the lever), the content filter refused it, or whatever the provider reported. finish_reason is recorded on successful investigations too (#476).

  • The worker reads the responses these APIs actually send. It only ever looked at a string message.content, so three shapes that arrive with a perfectly good 200 all came back as no text: content returned as an array of parts, which several OpenAI-compatible backends use because the request sends its prompt that way; a reasoning model's reasoning field, which is the only text present when the model never committed to a final answer; and the legacy choice.text shape some OpenRouter backends still emit (#476).

  • The investigation worker no longer treats an empty queue as a broken one. claim_next_task_investigation() returns a composite, and PostgREST reads a composite-returning function as select * from f() — where Postgres expands a NULL composite into one row of all-NULL columns rather than zero rows. An idle queue therefore arrived as a perfectly truthy object with every field null, so the "nothing to claim" check never fired: the worker processed a phantom investigation, failed to find a task for task_id: null, and then tried to record that failure against id: null, which Postgres rejects with invalid input syntax for type uuid: "null". The claim is now judged on the row's id (#476).

  • The AI investigation worker runs on Node 22. @supabase/realtime-js resolves a WebSocket constructor inside createClient(), and Node only exposes a global WebSocket from 22 onward — so on the Node 20 base image the worker threw before it reached its poll loop, exited, and was restarted forever by Docker. Every investigation stayed queued with nothing in the app to say why. The library suggests installing ws and passing it as a transport; that is the wrong repair here, because the worker never opens a realtime channel and Node 20 is past end-of-life. It now fails with a message naming the actual requirement if it is ever started on an older runtime (#476).

  • The AI investigation worker no longer dies on a single failed poll. Any error reaching the database — Supabase not up yet, a network blip, a permission change on claim_next_task_investigation() — escaped the loop, set a non-zero exit code and ended the process; because the container restarts unless-stopped, a recurring failure became an invisible crash loop that logged one line per restart while investigations sat queued forever with nothing in the app to say why. The loop now catches per iteration and retries with exponential backoff capped at a minute. It also names the Supabase URL it is polling at startup and logs the first successful poll, so a worker that cannot reach the database is distinguishable from one that simply has nothing to do (#476).

  • Every modal in the app now behaves like one. aria-modal="true" is a claim about the page rather than a mechanism: it tells assistive technology the rest of the document is inert without making it so. Opening the location consent prompt, the delete confirmation or the investigation dialog left focus on the control behind the overlay, Tab walked straight back out into a page the user could no longer see, and closing left focus wherever it had wandered. A shared useModalFocus hook supplies the three behaviours the attribute promises — move focus in, contain Tab, hand focus back — for all three at once. The delete dialog needed one extra turn: its trigger lives inside the overflow menu, which unmounts as the dialog opens, so the toolbar hands back the overflow button instead of a detached node (#476).

  • The map no longer claims a keyboard interface it hasn't got. role="application" tells a screen reader to stop intercepting keys and pass them to the widget; nothing on this surface answers a key, because the pin is placed by clicking and moved by dragging. It is a labelled region now, and the label points at the coordinate fields that are the actual keyboard path (#476).

  • The quest's image thumbnails are told apart. Every link took the quest's title as its accessible name, so three of them announced the same thing three times. Nothing here knows what any individual image depicts, so the name carries its position in the set — the one distinction that is both true and useful (#476).

  • The overflow menu describes itself as the disclosure it is. It announced aria-haspopup="menu" over a role="menu" panel, which promises the ARIA menu keyboard model — arrow keys moving a roving tabindex between menuitems. The children are ordinary buttons and none of that existed, so a reader was told to press keys that do nothing. It is now aria-expanded and aria-controls over a labelled group (#476).

  • Refusing the location prompt now cancels the request that raised it. The picker remembered that it was owed a pin as soon as consent landed, and nothing cleared that when the prompt was dismissed with "Not now" — so opting in later from somewhere else entirely, such as the "Near me" filter, dropped a pin on the task the user had already declined to locate (#476).

  • A failed investigation says why, where you can read it. The dialog deliberately stays open on failure so the typed question survives, but the error rendered in the task section underneath the fixed z-50 overlay — an explanation hidden behind the thing it was explaining (#476).

  • OpenAI and Anthropic finish reasons are read, not ignored. finish_reason is a chat-completions field, and only two of the four providers speak it: OpenAI's Responses API reports an unfinished reply as status: "incomplete" with the cause in incomplete_details.reason, and Anthropic uses stop_reason. Both are normalized at the edge now, so a reply truncated by the token limit names the limit instead of arriving as a generic empty response, and a successful run records the stop reason it advertises (#476).

  • An empty reasoning-only response no longer blames a budget that was never spent. "The model reasoned and did not answer" describes what came back, not why — reported as token exhaustion it sent the reader off to raise a limit the model had not reached. An explicit finish reason now decides the message, and reasoning-only only changes the wording when one confirms it (#476).

  • The repository runs on Node 22, not just the worker's Docker image. .nvmrc still pinned 20 while apps/worker declared engines.node >= 22, and every CI job reads that pin — so the workspace that needs a global WebSocket was being tested on the runtime that has not got one, and the unit-test workflow failed (#476).

  • The AI Coach no longer answers an interview question with an article. Asked to help climb Mount Everest it would reply with facts about Everest and no follow-up question, leaving the interview stuck. The system prompt now states the per-turn rules as hard constraints — at most two short sentences then exactly one question, no teaching, no lists, no answering on the user's behalf — and the route verifies rather than trusts: a reply carrying no question, running past 700 characters, or shaped like a document is regenerated once against a tight 200-token ceiling, falling back to the original rather than failing the turn. The closing turn is exempt, since it carries the draft (#471).

  • The AI Coach now focuses the answer field and scrolls it into view after each reply, so a long answer no longer leaves the user hunting for where to type. It uses block: 'nearest', which moves the page the minimum required and not at all when the field is already visible, and is skipped on the closing turn where the card is about to be replaced by the filled-in form (#471).

  • Fixed the AI Coach printing its raw JSON payload into the chat and then leaving the quest form empty. The parser only understood a correctly fenced, correctly closed ```json block, and the chat's display filter had the same blind spot — so any reply it could not read was also one it could not hide, and the two failed together. A single splitCoachReply now separates prose from payload for both, reading a bare unfenced object, an unclosed fence, and trailing commentary after the block, with a brace-balanced scan so braces and escaped quotes inside string values no longer break it. A payload that still cannot be parsed is withheld from the chat and answered with a retry prompt rather than shown (#471).

  • Raised the coach reply token cap from 800 to 1200. The last turn carries the JSON draft as well as the closing sentence, and a longer draft could be truncated mid-object — which parsed as nothing, so the interview stalled (#471).

  • Fixed the AI Coach interview scrolling the page instead of its own message thread. It called scrollIntoView() on a sentinel at the end of the thread, which walks every scrollable ancestor and, with the default block: 'start', parked that sentinel at the top of the viewport — pushing the reply the user was waiting to read off the top of the screen. The thread now scrolls itself, and is capped against the viewport height so the header, the newest reply, and the input stay visible together (#471).

  • Fixed the quest form treating any pre-filled create as an edit. The mode was inferred from defaultValues being set, so once the AI Coach drafted a quest the form hid the Quest Pack picker, offered Completed / Failed / Archived in the status select for a quest that did not exist yet, and reported persona changes to analytics as quest_edit. Editing is now an explicit isEditing prop (#471).

  • Fixed the quest edit form silently discarding a grandfathered Pro persona. For a downgraded owner the picker preselected a free substitute and submitted it through its hidden model_persona_key input, so saving an unrelated edit rewrote the stored reference and re-subscribing could no longer restore the original model. The picker now starts with no selection when the stored persona is not selectable — locked behind Pro, or filtered out of the list entirely, which is what happens in the default configuration — and submits nothing until the user actively chooses a replacement.

  • POST /api/billing/checkout refuses an account that already holds a Pro-granting subscription, with a 409 pointing at the Billing Portal. Hiding the buy buttons is not enforcement: a double click, a retry, or a direct call would open a second Checkout session, and Stripe will happily run two independent subscriptions for one customer (#486).

  • Stripe customer creation carries an idempotency key derived from the user id, so two concurrent first checkouts converge on one customer. Without it both saw no mapping, both created a customer, and the losing upsert left one holding a live Checkout session and its invoices that neither the mapping nor the Billing Portal could find again (#486).

  • Locked personas in the model picker render as plain cards rather than disabled buttons. The "Upgrade to Pro" link was interactive content nested inside a disabled <button> — invalid HTML, and unreachable by pointer and assistive technology alike, so the one control that could unlock the card was the one control that could not be used (#486).

  • The dev TUI re-checks for a live-mode context after an account switch. The guard ran before the switch was offered, so choosing a live account from the picker walked straight past it into fetching a live webhook signing secret into .env and forwarding production events at localhost (#486).

  • selectChallengeForPeriod() breaks slug ties by byte order rather than localeCompare(). The database sorts the same candidates with collate "C", and the two have to agree or they choose different challenges; slugs are constrained to ^[a-z0-9-]+$, where the two orderings are identical. It also drops a locale dependency that made the pick depend on where the code happened to run (#477).

  • The completion prompt counts the tasks that are actually outstanding. The owner toolbar is server-rendered once while the task section below it mutates tasks in place, so a page loaded at 3 of 10 still offered to finish "7 tasks" after two had been ticked off. The counts are re-read before the question is asked — in the database, not by measuring an array, since PostgREST caps an unpaginated read at max_rows — and a failed read falls back to the rendered numbers rather than blocking the transition (#477).

  • Quest status now changes what a quest does, not just its badge. Archived quests can no longer be edited or have their tasks changed, completed and failed quests have their task lists frozen until they are set back to active, and a draft quest's tasks can be written and reordered but not started or completed. The quest detail page and the task manager state the current restriction and disable the controls it blocks, and the task API answers a blocked write with 409 and the same message (#462).

  • Added lifecycle buttons to the Quest Actions section — Start Quest, Mark Complete, Mark Failed, Reopen Quest and Unarchive Quest, offered according to the quest's current status. Moving a draft quest to active previously required finding the status select inside the edit form, and an archived quest had no way back at all because its only status control was in an editor the archive should not have allowed (#462).

Security

  • Stripe configuration is checked per capability rather than as one predicate over every value. The webhook and the portal use no price at all, so requiring both price ids meant a retired price would 503 lifecycle events for subscriptions that are still being charged — Stripe retries those for days and then disables the endpoint — while locking those same subscribers out of the only page where they can cancel or fix a card (#486).

  • start_quest_challenge() refuses a challenge that is not the one on offer. Exactly one active template per cadence is the challenge for a given day or ISO week, chosen deterministically from the period key — but the function checked only is_active, and the uniqueness constraint is per template, so an authenticated caller could POST the RPC once per template and run the entire catalogue in a single period. The rule is an authorization rule and now lives at the database boundary: selected_challenge_template() recomputes the choice in SQL, and challenge_period_index() is the same FNV-1a the app computes, verified identical across 70 key/count pairs and 453 consecutive periods against the seeded catalogue. With six active daily templates, walking the catalogue now yields one run and five refusals (#477).

  • sync_quest_completion() is no longer executable by PUBLIC. CREATE FUNCTION grants EXECUTE to PUBLIC by default and PostgREST turns every executable public-schema function into an RPC, so a SECURITY DEFINER helper that takes any quest id and bypasses RLS was reachable by any authenticated caller — enough to re-close a quest its owner had deliberately reopened. sync_party_quest_completion(), which it replaces, carried the same hole since #263; it was narrower only because it returned early below two party members. The four internal task-guard helpers were locked down with it (#477).

  • Closed a plan-tier bypass found in review: task_investigations_guard_model_type skipped validation when model_type was unchanged, so a downgraded owner could PATCH an old paid investigation's status back to pending through PostgREST and have the worker claim and bill the paid model again. Every user-originated write to that table is now revalidated. The quest guard keeps its unchanged-persona exemption — editing a quest's title is not billable, whereas an investigation is billed every time it is claimed.

  • Onboarding Server Actions no longer render raw Supabase/Postgres error text. Failures the user cannot act on are logged server-side and surface as stable copy, so table, policy, and constraint details stay out of the UI (#460).

Technical changes· 83 entries for database, tooling, CI, tests and docs

Database

  • apply_subscription_state() now records the Stripe event id itself, in the same transaction as the write it guards, and returns false when that id was already recorded. Recording it from a separate statement — as the webhook did — left a window in which a process killed between the record and the write leaves a record with no work behind it, and every Stripe retry then reads as a duplicate: a subscription that is paid for, never granted, and reports no error anywhere. A crash now rolls back both (#486).

  • apply_subscription_state() takes a row lock on the owner's profile before it writes. Two events for different subscriptions of the same account each derived the tier from a snapshot that could not see the other's uncommitted rows, so whichever committed last won — a new active subscription racing an old one's cancellation could leave the account on Free while Stripe charged for Pro (#486).

  • Added profiles.plan_tier_source, recording whether the current tier was granted administratively or by billing, and taught reconciliation to revoke only what billing granted. The billing panel already surfaced accounts "granted Pro directly rather than through a subscription", and without an owner recorded on the tier the next lifecycle event for such an account — an abandoned checkout going incomplete, a retry of an old cancellation — computed free and silently revoked the comp. set_user_plan_tier() marks the tier administratively owned, and the existing self-service guard now covers the new column, so an account holder cannot re-label a billing grant as permanent (#486).

  • Auto-completion and task writes now serialise on the parent quest row. "Is anything still outstanding?" is a predicate read followed by a write, and without a lock a concurrent INSERT could slip a fresh todo task between the two: both transactions passed their own guard, both committed, and the quest ended up completed with unfinished work. assert_quest_accepts_task_writes() — which every task write already funnels through — takes FOR UPDATE, and sync_quest_completion() takes the same lock before counting. FOR UPDATE rather than FOR SHARE, because the completion trigger wants the exclusive lock moments later in the same transaction and two concurrent task writes would otherwise deadlock upgrading it. A task move locks its two quests in ascending id order so two moves in opposite directions cannot deadlock either (#477).

  • start_quest_challenge() is idempotent under concurrent starts. The existing-attempt check could be missed by two requests at once; both created a quest and cloned its checklist, and the loser hit the (user_id, challenge_template_id, period_key) unique constraint and returned a generic error, having left behind exactly the orphan quest this function was written to prevent. The inserts now sit in a subtransaction that catches unique_violation, rolls the half-built quest back and returns the winner's quest — which is what starting a challenge twice in one period should give you (#477).

  • A quest now closes itself when its tasks are done. sync_party_quest_completion() had done this for Party Quests since #263 but returned early for anything with fewer than two members, so the rule applied to exactly the quests most people do not have. It is generalised into sync_quest_completion() rather than duplicated: a solo quest is the party rule with one member. Only an ACTIVE quest closes — a draft has no progress to finish and a completed, failed or archived one must not be revived by a stray task write — and a quest with no tasks never completes itself. A party of two or more set to all_assigned_tasks still counts only its assigned tasks. tasks_sync_party_quest_completion is renamed tasks_sync_quest_completion (#477).

  • Added public.complete_quest(uuid, quest_status, boolean). Marking a quest complete can now finish its outstanding tasks with it, but only when the caller asks: doing it unconditionally would overwrite the record of what was actually done, and reopening the quest would not bring the distinction back. It marks the tasks first, because a completed quest refuses task writes (#462), and its compare-and-set accepts completed alongside the caller's expected status so the auto-completion its own task writes trigger is not mistaken for a lost race (#477).

  • Challenge tasks are now fixed in the database. tasks.origin records whether a task's wording belongs to its owner (user) or to whatever created the quest (template), tasks.origin_ref records the template row it was cloned from, and quests.allows_extra_tasks records whether the list is closed at all. tasks_guard_template_origin lets a locked task be worked — status, assignee, sort_order, location and extra_content, named in task_owner_editable_columns() — and refuses its title, details, a move to another quest, and deletion; quests_guard_task_policy keeps allows_extra_tasks out of a caller's hands entirely. Exemptions match assert_quest_accepts_task_writes() (service role, trigger-originated writes) plus a transaction-scoped GUC a SECURITY DEFINER clone sets and clears around its own inserts. Quest Packs are deliberately not locked: a pack is a starting point meant to be adapted (#477).

  • Added public.start_quest_challenge(uuid, text), which creates the quest, clones the challenge's checklist as locked tasks and records the attempt in one transaction. It replaces three separate client writes with hand-rolled compensating deletes — best-effort ones, so a failure between them left an orphan quest carrying the challenge's tasks and no attempt row, which the dashboard then offered to start again. It also decides whether the named period is open from the server clock instead of trusting the value posted in the form, and returns the existing quest when the challenge has already been started this period (#477).

  • get_my_active_quests() returns allows_extra_tasks, so the /tasks workspace can close its add-a-task form rather than failing on submit (#477).

  • Dropped quest_challenge_templates.reward_xp_bonus and quest_challenge_attempts.xp_bonus_awarded. They were the only mention of XP in the product — there is no ledger, no total on a profile and no level, and nothing read either column. Removing the reward also simplifies both challenge trigger functions: set_quest_challenge_attempt_derived_fields() no longer re-reads the template on every update to recover a value it merged with greatest() (#477).

  • Removed the Skill Track schema: skill_track_stages, skill_track_stage_tasks, user_skill_track_progress, the advance_skill_track_stage() function, and the quest_packs.type discriminator with its partial index. Track packs are deleted rather than converted, since their tasks lived in a table that is going away and a converted pack would sit in the browser with an empty task list. quests.quest_pack_id and user_onboarding.selected_quest_pack_id are both on delete set null, so quests started from a track keep their content (#477).

  • claim_next_task_investigation() now also reclaims investigations left in running by a worker that died mid-flight. It only ever looked at pending, so a row claimed by a process that then exited was orphaned permanently — no worker would look at it again, and the task showed it as in progress forever. A running row untouched for ten minutes is reclaimable; generation takes tens of seconds against every provider in the catalog, so that cannot overlap a worker that is merely slow, and for update skip locked already prevents two live workers claiming the same row (#476).

  • Added public.get_dashboard_stats(), which aggregates the dashboard's four headline numbers in one round trip: active quests, tasks done, task progress across active quests, and the current run of consecutive UTC days with a completed task. It replaces a client-side fold that read every quest id and then every task row — PostgREST caps an unpaginated read at max_rows (1000), so past a thousand tasks the numbers were silently understated, and the quest_id in (…) filter would have outgrown the request URL well before an account reached a thousand quests. SECURITY INVOKER reading auth.uid(), so RLS still applies and no caller can ask for another account's statistics. Backed by tasks (quest_id, status) and tasks (updated_at) where status = 'done' (#468).

  • Quest status is now enforced in the database. tasks_guard_quest_status refuses task inserts, updates and deletes that the parent quest's status does not allow — a draft quest accepts task edits but not task progress, and a completed, failed or archived quest accepts neither — while quests_guard_archived_updates freezes an archived quest itself so unarchiving (or soft deletion) is the only write it takes. Both exempt service-role callers and trigger-initiated writes such as party-member assignment clearing, and both raise 42501 with a message the app can show verbatim. Added public.quest_status_accepts_task_writes() and public.quest_status_accepts_task_progress() as the shared predicates (#462).

  • Added a two-tier AI entitlement model: profiles.plan_tier (free | pro, defaults to free) and model_personas.required_tier (free | pro, defaults to pro so a newly catalogued model is never accidentally free). A persona is usable when plan_tier_rank(required_tier) <= plan_tier_rank(current_plan_tier()), exposed as public.user_can_use_model_persona(uuid) and public.user_can_use_model_type(text).

  • Enforced plan tiers with three triggers rather than route-handler checks. profiles_guard_plan_tier blocks a user changing their own plan_tier; quests_guard_model_persona rejects a quest pointed at a persona above the owner's tier; task_investigations_guard_model_type rejects an investigation naming a model above the caller's tier or outside the persona catalog. The last one is load-bearing — task_investigations is user-insertable through PostgREST, so anyone could have queued a paid model directly and let the worker bill it. Service-role callers have no auth.uid() and pass through all three.

  • Quests keep working after a downgrade. The quest trigger only fires when model_persona_id actually changes, so a quest created while subscribed stays editable on a free plan and keeps its stored persona; generation substitutes an entitled model at run time instead of failing.

  • Added public.set_user_plan_tier(uuid, text), the only supported way to move an account between tiers. It requires the new ai_plans.write privilege (held by SuperAdmin and Admin); a payment-provider webhook will call the same function with the service role when billing lands.

  • Seeded two zero-cost OpenRouter personas — free-router (openrouter/free) and free-gpt-oss-20b — and left the six existing OpenAI/Anthropic/DeepSeek personas on the pro default. Added 17 pgTAP assertions covering tier defaults, self-promotion, quest and investigation writes at both tiers, grandfathering, and the admin grant path.

  • Added public.user_onboarding (one row per user) and the public.onboarding_step enum to track first-run onboarding: the step the wizard resumes on, which steps were completed, which were explicitly skipped, the chosen Quest Pack, and the quest created during the flow. RLS is owner-only for select/insert/update with no delete grant, mirroring public.profiles. handle_new_user() now creates the onboarding row alongside the profile row on signup, and existing users are backfilled as already-onboarded so the wizard never appears for them (#460).

Tooling & Configuration

  • Ctrl+D at a dev-TUI prompt is now treated as a cancel instead of surfacing readline's AbortError, and a prompt opened on an already-closed input stream returns immediately rather than hanging the TUI with no way back.

  • Added an API keys section to pnpm dev:tui (also pnpm dev:tui -- keys). It lists the provider credentials with their current values masked, reads new ones without echoing them to the terminal, validates before saving, and writes to the gitignored root .env atomically at mode 0600. A single - clears a key; a blank answer leaves it unchanged. Getting a fresh checkout running AI is now "paste the OpenRouter key" rather than working out which file to hand-write.

  • The keys action refuses SUPABASE_SERVICE_ROLE_KEY and SUPABASE_DB_URL by name, explaining that the start and seed workflows inject them from supabase status — a hand-written value there is overwritten on the next run and wrong until then.

  • Values are validated at the prompt rather than at boot. getServerEnv() parses these with Zod, so a malformed OPENROUTER_SITE_URL does not degrade one feature, it throws on every server route; the TUI rejects it before it reaches .env.

  • Added OPENROUTER_API_KEY, plus the optional OPENROUTER_SITE_URL / OPENROUTER_APP_NAME attribution headers, to .env.example, apps/web/src/lib/env.ts, apps/worker/src/env.ts, and turbo.json.

  • Added NEXT_PUBLIC_FEATURE_PAID_MODEL_UPSELL (default off) across env.ts, .env.example, apps/web/Dockerfile, docker-compose.yml, and turbo.json. It controls the upsell surface only: off, the picker lists just what the account can run; on, Pro personas render as locked upgrade prompts. Entitlement is enforced in Postgres either way and cannot be flagged off.

  • Added a Feature flags section to pnpm dev:tui (also pnpm dev:tui -- flags). It lists every NEXT_PUBLIC_* toggle documented in .env.example with the value the app will actually see, distinguishes "explicitly off" from "never set", writes selections to the gitignored root .env atomically at mode 0600, and offers the rebuild those build-time values require. The catalog is derived from .env.example rather than hard-coded, and a pnpm test:dev-tui case fails if a flag reaches the Docker build without being documented there (#460).

  • Documented NEXT_PUBLIC_FEATURE_STALL_DETECTION in .env.example. It was validated in env.ts and passed as a Docker build arg but never documented, so there was no way to discover or enable it (#460).

  • Added the NEXT_PUBLIC_FEATURE_ONBOARDING_WIZARD feature flag to apps/web/src/lib/env.ts and .env.example, defaulting to off (#460).

  • Plumbed NEXT_PUBLIC_FEATURE_ONBOARDING_WIZARD through apps/web/Dockerfile (build arg + env) and docker-compose.yml. It was declared in env.ts and .env.example but not in the container build, so the wizard could not be switched on in the Docker/dev-TUI stack at all (#460).

  • Dockerized local development: apps/web/Dockerfile builds a multi-stage Next.js standalone image, apps/worker/Dockerfile builds the AI investigation worker, and a root docker-compose.yml runs both against the Supabase CLI local stack. A clean checkout works with no .env — Supabase falls back to the CLI's well-known local demo keys (#459).

  • Added pnpm dev:tui, an interactive local-stack control plane with live Docker/Supabase/web/worker/app status badges and nuke (nuke-and-pave), start, seed, reset-db, stop, status, and logs actions. Every action also runs non-interactively via pnpm dev:tui -- <action> [--dry-run], with --yes available on the destructive nuke and reset-db actions to skip the typed confirmation (#459).

  • Dev TUI safety rails: destructive actions require a typed project-name confirmation, are scoped to this checkout's Compose project and the local Supabase CLI project, refuse non-local Docker daemons, and never run machine-wide Docker prunes or --linked/--db-url Supabase commands. Stack actions read the active local credentials from supabase status and pass them explicitly, so a .env pointing at a hosted project is never used for local builds (#459).

  • pnpm dev:tui -- seed provisions deterministic local test users — superadmin@questify.test (SuperAdmin), admin@questify.test (Admin), and member@questify.test (no admin roles), password QuestifyDevTest123!. The seeder only talks to loopback Supabase URLs and only replaces users it created, identified by an app_metadata.dev_fixture marker (#459).

  • Added GET /api/health, a dependency-free liveness probe returning { "status": "ok" }, used by the Docker HEALTHCHECK and the dev TUI's post-start verification (#459).

  • Server-side Supabase clients (server.ts, service-role.ts, middleware.ts) now resolve their transport URL through supabaseServerUrl(), which prefers the runtime-only SUPABASE_INTERNAL_URL so SSR and the worker can reach the host-published Supabase gateway from inside a container. The auth session cookie name is derived from the public URL on both browser and server via supabaseAuthCookieName(), keeping one session across the split URLs. Both are no-ops outside Docker, where the variable is unset (#459).

  • apps/web/next.config.mjs emits output: 'standalone' with a workspace-root outputFileTracingRoot only when DOCKER_BUILD=1, leaving Vercel and next dev builds unchanged; DOCKER_BUILD and SUPABASE_INTERNAL_URL are declared in turbo.json globalEnv (#459).

  • Added root scripts docker:up, docker:down, docker:build, docker:build:worker, docker:config, and test:dev-tui; pnpm test now runs the dev TUI suite before turbo run test (#459).

CI/CD

  • The Playwright job now runs as a matrix over NEXT_PUBLIC_FEATURE_ONBOARDING_WIZARD, so the onboarding wizard is exercised with the flag both off and on instead of only in its default state. NEXT_PUBLIC_FEATURE_ONBOARDING_WIZARD was added to the build, dev, and test:e2e env lists in turbo.json and to the Playwright webServer env allow-list so the flag actually reaches the app under test (#460).

  • Pinned the Supabase CLI in the pgTAP job to 2.105.0, matching the db-push job and the lockfile. The job installed latest, so a newer local Postgres image drifted out from under the repo and failed every run since June with permission denied for table profiles — on PRs that changed no migrations (#460).

Tests

  • Added 4 pgTAP assertions for challenge selection: the chooser follows the period index rather than activity alone, an active-but-not-selected template is refused, the selected one starts, and walking the catalogue yields one attempt. Fixtures now take the day by deactivating the other templates of their cadence, since being active no longer implies being startable. Total 138 (#477).

  • Added 8 pgTAP assertions for the PR #477 review fixes: the definer helpers are not PUBLIC-executable, both sides of the completion race request the quest's row lock, and starting a challenge twice returns one quest with no orphan left behind. Total 134. The two races themselves were reproduced and re-tested with real concurrent sessions — without the lock, a completed quest with one outstanding task; with it, the racing insert is refused (#477).

  • Added Vitest coverage for the completion prompt's fresh count read: it prefers the server's answer over the rendered props, skips the question entirely when the fresh counts say everything is done, and falls back to the props when the read fails (#477).

  • Added 15 pgTAP assertions for quest completion: a quest with no tasks does not complete itself, finishing some tasks does not close it, finishing the last one does and stamps completed_at, reopening is not immediately undone, and complete_quest() honours _complete_tasks in both directions while still refusing a stale expected status. Total 126 (#477).

  • Added Vitest coverage for the completion dialog (straight through when nothing is outstanding or there are no tasks, both answers, cancel writing nothing, and other transitions never asking), for setQuestStatusAction routing completion through complete_quest while leaving other transitions on the plain update, and for the task section's single re-read when the last task closes the quest (#477).

  • Added 17 pgTAP assertions for template task locking: starting a challenge clones a locked checklist onto a closed quest, a second start in the same period returns the first quest, a stale period key is refused, a locked task can be completed, reordered and pinned to a place but not renamed, rewritten, moved to another quest or deleted, nothing can be added to a closed list, the list cannot be unlocked, and neither the lock nor the closed list can be minted by a caller. Total 111 (#477).

  • Added Vitest coverage for the new task-provenance helpers, the start_quest_challenge action (one round trip, expected vs unexpected database errors, anonymous redirect), the two new route guards on /api/tasks and /api/tasks/[id], and the TaskManager's locked-task rendering — including that a locked task keeps its progress and reorder controls, since a UI stricter than the trigger would make a challenge impossible to finish (#477).

  • Added Vitest coverage for the Quests section: the shared quest list (cards linked to their detail page, the caller's empty message with the create CTA, the error state taking precedence over both, the model-persona flag), listQuests' new query limit and its error propagation, the nav model's separation of Quests from Tasks, and the middleware protection on /quests and /tasks including the onboarding check. Extended the navigation Playwright spec to reach the Quests section from the side bar. Coverage ratchet raised to 37.3 / 76.8 / 59.2 / 37.3 (#469).

  • Added Vitest coverage for the coach review step: nothing hands over without Continue, the answer field is replaced by the two choices, Continue is focused and scrolled to, Start over clears the draft and restores a blank interview without cancelling, and Cancel with a draft waiting abandons it (#471).

  • Added Vitest coverage for the runaway-reply guard (no question, a question buried after an essay, heading- and list-shaped replies, and the draft-carrying closing turn exempted) and for the route's corrective regeneration, including the fall-back when the second attempt is no better (#471).

  • Added Vitest coverage for the coach reply parser (fenced, bare, unclosed-fence, trailing commentary, braces and escaped quotes inside strings, truncated payloads, and prose that merely contains a brace) and for the interview component (payload never rendered in any shape, draft summary and task preview shown, retry prompt on a truncated payload, persona sent each turn, cancel abandoning a pending draft, and the thread scrolling rather than the page) (#471).

  • Added Vitest coverage for the coach wiring: the interview route (chosen persona used, reorder-not-replace fallback, 402 for an unentitled key, malformed key ignored), defaultPersonaFor / personasForTier, createQuestSchema's coach-task rules including the pack-plus-coach rejection, and CoachingQuestCreator's ordering, persona hand-off, and survival of the draft-triggered form remount (#471).

  • Added Vitest coverage for the New Quest rework: FormSection (default-closed, opened when seeded, children mounted while collapsed, summary in place of description), ImageUrlList (add, remove, Enter-to-add without submitting the form, and rejection of non-URLs, non-http protocols, commas that parseImageUrls would split, duplicates, and the ninth image), and QuestForm's disclosure behaviour, collapsed-field submission, persona-name summary, and create-vs-edit status options (#471).

  • Raised the apps/web coverage ratchet to 37% statements / 76.8% branches / 59.1% functions / 37% lines, the new floor after the New Quest rework (measured 37.31 / 77.12 / 59.4 / 37.31 at 666 tests), keeping the usual margin for Node-version drift (#471).

  • Added Vitest coverage for the quest lifecycle rules: the shared state helpers, setQuestStatusAction (valid transitions, rejected skips, unknown statuses, unowned quests), the task API's 409 responses for draft, completed and archived quests, the lifecycle buttons, and the task manager's disabled controls on draft and archived quests (#462).

  • Added a Playwright case covering the draft-quest hold: the lock message on the quest page, task creation still allowed, the status control disabled, and both released by Start Quest (#462).

  • Added pgTAP coverage for the quest-state triggers — task writes allowed and refused across draft, active, completed and archived, plus the archived quest's own read-only behaviour, a task move out of a frozen quest, and an unarchive that tries to carry a content edit — bringing the plan to 86 (#462).

  • Added Vitest coverage for the navigation model (active-section matching, including the /quests vs /quests/public overlap, and admin-only visibility), the side bar, and the top bar's drawer, account menu, Escape handling, and sign-out form. Added a Playwright spec covering section-to-section navigation, the active marker, sign-out, and the absence of the shell for signed-out visitors. A source-level guard asserts that every module importing a nav item list declares 'use client', since the lists carry icon component references that cannot cross the RSC boundary — a failure Vitest cannot otherwise see, because it renders everything as plain React (#461).

  • Raised the apps/web coverage ratchet to 27.2% statements / 71.2% branches / 50% functions / 27.2% lines, the new floor after the navigation work (measured 27.76 / 71.91 / 50.62 / 27.76), keeping the usual margin for Node-version drift (#461).

  • Added regression coverage for both Codex review findings: a pgTAP case asserting a downgraded owner cannot re-queue a Pro investigation by resetting its status (verified to fail against the pre-fix trigger), and quest-form.test.tsx asserting the edit form submits no model_persona_key when the stored persona is locked or filtered out, while ordinary and Pro-tier edits still round-trip.

  • Added scripts/dev-tui/env-keys.test.mjs (28 cases): masking never reveals the middle of a value and is fixed width so it cannot leak the length, a pasted line break is rejected, per-entry URL and email rules hold, KEY="" reads as unset, rendering preserves comments and collapses duplicates, and the writer validates everything before touching the filesystem so a bad key in a batch cannot leave a half-saved .env. Two drift guards assert every key the TUI offers is documented in .env.example and actually read by one of the env schemas.

  • Added dev-TUI cases asserting the keys action never prints a configured secret, lists every managed credential, and leaves .env untouched on --dry-run.

  • Ratcheted the web coverage thresholds to 30.4% statements/lines, 74.3% branches, and 52.6% functions — the floor for the combined suite once this branch and the navigation work are merged (measured 30.78 / 74.69 / 52.99 / 30.78 across 67 files and 499 tests), keeping the usual margin for Node-version drift. src/lib/ai sits at ~92% statements and components/layout at ~93%.

  • Added Vitest coverage for the entitlement layer: tier comparison against the SQL it mirrors, persona resolution (explicit request vs. inherited downgrade vs. missing persona), read failures resolving to free, the shared provider client's model-type parsing, system-message handling per provider, and the OpenRouter free-slug retry — including that a 401/429/500 is not retried, since a different model would fail identically and burn a second request.

  • Added persona-picker cases for locked cards: a locked persona cannot be selected or submitted, recommended does not override entitlement, a free persona is labelled Free rather than by cost band, and the picker renders nothing when the tier covers no persona at all.

  • Added Vitest coverage for the auth flows' post-login destination — signup, login, and the /auth/callback handler across both flag states, deep links, and the password-recovery guard (#460).

  • Added Vitest coverage for the onboarding wizard: step-sequence helpers, Zod validators for all four steps, onboarding queries (including the middleware probe's missing-row and fail-open behaviour), every Server Action (advance, skip, back, dismiss, first-quest idempotency, outcome-array corrections, validation and write-failure paths), and component tests for the wizard shell, progress indicator, step footer, and pack picker (#460).

  • Added pgTAP RLS coverage for public.user_onboarding — owner read/update, cross-user read and update denial, insert-on-behalf-of denial, and anonymous denial — bringing the plan to 50 (#460).

  • Added apps/web/e2e/onboarding.spec.ts covering the anonymous login bounce, protected-path redirect ordering, flag-off inertness, the full four-step flow, single-step skip, whole-wizard dismissal, back navigation, and that revisiting the first-quest step renames the quest instead of creating a second one (#460).

  • Added src/test/react-dom-form-hooks.ts, a shim supplying useFormState/useFormStatus under Vitest so components built on Server Action forms can be tested at all — the standalone react-dom@18 package does not ship the hooks Next.js provides via its vendored React (#460).

  • Raised the web coverage ratchet to 33.3% statements/lines, 74.3% branches, and 56% functions — the floor for the combined suite once the plan-wall work and this branch are merged (measured 33.73 / 74.61 / 56.56 across 73 files and 571 tests). The branch gate stays at its previous 74.3% rather than dropping to the newly measured floor (#462).

  • Raised the web coverage ratchet to 26.5% statements/lines, 70% branches, and 49% functions (measured 26.9 / 70.58 / 49.34) (#460).

  • Added node:test coverage for the dev TUI — workflow-plan step ordering and safety invariants, the process runner, and the seeder's loopback/fixture-marker guards — run via pnpm test:dev-tui (#459).

  • Added Vitest coverage for /api/health, supabaseServerUrl()/supabaseAuthCookieName(), and createServiceRoleClient(); the web coverage ratchet moves to 22.2% statements/lines, 67.25% branches, and 43.5% functions (#459).

Refactors

  • Removed Skill Tracks. The feature never recorded a row of user progress: nothing called startSkillTrack() or the advance_skill_track_stage() RPC, so user_skill_track_progress was permanently empty, every visitor saw stage 1 as "current" with stages 2..N padlocked forever, the dashboard resume widget could never render, and "track complete" was unreachable. Starting a stage cloned nothing either — it prefilled a quest title and dropped the stage's tasks — so a track constrained its tasks less than a Quest Pack did, not more. Gone with it: /skill-tracks and /skill-tracks/[slug], the resume widget, the stage map, both lib modules, the admin pack-type toggle, the ?stage= prefill and "Track map" link on New Quest, the /quest-packs/:slug/track redirect added in #470, and the three pack surfaces that had to filter type out (#477).

  • Extracted scripts/dev-tui/env-file.mjs. The feature-flag and API-key sections edit the same .env, and the parts worth getting right — preserving unrelated lines and comments, collapsing duplicate assignments, refusing a symlinked file, writing atomically at 0600 — are identical for both. env-flags.mjs now delegates to it and lost ~55 lines.

  • Extracted apps/web/src/lib/ai/providers.ts. The narrative route and the coach route each carried their own copy of the provider fetch, error extraction, and dispatch logic — both annotated "no shared package yet" — and adding OpenRouter would have made that three copies of four providers. The two routes shed ~250 lines between them. The worker keeps its own client: it needs image parts, long timeouts, and per-investigation usage accounting.

Documentation

  • Added reports/challenges-skill-tracks-review.md, the audit these changes came out of: what each feature claimed, what it actually enforced, and the four options considered before Skill Tracks was removed and Challenges was constrained in the database (#477).

  • Documented plan tiers in docs/ai-investigations.md (the free tier and OpenRouter's rate limits, the enforcement-trigger table, the grandfathering rule, the admin grant path, and the upsell flag) and in README.md. Recorded the tier rules and the shared web provider client in AGENTS.md.

  • Added a "Turning on a feature flag locally" section to docs/local-docker.md. A clean checkout has no .env, so every NEXT_PUBLIC_FEATURE_* flag stays off through a nuke-and-pave until one is set — and because the value is compiled into the bundle, the image has to be rebuilt rather than just restarted (#460).

  • Added docs/local-docker.md covering the dev TUI actions, safety rails, seeded users, plain Docker commands, and the build-time vs runtime environment split, plus a "Docker Quick Start (dev TUI)" section in README.md (#459).

  • Documented SUPABASE_INTERNAL_URL in .env.example, and made updating CHANGELOG.md an explicit per-PR requirement in AGENTS.md (#459).