Files
project-manager/web/README.md
T
aneurinandClaude Sonnet 5 4ee0f24078 Make the inbox global instead of per-project
A card either sits in its owner's inbox (project_id AND status_id both NULL)
or belongs to exactly one project with a status in it (both set) -- enforced
by a CHECK constraint, never one without the other. The inbox is global to a
user now, not per-project: cards can move from a project into the inbox and
back into any status column of any project.

Backend
- migrations/009: rebuilds `cards` (SQLite can't relax NOT NULL / add a CHECK
  in place) with a nullable project_id, a new owner_id (cards need direct
  ownership once they can have no project), and the CHECK constraint. Cards
  that had no status (the old per-project inbox) move to the new global inbox.
  status_id's FK is now ON DELETE RESTRICT, not SET NULL -- nulling it alone
  would violate the invariant, and there's no status-delete endpoint anyway.
- CardRepository: "column" is now (owner_id, project_id, status_id); every
  method that dealt with a project's columns is generalised to also cover the
  inbox and cross-project moves (orderColumn, idsInColumn, repack, ...).
- CardController/routes: single-card and ordering routes move to global,
  since a card may have no project to nest them under --
  GET/PATCH/DELETE /api/cards/{id}, PUT /api/cards/order (body now takes
  project_id + status_id, both null for the inbox). New GET/POST
  /api/inbox/cards. PATCH no longer accepts status_id -- moving a card, in or
  out of a project, is exclusively PUT /api/cards/order now. A card created
  directly in a project (POST /api/projects/{id}/cards) lands in its first
  status, since a project card can't have no status.
- Tests: ProjectTest/CardStatusTest updated for the new routes; CardOrderTest
  rewritten with full inbox/cross-project coverage. 57 tests pass.

