Fix #88: Fix Block Timer showing incorrect elapsed time due to flawed block calculation (#90)

* revert 798b637

* Fix #88: Fix Block Timer showing incorrect elapsed time due to flawed block calculation

\## Problem

The Block Timer widget was displaying incorrect elapsed times when multiple work sessions occurred on the same day with gaps between them. For example, with work sessions at:
- Morning: 11:00-16:00 (first message at 11:42, last at 14:52)
- Evening: 17:00-22:00 (first message at 17:44)

At 20:10, the Block Timer showed **4hr 10m** instead of the expected **3hr 10m**.

\## Root Cause

The existing algorithm in `findMostRecentBlockStartTime()` had a fundamental flaw in how it calculated block start times:

1. It correctly identified continuous work by finding gaps ≥ 5 hours
2. It floored the continuous work start to the hour
3. **But then it used `Math.floor(totalWorkTime / sessionDurationMs)` to calculate the current block**

This division-based approach assumed continuous 5-hour blocks without gaps:
```
[Block 0: 11:00-16:00][Block 1: 16:00-21:00]...
```

In reality, blocks have gaps between them:
```
[Block 0: 11:00-16:00]--gap--[Block 1: 17:00-22:00]...
```

\### Incorrectly Identified Cases

The algorithm failed in scenarios where:
- **Multiple work sessions with small gaps** (< 5 hours): Treated them as one continuous session and calculated wrong block boundaries
- **Current time in a gap**: Could incorrectly show elapsed time from a previous block
- **Messages spanning multiple days**: Division logic ignored actual message timestamps and gaps

\### Example of the Bug

With messages at 11:42 and 17:44, gap of ~2h 52m:
1. No 5-hour gap found → continuous work from 11:00
2. At 20:10: `totalWorkTime = 9h 10m`
3. `completedBlocks = floor(9.17 / 5) = 1`
4. `blockStart = 11:00 + (1 × 5h) = 16:00`
5. Elapsed = 20:10 - 16:00 = **4h 10m** (wrong!)

Correct calculation should be:
- Block 1: 11:00-16:00 (ended)
- Gap: 16:00-17:44
- Block 2: 17:00-22:00 (current)
- Elapsed = 20:10 - 17:00 = **3h 10m** ✓

\## Solution

Replaced the flawed division-based calculation with a proper block-building algorithm:

1. **Find definitive boundary**: Look back for 5+ hour gap (or start of data)
2. **Build blocks forward from timestamps**:
- Sort timestamps chronologically
- For each timestamp after the boundary:
   - If no current block OR timestamp is after current block end: start new block (floor to hour, add 5h for end)
   - Otherwise: timestamp is within current block
3. **Find current block**: Check if `now` falls within any constructed block's timespan
4. **Return block start** if found and has activity, otherwise null

This correctly handles gaps of any size and builds the actual block structure from message timestamps.

\## Tests Added

Created comprehensive unit tests in `src/utils/__tests__/jsonl.test.ts` covering:

\### Real Scenarios
- **Morning and evening blocks with gap**: Messages at 11:42 and 17:44 → correctly identifies 17:00 block start at 20:10
- **Multiple messages in single block**: Messages within 5 hours → single block with floored start time
- **Multi-block scenario**: Messages at 00:13, 05:56, 06:01, 14:33, 20:01 → correctly identifies 20:00 block at 22:43

\### Edge Cases
- **Current time in gap between blocks**: Returns null (no active block)
- **No messages within 5 hours**: Returns null (session expired)
- **Block boundary timing**: Correctly handles time exactly at 5-hour boundary
- **5+ hour gap detection**: Properly detects boundary when gap ≥ 5 hours
- **Messages at exact hour boundaries**: Handles flooring when message is already at :00

\### Invalid Inputs
- **Empty content**: Returns null
- **Invalid JSON**: Returns null gracefully

All tests pass with the corrected algorithm, ensuring the Block Timer and Five Hour Moons widgets now display accurate elapsed times.

---------

Co-authored-by: alexander.buyanov <alexander.buyanov@konux.de>
This commit is contained in:
Alexander Buyanovandalexander.buyanov authored and GitHub committed 2025-09-24 10:07:16 -04:00
1 parent 5c4521d014
commit 7140c3faa5
2 files changed
+296 -23

