Part 4|第 15/30 篇
今日要做的事: 定義 budget/time 的 Hard 邊界,並讓使用者自行宣告的 16:8 只改排程。
今天要解決的目的: 讓 DishFlow 對「花多少、多久吃得到」有一致判定,同時不把 LifeFlow 變成斷食療效工具。
Day 12 已經處理過敏與設備,今天不再重講蝦、花枝條與鍋具清洗。
我只補兩個常被寫成「大約」的上限:預算與時間。上限若可以被推薦文案說服,就不叫 Hard。
| 項目 | 內容 |
|---|---|
| 產出 | validateBudget()、validateTime()、16:8 排程 context、選配 feature flag |
| 工具 | 自寫 Rule Engine(Node 純函式);本篇未啟用 Remote Config |
| 不使用 | 過敏規則重跑、體重/體脂推論、療效宣稱、運動或營養功能擴張 |
| 驗收 | 剛好等於上限 PASS、超過 1 單位 BLOCK;缺價追問;窗外不推正餐 |
程式放在 material/code/day14,沒有呼叫 Gemini。
「100 元內」看似清楚,但我很快遇到三個洞:
家中已有的豆腐要不要再算一次成本?
外食的外帶費算不算?
缺一個價格時,能不能先當成 0?
時間也一樣。單口電磁爐上的兩個步驟不能同時跑;買缺料、備料與等待,也不能在估時時消失。
若 A、B1、B2、C 各用不同算法,Day 24 的「預算符合」與「時間可執行」就沒有可比性。今天不是多加兩個 if,而是先把量測口徑鎖死。

左邊是推薦文字把「多 1 元」說過去。右邊是今天要的:剛好等於上限 PASS,超過 1 單位 BLOCK。16:8 只改排程,不推論該不該斷食。
先鎖預算口徑:只算這次還要付的錢,冰箱裡已有的食材不重複計價;缺價格就追問,不當成 0。再鎖時間口徑:從取得食材、備料、單口爐序列化烹煮,到可以吃為止。兩邊都測剛好等於上限,以及超過 1 單位。接著測 16:8:未宣告不限制;已宣告且在窗外,不顯示正餐推薦,只顯示下一個可排程時間。最後用 node test.js 把邊界與窗外案例跑完。

v1 統一使用 TWD,採「這次決策的新增支出」:
home
→ needs_purchase 的實付小計
→ pantry 既有食材視為已購,不重複計價
convenience_store / supermarket / eat_out
→ 本次餐點實付總額
→ 已知外帶、平台或必要費用要納入
缺任何必要價格時,不可以先填 0:
cost_complete=false → needs_confirmation=true → 不判定 budget_ok
邊界採包含上限:
estimated_payable_twd <= max_budget_twd → PASS
estimated_payable_twd > max_budget_twd → BLOCK
這個定義回答的是「現在付不付得起」,不是歷史食材成本分析。
時間上限比較:
acquire_minutes
+ prep_minutes
+ executable_cook_schedule_minutes
+ required_wait_minutes
= estimated_total_minutes
若完全使用 pantry,acquire_minutes=0。
若需要外出購買或等外送,就不能把取得食物的時間藏掉。
設備仍固定:
induction_cooktop + deep_wok + tamagoyaki_pan + microwave
burners = 1
max_pots_willing = 2
burners=1 會把兩段爐火工作序列化後再算總時間。max_pots_willing=2 仍是 Day 13 的 soft,不加入 time Hard。
時間邊界同樣包含上限:
estimated_total_minutes <= max_minutes → PASS
estimated_total_minutes > max_minutes → BLOCK
估時欄位缺失就追問,不用「看起來很快」代替數字。
16:8 的資料責任是:
type = context_module
user_declared = true
timezone 必填
now_inside 由程式算
窗外時,不顯示「現在吃」的正餐 options,只顯示「目前不在你設定的進食時段」和下一個可排程時間。
時段是使用者自己設的,系統只照表排程。不寫減重或代謝,也不依體重、體脂或餐點 tags 自動打開 16:8;不想用了,設定裡關掉就好。
程式在 material/code/day14。budget.js 算這次還要付多少,time.js 把單口爐步驟序列化後加總分鐘,window.js 處理 16:8 與本地 feature flag。test.js 不發明新規則,只餵邊界值。
export function estimatePayable(input) {
const source = input.food_source ?? "home";
if (source === "home") {
const purchased = sumPrices(input.needs_purchase);
return {
food_source: source,
estimated_payable_twd: purchased.total,
cost_complete: purchased.complete,
missing_prices: purchased.missing,
note: "pantry 既有食材不重複計價",
};
}
const meal = sumPrices(input.meal_charges);
const fees = sumPrices(input.extra_fees);
return {
food_source: source,
estimated_payable_twd: meal.total + fees.total,
cost_complete: meal.complete && fees.complete,
missing_prices: [...meal.missing, ...fees.missing],
};
}
export function validateBudget(payableTwd, maxTwd, costComplete) {
if (!costComplete) {
return { ok: false, needs_confirmation: true, code: "BUDGET_PRICE_INCOMPLETE" };
}
const ok = payableTwd <= maxTwd;
return { ok, needs_confirmation: false, code: ok ? "BUDGET_OK" : "BUDGET_EXCEEDED" };
}
home 只加 needs_purchase。冰箱裡已有的豆腐不進這次應付。外食把餐點實付和外帶費分開加。sumPrices 遇到缺價就標 incomplete,不會把缺的欄位當成 0 元過關。
export function serializeCookSteps(steps, burners = 1) {
const heating = (steps ?? [])
.filter((step) => step.uses_burner)
.sort((a, b) => a.start_min - b.start_min || a.end_min - b.end_min);
let cursor = 0;
let hadOverlap = false;
const serialized = [];
for (const step of heating) {
if (step.start_min < cursor) hadOverlap = true;
const start = Math.max(step.start_min, cursor);
const end = start + (step.end_min - step.start_min);
serialized.push({ ...step, start_min: start, end_min: end });
cursor = end;
}
return { had_overlap: hadOverlap, serialized, cook_minutes: cursor };
}
單口爐上,前一段還沒結束,後一段就不能開始。總時間是:
acquire + prep + cook_minutes + wait
export function validateTime(totalMinutes, maxMinutes, estimateComplete) {
if (!estimateComplete) {
return { ok: false, needs_confirmation: true, code: "TIME_ESTIMATE_INCOMPLETE" };
}
const ok = totalMinutes <= maxMinutes;
return { ok, needs_confirmation: false, code: ok ? "TIME_OK" : "TIME_EXCEEDED" };
}
export function evaluateEatingWindow({ featureFlag, sessionWindow, nowMinutes, mealOptions }) {
if (!featureFlag) {
return { feature_enabled: false, ui_entry_visible: false, options: mealOptions };
}
if (!sessionWindow || sessionWindow.user_declared !== true) {
return { feature_enabled: true, ui_entry_visible: true, options: mealOptions };
}
const inside = isInsideWindow(nowMinutes, sessionWindow.window_start, sessionWindow.window_end);
if (inside) return { now_inside: true, options: mealOptions };
return {
now_inside: false,
options: [],
next_available_at: nextWindowStart(nowMinutes, sessionWindow.window_start).label,
message_key: "outside_user_declared_window",
message: "目前不在你設定的進食時段",
};
}
測試用的窗是 12:00–20:00,只是 fixture,不是系統建議的斷食時段。本篇 未啟用 Remote Config;本地 feature_eating_window = false,入口隱藏,也不遠端改使用者資料。
排程不改資料流。外食仍只寫 today_meals;自煮仍要等 Outflow 才扣 pantry。
在 material/code/day14 執行 node test.js。矩陣與結果如下:
| 案例 | 結果 |
|---|---|
| 預算上限 100,應付 100 | PASS → BUDGET_OK |
| 預算上限 100,應付 101 | PASS → BUDGET_EXCEEDED |
| 缺一項必要價格 | PASS → needs_confirmation |
| 時間上限 20,總時 20 | PASS → TIME_OK |
| 時間上限 20,總時 21 | PASS → TIME_EXCEEDED |
| 兩段爐火被錯排並行 | PASS → 序列化後總時 15 |
| 未宣告 16:8 | PASS → options 不限制 |
| 已宣告且在窗外 | PASS → options 空,顯示明天 12:00 |
| feature flag 關閉 | PASS → 入口隱藏 |

這張測的是邊界值,不是「大概一百」。家裡煮那組,needs_purchase 加總剛好 100,code 是 BUDGET_OK。外食那組便當 95 加外帶袋 6,合計 101,code 是 BUDGET_EXCEEDED。多 1 元也不能靠文案通融。note 寫著 pantry 既有食材不重複計價,對上第 2 節的口徑。

雞胸沒有 price_twd,missing_prices 列出「雞胸」,cost_complete 是 false。estimated_payable_twd 雖然印出 0,但那不是把缺價當成免費;needs_confirmation 是 true,code 是 BUDGET_PRICE_INCOMPLETE,不會判定 budget_ok。

備料 5 分、烹煮 15 分,總時 20,剛好等於上限,TIME_OK。烹煮多 1 分變成 21,TIME_EXCEEDED。跟預算同一套規則:包含上限,超過 1 單位就擋。

測試資料寫的是「0–10 煮湯、3–8 同時煎蛋」,had_overlap 是 true。序列化後煮湯仍是 0–10,煎蛋被推到 10–15,總烹煮 15 分。上限 20,所以 TIME_OK。這道菜沒有被擋,只是估時不能照兩個爐口算;先重排,再跟上限比。

options 是空陣列,next_available_at 是「明天 12:00」,文案是「目前不在你設定的進食時段」。畫面上沒有正餐推薦卡。ui_entry_visible 是 false。入口隱藏,options 仍在,沒有遠端改掉使用者資料。本篇沒接 Firebase Remote Config。九項都過。沒有呼叫 Gemini。
Day 24 比較 A、B1、B2、C 時,四種方法要沿用同一個成本與時間口徑。今天不預告誰勝。
今天的結論是:Hard 上限先要有一致量測口徑;16:8 則只能是使用者宣告的排程 Context。
max_pots_willing=2 仍留在 soft。下一篇: 把 store、compile、Hard、Soft 與排程組成完整 Strategy Stack,再開始 Pilot。
{
"budget_rule": {
"currency": "TWD",
"max_budget_twd": 100,
"comparison": "estimated_payable_twd_lte_cap",
"missing_price": "needs_confirmation"
},
"time_rule": {
"max_minutes": 20,
"start": "begin_acquisition_or_prep",
"end": "ready_to_eat",
"include": [
"acquire_minutes",
"prep_minutes",
"serialized_cook_minutes",
"required_wait_minutes"
],
"burners": 1,
"missing_estimate": "needs_confirmation"
},
"eating_window": {
"enabled": false,
"user_declared_required": true,
"timezone_required": true,
"outside_behavior": "empty_options_and_schedule_next"
}
}