Workstation4 / Blog / Swift
Swift SwiftData Feature Data Model

Going from four card types to eight.

Sequence shipped with four kinds of cards: Countdown, Count Up, Info, and Repetition. I wanted eight. Adding Rest, Interval, Checklist, and Random Duration sounds like a tidy feature checklist, but it turned into a small test of how well the data model held up, and where the app had quietly leaked assumptions about what a card could be.

A card is a type, not a bag of fields

The decision that made this expansion manageable was made long before it: every card in a sequence is modeled with a type-safe enum that carries its configuration as associated data, rather than a struct full of optional fields that may or may not matter depending on some mode flag.

enum CardKind: Codable, Equatable, Hashable, Sendable {
    case countdown(CountdownConfig)
    case countup(CountUpConfig)
    case info(InfoConfig)
    case repetition(RepetitionConfig)
    case rest(RestConfig)
    case interval(IntervalConfig)
    case checklist(ChecklistConfig)
    case randomDuration(RandomDurationConfig)
}

Each config is its own small value type, and each one is deliberately strict. An IntervalConfig holds workSeconds, restSeconds, and rounds, and its initializer clamps every one of them to at least 1. A RandomDurationConfig goes further: it clamps minSeconds up to 1, then clamps maxSeconds to be at least the resolved minimum, so a range can never come out inverted no matter what the editor hands it.

That strictness cuts both ways. It means you cannot construct a nonsensical card, like a checklist with a countdown duration but no items. It also means adding a new case is not a one-line change. The compiler turns the addition into a guided tour: every switch over CardKind refuses to build until you handle the new case. That is the cost, and it is also the safety net.

The full-stack pass

Adding one type meant touching every layer that knows a card exists. I walked the same path four times, once per new type. The commit ended up spanning twenty-four files, and the boring layers were the ones it would have been easiest to forget:

  • The domain config that defines what the type holds.
  • The SwiftData persistence mapping that stores and rehydrates it.
  • Export and import, so routines stay portable across versions.
  • The sequence engine that advances through cards at runtime.
  • The editor UI for building and tuning a card.
  • The player that runs it, including color and progress rendering.
  • The Live Activity and widgets that mirror the current card on the Lock Screen.
  • Built-in templates, so the new types show up in starting points.
  • The AI prompt builder, which has to teach the model the type exists at all.

The persistence layer is a good example of why an enum with associated values needs a deliberate bridge. SwiftData stores each card as a string discriminator plus a blob of encoded config, so encode and decode are a matched pair of switch statements. Decoding is the side that bites you if you skip it: an unknown discriminator falls through to kind = nil, which means a card that persisted fine but was never taught to the decoder would simply vanish on the next launch.

case "randomDuration":
    if let config = try? decoder.decode(RandomDurationConfig.self, from: configData) {
        kind = .randomDuration(config)
    } else {
        kind = nil
    }

None of those layers is hard on its own. The discipline is in not skipping one. A type that persists but has no editor is a dead feature. A type the engine understands but the Live Activity does not becomes a blank space on the Lock Screen. The enum-driven model is what kept me honest: forget a layer and something, usually the compiler, gently complains.

Interval and the expansion trap

Interval was the type that bent the model, and untangling it was the most interesting part of the whole pass. Most cards are one card: you define it, it runs once. Interval is different. You author a single work and rest pairing plus a round count, and at playback time that one definition auto-expands into a flat sequence of paired countdown cards. One Interval the user wrote becomes many cards the player actually walks through.

I chose to do the expansion once, in the runner's initializer, and keep the original around for reference. The expansion is a pure static function: it walks the authored cards, and whenever it sees an interval, it emits a Countdown for work and a Rest for each round, titled so the player reads naturally ("Sprints - Work 2/8").

static func expandIntervalCards(in sequence: SequenceDefinition) -> SequenceDefinition {
    var expandedCards: [CardDefinition] = []
    for card in sequence.cards {
        if case .interval(let config) = card.kind {
            for round in 1...config.rounds {
                expandedCards.append(CardDefinition(
                    title: "\(card.title) - Work \(round)/\(config.rounds)",
                    kind: .countdown(CountdownConfig(
                        durationSeconds: config.workSeconds, autoComplete: true))))
                expandedCards.append(CardDefinition(
                    title: "\(card.title) - Rest \(round)/\(config.rounds)",
                    kind: .rest(RestConfig(
                        durationSeconds: config.restSeconds, autoComplete: true))))
            }
        } else {
            expandedCards.append(card)
        }
    }
    return /* expanded definition */
}

