mirror of
https://github.com/Xevion/dotfiles.git
synced 2026-01-31 00:24:06 -06:00
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:
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ import chalk from 'chalk';
|
||||
const CLAUDE_CODE_PATH = join(homedir(), '.claude', '.credentials.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
|
||||
const args = process.argv.slice(2);
|
||||
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
|
||||
* Active window: 10am-2am (14 hours per day)
|
||||
* 2am is the LAST active hour (2:00-2:59am is active)
|
||||
* Compute the offset (in fractional hours) into the current day-cycle.
|
||||
* A "cycle" runs from SLEEP_START_HOUR (2 AM) to the next SLEEP_START_HOUR.
|
||||
* Offset 0 = exactly 2:00 AM, offset 7 = 9:00 AM, offset 24 = next 2:00 AM.
|
||||
*/
|
||||
function calculateElapsedActiveHours(now: Date, reset: Date): number {
|
||||
const totalPeriodSeconds = 7 * 24 * 3600;
|
||||
const remainingSeconds = (reset.getTime() - now.getTime()) / 1000;
|
||||
const elapsedSeconds = totalPeriodSeconds - remainingSeconds;
|
||||
|
||||
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;
|
||||
export function offsetInCycle(t: Date): number {
|
||||
const hours = t.getHours() + t.getMinutes() / 60 + t.getSeconds() / 3600;
|
||||
if (hours >= SLEEP_START_HOUR) return hours - SLEEP_START_HOUR;
|
||||
return hours + (24 - SLEEP_START_HOUR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const now = new Date();
|
||||
const resetDate = new Date(resetTimestamp * 1000);
|
||||
const periodStart = new Date(resetDate.getTime() - 7 * 24 * 3600 * 1000);
|
||||
|
||||
const totalActiveHours = 7 * 14;
|
||||
const elapsedActiveHours = calculateElapsedActiveHours(now, resetDate);
|
||||
const totalActiveHours = 7 * ACTIVE_HOURS_PER_DAY;
|
||||
const elapsed = elapsedActiveHoursBetween(periodStart, now);
|
||||
|
||||
const expectedUtil = elapsedActiveHours / totalActiveHours;
|
||||
const expectedUtil = elapsed / totalActiveHours;
|
||||
const diff = (utilization - expectedUtil) * 100;
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"chalk": "^5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user