server.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
/**
* Apple Health dashboard server — receives data from Health Auto Export (iOS) and serves a self-contained web dashboard
* Receives JSON from Health Auto Export (iOS), stores to SQLite + JSONL backup.
*/
import { existsSync, mkdirSync, readFileSync, appendFileSync } from "fs";
import { join } from "path";
import { Database } from "bun:sqlite";
const PORT = 8880;
const DATA_DIR = join(import.meta.dir, "data");
const PUBLIC_DIR = join(import.meta.dir, "public");
const RAW_LOG = join(DATA_DIR, "raw-payloads.jsonl");
const DB_PATH = join(DATA_DIR, "health.db");
const MAX_POST_BYTES = 25 * 1024 * 1024; // 25 MB — largest real HAE push ~1.5 MB
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
// ─── Helpers ──────────────────────────────────────────────
const round = (v: number, d = 2) => { const p = 10 ** d; return Math.round(v * p) / p; };
const isValidDay = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s);
const isValidYearMonth = (y: number, m: number) => Number.isInteger(y) && y >= 2020 && y <= 2100 && Number.isInteger(m) && m >= 1 && m <= 12;
// Add days to a YYYY-MM-DD string (positive or negative)
function dayShift(day: string, delta: number): string {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d + delta));
return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, "0")}-${String(dt.getUTCDate()).padStart(2, "0")}`;
}
// ─── SQLite Setup ─────────────────────────────────────────
const db = new Database(DB_PATH);
db.run("PRAGMA journal_mode=DELETE"); // WAL causes Bun 1.x to busy-loop on pread64
db.run("PRAGMA synchronous=NORMAL");
db.run("PRAGMA busy_timeout=5000");
db.run("PRAGMA cache_size=-8000");
db.run(`CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
date TEXT NOT NULL,
qty REAL,
units TEXT,
source TEXT,
UNIQUE(name, date)
)`);
db.run(`CREATE TABLE IF NOT EXISTS hr_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL UNIQUE,
avg REAL, min REAL, max REAL,
source TEXT
)`);
db.run(`CREATE TABLE IF NOT EXISTS workouts (
id TEXT PRIMARY KEY,
name TEXT, start TEXT, end TEXT,
duration REAL, distance_km REAL,
calories REAL, avg_hr REAL, max_hr REAL,
steps INTEGER, elevation_m REAL,
temperature_c REAL, humidity REAL,
speed_kmh REAL, cadence REAL, intensity REAL,
is_indoor INTEGER DEFAULT 0,
hr_data TEXT
)`);
// route_data column added after initial schema — ignore "duplicate column" on upgrade
try { db.run("ALTER TABLE workouts ADD COLUMN route_data TEXT"); } catch {}
db.run(`CREATE TABLE IF NOT EXISTS sleep (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
total_sleep REAL, deep REAL, rem REAL, core REAL, awake REAL,
sleep_start TEXT NOT NULL UNIQUE,
sleep_end TEXT,
source TEXT
)`);
db.run("CREATE INDEX IF NOT EXISTS idx_metrics_name_date ON metrics(name, date)");
db.run("CREATE INDEX IF NOT EXISTS idx_metrics_date ON metrics(date)");
db.run("CREATE INDEX IF NOT EXISTS idx_hr_date ON hr_readings(date)");
db.run("CREATE INDEX IF NOT EXISTS idx_workouts_start ON workouts(start)");
db.run("CREATE INDEX IF NOT EXISTS idx_sleep_date ON sleep(date)");
// Prepared statements for fast inserts
const insertMetric = db.prepare("INSERT OR REPLACE INTO metrics (name, date, qty, units, source) VALUES (?, ?, ?, ?, ?)");
const insertHR = db.prepare("INSERT OR REPLACE INTO hr_readings (date, avg, min, max, source) VALUES (?, ?, ?, ?, ?)");
const insertWorkout = db.prepare("INSERT OR REPLACE INTO workouts (id, name, start, end, duration, distance_km, calories, avg_hr, max_hr, steps, elevation_m, temperature_c, humidity, speed_kmh, cadence, intensity, is_indoor, hr_data, route_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
const insertSleep = db.prepare("INSERT OR REPLACE INTO sleep (date, total_sleep, deep, rem, core, awake, sleep_start, sleep_end, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
// Track last time iPhone app actually synced data (from SQLite, not 92MB JSONL)
let lastSyncTime: string = (() => {
try {
const r = db.query("SELECT date FROM metrics ORDER BY rowid DESC LIMIT 1").get() as any;
return r?.date || "";
} catch { return ""; }
})();
// ─── Data Ingestion ───────────────────────────────────────
function extractQty(val: any): number {
if (typeof val === "number") return val;
if (val && typeof val === "object" && "qty" in val) return val.qty;
if (Array.isArray(val)) return val.reduce((s: number, e: any) => s + (e.qty || 0), 0);
return 0;
}
function ingestPayload(payload: any) {
const data = payload?.data;
if (!data) return;
const ingestTx = db.transaction(() => {
// Metrics
if (data.metrics) {
for (const m of data.metrics) {
if (m.name === "heart_rate") {
for (const entry of m.data || []) {
const bpm = entry.Avg || entry.qty || entry.Max || 0;
if (bpm > 0) insertHR.run(entry.date, entry.Avg || bpm, entry.Min || bpm, entry.Max || bpm, entry.source || "");
}
} else if (m.name === "sleep_analysis") {
for (const entry of m.data || []) {
insertSleep.run(
entry.date, entry.totalSleep || entry.qty || 0,
entry.deep || 0, entry.rem || 0, entry.core || 0, entry.awake || 0,
entry.sleepStart || "", entry.sleepEnd || "",
entry.source || ""
);
}
} else {
for (const entry of m.data || []) {
if (entry.qty !== undefined) {
insertMetric.run(m.name, entry.date, entry.qty, m.units || "", entry.source || "");
}
}
}
}
}
// Workouts
if (data.workouts) {
for (const w of data.workouts) {
const id = w.id || `${w.start}-${w.name}`;
// Route data: compact lat/lon/alt/ts so playback works without re-scanning JSONL
const routeJson = w.route && Array.isArray(w.route) && w.route.length > 0
? JSON.stringify(w.route.map((p: any) => ({
lat: p.latitude ?? p.lat,
lon: p.longitude ?? p.lon,
alt: p.altitude ?? p.alt,
ts: p.timestamp,
})))
: null;
insertWorkout.run(
id, w.name || "", w.start || "", w.end || "",
w.duration || 0,
extractQty(w.distance) || extractQty(w.walkingAndRunningDistance) || 0,
extractQty(w.activeEnergyBurned) || 0,
extractQty(w.avgHeartRate) || 0,
extractQty(w.maxHeartRate) || 0,
Math.round(extractQty(w.stepCount)),
extractQty(w.elevationUp) || 0,
extractQty(w.temperature) || 0,
extractQty(w.humidity) || 0,
extractQty(w.speed) || 0,
extractQty(w.stepCadence) || 0,
extractQty(w.intensity) || 0,
w.isIndoor ? 1 : 0,
JSON.stringify((w.heartRateData || []).map((e: any) => ({ d: e.date, b: Math.round(e.qty || 0) }))),
routeJson
);
}
}
});
ingestTx();
}
// ─── Migrate existing JSONL data into SQLite ──────────────
function migrateJsonlToDb() {
if (!existsSync(RAW_LOG)) return;
const count = db.query("SELECT COUNT(*) as c FROM metrics").get() as any;
if (count.c > 0) return; // Already migrated
console.log("📦 Migrating JSONL data to SQLite...");
const lines = readFileSync(RAW_LOG, "utf-8").trim().split("\n").filter(l => l.length > 0);
let migrated = 0;
for (const line of lines) {
try {
const { payload } = JSON.parse(line);
ingestPayload(payload);
migrated++;
} catch {}
}
console.log(`✅ Migrated ${migrated} payloads to SQLite`);
}
migrateJsonlToDb();
// ─── One-time: backfill workout route_data from JSONL for rows missing it ────
function backfillWorkoutRoutes() {
if (!existsSync(RAW_LOG)) return;
const missing = db.query("SELECT id FROM workouts WHERE route_data IS NULL AND is_indoor = 0").all() as any[];
if (missing.length === 0) return;
const wanted = new Set(missing.map((r: any) => r.id));
console.log(`🗺️ Backfilling routes for ${wanted.size} workouts from JSONL...`);
const updateRoute = db.prepare("UPDATE workouts SET route_data = ? WHERE id = ?");
let filled = 0;
try {
const lines = readFileSync(RAW_LOG, "utf-8").split("\n");
for (const line of lines) {
if (!line) continue;
if (wanted.size === 0) break;
try {
const { payload } = JSON.parse(line);
const workouts = payload?.data?.workouts;
if (!workouts) continue;
for (const w of workouts) {
const id = w.id || `${w.start}-${w.name}`;
if (!wanted.has(id) || !Array.isArray(w.route) || w.route.length === 0) continue;
const routeJson = JSON.stringify(w.route.map((p: any) => ({
lat: p.latitude ?? p.lat, lon: p.longitude ?? p.lon, alt: p.altitude ?? p.alt, ts: p.timestamp,
})));
updateRoute.run(routeJson, id);
wanted.delete(id);
filled++;
}
} catch {}
}
} catch (e: any) {
console.error("Route backfill failed:", e?.message);
}
if (filled > 0) console.log(`✅ Backfilled ${filled} workout routes`);
}
backfillWorkoutRoutes();
// ─── Utilities ────────────────────────────────────────────
function todayStr(): string {
const d = new Date();
const est = new Date(d.toLocaleString("en-US", { timeZone: "America/New_York" }));
return `${est.getFullYear()}-${String(est.getMonth() + 1).padStart(2, "0")}-${String(est.getDate()).padStart(2, "0")}`;
}
function dateStrFmt(d: Date): string {
const est = new Date(d.toLocaleString("en-US", { timeZone: "America/New_York" }));
return `${est.getFullYear()}-${String(est.getMonth() + 1).padStart(2, "0")}-${String(est.getDate()).padStart(2, "0")}`;
}
// ─── Sleep Aggregation ────────────────────────────────────
//
// HAE sends multiple sleep_analysis entries per night that represent the
// same Apple Health sleep session at different aggregation levels (outer
// "InBed" interval + inner "Asleep stage" intervals). It also files
// pre-bedtime evening sleep under the *next* day's HAE date. Picking the
// longest single row per date drops naps and pre-bedtime sleep, so the
// dashboard would show e.g. 4h when iPhone shows 10h for a day with
// multiple sleep events.
//
// Rule (matches iPhone "Health → Sleep overview" totals to within ~1 min):
// For calendar day X, include all sleep rows where:
// (a) HAE's date field == X, OR
// (b) HAE's date field == X+1 AND sleep_end is on day X
// AND sleep_end's wall-clock time is before 22:00
// (catches HAE's late-evening-nap-misfile bug where Apple Health
// later reclassifies the entry from X+1 to X)
// Then drop nested intervals (one row's window strictly contained in
// another's larger-duration row), and SUM total_sleep + stages.
//
// This is a query-side rewrite — no schema changes, no data mutation.
interface AggregatedSleep {
date: string;
duration: number; // total time asleep in hours (sum across sessions, excluding awake)
deep: number;
rem: number;
core: number;
awake: number;
sleepStart: string; // earliest sleep_start across kept rows
sleepEnd: string; // latest sleep_end across kept rows
sessions: number; // count of distinct (non-nested) sleep sessions on this day
source: string;
}
// A row's "canonical day" — the single day it belongs to in iPhone's view.
// Default = HAE's `date` field. Exception = HAE filed it under day X+1 but
// sleep_end is on day X before 22:00 (HAE's late-nap-misfile bug).
function canonicalSleepDay(row: any): string {
const haeDay = (row.date || "").slice(0, 10);
const endDay = (row.sleep_end || "").slice(0, 10);
const endTime = (row.sleep_end || "").slice(11, 16);
if (endDay && endTime && endDay !== haeDay && endTime < "22:00" && dayShift(endDay, 1) === haeDay) {
return endDay;
}
return haeDay;
}
function aggregateSleepForDate(day: string): AggregatedSleep | null {
// Pull both candidate buckets (HAE-date == day, plus possible fold-back from day+1)
const nextDay = dayShift(day, 1);
const candidates = db.query(`
SELECT * FROM sleep
WHERE substr(date, 1, 10) = ?
OR substr(date, 1, 10) = ?
ORDER BY total_sleep DESC, sleep_start ASC
`).all(day, nextDay) as any[];
// Keep only rows whose canonical day matches the requested day
const rows = candidates.filter(r => canonicalSleepDay(r) === day);
if (rows.length === 0) return null;
// Drop rows whose [sleep_start, sleep_end] window is strictly contained in
// another row's window AND that other row has greater total_sleep.
const kept: any[] = [];
for (const r of rows) {
const nested = rows.some(o =>
o !== r &&
o.total_sleep > r.total_sleep &&
o.sleep_start && r.sleep_start && o.sleep_start <= r.sleep_start &&
o.sleep_end && r.sleep_end && o.sleep_end >= r.sleep_end
);
if (!nested) kept.push(r);
}
const sum = (k: string) => kept.reduce((s, r) => s + (r[k] || 0), 0);
const starts = kept.map(r => r.sleep_start).filter(Boolean).sort();
const ends = kept.map(r => r.sleep_end).filter(Boolean).sort();
return {
date: day,
duration: round(sum("total_sleep"), 2),
deep: round(sum("deep"), 2),
rem: round(sum("rem"), 2),
core: round(sum("core"), 2),
awake: round(sum("awake"), 2),
sleepStart: starts[0] || "",
sleepEnd: ends[ends.length - 1] || "",
sessions: kept.length,
source: kept[0]?.source || "",
};
}
// Find the most recent calendar day (local) that has ANY sleep data.
// Used by /api/dashboard, /api/briefing, /api/recovery for "last sleep".
function getLatestSleepDay(): string | null {
const r = db.query(`
SELECT substr(sleep_end, 1, 10) as day FROM sleep
WHERE sleep_end != ''
ORDER BY sleep_end DESC LIMIT 1
`).get() as any;
return r?.day || null;
}
// ─── Query Builders (SQLite) ──────────────────────────────
function queryDaySummary(day: string) {
const sumMetric = (name: string) => {
const r = db.query("SELECT COALESCE(SUM(qty), 0) as total FROM metrics WHERE name = ? AND date LIKE ?").get(name, `${day}%`) as any;
return Math.round(r.total * 100) / 100;
};
const latestMetric = (name: string) => {
const r = db.query("SELECT qty FROM metrics WHERE name = ? AND date LIKE ? ORDER BY date DESC LIMIT 1").get(name, `${day}%`) as any;
return r ? Math.round(r.qty * 100) / 100 : 0;
};
const standHours = () => {
const r = db.query("SELECT COUNT(*) as c FROM metrics WHERE name = 'apple_stand_hour' AND date LIKE ?").get(`${day}%`) as any;
return r.c;
};
// HR
const hrRows = db.query("SELECT avg, min, max, date FROM hr_readings WHERE date LIKE ? ORDER BY date").all(`${day}%`) as any[];
const hrAvgs = hrRows.map(r => r.avg).filter((v: number) => v > 0);
const hrData = hrRows.map(r => {
const d = new Date(r.date.replace(" ", "T").replace(/ ([+-]\d{4})$/, "$1"));
return { time: `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`, bpm: Math.round(r.avg) };
}).filter(e => e.bpm > 0);
// Sleep — sum all sessions on the day (matches iPhone "Time Asleep" total).
// See aggregateSleepForDate above for the grouping rule.
const sleepAgg = aggregateSleepForDate(day);
// Workouts
const workoutRows = db.query("SELECT * FROM workouts WHERE start LIKE ? ORDER BY start DESC").all(`${day}%`) as any[];
const workouts = workoutRows.map(mapDbWorkout);
return {
date: day,
activity: {
steps: Math.round(sumMetric("step_count")),
distance: Math.round(sumMetric("walking_running_distance") * 100) / 100,
activeCalories: Math.round(sumMetric("active_energy")),
exerciseMinutes: Math.round(sumMetric("apple_exercise_time")),
standHours: standHours(),
flightsClimbed: Math.round(sumMetric("flights_climbed")),
},
heartRate: {
avg: hrAvgs.length > 0 ? Math.round(hrAvgs.reduce((a, b) => a + b, 0) / hrAvgs.length) : 0,
resting: hrAvgs.length > 0 ? Math.round(Math.min(...hrAvgs)) : 0,
max: hrAvgs.length > 0 ? Math.round(Math.max(...hrAvgs)) : 0,
data: hrData,
},
sleep: {
duration: sleepAgg?.duration || 0,
deep: sleepAgg?.deep || 0,
rem: sleepAgg?.rem || 0,
core: sleepAgg?.core || 0,
awake: sleepAgg?.awake || 0,
sessions: sleepAgg?.sessions || 0,
},
workouts,
extra: {
respiratoryRate: latestMetric("respiratory_rate"),
wristTemp: latestMetric("apple_sleeping_wrist_temperature"),
walkingSpeed: latestMetric("walking_speed"),
vo2Max: latestMetric("vo2_max"),
stepLength: latestMetric("walking_step_length"),
walkingAsymmetry: latestMetric("walking_asymmetry_percentage"),
doubleSupport: latestMetric("walking_double_support_percentage"),
cardioRecovery: latestMetric("cardio_recovery"),
basalCalories: Math.round(sumMetric("basal_energy_burned")),
totalCalories: Math.round(sumMetric("active_energy") + sumMetric("basal_energy_burned")),
physicalEffort: Math.round(sumMetric("physical_effort") * 10) / 10,
envAudio: (() => { const r = db.query("SELECT AVG(qty) as a FROM metrics WHERE name = 'environmental_audio_exposure' AND date LIKE ?").get(`${day}%`) as any; return r?.a ? Math.round(r.a) : 0; })(),
totalWorkoutMin: workouts.reduce((s, w) => s + w.duration, 0),
totalWorkoutCal: workouts.reduce((s, w) => s + w.calories, 0),
totalWorkoutDist: Math.round(workouts.reduce((s, w) => s + w.distance, 0) * 100) / 100,
totalElevation: workouts.reduce((s, w) => s + w.elevationGain, 0),
avgWorkoutHR: workouts.length > 0 ? Math.round(workouts.reduce((s, w) => s + w.avgHR, 0) / workouts.length) : 0,
avgPace: (() => { const p = workouts.filter(w => w.pace > 0); return p.length > 0 ? Math.round(p.reduce((s, w) => s + w.pace, 0) / p.length * 10) / 10 : 0; })(),
},
};
}
// Workout strain: 0-21 scale based on duration × HR intensity (Whoop-inspired)
function calcStrain(durationMin: number, avgHR: number, maxHR: number): number {
const estMaxHR = 180; // Conservative estimate; adjusts as more data comes in
const hrPct = avgHR / estMaxHR;
// Strain = duration-weighted intensity on a log scale, capped at 21
const rawStrain = durationMin * hrPct * hrPct * 0.15; // quadratic HR weighting
return Math.round(Math.min(21, rawStrain) * 10) / 10;
}
// VO2 Max fitness age: maps VO2 to the age where it would be average
function vo2FitnessAge(vo2: number): number {
// Population norms (male, from ACSM): VO2 max declines ~0.5 ml/kg/min per year
// Average by age: 20→45, 30→40, 40→36, 50→32, 60→28, 70→24
if (vo2 <= 0) return 0;
const ageTable = [[20,45],[25,43],[30,40],[35,38],[40,36],[45,34],[50,32],[55,30],[60,28],[65,26],[70,24],[75,22],[80,20]];
for (let i = 0; i < ageTable.length - 1; i++) {
if (vo2 >= ageTable[i][1]) return ageTable[i][0];
if (vo2 >= ageTable[i+1][1] && vo2 < ageTable[i][1]) {
const frac = (vo2 - ageTable[i+1][1]) / (ageTable[i][1] - ageTable[i+1][1]);
return Math.round(ageTable[i+1][0] - frac * (ageTable[i+1][0] - ageTable[i][0]));
}
}
return 80;
}
function mapDbWorkout(w: any) {
const distMi = Math.round(w.distance_km * 0.621371 * 100) / 100;
const durationMin = Math.round(w.duration / 60);
const strain = calcStrain(durationMin, w.avg_hr, w.max_hr);
return {
id: w.id || `${w.start}-${w.name}`,
name: w.name, start: w.start, end: w.end,
duration: durationMin,
distance: distMi,
calories: Math.round(w.calories),
avgHR: Math.round(w.avg_hr),
maxHR: Math.round(w.max_hr),
steps: w.steps,
elevationGain: Math.round(w.elevation_m * 3.28084),
temperature: Math.round(w.temperature_c * 9 / 5 + 32),
humidity: Math.round(w.humidity),
speed: Math.round(w.speed_kmh * 0.621371 * 100) / 100,
cadence: Math.round(w.cadence),
intensity: Math.round(w.intensity * 100) / 100,
pace: distMi > 0 ? Math.round(durationMin / distMi * 10) / 10 : 0,
calPerMin: durationMin > 0 ? Math.round(w.calories / durationMin * 10) / 10 : 0,
strain,
isIndoor: w.is_indoor === 1,
};
}
// ─── Metric Enrichment (baseline + trend for any value) ───
function enrichMetric(name: string, current: number, days = 30): { value: number; avg: number; delta: number; trend: string; arrow: string } {
const base = getBaseline(name, days);
const slope = base.values.length >= 5 ? linearSlope(base.values) : 0;
const slopePerWeek = slope * 7;
const delta = base.avg > 0 ? Math.round((current - base.avg) * 10) / 10 : 0;
const trend = slopePerWeek > 0.01 ? "up" : slopePerWeek < -0.01 ? "down" : "flat";
const arrow = trend === "up" ? "↑" : trend === "down" ? "↓" : "→";
return { value: Math.round(current * 100) / 100, avg: base.avg, delta, trend, arrow };
}
// ─── API Builders ─────────────────────────────────────────
function buildDashboardData() {
const today = todayStr();
const summary = queryDaySummary(today);
// Latest body metrics (global, not day-specific)
const latestGlobal = (name: string) => {
const r = db.query("SELECT qty FROM metrics WHERE name = ? ORDER BY date DESC LIMIT 1").get(name) as any;
return r ? Math.round(r.qty * 100) / 100 : 0;
};
// Latest sleep — aggregated across all sessions on the most recent sleep day
const latestDay = getLatestSleepDay();
const lastSleepAgg = latestDay ? aggregateSleepForDate(latestDay) : null;
return {
today: summary.activity,
heartRate: summary.heartRate,
sleep: {
...(summary.sleep.duration > 0 ? summary.sleep : {
duration: lastSleepAgg?.duration || 0,
deep: lastSleepAgg?.deep || 0,
rem: lastSleepAgg?.rem || 0,
core: lastSleepAgg?.core || 0,
awake: lastSleepAgg?.awake || 0,
sessions: lastSleepAgg?.sessions || 0,
}),
wristTemp: Math.round(latestGlobal("apple_sleeping_wrist_temperature") * 10) / 10,
respiratoryRate: Math.round(latestGlobal("respiratory_rate") * 10) / 10,
breathingDisturbances: Math.round(latestGlobal("breathing_disturbances") * 10) / 10,
},
workouts: (db.query("SELECT * FROM workouts ORDER BY start DESC LIMIT 6").all() as any[]).map(mapDbWorkout),
body: {
vo2Max: enrichMetric("vo2_max", latestGlobal("vo2_max"), 90),
walkingSpeed: enrichMetric("walking_speed", latestGlobal("walking_speed")),
stepLength: enrichMetric("walking_step_length", latestGlobal("walking_step_length")),
walkingAsymmetry: enrichMetric("walking_asymmetry_percentage", latestGlobal("walking_asymmetry_percentage")),
cardioRecovery: enrichMetric("cardio_recovery", latestGlobal("cardio_recovery")),
},
lastUpdated: lastSyncTime || new Date().toISOString(),
};
}
function buildHistoryData(days: number) {
const result: any[] = [];
const now = new Date();
for (let i = 0; i < days; i++) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const day = dateStrFmt(d);
const summary = queryDaySummary(day);
if (summary.activity.steps > 0 || summary.heartRate.avg > 0 || summary.sleep.duration > 0 || summary.workouts.length > 0) {
result.push(summary);
}
}
return result;
}
function buildSleepHistory() {
// Distinct calendar days in the sleep table, most recent 6
const dayRows = db.query(`
SELECT DISTINCT substr(date, 1, 10) as day FROM sleep
ORDER BY day DESC LIMIT 6
`).all() as any[];
return dayRows.map(({ day }) => {
const agg = aggregateSleepForDate(day);
if (!agg) return null;
const resp = db.query("SELECT qty FROM metrics WHERE name = 'respiratory_rate' AND date LIKE ? ORDER BY date DESC LIMIT 1").get(`${day}%`) as any;
const temp = db.query("SELECT qty FROM metrics WHERE name = 'apple_sleeping_wrist_temperature' AND date LIKE ? ORDER BY date DESC LIMIT 1").get(`${day}%`) as any;
const breath = db.query("SELECT qty FROM metrics WHERE name = 'breathing_disturbances' AND date LIKE ? ORDER BY date DESC LIMIT 1").get(`${day}%`) as any;
return {
date: day + " 00:00:00",
duration: agg.duration, deep: agg.deep, rem: agg.rem, core: agg.core, awake: agg.awake,
sleepStart: agg.sleepStart, sleepEnd: agg.sleepEnd, sessions: agg.sessions,
respiratoryRate: resp ? round(resp.qty, 1) : 0,
wristTemp: temp ? round(temp.qty, 1) : 0,
breathingDisturbances: breath ? round(breath.qty, 1) : 0,
};
}).filter(Boolean);
}
function buildCalendarData(year: number, month: number) {
const daysInMonth = new Date(year, month, 0).getDate();
const prefix = `${year}-${String(month).padStart(2, "0")}`;
const likePrefix = `${prefix}%`;
// Batch: one GROUP BY per dataset instead of 5 queries × 31 days
const metricByDay = (name: string) => {
const rows = db.query(`SELECT substr(date,1,10) as day, COALESCE(SUM(qty),0) as t FROM metrics WHERE name=? AND date LIKE ? GROUP BY day`).all(name, likePrefix) as any[];
const m = new Map<string, number>();
for (const r of rows) m.set(r.day, r.t);
return m;
};
const stepsByDay = metricByDay("step_count");
const calByDay = metricByDay("active_energy");
const exerciseByDay = metricByDay("apple_exercise_time");
const wkRows = db.query(`SELECT substr(start,1,10) as day, COUNT(*) as c FROM workouts WHERE start LIKE ? GROUP BY day`).all(likePrefix) as any[];
const wkByDay = new Map<string, number>();
for (const r of wkRows) wkByDay.set(r.day, r.c);
// Sleep — per-day aggregate (sum, dedup nested) so calendar matches dashboard.
// 31 calls/month is fine — each is a small indexed query and we only do it once per month load.
const sleepByDay = new Map<string, number>();
for (let d = 1; d <= daysInMonth; d++) {
const day = `${prefix}-${String(d).padStart(2, "0")}`;
const agg = aggregateSleepForDate(day);
if (agg) sleepByDay.set(day, agg.duration);
}
const result: any[] = [];
for (let d = 1; d <= daysInMonth; d++) {
const day = `${prefix}-${String(d).padStart(2, "0")}`;
result.push({
date: day, day: d,
steps: Math.round(stepsByDay.get(day) || 0),
calories: Math.round(calByDay.get(day) || 0),
exerciseMin: Math.round(exerciseByDay.get(day) || 0),
workouts: wkByDay.get(day) || 0,
sleepHrs: round(sleepByDay.get(day) || 0, 1),
});
}
return { year, month, days: result };
}
function dbStats() {
const metrics = (db.query("SELECT COUNT(*) as c FROM metrics").get() as any).c;
const hr = (db.query("SELECT COUNT(*) as c FROM hr_readings").get() as any).c;
const workouts = (db.query("SELECT COUNT(*) as c FROM workouts").get() as any).c;
const sleep = (db.query("SELECT COUNT(*) as c FROM sleep").get() as any).c;
const oldest = db.query("SELECT MIN(date) as d FROM metrics").get() as any;
const newest = db.query("SELECT MAX(date) as d FROM metrics").get() as any;
const dbSize = existsSync(DB_PATH) ? Math.round(Bun.file(DB_PATH).size / 1024) : 0;
return { metrics, hr_readings: hr, workouts, sleep, oldest_date: oldest?.d, newest_date: newest?.d, db_size_kb: dbSize };
}
// ─── NixVitals 2.0: Computations ──────────────────────────
// Linear regression: returns slope per day
function linearSlope(values: number[]): number {
if (values.length < 3) return 0;
const n = values.length;
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (let i = 0; i < n; i++) { sumX += i; sumY += values[i]; sumXY += i * values[i]; sumX2 += i * i; }
const denom = n * sumX2 - sumX * sumX;
return denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom;
}
// Pearson correlation coefficient
function pearsonCorr(x: number[], y: number[]): number {
const n = Math.min(x.length, y.length);
if (n < 5) return 0;
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
for (let i = 0; i < n; i++) { sumX += x[i]; sumY += y[i]; sumXY += x[i] * y[i]; sumX2 += x[i] * x[i]; sumY2 += y[i] * y[i]; }
const denom = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
return denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom;
}
// ─── Recovery, Anomalies, Briefing ────────────────────────
function getBaseline(name: string, days: number): { avg: number; stddev: number; values: number[] } {
const rows = db.query(`SELECT qty FROM metrics WHERE name = ? AND date >= date('now', '-${days} days') ORDER BY date`).all(name) as any[];
const vals = rows.map(r => r.qty).filter((v: number) => v > 0);
if (vals.length === 0) return { avg: 0, stddev: 0, values: [] };
const avg = vals.reduce((a: number, b: number) => a + b, 0) / vals.length;
const variance = vals.reduce((s: number, v: number) => s + (v - avg) ** 2, 0) / vals.length;
return { avg: Math.round(avg * 100) / 100, stddev: Math.round(Math.sqrt(variance) * 100) / 100, values: vals };
}
function getHRBaseline(days: number): { avg: number; stddev: number; resting: number } {
// Get daily resting HR (min avg per day) for the last N days
const rows = db.query(`SELECT substr(date,1,10) as day, MIN(avg) as resting FROM hr_readings WHERE date >= date('now', '-${days} days') GROUP BY day ORDER BY day`).all() as any[];
const vals = rows.map(r => r.resting).filter((v: number) => v > 0);
if (vals.length === 0) return { avg: 0, stddev: 0, resting: 0 };
const avg = vals.reduce((a: number, b: number) => a + b, 0) / vals.length;
const variance = vals.reduce((s: number, v: number) => s + (v - avg) ** 2, 0) / vals.length;
return { avg: Math.round(avg * 10) / 10, stddev: Math.round(Math.sqrt(variance) * 10) / 10, resting: Math.round(vals[vals.length - 1]) };
}
function buildRecoveryScore() {
const today = todayStr();
const yesterday = (() => { const d = new Date(); d.setDate(d.getDate() - 1); return dateStrFmt(d); })();
// Get baselines
const hrBase = getHRBaseline(30);
const sleepBase = getBaseline("sleep_analysis", 30); // won't have qty but we'll use sleep table
const respBase = getBaseline("respiratory_rate", 30);
const tempBase = getBaseline("apple_sleeping_wrist_temperature", 14);
// Last night's sleep — aggregated per latest day
const _latestDay = getLatestSleepDay();
const lastSleep = _latestDay ? aggregateSleepForDate(_latestDay) : null;
const sleepDuration = lastSleep?.duration || 0;
const deepPct = sleepDuration > 0 ? ((lastSleep!.deep || 0) / sleepDuration) * 100 : 0;
const remPct = sleepDuration > 0 ? ((lastSleep!.rem || 0) / sleepDuration) * 100 : 0;
// Sleep avg from sleep table
const sleepAvgRow = db.query("SELECT AVG(total_sleep) as avg FROM sleep WHERE total_sleep > 0").get() as any;
const sleepAvg = sleepAvgRow?.avg || 7;
// Today/yesterday resting HR
const todayResting = (db.query("SELECT MIN(avg) as rhr FROM hr_readings WHERE date LIKE ?").get(`${today}%`) as any)?.rhr;
const yesterdayResting = (db.query("SELECT MIN(avg) as rhr FROM hr_readings WHERE date LIKE ?").get(`${yesterday}%`) as any)?.rhr;
const currentResting = todayResting || yesterdayResting || 0;
// Latest resp rate and temp
const latestResp = (db.query("SELECT qty FROM metrics WHERE name='respiratory_rate' ORDER BY date DESC LIMIT 1").get() as any)?.qty || 0;
const latestTemp = (db.query("SELECT qty FROM metrics WHERE name='apple_sleeping_wrist_temperature' ORDER BY date DESC LIMIT 1").get() as any)?.qty || 0;
// Component scores (each 0-100)
// HRV proxy: how much resting HR deviates from baseline (lower = better)
let hrScore = 50;
if (hrBase.avg > 0 && currentResting > 0) {
const deviation = (currentResting - hrBase.avg) / (hrBase.stddev || 1);
hrScore = Math.max(0, Math.min(100, 50 - deviation * 25)); // centered at 50, ±2σ = 0-100
}
// Sleep quality: duration(30%) + deep%(20%) + REM%(15%) + efficiency(20%) + bedtime consistency(15%)
let sleepScore = 50;
if (sleepDuration > 0) {
const durationScore = Math.min(30, (sleepDuration / sleepAvg) * 30);
const deepScore = Math.min(20, deepPct * 1.5);
const remScore = Math.min(15, remPct * 0.75);
// Sleep efficiency: time asleep / time in bed
const sleepStart = lastSleep?.sleepStart ? new Date(lastSleep.sleepStart.replace(" ","T").replace(/ ([+-]\d{4})$/,"$1")).getTime() : 0;
const sleepEnd = lastSleep?.sleepEnd ? new Date(lastSleep.sleepEnd.replace(" ","T").replace(/ ([+-]\d{4})$/,"$1")).getTime() : 0;
const timeInBed = sleepStart && sleepEnd ? (sleepEnd - sleepStart) / 3600000 : sleepDuration;
const efficiency = timeInBed > 0 ? (sleepDuration / timeInBed) * 100 : 80;
const efficiencyScore = Math.min(20, efficiency >= 85 ? 20 : efficiency >= 75 ? 15 : efficiency >= 65 ? 10 : 5);
// Bedtime consistency: std dev of sleep_start times over last 7 nights
const recentStarts = (db.query("SELECT sleep_start FROM sleep WHERE sleep_start != '' ORDER BY date DESC LIMIT 7").all() as any[]).map(r => {
const d = new Date(r.sleep_start.replace(" ","T").replace(/ ([+-]\d{4})$/,"$1"));
return d.getHours() * 60 + d.getMinutes(); // minutes since midnight
}).filter(m => m > 0);
let consistencyScore = 10; // default mid
if (recentStarts.length >= 3) {
const avgStart = recentStarts.reduce((a,b) => a+b, 0) / recentStarts.length;
const stddev = Math.sqrt(recentStarts.reduce((s,v) => s + (v - avgStart) ** 2, 0) / recentStarts.length);
consistencyScore = stddev <= 30 ? 15 : stddev <= 60 ? 10 : stddev <= 90 ? 5 : 2; // ≤30min variance = great
}
sleepScore = Math.max(0, Math.min(100, durationScore + deepScore + remScore + efficiencyScore + consistencyScore));
}
// Respiratory rate: deviation from baseline (lower = better)
let respScore = 50;
if (respBase.avg > 0 && latestResp > 0) {
const deviation = (latestResp - respBase.avg) / (respBase.stddev || 1);
respScore = Math.max(0, Math.min(100, 50 - deviation * 20));
}
// Wrist temp: deviation from baseline
let tempScore = 50;
if (tempBase.avg > 0 && latestTemp > 0) {
const deviation = Math.abs(latestTemp - tempBase.avg) / (tempBase.stddev || 0.5);
tempScore = Math.max(0, Math.min(100, 100 - deviation * 30));
}
// Weighted composite
const score = Math.round(hrScore * 0.35 + sleepScore * 0.30 + respScore * 0.20 + tempScore * 0.15);
const level = score >= 67 ? "green" : score >= 34 ? "yellow" : "red";
const recommendation = score >= 67 ? "Ready to push — go hard today" : score >= 50 ? "Moderate day — steady effort" : score >= 34 ? "Take it easy — light activity only" : "Rest day recommended — prioritize recovery";
return {
score, level, recommendation,
components: {
heartRate: { score: Math.round(hrScore), current: currentResting, baseline: hrBase.avg, weight: "35%" },
sleep: { score: Math.round(sleepScore), duration: sleepDuration, deepPct: Math.round(deepPct), remPct: Math.round(remPct), baseline: Math.round(sleepAvg * 10) / 10, weight: "30%" },
respiratory: { score: Math.round(respScore), current: latestResp, baseline: respBase.avg, weight: "20%" },
temperature: { score: Math.round(tempScore), current: Math.round(latestTemp * 10) / 10, baseline: tempBase.avg, weight: "15%" },
},
};
}
function buildAnomalyDetection() {
const today = todayStr();
const anomalies: any[] = [];
// Check key metrics against 30-day baselines
const checks = [
{ name: "respiratory_rate", label: "Respiratory Rate", unit: "/min", lower: true },
{ name: "apple_sleeping_wrist_temperature", label: "Wrist Temperature", unit: "°F", lower: false },
{ name: "walking_speed", label: "Walking Speed", unit: "mph", lower: false },
{ name: "walking_asymmetry_percentage", label: "Walking Asymmetry", unit: "%", lower: true },
{ name: "vo2_max", label: "VO2 Max", unit: "ml/kg·min", lower: false },
];
for (const check of checks) {
const base = getBaseline(check.name, 30);
if (base.avg === 0 || base.stddev === 0) continue;
const latest = (db.query("SELECT qty FROM metrics WHERE name = ? ORDER BY date DESC LIMIT 1").get(check.name) as any);
if (!latest) continue;
const zscore = (latest.qty - base.avg) / base.stddev;
if (Math.abs(zscore) >= 1.5) {
anomalies.push({
metric: check.label, unit: check.unit,
current: Math.round(latest.qty * 100) / 100,
baseline: base.avg, stddev: base.stddev,
zscore: Math.round(zscore * 10) / 10,
direction: zscore > 0 ? "above" : "below",
severity: Math.abs(zscore) >= 2.5 ? "high" : Math.abs(zscore) >= 2 ? "medium" : "low",
concern: (check.lower && zscore > 0) || (!check.lower && zscore < 0),
});
}
}
// Check resting HR
const hrBase = getHRBaseline(30);
if (hrBase.avg > 0) {
const zscore = (hrBase.resting - hrBase.avg) / (hrBase.stddev || 1);
if (Math.abs(zscore) >= 1.5) {
anomalies.push({
metric: "Resting Heart Rate", unit: "bpm",
current: hrBase.resting, baseline: hrBase.avg, stddev: hrBase.stddev,
zscore: Math.round(zscore * 10) / 10,
direction: zscore > 0 ? "above" : "below",
severity: Math.abs(zscore) >= 2.5 ? "high" : Math.abs(zscore) >= 2 ? "medium" : "low",
concern: zscore > 0, // elevated resting HR is always concerning
});
}
}
// Check sleep duration
const sleepRows = db.query("SELECT total_sleep FROM sleep WHERE total_sleep > 0 ORDER BY date DESC LIMIT 30").all() as any[];
if (sleepRows.length >= 7) {
const vals = sleepRows.map(r => r.total_sleep);
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
const stddev = Math.sqrt(vals.reduce((s, v) => s + (v - avg) ** 2, 0) / vals.length);
const latest = vals[0];
const zscore = (latest - avg) / (stddev || 1);
if (Math.abs(zscore) >= 1.5) {
anomalies.push({
metric: "Sleep Duration", unit: "hrs",
current: Math.round(latest * 10) / 10, baseline: Math.round(avg * 10) / 10, stddev: Math.round(stddev * 10) / 10,
zscore: Math.round(zscore * 10) / 10,
direction: zscore > 0 ? "above" : "below",
severity: Math.abs(zscore) >= 2.5 ? "high" : Math.abs(zscore) >= 2 ? "medium" : "low",
concern: zscore < 0,
});
}
}
return { anomalies, checkedAt: new Date().toISOString() };
}
function buildMorningBriefing() {
const recovery = buildRecoveryScore();
const { anomalies } = buildAnomalyDetection();
const today = todayStr();
// Last night's sleep — aggregated per latest day (matches dashboard headline)
const _briefingDay = getLatestSleepDay();
const lastSleep = _briefingDay ? aggregateSleepForDate(_briefingDay) : null;
const sleepQuality = lastSleep ? (() => {
const dur = lastSleep.duration || 0;
const deepPct = dur > 0 ? ((lastSleep.deep || 0) / dur) * 100 : 0;
const remPct = dur > 0 ? ((lastSleep.rem || 0) / dur) * 100 : 0;
const durationScore = Math.min(60, (dur / 7.5) * 60);
const deepScore = Math.min(20, deepPct * 1.5);
const remScore = Math.min(20, remPct);
return Math.round(Math.max(0, Math.min(100, durationScore + deepScore + remScore)));
})() : 0;
// Sleep debt (14-day rolling) — use aggregated per-day totals so naps count
const sleepAvgRow = db.query("SELECT AVG(total_sleep) as avg FROM sleep WHERE total_sleep > 0").get() as any;
const sleepNeed = sleepAvgRow?.avg || 7;
const recentDays = db.query(`SELECT DISTINCT substr(date,1,10) as day FROM sleep ORDER BY day DESC LIMIT 14`).all() as any[];
const last14Totals = recentDays.map(({ day }) => aggregateSleepForDate(day)?.duration || 0);
const sleepDebt = round(last14Totals.reduce((debt, t) => debt + (sleepNeed - t), 0), 1);
// Training load (simple: last 7 days workout minutes)
const recentWorkouts = db.query("SELECT duration, calories, avg_hr FROM workouts WHERE start >= date('now', '-7 days')").all() as any[];
const acuteLoad = recentWorkouts.reduce((s, w) => s + (w.duration || 0) / 3600, 0); // hours
const chronicWorkouts = db.query("SELECT duration FROM workouts WHERE start >= date('now', '-42 days')").all() as any[];
const chronicLoad = chronicWorkouts.reduce((s, w) => s + (w.duration || 0) / 3600, 0) / 6; // avg weekly hours over 6 weeks
const loadRatio = chronicLoad > 0 ? Math.round(acuteLoad / chronicLoad * 100) / 100 : 0;
const loadStatus = loadRatio > 1.5 ? "overreaching" : loadRatio > 1.2 ? "pushing" : loadRatio > 0.8 ? "optimal" : loadRatio > 0 ? "detraining" : "no data";
// Today's activity so far
const todaySummary = queryDaySummary(today);
return {
recovery,
sleep: {
duration: lastSleep?.duration || 0,
quality: sleepQuality,
deep: lastSleep?.deep || 0,
rem: lastSleep?.rem || 0,
sessions: lastSleep?.sessions || 0,
debt: sleepDebt,
need: round(sleepNeed, 2),
},
training: {
acuteLoad: Math.round(acuteLoad * 10) / 10,
chronicLoad: Math.round(chronicLoad * 10) / 10,
loadRatio, loadStatus,
},
anomalies: anomalies.filter(a => a.concern),
today: todaySummary.activity,
// VO2 Max + Fitness Age
vo2: (() => {
const row = db.query("SELECT qty FROM metrics WHERE name='vo2_max' ORDER BY date DESC LIMIT 1").get() as any;
const val = row?.qty || 0;
const slope = linearSlope(getBaseline("vo2_max", 90).values);
return { current: Math.round(val * 10) / 10, fitnessAge: vo2FitnessAge(val), trend: slope > 0.01 ? "improving" : slope < -0.01 ? "declining" : "stable", slopePerMonth: Math.round(slope * 30 * 100) / 100 };
})(),
// Cardiac Recovery
cardioRecovery: (() => {
const row = db.query("SELECT qty FROM metrics WHERE name='cardio_recovery' ORDER BY date DESC LIMIT 1").get() as any;
const base = getBaseline("cardio_recovery", 30);
const val = row?.qty || 0;
const grade = val >= 20 ? "excellent" : val >= 15 ? "good" : val >= 12 ? "fair" : "poor";
return { current: Math.round(val * 10) / 10, grade, baseline: base.avg };
})(),
// Environmental Audio
envAudio: (() => {
const today = todayStr();
const todayAvg = db.query("SELECT AVG(qty) as a, MAX(qty) as mx FROM metrics WHERE name='environmental_audio_exposure' AND date LIKE ?").get(`${today}%`) as any;
const base = getBaseline("environmental_audio_exposure", 30);
return {
todayAvg: todayAvg?.a ? Math.round(todayAvg.a) : 0,
todayMax: todayAvg?.mx ? Math.round(todayAvg.mx) : 0,
baseline: Math.round(base.avg),
safe: (todayAvg?.a || 0) < 70, // WHO: sustained <70dB is safe
};
})(),
illness: buildIllnessPrediction(),
sleepPrediction: buildSleepPrediction(),
lastSync: lastSyncTime || "No data received yet",
};
}
// ─── Phase 2: Correlations, Insights, Gait ────────────────
function buildCorrelations() {
// Get daily data for correlation analysis
const days = 90;
const dailyData: any[] = [];
const now = new Date();
for (let i = 0; i < days; i++) {
const d = new Date(now); d.setDate(d.getDate() - i);
const day = dateStrFmt(d);
const steps = (db.query("SELECT COALESCE(SUM(qty),0) as t FROM metrics WHERE name='step_count' AND date LIKE ?").get(`${day}%`) as any).t;
const cal = (db.query("SELECT COALESCE(SUM(qty),0) as t FROM metrics WHERE name='active_energy' AND date LIKE ?").get(`${day}%`) as any).t;
const exercise = (db.query("SELECT COALESCE(SUM(qty),0) as t FROM metrics WHERE name='apple_exercise_time' AND date LIKE ?").get(`${day}%`) as any).t;
const hrRow = db.query("SELECT MIN(avg) as resting, AVG(avg) as avg FROM hr_readings WHERE date LIKE ?").get(`${day}%`) as any;
const sleepAggDay = aggregateSleepForDate(day);
const workoutRow = db.query("SELECT COUNT(*) as c, SUM(duration) as dur, SUM(calories) as cal FROM workouts WHERE start LIKE ?").get(`${day}%`) as any;
if (steps > 0 || sleepAggDay || hrRow?.resting) {
dailyData.push({
date: day, steps, calories: cal, exercise,
restingHR: hrRow?.resting || 0, avgHR: hrRow?.avg || 0,
sleepDuration: sleepAggDay?.duration || 0,
deepSleep: sleepAggDay?.deep || 0, remSleep: sleepAggDay?.rem || 0,
workoutCount: workoutRow?.c || 0,
workoutMinutes: Math.round((workoutRow?.dur || 0) / 60),
workoutCal: workoutRow?.cal || 0,
envAudio: (() => { const r = db.query("SELECT AVG(qty) as a FROM metrics WHERE name='environmental_audio_exposure' AND date LIKE ?").get(`${day}%`) as any; return r?.a ? Math.round(r.a) : 0; })(),
});
}
}
if (dailyData.length < 7) return { correlations: [], dailyData: dailyData.length };
// Define metric pairs to correlate
const pairs: Array<{ xKey: string; yKey: string; xLabel: string; yLabel: string; note: string; lowerYBetter?: boolean }> = [
{ xKey: "sleepDuration", yKey: "restingHR", xLabel: "Sleep Duration", yLabel: "Next-Day Resting HR", note: "More sleep → lower resting HR?", lowerYBetter: true },
{ xKey: "sleepDuration", yKey: "steps", xLabel: "Sleep Duration", yLabel: "Next-Day Steps", note: "More sleep → more active?" },
{ xKey: "deepSleep", yKey: "restingHR", xLabel: "Deep Sleep", yLabel: "Next-Day Resting HR", note: "Deep sleep → better HR recovery?", lowerYBetter: true },
{ xKey: "deepSleep", yKey: "calories", xLabel: "Deep Sleep", yLabel: "Next-Day Active Calories", note: "Deep sleep → more energy?" },
{ xKey: "exercise", yKey: "sleepDuration", xLabel: "Exercise Minutes", yLabel: "That Night's Sleep", note: "Exercise → better sleep?" },
{ xKey: "steps", yKey: "sleepDuration", xLabel: "Daily Steps", yLabel: "That Night's Sleep", note: "More active → sleep longer?" },
{ xKey: "workoutMinutes", yKey: "sleepDuration", xLabel: "Workout Duration", yLabel: "That Night's Sleep", note: "Harder workout → more sleep?" },
{ xKey: "workoutMinutes", yKey: "restingHR", xLabel: "Workout Duration", yLabel: "Next-Day Resting HR", note: "Training load → HR impact?", lowerYBetter: true },
{ xKey: "sleepDuration", yKey: "exercise", xLabel: "Sleep Duration", yLabel: "Next-Day Exercise", note: "Sleep → more exercise?" },
{ xKey: "remSleep", yKey: "steps", xLabel: "REM Sleep", yLabel: "Next-Day Steps", note: "REM → next-day activity?" },
{ xKey: "envAudio", yKey: "sleepDuration", xLabel: "Noise Exposure", yLabel: "That Night's Sleep", note: "Noise → sleep impact?", lowerYBetter: false },
{ xKey: "envAudio", yKey: "restingHR", xLabel: "Noise Exposure", yLabel: "Next-Day Resting HR", note: "Noise → cardiovascular impact?", lowerYBetter: true },
];
const correlations: any[] = [];
for (const pair of pairs) {
// For "next-day" correlations, offset by 1 day
const isNextDay = pair.yLabel.includes("Next-Day");
const xVals: number[] = [];
const yVals: number[] = [];
for (let i = 0; i < dailyData.length - (isNextDay ? 1 : 0); i++) {
const x = dailyData[i][pair.xKey];
const yIdx = isNextDay ? i + 1 : i;
const y = dailyData[yIdx]?.[pair.yKey.replace("Next-Day ", "")];
if (x > 0 && y > 0) { xVals.push(x); yVals.push(y); }
}
if (xVals.length >= 7) {
const r = pearsonCorr(xVals, yVals);
if (Math.abs(r) >= 0.15) {
const direction = r > 0 ? "positive" : "negative";
const strength = Math.abs(r) >= 0.5 ? "strong" : Math.abs(r) >= 0.3 ? "moderate" : "weak";
const goodOrBad = (pair.lowerYBetter && r < 0) || (!pair.lowerYBetter && r > 0) ? "beneficial" : (pair.lowerYBetter && r > 0) || (!pair.lowerYBetter && r < 0) ? "concerning" : "neutral";
correlations.push({
x: pair.xLabel, y: pair.yLabel,
r: Math.round(r * 100) / 100,
direction, strength, goodOrBad,
note: pair.note,
dataPoints: xVals.length,
});
}
}
}
// Sort by absolute correlation strength
correlations.sort((a, b) => Math.abs(b.r) - Math.abs(a.r));
return { correlations, totalDays: dailyData.length };
}
function buildInsights() {
const correlations = buildCorrelations();
const anomalies = buildAnomalyDetection();
const recovery = buildRecoveryScore();
// Gait trends (2-week regression)
const gaitMetrics = ["walking_speed", "walking_step_length", "walking_asymmetry_percentage", "walking_double_support_percentage"];
const gaitTrends: any[] = [];
for (const name of gaitMetrics) {
const base = getBaseline(name, 30);
if (base.values.length >= 5) {
const slope = linearSlope(base.values);
const slopePerWeek = slope * 7;
const pctChange = base.avg > 0 ? Math.round(slopePerWeek / base.avg * 100 * 10) / 10 : 0;
const label = name.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase());
const concerning = (name.includes("asymmetry") || name.includes("double_support")) ? slopePerWeek > 0 : slopePerWeek < 0;
gaitTrends.push({
metric: label, current: base.avg, slope: Math.round(slopePerWeek * 1000) / 1000,
pctChangePerWeek: pctChange, direction: slopePerWeek > 0 ? "increasing" : "decreasing",
concerning, dataPoints: base.values.length,
});
}
}
// Key metric trends (30-day regression)
const trendMetrics = [
{ name: "vo2_max", label: "VO2 Max", lowerBad: true },
{ name: "walking_speed", label: "Walking Speed", lowerBad: true },
{ name: "respiratory_rate", label: "Respiratory Rate", lowerBad: false },
];
const metricTrends: any[] = [];
for (const m of trendMetrics) {
const base = getBaseline(m.name, 90);
if (base.values.length >= 7) {
const slope = linearSlope(base.values);
const slopePerMonth = slope * 30;
const arrow = slopePerMonth > 0.01 ? "↑" : slopePerMonth < -0.01 ? "↓" : "→";
metricTrends.push({
metric: m.label, current: base.avg,
trend: arrow, slopePerMonth: Math.round(slopePerMonth * 100) / 100,
concerning: m.lowerBad ? slopePerMonth < -0.01 : slopePerMonth > 0.01,
});
}
}
// Sleep resting HR data
const sleepRows = db.query("SELECT total_sleep, deep, rem FROM sleep WHERE total_sleep > 0 ORDER BY date DESC LIMIT 30").all() as any[];
const hrDays = db.query("SELECT substr(date,1,10) as day, MIN(avg) as resting FROM hr_readings GROUP BY day ORDER BY day DESC LIMIT 30").all() as any[];
return {
correlations: correlations.correlations.slice(0, 8),
anomalies: anomalies.anomalies,
gaitTrends,
metricTrends,
recovery: { score: recovery.score, level: recovery.level },
};
}
// ─── Monthly Report ───────────────────────────────────────
function buildMonthlyReport(year: number, month: number) {
const prefix = `${year}-${String(month).padStart(2, "0")}`;
const monthNames = ['','January','February','March','April','May','June','July','August','September','October','November','December'];
const avg = (arr: number[]) => arr.length ? Math.round(arr.reduce((a,b)=>a+b,0)/arr.length*10)/10 : 0;
// Activity — batched GROUP BY per metric (was 4 queries × 31 days = 124)
const likePrefix = `${prefix}%`;
const sumByDay = (name: string) => {
const rows = db.query(`SELECT substr(date,1,10) as day, COALESCE(SUM(qty),0) as t FROM metrics WHERE name=? AND date LIKE ? GROUP BY day`).all(name, likePrefix) as any[];
const m = new Map<string, number>();
for (const r of rows) m.set(r.day, r.t);
return m;
};
const stepMap = sumByDay("step_count");
const calMap = sumByDay("active_energy");
const exMap = sumByDay("apple_exercise_time");
const distMap = sumByDay("walking_running_distance");
const dailySteps: number[] = [], dailyCal: number[] = [], dailyExercise: number[] = [], dailyDist: number[] = [];
const daysInMonth = new Date(year, month, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const day = `${prefix}-${String(d).padStart(2, "0")}`;
const steps = stepMap.get(day) || 0;
if (steps > 0) {
dailySteps.push(steps);
dailyCal.push(calMap.get(day) || 0);
dailyExercise.push(exMap.get(day) || 0);
dailyDist.push(distMap.get(day) || 0);
}
}
// Sleep — aggregate per date (sum + nested-dedup, matches dashboard)
const sleepDayRows = db.query(`SELECT DISTINCT substr(date,1,10) as day FROM sleep WHERE date LIKE ? ORDER BY day ASC`).all(`${prefix}%`) as any[];
const sleepNights = sleepDayRows.map(({ day }) => {
const a = aggregateSleepForDate(day);
if (!a) return null;
return { date: day, total_sleep: a.duration, deep: a.deep, rem: a.rem, core: a.core, awake: a.awake };
}).filter(Boolean) as any[];
// HR
const hrRows = db.query(`SELECT substr(date,1,10) as day, MIN(avg) as resting, AVG(avg) as avg, MAX(max) as peak FROM hr_readings WHERE date LIKE ? GROUP BY day`).all(`${prefix}%`) as any[];
// Workouts
const workouts = (db.query("SELECT * FROM workouts WHERE start LIKE ? ORDER BY start ASC").all(`${prefix}%`) as any[]).map(mapDbWorkout);
// Anomalies for the month (simplified — count days with elevated resting HR)
const restingHRs = hrRows.map(r => r.resting).filter(v => v > 0);
const hrAvg = avg(restingHRs);
const hrStd = restingHRs.length > 0 ? Math.sqrt(restingHRs.reduce((s, v) => s + (v - hrAvg) ** 2, 0) / restingHRs.length) : 0;
const elevatedHRDays = restingHRs.filter(v => v > hrAvg + 1.5 * hrStd).length;
// VO2 trend
const vo2Rows = db.query("SELECT qty FROM metrics WHERE name='vo2_max' AND date LIKE ? ORDER BY date").all(`${prefix}%`) as any[];
const vo2Start = vo2Rows.length > 0 ? Math.round(vo2Rows[0].qty * 10) / 10 : 0;
const vo2End = vo2Rows.length > 0 ? Math.round(vo2Rows[vo2Rows.length - 1].qty * 10) / 10 : 0;
// Personal records for the month
const bestStepDay = dailySteps.length > 0 ? Math.round(Math.max(...dailySteps)) : 0;
const bestSleepNight = sleepNights.length > 0 ? Math.round(Math.max(...sleepNights.map(s => s.total_sleep)) * 10) / 10 : 0;
const longestWorkout = workouts.length > 0 ? Math.max(...workouts.map(w => w.duration)) : 0;
const farthestWorkout = workouts.length > 0 ? Math.max(...workouts.map(w => w.distance)) : 0;
return {
title: `${monthNames[month]} ${year}`,
period: prefix,
daysTracked: dailySteps.length,
activity: {
avgSteps: Math.round(avg(dailySteps)),
totalSteps: Math.round(dailySteps.reduce((a, b) => a + b, 0)),
avgCalories: Math.round(avg(dailyCal)),
totalCalories: Math.round(dailyCal.reduce((a, b) => a + b, 0)),
avgExercise: Math.round(avg(dailyExercise)),
totalExercise: Math.round(dailyExercise.reduce((a, b) => a + b, 0)),
avgDistance: Math.round(avg(dailyDist) * 100) / 100,
totalDistance: Math.round(dailyDist.reduce((a, b) => a + b, 0) * 100) / 100,
},
sleep: {
nights: sleepNights.length,
avgDuration: avg(sleepNights.map(s => s.total_sleep)),
avgDeep: avg(sleepNights.map(s => s.deep)),
avgRem: avg(sleepNights.map(s => s.rem)),
avgCore: avg(sleepNights.map(s => s.core)),
bestNight: bestSleepNight,
},
heartRate: {
avgResting: avg(restingHRs),
lowestResting: restingHRs.length > 0 ? Math.round(Math.min(...restingHRs)) : 0,
elevatedDays: elevatedHRDays,
},
workouts: {
count: workouts.length,
totalDuration: workouts.reduce((s, w) => s + w.duration, 0),
totalDistance: Math.round(workouts.reduce((s, w) => s + w.distance, 0) * 100) / 100,
totalCalories: workouts.reduce((s, w) => s + w.calories, 0),
totalStrain: Math.round(workouts.reduce((s, w) => s + w.strain, 0) * 10) / 10,
avgStrain: avg(workouts.map(w => w.strain)),
},
fitness: {
vo2Start, vo2End,
vo2Change: Math.round((vo2End - vo2Start) * 10) / 10,
vo2Direction: vo2End > vo2Start ? "improving" : vo2End < vo2Start ? "declining" : "stable",
},
records: { bestStepDay, bestSleepNight, longestWorkout, farthestWorkout },
};
}
// ─── 3.0: Illness Prediction ──────────────────────────────
function buildIllnessPrediction() {
// Check 3 signals over last 3 days: resting HR, respiratory rate, wrist temperature
const signals: any[] = [];
const days = 3;
let triggeredSignals = 0;
// Signal 1: Resting HR elevated for 2+ consecutive days
const hrBase = getHRBaseline(30);
const recentHR = db.query("SELECT substr(date,1,10) as day, MIN(avg) as resting FROM hr_readings GROUP BY day ORDER BY day DESC LIMIT ?").all(days) as any[];
const elevatedHRDays = recentHR.filter(r => r.resting > hrBase.avg + 1.5 * (hrBase.stddev || 3));
const hrElevated = elevatedHRDays.length >= 2;
if (hrElevated) triggeredSignals++;
signals.push({
name: "Resting Heart Rate", icon: "❤️",
status: hrElevated ? "elevated" : "normal",
current: recentHR[0]?.resting ? Math.round(recentHR[0].resting) : 0,
baseline: hrBase.avg,
threshold: Math.round(hrBase.avg + 1.5 * (hrBase.stddev || 3)),
consecutiveDays: elevatedHRDays.length,
});
// Signal 2: Respiratory rate elevated
const respBase = getBaseline("respiratory_rate", 30);
const recentResp = db.query("SELECT qty FROM metrics WHERE name='respiratory_rate' ORDER BY date DESC LIMIT ?").all(days) as any[];
const elevatedRespDays = recentResp.filter(r => r.qty > respBase.avg + 1 * (respBase.stddev || 1));
const respElevated = elevatedRespDays.length >= 2;
if (respElevated) triggeredSignals++;
signals.push({
name: "Respiratory Rate", icon: "🫁",
status: respElevated ? "elevated" : "normal",
current: recentResp[0]?.qty ? Math.round(recentResp[0].qty * 10) / 10 : 0,
baseline: respBase.avg,
threshold: Math.round((respBase.avg + 1 * (respBase.stddev || 1)) * 10) / 10,
consecutiveDays: elevatedRespDays.length,
});
// Signal 3: Wrist temperature deviation
const tempBase = getBaseline("apple_sleeping_wrist_temperature", 14);
const recentTemp = db.query("SELECT qty FROM metrics WHERE name='apple_sleeping_wrist_temperature' ORDER BY date DESC LIMIT ?").all(days) as any[];
const elevatedTempDays = recentTemp.filter(r => Math.abs(r.qty - tempBase.avg) > 0.5);
const tempElevated = elevatedTempDays.length >= 2;
if (tempElevated) triggeredSignals++;
signals.push({
name: "Wrist Temperature", icon: "🌡️",
status: tempElevated ? "elevated" : "normal",
current: recentTemp[0]?.qty ? Math.round(recentTemp[0].qty * 10) / 10 : 0,
baseline: tempBase.avg,
threshold: Math.round((tempBase.avg + 0.5) * 10) / 10,
consecutiveDays: elevatedTempDays.length,
});
const alert = triggeredSignals >= 2;
const level = triggeredSignals >= 3 ? "high" : triggeredSignals >= 2 ? "moderate" : "none";
const message = alert
? triggeredSignals >= 3
? "Multiple body signals are elevated. Your body may be fighting something. Strongly consider rest and extra sleep."
: "Two early warning signals are elevated. Monitor closely — this pattern often precedes illness by 48-72 hours."
: "All clear — no illness indicators detected.";
return { alert, level, triggeredSignals, signals, message };
}
// ─── 3.0: Sleep Prediction ───────────────────────────────
function buildSleepPrediction() {
const today = todayStr();
// Gather today's inputs
const todayExercise = (db.query("SELECT COALESCE(SUM(qty),0) as t FROM metrics WHERE name='apple_exercise_time' AND date LIKE ?").get(`${today}%`) as any).t;
const todaySteps = (db.query("SELECT COALESCE(SUM(qty),0) as t FROM metrics WHERE name='step_count' AND date LIKE ?").get(`${today}%`) as any).t;
const todayWorkouts = (db.query("SELECT COUNT(*) as c, COALESCE(SUM(duration),0) as dur FROM workouts WHERE start LIKE ?").get(`${today}%`) as any);
const workoutMinutes = Math.round((todayWorkouts?.dur || 0) / 60);
// Get correlation data: exercise → sleep duration
const corrData = buildCorrelations();
const exerciseSleepCorr = corrData.correlations.find((c: any) => c.x === "Exercise Minutes" && c.y.includes("Sleep"));
// Historical averages
const sleepAvgRow = db.query("SELECT AVG(total_sleep) as avg FROM sleep WHERE total_sleep > 0").get() as any;
const sleepAvg = sleepAvgRow?.avg || 7;
// Day of week pattern
const dow = new Date().getDay(); // 0=Sun
const dowSleep = db.query(`SELECT AVG(s.total_sleep) as avg FROM sleep s WHERE cast(strftime('%w', substr(s.date,1,10)) as integer) = ? AND s.total_sleep > 0`).get(dow) as any;
const dowAvg = dowSleep?.avg ? Math.round(dowSleep.avg * 10) / 10 : sleepAvg;
// Training load effect
// Get training load status directly (avoid circular call to buildMorningBriefing)
const recentWk = db.query("SELECT duration FROM workouts WHERE start >= date('now', '-7 days')").all() as any[];
const acLoad = recentWk.reduce((s: number, w: any) => s + (w.duration || 0) / 3600, 0);
const chronicWk = db.query("SELECT duration FROM workouts WHERE start >= date('now', '-42 days')").all() as any[];
const chLoad = chronicWk.reduce((s: number, w: any) => s + (w.duration || 0) / 3600, 0) / 6;
const lRatio = chLoad > 0 ? acLoad / chLoad : 0;
const loadStatus = lRatio > 1.5 ? "overreaching" : lRatio > 1.2 ? "pushing" : lRatio > 0.8 ? "optimal" : "detraining";
const loadPenalty = loadStatus === "overreaching" ? -0.3 : loadStatus === "pushing" ? -0.1 : 0;
// Exercise effect (moderate exercise helps, excessive hurts)
const exerciseBonus = todayExercise > 20 && todayExercise < 60 ? 0.2 : todayExercise >= 60 ? -0.1 : 0;
// Step effect
const stepBase = getBaseline("step_count", 30);
const stepBonus = todaySteps > stepBase.avg * 1.2 ? 0.15 : todaySteps < stepBase.avg * 0.5 ? -0.1 : 0;
// Predicted duration
const predictedDuration = Math.round((dowAvg + exerciseBonus + stepBonus + loadPenalty) * 10) / 10;
// Predicted quality (0-100)
const baseQuality = 70;
const exerciseQualBonus = todayExercise > 20 ? 10 : 0;
const loadQualPenalty = loadStatus === "overreaching" ? -15 : loadStatus === "pushing" ? -5 : 0;
const predictedQuality = Math.max(20, Math.min(100, Math.round(baseQuality + exerciseQualBonus + loadQualPenalty)));
const factors: any[] = [];
if (todayExercise > 20) factors.push({ factor: "Exercise", effect: "positive", note: `${Math.round(todayExercise)} min of exercise today` });
if (todayExercise >= 60) factors.push({ factor: "Heavy exercise", effect: "negative", note: "Late/heavy exercise may reduce sleep quality" });
if (workoutMinutes > 0) factors.push({ factor: "Workout", effect: "positive", note: `${workoutMinutes} min workout completed` });
if (loadStatus === "overreaching") factors.push({ factor: "Training load", effect: "negative", note: "Overreaching — body under stress" });
if (todaySteps > stepBase.avg * 1.2) factors.push({ factor: "High activity", effect: "positive", note: `${Math.round(todaySteps)} steps (above average)` });
return {
predictedDuration,
predictedQuality,
yourAverage: Math.round(sleepAvg * 10) / 10,
dayOfWeekAverage: dowAvg,
factors,
confidence: corrData.totalDays > 30 ? "moderate" : "low",
};
}
// ─── 3.0: Workout Route Map Data ─────────────────────────
function getWorkoutRoute(workoutId: string) {
const row = db.query("SELECT * FROM workouts WHERE id = ?").get(workoutId) as any;
if (!row) return { error: "Workout not found" };
const workout = mapDbWorkout(row);
let route: any[] = [];
let hrTimeline: any[] = [];
try {
const hrData = JSON.parse(row.hr_data || "[]");
hrTimeline = hrData.map((h: any) => ({ time: h.d, bpm: h.b }));
} catch {}
// Prefer DB-stored route_data (populated during ingest + backfill). Fall back to
// JSONL scan only if DB has nothing — previously we scanned the full 108 MB JSONL
// on every request, which blocked the event loop for hundreds of ms.
if (row.route_data) {
try {
const parsed = JSON.parse(row.route_data);
route = parsed.map((p: any) => ({ lat: p.lat, lon: p.lon, alt: p.alt, timestamp: p.ts }));
} catch {}
}
return { workout, route, hrTimeline, hasRoute: route.length > 0 };
}
// ─── Server ───────────────────────────────────────────────
const server = Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
const json = (data: any, status = 200) => new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });
// POST health data → ingest to SQLite + append to JSONL backup
if (url.pathname === "/health" && req.method === "POST") {
const declaredLen = parseInt(req.headers.get("content-length") || "0");
if (declaredLen > MAX_POST_BYTES) return json({ status: "error", message: "Payload too large" }, 413);
let body: string;
try {
body = await req.text();
} catch (err: any) {
return json({ status: "error", message: "Failed to read body: " + err.message }, 400);
}
if (body.length > MAX_POST_BYTES) return json({ status: "error", message: "Payload too large" }, 413);
let payload: any;
try {
payload = JSON.parse(body);
} catch (err: any) {
return json({ status: "error", message: "Invalid JSON: " + err.message }, 400);
}
const timestamp = new Date().toISOString();
try {
ingestPayload(payload);
appendFileSync(RAW_LOG, JSON.stringify({ timestamp, size: body.length, payload }) + "\n");
lastSyncTime = timestamp;
console.log(`[${timestamp}] Ingested health data: ${body.length} bytes`);
return json({ status: "ok", received: body.length });
} catch (err: any) {
console.error(`[${timestamp}] Ingest failed:`, err?.message);
return json({ status: "error", message: "Ingest failed: " + err.message }, 500);
}
}
if (url.pathname === "/health" && req.method === "GET") return json({ status: "ok", uptime: process.uptime() });
// Browser-safe runtime config. CARTO requires the API key client-side,
// so expose only that key from the container environment.
if (url.pathname === "/api/config" && req.method === "GET") {
return json({ cartoApiKey: process.env.CARTO_API_KEY || "" });
}
// ─── NixVitals 2.0 Endpoints ─────────────────────────
if (url.pathname === "/api/briefing") return json(buildMorningBriefing());
if (url.pathname === "/api/recovery") return json(buildRecoveryScore());
if (url.pathname === "/api/anomalies") return json(buildAnomalyDetection());
if (url.pathname === "/api/correlations") return json(buildCorrelations());
if (url.pathname === "/api/insights") return json(buildInsights());
if (url.pathname === "/api/illness") return json(buildIllnessPrediction());
if (url.pathname === "/api/sleep-prediction") return json(buildSleepPrediction());
if (url.pathname === "/api/route") { const id = url.searchParams.get("id") || ""; return json(getWorkoutRoute(id)); }
if (url.pathname === "/api/monthly-report") {
const now = new Date();
const year = parseInt(url.searchParams.get("year") || String(now.getFullYear()));
const month = parseInt(url.searchParams.get("month") || String(now.getMonth() + 1));
if (!isValidYearMonth(year, month)) return json({ error: "Invalid year/month" }, 400);
return json(buildMonthlyReport(year, month));
}
if (url.pathname === "/api/dashboard") return json(buildDashboardData());
if (url.pathname === "/api/history") {
const raw = parseInt(url.searchParams.get("days") || "7");
const days = Math.max(1, Math.min(Number.isFinite(raw) ? raw : 7, 365));
return json(buildHistoryData(days));
}
if (url.pathname === "/api/day") {
const day = url.searchParams.get("date") || todayStr();
if (!isValidDay(day)) return json({ error: "Invalid date format (expected YYYY-MM-DD)" }, 400);
return json(queryDaySummary(day));
}
if (url.pathname === "/api/workouts") return json((db.query("SELECT * FROM workouts ORDER BY start DESC").all() as any[]).map(mapDbWorkout));
// Workout analytics — aggregated stats, weekly/monthly breakdowns, trends, PRs
if (url.pathname === "/api/workout-analytics") {
const all = (db.query("SELECT * FROM workouts ORDER BY start ASC").all() as any[]).map(mapDbWorkout);
if (all.length === 0) return json({ error: "No workouts" });
const avg = (arr: number[]) => arr.length ? Math.round(arr.reduce((a,b)=>a+b,0)/arr.length*10)/10 : 0;
const sum = (arr: number[]) => Math.round(arr.reduce((a,b)=>a+b,0)*100)/100;
const withPace = all.filter(w => w.pace > 0);
// Overall stats
const overall = {
count: all.length,
totalDuration: sum(all.map(w=>w.duration)),
totalDistance: sum(all.map(w=>w.distance)),
totalCalories: sum(all.map(w=>w.calories)),
totalSteps: sum(all.map(w=>w.steps)),
totalElevation: sum(all.map(w=>w.elevationGain)),
avgDuration: avg(all.map(w=>w.duration)),
avgDistance: avg(all.map(w=>w.distance)),
avgCalories: avg(all.map(w=>w.calories)),
avgHR: avg(all.map(w=>w.avgHR)),
avgPace: avg(withPace.map(w=>w.pace)),
avgCadence: avg(all.filter(w=>w.cadence>0).map(w=>w.cadence)),
avgCalPerMin: avg(all.filter(w=>w.calPerMin>0).map(w=>w.calPerMin)),
avgSteps: avg(all.map(w=>w.steps)),
avgElevation: avg(all.filter(w=>w.elevationGain>0).map(w=>w.elevationGain)),
avgStrain: avg(all.map(w=>w.strain)),
totalStrain: sum(all.map(w=>w.strain)),
};
// Personal records
const prs = {
longestDuration: all.reduce((b,w)=>w.duration>b.duration?w:b),
farthestDistance: all.reduce((b,w)=>w.distance>b.distance?w:b),
mostCalories: all.reduce((b,w)=>w.calories>b.calories?w:b),
highestHR: all.reduce((b,w)=>w.maxHR>b.maxHR?w:b),
fastestPace: withPace.reduce((b,w)=>w.pace<b.pace?w:b,withPace[0]),
mostSteps: all.reduce((b,w)=>w.steps>b.steps?w:b),
mostElevation: all.filter(w=>w.elevationGain>0).reduce((b,w)=>w.elevationGain>b.elevationGain?w:b,all[0]),
};
// Weekly aggregates
const weeks = new Map<string, any[]>();
for (const w of all) {
const d = new Date(w.start.replace(" ","T").replace(/ ([+-]\d{4})$/,"$1"));
const sun = new Date(d); sun.setDate(d.getDate() - d.getDay());
const wk = `${sun.getFullYear()}-${String(sun.getMonth()+1).padStart(2,"0")}-${String(sun.getDate()).padStart(2,"0")}`;
if (!weeks.has(wk)) weeks.set(wk, []);
weeks.get(wk)!.push(w);
}
const weeklyStats = Array.from(weeks.entries()).map(([wk, ws]) => ({
week: wk, count: ws.length,
totalDuration: sum(ws.map(w=>w.duration)), totalDistance: sum(ws.map(w=>w.distance)),
totalCalories: sum(ws.map(w=>w.calories)), totalSteps: sum(ws.map(w=>w.steps)),
avgDuration: avg(ws.map(w=>w.duration)), avgDistance: avg(ws.map(w=>w.distance)),
avgCalories: avg(ws.map(w=>w.calories)), avgHR: avg(ws.map(w=>w.avgHR)),
avgPace: avg(ws.filter(w=>w.pace>0).map(w=>w.pace)),
})).sort((a,b)=>a.week.localeCompare(b.week));
// Per-workout trend data (for charts)
const trends = all.map(w => ({
date: w.start.slice(0,10), duration: w.duration, distance: w.distance,
calories: w.calories, avgHR: w.avgHR, pace: w.pace, steps: w.steps,
elevation: w.elevationGain, cadence: w.cadence, calPerMin: w.calPerMin, strain: w.strain,
}));
// Cardiac recovery rate over time — from cardio_recovery metric entries
const recoveryTrend = (db.query("SELECT date, qty FROM metrics WHERE name='cardio_recovery' ORDER BY date ASC").all() as any[]).map(r => ({
date: r.date.slice(0, 10),
rate: Math.round(r.qty * 10) / 10,
grade: r.qty >= 20 ? "excellent" : r.qty >= 15 ? "good" : r.qty >= 12 ? "fair" : "poor",
}));
return json({ overall, prs, weeklyStats, trends, recoveryTrend, workouts: all.reverse() });
}
if (url.pathname === "/api/sleep") return json(buildSleepHistory());
// HR samples within a specific night's sleep window + the night's stage breakdown.
// Used by the HR-during-sleep curve chart on the Sleep tab.
if (url.pathname === "/api/sleep-hr") {
const day = url.searchParams.get("date") || todayStr();
if (!isValidDay(day)) return json({ error: "Invalid date format" }, 400);
const agg = aggregateSleepForDate(day);
if (!agg || !agg.sleepStart || !agg.sleepEnd) return json({ error: "No sleep data for date" }, 404);
const hrRows = db.query(`
SELECT date, avg, min, max FROM hr_readings
WHERE date >= ? AND date <= ?
ORDER BY date ASC
`).all(agg.sleepStart, agg.sleepEnd) as any[];
// Pull all sleep entries in the window so we can render the stage timeline
const stageRows = db.query(`
SELECT sleep_start, sleep_end, deep, rem, core, awake, total_sleep
FROM sleep
WHERE sleep_start >= ? AND sleep_end <= ?
ORDER BY total_sleep ASC
`).all(agg.sleepStart, agg.sleepEnd) as any[];
return json({
date: day,
sleepStart: agg.sleepStart,
sleepEnd: agg.sleepEnd,
duration: agg.duration,
sessions: agg.sessions,
hrSamples: hrRows.map(r => ({ t: r.date, bpm: Math.round(r.avg || 0), min: Math.round(r.min || 0), max: Math.round(r.max || 0) })),
stageWindows: stageRows.map(r => ({
start: r.sleep_start, end: r.sleep_end,
deep: round(r.deep || 0, 2), rem: round(r.rem || 0, 2),
core: round(r.core || 0, 2), awake: round(r.awake || 0, 2),
total: round(r.total_sleep || 0, 2),
})),
});
}
// 14-day recovery score history (compact computation reusing per-day inputs)
if (url.pathname === "/api/recovery-history") {
const days = Math.max(7, Math.min(parseInt(url.searchParams.get("days") || "14"), 60));
const dayRows = db.query(`SELECT DISTINCT substr(date,1,10) as day FROM sleep ORDER BY day DESC LIMIT ?`).all(days) as any[];
const hrBase = getHRBaseline(30);
const respBase = getBaseline("respiratory_rate", 30);
const tempBase = getBaseline("apple_sleeping_wrist_temperature", 14);
const result: any[] = [];
for (const { day } of dayRows.reverse()) {
const sleep = aggregateSleepForDate(day);
const dur = sleep?.duration || 0;
const deepPct = dur > 0 ? ((sleep!.deep || 0) / dur) * 100 : 0;
const remPct = dur > 0 ? ((sleep!.rem || 0) / dur) * 100 : 0;
// Sleep score (simplified, 0-100)
let sleepScore = 0;
if (dur > 0) {
const durationScore = Math.min(50, (dur / 7.5) * 50);
const deepScore = Math.min(25, deepPct * 1.7);
const remScore = Math.min(25, remPct * 1.2);
sleepScore = Math.round(durationScore + deepScore + remScore);
}
// HR score (lower deviation = higher score)
const dayResting = (db.query("SELECT MIN(avg) as r FROM hr_readings WHERE date LIKE ?").get(`${day}%`) as any)?.r || 0;
let hrScore = 50;
if (hrBase.avg > 0 && dayResting > 0) {
const dev = (dayResting - hrBase.avg) / (hrBase.stddev || 1);
hrScore = Math.max(0, Math.min(100, 50 - dev * 25));
}
// Respiratory + temp scores
const dayResp = (db.query("SELECT qty FROM metrics WHERE name='respiratory_rate' AND date LIKE ? ORDER BY date DESC LIMIT 1").get(`${day}%`) as any)?.qty || 0;
let respScore = 50;
if (respBase.avg > 0 && dayResp > 0) respScore = Math.max(0, Math.min(100, 50 - ((dayResp - respBase.avg) / (respBase.stddev || 1)) * 20));
const dayTemp = (db.query("SELECT qty FROM metrics WHERE name='apple_sleeping_wrist_temperature' AND date LIKE ? ORDER BY date DESC LIMIT 1").get(`${day}%`) as any)?.qty || 0;
let tempScore = 50;
if (tempBase.avg > 0 && dayTemp > 0) tempScore = Math.max(0, Math.min(100, 100 - Math.abs(dayTemp - tempBase.avg) / (tempBase.stddev || 0.5) * 30));
const composite = Math.round(hrScore * 0.35 + sleepScore * 0.30 + respScore * 0.20 + tempScore * 0.15);
result.push({ date: day, score: composite, sleep: sleepScore, hr: Math.round(hrScore), resp: Math.round(respScore), temp: Math.round(tempScore), duration: round(dur, 2) });
}
return json({ days: result });
}
// Sleep analytics — aggregate per calendar date (sum + nested-dedup, matches dashboard rule)
if (url.pathname === "/api/sleep-analytics") {
const dayList = db.query(`SELECT DISTINCT substr(date,1,10) as day FROM sleep ORDER BY day ASC`).all() as any[];
const all = dayList.map(({ day }) => {
const a = aggregateSleepForDate(day);
if (!a) return null;
const resp = db.query("SELECT qty FROM metrics WHERE name='respiratory_rate' AND date LIKE ? ORDER BY date DESC LIMIT 1").get(`${day}%`) as any;
const temp = db.query("SELECT qty FROM metrics WHERE name='apple_sleeping_wrist_temperature' AND date LIKE ? ORDER BY date DESC LIMIT 1").get(`${day}%`) as any;
const breath = db.query("SELECT qty FROM metrics WHERE name='breathing_disturbances' AND date LIKE ? ORDER BY date DESC LIMIT 1").get(`${day}%`) as any;
const dur = a.duration;
return {
date: day + " 00:00:00",
duration: round(dur, 1), deep: round(a.deep, 1), rem: round(a.rem, 1),
core: round(a.core, 1), awake: round(a.awake, 1),
sleepStart: a.sleepStart, sleepEnd: a.sleepEnd, sessions: a.sessions,
respiratoryRate: resp ? round(resp.qty, 1) : 0,
wristTemp: temp ? round(temp.qty, 1) : 0,
breathingDisturbances: breath ? round(breath.qty, 1) : 0,
deepPct: dur > 0 ? Math.round(a.deep / dur * 100) : 0,
remPct: dur > 0 ? Math.round(a.rem / dur * 100) : 0,
corePct: dur > 0 ? Math.round(a.core / dur * 100) : 0,
};
}).filter(Boolean) as any[];
if(all.length===0) return json({error:"No sleep data"});
const avg=(arr:number[])=>arr.length?Math.round(arr.reduce((a,b)=>a+b,0)/arr.length*10)/10:0;
const sum=(arr:number[])=>Math.round(arr.reduce((a,b)=>a+b,0)*10)/10;
const withResp=all.filter(s=>s.respiratoryRate>0);
const withTemp=all.filter(s=>s.wristTemp>0);
const overall={
nights: all.length,
avgDuration: avg(all.map(s=>s.duration)),
avgDeep: avg(all.map(s=>s.deep)),
avgRem: avg(all.map(s=>s.rem)),
avgCore: avg(all.map(s=>s.core)),
avgAwake: avg(all.map(s=>s.awake)),
avgDeepPct: avg(all.map(s=>s.deepPct)),
avgRemPct: avg(all.map(s=>s.remPct)),
avgRespRate: avg(withResp.map(s=>s.respiratoryRate)),
avgWristTemp: avg(withTemp.map(s=>s.wristTemp)),
totalSleep: sum(all.map(s=>s.duration)),
};
// Best/worst nights
const records={
bestSleep: all.reduce((b,s)=>s.duration>b.duration?s:b),
worstSleep: all.reduce((b,s)=>s.duration<b.duration&&s.duration>0?s:b),
mostDeep: all.reduce((b,s)=>s.deep>b.deep?s:b),
mostRem: all.reduce((b,s)=>s.rem>b.rem?s:b),
leastAwake: all.filter(s=>s.awake>=0).reduce((b,s)=>s.awake<b.awake?s:b),
};
// Weekly aggregates
const weeks=new Map<string,any[]>();
for(const s of all){
const d=new Date(s.date.replace(" ","T").replace(/ ([+-]\d{4})$/,"$1"));
const sun=new Date(d);sun.setDate(d.getDate()-d.getDay());
const wk=`${sun.getFullYear()}-${String(sun.getMonth()+1).padStart(2,"0")}-${String(sun.getDate()).padStart(2,"0")}`;
if(!weeks.has(wk))weeks.set(wk,[]);
weeks.get(wk)!.push(s);
}
const weeklyStats=Array.from(weeks.entries()).map(([wk,ss])=>({
week:wk,nights:ss.length,
avgDuration:avg(ss.map(s=>s.duration)),avgDeep:avg(ss.map(s=>s.deep)),
avgRem:avg(ss.map(s=>s.rem)),avgCore:avg(ss.map(s=>s.core)),
avgAwake:avg(ss.map(s=>s.awake)),
avgDeepPct:avg(ss.map(s=>s.deepPct)),avgRemPct:avg(ss.map(s=>s.remPct)),
})).sort((a,b)=>a.week.localeCompare(b.week));
// Monthly aggregates
const months=new Map<string,any[]>();
for(const s of all){
const mo=s.date.slice(0,7);
if(!months.has(mo))months.set(mo,[]);
months.get(mo)!.push(s);
}
const monthlyStats=Array.from(months.entries()).map(([mo,ss])=>({
month:mo,nights:ss.length,
avgDuration:avg(ss.map(s=>s.duration)),avgDeep:avg(ss.map(s=>s.deep)),
avgRem:avg(ss.map(s=>s.rem)),avgCore:avg(ss.map(s=>s.core)),
totalSleep:sum(ss.map(s=>s.duration)),
})).sort((a,b)=>a.month.localeCompare(b.month));
// Trend data
const trends=all.map(s=>({date:s.date.slice(0,10),...s}));
return json({overall,records,weeklyStats,monthlyStats,trends,nights:all.reverse()});
}
if (url.pathname === "/api/calendar") {
const now = new Date();
const year = parseInt(url.searchParams.get("year") || String(now.getFullYear()));
const month = parseInt(url.searchParams.get("month") || String(now.getMonth() + 1));
if (!isValidYearMonth(year, month)) return json({ error: "Invalid year/month" }, 400);
return json(buildCalendarData(year, month));
}
if (url.pathname === "/api/stats") return json(dbStats());
// Static files — reject any path that escapes PUBLIC_DIR
const rawPath = url.pathname === "/" ? "/index.html" : url.pathname;
const filePath = join(PUBLIC_DIR, rawPath);
if (filePath.startsWith(PUBLIC_DIR + "/") && existsSync(filePath)) {
return new Response(Bun.file(filePath));
}
// SPA fallback — serve index.html for unknown routes
if (existsSync(join(PUBLIC_DIR, "index.html"))) return new Response(Bun.file(join(PUBLIC_DIR, "index.html")));
return new Response("Health dashboard — POST health data to /health", { status: 200 });
},
});
// Graceful shutdown: flush DB, stop listener, exit cleanly
const shutdown = (signal: string) => {
console.log(`\n🛑 ${signal} received — shutting down…`);
try { server.stop(); } catch {}
try { db.close(); } catch {}
process.exit(0);
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
console.log(`🏥 Health server running on http://localhost:${PORT}`);
console.log(`📊 SQLite database: ${DB_PATH}`);