diff --git a/apps/api/src/routes/onboarding.ts b/apps/api/src/routes/onboarding.ts index d593dd5..9b0173b 100644 --- a/apps/api/src/routes/onboarding.ts +++ b/apps/api/src/routes/onboarding.ts @@ -62,17 +62,27 @@ export async function onboardingRoutes(app: FastifyInstance) { app.post("/v1/onboarding/quick-start", auth, async (req) => { const input = parse(quickStartInputSchema, req.body); + // Normalize goals: multi-select UI sends `goals`; legacy clients send `primaryGoal`. + const goals = input.goals?.length + ? input.goals + : input.primaryGoal + ? [input.primaryGoal] + : []; + const primaryGoal = goals[0]; + // Upsert minimal preferences await app.db .insert(schema.userPreferences) .values({ userId: req.userId, - ...(input.primaryGoal ? { primaryGoal: input.primaryGoal } : {}), + goals, + ...(primaryGoal ? { primaryGoal } : {}), }) .onConflictDoUpdate({ target: schema.userPreferences.userId, set: { - ...(input.primaryGoal ? { primaryGoal: input.primaryGoal } : {}), + goals, + ...(primaryGoal ? { primaryGoal } : {}), updatedAt: new Date(), }, }); @@ -91,7 +101,7 @@ export async function onboardingRoutes(app: FastifyInstance) { await audit(app.db, { actorUserId: req.userId, action: "onboarding.quick_start", - metadata: { primaryGoal: input.primaryGoal, precisionMode: input.precisionMode }, + metadata: { goals, primaryGoal, precisionMode: input.precisionMode }, ip: req.ip, correlationId: req.correlationId, }); diff --git a/apps/api/src/routes/recommendations.ts b/apps/api/src/routes/recommendations.ts index 04ef3f6..82acb6e 100644 --- a/apps/api/src/routes/recommendations.ts +++ b/apps/api/src/routes/recommendations.ts @@ -20,7 +20,7 @@ import { } from "@app/recommendation-engine"; import { DEFAULT_TARGETS, computeDailyTargets, summarizeDay } from "@app/nutrition-engine"; import { parse } from "../lib/errors.js"; -import { requireActiveHousehold, todayIso } from "../lib/helpers.js"; +import { getActiveHouseholdId, todayIso } from "../lib/helpers.js"; /** * "Vad ska vi äta?" (spec §18) – appens viktigaste endpoint. @@ -37,34 +37,38 @@ export async function recommendationRoutes(app: FastifyInstance) { app.get("/v1/recommendations/what-to-eat", auth, async (req) => { const q = parse(whatToEatQuerySchema, req.query); - const householdId = await requireActiveHousehold(app.db, req.userId); + // Graceful for users without a household yet (e.g. after step A onboarding): + // treat it as an empty pantry / single-person context instead of failing. + const householdId = await getActiveHouseholdId(app.db, req.userId); const today = new Date(); // --- 1. Kontext: lager --- - const stockRows = await app.db - .select({ - item: schema.inventoryItems, - locationType: schema.storageLocations.type, - shelfLife: schema.canonicalIngredients.shelfLifeGuidance, - density: schema.canonicalIngredients.densityGPerMl, - gramsPerPiece: schema.canonicalIngredients.gramsPerPiece, - }) - .from(schema.inventoryItems) - .innerJoin( - schema.storageLocations, - eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id), - ) - .leftJoin( - schema.canonicalIngredients, - eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id), - ) - .where( - and( - eq(schema.inventoryItems.householdId, householdId), - isNull(schema.inventoryItems.depletedAt), - gt(schema.inventoryItems.quantity, 0), - ), - ); + const stockRows = householdId + ? await app.db + .select({ + item: schema.inventoryItems, + locationType: schema.storageLocations.type, + shelfLife: schema.canonicalIngredients.shelfLifeGuidance, + density: schema.canonicalIngredients.densityGPerMl, + gramsPerPiece: schema.canonicalIngredients.gramsPerPiece, + }) + .from(schema.inventoryItems) + .innerJoin( + schema.storageLocations, + eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id), + ) + .leftJoin( + schema.canonicalIngredients, + eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id), + ) + .where( + and( + eq(schema.inventoryItems.householdId, householdId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + ), + ) + : []; const pantry: PantryItem[] = stockRows.map((r) => ({ id: r.item.id, @@ -90,11 +94,13 @@ export async function recommendationRoutes(app: FastifyInstance) { ); // --- 2. Hushållets samlade begränsningar (spec §7: strängaste gäller) --- - const members = await app.db - .select({ userId: schema.householdMembers.userId }) - .from(schema.householdMembers) - .where(eq(schema.householdMembers.householdId, householdId)); - const memberIds = members.map((m) => m.userId); + const members = householdId + ? await app.db + .select({ userId: schema.householdMembers.userId }) + .from(schema.householdMembers) + .where(eq(schema.householdMembers.householdId, householdId)) + : []; + const memberIds = members.length ? members.map((m) => m.userId) : [req.userId]; const allPrefs = await app.db .select() .from(schema.userPreferences) @@ -189,14 +195,16 @@ export async function recommendationRoutes(app: FastifyInstance) { .map((e) => e.slug); // --- 6. Senast lagat (variation) + hushållsbetyg --- - const cooks = await app.db - .select({ - recipeId: schema.recipeCooks.recipeId, - last: sql`max(${schema.recipeCooks.cookedAt})`, - }) - .from(schema.recipeCooks) - .where(eq(schema.recipeCooks.householdId, householdId)) - .groupBy(schema.recipeCooks.recipeId); + const cooks = householdId + ? await app.db + .select({ + recipeId: schema.recipeCooks.recipeId, + last: sql`max(${schema.recipeCooks.cookedAt})`, + }) + .from(schema.recipeCooks) + .where(eq(schema.recipeCooks.householdId, householdId)) + .groupBy(schema.recipeCooks.recipeId) + : []; const lastCooked = new Map(cooks.map((c) => [c.recipeId, c.last])); const householdRatings = await app.db .select({ @@ -213,7 +221,7 @@ export async function recommendationRoutes(app: FastifyInstance) { const ctx: RecommendationContext = { mealType: q.mealType, - persons: q.persons ?? members.length, + persons: q.persons ?? (members.length ? members.length : 1), maxMinutes: q.maxMinutes ?? myPrefs?.maxCookingMinutesWeekday ?? undefined, maxCostMinorPerPortion: q.maxCostMinorPerPortion, remainingProteinG: Math.max(0, daySummary.remaining.proteinG), @@ -312,7 +320,7 @@ export async function recommendationRoutes(app: FastifyInstance) { } // --- 10. Matlådor först när rimligt (spec §24) --- - const mealBoxes = q.includeLeftovers + const mealBoxes = q.includeLeftovers && householdId ? await app.db .select() .from(schema.mealBoxes) diff --git a/apps/api/test/onboarding.test.ts b/apps/api/test/onboarding.test.ts index 34fb144..68e34c3 100644 --- a/apps/api/test/onboarding.test.ts +++ b/apps/api/test/onboarding.test.ts @@ -12,6 +12,16 @@ describe("progressive onboarding validering (FAS 1b)", () => { expect(result.precisionMode).toBe("exact"); }); + it("quick-start accepterar flera mål", () => { + const result = parse(quickStartInputSchema, { + goals: ["lose_weight", "cook_more"], + precisionMode: "simple", + }); + expect(result.goals).toEqual(["lose_weight", "cook_more"]); + expect(result.primaryGoal).toBeUndefined(); + expect(result.precisionMode).toBe("simple"); + }); + it("quick-start är valfri – endast precision får default", () => { const result = parse(quickStartInputSchema, {}); expect(result.primaryGoal).toBeUndefined(); diff --git a/apps/api/test/recommendations.test.ts b/apps/api/test/recommendations.test.ts new file mode 100644 index 0000000..33e1e18 --- /dev/null +++ b/apps/api/test/recommendations.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { eq, inArray } from "drizzle-orm"; +import { buildServer } from "../src/server.js"; +import { loadConfig } from "../src/config.js"; +import { createDatabase, closeDatabase, schema } from "@app/database"; + +/** + * Regression test: what-to-eat must work for a brand-new user who has not + * created a household yet (empty pantry, single-person context). + */ +describe("what-to-eat without household", () => { + const testDb = createDatabase(process.env.TEST_DATABASE_URL); + const config = loadConfig({ + ...process.env, + DATABASE_URL: process.env.TEST_DATABASE_URL!, + }); + let app: Awaited>; + let accessToken: string; + const userEmail = "what-to-eat-repro@example.invalid"; + + async function cleanup() { + const emails = [userEmail, "goals-multi@example.invalid"]; + const existing = await testDb.db + .select({ id: schema.users.id }) + .from(schema.users) + .where(inArray(schema.users.email, emails)); + for (const u of existing) { + await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); + await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); + } + } + + beforeAll(async () => { + await cleanup(); + app = await buildServer(config); + await app.ready(); + + const res = await app.inject({ + method: "POST", + url: "/v1/auth/register", + payload: { email: userEmail, password: "Password123!", displayName: "Repro" }, + }); + const body = JSON.parse(res.body) as { accessToken: string }; + accessToken = body.accessToken; + + await testDb.db + .insert(schema.userPreferences) + .values({ userId: (JSON.parse(atob(accessToken.split(".")[1]!)) as { sub: string }).sub, primaryGoal: "cook_more" }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + await cleanup(); + await closeDatabase(); + await app.close(); + }); + + it("returns recommendations for a new user without a household and a craving set", async () => { + const res = await app.inject({ + method: "GET", + url: "/v1/recommendations/what-to-eat?limit=5&craving=asiatiskt", + headers: { authorization: `Bearer ${accessToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body) as { + recommendations: unknown[]; + mealBoxSuggestions: unknown[]; + context: { persons: number; craving: { cuisine: string } | null }; + }; + expect(body.recommendations).toBeDefined(); + expect(body.mealBoxSuggestions).toBeDefined(); + expect(body.context.persons).toBe(1); + expect(body.context.craving).not.toBeNull(); + expect(body.context.craving?.cuisine).toBe("thai"); + }); + + it("quick-start stores goals array and sets primaryGoal to the first goal", async () => { + const registerRes = await app.inject({ + method: "POST", + url: "/v1/auth/register", + payload: { email: "goals-multi@example.invalid", password: "Password123!", displayName: "Goals" }, + }); + const { accessToken: token } = JSON.parse(registerRes.body) as { accessToken: string }; + const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub; + + const quickRes = await app.inject({ + method: "POST", + url: "/v1/onboarding/quick-start", + headers: { authorization: `Bearer ${token}` }, + payload: { goals: ["cook_more", "less_waste"], precisionMode: "simple" }, + }); + expect(quickRes.statusCode).toBe(200); + + const [prefs] = await testDb.db + .select() + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, userId)) + .limit(1); + + expect(prefs?.goals).toEqual(["cook_more", "less_waste"]); + expect(prefs?.primaryGoal).toBe("cook_more"); + }); +}); diff --git a/apps/mobile/src/app/onboarding.tsx b/apps/mobile/src/app/onboarding.tsx index 194d92c..789f1c3 100644 --- a/apps/mobile/src/app/onboarding.tsx +++ b/apps/mobile/src/app/onboarding.tsx @@ -22,7 +22,7 @@ import { Spacer, Title, } from "@/components/ui"; -import { colors, spacing } from "@/lib/theme"; +import { colors, spacing, typography } from "@/lib/theme"; /** * Progressive onboarding (FAS 1b): 3-layer flow. @@ -73,8 +73,9 @@ export default function OnboardingScreen() { }, []); // eslint-disable-line react-hooks/exhaustive-deps // Step A state - const [goal, setGoal] = useState(null); + const [goals, setGoals] = useState([]); const [mode, setMode] = useState<"simple" | "exact">("simple"); + const primaryGoal = goals[0] ?? null; // Step B state const [diet, setDiet] = useState("omnivore"); @@ -94,6 +95,13 @@ export default function OnboardingScreen() { const toggleAllergen = (id: string) => setAllergens((prev) => (prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id])); + const toggleGoal = (id: string) => + setGoals((prev) => { + if (prev.includes(id)) return prev.filter((g) => g !== id); + // Keep the first-selected goal as primary by appending at the end. + return [...prev, id]; + }); + const finishStepA = async () => { setBusy(true); setError(null); @@ -101,11 +109,11 @@ export default function OnboardingScreen() { const res = await api<{ step: "a" | "b" | "c" }>("/v1/onboarding/quick-start", { method: "POST", body: { - ...(goal ? { primaryGoal: goal } : {}), + ...(goals.length ? { goals } : {}), precisionMode: mode, }, }); - track(onboardingStepCompleted({ properties: { step: "a", goal, precisionMode: mode } })); + track(onboardingStepCompleted({ properties: { step: "a", goals, primaryGoal, precisionMode: mode } })); setOnboardingStep(res.step); setLayer("b"); // Let user into the app – Step B will be shown contextually later @@ -201,14 +209,26 @@ export default function OnboardingScreen() { {t("onboarding.goal")} - {GOALS.map((id) => ( - setGoal(id)} - /> - ))} + {GOALS.map((id) => { + const index = goals.indexOf(id); + const selected = index >= 0; + return ( + toggleGoal(id)} + > + {selected && index === 0 && ( + + + {t("onboarding.primaryGoal")} + + + )} + + ); + })} @@ -367,10 +387,12 @@ function SelectRow({ label, selected, onPress, + children, }: { label: string; selected: boolean; onPress: () => void; + children?: React.ReactNode; }) { return ( {label} + {children} ); } diff --git a/apps/mobile/src/locales/da/common.json b/apps/mobile/src/locales/da/common.json index 398272b..aacc0b7 100644 --- a/apps/mobile/src/locales/da/common.json +++ b/apps/mobile/src/locales/da/common.json @@ -345,5 +345,6 @@ "wte.refresh": "Nye forslag", "wte.subtitle": "Ud fra hvad I har derhjemme, hvad der snart skal bruges, og hvad I kan lide.", "wte.title": "Hvad skal vi spise?", - "wte.whyTitle": "Hvorfor dette forslag?" + "wte.whyTitle": "Hvorfor dette forslag?", + "onboarding.primaryGoal": "Primært" } diff --git a/apps/mobile/src/locales/de/common.json b/apps/mobile/src/locales/de/common.json index 4167dff..6aa00fb 100644 --- a/apps/mobile/src/locales/de/common.json +++ b/apps/mobile/src/locales/de/common.json @@ -345,5 +345,6 @@ "wte.refresh": "Neue Vorschläge", "wte.subtitle": "Basierend auf dem, was ihr zuhause habt, was bald verbraucht werden sollte und was ihr mögt.", "wte.title": "Was essen wir?", - "wte.whyTitle": "Warum dieser Vorschlag?" + "wte.whyTitle": "Warum dieser Vorschlag?", + "onboarding.primaryGoal": "Primär" } diff --git a/apps/mobile/src/locales/en/common.json b/apps/mobile/src/locales/en/common.json index 3ba9f1b..36b6f09 100644 --- a/apps/mobile/src/locales/en/common.json +++ b/apps/mobile/src/locales/en/common.json @@ -345,5 +345,6 @@ "wte.refresh": "New suggestions", "wte.subtitle": "Based on what you have at home, what should be used up, and what you like.", "wte.title": "What's for dinner?", - "wte.whyTitle": "Why this suggestion?" + "wte.whyTitle": "Why this suggestion?", + "onboarding.primaryGoal": "Primary" } diff --git a/apps/mobile/src/locales/es/common.json b/apps/mobile/src/locales/es/common.json index ec4a178..5ab4afa 100644 --- a/apps/mobile/src/locales/es/common.json +++ b/apps/mobile/src/locales/es/common.json @@ -345,5 +345,6 @@ "wte.refresh": "Nuevas sugerencias", "wte.subtitle": "Según lo que tenéis en casa, lo que conviene usar pronto y lo que os gusta.", "wte.title": "¿Qué comemos?", - "wte.whyTitle": "¿Por qué esta sugerencia?" + "wte.whyTitle": "¿Por qué esta sugerencia?", + "onboarding.primaryGoal": "Principal" } diff --git a/apps/mobile/src/locales/fi/common.json b/apps/mobile/src/locales/fi/common.json index f12fce7..d0c3789 100644 --- a/apps/mobile/src/locales/fi/common.json +++ b/apps/mobile/src/locales/fi/common.json @@ -345,5 +345,6 @@ "wte.refresh": "Uudet ehdotukset", "wte.subtitle": "Sen mukaan mitä kotona on, mikä pitäisi käyttää pian ja mistä pidätte.", "wte.title": "Mitä syödään?", - "wte.whyTitle": "Miksi tämä ehdotus?" + "wte.whyTitle": "Miksi tämä ehdotus?", + "onboarding.primaryGoal": "Ensisijainen" } diff --git a/apps/mobile/src/locales/fr/common.json b/apps/mobile/src/locales/fr/common.json index 2b11b58..fc73fd9 100644 --- a/apps/mobile/src/locales/fr/common.json +++ b/apps/mobile/src/locales/fr/common.json @@ -345,5 +345,6 @@ "wte.refresh": "Nouvelles suggestions", "wte.subtitle": "Selon ce que vous avez chez vous, ce qu'il faut consommer vite et vos goûts.", "wte.title": "On mange quoi ?", - "wte.whyTitle": "Pourquoi cette suggestion ?" + "wte.whyTitle": "Pourquoi cette suggestion ?", + "onboarding.primaryGoal": "Principal" } diff --git a/apps/mobile/src/locales/it/common.json b/apps/mobile/src/locales/it/common.json index e12bc24..38d4a76 100644 --- a/apps/mobile/src/locales/it/common.json +++ b/apps/mobile/src/locales/it/common.json @@ -345,5 +345,6 @@ "wte.refresh": "Nuovi suggerimenti", "wte.subtitle": "In base a ciò che avete in casa, a cosa va consumato presto e ai vostri gusti.", "wte.title": "Cosa mangiamo?", - "wte.whyTitle": "Perché questo suggerimento?" + "wte.whyTitle": "Perché questo suggerimento?", + "onboarding.primaryGoal": "Primario" } diff --git a/apps/mobile/src/locales/nb/common.json b/apps/mobile/src/locales/nb/common.json index d3de661..866e1e2 100644 --- a/apps/mobile/src/locales/nb/common.json +++ b/apps/mobile/src/locales/nb/common.json @@ -345,5 +345,6 @@ "wte.refresh": "Nye forslag", "wte.subtitle": "Basert på hva dere har hjemme, hva som snart bør brukes, og hva dere liker.", "wte.title": "Hva skal vi spise?", - "wte.whyTitle": "Hvorfor dette forslaget?" + "wte.whyTitle": "Hvorfor dette forslaget?", + "onboarding.primaryGoal": "Primært" } diff --git a/apps/mobile/src/locales/nl/common.json b/apps/mobile/src/locales/nl/common.json index 4022d86..eebd5c4 100644 --- a/apps/mobile/src/locales/nl/common.json +++ b/apps/mobile/src/locales/nl/common.json @@ -345,5 +345,6 @@ "wte.refresh": "Nieuwe suggesties", "wte.subtitle": "Op basis van wat jullie in huis hebben, wat snel op moet en wat jullie lekker vinden.", "wte.title": "Wat eten we?", - "wte.whyTitle": "Waarom deze suggestie?" + "wte.whyTitle": "Waarom deze suggestie?", + "onboarding.primaryGoal": "Primair" } diff --git a/apps/mobile/src/locales/pl/common.json b/apps/mobile/src/locales/pl/common.json index 019e365..4b4fd64 100644 --- a/apps/mobile/src/locales/pl/common.json +++ b/apps/mobile/src/locales/pl/common.json @@ -359,5 +359,6 @@ "wte.refresh": "Nowe propozycje", "wte.subtitle": "Na podstawie tego, co macie w domu, co trzeba wkrótce zużyć i co lubicie.", "wte.title": "Co jemy?", - "wte.whyTitle": "Dlaczego ta propozycja?" + "wte.whyTitle": "Dlaczego ta propozycja?", + "onboarding.primaryGoal": "Główny" } diff --git a/apps/mobile/src/locales/pt/common.json b/apps/mobile/src/locales/pt/common.json index 0acef3f..0f5cacd 100644 --- a/apps/mobile/src/locales/pt/common.json +++ b/apps/mobile/src/locales/pt/common.json @@ -345,5 +345,6 @@ "wte.refresh": "Novas sugestões", "wte.subtitle": "Com base no que têm em casa, no que deve ser usado em breve e no que gostam.", "wte.title": "O que vamos comer?", - "wte.whyTitle": "Porquê esta sugestão?" + "wte.whyTitle": "Porquê esta sugestão?", + "onboarding.primaryGoal": "Principal" } diff --git a/apps/mobile/src/locales/sv/common.json b/apps/mobile/src/locales/sv/common.json index c14717d..5a35376 100644 --- a/apps/mobile/src/locales/sv/common.json +++ b/apps/mobile/src/locales/sv/common.json @@ -345,5 +345,6 @@ "wte.refresh": "Nya förslag", "wte.subtitle": "Utifrån vad ni har hemma, vad som bör användas och vad ni gillar.", "wte.title": "Vad ska vi äta?", - "wte.whyTitle": "Varför detta förslag?" + "wte.whyTitle": "Varför detta förslag?", + "onboarding.primaryGoal": "Primärt" } diff --git a/packages/validation/src/profile.ts b/packages/validation/src/profile.ts index 2818fa8..472ab1e 100644 --- a/packages/validation/src/profile.ts +++ b/packages/validation/src/profile.ts @@ -73,7 +73,10 @@ export type OnboardingInput = z.infer; /** Progressive onboarding Step A – minimal info for immediate value (FAS 1b). */ export const quickStartInputSchema = z.object({ + // Backwards-compatible single goal; if `goals` is omitted we fall back to this. primaryGoal: z.enum(GOAL_TYPES).optional(), + // Multi-select goals (new UI). First goal is considered primary. + goals: z.array(z.enum(GOAL_TYPES)).max(11).optional(), precisionMode: z.enum(PRECISION_MODES).default("simple"), }); export type QuickStartInput = z.infer;