feat: review and rebalance Week Ahead (Closes #1188)
This commit is contained in:
1 parent
69532bdc1f
commit
d5f13c4afb
21 files changed
+351
-53
No files matched your search
@@ -76,6 +76,14 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||
dates.nth(6).click()
|
||||
expect(page.locator("#save-today-plan")).to_have_text("Save week")
|
||||
page.locator("#save-today-plan").click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(page.locator("#week-review-days .week-review-day")).to_have_count(7)
|
||||
page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
|
||||
confirm = page.locator("#confirm-week-plan")
|
||||
bounds = confirm.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
confirm.click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
page.wait_for_function("() => document.querySelector('#mobile-week-summary').textContent !== 'Loading Week Ahead…'")
|
||||
assert saved and len(saved[-1]["days"]) == 2
|
||||
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v127" in worker
|
||||
assert "stackchain-dashboard-shell-v128" in worker
|
||||
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v127" in worker
|
||||
assert "stackchain-dashboard-shell-v128" in worker
|
||||
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v127" in worker
|
||||
assert "stackchain-dashboard-shell-v128" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "controller.recoverPermission('deadline')" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v127" in worker
|
||||
assert "stackchain-dashboard-shell-v128" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in worker
|
||||
assert "stackchain-dashboard-shell-v128" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v127" in service_worker
|
||||
assert "stackchain-dashboard-shell-v128" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -180,20 +180,20 @@ async function dispatchPush(payload) {{
|
||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
|
||||
|
||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -202,7 +202,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
@@ -211,7 +211,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -219,14 +219,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -234,7 +234,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -242,7 +242,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
@@ -252,14 +252,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -268,21 +268,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -1252,7 +1252,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v127';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v128';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v127" in source
|
||||
assert "stackchain-dashboard-shell-v128" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,29 @@ def test_week_plan_is_encrypted_account_scoped_revisioned_and_preserves_other_da
|
||||
assert conflict.value.snapshot == updated
|
||||
|
||||
|
||||
def test_week_plan_rejects_the_same_work_on_multiple_dates_without_advancing_revision(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"w" * 32)
|
||||
original = store.replace_week(
|
||||
"timmy", base_revision=0, days=sample_days(), timezone="UTC"
|
||||
)
|
||||
duplicated = [
|
||||
sample_days()[0],
|
||||
{
|
||||
"plan_date": "2026-08-22",
|
||||
"ids": ["issue:secret/repo:3:"],
|
||||
"capacity_minutes": 60,
|
||||
"estimates": {"issue:secret/repo:3:": 30},
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="work must be assigned to only one Week Ahead date"):
|
||||
store.replace_week(
|
||||
"timmy", base_revision=original["revision"], days=duplicated, timezone="UTC"
|
||||
)
|
||||
|
||||
assert store.get_week("timmy") == original
|
||||
|
||||
|
||||
def test_week_promotion_moves_only_due_date_to_today_exactly_once(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3",
|
||||
|
||||
@@ -67,6 +67,34 @@ console.log(JSON.stringify({first,middle,last}));
|
||||
}
|
||||
|
||||
|
||||
def test_week_controller_reviews_capacity_duplicates_and_moves_work_without_copying():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>({}),
|
||||
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:6,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-21',ids:['shared','one'],capacity_minutes:45,estimates:{shared:30,one:30}},
|
||||
{plan_date:'2026-08-22',ids:['shared','two'],capacity_minutes:120,estimates:{shared:30,two:40}}
|
||||
]});
|
||||
const before=week.review();
|
||||
const moved=week.move('shared','2026-08-23');
|
||||
const after=week.review();
|
||||
console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
|
||||
""")
|
||||
|
||||
assert len(result["before"]["days"]) == 7
|
||||
assert result["before"]["days"][0]["planned_minutes"] == 60
|
||||
assert result["before"]["days"][0]["overloaded"] is True
|
||||
assert result["before"]["duplicates"] == [{
|
||||
"id": "shared", "dates": ["2026-08-21", "2026-08-22"]
|
||||
}]
|
||||
assert result["moved"] is True
|
||||
assert result["after"]["duplicates"] == []
|
||||
assert [day["ids"] for day in result["after"]["days"][:3]] == [["one"], ["two"], ["shared"]]
|
||||
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
|
||||
|
||||
|
||||
def test_week_workflow_saves_and_advances_without_closing_the_planner():
|
||||
result = run_controller("""
|
||||
const createWorkflow=createWeekPlan.Workflow;
|
||||
@@ -104,6 +132,47 @@ console.log(JSON.stringify({continued,selected:workflow.selectedDate(),opened,st
|
||||
assert result["label"] == "Save & next"
|
||||
|
||||
|
||||
def test_week_workflow_opens_review_after_day_seven_and_blocks_duplicate_confirmation():
|
||||
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 root=makeElement();
|
||||
Object.defineProperty(root,'innerHTML',{set(value){this.value=value;this.buttons=[];},get(){return this.value||'';}});
|
||||
elements.set('#week-plan-dates',root);
|
||||
for(const selector of ['#mobile-week-summary','#my-work-action-status','#week-plan-progress','#save-today-plan',
|
||||
'#week-review','#week-review-days','#week-review-duplicates','#confirm-week-plan','#week-review-status']) elements.set(selector,makeElement());
|
||||
let reviewMode=false;
|
||||
const review={days:dates.map((date,index)=>({plan_date:date,label:'Day '+(index+1),ids:index<2?['shared']:[],
|
||||
capacity_minutes:index===0?30:60,planned_minutes:index===0?45:15,overloaded:index===0})),
|
||||
duplicates:[{id:'shared',dates:dates.slice(0,2)}],can_confirm:false};
|
||||
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),day:date=>review.days.find(day=>day.plan_date===date),
|
||||
pass:date=>{const index=dates.indexOf(date);return {position:index+1,total:7,planned:2,next_date:dates[index+1]||null,last:index===6};},
|
||||
load:async()=>({}),summary:()=> '2 items across 2 days',review:()=>review,move:()=>true,flush:async()=>({})};
|
||||
const workflow=createWorkflow({controller,qs:selector=>elements.get(selector),getLogin:()=> 'timmy',
|
||||
openPlanner:()=>{},setReviewMode:value=>{reviewMode=value;},escapeHtml:value=>value,escapeAttribute:value=>value,
|
||||
todayWork:{replace:()=>{},replacePlanning:()=>{}},refresh:()=>{},warm:()=>{}});
|
||||
await workflow.open({disabled:false});
|
||||
for(let index=0;index<6;index++) workflow.advance();
|
||||
const advanced=workflow.advance();
|
||||
console.log(JSON.stringify({advanced,reviewing:workflow.reviewing(),reviewMode,
|
||||
reviewHidden:elements.get('#week-review').hidden,progress:elements.get('#week-plan-progress').textContent,
|
||||
confirmDisabled:elements.get('#confirm-week-plan').disabled,days:elements.get('#week-review-days').innerHTML,
|
||||
duplicates:elements.get('#week-review-duplicates').innerHTML}));
|
||||
""")
|
||||
|
||||
assert result["advanced"] is True
|
||||
assert result["reviewing"] is True
|
||||
assert result["reviewMode"] is True
|
||||
assert result["reviewHidden"] is False
|
||||
assert result["progress"] == "Review week · 2 planned"
|
||||
assert result["confirmDisabled"] is True
|
||||
assert "45 of 30 min · over capacity" in result["days"]
|
||||
assert "shared" in result["duplicates"]
|
||||
|
||||
|
||||
def test_week_controller_preserves_both_versions_when_another_device_changes_the_week():
|
||||
result = run_controller("""
|
||||
let calls=0;
|
||||
@@ -260,6 +329,35 @@ console.log(JSON.stringify({requests,during,saved,reused,pendingAfter:week.pendi
|
||||
assert result["state"]["revision"] == 9
|
||||
|
||||
|
||||
def test_week_controller_drains_work_staged_while_a_save_is_in_flight():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let releaseFirst;const bodies=[];
|
||||
const fetchJson=async(_url,options={})=>{
|
||||
const body=JSON.parse(options.body);bodies.push(body);
|
||||
if(bodies.length===1)await new Promise(resolve=>{releaseFirst=resolve});
|
||||
return {revision:8+bodies.length,timezone:body.timezone,days:body.days};
|
||||
};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:8,timezone:'UTC',days:[]});
|
||||
week.stageDay('2026-08-21',{ids:['one'],capacity_minutes:60,estimates:{one:30}});
|
||||
const first=week.flush();await Promise.resolve();
|
||||
week.stageDay('2026-08-22',{ids:['two'],capacity_minutes:90,estimates:{two:45}});
|
||||
const second=week.flush();releaseFirst();
|
||||
const [saved,reused]=await Promise.all([first,second]);
|
||||
console.log(JSON.stringify({bodies,saved,reused,pending:week.pending(),state:week.state()}));
|
||||
""")
|
||||
|
||||
assert len(result["bodies"]) == 2
|
||||
assert [day["plan_date"] for day in result["bodies"][1]["days"]] == ["2026-08-21", "2026-08-22"]
|
||||
assert result["bodies"][1]["base_revision"] == 9
|
||||
assert result["saved"]["revision"] == 10
|
||||
assert result["reused"]["revision"] == 10
|
||||
assert result["pending"] is False
|
||||
assert result["state"]["revision"] == 10
|
||||
|
||||
|
||||
def test_week_controller_keeps_phone_week_by_rebasing_onto_the_latest_revision():
|
||||
result = run_controller("""
|
||||
const values=new Map();
|
||||
@@ -423,6 +521,22 @@ def test_mobile_week_ahead_entry_and_date_strip_are_touch_safe():
|
||||
assert "overflow-x:auto" in css
|
||||
|
||||
|
||||
def test_mobile_week_ahead_review_is_rendered_touch_safe_and_confirmed_explicitly():
|
||||
index = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert 'id="week-review"' in index
|
||||
assert 'id="week-review-days"' in index
|
||||
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 ".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
|
||||
|
||||
|
||||
def test_mobile_week_ahead_exposes_durable_save_and_touch_safe_conflict_choices():
|
||||
index = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
Reference in new issue
Block a user