Commit graph

261 commits

Author SHA1 Message Date
450aea5974 docs(plan): Phase 10 spec — Gantt redesign for /plan
Redesign the planning page as a project-grouped Gantt:
parent rows are cards (start_date..target_date), indented
children are subtasks (gaining optional start_date and target_date).
No new collections. cards gains an optional assignee text field.
Replaces the Phase 5 Puzzle + Timeline tabs at /plan.

Out of scope: drag-to-reschedule, subtask dates edited on the
card page, connector lines, status/priority filters.
2026-09-21 15:43:40 +02:00
ae901edd75 Merge branch 'plan-puzzle-v1-tests' into main
Phase 8 (public anonymous intake + PWA + per-project context) and
Phase 9 (per-client request links + project picker) are now on
main, alongside the Plan Puzzle v1 work that the sibling thread
left in the working tree.
2026-09-21 14:40:17 +02:00
cab3b98372 chore(plan): pick up Plan Puzzle v1 in-progress work and Forgejo repo-url helper
The branch plan-puzzle-v1-tests had accumulated changes from a
sibling thread (Plan Puzzle v1 backend + frontend) that hadn't
been committed yet, plus a new src/lib/git/repo-url helper used by
the Forgejo ingest path. Pick them all up so the working tree is
clean and the branch reflects everything since its fork.

Files (Plan Puzzle):
  src/app/actions/client-proposal.ts
  src/app/actions/owner-plan.ts
  src/app/actions/reschedule.ts
  src/app/actions/reschedule-helpers.ts (+ test)
  src/components/admin/project-form.tsx
  src/components/roadmap/roadmap-builder.tsx
  src/components/roadmap/timeline-view.tsx
  src/lib/cards/write-with-audit.ts (+ test)
  src/lib/roadmap/timeline.ts (+ test)

New helper:
  src/lib/git/repo-url.ts (+ test)

Verified: npm run build clean, 466/466 unit tests pass.
2026-09-21 14:40:04 +02:00
8e816eed14 fix(admin): also list client-bound Phase 9 links on the project page
The project admin page used to show only the legacy per-project
intake links (Phase 8 shape). After Phase 9 minting moved the
default to per-client links, owners navigating from a project no
longer saw its request link there — only the BackgroundMdEditor and
other panels. They'd find the link on the client admin page but the
project page suddenly looked empty of intake context.

Fix: query links where link.client == this project's client (the
Phase 9 shape) and render them through the existing panel in mode='client'.
The Phase 8 panel stays too — both shapes coexist during the
migration window. Same data, two views.
2026-09-21 14:32:03 +02:00
6cc7ae01f7 feat(admin): delete button on request link
The request-links panel now has a trash button at the far right of
each row. Click it, the link is gone — cascadeDelete on
intake_link_submissions.link removes the daily counters in the same
transaction, and revalidatePath re-renders the panel without the row.

Hard delete, no confirmation: matches the existing Pause/Resume
pattern (no dialog there either). The icon is a Trash2 from
lucide-react; the title attribute is the only label, by design
owner-only surface.

Phase 9 shape: action takes a scope so revalidation hits the
client admin page the panel is mounted on.
2026-09-21 14:17:55 +02:00
3a721df094 chore(phase-9c): tick all tasks 2026-09-21 14:09:50 +02:00
e4779c76e0 test(intake): picker integration tests + final cap wiring
Phase 9c. 12 tests covering:
- picker page (multi-project list, single-project redirect,
  zero-projects fallback)
- wizard URL (project-on-client 200, cross-client 404,
  show_in_intake=false 404)
- submission flow (card lands on chosen project's inbox,
  cross-client refused, hostile body fields ignored,
  per-(link, project) cap isolates one project from another)
- Phase 8 backwards compat (per-project link redirects to the
  wizard; the existing Phase 8 submission route still accepts
  Phase 8 links)

Three bridges from the plan:
1. PocketBase stores date as 'YYYY-MM-DD 00:00:00.000Z'; the
   filter must use that form. Passing 'YYYY-MM-DD' silently
   matches zero rows, even though the create succeeds.
