From d7610208694754c864324a560059aa01728863f5 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:17:53 -0400 Subject: [PATCH] Family calendar: a colour per person, invitations that land on the invited person's calendar Co-Authored-By: Claude Sonnet 5.5 --- cal.js | 24 ++++++++++++++++++++---- public/calendar.js | 10 +++++++--- public/style.css | 1 + public/sw.js | 2 +- public/themes.js | 5 ++++- server.js | 1 + tools/cal-test.js | 8 ++++++++ users.js | 8 ++++++-- 8 files changed, 48 insertions(+), 11 deletions(-) diff --git a/cal.js b/cal.js index 7fa26e8..f8f3672 100644 --- a/cal.js +++ b/cal.js @@ -231,6 +231,8 @@ const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b', const FAM = 'family', FAMCAL = { id: 'local', name: 'Family', color: '#e0742b', shares: [] }; // the calendar the whole household shares const dirFor = id => { if (id !== FAM) return users.dirOf(id); const d = path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family'); fs.mkdirSync(d, { recursive: true }); return d; }; const myId = () => users.current().id; +/** The colour of whoever added an event (the family calendar shows each person's own colour). */ +const byCol = ev => ev && ev.by && users.get(ev.by) ? users.colorOf(users.get(ev.by)) : ''; /** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */ function calsIn(dir) { @@ -326,18 +328,31 @@ async function collectEvents(win) { const mine = dirFor(myId()), evs = eventsIn(mine); const add = (dir, evs, c, id, extra) => { const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields }))); - for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra }); + for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: (extra.family && byCol(evs.find(x => x.uid === e.uid))) || over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra }); }; for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true }); - for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName }); + for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName, family: sh.owner === FAM }); } catch (e) { errors.push('NE Hub: ' + e.message); } try { all.push(...require('./contacts').birthdayEvents(win)); } catch {} // birthdays from Contacts return { events: all, errors }; } +/** An invitation inside the family: the event is put on each invited person's own calendar and they are told. (They can delete it if they cannot come.) */ +function invite(ids, uid, fields, ok) { + if (!ok || !Array.isArray(ids) || !ids.length) return 0; const me = users.current(); let n = 0; + for (const id of new Set(ids.map(String))) { + const who = users.get(id); if (!who || who.disabled || who.id === me.id || !users.can(who, 'calendar')) continue; + const dir = dirFor(who.id), all = eventsIn(dir), cal = calsIn(dir)[0].id; if (all.some(x => x.uid === uid && evCal(x) === cal)) continue; + all.push({ uid, cal, fields, created: Date.now(), updated: Date.now(), by: me.id, byName: me.name, invitedBy: me.name }); saveEvents(dir, all); n++; + try { require('./inbox').add(who.id, { kind: 'info', title: `${me.name} invited you: ${String(fields.title || '').slice(0, 60)}`, body: String(fields.start || '').replace('T', ' ').slice(0, 16), url: '#/calendar', tag: 'invite:' + uid }); } catch { /* the event is on their calendar anyway */ } + } + return n; +} + async function handle(req, res) { const u = new URL(req.url, 'http://x'); try { + if (u.pathname === '/api/cal/people') return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId() && users.can(x, 'calendar')).map(x => ({ id: x.id, name: x.name, color: users.colorOf(x) }))); if (u.pathname === '/api/cal/list') { const over = colorOverrides(); return json(res, 200, [ @@ -438,7 +453,8 @@ async function handle(req, res) { const rec = { uid, cal: r.cid, fields: b.fields, updated: Date.now() }; if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now(), by: users.current().id, byName: users.current().name }); // (who added it) saveEvents(r.dir, all); - return json(res, 200, { ok: true, uid }); + const invited = invite(b.invite, uid, b.fields, r.dir === dirFor(myId()) || r.dir === dirFor(FAM)); + return json(res, 200, { ok: true, uid, invited }); } if (caldav.isCal(b.cal)) { ics.validate(b.fields); @@ -480,6 +496,6 @@ async function handle(req, res) { /** The Family calendar's events in a window, for the family board (no signed-in person needed). */ function familyEvents(win) { const evs = eventsIn(dirFor(FAM)), ics_ = ics.buildCalendar(evs.map(e => ({ uid: e.uid, fields: e.fields }))); - return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' })); + return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: byCol(evs.find(x => x.uid === e.uid)) || FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' })); } module.exports = { familyEvents, collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs }; diff --git a/public/calendar.js b/public/calendar.js index eb42954..ba77de7 100644 --- a/public/calendar.js +++ b/public/calendar.js @@ -126,7 +126,9 @@ function openEvent(e) { } if (e.contactId) { // a birthday from Contacts const el = openSheet(e.title, 'Birthdays', `
${allowed('contacts') ? 'Open contact' : ''}
`); - el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; }; + const inv = el.querySelector('#finv'), invOk = () => { const c = (o.cals || []).find(x => x.id === el.querySelector('#fc').value); return !!c && !c.icloud && !(c.shared && !String(c.id).startsWith('family~')); }; + if (inv) jfetch('/api/cal/people').then(ps => { if (!ps.length || !el.isConnected) return; inv.innerHTML = `
${ps.map(p => ``).join('')}

