const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas']; const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.'; // Bleeding is reported in more shapes than "blood in my stool": wiping, toilet paper, or the // anatomy word the person actually uses. Each alternative still requires a bowel or wiping // context, and the near-miss wording that must stay quiet ("a paper cut", "blood pressure") // is pinned in tests/escalation-regression.test.js. const BLEEDING_TEXT = new RegExp(`\\b(?:${[ 'rectal bleeding', 'bleeding from (?:the )?(?:rectum|bottom|back passage|anus)', '(?:blood|bleeding)\\b[^.!?]{0,30}\\b(?:when i wipe|when wiping|on (?:the )?toilet paper)', '(?:blood|bleeding)\\b[^.!?]{0,20}\\b(?:from|in|out of) (?:my|the) (?:bottom|rectum|back passage|anus)', '(?:blood|bleeding)\\b[^.!?]{0,20}\\b(?:in|mixed (?:in|into)) (?:my |the )?(?:poo|poop|stool|bowel movement)', '(?:diarrh(?:ea|oea)|loose stools?)\\b[^.!?]{0,15}\\bwith blood', 'blood(?:y)? (?:in|on|with) (?:my |the )?(?:poo|poop|stool|bowel movement)', '(?:stool|poop|poo) (?:has|contains|with) blood', ].join('|')})\\b`, 'i'); const URGENT_TEXT_PATTERNS = Object.freeze([ ['blood', BLEEDING_TEXT], ['blackOrDarkRed', new RegExp(`\\b(?:${[ '(?:black|dark[- ]?red|tarry) (?:stool|stools|poop|poo|bowel movement|bathroom visit)s?', '(?:stool|stools|poop|poo|bowel movement) (?:is|are|was|were|looks?|looked|seems?|came out) (?:very |pitch )?(?:black|dark[- ]?red|tarry)', '(?:stool|stools|poop|poo) (?:that )?looks? like tar', 'black[^.!?]{0,15}\\btarry', ].join('|')})\\b`, 'i')], ['severePain', new RegExp(`\\b(?:${[ '(?:severe|constant|unrelenting|unbearable|worst) (?:abdominal|stomach|belly|tummy|abdomen|gut) pain', '(?:abdominal|stomach|belly|tummy|abdomen|gut)[^.!?]{0,20}\\bpain\\b[^.!?]{0,20}\\b(?:severe|constant|unrelenting|unbearable|worse)\\b', '(?:severe|constant|unrelenting|unbearable|worst)\\b[^.!?]{0,40}\\bpain\\b[^.!?]{0,40}\\b(?:abdominal|stomach|belly|tummy|abdomen|gut)\\b', '\\bpain\\b[^.!?]{0,25}\\b(?:abdominal|stomach|belly|tummy|abdomen|gut)\\b[^.!?]{0,25}\\b(?:severe|constant|unrelenting|unbearable|worse)\\b', ].join('|')})\\b`, 'i')], ['vomiting', /\b(?:vomit(?:ing|ed|s)?|throw(?:ing|s)? up|threw up|thrown up|puk(?:e|ed|ing|es)|barf(?:ed|ing|s)?|upchuck(?:ed|ing|s)?|toss(?:ed|ing|es)? (?:my|your|his|her|our|their|the) cookies|los(?:e|t|ing|es) (?:my|your|his|her|our|their|the) lunch|(?:i|we|you|he|she|they|someone) (?:(?:have|had|just|already|recently|am|are|was|were|kept) )?(?:hurl(?:s|ed|ing)?|spew(?:s|ed|ing)?)(?=\s*(?:[.!?]|$|again\b|twice\b|all night\b))|emesis)\b/i], ['fever', new RegExp(`\\b(?:${[ 'fever(?:ish)?', 'running a (?:high )?temperature', 'high temperature', '(?:temp|temperature)\\b[^.!?]{0,15}\\b(?:of |is |was |at |hit |reached )?(?:1\\d\\d(?:\\.\\d+)?|3[89](?:\\.\\d+)?|4[01](?:\\.\\d+)?)\\b', ].join('|')})\\b`, 'i')], ['cannotPassGas', new RegExp(`\\b(?:${[ "(?:cannot|can['’]?t|cant|unable to|not able to|not|never|stopped|have not|haven['’]?t|did not|didn['’]?t)\\b[^.!?]{0,30}\\bpass(?:ing|ed)? (?:any )?(?:gas|wind|fart)\\b", '\\bno (?:gas|wind)\\b[^.!?]{0,20}\\b(?:passing|passed|coming)\\b', '\\b(?:cannot|can[\'’]?t|not)\\b[^.!?]{0,15}\\bfart(?:ing)?\\b', ].join('|')})\\b`, 'i')], ]); // A reported symptom is a claim. Anything truthy-ish is read as reported; only an explicit clear // (false, 0, "no", blank, or absent) is read as not reported. Dropping a value because it was not // the literal boolean `true` would silently clear a symptom the person reported, which is the one // direction this boundary may never fail in. export function isReportedSymptom(value) { if (value === true) return true; if (typeof value === 'number') return value === 1; if (typeof value === 'string') return /^(?:true|yes|y|1)$/i.test(value.trim()); return false; } export function bucketForBristolType(type) { const value = Number(type); if (value === 1 || value === 2) return 'constipation'; if (value === 3 || value === 4) return 'typical'; if (value >= 5 && value <= 7) return 'loose'; return 'unknown'; } export function detectUrgentFlags(symptoms = {}) { const flags = URGENT_KEYS.filter((key) => isReportedSymptom(symptoms[key])); return { urgent: flags.length > 0, flags, message: flags.length ? URGENT_MESSAGE : 'No urgent symptom was selected. This tracker is not a diagnosis; seek care whenever you are worried or symptoms persist.', }; } export function detectUrgentText(text = '') { const flags = URGENT_TEXT_PATTERNS.filter(([, pattern]) => pattern.test(String(text))).map(([key]) => key); return { urgent: flags.length > 0, flags, message: flags.length ? URGENT_MESSAGE : '' }; } export function hasUrgentLedgerContext(entries = []) { return Array.isArray(entries) && entries.some(entry => detectUrgentFlags(entry?.symptoms).urgent || detectUrgentText(entry?.note).urgent); } export const urgentChatMessage = `Pause and get medical help. ${URGENT_MESSAGE}`; export function buildTimmySummary(entries = []) { if (!entries.length) return 'No logs yet. Add one when you are ready and I’ll summarize the pattern—not diagnose it.'; const counts = entries.reduce((acc, entry) => { const bucket = bucketForBristolType(entry.bristolType); acc[bucket] = (acc[bucket] || 0) + 1; return acc; }, {}); const pieces = [`${entries.length} ${entries.length === 1 ? 'log' : 'logs'}`]; if (counts.typical) pieces.push(`${counts.typical} typical`); if (counts.constipation) pieces.push(`${counts.constipation} on the firm side`); if (counts.loose) pieces.push(`${counts.loose} on the loose side`); return `${pieces.join(' · ')}. Patterns matter more than one entry. You choose what to eat; I only help you notice changes.`; } export function sanitizeEntry(input = {}) { const symptoms = {}; for (const key of URGENT_KEYS) symptoms[key] = input.symptoms?.[key] === true; const bristolType = Math.min(7, Math.max(1, Number(input.bristolType) || 4)); return { id: String(input.id || globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`), occurredAt: new Date(input.occurredAt || Date.now()).toISOString(), bristolType, color: ['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(input.color) ? input.color : 'brown', urgency: Math.min(4, Math.max(0, Number(input.urgency) || 0)), discomfort: Math.min(4, Math.max(0, Number(input.discomfort) || 0)), note: String(input.note || '').trim().slice(0, 500), photoDataUrl: typeof input.photoDataUrl === 'string' && input.photoDataUrl.startsWith('data:image/') ? input.photoDataUrl : '', symptoms, provenance: sanitizeProvenance(input.provenance), }; } // Provenance is the only record of whether a confirmed log began as an AI suggestion. It is kept to // an allowlist so a backup can never smuggle a credential, a prompt, or a raw model reply out of the // device, and it is `null` for logs that never had one — a missing record is not a manual entry. const PROVENANCE_SOURCES = Object.freeze(['ai-confirmed', 'ai-corrected']); function sanitizeProvenance(value) { if (!value || typeof value !== 'object') return null; if (!PROVENANCE_SOURCES.includes(value.source)) return null; const provenance = { source: value.source }; if (typeof value.model === 'string' && value.model.trim()) provenance.model = value.model.trim().slice(0, 80); const suggestedType = Number(value.suggestedBristolType); if (Number.isInteger(suggestedType) && suggestedType >= 1 && suggestedType <= 7) { provenance.suggestedBristolType = suggestedType; } if (['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(value.suggestedColor)) { provenance.suggestedColor = value.suggestedColor; } const confirmedAt = value.confirmedAt ? new Date(value.confirmedAt) : null; if (confirmedAt && !Number.isNaN(confirmedAt.valueOf())) provenance.confirmedAt = confirmedAt.toISOString(); return provenance; } export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 } = {}) { if (width < 640 || height < 480) return 'Move a little closer or use a higher-resolution photo. The image stays on this device.'; if (brightness < 0.12) return 'Add more light before saving. Timmy only checks whether the photo is usable.'; if (brightness > 0.95) return 'Reduce glare before saving. Timmy only checks whether the photo is usable.'; return 'Ready for your review. Choose the matching Bristol form yourself; Timmy does not interpret the picture.'; } export const LEDGER_EXPORT_VERSION = 2; export function exportLedger(entries, exportedAt = new Date().toISOString()) { return exportLedgerBackup({ entries, exportedAt }); } // A backup is written through an allowlist, so only fields this build understands leave the device. // Entries are re-sanitized on the way out as well as in: an unknown key on an entry is not part of // the person's record, and a crafted key must never be laundered through a backup. export function exportLedgerBackup({ entries = [], contributions = [], exportedAt = new Date().toISOString() } = {}) { const list = Array.isArray(entries) ? entries.map(sanitizeEntry) : []; const receipts = (Array.isArray(contributions) ? contributions : []).map(sanitizeContribution).filter(Boolean); return JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: LEDGER_EXPORT_VERSION, exportedAt, entries: list, contributions: receipts, }, null, 2); } const CONTRIBUTION_STATES = Object.freeze(['queued-local', 'withdrawn']); const CONTRIBUTION_ARTIFACTS = Object.freeze(['raw-image', 'confirmed-label']); function isoOrNull(value) { if (!value) return null; const parsed = new Date(value); return Number.isNaN(parsed.valueOf()) ? null : parsed.toISOString(); } export function sanitizeContribution(input = {}) { const contributionId = String(input?.contributionId || '').trim().slice(0, 120); if (!contributionId) return null; const state = CONTRIBUTION_STATES.includes(input.state) ? input.state : 'queued-local'; const receipt = { contributionId, entryId: String(input.entryId || '').trim().slice(0, 120), consentVersion: String(input.consentVersion || '').trim().slice(0, 60), consentedAt: isoOrNull(input.consentedAt), labels: { bristolType: Math.min(7, Math.max(1, Number(input.labels?.bristolType) || 4)), color: ['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(input.labels?.color) ? input.labels.color : 'brown', }, state, }; const withdrawnAt = isoOrNull(input.withdrawnAt); if (state === 'withdrawn' && withdrawnAt) receipt.withdrawnAt = withdrawnAt; if (state === 'withdrawn') { receipt.artifacts = (Array.isArray(input.artifacts) ? input.artifacts : []) .filter(artifact => CONTRIBUTION_ARTIFACTS.includes(artifact?.artifact)) .map(artifact => ({ artifact: artifact.artifact, contributionId: String(artifact.contributionId || contributionId).slice(0, 120), state: artifact.state === 'withdrawn' ? 'withdrawn' : 'active', })); } return receipt; } export function importLedger(text) { return importLedgerBackup(text).entries; } // A backup can be older than this build. Version 1 files record no provenance for their entries, so // the migration leaves that field empty instead of guessing a source, and the caller is told which // version the file came from. export const LEDGER_IMPORT_LIMITS = Object.freeze({ maxTextLength: 16 * 1024 * 1024, maxEntries: 5000 }); export function importLedgerBackup(text) { const raw = String(text ?? ''); if (raw.length > LEDGER_IMPORT_LIMITS.maxTextLength) { throw new Error('That backup is too large for Timmy to read safely. Nothing was imported.'); } let parsed; try { parsed = JSON.parse(raw); } catch { throw new Error('That file is not readable JSON, so nothing was imported.'); } if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) { throw new Error('This is not a supported Timmy backup.'); } const fromVersion = Number(parsed.schemaVersion); if (Number.isInteger(fromVersion) && fromVersion > LEDGER_EXPORT_VERSION) { throw new Error(`This backup was written by a newer version of Timmy (version ${fromVersion}). Update Timmy before importing it; nothing was imported.`); } if (!Number.isInteger(fromVersion) || fromVersion < 1) { throw new Error('This is not a supported Timmy backup.'); } if (parsed.entries.length > LEDGER_IMPORT_LIMITS.maxEntries) { throw new Error('That backup holds more logs than Timmy will import at once. Nothing was imported.'); } const entries = parsed.entries.map(sanitizeEntry); const rawContributions = Array.isArray(parsed.contributions) ? parsed.contributions : []; const contributions = rawContributions.map(sanitizeContribution).filter(Boolean); const migrated = fromVersion < LEDGER_EXPORT_VERSION; // The caller already says what it restored, so this notice only reports the upgrade. Restating the // restored count here made the on-screen notice read as two stacked sentences saying one thing. const notices = migrated ? [`Upgraded from version ${fromVersion} to version ${LEDGER_EXPORT_VERSION}; fields the old file did not record are left empty.`] : []; // Skipping an unreadable receipt silently would tell someone their opt-in vanished. Say it. const skipped = rawContributions.length - contributions.length; if (skipped > 0) { notices.push(`${skipped} training contribution ${skipped === 1 ? 'receipt' : 'receipts'} could not be read and ${skipped === 1 ? 'was' : 'were'} skipped.`); } return { entries, contributions, fromVersion, migrated, notices, exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : null, }; } export const urgentSymptomKeys = Object.freeze([...URGENT_KEYS]);