Files
antidrift/internal/web/static/app.js
T
2026-05-31 21:13:04 -04:00

231 lines
9.9 KiB
JavaScript

const app = document.getElementById('app');
const view = document.getElementById('view');
let countdownTimer = null;
let renderedState = null; // which runtime_state the DOM currently shows
let dismissedNudge = null; // text of the nudge the user has dismissed
function post(path, body) {
return fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
}
function fmt(secs) {
secs = Math.max(0, Math.floor(secs));
const m = String(Math.floor(secs / 60)).padStart(2, '0');
const s = String(secs % 60).padStart(2, '0');
return `${m}:${s}`;
}
// stateKey maps server state to the data-state accent bucket. Drift outranks
// a nudge, and a nudge the user already dismissed reverts to plain active.
function stateKey(state) {
const rs = state.runtime_state;
if (rs !== 'active') return rs || 'locked';
const d = state.drift || {};
if (d.status === 'drifting') return 'drift';
if (d.nudge && d.nudge !== dismissedNudge) return 'nudge';
return 'active';
}
function setStateAttr(state) {
app.dataset.state = stateKey(state);
}
function evidenceBlock(ev) {
if (!ev) return '';
const health = ev.available
? `<span class="health-ok">tracking</span>`
: `<span class="health-bad">evidence unavailable: ${ev.reason || 'unknown'}</span>`;
const now = ev.current && (ev.current.class || ev.current.title)
? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—';
const rows = (ev.buckets || []).map(b =>
`<li><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></li>`).join('');
return `<div class="band evidence">
<div class="now">now ${now} &nbsp; ${health}</div>
<ul class="buckets">${rows}</ul>
<div class="switches">context switches: ${ev.switch_count || 0}</div>
</div>`;
}
// reviewSummary renders a presentational recap from already-available state:
// the commitment plus the per-window evidence buckets. No new backend data.
function reviewSummary(ev) {
if (!ev) return '';
const rows = (ev.buckets || []).map(b =>
`<div class="summary-row"><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></div>`).join('');
const switches = `<div class="summary-row"><span>context switches</span><span>${ev.switch_count || 0}</span></div>`;
return `<div class="band summary">${switches}${rows}</div>`;
}
// updateActiveDrift owns the active status band: it renders on-task, nudge,
// drift, or pending content into #statusband and rebinds the buttons.
function updateActiveDrift(state) {
const el = document.getElementById('statusband');
if (!el) return;
setStateAttr(state);
const drift = state.drift || {};
const switches = (state.evidence && state.evidence.switch_count) || 0;
if (drift.status === 'drifting') {
el.className = 'band statusband col';
el.innerHTML = `<div><span class="pill">Drift</span></div>
<div class="drift-reason">${drift.reason || 'This looks off task.'}</div>
<div class="band-actions">
<button id="refocus" type="button" class="btn btn-primary">Back to task</button>
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
</div>`;
document.getElementById('refocus').onclick = () => post('/refocus');
document.getElementById('ontask').onclick = () => post('/ontask');
document.getElementById('enddrift').onclick = () => post('/complete');
return;
}
const nudge = drift.nudge || '';
if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges
if (nudge && nudge !== dismissedNudge) {
el.className = 'band statusband col';
el.innerHTML = `<div><span class="pill">Heads up</span></div>
<div class="nudge-msg">${nudge}</div>
<div class="band-actions">
<button id="dismissnudge" type="button" class="btn btn-ghost">Dismiss</button>
</div>`;
document.getElementById('dismissnudge').onclick = () => {
dismissedNudge = nudge;
setStateAttr(state);
updateActiveDrift(state);
};
return;
}
if (drift.status === 'pending') {
el.className = 'band statusband';
el.innerHTML = `<span class="pill">Active</span><span class="status-meta">checking focus…</span>`;
return;
}
el.className = 'band statusband';
el.innerHTML = `<span class="pill">Active</span>
<span class="status-meta">on task · ${switches} switches</span>`;
}
function updatePlanningCoach(coach) {
const statusEl = document.getElementById('coachStatus');
if (!statusEl) return;
if (!coach || coach.status === 'idle') { statusEl.textContent = ''; return; }
if (coach.status === 'pending') { statusEl.textContent = 'Sharpening…'; return; }
if (coach.status === 'error') { statusEl.textContent = coach.error || 'coach unavailable'; return; }
if (coach.status === 'ready' && coach.proposal) {
statusEl.textContent = 'AI suggestion applied — edit freely.';
const stamp = JSON.stringify(coach.proposal);
if (statusEl.dataset.applied === stamp) return;
statusEl.dataset.applied = stamp;
const na = document.getElementById('na'), sc = document.getElementById('sc'),
mins = document.getElementById('mins'), start = document.getElementById('start');
if (na && !na.value.trim()) na.value = coach.proposal.next_action || '';
if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || '';
if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60);
if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0);
const apps = document.getElementById('apps');
if (apps && !apps.value.trim() && Array.isArray(coach.proposal.allowed_window_classes)) {
apps.value = coach.proposal.allowed_window_classes.join(', ');
}
}
}
function render(state) {
setStateAttr(state);
const rs = state.runtime_state;
if (rs === 'planning' && renderedState === 'planning') {
updatePlanningCoach(state.coach);
return;
}
if (rs === 'active' && renderedState === 'active') {
updateActiveDrift(state);
return;
}
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
renderedState = rs;
if (rs === 'locked') {
view.innerHTML = `<div class="band statusband"><span class="pill">Locked</span></div>
<div class="band">
<p class="meta">No active commitment.</p>
<button id="plan" class="btn btn-primary">Start planning</button>
</div>`;
document.getElementById('plan').onclick = () => post('/planning');
} else if (rs === 'planning') {
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
<div class="band">
<label>Rough intent</label>
<input id="intent" placeholder="e.g. work on the quarterly report">
<button id="sharpen" type="button" class="btn btn-ghost">Sharpen</button>
<div id="coachStatus" class="meta"></div>
</div>
<div class="band">
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
<label>Allowed apps (comma-separated window classes)</label>
<input id="apps" placeholder="e.g. code, firefox">
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
</div>`;
const na = document.getElementById('na'), sc = document.getElementById('sc'),
mins = document.getElementById('mins'), start = document.getElementById('start');
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
[na, sc, mins].forEach(el => el.oninput = check);
start.onclick = () => post('/commitment', {
next_action: na.value.trim(),
success_condition: sc.value.trim(),
timebox_secs: Math.round(+mins.value * 60),
allowed_window_classes: (document.getElementById('apps').value || '')
.split(',').map(s => s.trim()).filter(Boolean),
});
document.getElementById('sharpen').onclick = () => {
const intent = document.getElementById('intent').value.trim();
if (intent) post('/coach', { intent });
};
updatePlanningCoach(state.coach);
} else if (rs === 'active') {
const c = state.commitment || {};
view.innerHTML = `<div class="band statusband" id="statusband"></div>
<div class="band"><div class="timer" id="t">--:--</div></div>
<div class="band">
<div class="action">${c.next_action || ''}</div>
<p class="meta">done when: ${c.success_condition || ''}</p>
</div>
${evidenceBlock(state.evidence)}
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
document.getElementById('done').onclick = () => post('/complete');
const t = document.getElementById('t');
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
tick();
countdownTimer = setInterval(tick, 250);
updateActiveDrift(state);
} else if (rs === 'review') {
const c = state.commitment || {};
view.innerHTML = `<div class="band statusband"><span class="pill">Review</span></div>
<div class="band">
<div class="action">Session ended</div>
<p class="meta">${c.next_action || ''}</p>
<p class="meta">done when: ${c.success_condition || ''}</p>
</div>
${reviewSummary(state.evidence)}
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
document.getElementById('end').onclick = () => post('/end');
} else {
view.textContent = rs;
}
}
const es = new EventSource('/events');
es.onmessage = (e) => render(JSON.parse(e.data));
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };