treamz

Planning sourdough backwards in Swift, with quiet hours

How Proofday plans a sourdough bake backwards in Swift: a temperature model, a dynamic program on a 5-minute grid, quiet hours and Live Activity limits.

By 8 min readupdated

  • iOS
  • Swift
  • Algorithms
  • Scheduling
  • Live Activities

Proofday is an iPhone sourdough planner: you say when you want bread, and it works out when to feed the starter, mix, fold, shape and bake, adjusted for kitchen temperature and kept out of the hours you sleep. It's on the App Store, and the post for bakers covers what it does. This one is about how: the fermentation model, the dynamic program that plans backwards, how it refuses an impossible time, and the iOS limits that shaped the reminders.

The plans below come from running the app's planning package, a Swift package with no UI, on its Beginner Country Loaf recipe.

The fermentation model is one formula

Proofday's model says every 7 °C doubles or halves fermentation time, relative to 24 °C:

// FermentationConfig.swift
public var referenceTemperatureC: Double = 24
public var degreesPerDoubling: Double = 7
public var trustedTemperatureRangeC: ClosedRange<Double> = 16...30

// FermentationModel.swift
public func temperatureFactor(celsius: Double) -> Double {
    let t = clamp(temperatureC: celsius).value
    return pow(2, (config.referenceTemperatureC - t) / config.degreesPerDoubling)
}

Bulk is 5 hours at 24 °C with 20% levain, so 18 °C gives a factor of 1.81 and about 9 hours, and 30 °C gives 0.55 and 2.8 hours. Outside 16–30 °C the input is clamped and the plan carries a warning, so 34 °C plans exactly like 30 °C rather than trusting an exponential that far out.

The model's other constants, down to the starter peak table (5 to 14 hours at 24 °C, from 1:1:1 to 1:10:10 feeds), live in the same struct. Its doc comment calls them "starting heuristics, not lab science".

Learning from logged bakes

When a starter peaks or a bulk ends, the app folds actual over predicted into a per-starter moving average:

public mutating func record(actual: TimeInterval, baselinePrediction: TimeInterval,
                            config: FermentationConfig = .standard) -> Bool {
    guard actual > 0, baselinePrediction > 0 else { return false }
    let ratio = actual / baselinePrediction
    guard config.correctionSampleSanityRange.contains(ratio) else { return false }
    let alpha = config.correctionSmoothing
    let next = alpha * ratio + (1 - alpha) * value
    value = min(max(next, config.correctionRange.lowerBound), config.correctionRange.upperBound)
    sampleCount += 1
    return true
}

α is 0.3, the factor stays within 0.6–1.6, and a ratio outside 0.25–4 counts as a logging mistake. The part that matters is the argument name. Predictions are already multiplied by the factor, so a sample has to be compared with the uncorrected baseline. Compare with the corrected one and the average settles at the square root of the true factor: a starter that really takes 1.44 times as long gets learned as 1.2. So each feeding and each plan stores its uncorrected estimate up front.

Planning backwards is a shortest path

A recipe is a chain of typed steps. Each resolves into a minimum, ideal and maximum length, hands-on minutes, and the moments the baker acts: the step's start, each fold, and the oven preheat as a negative offset from the bake. Bulk doesn't flex at plan time; the aliquot jar decides when it really ends. The cold proof (8 to 24 hours), autolyse, bench rest, cooling and the levain do.

Rounding every length to a 5-minute grid turns the search into a dynamic program. The state is "step i starts at time t", filled in from the ready time backwards:

for i in stride(from: n - 1, through: 0, by: -1) {
    var next: [Int: Node] = [:]
    for t in levels[i + 1].keys.sorted(by: >) {
        let node = levels[i + 1][t]!
        for (vi, variant) in steps[i].enumerated() {
            let s = t - variant.duration
            if s < earliestStart { continue }
            let v = violations(of: variant, at: s, anchorExempt: false)
            if hard && v > 0 { continue }
            let cost = node.cost + variant.cost + Double(v) * violationCost
            if let existing = next[s], existing.cost <= cost { continue }
            next[s] = Node(cost: cost, violations: node.violations + v, link: t, variant: vi)
        }
    }
    if next.isEmpty { return nil }
    levels[i] = next
}

