fix: rewrite health-sync.js for current Scripting API

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.
This commit is contained in:
Anthony Merlo 2026-08-20 23:54:08 +01:00
parent 5d8976ef37
commit 191f5f473b
2 changed files with 173 additions and 0 deletions

159
scripts/health-sync.js Normal file
View file

@ -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}`);
}

View file

@ -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<AppState>,