refactor: fix active hours calculation with monotonic cycle model

Previous implementation had discontinuities at day boundaries. New
cycle-based approach (2 AM sleep start) provides continuous, testable
active hour tracking.
This commit is contained in:
2026-01-30 17:41:07 -06:00
parent 4ee5c673ba
commit dbe4804bc5
4 changed files with 376 additions and 29 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"devDependencies": {
"chalk": "^5.6.2",
},
},
},
"packages": {
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
}
}
+63 -29
View File
@@ -13,6 +13,12 @@ import chalk from 'chalk';
const CLAUDE_CODE_PATH = join(homedir(), '.claude', '.credentials.json'); const CLAUDE_CODE_PATH = join(homedir(), '.claude', '.credentials.json');
const OPENCODE_PATH = join(homedir(), '.local', 'share', 'opencode', 'auth.json'); const OPENCODE_PATH = join(homedir(), '.local', 'share', 'opencode', 'auth.json');
// Active hours model: 9 AM to 2 AM active, 2 AM to 9 AM sleep
export const SLEEP_START_HOUR = 2; // Sleep begins at 2:00 AM
export const WAKE_HOUR = 9; // Active period begins at 9:00 AM
export const SLEEP_HOURS_PER_DAY = 7; // 2 AM to 9 AM
export const ACTIVE_HOURS_PER_DAY = 17; // 9 AM to 2 AM
// Parse CLI flags // Parse CLI flags
const args = process.argv.slice(2); const args = process.argv.slice(2);
const VERBOSE = args.includes('-v') || args.includes('--verbose') || args.includes('-d') || args.includes('--debug'); const VERBOSE = args.includes('-v') || args.includes('--verbose') || args.includes('-d') || args.includes('--debug');
@@ -432,45 +438,73 @@ function calculate5HourPace(utilization: number, resetTimestamp: number): PaceRe
} }
/** /**
* Calculate elapsed active hours for 7-day period * Compute the offset (in fractional hours) into the current day-cycle.
* Active window: 10am-2am (14 hours per day) * A "cycle" runs from SLEEP_START_HOUR (2 AM) to the next SLEEP_START_HOUR.
* 2am is the LAST active hour (2:00-2:59am is active) * Offset 0 = exactly 2:00 AM, offset 7 = 9:00 AM, offset 24 = next 2:00 AM.
*/ */
function calculateElapsedActiveHours(now: Date, reset: Date): number { export function offsetInCycle(t: Date): number {
const totalPeriodSeconds = 7 * 24 * 3600; const hours = t.getHours() + t.getMinutes() / 60 + t.getSeconds() / 3600;
const remainingSeconds = (reset.getTime() - now.getTime()) / 1000; if (hours >= SLEEP_START_HOUR) return hours - SLEEP_START_HOUR;
const elapsedSeconds = totalPeriodSeconds - remainingSeconds; return hours + (24 - SLEEP_START_HOUR);
const completeDays = Math.floor(elapsedSeconds / (24 * 3600));
const currentHour = now.getHours();
const currentMinute = now.getMinutes();
let activeHoursToday = 0;
if (currentHour >= 10 && currentHour <= 23) {
activeHoursToday = (currentHour - 10) + (currentMinute / 60);
} else if (currentHour >= 0 && currentHour <= 2) {
const hoursAfterMidnight = currentHour + (currentMinute / 60);
activeHoursToday = 14 + hoursAfterMidnight;
}
const totalElapsedActiveHours = (completeDays * 14) + Math.min(activeHoursToday, 14);
return totalElapsedActiveHours;
} }
/** /**
* Calculate pace for 7-day period (accounting for active hours) * Compute the start of the current day-cycle (the most recent 2:00 AM).
*/
export function getCycleStart(t: Date): Date {
const result = new Date(t);
if (t.getHours() >= SLEEP_START_HOUR) {
result.setHours(SLEEP_START_HOUR, 0, 0, 0);
} else {
result.setDate(result.getDate() - 1);
result.setHours(SLEEP_START_HOUR, 0, 0, 0);
}
return result;
}
/**
* Compute active hours within a partial cycle given the offset from cycle start.
* Sleep occupies offsets [0, SLEEP_HOURS_PER_DAY), active occupies [SLEEP_HOURS_PER_DAY, 24).
* Returns a value in [0, ACTIVE_HOURS_PER_DAY].
*/
export function activeHoursInPartialCycle(offsetHours: number): number {
if (offsetHours <= SLEEP_HOURS_PER_DAY) return 0;
return Math.min(offsetHours - SLEEP_HOURS_PER_DAY, ACTIVE_HOURS_PER_DAY);
}
/**
* Compute total elapsed active hours between two timestamps.
* Uses a cycle-based model (2 AM to 2 AM) that is continuous and monotonically
* increasing. During sleep hours (2 AM - 9 AM), the value is flat.
* During active hours (9 AM - 2 AM), it increases linearly.
*/
export function elapsedActiveHoursBetween(start: Date, end: Date): number {
const startCycleStart = getCycleStart(start);
const endCycleStart = getCycleStart(end);
const completeCycles = Math.round(
(endCycleStart.getTime() - startCycleStart.getTime()) / 86_400_000
);
const startActiveInCycle = activeHoursInPartialCycle(offsetInCycle(start));
const endActiveInCycle = activeHoursInPartialCycle(offsetInCycle(end));
return completeCycles * ACTIVE_HOURS_PER_DAY + endActiveInCycle - startActiveInCycle;
}
/**
* Calculate pace for 7-day period (accounting for active hours).
* Uses cycle-based model: 9 AM to 2 AM active, 2 AM to 9 AM sleep.
*/ */
function calculate7DayPace(utilization: number, resetTimestamp: number): PaceResult { function calculate7DayPace(utilization: number, resetTimestamp: number): PaceResult {
const now = new Date(); const now = new Date();
const resetDate = new Date(resetTimestamp * 1000); const resetDate = new Date(resetTimestamp * 1000);
const periodStart = new Date(resetDate.getTime() - 7 * 24 * 3600 * 1000);
const totalActiveHours = 7 * 14; const totalActiveHours = 7 * ACTIVE_HOURS_PER_DAY;
const elapsedActiveHours = calculateElapsedActiveHours(now, resetDate); const elapsed = elapsedActiveHoursBetween(periodStart, now);
const expectedUtil = elapsedActiveHours / totalActiveHours; const expectedUtil = elapsed / totalActiveHours;
const diff = (utilization - expectedUtil) * 100; const diff = (utilization - expectedUtil) * 100;
return { return {
+6
View File
@@ -0,0 +1,6 @@
{
"dependencies": {},
"devDependencies": {
"chalk": "^5.6.2"
}
}
+293
View File
@@ -0,0 +1,293 @@
import { describe, test, expect } from "bun:test";
import {
SLEEP_START_HOUR,
WAKE_HOUR,
SLEEP_HOURS_PER_DAY,
ACTIVE_HOURS_PER_DAY,
offsetInCycle,
getCycleStart,
activeHoursInPartialCycle,
elapsedActiveHoursBetween,
} from "../home/dot_local/bin/executable_claude-usage";
/** Helper: create a Date for a specific day/hour/minute */
function makeDate(
year: number,
month: number,
day: number,
hour: number,
minute: number = 0,
second: number = 0
): Date {
return new Date(year, month - 1, day, hour, minute, second);
}
describe("constants", () => {
test("active + sleep = 24 hours", () => {
expect(ACTIVE_HOURS_PER_DAY + SLEEP_HOURS_PER_DAY).toBe(24);
});
test("wake hour = sleep start + sleep duration", () => {
expect(WAKE_HOUR).toBe(SLEEP_START_HOUR + SLEEP_HOURS_PER_DAY);
});
});
describe("offsetInCycle", () => {
test("2:00 AM is offset 0", () => {
expect(offsetInCycle(makeDate(2026, 1, 15, 2, 0))).toBe(0);
});
test("9:00 AM is offset 7 (sleep duration)", () => {
expect(offsetInCycle(makeDate(2026, 1, 15, 9, 0))).toBe(SLEEP_HOURS_PER_DAY);
});
test("midnight is offset 22", () => {
expect(offsetInCycle(makeDate(2026, 1, 15, 0, 0))).toBe(22);
});
test("1:59 AM is offset 23 + 59/60", () => {
const offset = offsetInCycle(makeDate(2026, 1, 15, 1, 59));
expect(offset).toBeCloseTo(23 + 59 / 60, 10);
});
test("offset is monotonically increasing from 2 AM through to next 1:59 AM", () => {
let prev = -1;
// Walk minute by minute from 2:00 AM to 1:59 AM next day
for (let minuteOffset = 0; minuteOffset < 24 * 60; minuteOffset++) {
const d = new Date(makeDate(2026, 1, 15, 2, 0).getTime() + minuteOffset * 60_000);
const offset = offsetInCycle(d);
expect(offset).toBeGreaterThan(prev);
prev = offset;
}
});
});
describe("getCycleStart", () => {
test("at 3 AM, cycle start is 2 AM same day", () => {
const result = getCycleStart(makeDate(2026, 1, 15, 3, 0));
expect(result.getHours()).toBe(2);
expect(result.getDate()).toBe(15);
});
test("at 1 AM, cycle start is 2 AM previous day", () => {
const result = getCycleStart(makeDate(2026, 1, 15, 1, 0));
expect(result.getHours()).toBe(2);
expect(result.getDate()).toBe(14);
});
test("at exactly 2 AM, cycle start is 2 AM same day", () => {
const result = getCycleStart(makeDate(2026, 1, 15, 2, 0));
expect(result.getHours()).toBe(2);
expect(result.getDate()).toBe(15);
});
});
describe("activeHoursInPartialCycle", () => {
test("offset 0 (sleep start) returns 0", () => {
expect(activeHoursInPartialCycle(0)).toBe(0);
});
test("offset during sleep returns 0", () => {
expect(activeHoursInPartialCycle(3)).toBe(0);
expect(activeHoursInPartialCycle(6.99)).toBe(0);
});
test("offset at sleep boundary returns 0", () => {
expect(activeHoursInPartialCycle(SLEEP_HOURS_PER_DAY)).toBe(0);
});
test("offset just after wake returns small positive", () => {
const result = activeHoursInPartialCycle(SLEEP_HOURS_PER_DAY + 0.01);
expect(result).toBeCloseTo(0.01, 10);
});
test("offset at end of cycle returns ACTIVE_HOURS_PER_DAY", () => {
expect(activeHoursInPartialCycle(24)).toBe(ACTIVE_HOURS_PER_DAY);
});
test("mid-active returns correct value", () => {
// offset 15 = 8 hours into active period
expect(activeHoursInPartialCycle(15)).toBe(15 - SLEEP_HOURS_PER_DAY);
});
});
describe("elapsedActiveHoursBetween", () => {
test("same timestamp returns 0", () => {
const t = makeDate(2026, 1, 15, 12, 0);
expect(elapsedActiveHoursBetween(t, t)).toBe(0);
});
test("1 hour during active period returns 1", () => {
const start = makeDate(2026, 1, 15, 12, 0);
const end = makeDate(2026, 1, 15, 13, 0);
expect(elapsedActiveHoursBetween(start, end)).toBeCloseTo(1, 10);
});
test("full sleep period returns 0 active hours", () => {
const start = makeDate(2026, 1, 15, 2, 0);
const end = makeDate(2026, 1, 15, 9, 0);
expect(elapsedActiveHoursBetween(start, end)).toBeCloseTo(0, 10);
});
test("full active period (9 AM to 2 AM) returns 17 hours", () => {
const start = makeDate(2026, 1, 15, 9, 0);
const end = makeDate(2026, 1, 16, 2, 0);
expect(elapsedActiveHoursBetween(start, end)).toBeCloseTo(ACTIVE_HOURS_PER_DAY, 10);
});
test("full 24-hour cycle returns 17 active hours", () => {
const start = makeDate(2026, 1, 15, 2, 0);
const end = makeDate(2026, 1, 16, 2, 0);
expect(elapsedActiveHoursBetween(start, end)).toBeCloseTo(ACTIVE_HOURS_PER_DAY, 10);
});
test("7 full days returns 7 * 17 = 119 active hours", () => {
const start = makeDate(2026, 1, 10, 2, 0);
const end = makeDate(2026, 1, 17, 2, 0);
expect(elapsedActiveHoursBetween(start, end)).toBeCloseTo(7 * ACTIVE_HOURS_PER_DAY, 10);
});
test("across midnight is continuous (11 PM to 1 AM = 2 active hours)", () => {
const start = makeDate(2026, 1, 15, 23, 0);
const end = makeDate(2026, 1, 16, 1, 0);
expect(elapsedActiveHoursBetween(start, end)).toBeCloseTo(2, 10);
});
});
describe("no discontinuities at critical boundaries", () => {
/**
* Walk minute-by-minute through a full 7-day period and verify:
* 1. The elapsed active hours never decrease (monotonically non-decreasing)
* 2. The maximum jump between consecutive minutes is small (no sudden jumps)
*/
test("elapsed active hours is monotonically non-decreasing (minute granularity, 7 days)", () => {
const periodStart = makeDate(2026, 1, 10, 14, 30); // arbitrary start mid-afternoon
const totalMinutes = 7 * 24 * 60;
let prevValue = 0;
let maxJump = 0;
for (let m = 0; m <= totalMinutes; m++) {
const now = new Date(periodStart.getTime() + m * 60_000);
const elapsed = elapsedActiveHoursBetween(periodStart, now);
// Must never decrease
expect(elapsed).toBeGreaterThanOrEqual(prevValue);
// Track maximum jump
const jump = elapsed - prevValue;
if (jump > maxJump) maxJump = jump;
prevValue = elapsed;
}
// Maximum jump in 1 minute should be ~1/60 hour (during active) or 0 (during sleep)
// Allow small floating point margin
expect(maxJump).toBeLessThanOrEqual(1 / 60 + 1e-9);
});
test("no jump at 2:00 AM boundary (sleep start)", () => {
const periodStart = makeDate(2026, 1, 10, 12, 0);
// Check the minute before and after 2:00 AM
const before = makeDate(2026, 1, 15, 1, 59);
const at = makeDate(2026, 1, 15, 2, 0);
const after = makeDate(2026, 1, 15, 2, 1);
const valBefore = elapsedActiveHoursBetween(periodStart, before);
const valAt = elapsedActiveHoursBetween(periodStart, at);
const valAfter = elapsedActiveHoursBetween(periodStart, after);
// 1:59 AM to 2:00 AM: still active, should increase by ~1/60
expect(valAt - valBefore).toBeCloseTo(1 / 60, 4);
// 2:00 AM to 2:01 AM: now sleeping, should be flat
expect(valAfter - valAt).toBeCloseTo(0, 10);
});
test("no jump at 9:00 AM boundary (wake start)", () => {
const periodStart = makeDate(2026, 1, 10, 12, 0);
const before = makeDate(2026, 1, 15, 8, 59);
const at = makeDate(2026, 1, 15, 9, 0);
const after = makeDate(2026, 1, 15, 9, 1);
const valBefore = elapsedActiveHoursBetween(periodStart, before);
const valAt = elapsedActiveHoursBetween(periodStart, at);
const valAfter = elapsedActiveHoursBetween(periodStart, after);
// 8:59 AM to 9:00 AM: still sleeping, should be flat
expect(valAt - valBefore).toBeCloseTo(0, 10);
// 9:00 AM to 9:01 AM: now active, should increase by ~1/60
expect(valAfter - valAt).toBeCloseTo(1 / 60, 4);
});
test("no jump at midnight boundary", () => {
const periodStart = makeDate(2026, 1, 10, 12, 0);
const before = makeDate(2026, 1, 14, 23, 59);
const at = makeDate(2026, 1, 15, 0, 0);
const after = makeDate(2026, 1, 15, 0, 1);
const valBefore = elapsedActiveHoursBetween(periodStart, before);
const valAt = elapsedActiveHoursBetween(periodStart, at);
const valAfter = elapsedActiveHoursBetween(periodStart, after);
// All active times - should increase smoothly
const jump1 = valAt - valBefore;
const jump2 = valAfter - valAt;
expect(jump1).toBeCloseTo(1 / 60, 4);
expect(jump2).toBeCloseTo(1 / 60, 4);
});
test("flat during sleep hours (2 AM - 9 AM)", () => {
const periodStart = makeDate(2026, 1, 10, 12, 0);
const sleepStart = elapsedActiveHoursBetween(periodStart, makeDate(2026, 1, 15, 2, 0));
const sleepMid = elapsedActiveHoursBetween(periodStart, makeDate(2026, 1, 15, 5, 30));
const sleepEnd = elapsedActiveHoursBetween(periodStart, makeDate(2026, 1, 15, 9, 0));
expect(sleepMid).toBeCloseTo(sleepStart, 10);
expect(sleepEnd).toBeCloseTo(sleepStart, 10);
});
test("monotonic with arbitrary period start times", () => {
// Test with various period start times to catch alignment issues
const startTimes = [
makeDate(2026, 1, 10, 0, 0), // midnight
makeDate(2026, 1, 10, 1, 30), // during sleep (before 2 AM boundary)
makeDate(2026, 1, 10, 2, 0), // exactly at sleep start
makeDate(2026, 1, 10, 5, 0), // mid-sleep
makeDate(2026, 1, 10, 9, 0), // exactly at wake
makeDate(2026, 1, 10, 15, 45), // mid-afternoon
makeDate(2026, 1, 10, 23, 59), // just before midnight
];
for (const periodStart of startTimes) {
let prevValue = 0;
// Check every 10 minutes for 7 days
const totalSteps = 7 * 24 * 6;
for (let step = 0; step <= totalSteps; step++) {
const now = new Date(periodStart.getTime() + step * 10 * 60_000);
const elapsed = elapsedActiveHoursBetween(periodStart, now);
expect(elapsed).toBeGreaterThanOrEqual(prevValue);
prevValue = elapsed;
}
}
});
});
describe("pace calculation sanity", () => {
test("at period midpoint during active hours, elapsed should be roughly half total", () => {
// 3.5 days into period, at noon (active time)
const start = makeDate(2026, 1, 10, 12, 0);
const midpoint = makeDate(2026, 1, 14, 0, 0); // 3.5 days later
const elapsed = elapsedActiveHoursBetween(start, midpoint);
const totalActive = 7 * ACTIVE_HOURS_PER_DAY;
// Should be roughly 50% through, give or take
const ratio = elapsed / totalActive;
expect(ratio).toBeGreaterThan(0.35);
expect(ratio).toBeLessThan(0.65);
});
});