import { bucketForBristolType } from './domain.js'; export const PATTERN_WINDOW_DAYS = 28; // Two logs are an anecdote. Below this the summary is required to say so instead of describing a trend. export const MIN_WINDOW_LOGS = 3; const DAY_MS = 86400000; // The summary may only describe what the user recorded. No cause, no health verdict, no food ruling. const BOUNDARY = 'Built only from your own confirmed records in this window. Timmy reports what you logged and when — not why, and never a verdict on your health or your food.'; function inWindowRecords(entries, now, windowDays) { const startedAt = now - windowDays * DAY_MS; return (Array.isArray(entries) ? entries : []) .map(entry => ({ entry, at: new Date(entry?.occurredAt).getTime() })) .filter(({ at }) => Number.isFinite(at) && at <= now && at >= startedAt) .sort((left, right) => left.at - right.at) .map(({ entry, at }) => ({ id: String(entry?.id ?? ''), occurredAt: new Date(at).toISOString(), bristolType: Number(entry?.bristolType), bucket: bucketForBristolType(entry?.bristolType), })); } export function buildPatternSummary(entries = [], { now = Date.now(), windowDays = PATTERN_WINDOW_DAYS } = {}) { const records = inWindowRecords(entries, now, windowDays); const window = { days: windowDays, label: `last ${windowDays} days`, start: new Date(now - windowDays * DAY_MS).toISOString(), end: new Date(now).toISOString(), }; if (records.length < MIN_WINDOW_LOGS) { return { window, records, insufficient: true, statements: [], boundary: BOUNDARY, copy: `Not enough confirmed logs in this window yet — ${records.length} of ${MIN_WINDOW_LOGS} needed before Timmy describes a pattern.`, }; } const frequency = { id: 'frequency', text: `${records.length} confirmed log${records.length === 1 ? '' : 's'} in the ${window.label}.`, entryIds: records.map(record => record.id), }; const buckets = new Map(); for (const record of records) { if (!buckets.has(record.bucket)) buckets.set(record.bucket, []); buckets.get(record.bucket).push(record.id); } const bucketLine = (bucket, label) => ({ id: bucket === 'constipation' ? 'firm' : bucket, text: `${label}: ${buckets.get(bucket).length} of ${records.length} confirmed log${records.length === 1 ? '' : 's'}.`, entryIds: buckets.get(bucket), }); const formLines = [ ['constipation', 'Firm side (Type 1–2)'], ['typical', 'Typical range (Type 3–4)'], ['loose', 'Loose side (Type 5–7)'], ].filter(([bucket]) => buckets.has(bucket)).map(([bucket, label]) => bucketLine(bucket, label)); // Both ends in one window is not a trend, and the summary has to say so out loud rather than // picking whichever side is marginally larger. const mixedLine = buckets.has('constipation') && buckets.has('loose') ? [{ id: 'mixed', text: `Firm and loose logs both appear in the ${window.label} (${buckets.get('constipation').length} firm, ${buckets.get('loose').length} loose), which is a mixed pattern and not one trend.`, entryIds: records.filter(record => record.bucket === 'constipation' || record.bucket === 'loose').map(record => record.id), }] : []; return { window, records, insufficient: false, statements: [frequency, ...formLines, ...mixedLine], copy: '', boundary: BOUNDARY }; }