Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/test-baseline.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"_comment": "Floor for how much testing this repo has. Raised by scripts/check-test-baseline.mjs when you add tests; lowering it is a deliberate, reviewable edit.",
"unit": 938,
"unit": 940,
"e2e": 90
}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Time-anchored Neapolitan pizza dough calculator. User picks **when to bake**; ev

Schedule: `preferment-mix → prep → autolyse → mix → bulk → divide → final-proof → ready`, computed backwards from `readyBy` (with `ballProof: 'cold'` the cold leg becomes a `proof-cold` step after divide instead of `bulk-cold` before it). Cold-mode `final-proof` is a single 4 h "balls on the counter from fridge to bake" phase (`COLD_FINAL_PROOF_MIN = 240`) — the previous separate `warmup` step was folded in at v3 with no change to the equivalent-hours sum (both legs were at room temperature).

- **Cold vs room is deterministic on available time, not a toggle.** Window ≥ 16 h → cold-bulk at `fridgeTempC`; else room.
- **Cold vs room is deterministic on available time, not a toggle.** Window ≥ 16 h → cold-bulk at `fridgeTempC`; else room. The fork is the whole of `computeSchedule`'s branching: it reads the inputs, picks the mode, then hands off to **`coldSchedule()` or `roomSchedule()`** in `schedule.ts`, which each return a `FermentPlan` (`yeastPercent`, `steps`, and the two cold-bulk signals). Room returns **null** for both signals — that is how `quality.ts` tells which leg ran, so it is a contract and pinned as one.
- **Autolyse** (v5): a fixed `AUTOLYSE_MIN = 30` flour+water rest between `prep` and `mix`, inserted **only when no pre-ferment is used** (`preFerments.length === 0 && inputs.autolyse` — a biga/poolish already rests the flour). Strict autolyse: `prep` weighs flour+water alone, the rest carries **no yeast**, and salt+yeast (plus any oil/sugar) are held back to `mix`. Because no yeast is present during the rest it is **not a fermentation leg** — like the mix minutes it shifts the ferment budget deterministically (30 min of the fixed window becomes non-fermenting, so the solved yeast rises slightly). Threaded through both room and cold fixed spans and the night-window cluster offsets. Default **on** (incl. beginner mode); expert-only opt-out toggle, shown when no pre-ferment is effectively enabled (sourdough always qualifies — its starter is not a schedule pre-ferment). Passive step: hollow node / dashed rail, `TRANSP:TRANSPARENT` in `.ics`, `isActiveStep` false.
- **Pre-ferments are real fermentation phases, and biga + poolish can run together** (v4). `DoughInputs.preFerments` is a list (empty = none, canonical biga-first order, each flour share in [5, 80] %, Σ ≤ 80 %). Biga ~14 h / poolish ~12 h at 22 °C, Q10-scaled, clamped [8, 24] h (`prefermentDurationHours` in `fermentation.ts`). All pre-ferments mature **in parallel and end at `prep`**: the schedule reserves the longest and emits one `preferment-mix` step per entry (longest-first, stamped with `ScheduleStep.preFermentType` — copy, row keys and `.ics` UIDs all branch on it). For fresh-yeast recipes the pre-ferments carry **all the yeast**, split proportional to flour share — no fresh yeast on baking day.
- **Pre-ferment ⊥ sourdough.** `effectivePreFerments` in `schedule.ts` empties the list when `yeastType === 'sourdough'`; InputForm hides the toggles. URL state retains the choice so toggling is reversible.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "knead-time",
"version": "6.10.9",
"version": "6.10.10",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
34 changes: 34 additions & 0 deletions src/lib/dough/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1494,3 +1494,37 @@ describe('computeSchedule — warning thresholds', () => {
expect(infeasible.warnings).not.toContain('yeast-tiny');
});
});

// The window is spent by one of two legs — coldSchedule or roomSchedule — and
// the cold-bulk signals are how the rest of the app tells which ran. quality.ts
// reads them directly, so "room reports neither" is a contract, not an
// implementation detail. Nothing checked it while the two legs were one
// function with a shared pair of `let`s above them.
describe('the cold-bulk signals say which leg ran', () => {
it('room mode reports no cold-bulk figures at all', () => {
const r = computeSchedule(
defaultInputs({
startAt: new Date('2026-05-12T09:00:00Z'),
readyBy: new Date('2026-05-12T19:00:00Z')
})
);
expect(r.mode).toBe('room');
expect(r.naturalColdBulkMin).toBeNull();
expect(r.desiredColdBulkMin).toBeNull();
expect(r.steps.some((step) => step.kind === 'bulk-cold' || step.kind === 'proof-cold')).toBe(
false
);
});

it('cold mode reports both, with the natural value capped at the 48 h ceiling', () => {
const r = computeSchedule(
defaultInputs({
startAt: new Date('2026-05-10T09:00:00Z'),
readyBy: new Date('2026-05-12T19:00:00Z')
})
);
expect(r.mode).toBe('cold');
expect(r.desiredColdBulkMin).not.toBeNull();
expect(r.naturalColdBulkMin).toBe(Math.min(COLD_BULK_CEIL_MIN, r.desiredColdBulkMin as number));
});
});
269 changes: 170 additions & 99 deletions src/lib/dough/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,159 @@ function effectivePreFerments(inputs: DoughInputs): PreFermentSpec[] {
return inputs.preFerments;
}

