Commit graph

277 commits

Author SHA1 Message Date
78fc06555d feat(plan): show work item details and wrap titles 2026-09-22 20:55:22 +02:00
ab81f32cdf perf(board): batch signed-in project summary reads 2026-09-22 20:39:55 +02:00
9778ee09d4 feat(plan): order timeline by dates and priority 2026-09-22 08:35:17 +02:00
a6785f74ee chore(plan): add targeted hosted schema command 2026-09-22 08:30:20 +02:00
48458f495f feat(plan): rebuild collaborative timeline planner 2026-09-22 08:28:41 +02:00
cc2efd1f23 feat(plan): drag + double-click editor on the Gantt bars
Any signed-in user — owner or client_manager — can now edit
dates directly on the Gantt. Three interaction shapes:

- Drag the bar body to move it (both start and target shift
  by the same number of days, snapped to whole UTC days).
- Drag the right edge to resize (target only).
- Double-click any card or subtask bar to open a modal with
  Start + End date inputs, ESC to close, Enter to save.

Both card and subtask rows ship the same affordances. Cards
go through the existing rescheduleCardAction so the
activity-log row keeps recording who moved the date;
subtasks go through a new setSubtaskDatesAction (no audit
row — matches the existing subtask-toggle pattern).

Components touched:
- GanttBar gains canEdit / onMove / onResize / onEditRequest.
- GanttDateEditor is the inline two-input modal.
- ProjectSection forwards four callbacks (card move/resize,
  subtask move/resize) plus onEditRequest.
- GanttPlanView holds the editor's open state and computes
  one shared timeline range across sections.
- GanttClient wraps the view and bridges to the actions.
- /plan/page.tsx renders GanttClient.

Spec §6 amended to move drag from out-of-scope into the
delivered surface.

482/482 unit tests pass (was 471, +11 from bar-math).
Integration plan-gantt tests still pass.
Build clean.
2026-09-22 05:19:19 +02:00
2b01d0e837 feat(plan): bar-math helpers + open reschedule to anyone signed in
Pure helpers for the Gantt's drag/double-click math:

- fractionToDate(fraction, range): what ISO date a fractional
  position in the chart represents, snapped to whole UTC days.
- shiftDatesByFraction: move a bar's start and target by the
  same delta, clamped to the visible range, with start dropped
  when clamping would invert or collapse the span.
- resizeEndToFraction: what the new target date becomes when
  the user drags a right edge.
- moveCardByFraction: alias that produces the action's input
  shape.

11 unit tests cover the corners (no dates set, point bar,
clamping past the right edge, inversion guard).

rescheduleCardAction also drops its owner-only gate. Any
signed-in user — owner or client manager — can move dates
now. PocketBase's existing row-level rules govern which cards
a given user can write (a client manager still only writes
their own client's cards); the activity-log row is unchanged
and attributes the move to the actual user either way.

Adds setSubtaskDatesAction so the same drag/double-click
gestures work on subtask rows. No audit log on subtasks —
matches the existing pattern (subtask toggles don't log).
2026-09-22 05:16:04 +02:00
cc988c66ca feat(card): show scheduled date range next to dated subtasks
When a subtask has start_date or target_date set, the card
detail's checklist row now shows a small '5 Mar → 19 Mar' label
next to the title. Mirrors the Gantt's view of the same data
without duplicating the chart on the card page.

Build clean.
2026-09-21 16:20:57 +02:00
14f6c067c2 test(plan): integration test for the new Gantt view
Two tests against the live database and the seeded project:

- loadPlan returns a section containing the seeded project.
- GanttPlanView renders the project name and at least one
  card title to HTML.

vitest integration config now picks up .test.tsx too.
2026-09-21 16:19:52 +02:00
5ff12a4ef0 feat(plan): replace /plan page body with GanttPlanView
The Puzzle + Timeline tabs of Phase 5 are gone. /plan now
renders one ProjectSection per active project. The old
RoadmapStats strip is removed — its only honest count was
'unscheduled', which the section footer already states
per project.

Build clean.
2026-09-21 16:18:08 +02:00
621a1d419a feat(plan): GanttPlanView top-level component
Composes one ProjectSection per section with a single shared
timeline range. Renders the 'Nothing scheduled yet' empty
state when no section has any dated cards or subtasks.
2026-09-21 16:16:40 +02:00
812af63e66 feat(plan): ProjectSection component
One project's Gantt section: header with brand-colour top border,
label column with indented subtask labels, timeline track with
month labels + W1..W4 ticks + today marker, and per-row bars
reusing GanttBar. Flat row array drives both columns so labels
and bars cannot drift apart. Footer counts undated cards and
subtasks.
2026-09-21 16:16:05 +02:00
644843ce0c feat(plan): GanttBar component
Single horizontal bar — same component renders both card bars
(full colour) and subtask bars (same colour, optionally
strikethrough when done). Uses the same TimelineRange as the
header so the bar and the month ticks above it can never
disagree. Keyboard-focusable with a brand-coloured focus ring.
2026-09-21 16:14:09 +02:00
92c2501726 feat(plan): loadPlan loader with pure buildPlanSections helper
New loader returns the sectioned shape the Gantt view needs:

- buildPlanSections (pure): groups cards by project, sorts by
  start_date asc / title, splits subtasks into dated vs undated.
  Five unit tests cover the grouping and sorting rules.
- loadPlan (server-only): one PocketBase trip per collection,
  delegates to buildPlanSections.

471/471 unit tests pass (was 466, +5 new).
2026-09-21 16:13:47 +02:00
a5dc6083dd feat(schema): add cards.assignee and subtask date fields
Three new optional fields, idempotent ensure helpers:

- cards.assignee: free-text label rendered above the card bar
  on the new /plan Gantt. Empty by default.
- subtasks.start_date: ISO date or empty. Lets subtasks appear
  on the Gantt under their parent card with their own duration.
- subtasks.target_date: same, for the bar end.

Live migration confirmed idempotent — first run added the
fields, second run reported both already present.
2026-09-21 16:12:29 +02:00
16c8ea3644 docs(plan): Phase 10 implementation plan
Nine tasks: schema adds, loadPlan + pure grouping helper,
GanttBar / ProjectSection / GanttPlanView components, page
wire-up, integration test, and full verification. Drag and
inline subtask editing stay out of scope.
2026-09-21 16:07:31 +02:00
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