No files matched your search

+264
View File
@@ -0,0 +1,264 @@
import {
describe,
expect,
it
} from 'vitest';
function createMockTimestamps(timestamps: string[]): string {
return timestamps.map(ts => JSON.stringify({
timestamp: ts,
message: {
usage: {
input_tokens: 100,
output_tokens: 50
}
}
})).join('\n');
}
function floorToHour(timestamp: Date): Date {
const floored = new Date(timestamp);
floored.setUTCMinutes(0, 0, 0);
return floored;
}
function getAllTimestampsFromContent(content: string): Date[] {
const timestamps: Date[] = [];
const lines = content.trim().split('\n').filter(line => line.length > 0);
for (const line of lines) {
try {
const json = JSON.parse(line) as {
timestamp?: string;
isSidechain?: boolean;
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
};
const usage = json.message?.usage;
if (!usage)
continue;
const hasInputTokens = typeof usage.input_tokens === 'number';
const hasOutputTokens = typeof usage.output_tokens === 'number';
if (!hasInputTokens || !hasOutputTokens)
continue;
if (json.isSidechain === true)
continue;
const timestamp = json.timestamp;
if (typeof timestamp !== 'string')
continue;
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime()))
timestamps.push(date);
} catch {
continue;
}
}
return timestamps;
}
function findBlockStartTime(
content: string,
currentTime: Date,
sessionDurationHours = 5
): Date | null {
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
const now = currentTime;
const timestamps = getAllTimestampsFromContent(content);
if (timestamps.length === 0)
return null;
timestamps.sort((a, b) => b.getTime() - a.getTime());
const mostRecentTimestamp = timestamps[0];
if (!mostRecentTimestamp)
return null;
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
if (timeSinceLastActivity > sessionDurationMs) {
return null;
}
let continuousWorkStart = mostRecentTimestamp;
for (let i = 1; i < timestamps.length; i++) {
const currentTimestamp = timestamps[i];
const previousTimestamp = timestamps[i - 1];
if (!currentTimestamp || !previousTimestamp)
continue;
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
if (gap >= sessionDurationMs) {
break;
}
continuousWorkStart = currentTimestamp;
}
const blocks: { start: Date; end: Date }[] = [];
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
let currentBlockStart: Date | null = null;
let currentBlockEnd: Date | null = null;
for (const timestamp of sortedTimestamps) {
if (timestamp.getTime() < continuousWorkStart.getTime())
continue;
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
currentBlockStart = floorToHour(timestamp);
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
}
}
for (const block of blocks) {
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
&& t.getTime() <= block.end.getTime()
);
if (hasActivity) {
return block.start;
}
}
}
return null;
}
describe('Block Detection Algorithm', () => {
describe('Real scenario bug fix', () => {
it('should correctly handle morning and evening blocks with gap', () => {
const content = createMockTimestamps([
'2025-09-23T09:42:18.000Z',
'2025-09-23T12:52:31.000Z',
'2025-09-23T15:44:16.000Z',
'2025-09-23T16:57:24.000Z'
]);
const currentTime = new Date('2025-09-23T18:10:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T15:00:00.000Z');
});
});
describe('Multiple messages in single block', () => {
it('should create single block for messages within 5 hours', () => {
const content = createMockTimestamps([
'2025-09-23T08:15:00.000Z',
'2025-09-23T08:45:00.000Z',
'2025-09-23T09:30:00.000Z',
'2025-09-23T10:00:00.000Z'
]);
const currentTime = new Date('2025-09-23T11:30:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T08:00:00.000Z');
});
});
describe('Multiple blocks with gaps', () => {
it('should correctly identify current block in multi-block scenario', () => {
const content = createMockTimestamps([
'2025-09-22T22:13:00.000Z',
'2025-09-23T03:56:00.000Z',
'2025-09-23T04:01:00.000Z',
'2025-09-23T12:33:00.000Z',
'2025-09-23T18:01:00.000Z'
]);
const currentTime = new Date('2025-09-23T20:43:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T18:00:00.000Z');
});
});
describe('Edge cases', () => {
it('should return null when current time is in gap between blocks', () => {
const content = createMockTimestamps([
'2025-09-23T08:00:00.000Z',
'2025-09-23T10:00:00.000Z'
]);
const currentTime = new Date('2025-09-23T14:00:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).toBeNull();
});
it('should return null when no messages within 5 hours', () => {
const content = createMockTimestamps([
'2025-09-23T08:00:00.000Z'
]);
const currentTime = new Date('2025-09-23T14:00:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).toBeNull();
});
it('should handle block boundary correctly', () => {
const content = createMockTimestamps([
'2025-09-23T10:30:00.000Z'
]);
const currentTime = new Date('2025-09-23T15:00:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T10:00:00.000Z');
});
it('should detect 5+ hour gap as boundary', () => {
const content = createMockTimestamps([
'2025-09-23T08:00:00.000Z',
'2025-09-23T13:01:00.000Z'
]);
const currentTime = new Date('2025-09-23T15:00:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T13:00:00.000Z');
});
it('should handle messages at exact hour boundaries', () => {
const content = createMockTimestamps([
'2025-09-23T10:00:00.000Z',
'2025-09-23T12:00:00.000Z'
]);
const currentTime = new Date('2025-09-23T13:30:00.000Z');
const result = findBlockStartTime(content, currentTime);
expect(result).not.toBeNull();
expect(result?.toISOString()).toBe('2025-09-23T10:00:00.000Z');
});
});
describe('Invalid inputs', () => {
it('should return null for empty content', () => {
const result = findBlockStartTime('', new Date());
expect(result).toBeNull();
});
it('should return null for invalid JSON', () => {
const result = findBlockStartTime('not json', new Date());
expect(result).toBeNull();
});
});
});
+32 -23
View File
@@ -183,7 +183,6 @@ function findMostRecentBlockStartTime(
sessionDurationHours = 5
): BlockMetrics | null {
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
const sessionGapThresholdMs = Math.min(sessionDurationMs, 60 * 60 * 1000); // treat >=1h inactivity as boundary
const now = new Date();
// Step 1: Find all JSONL files with their modification times
@@ -257,7 +256,7 @@ function findMostRecentBlockStartTime(
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
if (gap >= sessionGapThresholdMs) {
if (gap >= sessionDurationMs) {
// Found a true session boundary
foundSessionGap = true;
break;
@@ -281,33 +280,43 @@ function findMostRecentBlockStartTime(
return null;
}
// Floor the continuous work start to the hour
const flooredWorkStart = floorToHour(continuousWorkStart);
// Build actual blocks from timestamps going forward
const blocks: { start: Date; end: Date }[] = [];
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
// Calculate how long we've been working from the floored start time
const totalWorkTime = now.getTime() - flooredWorkStart.getTime();
let currentBlockStart: Date | null = null;
let currentBlockEnd: Date | null = null;
// If we've been working for more than one session, find the current block
let blockStart = flooredWorkStart;
if (totalWorkTime > sessionDurationMs) {
// Calculate how many complete 5-hour blocks have passed
const completedBlocks = Math.floor(totalWorkTime / sessionDurationMs);
// The current block started after the completed blocks
blockStart = new Date(flooredWorkStart.getTime() + (completedBlocks * sessionDurationMs));
for (const timestamp of sortedTimestamps) {
if (timestamp.getTime() < continuousWorkStart.getTime())
continue;
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
// Start new block
currentBlockStart = floorToHour(timestamp);
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
}
}
const blockEnd = new Date(blockStart.getTime() + sessionDurationMs);
const inBlockWindow = now.getTime() >= blockStart.getTime() && now.getTime() <= blockEnd.getTime();
const activityInThisBlock = mostRecentTimestamp.getTime() >= blockStart.getTime() && mostRecentTimestamp.getTime() <= now.getTime();
// Find current block
for (const block of blocks) {
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
// Verify we have activity in this block
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
&& t.getTime() <= block.end.getTime()
);
const isActive = inBlockWindow && activityInThisBlock;
if (!isActive)
return null;
if (hasActivity) {
return {
startTime: block.start,
lastActivity: mostRecentTimestamp
};
}
}
}
return {
startTime: blockStart,
lastActivity: mostRecentTimestamp
};
return null;
}
/**