mirror of
https://github.com/Xevion/dotfiles.git
synced 2026-01-31 04:24:10 -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 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 {
|
||||||
|
|||||||
@@ -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