Skip to content

Commit abb156d

Browse files
committed
perf(analytics): instant dashboard loading with disk cache persistence
- Add React lazy loading for heavy pages (Analytics, Settings, etc.) - Remove blocking prewarmUsageCache() from server startup - Add disk cache module for persistent usage data storage - Fix disk cache not being created on first Analytics visit - Write cache immediately when daily data available (don't wait for all 3 types) Dashboard now loads in <10ms from disk cache instead of waiting for better-ccusage library on every visit.
1 parent 23e66c2 commit abb156d

File tree

4 files changed

+342
-27
lines changed

4 files changed

+342
-27
lines changed

src/web-server/index.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,8 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
7777
// Start listening
7878
return new Promise<ServerInstance>((resolve) => {
7979
server.listen(options.port, () => {
80-
// Non-blocking prewarm: load usage cache in background
81-
import('./usage-routes').then(({ prewarmUsageCache }) => {
82-
prewarmUsageCache().catch(() => {
83-
// Error already logged in prewarmUsageCache
84-
});
85-
});
86-
80+
// Usage cache loads on-demand when Analytics page is visited
81+
// This keeps server startup instant for users who don't need analytics
8782
resolve({ server, wss, cleanup });
8883
});
8984
});

src/web-server/usage-disk-cache.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Persistent Disk Cache for Usage Data
3+
*
4+
* Caches aggregated usage data to disk to avoid re-parsing 6000+ JSONL files
5+
* on every dashboard startup. Uses TTL-based invalidation with stale-while-revalidate.
6+
*
7+
* Cache location: ~/.ccs/usage-cache.json
8+
* Default TTL: 5 minutes (configurable)
9+
*/
10+
11+
import * as fs from 'fs';
12+
import * as path from 'path';
13+
import * as os from 'os';
14+
import type { DailyUsage, MonthlyUsage, SessionUsage } from 'better-ccusage/data-loader';
15+
16+
// Cache configuration
17+
const CCS_DIR = path.join(os.homedir(), '.ccs');
18+
const CACHE_FILE = path.join(CCS_DIR, 'usage-cache.json');
19+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
20+
const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max age for stale data)
21+
22+
/** Structure of the disk cache file */
23+
export interface UsageDiskCache {
24+
version: number;
25+
timestamp: number;
26+
daily: DailyUsage[];
27+
monthly: MonthlyUsage[];
28+
session: SessionUsage[];
29+
}
30+
31+
// Current cache version - increment to invalidate old caches
32+
const CACHE_VERSION = 1;
33+
34+
/**
35+
* Ensure ~/.ccs directory exists
36+
*/
37+
function ensureCcsDir(): void {
38+
if (!fs.existsSync(CCS_DIR)) {
39+
fs.mkdirSync(CCS_DIR, { recursive: true });
40+
}
41+
}
42+
43+
/**
44+
* Read usage data from disk cache
45+
* Returns null if cache is missing, corrupted, or has incompatible version
46+
* NOTE: Does NOT reject based on age - caller handles staleness via SWR pattern
47+
*/
48+
export function readDiskCache(): UsageDiskCache | null {
49+
try {
50+
if (!fs.existsSync(CACHE_FILE)) {
51+
return null;
52+
}
53+
54+
const data = fs.readFileSync(CACHE_FILE, 'utf-8');
55+
const cache: UsageDiskCache = JSON.parse(data);
56+
57+
// Version check - invalidate if schema changed
58+
if (cache.version !== CACHE_VERSION) {
59+
console.log('[i] Cache version mismatch, will refresh');
60+
return null;
61+
}
62+
63+
// Always return cache regardless of age - SWR pattern handles staleness
64+
return cache;
65+
} catch (err) {
66+
// Cache corrupted or unreadable - treat as miss
67+
console.log('[i] Cache read failed, will refresh:', (err as Error).message);
68+
return null;
69+
}
70+
}
71+
72+
/**
73+
* Check if disk cache is fresh (within TTL)
74+
*/
75+
export function isDiskCacheFresh(cache: UsageDiskCache | null): boolean {
76+
if (!cache) return false;
77+
const age = Date.now() - cache.timestamp;
78+
return age < CACHE_TTL_MS;
79+
}
80+
81+
/**
82+
* Check if disk cache is stale but usable (between TTL and STALE_TTL)
83+
*/
84+
export function isDiskCacheStale(cache: UsageDiskCache | null): boolean {
85+
if (!cache) return false;
86+
const age = Date.now() - cache.timestamp;
87+
return age >= CACHE_TTL_MS && age < STALE_TTL_MS;
88+
}
89+
90+
/**
91+
* Write usage data to disk cache
92+
*/
93+
export function writeDiskCache(
94+
daily: DailyUsage[],
95+
monthly: MonthlyUsage[],
96+
session: SessionUsage[]
97+
): void {
98+
try {
99+
ensureCcsDir();
100+
101+
const cache: UsageDiskCache = {
102+
version: CACHE_VERSION,
103+
timestamp: Date.now(),
104+
daily,
105+
monthly,
106+
session,
107+
};
108+
109+
// Write atomically using temp file + rename
110+
const tempFile = CACHE_FILE + '.tmp';
111+
fs.writeFileSync(tempFile, JSON.stringify(cache), 'utf-8');
112+
fs.renameSync(tempFile, CACHE_FILE);
113+
114+
console.log('[OK] Disk cache updated');
115+
} catch (err) {
116+
// Non-fatal - we can still serve from memory
117+
console.log('[!] Failed to write disk cache:', (err as Error).message);
118+
}
119+
}
120+
121+
/**
122+
* Get cache age in human-readable format
123+
*/
124+
export function getCacheAge(cache: UsageDiskCache | null): string {
125+
if (!cache) return 'never';
126+
127+
const age = Date.now() - cache.timestamp;
128+
const seconds = Math.floor(age / 1000);
129+
const minutes = Math.floor(seconds / 60);
130+
const hours = Math.floor(minutes / 60);
131+
132+
if (hours > 0) return `${hours}h ${minutes % 60}m ago`;
133+
if (minutes > 0) return `${minutes}m ${seconds % 60}s ago`;
134+
return `${seconds}s ago`;
135+
}
136+
137+
/**
138+
* Delete disk cache (for manual refresh)
139+
*/
140+
export function clearDiskCache(): void {
141+
try {
142+
if (fs.existsSync(CACHE_FILE)) {
143+
fs.unlinkSync(CACHE_FILE);
144+
console.log('[OK] Disk cache cleared');
145+
}
146+
} catch (err) {
147+
console.log('[!] Failed to clear disk cache:', (err as Error).message);
148+
}
149+
}

