feat: kitchen screen, scan dedup, gemini-3.5-flash-lite, seed grapes/beetroot/pea shoots

This commit is contained in:
Sven (AAMOS AI)
2026-08-16 20:13:49 +07:00
parent d098bf705e
commit c0a15bb1b5
20 changed files with 295 additions and 48 deletions
+3 -1
View File
@@ -44,8 +44,10 @@ AAMOS_MODE=mock
AAMOS_API_URL=
AAMOS_API_KEY=
AAMOS_TIMEOUT_MS=60000
# GEMINI_API_KEY lämnas tom lokalt hämtas från AWS SSM (/cibello/prod/gemini-api-key) i prod,
# inte inskriven för hand. Skriv aldrig over en .env som redan har riktiga varden.
GEMINI_API_KEY=
GEMINI_MODEL=gemini-2.5-flash
GEMINI_MODEL=gemini-3.5-flash-lite
GEMINI_TIMEOUT_MS=60000
GEMINI_DAILY_BUDGET_USD=25
+1 -1
View File
@@ -52,7 +52,7 @@ pnpm install
docker compose -f infrastructure/docker/docker-compose.dev.yml up -d
# 3. Miljövariabler
cp .env.example .env
cp -n .env.example .env # -n = skriv inte over en befintlig .env
# 4. Migrera + seeda databasen
pnpm db:migrate
+8 -3
View File
@@ -48,7 +48,7 @@ const configSchema = z.object({
AAMOS_TIMEOUT_MS: z.coerce.number().int().default(60_000),
GEMINI_API_KEY: z.string().optional(),
GEMINI_MODEL: z.string().default("gemini-2.5-flash"),
GEMINI_MODEL: z.string().default("gemini-3.5-flash-lite"),
GEMINI_TIMEOUT_MS: z.coerce.number().int().default(60_000),
GEMINI_DAILY_BUDGET_USD: z.coerce.number().default(0),
@@ -104,8 +104,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
console.error("SÄKERHETSSTOPP: AAMOS_MODE=mock är inte tillåtet i produktion.");
process.exit(1);
}
if (cfg.AAMOS_MODE === "gemini" && !cfg.GEMINI_API_KEY) {
console.error("SÄKERHETSSTOPP: AAMOS_MODE=gemini kräver GEMINI_API_KEY i produktion.");
if (
cfg.AAMOS_MODE === "gemini" &&
(!cfg.GEMINI_API_KEY || cfg.GEMINI_API_KEY.startsWith("ROTATE"))
) {
console.error(
"SÄKERHETSSTOPP: AAMOS_MODE=gemini kräver en riktig GEMINI_API_KEY (hämtas från SSM /cibello/prod/gemini-api-key), inte tom eller placeholder.",
);
process.exit(1);
}
if (cfg.AAMOS_MODE === "http" && (!cfg.AAMOS_API_URL || !cfg.AAMOS_API_KEY)) {
+37
View File
@@ -150,6 +150,43 @@ export async function scanRoutes(app: FastifyInstance) {
if (!locationId)
throw errors.badRequest("storageLocationId saknas och ingen standardplats finns.");
// Dedup (#1): finns varan redan aktiv i hushållet? Uppdatera + hoppa över,
// så överlappande foton / omfotografering inte skapar dubletter.
const dedupCond = item.canonicalIngredientId
? and(
eq(schema.inventoryItems.householdId, householdId),
eq(schema.inventoryItems.canonicalIngredientId, item.canonicalIngredientId),
gt(schema.inventoryItems.quantity, 0),
)
: and(
eq(schema.inventoryItems.householdId, householdId),
isNull(schema.inventoryItems.canonicalIngredientId),
eq(schema.inventoryItems.displayName, item.displayName),
gt(schema.inventoryItems.quantity, 0),
);
const [dupe] = await app.db
.select({ id: schema.inventoryItems.id })
.from(schema.inventoryItems)
.where(dedupCond)
.limit(1);
if (dupe) {
await app.db
.update(schema.inventoryItems)
.set({
quantity: item.quantity,
unit: item.unit,
brand: item.brand ?? null,
bestBeforeDate: item.bestBeforeDate ?? null,
useByDate: item.useByDate ?? null,
lastVerifiedAt: new Date(),
verifiedByUser: true,
updatedAt: new Date(),
})
.where(eq(schema.inventoryItems.id, dupe.id));
created.push(dupe.id);
continue;
}
const [inv] = await app.db
.insert(schema.inventoryItems)
.values({
+1 -1
View File
@@ -109,7 +109,7 @@ describe("scan confirmation → ai_corrections", () => {
},
],
},
modelVersion: "gemini-2.5-flash",
modelVersion: "gemini-3.5-flash-lite",
promptVersion: "gemini-fridge-v1",
})
.returning();
+3
View File
@@ -117,6 +117,9 @@ export default function HomeScreen() {
<Card onPress={() => router.push("/saved-recipes")}>
<Body> Dina sparade recept</Body>
</Card>
<Card onPress={() => router.push("/kitchen")}>
<Body>🧺 Ditt kök</Body>
</Card>
{budget.data && (
<Card>
+1 -1
View File
@@ -58,7 +58,7 @@ export default function WhatToEatScreen() {
queryKey: ["what-to-eat", submittedCraving],
queryFn: () =>
api<WhatToEatResponse>(
`/v1/recommendations/what-to-eat?limit=15${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
`/v1/recommendations/what-to-eat?limit=15&view=pantry${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
),
});
+4 -3
View File
@@ -76,15 +76,16 @@ export default function RootLayout() {
name="cooking/[id]"
options={{ title: "", presentation: "fullScreenModal" }}
/>
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title") }} />
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title") }} />
<Stack.Screen name="scan-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
<Stack.Screen name="meal-review/[jobId]" options={{ title: t("scan.meal.title"), fullScreenGestureEnabled: false }} />
<Stack.Screen name="reconciliation" options={{ title: t("reconciliation.title") }} />
<Stack.Screen name="scan-diff-review/[jobId]" options={{ title: t("scan.review.title") }} />
<Stack.Screen name="scan-diff-review/[jobId]" options={{ title: t("scan.review.title"), fullScreenGestureEnabled: false }} />
<Stack.Screen name="shopping" options={{ title: t("shopping.title"), presentation: "modal" }} />
<Stack.Screen name="meal-boxes" options={{ title: t("mealbox.title"), presentation: "modal" }} />
<Stack.Screen name="household" options={{ title: t("home.household"), presentation: "modal" }} />
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
<Stack.Screen name="saved-recipes" options={{ title: "Sparade recept" }} />
<Stack.Screen name="kitchen" options={{ title: "Ditt kök" }} />
<Stack.Screen name="profile" options={{ title: t("profile.title"), presentation: "modal" }} />
<Stack.Screen
name="paywall"
+44 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { Alert, Pressable, ScrollView, Text, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useKeepAwake } from "expo-keep-awake";
import * as Haptics from "expo-haptics";
@@ -19,6 +19,7 @@ import {
Spacer,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
/**
* Cooking Mode (spec §41): stora steg, stora knappar, skärmen vaken,
@@ -38,6 +39,13 @@ interface RecipeForCooking {
temperatureC: number | null;
tip: string | null;
}>;
ingredients: Array<{
id: string;
displayNameSv: string;
quantity: number;
unit: string;
optional: boolean;
}>;
}
export default function CookingScreen() {
@@ -45,6 +53,7 @@ export default function CookingScreen() {
const { id, portions: portionsParam } = useLocalSearchParams<{ id: string; portions?: string }>();
const queryClient = useQueryClient();
const [stepIndex, setStepIndex] = useState(0);
const [showIngredients, setShowIngredients] = useState(false);
const [timerLeft, setTimerLeft] = useState<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [finishing, setFinishing] = useState(false);
@@ -265,6 +274,40 @@ export default function CookingScreen() {
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
</Text>
{step?.tip && <Small>💡 {step.tip}</Small>}
<Pressable
onPress={() => setShowIngredients((v) => !v)}
hitSlop={8}
style={{ alignSelf: "flex-start" }}
>
<Small style={{ color: colors.primaryDark, fontWeight: "600" }}>
{showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
</Small>
</Pressable>
{showIngredients && (
<View
style={{
maxHeight: 200,
backgroundColor: colors.surfaceAlt,
borderRadius: 12,
padding: spacing.md,
}}
>
<ScrollView>
{recipe.ingredients.map((ing) => {
const scaledQty = (ing.quantity * portionsCooked) / recipe.portions;
return (
<Row key={ing.id} style={{ justifyContent: "space-between" }}>
<Body>
{ing.displayNameSv}
{ing.optional ? " (valfritt)" : ""}
</Body>
<Small>{formatQuantity(scaledQty, ing.unit)}</Small>
</Row>
);
})}
</ScrollView>
</View>
)}
{step?.timerSeconds != null && (
<Pressable
+87
View File
@@ -0,0 +1,87 @@
import { Alert } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { formatQuantity } from "@/lib/units";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
LoadingView,
Row,
Screen,
Small,
} from "@/components/ui";
/** Ditt kök: se och ta bort varor i lagret. Radering = mjuk (kvantitet → 0, historik bevaras). */
interface InventoryItem {
id: string;
displayName: string;
quantity: number;
unit: string;
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
}
export default function KitchenScreen() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["inventory", "all"],
queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory?limit=200"),
});
const remove = useMutation({
mutationFn: (id: string) => api(`/v1/inventory/items/${id}`, { method: "DELETE" }),
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: ["inventory", "all"] });
const previous = queryClient.getQueryData<{ items: InventoryItem[] }>(["inventory", "all"]);
queryClient.setQueryData<{ items: InventoryItem[] }>(["inventory", "all"], (old) =>
old ? { ...old, items: old.items.filter((i) => i.id !== id) } : old,
);
return { previous };
},
onError: (err, _id, context) => {
if (context?.previous) queryClient.setQueryData(["inventory", "all"], context.previous);
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["inventory"] });
void queryClient.invalidateQueries({ queryKey: ["inventory-expiring"] });
},
});
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
const items = query.data?.items ?? [];
return (
<Screen>
{items.length === 0 && (
<EmptyState text="Ditt kök är tomt än. Skanna eller lägg till varor." />
)}
{items.map((item) => (
<Card key={item.id}>
<Row style={{ justifyContent: "space-between" }}>
<Body>
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
<Button
label="Ta bort"
variant="ghost"
onPress={() =>
Alert.alert("Ta bort", `Ta bort ${item.displayName} ur ditt kök?`, [
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(item.id) },
])
}
/>
</Row>
{item.expiry.pastBestBefore && <Small> bäst före har passerat</Small>}
</Card>
))}
</Screen>
);
}
+45 -15
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Alert, View } from "react-native";
import { Alert, Pressable, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
@@ -86,17 +86,37 @@ export default function RecipeScreen() {
const favorite = useMutation({
mutationFn: (isFavorite: boolean) =>
api(`/v1/recipes/${id}/favorite`, { method: isFavorite ? "DELETE" : "POST" }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
onMutate: async (isFavorite: boolean) => {
await queryClient.cancelQueries({ queryKey: ["recipe", id] });
const previous = queryClient.getQueryData<RecipeDetail>(["recipe", id]);
queryClient.setQueryData<RecipeDetail>(["recipe", id], (old) =>
old ? { ...old, isFavorite: !isFavorite } : old,
);
return { previous };
},
onError: (err, _isFavorite, context) => {
if (context?.previous) queryClient.setQueryData(["recipe", id], context.previous);
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
},
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
});
const rate = useMutation({
mutationFn: (stars: number) =>
api(`/v1/recipes/${id}/rate`, { method: "POST", body: { stars, feedbackTags: [] } }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
onMutate: async (stars: number) => {
await queryClient.cancelQueries({ queryKey: ["recipe", id] });
const previous = queryClient.getQueryData<RecipeDetail>(["recipe", id]);
queryClient.setQueryData<RecipeDetail>(["recipe", id], (old) =>
old ? { ...old, myRating: { stars } } : old,
);
return { previous };
},
onError: (err, _stars, context) => {
if (context?.previous) queryClient.setQueryData(["recipe", id], context.previous);
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
},
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["recipe", id] }),
});
if (query.isLoading) return <LoadingView />;
@@ -214,14 +234,24 @@ export default function RecipeScreen() {
<Card>
<Heading>{t("cooked.rate")}</Heading>
<Row>
{[1, 2, 3, 4, 5].map((stars) => (
<Button
key={stars}
label={(recipe.myRating?.stars ?? 0) >= stars ? "★" : "☆"}
variant="ghost"
onPress={() => rate.mutate(stars)}
/>
))}
{[1, 2, 3, 4, 5].map((star) => {
const filled = (recipe.myRating?.stars ?? 0) >= star;
return (
<Pressable
key={star}
onPress={() => rate.mutate(star)}
hitSlop={8}
style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
paddingHorizontal: spacing.xs,
})}
>
<Small style={{ fontSize: 34, color: filled ? colors.accent : colors.border }}>
{filled ? "★" : "☆"}
</Small>
</Pressable>
);
})}
</Row>
{recipe.ratingAverage != null && (
<Small>
+19 -3
View File
@@ -375,8 +375,8 @@
"home.trustStatus.uncertain": "Lagret är osäkert gör en snabbkoll",
"reconciliation.title": "Snabbkoll av lagret",
"reconciliation.subtitle": "Bekräfta vad du har kvar så blir förslagen bättre.",
"consent.title": "Innan du börjar",
"consent.body": "För att använda Cibello behöver du godkänna våra villkor. Bilderna du tar i appen (kylskåp, förpackningar, maträtter) används för att träna och förbättra Cibellos AI det ingår i villkoren.",
"consent.title": "Välkommen till Cibello",
"consent.body": "För att använda Cibello behöver du godkänna Användarvillkoren och Integritetspolicyn. I nästa steg väljer du själv hur personlig du vill att appen ska vara helt frivilligt, och du kan ändra det när som helst.",
"consent.termsLink": "Läs Användarvillkoren",
"consent.privacyLink": "Läs Integritetspolicyn",
"consent.checkbox": "Jag har läst och godkänner Användarvillkoren och Integritetspolicyn.",
@@ -419,5 +419,21 @@
"scan.meal.calories": "Uppskattade kalorier",
"scan.meal.namePlaceholder": "Vad åt du? (valfritt)",
"scan.meal.components": "Igenkänt på tallriken",
"scan.meal.noEstimate": "Kunde inte uppskatta kalorierna från fotot."
"scan.meal.noEstimate": "Kunde inte uppskatta kalorierna från fotot.",
"onboarding.consent.personalization.title": "Gör Cibello till din",
"onboarding.consent.personalization.body": "Ju mer Cibello lär känna dig, desto bättre blir det förslag som passar din smak, dina allergier och din vardag. Allt appen lär sig ser du i \"Vad Cibello vet om dig\" och kan ändra eller radera när du vill. Vi rekommenderar att ha den på.",
"onboarding.consent.personalization.yes": "Ja, gör appen personlig",
"onboarding.consent.skip": "Inte nu",
"onboarding.consent.image_training.title": "Var med och göra Cibello smartare",
"onboarding.consent.image_training.body": "Låt dina foton hjälpa till att träna vår AI så att den känner igen svenska varor och kvitton allt bättre för dig och alla andra. Din skanning fungerar lika bra oavsett, men varje ja gör appen vassare.",
"onboarding.consent.image_training.yes": "Ja, jag hjälper till",
"onboarding.consent.image_training.no": "Nej tack",
"onboarding.consent.dataPrivacy.title": "Data & integritet",
"onboarding.consent.dataPrivacy.body": "Två saker är på för att göra appen bättre för alla. Du kan stänga av dem när du vill.",
"onboarding.consent.anonymized_improvement.body": "Anonym förbättring: helt avidentifierad, aggregerad statistik aldrig dina bilder eller något som pekar ut dig hjälper oss göra förslagen bättre.",
"onboarding.consent.product_analytics.body": "Produktanalys: genom att se hur appen används kan vi fixa buggar och förbättra det som krånglar. Pseudonymt, och vi säljer aldrig din data.",
"onboarding.consent.dataPrivacy.continue": "Fortsätt",
"onboarding.consent.health_integration.body": "Koppla Apple Health / Health Connect så kan Cibello sätta kalori- och näringsmål utifrån just dig. Hälsodata är extra känslig och är av tills du själv slår på den.",
"onboarding.consent.location_weather.body": "Med din plats föreslår vi mat som passar vädret värmande när det är kallt, grillat när solen är framme.",
"onboarding.consent.push_notifications.body": "Få en påminnelse innan maten går ut och tips när det är dags att handla eller laga."
}
+1 -1
View File
@@ -36,7 +36,7 @@ async function main() {
],
imageQualityIssues: [],
},
modelVersion: "gemini-2.5-flash",
modelVersion: "gemini-3.5-flash-lite",
promptVersion: "gemini-fridge-v1",
latencyMs: 1234,
costUsd: 0.0001,
+21 -8
View File
@@ -301,19 +301,32 @@ function findBestCanonicalMatch(
return best.item;
}
function fold(s: string): string {
// Vik bort accenter (crème -> creme) OCH å/ä/ö -> a/a/o så att plural-vokalskifte
// (morot <-> morötter) och lånord (crème fraîche) matchar konsekvent.
return s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}
function scoreMatch(query: string, item: CanonicalIndex): number {
const candidates = [
item.nameSv.toLowerCase(),
item.nameEn.toLowerCase(),
...item.aliases.map((a) => a.toLowerCase()),
];
const q = fold(query);
const candidates = [item.nameSv, item.nameEn, ...item.aliases].map(fold);
let max = 0;
const queryTokens = tokenize(query);
const queryTokens = tokenize(q);
for (const cand of candidates) {
if (cand === query) return 1;
if (cand.includes(query) || query.includes(cand)) max = Math.max(max, 0.85);
if (cand === q) return 1;
if (cand.includes(q) || q.includes(cand)) {
max = Math.max(max, 0.85);
continue;
}
// Delad prefix (vindruva <-> vindruvor): stark signal utan token-exakthet.
const shorter = q.length <= cand.length ? q : cand;
const longer = q.length <= cand.length ? cand : q;
let p = 0;
while (p < shorter.length && shorter[p] === longer[p]) p++;
if (p >= 5 && p / shorter.length >= 0.7) max = Math.max(max, 0.7);
const candTokens = tokenize(cand);
const intersection = queryTokens.filter((t) => candTokens.includes(t));
+1 -1
View File
@@ -71,7 +71,7 @@ describe("BUILD_TRAINING_SAMPLE banks locally", () => {
corrected: { displayName: "Mellanmjölk", quantity: 1, unit: "LITER" },
},
imageS3Key: "fridge-scans/train.jpg",
modelVersion: "gemini-2.5-flash",
modelVersion: "gemini-3.5-flash-lite",
promptVersion: "gemini-fridge-v1",
consentSnapshot: { anonymized_improvement: "granted", image_training: "granted" },
})
+6 -1
View File
@@ -266,7 +266,7 @@ export class GeminiAamosClient implements AamosClient {
constructor(config: GeminiAamosClientConfig) {
this.cfg = {
apiKey: config.apiKey,
model: config.model ?? "gemini-2.5-flash",
model: config.model ?? "gemini-3.5-flash-lite",
timeoutMs: config.timeoutMs ?? 60_000,
dailyBudgetUsd: config.dailyBudgetUsd ?? 0,
promptVersion: config.promptVersion ?? "gemini-fridge-v1",
@@ -856,6 +856,11 @@ Identifiera varje livsmedelsprodukt du ser. För varje produkt, svara med:
- sistaForbruk: sista förbrukningsdatum om det syns (YYYY-MM-DD), annars null
- konfidens: 0.01.0
Viktigt:
- Kvantitet: uppskatta den FAKTISKA synliga/kvarvarande mängden, inte tryckta förpackningsantal. Ett tomt eller öppnat paket ska ha låg kvantitet (nära 0), även om lådan säger t.ex. "24-pack". Är du osäker, gissa lågt och sänk konfidensen.
- Svenska mjölk- och mejerifärger: röd förpackning = standardmjölk 3%, grön = mellanmjölk 1,5%, blå = lättmjölk 0,5%. Använd färgen för att avgöra fetthalt.
- Hellre lägre konfidens än en exakt gissning när du är osäker.
Svara ENDAST med giltig JSON i exakt detta format:
{
"items": [...],
+2 -2
View File
@@ -67,7 +67,7 @@ describe("GeminiAamosClient", () => {
const budget = new MemoryBudgetStore();
const client = new GeminiAamosClient({
apiKey: "test-key",
model: "gemini-2.5-flash",
model: "gemini-3.5-flash-lite",
fetchImpl: makeFetch(JPEG_BYTES),
budgetStore: budget,
dailyBudgetUsd: 10,
@@ -82,7 +82,7 @@ describe("GeminiAamosClient", () => {
expect(result.status).toBe("ok");
expect(result.output).not.toBeNull();
expect(result.modelVersion).toBe("gemini-2.5-flash");
expect(result.modelVersion).toBe("gemini-3.5-flash-lite");
expect(result.costUsd).toBeGreaterThan(0);
expect(result.inputTokens).toBe(392);
expect(result.outputTokens).toBe(121);
@@ -978,6 +978,9 @@ export const SEED_INGREDIENTS: SeedIngredient[] = [
ing("lingonberry_jam", "Lingonsylt", "Lingonberry jam", "skafferi", { n: { kcal: 200, p: 0.3, k: 49, f: 0.2, s: 45 }, isVegan: true, isVegetarian: true }),
ing("prinskorv", "Prinskorv", "Cocktail sausage", "chark", { n: { kcal: 280, p: 11, k: 3, f: 25, mf: 9, salt: 2 }, isPork: true }),
ing("rye_flour", "Rågmjöl", "Rye flour", "skafferi", { n: { kcal: 325, p: 9, k: 60, f: 2, fib: 14 }, containsGluten: true, allergens: ["gluten"], isVegan: true, isVegetarian: true }),
ing("grape", "Vindruvor", "Grapes", "frukt", { n: { kcal: 69, p: 0.7, k: 18, f: 0.2, fib: 0.9, s: 15 }, isVegan: true, isVegetarian: true, aliases: ["druvor", "vindruva", "roda druvor", "röda druvor", "grona druvor", "gröna druvor", "drue druvor", "drue", "grapes"] }),
ing("beetroot", "Rödbetor", "Beetroot", "gronsaker", { n: { kcal: 43, p: 1.6, k: 10, f: 0.2, fib: 2.8, s: 7 }, isVegan: true, isVegetarian: true, aliases: ["rödbeta", "rodbeta", "rödbetor", "rodbetor", "inlagda rödbetor", "inlagda rodbetor", "skivad rödbeta", "skivade rödbetor", "beetroot", "beets", "red beets"] }),
ing("pea_shoots", "Ärtskott", "Pea shoots", "gronsaker", { n: { kcal: 42, p: 4, k: 4, f: 0.5, fib: 2 }, isVegan: true, isVegetarian: true, aliases: ["ärtskott", "artskott", "ertskott", "artiskott", "pea shoots"] })
];
export const SEED_INGREDIENT_IDS = new Set(SEED_INGREDIENTS.map((i) => i.id));
+6 -5
View File
@@ -24,7 +24,8 @@ export interface PlanDefinition {
googleProductId: string;
}
export const TRIAL_DAYS = 7;
export const TRIAL_DAYS = 14;
export const TRIAL_SCANS = 25;
export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
free: {
@@ -33,7 +34,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
priceMinorPerMonth: 0,
currency: "SEK",
maxHouseholdMembers: 1,
aiScansPerMonth: 10,
aiScansPerMonth: 5,
weekPlanning: false,
advancedNutrition: false,
communityPublish: false,
@@ -46,7 +47,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
priceMinorPerMonth: 7900,
currency: "SEK",
maxHouseholdMembers: 3,
aiScansPerMonth: 300,
aiScansPerMonth: 100,
weekPlanning: true,
advancedNutrition: true,
communityPublish: true,
@@ -59,7 +60,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
priceMinorPerMonth: 12900,
currency: "SEK",
maxHouseholdMembers: 6,
aiScansPerMonth: 600,
aiScansPerMonth: 200,
weekPlanning: true,
advancedNutrition: true,
communityPublish: true,
@@ -72,7 +73,7 @@ export const PLAN_DEFINITIONS: Record<SubscriptionPlan, PlanDefinition> = {
priceMinorPerMonth: 16900,
currency: "SEK",
maxHouseholdMembers: 12,
aiScansPerMonth: 1000,
aiScansPerMonth: 400,
weekPlanning: true,
advancedNutrition: true,
communityPublish: true,
+2 -1
View File
@@ -1,6 +1,7 @@
import {
PLAN_DEFINITIONS,
TRIAL_DAYS,
TRIAL_SCANS,
type Entitlements,
type SubscriptionPlan,
type SubscriptionStatus,
@@ -67,7 +68,7 @@ export function computeEntitlements(
plan: "family",
status: "trial",
maxHouseholdMembers: def.maxHouseholdMembers,
aiScansPerMonth: def.aiScansPerMonth,
aiScansPerMonth: TRIAL_SCANS,
aiScansUsedThisMonth: usage.aiScansUsedThisMonth,
weekPlanning: true,
advancedNutrition: true,