diff --git a/README.md b/README.md
index 987e6e6..923d3a9 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
not connected for about a month; the hub stays connected, so this only matters if the stack is switched off.
- **Links**: your own bookmarks page. Add a name, address and optional group; filter as you type; opens in a new tab. Saved with your settings on the server, private to you, same on every device. Only http(s) addresses are accepted.
- **Security and backups**: every response carries `nosniff`, a `frame-ancestors 'self'` policy and a same-origin referrer policy; writes that come from another website are refused; JSON files are written atomically (temp file, then rename). Admins get **Settings -> Storage -> Download a backup** (a .tar.gz of `/data`; keep it private). `/healthz` answers without signing in and the container has a health check. In-app OnlyOffice editing now requires `ONLYOFFICE_JWT_SECRET` (or `ALLOW_UNSIGNED_OO=1` for testing). `npm test` runs every test script; CI also runs `npm audit`.
+- **Planner and grouped menu**: Calendar and Tasks are one menu entry, **Planner**, with a Calendar | Tasks switch at the top (each still needs its own permission in Settings -> People, and `#/calendar` and `#/tasks` still work as addresses). Tasks with a due date appear on the calendar (month chips, week/day all-day strip, agenda) with a tick to complete them; tapping one opens the task. The menu, the phone "More" sheet and the Home tiles are grouped by kind: Work (Planner, Files, Office, Links), Communicate (Mail, Signal), Media (Music, Audiobooks, Podcast, Podcast Admin), with Home/Search at the top and Settings at the bottom.
- **Command palette and quick add**: press **Ctrl/Cmd-K** anywhere (or the Command palette button on Home) to jump to any page, create things, open a saved link or find a task/event. Typing a sentence offers **Add task**, and understands a date at the end ("Call mom tomorrow", "Pay rent in 3 days", "Dentist fri", "2026-12-25"). **Quick add** on Home does the same plus new event and new link. Search also finds your links.
- **Tasks**: repeating tasks (daily, weekdays, weekly, monthly, yearly; finishing one moves it to its next date) and one level of subtasks, in NE Hub lists (iCloud reminder lists stay as Apple stores them).
- **Calendar**: Month, Week, Day and Agenda. Week and Day show a time grid with overlapping events side by side, an all-day strip and a "now" line; tap an empty slot to add an event.
diff --git a/public/app.js b/public/app.js
index 786782b..c3b637b 100644
--- a/public/app.js
+++ b/public/app.js
@@ -37,7 +37,7 @@ async function loadMe() {
}
// Signal is for desktop-class screens only (a window at least 900 px wide with a mouse or trackpad), and needs the server to have it set up.
const sgMQ = matchMedia('(min-width: 900px) and (pointer: fine)');
-const allowed = id => (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
+const allowed = id => id === 'planner' ? (allowed('calendar') || allowed('tasks')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
async function loadSettings() {
try {
@@ -130,9 +130,18 @@ const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: '
// ---- Navigation
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
-const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link' };
+const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
+/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry (each part still needs its own permission). */
+const navTabs = () => { const out = []; let done = false; for (const t of TABS) { if (!allowed(t[0])) continue; if (t[0] === 'tasks' || t[0] === 'calendar') { if (!done) { out.push(['planner', 'Planner']); done = true; } continue; } out.push(t); } return out; };
+/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
+const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['music', 'audio', 'podcast', 'verse']], ['', ['settings']]];
+const navGroups = (tabs = navTabs()) => {
+ const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
+ const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
+ return out.filter(g => g.tabs.length);
+};
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
-const PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
+const PRIMARY = ['home', 'files', 'planner', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
const phone = matchMedia('(max-width: 767px)');
let activeTab = 'home';
@@ -143,26 +152,27 @@ function renderNav(active = activeTab) {
const badge = n => n ? `${n > 99 ? '99+' : n} ` : '';
const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label} ${id === 'signal' ? badge(sg.unread) : id === 'mail' ? badge(mailUnread) : ''} `;
const brand = `
NE Hub `;
- const mine = TABS.filter(t => allowed(t[0]));
+ const mine = navTabs();
if (phone.matches) {
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
(rest.length ? `${icon('more')}More ${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''} ` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
} else {
- $('#nav').innerHTML = brand + mine.map(tab).join('') + `
${icon('back')}Collapse `;
+ $('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `${g.label ? `${esc(g.label)} ` : ''}
` : '') + g.tabs.map(tab).join('')).join('') + `
${icon('back')}Collapse `;
$('#navcol').onclick = () => setNav({ mini: !navPref().mini });
}
}
phone.addEventListener('change', () => renderNav());
function openMore() {
- const el = openSheet('More', '', ``);
+ const rest = navTabs().filter(t => !PRIMARY.includes(t[0]));
+ const rowOf = ([id, label]) => `${icon(TAB_ICON[id])}${label} ${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `${id === 'signal' ? sg.unread : mailUnread} ` : ''}${icon('chev', 'chev')} `;
+ const el = openSheet('More', '', navGroups(rest).map(g => `${g.label ? `${esc(g.label)} ` : ''}${g.tabs.map(rowOf).join('')}
`).join(''));
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
-const routesOf = () => ({ home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
+const routesOf = () => ({ planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
@@ -206,7 +216,7 @@ function route() {
document.body.classList.remove('inedit', 'onaudio');
sg.timers.forEach(clearInterval); sg.timers = []; // (Signal screens start their own)
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
- renderNav(name === 'edit' ? 'files' : name);
+ renderNav(name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : name);
drawNav();
routes[name](rest.map(decodeURIComponent));
sgDockSync(name);
@@ -219,6 +229,23 @@ function tile(href, color, ic, title, sub, external) {
}
// ---- Views
+/** The Home app tiles, grouped by kind like the menu. */
+function homeApps() {
+ const T = {
+ files: () => tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile'),
+ music: () => tile('#/music', '#e5a00d', 'music', 'Music', 'Plex'),
+ planner: () => tile('#/planner', '#d9453a', 'cal', 'Planner', allowed('calendar') && allowed('tasks') ? 'Calendar & tasks' : allowed('tasks') ? 'To-do lists' : 'Agenda'),
+ office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
+ audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
+ podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
+ verse: () => cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : '',
+ signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'),
+ links: () => tile('#/links', '#0f9d9a', 'link', 'Links', (store.get('links', []) || []).length + ' saved'),
+ mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
+ };
+ return navGroups().filter(g => g.tabs.some(t => T[t[0]])).map(g => `${esc(g.label || 'Apps')} ${g.tabs.map(t => T[t[0]] ? T[t[0]]() : '').join('')}
`).join('');
+}
+
function viewHome() {
const h = new Date().getHours();
const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
@@ -231,19 +258,7 @@ function viewHome() {
${recents.length && allowed('files') ? `Recent files ${recents.map((r, i) => `${icon('file')} ${esc(r.name)} ${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')} `).join('')}
` : ''}
- Apps
- ${allowed('files') ? tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile') : ''}
- ${allowed('music') ? tile('#/music', '#e5a00d', 'music', 'Music', 'Plex') : ''}
- ${allowed('tasks') ? tile('#/tasks', '#4f7cff', 'check', 'Tasks', 'To-do lists') : ''}
- ${allowed('calendar') ? tile('#/calendar', '#d9453a', 'cal', 'Calendar', 'Agenda') : ''}
- ${allowed('office') ? tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice') : ''}
- ${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen') : ''}
- ${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
- ${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
- ${allowed('signal') ? tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages') : ''}
- ${allowed('links') ? tile('#/links', '#0f9d9a', 'link', 'Links', (store.get('links', []) || []).length + ' saved') : ''}
- ${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com') : ''}
-
+ ${homeApps()}
${allowed('mail') && cfg.mail ? '
' : ''}`;
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
diff --git a/public/calendar-views.js b/public/calendar-views.js
index 3689cbb..8770c43 100644
--- a/public/calendar-views.js
+++ b/public/calendar-views.js
@@ -38,7 +38,7 @@ async function renderTimeGrid(nDays) {
${icon('back')} Today
${days.map(d => `${d.toLocaleDateString([], { weekday: 'short' })} ${d.getDate()} `).join('')}
- ${allDay.some(a => a.length) ? `
all day ${allDay.map(a => `
${a.map(e => { const i = evStore.push(e) - 1; return `${esc(e.title)} `; }).join('')}
`).join('')}
` : ''}
+ ${allDay.some(a => a.length) ? `
all day ${allDay.map(a => `
${a.map(e => { const i = evStore.push(e) - 1; return `${e.task ? '☐ ' : ''}${esc(e.title)} `; }).join('')}
`).join('')}
` : ''}
${hours}
${days.map((d, i) => `
${cols[i]}${ymd(d) === today ? ` ` : ''}
`).join('')}
${errors.map(e => `${esc(e)}
`).join('')}`;
const body = $('#tgbody'); if (body) { const wrap = body.parentElement.parentElement; wrap.scrollTop = Math.max(0, ((days.some(d => ymd(d) === today) ? now.getHours() - 1 : 7) * HOUR_PX)); }
diff --git a/public/calendar.js b/public/calendar.js
index 85ef361..390199e 100644
--- a/public/calendar.js
+++ b/public/calendar.js
@@ -1,11 +1,12 @@
'use strict';
// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server)
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
-async function loadRange(start, days) {
+async function loadRange(start, days, withTasks = true) {
const end = new Date(start); end.setDate(end.getDate() + days);
const last = new Date(end.getTime() - 1);
const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) });
- const { events, errors } = await jfetch('/api/cal/events?' + q);
+ const [{ events, errors }, due] = await Promise.all([jfetch('/api/cal/events?' + q),
+ withTasks && allowed('tasks') ? jfetch(`/api/tasks/due?from=${ymd(start)}&to=${ymd(last)}`).catch(() => []) : []]);
const byDay = new Map();
const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); };
for (const e of events) {
@@ -13,16 +14,25 @@ async function loadRange(start, days) {
for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); }
} else put(ymd(new Date(e.start)), e);
}
- for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start)));
+ for (const t of due) { // tasks with a due date show up on that day, after the all-day events
+ if (!byDay.has(t.due)) byDay.set(t.due, []);
+ byDay.get(t.due).push({ task: true, t, title: t.title, color: '#8a93a6', allDay: true, start: t.due, end: t.due, cal: t.listName || 'Tasks', editable: true });
+ }
+ for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || (!!a.task - !!b.task) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start)));
return { byDay, errors, start };
}
-const loadAgenda = days => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days); };
+const loadAgenda = (days, withTasks = true) => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days, withTasks); };
const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const evStore = []; // events currently on screen, so a tap can open the right one
const evRow = e => { const i = evStore.push(e) - 1;
+ if (e.task) return `${icon('check')} ${esc(e.title)} Task due${e.t.repeat && e.t.repeat !== 'none' ? ' · repeats' : ''} · ${esc(e.cal)}
`;
return `${esc(e.title)}
${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''} ${e.editable ? icon('chev', 'chev') : ''} `; };
-document.addEventListener('click', e => { const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]); });
+document.addEventListener('click', e => {
+ const tk = e.target.closest('[data-tt]');
+ if (tk) { const ev = evStore[+tk.dataset.tt]; tk.disabled = true; jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: ev.t.list, id: ev.t.id, href: ev.t.href, done: true }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); route(); }).catch(err => { tk.disabled = false; toast(err.message); }); return; }
+ const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]);
+});
const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
@@ -30,7 +40,7 @@ async function renderAgenda(el, mode) { // Home card (today only)
if (!el) return;
try {
const cals = await jfetch('/api/cal/list');
- const { byDay } = await loadAgenda(1);
+ const { byDay } = await loadAgenda(1, false); // (the Tasks card on Home already covers what is due)
const list = byDay.get(ymd(new Date())) || [];
if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
if (!document.body.contains(el)) return;
@@ -44,7 +54,7 @@ const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) };
async function viewCalendar() {
const mode = store.get('cal_view', 'month');
const v = $('#view');
- v.innerHTML = `Calendar
+ v.innerHTML = `
${plannerHead('calendar')}
${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `
${l} `).join('')}
${icon('cal')}Add event
Manage calendars
`;
@@ -83,7 +93,7 @@ async function renderMonth() {
cells.push(`
${d.getDate()}
${evs.slice(0, 4).map(e => ` `).join('')}
- ${evs.slice(0, 3).map(e => `${esc(e.title)} `).join('')}${evs.length > 3 ? `+${evs.length - 3} more ` : ''} `);
+
${evs.slice(0, 3).map(e => `${e.task ? '☐ ' : ''}${esc(e.title)} `).join('')}${evs.length > 3 ? `+${evs.length - 3} more ` : ''} `);
}
$('#cbody').innerHTML = `
${icon('back')}
${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}
@@ -109,6 +119,12 @@ const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.get
function openEvent(e) {
if (!e) return;
+ if (e.task) { // a task on the calendar: the normal task sheet, and the calendar refreshes when it closes
+ taskSheet({ ...e.t, repeat: e.t.repeat || 'none' }, e.t.list, { name: e.cal, local: String(e.t.list).startsWith('local:') });
+ const sh = document.getElementById('sheet'), mo = new MutationObserver(() => { if (!document.getElementById('sheet')) { mo.disconnect(); if (/^#\/calendar/.test(location.hash)) route(); } });
+ if (sh) mo.observe(document.body, { childList: true });
+ return;
+ }
if (e.editable) return editEvent(e);
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
const el = openSheet(e.title, e.cal, `
When ${esc(when)}
diff --git a/public/index.html b/public/index.html
index d890972..15ec87c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -55,6 +55,7 @@
+
diff --git a/public/palette.js b/public/palette.js
index e6c91ba..4e8fc46 100644
--- a/public/palette.js
+++ b/public/palette.js
@@ -51,7 +51,8 @@ function openPalette() {
let items = [], sel = 0, seq = 0, timer, live = [];
const go = h => () => { location.hash = h; };
const base = () => {
- const out = TABS.filter(t => allowed(t[0])).map(([id, label]) => ({ label: 'Go to ' + label, hint: 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
+ const out = navTabs().map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === id)) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
+ if (allowed('tasks') && allowed('calendar')) out.push({ label: 'Go to Tasks', hint: 'Planner', ic: 'check', run: go('#/tasks'), kw: 'todo' }, { label: 'Go to Calendar', hint: 'Planner', ic: 'cal', run: go('#/calendar'), kw: 'agenda events' });
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' });
if (allowed('mail') && cfg.mail) out.push({ label: 'New email', hint: 'Create', ic: 'mail', run: () => { location.hash = '#/mail'; setTimeout(() => mailCompose(), 300); }, kw: 'compose write message' });
diff --git a/public/planner.js b/public/planner.js
new file mode 100644
index 0000000..b212382
--- /dev/null
+++ b/public/planner.js
@@ -0,0 +1,15 @@
+'use strict';
+// Planner: Calendar and Tasks in one menu entry. Each part keeps its own address (#/calendar, #/tasks) so links and reminders still work.
+const PLANNER_PANES = [['calendar', 'Calendar'], ['tasks', 'Tasks']];
+function viewPlanner(args = []) {
+ const ok = PLANNER_PANES.map(p => p[0]).filter(p => allowed(p));
+ const want = args[0] && ok.includes(args[0]) ? args[0] : store.get('planner_pane', ok[0]);
+ location.replace('#/' + (ok.includes(want) ? want : ok[0] || 'home'));
+}
+/** The page heading, with a Calendar | Tasks switch when this person may use both. */
+function plannerHead(active) {
+ try { localStorage.setItem(nk('planner_pane'), JSON.stringify(active)); } catch {}
+ const panes = PLANNER_PANES.filter(p => allowed(p[0]));
+ if (panes.length < 2) return `
${esc((PLANNER_PANES.find(p => p[0] === active) || [0, 'Planner'])[1])} `;
+ return `
Planner ${panes.map(([k, l]) => `
${l} `).join('')}
`;
+}
diff --git a/public/style.css b/public/style.css
index 705f1cb..450d465 100644
--- a/public/style.css
+++ b/public/style.css
@@ -553,3 +553,9 @@ body.lyropen { overflow: hidden; }
.mfold a.on { background: var(--panel2); color: var(--text); font-weight: 600; }
.mtool select { display: none; }
}
+
+/* Menu groups (desktop rail) */
+.ngrp { margin: 8px 12px 2px; border-top: 1px solid var(--line); padding-top: 8px; }
+.ngrp span { font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
+body.nav-mini .ngrp { margin: 6px 14px; padding-top: 0; }
+body.nav-mini .ngrp span { display: none; }
diff --git a/public/tasks.js b/public/tasks.js
index fec996c..85241ab 100644
--- a/public/tasks.js
+++ b/public/tasks.js
@@ -6,13 +6,13 @@ const taskStore = []; // tasks on screen, so a tap can open the right one
async function viewTasks() {
const v = $('#view');
- v.innerHTML = '
';
+ v.innerHTML = '
' + plannerHead('tasks') + '
Loading…
';
try {
const { lists, icloud } = await jfetch('/api/tasks/lists');
if (!location.hash.startsWith('#/tasks')) return;
let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id;
const list = lists.find(l => l.id === cur);
- v.innerHTML = `
Tasks
+ v.innerHTML = `
${plannerHead('tasks')}
@@ -27,7 +27,7 @@ async function viewTasks() {
catch (err) { toast(err.message); }
};
loadTaskList(cur, list);
- } catch (e) { v.innerHTML = `
`; }
+ } catch (e) { v.innerHTML = `
${plannerHead('tasks')}
${esc(e.message)}
`; }
}
async function loadTaskList(cur, list) {
diff --git a/tasks.js b/tasks.js
index 13d4853..7a649cd 100644
--- a/tasks.js
+++ b/tasks.js
@@ -147,6 +147,11 @@ async function handle(req, res) {
if (isLocal(b.list)) { const d = load(); d.tasks = d.tasks.filter(x => x.id !== b.id && x.parent !== b.id); save(d); return json(res, 200, { ok: true }); }
await caldav.deleteEvent(b.list, b.href, b.etag); return json(res, 200, { ok: true });
}
+ if (p === '/api/tasks/due' && req.method === 'GET') { // open tasks (not subtasks) due in a date range, from every list: for the calendar views
+ const from = q.get('from') || '0000-00-00', to = q.get('to') || '9999-99-99';
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) throw new Error('Bad date range');
+ return json(res, 200, (await allTasks()).filter(x => !x.done && !x.parent && x.due && x.due >= from && x.due <= to).slice(0, 500));
+ }
if (p === '/api/tasks/summary' && req.method === 'GET') { // for the Home card: open tasks that are overdue or due today
const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 };
const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; });
@@ -161,7 +166,7 @@ async function handle(req, res) {
/** Every task from every list (NE Hub and iCloud), with its list name. */
async function allTasks() {
const d = load(), out = [];
- for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done }); }
+ for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '' }); }
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} }
return out;
}
diff --git a/tools/tasks-test.js b/tools/tasks-test.js
index 7c3cea7..cc3315c 100644
--- a/tools/tasks-test.js
+++ b/tools/tasks-test.js
@@ -36,6 +36,15 @@ const wait = ms => new Promise(r => setTimeout(r, ms));
ok('a missing parent is refused', r.status === 400, r);
r = await call('/api/tasks/delete', { list: L, id: rep.id });
ts = await list(); ok('deleting the parent deletes its subtasks', !ts.some(t => t.id === rep.id || t.id === sub.id), ts);
+ // tasks due in a date range, for the calendar
+ await call('/api/tasks', { list: L, fields: { title: 'Due soon', notes: '', due: plus(2), done: false, repeat: 'weekly' } });
+ await call('/api/tasks', { list: L, fields: { title: 'Due later', notes: '', due: plus(40), done: false } });
+ await call('/api/tasks', { list: L, fields: { title: 'Done already', notes: '', due: plus(2), done: true } });
+ ts = await list(); const soonT = ts.find(t => t.title === 'Due soon');
+ await call('/api/tasks', { list: L, fields: { title: 'A subtask', notes: '', due: plus(2), done: false, parent: soonT.id } });
+ r = await call(`/api/tasks/due?from=${plus(0)}&to=${plus(7)}`);
+ ok('the calendar gets open tasks due in the range (not done ones, subtasks or other dates)', r.status === 200 && r.json.length === 1 && r.json[0].title === 'Due soon' && r.json[0].repeat === 'weekly' && r.json[0].listName, r.json);
+ r = await call('/api/tasks/due?from=bad&to=worse'); ok('a bad date range is refused', r.status === 400, r.status);
ok('the server is still running', child.exitCode === null);
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll task checks passed'); process.exit(failed ? 1 : 0);