Rename lists -> projects and items -> cards throughout

Project scope shifts from a todo list to a project-management app. This is a
straight terminology rename across code, comments, migrations, tests, and
docs — no behaviour change.

- DB: table todo_lists -> projects, todo_items -> cards, column
  todo_items.list_id -> cards.project_id, indexes renamed. Migrations 003/004
  rewritten in place (destructive; recreate the volume with `down -v`).
- API: /api/lists -> /api/projects, nested /items -> /cards, reorder body
  item_ids -> card_ids, JSON keys list/lists/item/items -> project/projects/
  card/cards, item_count -> card_count, list_id -> project_id, and the
  matching error messages.
- PHP: TodoList/TodoItem Repository + Controller -> Project/Card; shared SQL
  aliases l/i -> p/c.
- Frontend: stores lists.ts/items.ts -> projects.ts/cards.ts (useProjectsStore
  / useCardsStore, MAX_PROJECTS), ListView -> ProjectView, TodoItemRow ->
  CardRow, route /lists/:id -> /projects/:id (name "project"), types TodoList/
  TodoItem -> Project/Card, and all UI copy. CSS .lists*/.list-head* ->
  .projects*/.project-head*, .item* -> .card-row* (kept the generic .card
  panel class), .items -> .cards.
- Product name in the header, PWA manifest, index.html title and package
  descriptions -> "Project Manager" / "Projects".

