import { bucketForBristolType, buildTimmySummary, 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'; 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 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 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 `
Draft kept in this session: ${esc(draftNotice(form))}
`; } 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 `
${esc(copy.label)}Type ${form.bristolType} · ${esc(form.color)}You’re in charge—tap any type to correct it.

${esc(copy.detail)}

`; } 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&©.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 `
That answer sets the red-flag check below.

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.

`; if(!verdict.answered)return '

Answer what applies. Anything you skip stays unanswered, and none of it is guessed from your photo.

'; return `
Mild report — general self-care, not a diagnosis.

${esc(verdict.note)}

${esc(verdict.seekCare)}

`; } function followUpBlock(){ if(!needsHardStoolFollowUp(form))return ''; const state=followUpState(); const rows=HARD_STOOL_QUESTIONS.map(question=>`
${esc(question.label)}${question.urgentFlag?'Also sets the red-flag check below.':''}
`).join(''); return `

Type ${form.bristolType} — a few quick questions

Timmy never guesses these from your photo. You report them, and you can skip any of them.

${rows}
${followUpOutput(evaluateHardStoolFollowUp(state))}
`; } 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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); } function loadEntries() { try { return JSON.parse(localStorage.getItem(STORE) || '[]'); } catch { return []; } } function saveEntries() { localStorage.setItem(STORE, JSON.stringify(entries)); } 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?``:''; app.innerHTML = `
Timmy mascot
Timmyprivate bowel journal
● Ledger local
${content}${label}${nav()}`; bindGlobal(); } function nav(){return ``} 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)); } function recentList(limit=5){ if(!entries.length)return `

Quiet bowl, clean slate.

Your first log takes about ten seconds.

`; return entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt)).slice(0,limit).map(e=>`
T${e.bristolType}
${formatDate(e.occurredAt)}${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}
${bucketForBristolType(e.bristolType)}
`).join(''); } function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()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 ? `

${esc(summary.copy)}

` : ``; return `
Timmy noticed

${esc(summary.window.label)} · ${logs}

${lines}

${esc(summary.boundary)}

`; } 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=>`
T${record.bristolType}
${formatDate(record.occurredAt)}Type ${record.bristolType} · ${esc(record.bucket)}
`).join(''); document.querySelector('.sheet-backdrop')?.remove(); const wrap=document.createElement('div');wrap.className='sheet-backdrop'; wrap.innerHTML=``; 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(`
Your intelligent pooping pal

Log it.
Learn the pattern.

Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.

${thisWeek()}this week
${currentStreak()}day streak
${entries.length}all logs
${patternSummaryCard()}
Latest

${latest?'Recent log':'Ready when you are'}

${latest?'':''}
${latest?`
T${latest.bristolType}
${formatDate(latest.occurredAt)}${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}
›
`:'

One quick, confirmed entry is enough to begin seeing your pattern.

'}
`); } 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('
').join(''); const boxes=Array.from({length:days},(_,i)=>`
${i+1}
`).join(''); shell(`
Your private journal

${now.toLocaleString(undefined,{month:'long'})}

A calm view of frequency and form. One unusual day is not a verdict.

${['S','M','T','W','T','F','S'].map(x=>`
${x}
`).join('')}${blanks}${boxes}
Confirmed entries

Recent logs

${recentList(100)}
`); } function agentStatusHtml(){ if(!agentStatus)return '
Checking Hermes…Your journal still works offline.
'; if(agentStatus.authenticated)return '
Hermes Agent connectedFull tools stay server-side. Photos are never sent to chat.
'; if(agentStatus.configured)return '
Hermes is lockedConnect once with the operator access code.
'; return '
Local Timmy modeSimple journal answers work without a backend.
'; } function messageHtml(message){return `
${esc(message.text)}
`} function timmy(){ shell(`
A real conversation

Talk to Timmy

Ask naturally. Timmy can reason over confirmed logs and use Hermes tools, but never diagnoses or invents symptoms.

${agentStatusHtml()}${agentStatus?.configured&&!agentStatus?.authenticated?`

The code is exchanged for an HttpOnly same-origin session and is never stored in this browser.

`:''}
${chatMessages.map(messageHtml).join('')}${chatBusy?'
':''}
${chatError?`

${esc(chatError)}

`:''}

Confirmed log fields may be sent to your configured Hermes backend. Photos never are.

Urgent symptoms always override chat. Blood, black or dark-red stool, severe pain, vomiting, fever, or inability to pass gas triggers deterministic medical guidance.
`); document.querySelector('#chat-form')?.addEventListener('submit',sendChat); 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 localChatReply(message){ const lower=message.toLowerCase(); if(/photo|privacy|upload|store/.test(lower))return 'Saved logs stay in this browser. Photo analysis sends one compressed copy only after consent. Chat can receive confirmed text fields, but never photos.'; if(/food|eat|restaurant|taco/.test(lower))return 'A bowel journal cannot clear a food or restaurant. I can help you compare confirmed entries over time, not decide what is safe to eat.'; return buildTimmySummary(entries); } 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){chatMessages.push({role:'timmy',text:localChatReply(message)});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.');chatMessages.push({role:'timmy',text:data.reply})} 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()} } function privacy(){ shell(`
Private by design

Your poop. Your phone.

This prototype has no account, analytics, ad tracker, or server database.

⌂

Stored locally

Saved entries and optional photos live in this browser’s local storage.

⇩

Portable

Export a readable JSON file. Import it in another copy of Timmy.

◎

AI only when you ask

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.

Data controls

Exports can contain sensitive health notes and photos. Store them somewhere you trust.

Health sources

The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.

Continence Health Australia

NHS rectal bleeding guidance

NIDDK constipation guidance

Delete everything

Permanently removes Timmy’s local ledger from this browser.

`); 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=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger 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 '
◌ Checking the vision worker…
'; // 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 '
◌ Vision worker up · the model is not loaded yet · manual logging is available
'; if(visionStatus.profile==='selfhost'&&visionStatus.providerReady)return `
● Self-hosted model ready · ${esc(visionStatus.model)}
`; if(visionStatus.providerReady)return `
● Vision provider ready · ${esc(visionStatus.model)}
`; return '
○ Vision worker offline · manual logging is still available
'; } function photoFirstBody(mode,error=''){ if(mode==='pick')return `${visionStatusHtml()}
Timmy

One photo. Two useful suggestions.

Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.

${error?`

${esc(error)}

`:''}
`; if(mode==='ready'){const readiness=visionReadiness(visionStatus);if(!readiness.ready)return `${visionStatusHtml()}Photo kept on this device

${esc(photoHint)}

${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.

`;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()}Photo awaiting AI analysis

${esc(photoHint)}

`;} if(mode==='analyzing')return `
Timmy

Timmy is looking at form and color…

Not symptoms. Not disease. Not whether Taco Bell was a strategic error.

`; if(mode==='error')return `
↻

Timmy couldn’t analyze that safely.

${esc(error||'Continue manually or try a clearer photo.')}

`; if(suggestionState.status==='pending'){const copy=provenanceCopy(suggestionState,form);return `
${esc(copy.label)} · ${Math.round(suggestionState.suggestion.confidence*100)}% CONFIDENCE
BRISTOL FORMType ${suggestionState.suggestion.bristolType}
VISIBLE COLOR${esc(suggestionState.suggestion.color)}

${esc(aiSuggestion.observations||'Visual match found.')}

${esc(copy.detail)}

${esc(aiSuggestion.warning)}

`;} return `
?

No confident match.

${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}

`; } function showPhotoFirst(mode='pick',error=''){ photoFirstMode=mode; document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=``;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=``;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()||'

Choose the closest match yourself. Timmy never treats a suggestion as fact.

'}
${[[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])=>``).join('')}
`; if(step===2)return `${form.photoDataUrl?`Private entry preview`:''}${photoHint?`

${esc(photoHint)}

`:''}${suggestionState.suggestion?`

${esc(provenanceCopy(suggestionState,form).detail)}

`:'

Manual-mode photos stay in this browser and receive quality checks only.

'}
`; const urgent=detectUrgentFlags(form.symptoms);return `

Select anything you have now. This is where Timmy stops joking.

${[['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])=>``).join('')}
${followUpBlock()}
${urgent.urgent?alertHtml(urgent.message):''}

Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.

`; } function alertHtml(msg){return `
Pause and get medical help.

${esc(msg)}

`} 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{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(()=>{});