fix: correct housekeeping module bugs after merge

- 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 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-08 20:18:26 +02:00
parent 22ec13e559
commit 761408ae7c
8 changed files with 54 additions and 272 deletions
-134
View File
@@ -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`
-88
View File
@@ -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.
+13 -13
View File
@@ -1062,11 +1062,11 @@
"helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.", "helpTooltipCardDAV": "CardDAV ermöglicht die Synchronisation von Kontakten mit iCloud, Nextcloud und anderen CardDAV-Servern.",
"emptyStateAddFirst": "Füge dein erstes Konto hinzu", "emptyStateAddFirst": "Füge dein erstes Konto hinzu",
"emptyStateNoAccounts": "Noch keine Konten verbunden", "emptyStateNoAccounts": "Noch keine Konten verbunden",
"sectionHousekeeping": "Housekeeping", "sectionHousekeeping": "Haushaltshilfe",
"housekeepingPaymentsTitle": "Payment tasks", "housekeepingPaymentsTitle": "Zahlungsaufgaben",
"housekeepingPaymentTasksLabel": "Create a payment task on each housekeeper check-in", "housekeepingPaymentTasksLabel": "Bei jedem Einchecken eine Zahlungsaufgabe erstellen",
"housekeepingPaymentTasksHint": "When enabled, each check-in creates a task for paying the staff member. Completing that task marks the visit payment as paid.", "housekeepingPaymentTasksHint": "Wenn aktiviert, wird bei jedem Einchecken eine Aufgabe zur Bezahlung der Haushaltshilfe erstellt. Das Erledigen dieser Aufgabe markiert den Besuch als bezahlt.",
"housekeepingPaymentTasksSaved": "Housekeeping payment setting saved." "housekeepingPaymentTasksSaved": "Einstellung für Haushaltshilfe-Zahlungen gespeichert."
}, },
"login": { "login": {
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
@@ -1298,14 +1298,14 @@
"dropzoneTitle": "Datei hier ablegen oder klicken", "dropzoneTitle": "Datei hier ablegen oder klicken",
"dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.", "dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.",
"selectedFileLabel": "Ausgewählt: {{name}}", "selectedFileLabel": "Ausgewählt: {{name}}",
"addFolderButton": "Add folder", "addFolderButton": "Ordner hinzufügen",
"allFolders": "All folders", "allFolders": "Alle Ordner",
"folderLabel": "Folder", "folderLabel": "Ordner",
"noFolder": "No folder", "noFolder": "Kein Ordner",
"newFolderTitle": "New folder", "newFolderTitle": "Neuer Ordner",
"folderNameLabel": "Folder name", "folderNameLabel": "Ordnername",
"createFolderAction": "Create folder", "createFolderAction": "Ordner erstellen",
"folderCreatedToast": "Folder created.", "folderCreatedToast": "Ordner erstellt.",
"housekeepingFolder": "Hausreinigung", "housekeepingFolder": "Hausreinigung",
"calendarItemsFolder": "Kalendereinträge", "calendarItemsFolder": "Kalendereinträge",
"folderBrowserTitle": "Ordner durchsuchen" "folderBrowserTitle": "Ordner durchsuchen"
+30 -20
View File
@@ -5,12 +5,16 @@
*/ */
import { api } from '/api.js'; 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 { esc } from '/utils/html.js';
import { openModal, closeModal, confirmModal } from '/components/modal.js'; import { openModal, closeModal, confirmModal } from '/components/modal.js';
const MAX_FILE_SIZE = 5 * 1024 * 1024; 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 = { let state = {
tab: 'dashboard', tab: 'dashboard',
dashboard: null, dashboard: null,
@@ -22,12 +26,13 @@ let state = {
workers: [], workers: [],
workerAvatar: undefined, workerAvatar: undefined,
selectedStaffId: null, selectedStaffId: null,
staffLogMonth: new Date().toISOString().slice(0, 7), staffLogMonth: localDate().slice(0, 7),
staffVisits: [], staffVisits: [],
currency: 'EUR',
}; };
function money(value) { 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 = '') { function initials(name = '') {
@@ -57,7 +62,7 @@ function templateLabel(template, field) {
} }
function visitTextPayload(worker, dateValue, dailyRate, extras) { 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 total = Number(dailyRate || 0) + Number(extras || 0);
const name = worker?.display_name || t('housekeeping.staff'); const name = worker?.display_name || t('housekeeping.staff');
return { return {
@@ -89,12 +94,13 @@ async function loadStaffVisits(workerId = state.selectedStaffId, monthValue = st
} }
async function loadData() { 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/dashboard'),
api.get('/housekeeping/decay-tasks'), api.get('/housekeeping/decay-tasks'),
api.get('/housekeeping/visits'), api.get('/housekeeping/visits'),
api.get('/housekeeping/task-templates'), api.get('/housekeeping/task-templates'),
api.get('/housekeeping/workers'), api.get('/housekeeping/workers'),
api.get('/preferences'),
]); ]);
state.dashboard = dashboard.data; state.dashboard = dashboard.data;
state.tasks = tasks.data || []; state.tasks = tasks.data || [];
@@ -103,6 +109,7 @@ async function loadData() {
state.templates = templates.data || []; state.templates = templates.data || [];
state.workers = workers.data || []; state.workers = workers.data || [];
state.worker = state.workers[0] || null; state.worker = state.workers[0] || null;
state.currency = prefs.data?.currency ?? 'EUR';
} }
function renderTabButton(tab, icon, label) { function renderTabButton(tab, icon, label) {
@@ -166,16 +173,19 @@ async function toggleSession(container, workerId) {
return; return;
} }
if (!worker) return; if (!worker) return;
if (current) return;
try { try {
const dateValue = new Date().toISOString().slice(0, 10); if (current) {
await api.post('/housekeeping/work-sessions/check-in', { await api.post('/housekeeping/work-sessions/check-out', { worker_id: worker.id });
worker_id: worker.id, window.oikos?.showToast(t('housekeeping.checkedOutToast'), 'success');
daily_rate: worker.daily_rate || 0, } else {
extras: 0, await api.post('/housekeeping/work-sessions/check-in', {
...visitTextPayload(worker, dateValue, worker.daily_rate || 0, 0), worker_id: worker.id,
}); daily_rate: worker.daily_rate || 0,
window.oikos?.showToast(t('housekeeping.checkedInToast'), 'success'); extras: 0,
...visitTextPayload(worker, localDate(), worker.daily_rate || 0, 0),
});
window.oikos?.showToast(t('housekeeping.checkedInToast'), 'success');
}
await loadData(); await loadData();
renderShell(container); renderShell(container);
} catch (err) { } catch (err) {
@@ -204,7 +214,7 @@ function renderWorkerSummary() {
const session = worker.today_session || worker.current_session; const session = worker.today_session || worker.current_session;
return ` return `
<section class="housekeeping-worker-strip"> <section class="housekeeping-worker-strip">
<div class="housekeeping-avatar" style="background:${esc(worker.avatar_color || '#7C3AED')}"> <div class="housekeeping-avatar" style="background:${esc(worker.avatar_color) || 'var(--module-housekeeping)'}">
${worker.avatar_data ? `<img src="${esc(worker.avatar_data)}" alt="${esc(worker.display_name)}">` : esc(initials(worker.display_name))} ${worker.avatar_data ? `<img src="${esc(worker.avatar_data)}" alt="${esc(worker.display_name)}">` : esc(initials(worker.display_name))}
</div> </div>
<div> <div>
@@ -393,7 +403,7 @@ function renderReports(content) {
const paid = !!visit.paid_at; const paid = !!visit.paid_at;
return ` return `
<article class="housekeeping-report-item housekeeping-report-item--visit"> <article class="housekeeping-report-item housekeeping-report-item--visit">
<div class="housekeeping-avatar" style="background:${esc(visit.worker_avatar_color || '#7C3AED')}"> <div class="housekeeping-avatar" style="background:${esc(visit.worker_avatar_color) || 'var(--module-housekeeping)'}">
${visit.worker_avatar_data ? `<img src="${esc(visit.worker_avatar_data)}" alt="${esc(visit.worker_name || '')}">` : esc(initials(visit.worker_name || 'HK'))} ${visit.worker_avatar_data ? `<img src="${esc(visit.worker_avatar_data)}" alt="${esc(visit.worker_name || '')}">` : esc(initials(visit.worker_name || 'HK'))}
</div> </div>
<div> <div>
@@ -449,7 +459,7 @@ function openVisitReportModal(visit) {
content: ` content: `
<div class="housekeeping-report-modal"> <div class="housekeeping-report-modal">
<div class="housekeeping-staff-row"> <div class="housekeeping-staff-row">
<div class="housekeeping-avatar" style="background:${esc(visit.worker_avatar_color || '#7C3AED')}"> <div class="housekeeping-avatar" style="background:${esc(visit.worker_avatar_color) || 'var(--module-housekeeping)'}">
${visit.worker_avatar_data ? `<img src="${esc(visit.worker_avatar_data)}" alt="${esc(visit.worker_name || '')}">` : esc(initials(visit.worker_name || 'HK'))} ${visit.worker_avatar_data ? `<img src="${esc(visit.worker_avatar_data)}" alt="${esc(visit.worker_name || '')}">` : esc(initials(visit.worker_name || 'HK'))}
</div> </div>
<div> <div>
@@ -476,7 +486,7 @@ function renderStaff(content) {
const workerRows = state.workers.map((item) => ` const workerRows = state.workers.map((item) => `
<article class="housekeeping-staff-row ${String(state.selectedStaffId || '') === String(item.id) ? 'housekeeping-staff-row--active' : ''}" <article class="housekeeping-staff-row ${String(state.selectedStaffId || '') === String(item.id) ? 'housekeeping-staff-row--active' : ''}"
data-select-worker="${item.id}" role="button" tabindex="0"> data-select-worker="${item.id}" role="button" tabindex="0">
<div class="housekeeping-avatar" style="background:${esc(item.avatar_color || '#7C3AED')}"> <div class="housekeeping-avatar" style="background:${esc(item.avatar_color) || 'var(--module-housekeeping)'}">
${item.avatar_data ? `<img src="${esc(item.avatar_data)}" alt="${esc(item.display_name)}">` : esc(initials(item.display_name))} ${item.avatar_data ? `<img src="${esc(item.avatar_data)}" alt="${esc(item.display_name)}">` : esc(initials(item.display_name))}
</div> </div>
<div> <div>
@@ -535,7 +545,7 @@ function renderStaff(content) {
}); });
}); });
content.querySelector('#housekeeping-staff-month')?.addEventListener('change', async (event) => { 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 { try {
await loadStaffVisits(); await loadStaffVisits();
renderStaff(content); renderStaff(content);
@@ -745,7 +755,7 @@ function openStaffModal(worker, content) {
<input type="hidden" name="id" value="${esc(item.id || '')}"> <input type="hidden" name="id" value="${esc(item.id || '')}">
<div class="housekeeping-profile-editor"> <div class="housekeeping-profile-editor">
<button class="housekeeping-avatar housekeeping-avatar--lg" type="button" id="housekeeping-avatar-btn" <button class="housekeeping-avatar housekeeping-avatar--lg" type="button" id="housekeeping-avatar-btn"
style="background:${esc(item.avatar_color || '#7C3AED')}" aria-label="${esc(t('housekeeping.profilePicture'))}"> style="background:${esc(item.avatar_color) || 'var(--module-housekeeping)'}" aria-label="${esc(t('housekeeping.profilePicture'))}">
${item.avatar_data ? `<img src="${esc(item.avatar_data)}" alt="${esc(item.display_name || '')}">` : esc(initials(item.display_name || 'HK'))} ${item.avatar_data ? `<img src="${esc(item.avatar_data)}" alt="${esc(item.display_name || '')}">` : esc(initials(item.display_name || 'HK'))}
</button> </button>
<input class="sr-only" type="file" id="housekeeping-avatar-file" accept="image/png,image/jpeg,image/webp"> <input class="sr-only" type="file" id="housekeeping-avatar-file" accept="image/png,image/jpeg,image/webp">
-3
View File
@@ -575,9 +575,6 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => {
.prepare(` .prepare(`
SELECT ${USER_PUBLIC_COLUMNS} SELECT ${USER_PUBLIC_COLUMNS}
FROM users FROM users
WHERE NOT EXISTS (
SELECT 1 FROM housekeeping_workers hw WHERE hw.user_id = users.id
)
ORDER BY display_name ORDER BY display_name
`) `)
.all(); .all();
-9
View File
@@ -1076,15 +1076,6 @@ const MIGRATIONS = [
}, },
{ {
version: 30, 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', description: 'CardDAV multi-account contacts sync',
up: ` up: `
-- ======================================== -- ========================================
+9 -4
View File
@@ -430,7 +430,12 @@ router.get('/dashboard', (_req, res) => {
}); });
router.get('/task-templates', (_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) => { router.get('/worker', (_req, res) => {
@@ -656,7 +661,7 @@ router.post('/work-sessions/check-in', (req, res) => {
return db.get().prepare(` 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) INSERT INTO housekeeping_work_sessions (worker_id, check_in, check_out, daily_rate, extras, calendar_event_id, payment_task_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) 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); const row = db.get().prepare('SELECT * FROM housekeeping_work_sessions WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ data: publicSession(row), summary: monthlySummary() }); res.status(201).json({ data: publicSession(row), summary: monthlySummary() });
@@ -755,7 +760,7 @@ router.delete('/visits/:id', (req, res) => {
deleteVisitLinks(db.get(), existing); deleteVisitLinks(db.get(), existing);
db.get().prepare('DELETE FROM housekeeping_work_sessions WHERE id = ?').run(existing.id); 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) { } catch (err) {
log.error('DELETE /visits/:id error:', err); log.error('DELETE /visits/:id error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 }); 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 }); 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); 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 }); 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) { } catch (err) {
log.error('DELETE /decay-tasks/:taskId error:', err); log.error('DELETE /decay-tasks/:taskId error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 }); res.status(500).json({ error: 'Internal server error.', code: 500 });
+2 -1
View File
@@ -57,7 +57,8 @@ function getOffsetMinutes(birthday) {
function birthdayReminderAt(birthDate, offsetMin = 0, from = new Date()) { function birthdayReminderAt(birthDate, offsetMin = 0, from = new Date()) {
const next = nextBirthdayDate(birthDate, from); 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) { function eventTitle(name) {