import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, exportLedger, importLedger, photoQualityMessage, sanitizeEntry } from './src/domain.js'; import { mergeVisualSuggestion } from './src/analysis.js'; const STORE = 'timmy-ledger-v1'; const app = document.querySelector('#app'); let entries = loadEntries(); let view = 'home'; let photoDataUrl = ''; let photoHint = ''; let aiSuggestion = null; let visionStatus = null; let photoFirstMode = 'pick'; const draft = () => ({ bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: '', symptoms: {} }); let form = draft(); 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) { app.innerHTML = `
Timmy mascot
Timmythe Talking Turd
🔒 Local ledger
${content}${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); } 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} function home(){ shell(`
Your intelligent pooping pal

Snap first.
Timmy fills the form.

Take a private photo. AI suggests the visible Bristol form and color; you confirm it, then add the things a camera cannot know.

AI photo mode is optional. Your saved ledger stays in this browser.

${thisWeek()}THIS WEEK
${currentStreak()}DAY STREAK
${entries.length}ALL LOGS
Timmy noticed

Your pattern

${esc(buildTimmySummary(entries))}

Recent business

Your logs

${entries.length?'':''}
${recentList(4)}
`); } 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)=>``).join(''); shell(`
The poop calendar

${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}

All entries

${recentList(100)}
`); } function timmy(){ const reply=buildTimmySummary(entries); shell(`
Pattern pal, not a doctor

Ask Timmy

Timmy answers from the records on this device. He never diagnoses or clears a food.

Hey, bowel buddy. I can summarize your recent form and frequency or explain what this prototype stores.
${esc(reply)}

Timmy’s hard boundary

If you report blood, black or dark-red stool, severe or constant abdominal pain, vomiting, fever, or inability to pass gas, Timmy stops joking and tells you to seek medical care.

`); document.querySelectorAll('[data-prompt]').forEach(btn=>btn.onclick=()=>chatReply(btn)); } function chatReply(btn){ const chat=document.querySelector('#chat'),kind=btn.dataset.prompt; const q={pattern:'What’s my pattern?',privacy:'Where are my photos?',food:'Can I eat Taco Bell?'}[kind]; const a={pattern:buildTimmySummary(entries),privacy:'Your saved ledger and photos stay in this browser. If you explicitly use Analyze a photo, one compressed copy is sent to the configured AI provider for that analysis and is not stored by Timmy’s server.',food:'That call is yours. A stool diary cannot clear a restaurant or prove a food is safe. Log what happens and look for repeated patterns.'}[kind]; chat.insertAdjacentHTML('beforeend',`
${q}
${esc(a)}
`); } 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 to the configured AI provider after consent; Timmy’s server does not save it.

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);render();toast('Local ledger deleted')}} function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()} async function loadVisionStatus(){ try{const response=await fetch('/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…
'; 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.

`; if(mode==='ready'){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(aiSuggestion?.status==='suggestion')return `
AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE
BRISTOL FORMType ${aiSuggestion.bristolType}
VISIBLE COLOR${esc(aiSuggestion.color)}

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

${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()}; const file=document.querySelector('#ai-photo');if(file)file.onchange=handleAiPhoto; const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||visionStatus?.providerReady===false;if(analyze)analyze.onclick=runAiAnalysis; document.querySelector('#retake-photo')?.addEventListener('click',()=>{photoDataUrl='';photoHint='';aiSuggestion=null;showPhotoFirst('pick')}); document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{aiSuggestion=null;showLogStep(1)}); document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);showLogStep(1)}); } async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);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('/api/analyze',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}} function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;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 `${aiSuggestion?.status==='suggestion'?`
AI PREFILLEDType ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}You’re in charge—tap any type to correct it.
`:'

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 `${photoDataUrl?`Private entry preview`:''}${photoHint?`

${esc(photoHint)}

`:''}${aiSuggestion?.status==='suggestion'?'

AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.

':'

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('')}
${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){ if(step===1){document.querySelectorAll('[data-type]').forEach(b=>b.onclick=()=>{form.bristolType=Number(b.dataset.type);aiSuggestion=null;showLogStep(1)});document.querySelector('#next').onclick=()=>showLogStep(2)} if(step===2){const color=document.querySelector('#color');color.value=form.color;color.onchange=()=>form.color=color.value;['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;document.querySelector('#urgent-box').innerHTML=detectUrgentFlags(form.symptoms).urgent?alertHtml(detectUrgentFlags(form.symptoms).message):''});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);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,photoDataUrl});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')}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('/service-worker.js').catch(()=>{});