diff --git a/CHANGELOG.md b/CHANGELOG.md index f23a02e..9bbf355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.48.0] - 2026-05-06 + +### Added +- **Multi-person assignment**: tasks and calendar events can now be assigned to multiple family members simultaneously. A new `task_assignments` / `event_assignments` join table (migration v32) stores the assignments; existing single-user data is migrated automatically. +- **Avatar stack**: task cards, Kanban cards, and the calendar agenda view display stacked avatars for all assigned users (up to 3 visible, then a `+N` overflow badge). +- **Shared UserMultiSelect component** (`public/components/user-multi-select.js`): checkbox-based dropdown used in both the task modal and the calendar event modal; replaces the previous single-user ` + + ${esc(initials)} + + ${esc(u.display_name)} + `; + }); + + 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 })}