diff --git a/src/__tests__/unit/assistant-workspace.test.ts b/src/__tests__/unit/assistant-workspace.test.ts index 58ae2d4..76b4d58 100644 --- a/src/__tests__/unit/assistant-workspace.test.ts +++ b/src/__tests__/unit/assistant-workspace.test.ts @@ -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); }); }); diff --git a/src/__tests__/unit/timezone-boundaries.test.ts b/src/__tests__/unit/timezone-boundaries.test.ts new file mode 100644 index 0000000..b1919a1 --- /dev/null +++ b/src/__tests__/unit/timezone-boundaries.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/workspace/checkin/route.ts b/src/app/api/workspace/checkin/route.ts index 0c96207..982460d 100644 --- a/src/app/api/workspace/checkin/route.ts +++ b/src/app/api/workspace/checkin/route.ts @@ -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', '')); } diff --git a/src/app/api/workspace/onboarding/route.ts b/src/app/api/workspace/onboarding/route.ts index a8b9b69..d981fc2 100644 --- a/src/app/api/workspace/onboarding/route.ts +++ b/src/app/api/workspace/onboarding/route.ts @@ -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 }); diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index d13a2ee..c3f034b 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -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; diff --git a/src/components/settings/AssistantWorkspaceSection.tsx b/src/components/settings/AssistantWorkspaceSection.tsx index c973c3c..1903e75 100644 --- a/src/components/settings/AssistantWorkspaceSection.tsx +++ b/src/components/settings/AssistantWorkspaceSection.tsx @@ -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 }> = [ diff --git a/src/components/settings/UsageStatsSection.tsx b/src/components/settings/UsageStatsSection.tsx index d1cf549..12d0c03 100644 --- a/src/components/settings/UsageStatsSection.tsx +++ b/src/components/settings/UsageStatsSection.tsx @@ -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 diff --git a/src/hooks/useAssistantWorkspace.ts b/src/hooks/useAssistantWorkspace.ts index d0b063d..b0e3fcc 100644 --- a/src/hooks/useAssistantWorkspace.ts +++ b/src/hooks/useAssistantWorkspace.ts @@ -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; diff --git a/src/lib/assistant-workspace.ts b/src/lib/assistant-workspace.ts index d31dcdc..fe7f519 100644 --- a/src/lib/assistant-workspace.ts +++ b/src/lib/assistant-workspace.ts @@ -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; } // ========================================== diff --git a/src/lib/db.ts b/src/lib/db.ts index 417eebf..c403894 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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(); + 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 }; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c397494..2fb956c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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