diff --git a/README.md b/README.md index 87671f9..39e0b84 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ 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 | Todo UI in the frontend | planned | +| 4 | Frontend lists view — list index + create form | ✅ done | +| 5 | Frontend list detail — items UI | planned | Registration signs the user in immediately, with the account's email marked unverified (`user.email_verified` is `false` until a future stage adds a @@ -180,12 +181,16 @@ owner (the creator); another user's list — or a missing one — always respond | Method | Path | Purpose | |--------|------|---------| -| `GET` | `/api/lists` | the caller's lists, newest first | +| `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/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`. + Create/update body: `title` (required on create, 1–255 chars), `description` (optional, ≤ 2000 chars, defaults to `""`). `PATCH` needs at least one field. diff --git a/src/Http/Controllers/TodoListController.php b/src/Http/Controllers/TodoListController.php index 51672f6..bc52b28 100644 --- a/src/Http/Controllers/TodoListController.php +++ b/src/Http/Controllers/TodoListController.php @@ -18,6 +18,7 @@ final class TodoListController extends Controller { private const TITLE_MAX = 255; private const DESCRIPTION_MAX = 2000; + private const MAX_LISTS_PER_OWNER = 100; public function __construct(private readonly TodoListRepository $lists) { @@ -38,12 +39,21 @@ final class TodoListController extends Controller */ public function store(Request $request, Response $response): Response { + $ownerId = $this->user($request)['id']; + $validator = new Validator($this->body($request)); $title = $validator->requiredString('title', self::TITLE_MAX); $description = $validator->optionalString('description', self::DESCRIPTION_MAX) ?? ''; $validator->assert(); - $list = $this->lists->create($this->user($request)['id'], $title, $description); + if ($this->lists->countForOwner($ownerId) >= self::MAX_LISTS_PER_OWNER) { + throw new ApiException( + sprintf('You have reached the maximum of %d lists.', self::MAX_LISTS_PER_OWNER), + 409, + ); + } + + $list = $this->lists->create($ownerId, $title, $description); return $this->json($response, ['list' => $this->present($list)], 201); } diff --git a/src/Repository/TodoListRepository.php b/src/Repository/TodoListRepository.php index c5d77d4..5255185 100644 --- a/src/Repository/TodoListRepository.php +++ b/src/Repository/TodoListRepository.php @@ -28,16 +28,29 @@ final class TodoListRepository } /** + * Every list owned by the user, always sorted alphabetically by title + * (case-insensitive). There is deliberately no other ordering option. + * * @return TodoListRow[] */ public function allForOwner(int $ownerId): array { - $stmt = $this->pdo->prepare(self::SELECT . ' WHERE l.owner_id = :owner ORDER BY l.created_at DESC, l.id DESC'); + $stmt = $this->pdo->prepare( + self::SELECT . ' WHERE l.owner_id = :owner ORDER BY l.title COLLATE NOCASE ASC, l.id ASC' + ); $stmt->execute(['owner' => $ownerId]); return array_map($this->cast(...), $stmt->fetchAll()); } + public function countForOwner(int $ownerId): int + { + $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM todo_lists WHERE owner_id = :owner'); + $stmt->execute(['owner' => $ownerId]); + + return (int) $stmt->fetchColumn(); + } + /** * @return TodoListRow|null */ diff --git a/tests/TodoTest.php b/tests/TodoTest.php index ca07d6c..3b2c7f8 100644 --- a/tests/TodoTest.php +++ b/tests/TodoTest.php @@ -31,6 +31,36 @@ final class TodoTest extends ApiTestCase self::assertSame($list['id'], $index['lists'][0]['id']); } + public function test_lists_come_back_alphabetically(): void + { + $auth = $this->authHeader(); + + foreach (['Banana', 'apple', 'Cherry'] as $title) { + $this->request('POST', '/api/lists', ['title' => $title], $auth); + } + + $titles = array_column($this->decode($this->request('GET', '/api/lists', null, $auth))['lists'], 'title'); + self::assertSame(['apple', 'Banana', 'Cherry'], $titles); + } + + public function test_an_owner_cannot_exceed_100_lists(): void + { + $auth = $this->authHeader(); + + for ($i = 1; $i <= 100; $i++) { + $response = $this->request('POST', '/api/lists', ['title' => "List {$i}"], $auth); + self::assertSame(201, $response->getStatusCode(), "list {$i} should be created"); + } + + $overflow = $this->request('POST', '/api/lists', ['title' => 'One too many'], $auth); + self::assertSame(409, $overflow->getStatusCode()); + self::assertStringContainsString('100', $this->decode($overflow)['error']['message']); + + // The cap is per owner, so a different user is unaffected. + $other = $this->authHeader('roomy@example.com'); + self::assertSame(201, $this->request('POST', '/api/lists', ['title' => 'Fine'], $other)->getStatusCode()); + } + public function test_list_creation_validates_title(): void { $response = $this->request('POST', '/api/lists', ['description' => 'no title'], $this->authHeader()); diff --git a/web/README.md b/web/README.md index 4f22be3..f1391ca 100644 --- a/web/README.md +++ b/web/README.md @@ -38,8 +38,9 @@ npm run preview 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, register/login/fetchMe +src/stores/lists.ts Pinia store: the user's lists (fetch + create) src/lib/api.ts fetch wrapper, bearer token, typed ApiError -src/views/ HomeView (placeholder), LoginView, RegisterView +src/views/ HomeView (lists + create form), LoginView, RegisterView ``` ## Auth flow diff --git a/web/src/App.vue b/web/src/App.vue index 88fe5fc..cb834ad 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,12 +1,15 @@ diff --git a/web/src/stores/lists.ts b/web/src/stores/lists.ts new file mode 100644 index 0000000..d3f0907 --- /dev/null +++ b/web/src/stores/lists.ts @@ -0,0 +1,44 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiRequest } from '../lib/api' +import type { TodoList } from '../types' + +/** Matches MAX_LISTS_PER_OWNER on the API. */ +export const MAX_LISTS = 100 + +export const useListsStore = defineStore('lists', () => { + // Kept in the order the API returns them (alphabetical by title). + const lists = ref([]) + const loaded = ref(false) + const loading = ref(false) + + async function fetchLists(): Promise { + loading.value = true + try { + const { lists: fetched } = await apiRequest<{ lists: TodoList[] }>('/lists', { auth: true }) + lists.value = fetched + loaded.value = true + } finally { + loading.value = false + } + } + + async function createList(title: string, description: string): Promise { + const { list } = await apiRequest<{ list: TodoList }>('/lists', { + method: 'POST', + auth: true, + body: { title, description }, + }) + + // Re-fetch so the new list lands in its correct alphabetical position. + await fetchLists() + return list + } + + function reset(): void { + lists.value = [] + loaded.value = false + } + + return { lists, loaded, loading, fetchLists, createList, reset } +}) diff --git a/web/src/style.css b/web/src/style.css index 9bed167..2e0afe8 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -100,32 +100,46 @@ h1 { padding: 0.1rem 0.45rem; border-radius: 999px; font-size: 0.75rem; - border: 1px solid var(--warn-border); - background: var(--warn-bg); + white-space: nowrap; + border: 1px solid var(--border); + background: var(--bg); + color: var(--muted); } -.facts { - margin: 1.25rem 0 0; +.badge--warn { + border-color: var(--warn-border); + background: var(--warn-bg); + color: inherit; +} + +.lists { + list-style: none; + margin: 1.5rem 0 0; + padding: 0; display: grid; gap: 0.75rem; } -.facts div { +.lists__item { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem 0.9rem; +} + +.lists__head { display: flex; + align-items: center; justify-content: space-between; - gap: 1rem; - border-top: 1px solid var(--border); - padding-top: 0.6rem; + gap: 0.75rem; } -.facts dt { - color: var(--muted); +.lists__title { + font-weight: 600; + word-break: break-word; } -.facts dd { - margin: 0; - text-align: right; - word-break: break-all; +.lists__item .muted { + margin: 0.35rem 0 0; } .form { diff --git a/web/src/types.ts b/web/src/types.ts index a6b4b76..7b4dbb5 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -12,6 +12,17 @@ export interface AuthResponse { expires_at: string } +export interface TodoList { + id: number + title: string + description: string + owner_id: number + item_count: number + completed_count: number + created_at: string + updated_at: string +} + /** Shape of every error body returned by the API. */ export interface ApiErrorBody { error: { diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index 15017d6..5e61ec7 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -1,35 +1,95 @@