diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..cf43df4 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/home/dot_local/bin/executable_claude-usage b/home/dot_local/bin/executable_claude-usage index f541b1a..6f79f04 100644 --- a/home/dot_local/bin/executable_claude-usage +++ b/home/dot_local/bin/executable_claude-usage @@ -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 { diff --git a/package.json b/package.json new file mode 100644 index 0000000..d346702 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": {}, + "devDependencies": { + "chalk": "^5.6.2" + } +} \ No newline at end of file diff --git a/tests/claude-usage.test.ts b/tests/claude-usage.test.ts new file mode 100644 index 0000000..21442f1 --- /dev/null +++ b/tests/claude-usage.test.ts @@ -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); + }); +});