@@ -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
3839const 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