From 4412d22f3eee8f0b664f9fdad3562cb414aacacf Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 12 Dec 2025 22:40:16 -0500 Subject: [PATCH] fix(analytics): fill hourly gaps with zero values in 24H view - add fillHourlyGaps() function to usage-routes.ts - insert zero entries for hours without activity - ensures continuous timeline display in chart --- src/web-server/usage-routes.ts | 97 ++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts index c6835a90..6efc399d 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -824,6 +824,7 @@ usageRoutes.get( * * Returns hourly usage trends for chart visualization. * Query: ?since=YYYYMMDD&until=YYYYMMDD (defaults to last 24 hours) + * Fills in gaps with zero values for hours without activity. */ usageRoutes.get( '/hourly', @@ -855,12 +856,12 @@ usageRoutes.get( requests: hour.modelBreakdowns.length, })); - // Sort by hour ascending for chart display - trends.sort((a, b) => a.hour.localeCompare(b.hour)); + // Fill gaps with zero values for hours without activity + const filledTrends = fillHourlyGaps(trends, since, until); res.json({ success: true, - data: trends, + data: filledTrends, }); } catch (error) { errorResponse(res, error, 'Failed to fetch hourly usage'); @@ -868,6 +869,96 @@ usageRoutes.get( } ); +/** + * Fill gaps in hourly data with zero values + * Ensures continuous timeline for chart display + */ +function fillHourlyGaps( + data: Array<{ + hour: string; + tokens: number; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + cost: number; + modelsUsed: number; + requests: number; + }>, + since?: string, + until?: string +): typeof data { + // If no date range specified, return as-is + if (!since && !until) { + return data.sort((a, b) => a.hour.localeCompare(b.hour)); + } + + // Create a map of existing hours for O(1) lookup + const hourMap = new Map(data.map((d) => [d.hour, d])); + + // Determine the hour range + const now = new Date(); + const startDate = since + ? new Date( + parseInt(since.slice(0, 4)), + parseInt(since.slice(4, 6)) - 1, + parseInt(since.slice(6, 8)), + 0, + 0, + 0 + ) + : new Date(now.getTime() - 24 * 60 * 60 * 1000); // Default: 24 hours ago + + const endDate = until + ? new Date( + parseInt(until.slice(0, 4)), + parseInt(until.slice(4, 6)) - 1, + parseInt(until.slice(6, 8)), + 23, + 59, + 59 + ) + : now; + + const result: typeof data = []; + + // Iterate through each hour in the range + const current = new Date(startDate); + current.setMinutes(0, 0, 0); + + while (current <= endDate) { + // Format hour key as "YYYY-MM-DD HH:00" in UTC to match storage format + const year = current.getUTCFullYear(); + const month = String(current.getUTCMonth() + 1).padStart(2, '0'); + const day = String(current.getUTCDate()).padStart(2, '0'); + const hour = String(current.getUTCHours()).padStart(2, '0'); + const hourKey = `${year}-${month}-${day} ${hour}:00`; + + if (hourMap.has(hourKey)) { + const entry = hourMap.get(hourKey); + if (entry) { + result.push(entry); + } + } else { + // Insert zero entry for this hour + result.push({ + hour: hourKey, + tokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + cost: 0, + modelsUsed: 0, + requests: 0, + }); + } + + // Move to next hour + current.setTime(current.getTime() + 60 * 60 * 1000); + } + + return result; +} + /** * GET /api/usage/models *