aidxnCC/lib/services/ai.service.ts

919 lines
30 KiB
TypeScript

import {
CCData,
DailyData,
HeatmapDay,
DailyDataWithTrend,
ModelUsage,
TokenTypeUsage,
TimeRangeKey,
} from '@/lib/types'
/**
* Configuration for heatmap color palette.
*
* @remarks
* Defines the color scheme used for GitHub-style activity heatmaps,
* with support for empty days and multi-step gradient scales.
*
* @example
* ```ts
* const palette: HeatmapPalette = {
* empty: '#1f2937', // Gray for zero activity
* steps: ['#4a3328', '#6b4530', '#8d5738', '#c15f3c'] // Brown gradient
* }
* ```
*
* @category Services
* @public
*/
export interface HeatmapPalette {
/** Color for days with no activity (value = 0) */
empty: string
/** Array of colors representing increasing activity levels */
steps: string[]
}
/**
* Comprehensive AI usage statistics and analytics.
*
* @remarks
* Provides a complete snapshot of AI usage including streaks, costs,
* token consumption, time-based averages, and token distribution breakdowns.
*
* @example
* ```ts
* const stats = AIService.getAIStats(ccData)
* console.log(`Streak: ${stats.streakFormatted}`)
* console.log(`Total cost: $${stats.totalCost.toFixed(2)}`)
* console.log(`Last 7 days: $${stats.last7Days.cost.toFixed(2)}`)
* ```
*
* @category Services
* @public
*/
export interface AIStatsResult {
/** Current activity streak in days */
streak: number
/** Formatted streak string (e.g., '2y', '3mo', '5d') */
streakFormatted: string
/** Total cost across all time */
totalCost: number
/** Total tokens consumed across all time */
totalTokens: number
/** Average daily cost across all recorded days */
dailyAverage: number
/** Statistics for the last 7 days */
last7Days: {
cost: number
tokens: number
dailyAverage: number
}
/** Statistics for the last 30 days */
last30Days: {
cost: number
tokens: number
dailyAverage: number
}
/** Token type breakdown */
tokenBreakdown: {
input: number
output: number
cache: number
}
}
/**
* Service for AI usage analytics, token tracking, and cost calculations.
*
* @remarks
* Provides comprehensive utilities for analyzing Claude API usage including:
* - **Activity streaks** - Track consecutive days of usage
* - **Trend analysis** - Linear regression for cost and token projections
* - **Heatmap generation** - GitHub-style activity visualization
* - **Time-range filtering** - Support for 7d, 1m, 3m, 6m, 1y, all
* - **Model analytics** - Usage breakdown by model
* - **Token composition** - Input, output, and cache token analysis
* - **Statistics** - Comprehensive metrics and aggregations
*
* All date operations use UTC to ensure consistency across timezones.
*
* @example
* ```ts
* import { AIService } from '@/lib/services'
*
* // Get activity streak
* const streak = AIService.computeStreak(ccData.daily)
* console.log(`${streak} day streak`)
*
* // Filter data by time range
* const last30Days = AIService.filterDailyByRange(ccData.daily, '1m')
*
* // Build trend data with linear regression
* const trendData = AIService.buildDailyTrendData(ccData.daily)
*
* // Generate heatmap for current year
* const heatmap = AIService.prepareHeatmapData(ccData.daily)
*
* // Get comprehensive statistics
* const stats = AIService.getAIStats(ccData)
* ```
*
* @category Services
* @public
*/
export class AIService {
private static readonly MODEL_LABELS: Record<string, string> = {
'claude-sonnet-4-20250514': 'Claude Sonnet 4',
'claude-sonnet-4-5-20250929': 'Claude Sonnet 4.5',
'claude-opus-4-1-20250805': 'Claude Opus 4.1',
'gpt-5': 'GPT-5',
'gpt-5-codex': 'GPT-5 Codex',
}
private static readonly RANGE_CONFIG: Record<Exclude<TimeRangeKey, 'all'>, { days?: number; months?: number }> = {
'7d': { days: 7 },
'1m': { months: 1 },
'3m': { months: 3 },
'6m': { months: 6 },
'1y': { months: 12 },
}
/**
* Converts a model ID to a human-readable label.
*
* @param modelName - The model identifier (e.g., 'claude-sonnet-4-20250514')
* @returns {string} Human-readable model name or original modelName if not found
*
* @example
* ```ts
* const label = AIService.getModelLabel('claude-sonnet-4-20250514')
* console.log(label) // 'Claude Sonnet 4'
* ```
*/
static getModelLabel(modelName: string): string {
return this.MODEL_LABELS[modelName] || modelName
}
/**
* Computes the current activity streak in days.
* A streak is broken if there's any day without usage between the latest day and today.
*
* @param daily - Array of daily usage data (doesn't need to be sorted)
* @returns {number} Consecutive days with activity from most recent date
*
* @example
* ```ts
* const streak = AIService.computeStreak(dailyData)
* console.log(`Current streak: ${streak} days`)
* ```
*/
static computeStreak(daily: DailyData[]): number {
if (!daily.length) return 0
const datesSet = new Set(daily.map(d => d.date))
const latest = daily
.map(d => new Date(d.date + 'T00:00:00Z'))
.reduce((a, b) => (a > b ? a : b))
const toKey = (d: Date) => {
const y = d.getUTCFullYear()
const m = (d.getUTCMonth() + 1).toString().padStart(2, '0')
const day = d.getUTCDate().toString().padStart(2, '0')
return `${y}-${m}-${day}`
}
let count = 0
for (
let d = new Date(latest.getTime());
;
d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - 1))
) {
const key = toKey(d)
if (datesSet.has(key)) count++
else break
}
return count
}
/**
* Formats a number of days into a compact string representation.
*
* @param days - Number of days to format
* @returns {`${number}${'y' | 'mo' | 'w' | 'd'}`} Compact string like '2y', '3mo', '5w', or '10d'
*
* @example
* ```ts
* console.log(AIService.formatStreakCompact(400)) // '1y'
* console.log(AIService.formatStreakCompact(45)) // '1mo'
* console.log(AIService.formatStreakCompact(14)) // '2w'
* console.log(AIService.formatStreakCompact(5)) // '5d'
* ```
*/
static formatStreakCompact(days: number): string {
if (days >= 365) return `${Math.floor(days / 365)}y`
if (days >= 30) return `${Math.floor(days / 30)}mo`
if (days >= 7) return `${Math.floor(days / 7)}w`
return `${days}d`
}
/**
* Normalizes a date to UTC midnight (start of day).
*
* @param date - Date to normalize
* @returns {Date} Date set to UTC midnight
* @internal
*/
private static startOfDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}
/**
* Converts a Date to YYYY-MM-DD string format in UTC.
*
* @param date - Date to convert
* @returns {string} Date string like '2025-01-15'
* @internal
*/
private static toDateKey(date: Date): string {
return `${date.getUTCFullYear()}-${(date.getUTCMonth() + 1).toString().padStart(2, '0')}-${date.getUTCDate().toString().padStart(2, '0')}`
}
/**
* Subtracts months from a date in UTC, handling month-end edge cases.
*
* @param date - Starting date
* @param months - Number of months to subtract
* @returns {Date} Date with months subtracted, clamped to valid days
* @internal
*/
private static subtractUtcMonths(date: Date, months: number): Date {
const targetMonthIndex = date.getUTCMonth() - months
const anchor = new Date(Date.UTC(date.getUTCFullYear(), targetMonthIndex, 1))
const endOfAnchorMonth = new Date(Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth() + 1, 0))
const clampedDay = Math.min(date.getUTCDate(), endOfAnchorMonth.getUTCDate())
anchor.setUTCDate(clampedDay)
return this.startOfDay(anchor)
}
/**
* Creates an empty DailyData object for dates with no activity.
*
* @param dateKey - Date string in YYYY-MM-DD format
* @returns {DailyData} Object with all metrics set to zero
* @internal
*/
private static emptyDay(dateKey: string): DailyData {
return {
date: dateKey,
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
totalCost: 0,
modelsUsed: [],
modelBreakdowns: [],
}
}
/**
* Builds a continuous daily series from start to end date, filling gaps with empty days.
*
* @param start - Start date (inclusive)
* @param end - End date (inclusive)
* @param byDate - Map of date keys to DailyData
* @returns {DailyData[]} Continuous array with one entry per day
* @internal
*/
private static buildFilledRange(
start: Date,
end: Date,
byDate: Map<string, DailyData>
): DailyData[] {
const series: DailyData[] = []
for (
let cursor = new Date(start.getTime());
cursor <= end;
cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), cursor.getUTCDate() + 1))
) {
const key = this.toDateKey(cursor)
series.push(byDate.get(key) ?? this.emptyDay(key))
}
return series
}
/**
* Fills gaps in daily data with empty days between the first and last date.
* Ensures a continuous time series for visualization.
*
* @param daily - Array of daily usage data (will be sorted internally)
* @returns {DailyData[]} Continuous array with empty days filled between first and last date
*
* @example
* ```ts
* const filled = AIService.computeFilledDailyRange(sparseData)
* // Now every day from first to last has an entry
* ```
*/
static computeFilledDailyRange(daily: DailyData[]): DailyData[] {
if (!daily.length) return []
const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
const start = this.startOfDay(new Date(sorted[0].date + 'T00:00:00Z'))
const end = this.startOfDay(new Date(sorted[sorted.length - 1].date + 'T00:00:00Z'))
const byDate = new Map(sorted.map(d => [d.date, d] as const))
return this.buildFilledRange(start, end, byDate)
}
/**
* Transforms daily token data into normalized values for charting.
* Converts token counts to thousands (K) or millions (M) for readability.
*
* @param daily - Array of daily usage data
* @returns {Array<{ date: string; inputTokens: number; outputTokens: number; cacheTokens: number }>}
* Array of objects with date and normalized token counts (inputTokens & outputTokens in thousands, cacheTokens in millions)
*
* @example
* ```ts
* const chartData = AIService.buildTokenCompositionData(dailyData)
* // [{ date: '2025-01-01', inputTokens: 150.5, outputTokens: 75.2, cacheTokens: 2.3 }]
* ```
*/
static buildTokenCompositionData(daily: DailyData[]) {
return daily.map(day => ({
date: day.date,
inputTokens: day.inputTokens / 1000, // Convert to K
outputTokens: day.outputTokens / 1000, // Convert to K
cacheTokens: (day.cacheCreationTokens + day.cacheReadTokens) / 1000000, // Convert to M
}))
}
/**
* Builds daily data with linear regression trend lines for cost and token usage.
* Uses least-squares method to compute trend from first non-zero data point.
*
* @param daily - Array of daily usage data
* @returns {DailyDataWithTrend[]} Array of daily data with added trend properties:
* - `costTrend: number | null` - Linear regression projected cost
* - `tokensTrend: number | null` - Linear regression projected tokens (in millions)
* - `inputTokensNormalized: number` - Input tokens / 1000
* - `outputTokensNormalized: number` - Output tokens / 1000
* - `cacheTokensNormalized: number` - Cache tokens / 1000000
*
* @example
* ```ts
* const trendData = AIService.buildDailyTrendData(dailyData)
* // Each day includes costTrend and tokensTrend for visualization
* ```
*/
static buildDailyTrendData(daily: DailyData[]): DailyDataWithTrend[] {
const filled = this.computeFilledDailyRange(daily)
const rows = filled.map(day => ({
...day,
costTrend: null as number | null,
tokensTrend: null as number | null,
inputTokensNormalized: day.inputTokens / 1000,
outputTokensNormalized: day.outputTokens / 1000,
cacheTokensNormalized: (day.cacheCreationTokens + day.cacheReadTokens) / 1000000,
}))
const applyTrend = (
startIndex: number,
valueAccessor: (row: typeof rows[number]) => number,
assign: (row: typeof rows[number], value: number | null) => void,
) => {
if (startIndex === -1 || startIndex >= rows.length) {
return
}
const subset = rows.slice(startIndex)
if (!subset.length) {
return
}
if (subset.length === 1) {
const value = Math.max(valueAccessor(subset[0]), 0)
assign(subset[0], value)
return
}
const n = subset.length
const sumX = (n * (n - 1)) / 2
const sumX2 = ((n - 1) * n * (2 * n - 1)) / 6
let sumY = 0
let sumXY = 0
subset.forEach((row, idx) => {
const y = valueAccessor(row)
sumY += y
sumXY += idx * y
})
const denom = n * sumX2 - sumX * sumX
const slope = denom !== 0 ? (n * sumXY - sumX * sumY) / denom : 0
const intercept = (sumY - slope * sumX) / n
subset.forEach((row, idx) => {
const projected = Math.max(intercept + slope * idx, 0)
assign(row, projected)
})
}
const firstCostIndex = rows.findIndex(row => row.totalCost > 0)
const firstTokenIndex = rows.findIndex(row => row.totalTokens > 0)
applyTrend(firstCostIndex, row => row.totalCost, (row, value) => {
row.costTrend = value
})
applyTrend(firstTokenIndex, row => row.totalTokens / 1000000, (row, value) => {
row.tokensTrend = value
})
return rows
}
/**
* Generates a heatmap grid for the current year.
*
* @param daily - Array of daily usage data
* @returns {(HeatmapDay | null)[][]} 2D array where each inner array is a week (Sunday-Saturday),
* null represents days before year start
*
* @remarks
* Creates a calendar-style heatmap visualization:
* - Covers January 1 through today of current year
* - Organized by weeks (Sunday-Saturday)
* - Fills missing days before year start with null
* - Pads incomplete final week with null
* - Uses UTC for consistent timezone handling
*
* @example
* ```ts
* const heatmap = AIService.prepareHeatmapData(ccData.daily)
* // Returns: [[null, null, {day1}, {day2}, ...], [{day8}, ...]]
*
* // Use with getHeatmapColor for visualization
* heatmap.forEach(week => {
* week.forEach(day => {
* if (day) {
* const color = AIService.getHeatmapColor(maxCost, day.cost)
* // Render day cell with color
* }
* })
* })
* ```
*/
static prepareHeatmapData(daily: DailyData[]): (HeatmapDay | null)[][] {
const dayMap = new Map<string, DailyData>()
daily.forEach(day => {
dayMap.set(day.date, day)
})
const today = new Date()
const startOfYear = new Date(Date.UTC(today.getUTCFullYear(), 0, 1))
const endDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()))
const weeks: (HeatmapDay | null)[][] = []
let currentWeek: (HeatmapDay | null)[] = []
const firstDay = startOfYear.getUTCDay()
const startDate = new Date(startOfYear)
startDate.setUTCDate(startDate.getUTCDate() - firstDay)
for (
let d = new Date(startDate);
d <= endDate;
d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1))
) {
if (d < startOfYear) {
currentWeek.push(null)
if (d.getUTCDay() === 6) {
weeks.push(currentWeek)
currentWeek = []
}
continue
}
const dateStr = `${d.getUTCFullYear()}-${(d.getUTCMonth() + 1).toString().padStart(2, '0')}-${d.getUTCDate().toString().padStart(2, '0')}`
const dayData = dayMap.get(dateStr)
currentWeek.push({
date: dateStr,
value: dayData ? dayData.totalCost : 0,
tokens: dayData ? dayData.totalTokens : 0,
cost: dayData ? dayData.totalCost : 0,
day: d.getUTCDay(),
formattedDate: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
})
if (d.getUTCDay() === 6 || d.getTime() === endDate.getTime()) {
while (currentWeek.length < 7) {
currentWeek.push(null)
}
weeks.push(currentWeek)
currentWeek = []
}
}
return weeks
}
/**
* Determines the heatmap color for a given activity value.
*
* @param maxCost - Maximum cost value in the dataset (for normalization)
* @param value - Current day's cost value
* @param palette - Color palette configuration (empty color + gradient steps)
* @returns {string} Hex color string
*
* @remarks
* Maps activity values to colors using a multi-step gradient:
* - Values ≤ 0 return the empty color
* - Values are normalized to 0-1 range using maxCost
* - Normalized values are mapped to palette steps
* - Handles edge cases (division by zero, infinite values)
*
* @example
* ```ts
* const maxCost = 10.50
* const dayCost = 7.25
*
* // With default palette (brown gradient)
* const color = AIService.getHeatmapColor(maxCost, dayCost)
* // Returns: '#8d5738' (upper-mid range color)
*
* // With custom palette
* const customColor = AIService.getHeatmapColor(maxCost, dayCost, {
* empty: '#1a1a1a',
* steps: ['#fee', '#fcc', '#faa', '#f88', '#f00']
* })
* ```
*
* @example
* ```ts
* // Zero activity
* AIService.getHeatmapColor(100, 0) // Returns empty color
*
* // Low activity (0-25% of max)
* AIService.getHeatmapColor(100, 20) // Returns steps[0]
*
* // High activity (75-100% of max)
* AIService.getHeatmapColor(100, 95) // Returns steps[3]
* ```
*/
static getHeatmapColor(
maxCost: number,
value: number,
palette: HeatmapPalette = {
empty: '#1f2937',
steps: ['#4a3328', '#6b4530', '#8d5738', '#c15f3c']
}
): string {
if (value <= 0 || maxCost <= 0) return palette.empty
const ratio = value / maxCost
if (!Number.isFinite(ratio) || ratio <= 0) {
return palette.empty
}
const steps = palette.steps.length ? palette.steps : ['#4a3328', '#6b4530', '#8d5738', '#c15f3c']
const clampedRatio = Math.min(Math.max(ratio, 0), 1)
const index = Math.min(Math.floor(clampedRatio * steps.length), steps.length - 1)
return steps[index]
}
/**
* Aggregates model usage data across all daily records.
*
* @param daily - Array of daily usage data
* @returns {ModelUsage[]} Array of model usage with cost, sorted by cost (descending)
*
* @remarks
* Processes model breakdowns to create a cost-based usage summary:
* - Converts model IDs to human-readable labels
* - Aggregates costs by model across all days
* - Calculates percentage of total cost for each model
* - Sorts by cost (highest first)
*
* @example
* ```ts
* const modelData = AIService.buildModelUsageData(ccData.daily)
* // Returns: [
* // { name: 'Claude Sonnet 4.5', value: 45.30, percentage: 65.2 },
* // { name: 'Claude Opus 4.1', value: 24.15, percentage: 34.8 }
* // ]
*
* // Use for pie charts or model comparison
* modelData.forEach(model => {
* console.log(`${model.name}: $${model.value.toFixed(2)} (${model.percentage.toFixed(1)}%)`)
* })
* ```
*/
static buildModelUsageData(daily: DailyData[]): ModelUsage[] {
const raw = daily.reduce((acc, day) => {
day.modelBreakdowns.forEach(model => {
const label = this.getModelLabel(model.modelName)
const existing = acc.find(m => m.name === label)
if (existing) {
existing.value += model.cost
} else {
acc.push({ name: label, value: model.cost })
}
})
return acc
}, [] as ModelUsage[])
const sorted = raw.sort((a, b) => b.value - a.value)
const total = sorted.reduce((sum, m) => sum + m.value, 0)
return sorted.map(m => ({
...m,
percentage: total > 0 ? (m.value / total) * 100 : 0
}))
}
/**
* Breaks down token usage by type (input, output, cache creation, cache read).
*
* @param totals - Aggregated totals from CCData
* @returns {TokenTypeUsage[]} Array of token types with counts and percentages
*
* @remarks
* Creates a distribution analysis of token consumption:
* - Input tokens (user prompts)
* - Output tokens (model responses)
* - Cache creation tokens (new cache entries)
* - Cache read tokens (cache hits)
*
* Useful for understanding token usage patterns and cache effectiveness.
*
* @example
* ```ts
* const breakdown = AIService.buildTokenTypeData(ccData.totals)
* // Returns: [
* // { name: 'Input', value: 1500000, percentage: 45.5 },
* // { name: 'Output', value: 850000, percentage: 25.8 },
* // { name: 'Cache Creation', value: 500000, percentage: 15.2 },
* // { name: 'Cache Read', value: 450000, percentage: 13.7 }
* // ]
*
* // Calculate cache efficiency
* const cacheCreation = breakdown.find(t => t.name === 'Cache Creation')
* const cacheRead = breakdown.find(t => t.name === 'Cache Read')
* const cacheHitRate = cacheRead / (cacheCreation + cacheRead)
* ```
*/
static buildTokenTypeData(totals: CCData['totals']): TokenTypeUsage[] {
const data = [
{ name: 'Input', value: totals.inputTokens },
{ name: 'Output', value: totals.outputTokens },
{ name: 'Cache Creation', value: totals.cacheCreationTokens },
{ name: 'Cache Read', value: totals.cacheReadTokens },
]
const total = data.reduce((sum, t) => sum + t.value, 0)
return data.map(t => ({
...t,
percentage: total > 0 ? (t.value / total) * 100 : 0
}))
}
/**
* Filters daily data to a specific time range with gap filling.
*
* @param daily - Array of daily usage data
* @param range - Time range key ('7d', '1m', '3m', '6m', '1y', 'all')
* @param options - Optional configuration
* @param options.endDate - Custom end date (defaults to last non-empty day)
* @returns {DailyData[]} Filtered and filled daily data for the specified range
*
* @remarks
* Advanced time-range filtering with intelligent date handling:
* - **'all'**: Returns all data from first to last day
* - **'7d'**: Last 7 days (day-based calculation)
* - **'1m', '3m', '6m', '1y'**: Month-based calculation (handles month-end edge cases)
* - Fills gaps with empty days for continuous series
* - Uses last non-empty day as default end date
* - All date operations in UTC
*
* @example
* ```ts
* // Get last 30 days of data
* const last30Days = AIService.filterDailyByRange(ccData.daily, '1m')
*
* // Get last 7 days
* const lastWeek = AIService.filterDailyByRange(ccData.daily, '7d')
*
* // Get all historical data
* const allData = AIService.filterDailyByRange(ccData.daily, 'all')
*
* // Custom end date (e.g., for historical analysis)
* const customRange = AIService.filterDailyByRange(ccData.daily, '1m', {
* endDate: new Date('2024-12-31')
* })
* ```
*
* @example
* ```ts
* // Compare costs across different time ranges
* const ranges: TimeRangeKey[] = ['7d', '1m', '3m']
* ranges.forEach(range => {
* const data = AIService.filterDailyByRange(ccData.daily, range)
* const totals = AIService.computeTotalsFromDaily(data)
* console.log(`${range}: $${totals.totalCost.toFixed(2)}`)
* })
* ```
*/
static filterDailyByRange(
daily: DailyData[],
range: TimeRangeKey,
options: { endDate?: Date } = {},
): DailyData[] {
if (!daily.length) return []
const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
let effectiveEnd: Date
if (options.endDate) {
effectiveEnd = this.startOfDay(options.endDate)
} else {
const lastNonEmptyDay = sorted.filter(d => d.totalCost > 0).pop()
const lastDate = lastNonEmptyDay?.date || sorted[sorted.length - 1]?.date
if (!lastDate) return []
effectiveEnd = this.startOfDay(new Date(lastDate + 'T00:00:00Z'))
}
const trimmed = sorted.filter(day => {
const current = new Date(day.date + 'T00:00:00Z')
return current <= effectiveEnd
})
if (!trimmed.length) return []
const byDate = new Map(trimmed.map(day => [day.date, day] as const))
const earliest = this.startOfDay(new Date(trimmed[0].date + 'T00:00:00Z'))
if (range === 'all') {
return this.buildFilledRange(earliest, effectiveEnd, byDate)
}
const config = this.RANGE_CONFIG[range as Exclude<TimeRangeKey, 'all'>]
if (!config) {
return this.buildFilledRange(earliest, effectiveEnd, byDate)
}
let start: Date
if (config.days) {
start = new Date(Date.UTC(
effectiveEnd.getUTCFullYear(),
effectiveEnd.getUTCMonth(),
effectiveEnd.getUTCDate() - (config.days - 1)
))
} else {
start = this.subtractUtcMonths(effectiveEnd, config.months ?? 0)
}
if (start > effectiveEnd) {
start = new Date(effectiveEnd)
}
return this.buildFilledRange(start, effectiveEnd, byDate)
}
/**
* Computes aggregate totals from an array of daily data.
*
* @param daily - Array of daily usage data
* @returns {CCData['totals']} Aggregated totals for all metrics
*
* @remarks
* Sums all token and cost metrics across the provided daily data:
* - Input tokens
* - Output tokens
* - Cache creation tokens
* - Cache read tokens
* - Total tokens
* - Total cost
*
* Useful for computing subtotals after filtering by time range.
*
* @example
* ```ts
* // Compute totals for last 30 days
* const last30Days = AIService.filterDailyByRange(ccData.daily, '1m')
* const totals = AIService.computeTotalsFromDaily(last30Days)
*
* console.log(`Total cost: $${totals.totalCost.toFixed(2)}`)
* console.log(`Total tokens: ${totals.totalTokens.toLocaleString()}`)
* console.log(`Cache hit rate: ${(totals.cacheReadTokens / totals.totalTokens * 100).toFixed(1)}%`)
* ```
*/
static computeTotalsFromDaily(daily: DailyData[]): CCData['totals'] {
return daily.reduce<CCData['totals']>((acc, day) => ({
inputTokens: acc.inputTokens + day.inputTokens,
outputTokens: acc.outputTokens + day.outputTokens,
cacheCreationTokens: acc.cacheCreationTokens + day.cacheCreationTokens,
cacheReadTokens: acc.cacheReadTokens + day.cacheReadTokens,
totalCost: acc.totalCost + day.totalCost,
totalTokens: acc.totalTokens + day.totalTokens,
}), {
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalCost: 0,
totalTokens: 0,
})
}
/**
* Computes AI usage statistics and analytics.
*
* @param data - Complete CCData object with daily data and totals
* @returns {AIStatsResult} Comprehensive usage statistics
*
* @remarks
* Generates a complete analytics snapshot including:
* - Current activity streak
* - Overall totals (cost, tokens)
* - Time-based averages (daily, last 7 days, last 30 days)
* - Token type breakdown
*
* This is the primary method for dashboard and summary views.
*
* @example
* ```ts
* const stats = AIService.getAIStats(ccData)
*
* // Display overview
* console.log(`Streak: ${stats.streakFormatted}`)
* console.log(`Total spent: $${stats.totalCost.toFixed(2)}`)
* console.log(`Daily average: $${stats.dailyAverage.toFixed(2)}`)
*
* // Last 7 days analysis
* console.log(`Last 7 days: $${stats.last7Days.cost.toFixed(2)}`)
* console.log(`7-day daily avg: $${stats.last7Days.dailyAverage.toFixed(2)}`)
*
* // Token distribution
* const { input, output, cache } = stats.tokenBreakdown
* const total = input + output + cache
* console.log(`Input: ${(input/total*100).toFixed(1)}%`)
* console.log(`Output: ${(output/total*100).toFixed(1)}%`)
* console.log(`Cache: ${(cache/total*100).toFixed(1)}%`)
* ```
*
* @example
* ```ts
* // Use for dashboard cards
* const stats = AIService.getAIStats(ccData)
* return (
* <Dashboard>
* <StatCard title="Streak" value={stats.streakFormatted} />
* <StatCard title="Total Cost" value={`$${stats.totalCost.toFixed(2)}`} />
* <StatCard title="Last 7 Days" value={`$${stats.last7Days.cost.toFixed(2)}`} />
* </Dashboard>
* )
* ```
*/
static getAIStats(data: CCData): AIStatsResult {
const streak = this.computeStreak(data.daily)
const dailyAverage = data.daily.length > 0
? data.totals.totalCost / data.daily.length
: 0
const last7Days = data.daily.slice(-7)
const last7DaysCost = last7Days.reduce((sum, d) => sum + d.totalCost, 0)
const last7DaysTokens = last7Days.reduce((sum, d) => sum + d.totalTokens, 0)
const last30Days = data.daily.slice(-30)
const last30DaysCost = last30Days.reduce((sum, d) => sum + d.totalCost, 0)
const last30DaysTokens = last30Days.reduce((sum, d) => sum + d.totalTokens, 0)
return {
streak,
streakFormatted: this.formatStreakCompact(streak),
totalCost: data.totals.totalCost,
totalTokens: data.totals.totalTokens,
dailyAverage,
last7Days: {
cost: last7DaysCost,
tokens: last7DaysTokens,
dailyAverage: last7Days.length > 0 ? last7DaysCost / last7Days.length : 0
},
last30Days: {
cost: last30DaysCost,
tokens: last30DaysTokens,
dailyAverage: last30Days.length > 0 ? last30DaysCost / last30Days.length : 0
},
tokenBreakdown: {
input: data.totals.inputTokens,
output: data.totals.outputTokens,
cache: data.totals.cacheCreationTokens + data.totals.cacheReadTokens
}
}
}
}