Shared Family calendar and task list; notes default to Family; phone chat and Family card fixes
build-container / build (push) Successful in 4m14s
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:
1 parent
0383da87f5
commit
058f410a58
11 files changed
+65
-17
No files matched your search
@@ -228,7 +228,8 @@ const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); }
|
|||||||
const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 });
|
const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 });
|
||||||
const HEX = /^#[0-9a-f]{6}$/i;
|
const HEX = /^#[0-9a-f]{6}$/i;
|
||||||
const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
|
const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
|
||||||
const dirFor = id => users.dirOf(id);
|
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;
|
const myId = () => users.current().id;
|
||||||
|
|
||||||
/** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */
|
/** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */
|
||||||
@@ -244,7 +245,7 @@ const colorOverrides = () => readJ(here(COLOR_FILE), {});
|
|||||||
|
|
||||||
/** Calendars other people shared with the current person: [{ id: "owner~calId", owner, ownerName, cal, mode }] */
|
/** Calendars other people shared with the current person: [{ id: "owner~calId", owner, ownerName, cal, mode }] */
|
||||||
function sharedWithMe() {
|
function sharedWithMe() {
|
||||||
const me = myId(), out = [];
|
const me = myId(), out = users.current() && users.can(users.current(), 'calendar') ? [{ id: FAM + '~local', owner: FAM, ownerName: 'the whole family', cal: FAMCAL, mode: 'edit' }] : [];
|
||||||
for (const u of users.all()) {
|
for (const u of users.all()) {
|
||||||
if (u.disabled || u.id === me) continue;
|
if (u.disabled || u.id === me) continue;
|
||||||
for (const c of calsIn(dirFor(u.id))) {
|
for (const c of calsIn(dirFor(u.id))) {
|
||||||
@@ -260,6 +261,7 @@ function resolveCal(id) {
|
|||||||
const me = myId();
|
const me = myId();
|
||||||
if (typeof id === 'string' && id.includes('~')) {
|
if (typeof id === 'string' && id.includes('~')) {
|
||||||
const [owner, cid] = id.split('~');
|
const [owner, cid] = id.split('~');
|
||||||
|
if (owner === FAM) return cid === 'local' && users.can(users.current(), 'calendar') ? { owner: FAM, dir: dirFor(FAM), cal: FAMCAL, cid: 'local', mode: 'edit' } : null;
|
||||||
const u = users.get(owner);
|
const u = users.get(owner);
|
||||||
if (!u || u.disabled) return null;
|
if (!u || u.disabled) return null;
|
||||||
const c = calsIn(dirFor(owner)).find(x => x.id === cid);
|
const c = calsIn(dirFor(owner)).find(x => x.id === cid);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function handle(req, res) {
|
|||||||
const b = JSON.parse(await readBody(req, MAX_BODY + 5e4) || '{}');
|
const b = JSON.parse(await readBody(req, MAX_BODY + 5e4) || '{}');
|
||||||
if (!b.id) {
|
if (!b.id) {
|
||||||
if (all().length >= MAX_NOTES) throw fail(400, 'That is plenty of notes');
|
if (all().length >= MAX_NOTES) throw fail(400, 'That is plenty of notes');
|
||||||
const type = b.type === 'list' ? 'list' : 'note', n = { id: newId(), type, title: str(b.title, 120).trim(), body: type === 'note' ? str(b.body, MAX_BODY) : '', items: type === 'list' ? (Array.isArray(b.items) ? b.items : []).map(t => str(t, 300).trim()).filter(Boolean).slice(0, MAX_ITEMS).map(text => ({ id: newId(), text, done: false })) : [], owner: me.id, share: cleanShare(b.share, me), color: COLORS.includes(b.color) ? b.color : '', pinned: false, archived: false, created: Date.now(), updated: Date.now(), updatedBy: me.id, rev: 1 };
|
const type = b.type === 'list' ? 'list' : 'note', n = { id: newId(), type, title: str(b.title, 120).trim(), body: type === 'note' ? str(b.body, MAX_BODY) : '', items: type === 'list' ? (Array.isArray(b.items) ? b.items : []).map(t => str(t, 300).trim()).filter(Boolean).slice(0, MAX_ITEMS).map(text => ({ id: newId(), text, done: false })) : [], owner: me.id, share: cleanShare(b.share === undefined ? 'family' : b.share, me), color: COLORS.includes(b.color) ? b.color : '', pinned: false, archived: false, created: Date.now(), updated: Date.now(), updatedBy: me.id, rev: 1 };
|
||||||
all().push(n); save(); return json(res, 200, view(n, me));
|
all().push(n); save(); return json(res, 200, view(n, me));
|
||||||
}
|
}
|
||||||
const n = find(String(b.id), me, b.share !== undefined);
|
const n = find(String(b.id), me, b.share !== undefined);
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox; do node tools/$t-test.js || exit 1; done",
|
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family; do node tools/$t-test.js || exit 1; done",
|
||||||
"audit": "npm audit --omit=dev --audit-level=high"
|
"audit": "npm audit --omit=dev --audit-level=high"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
@@ -340,3 +340,15 @@ async function chAnnounceCard(el) {
|
|||||||
el.innerHTML = `<div class="annc"><span class="ic" style="background:var(--accent)">${icon('chat')}</span><span class="grow"><small>Family announcement · ${esc(a.by)}</small><b>${esc(a.text)}</b></span><button class="ibtn" id="ancx" aria-label="Dismiss">${icon('x')}</button></div>`;
|
el.innerHTML = `<div class="annc"><span class="ic" style="background:var(--accent)">${icon('chat')}</span><span class="grow"><small>Family announcement · ${esc(a.by)}</small><b>${esc(a.text)}</b></span><button class="ibtn" id="ancx" aria-label="Dismiss">${icon('x')}</button></div>`;
|
||||||
el.querySelector('#ancx').onclick = () => { try { localStorage.setItem('ch_ann_gone:' + (ME ? ME.id : ''), a.id); } catch {} el.innerHTML = ''; };
|
el.querySelector('#ancx').onclick = () => { try { localStorage.setItem('ch_ann_gone:' + (ME ? ME.id : ''), a.id); } catch {} el.innerHTML = ''; };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** On a phone the open chat has to end above the mini player and the bottom bar, or the message box lands off screen: size it from what is really at the bottom. */
|
||||||
|
function chFit() {
|
||||||
|
const m = chMounted() && ch.root.querySelector('.sgm'); if (!m || m.offsetParent === null) return;
|
||||||
|
if (window.innerWidth >= 1024) { m.style.height = ''; return; }
|
||||||
|
let bottom = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
||||||
|
const top0 = m.getBoundingClientRect().top; // (look up from the screen's edge for the first spot that is page content, not the bottom bar or mini player)
|
||||||
|
for (let y = Math.floor(bottom) - 2; y > top0 + 130; y -= 4) { const hit = document.elementsFromPoint(innerWidth / 2, y).find(e => !e.closest('.sgm')); const covers = hit && !hit.closest('#view'); if (!covers) { bottom = y + 4; break; } }
|
||||||
|
const top = m.getBoundingClientRect().top; m.style.height = Math.max(260, Math.floor(bottom - top - 10)) + 'px';
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', chFit); if (window.visualViewport) window.visualViewport.addEventListener('resize', chFit);
|
||||||
|
setInterval(chFit, 700);
|
||||||
@@ -1056,3 +1056,4 @@ body.vizopen { overflow: hidden; }
|
|||||||
|
|
||||||
.inboxbell { position: relative; }
|
.inboxbell { position: relative; }
|
||||||
.inboxbell .nbadge { position: absolute; top: -6px; right: -6px; }
|
.inboxbell .nbadge { position: absolute; top: -6px; right: -6px; }
|
||||||
|
.fmrow .grow { min-width: 0; }
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v70';
|
const CACHE = 'hub-v72';
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
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'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -96,7 +96,7 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task
|
|||||||
}
|
}
|
||||||
|
|
||||||
function newListSheet(lists, list) {
|
function newListSheet(lists, list) {
|
||||||
const mine = lists.filter(l => l.local && l.id !== 'local:inbox');
|
const mine = lists.filter(l => l.local && l.id !== 'local:inbox' && !l.shared);
|
||||||
const el = openSheet('Lists', '', `${mine.length ? `<div class="list" style="background:var(--panel2);border:0">${mine.map(l => `<div class="row"><span class="grow"><b>${esc(l.name)}</b></span><button class="btn danger" data-rm="${esc(l.id)}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
|
const el = openSheet('Lists', '', `${mine.length ? `<div class="list" style="background:var(--panel2);border:0">${mine.map(l => `<div class="row"><span class="grow"><b>${esc(l.name)}</b></span><button class="btn danger" data-rm="${esc(l.id)}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
|
||||||
<form id="lf"><label for="ln">New list</label><input type="text" id="ln" placeholder="Groceries" autocomplete="off" required>
|
<form id="lf"><label for="ln">New list</label><input type="text" id="ln" placeholder="Groceries" autocomplete="off" required>
|
||||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Add list</button></div><div class="err" id="le"></div></form>`);
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Add list</button></div><div class="err" id="le"></div></form>`);
|
||||||
|
|||||||
@@ -10,8 +10,18 @@ const caldav = require('./caldav');
|
|||||||
|
|
||||||
const users = require('./users');
|
const users = require('./users');
|
||||||
const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own
|
const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own
|
||||||
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } };
|
const famFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family', 'tasks.json'); // the Family list: one for the whole household
|
||||||
const save = d => atomicWrite(file(), JSON.stringify(d, null, 2), { mode: 0o600 });
|
const FAMILY = 'family';
|
||||||
|
const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
||||||
|
/** Your own lists and tasks, plus the shared Family list (kept in its own file, so everyone sees and edits the same one). */
|
||||||
|
const load = () => {
|
||||||
|
const mine = readJ(file(), { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }), fam = readJ(famFile(), { tasks: [] });
|
||||||
|
return { ...mine, lists: (l => [...l.slice(0, 1), { id: FAMILY, name: 'Family', shared: true }, ...l.slice(1)])(mine.lists.filter(l => l.id !== FAMILY)), tasks: [...mine.tasks.filter(t => t.list !== FAMILY), ...(Array.isArray(fam.tasks) ? fam.tasks : [])] };
|
||||||
|
};
|
||||||
|
const save = d => {
|
||||||
|
atomicWrite(file(), JSON.stringify({ ...d, lists: d.lists.filter(l => l.id !== FAMILY), tasks: d.tasks.filter(t => t.list !== FAMILY) }, null, 2), { mode: 0o600 });
|
||||||
|
fs.mkdirSync(path.dirname(famFile()), { recursive: true }); atomicWrite(famFile(), JSON.stringify({ tasks: d.tasks.filter(t => t.list === FAMILY) }, null, 2), { mode: 0o600 });
|
||||||
|
};
|
||||||
const { readBody } = require('./body');
|
const { readBody } = require('./body');
|
||||||
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
||||||
const isLocal = id => String(id || '').startsWith('local:');
|
const isLocal = id => String(id || '').startsWith('local:');
|
||||||
@@ -78,7 +88,7 @@ async function handle(req, res) {
|
|||||||
try {
|
try {
|
||||||
if (p === '/api/tasks/lists' && req.method === 'GET') {
|
if (p === '/api/tasks/lists' && req.method === 'GET') {
|
||||||
const d = load(), st = caldav.status();
|
const d = load(), st = caldav.status();
|
||||||
return json(res, 200, { lists: [...d.lists.map(l => ({ id: 'local:' + l.id, name: l.name, local: true, ...(l.mealie ? { mealie: true } : {}) })), ...caldav.todoLists().map(l => ({ ...l, icloud: true }))],
|
return json(res, 200, { lists: [...d.lists.map(l => ({ id: 'local:' + l.id, name: l.name, local: true, ...(l.mealie ? { mealie: true } : {}), ...(l.shared ? { shared: true } : {}) })), ...caldav.todoLists().map(l => ({ ...l, icloud: true }))],
|
||||||
icloud: { connected: !!st.connected, reminderLists: st.reminderLists || 0 } });
|
icloud: { connected: !!st.connected, reminderLists: st.reminderLists || 0 } });
|
||||||
}
|
}
|
||||||
if (p === '/api/tasks/lists' && req.method === 'POST') {
|
if (p === '/api/tasks/lists' && req.method === 'POST') {
|
||||||
@@ -89,7 +99,7 @@ async function handle(req, res) {
|
|||||||
}
|
}
|
||||||
if (p === '/api/tasks/lists/delete' && req.method === 'POST') {
|
if (p === '/api/tasks/lists/delete' && req.method === 'POST') {
|
||||||
const { id } = JSON.parse(await readBody(req)), d = load(), raw = String(id).replace(/^local:/, '');
|
const { id } = JSON.parse(await readBody(req)), d = load(), raw = String(id).replace(/^local:/, '');
|
||||||
if (raw === 'inbox') throw new Error('The main list cannot be deleted');
|
if (raw === 'inbox' || raw === FAMILY) throw new Error('That list cannot be deleted');
|
||||||
d.lists = d.lists.filter(l => l.id !== raw); d.tasks = d.tasks.filter(t => t.list !== raw); save(d);
|
d.lists = d.lists.filter(l => l.id !== raw); d.tasks = d.tasks.filter(t => t.list !== raw); save(d);
|
||||||
return json(res, 200, { ok: true });
|
return json(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -24,7 +24,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
let r = await call(ann, '/api/cal/list');
|
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
|
// 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 }]));
|
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
|
// create, recolor, rename
|
||||||
r = await call(ann, '/api/cal/ncal', { name: ' Family ', color: '#d9453a' }); const fam = r.json.id;
|
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', { 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);
|
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' });
|
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);
|
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
|
// 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}`, 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);
|
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' }] });
|
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('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');
|
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);
|
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);
|
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);
|
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
|
// stopping sharing
|
||||||
await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: cy.id, mode: 'edit' }] });
|
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
|
// subscribe links
|
||||||
const feedOf = async (u, id, reset) => { const x = await call(u, '/api/cal/ncal/feed', { id, reset }); return x.json.url; };
|
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);
|
ok('making a new link kills the old one', u4 !== u1 && old.code === 404 && (await get(u4)).code === 200);
|
||||||
|
|
||||||
// delete
|
// 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)) === '');
|
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);
|
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'));
|
ok('older events survived all that', (await titles(ann)).includes('Old event'));
|
||||||
|
|||||||
@@ -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
@@ -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 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;
|
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 {
|
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');
|
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');
|
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
|
// checklist: items change one at a time, so two people do not overwrite each other
|
||||||
|
|||||||
Reference in new issue
Block a user