diff --git a/apps/admin/src/pages/Analytics.tsx b/apps/admin/src/pages/Analytics.tsx index f5f26ba..578863d 100644 --- a/apps/admin/src/pages/Analytics.tsx +++ b/apps/admin/src/pages/Analytics.tsx @@ -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(weekAgo()); const [countsEnd, setCountsEnd] = useState(today()); const [counts, setCounts] = useState(null); + const [activation, setActivation] = useState(null); const [error, setError] = useState(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("/admin/v1/analytics/activation") + .then(setActivation) + .catch((e) => setError(String(e.message))); }, []); const loadFunnel = () => { @@ -107,6 +119,30 @@ export function AnalyticsPage() {

Analytics

{error &&
{error}
} + {activation && ( +
+

Aktivering

+
+
+
{activation.totalHouseholds}
+
Hushåll
+
+
+
{activation.scanned}
+
Skannat
+
+
+
{(activation.activationRate * 100).toFixed(1)}%
+
Aktiverade
+
+
+
{(activation.valueConfirmationRate * 100).toFixed(1)}%
+
Value confirmed
+
+
+
+ )} +

Funnels

diff --git a/apps/api/src/routes/activation.ts b/apps/api/src/routes/activation.ts new file mode 100644 index 0000000..8cf464a --- /dev/null +++ b/apps/api/src/routes/activation.ts @@ -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, + }; + }); +} diff --git a/apps/api/src/routes/admin-analytics.ts b/apps/api/src/routes/admin-analytics.ts index 1eb6578..3516568 100644 --- a/apps/api/src/routes/admin-analytics.ts +++ b/apps/api/src/routes/admin-analytics.ts @@ -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; + 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. diff --git a/apps/api/src/routes/recipes.ts b/apps/api/src/routes/recipes.ts index d3393f9..3bbcd77 100644 --- a/apps/api/src/routes/recipes.ts +++ b/apps/api/src/routes/recipes.ts @@ -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 }; }); diff --git a/apps/api/src/routes/scans.ts b/apps/api/src/routes/scans.ts index 4b163bd..25b986c 100644 --- a/apps/api/src/routes/scans.ts +++ b/apps/api/src/routes/scans.ts @@ -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 }; }); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 535a351..52a5e7e 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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; } diff --git a/apps/mobile/src/app/(tabs)/scan.tsx b/apps/mobile/src/app/(tabs)/scan.tsx index 89f934f..9451166 100644 --- a/apps/mobile/src/app/(tabs)/scan.tsx +++ b/apps/mobile/src/app/(tabs)/scan.tsx @@ -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(null); + const coach = useFirstScanCoach(); const entitlements = useQuery({ queryKey: ["entitlements"], queryFn: () => api("/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 ( + {t("scan.title")} {t("scan.subtitle")} {quota != null && {t("scan.quotaLeft", { count: Math.max(0, quota) })}} diff --git a/apps/mobile/src/components/FirstScanCoach.tsx b/apps/mobile/src/components/FirstScanCoach.tsx new file mode 100644 index 0000000..d76a9f9 --- /dev/null +++ b/apps/mobile/src/components/FirstScanCoach.tsx @@ -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 ( + + + + {t("scan.coach.title")} + {t("scan.coach.body")} + + • {t("scan.coach.tip1")} + • {t("scan.coach.tip2")} + • {t("scan.coach.tip3")} + +