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:
1 parent
b2b772941b
commit
d761020869
8 files changed
+47
-10
No files matched your search
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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(); });
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user