1.1.5: fix activation analytics blind spots + purchase environment tagging

- dish_added now carries a `source` param; onboarding-created dishes fire
  it with source="onboarding" (previously untracked, undercounting activation)
- onboarding_completed now reports dish_count
- premium_purchased tagged with StoreKit environment (production/sandbox/xcode)
  so test purchases can be excluded from conversion metrics
- add .gitignore to keep signing secrets (.p12/.cer/.mobileprovision) and
  build artifacts out of the repo
- document branch↔MARKETING_VERSION naming convention

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtWT52pAE91D7mbDda1cAM
This commit is contained in:
alexandrev-tibco
2026-07-02 13:26:45 +02:00
parent 95f43c7f23
commit 9cadc1ad18
5 changed files with 59 additions and 9 deletions
+22
View File
@@ -0,0 +1,22 @@
# Signing secrets — NEVER commit
*.p12
*.p8
*.cer
*.certSigningRequest
*.mobileprovision
# Build artifacts
*.ipa
*.dSYM.zip
/build/
DerivedData/
# Fastlane generated
fastlane/report.xml
fastlane/Preview.html
fastlane/test_output/
# macOS / Xcode
.DS_Store
*.xcuserstate
xcuserdata/
+9 -1
View File
@@ -4,9 +4,17 @@
- **Name**: MealMood: Family Meal Planner - **Name**: MealMood: Family Meal Planner
- **Bundle ID**: com.alexandrevazquez.mealmood - **Bundle ID**: com.alexandrevazquez.mealmood
- **Platform**: iOS/iPadOS (also runs as iOS app on Mac) - **Platform**: iOS/iPadOS (also runs as iOS app on Mac)
- **Current version**: 1.1.0 - **Current version**: 1.1.5
- **Main branch**: `1.0.1` (production), feature branches named after versions - **Main branch**: `1.0.1` (production), feature branches named after versions
### Branch ↔ version convention (IMPORTANT)
The active dev branch is named after the **current** `MARKETING_VERSION`, not the one it was created with. Whenever the iOS app version changes, **rename the branch to match**:
```bash
# after bumping MARKETING_VERSION in the Xcode project to e.g. 1.1.6
git branch -m <old-version> 1.1.6 # e.g. git branch -m 1.1.5 1.1.6
```
Right now: `MARKETING_VERSION = 1.1.5` → active branch is `1.1.5`. Keep them in sync. (The build number bump within the same version does NOT rename the branch — only a version change does.)
## Credentials — all managed via `pass` (GPG-encrypted, syncs to Gitea) ## Credentials — all managed via `pass` (GPG-encrypted, syncs to Gitea)
Never hardcode credentials. Always read from `pass`: Never hardcode credentials. Always read from `pass`:
+9 -6
View File
@@ -58,8 +58,11 @@ enum AnalyticsService {
// MARK: - Convenience methods // MARK: - Convenience methods
static func logDishAdded(tagCount: Int) { static func logDishAdded(tagCount: Int, source: String = "editor") {
logEvent(AnalyticsEvent.dishAdded, parameters: ["tag_count": tagCount]) logEvent(AnalyticsEvent.dishAdded, parameters: [
"tag_count": tagCount,
"source": source
])
} }
static func logDishDeleted() { static func logDishDeleted() {
@@ -86,16 +89,16 @@ enum AnalyticsService {
logEvent(AnalyticsEvent.premiumUpgradeTapped, parameters: ["source": source]) logEvent(AnalyticsEvent.premiumUpgradeTapped, parameters: ["source": source])
} }
static func logPremiumPurchased() { static func logPremiumPurchased(environment: String = "unknown") {
logEvent(AnalyticsEvent.premiumPurchased) logEvent(AnalyticsEvent.premiumPurchased, parameters: ["environment": environment])
} }
static func logPremiumRestored() { static func logPremiumRestored() {
logEvent(AnalyticsEvent.premiumRestored) logEvent(AnalyticsEvent.premiumRestored)
} }
static func logOnboardingCompleted() { static func logOnboardingCompleted(dishCount: Int) {
logEvent(AnalyticsEvent.onboardingCompleted) logEvent(AnalyticsEvent.onboardingCompleted, parameters: ["dish_count": dishCount])
} }
static func logOnboardingStepViewed(step: Int) { static func logOnboardingStepViewed(step: Int) {
+12 -1
View File
@@ -81,7 +81,7 @@ final class StoreManager: ObservableObject {
if case .verified(let transaction) = verification { if case .verified(let transaction) = verification {
await transaction.finish() await transaction.finish()
isPremium = true isPremium = true
AnalyticsService.logPremiumPurchased() AnalyticsService.logPremiumPurchased(environment: Self.environmentName(for: transaction))
return .success return .success
} }
return .failed return .failed
@@ -181,6 +181,17 @@ final class StoreManager: ObservableObject {
return false return false
} }
/// Maps a transaction's StoreKit environment to an analytics-friendly string
/// so sandbox/Xcode test purchases can be excluded from real conversion metrics.
static func environmentName(for transaction: StoreKit.Transaction) -> String {
switch transaction.environment {
case .production: return "production"
case .sandbox: return "sandbox"
case .xcode: return "xcode"
default: return "unknown"
}
}
private struct TimeoutError: Error {} private struct TimeoutError: Error {}
private func loadProductsWithRetries(maxAttempts: Int = 3) async throws -> [Product] { private func loadProductsWithRetries(maxAttempts: Int = 3) async throws -> [Product] {
@@ -147,6 +147,7 @@ final class OnboardingViewModel: ObservableObject {
context.insert(dish) context.insert(dish)
} }
let createdDishes = !addedDishes.isEmpty let createdDishes = !addedDishes.isEmpty
let onboardingDishCount = addedDishes.count
// Create first week plan // Create first week plan
let weekStart = Date().startOfWeek() let weekStart = Date().startOfWeek()
@@ -154,7 +155,12 @@ final class OnboardingViewModel: ObservableObject {
do { do {
try context.save() try context.save()
AnalyticsService.logOnboardingCompleted() // Dishes created inside onboarding didn't previously fire dish_added,
// so activation looked lower than it was. Emit one per dish, tagged.
for dishData in addedDishes {
AnalyticsService.logDishAdded(tagCount: dishData.tagIds.count, source: "onboarding")
}
AnalyticsService.logOnboardingCompleted(dishCount: onboardingDishCount)
// If the user added dishes, auto-fill their first week on launch so they // If the user added dishes, auto-fill their first week on launch so they
// land on a ready plan instead of an empty one. Otherwise fall back to the // land on a ready plan instead of an empty one. Otherwise fall back to the
// home auto-assign prompt. // home auto-assign prompt.