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,
+};