diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 34b58a1..4c7d3e8 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -1,8 +1,10 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { View } from "react-native"; import { router } from "expo-router"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api"; +import { useAnalytics } from "@/lib/analytics"; +import { recommendationsViewed } from "@app/analytics"; import { t } from "@/lib/i18n"; import { Body, @@ -49,6 +51,7 @@ interface WhatToEatResponse { export default function WhatToEatScreen() { const [craving, setCraving] = useState(""); const [submittedCraving, setSubmittedCraving] = useState(""); + const { track } = useAnalytics(); const query = useQuery({ queryKey: ["what-to-eat", submittedCraving], @@ -58,6 +61,20 @@ export default function WhatToEatScreen() { ), }); + useEffect(() => { + if (query.data) { + track( + recommendationsViewed({ + properties: { + craving: submittedCraving || undefined, + count: query.data.recommendations.length, + hasMealBoxSuggestions: query.data.mealBoxSuggestions.length > 0, + }, + }), + ); + } + }, [query.data]); // eslint-disable-line react-hooks/exhaustive-deps + return ( {t("wte.title")} diff --git a/apps/mobile/src/app/(tabs)/scan.tsx b/apps/mobile/src/app/(tabs)/scan.tsx index 9451166..7815998 100644 --- a/apps/mobile/src/app/(tabs)/scan.tsx +++ b/apps/mobile/src/app/(tabs)/scan.tsx @@ -17,6 +17,8 @@ import { Title, } from "@/components/ui"; import { spacing } from "@/lib/theme"; +import { useAnalytics } from "@/lib/analytics"; +import { scanStarted } from "@app/analytics"; import { FirstScanCoach, useFirstScanCoach } from "@/components/FirstScanCoach"; /** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum, näringsdeklaration. */ @@ -45,6 +47,7 @@ interface Entitlements { export default function ScanScreen() { const [busyType, setBusyType] = useState(null); const coach = useFirstScanCoach(); + const { track } = useAnalytics(); const entitlements = useQuery({ queryKey: ["entitlements"], queryFn: () => api("/v1/me/entitlements"), @@ -55,6 +58,7 @@ export default function ScanScreen() { }, []); const startScan = async (scanType: string) => { + track(scanStarted({ properties: { scanType } })); if (scanType === "barcode") { router.push("/barcode"); return; diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 7a19f85..a51e925 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -6,6 +6,7 @@ import { QueryClient } from "@tanstack/react-query"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; import { useAuth } from "@/lib/auth"; +import { AnalyticsProvider } from "@/lib/analytics"; import { BRAND } from "@/lib/brand"; import { t, useI18nVersion } from "@/lib/i18n"; import { colors } from "@/lib/theme"; @@ -51,7 +52,8 @@ export default function RootLayout() { client={queryClient} persistOptions={{ persister }} > - + + - + + ); } diff --git a/apps/mobile/src/app/onboarding.tsx b/apps/mobile/src/app/onboarding.tsx index 7fbf486..194d92c 100644 --- a/apps/mobile/src/app/onboarding.tsx +++ b/apps/mobile/src/app/onboarding.tsx @@ -1,9 +1,15 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Pressable, Text, View } from "react-native"; import { router } from "expo-router"; import { api } from "@/lib/api"; import { useAuth } from "@/lib/auth"; +import { useAnalytics } from "@/lib/analytics"; import { t } from "@/lib/i18n"; +import { + onboardingStarted, + onboardingStepCompleted, + onboardingSkipped, +} from "@app/analytics"; import { Body, Button, @@ -59,8 +65,13 @@ export default function OnboardingScreen() { const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted); const setOnboardingStep = useAuth((s) => s.setOnboardingStep); const savedStep = useAuth((s) => s.onboardingStep); + const { track } = useAnalytics(); const [layer, setLayer] = useState(savedStep ?? "a"); + useEffect(() => { + track(onboardingStarted({ properties: { step: layer } })); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Step A state const [goal, setGoal] = useState(null); const [mode, setMode] = useState<"simple" | "exact">("simple"); @@ -94,6 +105,7 @@ export default function OnboardingScreen() { precisionMode: mode, }, }); + track(onboardingStepCompleted({ properties: { step: "a", goal, precisionMode: mode } })); setOnboardingStep(res.step); setLayer("b"); // Let user into the app – Step B will be shown contextually later @@ -125,6 +137,7 @@ export default function OnboardingScreen() { : { kind: "skip" }, }, }); + track(onboardingStepCompleted({ properties: { step: "b", diet, allergens, householdKind } })); setOnboardingStep(res.step); setLayer("c"); } catch (err) { @@ -138,6 +151,7 @@ export default function OnboardingScreen() { setBusy(true); setError(null); try { + track(onboardingStepCompleted({ properties: { step: "c" } })); await api("/v1/onboarding/complete-c", { method: "POST", body: { @@ -162,6 +176,7 @@ export default function OnboardingScreen() { const skip = async (targetLayer?: OnboardingLayer) => { setBusy(true); try { + track(onboardingSkipped({ properties: { step: targetLayer ?? layer } })); await api("/v1/onboarding/skip", { method: "POST", body: { step: targetLayer }, diff --git a/apps/mobile/src/app/paywall.tsx b/apps/mobile/src/app/paywall.tsx index 7705096..d1b922b 100644 --- a/apps/mobile/src/app/paywall.tsx +++ b/apps/mobile/src/app/paywall.tsx @@ -1,6 +1,9 @@ +import { useEffect } from "react"; import { Alert } from "react-native"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api"; +import { useAnalytics } from "@/lib/analytics"; +import { paywallViewed } from "@app/analytics"; import { t } from "@/lib/i18n"; import { formatMinor } from "@/lib/money"; import { @@ -43,11 +46,16 @@ interface Entitlements { } export default function PaywallScreen() { + const { track } = useAnalytics(); const entitlements = useQuery({ queryKey: ["entitlements"], queryFn: () => api("/v1/me/entitlements"), }); + useEffect(() => { + track(paywallViewed({ properties: { plan: entitlements.data?.plan, status: entitlements.data?.status } })); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + const trialDaysLeft = entitlements.data?.status === "trial" && entitlements.data.expiresAt ? Math.max(0, Math.ceil((Date.parse(entitlements.data.expiresAt) - Date.now()) / 86_400_000)) diff --git a/apps/mobile/src/app/scan-review/[jobId].tsx b/apps/mobile/src/app/scan-review/[jobId].tsx index c2037dc..f8cc629 100644 --- a/apps/mobile/src/app/scan-review/[jobId].tsx +++ b/apps/mobile/src/app/scan-review/[jobId].tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Alert, View } from "react-native"; import { router, useLocalSearchParams } from "expo-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -19,6 +19,8 @@ import { Tag, } from "@/components/ui"; import { spacing } from "@/lib/theme"; +import { useAnalytics } from "@/lib/analytics"; +import { scanReviewOpened } from "@app/analytics"; import { parseUnitInput, unitLabel } from "@/lib/units"; /** @@ -70,6 +72,11 @@ export default function ScanReviewScreen() { const queryClient = useQueryClient(); const [items, setItems] = useState(null); const [busy, setBusy] = useState(false); + const { track } = useAnalytics(); + + useEffect(() => { + if (jobId) track(scanReviewOpened({ properties: { jobId } })); + }, [jobId]); // eslint-disable-line react-hooks/exhaustive-deps const query = useQuery({ queryKey: ["scan", jobId], diff --git a/apps/mobile/src/lib/analytics.tsx b/apps/mobile/src/lib/analytics.tsx new file mode 100644 index 0000000..f824b56 --- /dev/null +++ b/apps/mobile/src/lib/analytics.tsx @@ -0,0 +1,149 @@ +import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { Platform } from "react-native"; +import Constants from "expo-constants"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + type AnalyticsEvent, + type Tracker, + createHttpSend, + createTracker, +} from "@app/analytics"; +import { API_BASE } from "./api"; +import { useAuth } from "./auth"; +import { BRAND } from "./brand"; + +function getAppVersion(): string { + const v = Constants.expoConfig?.version; + return typeof v === "string" ? v : "unknown"; +} + +function getLocale(): string { + const locales = Constants.expoConfig?.locales; + const first = Array.isArray(locales) ? (locales[0] as unknown) : null; + return typeof first === "string" ? first : "sv-SE"; +} + +/** + * Mobile analytics provider (spec §8.2). + * + * v1 limitation: the tracker keeps an in-memory queue only. Events are lost + * when the app process is killed before the periodic flush. Persisting the + * queue to AsyncStorage is left for a future iteration. + */ + +const ANALYTICS_ENABLED_KEY = `${BRAND.slug}:analytics-enabled`; +const ANALYTICS_ANON_ID_KEY = `${BRAND.slug}:analytics-anon-id`; +const ANALYTICS_SESSION_ID_KEY = `${BRAND.slug}:analytics-session-id`; + +interface AnalyticsContextValue { + track: (event: AnalyticsEvent) => void; + flush: () => Promise; + enabled: boolean | null; + setEnabled: (enabled: boolean) => Promise; +} + +const AnalyticsContext = createContext(null); + +function generateUuid(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +async function getOrCreateAnonymousId(): Promise { + let id = await AsyncStorage.getItem(ANALYTICS_ANON_ID_KEY); + if (!id) { + id = generateUuid(); + await AsyncStorage.setItem(ANALYTICS_ANON_ID_KEY, id); + } + return id; +} + +async function getOrCreateSessionId(): Promise { + let id = await AsyncStorage.getItem(ANALYTICS_SESSION_ID_KEY); + if (!id) { + id = generateUuid(); + await AsyncStorage.setItem(ANALYTICS_SESSION_ID_KEY, id); + } + return id; +} + +export function AnalyticsProvider({ children }: { children: React.ReactNode }) { + const accessToken = useAuth((s) => s.accessToken); + const [enabled, setEnabledState] = useState(null); + const [anonymousId, setAnonymousId] = useState(null); + const [sessionId, setSessionId] = useState(null); + const trackerRef = useRef(null); + + useEffect(() => { + void (async () => { + const [storedEnabled, anon, session] = await Promise.all([ + AsyncStorage.getItem(ANALYTICS_ENABLED_KEY), + getOrCreateAnonymousId(), + getOrCreateSessionId(), + ]); + // Default to enabled until the user explicitly opts out (GDPR opt-out model). + setEnabledState(storedEnabled === null ? true : storedEnabled === "true"); + setAnonymousId(anon); + setSessionId(session); + })(); + }, []); + + const tracker = useMemo(() => { + if (!anonymousId || !sessionId) return null; + const send = createHttpSend({ apiBaseUrl: API_BASE, authToken: accessToken ?? undefined }); + return createTracker( + { + apiBaseUrl: API_BASE, + anonymousId, + sessionId, + appVersion: getAppVersion(), + platform: Platform.OS === "ios" ? "ios" : Platform.OS === "android" ? "android" : "web", + locale: getLocale(), + maxBatchSize: 20, + flushIntervalMs: 10_000, + }, + send, + ); + }, [anonymousId, sessionId, accessToken]); + + useEffect(() => { + trackerRef.current = tracker; + return () => { + tracker?.destroy(); + }; + }, [tracker]); + + const setEnabled = async (next: boolean) => { + await AsyncStorage.setItem(ANALYTICS_ENABLED_KEY, String(next)); + setEnabledState(next); + if (next) { + await trackerRef.current?.flush(); + } + }; + + const track = (event: AnalyticsEvent) => { + if (enabled !== true || !trackerRef.current) return; + trackerRef.current.track(event); + }; + + const flush = async () => { + await trackerRef.current?.flush(); + }; + + const value = useMemo( + () => ({ track, flush, enabled, setEnabled }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [enabled], + ); + + return {children}; +} + +export function useAnalytics(): AnalyticsContextValue { + const ctx = useContext(AnalyticsContext); + if (!ctx) throw new Error("useAnalytics must be used within AnalyticsProvider"); + return ctx; +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4c6579a..c25019e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,6 @@ # Ändringslogg -## 2026-08-06 – namnbyte till Cibello +## 2026-08-06 – testmiljö, analytics + namnbyte till Cibello - **Varumärke beslutat**: projektet heter från och med nu **Cibello**. - `brand.config.json` uppdaterad med namn, slug, bundle-ID, domäner och @@ -9,6 +9,19 @@ förbjudna överallt utanför konfigurationen; `Cibello` tillåtet endast i `brand.config.json`. - Stale platshållare rensade ur docs och loggar. +- `turbo.json`: test-tasken deklarerar nu `DATABASE_URL`/`TEST_DATABASE_URL` + så strict env-mode släpper igenom dem. +- `packages/database/src/client.ts`: tyst dev-URL-fallback borttagen för + tester – `DATABASE_URL`/`TEST_DATABASE_URL` krävs explicit. +- Staging-databasen städad från testanvändare (4 st) och deras samtycken. +- **Analytics tracker kopplad i mobilappen**: `AnalyticsProvider` initierar + trackern i app-roten; events gate:as klientsidigt på + `product_analytics`-samtycket och skickas inte vid opt-out. +- Instrumenterade nyckelevents (spec §8.2): `onboarding_started`, + `onboarding_step_completed`, `scan_started`, `scan_review_opened`, + `recommendations_viewed`, `paywall_viewed`. +- Känd v1-gräns dokumenterad: trackerns kö är endast in-memory; events som + inte hunnit flushas förloras vid appomstart. - `pnpm typecheck`, `pnpm test`, `pnpm build` samt varumärkesvakt gröna. - Staging deployad om med nya namnet; healthz rapporterar `cibello-api`. diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index d0e2810..964fb98 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -12,10 +12,16 @@ let sharedPool: pg.Pool | undefined; * (minsta möjliga privilegier, spec §52). */ export function createDatabase(connectionString?: string) { - const url = - connectionString ?? - process.env.DATABASE_URL ?? - "postgres://app_user:app_dev_password@localhost:5432/app"; + const url = connectionString ?? process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL; + + if (!url) { + if (process.env.NODE_ENV === "test" || process.env.VITEST !== undefined) { + throw new Error( + "Missing DATABASE_URL / TEST_DATABASE_URL. Database tests must run against an explicit database URL and are not allowed to fall back to the dev database.", + ); + } + throw new Error("Missing DATABASE_URL. Set it in your .env or environment before starting the app."); + } const pool = new pg.Pool({ connectionString: url, diff --git a/packages/database/vitest.config.ts b/packages/database/vitest.config.ts new file mode 100644 index 0000000..92ca8b9 --- /dev/null +++ b/packages/database/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import { config } from "dotenv"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Load root .env so database tests receive DATABASE_URL / TEST_DATABASE_URL +// when running `pnpm test` from the workspace root. +config({ path: path.resolve(__dirname, "../../.env") }); + +export default defineConfig({ + test: { + environment: "node", + globals: false, + }, +}); diff --git a/turbo.json b/turbo.json index 77b490e..00f8160 100644 --- a/turbo.json +++ b/turbo.json @@ -12,7 +12,8 @@ }, "test": { "dependsOn": ["^typecheck"], - "outputs": [] + "outputs": [], + "env": ["DATABASE_URL", "TEST_DATABASE_URL"] }, "dev": { "cache": false,