|
// Sleep Cycles 2.0 — JS port of sleep_cycles_2.py, for running inside the iOS Shortcut's |
|
// JS-in-WKWebView action. Mirrors the Python function-for-function; see sleep_cycles_2.py |
|
// for the input schema and the documented divergences from sleep_cycles.py (vo2Max is a |
|
// single latest reading). Stand is derived from AppleStandTime minutes to reproduce |
|
// AppleStandHour exactly — see standHoursByDate below. Steps/Calories/Exercise restrict to |
|
// Watch-sourced samples once their "sources" column is present — see series below. |
|
// |
|
// Local-time methods (getHours, getDate, etc.) are used throughout, relying on the |
|
// runtime's configured timezone to match the offset embedded in the ISO timestamps — |
|
// true in production (same device writes and reads the data), but when testing in Node |
|
// the TZ env var must be pinned to match (e.g. `TZ=Etc/GMT-3 node sleep_cycles_2.js ...`). |
|
// |
|
// The four walking/gait metrics and oxygen_saturation are iPhone-sourced (not Watch) and |
|
// never carry a "sources" column, so they're never Watch-filtered. Percent-unit metrics |
|
// (oxygen_saturation, w_asymmetry, w_steadiness) arrive already scaled 0-100, not the 0-1 |
|
// fraction HealthKit's XML export uses for the same "%" unit. |
|
|
|
const MIN_REM_CYCLE_MIN = 3; // minimum total REM to count as a cycle boundary |
|
const MAX_AWAKE_MIN = 60; // awake stretches longer than this mark a session break |
|
|
|
// Output is accumulated here rather than printed directly, since the Shortcut's JS host |
|
// isn't guaranteed to provide a `console` global the way Node does. |
|
let __lines = []; |
|
function log(s = "") { __lines.push(s); } |
|
|
|
// Entry point for the Shortcut: pass the "Sleep Cycles 2.0" output object, get the report |
|
// text back. from/to/extended default to "every night present in the data" / non-extended, |
|
// matching the CLI's behavior when those flags are omitted. |
|
function generateReport(data, { from = null, to = null, extended = false } = {}) { |
|
__lines = []; |
|
const { recordsByDate, metrics } = loadRecords(data); |
|
|
|
let dates; |
|
if (from) { |
|
const dateTo = to || from; |
|
dates = Object.keys(recordsByDate).sort().filter(d => d >= from && d <= dateTo); |
|
} else { |
|
dates = Object.keys(recordsByDate).sort(); |
|
} |
|
|
|
const allCycleDurations = []; |
|
for (const date of dates) { |
|
const records = recordsByDate[date]; |
|
if (!records) { |
|
if (from) log(`No sleep data for ${date}`); |
|
continue; |
|
} |
|
const cycles = printCycles(date, records, metrics, extended); |
|
for (const c of cycles) allCycleDurations.push(c.dur); |
|
} |
|
|
|
if (allCycleDurations.length) { |
|
allCycleDurations.sort((a, b) => a - b); |
|
const n = allCycleDurations.length; |
|
const p90 = allCycleDurations[Math.min(Math.floor(n * 0.9), n - 1)]; |
|
const median = allCycleDurations[Math.floor(n / 2)]; |
|
const fmt = m => `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; |
|
log(`Cycle duration (${n} cycles): median ${fmt(median)}, P90 ${fmt(p90)}`); |
|
} |
|
|
|
return __lines.join("\n"); |
|
} |
|
|
|
function loadRecords(data) { |
|
const sleep = data.sleep || {}; |
|
const starts = splitLines(sleep.starts); |
|
const ends = splitLines(sleep.ends); |
|
const stages = splitLines(sleep.values); |
|
|
|
const byDate = new Map(); |
|
for (let i = 0; i < starts.length; i++) { |
|
const stage = stages[i]; |
|
if (stage === "In Bed" || stage === "InBed") continue; // bracketing record, not a sleep stage |
|
const s = parseDt(starts[i]); |
|
const e = parseDt(ends[i]); |
|
// Records ending before noon belong to that morning's night; |
|
// records ending at noon or later are evening/onset records for the next night. |
|
const night = e.getHours() >= 12 ? dateStr(addDays(e, 1)) : dateStr(e); |
|
if (!byDate.has(night)) byDate.set(night, []); |
|
byDate.get(night).push({ s, e, stage }); |
|
} |
|
for (const records of byDate.values()) records.sort((a, b) => a.s - b.s); |
|
|
|
const sleepWindows = new Map(); |
|
for (const [night, records] of byDate) sleepWindows.set(night, sleepWindow(records)); |
|
|
|
function overnightKey(dt) { |
|
return dt.getHours() >= 12 ? dateStr(addDays(dt, 1)) : dateStr(dt); |
|
} |
|
|
|
// HRV and Overnight HR are restricted to the actual sleep window (below); |
|
// daytime spot-checks keyed to the night by overnightKey are excluded. |
|
const hrvByDate = new Map(); |
|
const hrvSeriesByDate = new Map(); |
|
for (const { dt, value } of series(data.hrv)) { |
|
const key = overnightKey(dt); |
|
const window = sleepWindows.get(key); |
|
if (window && window.start <= dt && dt <= window.end) { |
|
mapPush(hrvByDate, key, value); |
|
mapPush(hrvSeriesByDate, key, { dt, value }); |
|
} |
|
} |
|
|
|
const respByDate = new Map(); |
|
for (const { dt, value } of series(data.rr)) { |
|
mapPush(respByDate, overnightKey(dt), { dt, value }); |
|
} |
|
|
|
const hrSeriesByDate = new Map(); |
|
for (const { dt, value } of series(data.hr)) { |
|
mapPush(hrSeriesByDate, overnightKey(dt), { dt, value }); |
|
} |
|
|
|
const spo2ByDate = new Map(); |
|
for (const { dt, value } of series(data.oxygen_saturation)) { |
|
mapPush(spo2ByDate, overnightKey(dt), { dt, value }); |
|
} |
|
|
|
// Unlike HRV/HR, wrist temperature only ever exists during actual detected sleep |
|
// (Apple's own algorithm restricts it) — no daytime spot-checks to filter out, so |
|
// no window-clipping is needed, just bucketing by night. |
|
const wristTempByDate = new Map(); |
|
for (const { dt, value } of series(data.wrist_temperature)) { |
|
mapPush(wristTempByDate, overnightKey(dt), value); |
|
} |
|
|
|
let vo2Series = []; |
|
const vo2 = data.vo2Max || {}; |
|
if (vo2.value != null && vo2.date) { |
|
const day = dateStr(parseVo2Date(vo2.date)); |
|
vo2Series = [{ day, value: Number(vo2.value) }]; |
|
} |
|
|
|
const metrics = { |
|
hrv: hrvByDate, |
|
hrvSeries: hrvSeriesByDate, |
|
sleepWindow: sleepWindows, |
|
resp: respByDate, |
|
hr: hrSeriesByDate, |
|
vo2: vo2Series, |
|
steps: dailySums(data.steps, { watchOnly: true }), |
|
energy: dailySums(data.calories, { watchOnly: true }), |
|
exercise: dailySums(data.exercise, { watchOnly: true }), |
|
stand: standHoursByDate(data.stand), |
|
standMinutes: dailySums(data.stand), |
|
spo2: spo2ByDate, |
|
wristTemp: wristTempByDate, |
|
walkAsymmetry: dailyAverage(data.w_asymmetry), |
|
walkSpeed: dailyAverage(data.w_speed), |
|
walkStepLength: dailyAverage(data.w_step_length), |
|
walkSteadiness: latestReadingSeries(data.w_steadiness), |
|
}; |
|
return { recordsByDate: mapToObject(byDate), metrics }; |
|
} |
|
|
|
function dailySums(block, opts) { |
|
const acc = new Map(); |
|
for (const { dt, value } of series(block, opts)) { |
|
const key = dateStr(dt); |
|
acc.set(key, (acc.get(key) || 0) + value); |
|
} |
|
return acc; |
|
} |
|
|
|
// Averages a columnar block's values per calendar day (gait metrics). |
|
function dailyAverage(block) { |
|
const acc = new Map(); |
|
for (const { dt, value } of series(block)) { |
|
const key = dateStr(dt); |
|
if (!acc.has(key)) acc.set(key, []); |
|
acc.get(key).push(value); |
|
} |
|
const out = new Map(); |
|
for (const [day, vals] of acc) out.set(day, vals.reduce((a, b) => a + b, 0) / vals.length); |
|
return out; |
|
} |
|
|
|
// Turns a {starts, values} block into sorted {day, value} readings, latest reading per day. |
|
function latestReadingSeries(block) { |
|
const byDay = new Map(); |
|
for (const { dt, value } of series(block)) byDay.set(dateStr(dt), value); |
|
return [...byDay.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) |
|
.map(([day, value]) => ({ day, value })); |
|
} |
|
|
|
// Derives AppleStandHour-equivalent stood-hour counts from AppleStandTime minutes: |
|
// an hour counts as "stood" if it has >=1 minute of standing — verified to reproduce |
|
// Apple's own AppleStandHour records exactly against a week of real data (0 mismatches |
|
// across 171 hourly buckets). |
|
function standHoursByDate(block) { |
|
const minutesByHour = new Map(); |
|
for (const { dt, value } of series(block)) { |
|
const hourKey = `${dateStr(dt)} ${String(dt.getHours()).padStart(2, "0")}`; |
|
minutesByHour.set(hourKey, (minutesByHour.get(hourKey) || 0) + value); |
|
} |
|
|
|
const acc = new Map(); |
|
for (const [hourKey, minutes] of minutesByHour) { |
|
if (minutes >= 1) { |
|
const day = hourKey.slice(0, 10); |
|
acc.set(day, (acc.get(day) || 0) + 1); |
|
} |
|
} |
|
return acc; |
|
} |
|
|
|
// watchOnly restricts to Watch-sourced samples, matching sleep_cycles.py's --source |
|
// filtering — but only once a "sources" column is actually present, so metrics that |
|
// haven't had it added yet (Calories/Exercise, for now) keep summing everything. |
|
function series(block, { watchOnly = false } = {}) { |
|
if (!block) return []; |
|
const starts = splitLines(block.starts); |
|
const values = splitLines(block.values); |
|
const hasSources = watchOnly && block.sources != null; |
|
const sources = hasSources ? splitLines(block.sources) : null; |
|
const out = []; |
|
for (let i = 0; i < starts.length; i++) { |
|
const s = starts[i], v = values[i]; |
|
if (!s || !v) continue; |
|
if (hasSources && !(sources[i] || "").includes("Watch")) continue; |
|
out.push({ dt: parseDt(s), value: Number(v) }); |
|
} |
|
return out; |
|
} |
|
|
|
function parseVo2Date(s) { |
|
// VO2 date arrives ISO (oracle) or localized ("15 Jun 2026 at 11:37", device). |
|
const iso = parseDt(s); |
|
if (!isNaN(iso)) return iso; |
|
const m = s.match(/^(\d{1,2}) (\w{3}) (\d{4}) at (\d{1,2}):(\d{2})$/); |
|
if (!m) throw new Error(`Unrecognized vo2Max date: ${s}`); |
|
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; |
|
const [, d, mon, y, h, min] = m; |
|
return new Date(Number(y), months.indexOf(mon), Number(d), Number(h), Number(min)); |
|
} |
|
|
|
function splitSessions(records) { |
|
// Split records into sessions at long Awake gaps; drop the Awake record itself. |
|
const sessions = []; |
|
let current = []; |
|
for (const r of records) { |
|
if (r.stage === "Awake" && (r.e - r.s) / 60000 > MAX_AWAKE_MIN) { |
|
if (current.length) sessions.push(current); |
|
current = []; |
|
} else { |
|
current.push(r); |
|
} |
|
} |
|
if (current.length) sessions.push(current); |
|
return sessions; |
|
} |
|
|
|
function sleepWindow(records) { |
|
// Return {start, end} of the main sleep session for a night's records. |
|
const sessions = splitSessions(records); |
|
const main = sessions[sessions.length - 1]; |
|
return { start: main[0].s, end: main[main.length - 1].e }; |
|
} |
|
|
|
function printCycles(date, records, metrics, extended = false) { |
|
const sessions = splitSessions(records); |
|
const naps = sessions.slice(0, -1); |
|
const mainRecords = sessions[sessions.length - 1]; |
|
|
|
log(`Sleep cycles for ${date}`); |
|
log(); |
|
|
|
const cycles = findCycles(mainRecords); |
|
const labels = cycles.map((_, i) => `Cycle ${i + 1}`); |
|
|
|
log(`${"".padEnd(8)} ${"Start".padStart(5)} ${"End".padStart(5)} ${"Duration".padStart(10)} Composition`); |
|
log("-".repeat(70)); |
|
for (const nap of naps) { |
|
const napStart = nap[0].s, napEnd = nap[nap.length - 1].e; |
|
const dur = Math.floor((napEnd - napStart) / 60000); |
|
const h = Math.floor(dur / 60), m = dur % 60; |
|
const durStr = h ? `${h}h ${String(m).padStart(2, "0")}m` : `${m}m`; |
|
log(`${"Nap".padEnd(8)} ${hm(napStart).padStart(5)} ${hm(napEnd).padStart(5)} ${durStr.padStart(10)} ${summarizeStages(nap)}`); |
|
} |
|
for (let i = 0; i < cycles.length; i++) { |
|
const c = cycles[i]; |
|
const composition = summarizeStages(c.records); |
|
const h = Math.floor(c.dur / 60), m = c.dur % 60; |
|
const durStr = h ? `${h}h ${String(m).padStart(2, "0")}m` : `${m}m`; |
|
log(`${labels[i].padEnd(8)} ${hm(c.start).padStart(5)} ${hm(c.end).padStart(5)} ${durStr.padStart(10)} ${composition}`); |
|
} |
|
|
|
const total = cycles.reduce((sum, c) => sum + c.dur, 0); |
|
const th = Math.floor(total / 60), tm = total % 60; |
|
log(`\n${"Total sleep".padStart(32)} ${th}h ${String(tm).padStart(2, "0")}m`); |
|
const sleepStart = mainRecords[0].s; |
|
const sleepEnd = mainRecords[mainRecords.length - 1].e; |
|
printTrendMetric("HRV", date, metrics.hrv, "ms", 0); |
|
if (extended) printSeriesPairs(date, metrics.hrvSeries, sleepStart, sleepEnd, 1, cycles, mainRecords, 4); |
|
printTrendMetric("Wrist temp", date, metrics.wristTemp, "°C", 1); |
|
printOvernightHrMetric(date, metrics.hr, metrics.sleepWindow); |
|
if (extended) printHrPerCycle(date, metrics.hr, cycles); |
|
printRangeSeries("Resp rate", "br/min", date, metrics.resp, sleepStart, sleepEnd, 1, extended); |
|
printRangeSeries("SpO2", "%", date, metrics.spo2, sleepStart, sleepEnd, 0, extended); |
|
printLatestMetric("VO2 max", "mL/(kg·min)", date, metrics.vo2, 1); |
|
printLatestMetric("Walking steadiness", "%", date, metrics.walkSteadiness, 1); |
|
printActivity(date, metrics); |
|
printGait(date, metrics); |
|
log(); |
|
return cycles; |
|
} |
|
|
|
function printTrendMetric(label, date, byDate, unit, decimals) { |
|
const values = byDate.get(date); |
|
if (!values || !values.length) { |
|
log(`${label.padStart(32)} (no data)`); |
|
return; |
|
} |
|
const sorted = [...values].sort((a, b) => a - b); |
|
const current = sorted[Math.floor(sorted.length / 2)]; |
|
const priorDates = [...byDate.keys()].filter(d => d < date).sort().slice(-5); |
|
const priorVals = priorDates.flatMap(d => byDate.get(d)).sort((a, b) => a - b); |
|
let trendStr = ""; |
|
if (priorVals.length) { |
|
const priorMedian = priorVals[Math.floor(priorVals.length / 2)]; |
|
const delta = decimals === 0 |
|
? Math.trunc(current) - Math.trunc(priorMedian) |
|
: round(current - priorMedian, decimals); |
|
const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "→"; |
|
trendStr = ` ${arrow}${Math.abs(delta).toFixed(decimals)} vs 5-night median (${priorMedian.toFixed(decimals)})`; |
|
} |
|
log(`${label.padStart(32)} ${current.toFixed(decimals)} ${unit}${trendStr}`); |
|
} |
|
|
|
// Prints the most recent reading at or before `date` (VO2 max, Walking steadiness) — both |
|
// update on their own slow cadence rather than nightly, so there's no trend to show. |
|
function printLatestMetric(label, unit, date, dayValuePairs, decimals = 1) { |
|
let latest = null; |
|
for (const { day, value } of dayValuePairs) { |
|
if (day <= date) latest = { day, value }; |
|
else break; |
|
} |
|
if (!latest) return; |
|
log(`${label.padStart(32)} ${latest.value.toFixed(decimals)} ${unit} as of ${latest.day}`); |
|
} |
|
|
|
function printOvernightHrMetric(date, hrSeriesByDate, sleepWindows) { |
|
const series = hrSeriesByDate.get(date); |
|
const window = sleepWindows.get(date); |
|
if (!series || !window) { |
|
log(`${"Overnight HR".padStart(32)} (no data)`); |
|
return; |
|
} |
|
const vals = series.filter(({ dt }) => window.start <= dt && dt <= window.end) |
|
.map(({ value }) => value).sort((a, b) => a - b); |
|
if (!vals.length) { |
|
log(`${"Overnight HR".padStart(32)} (no data)`); |
|
return; |
|
} |
|
const current = Math.trunc(vals[Math.floor(vals.length / 2)]); |
|
const priorDates = [...hrSeriesByDate.keys()] |
|
.filter(d => d < date && sleepWindows.has(d)).sort().slice(-5); |
|
const priorVals = []; |
|
for (const d of priorDates) { |
|
const w = sleepWindows.get(d); |
|
for (const { dt, value } of hrSeriesByDate.get(d)) { |
|
if (w.start <= dt && dt <= w.end) priorVals.push(value); |
|
} |
|
} |
|
priorVals.sort((a, b) => a - b); |
|
let trendStr = ""; |
|
if (priorVals.length) { |
|
const priorMedian = Math.trunc(priorVals[Math.floor(priorVals.length / 2)]); |
|
const delta = current - priorMedian; |
|
const arrow = delta > 0 ? "↑" : delta < 0 ? "↓" : "→"; |
|
trendStr = ` ${arrow}${Math.abs(delta)} vs 5-night median (${priorMedian})`; |
|
} |
|
log(`${"Overnight HR".padStart(32)} ${current} bpm${trendStr}`); |
|
} |
|
|
|
function printHrPerCycle(date, hrByDate, cycles) { |
|
const entries = hrByDate.get(date); |
|
if (!entries) return; |
|
entries.sort((a, b) => a.dt - b.dt); |
|
const stats = []; |
|
let globalMin = { min: Infinity, time: null, idx: -1 }; |
|
for (let idx = 0; idx < cycles.length; idx++) { |
|
const c = cycles[idx]; |
|
const inCycle = entries.filter(({ dt }) => c.start <= dt && dt <= c.end); |
|
if (!inCycle.length) { stats.push(null); continue; } |
|
const vals = [...inCycle].map(e => e.value).sort((a, b) => a - b); |
|
const cmin = vals[0], cmax = vals[vals.length - 1]; |
|
const cmed = vals[Math.floor(vals.length / 2)]; |
|
const minTime = inCycle.find(e => e.value === cmin).dt; |
|
stats.push({ cstart: c.start, cend: c.end, cmin, cmax, cmed, minTime }); |
|
if (cmin < globalMin.min) globalMin = { min: cmin, time: minTime, idx }; |
|
} |
|
stats.forEach((stat, idx) => { |
|
if (!stat) return; |
|
const { cstart, cend, cmin, cmax, cmed, minTime } = stat; |
|
const annot = idx === globalMin.idx |
|
? ` ← min ${Math.trunc(cmin)} at ${hm(minTime)}` : ""; |
|
log(` Cycle ${idx + 1} (${hm(cstart)}–${hm(cend)}): min ${Math.trunc(cmin)}, max ${Math.trunc(cmax)}, med ${Math.trunc(cmed)} bpm${annot}`); |
|
}); |
|
} |
|
|
|
function printSeriesPairs(date, seriesByDate, sleepStart, sleepEnd, decimals = 1, cycles = null, records = null, perLine = 7) { |
|
let entries = seriesByDate.get(date); |
|
if (!entries) return; |
|
entries = entries.filter(({ dt }) => sleepStart <= dt && dt <= sleepEnd) |
|
.sort((a, b) => a.dt - b.dt); |
|
if (!entries.length) return; |
|
const pairs = entries.map(({ dt, value }) => { |
|
let s = `${hm(dt)} ${value.toFixed(decimals).padStart(4)}`; |
|
if (cycles !== null && records !== null) s += ` (${cycleStageAt(dt, cycles, records)})`; |
|
return s; |
|
}); |
|
for (let i = 0; i < pairs.length; i += perLine) { |
|
log(" " + pairs.slice(i, i + perLine).join(" ")); |
|
} |
|
} |
|
|
|
function cycleStageAt(t, cycles, records) { |
|
const ci = cycles.findIndex(c => c.start <= t && t <= c.end); |
|
const cycle = ci >= 0 ? `C${ci + 1}` : "?"; |
|
const rec = records.find(r => r.s <= t && t <= r.e); |
|
const stage = rec ? rec.stage : "?"; |
|
return `${cycle}-${stage}`; |
|
} |
|
|
|
function printRangeSeries(label, unit, date, seriesByDate, sleepStart, sleepEnd, decimals = 1, extended = false) { |
|
let entries = seriesByDate.get(date); |
|
if (entries) entries = entries.filter(({ dt }) => sleepStart <= dt && dt <= sleepEnd); |
|
if (!entries || !entries.length) { |
|
log(`${label.padStart(32)} (no data)`); |
|
return; |
|
} |
|
const values = entries.map(e => e.value); |
|
const vmin = Math.min(...values), vmax = Math.max(...values); |
|
log(`${label.padStart(32)} ${vmin.toFixed(decimals)}–${vmax.toFixed(decimals)} ${unit}, ${entries.length} readings`); |
|
if (extended) printSeriesPairs(date, seriesByDate, sleepStart, sleepEnd, decimals); |
|
} |
|
|
|
function printActivity(date, metrics) { |
|
const prevDay = dateStr(addDays(parseDateOnly(date), -1)); |
|
const energy = metrics.energy.get(prevDay); |
|
const exercise = metrics.exercise.get(prevDay); |
|
const stand = metrics.stand.get(prevDay); |
|
const standMin = metrics.standMinutes.get(prevDay); |
|
const steps = metrics.steps.get(prevDay); |
|
if (energy == null && exercise == null && stand == null && steps == null) { |
|
log(`${"Activity".padStart(32)} (no data)`); |
|
return; |
|
} |
|
const parts = []; |
|
if (energy != null) parts.push(`Move ${Math.trunc(energy)} kcal`); |
|
if (exercise != null) parts.push(`Exercise ${Math.trunc(exercise)} min`); |
|
if (stand != null) { |
|
parts.push(standMin != null |
|
? `Stand ${Math.trunc(standMin)} min across ${Math.trunc(stand)} h` |
|
: `Stand ${Math.trunc(stand)} h`); |
|
} |
|
if (steps != null) parts.push(`Steps ${Math.trunc(steps).toLocaleString("en-US")}`); |
|
log(`${"Activity".padStart(32)} ${parts.join(" · ")}`); |
|
} |
|
|
|
function printGait(date, metrics) { |
|
const prevDay = dateStr(addDays(parseDateOnly(date), -1)); |
|
const speed = metrics.walkSpeed.get(prevDay); |
|
const stepLen = metrics.walkStepLength.get(prevDay); |
|
const asymmetry = metrics.walkAsymmetry.get(prevDay); |
|
if (speed == null && stepLen == null && asymmetry == null) { |
|
log(`${"Gait".padStart(32)} (no data)`); |
|
return; |
|
} |
|
const parts = []; |
|
if (speed != null) parts.push(`Speed ${speed.toFixed(1)} km/h`); |
|
if (stepLen != null) parts.push(`Step length ${Math.trunc(stepLen)} cm`); |
|
if (asymmetry != null) parts.push(`Asymmetry ${Math.trunc(asymmetry)}%`); |
|
log(`${"Gait".padStart(32)} ${parts.join(" · ")}`); |
|
} |
|
|
|
function findCycles(records) { |
|
// Split records into cycles using REM blocks as cycle boundaries. |
|
const cycles = []; |
|
let currentStart = records.length ? records[0].s : null; |
|
|
|
let i = 0; |
|
while (i < records.length) { |
|
const { s, e, stage } = records[i]; |
|
if (stage === "REM") { |
|
// Merge consecutive REM blocks (possibly separated by tiny Core/Awake) |
|
let remEnd = e; |
|
let remTotal = (e - s) / 60000; |
|
let j = i + 1; |
|
while (j < records.length) { |
|
const { s: ns, e: ne, stage: nstage } = records[j]; |
|
const gap = (ns - remEnd) / 60000; |
|
if (gap <= 5 && (nstage === "REM" || nstage === "Core" || nstage === "Awake")) { |
|
if (nstage === "REM") { |
|
remEnd = ne; |
|
remTotal += (ne - ns) / 60000; |
|
} |
|
j += 1; |
|
} else { |
|
break; |
|
} |
|
} |
|
|
|
if (remTotal < MIN_REM_CYCLE_MIN) { |
|
i = j; |
|
continue; |
|
} |
|
|
|
// Collect all records up to and including this REM block |
|
const cycleRecords = records.filter(r => currentStart <= r.s && r.s < remEnd); |
|
const dur = Math.floor((remEnd - currentStart) / 60000); |
|
cycles.push({ start: currentStart, end: remEnd, dur, records: cycleRecords }); |
|
// Find the next record starting at or after remEnd (don't skip |
|
// records that the merge loop may have advanced j past). |
|
let k = i + 1; |
|
while (k < records.length && records[k].s < remEnd) k++; |
|
j = k < records.length ? k : records.length; |
|
currentStart = j < records.length ? records[j].s : null; |
|
i = j; |
|
} else { |
|
i += 1; |
|
} |
|
} |
|
|
|
// Tail: remaining records after last REM |
|
if (currentStart !== null) { |
|
const tail = records.filter(r => r.s >= currentStart); |
|
if (tail.length) { |
|
const tailEnd = tail[tail.length - 1].e; |
|
const dur = Math.floor((tailEnd - currentStart) / 60000); |
|
cycles.push({ start: currentStart, end: tailEnd, dur, records: tail }); |
|
} |
|
} |
|
|
|
return cycles; |
|
} |
|
|
|
function summarizeStages(cycleRecords) { |
|
// Resolve overlaps: when records overlap, the later-starting one takes precedence. |
|
// Build a list of non-overlapping segments by clipping earlier records. |
|
const sorted = [...cycleRecords].sort((a, b) => a.s - b.s || a.e - b.e); |
|
const segments = []; |
|
for (const r of sorted) { |
|
if (segments.length && r.s < segments[segments.length - 1].e) { |
|
const prev = segments[segments.length - 1]; |
|
segments[segments.length - 1] = { s: prev.s, e: r.s, stage: prev.stage }; |
|
} |
|
segments.push({ s: r.s, e: r.e, stage: r.stage }); |
|
} |
|
|
|
const totals = {}; |
|
for (const { s, e, stage } of segments) { |
|
const dur = (e - s) / 60000; |
|
if (dur >= 1) totals[stage] = (totals[stage] || 0) + dur; |
|
} |
|
|
|
const parts = []; |
|
for (const stage of ["Deep", "Core", "REM", "Awake"]) { |
|
if (stage in totals) parts.push(`${stage} ${Math.trunc(totals[stage])}m`); |
|
} |
|
return parts.join(" → "); |
|
} |
|
|
|
// --- small helpers --- |
|
|
|
function parseDt(s) { |
|
return new Date(s); |
|
} |
|
|
|
function splitLines(s) { |
|
return s ? s.split("\n") : []; |
|
} |
|
|
|
function dateStr(d) { |
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; |
|
} |
|
|
|
function parseDateOnly(s) { |
|
const [y, m, d] = s.split("-").map(Number); |
|
return new Date(y, m - 1, d); |
|
} |
|
|
|
function addDays(d, n) { |
|
return new Date(d.getTime() + n * 86400000); |
|
} |
|
|
|
function hm(d) { |
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; |
|
} |
|
|
|
function round(x, decimals) { |
|
const f = 10 ** decimals; |
|
return Math.round(x * f) / f; |
|
} |
|
|
|
function mapPush(map, key, value) { |
|
if (!map.has(key)) map.set(key, []); |
|
map.get(key).push(value); |
|
} |
|
|
|
function mapToObject(map) { |
|
const obj = {}; |
|
for (const [k, v] of map) obj[k] = v; |
|
return obj; |
|
} |
|
|
|
// Node CLI entry, for local testing only — not present/used when this file's contents |
|
// are pasted into the Shortcut's JS action, which calls generateReport(data) directly. |
|
if (typeof require !== "undefined" && typeof module !== "undefined" && require.main === module) { |
|
const argv = process.argv.slice(2); |
|
let jsonPath = null, dateFrom = null, dateTo = null, extended = false; |
|
for (let i = 0; i < argv.length; i++) { |
|
const a = argv[i]; |
|
if (a === "--from") dateFrom = argv[++i]; |
|
else if (a === "--to") dateTo = argv[++i]; |
|
else if (a === "--extended") extended = true; |
|
else jsonPath = a; |
|
} |
|
const text = jsonPath && jsonPath !== "-" |
|
? require("fs").readFileSync(jsonPath, "utf8") |
|
: require("fs").readFileSync(0, "utf8"); |
|
const data = JSON.parse(text); |
|
console.log(generateReport(data, { from: dateFrom, to: dateTo, extended })); |
|
} |