/** One pre-ferment's wall-clock duration, as the schedule reserves it. */
interface PrefermentDuration {
type: PreFermentSpec['type'];
min: number;
}

// Everything both legs need to work out. Assembled once by computeSchedule,
// which owns the reading of the inputs; the legs only decide how the time is
// spent, which is the part that differs between them.
interface PlanArgs {
inputs: DoughInputs;
totalAvailableMin: number;
prefermentDurationsMin: PrefermentDuration[];
/** The longest pre-ferment — they mature in parallel and all end at prep. */
naturalPrefermentMin: number;
/** Flour-share-weighted fermentation phases for a set of pre-ferment durations. */
prefermentPhases: (durations: PrefermentDuration[]) => FermentPhase[];
mixDurationMin: number;
autolyseMin: number;
}

// What a leg decides: how much yeast, and where every step falls. The two
// cold-bulk figures are the pre-shift, pre-clamp values the recipe-fit score
// measures the delivered schedule against — room mode has no cold leg, so it
// reports null for both.
interface FermentPlan {
yeastPercent: number;
steps: ScheduleStep[];
naturalColdBulkMin: number | null;
desiredColdBulkMin: number | null;
}

// Cold: a fixed frame — prep, autolyse, mix, an hour of room bulk, divide, and
// four hours on the counter at the end — with one variable leg in the fridge
// soaking up whatever time is left.
function coldSchedule({
inputs,
totalAvailableMin,
prefermentDurationsMin,
naturalPrefermentMin,
prefermentPhases,
mixDurationMin,
autolyseMin
}: PlanArgs): FermentPlan {
const fixedMin =
PREP_MIN +
autolyseMin +
mixDurationMin +
COLD_INITIAL_BULK_MIN +
DIVIDE_MIN +
COLD_FINAL_PROOF_MIN;
// Cold mode is only entered when totalAvailable − naturalPreferment ≥ 16 h,
// so this is always ≥ ~10 h — the pre-ferment-overflow branch room mode
// carries cannot fire here.
const desired = totalAvailableMin - fixedMin - naturalPrefermentMin;
// Cap cold-bulk at the 48 h ceiling. We do NOT clamp UP to the 12 h floor —
// that would pull the schedule's first step before startAt, which the
// contract forbids. Sub-floor cold-bulk is allowed, and flagged by the
// recipe-fit score instead.
const naturalColdMin = Math.min(COLD_BULK_CEIL_MIN, desired);
const ballProofCold = inputs.ballProof === 'cold';
const coldMin = adjustColdMinForNight(
inputs.readyBy,
naturalColdMin,
prefermentDurationsMin.map((d) => d.min),
mixDurationMin,
autolyseMin,
ballProofCold
);

return {
yeastPercent: yeastPercentForPhases(inputs.yeastType, [
...prefermentPhases(prefermentDurationsMin),
{ hours: (COLD_INITIAL_BULK_MIN + COLD_FINAL_PROOF_MIN) / 60, tempC: inputs.roomTempC },
{ hours: coldMin / 60, tempC: inputs.fridgeTempC }
]),
steps: buildSteps({
readyBy: inputs.readyBy,
prefermentDurationsMin,
mixDurationMin,
bulkRoomMin: COLD_INITIAL_BULK_MIN,
bulkColdMin: coldMin,
ballProofCold,
finalProofMin: COLD_FINAL_PROOF_MIN,
autolyseMin
}),
naturalColdBulkMin: naturalColdMin,
desiredColdBulkMin: desired
};
}

