diff --git a/.env.example b/.env.example
index c2c2003..0016a3b 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/README.md b/README.md
index a59f85c..09eb712 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index 703d493..6de2268 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -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)) {
diff --git a/apps/api/src/routes/scans.ts b/apps/api/src/routes/scans.ts
index 2580f68..2d9de75 100644
--- a/apps/api/src/routes/scans.ts
+++ b/apps/api/src/routes/scans.ts
@@ -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({
diff --git a/apps/api/test/scans.test.ts b/apps/api/test/scans.test.ts
index e4d36c8..e171aa6 100644
--- a/apps/api/test/scans.test.ts
+++ b/apps/api/test/scans.test.ts
@@ -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();
diff --git a/apps/mobile/src/app/(tabs)/home.tsx b/apps/mobile/src/app/(tabs)/home.tsx
index 946c779..f123f12 100644
--- a/apps/mobile/src/app/(tabs)/home.tsx
+++ b/apps/mobile/src/app/(tabs)/home.tsx
@@ -117,6 +117,9 @@ export default function HomeScreen() {
router.push("/saved-recipes")}>
⭐ Dina sparade recept
+ router.push("/kitchen")}>
+ 🧺 Ditt kök
+
{budget.data && (
diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx
index 5a7169b..96af839 100644
--- a/apps/mobile/src/app/(tabs)/index.tsx
+++ b/apps/mobile/src/app/(tabs)/index.tsx
@@ -58,7 +58,7 @@ export default function WhatToEatScreen() {
queryKey: ["what-to-eat", submittedCraving],
queryFn: () =>
api(
- `/v1/recommendations/what-to-eat?limit=15${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
+ `/v1/recommendations/what-to-eat?limit=15&view=pantry${submittedCraving ? `&craving=${encodeURIComponent(submittedCraving)}` : ""}`,
),
});
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 5ee9f7a..56595f7 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -76,15 +76,16 @@ export default function RootLayout() {
name="cooking/[id]"
options={{ title: "", presentation: "fullScreenModal" }}
/>
-
-
+
+
-
+
+
;
+ 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(null);
const timerRef = useRef | null>(null);
const [finishing, setFinishing] = useState(false);
@@ -265,6 +274,40 @@ export default function CookingScreen() {
{step?.temperatureC ? ` (${step.temperatureC} °C)` : ""}
{step?.tip && 💡 {step.tip}}
+ setShowIngredients((v) => !v)}
+ hitSlop={8}
+ style={{ alignSelf: "flex-start" }}
+ >
+
+ {showIngredients ? "▾ " : "▸ "}📋 {t("recipe.ingredients")}
+
+
+ {showIngredients && (
+
+
+ {recipe.ingredients.map((ing) => {
+ const scaledQty = (ing.quantity * portionsCooked) / recipe.portions;
+ return (
+
+
+ {ing.displayNameSv}
+ {ing.optional ? " (valfritt)" : ""}
+
+ {formatQuantity(scaledQty, ing.unit)}
+
+ );
+ })}
+
+
+ )}
{step?.timerSeconds != null && (
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 ;
+ if (query.isError) return void query.refetch()} />;
+ const items = query.data?.items ?? [];
+
+ return (
+
+ {items.length === 0 && (
+
+ )}
+ {items.map((item) => (
+
+
+
+ {item.displayName} · {formatQuantity(item.quantity, item.unit)}
+
+
+ {item.expiry.pastBestBefore && ⚠︎ bäst före har passerat}
+
+ ))}
+
+ );
+}
diff --git a/apps/mobile/src/app/recipe/[id].tsx b/apps/mobile/src/app/recipe/[id].tsx
index 29941b7..d013233 100644
--- a/apps/mobile/src/app/recipe/[id].tsx
+++ b/apps/mobile/src/app/recipe/[id].tsx
@@ -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(["recipe", id]);
+ queryClient.setQueryData(["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(["recipe", id]);
+ queryClient.setQueryData(["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 ;
@@ -214,14 +234,24 @@ export default function RecipeScreen() {
{t("cooked.rate")}
- {[1, 2, 3, 4, 5].map((stars) => (
-
{recipe.ratingAverage != null && (
diff --git a/apps/mobile/src/locales/sv/common.json b/apps/mobile/src/locales/sv/common.json
index 1364da8..bbb16cf 100644
--- a/apps/mobile/src/locales/sv/common.json
+++ b/apps/mobile/src/locales/sv/common.json
@@ -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."
}
diff --git a/apps/worker/src/eval/capture-eval.ts b/apps/worker/src/eval/capture-eval.ts
index 539ee25..89bcaed 100644
--- a/apps/worker/src/eval/capture-eval.ts
+++ b/apps/worker/src/eval/capture-eval.ts
@@ -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,
diff --git a/apps/worker/src/processors/scans.ts b/apps/worker/src/processors/scans.ts
index b453b8a..58d2ad0 100644
--- a/apps/worker/src/processors/scans.ts
+++ b/apps/worker/src/processors/scans.ts
@@ -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));
diff --git a/apps/worker/test/training-export.test.ts b/apps/worker/test/training-export.test.ts
index 89d6977..f0755e2 100644
--- a/apps/worker/test/training-export.test.ts
+++ b/apps/worker/test/training-export.test.ts
@@ -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" },
})
diff --git a/packages/ai-contracts/src/gemini.ts b/packages/ai-contracts/src/gemini.ts
index c9d9c40..0391e69 100644
--- a/packages/ai-contracts/src/gemini.ts
+++ b/packages/ai-contracts/src/gemini.ts
@@ -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.0–1.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": [...],
diff --git a/packages/ai-contracts/test/gemini.test.ts b/packages/ai-contracts/test/gemini.test.ts
index 181f1a5..90c916b 100644
--- a/packages/ai-contracts/test/gemini.test.ts
+++ b/packages/ai-contracts/test/gemini.test.ts
@@ -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);
diff --git a/packages/database/src/seed/data/ingredients.ts b/packages/database/src/seed/data/ingredients.ts
index d3d6b2d..07b6c6d 100644
--- a/packages/database/src/seed/data/ingredients.ts
+++ b/packages/database/src/seed/data/ingredients.ts
@@ -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));
diff --git a/packages/shared-types/src/constants.ts b/packages/shared-types/src/constants.ts
index d8e6a3e..12358e2 100644
--- a/packages/shared-types/src/constants.ts
+++ b/packages/shared-types/src/constants.ts
@@ -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 = {
free: {
@@ -33,7 +34,7 @@ export const PLAN_DEFINITIONS: Record = {
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 = {
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 = {
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 = {
priceMinorPerMonth: 16900,
currency: "SEK",
maxHouseholdMembers: 12,
- aiScansPerMonth: 1000,
+ aiScansPerMonth: 400,
weekPlanning: true,
advancedNutrition: true,
communityPublish: true,
diff --git a/packages/subscriptions/src/entitlements.ts b/packages/subscriptions/src/entitlements.ts
index fcb6ffc..ed93d4d 100644
--- a/packages/subscriptions/src/entitlements.ts
+++ b/packages/subscriptions/src/entitlements.ts
@@ -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,