diff --git a/public/locales/en.json b/public/locales/en.json index 3bcf10b..2757a44 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -131,6 +131,7 @@ "statusOpen": "Open", "statusInProgress": "In Progress", "statusDone": "Done", + "statusArchived": "Archived", "categoryHousehold": "Household", "categorySchool": "School", "categoryShopping": "Shopping", @@ -167,6 +168,7 @@ "kanbanOpen": "Open", "kanbanInProgress": "In Progress", "kanbanDone": "Done", + "kanbanArchived": "Archived", "kanbanMoveToInProgress": "Set to in progress", "kanbanMoveToDone": "Mark as done", "kanbanMoveToOpen": "Reopen", @@ -179,7 +181,8 @@ "filterGroupPriority": "Priority", "filterGroupStatus": "Status", "swipedDoneToast": "Marked as done.", - "swipedOpenToast": "Marked as open." + "swipedOpenToast": "Marked as open.", + "reminderNeedsDueDate": "Set a due date to enable task reminders." }, "shopping": { "title": "Shopping", diff --git a/public/locales/pt.json b/public/locales/pt.json index 9a85cea..c588e78 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -131,6 +131,7 @@ "statusOpen": "Aberto", "statusInProgress": "Em andamento", "statusDone": "Concluído", + "statusArchived": "Arquivado", "categoryHousehold": "Casa", "categorySchool": "Escola", "categoryShopping": "Compras", @@ -167,6 +168,7 @@ "kanbanOpen": "Aberto", "kanbanInProgress": "Em andamento", "kanbanDone": "Concluído", + "kanbanArchived": "Arquivado", "kanbanMoveToInProgress": "Mover para em andamento", "kanbanMoveToDone": "Marcar como concluído", "kanbanMoveToOpen": "Reabrir", @@ -179,7 +181,8 @@ "filterGroupPriority": "Prioridade", "filterGroupStatus": "Estado", "swipedDoneToast": "Marcado como concluído.", - "swipedOpenToast": "Marcado como aberto." + "swipedOpenToast": "Marcado como aberto.", + "reminderNeedsDueDate": "Defina uma data de vencimento para habilitar lembretes da tarefa." }, "shopping": { "title": "Compras", diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 2cd1932..9f8d0fd 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -30,6 +30,7 @@ const STATUSES = () => [ { value: 'open', label: t('tasks.statusOpen') }, { value: 'in_progress', label: t('tasks.statusInProgress') }, { value: 'done', label: t('tasks.statusDone') }, + { value: 'archived', label: t('tasks.statusArchived') }, ]; const CATEGORIES = [ @@ -376,7 +377,7 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) { ${renderRRuleFields('task', task?.recurrence_rule)} - ${renderReminderSection(reminder)} + ${renderReminderSection(task, reminder)} @@ -446,10 +447,36 @@ async function loadReminderForTask(taskId) { } } -function renderReminderSection(reminder = null) { - const hasReminder = !!reminder; - const remindDate = hasReminder ? reminder.remind_at.slice(0, 10) : ''; - const remindTime = hasReminder ? reminder.remind_at.slice(11, 16) : ''; +function parseOffsetMsFromReminder(task, reminder) { + if (!task?.due_date || !reminder?.remind_at) return null; + const due = task.due_time ? new Date(`${task.due_date}T${task.due_time}`) : new Date(`${task.due_date}T23:59:59`); + const remind = new Date(reminder.remind_at); + if (Number.isNaN(due.getTime()) || Number.isNaN(remind.getTime())) return null; + return due.getTime() - remind.getTime(); +} + +function resolveReminderPreset(task, reminder) { + const offset = parseOffsetMsFromReminder(task, reminder); + if (offset === null) return { preset: 'offset_15m', amount: '15', unit: 'minutes' }; + const map = new Map([ + [0, 'offset_at_time'], + [15 * 60 * 1000, 'offset_15m'], + [60 * 60 * 1000, 'offset_1h'], + [24 * 60 * 60 * 1000, 'offset_1d'], + [2 * 24 * 60 * 60 * 1000, 'offset_2d'], + [7 * 24 * 60 * 60 * 1000, 'offset_1w'], + [14 * 24 * 60 * 60 * 1000, 'offset_2w'], + ]); + if (map.has(offset)) return { preset: map.get(offset), amount: '1', unit: 'days' }; + const minutes = Math.round(offset / 60000); + if (minutes > 0) return { preset: 'offset_custom', amount: String(minutes), unit: 'minutes' }; + return { preset: 'offset_at_time', amount: '1', unit: 'days' }; +} + +function renderReminderSection(task = null, reminder = null) { + const hasReminder = !!reminder; + const resolved = resolveReminderPreset(task, reminder); + const showCustom = hasReminder && resolved.preset === 'offset_custom'; return `
@@ -462,12 +489,33 @@ function renderReminderSection(reminder = null) {
- - + +
-
- - +
`; @@ -493,9 +541,15 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai // Reminder-Toggle: Felder ein-/ausblenden const toggle = panel.querySelector('#reminder-toggle'); const fields = panel.querySelector('#reminder-fields'); + const offset = panel.querySelector('#reminder-offset'); + const customFields = panel.querySelector('#reminder-custom-fields'); toggle?.addEventListener('change', () => { fields.style.display = toggle.checked ? '' : 'none'; }); + offset?.addEventListener('change', () => { + if (!customFields) return; + customFields.style.display = offset.value === 'offset_custom' ? '' : 'none'; + }); panel.querySelectorAll('.js-date-input').forEach((input) => { input.addEventListener('blur', () => { const parsed = parseDateInput(input.value); @@ -537,9 +591,7 @@ async function handleFormSubmit(e, container) { const dueDate = parseDateInput(dueDateRaw); const rrule = getRRuleValues(document, 'task'); const reminderToggle = form.querySelector('#reminder-toggle'); - const reminderDateRaw = form.querySelector('#reminder-date')?.value || ''; - const reminderDate = parseDateInput(reminderDateRaw); - if (!isDateInputValid(dueDateRaw) || !rrule.valid_until || (reminderToggle?.checked && !isDateInputValid(reminderDateRaw))) { + if (!isDateInputValid(dueDateRaw) || !rrule.valid_until) { errorEl.textContent = t('calendar.invalidDate'); errorEl.hidden = false; submitBtn.disabled = false; @@ -572,10 +624,27 @@ async function handleFormSubmit(e, container) { // Erinnerung speichern oder löschen if (savedTaskId) { - const reminderTime = form.querySelector('#reminder-time')?.value || '08:00'; - - if (reminderToggle?.checked && reminderDate) { - const remindAt = `${reminderDate}T${reminderTime}`; + if (reminderToggle?.checked) { + if (!dueDate) throw new Error(t('tasks.reminderNeedsDueDate')); + const dueDateTime = body.due_time ? new Date(`${dueDate}T${body.due_time}`) : new Date(`${dueDate}T23:59:59`); + const offsetPreset = form.querySelector('#reminder-offset')?.value || 'offset_none'; + if (offsetPreset === 'offset_none') throw new Error(t('tasks.reminderNeedsDueDate')); + let offsetMs = 0; + if (offsetPreset === 'offset_15m') offsetMs = 15 * 60 * 1000; + else if (offsetPreset === 'offset_1h') offsetMs = 60 * 60 * 1000; + else if (offsetPreset === 'offset_1d') offsetMs = 24 * 60 * 60 * 1000; + else if (offsetPreset === 'offset_2d') offsetMs = 2 * 24 * 60 * 60 * 1000; + else if (offsetPreset === 'offset_1w') offsetMs = 7 * 24 * 60 * 60 * 1000; + else if (offsetPreset === 'offset_2w') offsetMs = 14 * 24 * 60 * 60 * 1000; + else if (offsetPreset === 'offset_custom') { + const customAmount = Number(form.querySelector('#reminder-custom-amount')?.value || 0); + const customUnit = form.querySelector('#reminder-custom-unit')?.value || 'days'; + if (!Number.isFinite(customAmount) || customAmount <= 0) throw new Error(t('common.invalidInput')); + const unitFactor = customUnit === 'minutes' ? 60000 : customUnit === 'hours' ? 3600000 : customUnit === 'days' ? 86400000 : 604800000; + offsetMs = customAmount * unitFactor; + } + const remindAtDate = new Date(dueDateTime.getTime() - offsetMs); + const remindAt = remindAtDate.toISOString().slice(0, 19); await api.post('/reminders', { entity_type: 'task', entity_id: savedTaskId, remind_at: remindAt }); refreshReminders(); } else if (!reminderToggle?.checked) { @@ -643,6 +712,7 @@ const KANBAN_COLS = () => [ { status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' }, { status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' }, { status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' }, + { status: 'archived', label: t('tasks.kanbanArchived'), colorVar: '--color-text-tertiary' }, ]; function kanbanNextStatus(status) { diff --git a/public/rrule-ui.js b/public/rrule-ui.js index 67deb64..f169b40 100644 --- a/public/rrule-ui.js +++ b/public/rrule-ui.js @@ -106,6 +106,11 @@ export function renderRRuleFields(prefix, existingRule) { ${unitLabel(parsed.freq, parsed.interval)} +
+ + +
@@ -113,11 +118,6 @@ export function renderRRuleFields(prefix, existingRule) {
${dayBtns}
-
- - -
`; diff --git a/public/styles/layout.css b/public/styles/layout.css index f58a965..8b75702 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -1688,6 +1688,7 @@ display: flex; align-items: flex-end; gap: var(--space-3); + flex-wrap: wrap; } .rrule-interval-wrap { @@ -1702,6 +1703,11 @@ white-space: nowrap; } +.rrule-until-field { + flex: 1; + min-width: 220px; +} + .rrule-day-grid { display: flex; gap: var(--space-1); diff --git a/public/sw.js b/public/sw.js index 4b17d5c..7272c5a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -13,10 +13,10 @@ * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) */ -const SHELL_CACHE = 'oikos-shell-v65'; -const PAGES_CACHE = 'oikos-pages-v60'; -const LOCALES_CACHE = 'oikos-locales-v9'; -const ASSETS_CACHE = 'oikos-assets-v60'; +const SHELL_CACHE = 'oikos-shell-v66'; +const PAGES_CACHE = 'oikos-pages-v61'; +const LOCALES_CACHE = 'oikos-locales-v10'; +const ASSETS_CACHE = 'oikos-assets-v61'; const BYPASS_CACHE = 'oikos-bypass-flag'; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];