// Room: no fridge leg, so the whole window minus the hands-on steps and the
// pre-ferments is fermentation, split between bulk and final proof.
function roomSchedule({
inputs,
totalAvailableMin,
prefermentDurationsMin,
naturalPrefermentMin,
prefermentPhases,
mixDurationMin,
autolyseMin
}: PlanArgs): FermentPlan {
const roomFixedMin = PREP_MIN + autolyseMin + mixDurationMin + DIVIDE_MIN;
const fermentBudget = totalAvailableMin - roomFixedMin - naturalPrefermentMin;

let durations = prefermentDurationsMin;
let bulkMin: number;
let finalProofMin: number;

if (fermentBudget >= 0) {
// Pre-ferments fit; the rest goes to bulk + final-proof in roughly a 2:1
// ratio. Both can shrink to 0 if the window is very tight.
finalProofMin = Math.min(90, Math.floor(fermentBudget / 3));
bulkMin = fermentBudget - finalProofMin;
} else {
// The longest pre-ferment alone overflows. Cap every pre-ferment at the
// wall budget so first-step >= startAt still holds — shorter ones may fit
// untouched. Bulk and final-proof are 0.
//
// Degenerate exception: when the window is shorter than the fixed hands-on
// steps themselves (totalAvailableMin < roomFixedMin), the budget bottoms
// out at 0 but prep + mix + divide keep their physical durations anchored
// to readyBy — the first step lands BEFORE startAt. We never compress fixed
// steps or slip readyBy; instead the schedule is honest about being
// infeasible: roomFixedMin is always well below ROOM_MIN_TOTAL_MIN, so the
// 'too-short' warning (feasible = false, quality.ts's 'infeasible' factor)
// has already fired by the time this runs.
const budget = Math.max(0, totalAvailableMin - roomFixedMin);
durations = durations.map((d) => ({ ...d, min: Math.min(d.min, budget) }));
bulkMin = 0;
finalProofMin = 0;
}

return {
yeastPercent: yeastPercentForPhases(inputs.yeastType, [
...prefermentPhases(durations),
{ hours: (bulkMin + finalProofMin) / 60, tempC: inputs.roomTempC }
]),
steps: buildSteps({
readyBy: inputs.readyBy,
prefermentDurationsMin: durations,
mixDurationMin,
bulkRoomMin: bulkMin,
bulkColdMin: null,
ballProofCold: false,
finalProofMin,
autolyseMin
}),
naturalColdBulkMin: null,
desiredColdBulkMin: null
};
}

export function computeSchedule(inputs: DoughInputs): ComputedSchedule {
const preFerments = effectivePreFerments(inputs);
const mixDurationMin = mixMin(inputs.mixingMethod);
Expand All @@ -189,7 +342,7 @@ export function computeSchedule(inputs: DoughInputs): ComputedSchedule {
// Pre-ferments mature wherever the user says they do — a 17 °C cellar
// biga runs much longer than a countertop one. null follows the room.
const prefermentTempC = inputs.preFermentTempC ?? inputs.roomTempC;
let prefermentDurationsMin = preFerments.map((pf) => ({
const prefermentDurationsMin: PrefermentDuration[] = preFerments.map((pf) => ({
type: pf.type,
min: Math.round(prefermentDurationHours(pf.type, prefermentTempC) * 60)
}));
Expand All @@ -199,7 +352,7 @@ export function computeSchedule(inputs: DoughInputs): ComputedSchedule {
// it carries: eq = Σ wᵢ · hoursᵢ · f(T). With a single pre-ferment w = 1,
// which reproduces the pre-v4 solve exactly.
const totalShare = preFerments.reduce((sum, pf) => sum + pf.flourPercent, 0);
const prefermentPhases = (durations: typeof prefermentDurationsMin): FermentPhase[] =>
const prefermentPhases = (durations: PrefermentDuration[]): FermentPhase[] =>
durations.map((d, i) => ({
hours: (preFerments[i].flourPercent / totalShare) * (d.min / 60),
tempC: prefermentTempC
Expand Down Expand Up @@ -230,101 +383,19 @@ export function computeSchedule(inputs: DoughInputs): ComputedSchedule {
else if (windowHours < band.min) warnings.push('flour-window-short');
}

let yeastPct: number;
let steps: ScheduleStep[];
let naturalColdBulkMin: number | null = null;
let desiredColdBulkMin: number | null = null;

if (mode === 'cold') {
const fixedMin =
PREP_MIN +
autolyseMin +
mixDurationMin +
COLD_INITIAL_BULK_MIN +
DIVIDE_MIN +
COLD_FINAL_PROOF_MIN;
// Cold mode is only entered when totalAvailable − naturalPreferment ≥
// 16 h, so this is always ≥ ~10 h — the pre-ferment-overflow branch
// can only fire in room mode.
const desired = totalAvailableMin - fixedMin - naturalPrefermentMin;
desiredColdBulkMin = desired;
// Cap cold-bulk at the 48 h ceiling. We do NOT clamp UP to the 12 h
// floor — that would pull the schedule's first step before startAt,
// which the contract forbids. Sub-floor cold-bulk is allowed and
// flagged via the recipe-fit score.
const naturalColdMin = Math.min(COLD_BULK_CEIL_MIN, desired);
naturalColdBulkMin = naturalColdMin;
const coldMin = adjustColdMinForNight(
inputs.readyBy,
naturalColdMin,
prefermentDurationsMin.map((d) => d.min),
mixDurationMin,
autolyseMin,
inputs.ballProof === 'cold'
);

yeastPct = yeastPercentForPhases(inputs.yeastType, [
...prefermentPhases(prefermentDurationsMin),
{ hours: (COLD_INITIAL_BULK_MIN + COLD_FINAL_PROOF_MIN) / 60, tempC: inputs.roomTempC },
{ hours: coldMin / 60, tempC: inputs.fridgeTempC }
]);
steps = buildSteps({
readyBy: inputs.readyBy,
prefermentDurationsMin,
mixDurationMin,
bulkRoomMin: COLD_INITIAL_BULK_MIN,
bulkColdMin: coldMin,
ballProofCold: inputs.ballProof === 'cold',
finalProofMin: COLD_FINAL_PROOF_MIN,
autolyseMin
});
} else {
const roomFixedMin = PREP_MIN + autolyseMin + mixDurationMin + DIVIDE_MIN;
const fermentBudget = totalAvailableMin - roomFixedMin - naturalPrefermentMin;
let bulkMin: number;
let finalProofMin: number;
if (fermentBudget >= 0) {
// Pre-ferments fit; the rest goes to bulk + final-proof in roughly
// a 2:1 ratio. Both can shrink to 0 if the window is very tight.
finalProofMin = Math.min(90, Math.floor(fermentBudget / 3));
bulkMin = fermentBudget - finalProofMin;
} else {
// The longest pre-ferment alone overflows. Cap every pre-ferment at
// the wall budget so first-step >= startAt still holds — shorter
// ones may fit untouched. Bulk and final-proof are 0.
//
// Degenerate exception: when the window is shorter than the fixed
// hands-on steps themselves (totalAvailableMin < roomFixedMin), the
// budget bottoms out at 0 but prep + mix + divide keep their physical
// durations anchored to readyBy — the first step lands BEFORE startAt.
// We never compress fixed steps or slip readyBy; instead the schedule
// is honest about being infeasible: roomFixedMin is always well below
// ROOM_MIN_TOTAL_MIN, so the 'too-short' warning (feasible = false,
// quality.ts's 'infeasible' factor) has already fired above.
const budget = Math.max(0, totalAvailableMin - roomFixedMin);
prefermentDurationsMin = prefermentDurationsMin.map((d) => ({
...d,
min: Math.min(d.min, budget)
}));
bulkMin = 0;
finalProofMin = 0;
}

yeastPct = yeastPercentForPhases(inputs.yeastType, [
...prefermentPhases(prefermentDurationsMin),
{ hours: (bulkMin + finalProofMin) / 60, tempC: inputs.roomTempC }
]);
steps = buildSteps({
readyBy: inputs.readyBy,
prefermentDurationsMin,
mixDurationMin,
bulkRoomMin: bulkMin,
bulkColdMin: null,
ballProofCold: false,
finalProofMin,
autolyseMin
});
}
// Which leg spends the window is the one real fork in the schedule, and it is
// now the whole of it: everything above is reading the inputs, everything
// below is assembling the answer.
const plan = (mode === 'cold' ? coldSchedule : roomSchedule)({
inputs,
totalAvailableMin,
prefermentDurationsMin,
naturalPrefermentMin,
prefermentPhases,
mixDurationMin,
autolyseMin
});
const { yeastPercent: yeastPct, steps } = plan;

const freshEquivalentPct = freshEquivalentPercent(yeastPct, inputs.yeastType);
if (freshEquivalentPct > 0 && freshEquivalentPct < 0.02) warnings.push('yeast-tiny');
Expand Down Expand Up @@ -372,8 +443,8 @@ export function computeSchedule(inputs: DoughInputs): ComputedSchedule {
mixingMethod: inputs.mixingMethod,
preFermentTempC: preFerments.length > 0 ? inputs.preFermentTempC : null,
idealWaterTempC: idealMixWaterTempC(inputs.roomTempC, inputs.mixingMethod),
naturalColdBulkMin,
desiredColdBulkMin,
naturalColdBulkMin: plan.naturalColdBulkMin,
desiredColdBulkMin: plan.desiredColdBulkMin,
naturalPreferments
};
}
Expand Down
Loading