71 lines
15 KiB
JavaScript
71 lines
15 KiB
JavaScript
/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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"><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"></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 || '', done: false, createdAt: new Date().toISOString() });
|
||
document.querySelector('#modal').innerHTML = ''; persist(data); toast('tasks.goalCreated');
|
||
};
|
||
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}.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}.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: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);
|
||
})();
|