Skip to content

Commit 2fdd5a5

Browse files
authored
Merge pull request #222 from embogomolov/fix/codex-activity-timezone
2 parents 56fa2b9 + 7d425df commit 2fdd5a5

3 files changed

Lines changed: 306 additions & 46 deletions

File tree

src/data.js

Lines changed: 127 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -252,12 +252,32 @@ function parseTimestamp(value) {
252252
if (typeof value === 'string') {
253253
const trimmed = value.trim();
254254
if (!trimmed) return NaN;
255-
if (/^\d+$/.test(trimmed)) return Number(trimmed);
255+
if (/^\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
256256
return Date.parse(trimmed);
257257
}
258258
return NaN;
259259
}
260260

261+
// Epoch seconds stay below this cutoff until 2286; millisecond epochs are larger.
262+
const EPOCH_SECONDS_CUTOFF = 10000000000;
263+
264+
function normalizeTimestampMs(ts) {
265+
if (!Number.isFinite(ts)) return NaN;
266+
return ts > 0 && ts < EPOCH_SECONDS_CUTOFF ? ts * 1000 : ts;
267+
}
268+
269+
function parseTimestampMs(value) {
270+
return normalizeTimestampMs(parseTimestamp(value));
271+
}
272+
273+
function parseEntryTimestampMs(entry) {
274+
if (!entry || typeof entry !== 'object') return NaN;
275+
const timestampTs = parseTimestampMs(entry.timestamp);
276+
if (Number.isFinite(timestampTs) && timestampTs > 0) return timestampTs;
277+
const fallbackTs = parseTimestampMs(entry.ts);
278+
return Number.isFinite(fallbackTs) && fallbackTs > 0 ? fallbackTs : NaN;
279+
}
280+
261281
function shortenHomePath(value, homes = ALL_HOMES) {
262282
value = normalizeProjectPath(value);
263283
if (!value || typeof value !== 'string') return value || '';
@@ -352,7 +372,7 @@ function parseKiloMcpServer(toolName) {
352372
}
353373

354374
// Disk cache for parsed Claude session files (keyed by path + mtime + size)
355-
const PARSED_CACHE_FILE = path.join(os.tmpdir(), 'codedash-parsed-cache-v2.json');
375+
const PARSED_CACHE_FILE = path.join(os.tmpdir(), 'codbash-parsed-cache.json');
356376
let _parsedDiskCache = null;
357377
let _parsedDiskCacheDirty = false;
358378
// Reverse index: file path -> cache key (avoids repeated fs.statSync)
@@ -1790,7 +1810,7 @@ function loadKiroDetail(conversationId) {
17901810

17911811
// Build workspace-hash -> project path mapping for VS Code workspaceStorage
17921812
let _copilotWsMapCache = null;
1793-
const COPILOT_WS_MAP_CACHE_FILE = path.join(os.tmpdir(), 'codedash-copilot-ws-map.json');
1813+
const COPILOT_WS_MAP_CACHE_FILE = path.join(os.tmpdir(), 'codbash-copilot-ws-map.json');
17941814
const COPILOT_WS_MAP_TTL = 600000; // 10 minutes
17951815

17961816
function buildCopilotWorkspaceMap() {
@@ -1884,7 +1904,7 @@ function parseCopilotJson(filePath) {
18841904
}
18851905

18861906
// Disk cache for Copilot session metadata (avoids re-scanning large files)
1887-
const COPILOT_PARSED_CACHE_FILE = path.join(os.tmpdir(), 'codedash-copilot-parsed-cache.json');
1907+
const COPILOT_PARSED_CACHE_FILE = path.join(os.tmpdir(), 'codbash-copilot-parsed-cache.json');
18881908
let _copilotParsedCache = null;
18891909

18901910
function _loadCopilotParsedCache() {
@@ -2199,7 +2219,7 @@ function decodeCursorProjectFolderKey(proj) {
21992219
// Build composerId -> project path mapping from Cursor workspace storage
22002220
// Uses disk cache to avoid querying 190+ SQLite files on every startup
22012221
let _cursorWsMapCache = null;
2202-
const CURSOR_WS_MAP_CACHE_FILE = path.join(os.tmpdir(), 'codedash-cursor-ws-map.json');
2222+
const CURSOR_WS_MAP_CACHE_FILE = path.join(os.tmpdir(), 'codbash-cursor-ws-map.json');
22032223
const CURSOR_WS_MAP_TTL = 600000; // 10 minutes
22042224

22052225
function buildCursorWorkspaceMap() {
@@ -2523,15 +2543,15 @@ function parseCodexSessionFile(sessionFile) {
25232543
let msgCount = 0;
25242544
let userMsgCount = 0;
25252545
let firstMsg = '';
2526-
let firstTs = stat.mtimeMs;
2527-
let lastTs = stat.mtimeMs;
2546+
let firstTs = Infinity;
2547+
let lastTs = -Infinity;
25282548
const mcpSet = new Set();
25292549

25302550
for (const line of lines) {
25312551
try {
25322552
const entry = JSON.parse(line);
2533-
const ts = parseTimestamp(entry.timestamp || entry.ts);
2534-
if (Number.isFinite(ts)) {
2553+
const ts = parseEntryTimestampMs(entry);
2554+
if (Number.isFinite(ts) && ts > 0) {
25352555
if (ts < firstTs) firstTs = ts;
25362556
if (ts > lastTs) lastTs = ts;
25372557
}
@@ -2565,6 +2585,10 @@ function parseCodexSessionFile(sessionFile) {
25652585
} catch {}
25662586
}
25672587

2588+
const fallbackTs = Number.isFinite(stat.mtimeMs) ? stat.mtimeMs : Date.now();
2589+
if (!Number.isFinite(firstTs)) firstTs = fallbackTs;
2590+
if (!Number.isFinite(lastTs)) lastTs = firstTs;
2591+
25682592
return {
25692593
projectPath,
25702594
msgCount,
@@ -2591,7 +2615,8 @@ function scanCodexSessions() {
25912615
const sid = d.session_id || d.sessionId || d.id;
25922616
if (!sid) continue;
25932617
if (importedFromClaude.has(sid)) continue; // skip — original Claude file loaded separately
2594-
const ts = d.ts ? d.ts * 1000 : (d.timestamp || Date.now());
2618+
const parsedTs = parseEntryTimestampMs(d);
2619+
const ts = Number.isFinite(parsedTs) && parsedTs > 0 ? parsedTs : Date.now();
25952620
if (!sessions.find(s => s.id === sid)) {
25962621
sessions.push({
25972622
id: sid,
@@ -2697,7 +2722,7 @@ function scanCodexSessions() {
26972722

26982723
const _gitRootCache = {};
26992724
// v2: collapses worktrees to main repo + ignores $HOME-as-git-root.
2700-
const GIT_ROOT_CACHE_FILE = path.join(os.tmpdir(), 'codedash-gitroot-cache-v2.json');
2725+
const GIT_ROOT_CACHE_FILE = path.join(os.tmpdir(), 'codbash-gitroot-cache.json');
27012726
let _gitRootDiskCache = null;
27022727

27032728
function _loadGitRootDiskCache() {
@@ -4793,7 +4818,7 @@ function getModelPricing(model) {
47934818
// ── Compute real cost from session file token usage ────────
47944819

47954820
// Disk cache for computed session costs
4796-
const COST_CACHE_FILE = path.join(os.tmpdir(), 'codedash-cost-cache.json');
4821+
const COST_CACHE_FILE = path.join(os.tmpdir(), 'codbash-cost-cache.json');
47974822
let _costDiskCache = null;
47984823

47994824
function _loadCostDiskCache() {
@@ -5162,7 +5187,7 @@ function computeSessionCost(sessionId, project) {
51625187
// ── Cost analytics ────────────────────────────────────────
51635188

51645189
// Analytics result cache — avoids recomputing 31k sessions every request
5165-
const ANALYTICS_CACHE_FILE = path.join(os.tmpdir(), 'codedash-analytics-cache.json');
5190+
const ANALYTICS_CACHE_FILE = path.join(os.tmpdir(), 'codbash-analytics-cache.json');
51665191
let _analyticsCacheResult = null;
51675192
let _analyticsCacheKey = null;
51685193

@@ -5366,8 +5391,9 @@ function _computeCostAnalytics(sessions) {
53665391
globalContextTurnCount += costData.contextTurnCount;
53675392

53685393
// Date range
5369-
const day = s.date || 'unknown';
5370-
if (s.date) {
5394+
const hasValidDate = isValidLocalDay(s.date);
5395+
const day = hasValidDate ? s.date : 'unknown';
5396+
if (hasValidDate) {
53715397
if (!firstDate || s.date < firstDate) firstDate = s.date;
53725398
if (!lastDate || s.date > lastDate) lastDate = s.date;
53735399
}
@@ -5377,11 +5403,11 @@ function _computeCostAnalytics(sessions) {
53775403
byDay[day].tokens += tokens;
53785404

53795405
// By week
5380-
if (s.date) {
5381-
const d = new Date(s.date);
5406+
if (hasValidDate) {
5407+
const d = parseLocalDayStart(s.date);
53825408
const weekStart = new Date(d);
53835409
weekStart.setDate(d.getDate() - d.getDay());
5384-
const weekKey = weekStart.toISOString().slice(0, 10);
5410+
const weekKey = fmtLocalDay(weekStart.getTime());
53855411
if (!byWeek[weekKey]) byWeek[weekKey] = { cost: 0, sessions: 0 };
53865412
byWeek[weekKey].cost += cost;
53875413
byWeek[weekKey].sessions++;
@@ -5394,20 +5420,20 @@ function _computeCostAnalytics(sessions) {
53945420
byProject[proj].sessions++;
53955421
byProject[proj].tokens += tokens;
53965422

5397-
sessionCosts.push({ id: s.id, cost, project: proj, date: s.date, last_ts: s.last_ts || 0 });
5423+
sessionCosts.push({ id: s.id, cost, project: proj, date: hasValidDate ? s.date : '', last_ts: s.last_ts || 0 });
53985424
}
53995425

54005426
// Sort top sessions by cost
54015427
sessionCosts.sort((a, b) => b.cost - a.cost);
54025428

54035429
const days = firstDate && lastDate
5404-
? Math.max(1, Math.round((new Date(lastDate) - new Date(firstDate)) / 86400000) + 1)
5430+
? Math.max(1, Math.round((parseLocalDayStart(lastDate) - parseLocalDayStart(firstDate)) / 86400000) + 1)
54055431
: 1;
54065432

54075433
// Burn rate: derived from already-computed sessionCosts — no extra IO
54085434
const now = Date.now();
5409-
const todayStr = new Date().toISOString().slice(0, 10);
5410-
const hoursElapsedToday = (now - new Date(todayStr).getTime()) / 3600000;
5435+
const todayStr = getLocalToday();
5436+
const hoursElapsedToday = (now - parseLocalDayStart(todayStr).getTime()) / 3600000;
54115437
let last1hCost = 0;
54125438
let todayCost = 0;
54135439
for (const sc of sessionCosts) {
@@ -5786,9 +5812,58 @@ function leaderboardAgentKey(session) {
57865812
return session.tool || 'unknown';
57875813
}
57885814

5815+
function getLocalToday() {
5816+
return fmtLocalDay(Date.now());
5817+
}
5818+
5819+
function parseLocalDayStart(day) {
5820+
if (typeof day !== 'string') return new Date(NaN);
5821+
const match = day.match(/^(\d{4})-(\d{2})-(\d{2})$/);
5822+
if (!match) return new Date(NaN);
5823+
const parsed = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
5824+
if (
5825+
parsed.getFullYear() !== Number(match[1]) ||
5826+
parsed.getMonth() !== Number(match[2]) - 1 ||
5827+
parsed.getDate() !== Number(match[3])
5828+
) {
5829+
return new Date(NaN);
5830+
}
5831+
return parsed;
5832+
}
5833+
5834+
function isValidLocalDay(day) {
5835+
return !Number.isNaN(parseLocalDayStart(day).getTime());
5836+
}
5837+
5838+
function getLocalTimezone() {
5839+
try {
5840+
return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
5841+
} catch {
5842+
return '';
5843+
}
5844+
}
5845+
5846+
function getUtcOffsetMinutes(ts = Date.now()) {
5847+
return -new Date(ts).getTimezoneOffset();
5848+
}
5849+
5850+
function computeCurrentStreak(daily, today = getLocalToday()) {
5851+
const activeDays = new Set((daily || []).map(d => d && d.date).filter(Boolean));
5852+
const dt = parseLocalDayStart(today);
5853+
if (Number.isNaN(dt.getTime())) return 0;
5854+
5855+
let streak = 0;
5856+
for (let i = 0; i < 365; i++) {
5857+
const day = fmtLocalDay(dt.getTime());
5858+
if (!activeDays.has(day)) break;
5859+
streak++;
5860+
dt.setDate(dt.getDate() - 1);
5861+
}
5862+
return streak;
5863+
}
57895864

57905865
// Disk cache for per-session daily message breakdown
5791-
const DAILY_STATS_CACHE_FILE = path.join(os.tmpdir(), 'codedash-daily-stats-cache.json');
5866+
const DAILY_STATS_CACHE_FILE = path.join(os.tmpdir(), 'codbash-daily-stats-cache.json');
57925867
let _dailyStatsDiskCache = null;
57935868

57945869
function _loadDailyStatsDiskCache() {
@@ -5813,10 +5888,13 @@ function _computeSessionDailyBreakdown(s, found) {
58135888
const tsByDay = {};
58145889

58155890
const addMsg = (day, ts) => {
5891+
if (!day) return;
58165892
msgsByDay[day] = (msgsByDay[day] || 0) + 1;
5817-
if (!tsByDay[day]) tsByDay[day] = { first: ts, last: ts };
5818-
if (ts < tsByDay[day].first) tsByDay[day].first = ts;
5819-
if (ts > tsByDay[day].last) tsByDay[day].last = ts;
5893+
const normalizedTs = typeof ts === 'number' ? normalizeTimestampMs(ts) : parseTimestampMs(ts);
5894+
if (!Number.isFinite(normalizedTs) || normalizedTs <= 0) return;
5895+
if (!tsByDay[day]) tsByDay[day] = { first: normalizedTs, last: normalizedTs };
5896+
if (normalizedTs < tsByDay[day].first) tsByDay[day].first = normalizedTs;
5897+
if (normalizedTs > tsByDay[day].last) tsByDay[day].last = normalizedTs;
58205898
};
58215899

58225900
try {
@@ -5867,24 +5945,27 @@ function _computeSessionDailyBreakdown(s, found) {
58675945
} else if (found.format === 'codex') {
58685946
if (entry.type === 'response_item' && entry.payload && entry.payload.role === 'user') {
58695947
isUser = true;
5870-
ts = s.first_ts;
5871-
const c = entry.payload.content;
5872-
if (Array.isArray(c)) { for (const p of c) { if ((p.text || '').trim()) { hasText = true; break; } } }
5948+
ts = parseEntryTimestampMs(entry);
5949+
const content = extractContent(entry.payload.content);
5950+
hasText = !!(content && content.trim() && !isSystemMessage(content));
58735951
} else continue;
58745952
}
58755953

58765954
if (!isUser || !hasText) continue;
5877-
if (!ts || ts < 1000000000000) ts = s.first_ts;
5878-
const day = (found.format === 'claude' && ts) ? fmtLocalDay(ts) : (s.date || fmtLocalDay(s.last_ts));
5879-
addMsg(day, ts || s.first_ts);
5955+
const normalizedTs = normalizeTimestampMs(ts);
5956+
const fallbackTs = Number.isFinite(s.first_ts) && s.first_ts > 0 ? s.first_ts : NaN;
5957+
const effectiveTs = Number.isFinite(normalizedTs) && normalizedTs > 0 ? normalizedTs : fallbackTs;
5958+
const fallbackDay = isValidLocalDay(s.date) ? s.date : (Number.isFinite(s.last_ts) && s.last_ts > 0 ? fmtLocalDay(s.last_ts) : '');
5959+
const day = Number.isFinite(effectiveTs) && effectiveTs > 0 ? fmtLocalDay(effectiveTs) : fallbackDay;
5960+
addMsg(day, effectiveTs);
58805961
} catch {}
58815962
}
58825963
} catch {}
58835964
return { msgsByDay, tsByDay };
58845965
}
58855966

58865967
// Daily stats result cache
5887-
const DAILY_RESULT_CACHE_FILE = path.join(os.tmpdir(), 'codedash-daily-result-cache-v2.json');
5968+
const DAILY_RESULT_CACHE_FILE = path.join(os.tmpdir(), 'codbash-daily-result-cache.json');
58885969
let _dailyResultCache = null;
58895970
let _dailyResultCacheKey = null;
58905971

@@ -5965,7 +6046,7 @@ function _computeDailyStats(sessions) {
59656046
}
59666047

59676048
// Fallback for non-Claude or sessions without detail: single-day attribution
5968-
const day = s.date || fmtLocalDay(s.last_ts);
6049+
const day = isValidLocalDay(s.date) ? s.date : (Number.isFinite(s.last_ts) ? fmtLocalDay(s.last_ts) : 'unknown');
59696050
const d = ensureDay(day);
59706051
d.sessions++;
59716052
// Use exact user_messages count if available, otherwise estimate
@@ -6018,21 +6099,11 @@ function getLeaderboardStats() {
60186099
}
60196100

60206101
// Today
6021-
const today = new Date().toISOString().slice(0, 10);
6102+
const today = getLocalToday();
60226103
const todayStats = daily.find(d => d.date === today) || { sessions: 0, messages: 0, hours: 0, cost: 0, agents: {} };
60236104

60246105
// Streak (consecutive days with sessions)
6025-
let streak = 0;
6026-
const dt = new Date();
6027-
for (let i = 0; i < 365; i++) {
6028-
const day = dt.toISOString().slice(0, 10);
6029-
if (daily.find(d => d.date === day)) {
6030-
streak++;
6031-
dt.setDate(dt.getDate() - 1);
6032-
} else {
6033-
break;
6034-
}
6035-
}
6106+
const streak = computeCurrentStreak(daily, today);
60366107

60376108
const result = {
60386109
anon,
@@ -6042,6 +6113,8 @@ function getLeaderboardStats() {
60426113
streak,
60436114
daily: daily.slice(0, 30), // last 30 days
60446115
activeDays: daily.length,
6116+
timezone: getLocalTimezone(),
6117+
utcOffsetMinutes: getUtcOffsetMinutes(),
60456118
};
60466119
_lbCache = result;
60476120
_lbCacheTs = Date.now();
@@ -6099,6 +6172,14 @@ module.exports = {
60996172
parseClaudeStructuredMessage,
61006173
parseStructuredMessage,
61016174
isFilteredClaudeStructuredMessage,
6175+
parseCodexSessionFile,
6176+
_computeSessionDailyBreakdown,
6177+
fmtLocalDay,
6178+
getLocalToday,
6179+
parseLocalDayStart,
6180+
getLocalTimezone,
6181+
getUtcOffsetMinutes,
6182+
computeCurrentStreak,
61026183
_parseMainWorktree,
61036184
resolveGitRoot,
61046185
ALL_HOMES,

src/server.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,6 +1557,8 @@ async function syncLeaderboard() {
15571557
token: profile.token, // for server-side GitHub verification
15581558
version: pkg.version,
15591559
integrity: integrity,
1560+
timezone: stats.timezone || '',
1561+
utcOffsetMinutes: stats.utcOffsetMinutes,
15601562
stats: {
15611563
today: { ...stats.today, hours: Math.min(stats.today.hours || 0, 24) },
15621564
week: stats.daily ? stats.daily.slice(0, 7).reduce((acc, d) => ({ messages: acc.messages + d.messages, hours: acc.hours + d.hours, cost: acc.cost + d.cost }), { messages: 0, hours: 0, cost: 0 }) : { messages: 0, hours: 0, cost: 0 },

0 commit comments

Comments
 (0)