import { bucketForBristolType, detectUrgentFlags, detectUrgentText, exportLedgerBackup, LEDGER_EXPORT_VERSION, hasUrgentLedgerContext, importLedgerBackup, 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; // Issue #35 — importing a backup reports which version the file came from and what changed, and a // refused file leaves the ledger exactly as it was. The notice is state, not a toast, because a // message that disappears in two seconds cannot tell anyone whether their records were replaced. let portabilityNotice = null; 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)); } // 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?``:''; 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)); 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 `

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=>{ const photo=photoState(e); return `
T${e.bristolType}
${formatDate(e.occurredAt)}${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}${photo.hasPhoto?`${esc(photo.label)}`:''}
${bucketForBristolType(e.bristolType)}
`; }).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=``; 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=`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()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.
'; } // 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 `
From your log${message.citations.map(citation=>``).join('')}
`; } function messageHtml(message){ const citations=chatCitationChips(message); const unverified=message.unverified?`

${esc(message.notice||'This answer was not verified against your journal.')}

`:''; return `
${esc(message.text)}${citations}${unverified}
`; } 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.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=``; 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=>`
Type ${receipt.labels.bristolType} · ${esc(receipt.labels.color)}Receipt ${esc(receipt.contributionId)} · consent ${esc(receipt.consentVersion)}
`); const offerable=entries.filter(entry=>contributionEligibility(entry).eligible&&!contributions.some(receipt=>receipt.entryId===entry.id)).map(entry=>`
${formatDate(entry.occurredAt)}Type ${entry.bristolType} · photo saved
`); const tombstones=contributions.filter(receipt=>receipt.state==='withdrawn').map(receipt=>`
Withdrawn ${esc(formatDate(receipt.withdrawnAt))}raw image, confirmed label · receipt ${esc(receipt.contributionId)}
`); const rows=[...active,...offerable].join(''); return ``; } 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=``; 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(`
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. You can remove one photo without deleting its confirmed log.

⇩

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.

${portabilityNoticeBlock()}${trainingConsentSection()}

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 and every saved photo 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([exportLedgerBackup({entries,contributions})],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`timmy-backup-v${LEDGER_EXPORT_VERSION}.json`;a.click();URL.revokeObjectURL(a.href);portabilityNotice={kind:'ok',version:LEDGER_EXPORT_VERSION,text:`Backup written as version ${LEDGER_EXPORT_VERSION}: ${entries.length} log${entries.length===1?'':'s'} and ${contributions.length} training receipt${contributions.length===1?'':'s'}.`};render();toast('Backup exported');} async function importData(e){const file=e.target.files?.[0];if(!file)return;try{const result=importLedgerBackup(await file.text());entries=result.entries;contributions=result.contributions;saveEntries();saveContributions();portabilityNotice={kind:'ok',version:result.fromVersion,text:[`Restored ${result.entries.length} log${result.entries.length===1?'':'s'} and ${result.contributions.length} training receipt${result.contributions.length===1?'':'s'} from a version ${result.fromVersion} backup.`,...result.notices].join(' ')};e.target.value='';render();toast('Backup imported')}catch(err){portabilityNotice={kind:'error',text:`${err.message||'That backup could not be read.'} Your ledger is unchanged.`};e.target.value='';render();toast('Nothing was imported')}} function portabilityNoticeBlock(){if(!portabilityNotice)return '';if(portabilityNotice.kind==='error')return ``;return `

${esc(portabilityNotice.text)}

`} function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];contributions=[];portabilityNotice=null;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 '
◌ 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})} // Issue #35 — if this log began as an AI suggestion, the saved record says so. The fields are // limited to what the app actually knows (which visible fields were suggested, whether they were // confirmed or corrected, and which model answered), so a backup cannot be read as a diagnosis or a // broader claim than the visual suggestion the person accepted. function provenanceForSave(){ const status=suggestionState?.status; if(status!=='confirmed'&&status!=='corrected')return null; const suggestion=suggestionState.suggestion; if(!suggestion)return null; const provenance={source:status==='confirmed'?'ai-confirmed':'ai-corrected',suggestedBristolType:suggestion.bristolType,suggestedColor:suggestion.color,confirmedAt:new Date().toISOString()}; const model=typeof visionStatus?.model==='string'?visionStatus.model.trim():''; if(model)provenance.model=model.slice(0,80); return provenance; } function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form,provenance:provenanceForSave()});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(()=>{});