Family calendar: a colour per person, invitations that land on the invited person's calendar

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 10:17:53 -04:00
1 parent b2b772941b
commit d761020869
8 files changed
+47 -10

No files matched your search

+20 -4
View File
@@ -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 };
+6 -2
View File
@@ -126,6 +126,8 @@ function openEvent(e) {
}
if (e.contactId) { // a birthday from Contacts
const el = openSheet(e.title, 'Birthdays', `<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button>${allowed('contacts') ? '<a class="btn" id="bco" style="flex:1;justify-content:center">Open contact</a>' : ''}</div>`);
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 = `<label>Invite</label><div class="invlist">${ps.map(p => `<label class="chk"><input type="checkbox" data-inv="${esc(p.id)}"><i class="invdot" style="background:${esc(p.color)}"></i>${esc(p.name)}</label>`).join('')}</div><p class="sub" id="invnote" style="margin:4px 0 0">The event is added to their calendar and they are told.</p>`; 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;
}
@@ -169,6 +171,7 @@ function eventForm(o) {
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
${calPick}
${editing ? '' : '<div id="finv"></div>'}
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
<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">${editing ? 'Save' : 'Add'}</button></div>
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
@@ -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');
+1
View File
@@ -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; }
+1 -1
View File
@@ -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'];
+4 -1
View File
@@ -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 = `<h2>Look</h2><p class="sub">Saved for you and used on every device you sign in on.</p>
<div class="seg" role="radiogroup" aria-label="Brightness">${THEME_MODES.map(([k, l]) => `<a href="#" role="radio" aria-checked="${mode === k}" data-m="${k}" class="${mode === k ? 'on' : ''}">${l}</a>`).join('')}</div>
<div class="swatches" role="radiogroup" aria-label="Accent colour">${THEME_ACCENTS.map(([k, l, c]) => `<button type="button" role="radio" aria-checked="${acc === k}" aria-label="${l}" title="${l}" data-a="${k}" class="${acc === k ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>`;
<div class="swatches" role="radiogroup" aria-label="Accent colour">${THEME_ACCENTS.map(([k, l, c]) => `<button type="button" role="radio" aria-checked="${acc === k}" aria-label="${l}" title="${l}" data-a="${k}" class="${acc === k ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>
<h3 class="sub" style="margin:14px 0 0">My colour on shared calendars</h3><div class="swatches" role="radiogroup" aria-label="My colour on shared calendars">${(typeof ME !== 'undefined' && ME ? THEME_PERSON : []).map(c => `<button type="button" role="radio" aria-checked="${ME.color === c}" aria-label="${c}" data-pc="${c}" class="${ME.color === c ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>`;
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(); });
};
+1
View File
@@ -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
+8
View File
@@ -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);
})();
+6 -2
View File
@@ -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 };