From 191f5f473bcd01d2ba71e7257f0ed794f71a0f18 Mon Sep 17 00:00:00 2001 From: Anthony Merlo Date: Thu, 20 Aug 2026 23:54:08 +0100 Subject: [PATCH] fix: rewrite health-sync.js for current Scripting API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old script used deprecated Health.read() with Health.Types.* and .quantity — every read silently failed in try/catch, so no samples were ever collected. Rewrote to use Health.queryQuantitySamples() with string type identifiers and .quantityValue(unit) per current Scripting app docs. Added proper units (kg, mmHg, count/min, count, percent) for each metric type. Also adds /scriptable/health-sync.js endpoint to serve the script directly. --- scripts/health-sync.js | 159 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 14 ++++ 2 files changed, 173 insertions(+) create mode 100644 scripts/health-sync.js diff --git a/scripts/health-sync.js b/scripts/health-sync.js new file mode 100644 index 0000000..7315461 --- /dev/null +++ b/scripts/health-sync.js @@ -0,0 +1,159 @@ +// Health Sync — reads Apple Health and POSTs to nutrition-mcp server +// Run via Scriptable/Scripting automation at 9:30am daily +// Server: http://chronos:9432/ingest +// +// Uses Health.queryQuantitySamples() (current Scripting API). +// The old Health.read() / Health.Types.* / .quantity API was removed. + +const SERVER_URL = "http://chronos:9432/ingest"; +const API_KEY = "333f09ce904e9cbbe3b578fda393be92869122c7222dbccd"; + +// --- Units for each quantity type --- +const U = { + kg: HealthUnit.gram(HealthMetricPrefix.kilo), + mmHg: HealthUnit.millimeterOfMercury(), + bpm: HealthUnit.count().divided(HealthUnit.minute()), + count: HealthUnit.count(), + percent: HealthUnit.percent() +}; + +// --- Helpers --- + +// Read the most recent sample of a type. Returns { value, startDate } or null. +async function readLatest(type, unit) { + try { + const samples = await Health.queryQuantitySamples(type, { + limit: 1, + sortDescriptors: [{ key: "startDate", order: "reverse" }] + }); + if (!samples || samples.length === 0) return null; + const s = samples[0]; + return { value: s.quantityValue(unit), startDate: s.startDate }; + } catch (e) { + return null; + } +} + +// Sum all samples of a type since midnight. Returns a number or null. +async function readTodaySum(type, unit) { + try { + const start = new Date(); + start.setHours(0, 0, 0, 0); + const samples = await Health.queryQuantitySamples(type, { + startDate: start, + endDate: new Date() + }); + if (!samples || samples.length === 0) return null; + let total = 0; + for (const s of samples) { + total += s.quantityValue(unit); + } + return total; + } catch (e) { + return null; + } +} + +function isoNow() { + return new Date().toISOString(); +} + +function todayDate() { + return new Date().toISOString().slice(0, 10); +} + +function notify(body) { + const n = new Notification(); + n.title = "Health Sync"; + n.body = body; + n.schedule(); +} + +// --- Read all health metrics --- +const weight = await readLatest("bodyMass", U.kg); +const bpSys = await readLatest("bloodPressureSystolic", U.mmHg); +const bpDia = await readLatest("bloodPressureDiastolic", U.mmHg); +const hr = await readLatest("heartRate", U.bpm); +const restingHr = await readLatest("restingHeartRate", U.bpm); +const steps = await readTodaySum("stepCount", U.count); +const spo2 = await readLatest("oxygenSaturation", U.percent); + +// --- Build samples array — only include metrics that have data --- +const samples = []; + +if (weight) { + samples.push({ + type: "weight", + timestamp_utc: weight.startDate.toISOString(), + value: weight.value, + source: "apple_health" + }); +} + +if (bpSys && bpDia) { + const bpValue = { systolic: bpSys.value, diastolic: bpDia.value }; + if (hr) bpValue.pulse = hr.value; + samples.push({ + type: "blood_pressure", + timestamp_utc: bpSys.startDate.toISOString(), + value: bpValue, + source: "omron" + }); +} + +if (hr && !bpSys) { + samples.push({ + type: "heart_rate", + timestamp_utc: hr.startDate.toISOString(), + value: hr.value, + source: "apple_health" + }); +} + +if (restingHr) { + samples.push({ + type: "resting_heart_rate", + timestamp_utc: restingHr.startDate.toISOString(), + value: restingHr.value, + source: "apple_health" + }); +} + +if (steps !== null) { + samples.push({ + type: "steps", + timestamp_utc: todayDate(), + value: steps, + source: "apple_health" + }); +} + +if (spo2) { + samples.push({ + type: "spo2", + timestamp_utc: spo2.startDate.toISOString(), + value: spo2.value * 100, // HealthKit stores 0-1, server expects % + source: "apple_health" + }); +} + +if (samples.length === 0) { + await notify("No health data found today"); + Script.done(); +} + +// --- POST to server --- +const req = new Request(SERVER_URL); +req.method = "POST"; +req.headers = { + "Authorization": `Bearer ${API_KEY}`, + "Content-Type": "application/json" +}; +req.body = JSON.stringify({ samples: samples }); + +try { + const res = await req.loadJSON(); + await notify(`✓ Sent ${res.ingested || samples.length} samples`); +} catch (e) { + await notify(`✗ Error: ${e.message || e}`); +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index e675bd6..167051c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,6 +82,7 @@ async fn main() -> Result<()> { let app = Router::new() // Public — no auth .route("/health", get(health_check)) + .route("/scriptable/health-sync.js", get(serve_scriptable_script)) // Bearer-protected routes (auth layer lives inside `protected`) .merge(protected); @@ -107,6 +108,19 @@ async fn health_check() -> &'static str { "OK" } +/// Serve the Scriptable health-sync.js file for easy phone download. +/// No auth — it's just a script, no sensitive data. +async fn serve_scriptable_script() -> impl axum::response::IntoResponse { + let script = include_str!("../scripts/health-sync.js"); + ( + [ + ("content-type", "text/javascript; charset=utf-8"), + ("content-disposition", "attachment; filename=\"health-sync.js\""), + ], + script, + ) +} + /// Bearer token auth middleware — shared by /mcp and /ingest. async fn auth_middleware( State(state): State,