Files
timmy-talking-turd/src/summary.js
T
timmy d670360560
Quality gates / quality (pull_request) Successful in 2m59s
feat: explainable longitudinal pattern summaries with cited records (#34)
The home insight card now reports the confirmed-log count and form
groupings for a window, every line names the exact confirmed records it
was built from, and following a citation opens those records. A window
below the minimum states that it is too sparse instead of describing a
trend, and a window with both firm and loose logs says it is mixed rather
than picking a winner. No line explains a cause, rules on food, or offers
a health verdict.

- src/summary.js: window filtering, insufficient-data guard, per-form
  lines, mixed-pattern line, boundary copy, record citations
- app.js: explainable home card plus a pattern-source sheet listing the
  cited records
- tests/summary.test.js, tests/pattern-summary.acceptance.mjs: unit and
  real-browser coverage, gated in CI and the release builder
- scripts/record_release_demo.mjs: fictional multi-day sample logs,
  citation follow-through, and the sparse-window refusal on camera
2026-10-14 08:13:02 +00:00

77 lines
3.3 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.
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 };
}