From 2a48fb7af0babc9ac3f31d070d7184003aae8402 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Wed, 6 May 2026 10:04:41 +0200 Subject: [PATCH 1/2] feat: multi-person assignment for tasks and calendar events - DB migration v32: task_assignments and event_assignments join tables with CASCADE delete; existing assigned_to data migrated automatically - Tasks API: accepts assigned_to as array, returns assigned_users[] with json_group_array; filter uses EXISTS on task_assignments - Calendar API: same pattern via event_assignments; serializeEvent includes assigned_users array - Recurring task completion copies all assignments to the new instance - Frontend: shared UserMultiSelect component with avatar stack display (renderAvatarStack, renderUserMultiSelect, getSelectedUserIds, bindUserMultiSelect); tasks.js and calendar.js use it in modals and card/agenda views - CSS: user-multi-select.css with avatar-stack and user-ms classes - 14 new tests covering CRUD, JSON aggregation, EXISTS filter, and CASCADE behavior for both task and event assignments Closes #125 --- package.json | 3 +- public/components/user-multi-select.js | 124 ++++++++++++++++++ public/index.html | 1 + public/locales/de.json | 4 + public/pages/calendar.js | 29 ++--- public/pages/tasks.js | 26 +--- public/styles/user-multi-select.css | 102 +++++++++++++++ server/db-schema-test.js | 11 ++ server/db.js | 20 +++ server/routes/calendar.js | 174 +++++++++++++++---------- server/routes/tasks.js | 134 +++++++++++++------ test-multi-assignment.js | 166 +++++++++++++++++++++++ 12 files changed, 648 insertions(+), 146 deletions(-) create mode 100644 public/components/user-multi-select.js create mode 100644 public/styles/user-multi-select.css create mode 100644 test-multi-assignment.js diff --git a/package.json b/package.json index 3958f22..6d1303b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "setup": "node --import dotenv/config setup.js", "test:db": "node --experimental-sqlite test-db.js", "test:dashboard": "node --experimental-sqlite test-dashboard.js", + "test:multi-assignment": "node --experimental-sqlite test-multi-assignment.js", "test:tasks": "node --experimental-sqlite test-tasks.js", "test:shopping": "node --experimental-sqlite test-shopping.js", "test:meals": "node --experimental-sqlite test-meals.js", @@ -29,7 +30,7 @@ "test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js", "test:caldav": "node --experimental-sqlite test-caldav-sync.js", "test:carddav": "node --experimental-sqlite test-carddav.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav" + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-multi-assignment.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/public/components/user-multi-select.js b/public/components/user-multi-select.js new file mode 100644 index 0000000..61c8721 --- /dev/null +++ b/public/components/user-multi-select.js @@ -0,0 +1,124 @@ +/** + * Modul: User Multi-Select + * Zweck: Wiederverwendbare Mehrfachauswahl-Komponente für Benutzer (Tasks & Kalender) + * Abhängigkeiten: public/utils/html.js, public/i18n.js + */ + +import { esc } from '/utils/html.js'; +import { t } from '/i18n.js'; + +/** + * Rendert einen Avatar-Stack für mehrere zugewiesene Benutzer. + * @param {Array<{id, display_name, color}>} users + * @param {object} opts + * @param {number} [opts.size=28] Avatar-Größe in px + * @param {number} [opts.maxVisible=3] Maximale Avatare vor "+N"-Anzeige + * @returns {string} HTML-String + */ +export function renderAvatarStack(users, { size = 28, maxVisible = 3 } = {}) { + if (!users?.length) return ''; + const visible = users.slice(0, maxVisible); + const overflow = users.length - visible.length; + const fs = Math.round(size * 0.4); + const avatars = visible.map((u) => { + const initials = (u.display_name ?? '') + .split(' ') + .map((w) => w[0] ?? '') + .join('') + .toUpperCase() + .slice(0, 2); + return ` + ${esc(initials)} + `; + }); + if (overflow > 0) { + avatars.push(`+${overflow}`); + } + return `${avatars.join('')}`; +} + +/** + * Rendert das Multi-Select-Widget als Dropdown-Checkbox-Liste. + * @param {Array<{id, display_name, avatar_color}>} allUsers Alle verfügbaren Benutzer + * @param {number[]} selectedIds Bereits ausgewählte IDs + * @param {string} inputName Name-Attribut des Widgets + * @param {string} labelKey i18n-Schlüssel für das Label + * @returns {string} HTML-String + */ +export function renderUserMultiSelect(allUsers, selectedIds, inputName, labelKey) { + const selectedSet = new Set(selectedIds ?? []); + const items = allUsers.map((u) => { + const checked = selectedSet.has(u.id) ? 'checked' : ''; + const initials = (u.display_name ?? '') + .split(' ') + .map((w) => w[0] ?? '') + .join('') + .toUpperCase() + .slice(0, 2); + return ` + `; + }); + + const noneLabel = t('userMultiSelect.nobody'); + return ` +
+ +
+ + ${items.join('')} +
+
`; +} + +/** + * Liest die ausgewählten User-IDs aus einem gerenderten Multi-Select-Widget. + * @param {Element} container DOM-Element, das das Widget enthält + * @param {string} inputName Name-Attribut des Widgets + * @returns {number[]} + */ +export function getSelectedUserIds(container, inputName) { + const checkboxes = container.querySelectorAll( + `[data-ms-input="${CSS.escape(inputName)}"]:not(.user-ms__none):checked` + ); + return Array.from(checkboxes).map((cb) => Number(cb.value)).filter(Boolean); +} + +/** + * Bindet die Checkbox-Logik: + * - "Niemand" deselektiert alle anderen + * - Andere Auswahl deselektiert "Niemand" + * @param {Element} container + * @param {string} inputName + */ +export function bindUserMultiSelect(container, inputName) { + const widget = container.querySelector(`.user-ms[data-ms-name="${CSS.escape(inputName)}"]`); + if (!widget) return; + + widget.addEventListener('change', (e) => { + const cb = e.target; + if (!cb.matches('.user-ms__checkbox')) return; + + if (cb.classList.contains('user-ms__none') && cb.checked) { + widget.querySelectorAll('.user-ms__checkbox:not(.user-ms__none)').forEach((c) => { c.checked = false; }); + } else if (!cb.classList.contains('user-ms__none') && cb.checked) { + const none = widget.querySelector('.user-ms__none'); + if (none) none.checked = false; + } + }); +} diff --git a/public/index.html b/public/index.html index 5423e07..c48f7dd 100644 --- a/public/index.html +++ b/public/index.html @@ -44,6 +44,7 @@ + diff --git a/public/locales/de.json b/public/locales/de.json index 36b7830..161aedb 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -1293,5 +1293,9 @@ "dropzoneTitle": "Datei hier ablegen oder klicken", "dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.", "selectedFileLabel": "Ausgewählt: {{name}}" + }, + "userMultiSelect": { + "nobody": "- Niemand -", + "moreUsers": "weitere" } } diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 0aeccdf..fffc6b0 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -11,6 +11,7 @@ import { stagger } from '/utils/ux.js'; import { t, formatDate as formatPreferredDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js'; import { esc, fmtLocation } from '/utils/html.js'; import { refresh as refreshReminders } from '/reminders.js'; +import { renderUserMultiSelect, getSelectedUserIds, bindUserMultiSelect, renderAvatarStack } from '/components/user-multi-select.js'; // -------------------------------------------------------- // Konstanten @@ -1032,11 +1033,8 @@ function renderAgendaEvent(ev) { : formatTime(ev.start_datetime) + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} ${t('calendar.timeSuffix')}`.trimEnd() : ` ${t('calendar.timeSuffix')}`.trimEnd()); - const initials = ev.assigned_name - ? ev.assigned_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2) - : ''; - const displayColor = ev.cal_color || ev.color; + const assignedUsers = ev.assigned_users ?? []; return `
@@ -1046,11 +1044,7 @@ function renderAgendaEvent(ev) { ${timeStr} ${ev.location ? `📍 ${esc(fmtLocation(ev.location))}` : ''} ${ev.cal_name ? `${esc(ev.cal_name)}` : ''} - ${ev.assigned_name ? ` - - ${initials} - ${esc(ev.assigned_name)} - ` : ''} + ${assignedUsers.length ? `${renderAvatarStack(assignedUsers, { size: 20, maxVisible: 3 })}` : ''}
@@ -1328,6 +1322,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { onSave(panel) { // RRULE-Events binden bindRRuleEvents(panel, 'event'); + bindUserMultiSelect(panel, 'cal_assigned'); const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0]; @@ -1573,12 +1568,9 @@ function buildEventModalContent({ mode, event, date, reminder = null }) { `).join(''); - const userOpts = [ - ``, - ...state.users.map((u) => - `` - ), - ].join(''); + const selectedUserIds = isEdit + ? (event.assigned_users?.map((u) => u.id) ?? (event.assigned_to ? [event.assigned_to] : [])) + : []; return `
@@ -1660,8 +1652,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
- - + ${renderUserMultiSelect(state.users, selectedUserIds, 'cal_assigned', 'calendar.assignedLabel')}
@@ -1738,7 +1729,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach const color = overlay.querySelector('.color-swatch--active')?.dataset.color || EVENT_COLORS[0]; const icon = eventIconName(overlay.querySelector('#modal-icon')?.value); const location = overlay.querySelector('#modal-location').value.trim() || null; - const assigned_to = overlay.querySelector('#modal-assigned').value || null; + const assigned_to = getSelectedUserIds(overlay, 'cal_assigned'); const description = overlay.querySelector('#modal-description').value.trim() || null; let start_datetime, end_datetime; @@ -1815,7 +1806,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach const body = { title, description, start_datetime, end_datetime, all_day: allday ? 1 : 0, - location, color, icon, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null, + location, color, icon, assigned_to, recurrence_rule: rrule.recurrence_rule, attachment_name: attachmentPayload.name, attachment_mime: attachmentPayload.mime, diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 062a62b..c1d05fa 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -11,6 +11,7 @@ import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js'; import { esc } from '/utils/html.js'; import { refresh as refreshReminders } from '/reminders.js'; +import { renderUserMultiSelect, getSelectedUserIds, bindUserMultiSelect, renderAvatarStack } from '/components/user-multi-select.js'; // -------------------------------------------------------- // Konstanten @@ -203,11 +204,7 @@ function renderTaskCard(task, opts = {}) {
- ${task.assigned_color ? ` -
- ${esc(initials(task.assigned_name ?? ''))} -
` : ''} + ${renderAvatarStack(task.assigned_users ?? [], { size: 28 })}