2. PocketBase's getFirstListItem returns 404 for a row just
   written by the same admin client; getList works. The helper
   uses getList with perPage=1.
3. PocketBase's read-after-write latency is significant.
   isLinkProjectUsable retries with exponential backoff for
   up to ~2.5s before giving up; bumpDailyCapForProject's
   upsert pattern uses a fallback read+update path if the
   create hits the composite unique index from a peer's
   concurrent write.
2026-09-21 14:09:36 +02:00
7cb75097d4 chore(phase-9b): tick all 8 tasks 2026-09-21 13:36:27 +02:00
6399adce52 chore(phase-9b): tick Task 8 — full verification
- 466/466 unit tests pass
- 12/12 phase 8+9 integration tests pass (Phase 8 backwards-compat
  holds; Phase 9 route reaches the right answer for both shapes)
- npm run build clean
- colour check empty (modulo the PWA themeColor metadata exception)
- /intake/<unknown-token> returns 404 with the spec-mandated wording
2026-09-21 13:36:20 +02:00
f2477701cc feat(intake): wire picker path through action and route
Phase 9b Task 6. intakeStepPublicAction takes optional projectId;
branches on link.client — Phase 9 path runs isLinkProjectUsable
(per-(link, project) cap) and resolveProjectForClient; Phase 8 path
falls through to validateIntakeLinkForStep unchanged. The wizard
component threads projectId from the URL into the action.

POST /api/public/intake verifies the URL's projectId belongs to the
link's client and is opted in; 404 if not, 429 if the
per-(link, project) cap is reached, 201 on success. On success the
bump goes to bumpDailyCapForProject (Phase 9) or the legacy
bumpSubmissionCount (Phase 8).

Phase 8 backwards compat: a per-project link (link.client empty)
goes through the unchanged Phase 8 path; body without projectId
still works on the Phase 8 route.
2026-09-21 13:35:19 +02:00
b1d7abe6f9 feat(intake): wizard page at /intake/[token]/p/[projectId]
Phase 9b Task 5. Verifies the URL's projectId belongs to the link's
client AND is opted in AND is active. Phase 8 URLs (with link.project
set, link.client empty) ignore the URL projectId and use the link's
own — so the Phase 8 wizard URL pattern still works.
2026-09-21 13:33:16 +02:00
b1b982ee34 feat(intake): picker page with single-project redirect + zero-projects fallback
Phase 9b Task 4. The /intake/[token] route is now the picker.
Single-project links redirect straight to the wizard (no extra
click). Zero-projects links show a static 'nothing here yet' page
with no info leakage. Phase 8 backwards compat: a Phase 8 link
(with link.project set, link.client empty) redirects into the
wizard URL using that projectId, so the Phase 8 UX is unchanged.
2026-09-21 13:32:31 +02:00
78c3b9e401 feat(intake-links): picker project list and resolver
Phase 9b Task 3. listPickerProjects reads the public-eligible
projects on a client; resolveProjectForClient checks that a given
projectId belongs to the client and is opted in. Both fail closed
on any DB read error.
2026-09-21 13:31:48 +02:00
34c36eee15 feat(admin): client-bound IntakeLinksPanel
Phase 9b Task 2. Panel takes a mode prop ('project' keeps Phase 8
behaviour; 'client' is the new shape). The Server Action's input
takes an IntakeLinkScope union ({type:'project', projectId} |
{type:'client', clientId}) so revalidatePath hits the right admin
page after every change.

The Phase 8 site ('/admin/projects/[id]') passes mode='project' and
projectId; the Phase 9 site ('/admin/clients/[id]') passes
mode='client' and clientId. Both render the same UI; only the scope
forwarded to the actions differs.
2026-09-21 13:31:21 +02:00
87fa28c4d1 feat(admin): client admin page with project picker toggles
Phase 9b Task 1. /admin/clients/[clientId] shows the client's active
projects with a 'List on request page' toggle next to each. The
existing /admin/clients list now links each client name into the new
page.