levels[n] holds the ready time at cost zero. A variant is one layout of a step: a length, plus a feed ratio for the levain or a preheat length for the bake. Keeping only the cheapest path into each boundary is exact because a step's cost and quiet-hours check depend only on its own start and length. On a tie the later start wins, so you wait less.

Quiet hours default to 23:00–07:00, and staying out of them has two shapes. An action is a point and may sit on the boundary, so a fold at 7:00 is fine. Hands-on minutes are a span and may not overlap at all, so a 15-minute shape has to start by 22:45.

Preferences are just prices

There's no rule saying "stretch the cold proof first". There are costs:

public var costPerMinute: Double {
    switch self {
    case .coldProof: 0.05
    case .levain: 0.15
    case .autolyse, .rest: 0.2
    case .cool, .roomProof: 0.3
    case .benchRest: 0.5
    default: 1
    }
}
// FermentationConfig
public var feedRatioSwitchCost: Double = 20
public var quietHoursViolationCost: Double = 10_000

A cold proof minute away from the ideal is the cheapest thing on the list, a third of a levain minute. A different feed ratio costs 20, as much as moving the cold proof 400 minutes. So a plan bridges the night with the fridge when it can, and reaches for a 1:5:5 or 1:10:10 overnight feed when that's cheaper.

For the Beginner Country Loaf at 23 °C, ready Saturday at 10:00, the 12 steps expand to 182 variants, 70 of them for the levain alone:

Fri 07:10  levain       465 min  1:2:2
Fri 14:55  autolyse      60 min
Fri 15:55  mix           15 min
Fri 16:10  bulk         330 min  folds 16:40, 17:10, 17:40, 18:10
Fri 21:40  preshape       5 min
Fri 21:45  bench rest    30 min
Fri 22:15  shape         15 min
Fri 22:30  cold proof   555 min  (ideal 720)
Sat 07:45  bake          20 min  preheat from 07:00
Sat 08:05  lid off       25 min
Sat 08:30  cool          90 min  (ideal 120)
Sat 10:00  ready

The preheat can't start before 7:00, which pins the bake and cuts cooling to 90 minutes; the plan says so in a note. On my M1 Pro, in a release build, that takes about a millisecond. The app still solves in a detached task, since the setup screen re-plans on every input change.

Rough edges: windows longer than two hours are searched in 15-minute steps, so the cold proof can't be 565 minutes and the levain starts at 7:10 rather than 7:00. And the cold proof used to stop at 16 hours, the figure I started from, which made dinner-time bread impossible with the default quiet hours. It now goes to 24: ready at 19:00 on Saturday means shaping at 22:45 on Friday and a 17 h 15 min proof.

Saying no, with a reason and a better time

When the hard solve fails, the planner works out why, in order: the target has passed, the step minimums don't fit, the target is inside quiet hours, or some action would be. For that last case it reruns the DP in soft mode, where a violation costs 10,000 instead of being forbidden, and reports the first action that lands in quiet hours. Then it walks a 15-minute grid outward, up to 48 hours each way, until a ready time solves.

From the package:

  • Saturday 9:00, asked on Thursday: the first violation is the preheat at 6:10 AM. Nearest: 9:30.
  • Saturday 10:00, asked Friday at 8 PM: the bake needs at least 21 h 20 min. Nearest: Sunday 9:30.
  • Sunday 9:30, asked Saturday at noon: shaping would land at 11:30 PM. Nearest: Monday 9:30. With "My starter is already ripe" on, Sunday 9:30 works, shaping at 7:30 PM.

The third looked like a bug. It isn't: with a levain to build, the earliest Saturday shape is about half an hour too late, and mixing on Sunday morning puts the bread after 11 PM. These refusals took roughly 3 to 13 ms, mostly the search for alternatives.

Re-planning while the dough is out

Done, +15 min and Need more time re-run the solver forwards from the current step, in soft mode, with one more price: each minute the ready time drifts from the promise costs 1, against 0.05 for a cold proof minute. In the plan above, asking for 30 more minutes of bulk at 9:30 PM keeps 10:00 and shortens the cold proof to 525 minutes. Asking for 45 can't be absorbed, because shaping would run from 23:00 to 23:15. The plan keeps 10:00 and flags that action as inside quiet hours rather than moving the bread.

Notifications, Live Activities and widgets

