Skip to content

Commit 06c3b05

Browse files
authored
Merge pull request #11 from OpenStrap/perf/drop-r2-raw
feat+perf: workout HAR engine & honest metrics, on the drop-raw-R2 ingest (1 write/POST)
2 parents d9eb8c1 + 051f1e3 commit 06c3b05

13 files changed

Lines changed: 375 additions & 584 deletions

src/analytics.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
stageHypnogram,
1212
calcSleepRegularity, detectSessions, calcLoad, calcFitnessTrend,
1313
calcVo2Max, calcFitnessModel, calcMonotony,
14-
calcAnomaly, calcBaselines, buildCoach,
14+
calcAnomaly, calcBaselines, buildCoach, calcCycle, calcRestlessness,
1515
calcSleepStress, calcNocturnalHeart, buildNotifications,
1616
type Minute, type Profile, type Baseline, type DayHistory,
1717
type DailyStrain, type NightSummary, type SleepValue, type Metric, type Driver,
@@ -33,6 +33,7 @@ interface MinuteRow {
3333
activity: number | null
3434
steps: number | null
3535
wrist_on: number | null
36+
act_class?: string | null
3637
}
3738

3839
const toMinute = (r: MinuteRow): Minute => ({
@@ -44,6 +45,7 @@ const toMinute = (r: MinuteRow): Minute => ({
4445
activity: r.activity ?? 0,
4546
steps: r.steps ?? 0,
4647
wrist_on: !!r.wrist_on,
48+
...(r.act_class ? { act_class: r.act_class as Minute['act_class'] } : {}),
4749
})
4850

4951
// Build a per-metric flag entry from any Metric<T>.
@@ -321,6 +323,18 @@ export async function processUser(
321323
}
322324
}
323325

326+
// Cycle-phase context (gated on track_cycle) so a luteal RHR/temp rise doesn't
327+
// false-fire the anomaly signal. Loaded once; phase computed per-day below.
328+
let cycleStarts: string[] = []
329+
const trackRow = await db.prepare('SELECT track_cycle FROM users WHERE id = ?')
330+
.bind(userId).first<{ track_cycle: number | null }>()
331+
if (trackRow?.track_cycle) {
332+
const { results } = await db.prepare(
333+
"SELECT date FROM cycle_log WHERE user_id = ? AND kind = 'start' ORDER BY date",
334+
).bind(userId).all<{ date: string }>()
335+
cycleStarts = (results ?? []).map((r) => r.date)
336+
}
337+
324338
for (let dayStart = firstDayStart; dayStart <= lastDayStart; dayStart += DAY) {
325339
const date = dayKey(dayStart)
326340
const dayMin = dayMinutes(dayStart)
@@ -376,19 +390,35 @@ export async function processUser(
376390
// user-owned and must survive a re-derive. Deleted tombstones are KEPT so a
377391
// user-deleted auto session isn't resurrected (its row's status stays
378392
// 'deleted'; the re-insert below only updates non-status fields via ON CONFLICT).
393+
// ALSO preserve any auto session the user CONFIRMED/CORRECTED (the calibration
394+
// ledger): exclude it from the DELETE and skip an overlapping re-detection so a
395+
// baseline-drift start_ts shift can't insert a duplicate that re-overwrites the
396+
// user's label. (Without this, every re-derive wiped user corrections.)
397+
const { results: keepRev } = await db.prepare(
398+
"SELECT start_ts, end_ts FROM sessions WHERE user_id = ? AND start_ts < ? AND end_ts > ? AND status != 'deleted' AND type_source IN ('confirmed','corrected')",
399+
).bind(userId, dayStart + DAY, dayStart).all<{ start_ts: number; end_ts: number }>()
400+
const reviewed = (keepRev ?? []) as { start_ts: number; end_ts: number }[]
401+
const overlapsReviewed = (s: { start_ts: number; end_ts: number }) =>
402+
reviewed.some((k) => s.start_ts < k.end_ts && s.end_ts > k.start_ts)
379403
statements.push(
380-
db.prepare("DELETE FROM sessions WHERE user_id = ? AND start_ts >= ? AND start_ts < ? AND (source IS NULL OR source = 'auto') AND status != 'deleted'")
404+
db.prepare("DELETE FROM sessions WHERE user_id = ? AND start_ts >= ? AND start_ts < ? AND (source IS NULL OR source = 'auto') AND COALESCE(type_source,'model') NOT IN ('confirmed','corrected') AND status != 'deleted'")
381405
.bind(userId, dayStart, dayStart + DAY),
382406
)
383407
for (const s of sessions) {
408+
if (overlapsReviewed(s)) continue
384409
const sid = `${userId}:${s.start_ts}`
385410
statements.push(db.prepare(
386-
'INSERT INTO sessions (user_id, id, start_ts, end_ts, type, avg_hr, max_hr, strain, calories, hrr60, zones, confidence, status, source) ' +
387-
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,'done','auto') ON CONFLICT(user_id, id) DO UPDATE SET " +
388-
'end_ts=excluded.end_ts, type=excluded.type, avg_hr=excluded.avg_hr, max_hr=excluded.max_hr, ' +
389-
'strain=excluded.strain, calories=excluded.calories, hrr60=excluded.hrr60, zones=excluded.zones, confidence=excluded.confidence',
411+
'INSERT INTO sessions (user_id, id, start_ts, end_ts, type, avg_hr, max_hr, strain, calories, hrr60, zones, confidence, status, source, segments, detected_type, type_confidence, type_source) ' +
412+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,'done','auto',?,?,?,'model') ON CONFLICT(user_id, id) DO UPDATE SET " +
413+
'end_ts=excluded.end_ts, avg_hr=excluded.avg_hr, max_hr=excluded.max_hr, ' +
414+
'strain=excluded.strain, calories=excluded.calories, hrr60=excluded.hrr60, zones=excluded.zones, confidence=excluded.confidence, ' +
415+
'segments=excluded.segments, detected_type=excluded.detected_type, ' +
416+
// Preserve a user-confirmed/corrected type across re-derivation; only the model fields refresh.
417+
'type_confidence=CASE WHEN sessions.type_source IN (\'confirmed\',\'corrected\') THEN sessions.type_confidence ELSE excluded.type_confidence END, ' +
418+
'type=CASE WHEN sessions.type_source IN (\'confirmed\',\'corrected\') THEN sessions.type ELSE excluded.type END',
390419
).bind(userId, sid, s.start_ts, s.end_ts, s.type, Math.round(s.avg_hr), Math.round(s.max_hr),
391-
s.strain, s.kcal, s.hrr60 == null ? null : Math.round(s.hrr60), JSON.stringify(s.zones), s.confidence))
420+
s.strain, s.kcal, s.hrr60 == null ? null : Math.round(s.hrr60), JSON.stringify(s.zones), s.confidence,
421+
s.segments ? JSON.stringify(s.segments) : null, s.detected_type ?? s.type, s.type_confidence))
392422
}
393423