The event is added to their calendar and they are told.

`; const sync = () => { inv.hidden = !invOk(); }; sync(); el.querySelector('#fc').addEventListener('change', sync); }).catch(() => {}); + el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; }; return; } if (e.editable) return editEvent(e); @@ -169,6 +171,7 @@ function eventForm(o) { ${calPick} + ${editing ? '' : '
'} ${editing && o.series ? '

This repeats. Changes apply to the whole series.

' : ''}
${editing ? '
' : ''} @@ -200,9 +203,10 @@ function eventForm(o) { start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value }; const cal = editing ? o.calId : el.querySelector('#fc').value; try { - await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) }); + const invite = editing || (inv && inv.hidden) ? [] : [...el.querySelectorAll('[data-inv]:checked')].map(c => c.dataset.inv); + const sent = await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields, invite }) }); if (!editing) store.set('cal_default', cal); - closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar(); + closeSheet(); toast(editing ? 'Event saved' : sent && sent.invited ? `Event added, ${sent.invited} invited` : 'Event added'); viewCalendar(); } catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; } }; const del = el.querySelector('#fd'); diff --git a/public/style.css b/public/style.css index 1803365..3fc6cb6 100644 --- a/public/style.css +++ b/public/style.css @@ -1101,3 +1101,4 @@ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simpleho .lsrow { display: flex; gap: 16px; } .lsrow span { flex: 1; min-width: 0; } .lsrow b { display: block; font-size: 22px; letter-spacing: -.01em; } .lsrow small { color: var(--muted); } .lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; } .lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); } +.invlist { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 6px 0; } .invdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin: 0 6px 0 2px; } diff --git a/public/sw.js b/public/sw.js index a705722..d6af495 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v104'; +const CACHE = 'hub-v105'; 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']; diff --git a/public/themes.js b/public/themes.js index e6d096b..6399202 100644 --- a/public/themes.js +++ b/public/themes.js @@ -1,6 +1,7 @@ 'use strict'; // ---- Themes: each person picks an accent colour and Auto, Light, Dark or AMOLED black (true black, easy on phone batteries). Kept in their synced settings, so it follows them between devices. const THEME_ACCENTS = [['blue', 'Blue', '#3d6bf0', '#6a8cff'], ['teal', 'Teal', '#0f8f8a', '#3cc7c0'], ['green', 'Green', '#2f9e6e', '#4cc98d'], ['orange', 'Orange', '#d9741a', '#f09a45'], ['red', 'Red', '#d4392f', '#ff6b60'], ['pink', 'Pink', '#c9407d', '#f26aa4'], ['purple', 'Purple', '#7b4fe0', '#a688ff']]; +const THEME_PERSON = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f8f8a', '#c9407d']; const THEME_MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark'], ['amoled', 'AMOLED']]; const THEME_VARS = { light: { '--bg': '#f5f6f8', '--panel': '#fff', '--panel2': '#eef0f4', '--text': '#14171c', '--muted': '#6a7280', '--line': '#e0e3e9' }, @@ -24,7 +25,9 @@ function themeSettings(el) { const mode = store.get('theme_mode', 'auto'), acc = store.get('theme_accent', 'blue'); el.innerHTML = `

Look

Saved for you and used on every device you sign in on.

