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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 08:56:31 -04:00
1 parent b305ccac51
commit 8d38584d37
11 files changed
+105 -36

No files matched your search

+37 -22
View File
@@ -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 ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
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 ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
} else {
$('#nav').innerHTML = brand + mine.map(tab).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
$('#navcol').onclick = () => setNav({ mini: !navPref().mini });
}
}
phone.addEventListener('change', () => renderNav());
function openMore() {
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([id, label]) => `
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `<i class="nbadge" style="position:static">${id === 'signal' ? sg.unread : mailUnread}</i>` : ''}${icon('chev', 'chev')}</a>`).join('')}</div>`);
const rest = navTabs().filter(t => !PRIMARY.includes(t[0]));
const rowOf = ([id, label]) => `<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `<i class="nbadge" style="position:static">${id === 'signal' ? sg.unread : mailUnread}</i>` : ''}${icon('chev', 'chev')}</a>`;
const el = openSheet('More', '', navGroups(rest).map(g => `${g.label ? `<h2 style="margin:14px 0 6px">${esc(g.label)}</h2>` : ''}<div class="list" ${g.label ? '' : 'style="margin-top:10px"'}>${g.tabs.map(rowOf).join('')}</div>`).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 => `<h2>${esc(g.label || 'Apps')}</h2><div class="grid">${g.tabs.map(t => T[t[0]] ? T[t[0]]() : '').join('')}</div>`).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() {
<div id="tcard"></div>
<div id="agenda"></div>
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
<h2>Apps</h2><div class="grid">
${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') : ''}
</div>
${homeApps()}
${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}</div>`;
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {