# 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.

- Author: [Ivan Chernoknizhnikov](https://treamz.me/), Flutter & iOS Engineer
- Published: 2026-11-09
- Updated: 2026-11-10
- URL: https://treamz.me/blog/planning-sourdough-backwards-in-swift
- Tags: 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](https://apps.apple.com/us/app/proofday-sourdough-planner/id6826169140),
and [the post for bakers](https://treamz.me/blog/sourdough-schedule-planner-app)
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:

```swift
// 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:

```swift
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:

```swift
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:

```swift
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](https://developer.apple.com/documentation/usernotifications/declaring-your-actionable-notification-types).

Live Activities were the awkward part. One
[lasts up to eight hours, twelve on the Lock Screen](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities),
and [`Activity.request`](https://developer.apple.com/documentation/activitykit/activity/request(attributes:content:pushtype:))
only works while the app is in the foreground. The Saturday plan runs almost 27
hours. So:

```swift
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](https://developer.apple.com/documentation/activitykit/activity/request(attributes:content:pushtype:style:alertconfiguration:start:))
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 `LiveActivityIntent`s, which
[run in the app's process](https://developer.apple.com/documentation/appintents/liveactivityintent),
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](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity)).
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](https://treamz.me/blog/sourdough-schedule-planner-app).

## 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.
