Finora il fastingProtocol era una preferenza salvata ma ignorata dal
planner — ogni giorno generava 4 pasti. Ora cambia struttura per protocollo
e per giorno della settimana.
@ketopath/shared/planner
- protocolPlanForDay(protocol, dayOfWeek) → { share, kcalMultiplier }
- 14:10 → tutti e 4 i pasti, share default
- 16:8 → niente colazione: pranzo 50%, spuntino 10%, cena 40%
- 18:6 → niente colazione: pranzo 55%, spuntino 5%, cena 40%
- 20:4 → solo spuntino+cena (10/90)
- ESE 24h → mercoledì kcalMultiplier=0 (giorno saltato), altri default
- 5:2 → lunedì + giovedì kcalMultiplier=0.25, share 100% cena
- 7 unit test in protocol.test.ts
apps/api/plan
- POST /me/meal-plans: legge preferences.fastingProtocol, deriva il piano
per ogni giorno, salta MealSlot con share=0 e giorni a kcalMultiplier=0
- GET /me/meal-plans/current: include fastingProtocol nella risposta
- baseDailyTarget restituito invariato per il client
apps/web
- /plan mostra "Finestra alimentare 16:8" sotto il titolo della settimana
- plan-week distingue i giorni "fasting" (digiuno completo) e "leggeri"
(5:2 fasting day) con copy dedicato; rimuove totali calorici inappropriati
- i18n: Plan.protocolLabel + Plan.fastingDay/lightDay
Smoke tested: settato 16:8 da /profile, rigenerato il piano, verificato
che l'etichetta appaia e che ogni giorno abbia esattamente 3 pasti
(Pranzo / Spuntino / Cena, niente Colazione).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Step "L'obiettivo" iniziale (perdere peso / mantenere / energia):
card-based, client-state, personalizza il copy degli step successivi
- Step "Promemoria": opt-in push notifications inline durante l'onboarding,
riusa subscribe() del push-client; skippable
- Step "Pronti a partire" arricchito:
· auto-genera il primo piano in background al primo render
· copy che cambia in base al goal scelto
· 3 next-cards (Cucina, Digiuno, Tracking) per orientare l'utente
· banner "Aggiungi alla Home" condizionale per iOS Safari non-standalone
(necessario per ricevere push su iPhone, vedi ADR 0003)
- Smart-jump aggiornato per la nuova sequenza (5 step invece di 3)
- i18n: namespace Onboarding esteso con goal/notifications/iOS install copy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ADR 0003: VAPID self-hosted today, DeviceToken model agnostic to
platform so iOS/Android (Expo/APNs/FCM) plug in as new senders later.
Backend (apps/api/src/modules/notifications)
- sender.ts: NotificationSender interface, WebPushSender via VAPID
- notifications.routes.ts: GET /me/notifications/config, POST/DELETE
/me/device-tokens, PATCH /me/notifications/settings, POST /me/notifications/test
- scheduler.ts: node-cron Mon 09:00 Europe/Rome for weekly weigh-in
reminder; auto-cleanup of expired tokens on 404/410
- env: VAPID_PUBLIC_KEY/PRIVATE_KEY/SUBJECT (all optional → push gracefully off)
Frontend
- public/sw.js minimal (push + notificationclick)
- lib/notifications/push-client.ts: subscribe / unsubscribe / getCurrentSubscription
- profile/notifications-{actions,panel}.tsx: editorial panel with toggles,
device list, "send test", per-device removal
- pushReady requires both permission AND active subscription (covers the
case where the user revoked the SW but kept the browser permission)
Schema
- DeviceToken { userId, platform, endpoint, p256dh, auth, token, userAgent,
createdAt, lastSeenAt } with unique(userId, endpoint)
- ExtendedPrismaClient type exported from @ketopath/db
- NotificationSettings zod schema in @ketopath/shared
Tooling
- lint-staged: split .js out of eslint glob so service worker is only
formatted (it lives outside the TS project)
i18n
- Notifications namespace (it) with typed error keys
Smoke tested: POST /me/device-tokens 201, POST /me/notifications/test 200,
real push delivered to a macOS Chrome device.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- regeneratePlan: switch return type to Promise<void> so it satisfies the
React form action contract.
- fast.routes PATCH: omit undefined keys instead of passing them, to
comply with Prisma + exactOptionalPropertyTypes.
- weight-entry-form: cast field value/defaultValues to bypass z.input
reporting `Date` for `z.coerce.date()` (zod 3.25 still emits the
target type, not unknown).
pnpm typecheck, lint, test now all green across the monorepo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recipe detail
- Seed catalog of 33 italian ingredients with macros, allergens, avg price.
- Each seed recipe now declares its RecipeIngredient links (qty + unit).
- /recipes/[id] page: editorial layout with prep meta, ingredients list,
macros block, preparation, chef's note. Plan-week meal names linkable.
Shopping list
- GET /me/shopping-list aggregates RecipeIngredient quantities of the
ACTIVE plan, grouped by ingredient category, sorted alphabetically.
- /shopping page: total items, estimated cost, per-category checklist
with check-off persisted in localStorage per plan id.
- Home gets a "Spesa" nav item; /plan links the list when a plan exists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New /fasting route. Editorial layout matching the rest of the app
("CAPITOLO VII / Digiuno intermittente.").
When no active session:
- "Pronto a iniziare" eyebrow + italic call-to-action
- Protocol Select (16:8 default; full set FASTING_PROTOCOLS, hours derived
from PROTOCOL_DEFAULT_MINUTES)
- "Avvia digiuno" CTA → server action that POSTs to /me/fast-events
- Right column: Roman-numeral guide (I-IV) for the four most common protocols
with title + description from messages.json
When a session is active (status=IN_PROGRESS, no endedAt):
- Live timer in massive monospace clamp(3rem,9vw,6.25rem), tabular numerals,
ticking once per second via setInterval
- Italic remaining-time line ("Mancano HH:MM:SS") or "Obiettivo raggiunto."
once the protocol target is reached
- Hairline progress bar; ink while running, pomodoro after target
- "Concludi adesso" / "Concludi" CTA → PATCH with status=COMPLETED
- "Annulla sessione" ghost link → PATCH with status=ABORTED (with confirm())
- Right column: "FASE METABOLICA" with current phase title in pomodoro and
italic description (postprandial 0-4h / glycogenolysis 4-12h / lipolysis
12-18h / ketogenesis 18-24h / autophagy 24h+, PRD §5.3)
- "INIZIATO" and "OBIETTIVO" stats in mono
History below:
- Three big mono stats: completed sessions count, total hours, total sessions
- Last 12 events listed with protocol label in pomodoro italic, italian
short date, status eyebrow, duration in mono
Home gains a fourth NavItem ("Digiuno — Digiuno intermittente").
Verified end-to-end in Chrome: started a 16:8 → live ticking timer at
00:00:04, "Mancano 15:59:55", "Post-prandiale" phase shown.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New /tracking route in the editorial language:
- "CAPITOLO VI / Pesata e misure." masthead
- 7/5 split: form left, STORICO right
Form (left, "NUOVA RILEVAZIONE"):
- Date + weight required, with the latest weight pre-filled to encourage
consistency between weeks
- Optional misure: girovita / fianchi / coscia / braccio (cm), grouped under
a "MISURE (CM) — OPZIONALI" eyebrow
- Subjective indicators 1–10: energia / sonno / fame
- Italic "Registrato." (oliva) confirmation after a successful POST
- Server action calls POST /me/weight-entries through API_URL with the
user's session cookie; sparkline + history revalidate via
revalidatePath('/tracking')
History (right, "STORICO"):
- Inline SVG sparkline of the user's weight series with a terracotta dot
marking the latest reading; ink-soft date axis labels under the line
- Two big mono stats: "PESO ATTUALE" and "VARIAZIONE" (oliva when negative,
pomodoro when positive, ink at zero)
- Newest 8 entries listed with hairline rules between rows
- Italian italic empty-state copy when no entries exist yet
Home gains a third NavItem ("Tracking — Pesata & misure").
Verified manually with a fresh user signup → disclaimer → profile → tracking:
sparkline / "PESO ATTUALE 76.0 kg" / "VARIAZIONE 0.0 kg" / list row "29 apr 76.0 kg".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A complete visual + tonal pass on every page. Replaces the previous shadcn
slate+emerald defaults with an Italian editorial aesthetic — refined slow-food
magazine crossed with a luxury cookbook. Mobile-first, premium, distinctly
Italian, no generic AI-wellness vibes.
Tokens:
- New palette: carta (cream parchment), ink (warm black), oliva (deep olive),
pomodoro (terracotta), oro (aged gold), rule (hairline). All exposed as
HSL CSS variables and as Tailwind colors. Dark mode = oxblood/cream.
- Type stack: Fraunces (variable, optical-sizing auto, italic + roman) for
display / Inter Tight for body / JetBrains Mono with tnum + zero for
numerals. Replaces Inter-only setup.
- Font feature settings on body and .font-display for ligatures, opsz
and stylistic alternates.
- Subtle SVG paper grain on body::before (multiply blend, 4.5% opacity).
- 2px radius default (editorial print, not soft web).
Component primitives:
- Button — mono uppercase 11px tracking-widest, sharp corners, hover
inverts ink/cream. Variants tuned for the new palette.
- Input — bottom-only hairline, Fraunces 18px text, italic placeholders.
- Label — tiny mono caps, ink-soft.
- Card — top + bottom hairlines only, no shadow; Fraunces title and italic
description; CardFooter has its own top-rule.
- Select — same hairline-bottom trigger as Input; SelectContent panel uses
a hard 6px offset shadow for an editorial print stamp feel.
New component:
- Masthead — top-of-page newspaper header with thick top-rule, KP wordmark
with terracotta dot, "ANNO I · N. 0X" issue label, today's date in Italian.
Pages:
- / — Massive Fraunces "Una keto italiana, sostenibile." with terracotta
dot, italic Fraunces tagline, right-column nav with category eyebrows
(CUCINA / PROFILO) and arrow rule. Bottom: "Tre principi" with roman
numerals I/II/III. Disclaimer in italic.
- /sign-in — "CAPITOLO II / Bentornato in cucina." split layout.
- /sign-up — "CAPITOLO III / Inizia il tuo percorso." Italian copy refresh.
- /welcome — Editor's-letter layout with the four PRD §14.3 points as a
Roman-numeral list; custom hand-drawn check icon for the consent boxes.
- /profile — 7/5 split: form on the left (anagrafica + peso group), summary
on the right with massive mono numerals (BMR, TDEE, multiplier) and italic
"kcal" units; empty-state copy until the profile is saved.
- /plan — "CAPITOLO V / Il tuo piano." Italian-formatted week range. Each
day has a Roman-numeral mark (I-VII), Fraunces day name, mono kcal total.
Each meal slot uses Roman numerals (I-IV) for the meal index.
- Cookie banner — refreshed to match (italic Fraunces, "PRIVACY" eyebrow,
outline button).
Italian copy refresh:
- Headline: "Una keto italiana, sostenibile" (was "KetoPath")
- Tagline: narrative arc instead of marketing line
- CTAs: "Iscriviti" / "Accedi" (was "Inizia gratis" / "Scopri come funziona")
- Greeting: "Buongiorno, {name}." (was "Accesso effettuato come ...")
- Profile: weight columns become Iniziale / Attuale / Obiettivo under a
"Peso (chilogrammi)" group eyebrow.
E2E updated to match: home looks for the new headline and CTA labels;
profile uses the new "Come ti chiami" / "Iniziale / Attuale / Obiettivo"
labels and asserts numeric values separately from the "kcal" unit span.
All 4 specs pass. Verified visually at 1280×900 and 375×812.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apps/api:
- New PATCH /me/meal-plans/slots/:slotId — accepts { recipeId } and validates
ownership + that the new recipe is one of the slot's existing alternatives
(or already selected). Returns the updated slot with the new selected recipe.
apps/web — /plan route:
- Server component runs the same auth+disclaimer+profile guard chain as
/profile, then redirects to /profile if no profile exists yet (you can't
generate a plan without macros)
- actions.ts exposes three server actions: fetchCurrentPlan, regeneratePlan
and swapSlotRecipe; all proxy to api with the user's session cookie and
revalidatePath('/plan') after writes
- plan-week.tsx renders day-by-day sections (Lun→Dom) with a 4-card meal
row (1/2/4 columns at sm/lg breakpoints), recipe name + kcal, daily kcal
totals, expandable list of alternatives with one-click "Scegli"
- "Genera piano" CTA when there's no plan yet, "Rigenera" when there is one
- Italian copy under Plan.* with day, meal and kcal-total interpolations
- Home gains a "Vedi il piano settimanale" CTA next to "Completa profilo"
Verified end-to-end in Chrome:
- generate plan → 7 sections (Lun-Dom), each with 4 meal cards and per-day
kcal sum (e.g. Lunedì 1510 kcal = 360+540+150+460)
- recently-consumed penalty visible: Mon/Tue/Wed have different breakfasts
- swap: clicked "Vedi 3 alternative" on Mon Colazione → "Scegli" on
"Uova strapazzate" → card refreshed to that recipe and 410 kcal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add shadcn Form helpers (Form, FormField, FormItem, FormLabel, FormControl,
FormMessage, FormDescription) on top of react-hook-form's Controller
- Add shadcn Select wrapping @radix-ui/react-select with Trigger / Content /
Item / Value, lucide chevron + check icons
- Profile form now wraps fields in <Form>...<FormField> blocks; gender and
activityLevel use the new Select with placeholder, the rest use Input;
validation errors land in <FormMessage> per field automatically
Playwright config:
- webServer is now an array — Playwright boots both api (:4000) and web (:3000)
before the suite, so server actions that proxy to API_URL work in CI/local
- profile.spec.ts updated for the new flow: post-signup goes through /welcome
to accept the medical disclaimer, then through Select primitives via
combobox click + role=option for Sesso and Livello di attività
Verified: pnpm test:e2e — 4/4 specs pass (home + 2 auth + profile).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ketopath/shared:
- tracking/schema.ts — Zod schemas for WeightEntry input (weight, optional
measurements/notes/energy/sleep/hunger, photo URLs) and FastEvent start /
update, plus PROTOCOL_DEFAULT_MINUTES table
- planner/macros.ts — macrosForPhase(): protein 1.6-1.8g/kg, netCarb 25/60/120g
by phase, fat = remainder (PRD §9.3)
- planner/matchmaking.ts — pure matchMeals() that filters by exclusion tags,
phase compatibility and meal category, then scores by euclidean distance
from the meal's macro target with a 1.5 penalty for recently-consumed recipes;
DEFAULT_MEAL_SHARE constant (25/35/10/30 %)
- 5 new unit tests cover exclusion, phase, recency, topN, category mismatch
apps/api:
- modules/tracking/weight.routes.ts — GET /me/weight-entries (last 60),
POST /me/weight-entries with upsert on (userId, date); encrypted fields
serialised to/from JSON strings
- modules/tracking/fast.routes.ts — GET, POST (start) and PATCH (update) on
fast events; symptoms stored as encrypted JSON
- modules/plan/plan.routes.ts — POST /me/meal-plans:
- reads profile + preferences, computes BMR (Mifflin-St Jeor), TDEE, daily
kcal target with the phase-dependent deficit, then macros via macrosForPhase
- upserts a MealPlan rooted at Monday-of-this-week, wipes prior slots, and
fills 28 slots running matchMeals per (day, meal) with a recent-2-day
rolling exclusion list to keep variety
- selected recipe + 4 alternatives per slot
- GET /me/meal-plans/current returns the active plan with selected/alternatives
@ketopath/db:
- prisma/seed-recipes.ts — 16 italian keto recipes (4 per meal type) with
estimated macros per serving and phase compatibility
- prisma/seed.ts — idempotent insertion (skip on existing name match)
Verified end-to-end with sign-up → create profile (Michele PRD persona) →
generate plan: 28 slots filled, daily target 1399 kcal / 137 P / 83 F / 25 C,
matchmaking distributes 4 different breakfasts across the first 4 days as
the recent-consumed penalty kicks in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated by `prisma migrate dev --name onboarding_v1_recipes`.
Adds:
- users.disclaimer_accepted_at (TIMESTAMP NULL) for the medical disclaimer flow
- weight_entries, fast_events with their indexes and FK cascade
- ingredients, recipes, recipe_ingredients with category index on recipes
- meal_plans, meal_slots with composite uniqueness on (planId, dayOfWeek, meal)
- 4 new enums: FastStatus, MealCategory, Difficulty, MealPlanStatus
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema changes (require a single migration to apply):
- User gains disclaimerAcceptedAt — null until the user accepts the medical
disclaimer, blocks /profile until set (PRD §14.3)
- New WeightEntry (PRD §5.2) — weight/measurements/notes encrypted at rest,
energy/sleep/hunger left in the clear so we can produce aggregate analytics
- New FastEvent (PRD §5.3) — symptoms/notes encrypted, protocol/status/timer
in the clear; indexed on (userId, startedAt)
- New Ingredient / Recipe / RecipeIngredient (PRD §5.1) — italian keto recipe
database with macros and exclusion groups, ready for the matchmaking algo
- New MealPlan / MealSlot — generated weekly plan with selected recipe and
alternatives per (day, meal)
- New enums: FastStatus, MealCategory, Difficulty, MealPlanStatus
Web — disclaimer flow:
- /welcome page (server component, requires auth) lists the 4 PRD-mandated
points and surfaces the 3 mandatory checkboxes; submit triggers a server
action that stamps disclaimerAcceptedAt and redirects to /profile
- /profile redirects to /welcome whenever disclaimerAcceptedAt is null,
so the disclaimer becomes a hard prerequisite for any sensitive page
- New Italian copy under Welcome.* in messages/it.json
- @ketopath/db added to apps/web dependencies (was indirect via @ketopath/auth)
@ketopath/db re-exports the new models and enums.
Run the migration before testing: `pnpm db:migrate --name onboarding_v1_recipes`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New CookieBanner client component anchored at the bottom of the locale
layout, dismissed via Button click and persisted via localStorage key
ketopath:cookie-notice-ack (no cookie set for the dismissal itself)
- Italian copy: only essential cookies for authentication/session, no
analytics or third-party trackers (consistent with the ADR 0001 stance
and CLAUDE.md "Privacy first" non-negotiable)
- Layout body now uses bg-background / text-foreground tokens so dark mode
toggling will work the day we add it
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated by `prisma migrate dev --name encrypt_profile_fields`.
ALTER TABLE "profiles" sets weight_start_kg / weight_current_kg /
weight_goal_kg / target_date to TEXT. Existing rows keep their decimal
representation as plaintext text (e.g. "76.00") and prisma-field-encryption
re-encrypts them on the next write — both ciphertext (v1.aesgcm256.<keyId>.
<iv>.<ciphertext>) and legacy plaintext are decrypted transparently on read,
so no app-level fallback was needed.
Verified end-to-end:
- pnpm test:e2e — all 4 specs pass, including profile signup → submit →
BMR 1583 / TDEE 1899 round-trip
- raw SQL on profiles shows ciphertext for new rows; older rows stay in clear
text until their next update
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire prisma-field-encryption AES-256-GCM extension on the shared Prisma
client and annotate the four sensitive columns on Profile with @encrypted:
- weightStartKg / weightCurrentKg / weightGoalKg (Decimal → String)
- targetDate (DateTime @db.Date → String, ISO YYYY-MM-DD)
Other Profile fields stay in clear text per ADR 0002 (age, gender,
heightCm, activityLevel) — they're needed for plan generation and
aggregate analytics, and are not strongly identifying on their own.
apps/api profile.routes.ts:
- serialize() now reads the columns as strings and parses them back to
numbers for BMR/TDEE; targetDate is already an ISO string from the DB
- the upsert stringifies numeric inputs and slices the date to YYYY-MM-DD
Env wiring:
- packages/db, apps/api, apps/web .env.example all document
PRISMA_FIELD_ENCRYPTION_KEY (k1.aesgcm256.<base64url>) — must match
across every process that hits the DB
- key generation snippet documented inline
Migration is intentionally NOT in this commit: needs to be created against
a live Postgres instance and applied. The fields change Decimal/Date → text
so prisma migrate dev will require a USING cast — see the follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks application-level field encryption via prisma-field-encryption
(AES-256-GCM, master key from KMS) as the primary defence, combined with
volume encryption on the production Postgres host as defence in depth.
Documents:
- which fields are sensitive now (Profile.weight*, Profile.targetDate) and
which arrive in V1 (WeightEntry, FastEvent, ProgressPhoto)
- which fields stay in clear text and why (email/login, age/gender for
aggregate analytics, height/activity for plan generation)
- alternatives rejected: pgcrypto (key in queries), volume-only (no app-level
protection), client-side E2E (kills BMR/TDEE server-side calculation)
- consequences and the implementation roadmap for a follow-up PR
Status: proposed — must be implemented before opening V1 to public users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.github/workflows/ci.yml runs on push to main and on PRs. Single job with
a Postgres 15 service container, applies migrations via prisma migrate
deploy, then lint / format:check / typecheck / unit tests / api+web build.
Concurrency group cancels superseded runs on the same ref. Node version is
read from .nvmrc and pnpm cache is used to keep installs fast.
BETTER_AUTH_SECRET is supplied as a repo secret; falls back to a known
test value when the secret is not set so external forks can still run CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `locale` parameter passed to getRequestConfig is deprecated; next-intl
now provides `requestLocale` as a Promise. Resolve it at the top of the
callback, fall back to defaultLocale when the requested one isn't supported,
and return both locale and messages.
Removes the per-request deprecation warning in dev/E2E logs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- e2e/profile.spec.ts: fresh user signs up, opens /profile via the home CTA,
fills the Michele PRD persona values (49, MALE, 170cm, 76kg, SEDENTARY)
and asserts the summary panel shows BMR 1583 kcal and TDEE 1899 kcal
- profile-form.tsx: each Field receives an explicit `id` so <Label htmlFor>
binds correctly — Playwright's getByLabel and screen-reader navigation
both rely on this association
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apps/web (@sentry/nextjs):
- sentry.client.config.ts / sentry.server.config.ts / sentry.edge.config.ts
initialize Sentry only when NEXT_PUBLIC_SENTRY_DSN (or SENTRY_DSN on the
server) is set; tracesSampleRate 0.1
- next.config.mjs wraps the existing config with withSentryConfig only when a
DSN is present; source-map upload stays disabled until SENTRY_AUTH_TOKEN is
provided
- .env.example documents NEXT_PUBLIC_SENTRY_DSN and the optional auth token
apps/api (@sentry/node):
- src/lib/sentry.ts initializes Sentry at module load when SENTRY_DSN is set
- server.ts imports sentry.ts as the very first side-effect so early-boot
errors (env validation, plugin registration) reach Sentry
- env schema gains optional SENTRY_DSN (URL)
- app.ts registers an errorHandler that captures the exception with the
authenticated user when SENTRY_DSN is set; logs and re-sends as before
Build-time housekeeping:
- profile route: targetDate ?? null on create to satisfy Prisma's input shape
under exactOptionalPropertyTypes
- profile form: useForm receives defaultValues only when initial is provided
(spread instead of `defaultValues: undefined`); Field error prop typed
`string | undefined` for exactOptionalPropertyTypes
Without a DSN both apps run unchanged (Sentry is inert).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ketopath/shared — new module profile/schema:
- profileInputSchema (Zod): age 18-110, gender, height 120-230 cm, weights
35-300 kg, activityLevel, optional targetDate
- GENDERS / ACTIVITY_LEVELS string-literal arrays for UI iteration
- Re-exported from @ketopath/shared
apps/api — new module modules/profile:
- PUT /me/profile: requireAuth, validates body via profileInputSchema, upserts
the row, returns the saved profile + derived { bmr, tdee, activityMultiplier }
(BMR/TDEE computed via @ketopath/shared)
- GET /me/profile: requireAuth, returns the same shape, 404 when missing
- Decimal columns serialised back as numbers
apps/web — new /profile page:
- src/app/[locale]/profile/page.tsx (server): redirects unauthenticated users
to /sign-in, calls fetchProfile to hydrate the form
- profile-form.tsx (client): react-hook-form + zodResolver bound to the same
shared schema, native styled selects for gender/activityLevel until shadcn
Select arrives, post-submit panel showing BMR/TDEE/multiplier
- actions.ts: server actions saveProfile / fetchProfile that proxy the call
to API_URL via cookie passthrough (no CORS, no exposed token)
- Home page gains a "Completa il tuo profilo" CTA when signed in
- API_URL env var added to .env.example
- Italian copy in messages/it.json under Profile
Verified end-to-end with the "Michele" PRD persona (49, M, 170cm, 76kg, SEDENTARY):
BMR = 1583 kcal, TDEE = 1899 kcal, multiplier 1.2 — matches the unit tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add shadcn/ui plumbing:
- components.json (style: new-york, baseColor: slate, primary: green ~142 71%)
- src/lib/utils.ts with cn() helper (clsx + tailwind-merge)
- Tailwind config switched to CSS variables theming with primary/destructive/
muted/card etc. tokens, dark-mode class, tailwindcss-animate plugin
- src/styles/globals.css declares :root and .dark variable palettes
Initial component set under src/components/ui:
- Button (cva variants: default/destructive/outline/secondary/ghost/link, asChild)
- Input
- Label (Radix Label)
- Card + CardHeader/Title/Description/Content/Footer
Refactor existing auth surface to the new primitives:
- sign-in / sign-up pages wrap form in a Card with Title/Description (+ footer
for sign-up disclaimer)
- sign-in-form / sign-up-form use Input, Label, Button; tokens replace bespoke
emerald/slate classes; divider and "Continua con Google" use the same Button
- Home page CTAs become Button asChild around Link; SignOutButton uses Button
- Hide-Google logic still works: enabledSocialProviders is now
ReadonlyArray<SocialProvider> for ergonomic .includes() checks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Export enabledSocialProviders from @ketopath/auth: ['google'] when
GOOGLE_CLIENT_ID/SECRET are both set, [] otherwise
- sign-in and sign-up pages read enabledSocialProviders server-side and pass
googleEnabled to their forms; the divider and button disappear when false
- No changes to the Better Auth instance — the Google provider is still
conditionally registered, this just keeps the UI honest about it
docs/runbooks/google-oauth-setup.md walks through the Google Cloud Console
flow end-to-end (project, consent screen, credentials, redirect URIs, env
injection, verification, troubleshooting, prod considerations).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Unit tests on @ketopath/auth (added to the Vitest workspace):
- readAuthEnv accepts a valid configuration
- rejects secret < 32 chars and non-URL BETTER_AUTH_URL
- preserves Google credentials when both vars are set
Playwright e2e/auth.spec.ts:
- happy path: sign-up creates a user, redirects home with welcome message,
sign-out clears session, sign-in with the same credentials restores it
- error path: invalid credentials surface a role="alert" message
- each test uses a unique generated email to avoid DB collisions
home.spec.ts: links instead of buttons (CTAs are now next/link).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- authPlugin runs preHandler that turns Fastify request headers into a Headers
object, calls auth.api.getSession, and decorates request.user / request.session
- requireAuth() preHandler short-circuits with 401 when not signed in
- New module modules/me with GET /me returning the authenticated user/session
- env validates BETTER_AUTH_SECRET (≥32) and BETTER_AUTH_URL — must match web
- Restored .js extensions in shared packages so NodeNext-resolution consumers
(api) typecheck cleanly; Next webpack now uses extensionAlias to map .js → .ts
- Re-enabled NodeNext for packages/auth and packages/db tsconfigs
Verified end-to-end:
- POST /api/auth/sign-in/email on web returns session cookie
- GET /me on api with the cookie returns 200 + user/session
- GET /me without the cookie returns 401
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Web app integration:
- /api/auth/[...all] route exposes Better Auth handler
- src/lib/auth.ts: server helper getServerSession() reading cookies via headers()
- src/lib/auth-client.ts: browser auth client built on shared makeAuthClient
- (auth) route group with sign-in and sign-up pages, both with email+password
form and "Continua con Google" button (Google flow active when env is set)
- Home page becomes async, shows signed-in user name with sign-out button or
routes to sign-up/sign-in CTAs
- Italian copy in messages/it.json under Auth.SignIn / Auth.SignUp
- transpilePackages includes @ketopath/auth
- .env.example: BETTER_AUTH_SECRET, BETTER_AUTH_URL, DATABASE_URL, Google placeholders
Build tooling:
- Drop .js extensions from internal package imports so Next webpack can
transpile them; switch packages/db tsconfig from NodeNext to Bundler
resolution to keep TS happy (same as packages/auth)
Verified end-to-end via browser:
- /sign-up creates user (Postgres rows in users + accounts), session cookie set,
redirect to / shows "Accesso effettuato come Mario Test"
- /sign-out clears session, page falls back to anonymous CTAs
- /sign-in with the same credentials restores session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New workspace packages/auth exporting:
- auth: Better Auth server instance using Prisma adapter on @ketopath/db
- makeAuthClient: React client factory parameterised by baseURL
- readAuthEnv: Zod-validated env reader (BETTER_AUTH_SECRET min 32 chars,
BETTER_AUTH_URL, optional GOOGLE_CLIENT_ID/SECRET)
- emailAndPassword enabled with min 8 chars, requireEmailVerification: false
for MVP (re-enable in V1, see ADR 0001)
- Google provider conditionally registered when both env vars are present —
keeps the package usable before OAuth configuration is delivered
- 7-day sessions, 24h rolling refresh, cookie prefix 'ketopath'
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- User extended with emailVerified, name, image (Better Auth fields)
- Session: signed session tokens with expiry, ip and user agent
- Account: credential rows for email+password (provider 'credential') and
OAuth providers (e.g. Google) — password hash stored on the credential row
- Verification: single-use tokens for email verification, password reset,
and magic links
- Re-export new types from @ketopath/db
Migration 20260429104721_add_auth_tables run against local Postgres.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reasons (full ADR in docs/decisions/0001-auth-provider.md):
- KetoPath targets EU users with Art. 9 GDPR data; Clerk on US infra
raises Schrems II concerns
- Free, self-hostable, stays in our Postgres
- TypeScript-first, fits Next.js + Fastify + Prisma stack
CLAUDE.md updated:
- Tech stack: Better Auth on EU Postgres
- "Cosa NON fare mai": no manual password hashing
- References: Better Auth docs link
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add @ketopath/db and fastify-plugin to apps/api
- prismaPlugin decorates the Fastify instance with prisma and disconnects
on app close, with FastifyInstance type augmented in src/types/fastify.d.ts
- DATABASE_URL is now required by env validation
- New module modules/db with GET /db/health running SELECT 1 via Prisma
- dev script switched to tsx --env-file=.env for env loading
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated by `prisma migrate dev --name init` against the local Postgres.
Creates the 6 enums and the 3 tables with their unique indexes and
foreign keys (profiles.user_id and preferences.user_id with ON DELETE CASCADE).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vitest:
- Workspace at root (vitest.workspace.ts) running per-package configs
- packages/shared/vitest.config.ts with v8 coverage and 70% thresholds
- Root scripts: test / test:watch / test:coverage
Nutrition module in @ketopath/shared:
- calculateBmr (Mifflin-St Jeor) + calculateTdee with activity multipliers
- ACTIVITY_MULTIPLIERS constants matching CLAUDE.md domain knowledge
- 14 unit tests covering Michele/Laura PRD personas, all sex variants,
every activity level, and input validation (100% coverage on src/nutrition)
Playwright in apps/web:
- chromium-only project, webServer auto-boot of pnpm dev
- e2e/home.spec.ts smoke test asserts Italian copy and disclaimer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Initial schema covers only the 3 base entities from PRD §8 — additional
models (MealPlan, Recipe, WeightEntry, FastEvent, ShoppingList, etc.)
will be added alongside their respective features.
- Postgres datasource via DATABASE_URL
- Enums: Role, Gender, ActivityLevel, Phase, CookingTime, FastingProtocol
- Singleton PrismaClient export from @ketopath/db
- Root scripts: db:generate / db:migrate / db:studio / db:seed / db:format
- Seed stub at prisma/seed.ts
- Ignore .claude/settings.local.json (per-user CLI permissions)
The first migration must be run by the developer:
pnpm db:migrate --name init
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ESM Fastify 4 with Pino logger (pretty-print in dev only)
- Plugins: helmet, cors (allowlist via CORS_ORIGINS), rate-limit, sensible
- Zod-validated environment config in src/config/env.ts
- Modular structure under src/modules with /health route
- tsx watch for dev, tsc for build, dist/server.js as production entry
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- App Router under src/app/[locale] with locale 'it' as default
- next-intl middleware and getRequestConfig with setRequestLocale
- Tailwind CSS with shared font variable and content from packages/ui
- ESLint via @ketopath/eslint-config/nextjs, tsconfig via @ketopath/tsconfig/nextjs
- Inter font, base layout, home page with translated copy and disclaimer
Tweaks to shared eslint-config:
- Disable consistent-type-imports for .cjs files (next parser is not @typescript-eslint)
- Configure import resolver to discover tsconfig in apps/* and packages/*
- Drop *.config.* ignore patterns (overrides handle them with env.node)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>