Backend suite: 37 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 11:28:59 +01:00
co-authored by Claude Sonnet 5
parent be592f38fc
commit c66e5ceb9b
30 changed files with 1068 additions and 1065 deletions
+53 -51
View File
@@ -1,7 +1,8 @@
# PHP Todo List
# PHP Project Manager
A small todo-list application: a REST API written in PHP (Slim 4) backed by an
SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
A small project-management application: a REST API written in PHP (Slim 4)
backed by an SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
Each user owns **projects**, and each project holds ordered **cards**.
## Status
@@ -9,10 +10,10 @@ SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/).
|-------|-------|-------|
| 1 | Auth API — register, login, `GET /me` | ✅ done |
| 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done |
| 3 | Todo list + item CRUD API | ✅ done |
| 4 | Frontend lists view — list index + create form | ✅ done |
| 5 | Frontend list detail — items UI with drag-and-drop reorder | ✅ done |
| 6 | List view — inline title/description editing, delete via a Manage menu | ✅ done |
| 3 | Project + card CRUD API | ✅ done |
| 4 | Frontend projects view — project index + create form | ✅ done |
| 5 | Frontend project detail — cards UI with drag-and-drop reorder | ✅ done |
| 6 | Project view — inline title/description editing, delete via a Manage menu | ✅ done |
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done |
| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done |
@@ -108,7 +109,7 @@ environment). See [.env.example](.env.example).
| `JWT_TTL` | `86400` | Token lifetime in seconds |
| `APP_URL` | `http://localhost:5173` | Frontend base URL used to build magic links |
| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) |
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Todo List` | Envelope sender |
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender |
| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes |
| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` |
@@ -235,37 +236,37 @@ The current password is required (`422` if wrong). The address must be free
until** the magic link sent to the new address is opened — until then `GET
/api/me` shows the old address with `pending_email` set.
### Todo lists
### Projects
All routes below require `Authorization: Bearer <jwt>`. A list belongs to one
owner (the creator); another user's list — or a missing one — always responds
All routes below require `Authorization: Bearer <jwt>`. A project belongs to one
owner (the creator); another user's project — or a missing one — always responds
`404`.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/lists` | the caller's lists, sorted A→Z by title |
| `POST` | `/api/lists` | create a list |
| `GET` | `/api/lists/{id}` | one list |
| `PATCH` | `/api/lists/{id}` | update `title` and/or `description` |
| `DELETE` | `/api/lists/{id}` | delete the list and its items (`204`) |
| `GET` | `/api/projects` | the caller's projects, sorted A→Z by title |
| `POST` | `/api/projects` | create a project |
| `GET` | `/api/projects/{id}` | one project |
| `PATCH` | `/api/projects/{id}` | update `title` and/or `description` |
| `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) |
`GET /api/lists` is always ordered alphabetically (case-insensitive) by title;
there is no other sort option. A user may own at most **100 lists** creating
one beyond that responds `409`.
`GET /api/projects` is always ordered alphabetically (case-insensitive) by
title; there is no other sort option. A user may own at most **100 projects**
creating one beyond that responds `409`.
Create/update body: `title` (required on create, 1255 chars), `description`
(optional, ≤ 2000 chars, defaults to `""`). `PATCH` needs at least one field.
List representation:
Project representation:
```json
{
"list": {
"project": {
"id": 1,
"title": "Shopping",
"description": "For the week",
"title": "Website relaunch",
"description": "Q3",
"owner_id": 1,
"item_count": 3,
"card_count": 3,
"completed_count": 1,
"created_at": "2026-09-03T12:00:00Z",
"updated_at": "2026-09-03T12:00:00Z"
@@ -273,40 +274,41 @@ List representation:
}
```
`GET /api/lists` returns `{ "lists": [ … ] }`.
`GET /api/projects` returns `{ "projects": [ … ] }`.
### Todo items
### Cards
Scoped to a list; the parent list's ownership is checked first (`404` otherwise).
Scoped to a project; the parent project's ownership is checked first
(`404` otherwise).
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/lists/{id}/items` | items, ordered by `position` then `id` |
| `POST` | `/api/lists/{id}/items` | add an item |
| `PUT` | `/api/lists/{id}/items/order` | reorder all items in one shot |
| `GET` | `/api/lists/{id}/items/{itemId}` | one item |
| `PATCH` | `/api/lists/{id}/items/{itemId}` | update `text`, `complete`, and/or `position` |
| `DELETE` | `/api/lists/{id}/items/{itemId}` | delete the item (`204`) |
| `GET` | `/api/projects/{id}/cards` | cards, ordered by `position` then `id` |
| `POST` | `/api/projects/{id}/cards` | add a card |
| `PUT` | `/api/projects/{id}/cards/order` | reorder all cards in one shot |
| `GET` | `/api/projects/{id}/cards/{cardId}` | one card |
| `PATCH` | `/api/projects/{id}/cards/{cardId}` | update `text`, `complete`, and/or `position` |
| `DELETE` | `/api/projects/{id}/cards/{cardId}` | delete the card (`204`) |
Create body: `text` (required, 11000 chars), `complete` (optional bool,
default `false`), `position` (optional integer ≥ 0; when omitted the item is
default `false`), `position` (optional integer ≥ 0; when omitted the card is
appended after the current highest position). `PATCH` needs at least one field.
`position` is a plain sort key the client manages — updating one item never
`position` is a plain sort key the client manages — updating one card never
renumbers its siblings.
`PUT …/items/order` takes `{ "item_ids": [3, 1, 2] }` — every item in the list,
each exactly once (`422` otherwise). It rewrites positions to `0..n-1` in one
transaction and returns `{ "items": [ … ] }` in the new order. This is what the
drag-and-drop reorder in the UI calls.
`PUT …/cards/order` takes `{ "card_ids": [3, 1, 2] }` — every card in the
project, each exactly once (`422` otherwise). It rewrites positions to `0..n-1`
in one transaction and returns `{ "cards": [ … ] }` in the new order. This is
what the drag-and-drop reorder in the UI calls.
Item representation:
Card representation:
```json
{
"item": {
"card": {
"id": 10,
"list_id": 1,
"text": "Milk",
"project_id": 1,
"text": "Design homepage",
"complete": false,
"position": 0,
"created_at": "2026-09-03T12:00:00Z",
@@ -315,7 +317,7 @@ Item representation:
}
```
`GET …/items` returns `{ "items": [ … ] }`.
`GET …/cards` returns `{ "cards": [ … ] }`.
### Error shape
@@ -342,16 +344,16 @@ TOKEN=$(curl -s -X POST $BASE/api/auth/login \
curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN"
LIST=$(curl -s -X POST $BASE/api/lists \
PROJECT=$(curl -s -X POST $BASE/api/projects \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Shopping","description":"For the week"}' \
-d '{"title":"Website relaunch","description":"Q3"}' \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
curl -s -X POST $BASE/api/lists/$LIST/items \
curl -s -X POST $BASE/api/projects/$PROJECT/cards \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"Milk"}'
-d '{"text":"Design homepage"}'
curl -s $BASE/api/lists/$LIST/items -H "Authorization: Bearer $TOKEN"
curl -s $BASE/api/projects/$PROJECT/cards -H "Authorization: Bearer $TOKEN"
```
## Tests
@@ -373,8 +375,8 @@ src/Auth/AuthMiddleware.php Bearer-token authentication
src/Auth/SessionPayload.php Shared user + session JSON shape
src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
src/Http/JsonErrorHandler.php Uniform JSON error envelope
src/Http/Controllers/ Request handlers (Auth, EmailVerification, TodoList, TodoItem)
src/Repository/ Database access (User, EmailVerification, TodoList, TodoItem)
src/Http/Controllers/ Request handlers (Auth, EmailVerification, Project, Card)
src/Repository/ Database access (User, EmailVerification, Project, Card)
src/Support/Validator.php Request-body validation helper
migrations/*.sql Schema, applied by bin/migrate.php
Dockerfile PHP 8.3 + Apache image