Skip to main content

Command Palette

Search for a command to run...

Numbers Up, Lessons Mixed 📊

New users nearly 5x'd in Camp Notes, plus a technical trick to keep everyone on the latest version.

Published
•3 min read

This past month, I really focused at the numbers in Camp Notes and looked at how people weren’t making accounts, which means they wouldn’t be able to save their trips. I decided I needed to redo onboarding in a way that gave them more information upfront and set them up to make an account.

Numbers

Last month:

  • MRR: $48

  • Track New Users: 61

  • iHog New Users: 21

  • Camp Notes New Users: 17

This month:

  • MRR: $51

  • Track New Users: 36

  • iHog New Users: 36

  • Camp Notes New Users: 80

Camp Notes really popped off with new users and a trial was started 🚀 These numbers moving seem to just be flukes and I can't really ties them to anything that caused increases or decreases.

Experiment Report

In March, I made the paywall open immediately after the user adds their first task on Track. I'm also not sure if it was properly implemented as in Analytics, I'm not seeing any opens for the paywall on task completion.

Technical spotlight

I made a few updates to Camp Notes and to Track that forces or nudges the user to update. I should have added this to the apps from the get go, but the next best time is now. Here's how it works:

  1. User launches app

  2. Service checks if there is an update from the iTunes API

  3. Both apps release with a X.Y, which is crucial. If X changes, then the user is presented a not dismissible full screen cover to tell the user to upgrade. If Y changes, then the user is going to see a sheet that asks the user to update, but can dismiss it.

This allows me to keep users on the most up to date versions which when running a NOSQL database like Camp Notes does it's important so the database is consistent as possible for all users. It also makes less crashes for this. Here's the code on how I did this:

struct AppVersion: Comparable {
    let major: Int
    let minor: Int
    
    init?(_ string: String) {
        let parts = string.split(separator: ".").compactMap { Int($0) }
        guard parts.count >= 2 else { return nil }
        self.major = parts[0]
        self.minor = parts[1]
    }
    
    static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
        if lhs.major != rhs.major { return lhs.major < rhs.major }
        return lhs.minor < rhs.minor
    }
}

enum UpdateType {
    case major
    case minor
    case none
}

@Observable
@MainActor
class AppUpdateService {
    var updateType: UpdateType = .none
    var storeURL: URL?

    private let bundleID: String
    private let currentVersion: AppVersion?

    init() {
        self.bundleID = Bundle.main.bundleIdentifier ?? ""
        let versionString = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
        self.currentVersion = AppVersion(versionString)
    }

    func checkForUpdate() async {
        guard let current = currentVersion,
              let url = URL(string: "https://itunes.apple.com/lookup?bundleId=\(bundleID)") else { return }
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let json = try JSONDecoder().decode(iTunesResponse.self, from: data)
            guard let result = json.results.first,
                  let store = AppVersion(result.version) else { return }
            storeURL = URL(string: result.trackViewUrl)
            if store.major > current.major {
                updateType = .major
            } else if store.minor > current.minor {
                updateType = .minor
            }
        } catch {
            print("Update check failed: \(error)")
        }
    }
}

This month's focus

In April, I am focusing on the following:

  • Camp Notes onboarding A/B test and new screenshots

  • Track will get some bug fixes

  • Continue pushing JABA forward

  • Still figuring out how to make time for marketing 😅