Fas 1b punkt 6: First Scan Coach + aktiveringsmätning
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 §13–14): 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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user