diff --git a/.env.example b/.env.example index 6d5b9393..ca095505 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,28 @@ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY= CLERK_JWT_ISSUER_DOMAIN= +# ----------------------------------------------------------------------------- +# PostHog analytics envs +# +# Expo / React Native app runtime: +# EXPO_PUBLIC_POSTHOG_API_KEY is the PostHog project API key. It is safe to +# expose to the mobile app and should be set in local .env files and EAS/CI. +# Tracking is disabled when this value is empty. +# +# EXPO_PUBLIC_POSTHOG_HOST is optional. Use the EU host for the current +# Validation Phase unless a PostHog reverse proxy is introduced later. +# Default in code: https://eu.i.posthog.com +# +# Validation Phase scope: +# PostHog tracking is identified-only after Clerk sign-in. Anonymous pre-auth +# events, autocapture, lifecycle events, and session replay are intentionally +# out of scope. +# ----------------------------------------------------------------------------- + +EXPO_PUBLIC_POSTHOG_API_KEY= +EXPO_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com + + # ----------------------------------------------------------------------------- # Convex backend envs: Lernplan file storage # Set in Convex, not in Expo/EAS public envs. diff --git a/app.json b/app.json index a475a5c0..7a307b6c 100644 --- a/app.json +++ b/app.json @@ -92,7 +92,8 @@ "resizeMode": "contain", "backgroundColor": "#ffffff" } - ] + ], + "expo-localization" ] } } diff --git a/convex/schema.ts b/convex/schema.ts index ab06b488..b10122f6 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -25,6 +25,7 @@ export default defineSchema({ schoolType: v.optional(v.string()), state: v.optional(v.string()), avatarUrl: v.optional(v.string()), + validationStudentCode: v.optional(v.string()), }) .index("by_tokenIdentifier", ["tokenIdentifier"]) .index("by_clerkId", ["clerkId"]) diff --git a/convex/users.ts b/convex/users.ts index 35242a75..40f3eb72 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -86,6 +86,7 @@ const profileFields = (args: { schoolType?: string; state?: string; avatarUrl?: string; + validationStudentCode?: string; }) => ({ ...(args.email !== undefined ? { email: normalizeEmail(args.email) } : {}), ...(args.name !== undefined ? { name: args.name } : {}), @@ -95,6 +96,9 @@ const profileFields = (args: { ...(args.schoolType !== undefined ? { schoolType: args.schoolType } : {}), ...(args.state !== undefined ? { state: args.state } : {}), ...(args.avatarUrl !== undefined ? { avatarUrl: args.avatarUrl } : {}), + ...(args.validationStudentCode !== undefined + ? { validationStudentCode: args.validationStudentCode } + : {}), }); export const syncCurrentUser = mutation({ @@ -106,6 +110,7 @@ export const syncCurrentUser = mutation({ schoolType: v.optional(v.string()), state: v.optional(v.string()), avatarUrl: v.optional(v.string()), + validationStudentCode: v.optional(v.string()), }, handler: async (ctx, args) => { const identity = await requireIdentity(ctx); diff --git a/docs/contexts/integrations/CONTEXT.md b/docs/contexts/integrations/CONTEXT.md index 8c609117..def55c28 100644 --- a/docs/contexts/integrations/CONTEXT.md +++ b/docs/contexts/integrations/CONTEXT.md @@ -6,7 +6,23 @@ Confluence is the current cross-functional documentation hub and may later move GitHub Issues is the current issue tracker. A planned migration to Linear is tracked in https://github.com/Dayova/dayova-mvp/issues/20. +## Language + +**PostHog Student Profile**: +An identifiable analytics profile for a validation-phase student, used to connect in-app behavior with the team's Confluence-based student table. +_Avoid_: Treating PostHog as the source of record for full research notes or qualitative interview data. + +**Validation Student Table**: +The Confluence-based table used by non-technical team members to manage test students, their real deadlines, check-ins, and qualitative observations. +_Avoid_: Splitting founder-facing validation notes across multiple unlinked tools. + +**Validation Student Code**: +A short Dayova-owned code such as `S1` or `S2` that links a student in the Confluence validation table to their app user and PostHog profile. +_Avoid_: Using email address or full name as the stable join key between tools. + ## Notes +- PostHog identifies validation-phase students with Clerk's stable user ID. The Validation Student Code should live in Dayova data, not Clerk auth metadata, and should be sent to PostHog as a profile property when present. +- PostHog tracking is identified-only during the Validation Phase. Anonymous pre-auth app activity, autocapture, lifecycle events, and session replay are intentionally out of scope unless login or onboarding becomes a measured blocker. - Capture integration ownership, external IDs, sync boundaries, and migration decisions here. - Put integrations ADRs in `docs/contexts/integrations/adr/`. diff --git a/docs/contexts/platform/CONTEXT.md b/docs/contexts/platform/CONTEXT.md index 8915ebce..71084940 100644 --- a/docs/contexts/platform/CONTEXT.md +++ b/docs/contexts/platform/CONTEXT.md @@ -6,5 +6,8 @@ Confluence is the current cross-functional documentation hub. Keep this file foc ## Notes +- Expo public env vars prefixed with `EXPO_PUBLIC_` are bundled into the mobile app and must only contain public client-side values. +- `EXPO_PUBLIC_POSTHOG_API_KEY` enables PostHog analytics in the Expo app. Leave it empty to disable analytics locally. +- `EXPO_PUBLIC_POSTHOG_HOST` defaults to `https://eu.i.posthog.com`; use a Dayova-owned reverse proxy only if event delivery or privacy requirements justify the extra infrastructure. - Capture platform conventions, release processes, and environment decisions here. - Put platform ADRs in `docs/contexts/platform/adr/`. diff --git a/docs/contexts/product/CONTEXT.md b/docs/contexts/product/CONTEXT.md index 38628465..41654144 100644 --- a/docs/contexts/product/CONTEXT.md +++ b/docs/contexts/product/CONTEXT.md @@ -6,7 +6,29 @@ Confluence is the current cross-functional documentation hub. Keep this file foc ## Glossary -Add stable product and learning-domain terms here as they are agreed. +**Validation Phase**: +A focused learning period where Dayova is tested with a small number of students who have real school deadlines, to learn whether the product causes earlier and more committed action. +_Avoid_: Treating generic app usage, polite feedback, or TestFlight downloads as validation. + +**First Real Block**: +The first genuine learning or work block a student starts for a real exam, assignment, presentation, or graded task before the last possible moment. +_Avoid_: Counting planning-only activity or artificial test tasks as this signal. + +**Product Signal**: +Observed behavior that suggests Dayova itself helped the student act, such as starting a slot without a personal check-in, returning voluntarily, or entering a second real use case. +_Avoid_: Mixing this with action triggered by a founder or coach message. + +**Accountability Signal**: +Observed behavior that happens reliably after a personal check-in or reminder, showing that accountability may be part of the product value. +_Avoid_: Treating check-in-driven action as pure product-loop success. + +**Core Loop**: +The product behavior loop of entering a real task, seeing the next step, starting, documenting the result, replanning after failure, and returning. +_Avoid_: Describing the loop as app engagement or calendar usage. + +**Recovery Loop**: +The part of the Core Loop where a missed or failed slot is explained, made smaller or replanned, and attempted again. +_Avoid_: Treating missed slots only as churn or lack of motivation. ## Notes diff --git a/package.json b/package.json index 52b43781..8db37c50 100644 --- a/package.json +++ b/package.json @@ -43,15 +43,18 @@ "clsx": "^2.1.1", "convex": "^1.39.1", "expo": "~56.0.2", + "expo-application": "~56.0.3", "expo-constants": "^56.0.13", "expo-crypto": "^56.0.3", "expo-dev-client": "~56.0.13", + "expo-device": "~56.0.4", "expo-document-picker": "~56.0.3", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-image-picker": "~56.0.11", "expo-linear-gradient": "~56.0.4", "expo-linking": "^56.0.10", + "expo-localization": "~56.0.6", "expo-notifications": "~56.0.11", "expo-router": "^56.2.4", "expo-secure-store": "~56.0.3", @@ -61,6 +64,7 @@ "expo-updates": "~56.0.14", "nativewind": "^4.2.3", "officeparser": "^6.1.0", + "posthog-react-native": "^4.45.14", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4167aaff..128ac149 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: expo: specifier: ~56.0.2 version: 56.0.2(@babel/core@7.29.0)(@expo/metro-runtime@56.0.10)(expo-router@56.2.4)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-application: + specifier: ~56.0.3 + version: 56.0.3(expo@56.0.2) expo-constants: specifier: ^56.0.13 version: 56.0.13(expo@56.0.2)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3)) @@ -67,6 +70,9 @@ importers: expo-dev-client: specifier: ~56.0.13 version: 56.0.13(expo@56.0.2)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3)) + expo-device: + specifier: ~56.0.4 + version: 56.0.4(expo@56.0.2) expo-document-picker: specifier: ~56.0.3 version: 56.0.3(expo@56.0.2) @@ -85,6 +91,9 @@ importers: expo-linking: specifier: ^56.0.10 version: 56.0.10(expo@56.0.2)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) + expo-localization: + specifier: ~56.0.6 + version: 56.0.6(expo@56.0.2)(react@19.2.3) expo-notifications: specifier: ~56.0.11 version: 56.0.11(expo@56.0.2)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) @@ -112,6 +121,9 @@ importers: officeparser: specifier: ^6.1.0 version: 6.1.1 + posthog-react-native: + specifier: ^4.45.14 + version: 4.45.14(@react-native-async-storage/async-storage@1.24.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3)))(expo-application@56.0.3(expo@56.0.2))(expo-device@56.0.4(expo@56.0.2))(expo-file-system@56.0.7(expo@56.0.2)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3)))(expo-localization@56.0.6(expo@56.0.2)(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)) react: specifier: 19.2.3 version: 19.2.3 @@ -1762,6 +1774,12 @@ packages: cpu: [x64] os: [win32] + '@posthog/core@1.29.9': + resolution: {integrity: sha512-DjvuIyBZ2Z/gBhtZlITlM2D8PlnMsHSQ1D78dbUYoVsgGguvanpJTobZObjLlFkybyvfZFYkpoJkFNI/2Pw4IQ==} + + '@posthog/types@1.376.0': + resolution: {integrity: sha512-gbFfxCuZDs/D4QZMwdE+smD1jsuqgGpS6yKGHZZ19foxMy8RYHsU1E47iG1b88n/uN02fAabLibVwuxLtq8juw==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -3356,6 +3374,11 @@ packages: expo: '*' react-native: '*' + expo-device@56.0.4: + resolution: {integrity: sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg==} + peerDependencies: + expo: '*' + expo-document-picker@56.0.3: resolution: {integrity: sha512-6ibeQkWfLl3xM9sNUaYthdqkz4d5UMIutDZhwgPDAZQZVxOFqE++u1zT1NO4JdmuUuOiOgsbZ5oYVQ1YBiKBtQ==} peerDependencies: @@ -3416,6 +3439,12 @@ packages: react: '*' react-native: '*' + expo-localization@56.0.6: + resolution: {integrity: sha512-zzBVoUFHCVNBywcxGsspoZeIXebihOo/AnmQYE4jMv8gHCSKlLNFT+ft+0+mWcZCMs9necvUs8S8TDonAu/xBA==} + peerDependencies: + expo: '*' + react: '*' + expo-manifests@56.0.4: resolution: {integrity: sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw==} peerDependencies: @@ -4605,6 +4634,47 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + posthog-react-native@4.45.14: + resolution: {integrity: sha512-C5549ztBFzqkX+MLJVuYiAXSGjurO0mfK1OOaOsk0lvitzTB1IQWgCKeWXqjq0rkxh0+9KgHz0SDEn2JxjaA9w==} + peerDependencies: + '@react-native-async-storage/async-storage': '>=1.0.0' + '@react-navigation/native': '>= 5.0.0' + expo-application: '>= 4.0.0' + expo-device: '>= 4.0.0' + expo-file-system: '>= 13.0.0' + expo-localization: '>= 11.0.0' + posthog-react-native-session-replay: '>= 1.5.8' + react-native-device-info: '>= 10.0.0' + react-native-localize: '>= 3.0.0' + react-native-navigation: '>= 6.0.0' + react-native-safe-area-context: '>= 4.0.0' + react-native-svg: '>= 15.0.0' + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + '@react-navigation/native': + optional: true + expo-application: + optional: true + expo-device: + optional: true + expo-file-system: + optional: true + expo-localization: + optional: true + posthog-react-native-session-replay: + optional: true + react-native-device-info: + optional: true + react-native-localize: + optional: true + react-native-navigation: + optional: true + react-native-safe-area-context: + optional: true + react-native-svg: + optional: true + preact@10.24.2: resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} @@ -4891,6 +4961,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rtl-detect@1.1.2: + resolution: {integrity: sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -5172,6 +5245,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-parser-js@0.7.41: + resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} + hasBin: true + uint8array-extras@1.5.0: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} @@ -7474,6 +7551,12 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.19.1': optional: true + '@posthog/core@1.29.9': + dependencies: + '@posthog/types': 1.376.0 + + '@posthog/types@1.376.0': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': @@ -9276,6 +9359,11 @@ snapshots: expo-dev-menu-interface: 56.0.1(expo@56.0.2) react-native: 0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3) + expo-device@56.0.4(expo@56.0.2): + dependencies: + expo: 56.0.2(@babel/core@7.29.0)(@expo/metro-runtime@56.0.10)(expo-router@56.2.4)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + ua-parser-js: 0.7.41 + expo-document-picker@56.0.3(expo@56.0.2): dependencies: expo: 56.0.2(@babel/core@7.29.0)(@expo/metro-runtime@56.0.10)(expo-router@56.2.4)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) @@ -9332,6 +9420,12 @@ snapshots: - expo - supports-color + expo-localization@56.0.6(expo@56.0.2)(react@19.2.3): + dependencies: + expo: 56.0.2(@babel/core@7.29.0)(@expo/metro-runtime@56.0.10)(expo-router@56.2.4)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + rtl-detect: 1.1.2 + expo-manifests@56.0.4(expo@56.0.2): dependencies: expo: 56.0.2(@babel/core@7.29.0)(@expo/metro-runtime@56.0.10)(expo-router@56.2.4)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) @@ -10663,6 +10757,19 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-react-native@4.45.14(@react-native-async-storage/async-storage@1.24.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3)))(expo-application@56.0.3(expo@56.0.2))(expo-device@56.0.4(expo@56.0.2))(expo-file-system@56.0.7(expo@56.0.2)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3)))(expo-localization@56.0.6(expo@56.0.2)(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)): + dependencies: + '@posthog/core': 1.29.9 + '@posthog/types': 1.376.0 + optionalDependencies: + '@react-native-async-storage/async-storage': 1.24.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3)) + expo-application: 56.0.3(expo@56.0.2) + expo-device: 56.0.4(expo@56.0.2) + expo-file-system: 56.0.7(expo@56.0.2)(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3)) + expo-localization: 56.0.6(expo@56.0.2)(react@19.2.3) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.3))(react@19.2.3) + preact@10.24.2: {} preact@10.29.2: {} @@ -10977,6 +11084,8 @@ snapshots: reusify@1.1.0: {} + rtl-detect@1.1.2: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -11265,6 +11374,8 @@ snapshots: typescript@6.0.3: {} + ua-parser-js@0.7.41: {} + uint8array-extras@1.5.0: {} unbash@3.0.0: {} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 3e384d1f..545c0bd4 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -15,8 +15,11 @@ import { ThemeProvider } from "expo-router/react-navigation"; import { useColorScheme } from "nativewind"; import { useEffect } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { PostHogProvider } from "posthog-react-native"; +import { AnalyticsIdentity } from "~/components/analytics-identity"; import { AuthProvider, useAuth } from "~/context/AuthContext"; import { OnboardingProvider } from "~/context/OnboardingContext"; +import { isPostHogConfigured, postHogHost } from "~/lib/analytics"; import { NAV_THEME } from "~/lib/theme"; // Fallback for the build phase. @@ -74,11 +77,23 @@ export default function RootLayout() { - - - - - + + + + + + + + + diff --git a/src/app/entry/new.tsx b/src/app/entry/new.tsx index 6b320faf..4039b7ba 100644 --- a/src/app/entry/new.tsx +++ b/src/app/entry/new.tsx @@ -49,6 +49,7 @@ import { SelectSheet } from "~/components/ui/select-sheet"; import { Text } from "~/components/ui/text"; import { Textarea } from "~/components/ui/textarea"; import { useAuth } from "~/context/AuthContext"; +import { useValidationAnalytics } from "~/lib/analytics"; import { getDayKey, parseDayKey, startOfLocalDay } from "~/lib/day-key"; import { goBackOrReplace, useBackIntent } from "~/lib/navigation"; import { ROUTES } from "~/lib/routes"; @@ -217,6 +218,7 @@ export default function NewEntryScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); const { user } = useAuth(); + const { capture } = useValidationAnalytics(); const { isAuthenticated: isConvexAuthenticated } = useConvexAuth(); const createDayEntry = useMutation(api.dayEntries.create); const params = useLocalSearchParams<{ @@ -346,6 +348,15 @@ export default function NewEntryScreen() { durationMinutes: resolvedDurationMinutes, ...(!isHomework ? { examTypeLabel: trimmedExamType } : {}), }); + capture(isHomework ? "homework_created" : "exam_created", { + entry_id: createdEntryId, + subject: trimmedSubject, + planned_day_key: nextDayKey, + duration_minutes: resolvedDurationMinutes, + ...(isHomework + ? { due_day_key: getDayKey(dueDate) } + : { exam_type_label: trimmedExamType }), + }); } finally { setIsCreating(false); } diff --git a/src/app/learning-plans/[planId]/generating.tsx b/src/app/learning-plans/[planId]/generating.tsx index de0db43f..a18d2bc8 100644 --- a/src/app/learning-plans/[planId]/generating.tsx +++ b/src/app/learning-plans/[planId]/generating.tsx @@ -12,6 +12,7 @@ import { useAuth } from "~/context/AuthContext"; import { AnalysisOrbitLoader } from "~/features/learning-plans/learning-plan-ui"; import type { LearningPlanSnapshot } from "~/features/learning-plans/types"; import { getErrorMessage } from "~/features/learning-plans/utils"; +import { useValidationAnalytics } from "~/lib/analytics"; import { goBackOrReplace } from "~/lib/navigation"; const planPath = (id: Id<"learningPlans">, step: string) => @@ -25,6 +26,7 @@ export default function LearningPlanGeneratingScreen() { const params = useLocalSearchParams<{ planId?: string }>(); const planId = params.planId as Id<"learningPlans"> | undefined; const { user } = useAuth(); + const { capture } = useValidationAnalytics(); const { isAuthenticated: isConvexAuthenticated } = useConvexAuth(); const generatePlan = useAction(api.learningPlanAi.generatePlan); const [isBusy, setIsBusy] = useState(false); @@ -82,6 +84,12 @@ export default function LearningPlanGeneratingScreen() { learningPlanId: planId, answers: answerList, }) + .then((result) => { + capture("study_plan_generated", { + learning_plan_id: planId, + session_count: result.sessionCount, + }); + }) .catch((error: unknown) => { setErrorMessage( getErrorMessage( @@ -93,7 +101,7 @@ export default function LearningPlanGeneratingScreen() { }) .finally(() => setIsBusy(false)); }); - }, [answerList, generatePlan, planId, retryAttempt, router, snapshot]); + }, [answerList, capture, generatePlan, planId, retryAttempt, router, snapshot]); useEffect(() => { return () => { diff --git a/src/app/learning-plans/new.tsx b/src/app/learning-plans/new.tsx index d932e9ff..7aafcff2 100644 --- a/src/app/learning-plans/new.tsx +++ b/src/app/learning-plans/new.tsx @@ -43,6 +43,7 @@ import { parseDateKey, retryOnceAfterAuthResume, } from "~/features/learning-plans/utils"; +import { useValidationAnalytics } from "~/lib/analytics"; import { goBackOrReplace } from "~/lib/navigation"; import { ROUTES } from "~/lib/routes"; import { ACCEPTED_FILE_TYPES, validateUploadFile } from "~/lib/upload-policy"; @@ -144,6 +145,7 @@ export default function NewLearningPlanScreen() { durationMinutes?: string; }>(); const { user } = useAuth(); + const { capture } = useValidationAnalytics(); const { isAuthenticated: isConvexAuthenticated } = useConvexAuth(); const startPlan = useMutation(api.learningPlans.start); const updateBasics = useMutation(api.learningPlans.updateBasics); @@ -299,6 +301,11 @@ export default function NewLearningPlanScreen() { fileSizeBytes, }), ); + capture("material_uploaded", { + learning_plan_id: id, + file_type: fileType, + file_size_bytes: fileSizeBytes, + }); }; const uploadMaterial = async () => { diff --git a/src/components/analytics-identity.tsx b/src/components/analytics-identity.tsx new file mode 100644 index 00000000..336fbb5a --- /dev/null +++ b/src/components/analytics-identity.tsx @@ -0,0 +1,54 @@ +import { useConvexAuth, useQuery } from "convex/react"; +import { usePostHog } from "posthog-react-native"; +import type React from "react"; +import { useEffect, useRef } from "react"; +import { api } from "#convex/_generated/api"; +import { useAuth } from "~/context/AuthContext"; +import { + definedAnalyticsProperties, + isPostHogConfigured, +} from "~/lib/analytics"; + +export function AnalyticsIdentity({ children }: { children: React.ReactNode }) { + const posthog = usePostHog(); + const { user, isSessionLoading } = useAuth(); + const { isAuthenticated: isConvexAuthenticated } = useConvexAuth(); + const convexUser = useQuery( + api.users.getMe, + user && isConvexAuthenticated ? {} : "skip", + ); + const identifiedClerkIdRef = useRef(null); + + useEffect(() => { + if (!isPostHogConfigured || isSessionLoading) return; + + if (!user) { + if (identifiedClerkIdRef.current) { + posthog.reset(); + identifiedClerkIdRef.current = null; + } + return; + } + + if (!isConvexAuthenticated || convexUser === undefined) return; + + posthog.identify( + user.clerkId, + definedAnalyticsProperties({ + clerk_id: user.clerkId, + convex_user_id: convexUser?._id, + email: user.email, + name: user.name, + birth_date: user.birthDate, + grade: user.grade, + school_type: user.schoolType, + state: user.state, + avatar_url: user.avatarUrl, + validation_student_code: convexUser?.validationStudentCode, + }), + ); + identifiedClerkIdRef.current = user.clerkId; + }, [convexUser, isConvexAuthenticated, isSessionLoading, posthog, user]); + + return children; +} diff --git a/src/components/ui/surface.tsx b/src/components/ui/surface.tsx index 4ee82c62..115ba2ed 100644 --- a/src/components/ui/surface.tsx +++ b/src/components/ui/surface.tsx @@ -64,4 +64,3 @@ function ActionSurface({ } export { ActionSurface, Surface }; -export type { SurfaceVariant }; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index a291de4f..d67c9657 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -44,6 +44,7 @@ type AuthUser = { schoolType?: string; state?: string; avatarUrl?: string; + validationStudentCode?: string; }; type AuthFlowResult = @@ -359,6 +360,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ schoolType: getMetadataString(unsafeMetadata, "schoolType"), state: getMetadataString(unsafeMetadata, "state"), avatarUrl: clerkUser.imageUrl, + validationStudentCode: getMetadataString( + unsafeMetadata, + "validationStudentCode", + ), }; }, [clerkUser]); @@ -388,6 +393,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ state: pendingProfile?.state ?? user.state, }), ...(user.avatarUrl !== undefined ? { avatarUrl: user.avatarUrl } : {}), + ...(user.validationStudentCode !== undefined + ? { validationStudentCode: user.validationStudentCode } + : {}), }; void syncCurrentUser(profile).catch(async (error: unknown) => { diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 00000000..1900cae3 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,66 @@ +import { usePostHog } from "posthog-react-native"; +import { useCallback } from "react"; +import { useAuth } from "~/context/AuthContext"; + +type ValidationEventName = + | "onboarding_completed" + | "homework_created" + | "exam_created" + | "study_plan_generated" + | "study_slot_started" + | "study_slot_completed" + | "study_slot_partially_completed" + | "study_slot_missed" + | "missed_reason_selected" + | "plan_adjusted" + | "user_returned_next_day" + | "material_uploaded" + | "generalprobe_completed"; + +type AnalyticsProperty = string | number | boolean | null; +type AnalyticsProperties = Record; +type AnalyticsPropertiesInput = Record< + string, + AnalyticsProperty | undefined +>; + +export const isPostHogConfigured = Boolean( + process.env.EXPO_PUBLIC_POSTHOG_API_KEY, +); + +export const postHogHost = + process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://eu.i.posthog.com"; + +export function definedAnalyticsProperties(properties: AnalyticsPropertiesInput) { + return Object.fromEntries( + Object.entries(properties).filter( + (entry): entry is [string, AnalyticsProperty] => entry[1] !== undefined, + ), + ); +} + +export function useValidationAnalytics() { + const posthog = usePostHog(); + const { user } = useAuth(); + const clerkId = user?.clerkId; + + const capture = useCallback( + (eventName: ValidationEventName, properties?: AnalyticsProperties) => { + if (!isPostHogConfigured || !clerkId) return; + posthog.identify(clerkId); + posthog.capture(eventName, { + ...properties, + clerk_id: clerkId, + }); + }, + [clerkId, posthog], + ); + + return { capture }; +} + +export type { + AnalyticsProperties, + AnalyticsPropertiesInput, + ValidationEventName, +};