feat(tasks): advanced reminders UI and recurrence layout improvements

This commit is contained in:
Rafael Foster
2026-04-29 05:33:06 -03:00
parent 9759f5e267
commit 0e7142edc2
6 changed files with 110 additions and 28 deletions
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+87 -17
View File
@@ -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)}
<div id="task-form-error" class="login-error" hidden></div>
@@ -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 `
<div class="reminder-section">
@@ -462,12 +489,33 @@ function renderReminderSection(reminder = null) {
</div>
<div id="reminder-fields" class="reminder-fields" ${hasReminder ? '' : 'style="display:none"'}>
<div class="form-group" style="margin:0">
<label class="label" for="reminder-date">${t('reminders.dateLabel')}</label>
<input class="input js-date-input" type="text" id="reminder-date" value="${formatDateInput(remindDate)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
<label class="label" for="reminder-offset">${t('reminders.offsetLabel')}</label>
<select class="input" id="reminder-offset">
<option value="offset_none">${t('reminders.offsetNone')}</option>
<option value="offset_at_time" ${resolved.preset === 'offset_at_time' ? 'selected' : ''}>${t('reminders.offsetAtTime')}</option>
<option value="offset_15m" ${resolved.preset === 'offset_15m' ? 'selected' : ''}>${t('reminders.offset15min')}</option>
<option value="offset_1h" ${resolved.preset === 'offset_1h' ? 'selected' : ''}>${t('reminders.offset1hour')}</option>
<option value="offset_1d" ${resolved.preset === 'offset_1d' ? 'selected' : ''}>${t('reminders.offset1day')}</option>
<option value="offset_2d" ${resolved.preset === 'offset_2d' ? 'selected' : ''}>${t('reminders.offset2days')}</option>
<option value="offset_1w" ${resolved.preset === 'offset_1w' ? 'selected' : ''}>${t('reminders.offset1week')}</option>
<option value="offset_2w" ${resolved.preset === 'offset_2w' ? 'selected' : ''}>${t('reminders.offset2weeks')}</option>
<option value="offset_custom" ${resolved.preset === 'offset_custom' ? 'selected' : ''}>${t('reminders.offsetCustom')}</option>
</select>
</div>
<div class="form-group" style="margin:0">
<label class="label" for="reminder-time">${t('reminders.timeLabel')}</label>
<input class="input" type="time" id="reminder-time" value="${remindTime || '08:00'}">
<div class="modal-grid modal-grid--2" id="reminder-custom-fields" style="${showCustom ? '' : 'display:none'};margin-top:var(--space-3)">
<div class="form-group" style="margin:0">
<label class="label" for="reminder-custom-amount">${t('reminders.customAmountLabel')}</label>
<input class="input" type="number" min="1" step="1" id="reminder-custom-amount" value="${resolved.amount}">
</div>
<div class="form-group" style="margin:0">
<label class="label" for="reminder-custom-unit">${t('reminders.customUnitLabel')}</label>
<select class="input" id="reminder-custom-unit">
<option value="minutes" ${resolved.unit === 'minutes' ? 'selected' : ''}>${t('reminders.customMinutes')}</option>
<option value="hours" ${resolved.unit === 'hours' ? 'selected' : ''}>${t('reminders.customHours')}</option>
<option value="days" ${resolved.unit === 'days' ? 'selected' : ''}>${t('reminders.customDays')}</option>
<option value="weeks" ${resolved.unit === 'weeks' ? 'selected' : ''}>${t('reminders.customWeeks')}</option>
</select>
</div>
</div>
</div>
</div>`;
@@ -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) {
+5 -5
View File
@@ -106,6 +106,11 @@ export function renderRRuleFields(prefix, existingRule) {
<span class="rrule-interval-unit" id="${prefix}-rrule-unit">${unitLabel(parsed.freq, parsed.interval)}</span>
</div>
</div>
<div class="form-group rrule-until-field" style="margin-bottom:0">
<label class="label form-label" for="${prefix}-rrule-until">${t('rrule.labelUntil')}</label>
<input class="input form-input js-date-input" type="text" id="${prefix}-rrule-until"
value="${formatDateInput(parsed.until)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div>
</div>
<div class="rrule-weekdays" id="${prefix}-rrule-weekdays" ${parsed.freq === 'WEEKLY' ? '' : 'hidden'}>
@@ -113,11 +118,6 @@ export function renderRRuleFields(prefix, existingRule) {
<div class="rrule-day-grid">${dayBtns}</div>
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="label form-label" for="${prefix}-rrule-until">${t('rrule.labelUntil')}</label>
<input class="input form-input js-date-input" type="text" id="${prefix}-rrule-until"
value="${formatDateInput(parsed.until)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric">
</div>
</div>
</div>
`;
+6
View File
@@ -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);
+4 -4
View File
@@ -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];