${THEME_MODES.map(([k, l]) => `${l}`).join('')}
-
${THEME_ACCENTS.map(([k, l, c]) => ``).join('')}
`; +
${THEME_ACCENTS.map(([k, l, c]) => ``).join('')}
+

My colour on shared calendars

${(typeof ME !== 'undefined' && ME ? THEME_PERSON : []).map(c => ``).join('')}
`; + el.querySelectorAll('[data-pc]').forEach(b => b.onclick = async () => { try { const r = await jfetch('/api/me/color', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ color: b.dataset.pc }) }); ME.color = r.color; try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} draw(); } catch (e) { toast(e.message); } }); el.querySelectorAll('[data-m]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('theme_mode', a.dataset.m); themeApply(); draw(); }); el.querySelectorAll('[data-a]').forEach(b => b.onclick = () => { store.set('theme_accent', b.dataset.a); themeApply(); draw(); }); }; diff --git a/server.js b/server.js index 15f1920..e8ad9fe 100644 --- a/server.js +++ b/server.js @@ -651,6 +651,7 @@ async function handleRequest(req, res) { const rule = ACCESS.find(([re]) => re.test(pathname)); if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res); if ((pathname === '/api/system' || pathname === '/api/health' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this'); + if (pathname === '/api/me/color' && req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); users.update(user.id, { color: b.color || null }); res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ color: users.colorOf(users.get(user.id)) })); } if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); } if (pathname.startsWith('/api/users')) return admin.handle(req, res, user); if (req.url === '/api/config') { // only reveal the addresses of things this person may use diff --git a/tools/cal-test.js b/tools/cal-test.js index 72f281e..fb23639 100644 --- a/tools/cal-test.js +++ b/tools/cal-test.js @@ -102,5 +102,13 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor ok('a calendar link can be recolored', (await call(bob, '/api/cal/list')).json.find(c => c.id === 'lnk').color === '#112233'); r = await call(bob, '/api/cal/color', { id: 'lnk', color: 'blue' }); ok('with a real color only', r.status === 400); + // invitations and each person's colour + r = await call(ann, '/api/cal/people'); ok('the calendar lists who can be invited (not yourself)', r.status === 200 && r.json.map(x => x.name).sort().join() === 'Bob,Cy' && /^#[0-9a-f]{6}$/.test(r.json[0].color), r.json); + r = await call(ann, '/api/cal/event', { cal: 'local', fields: F('Movie night', '2026-11-05'), invite: [bob.id, ann.id, 'nobody'] }); ok('inviting adds the event for the invited people only', r.status === 200 && r.json.invited === 1, r.json); + ok('and it is on their calendar', (await titles(bob)).includes('Movie night') && !(await titles(cy)).includes('Movie night')); + r = await call(ann, '/api/cal/event', { cal: 'local', fields: F('Movie night 2', '2026-11-05'), invite: [bob.id] }); r = await call(bob, '/api/cal/event', { cal: 'local', fields: F('Bob only', '2026-11-05'), invite: [] }); ok('no invite list, nobody invited', r.json.invited === 0); + { const fe = await call(bob, '/api/cal/event', { cal: 'family~local', fields: F('Family dinner', '2026-11-04') }); const ev = (await call(cy, win)).json.events.find(e => e.title === 'Family dinner'); ok('a family event shows in the colour of whoever added it', ev && ev.color === users.colorOf(users.get(bob.id)), ev); } + r = await call(bob, '/api/cal/people'); ok('and nobody sees a disabled or calendar-less person', !r.json.some(x => x.id === bob.id)); + console.log(failed ? `\n${failed} FAILED` : '\nall passed'); process.exit(failed ? 1 : 0); })(); diff --git a/users.js b/users.js index e23a40c..bb5222e 100644 --- a/users.js +++ b/users.js @@ -63,6 +63,7 @@ function update(id, patch) { if (patch.kid !== undefined) { const k = require('./kidlimits').cleanKid(patch.kid); if (u.role === 'admin' && k && k.on) throw new Error('An admin cannot be a child account'); if (k) u.kid = k; else delete u.kid; } if (patch.board !== undefined) { if (patch.board === false) u.board = false; else delete u.board; } // whether they are on the family board and in family chore progress if (patch.simple !== undefined) { if (patch.simple) u.simple = true; else delete u.simple; } /* simple mode: a big-button layout with few choices */ + if (patch.color !== undefined) { if (/^#[0-9a-f]{6}$/i.test(patch.color)) u.color = patch.color.toLowerCase(); else delete u.color; } // the person's own colour on shared calendars if (patch.signOutEverywhere) u.epoch++; save(); return u; } @@ -76,7 +77,10 @@ function remove(id) { const can = (u, tab) => !!u && (ALWAYS.includes(tab) || u.role === 'admin' || (u.tabs || []).includes(tab)); const allowedTabs = u => !u ? [] : [...ALWAYS, ...(u.role === 'admin' ? TABS : (u.tabs || []).filter(t => TABS.includes(t)))]; -const publicView = u => ({ id: u.id, name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false, simple: !!u.simple }); +/** The colour a person's events and name show in on shared calendars: their own pick, or one of a set by the order accounts were made. */ +const PERSON_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f8f8a', '#c9407d']; +const colorOf = u => !u ? PERSON_COLORS[0] : u.color || PERSON_COLORS[Math.max(0, all().findIndex(x => x.id === u.id)) % PERSON_COLORS.length]; +const publicView = u => ({ id: u.id, color: colorOf(u), name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false, simple: !!u.simple }); // ---- A single-user install becomes "the admin" without anyone doing anything: their passkey, tasks, events, settings, // iCloud connection and calendar links move into their own folder. (Copied, so the originals stay as a backup.) @@ -93,4 +97,4 @@ function migrateLegacy() { } migrateLegacy(); -module.exports = { TABS, TAB_LABELS, ALWAYS, runAs, current, dir, dirOf, all, get, firstAdmin, adminCount, create, update, remove, can, allowedTabs, publicView, cleanTabs }; +module.exports = { PERSON_COLORS, colorOf, TABS, TAB_LABELS, ALWAYS, runAs, current, dir, dirOf, all, get, firstAdmin, adminCount, create, update, remove, can, allowedTabs, publicView, cleanTabs };