src/web-server/usage-routes.ts

Lines changed: 157 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
* Supports daily, monthly, and session-based usage data aggregation.
66
*
77
* Performance optimizations:
8-
* - TTL-based caching to reduce better-ccusage library calls
8+
* - Persistent disk cache to avoid re-parsing JSONL files on startup
9+
* - TTL-based in-memory caching for fast repeated requests
910
* - Request coalescing to prevent duplicate concurrent requests
11+
* - Non-blocking prewarm with instant stale data serving
1012
*/
1113

1214
import { Router, Request, Response } from 'express';
@@ -18,6 +20,14 @@ import {
1820
type MonthlyUsage,
1921
type SessionUsage,
2022
} from 'better-ccusage/data-loader';
23+
import {
24+
readDiskCache,
25+
writeDiskCache,
26+
isDiskCacheFresh,
27+
isDiskCacheStale,
28+
clearDiskCache,
29+
getCacheAge,
30+
} from './usage-disk-cache';
2131

2232
export const usageRoutes = Router();
2333

@@ -50,8 +60,9 @@ const CACHE_TTL = {
5060
session: 60 * 1000, // 1 minute - user may refresh
5161
};
5262

53-
// Stale-while-revalidate: max age for stale data (1 hour)
54-
const STALE_TTL = 60 * 60 * 1000;
63+
/// Stale-while-revalidate: max age for stale data (7 days)
64+
// We always show cached data to avoid blocking UI, refresh happens in background
65+
const STALE_TTL = 7 * 24 * 60 * 60 * 1000;
5566

