From 8d38584d370037b1a43a4bdfd91e8340fb579b76 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:56:31 -0400 Subject: [PATCH] Planner: Calendar and Tasks in one menu entry, due tasks shown on the calendar; menu, More sheet and Home tiles grouped by kind Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 + public/app.js | 59 +++++++++++++++++++++++++--------------- public/calendar-views.js | 2 +- public/calendar.js | 32 ++++++++++++++++------ public/index.html | 1 + public/palette.js | 3 +- public/planner.js | 15 ++++++++++ public/style.css | 6 ++++ public/tasks.js | 6 ++-- tasks.js | 7 ++++- tools/tasks-test.js | 9 ++++++ 11 files changed, 105 insertions(+), 36 deletions(-) create mode 100644 public/planner.js 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('') + `
`; + $('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `` : '') + g.tabs.map(tab).join('')).join('') + `
`; $('#navcol').onclick = () => setNav({ mini: !navPref().mini }); } } phone.addEventListener('change', () => renderNav()); function openMore() { - const el = openSheet('More', '', `
${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([id, label]) => ` - ${icon(TAB_ICON[id])}${label}${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `${id === 'signal' ? sg.unread : mailUnread}` : ''}${icon('chev', 'chev')}`).join('')}
`); + 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) => ``).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) {
${days.map(d => ``).join('')}
- ${allDay.some(a => a.length) ? `
all day${allDay.map(a => `
${a.map(e => { const i = evStore.push(e) - 1; return ``; }).join('')}
`).join('')}
` : ''}
+ ${allDay.some(a => a.length) ? `
all day${allDay.map(a => `
${a.map(e => { const i = evStore.push(e) - 1; return ``; }).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 `
`; return ``; }; -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('')}

Loading…

`; @@ -83,7 +93,7 @@ async function renderMonth() { cells.push(``); + ${evs.slice(0, 3).map(e => `${e.task ? '☐ ' : ''}${esc(e.title)}`).join('')}${evs.length > 3 ? `+${evs.length - 3} more` : ''}`); } $('#cbody').innerHTML = `

${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 = '

Tasks

Loading…

'; + 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')}

Loading…

@@ -27,7 +27,7 @@ async function viewTasks() { catch (err) { toast(err.message); } }; loadTaskList(cur, list); - } catch (e) { v.innerHTML = `

Tasks

${esc(e.message)}

`; } + } 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);