Frontend
- New stores/inbox.ts (the global inbox) and lib/cardOrder.ts (the shared
  PUT /api/cards/order call, used by both the sidebar and a project's board).
- AppSidebar: an Inbox section under the project list -- a vuedraggable list
  in the same "kanban" drag group as every project's kanban columns, so a
  card drags straight from the sidebar into whichever project is open, or
  back out. (The empty-inbox state needed a real bugfix: it wasn't rendering
  a <draggable> at all, so there was nowhere to drop a card back into an
  empty inbox.) A drop reloads the inbox and, if a project is open, its cards.
- ProjectView's kanban board drops its synthetic Inbox column -- just the
  real statuses now.
- DashboardView simplified to a plain grid of project tiles (name + card
  count); its per-project "New" section is gone, since a project card can no
  longer have no status.
- stores/cards.ts: patch/remove move to the global /api/cards/{id} routes.

Verified end-to-end against the rebuilt container (existing per-project-inbox
cards correctly migrated to the global inbox, 0 invariant violations) and the
dev server via headless Chrome: sidebar inbox -> project A "To do" -> back to
inbox -> project B "Done", full journey confirmed via the API at each step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:22:33 +01:00

6.3 KiB

Project Manager — web

Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory.

Develop

npm install
npm run dev            # http://localhost:5173

The dev server proxies /api to http://localhost:8080 (the Dockerised API — run docker compose up -d in the parent directory first). Override the target with VITE_PROXY_TARGET, or point the app at a different API entirely with VITE_API_BASE_URL (see .env.example).

Build

npm run build         # type-checks, then emits dist/
npm run preview

The parent Dockerfile runs this build in a Node stage and copies dist/ into the PHP image's public/, so the app container serves the compiled SPA at /. There is no separate frontend container — a production image is docker compose build app from the parent directory.

Layout

src/main.ts             App bootstrap; resolves the stored session before mount
src/router/index.ts     Routes + guard (redirects to /login when unauthenticated)
src/stores/auth.ts      Pinia store: token in localStorage, magic-link + fetchMe
src/stores/projects.ts  Pinia store: the user's projects (fetch + create)
src/stores/cards.ts     Pinia store: one project's cards (CRUD; no reordering -- see lib/cardOrder.ts)
src/stores/inbox.ts     Pinia store: the caller's global inbox (fetch + create)
src/lib/api.ts          fetch wrapper, bearer token, typed ApiError
src/lib/cardOrder.ts    reorderColumn() -- PUT /api/cards/order, shared by the sidebar and kanban board
src/components/AppSidebar.vue left nav: Dashboard link, project list + form, Inbox + form
src/components/CardRow.vue    editable text + status chip + delete, one card
src/components/KanbanCard.vue small draggable card for the board columns and the inbox
src/views/              DashboardView, ProjectView, LoginView, ProfileView,
                        VerifyEmailView

Signed-in "app" routes (meta.requiresAuth) render inside a persistent shell: the top bar, then a left sidebar (AppSidebar.vue) beside the routed view. The sidebar stays mounted across navigation — it holds a Dashboard link, a divider, the project list + new-project form, another divider, then the Inbox (see below). The current page is highlighted via RouterLink's active-class. <RouterView :key="route.path"> remounts the view on every path change so sidebar → project → project navigation always does a fresh load.

/ redirects to /dashboard (DashboardView.vue), a full-width grid linking to each project, showing its title and card count. Signed-out routes (/login, /verify-email) render without the sidebar.

Inbox

A card with no project lives in the caller's inbox (useInboxStore), rendered in the sidebar under the project list -- not per-project, and not tied to whatever page is open. It's a vuedraggable list in the same "kanban" drag group as every project's kanban columns (below), so a card can be dragged straight out of the sidebar into any status column of whichever project is currently open, or the other way. AppSidebar's onInboxChange persists a drop via reorderColumn(null, null, ids), then reloads the inbox and, if a project view is currently mounted (route.name === 'project'), that project's cards too -- either side of a drag could have been the inbox. A small form under the list adds a card straight to the inbox.

Project detail

/projects/:id shows one project. It renders on a full-width layout (the route sets meta.wide, which widens .app__main in App.vue). The title and description are inline-editable (saved on blur via PATCH /api/projects/:id; the description shows an "Add a description" placeholder when empty). A Manage menu (top right) has a Delete project action that opens a confirmation modal; confirming calls DELETE /api/projects/:id and returns to the dashboard.

Below the header are two tabs (local activeTab state, v-show so both stay mounted):

All tasks

The flat card list, sorted by name (case-insensitive) via a sortedCards computed — there is no manual order here. Each row is an inline-editable text field (saved on blur), a status chip (card.status.name or "No status"), and a delete button.

Kanban

One column per project status, in position order -- the inbox is not a column here; it's in the sidebar (see above), though it's still a valid drag source/target. board is derived from cards.cards + the project's statuses and rebuilt by a watch whenever either changes.

Every drop — whether reordering within a column (moved) or dragging in from another column or the sidebar's inbox (added) — calls reorderColumn(projectId, column.statusId, ids) from lib/cardOrder.ts (shared with the sidebar) → PUT /api/cards/order. The server re-parents any moved-in card and re-packs whatever column it left; afterwards the view always reloads both inbox and this project's cards, since either could have been the other side of the move.

Auth flow

There is no password and no separate sign-up — LoginView is just an email field and a "Send sign-in link" button (POST /api/auth/magic-link), for a new address or a returning one alike. On success it shows a "check your email" message; it does not sign the caller in itself.

  • /verify-email?token=… is the target for every magic link (sign-in and email-change confirmation both). VerifyEmailView POSTs the token via auth.verifyEmail(), which returns a session — opening the link is what actually signs the caller in — then redirects to the dashboard.
  • The token is kept in localStorage and sent as Authorization: Bearer …. On load, fetchMe() validates it via GET /api/me; a failure clears it.
  • Routes with meta.requiresAuth redirect to /login (preserving the intended path) when there is no authenticated user.
  • Because the only way to get a session is opening a link, user.email_verified is always true for a signed-in user — the frontend doesn't show any verification nagging or resend UI.

Profile

/profile (ProfileView) shows the current address and a Change email form (new address only, no password). On success the API has emailed a confirmation link to the new address and set user.pending_email (shown as a notice until it's opened); the change only lands once that link is opened. The button shows a live countdown driven by retry_after and by 429 responses.