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", "statusOpen": "Open",
"statusInProgress": "In Progress", "statusInProgress": "In Progress",
"statusDone": "Done", "statusDone": "Done",
"statusArchived": "Archived",
"categoryHousehold": "Household", "categoryHousehold": "Household",
"categorySchool": "School", "categorySchool": "School",
"categoryShopping": "Shopping", "categoryShopping": "Shopping",
@@ -167,6 +168,7 @@
"kanbanOpen": "Open", "kanbanOpen": "Open",
"kanbanInProgress": "In Progress", "kanbanInProgress": "In Progress",
"kanbanDone": "Done", "kanbanDone": "Done",
"kanbanArchived": "Archived",
"kanbanMoveToInProgress": "Set to in progress", "kanbanMoveToInProgress": "Set to in progress",
"kanbanMoveToDone": "Mark as done", "kanbanMoveToDone": "Mark as done",
"kanbanMoveToOpen": "Reopen", "kanbanMoveToOpen": "Reopen",
@@ -179,7 +181,8 @@
"filterGroupPriority": "Priority", "filterGroupPriority": "Priority",
"filterGroupStatus": "Status", "filterGroupStatus": "Status",
"swipedDoneToast": "Marked as done.", "swipedDoneToast": "Marked as done.",
"swipedOpenToast": "Marked as open." "swipedOpenToast": "Marked as open.",
"reminderNeedsDueDate": "Set a due date to enable task reminders."
}, },
"shopping": { "shopping": {
"title": "Shopping", "title": "Shopping",
+4 -1
View File
@@ -131,6 +131,7 @@
"statusOpen": "Aberto", "statusOpen": "Aberto",
"statusInProgress": "Em andamento", "statusInProgress": "Em andamento",
"statusDone": "Concluído", "statusDone": "Concluído",
"statusArchived": "Arquivado",
"categoryHousehold": "Casa", "categoryHousehold": "Casa",
"categorySchool": "Escola", "categorySchool": "Escola",
"categoryShopping": "Compras", "categoryShopping": "Compras",
@@ -167,6 +168,7 @@
"kanbanOpen": "Aberto", "kanbanOpen": "Aberto",
"kanbanInProgress": "Em andamento", "kanbanInProgress": "Em andamento",
"kanbanDone": "Concluído", "kanbanDone": "Concluído",
"kanbanArchived": "Arquivado",
"kanbanMoveToInProgress": "Mover para em andamento", "kanbanMoveToInProgress": "Mover para em andamento",
"kanbanMoveToDone": "Marcar como concluído", "kanbanMoveToDone": "Marcar como concluído",
"kanbanMoveToOpen": "Reabrir", "kanbanMoveToOpen": "Reabrir",
@@ -179,7 +181,8 @@
"filterGroupPriority": "Prioridade", "filterGroupPriority": "Prioridade",
"filterGroupStatus": "Estado", "filterGroupStatus": "Estado",
"swipedDoneToast": "Marcado como concluído.", "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": { "shopping": {
"title": "Compras", "title": "Compras",
+87 -17
View File
@@ -30,6 +30,7 @@ const STATUSES = () => [
{ value: 'open', label: t('tasks.statusOpen') }, { value: 'open', label: t('tasks.statusOpen') },
{ value: 'in_progress', label: t('tasks.statusInProgress') }, { value: 'in_progress', label: t('tasks.statusInProgress') },
{ value: 'done', label: t('tasks.statusDone') }, { value: 'done', label: t('tasks.statusDone') },
{ value: 'archived', label: t('tasks.statusArchived') },
]; ];
const CATEGORIES = [ const CATEGORIES = [
@@ -376,7 +377,7 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
${renderRRuleFields('task', task?.recurrence_rule)} ${renderRRuleFields('task', task?.recurrence_rule)}
${renderReminderSection(reminder)} ${renderReminderSection(task, reminder)}
<div id="task-form-error" class="login-error" hidden></div> <div id="task-form-error" class="login-error" hidden></div>
@@ -446,10 +447,36 @@ async function loadReminderForTask(taskId) {
} }
} }
function renderReminderSection(reminder = null) { function parseOffsetMsFromReminder(task, reminder) {
const hasReminder = !!reminder; if (!task?.due_date || !reminder?.remind_at) return null;
const remindDate = hasReminder ? reminder.remind_at.slice(0, 10) : ''; const due = task.due_time ? new Date(`${task.due_date}T${task.due_time}`) : new Date(`${task.due_date}T23:59:59`);
const remindTime = hasReminder ? reminder.remind_at.slice(11, 16) : ''; 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 ` return `
<div class="reminder-section"> <div class="reminder-section">
@@ -462,12 +489,33 @@ function renderReminderSection(reminder = null) {
</div> </div>
<div id="reminder-fields" class="reminder-fields" ${hasReminder ? '' : 'style="display:none"'}> <div id="reminder-fields" class="reminder-fields" ${hasReminder ? '' : 'style="display:none"'}>
<div class="form-group" style="margin:0"> <div class="form-group" style="margin:0">
<label class="label" for="reminder-date">${t('reminders.dateLabel')}</label> <label class="label" for="reminder-offset">${t('reminders.offsetLabel')}</label>
<input class="input js-date-input" type="text" id="reminder-date" value="${formatDateInput(remindDate)}" placeholder="${dateInputPlaceholder()}" inputmode="numeric"> <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>
<div class="form-group" style="margin:0"> <div class="modal-grid modal-grid--2" id="reminder-custom-fields" style="${showCustom ? '' : 'display:none'};margin-top:var(--space-3)">
<label class="label" for="reminder-time">${t('reminders.timeLabel')}</label> <div class="form-group" style="margin:0">
<input class="input" type="time" id="reminder-time" value="${remindTime || '08:00'}"> <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> </div>
</div>`; </div>`;
@@ -493,9 +541,15 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai
// Reminder-Toggle: Felder ein-/ausblenden // Reminder-Toggle: Felder ein-/ausblenden
const toggle = panel.querySelector('#reminder-toggle'); const toggle = panel.querySelector('#reminder-toggle');
const fields = panel.querySelector('#reminder-fields'); const fields = panel.querySelector('#reminder-fields');
const offset = panel.querySelector('#reminder-offset');
const customFields = panel.querySelector('#reminder-custom-fields');
toggle?.addEventListener('change', () => { toggle?.addEventListener('change', () => {
fields.style.display = toggle.checked ? '' : 'none'; 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) => { panel.querySelectorAll('.js-date-input').forEach((input) => {
input.addEventListener('blur', () => { input.addEventListener('blur', () => {
const parsed = parseDateInput(input.value); const parsed = parseDateInput(input.value);
@@ -537,9 +591,7 @@ async function handleFormSubmit(e, container) {
const dueDate = parseDateInput(dueDateRaw); const dueDate = parseDateInput(dueDateRaw);
const rrule = getRRuleValues(document, 'task'); const rrule = getRRuleValues(document, 'task');
const reminderToggle = form.querySelector('#reminder-toggle'); const reminderToggle = form.querySelector('#reminder-toggle');
const reminderDateRaw = form.querySelector('#reminder-date')?.value || ''; if (!isDateInputValid(dueDateRaw) || !rrule.valid_until) {
const reminderDate = parseDateInput(reminderDateRaw);
if (!isDateInputValid(dueDateRaw) || !rrule.valid_until || (reminderToggle?.checked && !isDateInputValid(reminderDateRaw))) {
errorEl.textContent = t('calendar.invalidDate'); errorEl.textContent = t('calendar.invalidDate');
errorEl.hidden = false; errorEl.hidden = false;
submitBtn.disabled = false; submitBtn.disabled = false;
@@ -572,10 +624,27 @@ async function handleFormSubmit(e, container) {
// Erinnerung speichern oder löschen // Erinnerung speichern oder löschen
if (savedTaskId) { if (savedTaskId) {
const reminderTime = form.querySelector('#reminder-time')?.value || '08:00'; if (reminderToggle?.checked) {
if (!dueDate) throw new Error(t('tasks.reminderNeedsDueDate'));
if (reminderToggle?.checked && reminderDate) { const dueDateTime = body.due_time ? new Date(`${dueDate}T${body.due_time}`) : new Date(`${dueDate}T23:59:59`);
const remindAt = `${reminderDate}T${reminderTime}`; 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 }); await api.post('/reminders', { entity_type: 'task', entity_id: savedTaskId, remind_at: remindAt });
refreshReminders(); refreshReminders();
} else if (!reminderToggle?.checked) { } else if (!reminderToggle?.checked) {
@@ -643,6 +712,7 @@ const KANBAN_COLS = () => [
{ status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' }, { status: 'open', label: t('tasks.kanbanOpen'), colorVar: '--color-text-secondary' },
{ status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' }, { status: 'in_progress', label: t('tasks.kanbanInProgress'), colorVar: '--color-warning' },
{ status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' }, { status: 'done', label: t('tasks.kanbanDone'), colorVar: '--color-success' },
{ status: 'archived', label: t('tasks.kanbanArchived'), colorVar: '--color-text-tertiary' },
]; ];
function kanbanNextStatus(status) { 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> <span class="rrule-interval-unit" id="${prefix}-rrule-unit">${unitLabel(parsed.freq, parsed.interval)}</span>
</div> </div>
</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>
<div class="rrule-weekdays" id="${prefix}-rrule-weekdays" ${parsed.freq === 'WEEKLY' ? '' : 'hidden'}> <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 class="rrule-day-grid">${dayBtns}</div>
</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>
</div> </div>
`; `;
+6
View File
@@ -1688,6 +1688,7 @@
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: var(--space-3); gap: var(--space-3);
flex-wrap: wrap;
} }
.rrule-interval-wrap { .rrule-interval-wrap {
@@ -1702,6 +1703,11 @@
white-space: nowrap; white-space: nowrap;
} }
.rrule-until-field {
flex: 1;
min-width: 220px;
}
.rrule-day-grid { .rrule-day-grid {
display: flex; display: flex;
gap: var(--space-1); gap: var(--space-1);
+4 -4
View File
@@ -13,10 +13,10 @@
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
*/ */
const SHELL_CACHE = 'oikos-shell-v65'; const SHELL_CACHE = 'oikos-shell-v66';
const PAGES_CACHE = 'oikos-pages-v60'; const PAGES_CACHE = 'oikos-pages-v61';
const LOCALES_CACHE = 'oikos-locales-v9'; const LOCALES_CACHE = 'oikos-locales-v10';
const ASSETS_CACHE = 'oikos-assets-v60'; const ASSETS_CACHE = 'oikos-assets-v61';
const BYPASS_CACHE = 'oikos-bypass-flag'; const BYPASS_CACHE = 'oikos-bypass-flag';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE]; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];