394424
// -- Wear time (worn minutes). --
@@ -409,6 +439,9 @@ export async function processUser(
409439
// -- Sleep stress / nocturnal arousal (HR surges + motion in sleep; NOT HRV;
410440
// the HRV-based daytime stress is computed in biometrics.ts from RR). --
411441
const sleepStress = calcSleepStress(sleepWorn, baseline)
442+
// -- Restlessness (movement fragmentation; distinct from arousal). Folded into the
443+
// sleep_stress JSON so no new column / upsert change is needed. --
444+
const restlessness = calcRestlessness(sleepWorn)
412445

413446
// -- Main-metric drivers (the cross-metric "what affected this" graph). --
414447
const mainDrivers: Record<string, Driver[]> = {
@@ -471,7 +504,7 @@ export async function processUser(
471504
// idx points into the parallel arrays, which now START with `seedLen`
472505
// seeded history days — so trailing slices in Pass 3 reach into real history.
473506
date, dayStart, idx: seedLen + dayBuffer.length, rhr, strain, zones, calories, sleep, wearMin, daySteps,
474-
sleepStress: JSON.stringify(sleepStress),
507+
sleepStress: JSON.stringify({ ...sleepStress, restlessness }),
475508
nocturnal: JSON.stringify(nocturnal),
476509
sleepingHr: nocturnal.sleeping_hr_avg,
477510
nocturnalElevated: nocturnal.elevated,
@@ -523,12 +556,13 @@ export async function processUser(
523556
// Readiness + HRV CV/irregular are owned by biometrics.ts (HRV is fresh there).
524557
const nocDip = (() => { try { return JSON.parse(nocturnal)?.dip_pct ?? null } catch { return null } })()
525558

559+
const cyclePhase = cycleStarts.length ? calcCycle(cycleStarts, date).phase : null
526560
const anomaly = calcAnomaly({
527561
recent_rhr: recentRhr,
528562
skin_temp: baseline.skin_temp ?? null,
529563
sleep_efficiency: sleep.efficiency != null ? sleep.efficiency : null, // keep a real 0% (not coerced to "no data")
530564
baseline_sleep_efficiency: null,
531-
}, baseline)
565+
}, baseline, { cyclePhase })
532566

533567
const flags = JSON.stringify({
534568
strain: flag(strain, 'Strain'),

0 commit comments

Comments
 (0)