Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/plans/ab-testing-onboarding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Plan — A/B testing for onboarding and UX experiments

- **Status:** Proposed
- **Effort:** Medium
- **Firebase services:** Remote Config (conditions), A/B Testing, Analytics

## Overview

Use Firebase A/B Testing to drive split experiments off Remote Config conditions. First experiment: onboarding flow variants (current `Intro` vs. shorter two-screen variant). Follow-ups: compass fuzziness level, claim empty-state CTA copy.

## Initial experiments

1. **Onboarding length** — Control: current James intro. Variant: 2-screen condensed. Primary metric: `sign_up_complete` event. Minimum detectable effect: 5 pp uplift.
2. **Compass granularity** — Control: 8 sectors. Variant: 4 sectors (very fuzzy). Metric: `claim_success` per session.
3. **Empty state CTA** — Control: "No postboxes nearby". Variant: "James is on his rounds... try down the road". Metric: `nearby_retry` event.

## Technical approach

- Depends on the Remote Config plan being merged first.
- Add analytics events: `sign_up_complete`, `claim_success`, `nearby_retry`, `onboarding_step_viewed`.
- Declare Remote Config params `exp_onboarding_variant`, `exp_compass_sectors`, `exp_empty_copy` as strings with defaults.
- Use condition-based overrides configured in the Firebase console under A/B Testing.
- Client reads the active variant and branches UI accordingly; log a `experiment_exposure` event with variant name for downstream slicing.

## Files affected

- `lib/intro.dart`, `lib/nearby.dart`, `lib/fuzzy_compass.dart` — variant branches.
- `lib/services/analytics_service.dart` — new event helpers.

## Rollout

- Experiments are paused by default; enable one at a time with 10 % traffic for 48 h, then ramp.
- Document a runbook: how to conclude an experiment and promote the winning variant to 100 %.

## Risks