Every pending action is a local notification, rescheduled after any change. The triggers are UNTimeIntervalNotificationTrigger, elapsed seconds rather than calendar components, so a DST change can't move an absolute moment. A time-zone change re-solves the plan, since quiet hours follow the wall clock. Done, +15 min and Snooze 5 min carry no .foreground option, so iOS hands them to the app in the background.

Live Activities were the awkward part. One lasts up to eight hours, twelve on the Lock Screen, and Activity.request only works while the app is in the foreground. The Saturday plan runs almost 27 hours. So:

static let startWindow: TimeInterval = 7 * 3600

func sync(planID: UUID, schedule: BakeSchedule, presenter: ActionPresenter, now: Date = .now) async {
    guard !schedule.isComplete, let state = Self.state(for: schedule, presenter: presenter, now: now) else {
        await end(planID: planID)
        return
    }
    let content = ActivityContent(state: state, staleDate: state.nextDate.addingTimeInterval(30 * 60), relevanceScore: 100)
    if await Self.update(planID: planID, content: content) { return }
    guard ActivityAuthorizationInfo().areActivitiesEnabled,
          state.nextDate.timeIntervalSince(now) < Self.startWindow else { return }
    _ = try? Activity.request(
        attributes: BakeActivityAttributes(planID: planID, recipeName: schedule.recipeName),
        content: content, pushType: nil)
}

sync runs after every plan change and whenever the app comes to the foreground. It updates a running activity, or starts one when the next action is under seven hours away, inside the eight-hour limit. There's no push server, so the content changes only when the app's code runs, but the countdown keeps moving because it's Text(timerInterval:), drawn by the system. The catch, stated in the user docs: it appears once you open the app within seven hours of a step. iOS 26 added a request that takes a start date and starts the activity with the app in the background. Proofday supports iOS 17 and doesn't use it.

Its Done and +15 buttons are LiveActivityIntents, which run in the app's process, through a static bridge the app delegate fills at launch. Under Swift 6, Activity isn't Sendable, so its handles stay inside nonisolated helpers.

Widgets never open SwiftData: the app writes a Codable snapshot of the next seven actions to the App Group, and the timeline adds an entry as each comes due, so the Next bake step widget advances on its own.

App Attest for the one server call

Crumb Check, a photo analysis of the cut loaf, is the only server feature: a small proxy in front of a Claude model. Each install makes a key with DCAppAttestService and has Apple attest it once. After that, every request carries an assertion over a single-use challenge plus the SHA-256 of the body, and the server checks signature, counter and app ID (Apple's overview). It allows 30 checks per install per day and stores only public keys, challenges and counters.

What the app does for the people who use it: Sourdough schedule planner that works back from bread time.

FAQ

How do you schedule tasks backwards from a deadline with time windows?

Round durations to a grid and run a dynamic program from the deadline back: the state is "step i starts at time t", each step offers one choice per allowed length, choices that put an action in a blocked window are dropped, and the cheapest path into each boundary is kept. Proofday does this on a 5-minute grid.

How does Proofday estimate fermentation time at different temperatures?

It multiplies a duration at 24 °C by 2^((24 − T) / 7), so every 7 °C cooler doubles it: a 5-hour bulk becomes about 9 hours at 18 °C. It's trusted between 16 and 30 °C, and a per-starter factor learned from logged bakes scales it.

When can an iOS app start a Live Activity?

With Activity.request, only while the app is in the foreground, or from a LiveActivityIntent. From iOS 26 an app can also schedule one for a start date. A Live Activity lasts up to eight hours, then can stay on the Lock Screen for up to four more.

How do you keep local notifications correct across daylight saving time?

Use UNTimeIntervalNotificationTrigger with the seconds until the absolute moment, not calendar components, so a clock change can't shift it. If times follow the wall clock, re-plan and reschedule on NSSystemTimeZoneDidChange and UIApplication.significantTimeChangeNotification.

How do you learn a correction factor from logged data without bias?

Average the ratio of actual duration to the uncorrected prediction and multiply predictions by the result. Averaging against the already-corrected prediction settles at the square root of the true factor. Proofday uses an exponential moving average with weight 0.3, clamped to 0.6–1.6.

What should a scheduler do when a deadline is impossible?

Say why and offer the nearest time that works. Proofday finds the offending action by re-running its solver with quiet-hour violations penalised instead of forbidden, then searches a 15-minute grid up to 48 hours either side for the closest feasible ready time.

More writing