Förberedelse fas 2: testmiljö fix + analytics tracker i mobilappen
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
|
import { recommendationsViewed } from "@app/analytics";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@@ -49,6 +51,7 @@ interface WhatToEatResponse {
|
|||||||
export default function WhatToEatScreen() {
|
export default function WhatToEatScreen() {
|
||||||
const [craving, setCraving] = useState("");
|
const [craving, setCraving] = useState("");
|
||||||
const [submittedCraving, setSubmittedCraving] = useState("");
|
const [submittedCraving, setSubmittedCraving] = useState("");
|
||||||
|
const { track } = useAnalytics();
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["what-to-eat", submittedCraving],
|
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 (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<Title>{t("wte.title")}</Title>
|
<Title>{t("wte.title")}</Title>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { spacing } from "@/lib/theme";
|
import { spacing } from "@/lib/theme";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
|
import { scanStarted } from "@app/analytics";
|
||||||
import { FirstScanCoach, useFirstScanCoach } from "@/components/FirstScanCoach";
|
import { FirstScanCoach, useFirstScanCoach } from "@/components/FirstScanCoach";
|
||||||
|
|
||||||
/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum, näringsdeklaration. */
|
/** Skanna (spec §4.2): kyl, frys, skafferi, ingredienser, tallrik, kvitto, streckkod, datum, näringsdeklaration. */
|
||||||
@@ -45,6 +47,7 @@ interface Entitlements {
|
|||||||
export default function ScanScreen() {
|
export default function ScanScreen() {
|
||||||
const [busyType, setBusyType] = useState<string | null>(null);
|
const [busyType, setBusyType] = useState<string | null>(null);
|
||||||
const coach = useFirstScanCoach();
|
const coach = useFirstScanCoach();
|
||||||
|
const { track } = useAnalytics();
|
||||||
const entitlements = useQuery({
|
const entitlements = useQuery({
|
||||||
queryKey: ["entitlements"],
|
queryKey: ["entitlements"],
|
||||||
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
||||||
@@ -55,6 +58,7 @@ export default function ScanScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startScan = async (scanType: string) => {
|
const startScan = async (scanType: string) => {
|
||||||
|
track(scanStarted({ properties: { scanType } }));
|
||||||
if (scanType === "barcode") {
|
if (scanType === "barcode") {
|
||||||
router.push("/barcode");
|
router.push("/barcode");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { QueryClient } from "@tanstack/react-query";
|
|||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { AnalyticsProvider } from "@/lib/analytics";
|
||||||
import { BRAND } from "@/lib/brand";
|
import { BRAND } from "@/lib/brand";
|
||||||
import { t, useI18nVersion } from "@/lib/i18n";
|
import { t, useI18nVersion } from "@/lib/i18n";
|
||||||
import { colors } from "@/lib/theme";
|
import { colors } from "@/lib/theme";
|
||||||
@@ -51,7 +52,8 @@ export default function RootLayout() {
|
|||||||
client={queryClient}
|
client={queryClient}
|
||||||
persistOptions={{ persister }}
|
persistOptions={{ persister }}
|
||||||
>
|
>
|
||||||
<StatusBar style="dark" />
|
<AnalyticsProvider>
|
||||||
|
<StatusBar style="dark" />
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerStyle: { backgroundColor: colors.background },
|
headerStyle: { backgroundColor: colors.background },
|
||||||
@@ -87,7 +89,8 @@ export default function RootLayout() {
|
|||||||
options={{ title: t("myday.logMeal"), presentation: "modal" }}
|
options={{ title: t("myday.logMeal"), presentation: "modal" }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="barcode" options={{ title: "Streckkod" }} />
|
<Stack.Screen name="barcode" options={{ title: "Streckkod" }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</AnalyticsProvider>
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
import {
|
||||||
|
onboardingStarted,
|
||||||
|
onboardingStepCompleted,
|
||||||
|
onboardingSkipped,
|
||||||
|
} from "@app/analytics";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Button,
|
Button,
|
||||||
@@ -59,8 +65,13 @@ export default function OnboardingScreen() {
|
|||||||
const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted);
|
const setOnboardingCompleted = useAuth((s) => s.setOnboardingCompleted);
|
||||||
const setOnboardingStep = useAuth((s) => s.setOnboardingStep);
|
const setOnboardingStep = useAuth((s) => s.setOnboardingStep);
|
||||||
const savedStep = useAuth((s) => s.onboardingStep);
|
const savedStep = useAuth((s) => s.onboardingStep);
|
||||||
|
const { track } = useAnalytics();
|
||||||
const [layer, setLayer] = useState<OnboardingLayer>(savedStep ?? "a");
|
const [layer, setLayer] = useState<OnboardingLayer>(savedStep ?? "a");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(onboardingStarted({ properties: { step: layer } }));
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Step A state
|
// Step A state
|
||||||
const [goal, setGoal] = useState<string | null>(null);
|
const [goal, setGoal] = useState<string | null>(null);
|
||||||
const [mode, setMode] = useState<"simple" | "exact">("simple");
|
const [mode, setMode] = useState<"simple" | "exact">("simple");
|
||||||
@@ -94,6 +105,7 @@ export default function OnboardingScreen() {
|
|||||||
precisionMode: mode,
|
precisionMode: mode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
track(onboardingStepCompleted({ properties: { step: "a", goal, precisionMode: mode } }));
|
||||||
setOnboardingStep(res.step);
|
setOnboardingStep(res.step);
|
||||||
setLayer("b");
|
setLayer("b");
|
||||||
// Let user into the app – Step B will be shown contextually later
|
// Let user into the app – Step B will be shown contextually later
|
||||||
@@ -125,6 +137,7 @@ export default function OnboardingScreen() {
|
|||||||
: { kind: "skip" },
|
: { kind: "skip" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
track(onboardingStepCompleted({ properties: { step: "b", diet, allergens, householdKind } }));
|
||||||
setOnboardingStep(res.step);
|
setOnboardingStep(res.step);
|
||||||
setLayer("c");
|
setLayer("c");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -138,6 +151,7 @@ export default function OnboardingScreen() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
track(onboardingStepCompleted({ properties: { step: "c" } }));
|
||||||
await api("/v1/onboarding/complete-c", {
|
await api("/v1/onboarding/complete-c", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
@@ -162,6 +176,7 @@ export default function OnboardingScreen() {
|
|||||||
const skip = async (targetLayer?: OnboardingLayer) => {
|
const skip = async (targetLayer?: OnboardingLayer) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
track(onboardingSkipped({ properties: { step: targetLayer ?? layer } }));
|
||||||
await api("/v1/onboarding/skip", {
|
await api("/v1/onboarding/skip", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { step: targetLayer },
|
body: { step: targetLayer },
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
|
import { paywallViewed } from "@app/analytics";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import { formatMinor } from "@/lib/money";
|
import { formatMinor } from "@/lib/money";
|
||||||
import {
|
import {
|
||||||
@@ -43,11 +46,16 @@ interface Entitlements {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PaywallScreen() {
|
export default function PaywallScreen() {
|
||||||
|
const { track } = useAnalytics();
|
||||||
const entitlements = useQuery({
|
const entitlements = useQuery({
|
||||||
queryKey: ["entitlements"],
|
queryKey: ["entitlements"],
|
||||||
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
queryFn: () => api<Entitlements>("/v1/me/entitlements"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track(paywallViewed({ properties: { plan: entitlements.data?.plan, status: entitlements.data?.status } }));
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const trialDaysLeft =
|
const trialDaysLeft =
|
||||||
entitlements.data?.status === "trial" && entitlements.data.expiresAt
|
entitlements.data?.status === "trial" && entitlements.data.expiresAt
|
||||||
? Math.max(0, Math.ceil((Date.parse(entitlements.data.expiresAt) - Date.now()) / 86_400_000))
|
? Math.max(0, Math.ceil((Date.parse(entitlements.data.expiresAt) - Date.now()) / 86_400_000))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Alert, View } from "react-native";
|
import { Alert, View } from "react-native";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { spacing } from "@/lib/theme";
|
import { spacing } from "@/lib/theme";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
|
import { scanReviewOpened } from "@app/analytics";
|
||||||
import { parseUnitInput, unitLabel } from "@/lib/units";
|
import { parseUnitInput, unitLabel } from "@/lib/units";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +72,11 @@ export default function ScanReviewScreen() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [items, setItems] = useState<EditableItem[] | null>(null);
|
const [items, setItems] = useState<EditableItem[] | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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({
|
const query = useQuery({
|
||||||
queryKey: ["scan", jobId],
|
queryKey: ["scan", jobId],
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
enabled: boolean | null;
|
||||||
|
setEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnalyticsContext = createContext<AnalyticsContextValue | null>(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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<boolean | null>(null);
|
||||||
|
const [anonymousId, setAnonymousId] = useState<string | null>(null);
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const trackerRef = useRef<Tracker | null>(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<Tracker | null>(() => {
|
||||||
|
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 <AnalyticsContext.Provider value={value}>{children}</AnalyticsContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnalytics(): AnalyticsContextValue {
|
||||||
|
const ctx = useContext(AnalyticsContext);
|
||||||
|
if (!ctx) throw new Error("useAnalytics must be used within AnalyticsProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
+14
-1
@@ -1,6 +1,6 @@
|
|||||||
# Ändringslogg
|
# Ä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**.
|
- **Varumärke beslutat**: projektet heter från och med nu **Cibello**.
|
||||||
- `brand.config.json` uppdaterad med namn, slug, bundle-ID, domäner och
|
- `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
|
förbjudna överallt utanför konfigurationen; `Cibello` tillåtet endast i
|
||||||
`brand.config.json`.
|
`brand.config.json`.
|
||||||
- Stale platshållare rensade ur docs och loggar.
|
- 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.
|
- `pnpm typecheck`, `pnpm test`, `pnpm build` samt varumärkesvakt gröna.
|
||||||
- Staging deployad om med nya namnet; healthz rapporterar `cibello-api`.
|
- Staging deployad om med nya namnet; healthz rapporterar `cibello-api`.
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,16 @@ let sharedPool: pg.Pool | undefined;
|
|||||||
* (minsta möjliga privilegier, spec §52).
|
* (minsta möjliga privilegier, spec §52).
|
||||||
*/
|
*/
|
||||||
export function createDatabase(connectionString?: string) {
|
export function createDatabase(connectionString?: string) {
|
||||||
const url =
|
const url = connectionString ?? process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
|
||||||
connectionString ??
|
|
||||||
process.env.DATABASE_URL ??
|
if (!url) {
|
||||||
"postgres://app_user:app_dev_password@localhost:5432/app";
|
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({
|
const pool = new pg.Pool({
|
||||||
connectionString: url,
|
connectionString: url,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
+2
-1
@@ -12,7 +12,8 @@
|
|||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"dependsOn": ["^typecheck"],
|
"dependsOn": ["^typecheck"],
|
||||||
"outputs": []
|
"outputs": [],
|
||||||
|
"env": ["DATABASE_URL", "TEST_DATABASE_URL"]
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user