Files
Epicure/FEATURE_AUDIT.md
T
Arnaud d230098b1e docs: full re-audit of FEATURE_AUDIT.md, answer the "too tech-savvy" question
5 parallel domain re-sweeps against current code (last full audit was
2026-07-21/22, three days of heavy shipping since -- billing,
developer/BYOK permission split, moderator scoping). Corrections and
additions:

- Fixed stale admin nav count (13 -> 15 sections, was missing Billing
  entirely).
- Closed the /r/[id] followers-visibility gap that was flagged open --
  it's fixed in code, found a new inconsistency in its place: a
  followers-only recipe never shows on the author's own profile grid.
- Corrected "auto-deduct pantry on cook" -- the API logic is real and
  correct but both UI callers hardcode it off, so it never fires in
  practice. Was overstated as "Exists, one nuance."
- Fixed "bulk-set-week" mislabel (it's bulk-clear only, no bulk-create).
- Added several undocumented features: recipe notes, manual nutrition
  entry, AI conversation thread management + chat-history search,
  two more draft-recipe AI endpoints, the authenticated meal-plan
  collaboration UI (distinct from the public link), move-to-pantry
  button, pantry bulk-merge, public shopping-list page + print/QR,
  add-to-shopping-list AI tool, leftover-cron scope limitation.
- Noted shopping-list collaboration now supplements polling with real
  push notifications on add/check events.

New "Consumer-friendliness assessment" section directly answers
whether Epicure has too many technical/developer-oriented features
for a home-cook audience: mostly no -- API keys, webhooks, BYOK,
OpenAPI docs, and self-serve developer access are all real surface
but properly gated and hidden from nav by default. One genuine
exception found and flagged: Settings -> AI's Model Prefs picker lets
every user pick raw model IDs with zero gating -- the one place a
developer-tool control was left unfenced on a consumer screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 08:49:54 +02:00

189 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Epicure Feature Audit
Code-verified inventory of what exists in the app today, by domain. Written 2026-07-21, re-audited in full 2026-07-24 (5 parallel domain sweeps re-reading actual code against the prior version, not trusting it). **Keep this file updated whenever a feature is added or changes what it does** (see standing rule below).
Status legend: **Exists** (fully working) · **Partial** (works but with a real limitation, noted) · **Missing** (confirmed absent by grep/read, not just "didn't find it").
---
## 1. Recipes & AI
**AI stack**: Vercel AI SDK, provider-agnostic (`apps/web/lib/ai/factory.ts`) — OpenAI, Anthropic, OpenRouter, Ollama (local). Key resolution order: user's own BYOK key → admin default per use-case → admin's raw provider key → env var. Structured outputs always `generateObject` + Zod; chat features use `generateText` with optional tool-calling.
**⚠️ Found this pass — ungated technical UI:** `Settings → AI`'s **Model Prefs** section (`apps/web/components/settings/model-prefs-form.tsx`) lets *every* user, with no developer/BYOK gate at all, pick a raw provider + specific model ID (`gpt-4o-mini`, `o3-mini`, `claude-opus-4-8`, etc.) per use-case, spending the admin's own API budget. This is the one place in the app where a home cook can stumble into an LLM-playground-style control panel with zero fencing. See the "Consumer-friendliness" section at the end of this doc.
| Feature | Status | Description | Key files |
|---|---|---|---|
| Recipe CRUD | Exists | Create/get/list/update/delete; update snapshots the prior version first (`recipeSnapshots`) | `apps/web/app/api/v1/recipes/**` |
| Fork / duplicate | Exists | Same backend action for both — UI just swaps the label based on ownership | `apps/web/app/api/v1/recipes/[id]/fork/route.ts` |
| Version history | Exists | `GET /recipes/[id]/versions`, diff-able snapshots | `apps/web/app/api/v1/recipes/[id]/versions/**` |
| **Import from URL** | **Exists** | Fetches a page (SSRF-checked), strips markup, extracts a structured recipe via AI. Returns the extraction to the client — doesn't self-persist, caller POSTs it to create | `apps/web/app/api/v1/ai/import-url` |
| Import from photo | Exists | Two-stage vision→text; recognizes a photographed page/handwritten recipe, self-persists as a private recipe | `apps/web/app/api/v1/ai/import-photo`, `lib/ai/features/{recognize-photo,generate-recipe-from-recognition}.ts` |
| Generate from idea/title | Exists | Text-only generation from just a title | `apps/web/app/api/v1/ai/generate-from-idea` |
| Recipe ideas (browsing) | Exists | 6 idea cards from an optional prompt, not persisted | `apps/web/app/api/v1/ai/recipe-ideas` |
| Adapt (exclude ingredients / constraints) | Exists | Persists as new recipe + variation link, unless AI reports unchanged | `apps/web/app/api/v1/ai/adapt/[id]` |
| Suggest variations | Exists | Ideas only, not persisted; gated by tier feature flag `recipe_variations` | `apps/web/app/api/v1/ai/variations/[id]` |
| Meal / drink pairings | Exists | Gated by tier feature flags `meal_pairing` / `drink_pairing` | `apps/web/app/api/v1/ai/{pairings,drinks}/[id]` |
| Translate recipe | Exists | Saves as a new private recipe, keeps original quantities/units/timers | `apps/web/app/api/v1/ai/translate/[id]` |
| Batch-cook generator | Exists | One recipe, multiple dishes, per-dish fridge/freezer guidance | `apps/web/app/api/v1/ai/batch-cook/generate` |
| Nutrition estimation | Exists | Hybrid AI+USDA (see section 2's nutrition row), cached on the recipe row (`nutritionData`), author-triggered. A separate `nutritionManual` flag lets the author hand-enter facts at create/edit time instead, skipping estimation entirely — undocumented until this pass. | `apps/web/app/api/v1/recipes/[id]/nutrition`, `packages/db/src/schema/recipes.ts` (`nutritionManual`) |
| Recipe notes | Exists | Private, per-user free-text note attached to any recipe (not shared with other viewers) | `apps/web/app/api/v1/recipes/[id]/notes` |
| AI conversation management | Exists | List/create/rename/delete threads for recipe-chat/cooking-chat, plus a separate full-text search + bulk-delete over past chat messages — a full thread-management layer around the chat feature, not just the chat itself | `apps/web/app/api/v1/ai/conversations/**`, `apps/web/app/api/v1/ai/chat-history` |
| Draft-recipe AI helpers | Exists | Two additional, non-persisting endpoints: `generate` (freeform prompt → recipe, used by the "describe" tab in the AI-generate dialog) and `regenerate` (revise an in-progress unsaved draft with a free-text instruction) — distinct from the persisting `generate-from-idea` | `apps/web/app/api/v1/ai/{generate,regenerate}` |
| Scale servings | Exists | Model-driven, not naive linear math (handles "1 egg" etc.) | `apps/web/app/api/v1/ai/scale` |
| Substitute ingredient | Exists | | `apps/web/app/api/v1/ai/substitute` |
| Recipe chat / cooking chat | Exists | Recipe-grounded Q&A, and a general assistant with tool-calling (`createRecipe`, `addToShoppingList`) | `apps/web/app/api/v1/ai/{recipe-chat,cooking-chat}` |
| Themed meal generator | Exists | Multi-course meal into a collection | `apps/web/app/api/v1/ai/generate-meal` |
| Recipe media: photos | Exists | S3-compatible storage, counts against tier storage limit | `packages/db/src/schema/recipes.ts` (`recipePhotos`) |
| **Recipe video** | **Missing** | No video field/upload path anywhere in schema or components | — |
| Collections | Exists | Shared/collaborative (`collection_members`, viewer/editor roles), fork-a-collection, explore page, reorder | `apps/web/app/api/v1/collections/**` |
| Tags, difficulty, dietary labels | Exists | Dietary schema has 7 tags; search only exposes 4 of them as filters (`vegan/vegetarian/glutenFree/dairyFree`) | `packages/db/src/schema/recipes.ts` |
| Visibility (private/unlisted/public/followers) | Exists | `/r/[id]`'s followers-only gap (previously flagged here) is closed — it now explicitly checks `viewerId === authorId \|\| isFollowing(...)` before allowing a `followers`-only recipe through. **New inconsistency found this pass:** a followers-only recipe shows up in a follower's following-feed, but never on the author's own profile grid (`u/[username]/page.tsx` only queries `visibility = "public"` for the grid) — see "Real gaps" below. | `apps/web/app/r/[id]/page.tsx`, `apps/web/app/(app)/u/[username]/page.tsx` |
| Print views | Exists | Recipe, collection, meal plan, shopping list, pantry all have print routes | `apps/web/app/print/**` |
| Markdown export | Exists | Single recipe, bulk (up to 100), meal plan | `apps/web/lib/markdown/**` |
| Cooking mode | Exists | Step timers (multiple parallel), **voice control** (Web Speech API, EN/FR commands), wake-lock, batch-cook step grouping | `apps/web/components/cooking-mode/cooking-mode.tsx` |
| Search | Exists | Title/description/ingredient/tag substring match + filters (difficulty, type, max time, dietary, exact tags) | `apps/web/app/api/v1/search` |
| "What can I cook" (pantry-matched) | Exists | Deterministic ingredient-overlap scorer against the user's own recipes, not AI | `apps/web/lib/pantry-match.ts`, `/recipes/can-cook` |
| Explore / trending | Exists | Purely favorite-count-driven (7-day window) + recency — no editorial curation | `apps/web/app/(app)/explore` |
---
## 2. Meal planning, pantry, shopping, nutrition
| Feature | Status | Description | Key files |
|---|---|---|---|
| Weekly calendar meal plan | Exists | MonSun grid, manual CRUD; the "bulk" entries endpoint is bulk-*clear* only (a day or the whole week) — there's no bulk-create, entries are always added one at a time via `POST /entries` | `apps/web/app/(app)/meal-plan`, `apps/web/app/api/v1/meal-plans/[weekStart]/**` |
| AI weekly meal-plan generator | Exists | Pantry-aware, dietary-aware, nudged by nutrition goals; one real recipe per entry; entries can also link to a specific `batchDish` (reflected in ICS export and the calendar page) | `apps/web/app/api/v1/ai/meal-plan/generate` |
| Meal plan sharing | Exists | Two separate paths: (1) authenticated member collaboration — `meal_plan_members` viewer/editor roles, day-to-day editing UI at `/meal-plan/shared/[mealPlanId]` (this is where collaborators actually work, not the public link); (2) anonymous read-only public link (`isPublic` flag, view-only — no public-editable mode) at `/m/[id]`, with a client-generated QR code. Entry changes fire a `meal_plan.updated` webhook event. | `apps/web/lib/meal-plan-access.ts`, `apps/web/app/(app)/meal-plan/shared/[mealPlanId]/page.tsx`, `apps/web/app/m/[id]/page.tsx` |
| **Calendar export (.ics)** | **Exists** | Real ICS builder with per-meal-type wall-clock times | `apps/web/app/api/v1/meal-plans/[weekStart]/export/ics` |
| Weekly nutrition rollup | Exists | | `apps/web/app/api/v1/meal-plans/[weekStart]/nutrition` |
| Meal plan print + Markdown export | Exists | Undocumented until this pass despite section 1 mentioning print/markdown generally | `/print/meal-plan`, `apps/web/lib/markdown/meal-plan.ts` |
| Multiple shopping lists | Exists | | `apps/web/app/api/v1/shopping-lists/**` |
| Generate list from meal-plan week | Exists | Merges duplicate ingredients, flags items already covered by pantry (never silently drops) | `apps/web/lib/pantry-shopping-match.ts` |
| Add to list from a single recipe | Exists | Separate path from meal-plan-based generation; also reachable via the cooking-chat AI tool ("add to shopping list") | `apps/web/components/recipe/add-to-shopping-list-button.tsx`, `apps/web/lib/ai/tools/add-to-shopping-list-tool.ts` |
| Shared/collaborative lists | Exists | Member roles **and** an anonymous public link that can grant full anonymous edit rights (Google-Docs-style), not just read — `isPublic` + `publicEditable`; disabling `isPublic` force-revokes `publicEditable`. Also has its own print view with a QR code, and a public page distinct from the meal-plan one. | `apps/web/lib/shopping-list-access.ts`, `apps/web/app/s/[id]/page.tsx`, `/print/shopping-list/[id]` |
| List collaboration | Partial, upgraded | Still short-polling (~4s interval) for state sync, but item-add and item-checked events now also fire real Web Push notifications (VAPID) as a supplement — not purely polling anymore. Uncheck/rename/reorder still don't push. | `apps/web/components/meal-plan/shopping-list-view.tsx`, `apps/web/lib/shopping-list-notify.ts` |
| Move checked items to pantry | Exists | Undocumented until this pass — button on the shopping-list view moves checked-off items into pantry stock | `shopping-list-view.tsx``/api/v1/pantry/bulk` |
| Aisle categorization | Exists | Heuristic auto-assign + bulk re-categorize, plus inline custom-category creation and full drag-and-drop reordering (richer UI than previously documented) | `apps/web/lib/grocery-categories.ts` |
| Grocery delivery integration | **Partial / stub** | Generic export payload works (integrator-shaped — no visible in-app UI consumer besides the Instacart adapter); Instacart adapter is an explicit stub (requires a partnership agreement Epicure doesn't have — returns 501 if unconfigured, throws if "configured") | `apps/web/lib/grocery-providers/instacart.ts` |
| Other delivery/price integrations (DoorDash, Kroger, Walmart, live pricing) | **Missing** | Confirmed absent by repo-wide search | — |
| Pantry manual CRUD | Exists | Includes a bulk case-insensitive name+unit merge endpoint (used by the scan-confirm flow), undocumented until this pass | `apps/web/app/api/v1/pantry/**` |
| Auto-deduct pantry on cook | **API-only, dead in the UI** | The API logic is correct (name+unit matching, defaults `deductFromPantry: true`) — but both real UI callers (`batch-cook-dishes.tsx`, `meal-planner.tsx`'s mark-cooked flow) hardcode `deductFromPantry: false`. A home cook using the app today never triggers auto-deduction; it's only reachable via a direct API/API-key client. Previously documented as "Exists, one nuance," which overstated real-world behavior. | `apps/web/app/api/v1/recipes/[id]/cooked/route.ts`, `apps/web/components/recipe/batch-cook-dishes.tsx`, `apps/web/components/meal-plan/meal-planner.tsx` |
| Expiring-soon tracking | Exists | With "use it up" recipe suggestions. The leftover-expiry push/email cron only covers batch-cook dishes — plain pantry items only get an in-app badge, no reminder. | `apps/web/components/pantry/expiring-soon-suggestions.tsx`, `apps/web/app/api/internal/cron/leftover-reminders/route.ts` |
| **Barcode scan (pantry)** | **Exists** | Real Open Food Facts API lookup by UPC/EAN | `apps/web/app/api/v1/pantry/scan/barcode` |
| Photo scan (pantry) | Exists | Vision-model based | `apps/web/app/api/v1/pantry/scan/photo` |
| Nutrition goals | Exists | | `apps/web/app/api/v1/users/me/nutrition-goals` |
| Nutrition diary | Exists | Single-day diary view, plus a Trend tab (7/30/90-day calorie line chart + daily macro averages) — the same endpoint switches modes via a `range` query param | `apps/web/app/api/v1/users/me/nutrition-diary`, `apps/web/components/nutrition/nutrition-trend.tsx` |
| USDA nutrition-facts database | Exists | `estimateNutrition` now looks up each ingredient in USDA FoodData Central (SR Legacy + Survey (FNDDS) datasets) after converting its quantity/unit to grams, sums the matches, and only falls back to AI estimation for ingredients that couldn't be converted (count-based units like "2 cloves") or had no USDA match — including every ingredient, transparently, when `USDA_API_KEY` isn't configured. Barcode scan (pantry) still only IDs product names via Open Food Facts, unrelated to this. | `apps/web/lib/usda.ts`, `apps/web/lib/ingredient-grams.ts`, `apps/web/lib/ai/features/estimate-nutrition.ts` |
| Batch cooking | Exists | Shared prep steps grouped by which dish they serve, per-dish fridge/freezer countdown independent of the whole-batch pantry deduction | `apps/web/components/recipe/batch-cook-*.tsx` |
---
## 3. Social, messaging, notifications
| Feature | Status | Description | Key files |
|---|---|---|---|
| Follow / unfollow | Exists | One-way, no approval step | `apps/web/app/api/v1/users/[username]/follow` |
| Block | Exists | Blocks messaging both ways. Comment-hiding is one-directional only — hides comments *from* someone you've blocked, but not your own comments from someone who blocked *you* (not necessarily wrong, just undocumented directionality). | `apps/web/lib/blocks.ts` |
| Private accounts | Exists | Hides recipe grid from non-followers; excluded from "for you" unless already followed | `apps/web/app/(app)/u/[username]/page.tsx` |
| Profile page | Exists | Cooking-history streak stats, "cooked it" photo gallery (owner-visible) | `apps/web/app/(app)/u/[username]` |
| Ratings/reviews, comments (1-level threaded), reactions, @mentions | Exists | | `packages/db/src/schema/social.ts` |
| Favorites | Exists | Drives "for you" ranking and trending | `apps/web/app/api/v1/recipes/[id]/favorite` |
| Direct messaging | Exists, 1:1 by design | No group messaging (deliberate — unique-pair constraint at schema level) | `packages/db/src/schema/messaging.ts` |
| Read receipts | Partial | Thread-level `lastReadAt` only — no per-message delivery/seen state | `packages/db/src/schema/messaging.ts` |
| Following feed | Exists | | `apps/web/app/api/v1/feed` |
| "For You" personalized feed | Exists | Tag/dietary preference profile from favorites + high ratings | `apps/web/lib/for-you-ranking.ts` |
| Trending feed / explore | Exists | Favorite-count driven, no editorial curation, **no separate "featured" surface**. Trending/explore/for-you all additionally exclude private-account authors even when the specific recipe is `public` — a non-obvious interaction worth knowing about when debugging "why isn't my public recipe showing up." | `apps/web/app/api/v1/feed/trending` |
| Notification categories | Exists | 8 categories, independent push + email toggle each, plus email-only weekly digest | `packages/db/src/schema/users.ts` (`userNotificationPrefs`) |
| Push notifications | Exists | Web Push + VAPID, end-to-end, real trigger call sites (not just plumbing) | `apps/web/lib/push.ts` |
| Push display + click-through | Exists | `sw.js` previously had no `push` listener at all — push messages never displayed. Now has both `push` (calls `showNotification`) and `notificationclick` (focuses an existing tab on the payload's `url`, or opens one) | `apps/web/public/sw.js` |
| Email notifications + weekly digest cron | Exists | | `apps/web/lib/notifications.ts`, `apps/web/app/api/internal/cron/weekly-digest` |
| Reports/moderation | Exists | Report → admin webhook → admin queue → resolve (reviewed/dismissed) + audit log. Supports three target types — recipe, comment, **and user** (reporting a person directly, not previously called out) | `apps/web/app/api/v1/reports`, `apps/web/app/api/v1/admin/reports` |
| Moderator role | Exists | Moderators get `/admin/reports` (view/resolve) and `/admin/recipes` (view + unpublish takedown); every other `/admin/*` page redirects them out via `requireFullAdminPage()`. Comment deletion already allowed moderator before this. | `apps/web/lib/require-admin-page.ts`, `apps/web/app/admin/layout.tsx`, `apps/web/app/api/v1/admin/recipes/[id]/route.ts` |
---
## 4. Admin, billing, ops, integrations
| Feature | Status | Description | Key files |
|---|---|---|---|
| Tiers (free/pro/family) + limits | Exists | Recipe count, AI calls/month, storage, public-recipe count; TOCTOU-safe via advisory lock + transaction | `apps/web/lib/tiers.ts` |
| Stripe webhook (checkout/cancel) | Exists | Signature-verified, replay-protected, event-deduped — production quality | `apps/web/app/api/webhooks/stripe/route.ts` |
| Stripe checkout + self-serve billing portal (2026-07-23) | Exists | `POST /api/v1/billing/checkout` creates a subscription Checkout Session (promotion codes enabled); `POST /api/v1/billing/portal` opens Stripe's hosted Customer Portal for self-serve cancel/upgrade/card-update. Webhook route rewritten with the real `stripe` SDK (`stripe.webhooks.constructEvent`, replacing the hand-rolled HMAC verifier) and now handles the full event set: `checkout.session.completed`, `customer.subscription.{updated,deleted}`, `invoice.{payment_failed,paid}``past_due` deliberately doesn't downgrade tier (Stripe retries the card first). `/settings/billing` shows plan cards, usage, and a "Manage billing" button; `/admin/billing` shows connection status, subscriber counts, past-due list, recent billing audit events. Cancel/downgrade is at period end (Stripe Portal default); no trial period. **Not yet built:** family-group multi-user sharing (Family tier is purchasable solo, but the plan's per-account member invite/join/tier-resolution piece is deliberately deferred — see `plans/STRIPE_PLAN.md` §1a, flagged there as the most novel/error-prone piece, intentionally shipped after solo billing is proven). | `apps/web/lib/stripe.ts`, `apps/web/app/api/webhooks/stripe/route.ts`, `apps/web/app/api/v1/billing/**`, `apps/web/app/(app)/settings/billing/page.tsx`, `apps/web/app/admin/billing/page.tsx` |
| Admin dashboard | Exists | 15 sections (was 13, missing Billing until this pass): overview, insights/analytics, users, invites, recipe moderation, reports, support, tier limits, **billing**, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/layout.tsx` (`adminNav`) |
| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 3 AI capabilities by tier; prefs let users hide 7 nav sections, no billing implication | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
| **Developer permission** (2026-07-22, split 2026-07-23) | Exists | Two separate, orthogonal permissions — not one. `users.isDeveloper` gates webhooks + self-serve API keys: admin-toggled always, *and* self-serve toggleable by the user themselves once on a paid tier (no added fee) via `PATCH /api/v1/users/me/developer-access`; free-tier users still need an admin grant. `users.isByokEnabled` gates BYOK separately, admin-only, no self-serve — routing real AI spend through Epicure on the user's own key warrants a manual check-in. Previously all three (webhooks/API keys/BYOK) shared one flag with zero self-serve path. Existing users with a webhook/API key were already grandfathered for `isDeveloper`; a second migration grandfathered existing BYOK users into `isByokEnabled` specifically. | `apps/web/lib/permissions.ts` (`hasDeveloperAccess`, `hasByokAccess`, `canSelfServeDeveloperAccess`), `apps/web/lib/api-auth.ts` (`requireDeveloper`, `requireByok`) |
| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
| Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` |
| Support tickets ↔ Gitea two-way sync | Exists, verified real | Outbound (create/comment/close mirror to Gitea issue) + inbound (signed webhook receiver, dedup, loop-safe) | `apps/web/lib/gitea.ts`, `apps/web/app/api/webhooks/gitea/route.ts` |
| Self-serve API keys | Exists | Up to 10 per user, scoped full/read, SHA-256 hashed at rest, requires developer access | `apps/web/app/api/v1/api-keys` |
| Public OpenAPI spec + docs page | Exists | Scalar-rendered at `/docs`, spec at `/api/v1/openapi.json` | `apps/web/lib/openapi.ts` |
| i18n | Partial | Only `en` and `fr` — no other locales | `apps/web/messages/*.json` |
| 2FA | Exists | TOTP + backup codes + OTP, passwordless-account-compatible | Better Auth `twoFactor` plugin |
| Social login | Exists | Google always, GitHub/Discord/generic OIDC if configured | `apps/web/lib/auth/server.ts` |
| Rate limiting | Exists | Two layers: Better Auth's Redis-backed built-in limiter (tighter on 2FA verify endpoints) + a general sliding-window limiter for AI/API routes | `apps/web/lib/rate-limit.ts` |
| Invite-gated signup | Exists | Signups can be globally disabled, invite-only fallback; first-ever user auto-promoted to admin | — |
---
## 5. PWA, offline, mobile
| Feature | Status | Description | Key files |
|---|---|---|---|
| Installability | Exists | Manifest + SVG icons (no raster/PNG fallback), Chromium `beforeinstallprompt` button, **iOS Safari gets a static "tap Share" instructional banner** (no programmatic install API exists on Safari — Apple restriction, not fixable) | `apps/web/app/manifest.ts`, `apps/web/components/pwa/install-prompt.tsx` |
| Service worker | Exists | Cache-first for `/cook` pages, network-first-with-cache-populate elsewhere, `/api/*` bypassed | `apps/web/public/sw.js` |
| Background Sync | Exists | Chromium via native Background Sync API; Safari/Firefox (no support at all) via an `online`-event fallback reading the same IndexedDB queue | `apps/web/lib/offline-queue.ts`, `apps/web/public/sw.js` |
| Offline save-for-later | Exists | Per-recipe explicit pin + IndexedDB-tracked list shown on the `/offline` fallback page | `apps/web/lib/offline-db.ts`, `apps/web/components/recipe/save-offline-button.tsx` |
| Offline scope | Partial | Recipe viewing + one write path (mark-cooked). **Shopping lists and meal plan have no offline/queue coverage** | — |
| Push (client + server) | Exists end-to-end | Real subscribe/unsubscribe with correct state sync, VAPID, multiple triggers wired | `apps/web/lib/push.ts` |
| Voice control | **Exists** | Web Speech API in cooking mode (this was previously assumed missing — it isn't) | `apps/web/components/cooking-mode/cooking-mode.tsx` |
| Barcode scanning | **Exists** (pantry only) | `BarcodeDetector` isn't used, but a manual-entry-triggered Open Food Facts API lookup covers the same use case | `apps/web/app/api/v1/pantry/scan/barcode` |
| OS Share Target (share a URL into Epicure from another app) | Exists (Chromium/Android only) | Manifest `share_target``/recipes?title&text&url`; extracts the shared link from either `url` or a URL substring in `text` (senders vary), auto-opens the existing import-from-URL dialog pre-filled and auto-imports. Safari has no Share Target API at all — same Apple-restriction story as install prompts. | `apps/web/app/manifest.ts`, `apps/web/app/(app)/recipes/page.tsx`, `apps/web/components/recipe/url-import-dialog.tsx` |
| Home-screen widgets, Siri/Assistant shortcuts | **Missing**, expected — N/A for a pure PWA without a native shell | — |
| Native mobile app (React Native/Capacitor/Expo) | **Missing** | Confirmed PWA-only, no native project anywhere in the monorepo | — |
---
## Real gaps (confirmed absent, worth considering)
Ranked roughly by likely value:
1. ~~Stripe checkout + billing portal~~ — closed 2026-07-23 (solo Pro/Family subscribe/cancel/manage; family multi-user sharing still deferred, see the note in the table above).
2. **Recipe video** — no support at all.
3. ~~Nutrition trend/history view~~ — closed 2026-07-22 (7/30/90-day calorie chart + macro averages).
4. ~~USDA/nutrition-database lookup~~ — closed 2026-07-22 (per-ingredient, gram-convertible ingredients only; AI fills the rest).
5. ~~OS Share Target~~ — closed 2026-07-21 (Chromium/Android only, no Safari equivalent exists).
6. ~~Push click-through handling~~ — closed 2026-07-21 (`push`/`notificationclick` listeners added to `sw.js`).
7. ~~Anonymous public link for meal plans~~ — closed 2026-07-21 (read-only, with a QR code).
8. **Grocery delivery/live pricing** beyond the Instacart stub (which needs a partnership agreement to go live).
9. **Offline coverage for shopping lists / meal plan** — currently recipe-viewing + mark-cooked only.
10. ~~Moderator-scoped admin routes~~ — closed 2026-07-21 (reports + recipe-unpublish).
11. **Followers-only recipes invisible on the author's own profile grid** (2026-07-24) — `u/[username]/page.tsx` only queries `visibility = "public"` for the grid, so a followers-only recipe shows up in a follower's feed but never on the profile it belongs to. Real bug, not a design choice.
12. **Auto-deduct pantry on cook is dead in the UI** (2026-07-24) — correct, tested API logic that literally nothing in the product surfaces; both UI callers hardcode it off. Either wire it up (a real feature a home cook would want) or stop pretending it's a shipped feature.
13. **Model Prefs picker is ungated** (2026-07-24) — see "Consumer-friendliness" below; the one clear case of a technical control exposed with no fencing at all.
## Consumer-friendliness assessment — "is Epicure too tech-savvy?" (2026-07-24)
Asked directly this pass: has Epicure accumulated too many technical/developer-oriented features for its actual audience (home cooks)? Verdict from all 5 domain audits: **mostly no, one real exception.**
**The one real offender:** `Settings → AI`'s **Model Prefs** picker (`model-prefs-form.tsx`) — every user, gated by nothing, can select a raw provider + model ID (`gpt-4o-mini`, `o3-mini`, `claude-opus-4-8`...) per AI use-case. A home cook has no reason to know what "o3-mini (reasoning)" means or why they'd pick it over the default. This is the single clearest case of a developer-tool control bolted onto a consumer screen with zero fencing. Worth fixing: hide it behind developer/BYOK access (or just remove the picker for non-technical users and let the admin's configured default silently apply), since right now it's reachable by literally everyone.
**Everything else technical is already properly fenced, and mostly invisible by default:**
- **API keys, both webhook systems (user + admin-ops), BYOK, public OpenAPI/Scalar docs, self-serve developer-access toggle** — real engineering surface (auth gating, rate limiting, encryption-at-rest, SSRF validation, delivery/redelivery, replay protection), but every one of them is hidden from the Settings nav by default and only appears once a user has developer access (paid tier + opt-in) or BYOK (admin-only). A casual user never sees these nav entries at all. The audience is third-party integrators / automation hobbyists / self-hosters with their own AI keys — genuinely orthogonal to "save and share recipes," but it doesn't clutter the product because it's opt-in-gated, not default-visible.
- **Admin billing complexity, feature-flag matrix, Gitea sync, admin ops webhooks** — pure back-office/staff tooling. A home cook never has any path to these screens at all (admin-only). Legitimate SaaS-business infrastructure, zero consumer-facing weight.
- **Moderator console** — same story, staff-only.
- **Every consumer-facing domain checked clean**: recipe/meal-plan/pantry/shopping/social UI copy uses plain language throughout (checked directly — no "service worker," "IndexedDB," "webhook," "API," or similar jargon leaks into any user-facing string, including the newer offline/PWA surfaces). Cooking mode, batch cooking, collections, search, notifications — all read as ordinary consumer app features, not power-user tooling.
**Net take:** the *codebase* has a lot of technical depth relative to "recipe app," but almost all of it is correctly fenced off from the people it's not for. The fencing itself (developer-access self-serve, BYOK admin-gate, admin-only sections) is well-designed. The Model Prefs picker is the one place that fencing was skipped — fix that one thing and the "too tech-savvy" concern is essentially resolved.
## Deliberate design choices (not gaps)
- Direct messaging is 1:1 only — enforced at the schema level, not a missing feature.
- Feature toggles (`userFeaturePrefs`) are cosmetic nav-hiding only, never access control — separate from tier-based feature flags.
- No native app — PWA-only is the stated architecture, covered well by the offline/install/push work already shipped.
---
## Standing rule
**Every time a feature ships, update this file in the same change** — add/update its row, move it out of "Real gaps" if it closes one. Bug fixes to existing features don't need an entry unless they change what the feature does.