"New recipe" kept its text label below sm:, so on mobile (especially
French, where "Importer une URL"/"Nouvelle recette" run long) it wrapped
to its own second line — icon-only now below sm:, matching the Sort/Filter
treatment from the previous mobile-spacing fix.
The view-toggle + Select row used justify-between with an empty filler
div outside select mode, pushing the controls flush right and leaving a
large dead gap on the left — same width as the row above it, but visually
disconnected. Now left-aligned outside select mode (justify-between still
applies inside select mode, where there's real content on both sides).
Verified at a real 390px mobile viewport in both English and French —
Generate/Import/New now share one row, and the view-toggle row starts
flush left instead of floating on the right with empty space before it.
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.
"Get ideas" used variant="secondary" (flat grey) and "Surprise me" was
variant="ghost" (no background at all) — both sat awkwardly on the
violet/indigo gradient section. Restyled to match: solid violet for the
primary action, violet-outlined for the secondary.
(Also answering the "what's the difference" question inline: "Get ideas"
generates from whatever you typed in the box; "Surprise me" ignores the
box and picks a random prompt from a fixed list, filling the box and
generating immediately.)
--destructive-foreground was never defined in globals.css (light or dark
theme) — text-destructive-foreground resolved to nothing, so both the
clear-chat-history confirm button and recipe-form's discard-changes
confirm (same bg-destructive text-destructive-foreground pattern) fell
back to the default dark text color on a solid red background.
Added the missing variable (white, matching --primary-foreground's
light value — both themes use a saturated enough red that white stays
legible), mapped through @theme inline alongside the existing
--color-destructive.
Verified locally: screenshotted the clear-history confirm dialog,
button now shows white text on red.
Manual: DELETE /api/v1/ai/chat-history (scoped by recipeId or scope=general,
matching GET's scoping) plus a trash-icon button + confirm dialog in both
chat panels' headers.
Automatic: new internal cron endpoint (chat-cleanup), same shared-secret
pattern as the existing leftover-reminders/weekly-digest crons, deletes
any chat_messages older than 90 days. Wired into cron/crontab (daily,
03:00 UTC) and the Dockerfile's cron stage.
Verified locally: cleared a real conversation through the actual UI and
confirmed it didn't come back on reopen (not just cleared client-side);
inserted a 100-day-old and a 5-day-old message directly, called the cron
endpoint with the real shared-secret check, confirmed only the old one
was deleted and the recent one survived; confirmed the endpoint 401s with
no/wrong secret.
Both chat panels (per-recipe and the general cooking assistant) previously
kept messages in component state only — closing the sheet or reloading
the page lost everything. Both POST routes now persist question+answer
pairs to a new chat_messages table (recipeId null for the general
assistant), and both panels load their own history back on open.
New GET /api/v1/ai/chat-history (scoped by recipeId, or scope=general for
the homepage assistant, or neither to search across everything) backs a
new search control in both panels' header — debounced, shows matched
messages with date and (when searching across everything) which recipe
they belonged to.
Verified locally: asked a real question, closed and reopened the panel
and confirmed the conversation reloaded, then searched a keyword from
that conversation and confirmed both the question and answer surfaced
in the results dropdown.
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.
Moved the homepage cooking-assistant button from bottom-24 to bottom-16 —
still clears the recipe list's floating bulk-action bar, just not as far.
Both chat system prompts (cooking-chat and the per-recipe chat) now
explicitly identify as "Epicure" and are told never to name the underlying
model/provider if asked — previously they'd just answer honestly with
whatever the model calls itself.
Verified locally: screenshotted the new button position, and asked "what
model or AI are you" through the real UI — response identified as Epicure
with no model name.
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).
proxy.ts required a session cookie for every non-public /api/v1/* request,
rejecting with 401 before the request ever reached requireSessionOrApiKey
(lib/api-auth.ts) — the only place that actually verifies a Bearer API key
and updates lastUsedAt. Pure API-key clients never send a session cookie,
so every single API-key request was blocked at the middleware layer; the
lastUsedAt update code was correct but unreachable.
Now lets requests with an `Authorization: Bearer ek_...` header through to
the route, which still does the real verification (and 401s itself on an
invalid/unknown key) — middleware just stops pre-emptively rejecting valid
ones. Also added error logging to the fire-and-forget lastUsedAt update,
previously silent on failure.
Verified locally: hashed a raw key, confirmed it matched the stored hash
(so the lookup itself was never the problem), reproduced the 401 against
the unpatched middleware, then confirmed both the 200 response and
lastUsedAt populating correctly after the fix — visible in the real
Settings → API Keys UI.
script-src had no allowance for cdn.jsdelivr.net, which is where the
Scalar API-reference viewer's JS is loaded from — the script itself
never ran. Added it to script-src/style-src/font-src/connect-src.
(User-confirmed before applying — widens what's allowed to load, so
wanted explicit sign-off rather than just doing it in response to a
console error.)
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.
Never checked the browser's actual push subscription on mount — state
initialized to false unconditionally, so the button looked reset to
"Enable notifications" on every page load even when notifications were
genuinely still active. Now checks navigator.serviceWorker + getSubscription()
on mount and initializes from that.
Bump to 0.5.1. Fixes: unfollowed-redirect SSRF + DNS-rebinding in AI
url-import and webhook dispatch (new safeFetch with IP-pinned undici
dispatcher); cross-user photo deletion via unvalidated recipe/review
storage keys; comment-moderation and tier-quota checks trusting a
stale cached session role/tier instead of the DB.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Shared shopping lists (owner + collaborator-invite members) had no
real-time coordination signal — no way to know someone else just checked
off milk or added items while you're mid-aisle. Notifies every other
member (excluding the actor) via push when an item is checked off or
items are added; no-op for solo unshared lists.
Deliberately skips the notifications table/notification-center (same
pattern as the existing leftover-expiry cron) — this is a lightweight
best-effort ping, not a persistent event, so it avoids a notification_type
enum migration and a new FK column for something transient.
Verified locally: added a real member to a shared list, registered a
push subscription, confirmed the check-off request's added latency
(945ms vs ~20ms baseline) shows the actual webpush call fired against
the recipient — individual subscription failures are swallowed by design
(Promise.allSettled in the existing sendPushNotification, unchanged);
confirmed a solo list stays fast (no recipients to notify, true no-op).
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.
Ingredients aren't attributable to individual batch dishes (they're one
merged/shared list for the whole prep session, unlike steps which have
`appliesTo`) — the cooked route's pantry-deduction block was unconditionally
skipped whenever batchDishId was set, so pantry was never touched for any
batch-cook recipe.
Deducts once, on the first dish marked cooked for that recipe+user (checked
via prior cookingHistory rows) — matches the fridge/freezer-day design
intent of "cook the batch once, eat dishes over several days" rather than
deducting (or trying to deduct) per individual dish.
Verified locally: first dish cooked deducted the full recipe's ingredients
from pantry; second dish cooked did not double-deduct.
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.
sourceUrl was already persisted and shown on the recipe detail page, but
never surfaced on cards in the grid/list/compact views or the simpler
RecipeCard (collections) — added a small external-link icon with a
tooltip naming the source hostname, sized identically to the existing
batch-cook badge so it doesn't reintroduce the compact-view alignment
bug fixed earlier this session.
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.
The dish-count/servings and time spans had no fixed width (unlike the
difficulty badge slot, which reserves w-16 even when empty) — a batch-cook
recipe's longer "N dishes"/"Nm total" text, or a missing time value,
changed that column's width row-to-row, shifting the date and icon
cluster after it. Gave both spans fixed widths so every compact row has
identical column layout regardless of content.
- AI recipe generation always used the app's own locale rather than a
separate language picker — removed the picker, generation and the
saved recipe's language now both follow useLocale() directly.
- Bulk-select action bar had an ungated max-w-md that capped it at 448px
even past the sm: breakpoint where sm:w-auto was supposed to let it
grow to content — added sm:max-w-none so desktop never scrolls.
- Batch-cooking generation had no free-text "describe" field like the
standard recipe generator does — added one (BatchCookFields, both the
standalone dialog and the AI dialog's batch tab), threaded through the
API route and generateBatchCook's prompt as additional guidance.
- Recipes imported from a URL never actually got their sourceUrl
persisted — CreateRecipeSchema didn't declare the field, so Zod
silently stripped it before it ever reached the insert. The detail
page's "Source: <hostname>" link already existed but was dead code
for every real import until now.
Verified all 4 live: generate dialog has no language selector, batch
tab has a working describe textarea, bulk-select bar renders at full
content width with zero horizontal overflow on desktop, and a recipe
created with sourceUrl now round-trips and renders its source link.
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.
getPublicUrl() runs in the browser (called from client components rendering
recipe thumbnails), but read the plain STORAGE_PUBLIC_URL env var — never
inlined into the client bundle, so every browser fell back to the hardcoded
localhost:9000 default regardless of the real deployed storage domain,
tripping CSP img-src and mixed-content blocks in production. Added a
NEXT_PUBLIC_STORAGE_PUBLIC_URL build arg (Dockerfile, compose.prod.yml) wired
from the same STORAGE_PUBLIC_URL value, and getPublicUrl() now reads that.
Verified locally: building with a fake public storage domain set shows it
correctly inlined into the client JS chunk (previously only the localhost
fallback ever appeared there).
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>
compose.prod.yml passes STORAGE_PUBLIC_URL through ${STORAGE_PUBLIC_URL}
build args/env — when unset in .env.production, docker-compose
substitutes an empty string, not an absent var. ?? only falls back on
null/undefined, so next.config.ts's `new URL("")` threw and failed the
whole `pnpm --filter web build` step. Switched to || in next.config.ts
and lib/storage.ts so empty string also falls back to the default.
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>
- Presigned upload URLs were signed against STORAGE_ENDPOINT (the
internal docker hostname, e.g. http://minio:9000) instead of a
browser-reachable URL — uploads/edits with photos were broken in
prod. Now signed with a separate client bound to STORAGE_PUBLIC_URL,
which also needed threading through as a Docker build arg (CSP
headers are computed at build time) and into compose.prod.yml/
.env.production (gitignored, not committed).
- recipe-form.tsx used the wrong i18n namespace for the visibility
label (t("recipeForm") instead of t_recipe("recipe")), causing
MISSING_MESSAGE in French.
- Compact recipe-list view: batch-cook rows showed no dish count, and
the date/difficulty columns shifted per-row depending on whether a
difficulty badge was present — reserved a fixed-width slot for it.
- All three card icon badges (batch-cook, favorite, visibility) now
share the same muted color instead of the batch badge standing out.
- Recipes filter dropdown: clicking a filter option closed the whole
menu, and the tag text input couldn't be typed into (the menu's
roving-focus/type-ahead handling was intercepting keystrokes) — menu
items now stay open on click, and the tag input stops event
propagation so it behaves like a normal text field.
- Recipe form: added a floating save/cancel bar so long recipes don't
require scrolling back down to save.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Batch-cook indicator moved from next to the title into the icon row
alongside favorite/visibility (all three now get a tooltip, matching
the existing favorite-button pattern).
- Favorite button's tooltip text was hardcoded English ("Save"/
"Saved") instead of using the translation keys already used for its
aria-label.
- Shortened the batch-cook generate dialog's "Generating…" label
(fr.json edited by user) — the long text was the real cause of the
modal overlap, not the dialog structure.
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>
Adds a "Batch cooking" button next to "Generate with AI" on the meal
plan page, opening the same wizard used on /batch-cooking. Closes the
loop on the original ask: batch-cooking sessions can now be started
directly from the meal planner, not just from the recipes list.
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>