All checks were successful
Quality gates / quality (pull_request) Successful in 1m19s
96 lines
5.0 KiB
JavaScript
96 lines
5.0 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.';
|
||
const URGENT_TEXT_PATTERNS = Object.freeze([
|
||
['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i],
|
||
['blackOrDarkRed', /\b(?:(?:black|dark[- ]?red) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:is|looks?) (?:black|dark[- ]?red))s?\b/i],
|
||
['severePain', /\b(?:severe|constant|unrelenting) (?:abdominal|stomach|belly) pain\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)?|hurl(?:ed|ing|s)?|upchuck(?:ed|ing|s)?|spew(?:ed|ing|s)?|toss(?:ed|ing|es)? (?:my|the) cookies|los(?:e|t|ing|es) (?:my|the) lunch|emesis)\b/i],
|
||
['fever', /\bfever(?:ish)?\b/i],
|
||
['cannotPassGas', /\b(?:cannot|can['’]?t|cant|unable to|not able to) pass gas\b/i],
|
||
]);
|
||
|
||
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) => symptoms[key] === true);
|
||
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,
|
||
};
|
||
}
|
||
|
||
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 function exportLedger(entries, exportedAt = new Date().toISOString()) {
|
||
return JSON.stringify({
|
||
product: 'Timmy the Talking Turd',
|
||
schemaVersion: 1,
|
||
exportedAt,
|
||
entries: Array.isArray(entries) ? entries : [],
|
||
}, null, 2);
|
||
}
|
||
|
||
export function importLedger(text) {
|
||
const parsed = JSON.parse(text);
|
||
if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.entries)) throw new Error('This is not a supported Timmy export.');
|
||
return parsed.entries.map(sanitizeEntry);
|
||
}
|
||
|
||
export const urgentSymptomKeys = Object.freeze([...URGENT_KEYS]);
|