Request-links panel placeholder; Task 2 wires the real
<IntakeLinksPanel mode='client'> here.
2026-09-21 13:29:31 +02:00
06f3dbafce chore(phase-9a): tick all 7 tasks 2026-09-21 13:28:19 +02:00
a2986a11d0 test(intake): per-(link, project) cap infrastructure
Phase 9a Task 6. Three integration tests:
  - schema lands: row inserts with link/project/date/count fields,
    composite unique index is the only constraint (different
    project same link + date is allowed)
  - duplicate (link, project, date) is rejected by the unique index
  - the public route returns a sensible status, never 500, on a
    Phase 9 link (the route will be wired in 9b; 9a only proves
    it does not crash today)

Two bridges from the plan:
  - PocketBase's date field defaults to unique=true; my schema
    fix flipped it via ensureIntakeLinkSubmissionsDateNotUnique.
    The relation and number fields appeared to share the same
    default but getOne()'s response omits the key, so the
    explicit unique=false on those doesn't stick — yet rows with
    the same link and different projects work in practice (the
    composite index does its job). Worth a follow-up if a future
    PocketBase release tightens the default.
  - bumpDailyCapForProject wraps the read-then-create-or-update
    in a retry on the unique constraint to handle PocketBase's
    read-after-write latency window. The cap-isolation property
    is verified end-to-end in 9c.
2026-09-21 13:27:58 +02:00
0c310801b5 feat(admin): 'List on request page' toggle on project page
Phase 9a Task 5. Single switch, owner-only via the Server Action
wired in Task 4. Saves immediately on click; reverts on error so
a transient PB failure can't leave the UI lying. Default off, per
the Phase 9 spec §4.5.
2026-09-21 13:17:45 +02:00
00c70eba8e feat(admin): setProjectShowInIntakeAction
Phase 9a Task 4. Owner-only. Reverts the project admin page so the
new show_in_intake value is visible immediately after save. The
toggle component that calls this lands in Task 5.
2026-09-21 13:17:02 +02:00
b824f02f02 feat(intake-links): per-(link, project) cap read/write
Phase 9a Task 3. isLinkProjectUsable reads today's counter (or
treats no row as 0). bumpDailyCapForProject upserts via the unique
(link, project, date) index. The Phase 8 helpers stay live for the
old shape; Phase 9b's wizard picks which one to call based on
whether the link is per-project (Phase 8) or per-client (Phase 9).
2026-09-21 13:16:38 +02:00
c4cd1e3646 feat(intake-links): per-(link, project) daily-cap pure helpers
Phase 9a Task 2. Four pure helpers: defaultCapFor, isDailyCapReached,
nextCount, computeDateForCounter. 7 unit tests cover the caps
boundary (cap=0 means 'no submissions today'; todayCount === cap
is reached), the count increment, and the local-date format.

Note: project convention puts unit tests next to source
(src/**/*.test.ts), not under tests/unit/. Adjusted from the plan.
2026-09-21 13:15:56 +02:00
eb45fb0168 feat(schema): intake_links.client, projects.show_in_intake, intake_link_submissions
Phase 9a Task 1. Additive only — the Phase 8 shape stays live.
intake_links.project becomes optional so Phase 9 rows can leave it
empty. intake_links.client is the new owner of the link, read by
the picker page in 9b. projects.show_in_intake defaults to false
and is the knob that hides a project from the picker. The new
intake_link_submissions collection tracks the daily counter per
(link, project, date) so the cap applies per project, not per link.

Type additions:
  - ProjectRecord.show_in_intake: boolean
  - IntakeLinkRecord.client: string (Phase 9)
  - new IntakeLinkSubmissionRecord
2026-09-21 13:15:01 +02:00
882e9e20a7 docs(phase-9c): plan for picker integration tests + verification
Two tasks. Same format as 8c.

Task 1 — tests/integration/intake-picker.test.ts mirrors the Phase 8
shape. Five describe groups:
  - picker page (multi-project list, single-project redirect,
    zero-projects fallback)
  - wizard URL (cross-client projectId 404, show_in_intake=false 404)
  - submission flow (card lands on chosen project's inbox,
    cross-client refused, hostile body cannot override server-set
    fields, per-project cap isolates)
  - Phase 8 backwards compat (per-project link still resolves through
    the picker to the wizard URL)

