All checks were successful
Quality gates / quality (pull_request) Successful in 1m24s
167 lines
29 KiB
JavaScript
167 lines
29 KiB
JavaScript
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';
|
||
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) {
|
||
app.innerHTML = `<header class="topbar"><div class="brand"><img src="/assets/timmy.svg" alt="Timmy mascot"><div class="brand-copy"><strong>Timmy</strong><span>private bowel journal</span></div></div><span class="local-mark" title="Saved locally">● Ledger local</span></header>${content}${nav()}`;
|
||
bindGlobal();
|
||
}
|
||
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home"><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar"><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy"><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
|
||
function bindGlobal(){
|
||
document.querySelectorAll('[data-view]').forEach(btn=>btn.onclick=()=>{view=btn.dataset.view;render()});
|
||
document.querySelectorAll('[data-log]').forEach(btn=>btn.onclick=openLogger);
|
||
document.querySelectorAll('[data-scan]').forEach(btn=>btn.onclick=openPhotoFirst);
|
||
}
|
||
|
||
function recentList(limit=5){
|
||
if(!entries.length)return `<div class="empty"><img src="/assets/timmy.svg" alt=""><h3>Quiet bowl, clean slate.</h3><p>Your first log takes about ten seconds.</p></div>`;
|
||
return entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt)).slice(0,limit).map(e=>`<article class="entry"><div class="type-dot">T${e.bristolType}</div><div><strong>${formatDate(e.occurredAt)}</strong><span>${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}</span></div><span class="bucket bucket-${bucketForBristolType(e.bristolType)}">${bucketForBristolType(e.bristolType)}</span></article>`).join('');
|
||
}
|
||
function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()<week).length}
|
||
function currentStreak(){const dates=new Set(entries.map(e=>e.occurredAt.slice(0,10)));let n=0,d=new Date();while(dates.has(d.toISOString().slice(0,10))){n++;d.setDate(d.getDate()-1)}return n}
|
||
|
||
function home(){
|
||
const latest=entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt))[0];
|
||
shell(`<main class="home-main"><section class="hero sleek-hero"><span class="eyebrow">Your intelligent pooping pal</span><h1>Log it.<br>Learn the pattern.</h1><p class="lead">Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.</p><button class="capture-cta btn-primary" data-scan><span class="capture-icon" aria-hidden="true">◉</span><span><strong>Start photo log</strong><small>Private, guided, about 10 seconds</small></span><i aria-hidden="true">→</i></button><button class="text-action" data-log>Log manually instead</button></section><section class="glance" aria-label="Journal at a glance"><div><strong>${thisWeek()}</strong><span>this week</span></div><i></i><div><strong>${currentStreak()}</strong><span>day streak</span></div><i></i><div><strong>${entries.length}</strong><span>all logs</span></div></section><section class="section"><div class="insight-card"><div class="timmy-orb"><img src="/assets/timmy.svg" alt=""></div><div><span class="eyebrow">Timmy noticed</span><p>${esc(buildTimmySummary(entries))}</p></div></div></section><section class="section recent-section"><div class="section-head"><div><span class="eyebrow">Latest</span><h2>${latest?'Recent log':'Ready when you are'}</h2></div>${latest?'<button class="text-action compact" data-view="calendar">View journal</button>':''}</div>${latest?`<div class="latest-card"><div class="type-dot">T${latest.bristolType}</div><div><strong>${formatDate(latest.occurredAt)}</strong><span>${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}</span></div><span class="chevron">›</span></div>`:'<p class="fine">One quick, confirmed entry is enough to begin seeing your pattern.</p>'}</section></main>`);
|
||
}
|
||
|
||
function calendar(){
|
||
const now=new Date(),year=now.getFullYear(),month=now.getMonth(),first=new Date(year,month,1),days=new Date(year,month+1,0).getDate();
|
||
const counts={};entries.forEach(e=>{const d=new Date(e.occurredAt);if(d.getFullYear()===year&&d.getMonth()===month)counts[d.getDate()]=(counts[d.getDate()]||0)+1});
|
||
const blanks=Array(first.getDay()).fill('<div class="day blank"></div>').join('');
|
||
const boxes=Array.from({length:days},(_,i)=>`<div class="day ${counts[i+1]?'has-log':''}" title="${counts[i+1]||0} logs">${i+1}</div>`).join('');
|
||
shell(`<main><div class="page-title journal-title"><span class="eyebrow">Your private journal</span><h1>${now.toLocaleString(undefined,{month:'long'})}</h1><p>A calm view of frequency and form. One unusual day is not a verdict.</p></div><section class="calendar-card"><div class="calendar">${['S','M','T','W','T','F','S'].map(x=>`<div class="cal-head">${x}</div>`).join('')}${blanks}${boxes}</div></section><section class="section"><div class="section-head"><div><span class="eyebrow">Confirmed entries</span><h2>Recent logs</h2></div><button class="text-action compact" data-log>+ Add log</button></div><div class="entries-card">${recentList(100)}</div></section><section class="section journal-settings"><button class="settings-row" data-view="privacy"><span>Data, privacy & sources</span><b aria-hidden="true">›</b></button></section></main>`);
|
||
}
|
||
|
||
function agentStatusHtml(){
|
||
if(!agentStatus)return '<div class="agent-status checking"><i></i><span><strong>Checking Hermes…</strong><small>Your journal still works offline.</small></span></div>';
|
||
if(agentStatus.authenticated)return '<div class="agent-status connected"><i></i><span><strong>Hermes Agent connected</strong><small>Full tools stay server-side. Photos are never sent to chat.</small></span></div>';
|
||
if(agentStatus.configured)return '<div class="agent-status locked"><i></i><span><strong>Hermes is locked</strong><small>Connect once with the operator access code.</small></span></div>';
|
||
return '<div class="agent-status local"><i></i><span><strong>Local Timmy mode</strong><small>Simple journal answers work without a backend.</small></span></div>';
|
||
}
|
||
function messageHtml(message){return `<div class="bubble ${message.role==='user'?'user':'timmy'}">${esc(message.text)}</div>`}
|
||
function timmy(){
|
||
shell(`<main class="chat-page"><div class="page-title chat-title"><span class="eyebrow">A real conversation</span><h1>Talk to Timmy</h1><p>Ask naturally. Timmy can reason over confirmed logs and use Hermes tools, but never diagnoses or invents symptoms.</p></div>${agentStatusHtml()}${agentStatus?.configured&&!agentStatus?.authenticated?`<section class="unlock-card"><label for="agent-code">Operator access code</label><div class="unlock-row"><input class="input" id="agent-code" type="password" autocomplete="current-password" placeholder="Enter access code"><button class="btn btn-primary" id="connect-agent">Connect</button></div><p class="fine">The code is exchanged for an HttpOnly same-origin session and is never stored in this browser.</p></section>`:''}<section class="conversation"><div class="chat" id="chat" aria-live="polite">${chatMessages.map(messageHtml).join('')}${chatBusy?'<div class="bubble timmy thinking"><span></span><span></span><span></span></div>':''}</div>${chatError?`<p class="chat-error">${esc(chatError)}</p>`:''}<form class="composer" id="chat-form"><label class="sr-only" for="chat-message">Message Timmy</label><textarea id="chat-message" maxlength="4000" rows="1" placeholder="Ask about your pattern…" ${chatBusy?'disabled':''}></textarea><button id="send-chat" type="submit" aria-label="Send message" ${chatBusy?'disabled':''}>↑</button></form><p class="composer-note">Confirmed log fields may be sent to your configured Hermes backend. Photos never are.</p></section><section class="safety-line"><strong>Urgent symptoms always override chat.</strong> Blood, black or dark-red stool, severe pain, vomiting, fever, or inability to pass gas triggers deterministic medical guidance.</section></main>`);
|
||
document.querySelector('#chat-form')?.addEventListener('submit',sendChat);
|
||
document.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('/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('/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 urgentChatReply(message){
|
||
if(!/(blood|black (?:or |and )?dark[- ]?red stool|black stool|dark[- ]?red stool|severe|constant abdominal pain|vomit|fever|cannot pass gas|can['’]?t pass gas)/i.test(message))return '';
|
||
return 'Pause and get medical help. Those symptoms can need prompt medical assessment. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.';
|
||
}
|
||
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=urgentChatReply(message);if(urgent){chatMessages.push({role:'timmy',text:urgent});timmy();return}
|
||
if(!agentStatus?.authenticated){chatMessages.push({role:'timmy',text:localChatReply(message)});timmy();return}
|
||
chatBusy=true;timmy();
|
||
try{const response=await fetch('/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(`<main><div class="page-title"><span class="eyebrow">Private by design</span><h1>Your poop. Your phone.</h1><p>This prototype has no account, analytics, ad tracker, or server database.</p></div><section class="card privacy-list"><div class="privacy-item"><b>⌂</b><div><h3>Stored locally</h3><p>Saved entries and optional photos live in this browser’s local storage.</p></div></div><div class="privacy-item"><b>⇩</b><div><h3>Portable</h3><p>Export a readable JSON file. Import it in another copy of Timmy.</p></div></div><div class="privacy-item"><b>◎</b><div><h3>AI only when you ask</h3><p>Manual logging never uploads. Analyze a photo sends one compressed copy after consent. Hermes chat may receive up to 20 confirmed text-only entries after you connect; photos and backend credentials never enter chat.</p></div></div></section><section class="section card"><h2>Data controls</h2><div class="row"><button class="btn btn-primary" id="export">Export JSON</button><label class="btn btn-secondary" for="import">Import JSON</label><input class="hidden" type="file" id="import" accept="application/json"></div><p class="fine section">Exports can contain sensitive health notes and photos. Store them somewhere you trust.</p></section><section class="section card source-list"><h2>Health sources</h2><p class="fine">The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.</p><p><a target="_blank" rel="noreferrer" href="https://www.continence.org.au/about-incontinence/bowel-incontinence/bristol-stool-chart/">Continence Health Australia</a></p><p><a target="_blank" rel="noreferrer" href="https://www.nhs.uk/conditions/bleeding-from-the-bottom-rectal-bleeding/">NHS rectal bleeding guidance</a></p><p><a target="_blank" rel="noreferrer" href="https://www.niddk.nih.gov/health-information/digestive-diseases/constipation/symptoms-causes">NIDDK constipation guidance</a></p></section><section class="section card danger-zone"><h2>Delete everything</h2><p class="fine">Permanently removes Timmy’s local ledger from this browser.</p><button class="btn btn-danger" id="delete-all">Delete all local data</button></section></main>`);
|
||
document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData;
|
||
}
|
||
function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
|
||
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
|
||
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];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 '<div class="model-status checking">◌ Checking the vision worker…</div>';
|
||
if(visionStatus.profile==='selfhost'&&visionStatus.providerReady)return `<div class="model-status ready">● Self-hosted model ready · ${esc(visionStatus.model)}</div>`;
|
||
if(visionStatus.providerReady)return `<div class="model-status ready">● Vision provider ready · ${esc(visionStatus.model)}</div>`;
|
||
return '<div class="model-status offline">○ Vision worker offline · manual logging is still available</div>';
|
||
}
|
||
function photoFirstBody(mode,error=''){
|
||
if(mode==='pick')return `${visionStatusHtml()}<div class="scan-hero"><img src="/assets/timmy.svg" alt="Timmy"><h3>One photo. Two useful suggestions.</h3><p>Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.</p></div><label class="photo-capture" for="ai-photo"><b>📷</b><strong>Take or choose a photo</strong><span>JPEG, PNG, or WebP · compressed before analysis</span><input id="ai-photo" type="file" accept="image/*" capture="environment"></label><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
|
||
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()}<img class="photo-preview scan-preview" src="${photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
|
||
if(mode==='analyzing')return `<div class="analyzing"><img src="/assets/timmy.svg" alt="Timmy"><div class="spinner" aria-hidden="true"></div><h3>Timmy is looking at form and color…</h3><p>Not symptoms. Not disease. Not whether Taco Bell was a strategic error.</p></div>`;
|
||
if(mode==='error')return `<div class="scan-result needs-input"><b>↻</b><h3>Timmy couldn’t analyze that safely.</h3><p>${esc(error||'Continue manually or try a clearer photo.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Fill it out manually</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
|
||
if(aiSuggestion?.status==='suggestion')return `<div class="scan-result"><span class="ai-badge">AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${aiSuggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(aiSuggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;
|
||
return `<div class="scan-result needs-input"><b>?</b><h3>No confident match.</h3><p>${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Choose the form yourself</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
|
||
}
|
||
function showPhotoFirst(mode='pick',error=''){
|
||
photoFirstMode=mode;
|
||
document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet scan-sheet" role="dialog" aria-modal="true" aria-labelledby="scan-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Photo-first log</span><h2 id="scan-title">${mode==='result'?'Review Timmy’s suggestion':mode==='analyzing'?'Analyzing privately':'Start with the camera'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div>${photoFirstBody(mode,error)}</section>`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
|
||
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=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i style="width:${step*33.34}%"></i></div>${stepBody(step)}</section>`;document.body.append(wrap);
|
||
document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step);
|
||
}
|
||
function stepBody(step){
|
||
if(step===1)return `${aiSuggestion?.status==='suggestion'?`<div class="ai-prefill"><span class="ai-badge">AI PREFILLED</span><strong>Type ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}</strong><small>You’re in charge—tap any type to correct it.</small></div>`:'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`;
|
||
if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${photoDataUrl?`<img class="photo-preview" src="${photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${aiSuggestion?.status==='suggestion'?'<p class="fine">AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.</p>':'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`;
|
||
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
|
||
}
|
||
function alertHtml(msg){return `<div class="alert"><strong>Pause and get medical help.</strong><p>${esc(msg)}</p></div>`}
|
||
function bindStep(step){
|
||
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<sample.length;i+=4)total+=(sample[i]+sample[i+1]+sample[i+2])/3;URL.revokeObjectURL(url);resolve({dataUrl:canvas.toDataURL('image/jpeg',.7),width:img.width,height:img.height,brightness:total/(sample.length/4)/255})};img.onerror=()=>{URL.revokeObjectURL(url);reject()};img.src=url})}
|
||
function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form,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(()=>{});
|