Quality gates / quality (push) Successful in 29m22s
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.
264 lines
14 KiB
JavaScript
264 lines
14 KiB
JavaScript
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]);
|