All source files · Workbench
src/scripts/workbench.ts
import { getProofboardData, type ProofboardData, type Competition } from '../lib/sanity';
import { localTimeToUtc, scheduleItems, exportCalendar, fingerprintWorkInputs, type ScheduledItem } from '../lib/planner';
export function initializeWorkbench() {
const initial = document.getElementById('initial-data');
if (!initial) return;
let data: ProofboardData = JSON.parse(initial.textContent || '{}');
const el = (id: string) => document.getElementById(id)!;
const input = (id: string) => el(id) as HTMLInputElement;
const select = (id: string) => el(id) as HTMLSelectElement;
const escape = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
const safeUrl = (url: string | undefined) => {try {const result = new URL(url || ''); return ['https:','http:'].includes(result.protocol) ? result.href : '#';} catch {return '#';}};
const colors = ['#bde9a2','#d3c7ee','#cce2e1'];
const estimates = new Map<string,number>();
const prerequisites = new Map<string, Set<string>>();
const enabled = new Set(data.competitions.map(c=>c.id));
let plan: ScheduledItem[] = [];
let toastTimer: ReturnType<typeof setTimeout>;
let localRecords: Array<{id:string;title:string;note:string;fingerprint:string;at:string}> = [];
try { localRecords = JSON.parse(localStorage.getItem('deadline-loom-decisions') || '[]'); if (!Array.isArray(localRecords)) localRecords = []; } catch {localRecords=[];}
const timezone = () => select('timezone').value;
let previousTimezone=timezone();
const fmt = (epoch: number, short = false) => new Intl.DateTimeFormat('en-US', {timeZone:timezone(),month:'short',day:'numeric',hour:'numeric',minute:'2-digit', ...(short ? {} : {timeZoneName:'short'})}).format(new Date(epoch));
const fingerprint = () => JSON.stringify({start:localTimeToUtc(input('start-time').value,timezone()),capacity:input('capacity').value,enabled:[...enabled].sort(),estimates:[...estimates].sort(),effectiveWork:fingerprintWorkInputs(data.competitions.filter(c=>enabled.has(c.id)),estimates),scenario:input('counterfactual').checked,selected:select('selected-thread').value,version:select('source-version').value,prerequisites:[...prerequisites].map(([id, values])=>[id,[...values].sort()]),sources:data.competitions.map(c=>[c.id,c.deadlineUtc,c.verifiedAt]),revisions:data.evidenceRevisions.map(r=>[r.id,r.proposedValue,r.status])});
function toast(message: string) { el('toast').textContent=message; el('toast').classList.add('visible'); clearTimeout(toastTimer);toastTimer=setTimeout(()=>el('toast').classList.remove('visible'),3500); }
function renderThreads() {
el('threads').innerHTML = data.competitions.map((c,index)=>`<div class="thread"><input aria-label="Include ${escape(c.title)}" type="checkbox" data-include="${escape(c.id)}" ${enabled.has(c.id)?'checked':''}/><div><div class="thread-title"><i class="thread-dot" style="background:${colors[index%colors.length]}"></i>${escape(c.title)}</div><div class="thread-meta">${escape(c.humanMinutes??10)} HUMAN MIN · ${escape(c.verificationStatus)}<br/>${escape(c.prizeDescription)}</div></div><label><input class="thread-hours" aria-label="Estimated work hours for ${escape(c.title)}" type="number" min="0.25" max="100" step="0.25" value="${estimates.get(c.id)??c.buildHours??4}" data-hours="${escape(c.id)}"/><span>hours</span></label></div>`).join('');
el('threads').querySelectorAll<HTMLInputElement>('[data-include]').forEach(node=>node.addEventListener('change',()=>{if(node.checked)enabled.add(node.dataset.include!);else enabled.delete(node.dataset.include!); renderPlan();}));
el('threads').querySelectorAll<HTMLInputElement>('[data-hours]').forEach(node=>node.addEventListener('input',()=>{if(node.validity.valid&&Number(node.value)>0){estimates.set(node.dataset.hours!,Number(node.value));renderPlan();}}));
}
function renderMemory() {
const saved = data.approvals.map(a=>`<div class="history-row"><strong>${a.isSimulated?'Simulated':data.mode==='sample'?'Fixture':'Published'} ${escape(a.decision)}</strong><small>${escape(a.reviewerLabel)}</small><p>${escape(a.decisionNote)}</p></div>`).join('');
const local = localRecords.slice(-4).reverse().map(r=>`<div class="history-row"><strong>Local planning decision · ${r.fingerprint===fingerprint()?'current':'stale'}</strong><small>${escape(fmt(Date.parse(r.at)))}</small><p>${escape(r.note)}</p></div>`).join('');
el('decision-history').innerHTML = local+saved || '<p class="empty-memory">No decision yet. A reason recorded today becomes context for the next person.</p>';
}
function renderSources(c: Competition) {
const ids = new Set(c.sourceIds || []);
data.claims.filter(claim=>claim.competitionId===c.id).forEach(claim=>claim.sourceIds.forEach(id=>ids.add(id)));
const sources = data.sources.filter(s=>ids.has(s.id));
el('source-count').textContent = sources.length+' linked sources';
el('sources').innerHTML = sources.map((s,index)=>`<article class="evidence-source"><span class="source-number">${String(index+1).padStart(2,'0')}</span><div><a class="source-heading" href="${escape(safeUrl(s.url))}" target="_blank" rel="noopener noreferrer">${escape(s.title)} <span>↗</span></a><div class="source-detail">${escape(s.summary)}</div><div class="source-badges"><span>${escape(s.publisher)}</span><span>${escape(s.kind)}</span><span>Checked ${escape(new Date(s.checkedAt).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}))}</span></div></div></article>`).join('');
}
function renderPlan() {
const c = data.competitions.find(c=>c.id===select('selected-thread').value) || data.competitions[0];
const start = localTimeToUtc(input('start-time').value, timezone());
if (!Number.isFinite(start) || !c) return;
const counterfactual = input('counterfactual').checked;
const revision=data.evidenceRevisions.find(r=>r.id===select('source-version').value && r.competitionId===c.id && r.fieldKey==='deadlineUtc');
const chosenDeadline=revision?Date.parse(revision.proposedValue):Date.parse(c.deadlineUtc);
const scenarioDeadline=chosenDeadline-(counterfactual?86400000:0);
const override = counterfactual || revision ? {id:c.id,deadline:scenarioDeadline} : null;
plan = scheduleItems(data.competitions.filter(c=>enabled.has(c.id)).map(c=>({...c,buildHours:estimates.get(c.id)??c.buildHours??4})), start, Number(input('capacity').value), override);
el('capacity-value').textContent=input('capacity').value;
const end=plan.length?plan[plan.length-1].end:start;
el('finish-time').textContent=fmt(end);
const late=plan.filter(p=>p.late).length;
el('plan-health').textContent=!plan.length?'No threads selected':late?`${late} thread${late===1?'':'s'} miss the selected deadline`:`${plan.length} threads fit these assumptions`;
el('plan-health').style.color=late?'#925e3d':'#547044';
const horizon = Math.max(48*3600000, Math.min(96*3600000, end-start+12*3600000));
const pos = (epoch:number) => Math.max(0,Math.min(100,(epoch-start)/horizon*100));
const ruler = [0,.25,.5,.75,1].map(r=>`<span>${escape(fmt(start+horizon*r,true))}</span>`).join('');
const lanes = data.competitions.map((comp,index)=>{const item=plan.find(p=>p.id===comp.id);if(!item)return `<div class="timeline-lane"><span class="lane-empty">Thread paused</span></div>`;const beyond=item.deadline>start+horizon;const past=item.deadline<start;const left=pos(item.start);const width=Math.max(.4,pos(item.end)-left);return `<div class="timeline-lane"><div class="lane-block ${item.late?'late':''}" style="left:${left}%;width:${width}%;${item.late?'':`background:${colors[index%colors.length]}`};" title="${escape(comp.title+': '+fmt(item.start)+' to '+fmt(item.end))}">${escape(comp.title)}<small>${escape(item.buildHours)}h work · ${item.slackHours.toFixed(1)}h slack</small></div><i class="deadline-line" style="left:${pos(item.deadline)}%"></i><span class="deadline-label" style="left:${Math.max(7,Math.min(93,pos(item.deadline)))}%">${past?'← ':''}${beyond?'Later →':counterfactual&&comp.id===c.id?'Rehearsal':'Due'}</span></div>`;}).join('');
el('timeline').innerHTML=`<div class="timeline-ruler">${ruler}</div>${lanes}`;
el('selected-summary').textContent=c.summary;
el('recorded-deadline').textContent=fmt(Date.parse(c.deadlineUtc));
el('recorded-source').textContent=c.deadlineLabel;
const selectedPlan = plan.find(p=>p.id===c.id);
const missing=(c.dependencies||[]).filter(dependency=>!prerequisites.get(c.id)?.has(dependency)).length;
const prefix=counterfactual||revision?.isSimulated?'Simulation only. ':revision?'Proposed version. ':'';
el('scenario-outcome').textContent=`${prefix}Due ${fmt(scenarioDeadline)}. ${selectedPlan?selectedPlan.late?`${Math.abs(selectedPlan.slackHours).toFixed(1)} hours late under these estimates.`:`${selectedPlan.slackHours.toFixed(1)} hours of estimated slack.`:'Thread paused.'} ${missing} prerequisites unchecked. ${counterfactual||revision?'The organizer’s recorded date is unchanged.':'Eligibility and payout depend on the rules.'}`;
renderSources(c);renderMemory();
(el('export') as HTMLButtonElement).disabled=!plan.length;
}
function renderVersions() {
const c=data.competitions.find(c=>c.id===select('selected-thread').value)!;
const prior=select('source-version').value;
const revisions=data.evidenceRevisions.filter(r=>r.competitionId===c.id&&r.fieldKey==='deadlineUtc');
select('source-version').innerHTML='<option value="recorded">Recorded official version</option>'+revisions.map(r=>`<option value="${escape(r.id)}">${r.isSimulated?'SIMULATED':'Proposed'} · ${escape(fmt(Date.parse(r.proposedValue)))}</option>`).join('');
if(revisions.some(r=>r.id===prior))select('source-version').value=prior;
el('prerequisites').innerHTML=(c.dependencies||[]).map(dependency=>`<label class="prerequisite"><input type="checkbox" data-dependency="${escape(dependency)}" ${prerequisites.get(c.id)?.has(dependency)?'checked':''}/><span>${escape(dependency)}</span></label>`).join('');
el('prerequisites').querySelectorAll<HTMLInputElement>('input').forEach(node=>node.addEventListener('change',()=>{const checked=prerequisites.get(c.id)||new Set<string>();if(node.checked)checked.add(node.dataset.dependency!);else checked.delete(node.dataset.dependency!);prerequisites.set(c.id,checked);renderPlan();}));
}
function renderData() {
el('connection').textContent=data.mode==='live'?'Public Sanity content':'Local sample content';
el('mode-note').innerHTML=`<strong>${data.mode==='live'?'CONNECTED':'SAMPLE MODE'}</strong>${escape(data.notice)} Scenario controls and decision notes are local rehearsals.`;
const prior=select('selected-thread').value;
select('selected-thread').innerHTML=data.competitions.map(c=>`<option value="${escape(c.id)}">${escape(c.title)}</option>`).join('');
if(data.competitions.some(c=>c.id===prior))select('selected-thread').value=prior;
renderVersions();renderThreads();renderPlan();
}
['start-time','capacity','counterfactual','source-version'].forEach(id=>el(id).addEventListener('input',renderPlan));
el('timezone').addEventListener('input',()=>{
const instant=localTimeToUtc(input('start-time').value,previousTimezone);
const parts=new Intl.DateTimeFormat('en-GB',{timeZone:timezone(),year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}).formatToParts(new Date(instant));
const get=(type:string)=>parts.find(p=>p.type===type)?.value;
input('start-time').value=`${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}`;
previousTimezone=timezone();renderVersions();renderPlan();
});
el('selected-thread').addEventListener('input',()=>{renderVersions();renderPlan();});
el('reset').addEventListener('click',()=>{input('start-time').value='2026-10-02T13:00';select('timezone').value='America/Chicago';previousTimezone='America/Chicago';select('source-version').value='recorded';input('capacity').value='12';input('counterfactual').checked=false;estimates.clear();prerequisites.clear();enabled.clear();data.competitions.forEach(c=>enabled.add(c.id));renderVersions();renderThreads();renderPlan();toast('Scenario reset. Source records are unchanged.');});
el('approve').addEventListener('click',()=>{const note=(el('decision-note') as HTMLTextAreaElement).value.trim();if(!note){toast('Add a reason before recording this scenario.');(el('decision-note') as HTMLTextAreaElement).focus();return;}const c=data.competitions.find(c=>c.id===select('selected-thread').value)!;localRecords.push({id:c.id,title:c.title,note,fingerprint:fingerprint(),at:new Date().toISOString()});localRecords=localRecords.slice(-20);try{localStorage.setItem('deadline-loom-decisions',JSON.stringify(localRecords));}catch{}renderMemory();toast('Planning decision saved locally. No Sanity data was changed.');});
el('clear-decision').addEventListener('click',()=>{localRecords=[];try{localStorage.removeItem('deadline-loom-decisions');}catch{}renderMemory();toast('Local decision memory cleared.');});
el('export').addEventListener('click',()=>{const blob=new Blob([exportCalendar(plan)],{type:'text/calendar;charset=utf-8'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download='deadline-loom-scenario.ics';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('Calendar exported with UTC instants and source links.');});
renderData();
const projectId=document.body.dataset.project;
if(projectId) {
const refresh=async()=>{if(document.hidden)return;data=await getProofboardData({projectId,dataset:document.body.dataset.dataset||'production'});renderData();};
void refresh();setInterval(()=>void refresh(),60000);
}
}