Appearance
date: 2026-06-26 tags: [dashboard, daily-plan, empty-state, derived-state] status: active graduated_to:
An empty derived collection can't tell "never computed" from "computed-empty" — persist a generation marker
Symptom — The Today dashboard showed "No plan for today yet" + a Generate button on a day whose only Todoist tasks were all @routine. Pressing Generate rebuilt the plan and landed back on the identical screen — a silent loop with no explanation.
Root cause — planExists was derived purely from whether daily_plan_tasks rows existed. The planner deliberately excludes parked (@routine/@someday) tasks, so an all-parked day generates a plan with zero rows — indistinguishable from a plan that was never generated. Both rendered the "no plan yet" prompt, so re-generating just reproduced the empty result.
Fix — Record that a plan was generated for a date, separately from its row count. New daily_plans marker table (plan_date unique), written at the single chokepoint DailyPlanner::generateFor() (app/Services/DailyPlanner.php:82) inside the existing transaction, so both the morning job and the manual replan path mark it. DashboardController reads DailyPlan::wasGeneratedFor($today) in the empty branch and surfaces a distinct planEmpty state. (PR #795.)
Guard — tests/Feature/DashboardTest.php "a generated-but-empty plan reports planEmpty so the build prompt does not loop" pins planExists/planEmpty apart for the generated-empty vs never-generated cases; tests/Feature/DailyPlannerTest.php asserts the marker is written on an all-parked day. General principle for the next reader: when an empty derived collection drives a UI state, the emptiness alone is ambiguous — persist the fact that the computation ran, don't infer "ran" from the presence of output.