mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-13 04:14:55 +00:00
* 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:
1 parent
5c4521d014
commit
7140c3faa5
2 files changed
+296
-23
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user