Fas 1b punkt 6: First Scan Coach + aktiveringsmätning

This commit is contained in:
Sven (AAMOS AI)
2026-08-06 04:14:49 +07:00
parent 2f3cdf8016
commit bd8df59138
29 changed files with 9378 additions and 29 deletions
+36
View File
@@ -27,6 +27,14 @@ interface EventCountsResult {
unique_users: number;
}>;
}
interface ActivationResult {
totalHouseholds: number;
scanned: number;
activated: number;
valueConfirmed: number;
activationRate: number;
valueConfirmationRate: number;
}
function today() {
return new Date().toISOString().slice(0, 10);
@@ -51,6 +59,7 @@ export function AnalyticsPage() {
const [countsStart, setCountsStart] = useState<string>(weekAgo());
const [countsEnd, setCountsEnd] = useState<string>(today());
const [counts, setCounts] = useState<EventCountsResult | null>(null);
const [activation, setActivation] = useState<ActivationResult | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -60,6 +69,9 @@ export function AnalyticsPage() {
if (res.funnels[0]) setFunnelName(res.funnels[0].name);
})
.catch((e) => setError(String(e.message)));
api<ActivationResult>("/admin/v1/analytics/activation")
.then(setActivation)
.catch((e) => setError(String(e.message)));
}, []);
const loadFunnel = () => {
@@ -107,6 +119,30 @@ export function AnalyticsPage() {
<h2>Analytics</h2>
{error && <div className="error-text">{error}</div>}
{activation && (
<div className="panel">
<h3>Aktivering</h3>
<div className="metrics">
<div className="stat">
<div className="value">{activation.totalHouseholds}</div>
<div className="label">Hushåll</div>
</div>
<div className="stat">
<div className="value">{activation.scanned}</div>
<div className="label">Skannat</div>
</div>
<div className="stat">
<div className="value">{(activation.activationRate * 100).toFixed(1)}%</div>
<div className="label">Aktiverade</div>
</div>
<div className="stat">
<div className="value">{(activation.valueConfirmationRate * 100).toFixed(1)}%</div>
<div className="label">Value confirmed</div>
</div>
</div>
</div>
)}
<div className="panel">
<h3>Funnels</h3>
<div className="form-row">
+25
View File
@@ -0,0 +1,25 @@
import type { FastifyInstance } from "fastify";
import { getActivationState, schema } from "@app/database";
import { errors } from "../lib/errors.js";
import { getActiveHouseholdId } from "../lib/helpers.js";
/**
* Hushållsaktiveringsstatus (spec §4.2):
* - ACTIVATED_HOUSEHOLD
* - VALUE_CONFIRMED_HOUSEHOLD
*/
export async function activationRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/households/me/activation", auth, async (req) => {
const householdId = await getActiveHouseholdId(app.db, req.userId);
if (!householdId) throw errors.notFound("Aktivt hushåll saknas.");
const state = await getActivationState(app.db, householdId);
return {
...state,
isActivated: state.activatedAt != null,
isValueConfirmed: state.valueConfirmedAt != null,
};
});
}
+22
View File
@@ -200,6 +200,28 @@ export async function adminAnalyticsRoutes(app: FastifyInstance) {
const rows = Array.isArray(result) ? result : result.rows;
return { counts: rows };
});
/** Household activation metrics (Fas 1b). */
app.get("/admin/v1/analytics/activation", admin, async () => {
const result = await app.db.execute(sql`
SELECT
COUNT(*) FILTER (WHERE first_scan_completed_at IS NOT NULL) AS scanned,
COUNT(*) FILTER (WHERE activated_at IS NOT NULL) AS activated,
COUNT(*) FILTER (WHERE value_confirmed_at IS NOT NULL) AS value_confirmed,
COUNT(*) AS total
FROM households
`);
const rows = Array.isArray(result) ? result : result.rows;
const r = rows[0] as Record<string, number>;
return {
totalHouseholds: Number(r.total ?? 0),
scanned: Number(r.scanned ?? 0),
activated: Number(r.activated ?? 0),
valueConfirmed: Number(r.value_confirmed ?? 0),
activationRate: r.total ? Number(r.activated) / Number(r.total) : 0,
valueConfirmationRate: r.total ? Number(r.value_confirmed) / Number(r.total) : 0,
};
});
}
/** Escape a string/date parameter for raw SQL by wrapping it in single quotes.
+15 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { and, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { markMilestone, schema } from "@app/database";
import {
cookRecipeInputSchema,
createUserRecipeInputSchema,
@@ -226,6 +226,11 @@ export async function recipeRoutes(app: FastifyInstance) {
// Innehållsspråk (i18n-spec §1314): publicerad översättning om användarens
// språk inte är svenska; annars svensk källa. Struktur ändras aldrig.
const householdId = await getActiveHouseholdId(app.db, req.userId);
if (householdId) {
void markMilestone(app.db, householdId, "firstRecipeRecommendationViewedAt");
}
const languageTag = await userLanguageTag(app.db, req.userId);
const translation = await resolveRecipeTranslation(app.db, id, languageTag);
const ingredientNames = await resolveIngredientNames(
@@ -366,6 +371,10 @@ export async function recipeRoutes(app: FastifyInstance) {
.update(schema.recipes)
.set({ favoriteCount: sql`${schema.recipes.favoriteCount} + 1` })
.where(eq(schema.recipes.id, id));
const householdId = await getActiveHouseholdId(app.db, req.userId);
if (householdId) {
void markMilestone(app.db, householdId, "firstRecipeSavedOrStartedAt");
}
return { ok: true };
});
@@ -598,6 +607,11 @@ export async function recipeRoutes(app: FastifyInstance) {
correlationId: req.correlationId,
});
await markMilestone(app.db, householdId, "firstCookingSessionCompletedAt");
if (input.deductInventory && deductions.length > 0) {
await markMilestone(app.db, householdId, "inventoryUpdatedAfterCookingAt");
}
return { ok: true, mealIds, mealBoxId, inventoryDeductions: deductions };
});
+7 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gt, isNull } from "drizzle-orm";
import { schema } from "@app/database";
import { markMilestone, schema } from "@app/database";
import type { JobType, ScanType } from "@app/shared-types";
import { confirmScanInputSchema, createScanInputSchema, idParamSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
@@ -200,6 +200,12 @@ export async function scanRoutes(app: FastifyInstance) {
.set({ status: "completed", updatedAt: new Date() })
.where(eq(schema.scanJobs.id, id));
const acceptedCount = input.items.filter((i) => i.action !== "reject").length;
await markMilestone(app.db, householdId, "firstScanCompletedAt");
if (acceptedCount >= 5) {
await markMilestone(app.db, householdId, "fifthItemConfirmedAt");
}
return { ok: true, createdItemIds: created };
});
+2
View File
@@ -26,6 +26,7 @@ import { adminReleaseGateRoutes } from "./routes/admin-release-gates.js";
import { adminWorkersRoutes } from "./routes/admin-workers.js";
import { analyticsRoutes } from "./routes/analytics.js";
import { onboardingRoutes } from "./routes/onboarding.js";
import { activationRoutes } from "./routes/activation.js";
declare module "fastify" {
interface FastifyInstance {
@@ -90,6 +91,7 @@ export async function buildServer(config: AppConfig) {
await app.register(adminWorkersRoutes);
await app.register(analyticsRoutes);
await app.register(onboardingRoutes);
await app.register(activationRoutes);
return app;
}
+8 -1
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Alert, View } from "react-native";
import { router } from "expo-router";
import * as ImagePicker from "expo-image-picker";
@@ -17,6 +17,7 @@ import {
Title,
} from "@/components/ui";
import { spacing } from "@/lib/theme";
import { FirstScanCoach, useFirstScanCoach } from "@/components/FirstScanCoach";
/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum, näringsdeklaration. */
@@ -43,11 +44,16 @@ interface Entitlements {
export default function ScanScreen() {
const [busyType, setBusyType] = useState<string | null>(null);
const coach = useFirstScanCoach();
const entitlements = useQuery({
queryKey: ["entitlements"],
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
});
useEffect(() => {
void coach.showIfNeeded();
}, []);
const startScan = async (scanType: string) => {
if (scanType === "barcode") {
router.push("/barcode");
@@ -93,6 +99,7 @@ export default function ScanScreen() {
return (
<Screen>
<FirstScanCoach visible={coach.visible} onDismiss={coach.dismiss} />
<Title>{t("scan.title")}</Title>
<Small>{t("scan.subtitle")}</Small>
{quota != null && <Small>{t("scan.quotaLeft", { count: Math.max(0, quota) })}</Small>}
@@ -0,0 +1,50 @@
import { useState } from "react";
import { Modal, View } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Body, Button, Card, Heading, Screen, Small, Spacer } from "@/components/ui";
import { spacing } from "@/lib/theme";
import { t } from "@/lib/i18n";
const COACH_DISMISSED_KEY = "firstScanCoachDismissed";
export function useFirstScanCoach() {
const [visible, setVisible] = useState(false);
const showIfNeeded = async () => {
const dismissed = await AsyncStorage.getItem(COACH_DISMISSED_KEY);
if (dismissed !== "true") setVisible(true);
};
const dismiss = async () => {
await AsyncStorage.setItem(COACH_DISMISSED_KEY, "true");
setVisible(false);
};
return { visible, showIfNeeded, dismiss };
}
export function FirstScanCoach({ visible, onDismiss }: { visible: boolean; onDismiss: () => void }) {
return (
<Modal visible={visible} transparent animationType="fade">
<View
style={{
flex: 1,
backgroundColor: "rgba(0,0,0,0.6)",
justifyContent: "center",
padding: spacing.lg,
}}
>
<Card>
<Heading>{t("scan.coach.title")}</Heading>
<Small>{t("scan.coach.body")}</Small>
<Spacer size={spacing.md} />
<Small> {t("scan.coach.tip1")}</Small>
<Small> {t("scan.coach.tip2")}</Small>
<Small> {t("scan.coach.tip3")}</Small>
<Spacer size={spacing.lg} />
<Button label={t("scan.coach.button")} onPress={onDismiss} />
</Card>
</View>
</Modal>
);
}
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "Hvor præcis vil du være?",
"onboarding.modeExact": "Præcis tilstand",
"onboarding.modeExactDesc": "Vej maden, indtast gram, fuld kontrol over tallene.",
"onboarding.modesCombine": "Tilstandene kan kombineres enkelt til hverdag, præcist når du vil.",
"onboarding.modeSimple": "Enkel tilstand",
"onboarding.modeSimpleDesc": "Tag billeder af maden, accepter estimater, minimalt besvær.",
"onboarding.modesCombine": "Tilstandene kan kombineres enkelt til hverdag, præcist når du vil.",
"onboarding.notMedical": "{brand} giver vejledning ikke lægelig rådgivning.",
"onboarding.stepBSubtitle": "Tilpas din profil for bedre forslag.",
"onboarding.stepBTitle": "Fortsæt opsætning",
@@ -270,6 +270,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "Analyserer …",
"scan.barcode": "Stregkode",
"scan.coach.body": "Tag et billede af hele hylden eller køleskabet. Hold telefonen rolig og sørg for godt lys.",
"scan.coach.button": "Forstået",
"scan.coach.tip1": "Sørg for at alle varer er synlige",
"scan.coach.tip2": "Undgå blitz og skygger",
"scan.coach.tip3": "Bekræft usikre rækker i næste trin",
"scan.coach.title": "Første scanning",
"scan.expiry": "Bedst før-dato",
"scan.failed": "Analysen mislykkedes. Prøv igen eller registrér manuelt.",
"scan.freezer": "Fryser",
@@ -306,10 +312,10 @@
"scan.title": "Scan",
"shopping.addPlaceholder": "Tilføj vare …",
"shopping.complete": "Afslut indkøbsturen",
"shopping.completeNote": "Afkrydsede varer lægges på madlageret.",
"shopping.completedBody_one": "1 vare blev lagt i madlageret.",
"shopping.completedBody_other": "{count} varer blev lagt i madlageret.",
"shopping.completedTitle": "Færdig!",
"shopping.completeNote": "Afkrydsede varer lægges på madlageret.",
"shopping.empty": "Listen er tom.",
"shopping.estimated": "ca. {amount}",
"shopping.estimatedTotal": "Anslået i alt: ca. {amount}",
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "Wie genau möchtest du es?",
"onboarding.modeExact": "Exakter Modus",
"onboarding.modeExactDesc": "Essen wiegen, Gramm eingeben, volle Kontrolle über die Zahlen.",
"onboarding.modesCombine": "Die Modi lassen sich kombinieren im Alltag einfach, exakt wenn du willst.",
"onboarding.modeSimple": "Einfacher Modus",
"onboarding.modeSimpleDesc": "Essen fotografieren, Schätzungen akzeptieren, kein Aufwand.",
"onboarding.modesCombine": "Die Modi lassen sich kombinieren im Alltag einfach, exakt wenn du willst.",
"onboarding.notMedical": "{brand} bietet Orientierung keine medizinische Beratung.",
"onboarding.stepBSubtitle": "Passe dein Profil für bessere Vorschläge an.",
"onboarding.stepBTitle": "Einrichtung fortsetzen",
@@ -270,6 +270,12 @@
"recipe.time": "{min} Min.",
"scan.analyzing": "Analyse läuft …",
"scan.barcode": "Barcode",
"scan.coach.body": "Take a photo of the whole shelf or fridge. Keep the phone steady and make sure the light is good.",
"scan.coach.button": "Verstanden",
"scan.coach.tip1": "Make sure all items are visible",
"scan.coach.tip2": "Avoid flash and shadows",
"scan.coach.tip3": "Confirm uncertain items in the next step",
"scan.coach.title": "Erster Scan",
"scan.expiry": "Mindesthaltbarkeitsdatum",
"scan.failed": "Analyse fehlgeschlagen. Erneut versuchen oder manuell erfassen.",
"scan.freezer": "Gefrierschrank",
@@ -306,10 +312,10 @@
"scan.title": "Scannen",
"shopping.addPlaceholder": "Artikel hinzufügen …",
"shopping.complete": "Einkauf abschließen",
"shopping.completeNote": "Abgehakte Artikel wandern in den Vorrat.",
"shopping.completedBody_one": "1 Artikel wurde in den Vorrat übernommen.",
"shopping.completedBody_other": "{count} Artikel wurden in den Vorrat übernommen.",
"shopping.completedTitle": "Fertig!",
"shopping.completeNote": "Abgehakte Artikel wandern in den Vorrat.",
"shopping.empty": "Die Liste ist leer.",
"shopping.estimated": "ca. {amount}",
"shopping.estimatedTotal": "Geschätzte Summe: ca. {amount}",
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "How precise do you want to be?",
"onboarding.modeExact": "Exact mode",
"onboarding.modeExactDesc": "Weigh food, enter grams, full control of the numbers.",
"onboarding.modesCombine": "You can mix modes simple day to day, exact when you want.",
"onboarding.modeSimple": "Simple mode",
"onboarding.modeSimpleDesc": "Snap photos of your food, accept estimates, minimal fuss.",
"onboarding.modesCombine": "You can mix modes simple day to day, exact when you want.",
"onboarding.notMedical": "{brand} provides guidance not medical advice.",
"onboarding.stepBSubtitle": "Customize your profile for better suggestions.",
"onboarding.stepBTitle": "Continue setup",
@@ -270,6 +270,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "Analyzing …",
"scan.barcode": "Barcode",
"scan.coach.body": "Take a photo of the whole shelf or fridge. Keep the phone steady and make sure the light is good.",
"scan.coach.button": "Got it",
"scan.coach.tip1": "Make sure all items are visible",
"scan.coach.tip2": "Avoid flash and shadows",
"scan.coach.tip3": "Confirm uncertain items in the next step",
"scan.coach.title": "First scan",
"scan.expiry": "Best-before date",
"scan.failed": "The analysis failed. Try again or add items manually.",
"scan.freezer": "Freezer",
@@ -306,10 +312,10 @@
"scan.title": "Scan",
"shopping.addPlaceholder": "Add item …",
"shopping.complete": "Finish shopping trip",
"shopping.completeNote": "Checked items are added to your food inventory.",
"shopping.completedBody_one": "1 item was added to your inventory.",
"shopping.completedBody_other": "{count} items were added to your inventory.",
"shopping.completedTitle": "Done!",
"shopping.completeNote": "Checked items are added to your food inventory.",
"shopping.empty": "The list is empty.",
"shopping.estimated": "approx. {amount}",
"shopping.estimatedTotal": "Estimated total: approx. {amount}",
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "¿Cuánta precisión quieres?",
"onboarding.modeExact": "Modo exacto",
"onboarding.modeExactDesc": "Pesa la comida, introduce gramos, control total de las cifras.",
"onboarding.modesCombine": "Puedes combinar los modos: sencillo a diario, exacto cuando quieras.",
"onboarding.modeSimple": "Modo sencillo",
"onboarding.modeSimpleDesc": "Fotografía tu comida, acepta estimaciones, sin complicaciones.",
"onboarding.modesCombine": "Puedes combinar los modos: sencillo a diario, exacto cuando quieras.",
"onboarding.notMedical": "{brand} ofrece orientación, no consejo médico.",
"onboarding.stepBSubtitle": "Personaliza tu perfil para mejores sugerencias.",
"onboarding.stepBTitle": "Continuar configuración",
@@ -270,6 +270,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "Analizando …",
"scan.barcode": "Código de barras",
"scan.coach.body": "Take a photo of the whole shelf or fridge. Keep the phone steady and make sure the light is good.",
"scan.coach.button": "Entendido",
"scan.coach.tip1": "Make sure all items are visible",
"scan.coach.tip2": "Avoid flash and shadows",
"scan.coach.tip3": "Confirm uncertain items in the next step",
"scan.coach.title": "Primer escaneo",
"scan.expiry": "Fecha de consumo preferente",
"scan.failed": "El análisis falló. Inténtalo de nuevo o añade manualmente.",
"scan.freezer": "Congelador",
@@ -306,10 +312,10 @@
"scan.title": "Escanear",
"shopping.addPlaceholder": "Añadir producto …",
"shopping.complete": "Terminar la compra",
"shopping.completeNote": "Los productos marcados se añaden al inventario.",
"shopping.completedBody_one": "Se añadió 1 producto al inventario.",
"shopping.completedBody_other": "Se añadieron {count} productos al inventario.",
"shopping.completedTitle": "¡Listo!",
"shopping.completeNote": "Los productos marcados se añaden al inventario.",
"shopping.empty": "La lista está vacía.",
"shopping.estimated": "aprox. {amount}",
"shopping.estimatedTotal": "Total estimado: aprox. {amount}",
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "Kuinka tarkka haluat olla?",
"onboarding.modeExact": "Tarkka tila",
"onboarding.modeExactDesc": "Punnitse ruoka, syötä grammat, täysi hallinta lukuihin.",
"onboarding.modesCombine": "Tiloja voi yhdistellä arjessa helposti, tarkasti kun haluat.",
"onboarding.modeSimple": "Helppo tila",
"onboarding.modeSimpleDesc": "Kuvaa ruokasi, hyväksy arviot, mahdollisimman vaivatonta.",
"onboarding.modesCombine": "Tiloja voi yhdistellä arjessa helposti, tarkasti kun haluat.",
"onboarding.notMedical": "{brand} antaa ohjeita ei lääketieteellisiä neuvoja.",
"onboarding.stepBSubtitle": "Mukauta profiiliasi parempia ehdotuksia varten.",
"onboarding.stepBTitle": "Jatka asetuksia",
@@ -270,6 +270,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "Analysoidaan …",
"scan.barcode": "Viivakoodi",
"scan.coach.body": "Take a photo of the whole shelf or fridge. Keep the phone steady and make sure the light is good.",
"scan.coach.button": "Ymmärrän",
"scan.coach.tip1": "Make sure all items are visible",
"scan.coach.tip2": "Avoid flash and shadows",
"scan.coach.tip3": "Confirm uncertain items in the next step",
"scan.coach.title": "Ensimmäinen skannaus",
"scan.expiry": "Parasta ennen -päiväys",
"scan.failed": "Analyysi epäonnistui. Yritä uudelleen tai lisää käsin.",
"scan.freezer": "Pakastin",
@@ -306,10 +312,10 @@
"scan.title": "Skannaa",
"shopping.addPlaceholder": "Lisää tuote …",
"shopping.complete": "Päätä ostosreissu",
"shopping.completeNote": "Merkityt tuotteet lisätään ruokavarastoon.",
"shopping.completedBody_one": "1 tuote lisättiin ruokavarastoon.",
"shopping.completedBody_other": "{count} tuotetta lisättiin ruokavarastoon.",
"shopping.completedTitle": "Valmis!",
"shopping.completeNote": "Merkityt tuotteet lisätään ruokavarastoon.",
"shopping.empty": "Lista on tyhjä.",
"shopping.estimated": "n. {amount}",
"shopping.estimatedTotal": "Arvioitu yhteensä: n. {amount}",
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "Quel niveau de précision ?",
"onboarding.modeExact": "Mode exact",
"onboarding.modeExactDesc": "Pesez, saisissez les grammes, contrôle total des chiffres.",
"onboarding.modesCombine": "Les modes se combinent : simple au quotidien, précis quand vous voulez.",
"onboarding.modeSimple": "Mode simple",
"onboarding.modeSimpleDesc": "Photographiez vos aliments, acceptez les estimations, zéro prise de tête.",
"onboarding.modesCombine": "Les modes se combinent : simple au quotidien, précis quand vous voulez.",
"onboarding.notMedical": "{brand} fournit des repères, pas un avis médical.",
"onboarding.stepBSubtitle": "Personnalisez votre profil pour de meilleures suggestions.",
"onboarding.stepBTitle": "Continuer la configuration",
@@ -270,6 +270,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "Analyse en cours …",
"scan.barcode": "Code-barres",
"scan.coach.body": "Take a photo of the whole shelf or fridge. Keep the phone steady and make sure the light is good.",
"scan.coach.button": "Compris",
"scan.coach.tip1": "Make sure all items are visible",
"scan.coach.tip2": "Avoid flash and shadows",
"scan.coach.tip3": "Confirm uncertain items in the next step",
"scan.coach.title": "Premier scan",
"scan.expiry": "Date limite",
"scan.failed": "L'analyse a échoué. Réessayez ou saisissez manuellement.",
"scan.freezer": "Congélateur",
@@ -306,10 +312,10 @@
"scan.title": "Scanner",
"shopping.addPlaceholder": "Ajouter un article …",
"shopping.complete": "Terminer les courses",
"shopping.completeNote": "Les articles cochés rejoignent le stock.",
"shopping.completedBody_one": "1 article a été ajouté au stock.",
"shopping.completedBody_other": "{count} articles ont été ajoutés au stock.",
"shopping.completedTitle": "Terminé !",
"shopping.completeNote": "Les articles cochés rejoignent le stock.",
"shopping.empty": "La liste est vide.",
"shopping.estimated": "env. {amount}",
"shopping.estimatedTotal": "Total estimé : env. {amount}",
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "Quanta precisione vuoi?",
"onboarding.modeExact": "Modalità esatta",
"onboarding.modeExactDesc": "Pesa il cibo, inserisci i grammi, pieno controllo dei numeri.",
"onboarding.modesCombine": "Le modalità si possono combinare: semplice ogni giorno, esatta quando vuoi.",
"onboarding.modeSimple": "Modalità semplice",
"onboarding.modeSimpleDesc": "Fotografa il cibo, accetta le stime, zero complicazioni.",
"onboarding.modesCombine": "Le modalità si possono combinare: semplice ogni giorno, esatta quando vuoi.",
"onboarding.notMedical": "{brand} offre indicazioni, non consigli medici.",
"onboarding.stepBSubtitle": "Personalizza il profilo per suggerimenti migliori.",
"onboarding.stepBTitle": "Continua configurazione",
@@ -270,6 +270,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "Analisi in corso …",
"scan.barcode": "Codice a barre",
"scan.coach.body": "Take a photo of the whole shelf or fridge. Keep the phone steady and make sure the light is good.",
"scan.coach.button": "Capito",
"scan.coach.tip1": "Make sure all items are visible",
"scan.coach.tip2": "Avoid flash and shadows",
"scan.coach.tip3": "Confirm uncertain items in the next step",
"scan.coach.title": "Prima scansione",
"scan.expiry": "Data di scadenza",
"scan.failed": "Analisi non riuscita. Riprova o inserisci manualmente.",
"scan.freezer": "Congelatore",
@@ -306,10 +312,10 @@
"scan.title": "Scansiona",
"shopping.addPlaceholder": "Aggiungi prodotto …",
"shopping.complete": "Concludi la spesa",
"shopping.completeNote": "I prodotti spuntati vengono aggiunti alla dispensa.",
"shopping.completedBody_one": "1 prodotto aggiunto alla dispensa.",
"shopping.completedBody_other": "{count} prodotti aggiunti alla dispensa.",
"shopping.completedTitle": "Fatto!",
"shopping.completeNote": "I prodotti spuntati vengono aggiunti alla dispensa.",
"shopping.empty": "La lista è vuota.",
"shopping.estimated": "circa {amount}",
"shopping.estimatedTotal": "Totale stimato: circa {amount}",
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "Hvor nøyaktig vil du være?",
"onboarding.modeExact": "Nøyaktig modus",
"onboarding.modeExactDesc": "Vei maten, angi gram, full kontroll på tallene.",
"onboarding.modesCombine": "Modusene kan kombineres enkelt til hverdags, nøyaktig når du vil.",
"onboarding.modeSimple": "Enkel modus",
"onboarding.modeSimpleDesc": "Ta bilde av maten, godta estimater, minimalt styr.",
"onboarding.modesCombine": "Modusene kan kombineres enkelt til hverdags, nøyaktig når du vil.",
"onboarding.notMedical": "{brand} gir veiledning ikke medisinske råd.",
"onboarding.stepBSubtitle": "Tilpass profilen din for bedre forslag.",
"onboarding.stepBTitle": "Fortsett oppsett",
@@ -270,6 +270,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "Analyserer …",
"scan.barcode": "Strekkode",
"scan.coach.body": "Ta et bilde av hele hyllen eller kjøleskapet. Hold telefonen i ro og sørg for godt lys.",
"scan.coach.button": "Skjønner",
"scan.coach.tip1": "Sørg for at alle varer er synlige",
"scan.coach.tip2": "Unngå blitz og skygger",
"scan.coach.tip3": "Bekreft usikre rader i neste steg",
"scan.coach.title": "Første skanning",
"scan.expiry": "Best før-dato",
"scan.failed": "Analysen mislyktes. Prøv igjen eller registrer manuelt.",
"scan.freezer": "Fryser",
@@ -306,10 +312,10 @@
"scan.title": "Skann",
"shopping.addPlaceholder": "Legg til vare …",
"shopping.complete": "Avslutt handleturen",
"shopping.completeNote": "Avkryssede varer legges i matlageret.",
"shopping.completedBody_one": "1 vare ble lagt inn i matlageret.",
"shopping.completedBody_other": "{count} varer ble lagt inn i matlageret.",
"shopping.completedTitle": "Ferdig!",
"shopping.completeNote": "Avkryssede varer legges i matlageret.",
"shopping.empty": "Listen er tom.",
"shopping.estimated": "ca. {amount}",
"shopping.estimatedTotal": "Anslått totalt: ca. {amount}",
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "Hoe precies wil je het?",
"onboarding.modeExact": "Exacte modus",
"onboarding.modeExactDesc": "Weeg je eten, voer grammen in, volledige controle over de cijfers.",
"onboarding.modesCombine": "De standen zijn te combineren simpel in het dagelijks leven, exact wanneer je wilt.",
"onboarding.modeSimple": "Simpele modus",
"onboarding.modeSimpleDesc": "Fotografeer je eten, accepteer schattingen, minimaal gedoe.",
"onboarding.modesCombine": "De standen zijn te combineren simpel in het dagelijks leven, exact wanneer je wilt.",
"onboarding.notMedical": "{brand} geeft richtlijnen geen medisch advies.",
"onboarding.stepBSubtitle": "Pas je profiel aan voor betere suggesties.",
"onboarding.stepBTitle": "Doorgaan met instellen",
@@ -270,6 +270,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "Analyseren …",
"scan.barcode": "Barcode",
"scan.coach.body": "Take a photo of the whole shelf or fridge. Keep the phone steady and make sure the light is good.",
"scan.coach.button": "Begrepen",
"scan.coach.tip1": "Make sure all items are visible",
"scan.coach.tip2": "Avoid flash and shadows",
"scan.coach.tip3": "Confirm uncertain items in the next step",
"scan.coach.title": "Eerste scan",
"scan.expiry": "THT-datum",
"scan.failed": "De analyse is mislukt. Probeer opnieuw of voer handmatig in.",
"scan.freezer": "Vriezer",
@@ -306,10 +312,10 @@
"scan.title": "Scannen",
"shopping.addPlaceholder": "Product toevoegen …",
"shopping.complete": "Boodschappen afronden",
"shopping.completeNote": "Afgevinkte producten gaan naar de voorraad.",
"shopping.completedBody_one": "1 product is aan de voorraad toegevoegd.",
"shopping.completedBody_other": "{count} producten zijn aan de voorraad toegevoegd.",
"shopping.completedTitle": "Klaar!",
"shopping.completeNote": "Afgevinkte producten gaan naar de voorraad.",
"shopping.empty": "De lijst is leeg.",
"shopping.estimated": "ca. {amount}",
"shopping.estimatedTotal": "Geschat totaal: ca. {amount}",
+8 -2
View File
@@ -197,9 +197,9 @@
"onboarding.mode": "Jak dokładnie chcesz to robić?",
"onboarding.modeExact": "Tryb dokładny",
"onboarding.modeExactDesc": "Waż jedzenie, wpisuj gramy, pełna kontrola nad liczbami.",
"onboarding.modesCombine": "Tryby można łączyć na co dzień prosto, dokładnie, gdy chcesz.",
"onboarding.modeSimple": "Tryb prosty",
"onboarding.modeSimpleDesc": "Fotografuj jedzenie, akceptuj szacunki, minimum zachodu.",
"onboarding.modesCombine": "Tryby można łączyć na co dzień prosto, dokładnie, gdy chcesz.",
"onboarding.notMedical": "{brand} to wskazówki nie porada medyczna.",
"onboarding.stepBSubtitle": "Dostosuj swój profil, aby uzyskać lepsze sugestie.",
"onboarding.stepBTitle": "Kontynuuj konfigurację",
@@ -280,6 +280,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "Analizowanie …",
"scan.barcode": "Kod kreskowy",
"scan.coach.body": "Take a photo of the whole shelf or fridge. Keep the phone steady and make sure the light is good.",
"scan.coach.button": "Rozumiem",
"scan.coach.tip1": "Make sure all items are visible",
"scan.coach.tip2": "Avoid flash and shadows",
"scan.coach.tip3": "Confirm uncertain items in the next step",
"scan.coach.title": "Pierwsze skanowanie",
"scan.expiry": "Data przydatności",
"scan.failed": "Analiza się nie powiodła. Spróbuj ponownie lub dodaj ręcznie.",
"scan.freezer": "Zamrażarka",
@@ -318,12 +324,12 @@
"scan.title": "Skanuj",
"shopping.addPlaceholder": "Dodaj produkt …",
"shopping.complete": "Zakończ zakupy",
"shopping.completeNote": "Odhaczone produkty trafiają do spiżarni.",
"shopping.completedBody_few": "{count} produkty trafiły do spiżarni.",
"shopping.completedBody_many": "{count} produktów trafiło do spiżarni.",
"shopping.completedBody_one": "1 produkt trafił do spiżarni.",
"shopping.completedBody_other": "{count} produktu trafiło do spiżarni.",
"shopping.completedTitle": "Gotowe!",
"shopping.completeNote": "Odhaczone produkty trafiają do spiżarni.",
"shopping.empty": "Lista jest pusta.",
"shopping.estimated": "ok. {amount}",
"shopping.estimatedTotal": "Szacowana suma: ok. {amount}",
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "Que nível de precisão quer?",
"onboarding.modeExact": "Modo exato",
"onboarding.modeExactDesc": "Pese a comida, introduza gramas, controlo total dos números.",
"onboarding.modesCombine": "Os modos podem combinar-se: simples no dia a dia, exato quando quiser.",
"onboarding.modeSimple": "Modo simples",
"onboarding.modeSimpleDesc": "Fotografe a comida, aceite estimativas, zero complicações.",
"onboarding.modesCombine": "Os modos podem combinar-se: simples no dia a dia, exato quando quiser.",
"onboarding.notMedical": "{brand} dá orientações não aconselhamento médico.",
"onboarding.stepBSubtitle": "Personaliza o teu perfil para melhores sugestões.",
"onboarding.stepBTitle": "Continuar configuração",
@@ -270,6 +270,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "A analisar …",
"scan.barcode": "Código de barras",
"scan.coach.body": "Take a photo of the whole shelf or fridge. Keep the phone steady and make sure the light is good.",
"scan.coach.button": "Entendi",
"scan.coach.tip1": "Make sure all items are visible",
"scan.coach.tip2": "Avoid flash and shadows",
"scan.coach.tip3": "Confirm uncertain items in the next step",
"scan.coach.title": "Primeira verificação",
"scan.expiry": "Data de validade",
"scan.failed": "A análise falhou. Tente novamente ou adicione manualmente.",
"scan.freezer": "Congelador",
@@ -306,10 +312,10 @@
"scan.title": "Digitalizar",
"shopping.addPlaceholder": "Adicionar artigo …",
"shopping.complete": "Terminar as compras",
"shopping.completeNote": "Os artigos marcados vão para a despensa.",
"shopping.completedBody_one": "1 produto foi adicionado à despensa.",
"shopping.completedBody_other": "{count} produtos foram adicionados à despensa.",
"shopping.completedTitle": "Feito!",
"shopping.completeNote": "Os artigos marcados vão para a despensa.",
"shopping.empty": "A lista está vazia.",
"shopping.estimated": "aprox. {amount}",
"shopping.estimatedTotal": "Total estimado: aprox. {amount}",
+8 -2
View File
@@ -191,9 +191,9 @@
"onboarding.mode": "Hur noggrann vill du vara?",
"onboarding.modeExact": "Exakt läge",
"onboarding.modeExactDesc": "Väg mat, ange gram, full kontroll på siffrorna.",
"onboarding.modesCombine": "Lägena kan kombineras enkelt till vardags, exakt när du vill.",
"onboarding.modeSimple": "Enkelt läge",
"onboarding.modeSimpleDesc": "Fota maten, acceptera uppskattningar, minimalt pyssel.",
"onboarding.modesCombine": "Lägena kan kombineras enkelt till vardags, exakt när du vill.",
"onboarding.notMedical": "{brand} ger vägledning inte medicinsk rådgivning.",
"onboarding.stepBSubtitle": "Anpassa din profil för bättre förslag.",
"onboarding.stepBTitle": "Fortsätt konfiguration",
@@ -270,6 +270,12 @@
"recipe.time": "{min} min",
"scan.analyzing": "Analyserar …",
"scan.barcode": "Streckkod",
"scan.coach.body": "Fota hela hyllan eller kylen. Håll telefonen stilla och se till att ljuset är bra.",
"scan.coach.button": "Jag förstår",
"scan.coach.tip1": "Se till att alla varor syns",
"scan.coach.tip2": "Undvik blixt och skuggor",
"scan.coach.tip3": "Bekräfta osäkra rader i nästa steg",
"scan.coach.title": "Första skanningen",
"scan.expiry": "Bäst före-datum",
"scan.failed": "Analysen misslyckades. Försök igen eller registrera manuellt.",
"scan.freezer": "Frys",
@@ -306,10 +312,10 @@
"scan.title": "Skanna",
"shopping.addPlaceholder": "Lägg till vara …",
"shopping.complete": "Avsluta köprundan",
"shopping.completeNote": "Bockade varor läggs in i matlagret.",
"shopping.completedBody_one": "1 vara lades in i matlagret.",
"shopping.completedBody_other": "{count} varor lades in i matlagret.",
"shopping.completedTitle": "Klart!",
"shopping.completeNote": "Bockade varor läggs in i matlagret.",
"shopping.empty": "Listan är tom.",
"shopping.estimated": "ca {amount}",
"shopping.estimatedTotal": "Uppskattad totalsumma: ca {amount}",