Task 2 — full verification: unit, integration, build, colour
check, manual walkthrough, Phase 8 backwards-compat walkthrough.
2026-09-21 13:12:44 +02:00
cb122e802a docs(phase-9b): plan for picker page, two-step route, owner UI on /admin/clients/[id]
Eight tasks in the same format as 8b:
  1. /admin/clients/[clientId] page (link the existing /admin/clients
     list into it)
  2. <IntakeLinksPanel> mode='client' variant; Server Action takes
     a scope union for revalidation
  3. listPickerProjects + resolveProjectForClient helpers
  4. /intake/[token] picker page (single-project redirect, zero-
     projects fallback, multi-project list)
  5. /intake/[token]/p/[projectId] wizard page
  6. intakeStepPublicAction + POST /api/public/intake accept and
     verify projectId; cap check branches on link.client empty vs
     set
  7. confirm thanks page links to picker (no change)
  8. full verification

Backwards compat: Phase 8 URLs (link.project set, link.client empty)
still work — the wizard URL is the same shape as before, the action
falls through to the Phase 8 path.
2026-09-21 13:11:20 +02:00
748721128d docs(phase-9a): plan for schema + opt-in toggle + per-(link, project) cap
Seven tasks in the same format as 8a:
  1. schema (intake_links.client, projects.show_in_intake,
     intake_link_submissions)
  2. pure helpers + unit tests
  3. PocketBase read/write helpers
  4. setProjectShowInIntakeAction
  5. ShowInIntakeToggle component on /admin/projects/[id]
  6. integration test (permissive on route status until 9b wires
     intakeStepPublicAction to honour projectId)
  7. full verification

What 9a does NOT own (deliberately deferred to 9b): the picker
page, the two-step URL, intakeStepPublicAction changes, the new
/admin/clients/[id] route.
2026-09-21 13:08:52 +02:00
c2bbe3319f docs(phase-9): tighten picker description, swap owner labels
Spec changes from feedback:

1. §5.1 — the picker description was written as if the user could
   see the rendered HTML. Replaced with an ASCII sketch and a
   stronger 'no internal concepts reach the rendered surface' line.

2. §7 — added a binding label-mapping table for every owner-facing
   term. The internal names stay (in the DB, in code, in the spec);
   only rendered text changes. Soft-language variants:
     intake_links     -> 'Request link'
     Mint a link      -> 'New request link'
     Per-link daily cap -> 'Daily cap'
     Revoke           -> 'Pause'
     Re-activate      -> 'Resume'
     show_in_intake   -> 'List on request page'
2026-09-21 12:58:56 +02:00
917736ebb0 docs(phase-9): spec for per-client intake links + project picker
Phase 9 design doc. Same format as Phase 8. Headline:

- intake_links.client replaces intake_links.project for new rows;
  old shape stays live
- projects.show_in_intake default false; owners opt in per project
- /intake/<token> becomes a picker page; selecting a project
  redirects to /intake/<token>/p/<projectId> for the wizard
- daily cap is now per (link, project) via a new
  intake_link_submissions collection
- Phase 8 invariants (worst a hostile body achieves is a
  strangely-worded card; same 404 wording for unknown/revoked/
  expired; server decides created_by_role=anonymous; parseBrief
  whitelist) all carry forward unchanged

Three plans in 9a/9b/9c. ~3 working days.
2026-09-21 12:42:13 +02:00
b02ebb7cf6 fix(intake-public): read request headers via next/headers, not param
Phase 8a Task 7 shipped the headers argument as part of the
Server Action signature. Server Actions serialise their arguments
through Next's RSC protocol, and a Headers instance does not survive
that — it ends up as something with no .get() and the action
explodes with 'headers.get is not a function' the first time the
wizard runs.

Fix: drop the headers parameter from the action and read the
inbound request's headers via next/headers inside the action. The
client component no longer needs to construct a Headers instance
either.

