feat (v1.0.0): initial refactor and redesign

This commit is contained in:
Aidan 2025-10-09 04:12:05 -04:00
parent 3058aa1ab4
commit fe9b50b30e
134 changed files with 17792 additions and 3670 deletions

919
lib/services/ai.service.ts Normal file
View file

@ -0,0 +1,919 @@
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
}
}
}
}

View file

@ -0,0 +1,454 @@
import type { DeviceSpec, DeviceType, DeviceWithMetrics } from '@/lib/types'
import type { SortOrder } from '@/lib/types/service'
import { devices as deviceData } from '@/lib/devices/data'
/**
* Statistics and aggregated metrics for the device portfolio.
*
* @remarks
* Provides a comprehensive snapshot of device collection metrics including
* type distribution, manufacturer breakdown, age statistics, and device lifecycle data.
*
* @example
* ```ts
* const stats = DeviceService.getDeviceStats()
* console.log(`Total devices: ${stats.total}`)
* console.log(`Mobile devices: ${stats.mobile}`)
* console.log(`Average age: ${stats.averageAge.toFixed(1)} years`)
* ```
*
* @category Services
* @public
*/
export interface DevicePortfolioStats {
/** Total number of devices in the portfolio */
total: number
/** Number of mobile devices */
mobile: number
/** Number of digital audio players (DAPs) */
dap: number
/** Device count grouped by manufacturer name */
byManufacturer: Record<string, number>
/** Average age of all devices in years */
averageAge: number
/** Most recently released device */
newestDevice: DeviceWithMetrics
/** Oldest device by release year */
oldestDevice: DeviceWithMetrics
}
/**
* Service for managing device data and computing device metrics.
*
* @remarks
* Provides a centralized API for device portfolio management including:
* - Device enrichment with computed metrics (age, category labels)
* - Filtering by type, manufacturer, status, and release year
* - Sorting with type-safe key selection
* - Portfolio statistics and analytics
* - Related device discovery
*
* All methods are static and operate on the device data store without side effects.
*
* @example
* ```ts
* import { DeviceService } from '@/lib/services'
*
* // Get all enriched devices
* const devices = DeviceService.getAllDevicesEnriched()
*
* // Filter mobile devices only
* const mobile = DeviceService.getMobileDevices()
*
* // Get statistics
* const stats = DeviceService.getDeviceStats()
* console.log(`You have ${stats.total} devices`)
*
* // Find related devices
* const device = DeviceService.getDeviceBySlug('bonito')
* const related = DeviceService.getRelatedDevices(device!)
* ```
*
* @category Services
* @public
*/
export class DeviceService {
/**
* Computes the age of a device in years since release.
*
* @param device - Device specification
* @returns {number} Years since release, or 0 if no release year
* @internal
*/
private static computeAgeInYears(device: DeviceSpec): number {
if (!device.releaseYear) return 0
return new Date().getFullYear() - device.releaseYear
}
/**
* Checks if a device was released in the current year.
*
* @param device - Device specification
* @returns {boolean} True if released this year
* @internal
*/
private static isCurrentYear(device: DeviceSpec): boolean {
if (!device.releaseYear) return false
return device.releaseYear === new Date().getFullYear()
}
/**
* Determines a human-readable category label based on device type and status.
*
* @param device - Device specification
* @returns {string} Label like 'Daily Driver', 'Beta Testing', 'Digital Audio Player'
* @internal
*/
private static getCategoryLabel(device: DeviceSpec): string {
if (device.type === 'mobile') {
if (device.status?.toLowerCase().includes('daily')) return 'Daily Driver'
if (device.status?.toLowerCase().includes('beta')) return 'Beta Testing'
if (device.status?.toLowerCase().includes('experiment')) return 'Experimental'
return 'Mobile Device'
}
if (device.type === 'dap') {
return 'Digital Audio Player'
}
return 'Device'
}
/**
* Enriches a device specification with computed metrics.
*
* @param device - The device specification to enrich
* @returns {DeviceWithMetrics} Device with added properties:
* - `ageInYears: number` - Years since release
* - `isCurrentYear: boolean` - Released in current year
* - `categoryLabel: string` - Human-readable category ('Daily Driver', 'Digital Audio Player', etc.)
*
* @example
* ```ts
* const rawDevice = { slug: 'bonito', name: 'Pixel 3a XL', releaseYear: 2019, type: 'mobile', ... }
* const enriched = DeviceService.enrichDevice(rawDevice)
* console.log(enriched.ageInYears) // 6 (as of 2025)
* console.log(enriched.categoryLabel) // 'Mobile Device'
* ```
*/
static enrichDevice(device: DeviceSpec): DeviceWithMetrics {
return {
...device,
ageInYears: this.computeAgeInYears(device),
isCurrentYear: this.isCurrentYear(device),
categoryLabel: this.getCategoryLabel(device)
}
}
/**
* Retrieves all devices from the data store.
*
* @returns {DeviceSpec[]} Array of all device specifications
*
* @example
* ```ts
* const devices = DeviceService.getAllDevices()
* console.log(`Found ${devices.length} devices`)
* ```
*/
static getAllDevices(): DeviceSpec[] {
return Object.values(deviceData)
}
/**
* Retrieves all devices with enriched metrics.
*
* @returns {DeviceWithMetrics[]} Array of all devices with computed properties
*
* @example
* ```ts
* const enriched = DeviceService.getAllDevicesEnriched()
* enriched.forEach(device => {
* console.log(`${device.name} is ${device.ageInYears} years old`)
* })
* ```
*/
static getAllDevicesEnriched(): DeviceWithMetrics[] {
return this.getAllDevices().map(device => this.enrichDevice(device))
}
/**
* Retrieves a single device by its slug identifier.
*
* @param slug - The device slug (e.g., 'bonito', 'cheetah')
* @returns {DeviceWithMetrics | null} Enriched device or null if not found
*
* @example
* ```ts
* const device = DeviceService.getDeviceBySlug('bonito')
* if (device) {
* console.log(`Found: ${device.name}`)
* }
* ```
*/
static getDeviceBySlug(slug: string): DeviceWithMetrics | null {
const device = deviceData[slug]
return device ? this.enrichDevice(device) : null
}
/**
* Filters devices based on multiple criteria.
*
* @param filters - Filter criteria
* @param filters.type - Device type to filter by
* @param filters.manufacturer - Manufacturer name to filter by
* @param filters.status - Status string to filter by
* @param filters.releaseYear - Release year to filter by
* @returns {DeviceWithMetrics[]} Array of enriched devices matching all specified filters
*
* @example
* ```ts
* const mobileDevices = DeviceService.filterDevices({ type: 'mobile' })
* const googleDevices = DeviceService.filterDevices({ manufacturer: 'Google' })
* ```
*/
static filterDevices(filters: {
type?: DeviceType
manufacturer?: string
status?: string
releaseYear?: number
}): DeviceWithMetrics[] {
let filtered = this.getAllDevices()
if (filters.type) {
filtered = filtered.filter(d => d.type === filters.type)
}
if (filters.manufacturer) {
filtered = filtered.filter(d => d.manufacturer === filters.manufacturer)
}
if (filters.status) {
filtered = filtered.filter(d => d.status === filters.status)
}
if (filters.releaseYear) {
filtered = filtered.filter(d => d.releaseYear === filters.releaseYear)
}
return filtered.map(d => this.enrichDevice(d))
}
/**
* Retrieves devices filtered by device type.
*
* @param type - Device type ('mobile' or 'dap')
* @returns {DeviceWithMetrics[]} Array of enriched devices matching the type
*
* @remarks
* This is a convenience wrapper around `filterDevices()` for type-based filtering.
*
* @example
* ```ts
* const mobileDevices = DeviceService.getDevicesByType('mobile')
* const daps = DeviceService.getDevicesByType('dap')
* ```
*/
static getDevicesByType(type: DeviceType): DeviceWithMetrics[] {
return this.filterDevices({ type })
}
/**
* Retrieves all mobile devices from the portfolio.
*
* @returns {DeviceWithMetrics[]} Array of enriched mobile devices
*
* @remarks
* Convenience method equivalent to `getDevicesByType('mobile')`.
*
* @example
* ```ts
* const mobile = DeviceService.getMobileDevices()
* console.log(`You have ${mobile.length} mobile devices`)
* ```
*/
static getMobileDevices(): DeviceWithMetrics[] {
return this.getDevicesByType('mobile')
}
/**
* Retrieves all digital audio players (DAPs) from the portfolio.
*
* @returns {DeviceWithMetrics[]} Array of enriched DAP devices
*
* @remarks
* Convenience method equivalent to `getDevicesByType('dap')`.
*
* @example
* ```ts
* const daps = DeviceService.getDAPDevices()
* console.log(`You have ${daps.length} DAPs`)
* ```
*/
static getDAPDevices(): DeviceWithMetrics[] {
return this.getDevicesByType('dap')
}
/**
* Sorts devices by a specified property in ascending or descending order.
*
* @param devices - Array of devices to sort
* @param sortBy - Property key to sort by (type-safe, must be valid DeviceWithMetrics key)
* @param order - Sort direction ('asc' or 'desc'), defaults to 'asc'
* @returns {DeviceWithMetrics[]} New sorted array (original array is not modified)
*
* @remarks
* - Creates a shallow copy to avoid mutating the input array
* - Handles undefined values by placing them at the end (asc) or start (desc)
* - Works with any comparable property (strings, numbers, etc.)
*
* @example
* ```ts
* const devices = DeviceService.getAllDevicesEnriched()
*
* // Sort by release year, newest first
* const newest = DeviceService.sortDevices(devices, 'releaseYear', 'desc')
*
* // Sort by name alphabetically
* const alphabetical = DeviceService.sortDevices(devices, 'name', 'asc')
*
* // Sort by age
* const oldest = DeviceService.sortDevices(devices, 'ageInYears', 'desc')
* ```
*/
static sortDevices(
devices: DeviceWithMetrics[],
sortBy: keyof DeviceWithMetrics,
order: SortOrder = 'asc'
): DeviceWithMetrics[] {
return [...devices].sort((a, b) => {
const aVal = a[sortBy]
const bVal = b[sortBy]
if (aVal === undefined || bVal === undefined) {
if (aVal === undefined && bVal === undefined) return 0
if (aVal === undefined) return order === 'asc' ? 1 : -1
return order === 'asc' ? -1 : 1
}
if (aVal === bVal) return 0
const comparison = aVal < bVal ? -1 : 1
return order === 'asc' ? comparison : -comparison
})
}
/**
* Finds related devices based on shared type and manufacturer.
*
* @param device - The reference device to find related devices for
* @returns {DeviceWithMetrics[]} Up to 3 related devices (excludes the input device)
*
* @remarks
* The algorithm prioritizes devices that share:
* 1. Same device type (mobile or DAP)
* 2. Same manufacturer
*
* The method deduplicates results using a Map and returns up to 3 devices.
* Useful for "You might also like" or related device suggestions.
*
* @example
* ```ts
* const pixel3a = DeviceService.getDeviceBySlug('bonito')
* if (pixel3a) {
* const related = DeviceService.getRelatedDevices(pixel3a)
* console.log(`Related devices: ${related.map(d => d.name).join(', ')}`)
* // Example output: "Pixel 7a, Pixel 3, Pixel 4a"
* }
* ```
*
* @example
* ```ts
* // For a Samsung DAP, finds other DAPs and Samsung devices
* const dap = DeviceService.getDeviceBySlug('komodo')
* const similar = DeviceService.getRelatedDevices(dap!)
* ```
*/
static getRelatedDevices(device: DeviceSpec): DeviceWithMetrics[] {
const sameType = this.filterDevices({ type: device.type })
.filter(d => d.slug !== device.slug)
const sameManufacturer = device.manufacturer
? this.filterDevices({ manufacturer: device.manufacturer })
.filter(d => d.slug !== device.slug)
: []
const combined = new Map<string, DeviceWithMetrics>()
sameType.forEach(d => combined.set(d.slug, d))
sameManufacturer.forEach(d => combined.set(d.slug, d))
return Array.from(combined.values()).slice(0, 3)
}
/**
* Computes comprehensive statistics for the entire device portfolio.
*
* @returns {DevicePortfolioStats} Aggregated portfolio metrics
*
* @remarks
* Calculates portfolio-wide statistics including:
* - Type distribution (mobile vs DAP)
* - Manufacturer breakdown
* - Average device age
* - Newest and oldest devices
*
* All devices are enriched with metrics before calculations.
*
* @example
* ```ts
* const stats = DeviceService.getDeviceStats()
*
* console.log(`Portfolio Overview:`)
* console.log(`Total: ${stats.total} devices`)
* console.log(`Mobile: ${stats.mobile}, DAP: ${stats.dap}`)
* console.log(`Average age: ${stats.averageAge.toFixed(1)} years`)
* console.log(`Newest: ${stats.newestDevice.name}`)
* console.log(`Oldest: ${stats.oldestDevice.name}`)
*
* // Manufacturer breakdown
* Object.entries(stats.byManufacturer).forEach(([mfr, count]) => {
* console.log(`${mfr}: ${count} devices`)
* })
* ```
*
* @example
* ```ts
* // Use for dashboard summary cards
* const stats = DeviceService.getDeviceStats()
* return (
* <div>
* <StatsCard label="Total Devices" value={stats.total} />
* <StatsCard label="Mobile" value={stats.mobile} />
* <StatsCard label="Average Age" value={`${stats.averageAge.toFixed(1)}y`} />
* </div>
* )
* ```
*/
static getDeviceStats(): DevicePortfolioStats {
const enriched = this.getAllDevicesEnriched()
return {
total: enriched.length,
mobile: enriched.filter(d => d.type === 'mobile').length,
dap: enriched.filter(d => d.type === 'dap').length,
byManufacturer: enriched.reduce((acc, d) => {
if (d.manufacturer) {
acc[d.manufacturer] = (acc[d.manufacturer] || 0) + 1
}
return acc
}, {} as Record<string, number>),
averageAge: enriched.reduce((sum, d) => sum + d.ageInYears, 0) / enriched.length,
newestDevice: this.sortDevices(enriched, 'releaseYear', 'desc')[0],
oldestDevice: this.sortDevices(enriched, 'releaseYear', 'asc')[0]
}
}
}