5667
// Track when data was last fetched (for UI indicator)
5768
let lastFetchTimestamp: number | null = null;
@@ -67,12 +78,34 @@ const cache = new Map<string, CacheEntry<unknown>>();
6778
// Pending requests for coalescing (prevents duplicate concurrent calls)
6879
const pendingRequests = new Map<string, Promise<unknown>>();
6980

81+
// Track if disk cache has been loaded into memory
82+
let diskCacheInitialized = false;
83+
84+
/**
85+
* Persist cache to disk when we have enough data to be useful.
86+
* Writes immediately with whatever data is available (empty arrays for missing).
87+
* This ensures disk cache is created after first Analytics page visit.
88+
*/
89+
function persistCacheIfComplete(): void {
90+
const daily = cache.get('daily') as CacheEntry<DailyUsage[]> | undefined;
91+
const monthly = cache.get('monthly') as CacheEntry<MonthlyUsage[]> | undefined;
92+
const session = cache.get('session') as CacheEntry<SessionUsage[]> | undefined;
93+
94+
// Write if we have at least daily data (the most essential)
95+
if (daily) {
96+
writeDiskCache(daily.data, monthly?.data ?? [], session?.data ?? []);
97+
}
98+
}
99+
70100
/**
71101
* Get cached data or fetch from loader with TTL
72102
* Also coalesces concurrent requests to prevent duplicate library calls
73103
* Implements stale-while-revalidate pattern for instant responses
74104
*/
75105
async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
106+
// Ensure disk cache is loaded on first request
107+
ensureDiskCacheLoaded();
108+
76109
const cached = cache.get(key) as CacheEntry<T> | undefined;
77110
const now = Date.now();
78111

