From 0cb7328aa1da46f20f17be796c4a227a009c6087 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 22:46:56 +0000 Subject: [PATCH] feat: plan tomorrow independently (Closes #1152) --- frontend/dashboard.css | 2 + frontend/dashboard.js | 81 +++++++- frontend/index.html | 2 + frontend/service-worker.js | 1 + frontend/tomorrow-plan.js | 29 +++ frontend/workspace-bootstrap.js | 14 +- src/frontend_bundle.py | 9 +- src/main.py | 92 ++++++++- src/today_store.py | 174 +++++++++++++++++- .../e2e/test_mobile_home_bootstrap_release.py | 12 ++ tests/test_frontend_bundle.py | 5 +- tests/test_service_worker.py | 1 + tests/test_tomorrow_plan.py | 112 +++++++++++ tests/test_tomorrow_plan_frontend.py | 100 ++++++++++ tests/test_workspace_bootstrap.py | 23 ++- 15 files changed, 632 insertions(+), 25 deletions(-) create mode 100644 frontend/tomorrow-plan.js create mode 100644 tests/test_tomorrow_plan.py create mode 100644 tests/test_tomorrow_plan_frontend.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index f370e74..92ebf0e 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -768,6 +768,7 @@ textarea { resize: vertical; min-height: 120px; } .new-issue { min-height:44px; } .find-work-action { min-height:44px; } .my-work-actions { display:flex; flex-wrap:wrap; gap:8px; } +.plan-tomorrow { min-height:44px; } .start-work-session { min-height:44px; } .resume-today-session, .end-today-session { min-height:44px; } .work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; } @@ -1095,6 +1096,7 @@ textarea { resize: vertical; min-height: 120px; } .attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); } .my-work-header { align-items:flex-start; } .my-work-actions { display:none; } + .my-work-actions { display:flex; } .work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; } .work-settings:not([open]) > .work-settings-panel { display:none; } .work-settings-panel { display:grid; gap:10px; margin-top:8px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 0fcac55..c093e59 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -376,10 +376,37 @@ let rolloverReviewPlan = null; const outboxCoordinator = createOutboxCoordinator({ storage: localStorage }); const todayRollover = createTodayRollover(); + let planningTomorrow = false; + const tomorrowPlan = createTomorrowPlan({ + fetchJson:fetchReviewJson, + localDate:todayRollover.localDate, + timeZone:todayRollover.timeZone, + }); const todayWork = createTodayWork({ storage: localStorage, getLogin: () => planningOwnerLogin, }); + async function promoteTomorrowIfDue(plan) { + try { + await tomorrowPlan.load(); + const promoted = await tomorrowPlan.promote(plan.revision); + if (!promoted) return false; + todayWork.replace(promoted.ids); + todayWork.replacePlanning({ + capacity_minutes:promoted.capacity_minutes ?? null, + estimates:promoted.estimates || {}, + }); + refreshMyWorkView(); + warmTodayOffline(); + qs('#my-work-action-status').textContent = + 'Your saved Tomorrow plan is now Today.'; + return true; + } catch (error) { + qs('#my-work-action-status').textContent = + `${error.message || 'Tomorrow needs review before promotion.'} Open Plan Tomorrow to review.`; + return false; + } + } const todaySync = createTodaySync({ storage: localStorage, getLogin: () => planningOwnerLogin, @@ -392,6 +419,7 @@ }, onRemotePlan: plan => { if (!planningOwnerLogin) return; + promoteTomorrowIfDue(plan); todayWork.replacePlanning({ capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {}, @@ -2605,6 +2633,7 @@ return; } planToday.cancel(); + planningTomorrow = false; qs('#plan-today-sheet').hidden = true; document.body.classList.remove('task-overlay-open'); planTodayTrigger?.focus(); @@ -2638,10 +2667,24 @@ return true; } + function saveTomorrowPlan(plan) { + const normalized = Array.isArray(plan) ? + {ids:plan, capacity_minutes:null, estimates:{}} : plan; + tomorrowPlan.save(normalized).then(saved => { + qs('#my-work-action-status').textContent = saved.ids.length ? + `Tomorrow saved for ${saved.plan_date} without changing Today.` : + `Tomorrow cleared for ${saved.plan_date}.`; + }).catch(error => { + qs('#my-work-action-status').textContent = + `${error.message || 'Tomorrow could not be saved.'} Reopen Plan Tomorrow to retry.`; + }); + return true; + } + const planToday = createPlanToday({ identity: item => todayWork.identity(item), limit: todayWork.limit, - save: saveTodayPlan, + save: plan => planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan), start: () => { qs('[data-work-filter="today"]').click(); startTodaySession(); @@ -2919,7 +2962,8 @@ return; } if (trigger) planTodayTrigger = trigger; - qs('#plan-today-title').textContent = pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today'); + qs('#plan-today-title').textContent = planningTomorrow ? 'Plan Tomorrow' : + (pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today')); if (actualMinutes) pendingPlanActualMinutes = actualMinutes; if (navigate) { taskOverlayHistory.open('plan-today'); @@ -2928,8 +2972,18 @@ const recommendations = actualMinutes || pendingPlanActualMinutes || todayRecapView.pendingReplan()?.actual_minutes; pendingPlanActualMinutes = null; const protectProposal = pendingProtectToday; - const selected = protectProposal?.selected || todayMyWork; - planToday.open(selected, activeMyWork, todayWork.planning(), recommendations); + const tomorrow = planningTomorrow ? tomorrowPlan.state() : null; + const availablePlanningItems = [...todayMyWork, ...activeMyWork].filter((item, index, items) => + items.findIndex(candidate => todayWork.identity(candidate) === todayWork.identity(item)) === index + ); + const selected = planningTomorrow ? availablePlanningItems.filter(item => + tomorrow.ids.includes(todayWork.identity(item)) + ) : (protectProposal?.selected || todayMyWork); + planToday.open(selected, activeMyWork, planningTomorrow ? tomorrow : todayWork.planning(), recommendations); + qs('#today-plan-heading').textContent = planningTomorrow ? 'Tomorrow, in order' : 'Today, in order'; + qs('.plan-today-available').firstChild.textContent = planningTomorrow ? 'Available tomorrow' : 'Available today'; + qs('#build-today-plan').textContent = planningTomorrow ? 'Build my Tomorrow' : 'Build my Today'; + qs('#save-and-start-today').hidden = planningTomorrow; pendingProtectToday = null; qs('#discard-recap-replan').hidden = !todayRecapView.pendingReplan(); qs('#plan-today-error').textContent = ''; @@ -7633,6 +7687,25 @@ qs('#refresh').addEventListener('click', load); qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget)); + qs('#plan-tomorrow').addEventListener('click', async event => { + const button = event.currentTarget; + if (!planningOwnerLogin) { + qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.'; + return; + } + button.disabled = true; + qs('#my-work-action-status').textContent = 'Loading Tomorrow…'; + try { + await tomorrowPlan.load(); + planningTomorrow = true; + openPlanToday(button); + qs('#my-work-action-status').textContent = ''; + } catch (error) { + qs('#my-work-action-status').textContent = `${error.message || 'Tomorrow is unavailable.'} Retry when connected.`; + } finally { + button.disabled = false; + } + }); qs('#cancel-plan-today').addEventListener('click', closePlanToday); qs('#plan-today-sheet').addEventListener('click', event => { if (event.target === qs('#plan-today-sheet')) closePlanToday(); diff --git a/frontend/index.html b/frontend/index.html index 85ba709..f595694 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -192,6 +192,7 @@ +