Files
timmy-talking-turd/src/domain.js
T
mercury 4ac71915fb
Quality gates / quality (push) Successful in 29m22s
fix(portability): report only the upgrade in the import notice (#35)
The import screen already states what it restored, and the migration notice
restated the same count, so the on-screen notice read as two stacked sentences
saying one thing. The notice now only describes the upgrade, and a unit test
fails if it starts restating the restored count again.
2026-10-22 12:20:31 +00:00

264 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]);