Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae8533702 | ||
|
|
ea3204c78c | ||
|
|
6e72f59fcf | ||
|
|
24cadf88f8 | ||
|
|
4b645d8460 | ||
|
|
a4498fdbbc |
No files matched your search
@@ -254,10 +254,12 @@ each envelope to its operation key and field purpose so rows and fields cannot b
|
||||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||
likewise migrate on first read. Synchronized Saved Search collections use the private-state key and
|
||||
authenticate each envelope to its normalized account, preventing rows from being substituted between
|
||||
operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their
|
||||
revision; missing, wrong, or modified key material returns no saved-view content. Other private stores
|
||||
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
|
||||
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
||||
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
||||
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
||||
startup without changing order or acknowledgement state. Missing, wrong, or modified key material
|
||||
returns no saved-view or receipt content. Other private stores
|
||||
are not encrypted at the application layer. Web
|
||||
Push subscriptions use a third, independent
|
||||
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
|
||||
@@ -330,7 +332,7 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
|
||||
# planning and Saved Search state, and the Security activity journal. Keep this key independent
|
||||
# planning, Saved Search state, completed Filed review receipts, and the Security activity journal. Keep this key independent
|
||||
# from the Draft key and inject the
|
||||
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
|
||||
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
||||
|
||||
@@ -329,6 +329,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.week-conflict-plans { grid-template-columns:1fr; }
|
||||
.week-conflict-actions { flex-direction:column; }
|
||||
}
|
||||
.week-pull-conflict-review { min-width:0; margin-top:14px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
.week-pull-conflict-item { margin-bottom:6px; color:#eff6ff; font-weight:700; }
|
||||
.week-pull-conflict-actions { display:flex; gap:8px; margin-top:14px; }
|
||||
.week-pull-conflict-actions button { min-height:44px; flex:1 1 0; }
|
||||
.week-pull-conflict-status { min-height:1.4em; margin-top:8px; }
|
||||
@media (max-width:480px) { .week-pull-conflict-actions { flex-direction:column; } }
|
||||
.week-review { margin-top:14px; }
|
||||
.week-review h2 { margin-bottom:6px; }
|
||||
.week-review-days { display:grid; gap:12px; }
|
||||
@@ -386,6 +392,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
|
||||
.week-capacity-day small { color:#a9bdd3; }
|
||||
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
.week-availability-editor { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
#open-week-availability { min-height:44px; }
|
||||
.week-availability-editor h3 { margin-top:0; }
|
||||
.week-availability-grid { display:grid; grid-template-columns:1fr; gap:8px; }
|
||||
.week-availability-grid label { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,120px); gap:10px; align-items:center; }
|
||||
.week-availability-grid input { min-height:44px; min-width:0; box-sizing:border-box; }
|
||||
.week-availability-actions { display:grid; grid-template-columns:1fr; gap:8px; margin-top:12px; }
|
||||
.week-availability-actions button { min-height:44px; }
|
||||
@media (min-width:600px) { .week-availability-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .week-availability-actions { grid-template-columns:repeat(3,minmax(0,1fr)); } }
|
||||
@media (max-width:359px) { .week-capacity-hours { grid-template-columns:1fr; } }
|
||||
.week-calendar-handoff h2 { margin-bottom:6px; }
|
||||
.week-calendar-days { display:grid; gap:12px; margin:14px 0; }
|
||||
|
||||
@@ -505,6 +505,17 @@
|
||||
</div>
|
||||
<div class="small week-conflict-status" id="week-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-pull-conflict-review" id="week-pull-conflict-review" aria-labelledby="week-pull-conflict-title" tabindex="-1" hidden>
|
||||
<div class="small">Saved transfer needs review</div>
|
||||
<h3 id="week-pull-conflict-title">Resolve Today transfer</h3>
|
||||
<p id="week-pull-conflict-item" class="week-pull-conflict-item"></p>
|
||||
<p id="week-pull-conflict-context" class="small muted"></p>
|
||||
<div class="week-pull-conflict-actions">
|
||||
<button id="rebase-week-pull" type="button">Add using current plans</button>
|
||||
<button id="keep-week-pull" type="button">Keep in Week Ahead</button>
|
||||
</div>
|
||||
<div class="small week-pull-conflict-status" id="week-pull-conflict-status" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<section class="week-review" id="week-review" aria-labelledby="week-review-title" hidden>
|
||||
<div class="small">Seven-day overview</div>
|
||||
<h2 id="week-review-title">Week Ahead</h2>
|
||||
@@ -512,6 +523,26 @@
|
||||
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
|
||||
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
|
||||
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
|
||||
<button id="open-week-availability" type="button">Set weekly availability</button>
|
||||
<section class="week-availability-editor" id="week-availability-editor" aria-labelledby="week-availability-title" hidden>
|
||||
<h3 id="week-availability-title">Weekly availability</h3>
|
||||
<p class="small muted">New Week Ahead dates use these defaults. Capacity set on a specific date remains an override.</p>
|
||||
<div class="week-availability-grid">
|
||||
<label>Monday <input data-weekday-capacity="0" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Tuesday <input data-weekday-capacity="1" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Wednesday <input data-weekday-capacity="2" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Thursday <input data-weekday-capacity="3" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Friday <input data-weekday-capacity="4" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Saturday <input data-weekday-capacity="5" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
<label>Sunday <input data-weekday-capacity="6" type="number" inputmode="numeric" min="0" max="1440" step="15" /></label>
|
||||
</div>
|
||||
<div class="week-availability-actions">
|
||||
<button id="cancel-week-availability" type="button">Cancel</button>
|
||||
<button id="disable-week-availability" type="button">Disable defaults</button>
|
||||
<button id="save-week-availability" type="button">Save weekly availability</button>
|
||||
</div>
|
||||
<div id="week-availability-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<button id="open-week-reflow" type="button" hidden>Reflow remaining week</button>
|
||||
<section class="week-reflow-review" id="week-reflow-review" aria-labelledby="week-reflow-title" hidden>
|
||||
<div class="small">Capacity-safe preview</div>
|
||||
|
||||
+171
-17
@@ -10,6 +10,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const storagePrefix='stackchain.week-sync.v1.';
|
||||
const confirmedPrefix='stackchain.week-confirmed.v1.';
|
||||
const pullPrefix='stackchain.week-today-pull.v1.';
|
||||
const cloneDefaults=value=>Array.isArray(value)&&value.length===7?value.slice():null;
|
||||
const cloneDay=day=>({
|
||||
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
|
||||
estimates:{...(day.estimates||{})},
|
||||
@@ -25,6 +26,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
return sanitized;
|
||||
}
|
||||
const state=()=>({revision:week.revision,timezone:week.timezone,days:week.days.map(cloneDay),
|
||||
...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{}),
|
||||
offline_snapshot:offlineSnapshot,...(refreshedAt?{refreshed_at:refreshedAt}:{}),
|
||||
...(week.sync_pending?{base_revision:week.base_revision,sync_pending:true}:{})});
|
||||
function storageKey() {
|
||||
@@ -81,7 +83,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const key=confirmedKey();
|
||||
if(!key||!storage||week.sync_pending)return false;
|
||||
const record={refreshed_at:new Date(now()).toISOString(),week:{revision:week.revision,
|
||||
timezone:week.timezone,days:week.days.map(cloneDay)},items:{...confirmedItems}};
|
||||
timezone:week.timezone,days:week.days.map(cloneDay),
|
||||
...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{})},items:{...confirmedItems}};
|
||||
try {storage.setItem(key,JSON.stringify(record));refreshedAt=record.refreshed_at;return true;}
|
||||
catch(_error){return false;}
|
||||
}
|
||||
@@ -96,6 +99,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const value=JSON.parse(storage.getItem(key)||'null');
|
||||
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.days)?{
|
||||
revision:value.base_revision,base_revision:value.base_revision,timezone:value.timezone||null,
|
||||
...(cloneDefaults(value.availability_defaults)?{availability_defaults:cloneDefaults(value.availability_defaults)}:{}),
|
||||
days:value.days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),sync_pending:true,
|
||||
items:sanitizeItems(value.days,value.items||{}),
|
||||
...(Array.isArray(value.base_days)?{base_days:value.base_days.map(cloneDay)
|
||||
@@ -108,6 +112,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
if(!Number.isInteger(value?.revision)||!Array.isArray(value?.days)) return false;
|
||||
week={revision:value.revision,timezone:value.timezone||null,days:value.days.map(cloneDay)
|
||||
.sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
|
||||
if(cloneDefaults(value.availability_defaults))week.availability_defaults=cloneDefaults(value.availability_defaults);
|
||||
offlineSnapshot=false;
|
||||
pendingItems={};
|
||||
lastConflict=null;
|
||||
@@ -131,7 +136,12 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
}
|
||||
function day(planDate) {
|
||||
const found=week.days.find(item=>item.plan_date===planDate);
|
||||
return found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}};
|
||||
if(found&&found.capacity_minutes!==null&&found.capacity_minutes!==undefined)
|
||||
return {...cloneDay(found),capacity_source:'override'};
|
||||
const parsed=new Date(planDate+'T12:00:00Z'),weekday=(parsed.getUTCDay()+6)%7;
|
||||
const inherited=cloneDefaults(week.availability_defaults)?.[weekday];
|
||||
return {...(found?cloneDay(found):{plan_date:planDate,ids:[],capacity_minutes:null,estimates:{}}),
|
||||
...(Number.isInteger(inherited)?{capacity_minutes:inherited,capacity_source:'default'}:{capacity_source:'unset'})};
|
||||
}
|
||||
function pass(planDate) {
|
||||
const available=dates(),index=available.findIndex(item=>item.date===planDate);
|
||||
@@ -166,6 +176,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const queued={base_revision:Number.isInteger(week.base_revision)?week.base_revision:week.revision,
|
||||
timezone:timeZone(),days:days.map(cloneDay).sort((left,right)=>left.plan_date.localeCompare(right.plan_date)),
|
||||
base_days:(week.base_days||week.days).map(cloneDay)};
|
||||
if(cloneDefaults(week.availability_defaults))queued.availability_defaults=cloneDefaults(week.availability_defaults);
|
||||
queued.items=sanitizeItems(queued.days,{...confirmedItems,...pendingItems,...(pending()?.items||{})});
|
||||
try { storage.setItem(key,JSON.stringify(queued)); }
|
||||
catch(_error) { return false; }
|
||||
@@ -204,6 +215,22 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const value=day(planDate);value.capacity_minutes=capacities.get(planDate);return value;
|
||||
}));
|
||||
}
|
||||
function stageAvailabilityDefaults(values) {
|
||||
if(!cloneDefaults(values)||values.some(value=>!Number.isInteger(value)||value<0||value>1440)||offlineSnapshot)return false;
|
||||
const previous=week.availability_defaults;
|
||||
week.availability_defaults=values.slice();
|
||||
const staged=stageDays(week.days);
|
||||
if(!staged)week.availability_defaults=previous;
|
||||
return staged;
|
||||
}
|
||||
function disableAvailabilityDefaults() {
|
||||
if(offlineSnapshot)return false;
|
||||
const previous=week.availability_defaults;
|
||||
delete week.availability_defaults;
|
||||
const staged=stageDays(week.days);
|
||||
if(!staged&&previous)week.availability_defaults=previous;
|
||||
return staged;
|
||||
}
|
||||
function review() {
|
||||
const assigned=new Map();
|
||||
const days=dates().map(item=>{
|
||||
@@ -322,7 +349,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
return Boolean(stageDays(changed));
|
||||
}
|
||||
function deliveryBody(value) {
|
||||
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay)};
|
||||
return {base_revision:value.base_revision,timezone:value.timezone,days:value.days.map(cloneDay),
|
||||
...(cloneDefaults(value.availability_defaults)?{availability_defaults:cloneDefaults(value.availability_defaults)}:{})};
|
||||
}
|
||||
function reconcile(local,remote) {
|
||||
if(!Array.isArray(local.base_days)) return null;
|
||||
@@ -359,14 +387,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const merged=local&&reconcile(local,remote);
|
||||
if(merged&&!merged.conflicts.length){
|
||||
saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days})});
|
||||
body:JSON.stringify({base_revision:remote.revision,timezone:local.timezone,days:merged.days,
|
||||
...(cloneDefaults(local.availability_defaults)?{availability_defaults:cloneDefaults(local.availability_defaults)}:{})})});
|
||||
const current=pending();
|
||||
if(current&&sameBody(current,deliveryBody(local)))storage.removeItem(key);
|
||||
if(!pending())adoptConfirmed(saved,{...confirmedItems,...(local.items||{})});
|
||||
return saved;
|
||||
}
|
||||
lastConflict={key,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||
days:remote.days.map(cloneDay)},merged,choices:{...(local?.resolutions||{})}};
|
||||
days:remote.days.map(cloneDay),...(cloneDefaults(remote.availability_defaults)?
|
||||
{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})},merged,choices:{...(local?.resolutions||{})}};
|
||||
} else {
|
||||
const current=pending();
|
||||
if(current&&!sameBody(current,body))return deliver();
|
||||
@@ -399,13 +429,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
||||
estimates:{...(value.estimates||{})},
|
||||
}]).sort((left,right)=>left.plan_date.localeCompare(right.plan_date))};
|
||||
const body={base_revision:local.revision,timezone:local.timezone,days:local.days};
|
||||
const body={base_revision:local.revision,timezone:local.timezone,days:local.days,
|
||||
...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{})};
|
||||
try {
|
||||
return adoptConfirmed(await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
|
||||
} catch(error) {
|
||||
if(error?.status===409) {
|
||||
const remote=await fetchJson('api/v1/week');
|
||||
lastConflict={local,remote:{revision:remote.revision,timezone:remote.timezone,days:remote.days.map(cloneDay)}};
|
||||
lastConflict={local:{...local,...(cloneDefaults(week.availability_defaults)?{availability_defaults:cloneDefaults(week.availability_defaults)}:{})},
|
||||
remote:{revision:remote.revision,timezone:remote.timezone,days:remote.days.map(cloneDay),
|
||||
...(cloneDefaults(remote.availability_defaults)?{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})}};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -443,7 +476,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const item=lastConflict.merged.conflicts.find(value=>value.plan_date===day.plan_date);
|
||||
return item?cloneDay(choices[day.plan_date]==='account'?item.remote:item.local):cloneDay(day);
|
||||
});
|
||||
const local=lastConflict.local,body={base_revision:lastConflict.remote.revision,timezone:local.timezone,days};
|
||||
const local=lastConflict.local,body={base_revision:lastConflict.remote.revision,timezone:local.timezone,days,
|
||||
...(cloneDefaults(local.availability_defaults)?{availability_defaults:cloneDefaults(local.availability_defaults)}:{})};
|
||||
try {
|
||||
const saved=await fetchJson('api/v1/week',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
const current=pending();
|
||||
@@ -455,7 +489,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
if(error?.status===409){
|
||||
const remote=await fetchJson('api/v1/week'),merged=reconcile(local,remote);
|
||||
lastConflict={key:storageKey(),local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||
days:remote.days.map(cloneDay)},merged,choices:{...(local.resolutions||{})}};
|
||||
days:remote.days.map(cloneDay),...(cloneDefaults(remote.availability_defaults)?
|
||||
{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})},merged,choices:{...(local.resolutions||{})}};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -476,7 +511,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
if(error?.status===409){
|
||||
const remote=await fetchJson('api/v1/week');
|
||||
lastConflict={key:conflictKey,local,remote:{revision:remote.revision,timezone:remote.timezone||null,
|
||||
days:remote.days.map(cloneDay)}};
|
||||
days:remote.days.map(cloneDay),...(cloneDefaults(remote.availability_defaults)?
|
||||
{availability_defaults:cloneDefaults(remote.availability_defaults)}:{})}};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -527,6 +563,46 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const result=await fetchJson('api/v1/week/pull-item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
adoptConfirmed(result.week,{...confirmedItems,...pendingItems});return result;
|
||||
}
|
||||
function pullConflict() {
|
||||
const queue=pullQueue(),entry=queue[0];
|
||||
if(!entry?.conflict||!entry.current)return null;
|
||||
const identity=entry.body.identity,today=entry.current.today;
|
||||
const source=(entry.current.week.days||[]).find(day=>(day.ids||[]).includes(identity));
|
||||
return {identity,title:(pendingItems[identity]||confirmedItems[identity])?.title||identity,
|
||||
source_date:source?.plan_date||null,today_items:(today.ids||[]).length,
|
||||
today_minutes:(today.ids||[]).reduce((total,id)=>total+(Number(today.estimates?.[id])||0),0),
|
||||
today_capacity:Number(today.capacity_minutes)||0,operation_id:entry.body.operation_id,queue_length:queue.length};
|
||||
}
|
||||
async function resolvePullConflict(action) {
|
||||
const queue=pullQueue(),current=queue[0];
|
||||
if(!current?.conflict||!current.current||!['add','keep'].includes(action))return false;
|
||||
const latest=current.current,identity=current.body.identity;
|
||||
const alreadyToday=(latest.today.ids||[]).includes(identity);
|
||||
const stillInWeek=(latest.week.days||[]).some(day=>(day.ids||[]).includes(identity));
|
||||
let remaining=queue.slice(1);
|
||||
if(action==='add'&&!alreadyToday&&stillInWeek){
|
||||
if((latest.today.ids||[]).length>=5){const error=new Error('Today is full.');error.code='today_full';throw error;}
|
||||
const source=latest.week.days.find(day=>(day.ids||[]).includes(identity));
|
||||
const estimate=Number(source?.estimates?.[identity])||0;
|
||||
const minutes=(latest.today.ids||[]).reduce((total,id)=>total+(Number(latest.today.estimates?.[id])||0),0);
|
||||
const capacity=Number(latest.today.capacity_minutes)||0;
|
||||
if(capacity&&minutes+estimate>capacity&&!current.body.allow_over_capacity){
|
||||
const error=new Error('Today would exceed its current capacity.');error.code='today_over_capacity';throw error;
|
||||
}
|
||||
const rebased={...current,conflict:false,current:undefined,body:{...current.body,
|
||||
today_revision:latest.today.revision,week_revision:latest.week.revision}};
|
||||
remaining=[rebased,...remaining];
|
||||
}
|
||||
if(remaining.length){
|
||||
const head=remaining[0];
|
||||
remaining[0]={...head,conflict:false,current:undefined,body:{...head.body,
|
||||
today_revision:latest.today.revision,week_revision:latest.week.revision}};
|
||||
}
|
||||
if(!writePullQueue(remaining))throw new Error('Could not update queued Week Ahead work on this device.');
|
||||
adopt(latest.week);
|
||||
if(!remaining.length)return {...latest,sync_pending:false,resolved:alreadyToday?'already-moved':(!stillInWeek?'no-longer-planned':'kept')};
|
||||
return flushPull();
|
||||
}
|
||||
function flushPull() {
|
||||
if(pulling)return pulling;
|
||||
if(!pendingPull()||!pullKey())return Promise.resolve(false);
|
||||
@@ -598,8 +674,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
|
||||
return label+(pending()?' · sync pending':'');
|
||||
}
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,pullConflict,resolvePullConflict,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
||||
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
|
||||
}
|
||||
@@ -629,7 +705,40 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
|
||||
}
|
||||
if(retry)retry.hidden=!offline;
|
||||
if(capacity)capacity.hidden=offline;
|
||||
const availability=qs('#open-week-availability');if(availability)availability.hidden=offline;
|
||||
}
|
||||
function closeAvailability({focus=false}={}) {
|
||||
const panel=qs('#week-availability-editor');if(panel)panel.hidden=true;
|
||||
if(focus)qs('#open-week-availability')?.focus?.();
|
||||
}
|
||||
function openAvailability() {
|
||||
const panel=qs('#week-availability-editor'),defaults=controller.state().availability_defaults||[0,0,0,0,0,0,0];
|
||||
panel?.querySelectorAll?.('[data-weekday-capacity]').forEach(input=>{input.value=String(defaults[Number(input.dataset.weekdayCapacity)]||0);});
|
||||
if(panel){panel.hidden=false;panel.querySelector?.('[data-weekday-capacity]')?.focus?.();}
|
||||
}
|
||||
async function saveAvailability() {
|
||||
const panel=qs('#week-availability-editor'),inputs=[...(panel?.querySelectorAll?.('[data-weekday-capacity]')||[])];
|
||||
const values=inputs.map(input=>Number(input.value));
|
||||
const status=qs('#week-availability-status');
|
||||
if(values.length!==7||values.some(value=>!Number.isInteger(value)||value<0||value>1440)){
|
||||
if(status)status.textContent='Enter 0 to 1440 minutes for every weekday.';return false;
|
||||
}
|
||||
if(!controller.stageAvailabilityDefaults(values))return false;
|
||||
closeAvailability();renderReview();
|
||||
try{await controller.flush();renderReview();if(status)status.textContent='Weekly availability saved.';
|
||||
qs('#week-review-status').textContent='Weekly availability saved.';return true;}
|
||||
catch(error){if(status)status.textContent=(error.message||'Sync is unavailable.')+' Weekly availability remains saved on this phone.';return false;}
|
||||
}
|
||||
async function disableAvailability() {
|
||||
if(!controller.disableAvailabilityDefaults?.())return false;
|
||||
closeAvailability();renderReview();
|
||||
try{await controller.flush();renderReview();qs('#week-review-status').textContent='Weekly availability disabled.';return true;}
|
||||
catch(error){qs('#week-review-status').textContent=(error.message||'Sync is unavailable.')+' Change remains saved on this phone.';return false;}
|
||||
}
|
||||
qs('#open-week-availability')?.addEventListener('click',openAvailability);
|
||||
qs('#cancel-week-availability')?.addEventListener('click',()=>closeAvailability({focus:true}));
|
||||
qs('#save-week-availability')?.addEventListener('click',saveAvailability);
|
||||
qs('#disable-week-availability')?.addEventListener('click',disableAvailability);
|
||||
function reconciliationDay(){
|
||||
if(!reconciliation)return null;
|
||||
const due=(reconciliation.week?.days||[]).find(day=>day.ids?.length);
|
||||
@@ -791,7 +900,8 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
|
||||
};
|
||||
root.innerHTML=value.days.map(day=>{
|
||||
const capacity=Number(day.capacity_minutes)||0;
|
||||
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+(day.overloaded?' · over capacity':'');
|
||||
const source=day.capacity_source==='default'?' · Default':(day.capacity_source==='override'?' · Override':'');
|
||||
const load=day.planned_minutes+(capacity?' of '+capacity:'')+' min'+source+(day.overloaded?' · over capacity':'');
|
||||
const move=id=>overviewing?'':'<div class="week-review-move"><label>Move to <select data-week-move-destination="'+escapeAttribute(id)+'">'+destinations+
|
||||
'</select></label><button type="button" data-week-move="'+escapeAttribute(id)+'">Move</button></div>';
|
||||
const unplan=id=>overviewing&&!readOnly?'<button type="button" class="week-review-unplan" data-week-unplan="'+escapeAttribute(id)+'">Remove from week</button>':'';
|
||||
@@ -1013,19 +1123,63 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
|
||||
qs('#my-work-action-status').textContent=message;
|
||||
return value;
|
||||
}
|
||||
function renderPullConflict({open=false}={}) {
|
||||
const detail=controller.pullConflict?.(),root=qs('#week-pull-conflict-review');
|
||||
if(!root)return false;
|
||||
root.hidden=!detail;
|
||||
if(!detail)return false;
|
||||
const item=qs('#week-pull-conflict-item'),context=qs('#week-pull-conflict-context');
|
||||
if(item)item.textContent=detail.title;
|
||||
if(context)context.textContent=(detail.source_date?'Planned for '+detail.source_date+' · ':'')+
|
||||
'Today has '+detail.today_items+' item'+(detail.today_items===1?'':'s')+' · '+detail.today_minutes+
|
||||
(detail.today_capacity?' of '+detail.today_capacity:'')+' min'+(detail.queue_length>1?' · '+detail.queue_length+' saved transfers waiting':'')+'.';
|
||||
if(open){openPlanner(null,false);const title=qs('#plan-today-title');if(title)title.textContent='Resolve Today transfer';}
|
||||
root.focus?.();
|
||||
return detail;
|
||||
}
|
||||
async function resolvePullConflict(action) {
|
||||
const status=qs('#week-pull-conflict-status'),add=qs('#rebase-week-pull'),keep=qs('#keep-week-pull');
|
||||
if(add)add.disabled=true;if(keep)keep.disabled=true;
|
||||
if(status)status.textContent=action==='add'?'Checking current plans…':'Keeping this work in Week Ahead…';
|
||||
try{
|
||||
const result=await controller.resolvePullConflict?.(action);
|
||||
if(!result)return false;
|
||||
adoptPull(result,action==='add'?'Saved transfer added using current plans.':'Work kept in Week Ahead.');
|
||||
if(controller.pullConflict?.())return renderPullConflict();
|
||||
const root=qs('#week-pull-conflict-review');if(root)root.hidden=true;
|
||||
overviewing=true;reviewing=true;setReviewMode(true);
|
||||
const reviewRoot=qs('#week-review');if(reviewRoot){reviewRoot.hidden=false;renderReview();}
|
||||
return result;
|
||||
}catch(error){
|
||||
if(status)status.textContent=error?.code==='today_full'?'Today is full. Finish or move work, then retry.':
|
||||
(error?.code==='today_over_capacity'?'Today would exceed its current capacity. Reopen Week Ahead and review Today first.':
|
||||
(error.message||'Current plans could not be checked.')+' Your saved transfer is still waiting.');
|
||||
return false;
|
||||
}finally{if(add)add.disabled=false;if(keep)keep.disabled=false;}
|
||||
}
|
||||
qs('#rebase-week-pull')?.addEventListener('click',()=>controller.resolvePullConflict&&resolvePullConflict('add'));
|
||||
qs('#keep-week-pull')?.addEventListener('click',()=>controller.resolvePullConflict&&resolvePullConflict('keep'));
|
||||
function resumePull() {
|
||||
const restored=controller.resumePull?.();
|
||||
return restored?adoptPull(restored,'Week-to-Today pull saved on this phone · sync pending.'):false;
|
||||
if(!restored)return false;
|
||||
const adopted=adoptPull(restored,restored.conflict?'Today or Week Ahead changed. Choose how to finish the saved transfer.':
|
||||
'Week-to-Today pull saved on this phone · sync pending.');
|
||||
if(restored.conflict)renderPullConflict({open:true});
|
||||
return adopted;
|
||||
}
|
||||
async function flushPull() {
|
||||
const pendingPull=controller.pendingPull?.();
|
||||
if(!pendingPull)return false;
|
||||
if(pendingPull.conflict)return adoptPull(pendingPull.current,
|
||||
'Today or Week Ahead changed on another device. Saved pull needs review.');
|
||||
if(pendingPull.conflict){
|
||||
const restored=adoptPull(pendingPull.current,'Today or Week Ahead changed on another device. Choose how to finish the saved transfer.');
|
||||
renderPullConflict({open:true});return restored;
|
||||
}
|
||||
try {
|
||||
const result=await controller.flushPull();
|
||||
return adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Saved pull needs review.':
|
||||
const adopted=adoptPull(result,result.conflict?'Today or Week Ahead changed on another device. Choose how to finish the saved transfer.':
|
||||
'Week Ahead work added to Today.');
|
||||
if(result.conflict)renderPullConflict({open:true});
|
||||
return adopted;
|
||||
} catch(error) {
|
||||
qs('#my-work-action-status').textContent=(error.message||'Week-to-Today sync is unavailable.')+' Saved on this phone · sync pending.';
|
||||
return false;
|
||||
|
||||
@@ -78,6 +78,19 @@ STORES = (
|
||||
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
|
||||
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
|
||||
)),
|
||||
Store(
|
||||
"completed-filed-reviews",
|
||||
"completed-filed-reviews",
|
||||
"STACKCHAIN_COMPLETED_FILED_REVIEW_DB",
|
||||
"completed-filed-reviews.sqlite3",
|
||||
(
|
||||
Table(
|
||||
"completed_filed_review_collections",
|
||||
("login",),
|
||||
(Field("receipts", "receipts:{login}"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", (
|
||||
Table("security_events", ("id",), (Field("payload", "event:{id}"),)),
|
||||
)),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Durable, account-scoped completed Filed review receipts."""
|
||||
"""Durable, encrypted, account-scoped completed Filed review receipts."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -6,33 +6,68 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
class CompletedFiledReviewStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 200,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_config(),
|
||||
store="completed-filed-reviews",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA secure_delete=ON")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS completed_filed_reviews (
|
||||
login TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
touched_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (login, repository, issue_number)
|
||||
CREATE TABLE IF NOT EXISTS completed_filed_review_collections (
|
||||
login TEXT PRIMARY KEY,
|
||||
receipts TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
legacy = connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'completed_filed_reviews'"
|
||||
).fetchone()
|
||||
if legacy:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT login, repository, issue_number, updated_at "
|
||||
"FROM completed_filed_reviews ORDER BY login, touched_at"
|
||||
).fetchall()
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
for login, repository, number, updated_at in rows:
|
||||
grouped.setdefault(login, []).append({
|
||||
"repository": repository,
|
||||
"number": int(number),
|
||||
"updated_at": updated_at,
|
||||
})
|
||||
for login, receipts in grouped.items():
|
||||
normalized = [self._receipt(item) for item in receipts][-self.limit:]
|
||||
connection.execute(
|
||||
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
|
||||
(login, self._seal(login, normalized)),
|
||||
)
|
||||
connection.execute("DROP TABLE completed_filed_reviews")
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return connect_private_sqlite(self.path, timeout=self.timeout)
|
||||
@@ -45,7 +80,7 @@ class CompletedFiledReviewStore:
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _receipt(raw: dict) -> tuple[str, int, str]:
|
||||
def _receipt(raw: dict) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("receipt must be an object")
|
||||
repository = raw.get("repository")
|
||||
@@ -63,23 +98,41 @@ class CompletedFiledReviewStore:
|
||||
raise ValueError("updated_at is invalid") from error
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("updated_at is invalid")
|
||||
return repository, number, updated_at
|
||||
return {"repository": repository, "number": number, "updated_at": updated_at}
|
||||
|
||||
def _open(self, login: str, payload: str | None) -> tuple[list[dict], bool]:
|
||||
if payload is None:
|
||||
return [], False
|
||||
value, stale = self._cipher.open(payload, binding=f"receipts:{login}")
|
||||
if not isinstance(value, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
try:
|
||||
return [self._receipt(item) for item in value], stale
|
||||
except ValueError as error:
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted") from error
|
||||
|
||||
def _seal(self, login: str, receipts: list[dict]) -> str:
|
||||
return self._cipher.seal(receipts, binding=f"receipts:{login}")
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(rows) -> dict:
|
||||
return {"receipts": [
|
||||
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
|
||||
for row in rows
|
||||
]}
|
||||
def _snapshot(receipts: list[dict]) -> dict:
|
||||
return {"receipts": receipts}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? ORDER BY touched_at",
|
||||
(self._login(login),),
|
||||
).fetchall()
|
||||
return self._snapshot(rows)
|
||||
row = connection.execute(
|
||||
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
|
||||
(login,),
|
||||
).fetchone()
|
||||
receipts, stale = self._open(login, row[0] if row else None)
|
||||
if row is not None and stale:
|
||||
connection.execute(
|
||||
"UPDATE completed_filed_review_collections SET receipts = ? "
|
||||
"WHERE login = ? AND receipts = ?",
|
||||
(self._seal(login, receipts), login, row[0]),
|
||||
)
|
||||
return self._snapshot(receipts)
|
||||
|
||||
def merge(self, login: str, receipts: list[dict]) -> dict:
|
||||
login = self._login(login)
|
||||
@@ -88,35 +141,25 @@ class CompletedFiledReviewStore:
|
||||
normalized = [self._receipt(receipt) for receipt in receipts]
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
touched = int(connection.execute(
|
||||
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
|
||||
row = connection.execute(
|
||||
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
|
||||
(login,),
|
||||
).fetchone()[0])
|
||||
for repository, number, updated_at in normalized:
|
||||
current = connection.execute(
|
||||
"SELECT updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? AND repository = ? AND issue_number = ?",
|
||||
(login, repository, number),
|
||||
).fetchone()
|
||||
if current is not None and current[0] >= updated_at:
|
||||
).fetchone()
|
||||
current, _stale = self._open(login, row[0] if row else None)
|
||||
for incoming in normalized:
|
||||
match = next((
|
||||
item for item in current
|
||||
if item["repository"] == incoming["repository"] and item["number"] == incoming["number"]
|
||||
), None)
|
||||
if match is not None and match["updated_at"] >= incoming["updated_at"]:
|
||||
continue
|
||||
touched += 1
|
||||
connection.execute(
|
||||
"INSERT INTO completed_filed_reviews "
|
||||
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
|
||||
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
|
||||
(login, repository, number, updated_at, touched),
|
||||
)
|
||||
if match is not None:
|
||||
current.remove(match)
|
||||
current.append(incoming)
|
||||
current = current[-self.limit:]
|
||||
connection.execute(
|
||||
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
|
||||
"SELECT rowid FROM completed_filed_reviews WHERE login = ? "
|
||||
"ORDER BY touched_at DESC LIMIT ?)",
|
||||
(login, login, self.limit),
|
||||
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
|
||||
(login, self._seal(login, current)),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
|
||||
"WHERE login = ? ORDER BY touched_at",
|
||||
(login,),
|
||||
).fetchall()
|
||||
return self._snapshot(rows)
|
||||
return self._snapshot(current)
|
||||
@@ -738,6 +738,9 @@ class WeekPlanUpdate(BaseModel):
|
||||
base_revision: int = Field(ge=0)
|
||||
days: list[WeekPlanDay] = Field(max_length=7)
|
||||
timezone: str = Field(min_length=1, max_length=100)
|
||||
availability_defaults: list[int] | None = Field(
|
||||
default=None, min_length=7, max_length=7,
|
||||
)
|
||||
|
||||
|
||||
class WeekPromotion(BaseModel):
|
||||
@@ -2828,6 +2831,7 @@ async def replace_week_plan(payload: WeekPlanUpdate):
|
||||
base_revision=payload.base_revision,
|
||||
days=[day.model_dump() for day in payload.days],
|
||||
timezone=payload.timezone,
|
||||
availability_defaults=payload.availability_defaults,
|
||||
)
|
||||
except WeekPlanConflict as error:
|
||||
raise HTTPException(
|
||||
|
||||
+30
-5
@@ -519,7 +519,10 @@ class TodayStore:
|
||||
)
|
||||
return {"revision": 1, **payload}
|
||||
|
||||
def _normalize_week(self, *, days: list[dict], timezone: str) -> dict:
|
||||
def _normalize_week(
|
||||
self, *, days: list[dict], timezone: str,
|
||||
availability_defaults: list[int] | None = None,
|
||||
) -> dict:
|
||||
if not isinstance(days, list) or len(days) > 7:
|
||||
raise ValueError("Week Ahead is limited to seven dates")
|
||||
if not isinstance(timezone, str) or not timezone.strip() or len(timezone) > 100:
|
||||
@@ -555,13 +558,32 @@ class TodayStore:
|
||||
plan.pop("timezone")
|
||||
normalized.append(plan)
|
||||
normalized.sort(key=lambda item: item["plan_date"])
|
||||
return {"timezone": timezone, "days": normalized}
|
||||
result = {"timezone": timezone, "days": normalized}
|
||||
if availability_defaults is not None:
|
||||
if (
|
||||
not isinstance(availability_defaults, list)
|
||||
or len(availability_defaults) != 7
|
||||
or any(
|
||||
not isinstance(value, int) or isinstance(value, bool)
|
||||
or value < 0 or value > 1440
|
||||
for value in availability_defaults
|
||||
)
|
||||
):
|
||||
raise ValueError("availability defaults must contain seven weekday capacities")
|
||||
result["availability_defaults"] = list(availability_defaults)
|
||||
return result
|
||||
|
||||
def replace_week(self, login: str, *, base_revision: int, days: list[dict], timezone: str) -> dict:
|
||||
def replace_week(
|
||||
self, login: str, *, base_revision: int, days: list[dict], timezone: str,
|
||||
availability_defaults: list[int] | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
||||
raise ValueError("base_revision must be a non-negative integer")
|
||||
normalized = self._normalize_week(days=days, timezone=timezone)
|
||||
normalized = self._normalize_week(
|
||||
days=days, timezone=timezone,
|
||||
availability_defaults=availability_defaults,
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
@@ -649,7 +671,10 @@ class TodayStore:
|
||||
and not allow_over_capacity
|
||||
):
|
||||
raise ValueError("explicit overload confirmation is required")
|
||||
normalized_week = self._normalize_week(days=days, timezone=week["timezone"])
|
||||
normalized_week = self._normalize_week(
|
||||
days=days, timezone=week["timezone"],
|
||||
availability_defaults=week.get("availability_defaults"),
|
||||
)
|
||||
|
||||
today_ids = [item_id for item_id in today["ids"] if item_id != identity]
|
||||
today_estimates = {
|
||||
|
||||
@@ -65,6 +65,8 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"revision": body["base_revision"] + 1,
|
||||
"timezone": body["timezone"], "days": body["days"],
|
||||
**({"availability_defaults": body["availability_defaults"]}
|
||||
if "availability_defaults" in body else {}),
|
||||
}))
|
||||
return
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
@@ -107,6 +109,29 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||
"Week Ahead overview · no changes made."
|
||||
)
|
||||
expect(page.locator("#confirm-week-plan")).to_be_hidden()
|
||||
availability = page.locator("#open-week-availability")
|
||||
expect(availability).to_be_visible()
|
||||
availability_bounds = availability.bounding_box()
|
||||
assert availability_bounds and availability_bounds["height"] >= 44
|
||||
availability.click()
|
||||
editor = page.locator("#week-availability-editor")
|
||||
expect(editor).to_be_visible()
|
||||
capacities = editor.locator("[data-weekday-capacity]")
|
||||
expect(capacities).to_have_count(7)
|
||||
for index, minutes in enumerate((480, 480, 480, 480, 360, 0, 0)):
|
||||
capacities.nth(index).fill(str(minutes))
|
||||
bounds = capacities.nth(index).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#save-week-availability").click()
|
||||
expect(editor).to_be_hidden()
|
||||
expect(page.locator("#week-review-status")).to_have_text("Weekly availability saved.")
|
||||
assert saved[-1]["availability_defaults"] == [480, 480, 480, 480, 360, 0, 0]
|
||||
availability.click()
|
||||
page.locator("#disable-week-availability").click()
|
||||
expect(editor).to_be_hidden()
|
||||
expect(page.locator("#week-review-status")).to_have_text("Weekly availability disabled.")
|
||||
assert "availability_defaults" not in saved[-1]
|
||||
edit_week = page.locator("#edit-week-plan")
|
||||
expect(edit_week).to_be_visible()
|
||||
remove_actions = cards.first.locator("[data-week-unplan]")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -21,6 +23,30 @@ def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path)
|
||||
assert store.get("alexander") == {"receipts": []}
|
||||
|
||||
|
||||
def test_receipt_history_is_encrypted_and_bound_to_its_account(tmp_path):
|
||||
database = tmp_path / "completed-filed.sqlite3"
|
||||
store = CompletedFiledReviewStore(database, encryption_key=b"f" * 32)
|
||||
timmy = receipt("private-timmy/repository", 912)
|
||||
alexander = receipt("private-alexander/repository", 427)
|
||||
|
||||
store.merge("timmy-private-login", [timmy])
|
||||
store.merge("alexander-private-login", [alexander])
|
||||
|
||||
raw = database.read_bytes()
|
||||
assert b"private-timmy/repository" not in raw
|
||||
assert b"private-alexander/repository" not in raw
|
||||
with sqlite3.connect(database) as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT login, receipts FROM completed_filed_review_collections ORDER BY login"
|
||||
).fetchall()
|
||||
connection.execute(
|
||||
"UPDATE completed_filed_review_collections SET receipts = ? WHERE login = ?",
|
||||
(rows[1][1], rows[0][0]),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="private state could not be decrypted"):
|
||||
store.get("alexander-private-login")
|
||||
|
||||
|
||||
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
|
||||
newer = receipt(updated_at="2026-08-16T12:00:00Z")
|
||||
@@ -32,6 +58,42 @@ def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
|
||||
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
|
||||
|
||||
|
||||
def test_legacy_plaintext_receipts_migrate_for_every_account_without_loss(tmp_path):
|
||||
database = tmp_path / "completed-filed.sqlite3"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE completed_filed_reviews ("
|
||||
"login TEXT NOT NULL, repository TEXT NOT NULL, issue_number INTEGER NOT NULL, "
|
||||
"updated_at TEXT NOT NULL, touched_at INTEGER NOT NULL, "
|
||||
"PRIMARY KEY (login, repository, issue_number))"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO completed_filed_reviews VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("timmy", "legacy-private/first", 1, "2026-08-14T12:00:00Z", 1),
|
||||
("timmy", "legacy-private/second", 2, "2026-08-15T12:00:00Z", 2),
|
||||
("alexander", "legacy-private/other", 3, "2026-08-16T12:00:00Z", 1),
|
||||
],
|
||||
)
|
||||
|
||||
store = CompletedFiledReviewStore(database, encryption_key=b"m" * 32)
|
||||
|
||||
assert store.get("timmy") == {"receipts": [
|
||||
receipt("legacy-private/first", 1, "2026-08-14T12:00:00Z"),
|
||||
receipt("legacy-private/second", 2, "2026-08-15T12:00:00Z"),
|
||||
]}
|
||||
assert store.get("alexander") == {"receipts": [
|
||||
receipt("legacy-private/other", 3, "2026-08-16T12:00:00Z")
|
||||
]}
|
||||
raw = database.read_bytes()
|
||||
assert b"legacy-private/first" not in raw
|
||||
assert b"legacy-private/other" not in raw
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT count(*) FROM sqlite_master WHERE name = 'completed_filed_reviews'"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("candidate", [
|
||||
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
|
||||
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},
|
||||
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from src.completed_filed_review_store import CompletedFiledReviewStore
|
||||
from src.saved_search_store import SavedSearchStore
|
||||
|
||||
|
||||
@@ -81,3 +82,32 @@ def test_rotation_command_fails_closed_and_never_prints_private_content(tmp_path
|
||||
assert secret not in completed.stdout
|
||||
assert "timmy" not in completed.stdout
|
||||
assert completed.stderr == ""
|
||||
|
||||
|
||||
def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tmp_path):
|
||||
state = tmp_path / "state"
|
||||
path = state / "completed-filed-reviews.sqlite3"
|
||||
private_receipt = {
|
||||
"repository": "private-rotation/canary",
|
||||
"number": 1252,
|
||||
"updated_at": "2026-08-22T12:00:00Z",
|
||||
}
|
||||
CompletedFiledReviewStore(path, encryption_key=b"o" * 32).merge("timmy", [private_receipt])
|
||||
|
||||
completed = run_rotation(state)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
report = json.loads(completed.stdout)
|
||||
assert report["completed-filed-reviews"] == {
|
||||
"current": 0, "failed": 0, "migrated": 1, "total": 1
|
||||
}
|
||||
assert "private-rotation" not in completed.stdout
|
||||
assert "timmy" not in completed.stdout
|
||||
with sqlite3.connect(path) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT receipts FROM completed_filed_review_collections WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v2:next:")
|
||||
assert CompletedFiledReviewStore(
|
||||
path, encryption_key=({"next": b"n" * 32}, "next")
|
||||
).get("timmy") == {"receipts": [private_receipt]}
|
||||
@@ -51,6 +51,42 @@ def test_week_plan_is_encrypted_account_scoped_revisioned_and_preserves_other_da
|
||||
assert conflict.value.snapshot == updated
|
||||
|
||||
|
||||
def test_week_plan_persists_bounded_weekly_availability_with_the_revisioned_plan(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, encryption_key=b"w" * 32)
|
||||
defaults = [0, 480, 420, 360, 300, 240, 0]
|
||||
|
||||
saved = store.replace_week(
|
||||
"timmy", base_revision=0, days=sample_days(), timezone="UTC",
|
||||
availability_defaults=defaults,
|
||||
)
|
||||
|
||||
assert saved["availability_defaults"] == defaults
|
||||
assert store.get_week("timmy")["availability_defaults"] == defaults
|
||||
retained = path.read_bytes()
|
||||
assert b'"availability_defaults"' not in retained
|
||||
|
||||
with pytest.raises(ValueError, match="seven weekday capacities"):
|
||||
store.replace_week(
|
||||
"timmy", base_revision=1, days=sample_days(), timezone="UTC",
|
||||
availability_defaults=[60] * 6,
|
||||
)
|
||||
|
||||
|
||||
def test_week_update_model_accepts_zero_capacity_weekday_defaults():
|
||||
payload = main.WeekPlanUpdate.model_validate({
|
||||
"base_revision": 2, "timezone": "UTC", "days": [],
|
||||
"availability_defaults": [0, 480, 480, 480, 480, 300, 0],
|
||||
})
|
||||
|
||||
assert payload.model_dump()["availability_defaults"] == [0, 480, 480, 480, 480, 300, 0]
|
||||
with pytest.raises(ValueError):
|
||||
main.WeekPlanUpdate.model_validate({
|
||||
"base_revision": 2, "timezone": "UTC", "days": [],
|
||||
"availability_defaults": [60] * 8,
|
||||
})
|
||||
|
||||
|
||||
def test_start_day_plan_selects_earliest_nonempty_week_date_without_mutation(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"r" * 32,
|
||||
@@ -507,6 +543,7 @@ def test_reschedule_today_item_atomically_moves_it_into_selected_week_day(tmp_pa
|
||||
"timmy",
|
||||
base_revision=0,
|
||||
timezone="UTC",
|
||||
availability_defaults=[0, 480, 480, 480, 480, 0, 0],
|
||||
days=[{
|
||||
"plan_date": "2026-08-24",
|
||||
"ids": [active_id],
|
||||
@@ -531,6 +568,7 @@ def test_reschedule_today_item_atomically_moves_it_into_selected_week_day(tmp_pa
|
||||
)
|
||||
|
||||
assert result["today"]["ids"] == [other_id]
|
||||
assert result["week"]["availability_defaults"] == [0, 480, 480, 480, 480, 0, 0]
|
||||
assert result["week"]["days"] == [{
|
||||
"plan_date": "2026-08-24", "ids": [], "capacity_minutes": 120, "estimates": {},
|
||||
}, {
|
||||
|
||||
@@ -8,6 +8,8 @@ import pytest
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
CONTROLLER = FRONTEND / "week-plan.js"
|
||||
COORDINATOR = FRONTEND / "outbox-coordinator.js"
|
||||
INDEX = FRONTEND / "index.html"
|
||||
CSS = FRONTEND / "dashboard.css"
|
||||
|
||||
|
||||
def run_controller(scenario: str) -> dict:
|
||||
@@ -20,6 +22,49 @@ const createOutboxCoordinator = require({json.dumps(str(COORDINATOR))});
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_weekly_availability_editor_is_mobile_safe_and_wired_to_durable_defaults():
|
||||
markup = INDEX.read_text()
|
||||
styles = CSS.read_text()
|
||||
source = CONTROLLER.read_text()
|
||||
|
||||
assert 'id="open-week-availability"' in markup
|
||||
assert 'id="week-availability-editor"' in markup
|
||||
assert markup.count('data-weekday-capacity=') == 7
|
||||
assert 'id="save-week-availability"' in markup
|
||||
assert 'id="disable-week-availability"' in markup
|
||||
assert 'aria-labelledby="week-availability-title"' in markup
|
||||
assert ".week-availability-grid input { min-height:44px" in styles
|
||||
assert "grid-template-columns:1fr" in styles
|
||||
assert "stageAvailabilityDefaults(values)" in source
|
||||
assert "Weekly availability saved" in source
|
||||
|
||||
|
||||
def test_conflicted_pull_review_has_accessible_touch_safe_recovery_actions():
|
||||
markup = INDEX.read_text()
|
||||
styles = CSS.read_text()
|
||||
|
||||
assert 'id="week-pull-conflict-review"' in markup
|
||||
assert 'aria-labelledby="week-pull-conflict-title"' in markup
|
||||
assert 'id="week-pull-conflict-item"' in markup
|
||||
assert 'id="week-pull-conflict-context"' in markup
|
||||
assert 'id="rebase-week-pull"' in markup
|
||||
assert 'id="keep-week-pull"' in markup
|
||||
assert "Add using current plans" in markup
|
||||
assert "Keep in Week Ahead" in markup
|
||||
assert ".week-pull-conflict-actions button { min-height:44px" in styles
|
||||
assert "overflow-wrap:anywhere" in styles
|
||||
|
||||
|
||||
def test_week_workflow_wires_both_conflict_decisions_to_the_durable_controller():
|
||||
source = CONTROLLER.read_text()
|
||||
|
||||
assert "resolvePullConflict('add')" in source
|
||||
assert "resolvePullConflict('keep')" in source
|
||||
assert "controller.pullConflict?.()" in source
|
||||
assert "renderPullConflict" in source
|
||||
assert "#week-pull-conflict-review" in source
|
||||
|
||||
|
||||
def test_week_controller_requires_executable_days_before_calendar_handoff():
|
||||
result = run_controller("""
|
||||
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
@@ -149,6 +194,36 @@ console.log(JSON.stringify({before,moved,after,pending:week.pending()}));
|
||||
assert result["pending"]["days"][2]["estimates"] == {"shared": 30}
|
||||
|
||||
|
||||
def test_week_controller_applies_weekday_defaults_to_new_dates_and_keeps_date_overrides():
|
||||
result = run_controller("""
|
||||
const values=new Map();const requests=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const fetchJson=async(url,options={})=>{
|
||||
requests.push(options.body?JSON.parse(options.body):null);
|
||||
if(!options.method)return {revision:3,timezone:'UTC',availability_defaults:[60,120,180,240,300,0,0],days:[
|
||||
{plan_date:'2026-08-22',ids:['override'],capacity_minutes:45,estimates:{override:30}}
|
||||
]};
|
||||
return {revision:4,...JSON.parse(options.body)};
|
||||
};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,
|
||||
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
await week.load();
|
||||
const inherited=week.day('2026-08-21');
|
||||
const overridden=week.day('2026-08-22');
|
||||
const staged=week.stageAvailabilityDefaults([90,120,180,240,300,0,0]);
|
||||
await week.flush();
|
||||
console.log(JSON.stringify({inherited,overridden,staged,request:requests.at(-1),state:week.state()}));
|
||||
""")
|
||||
|
||||
assert result["inherited"]["capacity_minutes"] == 300
|
||||
assert result["inherited"]["capacity_source"] == "default"
|
||||
assert result["overridden"]["capacity_minutes"] == 45
|
||||
assert result["overridden"]["capacity_source"] == "override"
|
||||
assert result["staged"]["sync_pending"] is True
|
||||
assert result["request"]["availability_defaults"] == [90, 120, 180, 240, 300, 0, 0]
|
||||
assert result["state"]["availability_defaults"] == [90, 120, 180, 240, 300, 0, 0]
|
||||
|
||||
|
||||
def test_week_controller_applies_seven_imported_capacities_in_one_local_transition():
|
||||
result = run_controller("""
|
||||
const values=new Map();let writes=0;
|
||||
@@ -514,6 +589,116 @@ console.log(JSON.stringify({outcome,state:week.state(),pending:week.pendingPull(
|
||||
]
|
||||
|
||||
|
||||
def test_week_controller_describes_the_conflicted_transfer_from_current_plans():
|
||||
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 remoteToday={revision:9,ids:['active'],capacity_minutes:90,estimates:{active:30}};
|
||||
const remoteWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:45}}]};
|
||||
const fetchJson=async(url,options={})=>{if(options.method){const error=new Error('changed');error.status=409;throw error;}return url.endsWith('/today')?remoteToday:remoteWeek;};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:45}}]});
|
||||
week.rememberItems({'pull':{title:'Ship recovery',kind:'issue',repository:'acme/app',number:7}});
|
||||
await week.pullItem('pull',{revision:4,ids:['old'],capacity_minutes:90,estimates:{old:30}},'stable-pull');
|
||||
console.log(JSON.stringify(week.pullConflict()));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"identity": "pull", "title": "Ship recovery", "source_date": "2026-08-21",
|
||||
"today_items": 1, "today_minutes": 30, "today_capacity": 90,
|
||||
"operation_id": "stable-pull", "queue_length": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_week_controller_rechecks_capacity_before_rebasing_a_conflicted_pull():
|
||||
result = run_controller("""
|
||||
const values=new Map();let posts=0;
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const remoteToday={revision:9,ids:['active'],capacity_minutes:50,estimates:{active:40}};
|
||||
const remoteWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:20}}]};
|
||||
const fetchJson=async(url,options={})=>{if(options.method){posts+=1;const error=new Error('changed');error.status=409;throw error;}return url.endsWith('/today')?remoteToday:remoteWeek;};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['pull'],capacity_minutes:90,estimates:{pull:20}}]});
|
||||
await week.pullItem('pull',{revision:4,ids:['old'],capacity_minutes:90,estimates:{old:30}},'stable-pull');
|
||||
let code=null;try{await week.resolvePullConflict('add');}catch(error){code=error.code;}
|
||||
console.log(JSON.stringify({code,posts,pending:week.pendingPull()}));
|
||||
""")
|
||||
|
||||
assert result["code"] == "today_over_capacity"
|
||||
assert result["posts"] == 1
|
||||
assert result["pending"]["conflict"] is True
|
||||
|
||||
|
||||
def test_week_controller_rebases_a_conflicted_pull_and_drains_the_fifo_with_stable_operations():
|
||||
result = run_controller("""
|
||||
const values=new Map(),delivered=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let offline=true;
|
||||
let serverToday={revision:10,ids:['active'],capacity_minutes:180,estimates:{active:30}};
|
||||
let serverWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]};
|
||||
const fetchJson=async(url,options={})=>{
|
||||
if(offline)throw new Error('offline');
|
||||
if(!options.method)return url.endsWith('/today')?serverToday:serverWeek;
|
||||
const body=JSON.parse(options.body);delivered.push(body);
|
||||
if(body.today_revision!==serverToday.revision||body.week_revision!==serverWeek.revision){const error=new Error('changed');error.status=409;throw error;}
|
||||
serverToday={...serverToday,revision:serverToday.revision+1,ids:[...serverToday.ids,body.identity],estimates:{...serverToday.estimates,[body.identity]:serverWeek.days[0].estimates[body.identity]}};
|
||||
serverWeek={...serverWeek,revision:serverWeek.revision+1,days:serverWeek.days.map(day=>({...day,ids:day.ids.filter(id=>id!==body.identity),estimates:Object.fromEntries(Object.entries(day.estimates).filter(([id])=>id!==body.identity))}))};
|
||||
return {today:serverToday,week:serverWeek};
|
||||
};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]});
|
||||
let today={revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}};
|
||||
today=(await week.pullItem('first',today,'pull-first')).today;
|
||||
await week.pullItem('second',today,'pull-second');
|
||||
offline=false;await week.flushPull();
|
||||
const resolved=await week.resolvePullConflict('add');
|
||||
console.log(JSON.stringify({resolved,delivered,pending:week.pendingPull(),state:week.state()}));
|
||||
""")
|
||||
|
||||
assert [request["operation_id"] for request in result["delivered"]] == [
|
||||
"pull-first", "pull-first", "pull-second"
|
||||
]
|
||||
assert result["delivered"][1]["today_revision"] == 10
|
||||
assert result["delivered"][1]["week_revision"] == 12
|
||||
assert result["resolved"]["today"]["ids"] == ["active", "first", "second"]
|
||||
assert result["pending"] is None
|
||||
assert result["state"]["revision"] == 14
|
||||
|
||||
|
||||
def test_week_controller_keeps_the_conflicted_item_in_week_and_advances_only_the_fifo_head():
|
||||
result = run_controller("""
|
||||
const values=new Map(),delivered=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
let offline=true;
|
||||
let serverToday={revision:10,ids:['active'],capacity_minutes:180,estimates:{active:30}};
|
||||
let serverWeek={revision:12,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]};
|
||||
const fetchJson=async(url,options={})=>{
|
||||
if(offline)throw new Error('offline');
|
||||
if(!options.method)return url.endsWith('/today')?serverToday:serverWeek;
|
||||
const body=JSON.parse(options.body);delivered.push(body);
|
||||
if(body.today_revision!==serverToday.revision||body.week_revision!==serverWeek.revision){const error=new Error('changed');error.status=409;throw error;}
|
||||
serverToday={...serverToday,revision:11,ids:[...serverToday.ids,body.identity],estimates:{...serverToday.estimates,[body.identity]:40}};
|
||||
serverWeek={...serverWeek,revision:13,days:serverWeek.days.map(day=>({...day,ids:day.ids.filter(id=>id!==body.identity)}))};
|
||||
return {today:serverToday,week:serverWeek};
|
||||
};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:7,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['first','second'],capacity_minutes:180,estimates:{first:35,second:40}}]});
|
||||
let today={revision:4,ids:['active'],capacity_minutes:180,estimates:{active:30}};
|
||||
today=(await week.pullItem('first',today,'pull-first')).today;
|
||||
await week.pullItem('second',today,'pull-second');
|
||||
offline=false;await week.flushPull();
|
||||
const resolved=await week.resolvePullConflict('keep');
|
||||
console.log(JSON.stringify({resolved,delivered,pending:week.pendingPull()}));
|
||||
""")
|
||||
|
||||
assert [request["operation_id"] for request in result["delivered"]] == ["pull-first", "pull-second"]
|
||||
assert result["delivered"][1]["today_revision"] == 10
|
||||
assert result["delivered"][1]["week_revision"] == 12
|
||||
assert result["resolved"]["today"]["ids"] == ["active", "second"]
|
||||
assert result["resolved"]["week"]["days"][0]["ids"] == ["first"]
|
||||
assert result["pending"] is None
|
||||
|
||||
|
||||
def test_week_controller_retires_completed_work_from_every_day_in_one_durable_transition():
|
||||
result = run_controller("""
|
||||
const values=new Map();let writes=0;
|
||||
|
||||
Reference in new issue
Block a user