From bcd8c1241befc96b87ccac7cfc2afcf06d203f75 Mon Sep 17 00:00:00 2001 From: Anthony Merlo Date: Sat, 22 Aug 2026 22:55:55 +0100 Subject: [PATCH] fix: rewrite script as TypeScript for Scripting app (.tsx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripting app expects TypeScript with import from 'scripting' and Script.exit(). - Renamed health-sync.js → health-sync.tsx - Added 'import { Script } from "scripting"' and Script.exit() at end - Added TypeScript type annotations - Health/HealthUnit/Notification/fetch are global APIs (no import needed) --- scripts/health-sync.tsx | 190 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 6 +- 2 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 scripts/health-sync.tsx diff --git a/scripts/health-sync.tsx b/scripts/health-sync.tsx new file mode 100644 index 0000000..7d4d4e9 --- /dev/null +++ b/scripts/health-sync.tsx @@ -0,0 +1,190 @@ +// Health Sync — reads Apple Health via Scripting app and POSTs to health-mcp server +// Run via Scripting app automation at 9:30am daily +// Server: http://chronos:9432/ingest +// +// Uses Scripting app APIs: +// Health.queryQuantitySamples(type, options) — global API, no import needed +// sample.quantityValue(unit) — extracts numeric value +// HealthUnit / HealthMetricPrefix — unit construction (global) +// fetch() — standard Fetch API (with allowInsecureRequest for HTTP) +// Notification.schedule({ title, body }) — global API +// Script — imported from "scripting" for Script.exit() +// +// Docs: https://scriptingapp.github.io/guide/Device%20Capabilities/Health/ + +import { Script } from "scripting" + +// Variables used by Scripting app. These must be at the very top of the file. +// icon-color: deep-blue; icon-glyph: heart; + +const SERVER_URL = "http://chronos:9432/ingest" +const API_KEY = "333f09ce904e9cbbe3b578fda393be92869122c7222dbccd" + +// --- Units for each quantity type --- +const U = { + kg: HealthUnit.gramUnit(HealthMetricPrefix.kilo), + mmHg: HealthUnit.fromString("mmHg"), + bpm: HealthUnit.count().divided(HealthUnit.minute()), + count: HealthUnit.count(), + percent: HealthUnit.percent() +} + +// Read the most recent sample of a type. Returns { value, startDate } or null. +async function readLatest(type: string, unit: HealthUnit): Promise<{ value: number; startDate: Date } | null> { + try { + const samples = await Health.queryQuantitySamples(type, { + limit: 1, + sortDescriptors: [{ key: "startDate", order: "reverse" }] + }) + if (!samples || samples.length === 0) { + console.log(`readLatest(${type}): no samples`) + return null + } + const s = samples[0] + const value = s.quantityValue(unit) + console.log(`readLatest(${type}): value=${value} date=${s.startDate}`) + return { value, startDate: s.startDate } + } catch (e) { + console.log(`readLatest(${type}): error ${e}`) + return null + } +} + +// Sum all samples of a type since midnight. Returns a number or null. +async function readTodaySum(type: string, unit: HealthUnit): Promise { + 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) { + console.log(`readTodaySum(${type}): no samples`) + return null + } + let total = 0 + for (const s of samples) { + total += s.quantityValue(unit) + } + console.log(`readTodaySum(${type}): total=${total}`) + return total + } catch (e) { + console.log(`readTodaySum(${type}): error ${e}`) + return null + } +} + +function todayDate(): string { + return new Date().toISOString().slice(0, 10) +} + +async function notify(body: string) { + console.log(`notify: ${body}`) + await Notification.schedule({ + title: "Health Sync", + body: body + }) +} + +// --- Read all health metrics --- +console.log("=== Health Sync starting ===") + +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) + +console.log(`Results: weight=${weight?.value} bpSys=${bpSys?.value} bpDia=${bpDia?.value} hr=${hr?.value} rhr=${restingHr?.value} steps=${steps} spo2=${spo2?.value}`) + +// --- Build samples array — only include metrics that have data --- +const samples: any[] = [] + +if (weight) { + samples.push({ + type: "weight", + timestamp_utc: weight.startDate.toISOString(), + value: weight.value, + source: "apple_health" + }) +} + +if (bpSys && bpDia) { + const bpValue: any = { 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" + }) +} + +console.log(`Total samples to send: ${samples.length}`) + +// --- POST to server --- +if (samples.length === 0) { + await notify("No health data found today") +} else { + + try { + const response = await fetch(SERVER_URL, { + method: "POST", + headers: { + "Authorization": `Bearer ${API_KEY}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ samples }), + allowInsecureRequest: true + }) + const res = await response.json() + console.log(`Server response: ${JSON.stringify(res)}`) + await notify(`Sent ${res.ingested || samples.length} samples`) + } catch (e: any) { + console.log(`POST error: ${e}`) + await notify(`Error: ${e.message || e}`) + } + +} + +console.log("=== Health Sync done ===") +Script.exit() \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 64f9432..25fd0b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,14 +108,14 @@ async fn health_check() -> &'static str { "OK" } -/// Serve the health-sync.js script for easy phone access. +/// Serve the health-sync.tsx script for easy phone access. /// No auth — it's just a script, no sensitive data. /// Served as inline text so Safari displays it for copy-paste into Scripting app. async fn serve_scriptable_script() -> impl axum::response::IntoResponse { - let script = include_str!("../scripts/health-sync.js"); + let script = include_str!("../scripts/health-sync.tsx"); ( [ - ("content-type", "text/javascript; charset=utf-8"), + ("content-type", "text/plain; charset=utf-8"), ("content-disposition", "inline"), ], script,