Admins can now disable specific AI features per tier from Admin > Tier
Limits — new feature_flags table (feature x tier -> enabled, defaulting
to true so adding a new gated feature never needs a backfill).
Covers recipe variations, drink pairing, and meal pairing to start.
When disabled for a user's tier, the button stays visible (with a small
lock badge) but opens an upgrade dialog instead of running; the API
route rejects the call server-side either way (requireFeatureEnabled,
re-reads tier from the DB rather than trusting the session's cache,
same rationale as checkAndIncrementTierLimit).
The upgrade dialog is informational only — no Stripe checkout exists
yet (STRIPE_PLAN.md is still just a plan) — its CTA links to /support
prefilled as an upgrade-interest suggestion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Support form now accepts up to 5 attachments (images, PDF, text, JSON,
zip; 10MB each) via the existing S3/MinIO presigned-upload flow. New
support_ticket_attachments table; attachment links get folded into the
Gitea issue body and shown as thumbnails in both the user's ticket
history and the admin support view.
New presign endpoint (/api/v1/support/attachments/presign, rate-limited
20/hr) scoped to the uploading user rather than a ticket id, since the
ticket doesn't exist yet at upload time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Users can now report bugs, suggestions, or questions from a new /support
page. Each submission sends a confirmation email and, when GITEA_URL/
GITEA_TOKEN/GITEA_REPO are configured in admin Settings, opens a labeled
issue on that repo automatically (best-effort — failure doesn't block the
ticket). Admins get a Support section to triage status and retry failed
Gitea issue creation.
New support_tickets table, gitea.ts client, site-settings entries for the
three new secrets, and OpenAPI docs for the two user-facing endpoints.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a fourth visibility tier alongside private/unlisted/public:
"followers" — visible to the author and anyone who follows them,
excluded from public search/explore/profile listings and from the
anonymous /r/[id] share route, included in the Following feed. The
in-app recipe detail gate and the public share route both check
follow status directly against the DB rather than the union type
alone, since neither previously had any per-viewer access logic for
a non-public/non-owner case.
Requires the generated migration (0041) to run against a live DB —
not applied in this sandbox (no Docker here); run `pnpm db:migrate`.
v0.41.0
No user-facing view of AI-call/recipe/storage usage existed — only an
admin-only per-user panel. Adds the same numbers, with progress bars
against the user's actual tier limits (read fresh from the DB, not
the cached session), to Settings > AI.
v0.40.0
Explore and Feed covered overlapping ground (recommendations, following,
trending) in two separate places with three different card designs.
Explore now hosts Discover/Following/For You tabs, and every recipe
card everywhere on the page matches the Recipes page's cover-photo
card instead of the old text-only search result.
v0.36.0
Lets authors enter nutrition values by hand instead of relying only on
the AI estimate; the detail-page panel now shows whether data was
manually entered or AI-estimated and offers to switch between them.
v0.35.0
Full list of the audit's confirmed findings and their fixes:
- Stored XSS via unescaped JSON-LD on the public recipe page
(app/r/[id]/page.tsx) — escape < before injecting.
- CSP allowed unsafe-eval in production — now dev-only (Next prod
never eval()s; only its HMR does).
- avatarUrl accepted any URL with no ownership check — now takes an
avatarKey issued by avatar-presign, validated server-side, same
pattern as recipe/review photos.
- No session revocation on password change/reset — both now revoke
other sessions (revokeOtherSessions: true, revokeSessionsOnPasswordReset).
- Rate-limit bypass via spoofable X-Forwarded-For — take the last
(proxy-appended) hop instead of the first (client-supplied) one,
matching the single-Traefik-hop topology.
- Webhook signing secrets stored plaintext — now AES-256-GCM
encrypted like every other secret in this app, with a legacy-
plaintext fallback for pre-existing rows (bare hex has no ":", our
ciphertext format always does).
- Better Auth's own rate limiter defaulted to in-memory storage,
ineffective across replicas — now backed by the same Redis as
lib/rate-limit.ts (secondaryStorage), with storeSessionInDatabase
explicit so session storage itself doesn't move as a side effect.
- Presigned upload URLs didn't bind the declared file size to the
actual upload, letting a client under-declare size (and quota
charge) then PUT an arbitrarily large object — switched to S3
presigned POST with a signed content-length-range condition,
enforced by the storage server itself.
- generateMetadata() on the recipe page skipped the visibility
filter the page body uses, leaking a private recipe's title via
<title> to any signed-in user with the id.
- Block/unblock had no rate limit, unlike follow/unfollow.
- AI quota was charged even when a user's own BYOK key was used
(their own credentials/billing) — added an isByok flag through
the config-resolution chain and skip the charge when set. Also
wired BYOK into generate/generate-from-idea/translate/import-url,
which never looked it up at all before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add recipes.recipeType enum (dish|drink, default dish). Recipe form
now shows a Type selector and hides food-only fields for drinks
(prep/cook time, difficulty, batch-cook toggle) instead of forcing
every cocktail through meal-oriented UI. Detail page skips the
meal/drink pairing suggestion buttons on drink recipes (pairing a
drink with a drink doesn't make sense). Recipes list gains a Type
filter facet alongside difficulty/batch-cook.
Scoped deliberately narrow per investigation: no ABV/glass-type/
garnish structured fields, no meal-plan schema changes, and the
existing AI drink-pairing-suggestion feature (ephemeral, never saved
as a recipe) is left as-is rather than wired into recipe creation —
those suggestions are wine/beer/spirit recommendations without
ingredients/steps, not really recipes to save.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Match the recipe detail page's action-row convention: Share, Generate
shopping list, Shopping lists link, Print, Export to calendar, and
Send to grocery delivery collapse to icon-only with a tooltip instead
of icon+text, freeing up header space. Week prev/next arrows moved
next to the week date instead of sitting at the end of the button row.
Note: verified via typecheck/lint only — this dev environment has no
authenticated session to visually confirm in-browser.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
These were hardcoded English strings ignoring app locale:
DietaryTagPicker, the recipe tag-input label/placeholder/help/aria-label,
the Edit recipe page heading, and WeeklyNutritionBar's labels/messages.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously every account without a custom avatar automatically got
its email MD5-hashed and sent to gravatar.com at signup, with no way
to turn it off. Adds users.useGravatar (default false): removed the
automatic signup-time lookup entirely, and "remove photo" now falls
back to the initials placeholder instead of silently re-deriving a
Gravatar URL. New toggle in Settings -> Profile, off by default,
description explains the MD5-hash-to-third-party tradeoff. Existing
accounts' current avatarUrl is left untouched either way — no
retroactive avatar changes for anyone already using one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
My Recipes search previously only matched title/description; now also
matches ingredient rawName and tags, mirroring the public search
improvement from earlier. Hit and fixed a real bug along the way:
embedding drizzle column proxies from a foreign table inside a raw
sql`` fragment passed to db.query.recipes.findMany's `where` gets
rewritten to the wrong table alias by the relational query builder,
producing broken SQL — worked fine in the plain query builder used by
/api/v1/search, but not here. Fixed by using literal SQL identifiers
for the ingredients subquery instead of column proxies.
Recipe print pages get the same QR-code treatment shopping lists got
earlier, gated on visibility !== "private" (both public and unlisted
resolve at /r/[id]).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Uses better-auth's built-in twoFactor plugin rather than hand-rolling
TOTP — adds the two_factors table and users.twoFactorEnabled, wires
the server/client plugins (allowPasswordless: true so OAuth-only
accounts aren't locked out of managing 2FA), and adds a custom rate
limit for the verify endpoints (5/min — far stricter than the default
100/10s, since a 6-digit code has a much smaller keyspace than a
password).
New Settings → Security section walks through enable (password
confirm -> QR + backup codes -> confirm a live code before it's
actually turned on, per the plugin's default flow) and disable. New
/verify-2fa page handles the post-password mid-login step, with a
backup-code fallback. Verified live end-to-end: enable, confirm,
forced re-auth on next sign-in, TOTP accepted, backup code accepted
(single-use), disable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New lib/recipe-ratings.ts helper batches avg score + review count for
a set of recipe ids in one grouped query, kept separate from the main
paginated/sorted search query rather than joining+grouping it directly
(would've forced grouping by every selected column). Wired into
/api/v1/search and the Explore page's trending/recent queries;
SearchResultCard renders the star only when a recipe has at least one
rating.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The fork endpoint already had no "isn't yours" check, so this is a
UI-only change: ForkRecipeButton takes a variant prop ("fork" for
others' recipes, "duplicate" for your own) that swaps icon/label/toast
copy, and now renders unconditionally instead of only for non-owners.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Hand-rolled a minimal RFC 5545 writer (lib/ics.ts) rather than adding
a dependency for what's a handful of VEVENTs. Meal slots have no
stored time-of-day, so each mealType maps to a conventional wall-clock
time (dinner 7pm, etc.) written as floating local time, not pinned to
a timezone. Authenticated-only download for now, not a subscribe URL
— that would need a new unguessable-link mechanism on meal plans,
which don't have one today (unlike shopping lists' isPublic).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Search previously only matched title/description. Now also matches
ingredient rawName (EXISTS subquery) and tags (unnest+ILIKE) via
sequential scan — fine at current scale, flagged for a trigram/GIN
index if it gets slow. Explore also shows the 12 most-used public
tags as clickable chips that AND-filter results via a new `tags`
query param (array containment).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Usernames are auto-assigned at signup (shipped earlier this session)
but there was still no way to change one. Adds a Username field to
Settings, validated client- and server-side against the same
3-20-char lowercase/digits/underscore pattern used for generation,
with a 409 on collision (excluding the user's own current username).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
People search existed but was buried in its own tab with no entry
point from anywhere else — easy to conclude it "doesn't work" since
nobody would find it. Now one query on Explore hits both
/api/v1/search and /api/v1/users/search and renders People/Recipes
sections together; the standalone people tab and its now-dead
PeopleSearch component are gone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Owner opts in per-list via a new "Allow editing" toggle next to the
existing public-link switch. Anonymous writes are scoped to that one
list only — the link id is the sole credential, enforced in
getShoppingListAccess and the item routes (no session required there
now), with an IP rate limit on genuinely anonymous requests. Turning
off the public link also revokes editing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends the existing bulk-select infra: recipes list gets tag add/remove
and multi-recipe Markdown export; collection pages get a select mode to
move recipes to another collection or remove them from the current one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Swept meal-plan, shopping-lists, pantry, nutrition, explore, feed,
collections, recipe detail/edit, and settings sub-pages at a real 390px
viewport. Two real bugs found (rest checked out clean — the meal-plan
calendar and settings tabs' horizontal scroll are intentional, not bugs):
- Explore's "Recipe ideas" input had no flex-basis and both buttons kept
full text labels, squeezing the input down to ~2 visible characters
on mobile. Input now flex-1, buttons icon-only below sm: (same pattern
as the recipes-page Sort/Filter fix).
- Recipe detail page had no bottom clearance, so the fixed per-recipe
chat button could sit directly on top of the private-notes Save
button on short pages. Added pb-20, matching recipe-form.tsx's
existing floating-save-bar clearance.
Verified both live at 390px: input now shows real placeholder text with
tappable icon buttons alongside it; Save button now clears the chat FAB
with real spacing.
Search bar forced its own full-width row (w-full), pushing Sort/Filter
to a second row below it — on mobile that's title + action buttons +
search row + sort/filter row + toolbar row before any recipe appears.
Sort/Filter now go icon-only below sm: (text labels hidden, matching the
convention already used for compact-view badges), letting Search share
one row with both instead of forcing its own. Also tightened the title
size and inter-section spacing on mobile (unchanged at sm: and up).
Verified at a real 390px mobile viewport: first recipe card now clears
with room to spare, versus barely visible at the same viewport height
before. Desktop screenshot confirms zero visual change there.
New keys can be created as "Full access" (default, unchanged) or
"Read-only" — read-only keys can only make GET/HEAD/OPTIONS requests,
enforced once in requireSessionOrApiKey (lib/api-auth.ts) rather than in
every route, since a route has no way to know a request came from a
scoped key without that check. Existing keys default to full access —
no behavior change for anyone who doesn't opt in.
Also included in this migration: the chat_messages table for the
next commit (chat history persistence) — generated together since both
touched packages/db/src/schema/users.ts in the same pass.
Verified locally: created both a read-only and a full-access key,
confirmed GET succeeds and POST 403s on the read-only key, confirmed
POST still works on the full-access key, and checked the scope badges
render correctly in the real Settings → API Keys UI.
The existing recipe chat (RecipeChatPanel) is tightly scoped to one
recipe — recipeId is required and its system prompt embeds that recipe's
full ingredient/step context. Added a sibling, not a modification: a new
floating assistant on the recipes list page for questions that aren't
about any specific recipe (technique, substitutions, timing, food safety).
New /api/v1/ai/cooking-chat route (same quota/rate-limit/model-resolution
pattern as recipe-chat, minus the recipe lookup) and CookingAssistantPanel
component (same floating-button + Sheet UI). Positioned at bottom-24
rather than bottom-6 so it doesn't sit in the same band as the recipe
list's floating bulk-action bar during select mode.
Verified locally: asked a real cooking question through the actual UI,
got a correctly-formatted markdown answer with no recipe context leaking
in (confirmed the request payload only carries the question, no recipeId).
New Settings → Notifications section with a toggle per category (follow,
comment, reply, reaction, rating, mention, leftover-expiring, shared
shopping list) — previously it was all-or-nothing (browser permission only).
userNotificationPrefs (one row per user, defaults all-on so existing users
see no behavior change until they opt out of something). Gated the push
send in the three places that dispatch one: lib/notifications.ts (the 6
in-app notification types), the leftover-expiry cron, and shopping-list-notify
— in-app notification-center entries and email are unaffected, this only
gates the push itself, matching the literal ask.
Verified locally: GET/PUT round-trips correctly, disabling a category
and confirming the settings page renders all 8 toggles.
userNutritionGoals are daily targets (the nutrition diary already compares
a single day's totals against them directly) — but the weekly meal-plan
route summed an entire week's entries and compared that raw total straight
against the same daily number, so coverage read roughly 7x too high.
Now computes a daily average (across days that actually have planned
meals) and compares that against the goal instead, adds a per-day
breakdown (byDay) for a future day-view, and tracks unknownCount for
entries whose recipe has no nutrition data yet, matching the diary route.
Also fixed a real bug this surfaced: meal-plan/page.tsx and print/meal-plan
parsed the ?week= date via new Date(dateStr) (UTC midnight) then read
.getDay() (local time) — in positive-UTC-offset timezones this resolves to
the wrong Monday, and the reverse (Date -> string via toISOString()) has
the same mismatch in the other direction. Both now do local y/m/d math
throughout.
Verified locally: correct daily-average math end to end (recipe entry +
batch-dish entry, which already attributed nutrition correctly via its
required parent recipeId — no separate fix needed there), and confirmed
the meal-plan page now resolves the right week and renders real coverage
bars against actual planned entries.
Mirrors the existing recipe public-share pattern (/r/[id], gated by a
visibility flag, no auth required) — shopping lists previously only
supported private per-user collaborator invites (email + viewer/editor
role), with no way to hand someone a link who isn't already a member.
- shoppingLists.isPublic (owner-only toggle, same access-control pattern
already used for renaming/deleting the list).
- New public /s/[id] route added to PUBLIC_PATHS — read-only aisle-grouped
view with a signup CTA for logged-out visitors, styled after /r/[id].
- The existing collaborator-invite dialog (ShareShoppingListButton) now
also has a "Public link" toggle + copy-link button at the top, so
there's one Share entry point for both private and public sharing.
Verified locally: toggling public via the real dialog UI persists
correctly (confirmed via DB + a fresh curl PATCH to rule out a client
race in my own test script), and the public link loads with zero auth
for a logged-out browser context.
New users get a Gravatar-backed avatar automatically (computed from email
at signup); users can upload a custom photo instead via Settings, or
revert to the Gravatar/initials fallback. avatarUrl stays the single
resolved value (custom photo, OAuth photo, or precomputed Gravatar URL)
so every existing avatar-rendering spot across the app needs zero changes.
- users.hasCustomAvatar tracks whether avatarUrl is a real upload vs a
computed Gravatar fallback, so "remove photo" knows what to revert to.
- New /api/v1/upload/avatar-presign route (session-scoped, generic —
the existing recipe-photo presign route required a recipeId).
- CSP img-src needed www.gravatar.com added, or every browser blocks
the fallback avatar outright.
- Settings page now reads avatarUrl from a fresh DB query instead of
Better Auth's session object — the session cookie cache (5 min TTL)
was serving a stale image right after upload, showing the initials
fallback until the cache happened to expire.
Verified locally: signup auto-sets a Gravatar URL, upload persists
across reload, remove correctly reverts to Gravatar/initials.
recipe-form.tsx previously had no batch-cook awareness at all — editing an
AI-generated batch-cook recipe and saving silently corrupted it (steps lost
their per-dish `appliesTo` tags, recipeBatchDishes rows went stale/orphaned,
since the create/update API schemas and edit-page query never touched them).
Adds a "batch-cook recipe" toggle to the form, an editable dish list (name,
description, fridge days, freezer-friendly/note, day-of instructions), and
per-step dish tagging (click a dish chip to mark which dish(es) that step
advances; empty = shared prep). Renaming a dish propagates to any step
still tagged with the old name instead of orphaning it. Wired through
Create/Update API schemas and the edit page's query/payload.
Verified locally: created a 2-dish batch recipe from scratch through the
real form, confirmed it renders identically to an AI-generated one (grouped
steps, dishes & storage cards), edited it, renamed a dish, reloaded, and
confirmed the step tag followed the rename rather than orphaning.
recipe-form/API routes were never sending/persisting photos on create+update.
PhotoUploader accumulated uploads via a stale closure over the photos prop, so
only the last file of a multi-select survived. Once both were fixed, thumbnails
still 400'd because Next's image optimizer blocks upstream hosts resolving to
private/loopback IPs (localhost:9000 MinIO) — skip optimization for
storage-hosted photos since they're already web-sized user uploads.
- MinIO had no CORS config at all, so the browser's direct PUT to a
presigned URL (cross-origin: app on :3001/:3000, storage on :9000)
was blocked outright. Added MINIO_API_CORS_ALLOW_ORIGIN — "*" in
dev, the app's own origin in prod. Verified end-to-end: photo
upload now succeeds with zero console errors.
- Removed the public /changelog page and its account-menu link —
changelog is admin-only now (/admin/changelog).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bumps version to 0.2.0 (root + apps/web package.json) and adds
CHANGELOG.md as the canonical human-readable history, mirrored by
apps/web/lib/changelog.ts as the in-app data source (single
ChangelogList component renders both).
- /changelog: user-facing page, linked from the account dropdown.
- /admin/changelog: same list, plus a "Version" card on the admin
overview dashboard linking to it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Shopping list add already worked generically for batch-cook recipes —
no code needed there.
New: mark a specific batch-cook dish as "cooked today", track its
fridge expiry (cookingHistory.batchDishId), surface a "Leftovers
expiring soon" widget on the pantry page, and send a daily push+email
reminder via a new /api/internal/cron/leftover-reminders endpoint
(mirrors the weekly-digest cron pattern; doesn't use the social
notifications table, which requires a non-null actor and isn't built
for self-reminders).
Also fixes, from user-reported bugs:
- Recipe cards showed no batch-cook badge/dish-count/prep-time in some
views — added dishCount + prepMins/cookMins (now generated by the AI
and persisted) to the card component and /recipes query.
- Batch-cook descriptions occasionally contained raw markdown
(**bold**) — added explicit "plain prose only" prompt instructions
and a stripMarkdown() defensive fallback at render time.
- Truncated/cut-off descriptions — the generateObject call had no
maxOutputTokens set, so long structured responses could get cut off
mid-field; now capped explicitly at 8000.
- Generate dialogs (batch-cook + the main AI dialog) could show
buttons unreachable once the progress bar appeared mid-generation —
restructured so the action row is pinned outside the scrollable
content area, not affected by content height changes.
- /api/internal/* routes were being redirected to /login by middleware
before their own CRON_SECRET check ever ran (pre-existing bug,
affected the weekly-digest cron too) — added to PUBLIC_PATHS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Batch-cook recipes no longer live in a separate /batch-cooking section:
- Removed the dedicated page/route and its nav button.
- /recipes shows both kinds together, with a chef-hat badge marking
batch sessions, and a filter to isolate/hide them.
- "Batch cooking" is now a third tab in the existing "Generate recipe
with AI" dialog (alongside Describe/Photo) instead of a separate
button — one AI entry point instead of three.
- Extracted the counters/difficulty/dietary form into BatchCookFields,
shared between that dialog and the meal-planner's batch-cooking
dialog.
- Also fixed a dialog resize issue (progress bar mounting mid-generate
grew the dialog's height, which the positioning library didn't
always handle) by reserving space up front and capping dialog height
with a scroll fallback.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Recipes page action buttons reordered/restyled to push AI generation
and batch cooking ahead of manual recipe creation.
- Recipe detail page hides meal/drink pairing (doesn't make sense for
a multi-dish batch session).
- Print pages force light mode regardless of app theme (dark bg +
hardcoded dark text was unreadable) and render sectioned steps +
dishes/storage for batch-cook recipes.
- Markdown export mirrors the same sectioned structure.
- Cooking mode tags each step with which dish(es) it belongs to.
- Meal planner: mealPlanEntries.batchDishId lets a slot point at one
specific dish within a batch session; picking a batch-cook recipe
now prompts for which dish, and the grid shows the dish name.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New AI-generated batch-cooking flow: pick N dinners/lunches + servings,
get one unified recipe covering a full prep session — merged shopping
list, steps tagged per-dish (a step can advance several dishes at once,
e.g. a shared oven bake), and per-dish storage (fridge/freezer) + exact
day-of reheat/finishing instructions.
Schema: recipes.isBatchCook, recipeSteps.appliesTo (dish names), new
recipeBatchDishes table. Batch recipes are excluded from the normal
/recipes list and live under their own /batch-cooking section.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every "nothing here" page had its own copy-pasted dashed-border box —
icon + one muted line, inconsistent (some had a CTA, some didn't, no
description text anywhere). Replaced with one shared EmptyState
component: icon in a soft tinted circle, a real heading plus optional
description, and primary/secondary actions (either a Link or an
arbitrary action slot for things like "New Collection" that open a
dialog rather than navigate).
Applied to: recipes (no recipes / no search match), favorites, feed
(no one followed / no new posts / trending / for-you), collections
(index + detail), shopping lists, pantry, notifications, can-cook.
Left the small inline "no trending"/"no recent" lines inside Explore's
already-labeled sections and the notification-bell dropdown alone —
different context, a full empty-state box would be heavier than the
space warrants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Added a heart toggle to all three recipe-grid views (grid card overlay,
list row, compact row), hidden while in multi-select mode. Detail page
already had it; this was the other place it made sense.
- recipes/page.tsx now fetches which of the current page's recipes are
already favorited and passes it through.
- FavoriteButton gained an optional iconClassName so the unfavorited heart
reads on a photo overlay (was invisible in light mode against the dark
backdrop — filled/favorited state was already fine since it's opaque red).
- Fixed the floating bulk-select action bar overflowing both edges of the
viewport on mobile (screenshotted: centered with no max-width, wider
than the screen, clipping "Supprimer" on one side and the selected-count
label on the other). Now caps to viewport width, scrolls horizontally
as a fallback, and drops button text labels below sm (icon + aria-label
+ tooltip only), matching the icon-row convention used elsewhere.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Favoriting already worked (heart toggle on the recipe detail page, DB
table, API route) but there was nowhere to see what you'd favorited —
the actual missing piece behind "add a favorites feature".
- New /recipes/favorites page: paginated grid of favorited recipes (cover
photo, difficulty, time, author), sorted by most-recently favorited.
A previously-favorited recipe stays visible even if the author later
goes private, matching the existing private-accounts scope decision
(existing access isn't revoked, only new discovery is affected).
- Unfavoriting from that page removes the card immediately — added an
optional onToggle callback to FavoriteButton for this.
- Added a lightweight "My Recipes / Favorites" tab pair at the top of
/recipes linking between the two, rather than a new top-level nav item
(nav is already at 8 entries).
Deliberately out of scope: favorite toggles on the main recipe grid, feed
cards, or search/explore results — favoriting your own recipes is odd
UX, and wiring session-aware favorited state into the public
(unauthenticated) search route is a separate, bigger change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
flex-wrap let the header buttons (export, share, print, markdown export,
rename/delete) drop to a second row once they didn't fit. Switched to the
same non-wrapping, horizontally-scrollable row pattern already used for
the recipe detail page's action buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed from a real screenshot: every item in an all-French shopping list
was uncategorized because guessAisle() only matched English keywords —
"sometimes reorganize doesn't work" was actually "always fails on non-English
ingredient names". Also fixed:
- Categories are now translated (grocery-categories.ts stores locale-
independent slugs like "produce", translated for display via
shoppingLists.categories.* instead of storing/rendering the English label
directly)
- Added French keyword coverage to the aisle guesser so auto-categorization
actually works for French recipes
- Users can now type a custom category name from the item's category menu
- The sort-mode dropdown was showing the raw value ("category") instead of
its label — base-ui's Select.Value has no built-in value->label lookup,
it needs an explicit render function, which no Select in this call site
had
- Widened the shopping list detail page (max-w-lg -> max-w-2xl)
- Root-caused a second bug visible in the same screenshot: ingredients with
the quantity embedded in the name (e.g. "1 avocat") still showed that way
even though quantity/unit were already populated separately, because the
extraction helper only fired when quantity was empty. Now it always
cleans the name but only backfills quantity/unit when they're missing,
and runs at shopping-list generation time too (not just recipe save) so
it also cleans up already-contaminated recipes, not just new ones.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Fixed a real i18n bug: the checked-count line called the wrong
translation namespace and rendered the literal key on screen
- Shopping lists can now be renamed and deleted from both the list index
and detail pages (API already supported delete; rename was net new)
- Root-caused "long list UI is off": meal-plan-generated lists never set
an aisle, so every item fell into one undifferentiated "Other" bucket
despite the grouping UI existing. Added a keyword-based aisle guesser
wired into list generation (fallback only, never overrides an explicit
aisle) plus a one-click "auto-categorize" for existing lists
- Items can now be reordered by drag-and-drop within a category (dnd-kit),
recategorized via a dropdown, deleted, searched, and sorted (category /
alphabetical / unchecked-first); searching flattens the grouped view
- Fixed a separate bug: AI-generated ingredients sometimes embedded the
quantity/unit in the name itself (e.g. "2 cups flour" as one string).
Added extractIngredientQuantity() as a Zod transform at both recipe
create/update routes (the choke point every creation path funnels
through) to split it back out, plus schema descriptions on the AI
ingredient schemas as a prevention layer
New migration 0028 (shopping_list_items.sort_order), left unapplied like
the others. Verified with typecheck, lint, and a clean --no-cache docker
build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Meal-plan page now has a direct "Add to shopping list" action for the
currently-viewed week, instead of requiring a trip to Shopping Lists and
manually typing the week's Monday date.
- Fixed a real under-shopping bug in shopping-lists/route.ts: when
generating from a meal-plan week, ingredients appearing in more than one
recipe were merged by keeping only the FIRST occurrence's quantity and
silently dropping the rest. New mergeIngredients() (pantry-shopping-match.ts)
groups by ingredientId (or normalized name+unit) and sums quantities
across every recipe in the week; incompatible/unparseable units are kept
as separate line items rather than guessed at.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Private accounts: users.isPrivate hides a user from search and their
recipes from search/trending/for-you discovery surfaces (follow-aware
where the route already has session context, blanket exclusion where it
doesn't); existing followers and direct links are unaffected, no
follow-request approval flow was built (explicit scope limit)
- Merged /people into the Explore tab (tab=people query param); the old
standalone route now redirects there
- "Get Ideas" vs "Surprise Me" were doing the same empty-prompt call;
Surprise Me now injects a real random constraint (5-ingredient, one-pot,
etc.), matching the existing pattern in the AI recipe-generate dialog
- Meal-plan day cells now link to their recipe (was dead text) and gained
a one-click "mark as cooked" action
- Theme toggle is now a real three-way light/dark/system control instead
of a binary flip
- Nutrition goals form had zero i18n wiring; fully localized now
New migration 0027 (users.is_private) generated, left unapplied like the
others. Verified with typecheck, lint, and a clean --no-cache docker build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Six M-sized items from HANDOFF.md's new-features backlog:
- Profile tabs: cooking-history stats (total cooked, last-cooked, streak)
and a "cooked it" photo gallery, both owner-only
- Display-time unit conversion (metric<->imperial) for recipe ingredients,
respecting each user's unitPref; original value always shown alongside
the conversion
- Nutrition daily diary: per-day macro totals computed from cooking history
x recipe nutritionData, compared against user goals
- Pantry scan: real barcode lookup (zxing + Open Food Facts, no API key)
with an AI-vision fallback for unbarcoded items, always confirm-before-
insert, both paths tier/rate-limited like other AI features
- Weekly digest email: new followers/comments/ratings + trending recipes,
sent via a new `cron` Docker stage (alpine+crond+curl) and `digest-cron`
compose service hitting a bearer-token-protected internal route
- Meal-plan generation can now target a user's nutrition goals as a
prompt-level nudge (recipes are AI-invented, not DB-sourced, so this
can't be a hard macro constraint)
Caught a real deploy-breaking issue while adding the cron stage: appending
it after `runner` silently changed the Dockerfile's default build target,
and `web`'s compose config didn't pin one — fixed by pinning `target:
runner` explicitly. Verified with typecheck, lint, and three separate
`docker build --target` runs (runner/cron/migrator) plus `docker compose
config` validation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The bell only ever showed the last 30; add a full paginated /notifications
page, per-notification mark-read, and a shared notificationHref helper so
the bell and the page route identically instead of duplicating the logic.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Five S-sized items from HANDOFF.md's new-features backlog, all wiring up
previously-orphaned infra:
- createNotification now sends web push + email for every notification type
(follow/comment/reply/reaction/rating/mention), not just comments
- Personal recipe notes: private per-user notes on any viewable recipe
(recipeNotes table had zero API/UI before this)
- Recipe fork/clone: deep-copies a viewable recipe into your own library as
a private draft, linked via recipeVariations, respects tier quota
- Pantry-aware shopping lists: meal-plan-generated lists now subtract
on-hand pantry quantities (ingredientId match, falling back to normalized
name match) and flag partial/ambiguous matches instead of guessing
- GDPR data export: downloadable JSON of a user's own content and activity
across every relevant table, secrets/internal tables excluded
New migrations 0025 (unique index for recipe-notes upsert) and 0026
(shopping_list_items.in_pantry) generated, left unapplied like 0023/0024.
Verified with typecheck, lint, and a full local `docker build`.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>