Files

85 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Goals and tasks are stored inside the existing synced Life OS system record. */
(() => {
const t = (key, values) => LifeI18n.t(`tasks.${key}`, values);
let filter = 'all';
const esc = value => String(value || '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
const board = () => {
const state = sys();
if (!state.tasksBoard || !Array.isArray(state.tasksBoard.goals) || !Array.isArray(state.tasksBoard.tasks)) state.tasksBoard = { goals: [], tasks: [] };
return state.tasksBoard;
};
const persist = data => { const state = sys(); state.tasksBoard = data; save('lifeos_system', state); renderAll(); };
const dateLabel = value => value ? new Intl.DateTimeFormat(LifeI18n.locale(), { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(`${value}T12:00:00`)) : t('noDate');
const isOverdue = task => !task.done && task.dueDate && task.dueDate < day();
function renderTasks(container) {
const data = board(), open = data.tasks.filter(task => !task.done).length;
const todayTasks = data.tasks.filter(task => !task.done && task.dueDate === day()).length;
const activeGoals = data.goals.filter(goal => !goal.done), finishedGoals = data.goals.filter(goal => goal.done);
const visible = data.tasks.filter(task => {
if (filter === 'today') return !task.done && (!task.dueDate || task.dueDate <= day());
if (filter === 'upcoming') return !task.done && task.dueDate > day();
if (filter === 'done') return task.done;
return !task.done;
}).sort((a, b) => Number(a.done) - Number(b.done) || (a.dueDate || '9999').localeCompare(b.dueDate || '9999') || b.createdAt.localeCompare(a.createdAt));
const goalOptions = data.goals.filter(goal => !goal.done).map(goal => `<option value="${esc(goal.id)}">${esc(goal.title)}</option>`).join('');
container.innerHTML = `<div class="eyebrow">${t('eyebrow')}</div><header class="task-head"><div><h1>${t('title')}</h1><p class="sub">${t('subtitle')}</p></div><button class="secondary" onclick="openGoalForm()">+ ${t('addGoal')}</button></header>
<section class="task-focus"><div><div class="eyebrow">${t('todayHeading')}</div><strong>${todayTasks}</strong><span>${t('openCount', { count: open })}</span></div><div class="task-focus-mark">${todayTasks ? '↗' : '✓'}</div></section>
<form class="task-quick" onsubmit="addTask(event)"><label class="sr-only" for="task-new-title">${t('taskPlaceholder')}</label><input id="task-new-title" maxlength="140" required placeholder="${t('taskPlaceholder')}"><input aria-label="${t('due')}" type="date" id="task-new-date"><select aria-label="${t('goal')}" id="task-new-goal"><option value="">${t('anyGoal')}</option>${goalOptions}</select><select aria-label="${t('priority.normal')}" id="task-new-priority"><option value="normal">${t('priority.normal')}</option><option value="high">${t('priority.high')}</option><option value="low">${t('priority.low')}</option></select><button class="primary" type="submit">+ ${t('addTask')}</button></form>
<nav class="task-filters" aria-label="${t('eyebrow')}">${['all','today','upcoming','done'].map(key => `<button class="chip ${filter === key ? 'selected' : ''}" aria-pressed="${filter === key}" onclick="setTaskFilter('${key}')">${t(key)}</button>`).join('')}</nav>
<section class="task-list" aria-live="polite">${visible.length ? visible.map(task => {
const goal = data.goals.find(item => item.id === task.goalId);
return `<article class="task-row ${task.done ? 'is-done' : ''} ${isOverdue(task) ? 'is-overdue' : ''}"><button class="task-check" type="button" aria-label="${task.done ? t('reopen') : t('complete')}: ${esc(task.title)}" aria-pressed="${task.done}" onclick="toggleTask('${esc(task.id)}')">${task.done ? '✓' : ''}</button><div class="task-copy"><strong>${esc(task.title)}</strong><div class="task-meta"><span class="task-priority ${esc(task.priority)}">${t(`priority.${task.priority}`)}</span>${goal ? `<span>${esc(goal.title)}</span>` : ''}<span>${isOverdue(task) ? `<b>${t('overdue')} · </b>` : ''}${task.dueDate ? t('due', { date: dateLabel(task.dueDate) }) : t('noDate')}</span></div></div><button class="task-edit" type="button" data-id="${esc(task.id)}" aria-label="${t('editTask')}: ${esc(task.title)}" onclick="openTaskEdit(this.dataset.id)">${t('edit')}</button><button class="task-delete" aria-label="${t('delete')}: ${esc(task.title)}" onclick="deleteTask('${esc(task.id)}')">×</button></article>`;
}).join('') : `<div class="task-empty"><span>○</span><p>${t('noTasks')}</p></div>`}</section>
<section class="goal-section"><div class="goal-section-head"><div><div class="eyebrow">${t('activeGoals')}</div></div><span class="goal-count">${activeGoals.length}</span></div>${activeGoals.length ? `<div class="goal-grid">${activeGoals.map(goal => {
const linked = data.tasks.filter(task => task.goalId === goal.id), completed = linked.filter(task => task.done).length, progress = linked.length ? Math.round(completed / linked.length * 100) : 0;
return `<article class="goal-card"><div class="goal-card-top"><div class="goal-mark">◎</div><div class="goal-actions">${goal.targetDate ? `<button class="goal-desktop-toggle ${goal.showOnDesktop ? 'selected' : ''}" aria-pressed="${Boolean(goal.showOnDesktop)}" onclick="toggleGoalDesktop('${esc(goal.id)}')">${t(goal.showOnDesktop ? 'desktopHide' : 'desktopShow')}</button>` : ''}<button class="task-edit" type="button" data-id="${esc(goal.id)}" aria-label="${t('editGoal')}: ${esc(goal.title)}" onclick="openGoalForm(this.dataset.id)">${t('edit')}</button><button class="goal-finish" aria-label="${t('goalComplete')}: ${esc(goal.title)}" onclick="setGoalDone('${esc(goal.id)}',true)">${t('goalComplete')}</button><button class="task-delete" aria-label="${t('delete')}: ${esc(goal.title)}" onclick="deleteGoal('${esc(goal.id)}')">×</button></div></div><h3>${esc(goal.title)}</h3>${goal.description ? `<p>${esc(goal.description)}</p>` : ''}<div class="goal-progress-line"><span>${t('progress', { done: completed, total: linked.length })}</span><span>${progress}%</span></div><div class="goal-progress"><i style="width:${progress}%"></i></div>${goal.targetDate ? `<div class="goal-date">${t('target')} · ${dateLabel(goal.targetDate)}</div>` : ''}</article>`;
}).join('')}</div>` : `<div class="goal-empty">${t('noGoals')}</div>`}${finishedGoals.length ? `<details class="finished-goals"><summary>${t('finishedGoals')} · ${finishedGoals.length}</summary><div class="goal-grid">${finishedGoals.map(goal => `<article class="goal-card is-goal-done"><div class="goal-card-top"><div class="goal-mark">✓</div><div class="goal-actions"><button class="task-edit" type="button" data-id="${esc(goal.id)}" aria-label="${t('editGoal')}: ${esc(goal.title)}" onclick="openGoalForm(this.dataset.id)">${t('edit')}</button><button class="goal-finish" onclick="setGoalDone('${esc(goal.id)}',false)">${t('goalReopen')}</button></div></div><h3>${esc(goal.title)}</h3></article>`).join('')}</div></details>` : ''}</section>`;
}
window.tasksApp = renderTasks;
window.setTaskFilter = value => { filter = value; renderAll(); };
window.addTask = event => {
event.preventDefault();
const form = event.currentTarget, input = form.querySelector('#task-new-title'), title = input.value.trim();
if (!title) return;
window.LifeOSActions.createTask({ title, dueDate: form.querySelector('#task-new-date').value || '', goalId: form.querySelector('#task-new-goal').value || '', priority: form.querySelector('#task-new-priority').value || 'normal' }); toast('tasks.taskAdded');
};
window.toggleTask = id => { const task = board().tasks.find(item => item.id === id); if (task) window.LifeOSActions.setTaskDone(id, !task.done); };
window.deleteTask = id => { window.LifeOSActions.deleteTask(id); toast('tasks.taskRemoved'); };
window.openTaskEdit = id => {
const task = board().tasks.find(item => item.id === id); if (!task) return;
const goals = board().goals.map(goal => `<option value="${esc(goal.id)}" ${goal.id === task.goalId ? 'selected' : ''}>${esc(goal.title)}</option>`).join('');
document.querySelector('#modal').innerHTML = `<div class="modal" onclick="if(event.target===this) closeModal()"><form class="modalbox goal-form" data-id="${esc(id)}" onsubmit="saveTaskEdit(event)" role="dialog" aria-modal="true" aria-labelledby="task-edit-title"><button type="button" class="modal-close" onclick="closeModal()" aria-label="${LifeI18n.t('common.close')}">×</button><div class="eyebrow">${t('eyebrow')}</div><h2 id="task-edit-title">${t('editTask')}</h2><label>${t('taskPlaceholder')}<input id="task-edit-title-input" maxlength="140" required value="${esc(task.title)}"></label><label>${t('dueDate')}<input id="task-edit-date" type="date" value="${esc(task.dueDate || '')}"></label><label>${t('goal')}<select id="task-edit-goal"><option value="">${t('anyGoal')}</option>${goals}</select></label><label>${t('priorityLabel')}<select id="task-edit-priority">${['normal','high','low'].map(value => `<option value="${value}" ${task.priority === value ? 'selected' : ''}>${t(`priority.${value}`)}</option>`).join('')}</select></label><div class="actions"><button class="primary" type="submit">${t('save')}</button><button class="secondary" type="button" onclick="closeModal()">${t('cancel')}</button></div></form></div>`;
setTimeout(() => document.querySelector('#task-edit-title-input')?.focus(), 0);
};
window.saveTaskEdit = event => {
event.preventDefault(); const form = event.currentTarget, id = form.dataset.id;
window.LifeOSActions.updateTask(id, { title: form.querySelector('#task-edit-title-input').value, dueDate: form.querySelector('#task-edit-date').value, goalId: form.querySelector('#task-edit-goal').value, priority: form.querySelector('#task-edit-priority').value });
closeModal(); toast('tasks.taskUpdated');
};
window.openGoalForm = (id = '') => {
const goal = id ? board().goals.find(item => item.id === id) : null; if (id && !goal) return;
document.querySelector('#modal').innerHTML = `<div class="modal" onclick="if(event.target===this) closeModal()"><form class="modalbox goal-form" data-id="${esc(id)}" onsubmit="saveGoal(event)" role="dialog" aria-modal="true" aria-labelledby="goal-form-title"><button type="button" class="modal-close" onclick="closeModal()" aria-label="${LifeI18n.t('common.close')}">×</button><div class="eyebrow">${t('eyebrow')}</div><h2 id="goal-form-title">${t(goal ? 'editGoal' : 'goalTitle')}</h2><label>${t('goalPlaceholder')}<input id="goal-new-title" maxlength="100" required value="${esc(goal?.title || '')}"></label><label>${t('goalDescription')}<textarea id="goal-new-description" maxlength="240" rows="3">${esc(goal?.description || '')}</textarea></label><label>${t('target')}<input id="goal-new-date" type="date" value="${esc(goal?.targetDate || '')}" oninput="document.querySelector('#goal-new-desktop').disabled=!this.value;if(!this.value)document.querySelector('#goal-new-desktop').checked=false"></label><label class="goal-pin-option"><input id="goal-new-desktop" type="checkbox" ${goal?.showOnDesktop ? 'checked' : ''} ${goal?.targetDate ? '' : 'disabled'}>${t('pinOnCreate')}</label><div class="actions"><button class="primary" type="submit">${t('save')}</button><button class="secondary" type="button" onclick="closeModal()">${t('cancel')}</button></div></form></div>`;
setTimeout(() => document.querySelector('#goal-new-title')?.focus(), 0);
};
window.saveGoal = event => {
event.preventDefault(); const form = event.currentTarget, title = form.querySelector('#goal-new-title').value.trim(); if (!title) return;
const values = { title, description: form.querySelector('#goal-new-description').value, targetDate: form.querySelector('#goal-new-date').value, showOnDesktop: form.querySelector('#goal-new-desktop').checked };
if (form.dataset.id) window.LifeOSActions.updateGoal(form.dataset.id, values); else window.LifeOSActions.createGoal(values);
closeModal(); toast(form.dataset.id ? 'tasks.goalUpdated' : 'tasks.goalCreated');
};
window.toggleGoalDesktop = id => { const data = board(), goal = data.goals.find(item => item.id === id); if (!goal || !goal.targetDate) return; goal.showOnDesktop = !goal.showOnDesktop; persist(data); };
window.renderDesktopGoalCountdowns = () => { const container = document.querySelector('#desktop-goal-countdowns'); if (!container) return; const goals = board().goals.filter(goal => !goal.done && goal.targetDate && goal.showOnDesktop); if (!goals.length) { container.innerHTML = ''; container.classList.add('hidden'); return; } container.classList.remove('hidden'); const now = new Date(), todayUtc = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); container.innerHTML = `<div class="desktop-goal-heading">${t('countdown')}</div>${goals.map(goal => { const [year, month, date] = goal.targetDate.split('-').map(Number), delta = Math.round((Date.UTC(year, month - 1, date) - todayUtc) / 864e5), count = Math.abs(delta), label = delta === 0 ? t('todayTarget') : delta > 0 ? t('daysLeft', { count }) : t('daysOverdue', { count }); return `<button class="desktop-goal-card" onclick="openApp('tasks')"><span>${esc(goal.title)}</span><strong>${label}</strong><small>${dateLabel(goal.targetDate)}</small></button>`; }).join('')}`; };
setInterval(window.renderDesktopGoalCountdowns, 60000);
window.setGoalDone = (id, done) => { window.LifeOSActions.setGoalDone(id, done); };
window.deleteGoal = id => {
document.querySelector('#modal').innerHTML = `<div class="modal" onclick="if(event.target===this) closeModal()"><div class="modalbox" role="dialog" aria-modal="true"><button class="modal-close" onclick="closeModal()" aria-label="${LifeI18n.t('common.close')}">×</button><div class="eyebrow">${t('eyebrow')}</div><h2>${t('confirmGoalDelete')}</h2><p class="sub">${t('confirmGoalDeleteBody')}</p><div class="actions"><button class="danger" onclick="confirmDeleteGoal('${esc(id)}')">${t('delete')}</button><button class="secondary" onclick="closeModal()">${t('cancel')}</button></div></div></div>`;
};
window.confirmDeleteGoal = id => { window.LifeOSActions.deleteGoal(id); document.querySelector('#modal').innerHTML = ''; toast('tasks.goalRemoved'); };
const style = document.createElement('style');
style.textContent = `.task-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}.task-head h1{max-width:430px}.task-head .secondary{white-space:nowrap}.task-focus{display:flex;align-items:center;justify-content:space-between;margin:18px 0;padding:18px 20px;background:linear-gradient(115deg,color-mix(in srgb,var(--accent) 15%,var(--panel2)),var(--panel2));border:1px solid var(--line);border-radius:13px}.task-focus .eyebrow{margin-bottom:5px}.task-focus strong{font-size:30px;letter-spacing:-.06em}.task-focus span{margin-left:10px;color:var(--muted);font-size:12px}.task-focus-mark{font-size:26px;color:var(--accent)}.task-quick{display:grid;grid-template-columns:minmax(130px,1fr) 145px 120px 95px auto;gap:7px}.task-quick input,.task-quick select,.goal-form input,.goal-form textarea,.goal-form select{min-width:0;border:1px solid var(--line);background:var(--panel2);border-radius:8px;padding:10px 11px;outline:none}.task-quick input:focus,.task-quick select:focus,.goal-form input:focus,.goal-form textarea:focus,.goal-form select:focus{border-color:var(--accent)}.task-quick select{font-size:12px}.task-quick .primary{padding:10px 11px;white-space:nowrap}.task-filters{display:flex;gap:7px;flex-wrap:wrap;margin:19px 0 8px}.task-filters .chip{padding:7px 12px}.task-list{display:grid;gap:7px}.task-row{display:flex;align-items:center;gap:12px;padding:12px;background:color-mix(in srgb,var(--panel2) 55%,transparent);border:1px solid var(--line);border-radius:10px}.task-check{flex:none;width:21px;height:21px;border:1px solid var(--muted);border-radius:7px;background:transparent;color:var(--accent);font-weight:700}.task-check[aria-pressed=true]{background:var(--accent);border-color:var(--accent);color:var(--bg)}.task-copy{min-width:0;flex:1}.task-copy>strong{display:block;font-size:13px;font-weight:550;overflow-wrap:anywhere}.task-meta{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-top:5px;color:var(--muted);font-size:10px}.task-meta span{display:inline-flex;align-items:center;gap:4px}.task-meta b,.is-overdue .task-meta{color:#df8a70}.task-priority{font:9px 'DM Mono';letter-spacing:.05em}.task-priority.high{color:#df8a70}.task-priority.low{color:var(--muted)}.is-done .task-copy>strong{text-decoration:line-through;color:var(--muted)}.task-edit{border:1px solid var(--line);border-radius:7px;background:transparent;color:var(--muted);font:10px 'DM Mono';padding:6px 8px;white-space:nowrap}.task-edit:hover,.task-edit:focus-visible{color:var(--accent);border-color:var(--accent)}.task-delete{width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--muted);font-size:19px;line-height:1}.task-delete:hover{background:var(--panel2);color:var(--ink)}.task-empty,.goal-empty{padding:21px;text-align:center;color:var(--muted);font-size:13px}.task-empty span{font-size:23px;color:var(--accent)}.goal-section{margin-top:24px;padding-top:16px;border-top:1px solid var(--line)}.goal-section-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}.goal-section-head .eyebrow{margin:0}.goal-count{font:11px 'DM Mono';color:var(--muted)}.goal-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.goal-card{min-width:0;padding:14px;border:1px solid var(--line);border-radius:11px;background:color-mix(in srgb,var(--panel2) 55%,transparent)}.goal-card-top{display:flex;justify-content:space-between;align-items:center}.goal-actions{display:flex;align-items:center;gap:4px;flex-wrap:wrap;justify-content:flex-end}.goal-desktop-toggle{border:1px solid var(--line);border-radius:7px;background:transparent;color:var(--muted);font:9px 'DM Mono';padding:6px 8px;white-space:nowrap}.goal-desktop-toggle.selected{color:var(--accent);border-color:var(--accent)}.goal-form label.goal-pin-option{display:flex!important;grid-template-columns:18px 1fr;align-items:center;gap:8px!important}.goal-form label.goal-pin-option input{width:16px;height:16px;min-width:16px;padding:0;accent-color:var(--accent)}.goal-finish{border:0;background:transparent;color:var(--muted);font:9px 'DM Mono';letter-spacing:.04em;padding:5px}.goal-finish:hover{color:var(--accent)}.finished-goals{margin-top:14px}.finished-goals summary{cursor:pointer;color:var(--muted);font:10px 'DM Mono';letter-spacing:.08em;margin-bottom:9px}.is-goal-done{opacity:.72}.goal-mark{font-size:20px;color:var(--accent)}.goal-card h3{margin:8px 0 4px;font-size:15px;letter-spacing:-.025em;overflow-wrap:anywhere}.goal-card p{margin:0 0 12px;color:var(--muted);font-size:11px;line-height:1.5}.goal-progress-line{display:flex;justify-content:space-between;gap:8px;margin-top:11px;font:9px 'DM Mono';color:var(--muted)}.goal-progress{height:4px;background:var(--line);border-radius:5px;margin-top:5px;overflow:hidden}.goal-progress i{display:block;height:100%;background:var(--accent);border-radius:5px}.goal-date{margin-top:10px;color:var(--muted);font-size:10px}.goal-form{display:grid;gap:13px}.goal-form h2{font-size:21px;margin-bottom:0}.goal-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.goal-form textarea{resize:vertical}.goal-form .actions{margin:0}.desktop-goal-countdowns{position:absolute;z-index:1;top:24px;right:28px;width:min(310px,30vw);max-height:72vh;overflow:auto;display:grid;gap:8px}.desktop-goal-countdowns.hidden{display:none}.desktop-goal-heading{color:var(--muted);font:10px 'DM Mono';letter-spacing:.15em;text-align:right}.desktop-goal-card{display:grid;gap:5px;width:100%;padding:13px 15px;text-align:left;background:color-mix(in srgb,var(--panel) 88%,transparent);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);backdrop-filter:blur(12px)}.desktop-goal-card span{font-size:13px;font-weight:600;overflow-wrap:anywhere}.desktop-goal-card strong{color:var(--accent);font:500 13px 'DM Mono'}.desktop-goal-card small{color:var(--muted);font-size:11px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:650px){.desktop-goal-countdowns{position:relative;top:auto;right:auto;width:auto;max-width:none;max-height:30vh;margin:12px 16px 92px;overflow:auto;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start}.desktop-goal-heading{grid-column:1/-1;text-align:left}.desktop-icons{padding:76px 14px 18px;display:flex;flex-wrap:wrap;justify-content:flex-start;gap:16px 4px}.app-icon{flex:0 0 calc(25% - 4px);min-width:0;justify-items:center;font-size:10px;letter-spacing:.04em}.desktop-goal-card{padding:11px 12px}.desktop-goal-card span{font-size:12px}}@media(min-width:651px){.desktop-icons{padding:72px 32px;display:grid;grid-template-columns:96px;justify-items:start;align-content:start;gap:14px}.app-icon{width:96px;justify-items:center;font-size:11px}.desktop-goal-countdowns{top:26px;right:30px;width:min(330px,28vw);max-height:calc(100vh - 110px)}}@media(max-width:700px){.task-quick{grid-template-columns:1fr 1fr}.task-quick input:first-of-type{grid-column:1/-1}.task-quick .primary{grid-column:1/-1}.task-head{align-items:flex-start}.task-head .secondary{font-size:9px;padding:9px 10px}.goal-grid{grid-template-columns:1fr}.task-meta{gap:6px}.task-focus{padding:14px}.task-focus span{display:block;margin:3px 0 0}}`;
document.head.append(style);
})();