Files
timmy-talking-turd/app.js
T
timmy ef22ea8970
Quality gates / quality (pull_request) Successful in 3m44s
feat(training): separate opt-in contribution receipts with withdrawable tombstones
A journal save now records no training contribution. A contribution is a second
consent: the privacy page states consent version training-consent-v1 and the
receipt (contribution id, included fields, never-included fields) before the
confirm control is enabled, the receipt is queued on this device only and copies
no photo bytes, note text, or symptom answers, and withdrawal keeps a tombstone
naming every derived artifact by contribution id. Delete-all clears the receipts
with the ledger.

Closes #24
2026-10-21 10:58:50 +00:00

379 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { bucketForBristolType, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
import { mergeVisualSuggestion } from './src/analysis.js';
import { createDraft, draftNotice, isDraftDirty } from './src/draft.js';
import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from './src/suggestion.js';
import { HARD_STOOL_QUESTIONS, createFollowUpAnswers, evaluateHardStoolFollowUp, needsHardStoolFollowUp } from './src/followup.js';
import { visionReadiness } from './src/vision-readiness.js';
import { buildPatternSummary } from './src/summary.js';
import { answerFromLedger, buildChatLedger, resolveAgentReply } from './src/grounded-chat.js';
import { photoState, withoutEntry, withoutPhoto } from './src/photo-lifecycle.js';
import { addContribution, consentTerms, contributionEligibility, contributionForEntry, receiptPreview, withdrawContribution } from './src/training-contribution.js';
const runtimeConfig = {
basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/',
stagingLabel: document.querySelector('meta[name="timmy-staging-label"]')?.content || '',
};
const BASE_PATH = runtimeConfig.basePath || '/';
const APP_ROOT = BASE_PATH === '/' ? '/' : `${BASE_PATH}/`;
function appPath(path='') { return `${APP_ROOT}${String(path).replace(/^\/+/, '')}`; }
const STORE = `timmy:${BASE_PATH}:ledger-v1`;
const TRAINING_STORE = `timmy:${BASE_PATH}:training-contributions-v1`;
const LEGACY_STORE = 'timmy-ledger-v1';
if (BASE_PATH === '/') {
const legacyEntries = localStorage.getItem(LEGACY_STORE);
if (legacyEntries !== null) {
try {
if (!localStorage.getItem(STORE)) localStorage.setItem(STORE, legacyEntries);
localStorage.removeItem(LEGACY_STORE);
} catch {}
}
}
const app = document.querySelector('#app');
let entries = loadEntries();
let contributions = loadContributions();
let view = 'home';
let photoHint = '';
let aiSuggestion = null;
let suggestionState = createSuggestionState(null);
let visionStatus = null;
let photoFirstMode = 'pick';
let agentStatus = null;
let chatBusy = false;
let chatError = '';
let chatMessages = [{ role: 'timmy', text: 'Ask me about your confirmed logs, visible patterns, privacy, or how Timmy works.' }];
let draftResumed = false;
let form = createDraft();
let followUpAnswers = createFollowUpAnswers();
function draftBanner(){
if(!draftResumed||!isDraftDirty(form))return '';
return `<div class="draft-resume"><span role="status">Draft kept in this session: <strong>${esc(draftNotice(form))}</strong></span><button class="text-action compact" id="discard-draft">Start over</button></div>`;
}
function beginDraft(){draftResumed=isDraftDirty(form);if(!draftResumed)form=createDraft()}
function clearDraft(){form=createDraft();draftResumed=false;suggestionState=createSuggestionState(null);followUpAnswers=createFollowUpAnswers()}
function resetSuggestion(){aiSuggestion=null;suggestionState=createSuggestionState(null)}
// A single place where provisional, confirmed, and corrected AI states become visible copy.
function provenanceBlock(){
const copy=provenanceCopy(suggestionState,form);
if(!copy.label)return '';
return `<div class="ai-prefill ai-provenance" data-provenance="${copy.status}" aria-label="${esc(copy.aria)}"><span class="ai-badge">${esc(copy.label)}</span><strong>Type ${form.bristolType} · ${esc(form.color)}</strong><small>You’re in charge—tap any type to correct it.</small><p class="provenance-note fine" role="status">${esc(copy.detail)}</p></div>`;
}
function refreshProvenance(){const copy=provenanceCopy(suggestionState,form);document.querySelectorAll('.provenance-note').forEach(node=>{node.textContent=copy.detail||''});const container=document.querySelector('.ai-provenance');if(container&&copy.aria)container.setAttribute('aria-label',copy.aria)}
// Hard-stool follow-up (field finding #67). After a Type 1-2 entry Timmy asks a short
// user-reported follow-up: soreness, difficulty passing, hydration, and black/tarry
// character are never inferred from pixels. A confirmed red-flag answer writes the same
// symptom flag the rest of the app already escalates on, so the safety path stays one path.
function followUpState(){return {...followUpAnswers}}
function syncFollowUpFlags(){for(const question of HARD_STOOL_QUESTIONS){if(!question.urgentFlag)continue;const box=document.querySelector(`[data-symptom="${question.urgentFlag}"]`);if(box)box.checked=form.symptoms[question.urgentFlag]===true}}
function followUpOutput(verdict){
if(verdict.urgent)return `<div id="followup-guidance" class="followup-guidance escalated" role="status"><strong>That answer sets the red-flag check below.</strong><p class="fine">Timmy is not diagnosing you here. The answer you gave is what triggers this, and the check below is the same one manual logging uses.</p></div>`;
if(!verdict.answered)return '<p class="fine" id="followup-guidance" role="status">Answer what applies. Anything you skip stays unanswered, and none of it is guessed from your photo.</p>';
return `<div id="followup-guidance" class="followup-guidance" role="status"><strong>Mild report — general self-care, not a diagnosis.</strong><ul>${verdict.guidance.map(line=>`<li>${esc(line)}</li>`).join('')}</ul><p class="fine">${esc(verdict.note)}</p><p class="fine">${esc(verdict.seekCare)}</p></div>`;
}
function followUpBlock(){
if(!needsHardStoolFollowUp(form))return '';
const state=followUpState();
const rows=HARD_STOOL_QUESTIONS.map(question=>`<div class="followup-row" data-question="${question.id}"><span>${esc(question.label)}${question.urgentFlag?'<small class="followup-map">Also sets the red-flag check below.</small>':''}</span><div class="followup-toggle"><button type="button" data-followup="${question.id}" data-answer="yes" class="${state[question.id]===true?'selected':''}" aria-pressed="${state[question.id]===true}">Yes</button><button type="button" data-followup="${question.id}" data-answer="no" class="${state[question.id]===false?'selected':''}" aria-pressed="${state[question.id]===false}">No</button></div></div>`).join('');
return `<section class="followup" data-followup-block aria-label="Hard stool follow-up questions"><h3>Type ${form.bristolType} — a few quick questions</h3><p class="fine">Timmy never guesses these from your photo. You report them, and you can skip any of them.</p>${rows}<div id="followup-output">${followUpOutput(evaluateHardStoolFollowUp(state))}</div></section>`;
}
function refreshFollowUp(){
const state=followUpState();
if(document.querySelector('[data-followup-block]')){
document.querySelectorAll('[data-followup]').forEach(button=>{const selected=state[button.dataset.followup]===(button.dataset.answer==='yes');button.classList.toggle('selected',selected);button.setAttribute('aria-pressed',String(selected))});
const output=document.querySelector('#followup-output');if(output)output.innerHTML=followUpOutput(evaluateHardStoolFollowUp(state));
}
const urgent=detectUrgentFlags(form.symptoms);const box=document.querySelector('#urgent-box');if(box)box.innerHTML=urgent.urgent?alertHtml(urgent.message):'';
}
function esc(value='') { return String(value).replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); }
function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } }
function saveEntries() { localStorage.setItem(STORE, JSON.stringify(entries)); }
// Training contribution receipts are their own store: a journal save never writes here, and a
// withdrawal keeps a tombstone instead of quietly erasing the audit trail.
function loadContributions() { try { return JSON.parse(localStorage.getItem(TRAINING_STORE) || '[]'); } catch { return []; } }
function saveContributions() { localStorage.setItem(TRAINING_STORE, JSON.stringify(contributions)); }
function formatDate(value) { return new Intl.DateTimeFormat(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}).format(new Date(value)); }
function toast(message) { const node=document.createElement('div');node.className='toast';node.textContent=message;document.body.append(node);setTimeout(()=>node.remove(),2400); }
function shell(content) {
const label=runtimeConfig.stagingLabel?`<footer class="staging-label">${esc(runtimeConfig.stagingLabel)}</footer>`:'';
app.innerHTML = `<header class="topbar"><div class="brand"><img src="${appPath('assets/timmy.svg')}" alt="Timmy mascot"><div class="brand-copy"><strong>Timmy</strong><span>private bowel journal</span></div></div><span class="local-mark" title="Saved locally">● Ledger local</span></header>${content}${label}${nav()}`;
bindGlobal();
}
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home"><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar"><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy"><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
function bindGlobal(){
document.querySelectorAll('[data-view]').forEach(btn=>btn.onclick=()=>{view=btn.dataset.view;render()});
document.querySelectorAll('[data-log]').forEach(btn=>btn.onclick=openLogger);
document.querySelectorAll('[data-scan]').forEach(btn=>btn.onclick=openPhotoFirst);
document.querySelectorAll('[data-statement-link]').forEach(btn=>btn.onclick=()=>showPatternRecords(btn.dataset.statementLink));
document.querySelectorAll('[data-entry-open]').forEach(btn=>btn.onclick=()=>showEntrySheet(btn.dataset.entryOpen));
document.querySelectorAll('[data-contribute-entry]').forEach(btn=>btn.onclick=()=>showContributionConsent(btn.dataset.contributeEntry));
document.querySelectorAll('[data-withdraw]').forEach(btn=>btn.onclick=()=>withdrawTrainingContribution(btn.dataset.withdraw));
}
function recentList(limit=5){
if(!entries.length)return `<div class="empty"><img src="${appPath('assets/timmy.svg')}" alt=""><h3>Quiet bowl, clean slate.</h3><p>Your first log takes about ten seconds.</p></div>`;
return entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt)).slice(0,limit).map(e=>{
const photo=photoState(e);
return `<article class="entry"><div class="type-dot">T${e.bristolType}</div><div><strong>${formatDate(e.occurredAt)}</strong><span>${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}</span>${photo.hasPhoto?`<em class="photo-chip" data-photo-indicator="present">${esc(photo.label)}</em>`:''}</div><span class="entry-tail"><span class="bucket bucket-${bucketForBristolType(e.bristolType)}">${bucketForBristolType(e.bristolType)}</span><button class="entry-open" data-entry-open="${esc(e.id)}" aria-label="Open confirmed log from ${esc(formatDate(e.occurredAt))}, Type ${e.bristolType}"><span aria-hidden="true">›</span></button></span></article>`;
}).join('');
}
// Issue #36 — the saved photo and the confirmed log are independent. Presence is stated without
// rendering a thumbnail, showing the image is an explicit request, removing it keeps every confirmed
// field, and deleting the log is a separate two-step decision that takes the photo with it.
let entrySheetKeyHandler=null;
function showEntrySheet(id, armed=false){
const entry=entries.find(item=>item.id===id);
if(!entry)return;
const photo=photoState(entry);
const urgent=detectUrgentFlags(entry.symptoms);
document.querySelector('.sheet-backdrop')?.remove();
const wrap=document.createElement('div');wrap.className='sheet-backdrop';
wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="entry-sheet-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Confirmed log</span><h2 id="entry-sheet-title">${formatDate(entry.occurredAt)}</h2></div><button class="icon-btn" id="entry-sheet-close" aria-label="Close">×</button></div><div class="entry-facts"><div><span>Form</span><strong>Type ${entry.bristolType}</strong></div><div><span>Color</span><strong>${esc(entry.color)}</strong></div><div><span>Urgency</span><strong>${entry.urgency}/4</strong></div><div><span>Discomfort</span><strong>${entry.discomfort}/4</strong></div></div>${entry.note?`<p class="fine entry-note">${esc(entry.note)}</p>`:''}${urgent.urgent?alertHtml(urgent.message):''}${photo.hasPhoto?`<section class="photo-lifecycle" data-photo-lifecycle="present"><strong>Photo saved with this log</strong><p class="fine">The image stays in this browser. The journal never shows it as a thumbnail until you ask.</p><div id="entry-photo-slot"></div><div class="row"><button class="btn btn-secondary" id="show-entry-photo">Show photo</button><button class="btn btn-ghost" id="remove-entry-photo">Remove photo, keep log</button></div></section>`:`<section class="photo-lifecycle" data-photo-lifecycle="absent"><strong>No photo saved</strong><p class="fine">This confirmed log is text only. Its record does not depend on an image.</p></section>`}<div class="entry-actions">${armed?`<button class="btn btn-danger btn-wide" id="delete-entry-confirm">Tap again to delete this log and its photo</button><p class="fine">This removes the confirmed log and its saved photo from this browser. It cannot be undone.</p>`:`<button class="btn btn-ghost btn-wide" id="delete-entry">Delete this log and its photo</button>`}</div></section>`;
document.body.append(wrap);
if(entrySheetKeyHandler)document.removeEventListener('keydown',entrySheetKeyHandler);
const close=()=>{wrap.remove();document.removeEventListener('keydown',onKey);if(entrySheetKeyHandler===onKey)entrySheetKeyHandler=null};
const onKey=event=>{if(event.key==='Escape')close()};
entrySheetKeyHandler=onKey;
document.addEventListener('keydown',onKey);
document.querySelector('#entry-sheet-close').onclick=close;
wrap.onclick=event=>{if(event.target===wrap)close()};
document.querySelector('#show-entry-photo')?.addEventListener('click',()=>{
const slot=document.querySelector('#entry-photo-slot');
slot.innerHTML=`<img class="photo-preview" id="entry-photo" src="${esc(entry.photoDataUrl)}" alt="Photo saved with this log">`;
document.querySelector('#show-entry-photo')?.remove();
});
document.querySelector('#remove-entry-photo')?.addEventListener('click',()=>{
entries=withoutPhoto(entries,id);saveEntries();render();showEntrySheet(id);toast('Photo removed. Your log is still here.');
});
document.querySelector('#delete-entry')?.addEventListener('click',()=>showEntrySheet(id,true));
document.querySelector('#delete-entry-confirm')?.addEventListener('click',()=>{
entries=withoutEntry(entries,id);saveEntries();close();render();toast('Log and photo deleted');
});
}
function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()<week).length}
function currentStreak(){const dates=new Set(entries.map(e=>e.occurredAt.slice(0,10)));let n=0,d=new Date();while(dates.has(d.toISOString().slice(0,10))){n++;d.setDate(d.getDate()-1)}return n}
// Issue #34: the home insight card is explainable. Every line says which confirmed records it was
// built from, following a citation shows exactly those records, and a sparse window says so
// instead of describing a trend the data cannot support.
function patternSummaryCard(){
const summary=buildPatternSummary(entries);
const logs=`${summary.records.length} confirmed log${summary.records.length===1?'':'s'}`;
const lines=summary.insufficient
? `<p class="fine" data-summary-insufficient>${esc(summary.copy)}</p>`
: `<ul class="pattern-statements">${summary.statements.map(statement=>`<li data-statement="${statement.id}" data-records="${statement.entryIds.length}">${esc(statement.text)} <button class="pattern-link" data-statement-link="${statement.id}">${statement.entryIds.length} record${statement.entryIds.length===1?'':'s'} ›</button></li>`).join('')}</ul>`;
return `<section class="section"><div class="insight-card pattern-card" data-pattern-summary role="group" aria-label="Explainable pattern summary"><div class="timmy-orb"><img src="${appPath('assets/timmy.svg')}" alt=""></div><div><span class="eyebrow">Timmy noticed</span><p class="pattern-window" data-summary-window>${esc(summary.window.label)} · ${logs}</p>${lines}<p class="fine pattern-boundary">${esc(summary.boundary)}</p></div></div></section>`;
}
function showPatternRecords(statementId){
const summary=buildPatternSummary(entries);
const statement=summary.statements.find(item=>item.id===statementId);
if(!statement)return;
const byId=new Map(summary.records.map(record=>[record.id,record]));
const rows=statement.entryIds.map(id=>byId.get(id)).filter(Boolean)
.map(record=>`<article class="entry" data-summary-record data-entry-id="${esc(record.id)}"><div class="type-dot">T${record.bristolType}</div><div><strong>${formatDate(record.occurredAt)}</strong><span>Type ${record.bristolType} · ${esc(record.bucket)}</span></div></article>`).join('');
document.querySelector('.sheet-backdrop')?.remove();
const wrap=document.createElement('div');wrap.className='sheet-backdrop';
wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="pattern-records-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Pattern source</span><h2 id="pattern-records-title">${esc(statement.text)}</h2></div><button class="icon-btn" id="pattern-records-close" aria-label="Close">×</button></div><div data-summary-records><p class="fine">These are the confirmed logs this line was built from.</p><div class="entries-card">${rows}</div><p class="fine">Window: ${esc(summary.window.label)}. Change or delete any of these logs and this line changes with them.</p></div></section>`;
document.body.append(wrap);
document.querySelector('#pattern-records-close').onclick=()=>wrap.remove();
wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
}
function home(){
const latest=entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt))[0];
shell(`<main class="home-main"><section class="hero sleek-hero"><span class="eyebrow">Your intelligent pooping pal</span><h1>Log it.<br>Learn the pattern.</h1><p class="lead">Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.</p><button class="capture-cta btn-primary" data-scan><span class="capture-icon" aria-hidden="true">◉</span><span><strong>Start photo log</strong><small>Private, guided, about 10 seconds</small></span><i aria-hidden="true">→</i></button><button class="text-action" data-log>Log manually instead</button></section><section class="glance" aria-label="Journal at a glance"><div><strong>${thisWeek()}</strong><span>this week</span></div><i></i><div><strong>${currentStreak()}</strong><span>day streak</span></div><i></i><div><strong>${entries.length}</strong><span>all logs</span></div></section>${patternSummaryCard()}<section class="section recent-section"><div class="section-head"><div><span class="eyebrow">Latest</span><h2>${latest?'Recent log':'Ready when you are'}</h2></div>${latest?'<button class="text-action compact" data-view="calendar">View journal</button>':''}</div>${latest?`<div class="latest-card"><div class="type-dot">T${latest.bristolType}</div><div><strong>${formatDate(latest.occurredAt)}</strong><span>${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}</span></div><span class="chevron">›</span></div>`:'<p class="fine">One quick, confirmed entry is enough to begin seeing your pattern.</p>'}</section></main>`);
}
function calendar(){
const now=new Date(),year=now.getFullYear(),month=now.getMonth(),first=new Date(year,month,1),days=new Date(year,month+1,0).getDate();
const counts={};entries.forEach(e=>{const d=new Date(e.occurredAt);if(d.getFullYear()===year&&d.getMonth()===month)counts[d.getDate()]=(counts[d.getDate()]||0)+1});
const blanks=Array(first.getDay()).fill('<div class="day blank"></div>').join('');
const boxes=Array.from({length:days},(_,i)=>`<div class="day ${counts[i+1]?'has-log':''}" title="${counts[i+1]||0} logs">${i+1}</div>`).join('');
shell(`<main><div class="page-title journal-title"><span class="eyebrow">Your private journal</span><h1>${now.toLocaleString(undefined,{month:'long'})}</h1><p>A calm view of frequency and form. One unusual day is not a verdict.</p></div><section class="calendar-card"><div class="calendar">${['S','M','T','W','T','F','S'].map(x=>`<div class="cal-head">${x}</div>`).join('')}${blanks}${boxes}</div></section><section class="section"><div class="section-head"><div><span class="eyebrow">Confirmed entries</span><h2>Recent logs</h2></div><button class="text-action compact" data-log>+ Add log</button></div><div class="entries-card">${recentList(100)}</div></section><section class="section journal-settings"><button class="settings-row" data-view="privacy"><span>Data, privacy & sources</span><b aria-hidden="true">›</b></button></section></main>`);
}
function agentStatusHtml(){
if(!agentStatus)return '<div class="agent-status checking"><i></i><span><strong>Checking Hermes…</strong><small>Your journal still works offline.</small></span></div>';
if(agentStatus.authenticated)return '<div class="agent-status connected"><i></i><span><strong>Hermes Agent connected</strong><small>Full tools stay server-side. Photos are never sent to chat.</small></span></div>';
if(agentStatus.configured)return '<div class="agent-status locked"><i></i><span><strong>Hermes is locked</strong><small>Connect once with the operator access code.</small></span></div>';
return '<div class="agent-status local"><i></i><span><strong>Local Timmy mode</strong><small>Simple journal answers work without a backend.</small></span></div>';
}
// Issue #37: an answer about the journal has to point at the confirmed records behind it. The chips
// are the records themselves — following one opens exactly that log — and an answer the journal
// cannot verify carries an explicit unverified notice instead of quiet confidence.
function chatDateLabel(value){const date=new Date(value);return Number.isFinite(date.getTime())?date.toLocaleDateString(undefined,{month:'short',day:'numeric'}):'unknown date'}
function chatCitationChips(message){
if(!message.citations?.length)return '';
return `<div class="chat-citations" data-chat-citations aria-label="Confirmed records behind this answer"><span class="chat-citations-label">From your log</span>${message.citations.map(citation=>`<button type="button" class="chat-citation" data-chat-citation data-entry-id="${esc(citation.id)}">${esc(chatDateLabel(citation.occurredAt))} · T${Number(citation.bristolType)}</button>`).join('')}</div>`;
}
function messageHtml(message){
const citations=chatCitationChips(message);
const unverified=message.unverified?`<p class="chat-unverified" data-chat-unverified>${esc(message.notice||'This answer was not verified against your journal.')}</p>`:'';
return `<div class="bubble ${message.role==='user'?'user':'timmy'}">${esc(message.text)}${citations}${unverified}</div>`;
}
function timmy(){
shell(`<main class="chat-page"><div class="page-title chat-title"><span class="eyebrow">A real conversation</span><h1>Talk to Timmy</h1><p>Ask naturally. Timmy can reason over confirmed logs and use Hermes tools, but never diagnoses or invents symptoms.</p></div>${agentStatusHtml()}${agentStatus?.configured&&!agentStatus?.authenticated?`<section class="unlock-card"><label for="agent-code">Operator access code</label><div class="unlock-row"><input class="input" id="agent-code" type="password" autocomplete="current-password" placeholder="Enter access code"><button class="btn btn-primary" id="connect-agent">Connect</button></div><p class="fine">The code is exchanged for an HttpOnly same-origin session and is never stored in this browser.</p></section>`:''}<section class="conversation"><div class="chat" id="chat" aria-live="polite">${chatMessages.map(messageHtml).join('')}${chatBusy?'<div class="bubble timmy thinking"><span></span><span></span><span></span></div>':''}</div>${chatError?`<p class="chat-error">${esc(chatError)}</p>`:''}<form class="composer" id="chat-form"><label class="sr-only" for="chat-message">Message Timmy</label><textarea id="chat-message" maxlength="4000" rows="1" placeholder="Ask about your pattern…" ${chatBusy?'disabled':''}></textarea><button id="send-chat" type="submit" aria-label="Send message" ${chatBusy?'disabled':''}>↑</button></form><p class="composer-note">Confirmed log fields may be sent to your configured Hermes backend. Photos never are.</p></section><section class="safety-line"><strong>Urgent symptoms always override chat.</strong> Blood, black or dark-red stool, severe pain, vomiting, fever, or inability to pass gas triggers deterministic medical guidance.</section></main>`);
document.querySelector('#chat-form')?.addEventListener('submit',sendChat);
document.querySelectorAll('[data-chat-citation]').forEach(btn=>btn.onclick=()=>showChatRecord(btn.dataset.entryId));
document.querySelector('#connect-agent')?.addEventListener('click',unlockAgent);
if(!agentStatus)loadAgentStatus();
requestAnimationFrame(()=>{const chat=document.querySelector('#chat');if(chat)chat.scrollTop=chat.scrollHeight});
}
async function loadAgentStatus(){
try{const response=await fetch(appPath('api/agent/status'),{headers:{accept:'application/json'}});agentStatus=response.ok?await response.json():{enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
catch{agentStatus={enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
if(view==='timmy')timmy();
}
async function unlockAgent(){
const code=document.querySelector('#agent-code')?.value||'';chatError='';
try{const response=await fetch(appPath('api/agent/unlock'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessCode:code})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not connect.');agentStatus=data;toast('Hermes Agent connected');timmy()}
catch(error){chatError=error.message||'Could not connect.';timmy()}
}
function showChatRecord(entryId){
const entry=entries.find(item=>String(item.id)===String(entryId));
if(!entry)return;
document.querySelector('.sheet-backdrop')?.remove();
const wrap=document.createElement('div');wrap.className='sheet-backdrop';
wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="chat-record-title" data-chat-record-sheet><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Cited record</span><h2 id="chat-record-title">${esc(formatDate(entry.occurredAt))}</h2></div><button class="icon-btn" id="chat-record-close" aria-label="Close">×</button></div><div class="entries-card"><article class="entry" data-chat-record data-entry-id="${esc(entry.id)}"><div class="type-dot">T${entry.bristolType}</div><div><strong>${esc(formatDate(entry.occurredAt))}</strong><span>Type ${entry.bristolType} · ${esc(entry.color)} · urgency ${entry.urgency}/4 · discomfort ${entry.discomfort}/4${entry.note?` · ${esc(entry.note)}`:''}</span></div></article></div><p class="fine">This is the confirmed log this answer was built from. Change or delete it and the answer changes with it.</p></section>`;
document.body.append(wrap);
document.querySelector('#chat-record-close').onclick=()=>wrap.remove();
wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
}
function ledgerForAgent(){return entries.slice(-20).map(({photoDataUrl,...entry})=>entry)}
async function sendChat(event){
event.preventDefault();if(chatBusy)return;const input=document.querySelector('#chat-message');const message=String(input?.value||'').trim();if(!message)return;
chatMessages.push({role:'user',text:message});chatError='';input.value='';
const urgent=detectUrgentText(message).urgent||hasUrgentLedgerContext(ledgerForAgent());if(urgent){chatMessages.push({role:'timmy',text:urgentChatMessage});timmy();return}
if(!agentStatus?.authenticated){const answer=answerFromLedger(message,entries);chatMessages.push({role:'timmy',text:answer.text,citations:answer.citations});timmy();return}
chatBusy=true;timmy();
try{const response=await fetch(appPath('api/agent/chat'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message,ledger:ledgerForAgent()})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Hermes is unavailable.');const resolved=resolveAgentReply(data.reply,buildChatLedger(entries).chatRecords);chatMessages.push({role:'timmy',text:resolved.text,citations:resolved.citations,unverified:resolved.unverified,notice:resolved.notice})}
catch(error){chatError=error.message||'Hermes is unavailable.';chatMessages.push({role:'timmy',text:'I could not reach Hermes. Your local journal still works, and nothing was changed.'})}
finally{chatBusy=false;timmy()}
}
// Issue #24 — contributing a confirmed photo to training is a second, separate consent. A journal
// save writes nothing here, the consent version and the receipt are on screen before the confirm
// control can be pressed, and a withdrawal keeps a tombstone naming every derived artifact by
// contribution id. Nothing is transmitted in this build: the receipt is queued on the device.
function newContributionId(){
const raw=globalThis.crypto?.randomUUID?.()||`${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`;
return `contrib-${String(raw).replace(/[^a-z0-9]/gi,'').slice(0,12)}`;
}
function trainingConsentSection(){
const terms=consentTerms();
const queued=contributions.filter(receipt=>receipt.state!=='withdrawn').length;
const active=contributions.filter(receipt=>receipt.state!=='withdrawn').map(receipt=>`<div class="contribution-row" data-contribution-state="${esc(receipt.state)}"><div><strong>Type ${receipt.labels.bristolType} · ${esc(receipt.labels.color)}</strong><span>Receipt ${esc(receipt.contributionId)} · consent ${esc(receipt.consentVersion)}</span></div><button class="btn btn-ghost" data-withdraw="${esc(receipt.contributionId)}">Withdraw</button></div>`);
const offerable=entries.filter(entry=>contributionEligibility(entry).eligible&&!contributions.some(receipt=>receipt.entryId===entry.id)).map(entry=>`<div class="contribution-row"><div><strong>${formatDate(entry.occurredAt)}</strong><span>Type ${entry.bristolType} · photo saved</span></div><button class="btn btn-secondary" data-contribute-entry="${esc(entry.id)}">Review opt-in</button></div>`);
const tombstones=contributions.filter(receipt=>receipt.state==='withdrawn').map(receipt=>`<div class="contribution-row withdrawn" data-contribution-state="withdrawn"><div><strong>Withdrawn ${esc(formatDate(receipt.withdrawnAt))}</strong><span data-withdrawn-artifacts>raw image, confirmed label · receipt ${esc(receipt.contributionId)}</span></div></div>`);
const rows=[...active,...offerable].join('');
return `<section class="section card training-consent" data-training-consent><h2>Training contribution</h2><p class="fine">Off by default. Saving a log never contributes it, and nothing is uploaded in this build — a receipt is queued on this device.</p><p class="fine">Consent version <code data-contribution-consent-version>${esc(terms.version)}</code> · <span data-contribution-queued-count>${queued}</span> queued</p>${rows?`<div class="contribution-list">${rows}</div>`:'<p class="fine">No saved photos are available to contribute yet.</p>'}${tombstones.length?`<h3>Withdrawn receipts</h3>${tombstones.join('')}`:''}<p class="fine">Deleting all local data below also clears these receipts.</p></section>`;
}
let contributionSheetKeyHandler=null;
function showContributionConsent(entryId){
const entry=entries.find(item=>item.id===entryId);
const eligibility=contributionEligibility(entry);
if(!eligibility.eligible){toast(eligibility.reason||'This log cannot be contributed.');return}
if(contributionForEntry(contributions,entryId)){toast('This log already has an active contribution receipt.');return}
const terms=consentTerms();
const contributionId=newContributionId();
const consentedAt=new Date().toISOString();
document.querySelector('.sheet-backdrop')?.remove();
const wrap=document.createElement('div');wrap.className='sheet-backdrop';
wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="contribution-title" data-contribution-sheet><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Separate consent</span><h2 id="contribution-title">Contribute one photo?</h2></div><button class="icon-btn" id="contribution-cancel" aria-label="Close">×</button></div><div class="consent-card"><p class="fine">Consent version <code data-contribution-version>${esc(terms.version)}</code></p><p class="fine">Receipt <code data-receipt-id>${esc(contributionId)}</code> · ${esc(formatDate(consentedAt))}</p><p class="fine"><strong>Included:</strong> ${esc(terms.includes.join('; '))}.</p><p class="fine"><strong>Never included:</strong> ${esc(terms.excludes.join('; '))}.</p><p class="fine">${esc(terms.transport)}</p></div><label class="check"><input id="training-consent-check" type="checkbox"><span>I have read consent ${esc(terms.version)} and want this one log contributed.</span></label><button class="btn btn-primary btn-wide" id="contribute-confirm" disabled>Contribute this photo and label</button><button class="btn btn-ghost btn-wide section" id="contribution-cancel-bottom">Not now</button></section>`;
document.body.append(wrap);
if(contributionSheetKeyHandler)document.removeEventListener('keydown',contributionSheetKeyHandler);
const close=()=>{wrap.remove();document.removeEventListener('keydown',onKey);if(contributionSheetKeyHandler===onKey)contributionSheetKeyHandler=null};
const onKey=event=>{if(event.key==='Escape')close()};
contributionSheetKeyHandler=onKey;
document.addEventListener('keydown',onKey);
document.querySelector('#contribution-cancel').onclick=close;
document.querySelector('#contribution-cancel-bottom').onclick=close;
wrap.onclick=event=>{if(event.target===wrap)close()};
const check=document.querySelector('#training-consent-check'),confirm=document.querySelector('#contribute-confirm');
check.onchange=()=>{confirm.disabled=!check.checked};
confirm.onclick=()=>{
try{
contributions=addContribution(contributions,receiptPreview(entry,{contributionId,consentedAt}));
saveContributions();close();render();toast('Queued on this device. Nothing was uploaded.');
}catch(error){toast(error.message||'That receipt could not be recorded.')}
};
}
function withdrawTrainingContribution(contributionId){
const result=withdrawContribution(contributions,contributionId,new Date().toISOString());
if(!result.tombstone){toast('That contribution receipt was not found.');return}
contributions=result.contributions;saveContributions();render();toast('Contribution withdrawn. The receipt is kept as a tombstone.');
return result.tombstone;
}
function privacy(){
shell(`<main><div class="page-title"><span class="eyebrow">Private by design</span><h1>Your poop. Your phone.</h1><p>This prototype has no account, analytics, ad tracker, or server database.</p></div><section class="card privacy-list"><div class="privacy-item"><b>⌂</b><div><h3>Stored locally</h3><p>Saved entries and optional photos live in this browser’s local storage. You can remove one photo without deleting its confirmed log.</p></div></div><div class="privacy-item"><b>⇩</b><div><h3>Portable</h3><p>Export a readable JSON file. Import it in another copy of Timmy.</p></div></div><div class="privacy-item"><b>◎</b><div><h3>AI only when you ask</h3><p>Manual logging never uploads. Analyze a photo sends one compressed copy after consent. Hermes chat may receive up to 20 confirmed text-only entries after you connect; photos and backend credentials never enter chat.</p></div></div></section><section class="section card"><h2>Data controls</h2><div class="row"><button class="btn btn-primary" id="export">Export JSON</button><label class="btn btn-secondary" for="import">Import JSON</label><input class="hidden" type="file" id="import" accept="application/json"></div><p class="fine section">Exports can contain sensitive health notes and photos. Store them somewhere you trust.</p></section>${trainingConsentSection()}<section class="section card source-list"><h2>Health sources</h2><p class="fine">The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.</p><p><a target="_blank" rel="noreferrer" href="https://www.continence.org.au/about-incontinence/bowel-incontinence/bristol-stool-chart/">Continence Health Australia</a></p><p><a target="_blank" rel="noreferrer" href="https://www.nhs.uk/conditions/bleeding-from-the-bottom-rectal-bleeding/">NHS rectal bleeding guidance</a></p><p><a target="_blank" rel="noreferrer" href="https://www.niddk.nih.gov/health-information/digestive-diseases/constipation/symptoms-causes">NIDDK constipation guidance</a></p></section><section class="section card danger-zone"><h2>Delete everything</h2><p class="fine">Permanently removes Timmy’s local ledger and every saved photo from this browser.</p><button class="btn btn-danger" id="delete-all">Delete all local data</button></section></main>`);
document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData;
}
function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];contributions=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);localStorage.removeItem(TRAINING_STORE);render();toast('Local ledger and photos deleted')}}
function openPhotoFirst(){beginDraft();photoHint='';resetSuggestion();visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
async function loadVisionStatus(){
try{const response=await fetch(appPath('api/vision-status'),{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}}
catch{visionStatus={enabled:false,providerReady:false}}
if(document.querySelector('.scan-sheet')&&['pick','ready'].includes(photoFirstMode))showPhotoFirst(photoFirstMode)
}
function visionStatusHtml(){
if(!visionStatus)return '<div class="model-status checking">◌ Checking the vision worker…</div>';
// Reachable is not loaded (#15): a worker answering before its pinned model is loaded must not be
// shown as ready, because the AI controls are withheld until the model can actually answer.
if(visionStatus.providerReady&&visionStatus.modelSeen!==true)return '<div class="model-status loading">◌ Vision worker up · the model is not loaded yet · manual logging is available</div>';
if(visionStatus.profile==='selfhost'&&visionStatus.providerReady)return `<div class="model-status ready">● Self-hosted model ready · ${esc(visionStatus.model)}</div>`;
if(visionStatus.providerReady)return `<div class="model-status ready">● Vision provider ready · ${esc(visionStatus.model)}</div>`;
return '<div class="model-status offline">○ Vision worker offline · manual logging is still available</div>';
}
function photoFirstBody(mode,error=''){
if(mode==='pick')return `${visionStatusHtml()}<div class="scan-hero"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><h3>One photo. Two useful suggestions.</h3><p>Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.</p></div>${error?`<p class="capture-status" role="status">${esc(error)}</p>`:''}<div class="capture-choice-grid"><label class="photo-capture" for="camera-photo"><b>📷</b><strong>Take photo</strong><span>Use the rear camera</span><input id="camera-photo" type="file" accept="image/*" capture="environment"></label><label class="photo-capture" for="gallery-photo"><b>▧</b><strong>Choose from gallery</strong><span>JPEG, PNG, or WebP</span><input id="gallery-photo" type="file" accept="image/*"></label></div><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
if(mode==='ready'){const readiness=visionReadiness(visionStatus);if(!readiness.ready)return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${form.photoDataUrl}" alt="Photo kept on this device"><p class="quality-note">${esc(photoHint)}</p><p class="fine">${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.</p><button class="btn btn-primary btn-wide" id="manual-from-scan">${esc(readiness.manualLabel)}</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${form.photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
if(mode==='analyzing')return `<div class="analyzing"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><div class="spinner" aria-hidden="true"></div><h3>Timmy is looking at form and color…</h3><p>Not symptoms. Not disease. Not whether Taco Bell was a strategic error.</p></div>`;
if(mode==='error')return `<div class="scan-result needs-input"><b>↻</b><h3>Timmy couldn’t analyze that safely.</h3><p>${esc(error||'Continue manually or try a clearer photo.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Fill it out manually</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
if(suggestionState.status==='pending'){const copy=provenanceCopy(suggestionState,form);return `<div class="scan-result provisional" data-provenance="${copy.status}" aria-label="${esc(copy.aria)}"><span class="ai-badge">${esc(copy.label)} · ${Math.round(suggestionState.suggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${suggestionState.suggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(suggestionState.suggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="provenance-note" role="status">${esc(copy.detail)}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;}
return `<div class="scan-result needs-input"><b>?</b><h3>No confident match.</h3><p>${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Choose the form yourself</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
}
function showPhotoFirst(mode='pick',error=''){
photoFirstMode=mode;
document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet scan-sheet" role="dialog" aria-modal="true" aria-labelledby="scan-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Photo-first log</span><h2 id="scan-title">${mode==='result'?'Review Timmy’s suggestion':mode==='analyzing'?'Analyzing privately':'Start with the camera'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div>${photoFirstBody(mode,error)}</section>`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))});
const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||!visionReadiness(visionStatus).ready;if(analyze)analyze.onclick=runAiAnalysis;
document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';resetSuggestion();showPhotoFirst('pick')});
document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{resetSuggestion();showLogStep(1)});
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{suggestionState=confirmSuggestion(suggestionState);form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;showLogStep(1)});
}
async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}}
async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:form.photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;suggestionState=createSuggestionState(data);showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
function openLogger(){beginDraft();photoHint='';resetSuggestion();showLogStep(1)}
function showLogStep(step){
document.querySelector('.sheet-backdrop')?.remove();
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${draftBanner()}${stepBody(step)}</section>`;document.body.append(wrap);
document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step);
}
function stepBody(step){
if(step===1)return `${provenanceBlock()||'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`;
if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${form.photoDataUrl?`<img class="photo-preview" src="${form.photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${suggestionState.suggestion?`<p class="provenance-note fine" role="status">${esc(provenanceCopy(suggestionState,form).detail)}</p>`:'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`;
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div>${followUpBlock()}<div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
}
function alertHtml(msg){return `<div class="alert"><strong>Pause and get medical help.</strong><p>${esc(msg)}</p></div>`}
function bindStep(step){
document.querySelector('#discard-draft')?.addEventListener('click',()=>{clearDraft();showLogStep(1)});
if(step===1){document.querySelectorAll('[data-type]').forEach(b=>b.onclick=()=>{const chosen=Number(b.dataset.type);form.bristolType=chosen;form.typeChosen=true;suggestionState=changeVisualField(suggestionState,'bristolType',chosen);showLogStep(1)});document.querySelector('#next').onclick=()=>{form.typeChosen=true;showLogStep(2)}}
if(step===2){const color=document.querySelector('#color');color.value=form.color;color.onchange=()=>{form.color=color.value;suggestionState=changeVisualField(suggestionState,'color',form.color);refreshProvenance()};['urgency','discomfort'].forEach(k=>{const n=document.querySelector('#'+k);n.oninput=()=>{form[k]=Number(n.value);n.nextElementSibling.value=n.value}});document.querySelector('#note').oninput=e=>form.note=e.target.value;document.querySelector('#photo').onchange=handlePhoto;document.querySelector('#back').onclick=()=>showLogStep(1);document.querySelector('#next').onclick=()=>showLogStep(3)}
if(step===3){document.querySelectorAll('[data-symptom]').forEach(c=>c.onchange=()=>{form.symptoms[c.dataset.symptom]=c.checked;const mirrored=HARD_STOOL_QUESTIONS.find(q=>q.urgentFlag===c.dataset.symptom);if(mirrored)followUpAnswers[mirrored.id]=c.checked;refreshFollowUp()});document.querySelectorAll('[data-followup]').forEach(b=>b.onclick=()=>{const question=HARD_STOOL_QUESTIONS.find(q=>q.id===b.dataset.followup);const value=b.dataset.answer==='yes';followUpAnswers[b.dataset.followup]=value;if(question?.urgentFlag)form.symptoms[question.urgentFlag]=value;syncFollowUpFlags();refreshFollowUp()});document.querySelector('#back').onclick=()=>showLogStep(2);document.querySelector('#save').onclick=saveLog}
}
async function handlePhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}}
function compressPhoto(file){return new Promise((resolve,reject)=>{const img=new Image(),url=URL.createObjectURL(file);img.onload=()=>{const scale=Math.min(1,1200/Math.max(img.width,img.height)),canvas=document.createElement('canvas');canvas.width=Math.round(img.width*scale);canvas.height=Math.round(img.height*scale);const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,canvas.width,canvas.height);const sample=ctx.getImageData(0,0,Math.min(canvas.width,120),Math.min(canvas.height,120)).data;let total=0;for(let i=0;i<sample.length;i+=4)total+=(sample[i]+sample[i+1]+sample[i+2])/3;URL.revokeObjectURL(url);resolve({dataUrl:canvas.toDataURL('image/jpeg',.7),width:img.width,height:img.height,brightness:total/(sample.length/4)/255})};img.onerror=()=>{URL.revokeObjectURL(url);reject()};img.src=url})}
function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form});try{entries.push(entry);saveEntries()}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;saveEntries();toast('Log saved, but the photo was too large for browser storage')}clearDraft();document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')}
function render(){({home,calendar,timmy,privacy}[view]||home)()}
render();
if('serviceWorker' in navigator)navigator.serviceWorker.register(appPath('service-worker.js'),{scope:APP_ROOT}).catch(()=>{});