openvibers/openvibe.food Commits

Food home shows what it holds: the cheapest meals in the recipe library, the most food and protein for a dollar, the food groups, every delivery app with a search, the meal kits side by side, food banks by city, and the questions people ask (#70)

OP OpenVibers committed 22 days ago · 9521b3ca54 · 7 files, +140 −19

Changes

ddiff --git a/public/css/home.css b/public/css/home.css
nnew file mode 100644
iindex 0000000..4f00899
---- /dev/null
++++ b/public/css/home.css
@@@ -0,0 +1,39 @@
++/* OpenVibe.Food's home page only (server/http/pages.js): counts, recipe and food cards, the delivery search, meal kits,
++ cities and questions. Theme tokens only, so light and dark both read; every grid fits a 320px phone. */
++.home-stats { list-style: none; padding: 0; margin: 32px 0 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); }
++.home-stats li { border-radius: 14px; border: 1px solid var(--border, #1f2d47); border-top: 3px solid var(--accent, #3b82f6); background: var(--bg-secondary, #0f1729); }
++.home-stats a { display: block; height: 100%; box-sizing: border-box; padding: 14px 16px; color: inherit; text-decoration: none; border-radius: 14px; }
++.home-stats a:hover, .home-stats a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
++.home-stats b { display: block; font-size: 1.6rem; line-height: 1.1; font-weight: 800; font-variant-numeric: tabular-nums; }
++.home-stats span { display: block; margin-top: 4px; font-size: .86rem; color: var(--text-secondary, #96a7c2); }
++
++.home-cards { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }
++.home-cards.three { grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); }
++.home-cards.five { grid-template-columns: repeat(auto-fill, minmax(min(190px, 100%), 1fr)); }
++.home-cards a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color .15s ease, transform .15s ease; }
++.home-cards a:hover, .home-cards a:focus-visible { border-color: var(--accent, #3b82f6); transform: translateY(-1px); outline: 0; }
++.home-cards b { font-size: 1rem; overflow-wrap: anywhere; }
++.hc-main { font-size: .88rem; color: var(--text-secondary, #96a7c2); }
++.hc-main strong { font-size: 1.25rem; color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }
++.hc-sub { font-size: .84rem; color: var(--text-secondary, #96a7c2); }
++.hc-host { margin-top: auto; padding-top: 4px; font-size: .78rem; color: var(--text-muted, #7386a3); }
++.hc-tag { margin-left: 6px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); font-size: .72rem; font-weight: 600; color: var(--text-secondary, #96a7c2); vertical-align: 2px; }
++
++.home-chips { list-style: none; padding: 0; margin: 14px 0; display: flex; flex-wrap: wrap; gap: 8px; }
++.home-chips li { font-size: .92rem; }
++.home-chips li:not(:has(a)) { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }
++.home-chips a { display: inline-flex; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; }
++.home-chips a:hover, .home-chips a:focus-visible { border-color: var(--accent, #3b82f6); }
++
++.home-search { display: grid; gap: 6px; max-width: 640px; }
++.home-search label { font-size: .85rem; font-weight: 650; color: var(--text-secondary, #96a7c2); }
++.home-search-row { display: flex; flex-wrap: wrap; gap: 8px; }
++.home-search input { flex: 1 1 220px; min-width: 0; font: inherit; height: 44px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }
++
++.home-faq { display: grid; gap: 8px; max-width: 820px; }
++.home-faq details { border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.home-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 650; }
++.home-faq summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 12px; }
++.home-faq details p { margin: 0; padding: 0 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
++@media (max-width: 520px) { .home-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .home-cards a { padding: 10px 12px; } .hc-main strong { font-size: 1.1rem; } }
++@media (prefers-reduced-motion: reduce) { .home-cards a { transition: none; } .home-cards a:hover { transform: none; } }
ddiff --git a/server/food/library.js b/server/food/library.js
iindex 574d8ca..bef50a9 100644
---- a/server/food/library.js
++++ b/server/food/library.js
@@@ -144,6 +144,12 @@ function createLibrary({ s, ai, log = console }) {
[String(sinceIso || '1970-01-01T00:00:00.000Z'), Math.max(1, Math.min(200, limit))]);
return rows.map(shape);
},
++ /** The published recipes that cost least per serving, every ingredient priced (none partial): the home's list. */
++ async cheapest(limit = 6) {
++ const rows = await s.db.many("SELECT * FROM food_library_recipes WHERE status = 'published' AND NOT cost_partial AND cost IS NOT NULL AND serves > 0 ORDER BY cost / serves ASC, created_at DESC, id LIMIT $1",
++ [Math.max(1, Math.min(24, 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);
},
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a667f4a..7782e54 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -34,6 +34,8 @@ const { suggestRecipes } = require('../food/recipes');
const store = require('../food/store');
const delivery = require('../food/delivery');
const householdChat = require('../food/household-chat');
++const seo = require('openvibe-shared/seo');
++const { products } = require('openvibe-contracts');
const SITE_NAME = 'OpenVibe.Food';
const TAGLINE = 'What should we eat?';
@@@ -97,11 +99,29 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
}
// ── Home ─────────────────────────────────────────────────
-- r.get('/', (req, res) => {
++ // What the home shows is read, never restated: the food list's own numbers (typical prices, nutrition), the recipe
++ // library's cheapest published recipes, the delivery apps and meal kits Food compares, and the product's questions.
++ // A block with nothing to show is left out.
++ const FAQ = ((products.get('openvibe.food') || {}).faq || []);
++ const proteinPerDollar = (f) => (f.price ? Math.round(f.package_protein_g / f.price) : null);
++ const byKcal = foods.FOODS.filter((f) => f.kcal_per_dollar).sort((a, b) => b.kcal_per_dollar - a.kcal_per_dollar).slice(0, 8);
++ const byProtein = foods.FOODS.filter((f) => proteinPerDollar(f)).sort((a, b) => proteinPerDollar(b) - proteinPerDollar(a)).slice(0, 8);
++ const shortName = (f) => f.name.replace(/\s*\([^)]*\)\s*$/, '');
++ const foodCard = (f, main, label) => html`<li><a href="/foods/${f.slug}"><b>${shortName(f)}</b><span class="hc-main"><strong>${main}</strong> ${label}</span><span class="hc-sub">${money(f.price)} at ${f.cheapest_store_name}, typical</span></a></li>`;
++ const NEAR_CITIES = ['New York, NY', 'Los Angeles, CA', 'Chicago, IL', 'Houston, TX', 'Phoenix, AZ', 'Philadelphia, PA', 'San Antonio, TX', 'Seattle, WA'];
++ r.get('/', async (req, res) => {
++ let lib = { total: 0, cheap: [] };
++ if (ctx.library) {
++ try {
++ const [{ total }, cheap] = await Promise.all([ctx.library.list({ limit: 1 }), ctx.library.cheapest(8)]);
++ lib = { total, cheap };
++ } catch { lib = { total: 0, cheap: [] }; }
++ }
++ const base = String(config.baseUrl).replace(/\/+$/, '');
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: 'Eat well.', accent: 'Spend less.',
-- lede: 'Find food banks and budget grocers near you, plan meals with the shopping list and its cost, cook from what is in your cupboard, and compare every delivery app at once.',
++ lede: 'Find food banks and budget grocers near you, plan meals with the shopping list and its cost, cook from what is in your cupboard, and compare every delivery app and meal kit at once.',
actions: [
{ label: 'Find food near you', href: '/near', primary: true },
{ label: 'Plan a week', href: '/plan' },
@@@ -110,24 +130,62 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
note: 'Open source (AGPL-3.0). Places come from OpenStreetMap © contributors (ODbL); prices are typical, not live.',
aside: { html: String(searchForm({ panel: true })) },
});
++ const stats = [
++ { value: foods.FOODS.length, label: 'foods with nutrition and typical prices', href: '/foods' },
++ { value: lib.total, label: 'budget recipes in the library', href: '/recipes/library' },
++ { value: Object.keys(foods.STORES).length, label: 'budget chains priced', href: '/foods' },
++ { value: delivery.APPS.length, label: 'delivery apps in one search', href: '/delivery' },
++ { value: delivery.KITS.length, label: 'meal kits side by side', href: '/delivery#kits' },
++ ].filter((x) => x.value);
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
-- description: 'Find food banks and budget grocers near you, plan meals for a household with the shopping list and its cost, and cook from your cupboard.',
-- jsonLd: homeJsonLd(config),
-- styles: [showcase.STYLESHEET],
++ title: 'Food banks, cheap meals, every delivery app',
++ description: 'Find food banks and budget grocers, plan cheap meals with the shopping list and its cost, cook from your cupboard and compare delivery apps.',
++ jsonLd: [
++ ...homeJsonLd(config),
++ seo.jsonLd.itemList('The most food for a dollar on OpenVibe.Food', byKcal.map((f) => ({ name: `${shortName(f)}: ${f.kcal_per_dollar} calories for $1 (typical price)`, url: `${base}/foods/${f.slug}` })), { url: `${base}/foods` }),
++ FAQ.length ? seo.jsonLd.faq(FAQ.map((f) => ({ q: f.question, a: f.answer }))) : null,
++ ],
++ styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],
body: html`${raw(hero)}
++${stats.length ? html`<ul class="home-stats" aria-label="${SITE_NAME} in numbers">${stats.map((x) => html`<li><a href="${x.href}"><b>${x.value}</b><span>${x.label}</span></a></li>`)}</ul>` : ''}
++${lib.cheap.length ? html`<section class="sc-sec" aria-labelledby="h-meals"><h2 id="h-meals">Cheap meals from the recipe library</h2>
++<p class="sc-lede">The recipes that cost least per serving, with every ingredient priced from the food list. Written by OpenVibe.AI: check a recipe before you cook it. <a href="/recipes/library">All ${lib.total} recipes</a></p>
++<ul class="home-cards">${lib.cheap.map((x) => html`<li><a href="/recipes/library/${x.slug}"><b>${x.title}</b><span class="hc-main"><strong>${money(x.cost / x.serves)}</strong> a serving</span><span class="hc-sub">${[`${money(x.cost)} to buy`, `serves ${x.serves}`, x.minutes ? `about ${x.minutes} min` : null].filter(Boolean).join(' · ')}</span></a></li>`)}</ul></section>` : ''}
++<section class="sc-sec" aria-labelledby="h-value"><h2 id="h-value">The most food for a dollar</h2>
++<p class="sc-lede">Calories per dollar at the cheapest budget chain's typical price, from <a href="/foods">the food list</a>. Prices are a band to plan by, not a live price.</p>
++<ul class="home-cards">${byKcal.map((f) => foodCard(f, f.kcal_per_dollar.toLocaleString('en-US'), 'calories for $1'))}</ul></section>
++<section class="sc-sec" aria-labelledby="h-protein"><h2 id="h-protein">The cheapest protein</h2>
++<p class="sc-lede">Grams of protein a dollar buys, at the same typical prices.</p>
++<ul class="home-cards">${byProtein.map((f) => foodCard(f, `${proteinPerDollar(f)} g`, 'of protein for $1'))}</ul></section>
++<section class="sc-sec" aria-labelledby="h-groups"><h2 id="h-groups">Browse the food list</h2>
++<p class="sc-lede">Every food with its nutrition per serving and per 100 g, its typical price and the cheapest chain for it.</p>
++<ul class="home-cards three">${Object.entries(foods.GROUPS).map(([id, g]) => { const n = foods.FOODS.filter((f) => f.group === id).length; return n ? html`<li><a href="/foods?group=${id}"><b>${g.name}</b><span class="hc-main"><strong>${n}</strong> foods</span><span class="hc-sub">${g.examples}</span></a></li>` : ''; })}</ul></section>
++<section class="sc-sec" aria-labelledby="h-delivery"><h2 id="h-delivery">Every delivery app at once</h2>
++<p class="sc-lede">Type a dish, a restaurant or groceries and each app opens its own search for it, in your own account, where its prices and fees are.</p>
++<form class="home-search" method="get" action="/delivery" role="search"><label for="home-dv-q">A dish, a restaurant or groceries</label><span class="home-search-row"><input id="home-dv-q" name="q" type="search" maxlength="120" placeholder="pad thai, tacos, milk and eggs" required><button class="sc-btn sc-primary" type="submit">Search every app</button></span></form>
++<ul class="home-chips">${delivery.APPS.map((a) => html`<li title="${a.best}">${a.name}</li>`)}</ul></section>
++<section class="sc-sec" id="kits" aria-labelledby="h-kits"><h2 id="h-kits">Meal kits, side by side</h2>
++<p class="sc-lede">From the cheapest kits to fully cooked meals. Each opens its own plans page, where the current price per serving is.</p>
++<ul class="home-cards five">${delivery.KITS.map((k) => html`<li><a href="${k.plans}" rel="noopener nofollow" target="_blank"><b>${k.name} <span class="hc-tag">${delivery.TIER_TEXT[k.tier]}</span></b><span class="hc-sub">${k.what}</span><span class="hc-host">${k.host} ↗</span></a></li>`)}</ul></section>
++<section class="sc-sec" aria-labelledby="h-near"><h2 id="h-near">Find a food bank</h2>
++<p class="sc-lede">Food banks, soup kitchens, community fridges and free pantry boxes, with hours, from OpenStreetMap. Search any town or postcode, or start with a city.</p>
++<ul class="home-chips">${NEAR_CITIES.map((c) => html`<li><a href="/near?${new URLSearchParams({ q: c })}">${c.split(',')[0]}</a></li>`)}</ul>
++<p><a class="sc-btn sc-primary" href="/near">Search near you</a></p></section>
${raw(showcase.features({
title: 'Everything food',
-- lede: 'Six things, all working today: no card, no account needed to look around.',
++ lede: '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 budget grocers, with hours.', href: '/near' },
-- { icon: 'ov:page', title: 'A meal planner', text: 'Up to fourteen days for one person to a group, with the shopping list and its cost. Share it, and your household ticks it off together.', href: '/plan' },
++ { icon: 'ov:map', title: 'Food near you', text: 'Food banks, soup kitchens, community fridges and free pantry boxes, plus budget grocers, with hours.', href: '/near' },
++ { icon: 'ov:docs', title: 'A meal planner', text: 'Up to fourteen days for one person to a group, with the shopping list and its cost. Share it, and your household ticks it off together.', href: '/plan' },
{ icon: 'ov:tools', title: 'A food list you can read', text: 'Nutrition per serving and per 100 g, the typical price, and the cheapest budget chain for each item.', href: '/foods' },
{ icon: 'ov:account', title: 'Your cupboard', text: 'Add what you have by barcode, photo or receipt; see what you can make now and what one more item unlocks.', href: '/pantry' },
{ icon: 'ov:search', title: 'Delivery, compared', text: 'Search DoorDash, Uber Eats, Grubhub and more at once, and compare meal kits.', href: '/delivery' },
{ icon: 'ov:ai', title: 'Recipes on a budget', text: 'Cheap recipes costed from the food list, and ideas from your cupboard. Written by AI: check before you cook.', href: '/recipes' },
],
}))}
++${FAQ.length ? html`<section class="sc-sec" aria-labelledby="h-faq"><h2 id="h-faq">Questions people ask</h2>
++<div class="home-faq">${FAQ.map((f) => html`<details><summary>${f.question}</summary><p>${f.answer}</p></details>`)}</div></section>` : ''}
${raw(showcase.cta({
title: 'Start where you are',
text: 'Search your town for a food bank, plan the next three days for what you can spend, or look up what is in the tin before you open it.',
@@@ -526,7 +584,7 @@ ${f.tags.length ? html`<p class="tags">${f.tags.map((t) => badge(t))}</p>` : ''}
async function pantryPage(req, res, { saved = false, added = null, addedCount = 0 } = {}) {
if (!signedIn(req)) {
return page(req, res, {
-- title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET],
++ description: 'Keep what is in your cupboard, added by barcode, photo or receipt, and see which budget recipes you can make with it now.', title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET],
body: html`<h1>Your cupboard</h1>
<p class="lede">Keep a list of what you already have and OpenVibe.Food will say which of its simple recipes you can make right now — and what one more item would unlock.</p>
<p><a class="sc-btn sc-primary" href="/auth/login?next=%2Fpantry">Sign in to keep a cupboard</a></p>`,
@@@ -540,7 +598,7 @@ ${foods.FOODS.filter((f) => f.group === id).map((f) => html`<label class="pantry
</fieldset>`);
const recipeLine = (x) => html`<li><b>${x.name}</b> — serves ${x.serves}, about ${x.minutes} minutes${x.missing && x.missing.length ? html` · missing ${x.missing.join(', ')}` : ''}</li>`;
page(req, res, {
-- title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'], scripts: ['js/barcode-scan.js', 'js/pantry-photo.js'],
++ description: 'Keep what is in your cupboard, added by barcode, photo or receipt, and see which budget recipes you can make with it now.', title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'], scripts: ['js/barcode-scan.js', 'js/pantry-photo.js'],
body: html`<h1>Your cupboard</h1>
<p class="lede">Tick what you have. Nothing leaves your account; the matching is done here, not by a model.</p>
${saved ? notice('Cupboard saved.', 'ok') : ''}
@@@ -707,7 +765,7 @@ ${r.notes ? html`<div class="recipe-notes small">${raw(markdown(r.notes))}</div>
async function recipesPage(req, res, { saved = false, error = null } = {}) {
if (!signedIn(req)) {
return page(req, res, {
-- title: 'Recipe ideas', crumbs: [{ label: 'Recipe ideas' }], styles: [showcase.STYLESHEET], localStyles: ['css/recipes.css'],
++ description: 'Budget recipe ideas from what is in your cupboard, costed from the food list, plus a library of cheap recipes. Written by AI.', title: 'Recipe ideas', crumbs: [{ label: 'Recipe ideas' }], styles: [showcase.STYLESHEET], localStyles: ['css/recipes.css'],
body: html`<h1>Recipe ideas</h1>
<p class="lede">Tell OpenVibe.Food what you feel like and how many people you are feeding. It suggests up to three recipes from the food list, marks what is already in your cupboard and lists what you would still need to buy, with a typical cost.</p>
<p><a class="sc-btn sc-primary" href="/auth/login?next=%2Frecipes">Sign in to get recipe ideas</a> <a class="sc-btn" href="/recipes/library">Browse the recipe library</a></p>`,
@@@ -721,7 +779,7 @@ ${r.notes ? html`<div class="recipe-notes small">${raw(markdown(r.notes))}</div>
'food.recipes_daily_limit': 'You have reached today\'s recipe idea limit. Come back tomorrow.',
};
page(req, res, {
-- title: 'Recipe ideas', crumbs: [{ label: 'Recipe ideas' }], styles: [showcase.STYLESHEET], localStyles: ['css/recipes.css'],
++ description: 'Budget recipe ideas from what is in your cupboard, costed from the food list, plus a library of cheap recipes. Written by AI.', title: 'Recipe ideas', crumbs: [{ label: 'Recipe ideas' }], styles: [showcase.STYLESHEET], localStyles: ['css/recipes.css'],
body: html`<h1>Recipe ideas</h1>
<p class="lede">What do you feel like, and for how many people? OpenVibe.AI suggests up to three recipes from the food list, and OpenVibe.Food works out what you would still need to buy from its own typical prices.</p>
${saved ? notice('Recipe ideas saved below.', 'ok') : ''}
ddiff --git a/test/foods.test.js b/test/foods.test.js
iindex b223dd2..0a3ea3a 100644
---- a/test/foods.test.js
++++ b/test/foods.test.js
@@@ -104,6 +104,10 @@ const foods = require('../server/food/foods');
assert.ok(r.text.includes('action="/near"'), 'the search form posts to /near');
assert.ok(r.text.includes('name="q"'));
for (const href of ['/near', '/plan', '/foods', '/pantry']) assert.ok(r.text.includes(`href="${href}"`), `no link to ${href}`);
++ for (const h of ['The most food for a dollar', 'The cheapest protein', 'Browse the food list', 'Every delivery app at once', 'Meal kits, side by side', 'Find a food bank', 'Questions people ask']) assert.ok(r.text.includes(h), `the home has "${h}"`);
++ assert.match(r.text, /<a href="\/foods\/[a-z0-9-]+"><b>[^<]+<\/b><span class="hc-main"><strong>[\d,]+<\/strong> calories for \$1<\/span>/, 'a food card says what a dollar buys and opens the food');
++ assert.ok(r.text.includes('href="/foods?group=protein"'), 'each food group opens the list at it');
++ assert.ok(!r.text.includes('Cheap meals from the recipe library'), 'no recipe block while the library is empty');
});
} finally { await t.close(); }
done();
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex df070ee..d59456f 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -38,11 +38,11 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200);
const head = r.text.slice(0, r.text.indexOf('</head>'));
assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
-- assert.ok(head.includes('<title>OpenVibe.Food: What should we eat?</title>'), 'home title');
++ assert.ok(head.includes('<title>Food banks, cheap meals, every delivery app · OpenVibe.Food</title>'), 'home title');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.food/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'ItemList', 'FAQPage'], 'the home JSON-LD (the most food for a dollar, and the questions shown on the page)');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="food"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
}
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 3709b5f..feba647 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -14,16 +14,19 @@ 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: 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)
++ // Raised 2026-10-22 for the curated home (the owner's "rich content on the home page"): the most food for a dollar,
++ // the cheapest protein, the food groups, every delivery app with a search, the meal kits, food banks by city, the
++ // questions, ItemList + FAQPage JSON-LD, and css/home.css. Measured 43.3/9.8 KB HTML (empty library) and 22.2/5.3 KB CSS.
++ htmlRawKB: 48, // 43.3 on 2026-10-22; 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: 11, // 9.8 on 2026-10-22; 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
jsBrotliKB: 62.5, // 56.5
-- cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
++ cssFiles: 3, // 3 (app.css, the cached /shared/showcase.css and css/home.css)
// 16.4 since the product's own pages brought their stylesheet with them (the search and plan forms, the place
// cards, the pantry's tick list); app.css itself stayed within a few lines.
-- cssRawKB: 18.0, // 16.4
-- cssBrotliKB: 4.6, // 4.3 with the hero search panel
++ cssRawKB: 24.5, // 22.2 with css/home.css; 16.4 before
++ cssBrotliKB: 5.9, // 5.3 with css/home.css; 4.3 with the hero search panel
externalFiles: 0, // 0
};
ddiff --git a/test/recipe-library.test.js b/test/recipe-library.test.js
iindex 587ed12..5eb9190 100644
---- a/test/recipe-library.test.js
++++ b/test/recipe-library.test.js
@@@ -115,6 +115,17 @@ function aiStub() {
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');
});
++
++ await check('the home lists the cheapest published recipes per serving, fully priced, each opening its page', async () => {
++ const cheap = await t.ctx.library.cheapest(8);
++ assert.ok(cheap.length >= 1 && cheap.every((x) => x.status === 'published' && !x.cost_partial && x.serves > 0));
++ const per = cheap.map((x) => x.cost / x.serves);
++ assert.deepStrictEqual(per, [...per].sort((a, b) => a - b), 'cheapest per serving first');
++ const home = await t.get('/');
++ assert.ok(home.text.includes('Cheap meals from the recipe library'));
++ assert.ok(home.text.includes(`href="/recipes/library/${cheap[0].slug}"`), 'the cheapest recipe opens its page');
++ assert.match(home.text, /<strong>\$\d+\.\d\d<\/strong> a serving/);
++ });
} finally { await t.close(); }
done();
})();