fix: use local timezone for date-based features (check-in, usage stats)

Root cause: `new Date().toISOString().slice(0, 10)` returns UTC date,
causing date-based features to be off by up to ±1 day for non-UTC users.

Check-in / assistant workspace:
- Add `getLocalDateString()` utility, replace all UTC date comparisons
- Schema v2→v3 migration: normalize lastCheckInDate only when it matches
  UTC today (ambiguous window); leave past dates untouched so check-in
  triggers correctly after upgrade
- Runtime compat in `needsDailyCheckIn`: accept UTC today as fallback
  for edge cases the migration cannot catch
- `needsDailyCheckIn(state, now?)` accepts optional time for testability

Usage stats:
- Replace single-offset SQL bucketing (`DATE(datetime(..., '+N minutes'))`)
  with JS-side per-row aggregation using `getLocalDateString(new Date(utc))`
- Date methods account for historical DST offset at each message's timestamp
- `getTokenUsageStats(days, now?)` accepts optional time to pin window start
- Window boundary via `localDayStartAsUTC()` is already DST-aware

Tests (new file: timezone-boundaries.test.ts, 22 tests):
- getLocalDateString at UTC+9/UTC-5/UTC boundaries with fixed timestamps
- needsDailyCheckIn: compat suppression, clearly-past trigger, local match
- v2→v3 migration: targeted normalization, past-date preservation, null safe
- localDayStartAsUTC: UTC+8/UTC-5 midnight boundaries
- Usage stats bucketing: UTC+9 cross-day, summary window, DST spring-forward
- All usage stats tests use pinned `now` — no time-bomb from fixed test data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-09 11:55:29 +08:00
1 parent 4da188bfe5
commit 94528edac9
11 files changed
+664 -38

No files matched your search