- Small DAU → long test duration. Pre-register minimum sample size per experiment.
- Cross-experiment interaction — avoid running overlapping experiments on the same surface.
11 changes: 7 additions & 4 deletions lib/claim.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,14 @@ class _ClaimState extends State<Claim> with TickerProviderStateMixin {
postboxes[entry.key as String] =
Map<String, dynamic>.from(entry.value as Map);
}
// Cloud Functions serialise JS numbers as either int or double;
// assigning a double to a typed int field throws, so normalise via num.
int asInt(dynamic v) => (v as num?)?.toInt() ?? 0;
setState(() {
_count = counts['total'] ?? 0;
_maxPoints = points['max'] ?? 0;
_minPoints = points['min'] ?? 0;
_claimedToday = counts['claimedToday'] ?? 0;
_count = asInt(counts['total']);
_maxPoints = asInt(points['max']);
_minPoints = asInt(points['min']);
_claimedToday = asInt(counts['claimedToday']);
_postboxes = postboxes;
currentStage = _count > 0 ? ClaimStage.results : ClaimStage.empty;
});
Expand Down
19 changes: 11 additions & 8 deletions lib/nearby.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,23 +113,26 @@ class _NearbyState extends State<Nearby> {
final claimedCompass = data['claimedCompass'] != null
? Map<String, dynamic>.from(data['claimedCompass'] as Map)
: <String, dynamic>{};
// Cloud Functions serialise JS numbers as either int or double; assigning
// a double to a typed int field throws, so normalise every count via num.
int asInt(dynamic v) => (v as num?)?.toInt() ?? 0;
setState(() {
_count = counts['total'] ?? 0;
_maxPoints = pts['max'] ?? 0;
_minPoints = pts['min'] ?? 0;
_count = asInt(counts['total']);
_maxPoints = asInt(pts['max']);
_minPoints = asInt(pts['min']);
currentStage = NearbyStage.results;
for (final dir in const [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE',
'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW',
'W', 'WNW', 'NW', 'NNW',
]) {
_compassCounts[dir] = compass[dir] ?? 0;
_claimedCompassCounts[dir] = claimedCompass[dir] ?? 0;
_compassCounts[dir] = asInt(compass[dir]);
_claimedCompassCounts[dir] = asInt(claimedCompass[dir]);
}
_claimedToday = counts['claimedToday'] ?? 0;
_claimedToday = asInt(counts['claimedToday']);
for (final cipher in MonarchInfo.all) {
_cipherTotals[cipher] = counts[cipher] ?? 0;
_cipherClaimed[cipher] = counts['${cipher}_claimed'] ?? 0;
_cipherTotals[cipher] = asInt(counts[cipher]);
_cipherClaimed[cipher] = asInt(counts['${cipher}_claimed']);
}
_lastScanned = DateTime.now();
});
Expand Down
6 changes: 4 additions & 2 deletions lib/wear/wear_claim_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ class _WearClaimPageState extends State<WearClaimPage> {
});
if (!mounted) return;
final counts = result.data['counts'] ?? {};
final total = (counts['total'] as int?) ?? 0;
final claimed = (counts['claimedToday'] as int?) ?? 0;
// Cloud Functions serialise JS numbers as either int or double; `as int?`
// would throw on a double, so normalise via num.
final total = (counts['total'] as num?)?.toInt() ?? 0;
final claimed = (counts['claimedToday'] as num?)?.toInt() ?? 0;
_postboxes = Map<String, dynamic>.from(result.data['postboxes'] ?? {});
setState(() {
_count = total;
Expand Down
17 changes: 14 additions & 3 deletions lib/wear/wear_compass_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,13 @@ class _WearCompassPageState extends State<WearCompassPage> {
final counts = result.data['counts'] ?? {};
final compassRaw = result.data['compass'] ?? {};
setState(() {
_totalCount = (counts['total'] as int?) ?? 0;
_compassCounts = Map<String, int>.from(compassRaw);
// Cloud Functions serialise JS numbers as either int or double;
// `as int?` would throw on a double, so normalise via num.
_totalCount = (counts['total'] as num?)?.toInt() ?? 0;
_compassCounts = {
for (final e in (compassRaw as Map).entries)
e.key as String: (e.value as num).toInt(),
};
_stage = _CompassStage.results;
});
// Haptic pulse to signal scan complete.
Expand Down Expand Up @@ -206,7 +211,13 @@ class _WearCompassPageState extends State<WearCompassPage> {
angle: -rotation,
child: CustomPaint(
size: Size(size, size),
painter: FuzzyCompassPainter(sectors: sectorValues),
// Pass rotation so the painter counter-rotates the 'N' label;
// otherwise it spins with the canvas and is unreadable whenever
// the watch is not pointing north.
painter: FuzzyCompassPainter(
sectors: sectorValues,
rotation: rotation,
),
),
),
// Centre count overlay (doesn't rotate)
Expand Down
8 changes: 6 additions & 2 deletions lib/wear/wear_status_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ class WearStatusPage extends StatefulWidget {
class _WearStatusPageState extends State<WearStatusPage> {
final StreakService _streakService = StreakService();
late final Stream<int?> _streakStream = _streakService.streakStream();
// Cache so the StreamBuilder doesn't re-subscribe (and briefly show empty
// stats) on every rebuild. Computed once in initState from the uid at mount.
late final Stream<DocumentSnapshot<Map<String, dynamic>>>? _userDocStream =
_buildUserDocStream();

String? get _displayName => FirebaseAuth.instance.currentUser?.displayName;

Stream<DocumentSnapshot<Map<String, dynamic>>>? _userDocStream() {
Stream<DocumentSnapshot<Map<String, dynamic>>>? _buildUserDocStream() {
final uid = FirebaseAuth.instance.currentUser?.uid;
if (uid == null) return null;
return FirebaseFirestore.instance.collection('users').doc(uid).snapshots();
Expand Down Expand Up @@ -71,7 +75,7 @@ class _WearStatusPageState extends State<WearStatusPage> {

// Lifetime points
StreamBuilder<DocumentSnapshot<Map<String, dynamic>>>(
stream: _userDocStream(),
stream: _userDocStream,
builder: (context, snap) {
final data = snap.data?.data();
final lifetimePoints =
Expand Down
Loading