diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 42cebf1..90f454f 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -34,6 +34,7 @@ jobs: - run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export - run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books - run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote + - run: node tools/pantry-test.js # pantry: counts, low levels, barcodes (several per item), kids can only take - run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy - run: node tools/signal-test.js # Signal: linking, messages, sending, live updates diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md index 5effb22..fe5cd0f 100644 --- a/docs/v2-roadmap.md +++ b/docs/v2-roadmap.md @@ -46,7 +46,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup, - [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos) - [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.** - [x] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.** -- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry counts are kept by hand; they do not go down when you cook.** +- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry counts are kept by hand and by scanning; they do not go down when you cook. The Pantry lives at Recipes > Pantry and works without Mealie. A phone can scan a barcode to add or use one (several barcodes can belong to one item, so different brands of corn share a count), and a Take one screen lets anyone, children included, tap an item to take one; children can do nothing else to the pantry.** - [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).** ## 3. Kids and safety diff --git a/pantry.js b/pantry.js new file mode 100644 index 0000000..807f9ca --- /dev/null +++ b/pantry.js @@ -0,0 +1,98 @@ +// The pantry: what the family keeps in the kitchen, one shared list. Each item has a name, how many are left (blank when you do not count it), the level it counts as running low +// at (blank: never), and any number of barcodes linked to it, so Green Giant corn and the store brand are both "Corn" with one count. Scanning a barcode (on a phone) looks the product +// up in Open Food Facts (through this server, so the phone never contacts it) and adds or uses one; the first time a barcode is seen the person says which item it is. +// An item going low starts the "pantry item runs low" automations. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const users = require('./users'); +const { readBody } = require('./body'); + +const DATA = () => process.env.DATA_DIR || path.join(__dirname, 'data'); +const file = () => path.join(DATA(), 'pantry.json'), cacheFile = () => path.join(DATA(), 'barcode-cache.json'); +const OFF = () => (process.env.OPENFOODFACTS_URL || 'https://world.openfoodfacts.org').replace(/\/+$/, ''); +const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const fail = (code, message) => Object.assign(new Error(message), { code }); +const num = v => v === '' || v == null || !isFinite(+v) || +v < 0 ? null : Math.min(100000, Math.round(+v * 100) / 100); +const CODE = /^\d{8,14}$/; + +const read = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return (Array.isArray(a) ? a : []).map(x => typeof x === 'string' ? { id: crypto.randomBytes(4).toString('hex'), name: x, qty: null, low: null, codes: [] } : { ...x, codes: Array.isArray(x.codes) ? x.codes : [] }).filter(x => x && x.name); } catch { return []; } }; +const write = l => atomicWrite(file(), JSON.stringify(l), { mode: 0o600 }); +const isLow = x => x.qty != null && x.low != null && x.qty <= x.low; +const view = l => l.map(x => ({ id: x.id, name: x.name, qty: x.qty, low: x.low, isLow: isLow(x), codes: x.codes })).sort((a, b) => b.isLow - a.isLow || a.name.localeCompare(b.name)); +// Who took what: the last few uses, so parents can see where the snacks went. +const logFile = () => path.join(DATA(), 'pantry-log.json'); +const readLog = () => { try { const a = JSON.parse(fs.readFileSync(logFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } }; +function logUse(me, it, delta) { const l = readLog(); l.unshift({ t: Date.now(), who: me.name, item: it.name, delta }); atomicWrite(logFile(), JSON.stringify(l.slice(0, 60)), { mode: 0o600 }); } +const isKid = u => !!(u && u.kid && u.kid.on); +/** An item has just gone low: tell the automations of everyone who asked to hear about it. */ +function lowNow(it) { try { require('./automations').fireAll('pantry.low', { title: it.name }); } catch (e) { console.warn('[pantry] rules failed:', e.message); } } +/** Change an item's count, firing the "went low" rules when it crosses into low. */ +function bump(it, delta) { const before = isLow(it); it.qty = Math.max(0, Math.round(((it.qty || 0) + delta) * 100) / 100); if (isLow(it) && !before) lowNow(it); } + +// ---- matching words: "3 cups flour" has "flour"; "Green Giant Sweet Corn" has "Corn" +const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, ''); +const wordsOf = t => (String(t).toLowerCase().match(/[a-z]+/g) || []).map(stem); +/** The pantry names (from items or strings) that every word of is in `text`. */ +function inPantry(text, items) { const have = new Set(wordsOf(text)); return items.map(x => typeof x === 'string' ? x : x.name).filter(n => { const ws = wordsOf(n); return ws.length && ws.every(w => have.has(w)); }); } +/** A short name for a product: the brand and the size taken off ("Heinz Tomato Ketchup 500 ml" -> "Tomato Ketchup"). */ +function shortName(name, brand) { + let n = String(name || '').replace(/\b\d+([.,]\d+)?\s?(x\s?\d+\s?)?(ml|cl|l|g|kg|oz|fl\.?\s?oz|lb|lbs|ct|count|pack|pk)\b\.?/gi, ' '); + for (const b of String(brand || '').split(',').map(x => x.trim()).filter(Boolean)) n = n.replace(new RegExp('\\b' + b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'ig'), ' '); + n = n.replace(/[()\[\],]/g, ' ').replace(/\s+/g, ' ').trim(); return (n || String(name || '').trim()).replace(/\b\w/g, c => c.toUpperCase()).slice(0, 60); +} + +// ---- the product database (Open Food Facts), remembered so a barcode is only looked up once +const loadCache = () => { try { return JSON.parse(fs.readFileSync(cacheFile(), 'utf8')) || {}; } catch { return {}; } }; +async function product(code) { + const c = loadCache(), hit = c[code]; if (hit && (hit.found || Date.now() - hit.t < 24 * 3600e3)) return hit.found ? hit : null; // (a miss is tried again after a day) + let found = null; + try { + const r = await fetch(`${OFF()}/api/v2/product/${code}.json?fields=product_name,brands,quantity,generic_name`, { headers: { 'user-agent': 'NE-Hub/1.0 (family pantry)', accept: 'application/json' }, signal: AbortSignal.timeout(6000) }); + if (r.ok) { const j = await r.json(), p = j && j.status === 1 && j.product; if (p && (p.product_name || p.generic_name)) found = { name: String(p.product_name || p.generic_name).slice(0, 120), brand: String(p.brands || '').slice(0, 80), quantity: String(p.quantity || '').slice(0, 30) }; } + } catch { return undefined; } // (could not ask: not remembered) + c[code] = found ? { found: true, t: Date.now(), ...found } : { found: false, t: Date.now() }; + const keys = Object.keys(c); if (keys.length > 3000) for (const k of keys.sort((a, b) => c[a].t - c[b].t).slice(0, 500)) delete c[k]; + fs.mkdirSync(DATA(), { recursive: true }); atomicWrite(cacheFile(), JSON.stringify(c), { mode: 0o600 }); return found ? c[code] : null; +} + +async function handle(req, res) { + const me = users.current(), u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/pantry'.length) || '/'; + try { + if (!me) throw fail(401, 'Sign in'); + if (p === '/' && req.method === 'GET') return json(res, 200, { items: view(read()), recent: readLog().slice(0, 12), kid: isKid(me) }); + if (p === '/lookup' && req.method === 'GET') { // what is this barcode? Linked to an item already, or which products and items it might be + const code = u.searchParams.get('code') || ''; if (!CODE.test(code)) throw fail(400, 'That is not a barcode number (8 to 14 digits)'); + const l = read(), linked = l.find(x => x.codes.includes(code)); if (linked) return json(res, 200, { code, linked: { id: linked.id, name: linked.name } }); + const pr = await product(code), name = pr ? shortName(pr.name, pr.brand) : ''; + return json(res, 200, { code, linked: null, product: pr ? { name: pr.name, brand: pr.brand, quantity: pr.quantity, short: name } : null, lookedUp: pr !== undefined, matches: pr ? view(l.filter(x => inPantry(pr.name, [x.name]).length)).map(x => ({ id: x.id, name: x.name })) : [] }); + } + if (req.method !== 'POST') throw fail(404, 'Not found'); + const b = JSON.parse(await readBody(req, 4e3) || '{}'), l = read(); + if (p === '/use') { // the simple way: take one (or put it back, to undo). Open to everyone, children included. + const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); const delta = b.undo ? 1 : -1; + if (it.qty != null) { if (delta < 0 && it.qty <= 0) throw fail(409, 'There is none left of ' + it.name); bump(it, delta); write(l); } + logUse(me, it, b.undo ? 0 : -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, isLow: isLow(it) }, items: view(l), recent: readLog().slice(0, 12) }); + } + if (isKid(me) && !(p === '/scan' && b.mode === 'use' && l.some(x => x.codes.includes(String(b.code))))) throw fail(403, 'Ask a grown-up to change the pantry. You can tap an item to take one.'); + if (p === '/item') { // add an item, or change one (the same name changes the existing item) + const name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the item a name'); + const i = b.id ? l.findIndex(x => x.id === b.id) : l.findIndex(x => x.name.toLowerCase() === name.toLowerCase()); if (b.id && i < 0) throw fail(404, 'That item is gone'); if (i < 0 && l.length >= 400) throw fail(400, 'The pantry is full'); + const before = i >= 0 ? isLow(l[i]) : false, it = { id: i >= 0 ? l[i].id : crypto.randomBytes(4).toString('hex'), name: i >= 0 && !b.id ? l[i].name : name, qty: num(b.qty), low: num(b.low), codes: i >= 0 ? l[i].codes : [] }; + if (i >= 0) l[i] = it; else l.push(it); write(l); if (isLow(it) && !before) lowNow(it); return json(res, 200, { items: view(l) }); + } + if (p === '/adjust') { const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); bump(it, +b.delta || 0); write(l); return json(res, 200, { items: view(l) }); } + if (p === '/delete') { const k = l.filter(x => x.id !== b.id); write(k); return json(res, 200, { items: view(k) }); } + if (p === '/scan') { // one scanned: add one ("add") or use one ("use"). A new barcode must say which item it is (itemId) or name a new one (name). + const code = String(b.code || ''); if (!CODE.test(code)) throw fail(400, 'That is not a barcode number (8 to 14 digits)'); const delta = b.mode === 'use' ? -1 : 1; + let it = l.find(x => x.codes.includes(code)), made = false; + if (!it && b.itemId) { it = l.find(x => x.id === b.itemId); if (!it) throw fail(404, 'That item is gone'); } + if (!it && String(b.name || '').trim()) { const name = String(b.name).replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); it = l.find(x => x.name.toLowerCase() === name.toLowerCase()); if (!it) { if (l.length >= 400) throw fail(400, 'The pantry is full'); it = { id: crypto.randomBytes(4).toString('hex'), name, qty: 0, low: null, codes: [] }; l.push(it); made = true; } } + if (!it) throw fail(400, 'Say which item this is'); + if (!it.codes.includes(code)) { for (const x of l) x.codes = x.codes.filter(c => c !== code); it.codes.push(code); it.codes = it.codes.slice(-30); } + bump(it, delta); write(l); if (delta < 0) logUse(me, it, -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, isLow: isLow(it) }, made, items: view(l), recent: readLog().slice(0, 12) }); + } + if (p === '/unlink') { const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); it.codes = it.codes.filter(c => c !== String(b.code)); write(l); return json(res, 200, { items: view(l) }); } + throw fail(404, 'Not found'); + } catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); } +} +module.exports = { handle, read, isLow, inPantry, shortName }; diff --git a/public/index.html b/public/index.html index 9ab2e84..eab398b 100644 --- a/public/index.html +++ b/public/index.html @@ -81,6 +81,7 @@ + diff --git a/public/pantry-view.js b/public/pantry-view.js new file mode 100644 index 0000000..8224220 --- /dev/null +++ b/public/pantry-view.js @@ -0,0 +1,91 @@ +'use strict'; +// ---- Pantry (Recipes > Pantry): what you keep in the kitchen, how many are left, and the level that counts as running low. A phone can scan a barcode to add one or use one; +// the first time a barcode is seen you say which item it is (so Green Giant corn and the store brand are both "Corn"). Low items go on the shopping list in a tap. +const paApi = (p, o) => jfetch('/api/pantry' + p, o), paOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) }); +const paAgo = t => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { weekday: 'short' }); }; +const paRecent = r => r.length ? `
${esc(e.message)}
Tap what you are taking. It counts one off so the family knows when to buy more.
+Nothing in the pantry yet.
'}${esc(e.message)}
Counts are kept by hand (and by scanning); they do not go down when you cook. The meal plan points out ingredients you already have.
${paRecent(recent)}Starting the camera…
+ `); + const video = el.querySelector('#pscv'), status = t => { const s = el.querySelector('#pscs'); if (s) s.textContent = t; }; + let stream = null, stopped = false, busy = false, last = '', lastAt = 0, reader = null, timer = 0; + const stop = () => { stopped = true; clearInterval(timer); try { reader && reader.reset(); } catch { /* not started */ } if (stream) stream.getTracks().forEach(t => t.stop()); }; + const alive = () => !stopped && el.isConnected; + const resume = () => { busy = false; if (alive()) { el.querySelector('#pscq').innerHTML = ''; status('Point the camera at a barcode'); } }; + const finish = (r, said) => { onChange(r.items); status(said); toast(said); try { navigator.vibrate && navigator.vibrate(60); } catch { /* no vibration */ } setTimeout(resume, 1300); }; + const found = async code => { + if (busy || !alive() || (code === last && Date.now() - lastAt < 4000)) return; busy = true; last = code; lastAt = Date.now(); status('Looking up ' + code + '…'); + try { + const r = await paApi('/lookup?code=' + code); if (!alive()) return; + if (r.linked) { const o = await paApi('/scan', paOpt({ code, mode })); return finish(o, `${o.item.name}: ${o.item.qty} left${o.item.isLow ? ' (running low)' : ''}`); } + const list = (getItems() || []), matches = (r.matches || []).map(m => m.id), sorted = [...list.filter(x => matches.includes(x.id)), ...list.filter(x => !matches.includes(x.id))]; + el.querySelector('#pscq').innerHTML = ``; + const sel = el.querySelector('#pscw'), nw = el.querySelector('#pscnw'); sel.onchange = () => { nw.hidden = !!sel.value; }; if (sel.value === '' ) nw.hidden = false; status('Which item is this?'); + el.querySelector('#pscx').onclick = resume; + el.querySelector('#pscf').onsubmit = async e => { e.preventDefault(); try { const o = await paApi('/scan', paOpt({ code, mode, itemId: sel.value || undefined, name: sel.value ? undefined : el.querySelector('#pscnn').value })); finish(o, `${o.item.name}: ${o.item.qty} left${o.made ? ' (new item)' : ''}`); } catch (er) { el.querySelector('#psce').textContent = er.message; } }; + } catch (e) { status(e.message); toast(e.message); setTimeout(resume, 1500); } + }; + el.querySelector('#pscm').onsubmit = e => { e.preventDefault(); const c = el.querySelector('#pscn').value.replace(/\D/g, ''); el.querySelector('#pscn').value = ''; if (/^\d{8,14}$/.test(c)) { last = ''; found(c); } else status('A barcode is 8 to 14 digits.'); }; + (async () => { + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return status('This browser cannot use the camera here (it needs a secure page). Type the number instead.'); + try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); } catch (e) { return status(e && e.name === 'NotAllowedError' ? 'The camera is blocked. Allow it for this site, or type the number instead.' : 'No camera could be started. Type the number instead.'); } + if (!alive()) return stop(); video.srcObject = stream; try { await video.play(); } catch { /* autoplay is allowed on a tap */ } status('Point the camera at a barcode'); + if ('BarcodeDetector' in window) { // Chrome and Android have a built-in reader + let det; try { det = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e'] }); } catch { det = null; } + if (det) { timer = setInterval(async () => { if (!alive()) return stop(); if (busy || video.readyState < 2) return; try { const r = await det.detect(video); if (r[0] && r[0].rawValue) found(r[0].rawValue); } catch { /* a frame that cannot be read */ } }, 300); return; } + } + try { // everything else (iPhone Safari): a reader written in JavaScript, loaded only now + const Z = await paLoadZx(), hints = new Map(); hints.set(Z.DecodeHintType.POSSIBLE_FORMATS, [Z.BarcodeFormat.EAN_13, Z.BarcodeFormat.EAN_8, Z.BarcodeFormat.UPC_A, Z.BarcodeFormat.UPC_E]); reader = new Z.BrowserMultiFormatReader(hints); + timer = setInterval(() => { if (!alive()) stop(); }, 500); + reader.decodeFromVideoElement(video, (res) => { if (res && alive() && !busy) found(res.getText()); }); + } catch (e) { status(e.message + ' Type the number instead.'); } + })(); + new MutationObserver((_, ob) => { if (!el.isConnected) { stop(); ob.disconnect(); } }).observe(document.body, { childList: true }); +} diff --git a/public/recipes-view.js b/public/recipes-view.js index df0612c..649fe06 100644 --- a/public/recipes-view.js +++ b/public/recipes-view.js @@ -4,7 +4,7 @@ const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts); const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; }; -const rcTabs = on => ``; +const rcTabs = on => ``; let rcQuery = '', rcPage = 1; async function viewRecipes(args = []) { @@ -12,12 +12,14 @@ async function viewRecipes(args = []) { v.innerHTML = 'Loading…
${esc(e.message)}
${esc(e.message)}
You may already have ${esc(names.slice(0, 8).join(', '))}${names.length > 8 ? '…' : ''} (${r.items.length} ingredient${r.items.length === 1 ? '' : 's'} this week). They still go on the shopping list; tick them off there.
`; }).catch(() => {}); $('#rcpshop').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/plan/shop', rcOpt('POST', { from, to })); toast(`Ingredients for ${o.recipes} recipe${o.recipes === 1 ? '' : 's'} added`, { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (er) { toast(er.message); } b.disabled = false; }; } @@ -137,22 +139,3 @@ function rcPlanPick(slug, name, rid, date) { let t = 0; const find = async () => { try { const r = await rcApi('/?page=1&q=' + encodeURIComponent(el.querySelector('#rcpq').value)); el.querySelector('#rcpr').innerHTML = r.items.slice(0, 8).map(x => `${esc(x.name)}`).join('') || '