Back to blog
FILE 0x25·PER-DAY-OF-WEEK SLEEP WINDOWS FOR A SAFETY APP

Per-day-of-week sleep windows for a safety app

April 25, 2026 · aws, lambda, anomaly-detection, patterns

A safety app I run was firing "missed check-in" pre-alerts at 7am on Saturdays. I don't get up at 7am on Saturdays. The fix was moving from one global sleep window per user to a per-day-of-week window, learned from real activity.

What was happening

The first version of the sleep-window learner produced a single sleep_start_hour and sleep_end_hour per user. Mine were 00:00–07:00 — biased by the densest stretch of low activity in my data. That worked Monday through Friday. On Saturday and Sunday, when I actually wake up around 09:15, the cron treated 07:00 to 09:15 as "should be active" and the inactivity threshold tripped.

What I found

Walking my 90-day activity table by ISO weekday gave very different medians per day:

Mon 06:58 (n=13)   Tue 06:57 (n=13)   Wed 06:59 (n=13)
Thu 06:55 (n=13)   Fri 06:56 (n=13)
Sat 09:19 (n=12)   Sun 09:08 (n=13)
Global median 06:59 (n=90)

A single number was always going to be wrong for some days.

The fix

The new learner walks the user's activity chronologically over 90 days. For each calendar date, the "wake event" is the first activity that follows a quiet gap of at least 3 hours. (Naive "first activity of day" counted 2am phone glances as wake; gap-anchored is much better.) Group those wake events by ISO weekday, require at least 3 samples, take the median per bucket.

Median, not mean — one anomalous early train ride doesn't drag the whole Saturday bucket two hours earlier.

function learnWakeTimesByDow($db, $userId) {
    $events = $db->queryAll('checkonmine_activities', [
        'KeyConditionExpression' =>
            'user_id = :u AND #ts >= :cutoff',
        // 90 days back
    ]);

    $wakes = [];
    $prev = null;
    foreach ($events as $e) {
        $ts = strtotime($e['timestamp']);
        if ($prev !== null && ($ts - $prev) >= 3 * 3600) {
            $dow = (int)date('N', $ts); // 1=Mon..7=Sun
            $wakes[$dow][] = ($ts - strtotime('today', $ts));
        }
        $prev = $ts;
    }

    $perDow = [];
    foreach ($wakes as $dow => $seconds) {
        if (count($seconds) < 3) continue;
        sort($seconds);
        $perDow[$dow] = $seconds[(int)(count($seconds) / 2)];
    }
    return $perDow;
}

The threshold check at cron time now reads getEffectiveSleepEndHour($settings) which resolves per-DOW → global learned → static user setting. ceil() is used so the window covers up through the typical wake (learned 09:19 → effective 10).

The symmetric bed-time learner was a follow-up. Same chronological walk, but it captures the last activity before each 3-hour quiet gap as a "bed event," bucketed by the bed event's own ISO weekday. One twist for early-morning beds: bed times before 06:00 get +24h added before the median, so a 01:30 bed clusters with 23:45 around 24:30 instead of being split by midnight.

What I'd do differently

The static global learner had been quietly wrong for months. It worked "well enough" because I'm a night owl and my densest low-activity zone happened to be 00:00–07:00 — close to my actual weekday window by coincidence. The Saturday alerts were the only visible symptom.

Anytime a model collapses naturally multidimensional data into a single number, write down what assumption you're betting on. Mine was "this person has the same sleep schedule every day," which is true for almost no one.