feat (v1.0.0): initial refactor and redesign
This commit is contained in:
parent
3058aa1ab4
commit
fe9b50b30e
134 changed files with 17792 additions and 3670 deletions
919
lib/services/ai.service.ts
Normal file
919
lib/services/ai.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
454
lib/services/device.service.ts
Normal file
454
lib/services/device.service.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
419
lib/services/domain.service.ts
Normal file
419
lib/services/domain.service.ts
Normal 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
3
lib/services/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { DomainService } from './domain.service'
|
||||
export { DeviceService } from './device.service'
|
||||
export { AIService } from './ai.service'
|
||||
Loading…
Add table
Add a link
Reference in a new issue