From 761408ae7c697c5099148eb4c8b899fa3832caf4 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Fri, 8 May 2026 20:18:26 +0200 Subject: [PATCH] fix: correct housekeeping module bugs after merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore migration order: remove spurious v30 birthday-reminders entry inserted before CardDAV (v30) and birthday-reminders (v31), which caused a duplicate v31 on fresh installs - Restore birthdayReminderAt() offsetMin handling (regression from merge) - Fix check-in INSERT: check_out was set to checkIn instead of NULL, making sessions invisible to loadOpenSession (IS NULL query) - Implement check-out path in toggleSession() — only check-in was reachable - Wrap GET /task-templates in try/catch per project convention - Fix DELETE response envelopes: { ok: true } → { data: ... } - Remove housekeeping worker exclusion from GET /auth/users - Replace toISOString() with local-date helper to avoid UTC date shift - Use user currency preference in money() instead of hardcoded BRL - Replace hardcoded #7C3AED fallbacks in style attrs with CSS token - Add German translations for documents folder and settings housekeeping keys - Remove DESIGN.md and IMPLEMENTATION.md (AI planning artifacts) Co-Authored-By: Claude Sonnet 4.6 --- DESIGN.md | 134 ---------------------------------- IMPLEMENTATION.md | 88 ---------------------- public/locales/de.json | 26 +++---- public/pages/housekeeping.js | 50 ++++++++----- server/auth.js | 3 - server/db.js | 9 --- server/routes/housekeeping.js | 13 +++- server/services/birthdays.js | 3 +- 8 files changed, 54 insertions(+), 272 deletions(-) delete mode 100644 DESIGN.md delete mode 100644 IMPLEMENTATION.md diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index c236988..0000000 --- a/DESIGN.md +++ /dev/null @@ -1,134 +0,0 @@ -# Housekeeping Design - -## Goal - -Housekeeping adds a simplified mobile/PWA module for the cleaner workflow in Oikos. It keeps the existing private-network, authenticated-session security model and exposes no public endpoints. - -## User Experience - -The `/housekeeping` route is a focused module that follows the same toolbar, tab, card, and chip patterns used by the rest of Oikos: - -- **Dashboard**: staff-specific check-in/check-out actions, visits this month, last visit, pending/finished chores, pending payments, and a compact monthly payment chart. -- **Tasks**: suggested chore templates, custom chore creation, urgency-sorted recurring tasks, and one-tap completion. -- **Reports**: camera upload plus text description for maintenance occurrences. -- **Staff**: one or more housekeeper people, contact data, profile pictures, daily rates, and payment schedules. - -Accessibility constraints: - -- Primary actions are at least 44px high. -- Check-in/out is a compact top-toolbar action, matching the small action pattern used elsewhere in the app. -- Status is communicated by text and color, not color alone. -- Inputs and buttons have explicit labels or accessible names. -- Icons use the locally bundled Lucide runtime; no external CDN is introduced. - -## Data Model - -### `housekeeping_work_sessions` - -Stores point/finance records: - -- `id` -- `worker_id` -- `check_in` -- `check_out` -- `daily_rate` -- `extras` -- `calendar_event_id` -- `created_by` -- `created_at` -- `updated_at` - -Monthly amount is calculated as `SUM(daily_rate + extras)` for sessions whose `check_in` belongs to the requested month. -Each check-in creates a linked local calendar event for the selected staff member. Check-out updates that event end time. - -### `housekeeping_decay_tasks` - -Stores dynamic recurring cleaning tasks: - -- `id` -- `name` -- `area` -- `frequency_days` -- `last_completed` -- `created_by` -- `created_at` -- `updated_at` - -Urgency is computed at read time: - -```text -urgency = (now - last_completed) / frequency_days -``` - -Status mapping: - -- `overdue`: due date is before today. -- `today`: due date is today. -- `ok`: due date is in the future. - -Rows with no `last_completed` are treated as overdue. - -### `housekeeping_supply_requests` - -Stores quick supply requests and links each request to an Oikos shopping item: - -- `id` -- `name` -- `quantity` -- `shopping_item_id` -- `created_by` -- `created_at` - -The supply request transaction always appends an item to the main `shopping_items` table. If no shopping list exists, the backend creates a private authenticated list named `Housekeeping`. - -### `housekeeping_maintenance_log` - -Stores maintenance occurrences: - -- `id` -- `description` -- `photo_url` -- `created_by` -- `created_at` -- `updated_at` - -`photo_url` accepts self-contained `data:image/png|jpeg|webp;base64,...` values only, keeping uploaded camera photos inside the authenticated Oikos database boundary. - -### `housekeeping_workers` - -Stores housekeeper-specific employment/payment settings while keeping the person unified with Oikos user/contact/birthday data: - -- `id` -- `user_id` -- `daily_rate` -- `payment_schedule` -- `calendar_color` -- `notes` -- `created_at` -- `updated_at` - -The linked `users` row is excluded from normal Family Management and Family APIs through the `housekeeping_workers` association, but remains synchronized with contacts and birthdays. -Multiple housekeepers can be registered; each has its own linked `users` row. -`calendar_color` controls the default color used for housekeeping visit events. Visit events use the cleaning icon (`sparkles`). - -## REST API - -All endpoints are mounted under `/api/v1/housekeeping` and inherit the existing `requireAuth` and CSRF middleware. - -- `GET /summary?month=YYYY-MM` -- `GET /dashboard` -- `GET /task-templates` -- `GET /worker` -- `GET /workers` -- `POST /worker` -- `GET /work-sessions?month=YYYY-MM` -- `POST /work-sessions/check-in` -- `POST /work-sessions/check-out` -- `GET /decay-tasks` -- `POST /decay-tasks` -- `PATCH /decay-tasks/:taskId` -- `POST /decay-tasks/:taskId/complete` -- `DELETE /decay-tasks/:taskId` -- `POST /supply-requests` -- `GET /maintenance-log` -- `POST /maintenance-log` diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md deleted file mode 100644 index 21f87b2..0000000 --- a/IMPLEMENTATION.md +++ /dev/null @@ -1,88 +0,0 @@ -# Housekeeping Implementation - -## Backend - -The module is implemented in `server/routes/housekeeping.js` and registered in `server/index.js` under: - -```text -/api/v1/housekeeping -``` - -The registration happens after the global authenticated `/api/v1` middleware, so the module follows the existing Oikos security model: - -- Session or API-token authentication required. -- CSRF required for state-changing session requests. -- API rate limiting inherited from `/api/`. -- No unauthenticated housekeeping route. - -Database schema is migration `33` in `server/db.js`. The migration creates: - -- `housekeeping_work_sessions` -- `housekeeping_decay_tasks` -- `housekeeping_supply_requests` -- `housekeeping_maintenance_log` - -Migration `34` adds: - -- `housekeeping_workers` -- `housekeeping_work_sessions.paid_at` - -Migration `35` adds per-staff visit tracking: - -- `housekeeping_workers.calendar_color` -- `housekeeping_work_sessions.worker_id` -- `housekeeping_work_sessions.calendar_event_id` - -Each staff profile links to `users.id`. These users are hidden from the normal family list by filtering rows associated with `housekeeping_workers`, while contact and birthday sync remains shared with the existing family-member artifact flow. - -The quick supply endpoint uses a SQLite transaction: - -1. Resolve the first existing shopping list, or create `Housekeeping`. -2. Insert a `shopping_items` row. -3. Insert a `housekeeping_supply_requests` row linked to the shopping item. - -If any step fails, the transaction rolls back. - -## Frontend - -The SPA route `/housekeeping` is registered in `public/router.js` and loads: - -- `public/pages/housekeeping.js` -- `public/styles/housekeeping.css` - -The page uses the existing API wrapper in `public/api.js`, so CSRF tokens and auth expiry behavior remain centralized. The UI now follows the standard Oikos module layout: sticky toolbar, horizontal tab chips, and regular cards. -Check-in/check-out actions live beside each staff member on the Dashboard and are disabled until at least one staff member exists. - -Calendar integration creates a local calendar event at check-in, assigns it to the staff user, uses the staff calendar color, and updates the end time on check-out. Calendar event icon selection now opens a dedicated icon picker dialog instead of expanding inline inside the event modal. - -The UI intentionally avoids `innerHTML`; rendering uses `replaceChildren()` and `insertAdjacentHTML()` with escaped dynamic values. - -## Localization - -The module adds: - -- `nav.housekeeping` -- `housekeeping.*` - -to every JSON locale under `public/locales`. - -Portuguese is the primary text for the cleaner-facing target workflow, with localized strings for the most common existing languages and English fallback text for remaining locales. - -## Validation - -Backend validation covers: - -- Required strings and max lengths. -- Positive integer `frequency_days`. -- Non-negative `daily_rate` and `extras`. -- `YYYY-MM` month filters. -- Maintenance photos limited to PNG, JPEG, or WebP data URLs under 6 MB. - -## Manual Use - -1. Navigate to `/housekeeping`. -2. Use the check-in/check-out button next to the staff member on the Dashboard. -3. Review the Dashboard metrics and payment chart. -4. On **Tasks**, choose suggested chores or create a custom recurring chore. -5. On **Reports**, take/upload a photo and submit a maintenance description. -6. On **Staff**, create or update one or more housekeepers, contacts, birthdays, rates, and payment schedules. diff --git a/public/locales/de.json b/public/locales/de.json index a83729f..896e14f 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -1062,11 +1062,11 @@ "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", "emptyStateAddFirst": "Füge dein erstes Konto hinzu", "emptyStateNoAccounts": "Noch keine Konten verbunden", - "sectionHousekeeping": "Housekeeping", - "housekeepingPaymentsTitle": "Payment tasks", - "housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", - "housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", - "housekeepingPaymentTasksSaved": "Housekeeping payment setting saved." + "sectionHousekeeping": "Haushaltshilfe", + "housekeepingPaymentsTitle": "Zahlungsaufgaben", + "housekeepingPaymentTasksLabel": "Bei jedem Einchecken eine Zahlungsaufgabe erstellen", + "housekeepingPaymentTasksHint": "Wenn aktiviert, wird bei jedem Einchecken eine Aufgabe zur Bezahlung der Haushaltshilfe erstellt. Das Erledigen dieser Aufgabe markiert den Besuch als bezahlt.", + "housekeepingPaymentTasksSaved": "Einstellung für Haushaltshilfe-Zahlungen gespeichert." }, "login": { "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", @@ -1298,14 +1298,14 @@ "dropzoneTitle": "Datei hier ablegen oder klicken", "dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.", "selectedFileLabel": "Ausgewählt: {{name}}", - "addFolderButton": "Add folder", - "allFolders": "All folders", - "folderLabel": "Folder", - "noFolder": "No folder", - "newFolderTitle": "New folder", - "folderNameLabel": "Folder name", - "createFolderAction": "Create folder", - "folderCreatedToast": "Folder created.", + "addFolderButton": "Ordner hinzufügen", + "allFolders": "Alle Ordner", + "folderLabel": "Ordner", + "noFolder": "Kein Ordner", + "newFolderTitle": "Neuer Ordner", + "folderNameLabel": "Ordnername", + "createFolderAction": "Ordner erstellen", + "folderCreatedToast": "Ordner erstellt.", "housekeepingFolder": "Hausreinigung", "calendarItemsFolder": "Kalendereinträge", "folderBrowserTitle": "Ordner durchsuchen" diff --git a/public/pages/housekeeping.js b/public/pages/housekeeping.js index 0e7d6d8..cdce7c6 100644 --- a/public/pages/housekeeping.js +++ b/public/pages/housekeeping.js @@ -5,12 +5,16 @@ */ import { api } from '/api.js'; -import { t, formatDate, formatTime } from '/i18n.js'; +import { t, formatDate, formatTime, getLocale } from '/i18n.js'; import { esc } from '/utils/html.js'; import { openModal, closeModal, confirmModal } from '/components/modal.js'; const MAX_FILE_SIZE = 5 * 1024 * 1024; +function localDate(d = new Date()) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + let state = { tab: 'dashboard', dashboard: null, @@ -22,12 +26,13 @@ let state = { workers: [], workerAvatar: undefined, selectedStaffId: null, - staffLogMonth: new Date().toISOString().slice(0, 7), + staffLogMonth: localDate().slice(0, 7), staffVisits: [], + currency: 'EUR', }; function money(value) { - return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'BRL' }).format(Number(value || 0)); + return new Intl.NumberFormat(getLocale(), { style: 'currency', currency: state.currency }).format(Number(value || 0)); } function initials(name = '') { @@ -57,7 +62,7 @@ function templateLabel(template, field) { } function visitTextPayload(worker, dateValue, dailyRate, extras) { - const visitDate = dateValue || new Date().toISOString().slice(0, 10); + const visitDate = dateValue || localDate(); const total = Number(dailyRate || 0) + Number(extras || 0); const name = worker?.display_name || t('housekeeping.staff'); return { @@ -89,12 +94,13 @@ async function loadStaffVisits(workerId = state.selectedStaffId, monthValue = st } async function loadData() { - const [dashboard, tasks, reports, templates, workers] = await Promise.all([ + const [dashboard, tasks, reports, templates, workers, prefs] = await Promise.all([ api.get('/housekeeping/dashboard'), api.get('/housekeeping/decay-tasks'), api.get('/housekeeping/visits'), api.get('/housekeeping/task-templates'), api.get('/housekeeping/workers'), + api.get('/preferences'), ]); state.dashboard = dashboard.data; state.tasks = tasks.data || []; @@ -103,6 +109,7 @@ async function loadData() { state.templates = templates.data || []; state.workers = workers.data || []; state.worker = state.workers[0] || null; + state.currency = prefs.data?.currency ?? 'EUR'; } function renderTabButton(tab, icon, label) { @@ -166,16 +173,19 @@ async function toggleSession(container, workerId) { return; } if (!worker) return; - if (current) return; try { - const dateValue = new Date().toISOString().slice(0, 10); - await api.post('/housekeeping/work-sessions/check-in', { - worker_id: worker.id, - daily_rate: worker.daily_rate || 0, - extras: 0, - ...visitTextPayload(worker, dateValue, worker.daily_rate || 0, 0), - }); - window.oikos?.showToast(t('housekeeping.checkedInToast'), 'success'); + if (current) { + await api.post('/housekeeping/work-sessions/check-out', { worker_id: worker.id }); + window.oikos?.showToast(t('housekeeping.checkedOutToast'), 'success'); + } else { + await api.post('/housekeeping/work-sessions/check-in', { + worker_id: worker.id, + daily_rate: worker.daily_rate || 0, + extras: 0, + ...visitTextPayload(worker, localDate(), worker.daily_rate || 0, 0), + }); + window.oikos?.showToast(t('housekeeping.checkedInToast'), 'success'); + } await loadData(); renderShell(container); } catch (err) { @@ -204,7 +214,7 @@ function renderWorkerSummary() { const session = worker.today_session || worker.current_session; return `
-
+
${worker.avatar_data ? `${esc(worker.display_name)}` : esc(initials(worker.display_name))}
@@ -393,7 +403,7 @@ function renderReports(content) { const paid = !!visit.paid_at; return `
-
+
${visit.worker_avatar_data ? `${esc(visit.worker_name || '')}` : esc(initials(visit.worker_name || 'HK'))}
@@ -449,7 +459,7 @@ function openVisitReportModal(visit) { content: `
-
+
${visit.worker_avatar_data ? `${esc(visit.worker_name || '')}` : esc(initials(visit.worker_name || 'HK'))}
@@ -476,7 +486,7 @@ function renderStaff(content) { const workerRows = state.workers.map((item) => `
-
+
${item.avatar_data ? `${esc(item.display_name)}` : esc(initials(item.display_name))}
@@ -535,7 +545,7 @@ function renderStaff(content) { }); }); content.querySelector('#housekeeping-staff-month')?.addEventListener('change', async (event) => { - state.staffLogMonth = event.currentTarget.value || new Date().toISOString().slice(0, 7); + state.staffLogMonth = event.currentTarget.value || localDate().slice(0, 7); try { await loadStaffVisits(); renderStaff(content); @@ -745,7 +755,7 @@ function openStaffModal(worker, content) {
diff --git a/server/auth.js b/server/auth.js index 1b48265..0c4ea36 100644 --- a/server/auth.js +++ b/server/auth.js @@ -575,9 +575,6 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => { .prepare(` SELECT ${USER_PUBLIC_COLUMNS} FROM users - WHERE NOT EXISTS ( - SELECT 1 FROM housekeeping_workers hw WHERE hw.user_id = users.id - ) ORDER BY display_name `) .all(); diff --git a/server/db.js b/server/db.js index f980a17..7d324be 100644 --- a/server/db.js +++ b/server/db.js @@ -1076,15 +1076,6 @@ const MIGRATIONS = [ }, { version: 30, - description: 'Advanced reminder options for birthdays', - up: ` - ALTER TABLE birthdays ADD COLUMN reminder_offset TEXT; - ALTER TABLE birthdays ADD COLUMN reminder_custom_amount INTEGER; - ALTER TABLE birthdays ADD COLUMN reminder_custom_unit TEXT; - `, - }, - { - version: 31, description: 'CardDAV multi-account contacts sync', up: ` -- ======================================== diff --git a/server/routes/housekeeping.js b/server/routes/housekeeping.js index 07d3309..74cd24c 100644 --- a/server/routes/housekeeping.js +++ b/server/routes/housekeeping.js @@ -430,7 +430,12 @@ router.get('/dashboard', (_req, res) => { }); router.get('/task-templates', (_req, res) => { - res.json({ data: TASK_TEMPLATES }); + try { + res.json({ data: TASK_TEMPLATES }); + } catch (err) { + log.error('GET /task-templates error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } }); router.get('/worker', (_req, res) => { @@ -656,7 +661,7 @@ router.post('/work-sessions/check-in', (req, res) => { return db.get().prepare(` INSERT INTO housekeeping_work_sessions (worker_id, check_in, check_out, daily_rate, extras, calendar_event_id, payment_task_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run(worker.id, checkIn, checkIn, vDailyRate.value, vExtras.value ?? 0, eventId, taskId, actorId); + `).run(worker.id, checkIn, null, vDailyRate.value, vExtras.value ?? 0, eventId, taskId, actorId); })(); const row = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(result.lastInsertRowid); res.status(201).json({ data: publicSession(row), summary: monthlySummary() }); @@ -755,7 +760,7 @@ router.delete('/visits/:id', (req, res) => { deleteVisitLinks(db.get(), existing); db.get().prepare('DELETE FROM housekeeping_work_sessions WHERE id = ?').run(existing.id); })(); - res.json({ ok: true, summary: monthlySummary() }); + res.json({ data: { summary: monthlySummary() } }); } catch (err) { log.error('DELETE /visits/:id error:', err); res.status(500).json({ error: 'Internal server error.', code: 500 }); @@ -885,7 +890,7 @@ router.delete('/decay-tasks/:taskId', (req, res) => { if (vId.error) return res.status(400).json({ error: vId.error, code: 400 }); const result = db.get().prepare('DELETE FROM housekeeping_decay_tasks WHERE id = ?').run(vId.value); if (result.changes === 0) return res.status(404).json({ error: 'Task not found.', code: 404 }); - res.json({ ok: true }); + res.json({ data: null }); } catch (err) { log.error('DELETE /decay-tasks/:taskId error:', err); res.status(500).json({ error: 'Internal server error.', code: 500 }); diff --git a/server/services/birthdays.js b/server/services/birthdays.js index d9362f4..175201e 100644 --- a/server/services/birthdays.js +++ b/server/services/birthdays.js @@ -57,7 +57,8 @@ function getOffsetMinutes(birthday) { function birthdayReminderAt(birthDate, offsetMin = 0, from = new Date()) { const next = nextBirthdayDate(birthDate, from); - return `${next}T12:00:00Z`; + const baseTime = new Date(`${next}T12:00:00Z`).getTime(); + return new Date(baseTime - (offsetMin || 0) * 60000).toISOString(); } function eventTitle(name) {