openvibers/openvibe.food Commits
Food: the recipe library as a JSON feed (/recipes/library.json?since=) for OpenVibe.Blog's budget-recipes desk (#57)
Changes
@@@ -26,7 +26,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and| Pantry by barcode | [/pantry/barcode](server/http/pages.js), [server/food/barcode.js](server/food/barcode.js), [public/js/barcode-scan.js](public/js/barcode-scan.js) | Type a product's barcode, or scan it with the camera where the browser can read barcodes (the picture stays on the device). Open Food Facts names the product, and Food matches it to the item on its list it is (peanut butter is not butter, apple juice is not apples). The person confirms or picks another item before anything is added. Only the barcode is sent, with Food's User-Agent; answers are cached a day and capped at `FOOD_BARCODE_PER_MINUTE` (60) || Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route || Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |--| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one |++| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it || Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown || The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services || Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |@@@ -138,6 +138,12 @@ function createLibrary({ s, ai, log = console }) {const rows = await s.db.many('SELECT * FROM food_library_recipes WHERE status = ANY($1::text[]) ORDER BY created_at DESC, id LIMIT $2 OFFSET $3', [states, limit, offset]);return { total, recipes: rows.map(shape) };},++ /** Published recipes added at or after `since` (ISO), oldest first: the public feed OpenVibe.Blog's recipe desk reads. */++ async since(sinceIso, limit = 100) {++ const rows = await s.db.many("SELECT * FROM food_library_recipes WHERE status = 'published' AND created_at >= $1 ORDER BY created_at ASC, id LIMIT $2",++ [String(sinceIso || '1970-01-01T00:00:00.000Z'), Math.max(1, Math.min(200, limit))]);++ return rows.map(shape);++ },async reviewed() {return (await s.db.many("SELECT * FROM food_library_recipes WHERE status = 'published' AND reviewed_by IS NOT NULL ORDER BY created_at DESC")).map(shape);},@@@ -4,6 +4,8 @@** GET /recipes/library every recipe, newest first (24 a page)* GET /recipes/library/:slug one recipe: ingredients from the food list, steps, what to buy and its cost++ * GET /recipes/library.json?since= the published recipes added since an ISO time, oldest first (up to 100): the++ * feed OpenVibe.Blog's budget-recipes desk reads; public, like the pages* POST /recipes/library/:slug/review staff: a person checked it (indexed, with Recipe JSON-LD) or takes it back* POST /recipes/library/:slug/hide|show staff*@@@ -23,6 +25,23 @@ function mountLibrary(r, { ctx, page, config, signedIn, express }) {const isStaff = (req) => Boolean(signedIn(req) && req.viewer.role === 'admin');const meta = (x) => [x.serves ? `Serves ${x.serves}` : null, x.minutes ? `About ${x.minutes} min` : null, x.cost != null ? `${money(x.cost)} to buy${x.cost_partial ? '+' : ''}` : null].filter(Boolean).join(' · ');++ r.get('/recipes/library.json', async (req, res) => {++ const since = String(req.query.since || '');++ if (since && Number.isNaN(Date.parse(since))) return res.status(400).json({ error: 'since must be an ISO 8601 time' });++ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 100));++ const recipes = await lib.since(since ? new Date(since).toISOString() : null, limit);++ res.set('Cache-Control', 'public, max-age=300');++ return res.json({++ recipes: recipes.map((x) => ({++ slug: x.slug, url: `${config.baseUrl}/recipes/library/${x.slug}`, title: x.title, theme: x.theme,++ serves: x.serves, minutes: x.minutes, cost: x.cost, cost_partial: x.cost_partial,++ ingredients: x.ingredients.map((i) => i.name).filter(Boolean),++ reviewed: x.reviewed, created_at: x.created_at,++ })),++ note: AI_LINE,++ });++ });++r.get('/recipes/library', async (req, res) => {const n = parseInt(req.query.page, 10);const pageNo = Number.isInteger(n) && n > 0 ? Math.min(n, 500) : 1;@@@ -100,6 +100,21 @@ function aiStub() {assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1')).status, 200);assert.ok((await t.get('/recipes')).text.includes('href="/recipes/library"'));});++++ await check('the JSON feed: published recipes since a time, oldest first; hidden ones never; a bad time is refused', async () => {++ const all = await t.get('/recipes/library.json');++ assert.strictEqual(all.status, 200);++ const recipes = all.json().recipes;++ assert.ok(recipes.length >= 1);++ assert.ok(recipes.every((x) => x.url.endsWith(`/recipes/library/${x.slug}`) && Array.isArray(x.ingredients)));++ const times = recipes.map((x) => x.created_at);++ assert.deepStrictEqual(times, [...times].sort(), 'oldest first');++ assert.strictEqual((await t.get('/recipes/library.json?since=2999-01-01T00:00:00Z')).json().recipes.length, 0);++ assert.strictEqual((await t.get('/recipes/library.json?since=yesterday-ish')).status, 400);++ const one = recipes[0].slug;++ await t.get(`/recipes/library/${one}/hide`, { method: 'POST', as: boss, form: {}, headers: SAME });++ assert.ok(!(await t.get('/recipes/library.json')).json().recipes.some((x) => x.slug === one), 'hidden recipes are not in the feed');++ });} finally { await t.close(); }done();})();
@@ -26,7 +26,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
@@ -26,7 +26,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| Pantry by barcode | [/pantry/barcode](server/http/pages.js), [server/food/barcode.js](server/food/barcode.js), [public/js/barcode-scan.js](public/js/barcode-scan.js) | Type a product's barcode, or scan it with the camera where the browser can read barcodes (the picture stays on the device). Open Food Facts names the product, and Food matches it to the item on its list it is (peanut butter is not butter, apple juice is not apples). The person confirms or picks another item before anything is added. Only the barcode is sent, with Food's User-Agent; answers are cached a day and capped at `FOOD_BARCODE_PER_MINUTE` (60) |
| Pantry by barcode | [/pantry/barcode](server/http/pages.js), [server/food/barcode.js](server/food/barcode.js), [public/js/barcode-scan.js](public/js/barcode-scan.js) | Type a product's barcode, or scan it with the camera where the browser can read barcodes (the picture stays on the device). Open Food Facts names the product, and Food matches it to the item on its list it is (peanut butter is not butter, apple juice is not apples). The person confirms or picks another item before anything is added. Only the barcode is sent, with Food's User-Agent; answers are cached a day and capped at `FOOD_BARCODE_PER_MINUTE` (60) |
| Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route |
| Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route |
| Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |
| Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |
-| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one |
+| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one. `/recipes/library.json?since=` lists the published ones added since a time (oldest first, up to 100): OpenVibe.Blog's budget-recipes desk reads it |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |
| Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |
@@ -138,6 +138,12 @@ function createLibrary({ s, ai, log = console }) {
@@ -138,6 +138,12 @@ function createLibrary({ s, ai, log = console }) {
const rows = await s.db.many('SELECT * FROM food_library_recipes WHERE status = ANY($1::text[]) ORDER BY created_at DESC, id LIMIT $2 OFFSET $3', [states, limit, offset]);
const rows = await s.db.many('SELECT * FROM food_library_recipes WHERE status = ANY($1::text[]) ORDER BY created_at DESC, id LIMIT $2 OFFSET $3', [states, limit, offset]);
return { total, recipes: rows.map(shape) };
return { total, recipes: rows.map(shape) };
},
},
+ /** Published recipes added at or after `since` (ISO), oldest first: the public feed OpenVibe.Blog's recipe desk reads. */
+ async since(sinceIso, limit = 100) {
+ const rows = await s.db.many("SELECT * FROM food_library_recipes WHERE status = 'published' AND created_at >= $1 ORDER BY created_at ASC, id LIMIT $2",
+ [String(sinceIso || '1970-01-01T00:00:00.000Z'), Math.max(1, Math.min(200, limit))]);
+ return rows.map(shape);
+ },
async reviewed() {
async reviewed() {
return (await s.db.many("SELECT * FROM food_library_recipes WHERE status = 'published' AND reviewed_by IS NOT NULL ORDER BY created_at DESC")).map(shape);
return (await s.db.many("SELECT * FROM food_library_recipes WHERE status = 'published' AND reviewed_by IS NOT NULL ORDER BY created_at DESC")).map(shape);
},
},
@@ -4,6 +4,8 @@
@@ -4,6 +4,8 @@
*
*
* GET /recipes/library every recipe, newest first (24 a page)
* GET /recipes/library every recipe, newest first (24 a page)
* GET /recipes/library/:slug one recipe: ingredients from the food list, steps, what to buy and its cost
* GET /recipes/library/:slug one recipe: ingredients from the food list, steps, what to buy and its cost
+ * GET /recipes/library.json?since= the published recipes added since an ISO time, oldest first (up to 100): the
+ * feed OpenVibe.Blog's budget-recipes desk reads; public, like the pages
* POST /recipes/library/:slug/review staff: a person checked it (indexed, with Recipe JSON-LD) or takes it back
* POST /recipes/library/:slug/review staff: a person checked it (indexed, with Recipe JSON-LD) or takes it back
* POST /recipes/library/:slug/hide|show staff
* POST /recipes/library/:slug/hide|show staff
*
*
@@ -23,6 +25,23 @@ function mountLibrary(r, { ctx, page, config, signedIn, express }) {
@@ -23,6 +25,23 @@ function mountLibrary(r, { ctx, page, config, signedIn, express }) {
const isStaff = (req) => Boolean(signedIn(req) && req.viewer.role === 'admin');
const isStaff = (req) => Boolean(signedIn(req) && req.viewer.role === 'admin');
const meta = (x) => [x.serves ? `Serves ${x.serves}` : null, x.minutes ? `About ${x.minutes} min` : null, x.cost != null ? `${money(x.cost)} to buy${x.cost_partial ? '+' : ''}` : null].filter(Boolean).join(' · ');
const meta = (x) => [x.serves ? `Serves ${x.serves}` : null, x.minutes ? `About ${x.minutes} min` : null, x.cost != null ? `${money(x.cost)} to buy${x.cost_partial ? '+' : ''}` : null].filter(Boolean).join(' · ');
+ r.get('/recipes/library.json', async (req, res) => {
+ const since = String(req.query.since || '');
+ if (since && Number.isNaN(Date.parse(since))) return res.status(400).json({ error: 'since must be an ISO 8601 time' });
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 100));
+ const recipes = await lib.since(since ? new Date(since).toISOString() : null, limit);
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.json({
+ recipes: recipes.map((x) => ({
+ slug: x.slug, url: `${config.baseUrl}/recipes/library/${x.slug}`, title: x.title, theme: x.theme,
+ serves: x.serves, minutes: x.minutes, cost: x.cost, cost_partial: x.cost_partial,
+ ingredients: x.ingredients.map((i) => i.name).filter(Boolean),
+ reviewed: x.reviewed, created_at: x.created_at,
+ })),
+ note: AI_LINE,
+ });
+ });
+
r.get('/recipes/library', async (req, res) => {
r.get('/recipes/library', async (req, res) => {
const n = parseInt(req.query.page, 10);
const n = parseInt(req.query.page, 10);
const pageNo = Number.isInteger(n) && n > 0 ? Math.min(n, 500) : 1;
const pageNo = Number.isInteger(n) && n > 0 ? Math.min(n, 500) : 1;
@@ -100,6 +100,21 @@ function aiStub() {
@@ -100,6 +100,21 @@ function aiStub() {
assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1')).status, 200);
assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1')).status, 200);
assert.ok((await t.get('/recipes')).text.includes('href="/recipes/library"'));
assert.ok((await t.get('/recipes')).text.includes('href="/recipes/library"'));
});
});
+
+ await check('the JSON feed: published recipes since a time, oldest first; hidden ones never; a bad time is refused', async () => {
+ const all = await t.get('/recipes/library.json');
+ assert.strictEqual(all.status, 200);
+ const recipes = all.json().recipes;
+ assert.ok(recipes.length >= 1);
+ assert.ok(recipes.every((x) => x.url.endsWith(`/recipes/library/${x.slug}`) && Array.isArray(x.ingredients)));
+ const times = recipes.map((x) => x.created_at);
+ assert.deepStrictEqual(times, [...times].sort(), 'oldest first');
+ assert.strictEqual((await t.get('/recipes/library.json?since=2999-01-01T00:00:00Z')).json().recipes.length, 0);
+ assert.strictEqual((await t.get('/recipes/library.json?since=yesterday-ish')).status, 400);
+ const one = recipes[0].slug;
+ await t.get(`/recipes/library/${one}/hide`, { method: 'POST', as: boss, form: {}, headers: SAME });
+ assert.ok(!(await t.get('/recipes/library.json')).json().recipes.some((x) => x.slug === one), 'hidden recipes are not in the feed');
+ });
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
})();
})();