import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js'; import { mergeVisualSuggestion } from './src/analysis.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 photoDataUrl = ''; let photoHint = ''; let aiSuggestion = 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.' }]; 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) { 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); } 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(){ 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
Timmy noticed

${esc(buildTimmySummary(entries))}

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(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;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…
'; 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 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()}; 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||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(appPath('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(appPath('service-worker.js'),{scope:APP_ROOT}).catch(()=>{});