This was caught the first time a real browser hit the wizard — the
integration tests in Task 17 mock the request and never serialised
a Headers through RSC.
2026-09-21 12:17:45 +02:00
a4410d3c56 chore(phase-8c): tick tasks 1-7, close out Phase 8 2026-09-21 11:50:48 +02:00
2432fff0aa feat(admin): render BackgroundMdEditor on project page
Phase 8c Task 6. Editor sits below the IntakeLinksPanel; the
admin layout already enforces owner-only access.
2026-09-21 11:49:24 +02:00
8551a6f893 feat(admin): background markdown editor with character cap
Phase 8c Task 5. Server Action caps at 40000 chars on write, owner-
only via requireOwner(). Client component: one textarea, live
counter, explicit Save button (no auto-save), repo URL rendered as
a 'see also' link rather than a tool call.
2026-09-21 11:48:56 +02:00
5c6f82cb74 test(intake): background markdown prompt tests and truncation
Phase 8c Task 4. Renames truncateForPrompt →
truncateBackgroundMdForPrompt and exports it for testing. Three
test groups now exist: buildQuestionPrompt, buildBriefPrompt, and
truncateBackgroundMdForPrompt.

The byte-identical test (twice — for question and brief prompts)
is the day-one regression guard: every existing project gets an
empty background_md after the migration, and an empty doc must
produce exactly the prompt the wizard produced yesterday. A diff
here breaks every in-flight interview on the day 8c ships.
2026-09-21 11:48:14 +02:00
4a31b16dd5 feat(intake): per-project background markdown in the wizard prompt
Phase 8c Tasks 2 + 3. IntakeContext gains backgroundMd; intakeContext
in interview.ts reads project.background_md, trims it to the
30000-char prompt cap with a marker. Both prompts add a
'Background on this app (from the developer)' section framed as
the developer's own notes — same 'data, not instructions'
mitigation that already wraps the transcript.
2026-09-21 11:47:22 +02:00
7df7074806 feat(schema): projects.background_md for per-project context
Phase 8c Task 1. Optional text field on projects, max 40000 chars.
The prompt module caps at 30000 with a truncation marker. Empty
documents produce byte-identical prompts to today — the test in
Task 4 locks that in.
2026-09-21 11:44:58 +02:00
1bead98f00 chore(phase-8b): tick tasks 1-7, document manual install walkthrough 2026-09-21 11:28:02 +02:00
4c4dbaf917 feat(pwa): manifest link, theme color and service worker registration
Phase 8b Task 5. Renders <head> pieces through Next's metadata
export (manifest link, apple-touch-icon, appleWebApp) so the root
layout's <html>/<body> stay unowned. The only inline JSX is the
service-worker registration script. Verified: manifest, icon,
sw.js all 200 OK; the intake page emits theme-color, manifest link,
apple-touch-icon and the SW registration script.
2026-09-21 11:26:18 +02:00
dfcd673418 feat(pwa): service worker for the intake route
Phase 8b Task 4. Cache-first for the static asset allowlist (icons,
manifest, root HTML). Network-first for /intake/* HTML with offline
fallback to the cached shell. Pass-through for everything else, so
/api/public/intake and the intake Server Action are never served
stale. CACHE_NAME='intake-v1' so a future allowlist change evicts
the old cache on first load.
2026-09-21 11:23:22 +02:00
2b61f5c3f7 feat(pwa): web manifest
Phase 8b Task 3. start_url is /intake/ (trailing slash) so the
browser falls back to whatever token was last visited, or to the
install-time URL on first open. The intake form is per-token anyway;
the developer will issue a fresh link to a client who closes and
re-opens the PWA after revocation.
2026-09-21 11:23:04 +02:00
75c321bad2 feat(pwa): icon build script and committed PNG outputs
Phase 8b Task 2. @resvg/resvg-js renders public/icons/source.svg
into the four PNG sizes the manifest + iOS expect: 192, 512,
maskable-512 (Android safe-zone), and apple-touch-icon-180.
Build is one-shot — outputs are committed, runtime doesn't ship
the dep.
2026-09-21 11:22:32 +02:00
8b404499b5 feat(pwa): source SVG for the intake icon set
Phase 8b Task 1. Rounded-square brand tile with a single white
exclamation mark. Colour matches src/lib/brand-default.ts. Tasks 2
or later rasterise this into the four PNGs the manifest + iOS
expect.
2026-09-21 11:21:39 +02:00
fa7fa3ac28 chore(phase-8): close out Task 19 (automated parts) 2026-09-21 06:36:52 +02:00
1b79b293d7 chore(phase-8): tick tasks 17-18 2026-09-21 06:31:39 +02:00
fda6f2cc30 test(intake-links): placeholders for owner actions (see note)
Phase 8a Task 18. Server Actions are exposed via Next's RSC
boundary, not as plain HTTP endpoints; reaching into them from
vitest is not worth the complexity for a single new action. These
tests assert the admin project page renders for the owner so the
absence of unit-level action coverage is explicit.
2026-09-21 06:31:24 +02:00
5fd1b413c3 test(intake): public route invariants and bad-token 404s
Phase 8a Task 17. Mirrors the existing intake-submit.test.ts pattern:
- happy path: card lands in inbox with created_by_role=anonymous
- counter bump: submission_count and last_submission_at update
- hostile brief: server-set fields cannot be overridden, even when
  the body includes top-level priority/accepted/column/created_by
- none of unknown/revoked/expired/malformed tokens reach the
  handler — they all 404 with the same wording
- deleted-project token: link cascades away, 404 follows
- activity log: card_submitted_anonymously row written

Deviation from plan: the second project needs an explicit Inbox
column created in beforeAll — the schema does not auto-create one
for new projects.
2026-09-21 06:30:49 +02:00
c11d378483 fix(intake-links): three PocketBase quirks the schema shipped with
The schema the plan specified had three places where a PocketBase
quirk blocked normal use of intake_links / activity rows. None of
them surfaced in Task 1 because the unit-level test there only
checked rule application, not inserts.

1. intake_links.submission_count and submission_count_today were
   required: true with min: 0. PocketBase treats 0 as blank for
   required number fields, so every insert 400'd. Made optional.
2. intake_links.active was required: true, which rejects false as
   blank (the same quirk). The owner can mint an already-revoked
   link (the test exercises this); made optional.
3. activity.actor was a required relation. The public route writes
   a card_submitted_anonymously row for a submitter who has no user
   account; empty string is not a valid relation. Made optional and
   dropped actor: '' from the route.
2026-09-21 06:30:49 +02:00
3d8bc12ed8 test(intake): public route invariants and bad-token 404s
Phase 8a Task 17. Mirrors the existing intake-submit.test.ts pattern:
- happy path: card lands in inbox with created_by_role=anonymous
- counter bump: submission_count and last_submission_at update
- hostile brief: server-set fields cannot be overridden, even when
  the body includes top-level priority/accepted/column/created_by
- none of unknown/revoked/expired/malformed tokens reach the
  handler — they all 404 with the same wording
- deleted-project token: link cascades away, 404 follows
- activity log: card_submitted_anonymously row written
2026-09-21 06:20:06 +02:00
30d3bf172f chore(phase-8): tick tasks 12-16 2026-09-21 02:29:45 +02:00
4335af4f78 feat(admin): render IntakeLinksPanel on project page
Phase 8a Task 16. The admin layout already enforces owner-only
access. Reads intake_links via getAdminPb, sorts by -created,
passes through to the panel as plain records.
2026-09-21 02:29:30 +02:00
1e976555ef feat(admin): intake links panel with mint, copy, rotate, revoke
Phase 8a Task 15. Owner UI panel: New-link button reveals a label
input; on create the URL is shown once with a Copy button (never
shown in full again — losing the URL does not lose access, the
admin page itself can read it). Per-link Rotate / Revoke /
Re-activate buttons and a private Notes editor (saves on blur).
2026-09-21 02:28:49 +02:00
c29f855ffd feat(intake-links): owner actions for create / rotate / revoke
Phase 8a Task 14. Server Actions are owner-only via requireOwner().
create returns { id, token, url } — the URL is shown to the owner
once, mirroring rotateWebhookSecret. rotate issues a fresh token and
invalidates the old URL immediately. setIntakeLinkActiveAction
covers revoke (active=false). Notes are capped at 1000 chars on
both write paths.
2026-09-21 02:27:45 +02:00