chore(launch): reconcile README/store live-status + add demo-funnel analytics (#111)
Two scoped changes for the no-spend growth launch (Growth Launch Kit,
Notion page 3a1ac25eb49f81099cc9f3a4286c8ec4):
1. README.md and distribution/play-listing.md said Google Play was
"coming soon" / internal-testing-only, while distribution/retention-analysis.md
and the live play.google.com listing show it's actually public with 1K+
installs. Fixed the contradiction, added Google Play as a third install
channel, and added an accurate mention of the new offline demo mode
("Try a Demo" — reasoning, grep, diff, permission prompt, ~30s, no server)
matching what app/demo.tsx + src/lib/demo-script.ts actually render.
play-listing.md's stale pre-launch checklists are marked historical
instead of rewritten, so #83's ASO copy/keyword work is untouched.
2. Added the demo funnel's key metric (demo-completion, per the launch
kit) as four consent-gated PostHog events: demo_started,
demo_step_advanced, demo_completed, demo_exited_to_connect. Pure
property-derivation logic lives in src/lib/demo-analytics.ts (no
RN/PostHog imports, unit-tested with node --test, same pattern as
analytics-classify.ts) and is wired into app/demo.tsx's lifecycle.
Updated docs/analytics.md's event table and the privacy policy's event
list (distribution/privacy-policy.md + its two HTML mirrors) per the
repo's "new event requires a policy update" convention.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
29
README.md
29
README.md
@@ -1,12 +1,12 @@
|
||||
# OpenCode Mobile
|
||||
|
||||
**The open-source Android client for the [opencode](https://github.com/sst/opencode) AI coding agent.**
|
||||
AI-assisted coding from your phone — Android, via F-Droid or a direct APK.
|
||||
AI-assisted coding from your phone — Android, via Google Play, F-Droid, or a direct APK.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://dzianisv.github.io/opencode-mobile/fdroid/repo)
|
||||
[](https://github.com/dzianisv/opencode-mobile/releases/latest)
|
||||
[](#)
|
||||
[](https://play.google.com/store/apps/details?id=cc.agentlabs.opencode)
|
||||
|
||||
> **Not affiliated with opencode.** OpenCode Mobile is an independent, community-built client and is
|
||||
> not made by, endorsed by, or affiliated with the opencode / Anomaly team. It talks to an opencode
|
||||
@@ -14,20 +14,26 @@ AI-assisted coding from your phone — Android, via F-Droid or a direct APK.
|
||||
|
||||
---
|
||||
|
||||
**New: tap "Try a Demo" in the app to see the agent fix a real bug — reasoning, a grep, a diff, a permission prompt — in about 30 seconds, no server needed.**
|
||||
|
||||
---
|
||||
|
||||
## Install (Android)
|
||||
|
||||
There are **two working ways** to install OpenCode Mobile today, both for Android:
|
||||
There are **three working ways** to install OpenCode Mobile today, all for Android:
|
||||
|
||||
1. **F-Droid (recommended)** — add our self-hosted repo to any F-Droid client, then install/update from there:
|
||||
1. **Google Play** — **https://play.google.com/store/apps/details?id=cc.agentlabs.opencode**
|
||||
|
||||
2. **F-Droid (self-hosted repo)** — add our self-hosted repo to any F-Droid client, then install/update from there:
|
||||
```
|
||||
https://dzianisv.github.io/opencode-mobile/fdroid/repo
|
||||
```
|
||||
In the F-Droid app: **Settings → Repositories → + (add)** and paste the URL above. Current version: **v0.4.3**.
|
||||
|
||||
2. **Direct signed APK** — download the latest release and install it manually:
|
||||
3. **Direct signed APK** — download the latest release and install it manually:
|
||||
**https://github.com/dzianisv/opencode-mobile/releases/latest**
|
||||
|
||||
> iOS is not available (see [Roadmap](#roadmap)). Google Play is in internal testing only (no public listing yet). IzzyOnDroid submission is pending.
|
||||
> iOS is not available (see [Roadmap](#roadmap)). IzzyOnDroid submission is pending.
|
||||
|
||||
---
|
||||
|
||||
@@ -45,6 +51,7 @@ OpenCode Mobile is a React Native / Expo app that brings the power of the [openc
|
||||
|
||||
## Features
|
||||
|
||||
- **Offline demo mode** — tap "Try a Demo" to see a full bug-fix walkthrough (reasoning → grep → diff → permission prompt) with zero setup, right from the empty state
|
||||
- **Multi-connection** — manage multiple opencode servers (local network, Cloudflare Tunnel, ngrok, or Tailscale)
|
||||
- **Biometric unlock** — Face ID, Touch ID, or Android fingerprint protects the app and individual message sends
|
||||
- **Streaming chat** — token-by-token streaming responses directly from your opencode server
|
||||
@@ -61,18 +68,20 @@ Package: `cc.agentlabs.opencode` · Android only · current version v0.4.3
|
||||
|
||||
| Channel | Status | How |
|
||||
|---|---|---|
|
||||
| **Google Play** | **Live** | [play.google.com/store/apps/details?id=cc.agentlabs.opencode](https://play.google.com/store/apps/details?id=cc.agentlabs.opencode) |
|
||||
| **F-Droid (self-hosted repo)** | **Live** | Add [`https://dzianisv.github.io/opencode-mobile/fdroid/repo`](https://dzianisv.github.io/opencode-mobile/fdroid/repo) in your F-Droid client |
|
||||
| **Direct APK** | **Live** | [github.com/dzianisv/opencode-mobile/releases/latest](https://github.com/dzianisv/opencode-mobile/releases/latest) |
|
||||
| Google Play | Internal testing / coming soon | No public listing yet |
|
||||
| IzzyOnDroid | Submission pending | Not live yet |
|
||||
| Apple App Store / iOS | Not available | See [Roadmap](#roadmap) |
|
||||
|
||||
> The two live, supported install channels are the **F-Droid self-hosted repo** and the **direct signed APK**, both Android. Google Play is internal-testing only, IzzyOnDroid is pending, and there is no iOS build.
|
||||
> The three live, supported install channels are **Google Play**, the **F-Droid self-hosted repo**, and the **direct signed APK**, all Android. IzzyOnDroid is pending, and there is no iOS build.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Don't have a server yet?** Install the app and tap **Try a Demo** on the Sessions screen first — no setup required. It plays back a scripted bug-fix session through the app's real chat, diff, and permission-approval UI, offline, in about 30 seconds.
|
||||
|
||||
**Step 1 — Start opencode on your machine**
|
||||
|
||||
```bash
|
||||
@@ -83,7 +92,7 @@ npm install -g opencode
|
||||
OPENCODE_SERVER_PASSWORD=yourpassword opencode serve --hostname 0.0.0.0 --port 4096
|
||||
```
|
||||
|
||||
**Step 2 — Install OpenCode Mobile** via the [F-Droid self-hosted repo or direct APK](#install-android) (or build from source — see [CONTRIBUTING.md](CONTRIBUTING.md)).
|
||||
**Step 2 — Install OpenCode Mobile** via [Google Play, F-Droid, or a direct APK](#install-android) (or build from source — see [CONTRIBUTING.md](CONTRIBUTING.md)).
|
||||
|
||||
**Step 3 — Add a connection in the app**
|
||||
|
||||
@@ -132,6 +141,8 @@ OpenCode Mobile is a thin client. It speaks the opencode HTTP + SSE API: listing
|
||||
|
||||
| Feature | Status |
|
||||
|---|---|
|
||||
| Offline demo mode | Stable |
|
||||
| First-run onboarding clarity | Stable |
|
||||
| Multi-connection management | Stable |
|
||||
| Session list + creation | Stable |
|
||||
| Streaming chat | Stable |
|
||||
|
||||
31
app/demo.tsx
31
app/demo.tsx
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, useColorScheme, Linking } from "react-native"
|
||||
import { Stack, useRouter } from "expo-router"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
@@ -6,6 +6,13 @@ import { useTranslation } from "react-i18next"
|
||||
import { MessageBubble, PermissionPrompt } from "../src/components/chat"
|
||||
import { buildDemoScript, buildDemoCompletionMessage, buildDemoDenialMessage } from "../src/lib/demo-script"
|
||||
import { SETUP_GUIDE_URL } from "../src/lib/links"
|
||||
import { track, AnalyticsEvent } from "../src/lib/analytics"
|
||||
import {
|
||||
demoStepAdvancedProps,
|
||||
demoCompletedOutcome,
|
||||
demoExitedToConnectProps,
|
||||
type DemoPermissionReply,
|
||||
} from "../src/lib/demo-analytics"
|
||||
|
||||
// Fully offline, scripted walkthrough for installers with no self-hosted
|
||||
// opencode server. ISOLATION: every message/part below is hardcoded local
|
||||
@@ -22,12 +29,26 @@ export default function DemoScreen() {
|
||||
|
||||
// Built once per mount from pure, hardcoded data (src/lib/demo-script.ts).
|
||||
const script = useMemo(() => buildDemoScript(), [])
|
||||
const [reply, setReply] = useState<"once" | "always" | "reject" | null>(null)
|
||||
const [reply, setReply] = useState<DemoPermissionReply | null>(null)
|
||||
|
||||
const handleReply = useCallback((r: "once" | "always" | "reject") => {
|
||||
setReply(r)
|
||||
// Fires once per screen mount — this is a fully offline, consent-gated
|
||||
// event (track() is a no-op without telemetry consent, same as every
|
||||
// other analytics call site).
|
||||
useEffect(() => {
|
||||
track(AnalyticsEvent.DemoStarted)
|
||||
}, [])
|
||||
|
||||
const handleReply = useCallback((r: DemoPermissionReply) => {
|
||||
setReply(r)
|
||||
track(AnalyticsEvent.DemoStepAdvanced, demoStepAdvancedProps(r))
|
||||
track(AnalyticsEvent.DemoCompleted, { outcome: demoCompletedOutcome(r) })
|
||||
}, [])
|
||||
|
||||
const handleConnectPress = useCallback(() => {
|
||||
track(AnalyticsEvent.DemoExitedToConnect, demoExitedToConnectProps(reply !== null))
|
||||
router.push("/connection/add")
|
||||
}, [reply, router])
|
||||
|
||||
const completion = useMemo(() => {
|
||||
if (!reply) return null
|
||||
return reply === "reject" ? buildDemoDenialMessage() : buildDemoCompletionMessage()
|
||||
@@ -59,7 +80,7 @@ export default function DemoScreen() {
|
||||
<Text style={[s.ctaSubtitle, isDark && s.metaDark]}>{t("demo.ctaSubtitle")}</Text>
|
||||
<TouchableOpacity
|
||||
style={s.connectButton}
|
||||
onPress={() => router.push("/connection/add")}
|
||||
onPress={handleConnectPress}
|
||||
testID="demo-connect-button"
|
||||
>
|
||||
<Text style={s.connectButtonText}>{t("demo.connectButton")}</Text>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# Google Play Store Listing — OpenCode Mobile
|
||||
|
||||
Copy-paste reference for completing the Play Console store listing for `cc.agentlabs.opencode`.
|
||||
Copy-paste reference for the Play Console store listing for `cc.agentlabs.opencode`.
|
||||
Paste these values directly into Play Console. Fields are validated against Play limits.
|
||||
|
||||
> **Status: live.** The app is publicly available on the production track —
|
||||
> https://play.google.com/store/apps/details?id=cc.agentlabs.opencode (1K+ installs;
|
||||
> see `distribution/retention-analysis.md`). The "First release strategy" and "Pending
|
||||
> before first publish" sections below are kept as a historical record of how it got
|
||||
> there — do not read them as the current state.
|
||||
|
||||
---
|
||||
|
||||
## Main store listing
|
||||
@@ -322,25 +328,28 @@ Bump this file before tagging a release. Keep it under 500 chars. Per-language v
|
||||
|
||||
---
|
||||
|
||||
## First release strategy
|
||||
## First release strategy (historical — completed)
|
||||
|
||||
1. **Internal testing** track first (up to 100 testers, no review) — what CI is wired for.
|
||||
1. **Internal testing** track first (up to 100 testers, no review) — what CI was wired for.
|
||||
2. **Closed testing** — 14+ days, 12+ testers required for new org accounts before promoting to production (Google's 2023 policy).
|
||||
3. **Open testing** — optional intermediate step.
|
||||
4. **Production** — only after Closed testing requirements met.
|
||||
|
||||
CI currently publishes to `internal` track. ✅
|
||||
Result: the app cleared this path and is now live on the production track (see the
|
||||
status note at the top of this file). CI's default dispatch track (`internal`) is
|
||||
still used for routine test builds; production releases are dispatched explicitly
|
||||
(`track=production`) — see `.github/workflows/publish-play-store.yml`.
|
||||
|
||||
---
|
||||
|
||||
## Pending before first publish
|
||||
## Pending before first publish (historical — resolved)
|
||||
|
||||
- [ ] Identity verification (government ID upload, Google review)
|
||||
- [ ] App icon (real PNG, not placeholder)
|
||||
- [ ] Adaptive icon (real PNG)
|
||||
- [ ] Feature graphic 1024×500
|
||||
- [ ] At least 2 phone screenshots
|
||||
- [ ] Privacy policy live at https://dzianisv.github.io/opencode-mobile/privacy/
|
||||
- [ ] Decide pricing model
|
||||
- [ ] Run IARC content rating questionnaire (after app created in Play Console)
|
||||
- [ ] Complete Data safety form (after app created in Play Console)
|
||||
- [x] Identity verification (government ID upload, Google review)
|
||||
- [x] App icon (real PNG, not placeholder)
|
||||
- [x] Adaptive icon (real PNG)
|
||||
- [x] Feature graphic 1024×500
|
||||
- [x] At least 2 phone screenshots
|
||||
- [x] Privacy policy live at https://dzianisv.github.io/opencode-mobile/privacy/
|
||||
- [x] Decide pricing model (free, no ads, no IAP)
|
||||
- [x] Run IARC content rating questionnaire (after app created in Play Console)
|
||||
- [x] Complete Data safety form (after app created in Play Console)
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<header>
|
||||
<h1>OpenCode Mobile — Privacy Policy</h1>
|
||||
<p class="meta">
|
||||
Effective date: 2026-05-24 |
|
||||
Effective date: 2026-07-18 |
|
||||
Operator: VIBE TECHNOLOGIES, LLC |
|
||||
App: OpenCode Mobile (<code>cc.agentlabs.opencode</code>)
|
||||
</p>
|
||||
@@ -232,6 +232,27 @@
|
||||
<td>An agent response finishes</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>demo_started</code></td>
|
||||
<td>You open the offline "Try a Demo" screen (no server, no network)</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>demo_step_advanced</code></td>
|
||||
<td>You reply to the demo's scripted permission prompt</td>
|
||||
<td><code>step_index</code>, <code>step_name</code>, <code>reply</code> ("once", "always", or
|
||||
"reject")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>demo_completed</code></td>
|
||||
<td>The scripted demo reaches its end</td>
|
||||
<td><code>outcome</code> ("completed" or "denied")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>demo_exited_to_connect</code></td>
|
||||
<td>You tap "Connect your own server" on the demo's CTA</td>
|
||||
<td><code>reached_completion</code> (true/false)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -239,7 +260,8 @@
|
||||
What analytics events <strong>never</strong> contain: your server URL, hostname, IP address,
|
||||
or port; prompts, messages, or AI responses; code or file contents; tokens or credentials;
|
||||
raw error messages. Connection failures are reduced to a fixed list of coarse categories
|
||||
before being sent.
|
||||
before being sent. The demo screen is fully offline and hardcoded — these events describe
|
||||
interaction with the scripted walkthrough, never real session content.
|
||||
</p>
|
||||
<p>
|
||||
Analytics data is sent to PostHog's <strong>EU region</strong> (<code>eu.i.posthog.com</code>)
|
||||
@@ -462,7 +484,7 @@
|
||||
|
||||
<footer>
|
||||
© 2026 VIBE TECHNOLOGIES, LLC. OpenCode Mobile is MIT-licensed open-source software.
|
||||
Privacy policy effective 2026-05-24.
|
||||
Privacy policy effective 2026-07-18.
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenCode Mobile — Privacy Policy
|
||||
|
||||
**Effective date:** 2026-05-24
|
||||
**Effective date:** 2026-07-18
|
||||
**Operator:** VIBE TECHNOLOGIES, LLC
|
||||
**App:** OpenCode Mobile (`cc.agentlabs.opencode`)
|
||||
|
||||
@@ -63,8 +63,12 @@ Events collected, with their only properties:
|
||||
| `connection_failed` | The connection test fails | `source`, `error_class` (a coarse category such as "timeout" or "unauthorized" — never the raw error text) |
|
||||
| `message_sent` | You send a message to an agent session | — |
|
||||
| `response_received` | An agent response finishes | — |
|
||||
| `demo_started` | You open the offline "Try a Demo" screen (no server, no network) | — |
|
||||
| `demo_step_advanced` | You reply to the demo's scripted permission prompt | `step_index`, `step_name`, `reply` ("once", "always", or "reject") |
|
||||
| `demo_completed` | The scripted demo reaches its end | `outcome` ("completed" or "denied") |
|
||||
| `demo_exited_to_connect` | You tap "Connect your own server" on the demo's CTA | `reached_completion` (true/false) |
|
||||
|
||||
What analytics events **never** contain: your server URL, hostname, IP address, or port; prompts, messages, or AI responses; code or file contents; tokens or credentials; raw error messages. Connection failures are reduced to a fixed list of coarse categories before being sent.
|
||||
What analytics events **never** contain: your server URL, hostname, IP address, or port; prompts, messages, or AI responses; code or file contents; tokens or credentials; raw error messages. Connection failures are reduced to a fixed list of coarse categories before being sent. The demo screen is fully offline and hardcoded — these events describe interaction with the scripted walkthrough, never real session content.
|
||||
|
||||
Analytics data is sent to PostHog's **EU region** (`eu.i.posthog.com`) and is identified only by a random, app-generated anonymous ID — not linked to your name, email, or any account.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ and reach first value (message sent → response received)?** Nothing else is tr
|
||||
| Destination | PostHog **EU region** — `https://eu.i.posthog.com` (override: `EXPO_PUBLIC_POSTHOG_HOST`) |
|
||||
| API key | `EXPO_PUBLIC_POSTHOG_KEY` (CI secret; unset ⇒ analytics is a strict no-op) |
|
||||
| Identity | PostHog's random app-generated anonymous ID only; no `identify()` calls, no user IDs |
|
||||
| Code | `src/lib/analytics.ts` (wrapper), `src/lib/analytics-classify.ts` (error bucketing), `src/lib/telemetry.ts` (consent gate) |
|
||||
| Code | `src/lib/analytics.ts` (wrapper), `src/lib/analytics-classify.ts` (error bucketing), `src/lib/demo-analytics.ts` (demo-funnel property derivation), `src/lib/telemetry.ts` (consent gate) |
|
||||
|
||||
## Event schema
|
||||
|
||||
@@ -42,6 +42,10 @@ section 3a of `distribution/privacy-policy.md`.
|
||||
| `connection_failed` | Health check fails | `source`, `error_class` | `src/stores/connections.ts` |
|
||||
| `message_sent` | User sends a prompt to an agent session (excludes slash commands) | — | `src/stores/sessions.ts` |
|
||||
| `response_received` | Agent response finishes streaming (busy → idle), excluding user-aborted runs | — | `src/stores/events.ts` |
|
||||
| `demo_started` | The offline `/demo` screen mounts (no server, no network) | — | `app/demo.tsx` |
|
||||
| `demo_step_advanced` | User advances a step in the scripted demo (currently: replies to the demo's permission prompt) | `step_index`, `step_name`, `reply` (`"once"` \| `"always"` \| `"reject"`) | `app/demo.tsx`, `src/lib/demo-analytics.ts` |
|
||||
| `demo_completed` | The scripted demo reaches its end (completion or denial message shown) — the key demo activation metric | `outcome` (`"completed"` \| `"denied"`) | `app/demo.tsx`, `src/lib/demo-analytics.ts` |
|
||||
| `demo_exited_to_connect` | User taps "Connect your own server" on the demo's CTA card | `reached_completion` (boolean) | `app/demo.tsx` |
|
||||
|
||||
`error_class` is one of a fixed enum — `malformed-url`, `no-internet`, `server-unreachable`,
|
||||
`unauthorized`, `tls-error`, `timeout`, `unknown` (`src/lib/analytics-classify.ts`). The raw
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
<header>
|
||||
<h1>OpenCode Mobile — Privacy Policy</h1>
|
||||
<p class="meta">
|
||||
Effective date: 2026-05-24 |
|
||||
Effective date: 2026-07-18 |
|
||||
Operator: VIBE TECHNOLOGIES, LLC |
|
||||
App: OpenCode Mobile (<code>cc.agentlabs.opencode</code>)
|
||||
</p>
|
||||
@@ -239,6 +239,27 @@
|
||||
<td>An agent response finishes</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>demo_started</code></td>
|
||||
<td>You open the offline "Try a Demo" screen (no server, no network)</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>demo_step_advanced</code></td>
|
||||
<td>You reply to the demo's scripted permission prompt</td>
|
||||
<td><code>step_index</code>, <code>step_name</code>, <code>reply</code> ("once", "always", or
|
||||
"reject")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>demo_completed</code></td>
|
||||
<td>The scripted demo reaches its end</td>
|
||||
<td><code>outcome</code> ("completed" or "denied")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>demo_exited_to_connect</code></td>
|
||||
<td>You tap "Connect your own server" on the demo's CTA</td>
|
||||
<td><code>reached_completion</code> (true/false)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -246,7 +267,8 @@
|
||||
What analytics events <strong>never</strong> contain: your server URL, hostname, IP address,
|
||||
or port; prompts, messages, or AI responses; code or file contents; tokens or credentials;
|
||||
raw error messages. Connection failures are reduced to a fixed list of coarse categories
|
||||
before being sent.
|
||||
before being sent. The demo screen is fully offline and hardcoded — these events describe
|
||||
interaction with the scripted walkthrough, never real session content.
|
||||
</p>
|
||||
<p>
|
||||
Analytics data is sent to PostHog's <strong>EU region</strong> (<code>eu.i.posthog.com</code>)
|
||||
@@ -469,7 +491,7 @@
|
||||
|
||||
<footer>
|
||||
© 2026 VIBE TECHNOLOGIES, LLC. OpenCode Mobile is MIT-licensed open-source software.
|
||||
Privacy policy effective 2026-05-24.<br>
|
||||
Privacy policy effective 2026-07-18.<br>
|
||||
<a href="https://dzianisv.github.io/opencode-mobile/">Home</a> ·
|
||||
<a href="https://dzianisv.github.io/opencode-mobile/guide/">Setup guide</a> ·
|
||||
<a href="https://github.com/dzianisv/opencode-mobile">GitHub</a>
|
||||
|
||||
@@ -85,6 +85,17 @@ export enum AnalyticsEvent {
|
||||
/** An agent response finished streaming (session transitioned busy -> idle),
|
||||
* excluding user-aborted runs. */
|
||||
ResponseReceived = "response_received",
|
||||
/** Fired once when the offline `/demo` screen mounts. */
|
||||
DemoStarted = "demo_started",
|
||||
/** User advanced a step in the scripted demo (currently: replied to the
|
||||
* demo's permission prompt). Always paired with `step_index`/`step_name`. */
|
||||
DemoStepAdvanced = "demo_step_advanced",
|
||||
/** The scripted demo reached its end (completion or denial message shown
|
||||
* after the permission reply). The key activation metric for the demo —
|
||||
* see distribution/retention-analysis.md. Always paired with `outcome`. */
|
||||
DemoCompleted = "demo_completed",
|
||||
/** User tapped "Connect your own server" on the demo's CTA card. */
|
||||
DemoExitedToConnect = "demo_exited_to_connect",
|
||||
}
|
||||
|
||||
/** Where a connection test/failure was initiated from. The activation funnel
|
||||
|
||||
41
src/lib/demo-analytics.test.ts
Normal file
41
src/lib/demo-analytics.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
DEMO_STEP_PERMISSION_REPLIED,
|
||||
demoStepAdvancedProps,
|
||||
demoCompletedOutcome,
|
||||
demoExitedToConnectProps,
|
||||
} from "./demo-analytics.ts"
|
||||
|
||||
test("demoStepAdvancedProps: includes the step index/name constant and the reply", () => {
|
||||
const props = demoStepAdvancedProps("once")
|
||||
assert.equal(props.step_index, DEMO_STEP_PERMISSION_REPLIED.index)
|
||||
assert.equal(props.step_name, DEMO_STEP_PERMISSION_REPLIED.name)
|
||||
assert.equal(props.reply, "once")
|
||||
})
|
||||
|
||||
test("demoStepAdvancedProps: reply is passed through verbatim for each variant", () => {
|
||||
assert.equal(demoStepAdvancedProps("always").reply, "always")
|
||||
assert.equal(demoStepAdvancedProps("reject").reply, "reject")
|
||||
})
|
||||
|
||||
test("demoStepAdvancedProps: properties are flat primitives only (no nested objects)", () => {
|
||||
const props = demoStepAdvancedProps("once")
|
||||
for (const value of Object.values(props)) {
|
||||
assert.ok(["string", "number", "boolean"].includes(typeof value) || value === null)
|
||||
}
|
||||
})
|
||||
|
||||
test("demoCompletedOutcome: reject maps to denied", () => {
|
||||
assert.equal(demoCompletedOutcome("reject"), "denied")
|
||||
})
|
||||
|
||||
test("demoCompletedOutcome: once and always map to completed", () => {
|
||||
assert.equal(demoCompletedOutcome("once"), "completed")
|
||||
assert.equal(demoCompletedOutcome("always"), "completed")
|
||||
})
|
||||
|
||||
test("demoExitedToConnectProps: reflects whether the demo reached completion first", () => {
|
||||
assert.deepEqual(demoExitedToConnectProps(true), { reached_completion: true })
|
||||
assert.deepEqual(demoExitedToConnectProps(false), { reached_completion: false })
|
||||
})
|
||||
38
src/lib/demo-analytics.ts
Normal file
38
src/lib/demo-analytics.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Pure derivation logic for the offline demo funnel's analytics properties
|
||||
// (app/demo.tsx). Kept free of RN/PostHog imports — same pattern as
|
||||
// analytics-classify.ts — so it is unit-testable with plain `node --test`
|
||||
// and safe to import from the fully-offline demo screen.
|
||||
import type { AnalyticsProps } from "./analytics"
|
||||
|
||||
/** Mirrors PermissionPrompt's `onReply` reply values (see
|
||||
* src/components/chat/PermissionPrompt.tsx). */
|
||||
export type DemoPermissionReply = "once" | "always" | "reject"
|
||||
|
||||
/** The demo funnel currently has exactly one step that "advances" past the
|
||||
* initial script view: replying to the scripted permission prompt. Kept as
|
||||
* a named constant (rather than inlined) so a future step can be added
|
||||
* without hunting for magic numbers at each call site. */
|
||||
export const DEMO_STEP_PERMISSION_REPLIED = { index: 1, name: "permission_replied" } as const
|
||||
|
||||
/** Properties for `demo_step_advanced` when the user replies to the
|
||||
* scripted permission prompt. `reply` is a flat enum, not free text. */
|
||||
export function demoStepAdvancedProps(reply: DemoPermissionReply): AnalyticsProps {
|
||||
return {
|
||||
step_index: DEMO_STEP_PERMISSION_REPLIED.index,
|
||||
step_name: DEMO_STEP_PERMISSION_REPLIED.name,
|
||||
reply,
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome bucket for `demo_completed` — never the raw completion/denial
|
||||
* text, just whether the scripted permission was allowed or denied. */
|
||||
export function demoCompletedOutcome(reply: DemoPermissionReply): "completed" | "denied" {
|
||||
return reply === "reject" ? "denied" : "completed"
|
||||
}
|
||||
|
||||
/** Properties for `demo_exited_to_connect`. The Connect CTA is reachable
|
||||
* before the user has replied to the permission prompt, so this records
|
||||
* whether they'd reached the end of the scripted flow first. */
|
||||
export function demoExitedToConnectProps(reachedCompletion: boolean): AnalyticsProps {
|
||||
return { reached_completion: reachedCompletion }
|
||||
}
|
||||
Reference in New Issue
Block a user