@@ -16,6 +16,7 @@ import assert from 'node:assert/strict';
import path from 'path';
import os from 'os';
import fs from 'fs';
import { getLocalDateString } from '@/lib/utils';
// Set a temp data dir before importing db module
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codepilot-workspace-test-'));
@@ -59,7 +60,7 @@ describe('Assistant Workspace', () => {
const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
assert.equal(state.onboardingComplete, false);
assert.equal(state.lastCheckInDate, null);
assert.equal(state.schemaVersion, 2);
assert.equal(state.schemaVersion, 3);
});
it('should create all 4 template files', () => {
@@ -90,7 +91,7 @@ describe('Assistant Workspace', () => {
initializeWorkspace(workDir);
const state = loadState(workDir);
state.onboardingComplete = true;
state.lastCheckInDate = new Date().toISOString().slice(0, 10);
state.lastCheckInDate = getLocalDateString();
saveState(workDir, state);
const reloaded = loadState(workDir);
@@ -133,13 +134,13 @@ describe('Assistant Workspace', () => {
});
it('should not trigger check-in if already done today', () => {
const today = new Date().toISOString().slice(0, 10);
const today = getLocalDateString();
const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 2 };
assert.equal(needsDailyCheckIn(state), false);
});
it('onboarding day should skip daily check-in (lastCheckInDate set)', () => {
const today = new Date().toISOString().slice(0, 10);
const today = getLocalDateString();
const state = { onboardingComplete: true, lastCheckInDate: today, schemaVersion: 2 };
assert.equal(needsDailyCheckIn(state), false);
});
@@ -262,20 +263,20 @@ describe('Assistant Workspace', () => {
migrateStateV1ToV2(workDir);
const state = loadState(workDir);
assert.equal(state.schemaVersion, 2);
assert.equal(state.schemaVersion, 3);
assert.ok(fs.existsSync(path.join(workDir, 'memory', 'daily')));
assert.ok(fs.existsSync(path.join(workDir, 'Inbox')));
});
it('should not re-migrate v2 state', () => {
it('should not re-migrate v3 state', () => {
initializeWorkspace(workDir);
const state = loadState(workDir);
assert.equal(state.schemaVersion, 2);
assert.equal(state.schemaVersion, 3);
// Should not throw or change anything
migrateStateV1ToV2(workDir);
const reloaded = loadState(workDir);
assert.equal(reloaded.schemaVersion, 2);
assert.equal(reloaded.schemaVersion, 3);
});
});
@@ -0,0 +1,484 @@
/**
* Timezone boundary tests for local-date logic.
*
* Run with: npx tsx --test src/__tests__/unit/timezone-boundaries.test.ts
*
* Tests verify:
* 1. getLocalDateString returns local (not UTC) date at timezone boundaries
* 2. needsDailyCheckIn uses local date, not UTC
* 3. v2→v3 migration normalizes old UTC-style lastCheckInDate
* 4. getTokenUsageStats buckets by local calendar day
* 5. localDayStartAsUTC computes correct UTC boundary
*
* Strategy: use process.env.TZ to shift timezone during tests, and pass
* fixed Date objects rather than calling the same helper to self-verify.
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import path from 'path';
import os from 'os';
import fs from 'fs';
// Set a temp data dir before importing db module
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codepilot-tz-test-'));
process.env.CLAUDE_GUI_DATA_DIR = tmpDir;
/* eslint-disable @typescript-eslint/no-require-imports */
const {
getLocalDateString,
getLocalTzOffsetMinutes,
localDayStartAsUTC,
} = require('../../lib/utils') as typeof import('../../lib/utils');
const {
needsDailyCheckIn,
loadState,
saveState,
initializeWorkspace,
migrateStateV2ToV3,
} = require('../../lib/assistant-workspace') as typeof import('../../lib/assistant-workspace');
const {
getDb,
createSession,
getTokenUsageStats,
closeDb,
} = require('../../lib/db') as typeof import('../../lib/db');
// ---------------------------------------------------------------------------
// TZ helper: save/restore original TZ around tests
// ---------------------------------------------------------------------------
const originalTZ = process.env.TZ;
function setTZ(tz: string) {
process.env.TZ = tz;
}
function restoreTZ() {
if (originalTZ === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTZ;
}
}
// ---------------------------------------------------------------------------
// 1. getLocalDateString at timezone boundaries
// ---------------------------------------------------------------------------
describe('getLocalDateString timezone boundaries', () => {
afterEach(() => restoreTZ());
it('UTC+9: UTC 2026-03-09 23:30 is local 2026-03-10 08:30', () => {
setTZ('Asia/Tokyo'); // UTC+9
const utcTime = new Date('2026-03-09T23:30:00Z');
// Local time: 2026-03-10 08:30 JST
assert.equal(getLocalDateString(utcTime), '2026-03-10');
});
it('UTC+9: UTC 2026-03-09 14:59 is still local 2026-03-09 23:59', () => {
setTZ('Asia/Tokyo');
const utcTime = new Date('2026-03-09T14:59:00Z');
// Local: 2026-03-09 23:59 JST
assert.equal(getLocalDateString(utcTime), '2026-03-09');
});
it('UTC+9: UTC 2026-03-09 15:00 crosses to local 2026-03-10 00:00', () => {
setTZ('Asia/Tokyo');
const utcTime = new Date('2026-03-09T15:00:00Z');
// Local: 2026-03-10 00:00 JST — midnight
assert.equal(getLocalDateString(utcTime), '2026-03-10');
});
it('UTC-5: UTC 2026-03-10 04:00 is still local 2026-03-09 23:00', () => {
setTZ('America/New_York'); // UTC-5 (EST, no DST in March 2026 after spring forward)
// Actually March 2026 is after spring forward (2nd Sunday of March)
// So it's EDT = UTC-4. UTC 04:00 → local 00:00 EDT on March 10.
// Let's use a January date to ensure EST (UTC-5).
const utcTime = new Date('2026-01-10T04:30:00Z');
// EST: 2026-01-09 23:30
assert.equal(getLocalDateString(utcTime), '2026-01-09');
});
it('UTC: date should match ISO date', () => {
setTZ('UTC');
const utcTime = new Date('2026-03-09T23:59:00Z');
assert.equal(getLocalDateString(utcTime), '2026-03-09');
});
});
// ---------------------------------------------------------------------------
// 2. needsDailyCheckIn with fixed time at timezone boundary
// ---------------------------------------------------------------------------
describe('needsDailyCheckIn timezone boundaries', () => {
afterEach(() => restoreTZ());
it('UTC+9: check-in done as local 2026-03-10, still valid at 08:30 JST', () => {
setTZ('Asia/Tokyo');
const now = new Date('2026-03-10T00:30:00Z'); // 09:30 JST, still March 10 local
const state = { onboardingComplete: true, lastCheckInDate: '2026-03-10', schemaVersion: 3 };
assert.equal(needsDailyCheckIn(state, now), false);
});
it('UTC+9: old date from yesterday (both local and UTC) triggers check-in', () => {
setTZ('Asia/Tokyo');
// now = UTC 2026-03-10 01:00 = JST 2026-03-10 10:00
// localToday = '2026-03-10', utcToday = '2026-03-10'
// stored '2026-03-09' matches neither → triggers
const now = new Date('2026-03-10T01:00:00Z');
const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3 };
assert.equal(needsDailyCheckIn(state, now), true);
});
it('UTC+9: at local midnight where utcToday still matches stored, compat suppresses', () => {
setTZ('Asia/Tokyo');
// now = UTC 2026-03-09 15:00 = JST 2026-03-10 00:00
// localToday = '2026-03-10', utcToday = '2026-03-09'
// stored '2026-03-09' matches utcToday → compat suppresses (correct:
// old code could have written this just hours ago during the same UTC day)
const now = new Date('2026-03-09T15:00:00Z');
const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3 };
assert.equal(needsDailyCheckIn(state, now), false);
});
it('UTC+8: old UTC date matching utcToday suppresses re-trigger (compat)', () => {
setTZ('Asia/Shanghai');
// At local 2026-03-10 01:00 (= UTC 2026-03-09 17:00):
// Old code wrote '2026-03-09' (UTC). New local date is '2026-03-10'.
// Without compat, this would be a spurious re-trigger because the user
// DID check in today (local March 10, 1am), but old code wrote UTC date.
// The UTC fallback catches this: '2026-03-09' === utcToday → skip.
const now = new Date('2026-03-09T17:00:00Z');
const state = { onboardingComplete: true, lastCheckInDate: '2026-03-09', schemaVersion: 3 };
// utcToday = '2026-03-09' matches stored → should NOT trigger
assert.equal(needsDailyCheckIn(state, now), false);
});
it('clearly-past date triggers check-in regardless of compat', () => {
setTZ('Asia/Shanghai');
const now = new Date('2026-03-10T02:00:00Z'); // local March 10 10:00
// localToday = '2026-03-10', utcToday = '2026-03-10'
// stored '2026-03-07' matches neither → triggers
const state = { onboardingComplete: true, lastCheckInDate: '2026-03-07', schemaVersion: 3 };
assert.equal(needsDailyCheckIn(state, now), true);
});
});
// ---------------------------------------------------------------------------
// 3. v2→v3 migration normalizes old UTC-style lastCheckInDate
// ---------------------------------------------------------------------------
describe('v2→v3 migration', () => {
let workDir: string;
beforeEach(() => {
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrate-v3-'));
});
afterEach(() => {
restoreTZ();
fs.rmSync(workDir, { recursive: true, force: true });
});
it('should normalize lastCheckInDate only if it matches UTC today', () => {
setTZ('Asia/Tokyo');
const stateDir = path.join(workDir, '.assistant');
fs.mkdirSync(stateDir, { recursive: true });
// Simulate: old v2 state where lastCheckInDate = today's UTC date.
// Migration should normalize this to today's local date.
const utcToday = new Date().toISOString().slice(0, 10);
fs.writeFileSync(
path.join(stateDir, 'state.json'),
JSON.stringify({
onboardingComplete: true,
lastCheckInDate: utcToday,
schemaVersion: 2,
}),
'utf-8'
);
migrateStateV2ToV3(workDir);
const raw = JSON.parse(fs.readFileSync(path.join(stateDir, 'state.json'), 'utf-8'));
assert.equal(raw.schemaVersion, 3);
// Should be normalized to local today
assert.match(raw.lastCheckInDate, /^\d{4}-\d{2}-\d{2}$/);
});
it('should leave clearly-past dates untouched so check-in triggers', () => {
setTZ('Asia/Tokyo');
const stateDir = path.join(workDir, '.assistant');
fs.mkdirSync(stateDir, { recursive: true });
// Old date from 5 days ago — should NOT be overwritten to today
fs.writeFileSync(
path.join(stateDir, 'state.json'),
JSON.stringify({
onboardingComplete: true,
lastCheckInDate: '2020-01-15',
schemaVersion: 2,
}),
'utf-8'
);
migrateStateV2ToV3(workDir);
const raw = JSON.parse(fs.readFileSync(path.join(stateDir, 'state.json'), 'utf-8'));
assert.equal(raw.schemaVersion, 3);
assert.equal(raw.lastCheckInDate, '2020-01-15', 'past date must be preserved');
});
it('should not re-migrate if already v3', () => {
const stateDir = path.join(workDir, '.assistant');
fs.mkdirSync(stateDir, { recursive: true });
const v3State = {
onboardingComplete: true,
lastCheckInDate: '2026-03-10',
schemaVersion: 3,
};
fs.writeFileSync(path.join(stateDir, 'state.json'), JSON.stringify(v3State), 'utf-8');
migrateStateV2ToV3(workDir);
const raw = JSON.parse(fs.readFileSync(path.join(stateDir, 'state.json'), 'utf-8'));
assert.equal(raw.lastCheckInDate, '2026-03-10', 'should not change v3 state');
});
it('loadState auto-migrates v2 state to v3', () => {
const stateDir = path.join(workDir, '.assistant');
fs.mkdirSync(stateDir, { recursive: true });
// Also need daily dir for v1→v2 migration path
fs.mkdirSync(path.join(workDir, 'memory', 'daily'), { recursive: true });
fs.mkdirSync(path.join(workDir, 'Inbox'), { recursive: true });
fs.writeFileSync(
path.join(stateDir, 'state.json'),
JSON.stringify({
onboardingComplete: true,
lastCheckInDate: '2026-01-15',
schemaVersion: 2,
}),
'utf-8'
);
const state = loadState(workDir);
assert.equal(state.schemaVersion, 3);
// Past date should be preserved, not overwritten to today
assert.equal(state.lastCheckInDate, '2026-01-15');
});
it('should preserve null lastCheckInDate during migration', () => {
const stateDir = path.join(workDir, '.assistant');
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(
path.join(stateDir, 'state.json'),
JSON.stringify({
onboardingComplete: false,
lastCheckInDate: null,
schemaVersion: 2,
}),
'utf-8'
);
migrateStateV2ToV3(workDir);
const raw = JSON.parse(fs.readFileSync(path.join(stateDir, 'state.json'), 'utf-8'));
assert.equal(raw.schemaVersion, 3);
assert.equal(raw.lastCheckInDate, null, 'null lastCheckInDate should stay null');
});
});
// ---------------------------------------------------------------------------
// 4. localDayStartAsUTC computes correct UTC boundary
// ---------------------------------------------------------------------------
describe('localDayStartAsUTC', () => {
afterEach(() => restoreTZ());
it('UTC+8: local midnight is UTC 16:00 previous day', () => {
setTZ('Asia/Shanghai');
// Fix "now" to local 2026-03-10 10:00 (= UTC 2026-03-10 02:00)
const now = new Date('2026-03-10T02:00:00Z');
const result = localDayStartAsUTC(0, now);
// Local midnight of 2026-03-10 = UTC 2026-03-09 16:00:00
assert.equal(result, '2026-03-09 16:00:00');
});
it('UTC-5: local midnight is UTC 05:00 same day', () => {
setTZ('EST'); // fixed UTC-5, no DST
const now = new Date('2026-01-10T12:00:00Z'); // local 07:00
const result = localDayStartAsUTC(0, now);
// Local midnight 2026-01-10 = UTC 2026-01-10 05:00:00
assert.equal(result, '2026-01-10 05:00:00');
});
it('daysAgo=1 returns yesterday local midnight in UTC', () => {
setTZ('Asia/Shanghai');
const now = new Date('2026-03-10T02:00:00Z');
const result = localDayStartAsUTC(1, now);
// Local midnight of 2026-03-09 = UTC 2026-03-08 16:00:00
assert.equal(result, '2026-03-08 16:00:00');
});
});
// ---------------------------------------------------------------------------
// 5. getTokenUsageStats buckets by local calendar day
// ---------------------------------------------------------------------------
describe('getTokenUsageStats local bucketing', () => {
afterEach(() => restoreTZ());
it('UTC+9: message at UTC 23:30 should bucket into next local day', () => {
setTZ('Asia/Tokyo');
const db = getDb();
// Create a session
const session = createSession('tz-test', '', undefined, '/tmp/tz-test');
// Insert a message at UTC 2026-03-09 23:30 (= JST 2026-03-10 08:30)
// Use raw SQL to control the exact timestamp
db.prepare(`
INSERT INTO messages (id, session_id, role, content, created_at, token_usage)
VALUES (?, ?, 'assistant', 'test', '2026-03-09 23:30:00', ?)
`).run(
'tz-msg-1',
session.id,
JSON.stringify({ input_tokens: 100, output_tokens: 50, cost_usd: 0.001 })
);
// Insert another message at UTC 2026-03-09 14:00 (= JST 2026-03-09 23:00)
db.prepare(`
INSERT INTO messages (id, session_id, role, content, created_at, token_usage)
VALUES (?, ?, 'assistant', 'test2', '2026-03-09 14:00:00', ?)
`).run(
'tz-msg-2',
session.id,
JSON.stringify({ input_tokens: 200, output_tokens: 100, cost_usd: 0.002 })
);
// Pin "now" to March 11 JST so the 2-day window always includes our test data.
const pinned = new Date('2026-03-10T16:00:00Z'); // = JST 2026-03-11 01:00
const stats = getTokenUsageStats(7, pinned);
// Find the daily entries for these messages
const day09 = stats.daily.filter(d => d.date === '2026-03-09');
const day10 = stats.daily.filter(d => d.date === '2026-03-10');
// msg-2 (UTC 14:00 = JST 23:00 on March 9) should be in March 9 bucket
const day09Tokens = day09.reduce((sum, d) => sum + d.input_tokens + d.output_tokens, 0);
assert.equal(day09Tokens, 300, 'March 9 local should have msg-2 (200+100 tokens)');
// msg-1 (UTC 23:30 = JST 08:30 on March 10) should be in March 10 bucket
const day10Tokens = day10.reduce((sum, d) => sum + d.input_tokens + d.output_tokens, 0);
assert.equal(day10Tokens, 150, 'March 10 local should have msg-1 (100+50 tokens)');
// Cleanup
db.prepare('DELETE FROM messages WHERE id IN (?, ?)').run('tz-msg-1', 'tz-msg-2');
db.prepare('DELETE FROM chat_sessions WHERE id = ?').run(session.id);
});
it('summary window uses local day boundary', () => {
setTZ('Asia/Tokyo');
const db = getDb();
const session = createSession('tz-test-2', '', undefined, '/tmp/tz-test-2');
// Insert a message just before local midnight (UTC 14:59 = JST 23:59 on March 9)
db.prepare(`
INSERT INTO messages (id, session_id, role, content, created_at, token_usage)
VALUES (?, ?, 'assistant', 'edge', '2026-03-09 14:59:00', ?)
`).run(
'tz-edge-1',
session.id,
JSON.stringify({ input_tokens: 500, output_tokens: 250, cost_usd: 0.005 })
);
// Pin "now" to JST March 10 so the window includes our test data.
const pinned = new Date('2026-03-10T00:00:00Z'); // = JST 2026-03-10 09:00
const stats = getTokenUsageStats(7, pinned);
const entry = stats.daily.find(d => d.date === '2026-03-09');
assert.ok(entry, 'Should find entry bucketed in local March 9');
assert.ok(entry!.input_tokens >= 500, 'Should include our message tokens');
// The same message should NOT appear in March 10 bucket
const day10Entry = stats.daily.find(d => d.date === '2026-03-10');
const day10Has = day10Entry ? day10Entry.input_tokens >= 500 : false;
assert.equal(day10Has, false, 'Should NOT bucket UTC March 9 14:59 into local March 10');
// Cleanup
db.prepare('DELETE FROM messages WHERE id = ?').run('tz-edge-1');
db.prepare('DELETE FROM chat_sessions WHERE id = ?').run(session.id);
});
});
// ---------------------------------------------------------------------------
// 6. DST transition: bucketing uses per-row offset, not a single current offset
// ---------------------------------------------------------------------------
describe('getTokenUsageStats DST transition', () => {
afterEach(() => restoreTZ());
it('messages across US DST spring-forward bucket by correct local date', () => {
// US spring forward 2026: March 8, 2:00 AM local → 3:00 AM (UTC-5 → UTC-4)
// Before: EST = UTC-5. After: EDT = UTC-4.
setTZ('America/New_York');
const db = getDb();
const session = createSession('dst-test', '', undefined, '/tmp/dst-test');
// Message BEFORE spring-forward: March 7, 11:00 PM EST = March 8 04:00 UTC
// Local date: March 7
db.prepare(`
INSERT INTO messages (id, session_id, role, content, created_at, token_usage)
VALUES (?, ?, 'assistant', 'before-dst', '2026-03-08 04:00:00', ?)
`).run(
'dst-msg-1',
session.id,
JSON.stringify({ input_tokens: 100, output_tokens: 50, cost_usd: 0.001 })
);
// Message AFTER spring-forward: March 8, 11:00 PM EDT = March 9 03:00 UTC
// Local date: March 8
db.prepare(`
INSERT INTO messages (id, session_id, role, content, created_at, token_usage)
VALUES (?, ?, 'assistant', 'after-dst', '2026-03-09 03:00:00', ?)
`).run(
'dst-msg-2',
session.id,
JSON.stringify({ input_tokens: 200, output_tokens: 100, cost_usd: 0.002 })
);
// Pin "now" to local March 10 EDT so the window includes both test messages.
const pinned = new Date('2026-03-10T12:00:00Z'); // = EDT 2026-03-10 08:00
const stats = getTokenUsageStats(7, pinned);
const mar7 = stats.daily.filter(d => d.date === '2026-03-07');
const mar8 = stats.daily.filter(d => d.date === '2026-03-08');
const mar7Tokens = mar7.reduce((sum, d) => sum + d.input_tokens + d.output_tokens, 0);
const mar8Tokens = mar8.reduce((sum, d) => sum + d.input_tokens + d.output_tokens, 0);
// msg-1 (UTC 04:00 March 8 → EST 23:00 March 7) should be in March 7 bucket
assert.equal(mar7Tokens, 150, 'March 7 local should have msg-1 (pre-DST, 100+50)');
// msg-2 (UTC 03:00 March 9 → EDT 23:00 March 8) should be in March 8 bucket
assert.equal(mar8Tokens, 300, 'March 8 local should have msg-2 (post-DST, 200+100)');
// If a single offset were used (e.g. current EDT = UTC-4):
// msg-1 would be UTC 04:00 + (-4h) = 00:00 March 8 → WRONG (should be March 7)
// This test proves per-row DST-aware bucketing works.
// Cleanup
db.prepare('DELETE FROM messages WHERE id IN (?, ?)').run('dst-msg-1', 'dst-msg-2');
db.prepare('DELETE FROM chat_sessions WHERE id = ?').run(session.id);
});
});
// ---------------------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------------------
describe('cleanup', () => {
it('close db', () => {
closeDb();
});
});
+3 -2
View File
@@ -3,6 +3,7 @@ import fs from 'fs';
import path from 'path';
import { getSetting, getDefaultProviderId, getSession } from '@/lib/db';
import { loadState, saveState, writeDailyMemory } from '@/lib/assistant-workspace';
import { getLocalDateString } from '@/lib/utils';
import { generateTextFromProvider } from '@/lib/text-generator';
const CHECK_IN_QUESTIONS = [
@@ -54,7 +55,7 @@ export async function POST(request: NextRequest) {
return `Q: ${q}\nA: ${answers[key] || '(skipped)'}`;
}).join('\n\n');
const today = new Date().toISOString().slice(0, 10);
const today = getLocalDateString();
// Read existing files for context
const memoryPath = path.join(workspacePath, 'memory.md');
@@ -147,7 +148,7 @@ ${qaText}`;
if (fs.existsSync(dailyDir)) {
const oldFiles = fs.readdirSync(dailyDir)
.filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
.filter(f => f.replace('.md', '') <= sevenDaysAgo.toISOString().slice(0, 10));
.filter(f => f.replace('.md', '') <= getLocalDateString(sevenDaysAgo));
for (const f of oldFiles) {
promoteDailyToLongTerm(workspacePath, f.replace('.md', ''));
}
+3 -2
View File
@@ -3,6 +3,7 @@ import fs from 'fs';
import path from 'path';
import { getSetting, getDefaultProviderId, getSession } from '@/lib/db';
import { loadState, saveState, ensureDailyDir, generateRootDocs } from '@/lib/assistant-workspace';
import { getLocalDateString } from '@/lib/utils';
import { generateTextFromProvider } from '@/lib/text-generator';
const QUESTIONS = [
@@ -179,11 +180,11 @@ Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaTe
generateRootDocs(workspacePath);
// Update state
const today = new Date().toISOString().slice(0, 10);
const today = getLocalDateString();
const state = loadState(workspacePath);
state.onboardingComplete = true;
state.lastCheckInDate = today; // Skip daily check-in on the day of onboarding
state.schemaVersion = 2;
state.schemaVersion = 3;
saveState(workspacePath, state);
return NextResponse.json({ success: true });
+2 -1
View File
@@ -10,6 +10,7 @@ import { ChatPermissionSelector } from './ChatPermissionSelector';
import { ContextUsageIndicator } from './ContextUsageIndicator';
import { ImageGenToggle } from './ImageGenToggle';
import { usePanel } from '@/hooks/usePanel';
import { getLocalDateString } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { PermissionPrompt } from './PermissionPrompt';
import { BatchExecutionDashboard, BatchContextSync } from './batch-image-gen';
@@ -271,7 +272,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
// If the session has no messages, the previous trigger may have failed — allow retry.
if (state.hookTriggeredSessionId === sessionId && initialMessages.length > 0) return;
const today = new Date().toISOString().slice(0, 10);
const today = getLocalDateString();
const needsOnboarding = !state.onboardingComplete;
const needsCheckIn = state.onboardingComplete && state.lastCheckInDate !== today;
@@ -2,6 +2,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { getLocalDateString } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
@@ -430,7 +431,7 @@ export function AssistantWorkspaceSection() {
);
}
const today = new Date().toISOString().slice(0, 10);
const today = getLocalDateString();
const checkInDoneToday = workspace?.state?.lastCheckInDate === today;
const tabs: Array<{ id: TabId; label: string }> = [
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { getLocalDateString } from "@/lib/utils";
import {
BarChart,
Bar,
@@ -430,7 +431,7 @@ function deriveChartData(daily: UsageStatsResponse["daily"], days: number): {
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
allDates.push(d.toISOString().slice(0, 10));
allDates.push(getLocalDateString(d));
}
// Format for recharts, filling missing dates with zeros
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getLocalDateString } from "@/lib/utils";
interface FileStatus {
exists: boolean;
@@ -74,7 +75,7 @@ export function useAssistantWorkspace() {
}
}, [workspace?.path, refetch]);
const today = new Date().toISOString().slice(0, 10);
const today = getLocalDateString();
const needsCheckIn = workspace?.path != null
&& workspace.state != null
&& workspace.state.lastCheckInDate !== today;
+67 -4
View File
@@ -1,11 +1,12 @@
import fs from 'fs';
import path from 'path';
import type { AssistantWorkspaceState, AssistantWorkspaceFiles, AssistantWorkspaceFilesV2, SearchResult } from '@/types';
import { getLocalDateString } from '@/lib/utils';
const DEFAULT_STATE: AssistantWorkspaceState = {
onboardingComplete: false,
lastCheckInDate: null,
schemaVersion: 2,
schemaVersion: 3,
};
const STATE_DIR = '.assistant';
@@ -117,6 +118,48 @@ export function migrateStateV1ToV2(dir: string): void {
saveState(dir, state);
}
/**
* Migrate schema v2 → v3: normalize lastCheckInDate from UTC to local.
*
* Before v3, lastCheckInDate was written as `new Date().toISOString().slice(0, 10)`
* which is a UTC date. After v3 it's written via `getLocalDateString()`.
*
* For users east/west of UTC, the old UTC date can differ from the local date
* by ±1 day. We only rewrite the stored date if it matches today's UTC date —
* meaning the user checked in "today" under the old semantics and the value
* just needs normalizing to local. Clearly-past dates are left as-is so the
* user correctly receives their next check-in.
*
* Edge cases that the migration cannot resolve (e.g. check-in written during
* the UTC/local day-boundary mismatch window, but migration runs after UTC
* midnight) are handled by a runtime compat fallback in needsDailyCheckIn.
*/
export function migrateStateV2ToV3(dir: string): void {
let state: AssistantWorkspaceState;
try {
const statePath = path.join(dir, STATE_DIR, STATE_FILE);
const raw = fs.readFileSync(statePath, 'utf-8');
state = JSON.parse(raw) as AssistantWorkspaceState;
} catch {
return;
}
if (state.schemaVersion >= 3) return;
if (state.lastCheckInDate) {
const utcToday = new Date().toISOString().slice(0, 10);
// Only normalize if the stored UTC date is "today" — the ambiguous case
// where the user checked in today but the UTC/local date may differ.
// Past dates are left untouched so needsDailyCheckIn triggers correctly.
if (state.lastCheckInDate === utcToday) {
state.lastCheckInDate = getLocalDateString();
}
}
state.schemaVersion = 3;
saveState(dir, state);
}
// ==========================================
// Root Docs
// ==========================================
@@ -270,8 +313,9 @@ export function initializeWorkspace(dir: string): string[] {
if (!fs.existsSync(statePath)) {
saveState(dir, { ...DEFAULT_STATE });
} else {
// Migrate existing v1 state
// Migrate existing state through all schema versions
migrateStateV1ToV2(dir);
migrateStateV2ToV3(dir);
}
// For existing directories, generate root docs and infer taxonomy
@@ -458,8 +502,16 @@ export function loadState(dir: string): AssistantWorkspaceState {
const state = JSON.parse(raw) as AssistantWorkspaceState;
// Auto-migrate on load
let migrated = false;
if (state.schemaVersion < 2) {
migrateStateV1ToV2(dir);
migrated = true;
}
if (state.schemaVersion < 3) {
migrateStateV2ToV3(dir);
migrated = true;
}
if (migrated) {
return loadState(dir); // Reload after migration
}
@@ -478,9 +530,20 @@ export function saveState(dir: string, state: AssistantWorkspaceState): void {
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf-8');
}
export function needsDailyCheckIn(state: AssistantWorkspaceState): boolean {
export function needsDailyCheckIn(state: AssistantWorkspaceState, now?: Date): boolean {
if (!state.onboardingComplete) return false;
return state.lastCheckInDate !== new Date().toISOString().slice(0, 10);
const d = now ?? new Date();
const localToday = getLocalDateString(d);
if (state.lastCheckInDate === localToday) return false;
// Compat: before schema v3, lastCheckInDate was stored as a UTC date.
// If the stored value matches today's UTC date, the user checked in "today"
// under the old semantics. Accept it to avoid a spurious re-trigger.
// Once the next check-in writes a local date + v3, this path becomes inert.
const utcToday = d.toISOString().slice(0, 10);
if (state.lastCheckInDate === utcToday) return false;
return true;
}
// ==========================================
+48 -18
View File
@@ -5,6 +5,7 @@ import fs from 'fs';
import os from 'os';
import type { ChatSession, Message, SettingsMap, TaskItem, TaskStatus, ApiProvider, CreateProviderRequest, UpdateProviderRequest, MediaJob, MediaJobStatus, MediaJobItem, MediaJobItemStatus, MediaContextEvent, BatchConfig } from '@/types';
import type { ChannelType, ChannelBinding } from './bridge/types';
import { getLocalDateString, localDayStartAsUTC } from './utils';
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot');
const DB_PATH = path.join(dataDir, 'codepilot.db');
@@ -1084,7 +1085,7 @@ export function deactivateAllProviders(): void {
// Token Usage Statistics
// ==========================================
export function getTokenUsageStats(days: number = 30): {
export function getTokenUsageStats(days: number = 30, now?: Date): {
summary: {
total_input_tokens: number;
total_output_tokens: number;
@@ -1103,6 +1104,10 @@ export function getTokenUsageStats(days: number = 30): {
} {
const db = getDb();
// Window boundary: localDayStartAsUTC computes the UTC equivalent of
// "local midnight N days ago" using Date methods, which are DST-aware.
const windowStartUTC = localDayStartAsUTC(days - 1, now);
const summary = db.prepare(`
SELECT
COALESCE(SUM(json_extract(m.token_usage, '$.input_tokens')), 0) AS total_input_tokens,
@@ -1114,8 +1119,8 @@ export function getTokenUsageStats(days: number = 30): {
FROM messages m
WHERE m.token_usage IS NOT NULL
AND json_valid(m.token_usage) = 1
AND m.created_at >= date('now', '-' || (? - 1) || ' days')
`).get(days) as {
AND m.created_at >= ?
`).get(windowStartUTC) as {
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
@@ -1124,37 +1129,62 @@ export function getTokenUsageStats(days: number = 30): {
cache_creation_tokens: number;
};
const daily = db.prepare(`
// Daily bucketing: fetch raw rows and aggregate by local date in JS.
// This handles DST correctly because getLocalDateString uses Date's
// local-time methods, which account for the historical DST offset at
// each message's timestamp — unlike a single SQL offset modifier.
const rawRows = db.prepare(`
SELECT
DATE(m.created_at) AS date,
m.created_at,
CASE
WHEN COALESCE(NULLIF(s.provider_name, ''), '') != ''
THEN s.provider_name
ELSE COALESCE(NULLIF(s.model, ''), 'unknown')
END AS model,
COALESCE(SUM(json_extract(m.token_usage, '$.input_tokens')), 0) AS input_tokens,
COALESCE(SUM(json_extract(m.token_usage, '$.output_tokens')), 0) AS output_tokens,
COALESCE(SUM(json_extract(m.token_usage, '$.cost_usd')), 0) AS cost
COALESCE(json_extract(m.token_usage, '$.input_tokens'), 0) AS input_tokens,
COALESCE(json_extract(m.token_usage, '$.output_tokens'), 0) AS output_tokens,
COALESCE(json_extract(m.token_usage, '$.cost_usd'), 0) AS cost
FROM messages m
LEFT JOIN chat_sessions s ON m.session_id = s.id
WHERE m.token_usage IS NOT NULL
AND json_valid(m.token_usage) = 1
AND m.created_at >= date('now', '-' || (? - 1) || ' days')
GROUP BY DATE(m.created_at),
CASE
WHEN COALESCE(NULLIF(s.provider_name, ''), '') != ''
THEN s.provider_name
ELSE COALESCE(NULLIF(s.model, ''), 'unknown')
END
ORDER BY date ASC
`).all(days) as Array<{
date: string;
AND m.created_at >= ?
`).all(windowStartUTC) as Array<{
created_at: string;
model: string;
input_tokens: number;
output_tokens: number;
cost: number;
}>;
// Aggregate by (local_date, model)
const buckets = new Map<string, { input_tokens: number; output_tokens: number; cost: number }>();
for (const row of rawRows) {
// Parse UTC timestamp → local date via Date methods (DST-aware per row)
const utcTs = new Date(row.created_at.replace(' ', 'T') + 'Z');
const localDate = getLocalDateString(utcTs);
const key = `${localDate}\0${row.model}`;
const existing = buckets.get(key);
if (existing) {
existing.input_tokens += row.input_tokens;
existing.output_tokens += row.output_tokens;
existing.cost += row.cost;
} else {
buckets.set(key, {
input_tokens: row.input_tokens,
output_tokens: row.output_tokens,
cost: row.cost,
});
}
}
const daily: Array<{ date: string; model: string; input_tokens: number; output_tokens: number; cost: number }> = [];
for (const [key, val] of buckets) {
const [date, model] = key.split('\0');
daily.push({ date, model, ...val });
}
daily.sort((a, b) => a.date.localeCompare(b.date));
return { summary, daily };
}
+42
View File
@@ -13,6 +13,48 @@ export function cn(...inputs: ClassValue[]) {
* these as local time. This helper normalises the string so the
* browser always treats it as UTC.
*/
/**
* Get today's date as YYYY-MM-DD in the host machine's local timezone.
*
* `new Date().toISOString().slice(0, 10)` returns UTC date, which can
* differ from the user's local date (e.g. UTC+8 at local midnight is
* still "yesterday" in UTC). This helper ensures date-based features
* (daily check-in, memory files) align with the user's wall-clock date.
*/
export function getLocalDateString(date: Date = new Date()): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
/**
* Get the host machine's local timezone offset in minutes east of UTC.
*
* `Date.getTimezoneOffset()` returns UTC−local in minutes (e.g. UTC+8 → −480).
* We negate it so callers get an intuitive "minutes east" value:
* UTC+8 → +480, UTC-5 → −300, UTC → 0.
*/
export function getLocalTzOffsetMinutes(): number {
return -(new Date().getTimezoneOffset());
}
/**
* Get the UTC datetime string for the start of a local calendar day.
*
* Used for SQL window boundaries: "give me all rows from local day X onwards".
* Converts local midnight to its UTC equivalent.
*
* @param daysAgo - how many days before today (0 = today)
* @returns UTC datetime string in "YYYY-MM-DD HH:mm:ss" format for SQLite
*/
export function localDayStartAsUTC(daysAgo: number = 0, now: Date = new Date()): string {
const d = new Date(now);
d.setDate(d.getDate() - daysAgo);
d.setHours(0, 0, 0, 0); // local midnight
return d.toISOString().replace('T', ' ').split('.')[0];
}
export function parseDBDate(dateStr: string): Date {
if (!dateStr) return new Date(0);
// Already has timezone info (ISO with Z or offset) — parse as-is