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];