@@ -89,6 +122,8 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
89122
.then((data) => {
90123
cache.set(key, { data, timestamp: Date.now() });
91124
lastFetchTimestamp = Date.now();
125+
// Persist to disk if all data types are cached
126+
persistCacheIfComplete();
92127
})
93128
.catch((err) => {
94129
console.error(`[!] Background refresh failed for ${key}:`, err);
@@ -112,6 +147,8 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
112147
.then((data) => {
113148
cache.set(key, { data, timestamp: Date.now() });
114149
lastFetchTimestamp = Date.now();
150+
// Persist to disk if all data types are cached
151+
persistCacheIfComplete();
115152
return data;
116153
})
117154
.finally(() => {
@@ -148,24 +185,135 @@ async function getCachedSessionData(): Promise<SessionUsage[]> {
148185
*/
149186
export function clearUsageCache(): void {
150187
cache.clear();
188+
clearDiskCache();
189+
// Reset so next API call will try to reload from disk/source
190+
diskCacheInitialized = false;
191+
}
192+
193+
// Track if background refresh is in progress
194+
let isRefreshing = false;
195+
196+
/**
197+
* Load fresh data from better-ccusage and update both memory and disk caches
198+
*/
199+
async function refreshFromSource(): Promise<{
200+
daily: DailyUsage[];
201+
monthly: MonthlyUsage[];
202+
session: SessionUsage[];
203+
}> {
204+
const [daily, monthly, session] = await Promise.all([
205+
loadDailyUsageData() as Promise<DailyUsage[]>,
206+
loadMonthlyUsageData() as Promise<MonthlyUsage[]>,
207+
loadSessionData() as Promise<SessionUsage[]>,
208+
]);
209+
210+
// Update in-memory cache
211+
const now = Date.now();
212+
cache.set('daily', { data: daily, timestamp: now });
213+
cache.set('monthly', { data: monthly, timestamp: now });
214+
cache.set('session', { data: session, timestamp: now });
215+
lastFetchTimestamp = now;
216+
217+
// Persist to disk
218+
writeDiskCache(daily, monthly, session);
219+
220+
return { daily, monthly, session };
221+
}
222+
223+
// ============================================================================
224+
// Module Initialization - Load disk cache immediately for instant API responses
225+
// ============================================================================
226+
227+
/**
228+
* Initialize in-memory cache from disk cache (lazy - called on first API request).
229+
* This ensures first API request gets instant data without calling better-ccusage.
230+
* Background refresh is NOT triggered here - it happens via SWR pattern in getCachedData().
231+
*/
232+
function ensureDiskCacheLoaded(): void {
233+
if (diskCacheInitialized) return;
234+
diskCacheInitialized = true;
235+
236+
const diskCache = readDiskCache();
237+
if (!diskCache) return;
238+
239+
// Load disk cache into memory (regardless of freshness)
240+
// SWR pattern in getCachedData() will handle background refresh
241+
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
242+
cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
243+
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
244+
lastFetchTimestamp = diskCache.timestamp;
151245
}
152246

153247
/**
154248
* Pre-warm usage caches on server startup
155-
* Loads all usage data into cache so first user request is instant
156-
* Returns timestamp when cache was populated
249+
*
250+
* Strategy:
251+
* 1. Check disk cache - if fresh, use it (instant startup)
252+
* 2. If stale, use it immediately but trigger background refresh
253+
* 3. If no cache, return immediately and let first request trigger load
254+
*
255+
* This ensures dashboard opens in <1s regardless of cache state
157256
*/
158-
export async function prewarmUsageCache(): Promise<{ timestamp: number; elapsed: number }> {
257+
export async function prewarmUsageCache(): Promise<{
258+
timestamp: number;
259+
elapsed: number;
260+
source: string;
261+
}> {
159262
const start = Date.now();
160263
console.log('[i] Pre-warming usage cache...');
161264

162265
try {
163-
await Promise.all([getCachedDailyData(), getCachedMonthlyData(), getCachedSessionData()]);
266+
const diskCache = readDiskCache();
267+
268+
// Fresh disk cache - use it directly
269+
if (diskCache && isDiskCacheFresh(diskCache)) {
270+
const now = Date.now();
271+
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
272+
cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
273+
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
274+
lastFetchTimestamp = diskCache.timestamp;
275+
276+
const elapsed = Date.now() - start;
277+
console.log(
278+
`[OK] Usage cache ready from disk (${elapsed}ms, cached ${getCacheAge(diskCache)})`
279+
);
280+
return { timestamp: now, elapsed, source: 'disk-fresh' };
281+
}
282+
283+
// Stale disk cache - use it immediately, refresh in background
284+
if (diskCache && isDiskCacheStale(diskCache)) {
285+
const now = Date.now();
286+
cache.set('daily', { data: diskCache.daily, timestamp: diskCache.timestamp });
287+
cache.set('monthly', { data: diskCache.monthly, timestamp: diskCache.timestamp });
288+
cache.set('session', { data: diskCache.session, timestamp: diskCache.timestamp });
289+
lastFetchTimestamp = diskCache.timestamp;
290+
291+
const elapsed = Date.now() - start;
292+
console.log(
293+
`[OK] Usage cache ready from disk (${elapsed}ms, stale ${getCacheAge(diskCache)}, refreshing...)`
294+
);
295+
296+
// Background refresh
297+
if (!isRefreshing) {
298+
isRefreshing = true;
299+
refreshFromSource()
300+
.then(() => console.log('[OK] Background refresh complete'))
301+
.catch((err) => console.error('[!] Background refresh failed:', err))
302+
.finally(() => {
303+
isRefreshing = false;
304+
});
305+
}
306+
307+
return { timestamp: now, elapsed, source: 'disk-stale' };
308+
}
309+
310+
// No usable disk cache - refresh from source (blocking for first startup only)
311+
console.log('[i] No disk cache, loading from source...');
312+
await refreshFromSource();
164313

165314
const elapsed = Date.now() - start;
166-
lastFetchTimestamp = Date.now();
167315
console.log(`[OK] Usage cache ready (${elapsed}ms)`);
168-
return { timestamp: lastFetchTimestamp, elapsed };
316+
return { timestamp: Date.now(), elapsed, source: 'fresh' };
169317
} catch (err) {
170318
console.error('[!] Failed to prewarm usage cache:', err);
171319
throw err;

0 commit comments

Comments
 (0)