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:
+22
@@ -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/
|
||||||
@@ -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`:
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user