f96fdcb3b9
Lock health recording behind confirm-only state machines, reject public publishes for the health category, and ship the 14-day experiment Page Data templates with tests. Co-authored-by: Cursor <cursoragent@cursor.com>
76 lines
2.7 KiB
JavaScript
76 lines
2.7 KiB
JavaScript
const RANGES = Object.freeze({
|
|
bp_systolic: { min: 60, max: 260, unit: 'mmHg' },
|
|
bp_diastolic: { min: 40, max: 160, unit: 'mmHg' },
|
|
hr: { min: 30, max: 200, unit: 'bpm' },
|
|
spo2: { min: 50, max: 100, unit: '%' },
|
|
temperature: { min: 33, max: 43, unit: 'celsius' },
|
|
weight: { min: 20, max: 250, unit: 'kg' },
|
|
sleep_minutes: { min: 0, max: 16 * 60, unit: 'min' },
|
|
});
|
|
|
|
export const HEALTH_CONTEXTS = Object.freeze(['morning', 'evening', 'other']);
|
|
|
|
export function inferBloodPressureContext(observedAt, { now = new Date(), timeZone = 'Asia/Shanghai' } = {}) {
|
|
const date = observedAt instanceof Date ? observedAt : new Date(observedAt ?? now);
|
|
if (Number.isNaN(date.getTime())) return 'other';
|
|
const hourText = new Intl.DateTimeFormat('en-GB', {
|
|
hour: '2-digit',
|
|
hourCycle: 'h23',
|
|
timeZone,
|
|
}).format(date);
|
|
const hour = Number(hourText);
|
|
if (hour < 10) return 'morning';
|
|
if (hour >= 18) return 'evening';
|
|
return 'other';
|
|
}
|
|
|
|
export function validateMetricValue(metricType, value) {
|
|
const range = RANGES[metricType];
|
|
if (!range) {
|
|
return { ok: false, error: 'unknown_metric' };
|
|
}
|
|
const num = Number(value);
|
|
if (!Number.isFinite(num)) {
|
|
return { ok: false, error: 'not_numeric' };
|
|
}
|
|
if (num < range.min || num > range.max) {
|
|
return { ok: false, error: 'out_of_range', range };
|
|
}
|
|
return { ok: true, value: num, unit: range.unit };
|
|
}
|
|
|
|
export function validateBloodPressurePair(systolic, diastolic) {
|
|
const sys = validateMetricValue('bp_systolic', systolic);
|
|
const dia = validateMetricValue('bp_diastolic', diastolic);
|
|
if (!sys.ok) return { ok: false, field: 'bp_systolic', ...sys };
|
|
if (!dia.ok) return { ok: false, field: 'bp_diastolic', ...dia };
|
|
if (sys.value <= dia.value) {
|
|
return { ok: false, error: 'systolic_not_greater', field: 'pair' };
|
|
}
|
|
return { ok: true, systolic: sys.value, diastolic: dia.value };
|
|
}
|
|
|
|
export function markSuspectVsBaseline(value, baselineMean, { ratio = 0.5 } = {}) {
|
|
const mean = Number(baselineMean);
|
|
const num = Number(value);
|
|
if (!Number.isFinite(mean) || mean <= 0 || !Number.isFinite(num)) {
|
|
return { qualityFlag: 'ok', suspect: false };
|
|
}
|
|
const deviation = Math.abs(num - mean) / mean;
|
|
if (deviation >= ratio) {
|
|
return { qualityFlag: 'suspect', suspect: true, deviation };
|
|
}
|
|
return { qualityFlag: 'ok', suspect: false, deviation };
|
|
}
|
|
|
|
export function resolveQualityAfterConfirm({ suspect = false, userCorrected = false } = {}) {
|
|
if (userCorrected) return 'user_corrected';
|
|
if (suspect) return 'ok';
|
|
return 'ok';
|
|
}
|
|
|
|
export function observationDedupKey({ metricType, observedAt, sourceRef } = {}) {
|
|
if (!sourceRef) return null;
|
|
return `${metricType}|${observedAt}|${sourceRef}`;
|
|
}
|