diff --git a/apps/api/src/routes/planning.ts b/apps/api/src/routes/planning.ts index f5bcde1..bd75cad 100644 --- a/apps/api/src/routes/planning.ts +++ b/apps/api/src/routes/planning.ts @@ -30,7 +30,7 @@ export async function planningRoutes(app: FastifyInstance) { .select() .from(schema.weekPlans) .where(and(...conditions)) - .orderBy(desc(schema.weekPlans.weekStartDate)) + .orderBy(desc(schema.weekPlans.weekStartDate), desc(schema.weekPlans.createdAt)) .limit(8); const result = []; @@ -50,6 +50,18 @@ export async function planningRoutes(app: FastifyInstance) { const input = parse(generateWeekPlanInputSchema, req.body); const householdId = await requireActiveHousehold(app.db, req.userId); + // "Planera om veckan" ersätter helt: ta bort ev. tidigare plan(er) för samma + // vecka (kaskad tar entries) så vi aldrig samlar dubbletter och alltid visar + // den nygenererade planen. Nytt plan-id ger dessutom ny variation (se workern). + await app.db + .delete(schema.weekPlans) + .where( + and( + eq(schema.weekPlans.householdId, householdId), + eq(schema.weekPlans.weekStartDate, input.weekStartDate), + ), + ); + const [plan] = await app.db .insert(schema.weekPlans) .values({ @@ -89,6 +101,26 @@ export async function planningRoutes(app: FastifyInstance) { const input = parse(updatePlanEntryInputSchema, req.body); const updates: Record = { ...input }; + // Byt rätt: när ett nytt recept väljs måste titeln (och portioner) följa med, + // annars visar kalendern kvar den gamla rätten trots att recipeId har bytts. + if (typeof input.recipeId === "string") { + const [recipe] = await app.db + .select({ + id: schema.recipes.id, + titleSv: schema.recipes.titleSv, + portions: schema.recipes.portions, + }) + .from(schema.recipes) + .where(and(eq(schema.recipes.id, input.recipeId), eq(schema.recipes.status, "published"))) + .limit(1); + if (!recipe) throw errors.notFound("Receptet finns inte."); + updates.titleSv = recipe.titleSv; + updates.mealBoxId = null; + if (input.portions == null) updates.portions = recipe.portions ?? 2; + updates.status = input.status ?? "planned"; + updates.rescheduleReasonSv = null; + } + // Dynamisk omplanering med förklaring (spec §25) if (input.status === "skipped") { const [entry] = await app.db diff --git a/apps/mobile/src/app/(tabs)/plan.tsx b/apps/mobile/src/app/(tabs)/plan.tsx index 21a8dae..bdafdeb 100644 --- a/apps/mobile/src/app/(tabs)/plan.tsx +++ b/apps/mobile/src/app/(tabs)/plan.tsx @@ -60,6 +60,14 @@ function addDays(d: Date, n: number): Date { copy.setDate(copy.getDate() + n); return copy; } +/** Måndagen i veckan som d ligger i – veckoplaneraren visar alltid mån–sön. */ +function mondayOf(d: Date): Date { + const copy = new Date(d); + const dow = copy.getDay(); // 0=sön, 1=mån … 6=lör + copy.setDate(copy.getDate() + (dow === 0 ? -6 : 1 - dow)); + copy.setHours(0, 0, 0, 0); + return copy; +} function cap(s: string): string { return s.length ? s[0]!.toUpperCase() + s.slice(1) : s; } @@ -82,9 +90,11 @@ export default function PlanScreen() { d.setHours(0, 0, 0, 0); return d; }, []); + // Veckan börjar ALLTID på måndag (mån–sön), oavsett vilken dag det är idag. + const thisMonday = useMemo(() => mondayOf(todayMidnight), [todayMidnight]); const weekStart = useMemo( - () => addDays(todayMidnight, weekOffset * 7), - [todayMidnight, weekOffset], + () => addDays(thisMonday, weekOffset * 7), + [thisMonday, weekOffset], ); const weekStartKey = toKey(weekStart); const weekDays = useMemo( @@ -95,7 +105,8 @@ export default function PlanScreen() { const goWeek = (delta: number) => setWeekOffset((o) => { const next = Math.max(0, o + delta); - setSelectedKey(toKey(addDays(todayMidnight, next * 7))); + // Denna vecka: markera idag. Kommande veckor: markera måndagen. + setSelectedKey(next === 0 ? todayKey : toKey(addDays(thisMonday, next * 7))); return next; }); // Lunch + middag varje dag som default (fler fyllda måltider), går att toggla. diff --git a/apps/mobile/src/app/barcode.tsx b/apps/mobile/src/app/barcode.tsx index c1af1a5..92c0d87 100644 --- a/apps/mobile/src/app/barcode.tsx +++ b/apps/mobile/src/app/barcode.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from "react"; -import { ScrollView, StyleSheet, View } from "react-native"; +import { Alert, ScrollView, StyleSheet, View } from "react-native"; import { router } from "expo-router"; import * as ImagePicker from "expo-image-picker"; import { CameraView, useCameraPermissions } from "expo-camera"; @@ -121,34 +121,57 @@ export default function BarcodeScreen() { * streckkoden – så nästa person som skannar samma kod får den direkt. * Lägger INTE till något bland användarens egna varor (spec §11). */ + // Fota fram- OCH baksida: framsidan ger namn/varumärke, baksidan näring + + // innehåll. AI:n slår ihop bilderna. Kamera med galleri-fallback. + const capturePackagePhotos = async (): Promise => { + const perm = await ImagePicker.requestCameraPermissionsAsync(); + const shoot = () => + perm.granted + ? ImagePicker.launchCameraAsync({ quality: 0.7 }) + : ImagePicker.launchImageLibraryAsync({ quality: 0.7 }); + const uris: string[] = []; + const first = await shoot(); + const a0 = first.assets?.[0]; + if (first.canceled || !a0) return uris; + uris.push(a0.uri); + const wantBack = await new Promise((resolve) => + Alert.alert(t("barcode.contributing.frontDone"), t("barcode.contributing.backPrompt"), [ + { text: t("barcode.contributing.skipBack"), style: "cancel", onPress: () => resolve(false) }, + { text: t("barcode.contributing.shootBack"), onPress: () => resolve(true) }, + ]), + ); + if (wantBack) { + const second = await shoot(); + const a1 = second.assets?.[0]; + if (!second.canceled && a1) uris.push(a1.uri); + } + return uris; + }; + const contributePhoto = async () => { const gtin = lastGtin.current; if (!gtin) return; try { - // Fota förpackningen (kamera, med galleri-fallback som i skanningsflödet). - const perm = await ImagePicker.requestCameraPermissionsAsync(); - const picked = perm.granted - ? await ImagePicker.launchCameraAsync({ quality: 0.7 }) - : await ImagePicker.launchImageLibraryAsync({ quality: 0.7 }); - const asset = picked.assets?.[0]; - if (picked.canceled || !asset) return; // avbröt – stanna kvar i "okänd"-vyn + const uris = await capturePackagePhotos(); + if (uris.length === 0) return; // avbröt – stanna kvar i nuvarande vy setContributing(true); - setErrorMsg(null); // Produktskanning kopplad till streckkoden – delad katalog, inte lagret. const created = await api("/v1/scans", { method: "POST", body: { scanType: "product_package", - imageCount: 1, + imageCount: uris.length, contentType: "image/jpeg", context: { barcode: gtin }, }, }); - const upload = created.uploads[0]; - if (!upload) throw new Error(t("common.error")); - await uploadImage(upload, asset.uri); + for (let i = 0; i < uris.length; i++) { + const upload = created.uploads[i]; + if (!upload) throw new Error(t("common.error")); + await uploadImage(upload, uris[i]!); + } await api(`/v1/scans/${created.scan.id}/start`, { method: "POST" }); const found = await pollScan(created.scan.id); @@ -156,13 +179,14 @@ export default function BarcodeScreen() { setUnknown(false); setProduct(found); } else { - setErrorMsg(t("barcode.contributing.failed")); + // Behåll nuvarande vy (okänd/produkt) och visa felet som dialog. + Alert.alert(t("common.oops"), t("barcode.contributing.failed")); } } catch (err) { if (err instanceof ApiError && err.code === "QUOTA_EXCEEDED") { router.push("/paywall"); } else { - setErrorMsg(err instanceof Error ? err.message : t("common.error")); + Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")); } } finally { setContributing(false); @@ -194,6 +218,8 @@ export default function BarcodeScreen() { const n = product?.nutrition?.values; const allergens = allergenLabels(product?.allergens); const mayContain = allergenLabels(product?.mayContainAllergens); + // Saknar produkten näring eller innehåll? Erbjud att komplettera med baksidesfoto. + const incomplete = !!product && (!n || !product.ingredientsText); return ( @@ -219,7 +245,7 @@ export default function BarcodeScreen() { )} {/* Produktinfo-panel (nedre delen, scrollbar) */} - {product && ( + {product && !contributing && ( {product.name} @@ -282,7 +308,15 @@ export default function BarcodeScreen() { -