posts

Numbers Up, Lessons Mixed 📊

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

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:

This month:

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. There were no purchases made, so I’m marking this experiment as done. 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. I’ll report on this later…

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.
App version flow

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 not matching data types.

Here’s the code:

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:

Mar 6, 2026 shipping notes
New app launched but basically no users
February 2026 recap and what I'll be doing in March.