diff --git a/frontend/dashboard.css b/frontend/dashboard.css index e1e0f6a..85f986a 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -330,6 +330,9 @@ textarea { resize: vertical; min-height: 120px; } .week-review-day ul { display:grid; gap:10px; margin:0; padding:0; list-style:none; } .week-review-day li { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; padding-top:8px; border-top:1px solid #31577f; overflow-wrap:anywhere; } .week-review-item-copy { display:grid; min-width:0; gap:3px; } +.week-review-item-open { display:block; width:100%; min-width:0; min-height:44px; padding:8px; border:0; border-radius:8px; background:transparent; color:inherit; text-align:left; } +.week-review-item-open:hover { background:#173453; } +.week-review-item-open:focus-visible { outline:3px solid #93c5fd; outline-offset:2px; } .week-review-item-copy strong, .week-review-item-copy small { overflow-wrap:anywhere; } .week-review-item-copy small { color:#a9bdd3; } .week-review-move { display:flex; align-items:end; gap:8px; } @@ -767,6 +770,7 @@ textarea { resize: vertical; min-height: 120px; } .update-decision-bar button, .update-decision-bar summary { min-height:44px; min-width:0; padding-inline:6px; } .update-retry { min-height:44px; width:100%; margin-top:10px; } .issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } +.task-overlay-open .issue-sheet.open, .task-overlay-open .pull-sheet.open { z-index:76; } .issue-sheet.open { display:flex; } .issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } .issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 02de798..594f676 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -393,7 +393,7 @@ fetchJson:fetchReviewJson,localDate:todayRollover.localDate,timeZone:todayRollover.timeZone, storage:localStorage,getLogin:() => planningOwnerLogin, }); - function openWeekPlanner(trigger) { planningTomorrow=false; return weekWorkflow.open(trigger); } + function openWeekPlanner(trigger) { planningTomorrow=false; return weekFlow.open(trigger); } function renderTomorrowQueueSummary(value) { qs('#mobile-tomorrow-summary').textContent = value ? tomorrowPlan.summary(value) : tomorrowPlan.summary(); } @@ -452,14 +452,14 @@ storage: localStorage, getLogin: () => planningOwnerLogin, }); - const weekItem=identity=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===identity); - const weekWorkflow=createWeekPlanWorkflow({controller:weekPlan,qs,getLogin:()=>planningOwnerLogin, - getItem:weekItem, + const weekItem=id=>[...todayMyWork,...activeMyWork].find(item=>todayWork.identity(item)===id); + const weekFlow=createWeekPlanWorkflow({controller:weekPlan,qs, + getItem:weekItem,openItem:openRoutedWork, openPlanner:openPlanToday,setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value), escapeHtml,escapeAttribute:escAttr,todayWork, refresh:refreshMyWorkView,warm:warmTodayOffline}); const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr, - onDone:()=>{weekWorkflow.finish();taskOverlayHistory.leave();}, + onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}, }); const todaySync = createTodaySync({ storage: localStorage, @@ -475,7 +475,7 @@ if (!planningOwnerLogin) return; latestTodayPlan = plan; const startDayLaunch = window.location.hash === '#/my-work/start-day'; - weekWorkflow.promote(plan).finally(() => { + weekFlow.promote(plan).finally(() => { if (!startDayLaunch) return; window.history.replaceState({}, '', '#/my-work/today'); openMobileStartDay(); @@ -519,7 +519,7 @@ check:() => Promise.all([ syncPendingTomorrow(), weekPlan.pending() ? weekPlan.flush().catch(() => false) : false, - latestTodayPlan ? weekWorkflow.promote(latestTodayPlan) : false, + latestTodayPlan ? weekFlow.promote(latestTodayPlan) : false, ]), }); const todayHandoff = createTodayHandoff({ @@ -2706,7 +2706,7 @@ planToday.cancel(); planningTomorrow = false; weekCalendar.close(); - weekWorkflow.clear(); + weekFlow.clear(); qs('#tomorrow-conflict-review').hidden = true; qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode'); qs('#week-conflict-review').hidden = true; @@ -2765,7 +2765,7 @@ const planToday = createPlanToday({ identity: item => todayWork.identity(item), limit: todayWork.limit, - save: plan => weekWorkflow.active() ? weekWorkflow.save(plan) : + save: plan => weekFlow.active() ? weekFlow.save(plan) : (planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)), start: () => { qs('[data-work-filter="today"]').click(); @@ -3072,7 +3072,7 @@ function showWeekConflict(conflict) { qs('#plan-today-title').textContent='Resolve Week Ahead conflict'; - const focus=weekWorkflow.renderConflict(conflict); + const focus=weekFlow.renderConflict(conflict); qs('#week-conflict-status').textContent=''; qs('#week-conflict-review').hidden=false; qs('#plan-today-sheet').classList.add('week-conflict-mode'); @@ -3082,12 +3082,12 @@ } function openPlanToday(trigger, navigate = true, actualMinutes = null) { - if (!planningOwnerLogin && !weekWorkflow.active()) { + if (!planningOwnerLogin && !weekFlow.active()) { qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.'; return; } if (trigger) planTodayTrigger = trigger; - const weekCopy=weekWorkflow.copy(); + const weekCopy=weekFlow.copy(); qs('#plan-today-title').textContent = weekCopy ? weekCopy.title : (planningTomorrow ? 'Plan Tomorrow' : (pendingProtectToday ? 'Protect Today' : (rolloverReviewPlan ? 'New day review' : 'Plan Today'))); if (actualMinutes) pendingPlanActualMinutes = actualMinutes; @@ -3100,7 +3100,7 @@ showTomorrowConflict(conflict); return; } - const weekConflict=weekWorkflow.active()?weekPlan.conflict():null; + const weekConflict=weekFlow.active()?weekPlan.conflict():null; if(weekConflict){showWeekConflict(weekConflict);return;} qs('#tomorrow-conflict-review').hidden = true; qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode'); @@ -3110,7 +3110,7 @@ pendingPlanActualMinutes = null; const protectProposal = pendingProtectToday; const tomorrow = planningTomorrow ? tomorrowPlan.state() : null; - const weekDay = weekWorkflow.day(); + const weekDay = weekFlow.day(); const availablePlanningItems = [...todayMyWork, ...activeMyWork].filter((item, index, items) => items.findIndex(candidate => todayWork.identity(candidate) === todayWork.identity(item)) === index ); @@ -3128,7 +3128,7 @@ qs('#plan-today-sheet').hidden = false; document.body.classList.add('task-overlay-open'); renderPlanToday(); - weekWorkflow.renderDates(); + weekFlow.renderDates(); if (protectProposal) qs('#plan-today-build-status').textContent = protectProposal.summary + (protectProposal.displaced.length ? '. Displaced work remains unchanged until you save.' : '. Review estimates and capacity before saving.'); qs('#cancel-plan-today').focus(); @@ -4421,7 +4421,7 @@ selectedIssueOffline = false; selectedIssueDetail = null; issueConversation = null; - if (issueTrigger?.isConnected) issueTrigger.focus(); + setTimeout(()=>issueTrigger?.focus()); } function closeIssueEditorFromHistory() { @@ -7920,7 +7920,7 @@ }); qs('#cancel-plan-today').addEventListener('click', closePlanToday); qs('#confirm-week-plan').addEventListener('click', () => { - if(!weekWorkflow.confirm()){ + if(!weekFlow.confirm()){ qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.'; return; } @@ -7950,7 +7950,7 @@ const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' }); if (result === 'saved') { button.dataset.confirmOverCapacity = ''; - if (!weekWorkflow.active() || !weekWorkflow.advance()) taskOverlayHistory.leave(); + if (!weekFlow.active() || !weekFlow.advance()) taskOverlayHistory.leave(); } else if (result === 'confirm-over-capacity') { button.dataset.confirmOverCapacity = 'true'; qs('#plan-today-error').textContent = 'This plan exceeds your available time. Press again to save over capacity.'; diff --git a/frontend/week-plan.js b/frontend/week-plan.js index abb0fc3..40b89e2 100644 --- a/frontend/week-plan.js +++ b/frontend/week-plan.js @@ -429,7 +429,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,now=()=>D item:id=>pendingItems[id]||confirmedItems[id]||null, offline:()=>offlineSnapshot}; } -function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute, +function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPlanner,setReviewMode=()=>{},escapeHtml,escapeAttribute, todayWork,refresh,warm}={}) { let selectedDate=null; let reviewing=false; @@ -528,7 +528,9 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla if(!item)return '
Work details unavailable'+escapeHtml(String(id).slice(0,96))+'
'; const kind=item.kind==='pull'?'Pull request':'Issue'; const reference=item.repository&&item.number!=null?item.repository+' #'+item.number:kind; - return '
'+escapeHtml(item.title||'Untitled work')+''+escapeHtml(reference+' · '+kind+(estimate?' · '+estimate+' min':''))+'
'; + const copy=''+escapeHtml(item.title||'Untitled work')+''+escapeHtml(reference+' · '+kind+(estimate?' · '+estimate+' min':''))+''; + const actionable=overviewing&&['issue','pull'].includes(item.kind)&&item.repository&&item.number>0; + return actionable?'':copy; }; root.innerHTML=value.days.map(day=>{ const capacity=Number(day.capacity_minutes)||0; @@ -563,6 +565,10 @@ function createWeekPlanWorkflow({controller,qs,getLogin,getItem=()=>null,openPla controller.flush().then(()=>{renderReview();qs('#mobile-week-summary').textContent=controller.summary();}) .catch(error=>{qs('#week-review-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Changes remain on this phone.';}); })); + root.querySelectorAll('[data-week-open-item]').forEach(button=>button.addEventListener('click',event=>{ + const id=button.dataset.weekOpenItem,item=getItem(id)||controller.item?.(id); + if(item)openItem?.(item,event.currentTarget); + })); root.querySelectorAll('[data-week-edit-day]').forEach(button=>button.addEventListener('click',()=>editDay(button.dataset.weekEditDay))); renderPass(); } diff --git a/tests/e2e/test_mobile_week_ahead_release.py b/tests/e2e/test_mobile_week_ahead_release.py index 786265c..20606f0 100644 --- a/tests/e2e/test_mobile_week_ahead_release.py +++ b/tests/e2e/test_mobile_week_ahead_release.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import os +import re import threading from datetime import date, timedelta from pathlib import Path @@ -75,6 +76,18 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates( expect(page.locator("#confirm-week-plan")).to_be_hidden() edit_week = page.locator("#edit-week-plan") expect(edit_week).to_be_visible() + planned_item = cards.first.locator("[data-week-open-item]") + expect(planned_item).to_have_count(1) + planned_bounds = planned_item.bounding_box() + assert planned_bounds and planned_bounds["height"] >= 44 + planned_item.click() + expect(page.locator("#issue-sheet")).to_have_class(re.compile(r"\bopen\b")) + expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture") + expect(overview).to_be_visible() + page.locator("#close-issue-sheet").click() + expect(page.locator("#issue-sheet")).not_to_have_class(re.compile(r"\bopen\b")) + expect(overview).to_be_visible() + expect(planned_item).to_be_focused() assert saved == [], "opening and inspecting Week Ahead must not write" for control in (cards.first.locator("[data-week-edit-day]"), edit_week): bounds = control.bounding_box() diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 51b5f95..e0c5718 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -7788,7 +7788,7 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut assert "window.confirm(confirmation)" in html assert "issueController.close(selectedIssue)" in html assert "lastMyWork.filter" in html - assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html + assert "setTimeout(()=>issueTrigger?.focus())" in html assert "e.key === 'Escape' && selectedIssue" in html diff --git a/tests/test_start_day_reminders.py b/tests/test_start_day_reminders.py index 00fa514..02719d8 100644 --- a/tests/test_start_day_reminders.py +++ b/tests/test_start_day_reminders.py @@ -312,6 +312,6 @@ def test_mobile_settings_and_launch_route_wire_start_day_into_existing_promotion assert "startDayControl:qs('#push-start-day')" in dashboard assert "startDayHour:qs('#push-start-day-hour')" in dashboard assert "window.location.hash === '#/my-work/start-day'" in dashboard - assert "weekWorkflow.promote(plan).finally(() =>" in dashboard + assert "weekFlow.promote(plan).finally(() =>" in dashboard assert "onRemotePlan: async plan =>" not in dashboard assert "openMobileStartDay()" in dashboard diff --git a/tests/test_tomorrow_plan_frontend.py b/tests/test_tomorrow_plan_frontend.py index 105b4a7..e601602 100644 --- a/tests/test_tomorrow_plan_frontend.py +++ b/tests/test_tomorrow_plan_frontend.py @@ -419,5 +419,5 @@ def test_tomorrow_plan_has_a_touch_safe_mobile_entry_and_reuses_the_ordered_plan assert "qs('#plan-tomorrow').addEventListener('click'" in dashboard assert "planningTomorrow ? saveTomorrowPlan(plan) : saveTodayPlan(plan)" in dashboard assert "tomorrowPlan.load()" in dashboard - assert "weekWorkflow.promote(plan)" in dashboard + assert "weekFlow.promote(plan)" in dashboard assert "tomorrowPlan.startLifecycle" in dashboard diff --git a/tests/test_week_plan_frontend.py b/tests/test_week_plan_frontend.py index 13b8723..14e0e63 100644 --- a/tests/test_week_plan_frontend.py +++ b/tests/test_week_plan_frontend.py @@ -260,6 +260,77 @@ console.log(JSON.stringify({reviewing:workflow.reviewing(),reviewMode,writes,ope assert result["editWeekLabel"] == "Edit week" +def test_week_overview_opens_known_work_with_the_originating_control_and_leaves_unknown_work_static(): + result = run_controller(""" +const createWorkflow=createWeekPlan.Workflow; +const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27']; +const elements=new Map(); +const makeElement=()=>({hidden:false,textContent:'',disabled:false,innerHTML:'',addEventListener:()=>{},focus:()=>{}, + querySelectorAll:()=>[],querySelector:()=>null,scrollIntoView:()=>{}}); +const dateRoot=makeElement(); +Object.defineProperty(dateRoot,'innerHTML',{set(value){this.value=value;},get(){return this.value||'';}}); +elements.set('#week-plan-dates',dateRoot); +const reviewDays=makeElement(); +Object.defineProperty(reviewDays,'innerHTML',{set(value){ + this.value=value; + this.openButtons=[...value.matchAll(/data-week-open-item=\"([^\"]+)/g)].map(match=>({ + dataset:{weekOpenItem:match[1]},listeners:{},isConnected:true,focused:false, + addEventListener(name,listener){this.listeners[name]=listener;},focus(){this.focused=true;} + })); +},get(){return this.value||'';}}); +reviewDays.querySelectorAll=selector=>selector==='[data-week-open-item]'?reviewDays.openButtons||[]:[]; +elements.set('#week-review-days',reviewDays); +for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan', + '#week-review','#week-review-duplicates','#confirm-week-plan','#week-review-status','#back-to-week-review', + '#edit-week-plan','#week-offline-snapshot','#retry-week-live','#open-week-capacity-import']) + if(!elements.has(selector))elements.set(selector,makeElement()); +const known={kind:'issue',title:'Inspect planned work',repository:'stackchain/dashboard',number:42}; +const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1), + ids:index===0?['known','missing']:[],capacity_minutes:90, + estimates:index===0?{known:30,missing:20}:{},planned_minutes:index===0?50:0,overloaded:false})), + duplicates:[],blockers:[],can_confirm:true}; +let opened=null,writes=0; +const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review.days.find(day=>day.plan_date===date), + pass:()=>({}),load:async()=>({}),summary:()=> '2 items across 1 day',review:()=>review,pending:()=>false,offline:()=>false, + move:()=>true,flush:async()=>{writes+=1;}}; +const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy', + getItem:id=>id==='known'?known:null,openItem:(item,trigger)=>{opened={item,sameTrigger:trigger===reviewDays.openButtons[0]};}, + openPlanner:()=>{},setReviewMode:()=>{},escapeHtml:value=>value,escapeAttribute:value=>value, + todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}}); +await workflow.open({disabled:false}); +const button=reviewDays.openButtons[0]; +if(button)button.listeners.click({currentTarget:button}); +console.log(JSON.stringify({markup:reviewDays.innerHTML,buttonCount:reviewDays.openButtons.length,opened,writes})); +""") + + assert result["buttonCount"] == 1 + assert 'class="week-review-item-open"' in result["markup"] + assert 'data-week-open-item="known"' in result["markup"] + assert 'data-week-open-item="missing"' not in result["markup"] + assert result["opened"] == { + "item": { + "kind": "issue", + "title": "Inspect planned work", + "repository": "stackchain/dashboard", + "number": 42, + }, + "sameTrigger": True, + } + assert result["writes"] == 0 + + +def test_dashboard_routes_week_overview_controls_through_existing_detail_flow_with_mobile_targets(): + dashboard = (FRONTEND / "dashboard.js").read_text() + css = (FRONTEND / "dashboard.css").read_text() + + workflow_mount = dashboard.split("const weekFlow=createWeekPlanWorkflow({", 1)[1].split("});", 1)[0] + assert "openItem:openRoutedWork" in workflow_mount + assert ".week-review-item-open" in css + rule = css.split(".week-review-item-open", 1)[1].split("}", 1)[0] + assert "min-height:44px" in rule + assert "width:100%" in rule + + def test_week_workflow_marks_a_confirmed_fallback_read_only_and_retries_live_data(): result = run_controller(""" const createWorkflow=createWeekPlan.Workflow; @@ -933,7 +1004,7 @@ def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe(): assert 'id="week-plan-dates"' in index assert '' in index assert "name === 'week' ? openWeekPlanner" in dashboard - assert "weekWorkflow.save" in dashboard + assert "weekFlow.save" in dashboard assert ".week-plan-dates button { min-height:44px;" in css assert "overflow-x:auto" in css @@ -948,7 +1019,7 @@ def test_mobile_week_ahead_review_is_rendered_touch_safe_and_confirmed_explicitl assert 'id="week-review-duplicates"' in index assert 'id="confirm-week-plan"' in index assert "setReviewMode:value=>qs('#plan-today-sheet').classList.toggle('week-review-mode',value)" in dashboard - assert "weekWorkflow.confirm()" in dashboard + assert "weekFlow.confirm()" in dashboard assert ".week-review-day.is-overloaded" in css assert ".week-review-day button { min-height:44px;" in css assert ".week-review-mode .mobile-plan-today-nav" in css