Quality gates / quality (pull_request) Successful in 2m59s
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
77 lines
3.3 KiB
JavaScript
77 lines
3.3 KiB
JavaScript
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 };
|
||
}
|