That expansion is convenient for the person building a HIIT routine and awkward for everything that counts cards. My first cut expanded the interval correctly at runtime but reported the wrong total in the player, because the progress readout was reading sequence.cards.count, the authored list, while the engine was advancing through runner.sequence.cards, the expanded one. The player walked the right work and rest rounds, but the "3 of 4" readout, the progress dots, and the up-next preview all disagreed with reality.

The fix was to make the runner own both lists explicitly. It keeps originalSequence for reference and exposes sequence as the expanded, runtime truth, and the player was changed to read counts and previews from runner.sequence everywhere, right down to the Live Activity's totalCards. The lesson was about where the boundary between authored and runtime should sit.

If a type can expand at runtime, every count in the UI has to ask the runtime, not the author.

State that only exists while you run

Checklist and Random Duration introduced a second new idea: cards whose state is real only during playback and is never persisted to the model. A Random Duration card resolves to a concrete length the moment it starts, and a Checklist remembers which items you have ticked, but neither belongs in the saved sequence definition. So the runner keeps that transient state in dictionaries keyed by the card's UUID, seeded lazily when a card becomes current:

private(set) var checkedItems: [UUID: Set<Int>] = [:]
private(set) var randomizedDurations: [UUID: Int] = [:]

private func initializeCardStateIfNeeded(for card: CardDefinition) {
    switch card.kind {
    case .randomDuration(let config):
        if randomizedDurations[card.id] == nil {
            randomizedDurations[card.id] = Int.random(in: config.minSeconds...config.maxSeconds)
        }
    case .checklist:
        if checkedItems[card.id] == nil { checkedItems[card.id] = [] }
    default:
        break
    }
}

The nil check matters. The randomized duration is computed once and then frozen, so pausing and resuming, or rehydrating from a background snapshot, never re-rolls the dice and changes the timer out from under you. The runner's remainingSeconds reads that stored value rather than the config range, which is exactly what you want once the number has been chosen.

Checklist and the feel of instant

Checklist had no expansion problem; it had a perception problem. Tapping an item to mark it done needs to feel instant, and the first version had a beat of lag between the tap and the green checkmark appearing. On a routine where you are tapping through a list of things to do, that small delay reads as the app being unsure of itself.

The cause was subtle: the runner is a plain reference type, not an observable object, so mutating checkedItems did not by itself tell SwiftUI to redraw. The button's action toggles the item, plays a tap sound, and then nudges a small @State value the view already watches for its timer:

Button {
    runner.toggleChecklistItem(at: index)
    feedback.playChecklistTap()
    timerTick = Date()   // force the view to re-read runner state now
} label: {
    Image(systemName: checked.contains(index)
        ? "checkmark.circle.fill" : "circle")
        .foregroundStyle(checked.contains(index) ? .green : .secondary)
    // ...
}

Binding the checkmark to the same tick the timer already drives means the visual state flips on the same frame as the tap, not a redraw later. Once the tap and the checkmark were tied that tightly, the list felt more like paper you cross items off than a form you submit.

What I took away

Doubling the card types was less a feature sprint than a quiet stress test of decisions I had already made. A few things stuck with me:

  • A type-safe config enum turns a risky expansion into a compiler-guided checklist. You pay for it up front and collect every time you add a case, with the persistence decode being the one branch worth auditing by hand.
  • Authored structure and runtime structure stop being the same thing the moment a type expands. Pick one as the source of truth for counts and progress, expose it explicitly, and make every readout ask it.
  • Transient runtime state keyed by ID, seeded once, is what lets Random Duration stay frozen and Checklist stay sticky across pauses without polluting the saved model.
  • Some correctness lives in feel. A checklist that flips on the tap frame and one that lags by a redraw are functionally identical and experientially different.

Eight types feels like the right number for now. The part I am most glad about is that the next type, whenever I want it, should be a known walk through known seams rather than a guess about what I forgot.

W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →