openvibers/openvibe.food Commits

Food: plan together (a saved plan shared with a household, a shopping list everyone ticks off) (#39)

OP OpenVibers committed 29 days ago · 6a44b060f5 · 10 files, +408 −26

Plan together: a saved meal plan can be shared with a household. Everyone in it sees the plan and ticks the shopping list off as they buy. This was the "Plan together" pillar Food's manifest promised and Help showed as working; Contracts 0.184.0 took the claim out until it existed.

- **A page per saved plan** (`/plan/p/:id`), for its owner and its household only (404 for anyone else, sign-in first).
  - The shopping list is a list of tick buttons; a tick shows for everyone with who got it ("got by lee").
  - Then the plan's numbers, menu and food groups, then the household.
  - `/plan/saved` links each plan and lists "Shared with you".
  - The "Saved" notice on `/plan` links the new plan.
- **Sharing:**
  - The owner makes a share link: 32 random bytes; only its SHA-256 is stored (`food_plans.share_hash`), so the link is shown once.
  - "Make a new share link" replaces it, and "Turn the link off" stops new joins.
  - Opening the link signed in asks "Join "<plan>"?" and says the household will see your name; joining is a POST.
  - At most 12 people besides the owner, and 50 households per person.
- **Who may do what:** only the owner shares, removes people or deletes the plan; a member can leave. Every change is a same-origin POST (403 from another site), and a tick must name an item on that plan's list (400 otherwise).
- **Data:** migration `0005_plan_households` (expand) adds `share_hash`, `food_plan_members` and `food_plan_ticks`. Both tables go with their plan (ON DELETE CASCADE).
  - Account export adds `households.json` and `shopping-ticks.json`.
  - Deletion erases a person's memberships and ticks, and confirms them with counts.
  - `/plan/p/` and `/plan/join/` are disallowed in robots.txt, and the pages are private and noindex.
- **Home page:** "Six things": Plan together joins the features. The home HTML budget is 29 KB raw (28.7 measured); brotli is unchanged under 7.3.
- **Look:** its own `css/plan.css`. On a phone, each item's store, price and who got it go on their own line. Checked in Chromium at 1280 and 390 px (owner, member and join pages, no overflow).

**Tests:**
- `test/plan-together.test.js` covers the whole flow with three people: the owner-only page; the link shown once and stored as a hash; join; ticks seen by everyone; refusals for items and roles; outsiders; rotation and turning off; remove and leave; delete cascading; robots.
- `account-data.test.js` covers export and erasure of memberships and ticks.
- `ov test` passes (8 files).

Changes

ddiff --git a/README.md b/README.md
iindex d840841..136e98e 100644
---- a/README.md
++++ b/README.md
@@@ -2,8 +2,8 @@
> What should we eat?
--**Status:** the product works — food near you, a meal planner, the food database, the pantry and recipe ideas from the network's AI are live and tested.
--Delivery comparison (the manifests' remaining pillar) is not built yet.
++**Status:** the product works — food near you, a meal planner, the food database, the pantry, recipe ideas from the network's AI and plan together (a saved plan shared with a household, with a shopping list everyone ticks off) are live and tested.
++Delivery price comparison is not built: the delivery apps publish no open prices.
**Domain:** `openvibe.food` · **Port:** 4970 · **Service id:** `food` · **Env prefix:** `FOOD`
**License:** AGPL-3.0 (same as every OpenVibe service).
ddiff --git a/migrations/0005_plan_households.sql b/migrations/0005_plan_households.sql
nnew file mode 100644
iindex 0000000..d8acf23
---- /dev/null
++++ b/migrations/0005_plan_households.sql
@@@ -0,0 +1,28 @@
++-- phase: expand
++-- OpenVibe.Food: plan together (the "Plan together" pillar). A saved plan can be shared with a household: its owner
++-- makes a share link (only the SHA-256 of its secret is kept, share_hash), anyone signed in who opens it joins as a
++-- member, and every member ticks the shopping list off for everyone. Deleting a plan deletes its members and ticks.
++-- Names are the display name a person had when they joined or ticked, shown to the household; nothing here is a
++-- credential, a key or a token.
++
++ALTER TABLE food_plans ADD COLUMN share_hash text COLLATE "C"; -- sha256 hex of the share link's secret
++CREATE UNIQUE INDEX food_plans_share ON food_plans (share_hash) WHERE share_hash IS NOT NULL;
++
++CREATE TABLE food_plan_members (
++ plan_id text COLLATE "C" NOT NULL REFERENCES food_plans (id) ON DELETE CASCADE,
++ member text COLLATE "C" NOT NULL, -- user:usr_…
++ name text, -- their display name when they joined
++ joined_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (plan_id, member)
++);
++CREATE INDEX food_plan_members_by_member ON food_plan_members (member, joined_at DESC);
++
++CREATE TABLE food_plan_ticks (
++ plan_id text COLLATE "C" NOT NULL REFERENCES food_plans (id) ON DELETE CASCADE,
++ item text COLLATE "C" NOT NULL, -- the shopping list item's food slug
++ ticked_by text COLLATE "C" NOT NULL, -- user:usr_…
++ name text, -- their display name when they ticked it
++ ticked_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (plan_id, item)
++);
++CREATE INDEX food_plan_ticks_by_person ON food_plan_ticks (ticked_by);
ddiff --git a/public/css/plan.css b/public/css/plan.css
nnew file mode 100644
iindex 0000000..d8a730e
---- /dev/null
++++ b/public/css/plan.css
@@@ -0,0 +1,24 @@
++/* OpenVibe.Food — a saved plan's page (/plan/p/:id) and joining a household (/plan/join/…) only, so the site-wide
++ stylesheet stays inside its budget. */
++.shop-list { list-style: none; padding: 0; margin: 8px 0 18px; display: grid; gap: 6px; }
++.shop-list li { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 10px; background: var(--bg-card, #131c2e); }
++.shop-list li.got .shop-name { text-decoration: line-through; opacity: .7; }
++.shop-list form { margin: 0; }
++.shop-list .tick { width: 28px; height: 28px; min-width: 28px; padding: 0; margin: 0; border-radius: 8px; border: 2px solid var(--border-light, #2c3d5c); background: transparent; color: var(--on-accent-strong, #fff); font-weight: 700; line-height: 1; cursor: pointer; }
++.shop-list li.got .tick { background: var(--accent-strong, #2563eb); border-color: var(--accent-strong, #2563eb); }
++.shop-list .tick:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.shop-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
++.shop-meta { text-align: right; }
++@media (max-width: 560px) {
++ .shop-list li { flex-wrap: wrap; row-gap: 2px; }
++ .shop-name { flex-basis: calc(100% - 40px); }
++ .shop-meta { flex-basis: 100%; padding-left: 40px; text-align: left; }
++}
++.household { list-style: none; padding: 0; margin: 8px 0 14px; }
++.household li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 6px 0; border-bottom: 1px solid var(--border, #1f2d47); }
++.household form { margin: 0 0 0 auto; }
++.share-link { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 8px 0; }
++.share-link input { flex: 1 1 320px; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
++.plan-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 22px; }
++.plan-actions form { margin: 0; }
++.sc-btn.danger { border-color: var(--danger, #ef4444); color: var(--danger, #ef4444); }
ddiff --git a/server/food/store.js b/server/food/store.js
iindex f7a97ba..593714b 100644
---- a/server/food/store.js
++++ b/server/food/store.js
@@@ -57,6 +57,46 @@ async function listPlans(s, owner, { limit = 20 } = {}) {
}
const deletePlan = (s, owner, id) => s.db.exec('DELETE FROM food_plans WHERE owner = $1 AND id = $2', [owner, id]);
++// ── Plan together: households (migrations/0005_plan_households.sql) ──
++const MEMBERS_MAX = 12; // people in one household besides its owner
++const SHARED_PLANS_MAX = 50; // households one person may belong to
++
++/** The plan and the viewer's place in it ('owner' or 'member'), or null when it is neither theirs nor shared with them. */
++async function planForViewer(s, viewer, id) {
++ const row = await s.db.maybe('SELECT * FROM food_plans WHERE id = $1', [id]);
++ if (!row) return null;
++ if (row.owner === viewer) return { row, role: 'owner' };
++ const m = await s.db.maybe('SELECT 1 AS ok FROM food_plan_members WHERE plan_id = $1 AND member = $2', [id, viewer]);
++ return m ? { row, role: 'member' } : null;
++}
++const setShareHash = (s, owner, id, hash) => s.db.exec('UPDATE food_plans SET share_hash = $3 WHERE owner = $1 AND id = $2', [owner, id, hash]);
++const planByShareHash = (s, hash) => s.db.maybe('SELECT * FROM food_plans WHERE share_hash = $1', [hash]);
++const listMembers = (s, planId) => s.db.many('SELECT member, name, joined_at FROM food_plan_members WHERE plan_id = $1 ORDER BY joined_at', [planId]);
++const listSharedPlans = (s, member) => s.db.many(
++ 'SELECT p.*, m.joined_at FROM food_plan_members m JOIN food_plans p ON p.id = m.plan_id WHERE m.member = $1 ORDER BY m.joined_at DESC LIMIT $2',
++ [member, SHARED_PLANS_MAX]);
++
++/** Join a household: 'joined', 'already', or a refusal code (food.plan.full, food.plan.too_many). */
++async function addMember(s, planId, member, name, at) {
++ return s.tx(async () => {
++ if (await s.db.maybe('SELECT 1 AS ok FROM food_plan_members WHERE plan_id = $1 AND member = $2', [planId, member])) return 'already';
++ const n = await s.db.maybe('SELECT COUNT(*)::int AS n FROM food_plan_members WHERE plan_id = $1', [planId]);
++ if (Number(n.n) >= MEMBERS_MAX) return 'food.plan.full';
++ const mine = await s.db.maybe('SELECT COUNT(*)::int AS n FROM food_plan_members WHERE member = $1', [member]);
++ if (Number(mine.n) >= SHARED_PLANS_MAX) return 'food.plan.too_many';
++ await s.db.exec('INSERT INTO food_plan_members (plan_id, member, name, joined_at) VALUES ($1, $2, $3, $4)', [planId, member, name || null, at]);
++ return 'joined';
++ });
++}
++const removeMember = (s, planId, member) => s.db.exec('DELETE FROM food_plan_members WHERE plan_id = $1 AND member = $2', [planId, member]);
++
++const listTicks = (s, planId) => s.db.many('SELECT item, ticked_by, name, ticked_at FROM food_plan_ticks WHERE plan_id = $1', [planId]);
++const tick = (s, planId, item, by, name, at) => s.db.exec(
++ `INSERT INTO food_plan_ticks (plan_id, item, ticked_by, name, ticked_at) VALUES ($1, $2, $3, $4, $5)
++ ON CONFLICT (plan_id, item) DO UPDATE SET ticked_by = EXCLUDED.ticked_by, name = EXCLUDED.name, ticked_at = EXCLUDED.ticked_at`,
++ [planId, item, by, name || null, at]);
++const untick = (s, planId, item) => s.db.exec('DELETE FROM food_plan_ticks WHERE plan_id = $1 AND item = $2', [planId, item]);
++
const planToWire = (row) => ({
id: row.id, title: row.title || null, people: Number(row.people), days: Number(row.days),
budget: row.budget == null ? null : Number(row.budget), created_at: row.created_at, plan: parse(row.plan),
@@@ -102,6 +142,8 @@ const recipeSuggestionToWire = (row) => ({
module.exports = {
cacheGet, cachePut, cachePrune, startCachePrune,
insertPlan, planRow, listPlans, deletePlan, planToWire,
++ planForViewer, setShareHash, planByShareHash, listMembers, listSharedPlans, addMember, removeMember, listTicks, tick, untick,
++ MEMBERS_MAX, SHARED_PLANS_MAX,
getPantry, pantryRows, setPantry,
insertRecipeSuggestion, recipeSuggestionRow, listRecipeSuggestions, countRecipeSuggestionsSince, recipeSuggestionToWire,
};
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex f7e17af..bb702fb 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -17,7 +17,7 @@ const foods = require('../food/foods');
const SITE_NAME = 'OpenVibe.Food';
const DESCRIPTION = 'OpenVibe.Food — What should we eat? Find food banks and budget grocery stores near you, plan meals, and cook from what is in your cupboard.';
--const DISALLOW = ['/auth/', '/api/', '/pantry', '/plan/saved'];
++const DISALLOW = ['/auth/', '/api/', '/pantry', '/plan/saved', '/plan/p/', '/plan/join/'];
const PAGE_TEXT = {
'/': ['OpenVibe.Food home', 'OpenVibe.Food: What should we eat? Find food near you, plan budget meals and cook from your cupboard.'],
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex d59a46a..ec64346 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -14,6 +14,7 @@
* Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt, JSON-LD) are http/discovery.js.
* Every page that shows OpenStreetMap data carries the attribution ODbL requires, linked back.
*/
++const crypto = require('crypto');
const express = require('express');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
@@@ -107,11 +108,12 @@ ${res_.phone ? html`<p class="small">Phone: <a href="tel:${String(res_.phone).re
body: html`${raw(hero)}
${raw(showcase.features({
title: 'Everything food',
-- lede: 'Five things, all working today: no card, no account needed to look around.',
++ lede: 'Six things, all working today: no card, no account needed to look around.',
items: [
{ icon: 'ov:location', title: 'Food near you', text: 'Food banks, soup kitchens, community fridges and free pantry boxes, plus the budget grocery stores, with distance, address and opening hours.', href: '/near' },
{ icon: 'ov:page', title: 'A meal planner', text: 'One person, a couple or a group, for up to fourteen days. It covers all six food groups, writes the shopping list and estimates what it costs.', href: '/plan' },
{ icon: 'ov:tools', title: 'A food list you can read', text: 'Every item with its nutrition per serving and per 100 g, the typical price band and the cheapest of the five budget chains.', href: '/foods' },
++ { icon: 'ov:check', title: 'Plan together', text: 'Share a saved plan with your household: everyone sees it, and ticks the shopping list off as they buy.', href: '/plan/saved' },
{ icon: 'ov:account', title: 'Your cupboard', text: 'Keep a list of what you have. Sign in and it says plainly which simple things you can already make, and what one item would unlock.', href: '/pantry' },
{ icon: 'ov:ai', title: 'Recipe ideas', text: 'Tell it what you feel like and it suggests up to three recipes from your cupboard, with what you would still need to buy and its typical cost. Suggested by AI, so check before you cook.', href: '/recipes' },
],
@@@ -193,7 +195,7 @@ ${osmNote(out.fetched_at)}
</form>`;
}
-- function planBody(plan) {
++ function planBody(plan, { shopping = true } = {}) {
return html`<section class="plan-summary">
<h2>${plan.spec.people} ${plan.spec.people === 1 ? 'person' : 'people'}, ${plan.spec.days} ${plan.spec.days === 1 ? 'day' : 'days'}</h2>
<ul class="stats">
@@@ -205,9 +207,9 @@ ${osmNote(out.fetched_at)}
</ul>
${plan.spec.budget != null ? html`<p class="muted small">Budget ${money(plan.spec.budget)}${plan.remaining_budget != null ? html` · ${money(plan.remaining_budget)} left` : ''}.</p>` : ''}
</section>
--<h2>The shopping list</h2>
++${shopping ? html`<h2>The shopping list</h2>
${table(['Item', 'Packs', 'Where', 'Cost'], plan.shopping_list.map((i) => [html`<a href="/foods/${i.slug}">${i.name}</a>`, String(i.packs), i.store_name || '—', money(i.cost)]), { empty: 'Nothing in the plan.' })}
--<p class="muted small">Cheapest total ${money(plan.totals.cost)} at ${plan.by_store.length} ${plan.by_store.length === 1 ? 'store' : 'stores'}: ${plan.by_store.map((x) => `${x.store_name} ${money(x.cost)}`).join(', ')}. ${plan.totals.packs} packs.</p>
++<p class="muted small">Cheapest total ${money(plan.totals.cost)} at ${plan.by_store.length} ${plan.by_store.length === 1 ? 'store' : 'stores'}: ${plan.by_store.map((x) => `${x.store_name} ${money(x.cost)}`).join(', ')}. ${plan.totals.packs} packs.</p>` : ''}
<h2>What to eat</h2>
${plan.menu.map((d) => html`<section class="plan-day"><h3>Day ${d.day}</h3>
<ul>${[['breakfast', 'Breakfast'], ['lunch', 'Lunch'], ['dinner', 'Dinner'], ['snack', 'Snack']].map(([slot, label]) => html`<li><b>${label}:</b> ${d[slot].join(', ') || '—'}</li>`)}</ul></section>`)}
@@@ -228,7 +230,7 @@ ${plan.notes.map((n) => html`<p class="muted small">${n}</p>`)}`;
body: html`<h1>Plan meals</h1>
<p class="lede">It buys one item from each of the six food groups first, cheapest per serving, then fills up with the best calories per dollar. Deterministic: the same numbers always give the same plan.</p>
${planForm(spec)}
--${savedId ? notice('Saved. It is in your plans below.', 'ok') : ''}
++${savedId && /^pln_[0-9A-Z]{26}$/.test(savedId) ? notice(html`Saved. <a href="/plan/p/${savedId}">Open it</a> to tick the shopping list off or share it with your household.`, 'ok') : ''}
${problem ? notice(problem, 'warn') : ''}
${plan ? planBody(plan) : ''}
${plan && signedIn(req) ? html`<h2>Save this plan</h2>
@@@ -261,18 +263,161 @@ ${plan && !signedIn(req) ? html`<p class="muted small"><a href="/auth/login?next
// Saved plans sit under the same page: nothing personal is ever in a public cache.
r.get('/plan/saved', async (req, res) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fplan%2Fsaved');
-- const rows = await store.listPlans(s, ownerOf(req), { limit: 50 });
++ const [rows, shared] = await Promise.all([store.listPlans(s, ownerOf(req), { limit: 50 }), store.listSharedPlans(s, ownerOf(req))]);
++ const cells = (row, when) => {
++ const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
++ return [html`<a href="/plan/p/${row.id}">${planTitle(row)}</a>`, `${row.people} people, ${row.days} days`, money(plan.totals.cost), time(when)];
++ };
page(req, res, {
title: 'Your plans', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Saved' }],
body: html`<h1>Your saved plans</h1>
--${table(['Name', 'For', 'Cost', 'Saved'], rows.map((row) => {
-- const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
-- return [row.title || `Plan ${String(row.id).slice(-6)}`, `${row.people} people, ${row.days} days`, money(plan.totals.cost), time(row.created_at)];
-- }), { empty: 'No saved plans yet. Make one on the plan page and save it.' })}
++${table(['Name', 'For', 'Cost', 'Saved'], rows.map((row) => cells(row, row.created_at)), { empty: 'No saved plans yet. Make one on the plan page and save it.' })}
++${shared.length ? html`<h2>Shared with you</h2>
++${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.joined_at)))}` : ''}
<p><a href="/plan">Plan another</a></p>`,
});
});
++ // ── Plan together: a saved plan's page, its household and the shared shopping list ──
++ // The owner makes a share link; anyone signed in who opens it and says yes joins the household, sees the plan and
++ // ticks the shopping list off for everyone. Only the SHA-256 of the link's secret is kept, so a link is shown once;
++ // a new one replaces it and turning it off stops new joins (members stay until removed). Every change is a POST
++ // from openvibe.food itself.
++ const PLAN_ID = /^pln_[0-9A-Z]{26}$/;
++ const SECRET = /^[A-Za-z0-9_-]{43}$/;
++ const hashOf = (secret) => crypto.createHash('sha256').update(secret).digest('hex');
++ const nameOf = (req) => String(req.viewer.displayName || req.viewer.username || 'someone').slice(0, 80);
++ const planTitle = (row) => row.title || `Plan ${String(row.id).slice(-6)}`;
++ const PLAN_STYLES = { styles: [showcase.STYLESHEET], localStyles: ['css/plan.css'] };
++ const form = express.urlencoded({ extended: false, limit: '16kb' });
++ const toLogin = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl.slice(0, 400))}`);
++ const notOurs = (req, res) => res.status(403).type('text/plain').send('A plan is changed from openvibe.food itself.');
++
++ async function loadPlan(req, res) {
++ const id = str(req.params.id, 40);
++ const got = id && PLAN_ID.test(id) ? await store.planForViewer(s, ownerOf(req), id) : null;
++ if (got) return got;
++ page(req, res, { title: 'Plan not found', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Not found' }],
++ body: html`<h1>Plan not found</h1><p>It is not yours or shared with you, or it was deleted.</p><p><a href="/plan/saved">Your plans</a></p>` }, 404);
++ return null;
++ }
++
++ async function planPage(req, res, got, { link = null, problem = null, status = 200 } = {}) {
++ const { row, role } = got;
++ const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
++ const [members, ticks] = await Promise.all([store.listMembers(s, row.id), store.listTicks(s, row.id)]);
++ const ticked = new Map(ticks.map((t) => [t.item, t]));
++ const got_ = plan.shopping_list.filter((i) => ticked.has(i.slug)).length;
++ const me = ownerOf(req);
++ const btn = (action, label, { cls = 'sc-btn', fields = {} } = {}) => html`<form method="post" action="/plan/p/${row.id}/${action}">${Object.entries(fields).map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}<button class="${cls}" type="submit">${label}</button></form>`;
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ page(req, res, {
++ title: planTitle(row), crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Your plans', href: '/plan/saved' }, { label: planTitle(row) }], ...PLAN_STYLES,
++ body: html`<h1>${planTitle(row)}</h1>
++<p class="lede">${role === 'owner' ? 'Your plan' : 'Shared with you'} · ${members.length + 1} ${members.length ? 'people in this household' : 'person so far'}.</p>
++${problem ? notice(problem, 'warn') : ''}
++<h2>The shopping list</h2>
++<p class="muted small">${got_} of ${plan.shopping_list.length} got. A tick shows for everyone in the household.</p>
++<ul class="shop-list">${plan.shopping_list.map((i) => {
++ const t = ticked.get(i.slug);
++ return html`<li class="${t ? 'got' : ''}"><form method="post" action="/plan/p/${row.id}/tick"><input type="hidden" name="item" value="${i.slug}"><input type="hidden" name="done" value="${t ? '0' : '1'}"><button class="tick" type="submit" aria-pressed="${t ? 'true' : 'false'}" aria-label="${t ? 'Not got yet' : 'Got it'}: ${i.name}">${t ? '✓' : ''}</button></form>
++<span class="shop-name"><a href="/foods/${i.slug}">${i.name}</a> × ${String(i.packs)}</span><span class="shop-meta muted small">${i.store_name || ''} ${money(i.cost)}${t ? html` · got by ${t.ticked_by === me ? 'you' : (t.name || 'someone')}` : ''}</span></li>`;
++ })}</ul>
++${planBody(plan, { shopping: false })}
++<h2 id="household">The household</h2>
++<ul class="household">
++<li>${role === 'owner' ? 'You' : 'The plan’s owner'} <span class="badge">owner</span></li>
++${members.map((m) => html`<li>${m.member === me ? 'You' : (m.name || 'Someone')} <span class="muted small">joined ${time(m.joined_at)}</span>${role === 'owner' ? btn('remove', 'Remove', { fields: { member: m.member } }) : ''}</li>`)}
++</ul>
++${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p><b>Your share link.</b> Anyone signed in with OpenVibe who opens it can join this household. It is shown once: copy it now. A new link replaces it.</p>
++<p class="share-link"><input type="text" readonly value="${link}" aria-label="Share link"></p></div>` : ''}
++<p class="muted small">${row.share_hash ? 'A share link is on.' : 'No share link is on.'} Members see this plan and tick the shopping list; only you change the household.</p>
++<div class="plan-actions">${btn('share', row.share_hash ? 'Make a new share link' : 'Make a share link', { cls: 'sc-btn sc-primary' })}${row.share_hash ? btn('unshare', 'Turn the link off') : ''}${btn('delete', 'Delete this plan', { cls: 'sc-btn danger' })}</div>`
++ : html`<div class="plan-actions">${btn('leave', 'Leave this household', { cls: 'sc-btn danger' })}</div>`}`,
++ }, status);
++ }
++
++ r.get('/plan/p/:id', async (req, res) => {
++ if (!signedIn(req)) return toLogin(req, res);
++ const got = await loadPlan(req, res);
++ if (got) await planPage(req, res, got);
++ });
++
++ r.post('/plan/p/:id/:action', form, async (req, res) => {
++ if (!signedIn(req)) return toLogin(req, res);
++ if (!sameOrigin(req, config.baseUrl)) return notOurs(req, res);
++ const got = await loadPlan(req, res);
++ if (!got) return undefined;
++ const { row, role } = got;
++ const body = req.body || {};
++ const back = () => res.redirect(303, `/plan/p/${row.id}`);
++ switch (req.params.action) {
++ case 'tick': {
++ const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
++ const item = str(body.item, 80);
++ if (!item || !plan.shopping_list.some((i) => i.slug === item)) return res.status(400).type('text/plain').send('That is not on this shopping list.');
++ if (body.done === '0') await store.untick(s, row.id, item);
++ else await store.tick(s, row.id, item, ownerOf(req), nameOf(req), s.iso());
++ return back();
++ }
++ case 'leave':
++ if (role === 'member') await store.removeMember(s, row.id, ownerOf(req));
++ return res.redirect(303, '/plan/saved');
++ case 'share': case 'unshare': case 'remove': case 'delete':
++ if (role !== 'owner') return res.status(403).type('text/plain').send('Only the plan’s owner changes the household.');
++ break;
++ default:
++ return res.status(404).type('text/plain').send('Not found.');
++ }
++ if (req.params.action === 'share') {
++ const secret = crypto.randomBytes(32).toString('base64url');
++ await store.setShareHash(s, ownerOf(req), row.id, hashOf(secret));
++ return planPage(req, res, { row: { ...row, share_hash: 'on' }, role }, { link: `${config.baseUrl}/plan/join/${secret}` });
++ }
++ if (req.params.action === 'unshare') await store.setShareHash(s, ownerOf(req), row.id, null);
++ if (req.params.action === 'remove') { const m = str(body.member, 80); if (m) await store.removeMember(s, row.id, m); }
++ if (req.params.action === 'delete') { await store.deletePlan(s, ownerOf(req), row.id); return res.redirect(303, '/plan/saved'); }
++ return back();
++ });
++
++ async function joinTarget(req, res) {
++ const secret = String(req.params.secret || '');
++ const row = SECRET.test(secret) ? await store.planByShareHash(s, hashOf(secret)) : null;
++ if (row) return row;
++ page(req, res, { title: 'Link not working', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Join' }],
++ body: html`<h1>This link does not work any more</h1><p>Its owner turned it off or made a new one. Ask them for the new link.</p><p><a href="/plan">Make your own plan</a></p>` }, 404);
++ return null;
++ }
++
++ r.get('/plan/join/:secret', async (req, res) => {
++ if (!signedIn(req)) return toLogin(req, res);
++ const row = await joinTarget(req, res);
++ if (!row) return undefined;
++ if (await store.planForViewer(s, ownerOf(req), row.id)) return res.redirect(303, `/plan/p/${row.id}`);
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ return page(req, res, {
++ title: 'Join a household', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Join' }], ...PLAN_STYLES,
++ body: html`<h1>Join “${planTitle(row)}”?</h1>
++<p class="lede">You will see this meal plan (${row.people} people, ${row.days} days) and tick its shopping list off with everyone in the household. The household sees your name, ${nameOf(req)}. You can leave at any time.</p>
++<form method="post" action="/plan/join/${req.params.secret}"><button class="sc-btn sc-primary" type="submit">Join the household</button></form>`,
++ });
++ });
++
++ r.post('/plan/join/:secret', form, async (req, res) => {
++ if (!signedIn(req)) return toLogin(req, res);
++ if (!sameOrigin(req, config.baseUrl)) return notOurs(req, res);
++ const row = await joinTarget(req, res);
++ if (!row) return undefined;
++ if (row.owner !== ownerOf(req)) {
++ const out = await store.addMember(s, row.id, ownerOf(req), nameOf(req), s.iso());
++ if (out === 'food.plan.full' || out === 'food.plan.too_many') {
++ return page(req, res, { title: 'Could not join', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Join' }],
++ body: html`<h1>Could not join</h1><p>${out === 'food.plan.full' ? `This household is full (${store.MEMBERS_MAX} people besides its owner).` : `You are already in ${store.SHARED_PLANS_MAX} households; leave one first.`}</p><p><a href="/plan/saved">Your plans</a></p>` }, 409);
++ }
++ }
++ return res.redirect(303, `/plan/p/${row.id}`);
++ });
++
// ── The food list ────────────────────────────────────────
r.get('/foods', (req, res) => {
const group = foods.GROUPS[req.query.group] ? req.query.group : null;
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 0be71ad..3b590dc 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -1,16 +1,17 @@
'use strict';
/**
-- * Account export and deletion → Food (ADR-033; openvibe-sdk/account-data). Food holds three things a person made:
-- * a saved meal plan, their pantry list and their recipe suggestions, each keyed by the person (plans and pantry under
-- * `owner = user:usr_…`, recipe suggestions under `subject_id = user:usr_…`). All three are the person's own — nothing
-- * anyone else hangs under them — so all three are deleted whole and nothing is kept.
++ * Account export and deletion → Food (ADR-033; openvibe-sdk/account-data). Food holds five things of a person's:
++ * their saved meal plans, their pantry list and their recipe suggestions (plans and pantry under
++ * `owner = user:usr_…`, recipe suggestions under `subject_id = user:usr_…`), and, from plan together, the households
++ * they joined (`member`) and the shopping-list items they ticked (`ticked_by`). All are deleted whole and nothing is
++ * kept: a plan's own members and ticks go with it (ON DELETE CASCADE); a member's tick on someone else's plan goes,
++ * and that item shows as not yet got.
*
-- * network.account.export_requested the person's plans (plans.json), pantry (pantry.json) and recipe suggestions
-- * (recipe-suggestions.json), newest first, pushed to Network
-- * (POST /internal/account-exports/:id/parts) with this service's token.
-- * network.account.deleted the person's plans, pantry and recipe suggestions go, and Food confirms with
-- * counts.
++ * network.account.export_requested plans.json, pantry.json, recipe-suggestions.json, households.json and
++ * shopping-ticks.json, pushed to Network (POST /internal/account-exports/:id/parts)
++ * with this service's token.
++ * network.account.deleted the same rows go, and Food confirms with counts.
*
* The Nominatim/Overpass cache (food_geo_cache) holds no person's rows: it is keyed by the query, not by a subject, so
* it is neither exported nor erased (see migrations/0002_food.sql). Nothing here is a secret: no token, key or
@@@ -19,13 +20,17 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
/**
-- * The tables that hold a person's rows, with the value the subject column really stores. All three store
++ * The tables that hold a person's rows, with the value the subject column really stores. All five store
* `user:usr_…` (server/food/store.js takes the owner straight from the principal's requester).
*/
const TABLES = [
{ table: 'food_plans', subject: 'owner', value: (usr) => `user:${usr}`, file: 'plans.json' },
{ table: 'food_pantry', subject: 'owner', value: (usr) => `user:${usr}`, file: 'pantry.json' },
{ table: 'food_recipe_suggestions', subject: 'subject_id', value: (usr) => `user:${usr}`, file: 'recipe-suggestions.json' },
++ // Plan together: the households a person joined, and the shopping-list items they ticked on any plan. Their own
++ // plans' members and ticks go with the plan (ON DELETE CASCADE).
++ { table: 'food_plan_members', subject: 'member', value: (usr) => `user:${usr}`, file: 'households.json' },
++ { table: 'food_plan_ticks', subject: 'ticked_by', value: (usr) => `user:${usr}`, file: 'shopping-ticks.json' },
];
/** The account-data handle for Food's store (server/db.js createStore). */
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex c28b5d5..bbde7f6 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -52,6 +52,15 @@ async function makeRows(t, subject, title) {
await foodStore.insertRecipeSuggestion(s, { id: s.newId('rcp'), subject_id: `user:${subject}`, created_at: s.iso(), ask: `ask-${title}`, people: 2, ai_run_id: 'run_1', recipes: [{ title }] });
}
++/** Plan together: A joins B's household and ticks an item off B's shopping list. */
++async function joinB(t) {
++ const s = t.ctx.s;
++ const bPlan = (await s.db.maybe('SELECT id FROM food_plans WHERE owner = $1', [`user:${B}`])).id;
++ await foodStore.addMember(s, bPlan, `user:${A}`, 'Person A', s.iso());
++ await foodStore.tick(s, bPlan, 'oats', `user:${A}`, 'Person A', s.iso());
++ return bPlan;
++}
++
(async () => {
await check('an export part carries only the person\'s plans and pantry, and no secret', async () => {
const stub = await startNetworkStub();
@@@ -60,6 +69,7 @@ async function makeRows(t, subject, title) {
try {
await makeRows(t, A, 'person-a');
await makeRows(t, B, 'person-b');
++ const bPlan = await joinB(t);
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
assert.strictEqual(res.status, 200);
@@@ -69,7 +79,9 @@ async function makeRows(t, subject, title) {
assert.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_food', 'with this service\'s own token');
assert.strictEqual(part.body.subject, A);
-- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['pantry.json', 'plans.json', 'recipe-suggestions.json']);
++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['households.json', 'pantry.json', 'plans.json', 'recipe-suggestions.json', 'shopping-ticks.json']);
++ assert.deepStrictEqual(part.body.files.find((f) => f.name === 'households.json').content.map((m) => m.plan_id), [bPlan], 'the household A joined');
++ assert.deepStrictEqual(part.body.files.find((f) => f.name === 'shopping-ticks.json').content.map((x) => x.item), ['oats'], 'what A ticked');
const plans = part.body.files.find((f) => f.name === 'plans.json').content;
assert.strictEqual(plans.length, 1);
assert.strictEqual(plans[0].title, 'person-a');
@@@ -88,6 +100,7 @@ async function makeRows(t, subject, title) {
try {
await makeRows(t, A, 'person-a');
await makeRows(t, B, 'person-b');
++ const bPlan = await joinB(t);
const gone = async (subject) => await t.ctx.s.db.value('SELECT count(*)::int FROM food_plans WHERE owner = $1', [`user:${subject}`]);
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
@@@ -97,10 +110,12 @@ async function makeRows(t, subject, title) {
assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_pantry WHERE owner = $1', [`user:${A}`]), 0, 'the pantry is gone');
assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_recipe_suggestions WHERE subject_id = $1', [`user:${A}`]), 0, 'the recipe suggestions are gone');
assert.strictEqual(await gone(B), 1, 'someone else\'s plan stays');
++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_members WHERE plan_id = $1', [bPlan]), 0, 'A left B\'s household');
++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_ticks WHERE plan_id = $1', [bPlan]), 0, 'A\'s tick on B\'s list is gone');
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
assert.ok(confirmation, 'the confirmation was sent');
-- assert.deepStrictEqual(confirmation.body.erased, { food_plans: 1, food_pantry: 1, food_recipe_suggestions: 1 });
++ assert.deepStrictEqual(confirmation.body.erased, { food_plans: 1, food_pantry: 1, food_recipe_suggestions: 1, food_plan_members: 1, food_plan_ticks: 1 });
assert.deepStrictEqual(confirmation.body.retained, {});
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex d960a1c..3709b5f 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -14,7 +14,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// headroom; file counts are the measured counts and external files stay at 0.
const BUDGETS = {
-- htmlRawKB: 28.5, // measured 27.6 with Shared 4.1.0's manifest + apple-touch-icon links (24.9 before; the home page: hero, features, steps, cta, JSON-LD)
++ htmlRawKB: 29, // measured 28.7 with plan together as the sixth feature (2026-10-15); 27.6 with Shared 4.1.0's manifest + apple-touch-icon links (24.9 before; the home page: hero, features, steps, cta, JSON-LD)
htmlBrotliKB: 7.3, // 7.0 (6.2 before 4.1.0)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
ddiff --git a/test/plan-together.test.js b/test/plan-together.test.js
nnew file mode 100644
iindex 0000000..f0b7c47
---- /dev/null
++++ b/test/plan-together.test.js
@@@ -0,0 +1,123 @@
++'use strict';
++/**
++ * Plan together (migrations/0005_plan_households.sql, server/http/pages.js): a saved plan has its own page; its
++ * owner makes a share link (shown once, only its hash kept); someone signed in who opens it and says yes joins the
++ * household, sees the plan and ticks the shopping list off for everyone; only the owner shares, rotates, turns the
++ * link off, removes people or deletes the plan; a member can leave; every change is a same-origin POST; nobody else
++ * sees the plan; and the private pages stay out of robots.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++const CROSS = { 'sec-fetch-site': 'cross-site' };
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ const ana = t.network.addUser('ana');
++ let id = null;
++ let secret = null;
++ let item = null;
++ const linkOf = (text) => (/https:\/\/openvibe\.food\/plan\/join\/([A-Za-z0-9_-]{43})/.exec(text) || [])[1] || null;
++ try {
++ await check('a saved plan has its own page, for its owner only', async () => {
++ const posted = await t.get('/plan', { as: kim, method: 'POST', form: { people: '3', days: '2', title: 'Our week' }, headers: SAME });
++ assert.strictEqual(posted.status, 303);
++ id = new URL(posted.headers.get('location'), t.base).searchParams.get('saved');
++ assert.match(id, /^pln_[0-9A-Z]{26}$/);
++ const back = await t.get(posted.headers.get('location'), { as: kim });
++ assert.ok(back.text.includes(`href="/plan/p/${id}"`), 'the saved notice links the plan');
++
++ const mine = await t.get(`/plan/p/${id}`, { as: kim });
++ assert.strictEqual(mine.status, 200);
++ assert.match(mine.headers.get('cache-control'), /private/);
++ assert.ok(mine.text.includes('Our week') && mine.text.includes('The household') && mine.text.includes('Make a share link'));
++ item = (/name="item" value="([a-z0-9-]+)"/.exec(mine.text) || [])[1];
++ assert.ok(item, 'the shopping list has tick buttons');
++ assert.ok(mine.text.includes('css/plan.css') || mine.text.includes('plan.css'), 'its own stylesheet');
++
++ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: lee })).status, 404, 'not shared: not found');
++ const out = await t.get(`/plan/p/${id}`);
++ assert.strictEqual(out.status, 303);
++ assert.match(out.headers.get('location'), /^\/auth\/login\?next=%2Fplan%2Fp%2F/);
++ const listed = await t.get('/plan/saved', { as: kim });
++ assert.ok(listed.text.includes(`/plan/p/${id}`), 'the saved list links it');
++ });
++
++ await check('the owner makes a share link, shown once; someone signed in joins with it', async () => {
++ assert.strictEqual((await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: CROSS })).status, 403, 'not from another site');
++ const shared = await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: SAME });
++ assert.strictEqual(shared.status, 200);
++ secret = linkOf(shared.text);
++ assert.ok(secret, 'the link is on the page');
++ assert.ok(!(await t.dbDump()).includes(secret), 'only its hash is stored');
++ assert.ok(!linkOf((await t.get(`/plan/p/${id}`, { as: kim })).text), 'shown once');
++
++ const ask = await t.get(`/plan/join/${secret}`, { as: lee });
++ assert.strictEqual(ask.status, 200);
++ assert.ok(ask.text.includes('Join “Our week”?') && ask.text.includes('Join the household'));
++ const joined = await t.get(`/plan/join/${secret}`, { as: lee, method: 'POST', form: {}, headers: SAME });
++ assert.strictEqual(joined.status, 303);
++ assert.strictEqual(joined.headers.get('location'), `/plan/p/${id}`);
++ const theirs = await t.get(`/plan/p/${id}`, { as: lee });
++ assert.strictEqual(theirs.status, 200);
++ assert.ok(theirs.text.includes('Shared with you') && theirs.text.includes('Leave this household'));
++ assert.ok(!theirs.text.includes('Make a new share link'), 'a member does not share');
++ assert.ok((await t.get('/plan/saved', { as: lee })).text.includes('Shared with you'));
++ const again = await t.get(`/plan/join/${secret}`, { as: lee });
++ assert.strictEqual(again.status, 303, 'a member opening the link goes to the plan');
++ });
++
++ await check('a tick shows for everyone; only items on the list; a member cannot change the household', async () => {
++ const ticked = await t.get(`/plan/p/${id}/tick`, { as: lee, method: 'POST', form: { item, done: '1' }, headers: SAME });
++ assert.strictEqual(ticked.status, 303);
++ const seen = await t.get(`/plan/p/${id}`, { as: kim });
++ assert.ok(/<li class="got">/.test(seen.text) && seen.text.includes('got by'), 'the owner sees the tick');
++ assert.ok(seen.text.includes('1 of '), 'and the count');
++ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: lee, method: 'POST', form: { item: 'not-on-the-list', done: '1' }, headers: SAME })).status, 400);
++ await t.get(`/plan/p/${id}/tick`, { as: kim, method: 'POST', form: { item, done: '0' }, headers: SAME });
++ assert.ok(!/<li class="got">/.test((await t.get(`/plan/p/${id}`, { as: lee })).text), 'untick shows for everyone too');
++ for (const action of ['share', 'unshare', 'remove', 'delete']) {
++ assert.strictEqual((await t.get(`/plan/p/${id}/${action}`, { as: lee, method: 'POST', form: { member: 'x' }, headers: SAME })).status, 403, action);
++ }
++ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: ana, method: 'POST', form: { item, done: '1' }, headers: SAME })).status, 404, 'an outsider ticks nothing');
++ });
++
++ await check('a new link replaces the old one; turning it off stops joins; removed and leaving people lose the plan', async () => {
++ const fresh = linkOf((await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: SAME })).text);
++ assert.ok(fresh && fresh !== secret);
++ assert.strictEqual((await t.get(`/plan/join/${secret}`, { as: ana })).status, 404, 'the old link is dead');
++ assert.strictEqual((await t.get(`/plan/join/${fresh}`, { as: ana, method: 'POST', form: {}, headers: SAME })).status, 303);
++ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: ana })).status, 200);
++ await t.get(`/plan/p/${id}/unshare`, { as: kim, method: 'POST', form: {}, headers: SAME });
++ assert.strictEqual((await t.get(`/plan/join/${fresh}`, { as: ana })).status, 404, 'turned off');
++ assert.strictEqual((await t.get(`/plan/join/${'x'.repeat(43)}`, { as: ana })).status, 404);
++
++ const page = (await t.get(`/plan/p/${id}`, { as: kim })).text;
++ const lees = (/name="member" value="(user:[^"]+)"/.exec(page) || [])[1];
++ assert.ok(lees);
++ await t.get(`/plan/p/${id}/remove`, { as: kim, method: 'POST', form: { member: lees }, headers: SAME });
++ const both = [(await t.get(`/plan/p/${id}`, { as: lee })).status, (await t.get(`/plan/p/${id}`, { as: ana })).status].sort();
++ assert.deepStrictEqual(both, [200, 404], 'one removed, one still in');
++ const stay = (await t.get(`/plan/p/${id}`, { as: lee })).status === 200 ? lee : ana;
++ assert.strictEqual((await t.get(`/plan/p/${id}/leave`, { as: stay, method: 'POST', form: {}, headers: SAME })).headers.get('location'), '/plan/saved');
++ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: stay })).status, 404, 'left');
++ });
++
++ await check('deleting the plan deletes its household and ticks; the private pages stay out of robots.txt', async () => {
++ const link = linkOf((await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: SAME })).text);
++ await t.get(`/plan/join/${link}`, { as: lee, method: 'POST', form: {}, headers: SAME });
++ await t.get(`/plan/p/${id}/tick`, { as: lee, method: 'POST', form: { item, done: '1' }, headers: SAME });
++ const del = await t.get(`/plan/p/${id}/delete`, { as: kim, method: 'POST', form: {}, headers: SAME });
++ assert.strictEqual(del.headers.get('location'), '/plan/saved');
++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_members WHERE plan_id = $1', [id]), 0);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_ticks WHERE plan_id = $1', [id]), 0);
++ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: lee })).status, 404);
++ const robots = (await t.get('/robots.txt')).text;
++ assert.ok(robots.includes('Disallow: /plan/p/') && robots.includes('Disallow: /plan/join/'));
++ });
++ } finally { await t.close(); }
++ done();
++})();