Shared Family calendar and task list; notes default to Family; phone chat and Family card fixes
build-container / build (push) Successful in 4m14s

- Built-in Family calendar everyone with Calendar can edit (data/family)
- Family task list shared by everyone with Tasks
- New notes and lists start shared with the family
- Phone chat sized above the bottom bar and mini player so the message box is visible
- Family page cards no longer overflow on long chat previews
- Tests added and updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 12:33:23 -04:00
1 parent 0383da87f5
commit 058f410a58
11 files changed
+65 -17

No files matched your search

+6 -6
View File
@@ -24,7 +24,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor
(async () => {
let r = await call(ann, '/api/cal/list');
ok('everyone starts with the one NE Hub calendar', r.json.length === 1 && r.json[0].id === 'local' && r.json[0].name === 'NE Hub' && r.json[0].color === '#4f7cff', r.json);
ok('everyone starts with the one NE Hub calendar', r.json.length === 2 && r.json[0].id === 'local' && r.json[0].name === 'NE Hub' && r.json[1].id === 'family~local' && r.json[0].color === '#4f7cff', r.json);
// legacy events (no calendar field) belong to the first calendar
fs.writeFileSync(path.join(users.dirOf(ann.id), 'events.json'), JSON.stringify([{ uid: 'old1', fields: F('Old event'), created: 1, updated: 1 }]));
@@ -32,7 +32,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor
// create, recolor, rename
r = await call(ann, '/api/cal/ncal', { name: ' Family ', color: '#d9453a' }); const fam = r.json.id;
ok('a person can make another calendar', r.status === 200 && fam && (await call(ann, '/api/cal/list')).json.length === 2);
ok('a person can make another calendar', r.status === 200 && fam && (await call(ann, '/api/cal/list')).json.filter(c => c.id !== 'family~local').length === 2);
r = await call(ann, '/api/cal/ncal', { name: ' ' }); ok('a calendar needs a name', r.status === 400, r);
r = await call(ann, '/api/cal/ncal/update', { id: fam, color: 'red' }); ok('colors must be real hex colors', r.status === 400, r);
await call(ann, '/api/cal/ncal/update', { id: fam, name: 'Family stuff', color: '#8a5cf0' });
@@ -48,7 +48,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor
ok('the same uid is not found in a different calendar', (await call(ann, `/api/cal/event?cal=local&uid=${dn.uid}&tz=UTC`)).status === 400);
// private by default
ok('other people see nothing of it by default', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1);
ok('other people see nothing of it by default', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.filter(c => c.id !== 'family~local').length === 1);
r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Sneaky') }); ok('and cannot write to it', r.status === 400, r);
r = await call(bob, `/api/cal/event?cal=${ann.id}~${fam}&uid=${dn.uid}&tz=UTC`); ok('or read it', r.status === 400, r);
@@ -57,7 +57,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor
r = await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: bob.id, mode: 'view' }, { user: cy.id, mode: 'edit' }, { user: ann.id, mode: 'edit' }, { user: 'nobody', mode: 'edit' }] });
ok('sharing saves, ignoring yourself and unknown people', r.status === 200 && (await call(ann, '/api/cal/list')).json.find(c => c.id === fam).shares.length === 2);
ok('a view-only person sees the events', (await titles(bob)) === 'Dinner');
const bl = (await call(bob, '/api/cal/list')).json.find(c => c.shared);
const bl = (await call(bob, '/api/cal/list')).json.find(c => c.shared && c.id !== 'family~local');
ok('and the calendar in their list, marked shared with the owner and their access', bl && bl.mode === 'view' && bl.owner === 'Ann' && bl.name === 'Family stuff', bl);
const bev = (await call(bob, win)).json.events[0]; ok('shared events say they cannot be edited', bev.editable === false && bev.shared === true, bev);
r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Bob adds') }); ok('view-only people cannot add', r.status === 400 && /view only/.test(r.json.error), r);
@@ -76,7 +76,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor
// stopping sharing
await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: cy.id, mode: 'edit' }] });
ok('removing someone hides it from them right away', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1);
ok('removing someone hides it from them right away', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.filter(c => c.id !== 'family~local').length === 1);
// subscribe links
const feedOf = async (u, id, reset) => { const x = await call(u, '/api/cal/ncal/feed', { id, reset }); return x.json.url; };
@@ -91,7 +91,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor
ok('making a new link kills the old one', u4 !== u1 && old.code === 404 && (await get(u4)).code === 200);
// delete
r = await call(ann, '/api/cal/ncal/delete', { id: fam }); ok('deleting a calendar works', r.status === 200 && (await call(ann, '/api/cal/list')).json.length === 1);
r = await call(ann, '/api/cal/ncal/delete', { id: fam }); ok('deleting a calendar works', r.status === 200 && (await call(ann, '/api/cal/list')).json.filter(c => c.id !== 'family~local').length === 1);
ok('and removes its events and its share', !(await titles(ann)).includes('Cy adds') && (await titles(cy)) === '');
r = await call(ann, '/api/cal/ncal/delete', { id: 'local' }); ok('the last calendar cannot be deleted', r.status === 400, r);
ok('older events survived all that', (await titles(ann)).includes('Old event'));
+23
View File
@@ -0,0 +1,23 @@
// The shared Family calendar and Family task list: every person with the tab sees and edits the same ones.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-fam-'));
const users = require('../users'), cal = require('../cal'), tasks = require('../tasks');
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['calendar', 'tasks'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['notes'], handle: 'Yw' }) };
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => req.url.startsWith('/api/tasks') ? tasks.handle(req, res) : cal.handle(req, res))).listen(0, async () => {
const B = 'http://127.0.0.1:' + hub.address().port, call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json();
try {
const day = new Date().toISOString().slice(0, 10), ev = { summary: 'Dinner at Grandma\'s', start: day + 'T18:00', end: day + 'T19:00' };
let r = await j(await post('/api/cal/event', { cal: 'family~local', fields: { title: 'Dinner at 6', start: day, end: day, allDay: true } }, 'a')); assert.ok(r.ok, JSON.stringify(r));
const win = '/api/cal/events?' + new URLSearchParams({ from: Date.parse(day + 'T00:00:00Z') - 864e5, to: Date.parse(day + 'T00:00:00Z') + 2 * 864e5, fd: day, td: day });
let e = await j(await call(win, {}, 'b')); assert.ok(e.events.some(x => x.title === 'Dinner at 6' && x.editable), 'Bob sees and can edit the family event');
assert.ok(!(await j(await call(win, {}, 'c'))).events.some(x => x.title === 'Dinner at 6'), 'Cy has no calendar tab: nothing');
assert.ok((await j(await call('/api/cal/list', {}, 'b'))).some(c => c.id === 'family~local' && c.name === 'Family'));
const lists = (await j(await call('/api/tasks/lists', {}, 'b'))).lists; assert.deepEqual(lists.map(l => l.name), ['Tasks', 'Family']);
await post('/api/tasks', { list: 'local:family', fields: { title: 'Book the sitter', due: day } }, 'a');
let t = await j(await call('/api/tasks?list=local:family', {}, 'b')); assert.equal(t.length, 1, 'Bob sees the family task'); assert.equal((await j(await call('/api/tasks?list=local:inbox', {}, 'b'))).length, 0);
await post('/api/tasks/toggle', { id: t[0].id, list: 'local:family', done: true }, 'b'); t = await j(await call('/api/tasks?list=local:family', {}, 'a')); assert.equal(t[0].done, true, 'ticked by Bob, seen by Ann');
const del = await post('/api/tasks/lists/delete', { id: 'local:family' }, 'a'); assert.equal(del.status, 400, 'the Family list stays');
console.log('family ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});
+1 -1
View File
@@ -6,7 +6,7 @@ const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: us
const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => notes.handle(req, res))).listen(0, async () => {
const B = 'http://127.0.0.1:' + hub.address().port + '/api/notes', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json(), list = async who => (await j(await call('/', {}, who))).notes;
try {
const n1 = await j(await post('/', { type: 'note', title: ' Private thoughts ', body: 'secret', color: 'blue' }, 'b')); assert.equal(n1.title, 'Private thoughts'); assert.equal(n1.share, 'me'); assert.equal((await list('a')).length, 0, 'a private note is only for its owner (even an admin does not see it in the list)');
const n1 = await j(await post('/', { type: 'note', title: ' Private thoughts ', body: 'secret', color: 'blue', share: 'me' }, 'b')); assert.equal(n1.title, 'Private thoughts'); assert.equal(n1.share, 'me'); assert.equal((await list('a')).length, 0, 'a private note is only for its owner (even an admin does not see it in the list)');
assert.equal((await post('/', { id: n1.id, title: 'Peek' }, 'a')).status, 404, 'and cannot be opened');
const gro = await j(await post('/', { type: 'list', title: 'Groceries', items: ['Milk', ' ', 'Eggs'], share: 'family' }, 'b')); assert.deepEqual(gro.items.map(x => x.text), ['Milk', 'Eggs'], 'blank items are dropped'); assert.equal((await list('c')).length, 1, 'a family note is seen by everyone with Notes'); assert.equal((await list('d')).length, 0, 'but not by someone without the Notes tab');
// checklist: items change one at a time, so two people do not overwrite each other