Files
Life-OS/public/tasks.js
T

74 lines
19 KiB
JavaScript
Raw 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(lang() === 'zh' ? 'zh-CN' : 'en-US', { 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-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="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><button class="goal-finish" onclick="setGoalDone('${esc(goal.id)}',false)">${t('goalReopen')}</button></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;
const data = board();
data.tasks.push({ id: `task_${crypto.randomUUID()}`, title, dueDate: form.querySelector('#task-new-date').value || '', goalId: form.querySelector('#task-new-goal').value || '', priority: form.querySelector('#task-new-priority').value || 'normal', done: false, createdAt: new Date().toISOString() });
persist(data); toast('tasks.taskAdded');
};
window.toggleTask = id => { const data = board(), task = data.tasks.find(item => item.id === id); if (!task) return; task.done = !task.done; task.completedAt = task.done ? new Date().toISOString() : null; persist(data); };
window.deleteTask = id => { const data = board(); data.tasks = data.tasks.filter(item => item.id !== id); persist(data); toast('tasks.taskRemoved'); };
window.openGoalForm = () => {
document.querySelector('#modal').innerHTML = `<div class="modal" onclick="if(event.target===this) closeModal()"><form class="modalbox goal-form" 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('goalTitle')}</h2><label>${t('goalPlaceholder')}<input id="goal-new-title" maxlength="100" required autofocus></label><label>${t('goalDescription')}<textarea id="goal-new-description" maxlength="240" rows="3"></textarea></label><label>${t('target')}<input id="goal-new-date" type="date" oninput="document.querySelector('#goal-new-desktop').disabled=!this.value"></label><label class="goal-pin-option"><input id="goal-new-desktop" type="checkbox" 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 title = document.querySelector('#goal-new-title').value.trim(); if (!title) return;
const data = board(); data.goals.unshift({ id: `goal_${crypto.randomUUID()}`, title, description: document.querySelector('#goal-new-description').value.trim(), targetDate: document.querySelector('#goal-new-date').value || '', showOnDesktop: document.querySelector('#goal-new-desktop').checked, done: false, createdAt: new Date().toISOString() });
document.querySelector('#modal').innerHTML = ''; persist(data); toast('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) => { const data = board(), goal = data.goals.find(item => item.id === id); if (!goal) return; goal.done = done; goal.completedAt = done ? new Date().toISOString() : null; persist(data); };
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 => { const data = board(); data.goals = data.goals.filter(goal => goal.id !== id); data.tasks.forEach(task => { if (task.goalId === id) task.goalId = ''; }); document.querySelector('#modal').innerHTML = ''; persist(data); 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{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{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-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);
})();