View file

@ -0,0 +1,419 @@
import type { Domain, DomainWithMetrics } from '@/lib/types'
import { domains as domainData } from '@/lib/domains/data'
import type { SortOrder } from '@/lib/types/service'
/**
* Service for managing domain portfolio data and computing domain metrics.
*
* @remarks
* This service provides comprehensive domain portfolio management:
* - Automatic metric calculation (expiration, ownership duration)
* - Flexible filtering and sorting
* - Statistical analysis
* - Renewal tracking
*
* All date calculations use UTC to ensure consistency across timezones.
*
* @example
* ```ts
* import { DomainService } from '@/lib/services'
*
* // Get all domains with computed metrics
* const domains = DomainService.getAllDomainsEnriched()
*
* // Find expiring domains
* const expiring = DomainService.filterDomains({ expiringSoon: true })
*
* // Get portfolio statistics
* const stats = DomainService.getDomainStats()
* console.log(`Total domains: ${stats.total}`)
* ```
*
* @category Services
* @public
*/
export class DomainService {
private static computeExpirationDate(domain: Domain): Date {
let expirationDate = new Date(domain.renewals[0].date)
for (const renewal of domain.renewals) {
const renewalDate = new Date(renewal.date)
expirationDate = new Date(renewalDate)
expirationDate.setFullYear(expirationDate.getFullYear() + renewal.years)
}
return expirationDate
}
private static computeRegistrationDate(domain: Domain): Date {
return new Date(domain.renewals[0].date)
}
private static computeOwnershipDays(domain: Domain): number {
const registrationDate = this.computeRegistrationDate(domain)
const now = new Date()
const diffTime = Math.abs(now.getTime() - registrationDate.getTime())
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
}
private static computeOwnershipYears(domain: Domain): number {
const days = this.computeOwnershipDays(domain)
return Math.floor(days / 365.25)
}
private static computeOwnershipMonths(domain: Domain): number {
const registrationDate = this.computeRegistrationDate(domain)
const now = new Date()
let months = (now.getFullYear() - registrationDate.getFullYear()) * 12
months += now.getMonth() - registrationDate.getMonth()
if (now.getDate() < registrationDate.getDate()) {
months--
}
return Math.max(0, months)
}
private static computeDaysUntilExpiration(domain: Domain): number {
const expirationDate = this.computeExpirationDate(domain)
const now = new Date()
const diffTime = expirationDate.getTime() - now.getTime()
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
}
private static computeRenewalProgressPercent(domain: Domain): number {
const ownershipDays = this.computeOwnershipDays(domain)
const registrationDate = this.computeRegistrationDate(domain)
const expirationDate = this.computeExpirationDate(domain)
const totalDays = Math.ceil((expirationDate.getTime() - registrationDate.getTime()) / (1000 * 60 * 60 * 24))
return Math.min(100, Math.max(0, (ownershipDays / totalDays) * 100))
}
private static isExpiringSoon(domain: Domain, thresholdDays: number = 90): boolean {
return this.computeDaysUntilExpiration(domain) <= thresholdDays
}
private static extractTLD(domain: Domain): string {
const lastDot = domain.domain.lastIndexOf('.')
return lastDot !== -1 ? domain.domain.substring(lastDot) : ''
}
/**
* Enriches a domain with computed metrics including expiration dates,
* ownership duration, and renewal progress.
*
* @param domain - The domain to enrich
* @returns {DomainWithMetrics} Domain with added properties:
* - `expirationDate: Date` - Computed expiration date
* - `registrationDate: Date` - Initial registration date
* - `ownershipDays: number` - Total days owned
* - `ownershipYears: number` - Total years owned (floor)
* - `ownershipMonths: number` - Total months owned
* - `daysUntilExpiration: number` - Days until domain expires
* - `renewalProgressPercent: number` - 0-100 percent through renewal period
* - `isExpiringSoon: boolean` - Expires within 90 days
* - `nextRenewalDate: Date` - Date of next renewal (same as expirationDate)
* - `tld: string` - Top-level domain (e.g., '.com', '.dev')
*/
static enrichDomain(domain: Domain): DomainWithMetrics {
const expirationDate = this.computeExpirationDate(domain)
const registrationDate = this.computeRegistrationDate(domain)
return {
...domain,
expirationDate,
registrationDate,
ownershipDays: this.computeOwnershipDays(domain),
ownershipYears: this.computeOwnershipYears(domain),
ownershipMonths: this.computeOwnershipMonths(domain),
daysUntilExpiration: this.computeDaysUntilExpiration(domain),
renewalProgressPercent: this.computeRenewalProgressPercent(domain),
isExpiringSoon: this.isExpiringSoon(domain),
nextRenewalDate: expirationDate,
tld: this.extractTLD(domain)
}
}
/**
* Retrieves all domains from the data store.
*
* @returns {Domain[]} Array of all domain specifications
*/
static getAllDomains(): Domain[] {
return domainData
}
/**
* Retrieves all domains with enriched metrics.
*
* @returns {DomainWithMetrics[]} Array of all domains with computed properties
*/
static getAllDomainsEnriched(): DomainWithMetrics[] {
return domainData.map(domain => this.enrichDomain(domain))
}
/**
* Retrieves a single domain by its full domain name.
*
* @param domainName - The full domain name (e.g., 'aidxn.com')
* @returns {DomainWithMetrics | null} Enriched domain or null if not found
*/
static getDomainByName(domainName: string): DomainWithMetrics | null {
const domain = domainData.find(d => d.domain === domainName)
return domain ? this.enrichDomain(domain) : null
}
/**
* Filters domains based on multiple criteria.
*
* @param filters - Filter criteria
* @param filters.status - Domain status to filter by ('active' | 'parked' | 'reserved')
* @param filters.category - Domain category to filter by ('personal' | 'service' | 'project' | 'fun' | 'legacy')
* @param filters.registrar - Registrar name to filter by
* @param filters.expiringSoon - Filter by expiration status (within 90 days)
* @param filters.autoRenew - Filter by auto-renewal setting
* @returns {DomainWithMetrics[]} Array of enriched domains matching all specified filters
*
* @example
* ```ts
* const activeDomains = DomainService.filterDomains({ status: 'active' })
* const expiringDomains = DomainService.filterDomains({ expiringSoon: true })
* ```
*/
static filterDomains(
filters: {
status?: Domain['status']
category?: Domain['category']
registrar?: string
expiringSoon?: boolean
autoRenew?: boolean
}
): DomainWithMetrics[] {
let filtered = domainData
if (filters.status) {
filtered = filtered.filter(d => d.status === filters.status)
}
if (filters.category) {
filtered = filtered.filter(d => d.category === filters.category)
}
if (filters.registrar) {
filtered = filtered.filter(d => d.registrar === filters.registrar)
}
if (filters.autoRenew !== undefined) {
filtered = filtered.filter(d => d.autoRenew === filters.autoRenew)
}
const enriched = filtered.map(d => this.enrichDomain(d))
if (filters.expiringSoon !== undefined) {
return enriched.filter(d => d.isExpiringSoon === filters.expiringSoon)
}
return enriched
}
/**
* Sorts an array of domains by a specified property.
*
* @param domains - Array of domains to sort
* @param sortBy - Property key to sort by (type-safe)
* @param order - Sort direction: 'asc' (ascending) or 'desc' (descending), default: 'asc'
* @returns {DomainWithMetrics[]} New sorted array (does not mutate original)
*
* @remarks
* Creates a shallow copy before sorting to avoid mutating the original array.
* Handles comparison of all value types (string, number, Date, boolean).
*
* @example
* ```ts
* const domains = DomainService.getAllDomainsEnriched()
*
* // Sort by expiration date (soonest first)
* const byExpiration = DomainService.sortDomains(domains, 'daysUntilExpiration', 'asc')
*
* // Sort by domain name alphabetically
* const byName = DomainService.sortDomains(domains, 'domain', 'asc')
*
* // Sort by ownership duration (longest first)
* const byOwnership = DomainService.sortDomains(domains, 'ownershipDays', 'desc')
* ```
*
* @public
*/
static sortDomains(
domains: DomainWithMetrics[],
sortBy: keyof DomainWithMetrics,
order: SortOrder = 'asc'
): DomainWithMetrics[] {
return [...domains].sort((a, b) => {
const aVal = a[sortBy]
const bVal = b[sortBy]
if (aVal === bVal) return 0
const comparison = aVal < bVal ? -1 : 1
return order === 'asc' ? comparison : -comparison
})
}
/**
* Groups all domains by their category.
*
* @returns {Record<Domain['category'], DomainWithMetrics[]>} Object mapping each category to its domains
*
* @remarks
* Returns an object with category keys ('personal', 'service', 'project', 'fun', 'legacy'),
* each containing an array of enriched domains in that category.
*
* All categories are present in the result, even if empty.
*
* @example
* ```ts
* const grouped = DomainService.groupDomainsByCategory()
*
* console.log(`Personal domains: ${grouped.personal.length}`)
* console.log(`Service domains: ${grouped.service.length}`)
*
* // Iterate through personal domains
* grouped.personal.forEach(domain => {
* console.log(`${domain.domain} - ${domain.description}`)
* })
*
* // Find most expensive category
* const categoryCosts = Object.entries(grouped).map(([category, domains]) => ({
* category,
* totalDomains: domains.length
* }))
* ```
*
* @public
*/
static groupDomainsByCategory(): Record<Domain['category'], DomainWithMetrics[]> {
const grouped: Record<Domain['category'], DomainWithMetrics[]> = {
personal: [],
service: [],
project: [],
fun: [],
legacy: []
}
const enriched = this.getAllDomainsEnriched()
enriched.forEach(domain => {
grouped[domain.category].push(domain)
})
return grouped
}
/**
* Computes statistics about my domain portfolio.
*
* @returns {DomainPortfolioStats} Statistics object with counts, breakdowns, and aggregates
*
* @remarks
* Provides a complete statistical overview including:
* - Total domain count
* - Counts by status (active, parked, reserved)
* - Domains expiring within 90 days
* - Auto-renewal counts
* - Breakdown by category
* - Breakdown by registrar
*
* This method performs a single pass through the domain list for efficiency.
*
* @example
* ```ts
* const stats = DomainService.getDomainStats()
*
* console.log(`Portfolio Overview:`)
* console.log(` Total: ${stats.total}`)
* console.log(` Active: ${stats.active}`)
* console.log(` Expiring Soon: ${stats.expiringSoon}`)
* console.log(` Auto-Renew Enabled: ${stats.autoRenew}`)
*
* // Category breakdown
* console.log(`\nBy Category:`)
* Object.entries(stats.byCategory).forEach(([category, count]) => {
* console.log(` ${category}: ${count}`)
* })
*
* // Registrar analysis
* console.log(`\nBy Registrar:`)
* Object.entries(stats.byRegistrar)
* .sort(([,a], [,b]) => b - a)
* .forEach(([registrar, count]) => {
* console.log(` ${registrar}: ${count}`)
* })
* ```
*
* @public
*/
static getDomainStats(): DomainPortfolioStats {
const enriched = this.getAllDomainsEnriched()
return {
total: enriched.length,
active: enriched.filter(d => d.status === 'active').length,
parked: enriched.filter(d => d.status === 'parked').length,
reserved: enriched.filter(d => d.status === 'reserved').length,
expiringSoon: enriched.filter(d => d.isExpiringSoon).length,
autoRenew: enriched.filter(d => d.autoRenew).length,
byCategory: {
personal: enriched.filter(d => d.category === 'personal').length,
service: enriched.filter(d => d.category === 'service').length,
project: enriched.filter(d => d.category === 'project').length,
fun: enriched.filter(d => d.category === 'fun').length,
legacy: enriched.filter(d => d.category === 'legacy').length,
},
byRegistrar: enriched.reduce((acc, d) => {
acc[d.registrar] = (acc[d.registrar] || 0) + 1
return acc
}, {} as Record<string, number>)
}
}
}
/**
* Domain portfolio statistics result.
*
* @remarks
* Breakdown of domain metrics returned by {@link DomainService.getDomainStats}.
*
* @example
* ```ts
* const stats: DomainPortfolioStats = DomainService.getDomainStats()
* ```
*
* @category Types
* @public
*/
export interface DomainPortfolioStats {
/** Total number of domains in portfolio */
total: number
/** Number of active domains */
active: number
/** Number of parked domains */
parked: number
/** Number of reserved domains */
reserved: number
/** Number of domains expiring within 90 days */
expiringSoon: number
/** Number of domains with auto-renew enabled */
autoRenew: number
/** Domain counts by category */
byCategory: {
personal: number
service: number
project: number
fun: number
legacy: number
}
/** Domain counts by registrar */
byRegistrar: Record<string, number>
}

3
lib/services/index.ts Normal file
View file

@ -0,0 +1,3 @@
export { DomainService } from './domain.service'
export { DeviceService } from './device.service'
export { AIService } from './ai.service'