Tasks: a due time with a reminder, and Today and Upcoming views
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d761020869
commit
fe651a8c37
6 files changed
+43
-20
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v105';
|
||||
const CACHE = 'hub-v106';
|
||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||
|
||||
|
||||
+16
-15
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
// ---- Tasks: NE Hub lists plus any iCloud reminder lists the account exposes
|
||||
const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
|
||||
const dueInfo = (d, tm) => { if (!d) return null; const at = tm ? ' · ' + new Date('2000-01-01T' + tm).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return d < t ? { text: 'Overdue · ' + label + at, color: 'var(--danger)' } : d === t ? { text: 'Today' + at, color: 'var(--accent)' } : { text: label + at, color: 'var(--muted)' }; };
|
||||
const taskStore = []; // tasks on screen, so a tap can open the right one
|
||||
let taskLists = []; // the lists (NE Hub and iCloud), so a task seen in "All" opens with its own list's options
|
||||
|
||||
@@ -13,11 +13,11 @@ async function viewTasks() {
|
||||
if (lists.some(l => l.mealie) && allowed('recipes')) { try { await jfetch('/api/recipes/shopping/sync', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); } catch {} } // (the Shopping list is shared with Mealie: bring it up to date first)
|
||||
if (!location.hash.startsWith('#/tasks')) return;
|
||||
taskLists = lists;
|
||||
let cur = store.get('task_list'); if (cur !== 'all' && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together)
|
||||
const list = cur === 'all' ? lists.find(l => l.id === 'local:inbox') || lists.find(l => l.local) || lists[0] : lists.find(l => l.id === cur); // (where a task typed into All goes)
|
||||
let cur = store.get('task_list'); if (!['all', 'today', 'upcoming'].includes(cur) && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together)
|
||||
const list = ['all', 'today', 'upcoming'].includes(cur) ? lists.find(l => l.id === 'local:inbox') || lists.find(l => l.local) || lists[0] : lists.find(l => l.id === cur); // (where a task typed into All goes)
|
||||
v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
|
||||
<div class="seg">${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
|
||||
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${cur === 'all' ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
|
||||
<div class="seg"><a href="#" data-l="today" class="${cur === 'today' ? 'on' : ''}">Today</a><a href="#" data-l="upcoming" class="${cur === 'upcoming' ? 'on' : ''}">Upcoming</a>${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
|
||||
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${['all', 'today', 'upcoming'].includes(cur) ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
|
||||
<div id="tl"><p class="empty">Loading…</p></div>
|
||||
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
|
||||
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
|
||||
@@ -26,7 +26,7 @@ async function viewTasks() {
|
||||
$('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); };
|
||||
$('#qa').onsubmit = async e => {
|
||||
e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return;
|
||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: list.id, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
|
||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: list.id, fields: { title: t, notes: '', due: cur === 'today' ? ymd(new Date()) : '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
|
||||
catch (err) { toast(err.message); }
|
||||
};
|
||||
loadTaskList(cur, list);
|
||||
@@ -36,23 +36,23 @@ async function viewTasks() {
|
||||
async function loadTaskList(cur, list) {
|
||||
const box = $('#tl'); if (!box) return;
|
||||
try {
|
||||
const all = cur === 'all', byId = Object.fromEntries(taskLists.map(l => [l.id, l]));
|
||||
const smart = cur === 'today' || cur === 'upcoming', all = cur === 'all' || smart, today = ymd(new Date()), byId = Object.fromEntries(taskLists.map(l => [l.id, l]));
|
||||
const tasks = all ? (await Promise.all(taskLists.map(l => jfetch('/api/tasks?list=' + encodeURIComponent(l.id)).then(r => r.map(t => ({ ...t, list: l.id, _ln: l.name + (l.icloud ? ' (iCloud)' : '') }))).catch(() => []))))
|
||||
.flat() : await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
|
||||
if (!$('#tl')) return;
|
||||
taskStore.length = 0;
|
||||
const ids = new Set(tasks.map(t => t.id)), byParent = {};
|
||||
tasks.forEach(t => { if (t.parent && ids.has(t.parent)) (byParent[t.parent] = byParent[t.parent] || []).push(t); });
|
||||
const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title);
|
||||
const open = tasks.filter(t => top(t) && !t.done).sort(byDue);
|
||||
const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
|
||||
const top = t => !t.parent || !ids.has(t.parent), byDue = (a, b) => (a.done - b.done) || (a.due || '9999').localeCompare(b.due || '9999') || (a.dueTime || '').localeCompare(b.dueTime || '') || a.title.localeCompare(b.title);
|
||||
const open = tasks.filter(t => top(t) && !t.done && (cur === 'today' ? t.due && t.due <= today : cur === 'upcoming' ? t.due && t.due > today : true)).sort(byDue);
|
||||
const done = smart ? [] : tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
|
||||
const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' };
|
||||
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
|
||||
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due, t.dueTime), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
|
||||
const extra = [all && t._ln ? t._ln : '', t.by && t.list === 'local:family' ? 'Added by ' + t.by : '', rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
|
||||
return `<div class="row it${t.done ? ' donetask' : ''}${sub ? ' subtask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
|
||||
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes || extra ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && (extra || t.notes) ? ' · ' : ''}${esc(extra)}${extra && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
|
||||
const showDone = store.get('task_show_done', false);
|
||||
box.innerHTML = (open.length ? `<div class="list">${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : '<p class="empty">Nothing to do. Add a task above.</p>')
|
||||
box.innerHTML = (open.length ? `<div class="list">${open.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : (cur === 'today' ? '<p class="empty">Nothing due today. Enjoy the day.</p>' : cur === 'upcoming' ? '<p class="empty">Nothing coming up with a due date.</p>' : '<p class="empty">Nothing to do. Add a task above.</p>'))
|
||||
+ (done.length ? `<p style="margin:18px 0 6px"><a href="#" id="sd" style="color:var(--accent)">${showDone ? 'Hide' : 'Show'} completed (${done.length})</a></p>${showDone ? `<div class="list">${done.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : ''}` : '');
|
||||
const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
|
||||
box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
|
||||
@@ -68,6 +68,7 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task
|
||||
const redraw = () => loadTaskList(viewCur || cur, viewList || list);
|
||||
const el = openSheet('Task', list.name, `<form id="tf"><input type="text" id="tt" value="${esc(t.title)}" autocomplete="off" required style="margin-top:6px">
|
||||
<label for="tdue">Due date</label><div style="display:flex;gap:8px"><input type="date" id="tdue" value="${esc(t.due)}" style="flex:1"><button type="button" class="btn ghost" id="tclr">Clear</button></div>
|
||||
${list.local ? `<label for="tdt">Remind me at</label><input type="time" id="tdt" value="${esc(t.dueTime || '')}"><p class="sub" style="margin:4px 0 0">Optional. Needs a due date; you get a notification at that time.</p>` : ''}
|
||||
<label for="tnote">Notes</label><textarea id="tnote" rows="3">${esc(t.notes)}</textarea>
|
||||
${list.local ? `<label for="trep">Repeat</label><select id="trep">${[['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']].map(([v, l]) => `<option value="${v}" ${(t.repeat || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<p class="sub" style="margin:4px 0 0">A repeating task moves to its next date when you complete it.</p>` : ''}
|
||||
@@ -76,10 +77,10 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task
|
||||
<div style="display:flex;gap:8px;margin-top:6px"><input type="text" id="tsub" placeholder="Add a subtask" autocomplete="off" style="flex:1"><button type="button" class="btn ghost" id="tsubadd">Add</button></div>` : ''}
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div>
|
||||
<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="td" style="flex:1;justify-content:center">Delete task</button></div><div class="err" id="te"></div></form>`);
|
||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; };
|
||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; const tt = el.querySelector('#tdt'); if (tt) tt.value = ''; };
|
||||
el.querySelector('#tf').onsubmit = async e => {
|
||||
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true;
|
||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); redraw(); }
|
||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, ...(el.querySelector('#tdt') ? { dueTime: el.querySelector('#tdt').value } : {}), done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); redraw(); }
|
||||
catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; }
|
||||
};
|
||||
if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => {
|
||||
|
||||
Reference in new issue
Block a user