fix: rewrite script as TypeScript for Scripting app (.tsx)
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)
This commit is contained in:
parent
ac2d2ad0ff
commit
bcd8c1241b
2 changed files with 193 additions and 3 deletions
190
scripts/health-sync.tsx
Normal file
190
scripts/health-sync.tsx
Normal file
|
|
@ -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<number | null> {
|
||||||
|
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()
|
||||||
|
|
@ -108,14 +108,14 @@ async fn health_check() -> &'static str {
|
||||||
"OK"
|
"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.
|
/// No auth — it's just a script, no sensitive data.
|
||||||
/// Served as inline text so Safari displays it for copy-paste into Scripting app.
|
/// Served as inline text so Safari displays it for copy-paste into Scripting app.
|
||||||
async fn serve_scriptable_script() -> impl axum::response::IntoResponse {
|
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"),
|
("content-disposition", "inline"),
|
||||||
],
|
],
|
||||||
script,
|
script,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue