Skip to content

Commit f67f7eb

Browse files
authored
Merge pull request #92 from ryoppippi/session-length
feat: add configurable session length to blocks command
2 parents 671bb42 + e10c9e4 commit f67f7eb

File tree

4 files changed

+172
-15
lines changed

4 files changed

+172
-15
lines changed

src/commands/blocks.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getDefaultClaudePath, loadSessionBlockData } from '../data-loader.ts';
55
import { log, logger } from '../logger.ts';
66
import {
77
calculateBurnRate,
8+
DEFAULT_SESSION_DURATION_HOURS,
89
filterRecentBlocks,
910
projectBlockUsage,
1011
type SessionBlock,
@@ -111,19 +112,32 @@ export const blocksCommand = define({
111112
short: 't',
112113
description: 'Token limit for quota warnings (e.g., 500000 or "max" for highest previous block)',
113114
},
115+
sessionLength: {
116+
type: 'number',
117+
short: 'l',
118+
description: `Session block duration in hours (default: ${DEFAULT_SESSION_DURATION_HOURS})`,
119+
default: DEFAULT_SESSION_DURATION_HOURS,
120+
},
114121
},
115122
toKebab: true,
116123
async run(ctx) {
117124
if (ctx.values.json) {
118125
logger.level = 0;
119126
}
120127

128+
// Validate session length
129+
if (ctx.values.sessionLength <= 0) {
130+
logger.error('Session length must be a positive number');
131+
process.exit(1);
132+
}
133+
121134
let blocks = await loadSessionBlockData({
122135
since: ctx.values.since,
123136
until: ctx.values.until,
124137
claudePath: getDefaultClaudePath(),
125138
mode: ctx.values.mode,
126139
order: ctx.values.order,
140+
sessionDurationHours: ctx.values.sessionLength,
127141
});
128142

129143
if (blocks.length === 0) {

src/data-loader.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ export type LoadOptions = {
478478
mode?: CostMode; // Cost calculation mode
479479
order?: SortOrder; // Sort order for dates
480480
offline?: boolean; // Use offline mode for pricing
481+
sessionDurationHours?: number; // Session block duration in hours
481482
} & DateFilter;
482483

483484
export async function loadDailyUsageData(
@@ -900,7 +901,7 @@ export async function loadSessionBlockData(
900901
}
901902

902903
// Identify session blocks
903-
const blocks = identifySessionBlocks(allEntries);
904+
const blocks = identifySessionBlocks(allEntries, options?.sessionDurationHours);
904905

905906
// Filter by date range if specified
906907
const filtered = (options?.since != null && options.since !== '') || (options?.until != null && options.until !== '')

src/session-blocks.internal.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,3 +494,141 @@ describe('filterRecentBlocks', () => {
494494
expect(result).toHaveLength(0);
495495
});
496496
});
497+
498+
describe('identifySessionBlocks with configurable duration', () => {
499+
test('creates single block for entries within custom 3-hour duration', () => {
500+
const baseTime = new Date('2024-01-01T10:00:00Z');
501+
const entries: LoadedUsageEntry[] = [
502+
createMockEntry(baseTime),
503+
createMockEntry(new Date(baseTime.getTime() + 60 * 60 * 1000)), // 1 hour later
504+
createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later
505+
];
506+
507+
const blocks = identifySessionBlocks(entries, 3);
508+
expect(blocks).toHaveLength(1);
509+
expect(blocks[0]?.startTime).toEqual(baseTime);
510+
expect(blocks[0]?.entries).toHaveLength(3);
511+
expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 3 * 60 * 60 * 1000));
512+
});
513+
514+
test('creates multiple blocks with custom 2-hour duration', () => {
515+
const baseTime = new Date('2024-01-01T10:00:00Z');
516+
const entries: LoadedUsageEntry[] = [
517+
createMockEntry(baseTime),
518+
createMockEntry(new Date(baseTime.getTime() + 3 * 60 * 60 * 1000)), // 3 hours later (beyond 2h limit)
519+
];
520+
521+
const blocks = identifySessionBlocks(entries, 2);
522+
expect(blocks).toHaveLength(3); // first block, gap block, second block
523+
expect(blocks[0]?.entries).toHaveLength(1);
524+
expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000));
525+
expect(blocks[1]?.isGap).toBe(true); // gap block
526+
expect(blocks[2]?.entries).toHaveLength(1);
527+
});
528+
529+
test('creates gap block with custom 1-hour duration', () => {
530+
const baseTime = new Date('2024-01-01T10:00:00Z');
531+
const entries: LoadedUsageEntry[] = [
532+
createMockEntry(baseTime),
533+
createMockEntry(new Date(baseTime.getTime() + 30 * 60 * 1000)), // 30 minutes later (within 1h)
534+
createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later (beyond 1h)
535+
];
536+
537+
const blocks = identifySessionBlocks(entries, 1);
538+
expect(blocks).toHaveLength(3); // first block, gap block, second block
539+
expect(blocks[0]?.entries).toHaveLength(2);
540+
expect(blocks[1]?.isGap).toBe(true);
541+
expect(blocks[2]?.entries).toHaveLength(1);
542+
});
543+
544+
test('works with fractional hours (2.5 hours)', () => {
545+
const baseTime = new Date('2024-01-01T10:00:00Z');
546+
const entries: LoadedUsageEntry[] = [
547+
createMockEntry(baseTime),
548+
createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // 2 hours later (within 2.5h)
549+
createMockEntry(new Date(baseTime.getTime() + 6 * 60 * 60 * 1000)), // 6 hours later (4 hours from last entry, beyond 2.5h)
550+
];
551+
552+
const blocks = identifySessionBlocks(entries, 2.5);
553+
expect(blocks).toHaveLength(3); // first block, gap block, second block
554+
expect(blocks[0]?.entries).toHaveLength(2);
555+
expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 2.5 * 60 * 60 * 1000));
556+
expect(blocks[1]?.isGap).toBe(true);
557+
expect(blocks[2]?.entries).toHaveLength(1);
558+
});
559+
560+
test('works with very short duration (0.5 hours)', () => {
561+
const baseTime = new Date('2024-01-01T10:00:00Z');
562+
const entries: LoadedUsageEntry[] = [
563+
createMockEntry(baseTime),
564+
createMockEntry(new Date(baseTime.getTime() + 20 * 60 * 1000)), // 20 minutes later (within 0.5h)
565+
createMockEntry(new Date(baseTime.getTime() + 80 * 60 * 1000)), // 80 minutes later (60 minutes from last entry, beyond 0.5h)
566+
];
567+
568+
const blocks = identifySessionBlocks(entries, 0.5);
569+
expect(blocks).toHaveLength(3); // first block, gap block, second block
570+
expect(blocks[0]?.entries).toHaveLength(2);
571+
expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 0.5 * 60 * 60 * 1000));
572+
expect(blocks[1]?.isGap).toBe(true);
573+
expect(blocks[2]?.entries).toHaveLength(1);
574+
});
575+
576+
test('works with very long duration (24 hours)', () => {
577+
const baseTime = new Date('2024-01-01T10:00:00Z');
578+
const entries: LoadedUsageEntry[] = [
579+
createMockEntry(baseTime),
580+
createMockEntry(new Date(baseTime.getTime() + 12 * 60 * 60 * 1000)), // 12 hours later (within 24h)
581+
createMockEntry(new Date(baseTime.getTime() + 20 * 60 * 60 * 1000)), // 20 hours later (within 24h)
582+
];
583+
584+
const blocks = identifySessionBlocks(entries, 24);
585+
expect(blocks).toHaveLength(1); // single block
586+
expect(blocks[0]?.entries).toHaveLength(3);
587+
expect(blocks[0]?.endTime).toEqual(new Date(baseTime.getTime() + 24 * 60 * 60 * 1000));
588+
});
589+
590+
test('gap detection respects custom duration', () => {
591+
const baseTime = new Date('2024-01-01T10:00:00Z');
592+
const entries: LoadedUsageEntry[] = [
593+
createMockEntry(baseTime),
594+
createMockEntry(new Date(baseTime.getTime() + 1 * 60 * 60 * 1000)), // 1 hour later
595+
createMockEntry(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000)), // 5 hours later (4h from last entry, beyond 3h)
596+
];
597+
598+
const blocks = identifySessionBlocks(entries, 3);
599+
expect(blocks).toHaveLength(3); // first block, gap block, second block
600+
601+
// Gap block should start 3 hours after last activity in first block
602+
const gapBlock = blocks[1];
603+
expect(gapBlock?.isGap).toBe(true);
604+
expect(gapBlock?.startTime).toEqual(new Date(baseTime.getTime() + 1 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000)); // 1h + 3h
605+
expect(gapBlock?.endTime).toEqual(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000)); // 5h
606+
});
607+
608+
test('no gap created when gap is exactly equal to session duration', () => {
609+
const baseTime = new Date('2024-01-01T10:00:00Z');
610+
const entries: LoadedUsageEntry[] = [
611+
createMockEntry(baseTime),
612+
createMockEntry(new Date(baseTime.getTime() + 2 * 60 * 60 * 1000)), // exactly 2 hours later (equal to session duration)
613+
];
614+
615+
const blocks = identifySessionBlocks(entries, 2);
616+
expect(blocks).toHaveLength(1); // single block (entries are exactly at session boundary)
617+
expect(blocks[0]?.entries).toHaveLength(2);
618+
});
619+
620+
test('defaults to 5 hours when no duration specified', () => {
621+
const baseTime = new Date('2024-01-01T10:00:00Z');
622+
const entries: LoadedUsageEntry[] = [
623+
createMockEntry(baseTime),
624+
];
625+
626+
const blocksDefault = identifySessionBlocks(entries);
627+
const blocksExplicit = identifySessionBlocks(entries, 5);
628+
629+
expect(blocksDefault).toHaveLength(1);
630+
expect(blocksExplicit).toHaveLength(1);
631+
expect(blocksDefault[0]!.endTime).toEqual(blocksExplicit[0]!.endTime);
632+
expect(blocksDefault[0]!.endTime).toEqual(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000));
633+
});
634+
});

src/session-blocks.internal.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const SESSION_DURATION_MS = 5 * 60 * 60 * 1000;
1+
export const DEFAULT_SESSION_DURATION_HOURS = 5;
22
const DEFAULT_RECENT_DAYS = 3;
33

44
export type LoadedUsageEntry = {
@@ -45,11 +45,15 @@ export type ProjectedUsage = {
4545
remainingMinutes: number;
4646
};
4747

48-
export function identifySessionBlocks(entries: LoadedUsageEntry[]): SessionBlock[] {
48+
export function identifySessionBlocks(
49+
entries: LoadedUsageEntry[],
50+
sessionDurationHours = DEFAULT_SESSION_DURATION_HOURS,
51+
): SessionBlock[] {
4952
if (entries.length === 0) {
5053
return [];
5154
}
5255

56+
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
5357
const blocks: SessionBlock[] = [];
5458
const sortedEntries = [...entries].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
5559

@@ -74,14 +78,14 @@ export function identifySessionBlocks(entries: LoadedUsageEntry[]): SessionBlock
7478
const lastEntryTime = lastEntry.timestamp;
7579
const timeSinceLastEntry = entryTime.getTime() - lastEntryTime.getTime();
7680

77-
if (timeSinceBlockStart > SESSION_DURATION_MS || timeSinceLastEntry > SESSION_DURATION_MS) {
81+
if (timeSinceBlockStart > sessionDurationMs || timeSinceLastEntry > sessionDurationMs) {
7882
// Close current block
79-
const block = createBlock(currentBlockStart, currentBlockEntries, now);
83+
const block = createBlock(currentBlockStart, currentBlockEntries, now, sessionDurationMs);
8084
blocks.push(block);
8185

8286
// Add gap block if there's a significant gap
83-
if (timeSinceLastEntry > SESSION_DURATION_MS) {
84-
const gapBlock = createGapBlock(lastEntryTime, entryTime);
87+
if (timeSinceLastEntry > sessionDurationMs) {
88+
const gapBlock = createGapBlock(lastEntryTime, entryTime, sessionDurationMs);
8589
if (gapBlock != null) {
8690
blocks.push(gapBlock);
8791
}
@@ -100,18 +104,18 @@ export function identifySessionBlocks(entries: LoadedUsageEntry[]): SessionBlock
100104

101105
// Close the last block
102106
if (currentBlockStart != null && currentBlockEntries.length > 0) {
103-
const block = createBlock(currentBlockStart, currentBlockEntries, now);
107+
const block = createBlock(currentBlockStart, currentBlockEntries, now, sessionDurationMs);
104108
blocks.push(block);
105109
}
106110

107111
return blocks;
108112
}
109113

110-
function createBlock(startTime: Date, entries: LoadedUsageEntry[], now: Date): SessionBlock {
111-
const endTime = new Date(startTime.getTime() + SESSION_DURATION_MS);
114+
function createBlock(startTime: Date, entries: LoadedUsageEntry[], now: Date, sessionDurationMs: number): SessionBlock {
115+
const endTime = new Date(startTime.getTime() + sessionDurationMs);
112116
const lastEntry = entries[entries.length - 1];
113117
const actualEndTime = lastEntry != null ? lastEntry.timestamp : startTime;
114-
const isActive = now.getTime() - actualEndTime.getTime() < SESSION_DURATION_MS && now < endTime;
118+
const isActive = now.getTime() - actualEndTime.getTime() < sessionDurationMs && now < endTime;
115119

116120
// Aggregate token counts
117121
const tokenCounts: TokenCounts = {
@@ -146,14 +150,14 @@ function createBlock(startTime: Date, entries: LoadedUsageEntry[], now: Date): S
146150
};
147151
}
148152

149-
function createGapBlock(lastActivityTime: Date, nextActivityTime: Date): SessionBlock | null {
150-
// Only create gap blocks for gaps longer than 5 hours
153+
function createGapBlock(lastActivityTime: Date, nextActivityTime: Date, sessionDurationMs: number): SessionBlock | null {
154+
// Only create gap blocks for gaps longer than the session duration
151155
const gapDuration = nextActivityTime.getTime() - lastActivityTime.getTime();
152-
if (gapDuration <= SESSION_DURATION_MS) {
156+
if (gapDuration <= sessionDurationMs) {
153157
return null;
154158
}
155159

156-
const gapStart = new Date(lastActivityTime.getTime() + SESSION_DURATION_MS);
160+
const gapStart = new Date(lastActivityTime.getTime() + sessionDurationMs);
157161
const gapEnd = nextActivityTime;
158162

159163
return {

0 commit comments

Comments
 (0)