Commit Graph

127 Commits

Author SHA1 Message Date
Arnaud 4e5f45a7e5 feat: QR code on printed shopping lists
Added the qrcode package (server-side data-URL generation, no client
JS) rather than hand-rolling QR encoding. Only rendered when the list
is public — a private list's /s/ link 404s for anyone who isn't the
owner, so a QR code there would just be a dead scan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:50:38 +02:00
Arnaud c21157fc93 feat: rating stars on recipe cards (Search, Explore)
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>
2026-07-13 22:43:58 +02:00
Arnaud 95a4dccce8 feat: duplicate your own recipe
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>
2026-07-13 22:27:14 +02:00
Arnaud 6513bfa6ee feat: export meal plan to a calendar (.ics) file
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>
2026-07-13 22:22:33 +02:00
Arnaud 00ca8b9d68 feat: live updates on shared meal plans
Two separate components (the owner's own week view and collaborators'
shared view) hit two different API surfaces for the same underlying
entries, so neither ever saw the other's edits without a manual
reload. Both now poll a new lean GET on their respective entries
routes every 4s, merged with the same dirty-until-ref guard used for
shopping lists so a poll can't stomp an in-flight local edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:17:15 +02:00
Arnaud aa67868b96 feat: clear a meal-plan day or the whole week in one action
New bulk-delete route for the owner's own weekStart-scoped plan
(mirrors recipes/bulk's DELETE), plus ?ids= support added to the
existing shared-plan DELETE route (kept ?entryId= working
unchanged). UI: hover-reveal trash icon per day header, "Clear week"
button in the toolbar, one shared confirm dialog for both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:07:12 +02:00
Arnaud a144052e46 feat: @mention autocomplete in comment composer
Typing "@" + 2+ characters now opens a dropdown of matching users
(reusing the existing people-search endpoint), with arrow-key/Enter/
Tab selection and click-to-select. Consolidated the previously
duplicated MENTION_REGEX (client display vs. server extraction) into
a single export from lib/mentions.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:00:10 +02:00
Arnaud 6efabeab8a feat: search matches ingredients/tags, add tag filter chips to Explore
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>
2026-07-13 21:55:17 +02:00
Arnaud f0397740d7 feat: let users change their auto-generated username in Settings
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>
2026-07-13 21:48:13 +02:00
Arnaud 2ee99ea463 fix: no user ever had a username, so people search found nobody
Root cause of "still cannot find users": username was optional in
better-auth's schema and no signup form or settings page ever set it,
but search/profile/follow all key on username, not user id. Every
account now gets a unique one auto-generated (from name/email) in the
user.create.before hook — covers email/password and OAuth signups.
Added a one-off db:backfill-usernames script for accounts created
before this fix and ran it against the current database.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 17:26:12 +02:00
Arnaud 79cdb8cd04 feat: merge people search into the recipe search bar on Explore
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>
2026-07-13 15:38:29 +02:00
Arnaud 6dd48b5c25 fix: changelog page rendered literal **bold** markup instead of bold text
Entries are plain strings with occasional **bold**/`code` spans, never
full markdown — added a tiny inline parser rather than pulling in
react-markdown just for this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 13:26:38 +02:00
Arnaud 2beb23b360 feat: public shopping list links can allow editing
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>
2026-07-13 12:53:52 +02:00
Arnaud b1f4bba6dd feat: live updates on shared shopping lists
Polls item state every 4s and merges it into the list view, guarding
any item with an in-flight local edit so a poll landing mid-edit can't
stomp on it. No websocket/SSE infra exists in this app yet, so this
follows the same polling convention already used for notifications and
message threads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 12:27:48 +02:00
Arnaud 70eb565eec feat: bulk tag editing, bulk export, and move recipes between collections
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>
2026-07-13 11:57:07 +02:00
Arnaud 4960dfc7c6 feat: rate limit public routes; backfill v0.9.5 changelog for mobile audit
Sign-in/sign-up now throttle at 3 req/10s/IP via better-auth's built-in
rate limiter; /r/ and /s/ share links throttle at 60 req/min/IP via
proxy.ts using the existing Redis-backed limiter (Next 16's Proxy always
runs Node.js, no Edge-runtime blocker after all).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 09:32:37 +02:00
Arnaud 7c63388618 chore: bump version to 0.9.4, backfill changelog with real commit times
Every entry got a time component pulled from actual git commit
timestamps (git log --date=format), not guessed — precise enough to
disambiguate the many same-day releases from this session. 0.2.0 and
0.1.0 kept date-only/"earlier" since no reliable timestamp exists for
them. Also adds the 0.9.4 entry for the previous action-buttons/
view-toggle mobile fix, which shipped without its own version bump.
2026-07-13 09:09:55 +02:00
Arnaud 13b8de0e0c chore: bump version to 0.9.3, changelog entry 2026-07-13 08:38:46 +02:00
Arnaud e117533f6f chore: bump version to 0.9.2, changelog entry 2026-07-13 07:14:38 +02:00
Arnaud 5d351f88b0 chore: bump version to 0.9.1, changelog entry 2026-07-13 06:59:00 +02:00
Arnaud da48d0ecef chore: bump version to 0.9.0, changelog entry 2026-07-12 23:28:18 +02:00
Arnaud 9549684254 chore: bump version to 0.8.0, changelog entry 2026-07-12 22:38:59 +02:00
Arnaud 0062220d8e feat: read-only API key scoping
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.
2026-07-12 22:37:14 +02:00
Arnaud b2f2274673 chore: bump version to 0.7.1, changelog entry 2026-07-12 21:36:32 +02:00
Arnaud 8ec11e9463 chore: bump version to 0.7.0, changelog entry 2026-07-12 19:36:37 +02:00
Arnaud eed57cd10b fix: API keys always showed "never used" — middleware blocked them entirely
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.
2026-07-12 19:34:25 +02:00
Arnaud b69f5845c3 chore: bump version to 0.6.0, changelog entry 2026-07-12 19:09:10 +02:00
Arnaud 4d5269aced feat: granular per-category push notification settings
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.
2026-07-12 19:07:32 +02:00
Arnaud 5a9e306357 fix: close SSRF/rebinding, IDOR, and stale-session authz gaps found in audit
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>
2026-07-12 19:05:20 +02:00
Arnaud 4f36ce24b2 chore: bump version to 0.5.0, changelog entry 2026-07-12 18:37:55 +02:00
Arnaud aa6bd03c5e feat: push notification when a shared shopping list is updated
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).
2026-07-12 18:36:20 +02:00
Arnaud 1bcea2f273 chore: bump version to 0.4.0, changelog entry 2026-07-12 16:13:09 +02:00
Arnaud d7e0d7eada feat: profile pictures — Gravatar fallback + custom upload
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.
2026-07-12 16:10:54 +02:00
Arnaud a42fd497b0 chore: bump version to 0.3.1, changelog entry 2026-07-12 15:08:07 +02:00
Arnaud 13128df19f feat: locale-based generation, describe field for batch cooking; fix bulk-bar width and sourceUrl not saving
- 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.
2026-07-12 15:06:48 +02:00
Arnaud 5b8c50dd1e chore: bump version to 0.3.0, changelog entry for manual batch-cook editing 2026-07-12 14:46:35 +02:00
Arnaud 20e4ce3597 chore: bump version to 0.2.2, changelog entry for storage-URL fix 2026-07-12 13:54:23 +02:00
Arnaud 8df292dfee fix: photo thumbnails always used localhost:9000 in production
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).
2026-07-12 13:53:15 +02:00
Arnaud b160fdc338 chore: bump version to 0.2.1, changelog entry for photo fixes 2026-07-12 13:21:07 +02:00
Arnaud 321507b570 fix: docker build crash when STORAGE_PUBLIC_URL unset
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>
2026-07-12 12:16:11 +02:00
Arnaud 83d18f5ad4 feat: add changelog and versioning, surface it in admin
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>
2026-07-12 12:06:07 +02:00
Arnaud 2682eba2be fix: presigned upload URLs, card badge alignment, filter menu UX, floating save
- 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>
2026-07-12 10:53:12 +02:00
Arnaud 002f14ced0 feat: batch-cook shopping list (already worked) + leftover expiry reminders
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>
2026-07-12 10:03:52 +02:00
Arnaud ba1614073b feat: adapt recipe surfaces for batch-cook sessions
- 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>
2026-07-12 02:32:57 +02:00
Arnaud 78d0599ffc feat: add batch-cooking sessions (single "mega recipe" with dish sections)
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>
2026-07-12 01:23:37 +02:00
Arnaud 51a323b054 fix: shopping list categories were English-only and untranslated, page too narrow
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>
2026-07-10 11:03:11 +02:00
Arnaud 5afc7cd182 feat: shopping list rename/delete, item reorder+categories+search, ingredient-quantity parsing fix
- 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>
2026-07-10 10:29:28 +02:00
Arnaud d62e2a6383 feat: one-click shopping list from meal plan, fix ingredient merge bug
- 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>
2026-07-10 09:45:51 +02:00
Arnaud b0849c3989 feat: cooking history/gallery, unit conversion, nutrition diary, pantry scan, digest cron, nutrition-targeted meal plans
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>
2026-07-10 08:06:28 +02:00
Arnaud 913410dbf3 feat: notifications inbox page
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>
2026-07-10 07:57:17 +02:00