diff --git a/.ddev/config.yaml b/.ddev/config.yaml index b95d650..d001d3f 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -14,6 +14,8 @@ composer_version: "2" composer_root: backend web_environment: [] corepack_enable: false +host_webserver_port: "8080" +bind_all_interfaces: true web_extra_exposed_ports: - name: vite diff --git a/CLAUDE.md b/CLAUDE.md index c2a9268..76d7b93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Basis-Software mit 3 geplanten Apps: Task Manager, Shopping List, Meal Planner. -Aktueller Stand: **Setup module** (siehe `module.md`) — minimales Symfony + Vue Gerüst, kein Feature-Code. +Aktueller Stand: **Task module** implementiert (siehe `module.md`) — Backend + Vue + Kotlin App jeweils end-to-end mit Task-CRUD, Status-Handling und Datum. ## Tech-Stack @@ -10,7 +10,7 @@ Aktueller Stand: **Setup module** (siehe `module.md`) — minimales Symfony + Vu |---------|------------| | Backend | Symfony 7.4, PHP 8.3, Doctrine ORM | | Frontend Web | Vue 3 (Composition API), Vite, Pinia, Vue Router 4 | -| Frontend Mobile | Kotlin + Jetpack Compose (noch nicht aufgesetzt) | +| Frontend Mobile | Kotlin + Jetpack Compose (Material 3), Retrofit + kotlinx.serialization | | Datenbank | MariaDB 10.11 (utf8mb4) | | CORS | Nelmio CORS Bundle | | Umgebung | DDEV | @@ -20,19 +20,85 @@ Aktueller Stand: **Setup module** (siehe `module.md`) — minimales Symfony + Vu ``` backend/ src/ - Kernel.php — Standard-Symfony-Kernel, keine App-Klassen - config/ — Symfony-Config (nelmio_cors, doctrine, framework, ...) - migrations/ — leer - public/index.php — Symfony-Einstieg + Collection/TaskCollection.php — IteratorAggregate, filterInactive/sortByDueDate* + Controller/Api/TaskController.php — REST-Routen unter /api/tasks + DTO/TaskRequest.php — readonly promoted constructor, Validator + Context(!Y-m-d) + Entity/Task.php — Doctrine entity, getStatus() derived-past + Enum/TaskStatus.php — active, done, inactive, past (past nicht user-selectable) + Repository/TaskRepository.php — currentTasks(), allTasks() → TaskCollection + Service/TaskManager.php — create/update/delete/toggle Business-Logik + Kernel.php + config/ — nelmio_cors, doctrine, framework, ... + migrations/Version20260411141650.php — tasks table + public/index.php frontend/ src/ - main.js — Vue-Init mit Pinia + Router - App.vue — RouterView, kein Content - router/index.js — Router mit leerem routes-Array - style.css — leer - index.html, vite.config.js + components/Icon.vue — zentrales Icon-Component + router/index.js — /, /tasks, /tasks/all, /tasks/create, /tasks/:id mit breadcrumb meta + services/api.js — fetch wrapper, tasks() + statuses() Endpoints + stores/tasks.js — Pinia store: fetchCurrent, fetchAll, get, create, update, remove, toggle, fetchStatuses + views/ + Startpage.vue — Nav-Kacheln + Task.vue — /tasks (aktuelle Tasks), Card-pro-Datum mit Legend-Titel + TaskAll.vue — /tasks/all (alle Tasks, flache Liste mit Edit/Delete) + TaskCreate.vue — /tasks/create + TaskEdit.vue — /tasks/:id + App.vue — Breadcrumb-Layout + RouterView + main.js — Vue-Init (Pinia + Router) +app/app/src/main/java/de/haushalt/app/ + MainActivity.kt — Compose entry + MainScreen.kt — NavHost (start, tasks, tasks/all, tasks/create, tasks/{id}) + StartScreen.kt — Nav-Kacheln + data/ + ApiClient.kt — Retrofit + kotlinx.serialization Setup + TaskApi.kt — Retrofit interface (list, get, create, update, delete, toggle, statuses) + Task.kt — Task + TaskStatus enum + ui/task/ + TaskScreen.kt — /tasks, gleiches Card-Gruppierungs-Muster wie Vue + TaskAllScreen.kt — /tasks/all flache Liste, Edit/Delete + TaskCreateScreen.kt / TaskCreateViewModel.kt + TaskEditScreen.kt / TaskEditViewModel.kt + TaskListViewModel.kt — visibleTasks, groupedTasks, showDone, refresh, toggle + TaskAllViewModel.kt + DatePickerField.kt — Material 3 DatePickerDialog Wrapper + StatusDropdown.kt — ExposedDropdownMenuBox, Status-Labels vom Backend + DateFormat.kt — formatDate() mit dd.MM.yyyy ``` +## Domänenmodell + +**Task** +- `id`, `name`, `date` (nullable, ISO), `status` +- Status-Enum (`TaskStatus`): `active`, `done`, `inactive`, `past` +- `past` ist **derived** — wenn `date < today`, liefert `Task::getStatus()` automatisch `past`; der rohe gespeicherte Wert bleibt erhalten (`getRawStatus()`) +- `past` ist **nicht user-selectable** (siehe `TaskStatus::userSelectableValues()`) +- Filter `/api/tasks?filter=current` → Tasks ohne `inactive`, sortiert nach `date` aufsteigend (null-first) +- Default (`/api/tasks`) → alle Tasks, sortiert nach `date` absteigend + +## REST-API + +| Methode | Route | Zweck | +|---|---|---| +| GET | `/api/tasks?filter=current` | aktuelle Tasks (für `/tasks`) | +| GET | `/api/tasks` | alle Tasks (für `/tasks/all`) | +| GET | `/api/tasks/statuses` | user-selectable Statuswerte als `string[]` | +| GET | `/api/tasks/{id}` | einzelner Task | +| POST | `/api/tasks` | Task anlegen | +| PUT | `/api/tasks/{id}` | Task aktualisieren | +| DELETE | `/api/tasks/{id}` | Task löschen | +| PATCH | `/api/tasks/{id}/toggle` | Status zwischen `active`/`done` togglen | + +Request-DTO: `TaskRequest` (name, date `!Y-m-d`, status). Deserialisiert + validiert via `#[MapRequestPayload]`. +Response-Serialisierung: Symfony Serializer mit `groups: ['task:read']`, Datum als ISO `Y-m-d` String. + +## UI-Muster + +- **`/tasks`** (Vue + Kotlin): Tasks nach Datum gruppiert in Cards. Datum sitzt als Pill auf dem Top-Border der Card (fieldset/legend-Look). No-Date-Tasks oben in titelloser Card. `showDone` default `false`. +- **`/tasks/all`** (Vue + Kotlin): Flache Liste mit Edit- und Delete-Icons. `past`-Tasks mit Opacity 0.5, `inactive` kursiv, `done` durchgestrichen. +- **Icon-Reihenfolge `/tasks` Header**: plus, list, eye, refresh. +- Kotlin-Screens refreshen auf `Lifecycle.Event.ON_RESUME` via `DisposableEffect` (gegen stale Daten nach Navigation). +- Status-Labels in Create/Edit kommen aus dem Backend (`/api/tasks/statuses`) — nicht clientseitig hardcoded. + ## Dokumentation - **`base.md`** — Vision: was gebaut wird (3 Apps, Systeme, Datenbank-Skizze) @@ -44,6 +110,8 @@ frontend/ - **Sprache Code**: Englisch (Klassen, Methoden, Variablen) - **Sprache UI**: Deutsch - **Enum-Werte**: Englisch in DB und Code +- **Datum im Backend**: ISO `Y-m-d` (als DTO-Input und in Response-JSON) +- **Datum im UI**: `dd.MM.yyyy` — Formatierung in Vue (`formatDate` in `Task.vue`) und Kotlin (`DateFormat.kt`) - **Frontend**: Vue 3 Composition API mit ` + + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 61b743e..9e77fe0 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -9,6 +9,45 @@ const router = createRouter({ component: () => import('../views/Startpage.vue'), meta: { breadcrumb: [] }, }, + { + path: '/tasks', + name: 'tasks', + component: () => import('../views/Task.vue'), + meta: { breadcrumb: [{ label: 'Tasks', to: '/tasks' }] }, + }, + { + path: '/tasks/all', + name: 'tasks-all', + component: () => import('../views/TaskAll.vue'), + meta: { + breadcrumb: [ + { label: 'Tasks', to: '/tasks' }, + { label: 'All' }, + ], + }, + }, + { + path: '/tasks/create', + name: 'tasks-create', + component: () => import('../views/TaskCreate.vue'), + meta: { + breadcrumb: [ + { label: 'Tasks', to: '/tasks' }, + { label: 'Create' }, + ], + }, + }, + { + path: '/tasks/:id(\\d+)', + name: 'tasks-edit', + component: () => import('../views/TaskEdit.vue'), + meta: { + breadcrumb: [ + { label: 'Tasks', to: '/tasks' }, + { label: 'Edit' }, + ], + }, + }, ], }) diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..beeb02f --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,23 @@ +const BASE = 'https://haushalt.ddev.site/api' + +async function request(path, opts = {}) { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) }, + ...opts, + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`API ${res.status} ${res.statusText}${body ? `: ${body}` : ''}`) + } + return res.status === 204 ? null : res.json() +} + +export const taskApi = { + list: (filter) => request(`/tasks${filter ? `?filter=${filter}` : ''}`), + get: (id) => request(`/tasks/${id}`), + create: (data) => request('/tasks', { method: 'POST', body: JSON.stringify(data) }), + update: (id, data) => request(`/tasks/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + remove: (id) => request(`/tasks/${id}`, { method: 'DELETE' }), + toggle: (id) => request(`/tasks/${id}/toggle`, { method: 'PATCH' }), + statuses: () => request('/tasks/statuses'), +} diff --git a/frontend/src/stores/tasks.js b/frontend/src/stores/tasks.js new file mode 100644 index 0000000..f48b9a4 --- /dev/null +++ b/frontend/src/stores/tasks.js @@ -0,0 +1,77 @@ +import { defineStore } from 'pinia' +import { taskApi } from '../services/api' + +export const useTasksStore = defineStore('tasks', { + state: () => ({ + tasks: [], + currentTasks: [], + availableStatuses: [], + loading: false, + error: null, + }), + + actions: { + async fetchAll() { + this.loading = true + this.error = null + try { + this.tasks = await taskApi.list() + } catch (e) { + this.error = e.message + } finally { + this.loading = false + } + }, + + async fetchCurrent() { + this.loading = true + this.error = null + try { + this.currentTasks = await taskApi.list('current') + } catch (e) { + this.error = e.message + } finally { + this.loading = false + } + }, + + async get(id) { + return taskApi.get(id) + }, + + async fetchStatuses() { + if (this.availableStatuses.length > 0) return + this.availableStatuses = await taskApi.statuses() + }, + + async create(data) { + const task = await taskApi.create(data) + this.tasks.push(task) + return task + }, + + async update(id, data) { + const updated = await taskApi.update(id, data) + this.replaceLocal(updated) + return updated + }, + + async remove(id) { + await taskApi.remove(id) + this.tasks = this.tasks.filter((t) => t.id !== id) + this.currentTasks = this.currentTasks.filter((t) => t.id !== id) + }, + + async toggle(id) { + const updated = await taskApi.toggle(id) + this.replaceLocal(updated) + return updated + }, + + replaceLocal(task) { + const replace = (list) => list.map((t) => (t.id === task.id ? task : t)) + this.tasks = replace(this.tasks) + this.currentTasks = replace(this.currentTasks) + }, + }, +}) diff --git a/frontend/src/views/Startpage.vue b/frontend/src/views/Startpage.vue index 61e11d9..6a65600 100644 --- a/frontend/src/views/Startpage.vue +++ b/frontend/src/views/Startpage.vue @@ -1,3 +1,35 @@ + + + + diff --git a/frontend/src/views/Task.vue b/frontend/src/views/Task.vue new file mode 100644 index 0000000..32b55ad --- /dev/null +++ b/frontend/src/views/Task.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/views/TaskAll.vue b/frontend/src/views/TaskAll.vue new file mode 100644 index 0000000..f4403ab --- /dev/null +++ b/frontend/src/views/TaskAll.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/frontend/src/views/TaskCreate.vue b/frontend/src/views/TaskCreate.vue new file mode 100644 index 0000000..c103d2c --- /dev/null +++ b/frontend/src/views/TaskCreate.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/frontend/src/views/TaskEdit.vue b/frontend/src/views/TaskEdit.vue new file mode 100644 index 0000000..7d3d2aa --- /dev/null +++ b/frontend/src/views/TaskEdit.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/module.md b/module.md index dc7fb51..0f2a065 100644 --- a/module.md +++ b/module.md @@ -47,7 +47,7 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird - Display current tasks (now to +2 weeks and without date) as list with name (done strikethrough), onclick toggle status, order by date (no-date then date asc, hide inactive) - top right nav - list icon (all tasks), + icon (create), eye icon (toggle task visibility by active/done) - TaskAll.vue - - Display all tasks as list with name (done strikethrough), pencil icon (edit), bin icon (delete), onclick toggle status, order by date (no-date then date asc then inactive asc) + - Display all tasks as list with name (done strikethrough, past faded), pencil icon (edit), bin icon (delete), onclick toggle status, order by date (no-date then date asc) - top right nav - + icon (create) - TaskCreate.vue - Display form with name-text, date-date, status-select, save-button, abort-button - TaskEdit.vue @@ -61,7 +61,7 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird - Display current tasks (now to +2 weeks and without date) as list with name (done strikethrough), onclick toggle status, order by date (no-date then date asc, hide inactive) - top right nav - list icon (all tasks), + icon (create), eye icon (toggle task visibility by active/done) - TaskAllScreen.kt - - Display all tasks as list with name (done strikethrough), pencil icon (edit), bin icon (delete), onclick toggle status, order by date (no-date then date asc then inactive asc) + - Display all tasks as list with name (done strikethrough, past faded), pencil icon (edit), bin icon (delete), onclick toggle status, order by date (no-date then date asc) - top right nav - + icon (create) - TaskCreateScreen.kt - Display form with name-text, date-date, status-select, save-button, abort-button - TaskEditScreen.kt @@ -70,7 +70,7 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird ## Features - Start page: task button - Task page: current tasks ordered by date, filter done -- TaskAll page: all tasks ordered by date and status, delete task +- TaskAll page: all tasks ordered by date, past faded, delete task - TaskCreate page: create task - TaskEdit page: update task @@ -100,22 +100,3 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird - TaskGenerator - Create tasks from schema - generate - -# Item module -- Item - Item entity -- ItemController - Item routes -- ItemManager - Item CRUD -- ItemRepository - Item queries -- UnitEnum - Unit for Item - -# Meal module -- Meal - Meal entity -- MealController - Meal routes -- MealManager - Meal CRUD -- MealRepository - Meal queries - -# Shopping module -- ShoppingList - ShoppingList entity -- ShoppingListController - ShoppingList routes -- ShoppingListManager - ShoppingList CRUD -- ShoppingListRepository - ShoppingList queries \ No newline at end of file