128 lines
24 KiB
JavaScript
128 lines
24 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';
|
||
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>the Talking Turd</span></div></div><div class="privacy-chip">🔒 Local ledger</div></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>⌂</b>Home</button><button class="nav-btn ${view==='calendar'?'active':''}" data-view="calendar"><b>▦</b>Calendar</button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy"><b>◉</b>Ask Timmy</button><button class="nav-btn ${view==='privacy'?'active':''}" data-view="privacy"><b>⌁</b>Privacy</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(){
|
||
shell(`<main><section class="hero"><span class="eyebrow">Your intelligent pooping pal</span><h1>Snap first.<br>Timmy fills the form.</h1><p class="lead">Take a private photo. AI suggests the visible Bristol form and color; you confirm it, then add the things a camera cannot know.</p><div class="hero-actions"><button class="btn btn-primary btn-scan" data-scan>📷 Analyze a photo</button><button class="btn btn-secondary" data-log>Log manually</button></div><p class="hero-foot">AI photo mode is optional. Your saved ledger stays in this browser.</p></section><section class="section"><div class="stats"><div class="stat"><strong>${thisWeek()}</strong><span>THIS WEEK</span></div><div class="stat"><strong>${currentStreak()}</strong><span>DAY STREAK</span></div><div class="stat"><strong>${entries.length}</strong><span>ALL LOGS</span></div></div></section><section class="section"><div class="section-head"><div><span class="eyebrow">Timmy noticed</span><h2>Your pattern</h2></div></div><div class="card summary-card"><img src="/assets/timmy.svg" alt=""><p>${esc(buildTimmySummary(entries))}</p></div></section><section class="section"><div class="section-head"><div><span class="eyebrow">Recent business</span><h2>Your logs</h2></div>${entries.length?'<button class="btn btn-ghost" data-view="calendar">See all</button>':''}</div><div class="card">${recentList(4)}</div></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)=>`<button class="day ${counts[i+1]?'has-log':''}" title="${counts[i+1]||0} logs">${i+1}</button>`).join('');
|
||
shell(`<main><div class="page-title"><span class="eyebrow">The poop calendar</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="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"><h2>All entries</h2><button class="btn btn-primary" data-log>+ Add</button></div><div class="card">${recentList(100)}</div></section></main>`);
|
||
}
|
||
|
||
function timmy(){
|
||
const reply=buildTimmySummary(entries);
|
||
shell(`<main><div class="page-title"><span class="eyebrow">Pattern pal, not a doctor</span><h1>Ask Timmy</h1><p>Timmy answers from the records on this device. He never diagnoses or clears a food.</p></div><section class="card"><div class="chat" id="chat"><div class="bubble timmy">Hey, bowel buddy. I can summarize your recent form and frequency or explain what this prototype stores.</div><div class="bubble timmy">${esc(reply)}</div></div><div class="prompt-row section"><button class="prompt" data-prompt="pattern">What’s my pattern?</button><button class="prompt" data-prompt="privacy">Where are my photos?</button><button class="prompt" data-prompt="food">Can I eat Taco Bell?</button></div></section><section class="section card"><h3>Timmy’s hard boundary</h3><p class="fine">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.</p></section></main>`);
|
||
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',`<div class="bubble user">${q}</div><div class="bubble timmy">${esc(a)}</div>`);
|
||
}
|
||
|
||
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 to the configured AI provider after consent; Timmy’s server does not save it.</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(()=>{});
|