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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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);
  }
}