openvibers/openvibe.food Commits
Food: delivery and meal kits, compared (one search opens every delivery app) (#52)
Changes
@@@ -3,7 +3,7 @@> What should we eat?**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.++Delivery is compared the permitted way: one search opens every delivery app's own search (`/delivery`), with the meal kits side by side; prices and fees stay on each app, where the person is signed in.**Domain:** `openvibe.food` · **Port:** 4970 · **Service id:** `food` · **Env prefix:** `FOOD`**License:** AGPL-3.0 (same as every OpenVibe service).@@@ -24,6 +24,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and| Food database | [/foods, /foods/:slug](server/http/pages.js), [server/food/foods.js](server/food/foods.js) | 42 items with nutrition per serving and per 100 g, the typical price band and the chain-by-chain comparison; server-rendered and in the sitemap || Pantry | [/pantry](server/http/pages.js), [server/food/pantry-match.js](server/food/pantry-match.js) | Signed in, keep what is in your cupboard and see which basic recipes you can make now, and what one more item would unlock. Deterministic; no model call || 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 |++| 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) | The only outbound calls this service makes: paced, cached, identified, bounded || Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` || Platform | the skeleton | Sign-in with OpenVibe.Network, the OpenVibe Frame, limits, readiness, discovery, deploy files |nnew file mode 100644@@@ -0,0 +1,24 @@++/* OpenVibe.Food — /delivery only (every delivery app's own search, meal kits side by side), so app.css stays in budget. */++.dv-form { margin: 14px 0 18px; display: grid; gap: 6px; max-width: 720px; }++.dv-form label { font-weight: 700; font-size: .9rem; }++.dv-row { display: flex; gap: 8px; flex-wrap: wrap; }++.dv-row input { flex: 1 1 260px; min-width: 0; font: inherit; height: 46px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }++.dv-apps { list-style: none; padding: 0; margin: 0 0 10px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }++.dv-app { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s ease, transform .15s ease; }++.dv-app:hover { border-color: var(--accent, #3b82f6); transform: translateY(-1px); }++.dv-open { display: flex; flex-direction: column; gap: 2px; text-decoration: none; color: var(--text-primary, #e6edf7); }++.dv-name { font-weight: 700; font-size: 1.05rem; }++.dv-host { font-size: .82rem; color: var(--accent-text, var(--accent, #3b82f6)); }++.dv-best { margin: 0; }++.dv-kits { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 8px; }++.dv-kit { display: grid; grid-template-columns: 92px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }++.dv-kit-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }++.dv-kit-name { font-weight: 700; color: var(--text-primary, #e6edf7); text-decoration: none; }++.dv-kit-name:hover { text-decoration: underline; text-underline-offset: 3px; }++.dv-tier { justify-self: start; font-size: .72rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); }++.dv-budget { color: color-mix(in srgb, #22c55e 75%, var(--text-primary, #e6edf7)); border-color: rgba(74, 222, 128, .45); }++.dv-mid { color: color-mix(in srgb, #3b82f6 75%, var(--text-primary, #e6edf7)); border-color: rgba(96, 165, 250, .45); }++.dv-premium { color: color-mix(in srgb, #d946ef 75%, var(--text-primary, #e6edf7)); border-color: rgba(240, 171, 252, .45); }++.dv-watch { white-space: nowrap; }++@media (max-width: 640px) { .dv-kit { grid-template-columns: minmax(0, 1fr); gap: 6px; } .dv-watch { justify-self: start; } }++@media (prefers-reduced-motion: reduce) { .dv-app, .dv-app:hover { transition: none; transform: none; } }nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++++/**++ * Delivery and meal kits, compared the way the owner allowed (2026-09-28, Q8: official links, a browser extension in++ * the person's own sessions, licensed data; never scraping): one search opens every delivery app's own search for the++ * dish or restaurant, and the meal-kit services are side by side with their plan pages. Prices, fees and the person's++ * address stay on each app (their own logged-in session); Food never fetches them.++ *++ * Every URL form here was opened in a real headless Chromium on 2026-10-15 (Food's tools note): DoorDash, Grubhub,++ * Seamless and Caviar search pages answer with results, Uber Eats' search is its real search (its bot check stops++ * headless browsers, not people), Instacart's search answers for groceries. Left out: Postmates (now Uber Eats), Gopuff++ * (refuses automated visits and has no stable search link), Slice and ezCater (no search link that keeps the query).++ *++ * APPS the delivery apps: id, name, host, what they are best for, search(q) → URL++ * KITS meal-kit and prepared-meal services: id, name, host, tier, what they are, plans URL++ * linksFor(q) → [{ id, name, url, best }] for a non-empty query (at most 120 characters)++ */++++const enc = encodeURIComponent;++++const APPS = [++ { id: 'doordash', name: 'DoorDash', host: 'doordash.com', best: 'The most restaurants in most US cities, plus groceries and convenience stores.',++ search: (q) => `https://www.doordash.com/search/store/${enc(q)}/` },++ { id: 'ubereats', name: 'Uber Eats', host: 'ubereats.com', best: 'Restaurants and groceries, with Uber One members\' free delivery. Postmates is part of it now.',++ search: (q) => `https://www.ubereats.com/search?q=${enc(q)}` },++ { id: 'grubhub', name: 'Grubhub', host: 'grubhub.com', best: 'Restaurants, with Grubhub+ free delivery (often through Amazon Prime).',++ search: (q) => `https://www.grubhub.com/search?queryText=${enc(q)}` },++ { id: 'seamless', name: 'Seamless', host: 'seamless.com', best: 'Grubhub\'s sister app, strongest in New York City.',++ search: (q) => `https://www.seamless.com/search?queryText=${enc(q)}` },++ { id: 'caviar', name: 'Caviar', host: 'trycaviar.com', best: 'DoorDash\'s pick of local and higher-end restaurants.',++ search: (q) => `https://www.trycaviar.com/search/store/${enc(q)}/` },++ { id: 'instacart', name: 'Instacart', host: 'instacart.com', best: 'Groceries from the stores near you, delivered the same day.',++ search: (q) => `https://www.instacart.com/store/s?k=${enc(q)}` },++];++++const KITS = [++ { id: 'everyplate', name: 'EveryPlate', host: 'everyplate.com', tier: 'budget', what: 'Simple recipes with fewer ingredients, among the cheapest kits.', plans: 'https://www.everyplate.com/plans' },++ { id: 'dinnerly', name: 'Dinnerly', host: 'dinnerly.com', tier: 'budget', what: 'Short recipes and a low price per serving, especially on bigger boxes.', plans: 'https://dinnerly.com/' },++ { id: 'hellofresh', name: 'HelloFresh', host: 'hellofresh.com', tier: 'mid', what: 'The biggest menu, with family, quick and calorie-smart recipes.', plans: 'https://www.hellofresh.com/plans' },++ { id: 'homechef', name: 'Home Chef', host: 'homechef.com', tier: 'mid', what: 'Kits plus oven-ready and fast-prep meals; meals can be customized.', plans: 'https://www.homechef.com/' },++ { id: 'marleyspoon', name: 'Marley Spoon', host: 'marleyspoon.com', tier: 'mid', what: 'Chef-designed recipes and a large weekly menu.', plans: 'https://marleyspoon.com/' },++ { id: 'blueapron', name: 'Blue Apron', host: 'blueapron.com', tier: 'mid', what: 'Kits and ready-to-eat meals, bought per meal without a subscription.', plans: 'https://www.blueapron.com/menu' },++ { id: 'hungryroot', name: 'Hungryroot', host: 'hungryroot.com', tier: 'mid', what: 'Groceries and simple recipes picked for you each week.', plans: 'https://www.hungryroot.com/' },++ { id: 'greenchef', name: 'Green Chef', host: 'greenchef.com', tier: 'premium', what: 'Organic ingredients; keto, paleo, vegan and gluten-free plans.', plans: 'https://www.greenchef.com/plans' },++ { id: 'purplecarrot', name: 'Purple Carrot', host: 'purplecarrot.com', tier: 'premium', what: 'Plant-based kits and prepared meals.', plans: 'https://www.purplecarrot.com/shop' },++ { id: 'factor', name: 'Factor', host: 'factor75.com', tier: 'premium', what: 'Fully cooked meals to heat up, no cooking (priced per meal).', plans: 'https://www.factor75.com/plans' },++];++const TIER_TEXT = { budget: 'Budget', mid: 'Mid-range', premium: 'Premium' };++++const cleanQuery = (q) => String(q == null ? '' : q).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120);++++function linksFor(q) {++ const query = cleanQuery(q);++ if (!query) return [];++ return APPS.map((a) => ({ id: a.id, name: a.name, url: a.search(query), best: a.best }));++}++++module.exports = { APPS, KITS, TIER_TEXT, cleanQuery, linksFor };@@@ -32,6 +32,7 @@ const { searchPlaces, safeUrl, ATTRIBUTION } = require('../food/places');const { matchPantry } = require('../food/pantry-match');const { suggestRecipes } = require('../food/recipes');const store = require('../food/store');++const delivery = require('../food/delivery');const SITE_NAME = 'OpenVibe.Food';const TAGLINE = 'What should we eat?';@@@ -99,7 +100,7 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watchconst hero = showcase.hero({eyebrow: `${SITE_NAME} · ${TAGLINE}`,title: 'Eat well.', accent: 'Spend less.',-- lede: 'Find the food banks, pantries and budget grocery stores near you on a map anyone can edit. Plan meals for one person, a couple or a household for the days ahead, with the shopping list and what it costs. Tell it what is already in your cupboard and see what you can make.',++ 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.',actions: [{ label: 'Find food near you', href: '/near', primary: true },{ label: 'Plan a week', href: '/plan' },@@@ -118,12 +119,12 @@ ${raw(showcase.features({title: 'Everything food',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' },++ { 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: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: 'List what you have and 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: 'Recipe ideas', text: 'Up to three recipes from your cupboard, with what to buy and its cost. Suggested by AI: check before you cook.', href: '/recipes' },],}))}${raw(showcase.cta({@@@ -599,6 +600,34 @@ ${row.recipes.length ? html`<div class="recipe-grid">${row.recipes.map(recipeBlo});}++ // ── Delivery and meal kits, compared (server/food/delivery.js): every app's own search, never their data ──++ r.get('/delivery', (req, res) => {++ const q = delivery.cleanQuery(req.query.q);++ const links = delivery.linksFor(q);++ const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };++ const kitRow = (k) => html`<li class="dv-kit"><span class="dv-tier dv-${k.tier}">${delivery.TIER_TEXT[k.tier]}</span>++<span class="dv-kit-main"><a class="dv-kit-name" href="${k.plans}" target="_blank" rel="noopener nofollow">${k.name} ↗</a><span class="muted small">${k.what}</span></span>++<a class="dv-watch small" href="${WATCH_NEW}?${new URLSearchParams({ template: 'page', url: k.plans, name: `Food: ${k.name} prices` })}">Watch its prices</a></li>`;++ page(req, res, {++ title: q ? `“${q}” on every delivery app` : 'Delivery and meal kits, compared',++ description: 'Search DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart at once, and compare meal kits from EveryPlate and Dinnerly to HelloFresh, Home Chef, Green Chef and Factor.',++ index: !q, cache: q ? null : cache.htmlHeaders({ maxAge: 300 }),++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Delivery' }],++ styles: [showcase.STYLESHEET], localStyles: ['css/delivery.css'],++ body: html`<h1>${q ? html`“${q}” on every delivery app` : 'Delivery and meal kits, compared'}</h1>++<p class="lede">Type it once: every delivery app opens its own search for it, in a new tab. Prices, fees and your address stay on each app, so you see your real total where you are signed in.</p>++<form class="dv-form" method="get" action="/delivery" role="search"><label for="dv-q">A dish, a restaurant or groceries</label>++<div class="dv-row"><input id="dv-q" name="q" type="search" maxlength="120" required placeholder="pad thai, Chipotle, eggs and milk" value="${q}"><button class="sc-btn sc-primary" type="submit">Search every app</button></div></form>++${links.length ? html`<ul class="dv-apps">${links.map((l) => html`<li class="dv-app"><a class="dv-open" href="${l.url}" target="_blank" rel="noopener nofollow"><span class="dv-name">${l.name}</span><span class="dv-host">${hostOf(l.url)} ↗</span></a><p class="dv-best muted small">${l.best}</p></li>`)}</ul>++<p class="muted small">Compare the totals each app shows you, delivery and service fees included. For coupon codes on an app's own page, the <a href="https://github.com/OpenVibers/OpenVibe.Extensions/releases/latest">OpenVibe Companion</a> browser extension shows what OpenVibe.Coupons knows for it.</p>` : ''}++<section aria-labelledby="h-kits"><h2 id="h-kits">Meal kits and prepared meals</h2>++<p class="muted">Boxes of recipes and ingredients, or meals ready to heat. Prices change with the box size and the week, so each links to its own plans page; OpenVibe.Watch can tell you when a price changes.</p>++<ul class="dv-kits">${delivery.KITS.map(kitRow)}</ul>++<p class="muted small">Budget kits usually cost the least per serving; premium ones cost more for organic or special-diet food, and prepared meals cost more again. The first box is often heavily discounted, so compare the price from the second week.</p></section>++<p class="muted small">Cooking from what you have is cheaper still: <a href="/pantry">your cupboard</a>, <a href="/recipes">recipe ideas</a> and <a href="/plan">a meal plan</a> with its shopping list.</p>`,++ });++ });++r.get('/recipes', (req, res) => recipesPage(req, res, { saved: !!req.query.saved, error: typeof req.query.error === 'string' ? req.query.error : null }));r.post('/recipes', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {@@@ -29,6 +29,7 @@ const NAV = [{ label: 'Food list', href: '/foods' },{ label: 'Cupboard', href: '/pantry' },{ label: 'Recipes', href: '/recipes' },++ { label: 'Delivery', href: '/delivery' },{ label: 'What shipped', href: '/updates' },];nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++/**++ * Delivery and meal kits, compared (server/food/delivery.js, /delivery): one query opens every delivery app's own++ * search (the URL forms checked in a real browser on 2026-10-15), encoded and bounded; the page lists the meal kits++ * by tier with their plans pages and a Watch link; Food never fetches the apps; the home and the navbar link it.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const delivery = require('../server/food/delivery');++++(async () => {++ await check('one query, every app\'s own search, encoded; nothing for an empty query', () => {++ const links = delivery.linksFor(' pad thai & spring rolls ');++ assert.deepStrictEqual(links.map((l) => l.id), ['doordash', 'ubereats', 'grubhub', 'seamless', 'caviar', 'instacart']);++ const u = Object.fromEntries(links.map((l) => [l.id, l.url]));++ assert.strictEqual(u.doordash, 'https://www.doordash.com/search/store/pad%20thai%20%26%20spring%20rolls/');++ assert.strictEqual(u.ubereats, 'https://www.ubereats.com/search?q=pad%20thai%20%26%20spring%20rolls');++ assert.strictEqual(u.grubhub, 'https://www.grubhub.com/search?queryText=pad%20thai%20%26%20spring%20rolls');++ assert.strictEqual(u.instacart, 'https://www.instacart.com/store/s?k=pad%20thai%20%26%20spring%20rolls');++ assert.deepStrictEqual(delivery.linksFor(' '), []);++ assert.strictEqual(delivery.cleanQuery('x'.repeat(300)).length, 120);++ assert.ok(links.every((l) => /^https:\/\//.test(l.url) && l.best));++ assert.ok(delivery.KITS.every((k) => /^https:\/\//.test(k.plans) && delivery.TIER_TEXT[k.tier]));++ });++++ const t = await boot();++ try {++ await check('/delivery: the apps for a query, in new tabs, the kits by tier, and a Watch link per kit', async () => {++ const r = await t.get('/delivery?q=pad+thai');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('href="https://www.doordash.com/search/store/pad%20thai/" target="_blank" rel="noopener nofollow"'));++ assert.ok(r.text.includes('noindex'), 'a search result is not indexed');++ assert.ok(r.text.includes('EveryPlate') && r.text.includes('Factor') && r.text.includes('Budget'));++ assert.ok(r.text.includes('https://openvibe.watch/watches/new?template=page&url=https%3A%2F%2Fwww.hellofresh.com%2Fplans'));++ const blank = await t.get('/delivery');++ assert.ok(blank.status === 200 && !blank.text.includes('doordash.com/search') && !blank.text.includes('noindex'));++ const home = await t.get('/');++ assert.ok(home.text.includes('href="/delivery"') && home.text.includes('Delivery, compared'));++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
> What should we eat?
> What should we eat?
**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.
**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.
+Delivery is compared the permitted way: one search opens every delivery app's own search (`/delivery`), with the meal kits side by side; prices and fees stay on each app, where the person is signed in.
**Domain:** `openvibe.food` · **Port:** 4970 · **Service id:** `food` · **Env prefix:** `FOOD`
**Domain:** `openvibe.food` · **Port:** 4970 · **Service id:** `food` · **Env prefix:** `FOOD`
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -24,6 +24,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
@@ -24,6 +24,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| Food database | [/foods, /foods/:slug](server/http/pages.js), [server/food/foods.js](server/food/foods.js) | 42 items with nutrition per serving and per 100 g, the typical price band and the chain-by-chain comparison; server-rendered and in the sitemap |
| Food database | [/foods, /foods/:slug](server/http/pages.js), [server/food/foods.js](server/food/foods.js) | 42 items with nutrition per serving and per 100 g, the typical price band and the chain-by-chain comparison; server-rendered and in the sitemap |
| Pantry | [/pantry](server/http/pages.js), [server/food/pantry-match.js](server/food/pantry-match.js) | Signed in, keep what is in your cupboard and see which basic recipes you can make now, and what one more item would unlock. Deterministic; no model call |
| Pantry | [/pantry](server/http/pages.js), [server/food/pantry-match.js](server/food/pantry-match.js) | Signed in, keep what is in your cupboard and see which basic recipes you can make now, and what one more item would unlock. Deterministic; no model call |
| 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 |
+| 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) | The only outbound calls this service makes: paced, cached, identified, bounded |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | The only outbound calls this service makes: paced, cached, identified, bounded |
| 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` |
| Platform | the skeleton | Sign-in with OpenVibe.Network, the OpenVibe Frame, limits, readiness, discovery, deploy files |
| Platform | the skeleton | Sign-in with OpenVibe.Network, the OpenVibe Frame, limits, readiness, discovery, deploy files |
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+/* OpenVibe.Food — /delivery only (every delivery app's own search, meal kits side by side), so app.css stays in budget. */
+.dv-form { margin: 14px 0 18px; display: grid; gap: 6px; max-width: 720px; }
+.dv-form label { font-weight: 700; font-size: .9rem; }
+.dv-row { display: flex; gap: 8px; flex-wrap: wrap; }
+.dv-row input { flex: 1 1 260px; min-width: 0; font: inherit; height: 46px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }
+.dv-apps { list-style: none; padding: 0; margin: 0 0 10px; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
+.dv-app { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s ease, transform .15s ease; }
+.dv-app:hover { border-color: var(--accent, #3b82f6); transform: translateY(-1px); }
+.dv-open { display: flex; flex-direction: column; gap: 2px; text-decoration: none; color: var(--text-primary, #e6edf7); }
+.dv-name { font-weight: 700; font-size: 1.05rem; }
+.dv-host { font-size: .82rem; color: var(--accent-text, var(--accent, #3b82f6)); }
+.dv-best { margin: 0; }
+.dv-kits { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 8px; }
+.dv-kit { display: grid; grid-template-columns: 92px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
+.dv-kit-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.dv-kit-name { font-weight: 700; color: var(--text-primary, #e6edf7); text-decoration: none; }
+.dv-kit-name:hover { text-decoration: underline; text-underline-offset: 3px; }
+.dv-tier { justify-self: start; font-size: .72rem; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); }
+.dv-budget { color: color-mix(in srgb, #22c55e 75%, var(--text-primary, #e6edf7)); border-color: rgba(74, 222, 128, .45); }
+.dv-mid { color: color-mix(in srgb, #3b82f6 75%, var(--text-primary, #e6edf7)); border-color: rgba(96, 165, 250, .45); }
+.dv-premium { color: color-mix(in srgb, #d946ef 75%, var(--text-primary, #e6edf7)); border-color: rgba(240, 171, 252, .45); }
+.dv-watch { white-space: nowrap; }
+@media (max-width: 640px) { .dv-kit { grid-template-columns: minmax(0, 1fr); gap: 6px; } .dv-watch { justify-self: start; } }
+@media (prefers-reduced-motion: reduce) { .dv-app, .dv-app:hover { transition: none; transform: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+
+/**
+ * Delivery and meal kits, compared the way the owner allowed (2026-09-28, Q8: official links, a browser extension in
+ * the person's own sessions, licensed data; never scraping): one search opens every delivery app's own search for the
+ * dish or restaurant, and the meal-kit services are side by side with their plan pages. Prices, fees and the person's
+ * address stay on each app (their own logged-in session); Food never fetches them.
+ *
+ * Every URL form here was opened in a real headless Chromium on 2026-10-15 (Food's tools note): DoorDash, Grubhub,
+ * Seamless and Caviar search pages answer with results, Uber Eats' search is its real search (its bot check stops
+ * headless browsers, not people), Instacart's search answers for groceries. Left out: Postmates (now Uber Eats), Gopuff
+ * (refuses automated visits and has no stable search link), Slice and ezCater (no search link that keeps the query).
+ *
+ * APPS the delivery apps: id, name, host, what they are best for, search(q) → URL
+ * KITS meal-kit and prepared-meal services: id, name, host, tier, what they are, plans URL
+ * linksFor(q) → [{ id, name, url, best }] for a non-empty query (at most 120 characters)
+ */
+
+const enc = encodeURIComponent;
+
+const APPS = [
+ { id: 'doordash', name: 'DoorDash', host: 'doordash.com', best: 'The most restaurants in most US cities, plus groceries and convenience stores.',
+ search: (q) => `https://www.doordash.com/search/store/${enc(q)}/` },
+ { id: 'ubereats', name: 'Uber Eats', host: 'ubereats.com', best: 'Restaurants and groceries, with Uber One members\' free delivery. Postmates is part of it now.',
+ search: (q) => `https://www.ubereats.com/search?q=${enc(q)}` },
+ { id: 'grubhub', name: 'Grubhub', host: 'grubhub.com', best: 'Restaurants, with Grubhub+ free delivery (often through Amazon Prime).',
+ search: (q) => `https://www.grubhub.com/search?queryText=${enc(q)}` },
+ { id: 'seamless', name: 'Seamless', host: 'seamless.com', best: 'Grubhub\'s sister app, strongest in New York City.',
+ search: (q) => `https://www.seamless.com/search?queryText=${enc(q)}` },
+ { id: 'caviar', name: 'Caviar', host: 'trycaviar.com', best: 'DoorDash\'s pick of local and higher-end restaurants.',
+ search: (q) => `https://www.trycaviar.com/search/store/${enc(q)}/` },
+ { id: 'instacart', name: 'Instacart', host: 'instacart.com', best: 'Groceries from the stores near you, delivered the same day.',
+ search: (q) => `https://www.instacart.com/store/s?k=${enc(q)}` },
+];
+
+const KITS = [
+ { id: 'everyplate', name: 'EveryPlate', host: 'everyplate.com', tier: 'budget', what: 'Simple recipes with fewer ingredients, among the cheapest kits.', plans: 'https://www.everyplate.com/plans' },
+ { id: 'dinnerly', name: 'Dinnerly', host: 'dinnerly.com', tier: 'budget', what: 'Short recipes and a low price per serving, especially on bigger boxes.', plans: 'https://dinnerly.com/' },
+ { id: 'hellofresh', name: 'HelloFresh', host: 'hellofresh.com', tier: 'mid', what: 'The biggest menu, with family, quick and calorie-smart recipes.', plans: 'https://www.hellofresh.com/plans' },
+ { id: 'homechef', name: 'Home Chef', host: 'homechef.com', tier: 'mid', what: 'Kits plus oven-ready and fast-prep meals; meals can be customized.', plans: 'https://www.homechef.com/' },
+ { id: 'marleyspoon', name: 'Marley Spoon', host: 'marleyspoon.com', tier: 'mid', what: 'Chef-designed recipes and a large weekly menu.', plans: 'https://marleyspoon.com/' },
+ { id: 'blueapron', name: 'Blue Apron', host: 'blueapron.com', tier: 'mid', what: 'Kits and ready-to-eat meals, bought per meal without a subscription.', plans: 'https://www.blueapron.com/menu' },
+ { id: 'hungryroot', name: 'Hungryroot', host: 'hungryroot.com', tier: 'mid', what: 'Groceries and simple recipes picked for you each week.', plans: 'https://www.hungryroot.com/' },
+ { id: 'greenchef', name: 'Green Chef', host: 'greenchef.com', tier: 'premium', what: 'Organic ingredients; keto, paleo, vegan and gluten-free plans.', plans: 'https://www.greenchef.com/plans' },
+ { id: 'purplecarrot', name: 'Purple Carrot', host: 'purplecarrot.com', tier: 'premium', what: 'Plant-based kits and prepared meals.', plans: 'https://www.purplecarrot.com/shop' },
+ { id: 'factor', name: 'Factor', host: 'factor75.com', tier: 'premium', what: 'Fully cooked meals to heat up, no cooking (priced per meal).', plans: 'https://www.factor75.com/plans' },
+];
+const TIER_TEXT = { budget: 'Budget', mid: 'Mid-range', premium: 'Premium' };
+
+const cleanQuery = (q) => String(q == null ? '' : q).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120);
+
+function linksFor(q) {
+ const query = cleanQuery(q);
+ if (!query) return [];
+ return APPS.map((a) => ({ id: a.id, name: a.name, url: a.search(query), best: a.best }));
+}
+
+module.exports = { APPS, KITS, TIER_TEXT, cleanQuery, linksFor };
@@ -32,6 +32,7 @@ const { searchPlaces, safeUrl, ATTRIBUTION } = require('../food/places');
@@ -32,6 +32,7 @@ const { searchPlaces, safeUrl, ATTRIBUTION } = require('../food/places');
const { matchPantry } = require('../food/pantry-match');
const { matchPantry } = require('../food/pantry-match');
const { suggestRecipes } = require('../food/recipes');
const { suggestRecipes } = require('../food/recipes');
const store = require('../food/store');
const store = require('../food/store');
+const delivery = require('../food/delivery');
const SITE_NAME = 'OpenVibe.Food';
const SITE_NAME = 'OpenVibe.Food';
const TAGLINE = 'What should we eat?';
const TAGLINE = 'What should we eat?';
@@ -99,7 +100,7 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
@@ -99,7 +100,7 @@ ${watchable ? html`<p class="muted small">Watch for changes opens OpenVibe.Watch
const hero = showcase.hero({
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: 'Eat well.', accent: 'Spend less.',
title: 'Eat well.', accent: 'Spend less.',
- lede: 'Find the food banks, pantries and budget grocery stores near you on a map anyone can edit. Plan meals for one person, a couple or a household for the days ahead, with the shopping list and what it costs. Tell it what is already in your cupboard and see what you can make.',
+ 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.',
actions: [
actions: [
{ label: 'Find food near you', href: '/near', primary: true },
{ label: 'Find food near you', href: '/near', primary: true },
{ label: 'Plan a week', href: '/plan' },
{ label: 'Plan a week', href: '/plan' },
@@ -118,12 +119,12 @@ ${raw(showcase.features({
@@ -118,12 +119,12 @@ ${raw(showcase.features({
title: 'Everything food',
title: 'Everything food',
lede: 'Six 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: [
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' },
+ { 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: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: 'List what you have and 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: 'Recipe ideas', text: 'Up to three recipes from your cupboard, with what to buy and its cost. Suggested by AI: check before you cook.', href: '/recipes' },
],
],
}))}
}))}
${raw(showcase.cta({
${raw(showcase.cta({
@@ -599,6 +600,34 @@ ${row.recipes.length ? html`<div class="recipe-grid">${row.recipes.map(recipeBlo
@@ -599,6 +600,34 @@ ${row.recipes.length ? html`<div class="recipe-grid">${row.recipes.map(recipeBlo
});
});
}
}
+ // ── Delivery and meal kits, compared (server/food/delivery.js): every app's own search, never their data ──
+ r.get('/delivery', (req, res) => {
+ const q = delivery.cleanQuery(req.query.q);
+ const links = delivery.linksFor(q);
+ const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };
+ const kitRow = (k) => html`<li class="dv-kit"><span class="dv-tier dv-${k.tier}">${delivery.TIER_TEXT[k.tier]}</span>
+<span class="dv-kit-main"><a class="dv-kit-name" href="${k.plans}" target="_blank" rel="noopener nofollow">${k.name} ↗</a><span class="muted small">${k.what}</span></span>
+<a class="dv-watch small" href="${WATCH_NEW}?${new URLSearchParams({ template: 'page', url: k.plans, name: `Food: ${k.name} prices` })}">Watch its prices</a></li>`;
+ page(req, res, {
+ title: q ? `“${q}” on every delivery app` : 'Delivery and meal kits, compared',
+ description: 'Search DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart at once, and compare meal kits from EveryPlate and Dinnerly to HelloFresh, Home Chef, Green Chef and Factor.',
+ index: !q, cache: q ? null : cache.htmlHeaders({ maxAge: 300 }),
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Delivery' }],
+ styles: [showcase.STYLESHEET], localStyles: ['css/delivery.css'],
+ body: html`<h1>${q ? html`“${q}” on every delivery app` : 'Delivery and meal kits, compared'}</h1>
+<p class="lede">Type it once: every delivery app opens its own search for it, in a new tab. Prices, fees and your address stay on each app, so you see your real total where you are signed in.</p>
+<form class="dv-form" method="get" action="/delivery" role="search"><label for="dv-q">A dish, a restaurant or groceries</label>
+<div class="dv-row"><input id="dv-q" name="q" type="search" maxlength="120" required placeholder="pad thai, Chipotle, eggs and milk" value="${q}"><button class="sc-btn sc-primary" type="submit">Search every app</button></div></form>
+${links.length ? html`<ul class="dv-apps">${links.map((l) => html`<li class="dv-app"><a class="dv-open" href="${l.url}" target="_blank" rel="noopener nofollow"><span class="dv-name">${l.name}</span><span class="dv-host">${hostOf(l.url)} ↗</span></a><p class="dv-best muted small">${l.best}</p></li>`)}</ul>
+<p class="muted small">Compare the totals each app shows you, delivery and service fees included. For coupon codes on an app's own page, the <a href="https://github.com/OpenVibers/OpenVibe.Extensions/releases/latest">OpenVibe Companion</a> browser extension shows what OpenVibe.Coupons knows for it.</p>` : ''}
+<section aria-labelledby="h-kits"><h2 id="h-kits">Meal kits and prepared meals</h2>
+<p class="muted">Boxes of recipes and ingredients, or meals ready to heat. Prices change with the box size and the week, so each links to its own plans page; OpenVibe.Watch can tell you when a price changes.</p>
+<ul class="dv-kits">${delivery.KITS.map(kitRow)}</ul>
+<p class="muted small">Budget kits usually cost the least per serving; premium ones cost more for organic or special-diet food, and prepared meals cost more again. The first box is often heavily discounted, so compare the price from the second week.</p></section>
+<p class="muted small">Cooking from what you have is cheaper still: <a href="/pantry">your cupboard</a>, <a href="/recipes">recipe ideas</a> and <a href="/plan">a meal plan</a> with its shopping list.</p>`,
+ });
+ });
+
r.get('/recipes', (req, res) => recipesPage(req, res, { saved: !!req.query.saved, error: typeof req.query.error === 'string' ? req.query.error : null }));
r.get('/recipes', (req, res) => recipesPage(req, res, { saved: !!req.query.saved, error: typeof req.query.error === 'string' ? req.query.error : null }));
r.post('/recipes', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
r.post('/recipes', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
@@ -29,6 +29,7 @@ const NAV = [
@@ -29,6 +29,7 @@ const NAV = [
{ label: 'Food list', href: '/foods' },
{ label: 'Food list', href: '/foods' },
{ label: 'Cupboard', href: '/pantry' },
{ label: 'Cupboard', href: '/pantry' },
{ label: 'Recipes', href: '/recipes' },
{ label: 'Recipes', href: '/recipes' },
+ { label: 'Delivery', href: '/delivery' },
{ label: 'What shipped', href: '/updates' },
{ label: 'What shipped', href: '/updates' },
];
];
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+/**
+ * Delivery and meal kits, compared (server/food/delivery.js, /delivery): one query opens every delivery app's own
+ * search (the URL forms checked in a real browser on 2026-10-15), encoded and bounded; the page lists the meal kits
+ * by tier with their plans pages and a Watch link; Food never fetches the apps; the home and the navbar link it.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const delivery = require('../server/food/delivery');
+
+(async () => {
+ await check('one query, every app\'s own search, encoded; nothing for an empty query', () => {
+ const links = delivery.linksFor(' pad thai & spring rolls ');
+ assert.deepStrictEqual(links.map((l) => l.id), ['doordash', 'ubereats', 'grubhub', 'seamless', 'caviar', 'instacart']);
+ const u = Object.fromEntries(links.map((l) => [l.id, l.url]));
+ assert.strictEqual(u.doordash, 'https://www.doordash.com/search/store/pad%20thai%20%26%20spring%20rolls/');
+ assert.strictEqual(u.ubereats, 'https://www.ubereats.com/search?q=pad%20thai%20%26%20spring%20rolls');
+ assert.strictEqual(u.grubhub, 'https://www.grubhub.com/search?queryText=pad%20thai%20%26%20spring%20rolls');
+ assert.strictEqual(u.instacart, 'https://www.instacart.com/store/s?k=pad%20thai%20%26%20spring%20rolls');
+ assert.deepStrictEqual(delivery.linksFor(' '), []);
+ assert.strictEqual(delivery.cleanQuery('x'.repeat(300)).length, 120);
+ assert.ok(links.every((l) => /^https:\/\//.test(l.url) && l.best));
+ assert.ok(delivery.KITS.every((k) => /^https:\/\//.test(k.plans) && delivery.TIER_TEXT[k.tier]));
+ });
+
+ const t = await boot();
+ try {
+ await check('/delivery: the apps for a query, in new tabs, the kits by tier, and a Watch link per kit', async () => {
+ const r = await t.get('/delivery?q=pad+thai');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('href="https://www.doordash.com/search/store/pad%20thai/" target="_blank" rel="noopener nofollow"'));
+ assert.ok(r.text.includes('noindex'), 'a search result is not indexed');
+ assert.ok(r.text.includes('EveryPlate') && r.text.includes('Factor') && r.text.includes('Budget'));
+ assert.ok(r.text.includes('https://openvibe.watch/watches/new?template=page&url=https%3A%2F%2Fwww.hellofresh.com%2Fplans'));
+ const blank = await t.get('/delivery');
+ assert.ok(blank.status === 200 && !blank.text.includes('doordash.com/search') && !blank.text.includes('noindex'));
+ const home = await t.get('/');
+ assert.ok(home.text.includes('href="/delivery"') && home.text.includes('Delivery, compared'));
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });