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

View file

@ -0,0 +1,41 @@
export interface FeaturedRepoConfig {
id: number
owner: string
repo: string
description: string
platform: 'github' | 'forgejo'
forgejoUrl?: string // Base URL for Forgejo instance
}
export const featuredRepos: FeaturedRepoConfig[] = [
{
id: 1,
owner: 'aidan',
repo: 'aidxnCC',
description: 'aidxnCC is the third version of my personal website',
platform: 'forgejo',
forgejoUrl: 'git.p0ntus.com',
},
{
id: 2,
owner: 'abocn',
repo: 'TelegramBot',
description: 'Landing page for p0ntus mail',
platform: 'github',
},
{
id: 3,
owner: 'abocn',
repo: 'modules',
description: 'A Magisk/KernelSU module repository',
platform: 'github',
},
{
id: 4,
owner: 'pontus',
repo: 'pontus-front',
description: 'The frontend and API for p0ntus, my free privacy-focused service provider',
platform: 'forgejo',
forgejoUrl: 'git.p0ntus.com',
},
]

61
lib/devices/config.ts Normal file
View file

@ -0,0 +1,61 @@
/**
* Device configuration constants and display labels.
*
* @remarks
* This module provides configuration constants used throughout the device showcase
* for consistent labeling and sizing of UI elements.
*
* @module lib/devices/config
* @category Devices
* @public
*/
/**
* Human-readable labels for device types.
*
* @remarks
* Maps device type identifiers to their display labels for use in UI components.
*
* @example
* ```tsx
* import { deviceTypeLabels } from '@/lib/devices/config'
*
* const DeviceTypeBadge = ({ type }: { type: keyof typeof deviceTypeLabels }) => (
* <span>{deviceTypeLabels[type]}</span>
* )
* ```
*
* @category Devices
* @public
*/
export const deviceTypeLabels = {
/** Label for mobile phone devices */
mobile: 'Mobile device',
/** Label for digital audio player devices */
dap: 'Digital audio player'
} as const
/**
* Standard icon sizes for device components.
*
* @remarks
* Provides consistent icon sizing across device stat displays and section headers.
* All sizes are in pixels.
*
* @example
* ```tsx
* import { iconSizes } from '@/lib/devices/config'
* import { Smartphone } from 'lucide-react'
*
* <Smartphone size={iconSizes.stat} />
* ```
*
* @category Devices
* @public
*/
export const iconSizes = {
/** Icon size for device stat displays (60px) */
stat: 60,
/** Icon size for device section headers (60px) */
section: 60
} as const

465
lib/devices/data.ts Normal file
View file

@ -0,0 +1,465 @@
import {
Battery,
Bluetooth,
Clock,
Cast,
Cpu,
Gauge,
HardDrive,
Hash,
Headphones,
Layers,
MemoryStick,
Monitor,
Music,
Package,
Radio,
Ruler,
ShieldCheck,
Smartphone,
Sparkles,
SquarePen,
Usb,
Wifi,
Zap
} from 'lucide-react';
import { FaYoutube } from 'react-icons/fa';
import { MdOutlineAndroid } from 'react-icons/md';
import { RiTelegram2Fill } from 'react-icons/ri';
import { TbDeviceSdCard } from 'react-icons/tb';
import { VscTerminalLinux } from 'react-icons/vsc';
import type { DeviceCollection } from '@/lib/types';
export const devices: DeviceCollection = {
komodo: {
slug: 'komodo',
name: 'Pixel 9 Pro XL',
shortName: 'Pixel 9 Pro XL',
codename: 'komodo',
type: 'mobile',
manufacturer: 'Google',
status: 'Android beta lab',
releaseYear: 2024,
heroImage: {
src: '/img/komodo.png',
alt: 'Google Pixel 9 Pro XL (komodo)',
},
tagline: 'Bleeding-edge Pixel tuned for experimentation and kernel tinkering.',
summary: [
'The Pixel 9 Pro XL is my sandbox for canary Android builds. It runs preview releases while staying rooted thanks to KernelSU-Next and a SUSFS-enabled kernel.',
'I lean on it for testing new modules and automation ideas before they touch my day-to-day setup.',
],
stats: [
{
title: 'Core silicon',
icon: Cpu,
items: [
{ label: 'SoC', value: 'Google Tensor G4' },
{ label: 'RAM', value: '16 GB LPDDR5X' },
{ label: 'Storage', value: '128 GB UFS 4.0' },
],
},
{
title: 'Software channel',
icon: MdOutlineAndroid,
items: [
{
label: 'ROM',
value: 'Android 16 QPR2',
href: 'https://developer.android.com/about/versions/16/qpr2',
},
{
label: 'Kernel',
value: '6.1.138 android14 (SUSFS Wild)',
href: 'https://github.com/WildKernels/GKI_KernelSU_SUSFS',
},
{
label: 'Root',
value: 'KernelSU-Next',
href: 'https://github.com/rifsxd/KernelSU-Next',
},
],
},
{
title: 'Media stack',
icon: Music,
items: [
{ label: 'Streaming', value: 'Tidal', href: 'https://tidal.com' },
{ label: 'Local files', value: 'MiXplorer', href: 'https://mixplorer.com/' },
{ label: 'Video', value: 'ReVanced', href: 'https://revanced.app' },
],
},
],
sections: [
{
id: 'hardware',
title: 'Hardware Snapshot',
icon: Smartphone,
rows: [
{ label: 'Chipset', value: 'Google Tensor G4', icon: Cpu },
{ label: 'RAM', value: '16 GB LPDDR5X', icon: MemoryStick },
{ label: 'Storage', value: '128 GB UFS 4.0', icon: HardDrive },
],
},
{
id: 'software',
title: 'Software Stack',
icon: MdOutlineAndroid,
rows: [
{
label: 'ROM',
value: 'Android 16 QPR2',
icon: MdOutlineAndroid,
href: 'https://developer.android.com/about/versions/16/qpr2',
},
{
label: 'Kernel',
value: '6.1.138 android14 (SUSFS Wild)',
icon: VscTerminalLinux,
href: 'https://github.com/WildKernels/GKI_KernelSU_SUSFS',
},
{
label: 'Root',
value: 'KernelSU-Next',
icon: ShieldCheck,
href: 'https://github.com/rifsxd/KernelSU-Next',
},
],
},
{
id: 'apps',
title: 'Daily Apps',
icon: Package,
rows: [
{ label: 'Music', value: 'Tidal', icon: Music, href: 'https://tidal.com' },
{ label: 'Files', value: 'MiXplorer', icon: Package, href: 'https://mixplorer.com/' },
{ label: 'Telegram', value: 'AyuGram', icon: RiTelegram2Fill, href: 'https://t.me/AyuGramReleases' },
{ label: 'YouTube', value: 'ReVanced', icon: FaYoutube, href: 'https://revanced.app' },
],
},
{
id: 'modules',
title: 'Module Suite',
icon: Layers,
listItems: [
{ label: 'bindhosts', href: 'https://modules.lol/module/kowx712-bindhosts' },
{ label: 'Emoji Replacer', href: 'https://github.com/EmojiReplacer/Emoji-Replacer' },
{ label: 'F-Droid Privileged Extension', href: 'https://modules.lol/module/entr0pia-f-droid-privileged-extension-installer' },
{ label: 'SUSFS for KernelSU', href: 'https://modules.lol/module/sidex15-susfs' },
{ label: 'Tricky Store', href: 'https://modules.lol/module/5ec1cff-tricky-store' },
{ label: 'Yuri Keybox Manager', href: 'https://modules.lol/module/dpejoh-and-yuri-yurikey' },
],
}
],
},
cheetah: {
slug: 'cheetah',
name: 'Pixel 7 Pro',
shortName: 'Pixel 7 Pro',
codename: 'cheetah',
type: 'mobile',
manufacturer: 'Google',
status: 'Daily driver',
releaseYear: 2022,
heroImage: {
src: '/img/cheetah.png',
alt: 'Google Pixel 7 Pro (cheetah)',
},
tagline: 'Reliable flagship tuned for rooted daily use.',
summary: [
'My everyday carry balances performance and battery life with a stable crDroid build and KernelSU-Next for system-level tweaks.',
'The camera stack and Tensor-only optimizations still impress, especially when paired with my media workflow.',
],
stats: [
{
title: 'Core hardware',
icon: Cpu,
items: [
{ label: 'SoC', value: 'Google Tensor G2' },
{ label: 'RAM', value: '12 GB LPDDR5' },
{ label: 'Storage', value: '128 GB' },
],
},
{
title: 'Current build',
icon: MdOutlineAndroid,
items: [
{ label: 'ROM', value: 'crDroid 11.6', href: 'https://crdroid.net' },
{ label: 'Kernel', value: '6.1.99 android14' },
{ label: 'Root', value: 'KernelSU-Next', href: 'https://github.com/rifsxd/KernelSU-Next' },
],
},
{
title: 'Media kit',
icon: Music,
items: [
{ label: 'Streaming', value: 'Tidal', href: 'https://tidal.com' },
{ label: 'Files', value: 'MiXplorer', href: 'https://mixplorer.com/' },
{ label: 'YouTube', value: 'ReVanced', href: 'https://revanced.app' },
],
},
],
sections: [
{
id: 'hardware',
title: 'Hardware Snapshot',
icon: Smartphone,
rows: [
{ label: 'Chipset', value: 'Google Tensor G2', icon: Cpu },
{ label: 'RAM', value: '12 GB LPDDR5', icon: MemoryStick },
{ label: 'Storage', value: '128 GB', icon: HardDrive },
],
},
{
id: 'software',
title: 'Software Stack',
icon: MdOutlineAndroid,
rows: [
{
label: 'ROM',
value: 'crDroid Android 11.6',
icon: MdOutlineAndroid,
href: 'https://crdroid.net',
},
{
label: 'Kernel',
value: '6.1.99 android14',
icon: VscTerminalLinux,
},
{
label: 'Root',
value: 'KernelSU-Next',
icon: ShieldCheck,
href: 'https://github.com/rifsxd/KernelSU-Next',
},
],
},
{
id: 'apps',
title: 'App Loadout',
icon: Package,
rows: [
{ label: 'Music', value: 'Tidal', icon: Music, href: 'https://tidal.com' },
{ label: 'Files', value: 'MiXplorer', icon: Package, href: 'https://mixplorer.com/' },
{ label: 'Telegram', value: 'AyuGram', icon: RiTelegram2Fill, href: 'https://t.me/AyuGramReleases' },
{ label: 'YouTube', value: 'ReVanced', icon: FaYoutube, href: 'https://revanced.app' },
],
},
{
id: 'modules',
title: 'Module Suite',
icon: Layers,
listItems: [
{ label: 'bindhosts', href: 'https://github.com/bindhosts/bindhosts' },
{ label: 'Emoji Replacer', href: 'https://github.com/EmojiReplacer/Emoji-Replacer' },
{ label: 'ReZygisk', href: 'https://github.com/PerformanC/ReZygisk' },
{ label: 'LSPosed JingMatrix', href: 'https://github.com/JingMatrix/LSPosed' },
],
},
{
id: 'review',
title: 'Review',
icon: SquarePen,
rating: {
value: 4.5,
scale: 5,
label: 'Personal score',
},
paragraphs: [
'The jump from a Galaxy A32 5G was dramatic. Tensor silicon keeps the phone responsive, especially with 12 GB of RAM backing daily multitasking.',
'Battery life wavers when Play Integrity tweaks are active, but the photo pipeline more than compensates—the Pixel still wins for quick captures.',
'Hardware quirks aside (RIP volume rocker), Android makes on-screen controls painless, so the device stays an easy recommendation.',
],
},
],
},
bonito: {
slug: 'bonito',
name: 'Pixel 3a XL',
shortName: 'Pixel 3a XL',
codename: 'bonito',
type: 'mobile',
manufacturer: 'Google',
status: 'Ubuntu Touch testing',
releaseYear: 2019,
heroImage: {
src: '/img/bonito.png',
alt: 'Google Pixel 3a XL (bonito)',
},
tagline: 'Legacy Pixel reborn as a sandbox for Ubuntu Touch.',
summary: [
'Retired from Android duty, the Pixel 3a XL now explores the Ubuntu Touch ecosystem as a daily development mule.',
'It highlights what the community-driven OS can do on aging hardware while still handling lightweight messaging and media.',
],
stats: [
{
title: 'Core silicon',
icon: Cpu,
items: [
{ label: 'Chipset', value: 'Snapdragon 670' },
{ label: 'RAM', value: '4 GB' },
{ label: 'Storage', value: '64 GB' },
],
},
{
title: 'Current build',
icon: MdOutlineAndroid,
items: [
{
label: 'OS',
value: 'Ubuntu Touch stable',
href: 'https://www.ubuntu-touch.io',
},
{ label: 'Kernel', value: '4.9.337' },
],
},
{
title: 'Essentials',
icon: Package,
items: [
{ label: 'Music', value: 'uSonic', href: 'https://github.com/arubislander/uSonic' },
{ label: 'Messaging', value: 'TELEports', href: 'https://open-store.io/app/teleports.ubports' },
],
},
],
sections: [
{
id: 'hardware',
title: 'Hardware Snapshot',
icon: Smartphone,
rows: [
{ label: 'Chipset', value: 'Qualcomm Snapdragon 670', icon: Cpu },
{ label: 'RAM', value: '4 GB', icon: MemoryStick },
{ label: 'Storage', value: '64 GB', icon: HardDrive },
],
},
{
id: 'software',
title: 'Software Stack',
icon: MdOutlineAndroid,
rows: [
{
label: 'OS',
value: 'Ubuntu Touch',
icon: MdOutlineAndroid,
href: 'https://www.ubuntu-touch.io',
},
{ label: 'Kernel', value: '4.9.337', icon: VscTerminalLinux },
],
},
{
id: 'apps',
title: 'App Loadout',
icon: Package,
rows: [
{ label: 'Music', value: 'uSonic', icon: Music, href: 'https://github.com/arubislander/uSonic' },
{ label: 'Messaging', value: 'TELEports', icon: RiTelegram2Fill, href: 'https://open-store.io/app/teleports.ubports' },
],
}
],
},
jm21: {
slug: 'jm21',
name: 'FiiO JM21',
shortName: 'FiiO JM21',
codename: 'jm21',
type: 'dap',
manufacturer: 'FiiO',
status: 'Portable Hi-Fi rig',
releaseYear: 2024,
heroImage: {
src: '/img/jm21.png',
alt: 'FiiO JM21 digital audio player',
},
tagline: 'Compact Android DAP with a dual-DAC audio chain.',
summary: [
'The JM21 is my dedicated portable rig. Dual Cirrus Logic DACs and a balanced amp stage deliver more headroom than a typical phone stack.',
'Android 13 keeps app support flexible, so streaming and offline libraries live together without compromise.',
],
stats: [
{
title: 'Audio pipeline',
icon: Headphones,
items: [
{ label: 'DAC', value: 'Dual CS43198' },
{ label: 'Amp', value: 'Dual SGM8262' },
{ label: 'SNR', value: 'Up to 129 dB' },
],
},
{
title: 'Outputs',
icon: Radio,
items: [
{ label: 'Single-ended', value: '3.5 mm' },
{ label: 'Balanced', value: '4.4 mm' },
{ label: 'Digital', value: 'USB-C / coaxial' },
],
},
{
title: 'Power & runtime',
icon: Battery,
items: [
{ label: 'Power', value: '245 mW SE / 700 mW BAL @32Ω' },
{ label: 'Battery', value: '2400 mAh' },
{ label: 'Storage', value: '32 GB + microSD up to 2 TB' },
],
},
],
sections: [
{
id: 'core-specs',
title: 'Core Specs',
icon: Cpu,
rows: [
{ label: 'Processor', value: 'Qualcomm Snapdragon 680 (octa-core, 2.4 GHz)', icon: Cpu },
{ label: 'RAM', value: '3 GB', icon: MemoryStick },
{ label: 'Storage', value: '32 GB (≈22 GB usable)', icon: HardDrive },
{ label: 'Expansion', value: 'microSD up to 2 TB', icon: TbDeviceSdCard },
{ label: 'Display', value: '4.7" IPS, 1334 × 750', icon: Monitor },
{ label: 'Dimensions', value: '120.7 × 68 × 13 mm', icon: Ruler },
{ label: 'Weight', value: '156 g', icon: Gauge },
{ label: 'Chassis', value: 'Ultra-thin 13 mm frame', icon: Layers },
],
},
{
id: 'audio',
title: 'Audio Hardware',
icon: Headphones,
rows: [
{ label: 'DAC', value: 'Dual Cirrus Logic CS43198', icon: Headphones },
{ label: 'Amplifier', value: 'Dual SGM8262', icon: Layers },
{ label: 'Outputs', value: '3.5 mm SE, 4.4 mm BAL, coaxial, USB-C', icon: Radio },
{ label: 'Power output', value: '245 mW SE / 700 mW BAL @32Ω (THD+N <1%)', icon: Zap },
{ label: 'Impedance', value: '<1Ω SE, <1.5Ω BAL', icon: Hash },
{ label: 'Frequency response', value: '20 Hz 80 kHz (<0.7 dB)', icon: Music },
{ label: 'Formats', value: 'PCM 384 kHz/32-bit, DSD256, full MQA', icon: Sparkles },
],
},
{
id: 'connectivity',
title: 'Connectivity & OS',
icon: Wifi,
rows: [
{ label: 'OS', value: 'Custom Android 13', icon: MdOutlineAndroid },
{ label: 'Bluetooth', value: 'v5.0 (SBC, AAC, aptX, aptX HD, LDAC, LHDC)', icon: Bluetooth },
{ label: 'Wi-Fi', value: '802.11 a/b/g/n/ac, dual-band', icon: Wifi },
{ label: 'USB DAC', value: 'Asynchronous, 384 kHz/32-bit', icon: Usb },
{ label: 'AirPlay / DLNA', value: 'Supported', icon: Cast },
{ label: 'Battery', value: '2400 mAh', icon: Battery },
{ label: 'Charging', value: '≈2 h via 5V 2A', icon: Clock },
],
},
],
},
};
export const deviceSlugs = Object.keys(devices);
export const mobileDevices = Object.values(devices).filter((device) => device.type === 'mobile');
export const dapDevices = Object.values(devices).filter((device) => device.type === 'dap');
export function getDeviceBySlug(slug: string) {
return devices[slug];
}

13
lib/devices/index.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* Device data exports for my portfolio showcase.
*
* @remarks
* This module re-exports all device-related data from the data module.
* Currently exports device specifications for display in the device showcase.
*
* @module lib/devices
* @category Devices
* @public
*/
export * from './data';

48
lib/docs/loader.ts Normal file
View file

@ -0,0 +1,48 @@
import { readFileSync } from 'fs'
import { join } from 'path'
import { parseTypeDocJSON } from './parser'
import type { TypeDocRoot, DocSection } from './types'
/**
* Loads and parses TypeDoc-generated API documentation from JSON file.
*
* @returns Array of documentation sections organized by category
*
* @remarks
* This function:
* 1. Reads the TypeDoc-generated JSON file from `public/docs/api.json`
* 2. Parses the raw TypeDoc data into structured documentation sections
* 3. Returns an empty array if the file is missing or invalid
*
* The TypeDoc JSON file should be generated by running:
* ```bash
* typedoc --options typedoc.json
* ```
*
* @example
* ```ts
* import { loadDocumentation } from '@/lib/docs/loader'
*
* // In a server component
* export default function DocsPage() {
* const sections = loadDocumentation()
* return <DocsList sections={sections} />
* }
* ```
*
* @throws Does not throw - errors are logged and an empty array is returned
*
* @category Documentation
* @public
*/
export function loadDocumentation(): DocSection[] {
try {
const filePath = join(process.cwd(), 'public/docs/api.json')
const fileContents = readFileSync(filePath, 'utf8')
const typeDocData: TypeDocRoot = JSON.parse(fileContents)
return parseTypeDocJSON(typeDocData)
} catch (error) {
console.error('Failed to load TypeDoc JSON:', error)
return []
}
}

614
lib/docs/parser.ts Normal file
View file

@ -0,0 +1,614 @@
/**
* TypeDoc JSON parser that transforms TypeDoc's reflection model into a
* simplified, searchable documentation structure.
*
* @remarks
* This module parses TypeDoc JSON output (generated with `typedoc --json`)
* and transforms it into a flattened, categorized structure optimized for:
* - Fast client-side search
* - Category-based navigation
* - Rich documentation display
*
* **Processing pipeline:**
* 1. Parse TypeDoc reflections recursively
* 2. Extract JSDoc metadata (descriptions, examples, tags)
* 3. Categorize items (Services, Utils, Types, Theme)
* 4. Generate type signatures and function signatures
* 5. Build navigation structure
*
* **Key features:**
* - Preserves JSDoc @example blocks with language detection
* - Filters out private/internal items
* - Handles complex TypeScript types (unions, intersections, generics)
* - Maintains source location references
*
* @module lib/docs/parser
* @category Docs
* @public
*/
import type {
TypeDocRoot,
TypeDocReflection,
TypeDocSignature,
TypeDocParameter,
DocItem,
DocSection,
DocNavigation,
DocCategory,
DocKind,
} from './types'
/**
* Maps TypeDoc's numeric kind identifiers to our simplified DocKind types.
*
* @remarks
* TypeDoc uses numeric identifiers (based on TypeScript's SymbolKind enum)
* to represent different declaration types. This map translates them to
* our simplified string-based kind system for easier consumption.
*
* **Common mappings:**
* - 1, 128: `'class'` (Class and Constructor)
* - 2: `'interface'`
* - 4, 16: `'enum'` (Enum and EnumMember)
* - 64, 512, 2048: `'function'` / `'method'`
* - 256, 1024, 2048: `'property'`
* - 4096, 8192, 16384: `'type'` (TypeAlias, TypeParameter)
*
* @internal
*/
const KIND_MAP: Record<number, DocKind> = {
1: 'class',
2: 'interface',
4: 'enum',
16: 'enum', // EnumMember
32: 'variable',
64: 'function',
128: 'class', // Constructor
256: 'property',
512: 'method',
1024: 'property',
2048: 'method',
4096: 'type',
8192: 'type',
16384: 'type', // TypeAlias
65536: 'method', // CallSignature
131072: 'method', // IndexSignature
262144: 'method', // ConstructorSignature
524288: 'property', // Parameter
1048576: 'type', // TypeParameter
2097152: 'property', // Accessor
4194304: 'property', // GetSignature
8388608: 'property', // SetSignature
16777216: 'type', // ObjectLiteral
33554432: 'type', // TypeLiteral
}
/**
* Parses TypeDoc JSON output into categorized documentation sections.
*
* @param json - TypeDoc JSON root object (generated with `typedoc --json`)
* @returns Array of documentation sections grouped by category (Services, Utils, Types, Theme, Other)
*
* @remarks
* This is the main entry point for the parser. It processes the entire TypeDoc
* reflection tree and produces a flat, categorized structure optimized for:
* - Client-side search and filtering
* - Category-based navigation
* - Alphabetically sorted items within categories
*
* **Processing steps:**
* 1. Recursively parse all top-level reflections
* 2. Filter out items without descriptions or in 'Other' category
* 3. Deduplicate items by ID
* 4. Group by category and sort items alphabetically
* 5. Sort sections by predefined category order
*
* **Category ordering:**
* Services Utils Types Theme Other
*
* @example
* ```ts
* import { parseTypeDocJSON } from '@/lib/docs/parser'
* import typedocJson from '@/public/docs/api.json'
*
* const sections = parseTypeDocJSON(typedocJson)
* // Returns: [
* // { title: 'Services', category: 'Services', items: [...] },
* // { title: 'Utils', category: 'Utils', items: [...] },
* // ...
* // ]
* ```
*
* @category Docs
* @public
*/
export function parseTypeDocJSON(json: TypeDocRoot): DocSection[] {
const sections: DocSection[] = []
const categoryMap = new Map<DocCategory, DocItem[]>()
if (!json.children) return sections
for (const child of json.children) {
const items = parseReflection(child, undefined, true)
for (const item of items) {
if (item.description || item.category !== 'Other') {
const existing = categoryMap.get(item.category) || []
existing.push(item)
categoryMap.set(item.category, existing)
}
}
}
for (const [category, items] of categoryMap.entries()) {
const uniqueItems = Array.from(
new Map(items.map(item => [item.id, item])).values()
)
sections.push({
title: category,
category,
items: uniqueItems.sort((a, b) => a.name.localeCompare(b.name)),
})
}
return sections.sort((a, b) => {
const order = ['Services', 'Utils', 'Types', 'Theme', 'Devices', 'Domains', 'Docs', 'API', 'Other']
return order.indexOf(a.category) - order.indexOf(b.category)
})
}
/**
* Recursively parses a TypeDoc reflection into one or more DocItem objects.
*
* @param reflection - TypeDoc reflection object to parse
* @param parentCategory - Inherited category from parent reflection
* @param topLevel - Whether this is a top-level reflection (controls child parsing)
* @returns Array of parsed DocItem objects
*
* @remarks
* This is a recursive parsing function that handles all TypeScript declaration types.
* It intelligently processes different reflection kinds (functions, classes, types, etc.)
* and extracts relevant metadata.
*
* **Filtering:**
* - Skips private items (isPrivate flag)
* - Skips external items (isExternal flag)
*
* **Parsing strategy:**
* - Functions with signatures Extract parameters, returns, examples
* - Classes/Interfaces/Types/Enums Create item with type signature
* - Variables/Properties Create simple item with type
* - Top-level items Parse children recursively
*
* @internal
*/
function parseReflection(
reflection: TypeDocReflection,
parentCategory?: DocCategory,
topLevel = false
): DocItem[] {
const items: DocItem[] = []
// Skip private/internal items
if (reflection.flags?.isPrivate || reflection.flags?.isExternal) {
return items
}
const kind = reflection.kindString
? (reflection.kindString.toLowerCase() as DocKind)
: KIND_MAP[reflection.kind] || 'variable'
const category = parentCategory || inferCategory(reflection)
const description = extractDescription(reflection.comment)
if (reflection.signatures && reflection.signatures.length > 0) {
for (const signature of reflection.signatures) {
items.push(createDocItemFromSignature(signature, reflection, category))
}
}
else if (
kind === 'class' ||
kind === 'interface' ||
kind === 'type' ||
kind === 'enum'
) {
const item: DocItem = {
id: createId(reflection),
name: reflection.name,
kind,
category,
description,
remarks: extractRemarks(reflection.comment),
see: extractSeeAlso(reflection.comment),
source: extractSource(reflection),
tags: extractTags(reflection.comment),
deprecated: isDeprecated(reflection.comment),
}
if (kind === 'type' || kind === 'interface') {
item.signature = formatTypeSignature(reflection)
if (kind === 'interface' && reflection.children && reflection.children.length > 0) {
item.parameters = reflection.children.map(child => ({
name: child.name,
type: child.type ? formatType(child.type) : 'any',
description: extractDescription(child.comment),
optional: child.flags?.isOptional || false,
defaultValue: child.defaultValue
}))
}
}
items.push(item)
if (topLevel && reflection.children) {
for (const child of reflection.children) {
items.push(...parseReflection(child, category, false))
}
}
}
else if (!parentCategory || topLevel) {
items.push({
id: createId(reflection),
name: reflection.name,
kind,
category,
description,
signature: reflection.type ? formatType(reflection.type) : undefined,
source: extractSource(reflection),
tags: extractTags(reflection.comment),
deprecated: isDeprecated(reflection.comment),
})
}
return items
}
/**
* Creates a complete DocItem from a function/method signature with metadata.
*
* @param signature - TypeDoc signature containing parameters, return type, and JSDoc
* @param parent - Parent reflection (for source location and naming)
* @param category - Documentation category for this item
* @returns Fully populated DocItem for a function/method
*
* @remarks
* This function extracts all relevant information from a function signature including:
* - Parameter names, types, and descriptions
* - Return type and description
* - Example code blocks with language identifiers
* - JSDoc tags and deprecation status
*
* @internal
*/
function createDocItemFromSignature(
signature: TypeDocSignature,
parent: TypeDocReflection,
category: DocCategory
): DocItem {
const description = extractDescription(signature.comment)
const parameters = signature.parameters?.map(parseParameter) || []
const returns = signature.type
? {
type: formatType(signature.type),
description: extractReturnDescription(signature.comment),
}
: undefined
return {
id: createId(parent),
name: parent.name,
kind: 'function',
category,
description,
remarks: extractRemarks(signature.comment),
signature: formatFunctionSignature(parent.name, parameters, returns?.type),
parameters,
returns,
examples: extractExamples(signature.comment),
throws: extractThrows(signature.comment),
see: extractSeeAlso(signature.comment),
source: extractSource(parent),
tags: extractTags(signature.comment),
deprecated: isDeprecated(signature.comment),
}
}
/**
* Parses a TypeDoc parameter into a simplified parameter object.
* @internal
*/
function parseParameter(param: TypeDocParameter) {
return {
name: param.name,
type: param.type ? formatType(param.type) : 'any',
description: extractDescription(param.comment),
optional: param.flags?.isOptional || false,
defaultValue: param.defaultValue,
}
}
/**
* Extract category from JSDoc @category tag
*/
function extractCategory(comment?: TypeDocReflection['comment']): DocCategory | undefined {
const categoryTag = comment?.blockTags?.find((tag) => tag.tag === '@category')
if (!categoryTag) return undefined
const categoryName = categoryTag.content.map((c) => c.text).join('').trim()
const categoryMap: Record<string, DocCategory> = {
'Services': 'Services',
'Utils': 'Utils',
'Types': 'Types',
'Theme': 'Theme',
'Devices': 'Devices',
'Domains': 'Domains',
'Docs': 'Docs',
'API': 'API',
}
return categoryMap[categoryName] || undefined
}
/**
* Infer category from reflection name and structure
*/
function inferCategory(reflection: TypeDocReflection): DocCategory {
const categoryFromTag = extractCategory(reflection.comment)
if (categoryFromTag) return categoryFromTag
const name = reflection.name.toLowerCase()
if (name.includes('service')) return 'Services'
if (name.includes('formatter') || name.includes('util')) return 'Utils'
if (name.includes('color') || name.includes('surface') || name.includes('theme'))
return 'Theme'
if (name.includes('device')) return 'Devices'
if (name.includes('domain')) return 'Domains'
if (reflection.kindString === 'Interface' || reflection.kindString === 'Type alias')
return 'Types'
// Check source file path
const source = reflection.sources?.[0]?.fileName
if (source) {
if (source.includes('/services/')) return 'Services'
if (source.includes('/utils/')) return 'Utils'
if (source.includes('/theme/')) return 'Theme'
if (source.includes('/types/')) return 'Types'
if (source.includes('/devices/')) return 'Devices'
if (source.includes('/domains/')) return 'Domains'
if (source.includes('/docs/')) return 'Docs'
}
return 'Other'
}
/**
* Extract description from TypeDoc comment
*/
function extractDescription(comment?: TypeDocReflection['comment']): string {
if (!comment?.summary) return ''
return comment.summary.map((s) => s.text).join('')
}
/**
* Extract return description from comment
*/
function extractReturnDescription(comment?: TypeDocSignature['comment']): string {
const returnTag = comment?.blockTags?.find((tag) => tag.tag === '@returns')
if (!returnTag) return ''
return returnTag.content.map((c) => c.text).join('')
}
/**
* Extract remarks (extended description) from comment
* @internal
*/
function extractRemarks(comment?: TypeDocReflection['comment']): string | undefined {
const remarksTag = comment?.blockTags?.find((tag) => tag.tag === '@remarks')
if (!remarksTag) return undefined
return remarksTag.content.map((c) => c.text).join('').trim()
}
/**
* Extract exception documentation from comment
* @internal
*/
function extractThrows(comment?: TypeDocReflection['comment']): string[] {
const throwsTags = comment?.blockTags?.filter((tag) => tag.tag === '@throws') || []
return throwsTags.map((tag) => tag.content.map((c) => c.text).join('').trim())
}
/**
* Extract see-also references from comment
* @internal
*/
function extractSeeAlso(comment?: TypeDocReflection['comment']): string[] {
const seeTags = comment?.blockTags?.filter((tag) => tag.tag === '@see') || []
return seeTags.map((tag) => tag.content.map((c) => c.text).join('').trim())
}
/**
* Extracts language identifier from markdown code fences and removes fence markers.
* @internal
*/
function extractCodeAndLanguage(code: string): { code: string; language: string } {
// Extract language from opening fence (e.g., ```ts, ```tsx, ```javascript)
const languageMatch = code.match(/^```(\w+)\n/)
const language = languageMatch?.[1] || 'typescript'
// Remove opening code fence with optional language identifier
let cleaned = code.replace(/^```(?:\w+)?\n/gm, '')
// Remove closing code fence
cleaned = cleaned.replace(/\n?```$/gm, '')
// Trim leading/trailing whitespace
return {
code: cleaned.trim(),
language,
}
}
/**
* Extracts example code blocks with language identifiers from TypeDoc comment tags.
* @internal
*/
function extractExamples(comment?: TypeDocSignature['comment']): Array<{ code: string; language: string }> {
const exampleTags = comment?.blockTags?.filter((tag) => tag.tag === '@example') || []
return exampleTags.map((tag) => {
const rawExample = tag.content.map((c) => c.text).join('')
return extractCodeAndLanguage(rawExample)
})
}
/**
* Extract tags from comment
*/
function extractTags(comment?: TypeDocReflection['comment']): string[] {
if (!comment?.blockTags) return []
return comment.blockTags
.map((tag) => tag.tag.replace('@', ''))
.filter((tag) => !['returns', 'param', 'example', 'remarks', 'throws', 'see', 'category'].includes(tag))
}
/**
* Check if item is deprecated
*/
function isDeprecated(comment?: TypeDocReflection['comment']): boolean {
return comment?.blockTags?.some((tag) => tag.tag === '@deprecated') || false
}
/**
* Extract source location
*/
function extractSource(reflection: TypeDocReflection) {
const source = reflection.sources?.[0]
if (!source) return undefined
return {
file: source.fileName,
line: source.line,
}
}
/**
* Format a type to string
*/
function formatType(type: TypeDocReflection['type']): string {
if (!type) return 'any'
switch (type.type) {
case 'intrinsic':
return type.name || 'any'
case 'reference':
return type.name || 'any'
case 'array':
return type.elementType ? `${formatType(type.elementType)}[]` : 'any[]'
case 'union':
return type.types ? type.types.map(formatType).join(' | ') : 'any'
case 'intersection':
return type.types ? type.types.map(formatType).join(' & ') : 'any'
case 'literal':
return JSON.stringify(type.value)
case 'reflection':
return 'object'
default:
return 'any'
}
}
/**
* Format function signature
*/
function formatFunctionSignature(
name: string,
parameters: Array<{
name: string
type: string
optional: boolean
defaultValue?: string
}>,
returnType?: string
): string {
const params = parameters
.map((p) => {
const opt = p.optional ? '?' : ''
const def = p.defaultValue ? ` = ${p.defaultValue}` : ''
return `${p.name}${opt}: ${p.type}${def}`
})
.join(', ')
return `${name}(${params})${returnType ? `: ${returnType}` : ''}`
}
/**
* Format type signature for interfaces/types
*/
function formatTypeSignature(reflection: TypeDocReflection): string {
if (!reflection.type) return ''
const type = reflection.type
if (type.type === 'reflection' && type.declaration?.children) {
const props = type.declaration.children
.map((child) => {
const opt = child.flags?.isOptional ? '?' : ''
const childType = child.type ? formatType(child.type) : 'any'
return ` ${child.name}${opt}: ${childType}`
})
.join('\n')
return `{\n${props}\n}`
}
return formatType(type)
}
/**
* Create a unique ID for a doc item
*/
function createId(reflection: TypeDocReflection): string {
const source = reflection.sources?.[0]
if (source) {
const file = source.fileName.replace(/^.*\/(lib|components)\//, '')
return `${file}-${reflection.name}-${reflection.id}`
}
return `${reflection.name}-${reflection.id}`
}
/**
* Build navigation structure from doc sections
*/
export function buildNavigation(sections: DocSection[]): DocNavigation {
return {
sections: sections.map((section) => ({
title: section.title,
category: section.category,
items: section.items.map((item) => ({
id: item.id,
name: item.name,
kind: item.kind,
})),
})),
}
}
/**
* Get all doc items flattened
*/
export function getAllItems(sections: DocSection[]): DocItem[] {
return sections.flatMap((section) => section.items)
}
/**
* Find a doc item by ID
*/
export function findItemById(sections: DocSection[], id: string): DocItem | undefined {
for (const section of sections) {
const item = section.items.find((i) => i.id === id)
if (item) return item
}
return undefined
}

344
lib/docs/search.ts Normal file
View file

@ -0,0 +1,344 @@
/**
* Documentation search engine with weighted scoring algorithm.
*
* @remarks
* This module provides fast, client-side search functionality for documentation items
* with a sophisticated scoring system that prioritizes different types of matches.
*
* **Features:**
* - Multi-term search with space-separated queries
* - Weighted scoring (exact matches > prefix matches > contains matches)
* - Category and kind filtering
* - Tag-based filtering
* - Search suggestions based on partial queries
* - Results grouping by category
*
* **Scoring system:**
* - Exact name match: 100 points
* - Name starts with term: 50 points
* - Name contains term: 30 points
* - Description contains term: 20 points
* - Signature contains term: 15 points
* - Tag contains term: 10 points
* - Parameter name contains term: 5 points
*
* @module lib/docs/search
* @category Docs
* @public
*/
import type { DocItem, DocFilters, APIEndpoint } from './types'
/**
* Searches through documentation items with filtering and scoring.
*
* @param items - Array of documentation items to search
* @param query - Search query string (space-separated terms)
* @param filters - Optional filters for category, kind, and tags
* @returns Filtered and scored array of documentation items, sorted by relevance
*
* @remarks
* This function implements a two-phase search:
* 1. **Filter phase**: Apply category, kind, and tag filters
* 2. **Search phase**: Score items based on query term matches
*
* **Empty query handling:**
* If query is empty or only whitespace, returns filtered items without scoring.
*
* **Multi-term queries:**
* Space-separated terms are searched independently and scores are accumulated.
* Example: "format date" searches for both "format" AND "date".
*
* @example
* ```ts
* import { searchDocs } from '@/lib/docs/search'
* import { getAllItems } from '@/lib/docs/parser'
*
* const allItems = getAllItems(sections)
*
* // Simple search
* const results = searchDocs(allItems, 'formatter')
*
* // Search with filters
* const serviceResults = searchDocs(allItems, 'get domain', {
* category: 'Services',
* kind: 'function'
* })
* ```
*
* @category Docs
* @public
*/
export function searchDocs(
items: DocItem[],
query: string,
filters?: DocFilters
): DocItem[] {
let results = items
// Apply filters
if (filters) {
if (filters.category) {
results = results.filter((item) => item.category === filters.category)
}
if (filters.kind) {
results = results.filter((item) => item.kind === filters.kind)
}
if (filters.tags && filters.tags.length > 0) {
results = results.filter((item) =>
filters.tags!.some((tag) => item.tags?.includes(tag))
)
}
}
// Apply search query
if (!query || query.trim() === '') {
return results
}
const searchTerms = query.toLowerCase().split(/\s+/)
return results
.map((item) => ({
item,
score: calculateSearchScore(item, searchTerms),
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ item }) => item)
}
/**
* Calculates weighted search score for a documentation item.
* @internal
*/
function calculateSearchScore(item: DocItem, searchTerms: string[]): number {
let score = 0
const name = item.name.toLowerCase()
const description = item.description.toLowerCase()
const signature = item.signature?.toLowerCase() || ''
const tags = (item.tags || []).join(' ').toLowerCase()
for (const term of searchTerms) {
// Exact name match (highest score)
if (name === term) {
score += 100
continue
}
// Name starts with term
if (name.startsWith(term)) {
score += 50
continue
}
// Name contains term
if (name.includes(term)) {
score += 30
continue
}
// Description contains term
if (description.includes(term)) {
score += 20
}
// Signature contains term
if (signature.includes(term)) {
score += 15
}
// Tags contain term
if (tags.includes(term)) {
score += 10
}
// Parameter names contain term
if (item.parameters) {
for (const param of item.parameters) {
if (param.name.toLowerCase().includes(term)) {
score += 5
}
}
}
}
return score
}
/**
* Searches through API endpoints with weighted scoring.
*
* @param endpoints - Array of API endpoints to search
* @param query - Search query string (space-separated terms)
* @returns Filtered and scored array of API endpoints, sorted by relevance
*
* @remarks
* Similar to searchDocs but optimized for API endpoint structure.
* Searches path, method, and description fields.
*
* @category Docs
* @public
*/
export function searchAPIs(
endpoints: APIEndpoint[],
query: string
): APIEndpoint[] {
if (!query || query.trim() === '') {
return endpoints
}
const searchTerms = query.toLowerCase().split(/\s+/)
return endpoints
.map((endpoint) => ({
endpoint,
score: calculateAPIScore(endpoint, searchTerms),
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ endpoint }) => endpoint)
}
/**
* Calculate search score for API endpoint
*/
function calculateAPIScore(endpoint: APIEndpoint, searchTerms: string[]): number {
let score = 0
const path = endpoint.path.toLowerCase()
const description = endpoint.description.toLowerCase()
const method = endpoint.method.toLowerCase()
for (const term of searchTerms) {
// Path exact match
if (path === term) {
score += 100
continue
}
// Path contains term
if (path.includes(term)) {
score += 50
}
// Method matches
if (method === term) {
score += 40
}
// Description contains term
if (description.includes(term)) {
score += 20
}
}
return score
}
/**
* Get search suggestions based on partial query
*/
export function getSearchSuggestions(
items: DocItem[],
query: string,
limit = 5
): string[] {
if (!query || query.trim() === '') {
return []
}
const queryLower = query.toLowerCase()
const suggestions = new Set<string>()
for (const item of items) {
// Suggest item names
if (item.name.toLowerCase().includes(queryLower)) {
suggestions.add(item.name)
}
// Suggest tags
if (item.tags) {
for (const tag of item.tags) {
if (tag.toLowerCase().includes(queryLower)) {
suggestions.add(tag)
}
}
}
if (suggestions.size >= limit) break
}
return Array.from(suggestions).slice(0, limit)
}
/**
* Group search results by category
*/
export function groupByCategory(items: DocItem[]): Map<string, DocItem[]> {
const grouped = new Map<string, DocItem[]>()
for (const item of items) {
const category = item.category
const existing = grouped.get(category) || []
existing.push(item)
grouped.set(category, existing)
}
return grouped
}
/**
* Highlight search terms in text
*/
export function highlightSearchTerms(
text: string,
searchTerms: string[]
): string {
let highlighted = text
for (const term of searchTerms) {
const regex = new RegExp(`(${escapeRegExp(term)})`, 'gi')
highlighted = highlighted.replace(regex, '<mark>$1</mark>')
}
return highlighted
}
/**
* Escape special regex characters
*/
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Create search index for faster lookups
*/
export function createSearchIndex(items: DocItem[]): Map<string, DocItem[]> {
const index = new Map<string, DocItem[]>()
for (const item of items) {
// Index by name tokens
const nameTokens = item.name.toLowerCase().split(/[_\-\s]+/)
for (const token of nameTokens) {
const existing = index.get(token) || []
if (!existing.includes(item)) {
existing.push(item)
index.set(token, existing)
}
}
// Index by tags
if (item.tags) {
for (const tag of item.tags) {
const existing = index.get(tag.toLowerCase()) || []
if (!existing.includes(item)) {
existing.push(item)
index.set(tag.toLowerCase(), existing)
}
}
}
}
return index
}

225
lib/docs/types.ts Normal file
View file

@ -0,0 +1,225 @@
/**
* Type definitions for documentation system
*/
export interface TypeDocReflection {
id: number
name: string
kind: number
kindString?: string
flags?: {
isExported?: boolean
isExternal?: boolean
isOptional?: boolean
isRest?: boolean
isPrivate?: boolean
isProtected?: boolean
isPublic?: boolean
isStatic?: boolean
isReadonly?: boolean
isAbstract?: boolean
}
comment?: {
summary?: Array<{ kind: string; text: string }>
blockTags?: Array<{
tag: string
content: Array<{ kind: string; text: string }>
}>
}
children?: TypeDocReflection[]
groups?: Array<{
title: string
children: number[]
}>
sources?: Array<{
fileName: string
line: number
character: number
}>
signatures?: TypeDocSignature[]
type?: TypeDocType
defaultValue?: string
parameters?: TypeDocParameter[]
}
export interface TypeDocSignature {
id: number
name: string
kind: number
kindString?: string
comment?: {
summary?: Array<{ kind: string; text: string }>
blockTags?: Array<{
tag: string
content: Array<{ kind: string; text: string }>
}>
}
parameters?: TypeDocParameter[]
type?: TypeDocType
}
export interface TypeDocParameter {
id: number
name: string
kind: number
kindString?: string
flags?: {
isOptional?: boolean
isRest?: boolean
}
comment?: {
summary?: Array<{ kind: string; text: string }>
}
type?: TypeDocType
defaultValue?: string
}
export interface TypeDocType {
type: string
name?: string
value?: string | number | boolean | null
types?: TypeDocType[]
typeArguments?: TypeDocType[]
elementType?: TypeDocType
declaration?: TypeDocReflection
target?: number
package?: string
qualifiedName?: string
}
export interface TypeDocRoot {
id: number
name: string
kind: number
kindString?: string
children?: TypeDocReflection[]
groups?: Array<{
title: string
children: number[]
}>
packageName?: string
packageVersion?: string
}
/**
* Processed documentation structure
*/
export interface DocItem {
id: string
name: string
kind: DocKind
category: DocCategory
description: string
remarks?: string
signature?: string
parameters?: DocParameter[]
returns?: {
type: string
description: string
}
examples?: Array<{
code: string
language: string
}>
throws?: string[]
see?: string[]
source?: {
file: string
line: number
}
tags?: string[]
deprecated?: boolean
}
export type DocKind =
| 'function'
| 'method'
| 'class'
| 'interface'
| 'type'
| 'variable'
| 'property'
| 'enum'
export type DocCategory = 'Services' | 'Utils' | 'Types' | 'Theme' | 'Devices' | 'Domains' | 'Docs' | 'API' | 'Other'
export interface DocParameter {
name: string
type: string
description: string
optional: boolean
defaultValue?: string
}
export interface DocSection {
title: string
items: DocItem[]
category: DocCategory
}
export interface DocNavigation {
sections: Array<{
title: string
category: DocCategory
items: Array<{
id: string
name: string
kind: DocKind
}>
}>
}
/**
* API endpoint documentation
*/
export interface APIEndpoint {
id: string
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
path: string
description: string
category: string
auth?: {
required: boolean
type?: string
description?: string
}
parameters?: {
query?: DocParameter[]
body?: DocParameter[]
headers?: DocParameter[]
}
responses: Array<{
status: number
description: string
schema?: Record<string, unknown>
example?: Record<string, unknown>
}>
examples?: Array<{
title: string
request: string | Record<string, unknown>
response: string | Record<string, unknown>
}>
}
/**
* Search result
*/
export interface SearchResult {
item: DocItem | APIEndpoint
matches: Array<{
key: string
value: string
indices: Array<[number, number]>
}>
score: number
}
/**
* Documentation filters
*/
export interface DocFilters {
category?: DocCategory
kind?: DocKind
search?: string
tags?: string[]
}

178
lib/domains/config.ts Normal file
View file

@ -0,0 +1,178 @@
/**
* Domain visual configuration for status and category badges, icons, and colors.
*
* @remarks
* This module provides the centralized visual configuration for domain portfolio display,
* including icons, colors, backgrounds, and borders for all domain statuses and categories.
* All color values use Tailwind CSS classes for consistency with the theme system.
*
* @module lib/domains/config
* @category Domains
* @public
*/
import {
CheckCircle,
Archive,
Construction,
User,
Briefcase,
Rocket,
PartyPopper,
Package
} from 'lucide-react'
import type { DomainVisualConfig } from '@/lib/types'
/**
* Visual configuration for domain status and category display.
*
* @remarks
* Provides a complete mapping of domain statuses and categories to their visual representation.
* Used throughout the domain portfolio for consistent badge styling, icons, and colors.
*
* **Configuration includes:**
* - **status**: Visual config for active, parked, and reserved domains
* - **category**: Visual config for personal, service, project, fun, and legacy domains
*
* Each configuration includes:
* - `label`: Human-readable display text
* - `icon`: Lucide React icon component
* - `color`: Tailwind text color class
* - `bg`: Tailwind background color class (semi-transparent)
* - `border`: Tailwind border color class (semi-transparent)
*
* @example
* ```tsx
* import { domainVisualConfig } from '@/lib/domains/config'
*
* const DomainStatusBadge = ({ status }: { status: DomainStatus }) => {
* const config = domainVisualConfig.status[status]
* const Icon = config.icon
*
* return (
* <span className={`${config.color} ${config.bg} ${config.border}`}>
* <Icon className="w-4 h-4" />
* {config.label}
* </span>
* )
* }
* ```
*
* @category Domains
* @public
*/
export const domainVisualConfig: DomainVisualConfig = {
status: {
active: {
label: 'Active',
icon: CheckCircle,
color: 'text-slate-400',
bg: 'bg-slate-500/10',
border: 'border-slate-500/20'
},
parked: {
label: 'Parked',
icon: Archive,
color: 'text-gray-400',
bg: 'bg-gray-600/10',
border: 'border-gray-600/20'
},
reserved: {
label: 'Reserved',
icon: Construction,
color: 'text-slate-400',
bg: 'bg-slate-600/10',
border: 'border-slate-600/20'
}
},
category: {
personal: {
label: 'Personal',
icon: User,
color: 'text-slate-400',
bg: 'bg-slate-500/10',
border: 'border-slate-500/20'
},
service: {
label: 'Service',
icon: Briefcase,
color: 'text-slate-400',
bg: 'bg-slate-500/10',
border: 'border-slate-500/20'
},
project: {
label: 'Project',
icon: Rocket,
color: 'text-slate-400',
bg: 'bg-slate-500/10',
border: 'border-slate-500/20'
},
fun: {
label: 'Fun',
icon: PartyPopper,
color: 'text-slate-400',
bg: 'bg-slate-500/10',
border: 'border-slate-500/20'
},
legacy: {
label: 'Legacy',
icon: Package,
color: 'text-slate-400',
bg: 'bg-slate-500/10',
border: 'border-slate-500/20'
}
}
}
/**
* Available sort options for domain list displays.
*
* @remarks
* Provides a predefined list of sort options for domain portfolio UI components.
* Each option includes a value (for logic) and label (for display).
*
* @example
* ```tsx
* import { sortOptions } from '@/lib/domains/config'
*
* const DomainSortSelect = () => (
* <select>
* {sortOptions.map(option => (
* <option key={option.value} value={option.value}>
* {option.label}
* </option>
* ))}
* </select>
* )
* ```
*
* @category Domains
* @public
*/
export const sortOptions = [
{ value: 'name', label: 'Name (A-Z)' },
{ value: 'expiration', label: 'Expiration Date' },
{ value: 'ownership', label: 'Ownership Duration' },
{ value: 'registrar', label: 'Registrar' }
] as const
/**
* Days threshold for "expiring soon" warning indicators.
*
* @remarks
* Domains expiring within this many days should display warning indicators.
* Default is 30 days.
*
* @example
* ```ts
* import { expirationThreshold } from '@/lib/domains/config'
* import { getDaysUntilExpiration } from '@/lib/domains/utils'
*
* const daysUntil = getDaysUntilExpiration(domain)
* const isExpiringSoon = daysUntil <= expirationThreshold
* ```
*
* @category Domains
* @public
*/
export const expirationThreshold = 30 // days

222
lib/domains/data.ts Normal file
View file

@ -0,0 +1,222 @@
import { SiNamecheap, SiSpaceship } from 'react-icons/si'
import NameIcon from '@/components/icons/NameIcon'
import DynadotIcon from '@/components/icons/DynadotIcon'
import { RegistrarConfig, Domain } from '@/lib/types'
export const registrars: Record<string, RegistrarConfig> = {
'Spaceship': {
name: 'Spaceship',
icon: SiSpaceship,
color: 'text-white'
},
'Namecheap': {
name: 'Namecheap',
icon: SiNamecheap,
color: 'text-orange-400'
},
'Name.com': {
name: 'Name.com',
icon: NameIcon,
color: 'text-green-300'
},
'Dynadot': {
name: 'Dynadot',
icon: DynadotIcon,
color: 'text-blue-400'
},
}
export const domains: Domain[] = [
{
domain: "aidan.so",
usage: "The home of my primary website",
registrar: "Dynadot",
autoRenew: false,
status: "active",
category: "personal",
tags: ["homepage", "nextjs"],
renewals: [
{ date: "2025-10-09", years: 1 }
]
},
{
domain: "aidxn.cc",
usage: "The old domain of my primary website",
registrar: "Spaceship",
autoRenew: false,
status: "active",
category: "personal",
tags: ["homepage", "nextjs"],
renewals: [
{ date: "2025-01-04", years: 1 }
]
},
{
domain: "pontushost.com",
usage: "My hosting provider project",
registrar: "Spaceship",
autoRenew: false,
status: "active",
category: "service",
tags: ["hosting", "services"],
renewals: [
{ date: "2025-08-18", years: 1 }
]
},
{
domain: "disfunction.blog",
usage: "My blog's official home",
registrar: "Spaceship",
autoRenew: false,
status: "active",
category: "personal",
tags: ["blog"],
renewals: [
{ date: "2025-02-02", years: 1 }
]
},
{
domain: "androidintegrity.org",
usage: "A project to fix Play Integrity",
registrar: "Spaceship",
autoRenew: false,
status: "reserved",
category: "project",
tags: ["android", "open-source"],
renewals: [
{ date: "2024-11-24", years: 3 },
]
},
{
domain: "librecloud.cc",
usage: "My old cloud services provider project",
registrar: "Spaceship",
autoRenew: false,
status: "parked",
category: "legacy",
tags: ["cloud", "services"],
renewals: [
{ date: "2025-02-02", years: 1 }
]
},
{
domain: "ihate.college",
usage: "One of my fun domains, used for p0ntus mail and services",
registrar: "Spaceship",
autoRenew: false,
status: "active",
category: "project",
tags: ["email", "humor", "vanity"],
renewals: [
{ date: "2025-01-05", years: 1 },
]
},
{
domain: "pontus.pics",
usage: "An unused domain for an upcoming image hosting service",
registrar: "Spaceship",
autoRenew: false,
status: "reserved",
category: "project",
tags: ["images", "hosting", "future"],
renewals: [
{ date: "2024-12-17", years: 1 }
]
},
{
domain: "p0ntus.com",
usage: "My active cloud services project",
registrar: "Spaceship",
autoRenew: false,
status: "active",
category: "service",
tags: ["cloud", "services"],
renewals: [
{ date: "2024-11-14", years: 1 }
]
},
{
domain: "modules.lol",
usage: "An 'app store' of Magisk modules and FOSS Android apps",
registrar: "Spaceship",
autoRenew: false,
status: "active",
category: "project",
tags: ["android", "apps", "open-source"],
renewals: [
{ date: "2024-12-17", years: 1 }
]
},
{
domain: "dontbeevil.lol",
usage: "A public Matrix homeserver",
registrar: "Namecheap",
autoRenew: false,
status: "active",
category: "project",
tags: ["matrix", "services", "humor", "vanity"],
renewals: [
{ date: "2025-01-08", years: 1 },
]
},
{
domain: "wikitools.cloud",
usage: "Unused (for now!)",
registrar: "Namecheap",
autoRenew: false,
status: "reserved",
category: "project",
tags: ["tools", "wiki", "future"],
renewals: [
{ date: "2025-01-04", years: 1 }
]
},
{
domain: "dont-be-evil.lol",
usage: "A joke domain for p0ntus mail",
registrar: "Spaceship",
autoRenew: false,
status: "parked",
category: "fun",
tags: ["email", "humor", "vanity"],
renewals: [
{ date: "2025-01-08", years: 1 }
]
},
{
domain: "pontusmail.org",
usage: "An email domain for p0ntus Mail",
registrar: "Spaceship",
autoRenew: false,
status: "active",
category: "service",
tags: ["email", "services"],
renewals: [
{ date: "2025-12-17", years: 1 }
]
},
{
domain: "strongintegrity.life",
usage: "A Play Integrity meme domain used for p0ntus mail (now inactive)",
registrar: "Spaceship",
autoRenew: false,
status: "reserved",
category: "fun",
tags: ["email", "humor", "android"],
renewals: [
{ date: "2025-01-08", years: 1 }
]
},
{
domain: "kowalski.social",
usage: "A domain for ABOCN's Kowalski project",
registrar: "Name.com",
autoRenew: true,
status: "active",
category: "project",
tags: ["social", "abocn"],
renewals: [
{ date: "2025-07-03", years: 1 }
]
}
]

590
lib/domains/utils.ts Normal file
View file

@ -0,0 +1,590 @@
/**
* Domain utility functions for date calculations and data enrichment.
*
* @remarks
* This module provides convenience wrapper functions around DomainService methods,
* offering a simpler API for common domain operations like date calculations,
* ownership metrics, and timeline generation.
*
* **Key features:**
* - Date extraction (registration, expiration, renewal)
* - Ownership duration calculations (years, months, days)
* - Expiration warnings and progress tracking
* - Timeline event generation
* - TLD extraction
*
* **Design pattern:**
* Most functions delegate to {@link DomainService.enrichDomain} which computes
* all metrics at once, making these wrappers efficient for accessing individual metrics.
*
* @example
* ```ts
* import { getOwnershipDuration, isExpiringSoon } from '@/lib/domains/utils'
*
* const years = getOwnershipDuration(domain)
* const expiring = isExpiringSoon(domain, 90) // 90 days threshold
* ```
*
* @module lib/domains/utils
* @category Domains
* @public
*/
import type { Domain } from '@/lib/types'
import { DomainService } from '@/lib/services'
import type { DomainTimelineEvent } from '@/lib/types/domain'
export type { Domain, Renewal } from '@/lib/types'
/**
* Gets the registration date for a domain.
* @param domain - Domain object
* @returns Registration date
* @see {@link DomainService.enrichDomain}
* @category Domains
* @public
*/
export function getRegistrationDate(domain: Domain): Date {
const enriched = DomainService.enrichDomain(domain)
return enriched.registrationDate
}
/**
* Gets the expiration date for a domain based on renewal history.
*
* @param domain - Domain object with renewal records
* @returns Computed expiration date
*
* @remarks
* This function delegates to {@link DomainService.enrichDomain} which computes
* the expiration date by summing all renewal periods from the initial registration.
* The calculation accounts for multi-year renewals and uses the last renewal's
* end date as the expiration.
*
* @example
* ```ts
* import { getExpirationDate } from '@/lib/domains/utils'
*
* const expirationDate = getExpirationDate(domain)
* console.log(expirationDate) // 2026-03-15T00:00:00.000Z
* ```
*
* @example
* ```ts
* // Check if domain is expired
* const expDate = getExpirationDate(domain)
* const isExpired = expDate < new Date()
* if (isExpired) {
* console.log('Domain has expired!')
* }
* ```
*
* @see {@link DomainService.enrichDomain}
* @category Domains
* @public
*/
export function getExpirationDate(domain: Domain): Date {
const enriched = DomainService.enrichDomain(domain)
return enriched.expirationDate
}
/**
* Gets the ownership duration in years for a domain.
*
* @param domain - Domain object with renewal history
* @returns Ownership duration in whole years (floor value)
*
* @remarks
* This function calculates how many full years the domain has been owned
* since the initial registration date. The value is floored, so a domain
* owned for 2 years and 11 months returns 2.
*
* Uses {@link DomainService.enrichDomain} for efficient metric computation.
*
* @example
* ```ts
* import { getOwnershipDuration } from '@/lib/domains/utils'
*
* const years = getOwnershipDuration(domain)
* console.log(`Owned for ${years} years`) // "Owned for 3 years"
* ```
*
* @example
* ```ts
* // Display ownership milestone
* const years = getOwnershipDuration(domain)
* if (years >= 5) {
* console.log('Long-term domain!')
* }
* ```
*
* @see {@link DomainService.enrichDomain}
* @see {@link getOwnershipMonths} For month-level precision
* @see {@link getOwnershipDays} For day-level precision
* @category Domains
* @public
*/
export function getOwnershipDuration(domain: Domain): number {
const enriched = DomainService.enrichDomain(domain)
return enriched.ownershipYears
}
/**
* Gets the ownership duration in months for a domain.
*
* @param domain - Domain object with renewal history
* @returns Total ownership duration in months
*
* @remarks
* This function calculates the precise number of months between the registration
* date and the current date. Month boundaries are respected - if the current
* day is earlier than the registration day, the month count is decremented.
*
* For year-level precision, use {@link getOwnershipDuration}.
*
* @example
* ```ts
* import { getOwnershipMonths } from '@/lib/domains/utils'
*
* const months = getOwnershipMonths(domain)
* console.log(`Owned for ${months} months`) // "Owned for 38 months"
* ```
*
* @example
* ```ts
* // Calculate years and remaining months
* const totalMonths = getOwnershipMonths(domain)
* const years = Math.floor(totalMonths / 12)
* const months = totalMonths % 12
* console.log(`${years}y ${months}mo`) // "3y 2mo"
* ```
*
* @see {@link DomainService.enrichDomain}
* @see {@link getOwnershipDuration} For year-level precision
* @see {@link getOwnershipDays} For day-level precision
* @category Domains
* @public
*/
export function getOwnershipMonths(domain: Domain): number {
const enriched = DomainService.enrichDomain(domain)
return enriched.ownershipMonths
}
/**
* Gets the ownership duration in days for a domain.
*
* @param domain - Domain object with renewal history
* @returns Total ownership duration in days
*
* @remarks
* This function provides the most precise ownership duration metric by
* calculating the exact number of days between registration and the current date.
* Uses ceiling to ensure partial days are counted.
*
* For less granular metrics, use {@link getOwnershipDuration} or {@link getOwnershipMonths}.
*
* @example
* ```ts
* import { getOwnershipDays } from '@/lib/domains/utils'
*
* const days = getOwnershipDays(domain)
* console.log(`Owned for ${days} days`) // "Owned for 1,247 days"
* ```
*
* @example
* ```ts
* // Check if domain is newly registered
* const days = getOwnershipDays(domain)
* if (days < 30) {
* console.log('New domain!')
* }
* ```
*
* @see {@link DomainService.enrichDomain}
* @see {@link getOwnershipDuration} For year-level precision
* @see {@link getOwnershipMonths} For month-level precision
* @category Domains
* @public
*/
export function getOwnershipDays(domain: Domain): number {
const enriched = DomainService.enrichDomain(domain)
return enriched.ownershipDays
}
/**
* Calculates the total renewal period in days from registration to expiration.
*
* @param domain - Domain object with renewal history
* @returns Total days from initial registration to current expiration date
*
* @remarks
* This function computes the complete lifecycle span of all renewals combined.
* Unlike {@link getOwnershipDays} which measures time from registration to now,
* this measures the total committed period (registration to expiration).
*
* Useful for calculating average renewal period length or total commitment duration.
*
* @example
* ```ts
* import { getTotalRenewalPeriodDays } from '@/lib/domains/utils'
*
* const totalDays = getTotalRenewalPeriodDays(domain)
* console.log(`Total period: ${totalDays} days`) // "Total period: 1,825 days"
* ```
*
* @example
* ```ts
* // Calculate average renewal length
* const totalDays = getTotalRenewalPeriodDays(domain)
* const renewalCount = domain.renewals.length
* const avgDays = totalDays / renewalCount
* console.log(`Avg renewal: ${Math.round(avgDays / 365)} years`)
* ```
*
* @see {@link getOwnershipDays} For current ownership duration
* @see {@link getDaysUntilExpiration} For remaining days
* @category Domains
* @public
*/
export function getTotalRenewalPeriodDays(domain: Domain): number {
const registrationDate = getRegistrationDate(domain)
const expirationDate = getExpirationDate(domain)
const diffTime = expirationDate.getTime() - registrationDate.getTime()
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
}
/**
* Gets the renewal progress as a percentage (0-100).
*
* @param domain - Domain object with renewal history
* @returns Percentage of current renewal period elapsed (0-100)
*
* @remarks
* This function calculates how far through the total renewal period
* the domain ownership currently is. The value represents the percentage
* from registration date to expiration date.
*
* - 0% = just registered
* - 50% = halfway through renewal period
* - 100% = at or past expiration
*
* @example
* ```ts
* import { getRenewalProgress } from '@/lib/domains/utils'
*
* const progress = getRenewalProgress(domain)
* console.log(`${progress.toFixed(1)}% complete`) // "67.3% complete"
* ```
*
* @example
* ```ts
* // Display progress bar
* const progress = getRenewalProgress(domain)
* const barWidth = Math.round(progress)
* console.log('█'.repeat(barWidth) + '░'.repeat(100 - barWidth))
* ```
*
* @see {@link DomainService.enrichDomain}
* @see {@link getDaysUntilExpiration} For days remaining
* @category Domains
* @public
*/
export function getRenewalProgress(domain: Domain): number {
const enriched = DomainService.enrichDomain(domain)
return enriched.renewalProgressPercent
}
/**
* Gets the number of days until domain expiration.
*
* @param domain - Domain object with renewal history
* @returns Days until expiration (negative if already expired)
*
* @remarks
* This function calculates days from the current date to the domain's
* expiration date. The value can be:
*
* - Positive: Days remaining before expiration
* - Zero: Expires today
* - Negative: Days since expiration (domain expired)
*
* Commonly used with {@link isExpiringSoon} for renewal alerts.
*
* @example
* ```ts
* import { getDaysUntilExpiration } from '@/lib/domains/utils'
*
* const days = getDaysUntilExpiration(domain)
* console.log(`${days} days until expiration`) // "45 days until expiration"
* ```
*
* @example
* ```ts
* // Display appropriate message based on status
* const days = getDaysUntilExpiration(domain)
* if (days < 0) {
* console.log(`Expired ${Math.abs(days)} days ago!`)
* } else if (days === 0) {
* console.log('Expires today!')
* } else if (days < 30) {
* console.log(`Renew soon - ${days} days left`)
* } else {
* console.log(`${days} days remaining`)
* }
* ```
*
* @see {@link DomainService.enrichDomain}
* @see {@link isExpiringSoon} For boolean expiration check
* @category Domains
* @public
*/
export function getDaysUntilExpiration(domain: Domain): number {
const enriched = DomainService.enrichDomain(domain)
return enriched.daysUntilExpiration
}
/**
* Checks if a domain is expiring soon based on a configurable threshold.
*
* @param domain - Domain object with renewal history
* @param thresholdDays - Number of days to consider "expiring soon" (default: 90)
* @returns True if domain expires within the threshold period
*
* @remarks
* This function provides a simple boolean check for domains requiring renewal attention.
* The default 90-day threshold is typical for domain renewal planning, but can be
* adjusted based on your renewal workflow.
*
* Returns true if:
* - Domain expires in fewer days than the threshold
* - Domain is already expired (negative days)
*
* @example
* ```ts
* import { isExpiringSoon } from '@/lib/domains/utils'
*
* // Check with default 90-day threshold
* if (isExpiringSoon(domain)) {
* console.log('Renewal required soon!')
* }
* ```
*
* @example
* ```ts
* // Custom threshold for urgent renewals
* const urgent = isExpiringSoon(domain, 30)
* const warning = isExpiringSoon(domain, 90)
*
* if (urgent) {
* console.log('🚨 URGENT: Renew within 30 days!')
* } else if (warning) {
* console.log('⚠️ Renewal recommended')
* }
* ```
*
* @example
* ```ts
* // Filter domains needing renewal
* const domains = DomainService.getAllDomains()
* const needsRenewal = domains.filter(d => isExpiringSoon(d, 60))
* console.log(`${needsRenewal.length} domains need renewal`)
* ```
*
* @see {@link getDaysUntilExpiration} For exact day count
* @category Domains
* @public
*/
export function isExpiringSoon(domain: Domain, thresholdDays: number = 90): boolean {
return getDaysUntilExpiration(domain) <= thresholdDays
}
/**
* Formats a Date object to a human-readable string.
*
* @param date - Date object to format
* @returns Formatted date string in "MMM DD, YYYY" format (e.g., "Jan 15, 2025")
*
* @remarks
* Uses US locale formatting with abbreviated month names. The format is
* consistent across the application for displaying domain-related dates.
*
* Output format: "Month Day, Year" where:
* - Month: 3-letter abbreviation (Jan, Feb, Mar, etc.)
* - Day: Numeric day of month
* - Year: Full 4-digit year
*
* @example
* ```ts
* import { formatDate } from '@/lib/domains/utils'
*
* const date = new Date('2025-03-15')
* console.log(formatDate(date)) // "Mar 15, 2025"
* ```
*
* @example
* ```ts
* // Format expiration date for display
* import { getExpirationDate, formatDate } from '@/lib/domains/utils'
*
* const expDate = getExpirationDate(domain)
* const formatted = formatDate(expDate)
* console.log(`Expires: ${formatted}`) // "Expires: Dec 31, 2026"
* ```
*
* @example
* ```ts
* // Format all renewal dates
* domain.renewals.forEach(renewal => {
* const date = formatDate(new Date(renewal.date))
* console.log(`${date}: ${renewal.years} year(s)`)
* })
* ```
*
* @category Domains
* @public
*/
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
/**
* Generates a timeline of renewal events from domain renewal history.
*
* @param domain - Domain object with renewal records
* @returns Array of timeline events with dates, types, and renewal periods
*
* @remarks
* This function transforms the domain's renewal array into a structured
* timeline suitable for visualization. Each event includes:
*
* - `date`: Date object of the renewal/registration
* - `type`: 'registration' for first renewal, 'renewal' for subsequent ones
* - `years`: Number of years renewed for that period
*
* The first renewal in the array is always treated as the initial registration.
*
* @example
* ```ts
* import { getRenewalTimeline } from '@/lib/domains/utils'
*
* const timeline = getRenewalTimeline(domain)
* timeline.forEach(event => {
* console.log(`${event.type}: ${event.date} (${event.years}y)`)
* })
* // Output:
* // registration: 2020-01-15 (2y)
* // renewal: 2022-01-15 (3y)
* // renewal: 2025-01-15 (1y)
* ```
*
* @example
* ```ts
* // Build visual timeline
* const timeline = getRenewalTimeline(domain)
* const formatted = timeline.map(event => ({
* ...event,
* icon: event.type === 'registration' ? '🎯' : '🔄',
* label: `${formatDate(event.date)} - ${event.years} year(s)`
* }))
* ```
*
* @see {@link DomainTimelineEvent} For event type definition
* @category Domains
* @public
*/
export function getRenewalTimeline(domain: Domain): DomainTimelineEvent[] {
return domain.renewals.map((renewal, index) => ({
date: new Date(renewal.date),
type: index === 0 ? 'registration' : 'renewal',
years: renewal.years
}))
}
/**
* Extracts the top-level domain (TLD) from a domain name.
*
* @param domain - Domain object
* @returns TLD including the dot (e.g., '.com', '.dev', '.co.uk')
*
* @remarks
* This function extracts the TLD portion of the domain name, which includes
* the dot separator. For complex TLDs like '.co.uk', only the final segment
* is returned ('.uk').
*
* Uses {@link DomainService.enrichDomain} for consistent TLD extraction logic.
*
* @example
* ```ts
* import { getTLD } from '@/lib/domains/utils'
*
* const domain = { domain: 'example.com', ... }
* console.log(getTLD(domain)) // ".com"
* ```
*
* @example
* ```ts
* // Group domains by TLD
* const domains = DomainService.getAllDomains()
* const byTLD = domains.reduce((acc, d) => {
* const tld = getTLD(d)
* acc[tld] = (acc[tld] || 0) + 1
* return acc
* }, {} as Record<string, number>)
* console.log(byTLD) // { '.com': 15, '.dev': 3, '.io': 2 }
* ```
*
* @see {@link DomainService.enrichDomain}
* @category Domains
* @public
*/
export function getTLD(domain: Domain): string {
const enriched = DomainService.enrichDomain(domain)
return enriched.tld
}
/**
* Gets the next renewal date for a domain (alias for expiration date).
*
* @param domain - Domain object with renewal history
* @returns Next renewal date (same as expiration date)
*
* @remarks
* This function is a semantic alias for {@link getExpirationDate}. Both return
* the same value - the date when the current renewal period ends and the domain
* must be renewed to avoid expiration.
*
* Use this function when you want to emphasize the "renewal" aspect rather than
* the "expiration" aspect of the date.
*
* @example
* ```ts
* import { getNextRenewalDate } from '@/lib/domains/utils'
*
* const renewalDate = getNextRenewalDate(domain)
* console.log(`Next renewal: ${renewalDate}`) // "Next renewal: 2026-03-15..."
* ```
*
* @example
* ```ts
* // Calculate time until next renewal
* import { getNextRenewalDate, formatDate } from '@/lib/domains/utils'
*
* const nextRenewal = getNextRenewalDate(domain)
* const daysUntil = Math.ceil((nextRenewal.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
* console.log(`Renewal in ${daysUntil} days (${formatDate(nextRenewal)})`)
* ```
*
* @see {@link DomainService.enrichDomain}
* @see {@link getExpirationDate} For the same value with different semantics
* @see {@link getDaysUntilExpiration} For days until renewal needed
* @category Domains
* @public
*/
export function getNextRenewalDate(domain: Domain): Date {
const enriched = DomainService.enrichDomain(domain)
return enriched.nextRenewalDate
}

486
lib/github.ts Normal file
View file

@ -0,0 +1,486 @@
/**
* GitHub and Forgejo API integration service with server-side caching.
*
* @remarks
* This module provides secure, cached access to GitHub and Forgejo APIs for:
* - Fetching recent repository updates
* - Retrieving repository metrics (stars, forks)
* - Managing featured project data
*
* **Security Features:**
* - Dual-layer caching (unstable_cache + React cache) prevents API abuse
* - 1-hour revalidation protects GitHub PAT from rate limits
* - Server-side cache shared across all requests
*
* **Environment Variables:**
* - `GITHUB_PROJECTS_USER` or `GITHUB_USERNAME`: Override default username
* - `GITHUB_PROJECTS_PAT` or `GITHUB_PAT`: GitHub Personal Access Token (optional)
*
* @example
* ```ts
* import { getRecentGitHubRepos, getFeaturedReposWithMetrics } from '@/lib/github'
*
* // Get recent repos for configured user
* const { username, repos } = await getRecentGitHubRepos()
*
* // Get featured projects with live metrics
* const projects = await getFeaturedReposWithMetrics()
* console.log(`${projects[0].name}: ${projects[0].stars} stars`)
* ```
*
* @category Services
* @module lib/github
* @public
*/
import { cache } from 'react'
import { unstable_cache } from 'next/cache'
import { featuredRepos } from '@/lib/config/featured-repos'
/** Default GitHub username when not configured via environment variables */
const DEFAULT_GITHUB_USER = 'ihatenodejs'
/** Default Forgejo instance URL */
const DEFAULT_FORGEJO_URL = 'git.p0ntus.com'
/** Maximum number of recent repositories to fetch */
const REPO_LIMIT = 4
/** Cache revalidation time in seconds (1 hour) */
const REVALIDATE_SECONDS = 60 * 60
/** GitHub API repository response structure */
interface GitHubRepoApi {
id: number
name: string
html_url: string
updated_at: string
stargazers_count?: number
forks_count?: number
}
/** Forgejo API repository response structure */
interface ForgejoRepoApi {
id: number
name: string
html_url: string
stars_count?: number
forks_count?: number
}
/**
* Simplified repository summary for recent repos list.
*
* @public
*/
export interface GitHubRepoSummary {
/** Repository ID from GitHub API */
id: number
/** Repository name */
name: string
/** Full URL to repository */
url: string
/** ISO 8601 timestamp of last update */
updatedAt: string
}
/**
* Featured project with live metrics from GitHub or Forgejo.
*
* @remarks
* This interface represents a featured repository with real-time
* star and fork counts fetched from the respective platform's API.
*
* @public
*/
export interface FeaturedProject {
/** Unique identifier for the project */
id: number
/** Repository owner username or organization */
owner: string
/** Repository name */
repo: string
/** Display name (may include owner prefix) */
name: string
/** Project description */
description: string
/** Source platform */
platform: 'github' | 'forgejo'
/** Full URL to repository */
url: string
/** Current star count */
stars: number
/** Current fork count */
forks: number
}
/**
* Repository engagement metrics.
*
* @internal
*/
interface RepoMetrics {
/** Number of stars/stargazers */
stars: number
/** Number of forks */
forks: number
}
/**
* Resolves GitHub username from environment variables or uses default.
*
* @returns GitHub username to use for API requests
* @internal
*/
const resolveConfiguredUser = (): string => {
const configured = process.env.GITHUB_PROJECTS_USER ?? process.env.GITHUB_USERNAME
const fallback = DEFAULT_GITHUB_USER
if (!configured) {
return fallback
}
const trimmed = configured.trim()
return trimmed.length ? trimmed : fallback
}
/**
* Resolves GitHub PAT from environment variables for authentication.
*
* @returns Authorization header value or undefined if no token configured
* @internal
*/
const resolveAuthHeader = (): string | undefined => {
const token = process.env.GITHUB_PROJECTS_PAT ?? process.env.GITHUB_PAT
if (!token) {
return undefined
}
return `Bearer ${token.trim()}`
}
/**
* Builds HTTP headers for GitHub API requests.
*
* @remarks
* Automatically includes Authorization header if PAT is configured.
*
* @returns Headers object for fetch requests
* @internal
*/
const buildGitHubHeaders = (): Record<string, string> => {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'User-Agent': 'aidan.so',
}
const authHeader = resolveAuthHeader()
if (authHeader) {
headers.Authorization = authHeader
}
return headers
}
/**
* Fetches recently updated repositories for a GitHub user.
*
* @param username - GitHub username to fetch repositories for
* @returns Array of repository summaries, sorted by most recently updated
*
* @remarks
* - Limited to 4 most recent repositories
* - Uses Next.js fetch cache with 1-hour revalidation
* - Returns empty array on error (logs to console)
*
* @internal
*/
const fetchRecentRepos = async (username: string): Promise<GitHubRepoSummary[]> => {
const url = new URL(`https://api.github.com/users/${username}/repos`)
url.searchParams.set('sort', 'updated')
url.searchParams.set('per_page', REPO_LIMIT.toString())
try {
const response = await fetch(url, {
headers: buildGitHubHeaders(),
next: {
revalidate: REVALIDATE_SECONDS,
tags: [`github-repos-${username}`],
},
})
if (!response.ok) {
console.error(`Failed to fetch GitHub repos for ${username}: ${response.status} ${response.statusText}`)
return []
}
const data = (await response.json()) as GitHubRepoApi[]
return data.slice(0, REPO_LIMIT).map((repo) => ({
id: repo.id,
name: repo.name,
url: repo.html_url,
updatedAt: repo.updated_at,
}))
} catch (error) {
console.error(`Unexpected error fetching GitHub repos for ${username}:`, error)
return []
}
}
/**
* Server-side cached wrapper for fetchRecentRepos.
*
* @remarks
* Uses Next.js unstable_cache to prevent API abuse across requests.
* Cache is shared server-side and persists for 1 hour.
*
* @internal
*/
const getCachedRecentRepos = unstable_cache(
async (username: string) => fetchRecentRepos(username),
['github-recent-repos'],
{
revalidate: REVALIDATE_SECONDS,
tags: ['github-repos'],
}
)
/**
* Retrieves recently updated GitHub repositories for the configured user.
*
* @returns Object containing username and array of recent repositories
*
* @remarks
* **Caching Strategy:**
* - Server-side cache (unstable_cache): Shared across all requests, 1-hour TTL
* - Request cache (React cache): Deduplicates calls within single request
*
* **Security:**
* - Protects GitHub PAT from rate limit abuse
* - Maximum 1 API call per hour regardless of page refreshes
*
* @example
* ```ts
* const { username, repos } = await getRecentGitHubRepos()
* repos.forEach(repo => console.log(`${repo.name}: ${repo.url}`))
* ```
*
* @public
*/
export const getRecentGitHubRepos = cache(async () => {
const username = resolveConfiguredUser()
const repos = await getCachedRecentRepos(username)
return {
username,
repos,
}
})
/**
* Fetches star and fork counts for a specific GitHub repository.
*
* @param owner - Repository owner (user or organization)
* @param repo - Repository name
* @returns Repository metrics or null on error
*
* @internal
*/
const fetchGitHubRepoMetrics = async (owner: string, repo: string): Promise<RepoMetrics | null> => {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: buildGitHubHeaders(),
next: {
revalidate: REVALIDATE_SECONDS,
tags: [`github-repo-${owner}-${repo}`],
},
})
if (!response.ok) {
console.error(`Failed to fetch GitHub repo ${owner}/${repo}: ${response.status}`)
return null
}
const data = (await response.json()) as GitHubRepoApi
return {
stars: data.stargazers_count ?? 0,
forks: data.forks_count ?? 0,
}
} catch (error) {
console.error(`Error fetching GitHub repo ${owner}/${repo}:`, error)
return null
}
}
/**
* Server-side cached wrapper for GitHub repository metrics.
*
* @remarks
* Prevents API abuse by caching metrics for 1 hour server-side.
*
* @internal
*/
const getCachedGitHubRepoMetrics = unstable_cache(
async (owner: string, repo: string) => fetchGitHubRepoMetrics(owner, repo),
['github-repo-metrics'],
{
revalidate: REVALIDATE_SECONDS,
tags: ['github-metrics'],
}
)
/**
* Fetches star and fork counts for a Forgejo repository.
*
* @param owner - Repository owner
* @param repo - Repository name
* @param forgejoBaseUrl - Forgejo instance base URL (default: git.p0ntus.com)
* @returns Repository metrics or null on error
*
* @internal
*/
const fetchForgejoRepoMetrics = async (
owner: string,
repo: string,
forgejoBaseUrl: string = DEFAULT_FORGEJO_URL
): Promise<RepoMetrics | null> => {
try {
const apiUrl = `https://${forgejoBaseUrl}/api/v1/repos/${owner}/${repo}`
const response = await fetch(apiUrl, {
headers: {
Accept: 'application/json',
'User-Agent': 'aidan.so',
},
next: {
revalidate: REVALIDATE_SECONDS,
tags: [`forgejo-repo-${forgejoBaseUrl}-${owner}-${repo}`],
},
})
if (!response.ok) {
console.error(`Failed to fetch Forgejo repo ${owner}/${repo} from ${forgejoBaseUrl}: ${response.status}`)
return null
}
const data = (await response.json()) as ForgejoRepoApi
return {
stars: data.stars_count ?? 0,
forks: data.forks_count ?? 0,
}
} catch (error) {
console.error(`Error fetching Forgejo repo ${owner}/${repo} from ${forgejoBaseUrl}:`, error)
return null
}
}
/**
* Server-side cached wrapper for Forgejo repository metrics.
*
* @remarks
* Prevents API abuse by caching metrics for 1 hour server-side.
*
* @internal
*/
const getCachedForgejoRepoMetrics = unstable_cache(
async (owner: string, repo: string, forgejoBaseUrl: string = DEFAULT_FORGEJO_URL) =>
fetchForgejoRepoMetrics(owner, repo, forgejoBaseUrl),
['forgejo-repo-metrics'],
{
revalidate: REVALIDATE_SECONDS,
tags: ['forgejo-metrics'],
}
)
/**
* Fetches all featured projects with live metrics from their platforms.
*
* @returns Array of featured projects with current star/fork counts
*
* @remarks
* Automatically fetches from the correct API (GitHub or Forgejo) based on
* platform configuration in featured-repos.ts.
*
* @internal
*/
const fetchFeaturedProjects = async (): Promise<FeaturedProject[]> => {
const projects = await Promise.all(
featuredRepos.map(async (config) => {
let metrics: RepoMetrics | null = null
if (config.platform === 'github') {
metrics = await getCachedGitHubRepoMetrics(config.owner, config.repo)
} else if (config.platform === 'forgejo') {
metrics = await getCachedForgejoRepoMetrics(
config.owner,
config.repo,
config.forgejoUrl ?? DEFAULT_FORGEJO_URL
)
}
const url = config.platform === 'github'
? `https://github.com/${config.owner}/${config.repo}`
: `https://${config.forgejoUrl ?? DEFAULT_FORGEJO_URL}/${config.owner}/${config.repo}`
return {
id: config.id,
owner: config.owner,
repo: config.repo,
name: config.owner === config.repo ? config.repo : `${config.owner}/${config.repo}`,
description: config.description,
platform: config.platform,
url,
stars: metrics?.stars ?? 0,
forks: metrics?.forks ?? 0,
}
})
)
return projects
}
/**
* Retrieves featured projects with live star and fork counts.
*
* @returns Array of featured projects with real-time metrics
*
* @remarks
* **Data Source:**
* Projects are configured in `lib/config/featured-repos.ts`.
* Metrics are fetched live from GitHub and Forgejo APIs.
*
* **Caching Strategy:**
* - Server-side cache (unstable_cache): Shared across all requests, 1-hour TTL
* - Request cache (React cache): Deduplicates calls within single request
*
* **Security:**
* - Prevents API abuse through dual-layer caching
* - Maximum 1 API call per repository per hour
*
* @example
* ```ts
* const projects = await getFeaturedReposWithMetrics()
*
* projects.forEach(project => {
* console.log(`${project.name}: ${project.stars}${project.forks} 🍴`)
* console.log(`Platform: ${project.platform}`)
* console.log(`URL: ${project.url}`)
* })
* ```
*
* @public
*/
export const getFeaturedReposWithMetrics = cache(
unstable_cache(
async () => fetchFeaturedProjects(),
['featured-repos'],
{
revalidate: REVALIDATE_SECONDS,
tags: ['featured-projects'],
}
)
)

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'

306
lib/theme/colors.ts Normal file
View file

@ -0,0 +1,306 @@
/**
* Grayscale color palette used throughout the application.
*
* @remarks
* Provides a complete grayscale from near-white (50) to near-black (950).
* Each shade has a specific semantic purpose in the dark-themed UI.
*
* Common usage:
* - 50-400: Text colors (lighter is more prominent)
* - 500-600: Interactive states and borders
* - 700-950: Backgrounds and surfaces
*
* @example
* ```ts
* import { gray } from '@/lib/theme/colors'
*
* const textColor = gray[100] // Primary text
* const cardBg = gray[800] // Card background
* ```
*
* @category Theme
* @public
*/
export const gray = {
/** Near-white, lightest shade - #f9fafb */
50: '#f9fafb',
/** Very light gray - Primary text on dark backgrounds - #f3f4f6 */
100: '#f3f4f6',
/** Light gray - Secondary headings - #e5e7eb */
200: '#e5e7eb',
/** Medium-light gray - Body text - #d1d5db */
300: '#d1d5db',
/** Medium gray - Muted text and descriptions - #9ca3af */
400: '#9ca3af',
/** Medium-dark gray - Disabled states - #6b7280 */
500: '#6b7280',
/** Dark gray - Hover states for borders - #4b5563 */
600: '#4b5563',
/** Darker gray - Default borders and cards - #374151 */
700: '#374151',
/** Very dark gray - Background gradient start - #1f2937 */
800: '#1f2937',
/** Near-black - Background gradient end - #111827 */
900: '#111827',
/** Deepest black - #030712 */
950: '#030712',
} as const
/**
* Background color tokens for page and surface backgrounds.
*
* @remarks
* Provides consistent background colors with semantic naming:
* - Page gradients for main background
* - Surface colors for cards and interactive elements
* - Hover states for transitions
*
* @example
* ```tsx
* import { backgrounds } from '@/lib/theme/colors'
*
* <div style={{ background: `linear-gradient(${backgrounds.pageGradientStart}, ${backgrounds.pageGradientEnd})` }}>
* <div style={{ backgroundColor: backgrounds.card }}>
* Card content
* </div>
* </div>
* ```
*
* @category Theme
* @public
*/
export const backgrounds = {
/** Page background gradient start color (gray-800) - rgb(31, 41, 55) */
pageGradientStart: 'rgb(31, 41, 55)',
/** Page background gradient end color (gray-900) - rgb(17, 24, 39) */
pageGradientEnd: 'rgb(17, 24, 39)',
/** Semi-transparent card background (gray-900/50) - rgba(31, 41, 55, 0.5) */
card: 'rgba(31, 41, 55, 0.5)',
/** Solid card background (gray-800) - #1f2937 */
cardSolid: '#1f2937',
/** Hover state background (gray-700 with opacity) - rgba(55, 65, 81, 0.6) */
hover: 'rgba(55, 65, 81, 0.6)',
} as const
/**
* Border color tokens for consistent border styling.
*
* @remarks
* Provides border colors with varying opacity levels:
* - Default: Standard border for cards and sections
* - Hover: Highlighted border on interactive elements
* - Subtle/Muted: Lower-contrast borders for nested elements
*
* @example
* ```tsx
* import { borders } from '@/lib/theme/colors'
*
* <div style={{ borderColor: borders.default }}>
* // On hover
* <div style={{ borderColor: borders.hover }}>
* Interactive element
* </div>
* </div>
* ```
*
* @category Theme
* @public
*/
export const borders = {
/** Default border color (gray-700) - #374151 */
default: '#374151',
/** Hover border color (gray-600) - #4b5563 */
hover: '#4b5563',
/** Subtle border with low opacity - rgba(75, 85, 99, 0.3) */
subtle: 'rgba(75, 85, 99, 0.3)',
/** Muted border with medium opacity - rgba(75, 85, 99, 0.5) */
muted: 'rgba(75, 85, 99, 0.5)',
} as const
/**
* Text color tokens organized by semantic meaning and visual hierarchy.
*
* @remarks
* Provides a consistent text color scale from most to least prominent:
* - Primary: Most important text (headings, key info)
* - Secondary: Subheadings and emphasized text
* - Body: Standard paragraph text
* - Muted: Less important text (descriptions, captions)
* - Disabled: Inactive or unavailable text
* - Inverse: Text on light backgrounds (opposite of normal)
*
* @example
* ```tsx
* import { text } from '@/lib/theme/colors'
*
* <h1 style={{ color: text.primary }}>Main Title</h1>
* <h2 style={{ color: text.secondary }}>Subtitle</h2>
* <p style={{ color: text.body }}>Paragraph text</p>
* <small style={{ color: text.muted }}>Caption</small>
* ```
*
* @category Theme
* @public
*/
export const text = {
/** Primary text color - Highest contrast (gray-100) - #f3f4f6 */
primary: '#f3f4f6',
/** Secondary text color - Headings and emphasis (gray-200) - #e5e7eb */
secondary: '#e5e7eb',
/** Body text color - Standard paragraphs (gray-300) - #d1d5db */
body: '#d1d5db',
/** Muted text color - Descriptions and captions (gray-400) - #9ca3af */
muted: '#9ca3af',
/** Disabled text color - Inactive elements (gray-500) - #6b7280 */
disabled: '#6b7280',
/** Inverse text color - For light backgrounds (gray-900) - #111827 */
inverse: '#111827',
} as const
/**
* Accent color tokens for links, branding, and semantic states.
*
* @remarks
* Organized into semantic categories:
* - Link colors: Blue tones for hyperlinks
* - AI theme: Orange/rust colors for Claude AI branding
* - Type colors: Purple for documentation type references
* - Semantic colors: Success, warning, error, info states
*
* Each accent color includes hover and background variants where applicable.
*
* @example
* ```tsx
* import { accents } from '@/lib/theme/colors'
*
* <a style={{ color: accents.link }}>Link</a>
* <div style={{ backgroundColor: accents.linkBg }}>Badge</div>
* <span style={{ color: accents.success }}>Success!</span>
* ```
*
* @category Theme
* @public
*/
export const accents = {
/** Link text color (blue-400) - #60a5fa */
link: '#60a5fa',
/** Link hover color (blue-500) - #3b82f6 */
linkHover: '#3b82f6',
/** Link badge background (blue-400/10) - rgba(96, 165, 250, 0.1) */
linkBg: 'rgba(96, 165, 250, 0.1)',
/** AI/Claude primary brand color - #c15f3c */
ai: '#c15f3c',
/** AI/Claude muted variant - #d68b6b */
aiMuted: '#d68b6b',
/** AI/Claude high contrast background - #1a100d */
aiContrast: '#1a100d',
/** Type/interface color for docs (purple-500) - #a855f7 */
type: '#a855f7',
/** Type badge background (purple-500/10) - rgba(168, 85, 247, 0.1) */
typeBg: 'rgba(168, 85, 247, 0.1)',
/** Documentation primary accent (blue-400) - #60a5fa */
docs: '#60a5fa',
/** Documentation card background (blue-500/8) - rgba(59, 130, 246, 0.08) */
docsBg: 'rgba(59, 130, 246, 0.08)',
/** Documentation border (blue-500/25) - rgba(59, 130, 246, 0.25) */
docsBorder: 'rgba(59, 130, 246, 0.25)',
/** Documentation glow/shadow (blue-500/12) - rgba(59, 130, 246, 0.12) */
docsGlow: 'rgba(59, 130, 246, 0.12)',
/** Documentation icon background (blue-500/20) - rgba(59, 130, 246, 0.2) */
docsIconBg: 'rgba(59, 130, 246, 0.2)',
/** Documentation blur effect (blue-500 solid) - #3b82f6 */
docsBlur: '#3b82f6',
/** Success state color (green) - #10b981 */
success: '#10b981',
/** Warning state color (amber) - #f59e0b */
warning: '#f59e0b',
/** Warning badge background (warning/10) - rgba(245, 158, 11, 0.1) */
warningBg: 'rgba(245, 158, 11, 0.1)',
/** Error state color (red) - #ef4444 */
error: '#ef4444',
/** Info state color (blue) - #3b82f6 */
info: '#3b82f6',
} as const
/**
* Effect color tokens for glows, shadows, and other visual effects.
*
* @remarks
* Provides colors specifically for CSS effects like text-shadow and box-shadow.
* Used to create depth and emphasis in the UI.
*
* @example
* ```tsx
* import { effectColors } from '@/lib/theme/colors'
*
* <h1 style={{ textShadow: `0 0 10px ${effectColors.textGlow}` }}>
* Glowing Text
* </h1>
* <div style={{ boxShadow: `0 4px 6px ${effectColors.shadowCard}` }}>
* Card with shadow
* </div>
* ```
*
* @category Theme
* @public
*/
export const effectColors = {
/** Standard text glow (80% opacity) - rgba(255, 255, 255, 0.8) */
textGlow: 'rgba(255, 255, 255, 0.8)',
/** Hover text glow (90% opacity) - rgba(255, 255, 255, 0.9) */
textGlowHover: 'rgba(255, 255, 255, 0.9)',
/** Intense text glow (100% opacity) - rgba(255, 255, 255, 1) */
textGlowIntense: 'rgba(255, 255, 255, 1)',
/** Card box shadow color - rgba(0, 0, 0, 0.2) */
shadowCard: 'rgba(0, 0, 0, 0.2)',
} as const
/**
* Complete color system combining all color token categories.
*
* @remarks
* Central export containing all color tokens organized by purpose:
* - {@link gray}: Grayscale palette
* - {@link backgrounds}: Background colors
* - {@link borders}: Border colors
* - {@link text}: Text colors
* - {@link accents}: Accent and semantic colors
* - {@link effectColors}: Shadow and glow colors
*
* @example
* ```ts
* import { colors } from '@/lib/theme/colors'
*
* // Access any color token
* const primaryText = colors.text.primary
* const cardBg = colors.backgrounds.card
* const linkColor = colors.accents.link
* ```
*
* @category Theme
* @public
*/
export const colors = {
gray,
backgrounds,
borders,
text,
accents,
effectColors,
} as const
/**
* TypeScript type representing all available color tokens.
*
* @remarks
* Use this type for type-safe color access in components and utilities.
*
* @category Theme
* @public
*/
export type ColorTokens = typeof colors

310
lib/theme/effects.ts Normal file
View file

@ -0,0 +1,310 @@
/**
* Text shadow effect tokens for glowing and emphasis effects.
*
* Provides white glow effects at varying intensities for creating visual hierarchy
* and emphasis on text elements in the dark theme.
*
* @example
* ```tsx
* import { textShadows } from '@/lib/theme/effects'
*
* // Standard glow effect
* <h1 style={{ textShadow: textShadows.glow }}>
* Glowing Title
* </h1>
*
* // Hover state with increased glow
* <button
* style={{ textShadow: textShadows.glow }}
* onMouseEnter={(e) => e.currentTarget.style.textShadow = textShadows.glowHover}
* >
* Hover Me
* </button>
*
* // Intense glow for hero text
* <h1 style={{ textShadow: textShadows.glowIntense }}>
* Hero Title
* </h1>
* ```
*
* @category Theme
* @public
*/
export const textShadows = {
/** Standard glow effect (80% opacity) - use for headings and emphasized text */
glow: '0 0 10px rgba(255, 255, 255, 0.8)',
/** Enhanced glow for hover states (90% opacity) - use with interactive text elements */
glowHover: '0 0 15px rgba(255, 255, 255, 0.9)',
/** Intense glow effect (100% opacity) - use sparingly for hero sections and primary headings */
glowIntense: '0 0 20px rgba(255, 255, 255, 1)',
/** Subtle glow (50% opacity) - use for secondary text that needs slight emphasis */
subtle: '0 0 10px rgba(255, 255, 255, 0.5)',
} as const
/**
* Box shadow effect tokens for depth and elevation.
*
* Provides subtle elevation effects for cards, panels, and buttons. All shadows
* use dark colors appropriate for the dark theme interface.
*
* @example
* ```tsx
* import { boxShadows } from '@/lib/theme/effects'
*
* // Card with elevation shadow
* <div style={{ boxShadow: boxShadows.card }}>
* <h3>Card Content</h3>
* </div>
*
* // Button with hover elevation
* <button
* style={{ boxShadow: boxShadows.button }}
* className="hover:shadow-[var(--shadow-button-hover)]"
* >
* Click Me
* </button>
*
* // Panel overlay with shadow
* <div style={{ boxShadow: boxShadows.panel }}>
* <nav>Navigation</nav>
* </div>
* ```
*
* @category Theme
* @public
*/
export const boxShadows = {
/** Card shadow for standard elevation - use on card containers */
card: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
/** Enhanced card shadow for hover states - creates lifted appearance */
cardHover: '0 20px 25px -5px rgba(0, 0, 0, 0.2)',
/** Panel shadow for floating UI elements - use on dropdowns, modals, and sidebars */
panel: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
/** Button shadow for default state - subtle depth for clickable elements */
button: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
/** Button shadow for hover state - increased shadow for interactivity feedback */
buttonHover: '0 6px 8px -1px rgba(0, 0, 0, 0.15)',
} as const
/**
* Gradient effect tokens for backgrounds and visual interest.
*
* Provides pre-configured gradient styles for page backgrounds, widgets, and special
* UI elements. All gradients follow the dark theme color palette.
*
* @example
* ```tsx
* import { gradients } from '@/lib/theme/effects'
*
* // Page background gradient
* <div style={{ background: gradients.pageBackground }}>
* <main>Page content</main>
* </div>
*
* // Music player widget gradient
* <div style={{ background: gradients.musicPlayer }}>
* <div>Now Playing...</div>
* </div>
*
* // Player button gradient (light variant)
* <button style={{ background: gradients.playerButton.light }}>
* Play
* </button>
* ```
*
* @category Theme
* @public
*/
export const gradients = {
/** Page background gradient from gray-800 to gray-900 - use for main page containers */
pageBackground: 'linear-gradient(180deg, rgb(31, 41, 55) 0%, rgb(17, 24, 39) 100%)',
/** Music player widget gradient with 4-stop gradient - use for music player components */
musicPlayer: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)',
/** Player button gradients - use for music control buttons */
playerButton: {
/** Light button gradient with subtle 3D effect - use for play/pause buttons */
light: 'linear-gradient(180deg, #f9fafb 0%, #e5e7eb 49%, #6b7280 51%, #d1d5db 100%)',
},
} as const
/**
* Transition effect tokens for smooth animations and state changes.
*
* Provides Tailwind CSS transition utilities with consistent timing and easing.
* Combine property transitions with duration and easing tokens for complete control.
*
* @example
* ```tsx
* import { transitions } from '@/lib/theme/effects'
*
* // Color transition on hover
* <button className={transitions.colors}>
* Hover Me
* </button>
*
* // All properties transition with custom duration
* <div className={cn(transitions.all, transitions.slow)}>
* Animates all properties slowly
* </div>
*
* // Shadow transition with easing
* <div className={cn(transitions.shadow, transitions.easeInOut)}>
* Shadow changes smoothly
* </div>
*
* // Fast transition for snappy interactions
* <button className={cn('bg-gray-800', transitions.fast)}>
* Quick feedback
* </button>
* ```
*
* @remarks
* Transition tokens are designed to be composable. Combine property transitions
* (colors, all, shadow) with duration modifiers (fast, normal, slow) and easing
* functions (easeInOut, ease) to create custom animation behaviors.
*
* @category Theme
* @public
*/
export const transitions = {
/** Color property transitions with 300ms duration - use for text/background color changes */
colors: 'transition-colors duration-300',
/** All property transitions with 300ms duration - use when multiple properties animate */
all: 'transition-all duration-300',
/** Shadow property transitions with 300ms duration - use for elevation changes */
shadow: 'transition-shadow duration-300',
/** Fast duration (200ms) - use for snappy, responsive interactions */
fast: 'duration-200',
/** Normal duration (300ms) - default timing for most animations */
normal: 'duration-300',
/** Slow duration (500ms) - use for deliberate, emphasis animations */
slow: 'duration-500',
/** Ease-in-out timing function - smooth start and end for polished feel */
easeInOut: 'ease-in-out',
/** Standard ease timing function - slight acceleration for natural motion */
ease: 'ease',
} as const
/**
* Backdrop blur effect tokens for glassmorphism and depth.
*
* Provides backdrop-filter blur utilities for creating frosted glass effects
* on overlays, modals, and panels. Commonly used with semi-transparent backgrounds.
*
* @example
* ```tsx
* import { backdrops } from '@/lib/theme/effects'
*
* // Subtle blur for card overlays
* <div className={cn('bg-gray-900/50', backdrops.blur)}>
* <h3>Content with backdrop blur</h3>
* </div>
*
* // Medium blur for modals
* <div className={cn('bg-gray-800/80', backdrops.blurMedium)}>
* <dialog>Modal Content</dialog>
* </div>
*
* // Strong blur for emphasized overlays
* <div className={cn('bg-gray-900/90', backdrops.blurLarge)}>
* <div>Strongly blurred background</div>
* </div>
* ```
*
* @remarks
* Backdrop blur effects work best when combined with semi-transparent backgrounds
* (e.g., `bg-gray-900/50`). The blur creates a frosted glass effect by blurring
* content behind the element.
*
* @category Theme
* @public
*/
export const backdrops = {
/** Small blur (4px) - use for subtle glassmorphism on cards and overlays */
blur: 'backdrop-blur-sm',
/** Medium blur (12px) - use for modals and prominent panels */
blurMedium: 'backdrop-blur-md',
/** Large blur (24px) - use for strong separation and emphasis */
blurLarge: 'backdrop-blur-lg',
} as const
/**
* Visual effect design tokens for the application theme.
*
* Provides a comprehensive set of visual effects including shadows, gradients,
* transitions, and backdrop filters. All effects are optimized for the dark theme
* and support smooth animations.
*
* @remarks
* This is the main effects export. Import individual categories or the full effects
* object depending on your needs. Effects can be combined with surface styles from
* `lib/theme/surfaces` for complete component styling.
*
* @example
* ```tsx
* import { effects } from '@/lib/theme/effects'
* // or
* import { textShadows, transitions, backdrops } from '@/lib/theme/effects'
*
* // Using the full effects object
* <h1 style={{ textShadow: effects.textShadows.glow }}>
* Glowing Title
* </h1>
*
* // Using individual imports
* <div className={cn(backdrops.blur, transitions.all)}>
* <p>Blurred and animated</p>
* </div>
* ```
*
* @category Theme
* @public
*/
export const effects = {
textShadows,
boxShadows,
gradients,
transitions,
backdrops,
} as const
/**
* TypeScript type representing all available effect tokens.
*
* Use this type for type-safe access to effect styles in components.
*
* @example
* ```tsx
* import type { EffectTokens } from '@/lib/theme/effects'
*
* function EffectWrapper(props: { effects: EffectTokens }) {
* return (
* <div style={{ textShadow: props.effects.textShadows.glow }}>
* Content
* </div>
* )
* }
* ```
*
* @category Theme
* @public
*/
export type EffectTokens = typeof effects

253
lib/theme/index.ts Normal file
View file

@ -0,0 +1,253 @@
/**
* Theme system entry point providing centralized access to design tokens.
*
* @remarks
* This module serves as the main entry point for the aidxnCC theme system,
* providing a unified interface for colors, visual effects, and surface styling.
* All theme tokens are designed to work seamlessly with Tailwind CSS v4.
*
* **Available theme categories:**
* - **colors**: Semantic color tokens (text, backgrounds, borders, accents)
* - **effects**: Visual effects (shadows, gradients, transitions, backdrops)
* - **surfaces**: Pre-composed UI element styles (cards, buttons, badges, sections)
*
* **Usage patterns:**
* - Import individual categories for specific needs
* - Use helper functions for dynamic variant selection
* - Combine with cn() utility for class composition
*
* @example
* ```tsx
* // Import entire theme
* import { theme } from '@/lib/theme'
* const textColor = theme.colors.text.primary
*
* // Import specific categories
* import { colors, surfaces } from '@/lib/theme'
* const cardStyle = surfaces.card.default
*
* // Use helper functions for dynamic variants
* import { getCardStyle, cn } from '@/lib/theme'
* const Card = ({ variant = 'default', className }) => (
* <div className={cn(getCardStyle(variant), className)}>
* Content
* </div>
* )
* ```
*
* @module lib/theme
* @category Theme
* @public
*/
export * from './colors'
export * from './effects'
export * from './surfaces'
import { colors } from './colors'
import { effects } from './effects'
import { surfaces } from './surfaces'
/**
* Unified theme object containing all design tokens.
*
* @remarks
* This object provides centralized access to all theme categories.
* The `as const` assertion ensures type safety and enables TypeScript
* to infer exact string literals for all token values.
*
* @example
* ```tsx
* import { theme } from '@/lib/theme'
*
* // Access colors
* const primaryText = theme.colors.text.primary
*
* // Access effects
* const cardShadow = theme.effects.boxShadows.card
*
* // Access surfaces
* const buttonStyle = theme.surfaces.button.primary
* ```
*
* @category Theme
* @public
*/
export const theme = {
colors,
effects,
surfaces,
} as const
/**
* Type representing the whole theme structure.
*
* @remarks
* This type enables autocomplete and type checking when working with
* theme tokens. It's automatically inferred from the theme object.
*
* @category Theme
* @public
*/
export type Theme = typeof theme
/**
* Utility function for conditionally combining CSS class names.
*
* @param classes - Array of class names (can include undefined, null, or false values)
* @returns Combined class string with falsy values filtered out
*
* @remarks
* This is a lightweight alternative to libraries like `clsx` or `classnames`.
* It filters out falsy values (undefined, null, false) and joins remaining
* strings with spaces. Perfect for conditional class application in React.
*
* **Compared to alternatives:**
* - Simpler than the `cn()` utility in lib/utils.ts (which uses tailwind-merge)
* - Use this for basic class combining without merge logic
* - Use lib/utils.ts cn() when you need Tailwind conflict resolution
*
* @example
* ```tsx
* import { cn } from '@/lib/theme'
*
* // Basic usage
* const classes = cn('text-gray-100', 'bg-gray-800')
* // Result: 'text-gray-100 bg-gray-800'
*
* // With conditional classes
* const isActive = true
* const classes = cn(
* 'base-class',
* isActive && 'active-class',
* undefined,
* null
* )
* // Result: 'base-class active-class'
* ```
*
* @category Theme
* @public
*/
export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ')
}
/**
* Get card surface styles by variant name.
*
* @param variant - Card variant name (default, domain, ai, featured, simple)
* @returns Tailwind CSS class string for the specified card variant
*
* @remarks
* This helper function provides runtime variant selection for card components.
* Use this when card variant is determined dynamically (e.g., from props or state).
*
* **Available variants:**
* - `default`: Standard card styling
* - `domain`: Domain-specific card styling
* - `ai`: AI-themed card styling
* - `featured`: Featured/highlighted card
* - `simple`: Minimal card styling
*
* @example
* ```tsx
* import { getCardStyle } from '@/lib/theme'
*
* // Basic usage
* const Card = ({ variant = 'default', children }) => (
* <div className={getCardStyle(variant)}>
* {children}
* </div>
* )
*
* // Dynamic variant selection
* const DomainCard = ({ domain }) => {
* const variant = domain.featured ? 'featured' : 'domain'
* return <div className={getCardStyle(variant)}>...</div>
* }
* ```
*
* @see {@link surfaces} For all available surface styles
* @category Theme
* @public
*/
export function getCardStyle(variant: keyof typeof surfaces.card = 'default'): string {
return surfaces.card[variant]
}
/**
* Get section surface styles by variant name.
*
* @param variant - Section variant name (default, compact, plain)
* @returns Tailwind CSS class string for the specified section variant
*
* @remarks
* This helper function provides runtime variant selection for section containers.
* Use this when section variant is determined dynamically.
*
* **Available variants:**
* - `default`: Standard section with full spacing
* - `compact`: Reduced spacing for denser layouts
* - `plain`: Minimal styling without background
*
* @example
* ```tsx
* import { getSectionStyle } from '@/lib/theme'
*
* const Section = ({ compact = false, children }) => {
* const variant = compact ? 'compact' : 'default'
* return (
* <section className={getSectionStyle(variant)}>
* {children}
* </section>
* )
* }
* ```
*
* @see {@link surfaces} For all available surface styles
* @category Theme
* @public
*/
export function getSectionStyle(variant: keyof typeof surfaces.section = 'default'): string {
return surfaces.section[variant]
}
/**
* Get button surface styles by variant name.
*
* @param variant - Button variant name (nav, dropdownItem, active, icon, primary)
* @returns Tailwind CSS class string for the specified button variant
*
* @remarks
* This helper function provides runtime variant selection for button components.
* Use this when button variant is determined dynamically.
*
* **Available variants:**
* - `nav`: Navigation button styling
* - `dropdownItem`: Dropdown menu item button
* - `active`: Active/selected state button
* - `icon`: Icon-only button (e.g., close buttons)
* - `primary`: Primary call-to-action button
*
* @example
* ```tsx
* import { getButtonStyle } from '@/lib/theme'
*
* const Button = ({ variant = 'nav', active = false, children }) => {
* const style = active ? 'active' : variant
* return (
* <button className={getButtonStyle(style)}>
* {children}
* </button>
* )
* }
* ```
*
* @see {@link surfaces} For all available surface styles
* @category Theme
* @public
*/
export function getButtonStyle(variant: keyof typeof surfaces.button = 'nav'): string {
return surfaces.button[variant]
}

382
lib/theme/surfaces.ts Normal file
View file

@ -0,0 +1,382 @@
/**
* Card surface styling variants for content containers.
*
* Provides consistent styling patterns for card components with various visual treatments.
* All variants include responsive hover states and smooth transitions.
*
* @example
* ```tsx
* import { card } from '@/lib/theme/surfaces'
*
* // Standard card
* <div className={card.default}>
* <h3>Content</h3>
* </div>
*
* // Domain portfolio card with backdrop blur
* <div className={card.domain}>
* <span>example.com</span>
* </div>
*
* // Featured content with accent border
* <div className={card.featured}>
* <h2>Top Pick</h2>
* </div>
* ```
*
* @category Theme
* @public
*/
export const card = {
/** Standard card with bold border and hover effect - use for general content containers */
default: 'border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300',
/** Domain-specific card with glassmorphism effect - used in domain portfolio grid */
domain: 'bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl hover:border-gray-700 transition-all hover:shadow-xl hover:shadow-black/20',
/** AI analytics card with padding and hover states - used for usage statistics */
ai: 'p-6 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300',
/** Featured/highlighted card with orange accent border and tinted background - used for TopPick component */
featured: 'p-6 sm:p-8 border-2 border-[#c15f3c] rounded-lg bg-orange-500/5',
/** Minimal card with thin border only - use for nested or subtle containers */
simple: 'border border-gray-700 rounded-lg',
} as const
/**
* Section surface styling variants for page layout containers.
*
* Sections are larger content areas that organize page content into logical blocks.
* Use these for page-level organization and content grouping.
*
* @example
* ```tsx
* import { section } from '@/lib/theme/surfaces'
*
* // Standard section with responsive padding
* <section className={section.default}>
* <h2>Section Title</h2>
* <p>Content...</p>
* </section>
*
* // Compact section for tighter layouts
* <section className={section.compact}>
* <div>Dense content</div>
* </section>
*
* // Plain section for nested content (no border)
* <div className={section.plain}>
* <div>Nested content without visual container</div>
* </div>
* ```
*
* @category Theme
* @public
*/
export const section = {
/** Standard section with responsive padding, border, and hover effect - use for main content areas */
default: 'p-4 sm:p-8 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300',
/** Compact section with reduced padding - use for sidebar or constrained layouts */
compact: 'p-4 border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300',
/** Plain section with padding only (no border) - use for nested content areas */
plain: 'p-4 sm:p-8',
} as const
/**
* Panel surface styling variants for overlays and UI elements.
*
* Panels are floating or fixed UI elements like dropdowns, modals, and sidebars.
* These styles provide consistent backdrop effects and layering.
*
* @example
* ```tsx
* import { panel } from '@/lib/theme/surfaces'
*
* // Dropdown menu
* <div className={panel.dropdown}>
* <button>Menu Item 1</button>
* <button>Menu Item 2</button>
* </div>
*
* // Modal overlay
* <div className={panel.overlay}>
* <h3>Modal Content</h3>
* </div>
*
* // Sidebar navigation
* <aside className={panel.sidebar}>
* <nav>Links...</nav>
* </aside>
* ```
*
* @category Theme
* @public
*/
export const panel = {
/** Dropdown panel with solid background and shadow - use for menus and popovers */
dropdown: 'bg-gray-800 rounded-lg shadow-xl border border-gray-700',
/** Overlay panel with translucent backdrop blur - use for modals and dialogs */
overlay: 'bg-gray-800/95 backdrop-blur-sm border border-gray-700/50',
/** Sidebar panel with right border - use for navigation sidebars */
sidebar: 'bg-gray-900/50 backdrop-blur-sm border-r border-gray-800',
} as const
/**
* Button surface styling variants for interactive elements.
*
* Button styles provide consistent interaction patterns across navigation,
* CTAs, and other clickable elements.
*
* @example
* ```tsx
* import { button } from '@/lib/theme/surfaces'
*
* // Navigation button
* <button className={button.nav}>
* Home
* </button>
*
* // Primary CTA button
* <button className={button.primary}>
* Get Started
* </button>
*
* // Active state (combine with nav)
* <button className={cn(button.nav, button.active)}>
* Current Page
* </button>
*
* // Icon button
* <button className={button.icon}>
* <IconComponent />
* </button>
* ```
*
* @category Theme
* @public
*/
export const button = {
/** Navigation button with subtle hover - use for header/sidebar navigation links */
nav: 'text-gray-300 hover:text-white hover:bg-gray-700 rounded-md px-3 py-2 transition-all duration-300',
/** Dropdown menu item with translucent hover - use for menu items in dropdowns */
dropdownItem: 'text-gray-300 hover:text-white hover:bg-gray-700/30 rounded-md transition-all duration-300',
/** Active state styling - combine with nav for current page indication */
active: 'text-white bg-gray-700/50',
/** Icon-only button with fixed size - use for toolbar icons and action buttons */
icon: 'inline-flex h-9 w-9 items-center justify-center rounded-xl bg-gray-800 text-gray-300',
/** Primary CTA button with shadow and lift effect - use for main call-to-action elements */
primary: 'bg-gray-800 text-white font-bold py-2 px-4 rounded-sm shadow-md transition-all duration-300 ease-in-out hover:bg-gray-700 hover:shadow-lg hover:-translate-y-0.5',
} as const
/**
* Badge surface styling variants for labels and status indicators.
*
* Badges are small inline labels used for categories, tags, and status indicators.
* All variants use consistent sizing with semantic color coding.
*
* @example
* ```tsx
* import { badge } from '@/lib/theme/surfaces'
*
* // Default badge
* <span className={badge.default}>Tag</span>
*
* // Muted/subtle badge
* <span className={badge.muted}>Draft</span>
*
* // Status badges with semantic colors
* <span className={badge.success}>Active</span>
* <span className={badge.warning}>Pending</span>
* <span className={badge.error}>Error</span>
* <span className={badge.accent}>Featured</span>
* ```
*
* @category Theme
* @public
*/
export const badge = {
/** Default badge with neutral gray background - use for general tags and categories */
default: 'px-2 py-1 bg-gray-700 rounded text-xs text-gray-300',
/** Muted badge with darker background - use for secondary or de-emphasized labels */
muted: 'px-2 py-1 bg-gray-800 rounded text-xs text-gray-400',
/** Accent badge with orange theme colors - use for featured or highlighted items */
accent: 'px-2 py-1 bg-orange-500/20 border border-orange-500/30 rounded text-xs text-orange-300',
/** Success badge with green colors - use for active, completed, or positive status */
success: 'px-2 py-1 bg-green-500/20 border border-green-500/30 rounded text-xs text-green-300',
/** Warning badge with yellow colors - use for pending, caution, or attention states */
warning: 'px-2 py-1 bg-yellow-500/20 border border-yellow-500/30 rounded text-xs text-yellow-300',
/** Error badge with red colors - use for errors, failures, or critical states */
error: 'px-2 py-1 bg-red-500/20 border border-red-500/30 rounded text-xs text-red-300',
} as const
/**
* Spacing utilities for consistent layout and content organization.
*
* Provides responsive spacing patterns for page layouts, sections, grids, and content flow.
* All spacing values use Tailwind's responsive breakpoints for mobile-first design.
*
* @example
* ```tsx
* import { spacing } from '@/lib/theme/surfaces'
*
* // Page-level container with responsive padding
* <main className={spacing.page}>
* <div>Page content</div>
* </main>
*
* // Section container with vertical spacing
* <div className={spacing.section}>
* <section>Section 1</section>
* <section>Section 2</section>
* </div>
*
* // Grid layout with responsive gaps
* <div className={cn('grid grid-cols-2', spacing.grid)}>
* <div>Grid item 1</div>
* <div>Grid item 2</div>
* </div>
*
* // Content flow with consistent vertical rhythm
* <article className={spacing.content}>
* <p>Paragraph 1</p>
* <p>Paragraph 2</p>
* </article>
* ```
*
* @category Theme
* @public
*/
export const spacing = {
/** Page-level responsive padding - use on main containers and page wrappers */
page: 'px-4 py-8 sm:px-6 lg:px-8',
/** Vertical spacing between major sections - creates consistent rhythm on pages */
section: 'space-y-8',
/** Grid gap with responsive sizing - use with CSS Grid or flex layouts */
grid: 'gap-4 sm:gap-6',
/** Content flow vertical spacing - use for text content and article layouts */
content: 'space-y-4',
} as const
/**
* Layout grid utilities for responsive card and content grids.
*
* Provides pre-configured responsive grid layouts with mobile-first column breakpoints.
* All layouts include consistent gap spacing and padding.
*
* @example
* ```tsx
* import { layout } from '@/lib/theme/surfaces'
*
* // 3-column grid (homepage, about page)
* <div className={layout.grid3col}>
* <div>Card 1</div>
* <div>Card 2</div>
* <div>Card 3</div>
* </div>
*
* // 2-column grid
* <div className={layout.grid2col}>
* <div>Card 1</div>
* <div>Card 2</div>
* </div>
*
* // 4-column grid (stats, icons)
* <div className={layout.grid4col}>
* <div>Stat 1</div>
* <div>Stat 2</div>
* <div>Stat 3</div>
* <div>Stat 4</div>
* </div>
* ```
*
* @category Theme
* @public
*/
export const layout = {
/** 2-column responsive grid - 1 col mobile, 2 cols tablet+ */
grid2col: 'grid grid-cols-1 md:grid-cols-2 gap-4 p-4',
/** 3-column responsive grid - 1 col mobile, 2 cols tablet, 3 cols desktop - use for homepage and about page */
grid3col: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4',
/** 4-column responsive grid - 2 cols mobile, 4 cols tablet+ */
grid4col: 'grid grid-cols-2 md:grid-cols-4 gap-4',
} as const
/**
* Surface design tokens for consistent UI styling across the application.
*
* Provides a comprehensive set of pre-configured surface styles for cards, sections,
* panels, buttons, badges, and spacing. All styles follow the dark theme design system
* with consistent colors, borders, shadows, and transitions.
*
* @remarks
* This is the main theme export. Import individual categories or the full surfaces object
* depending on your needs. All variants support responsive design and include smooth
* hover transitions.
*
* @example
* ```tsx
* import { surfaces } from '@/lib/theme/surfaces'
* // or
* import { card, button, badge } from '@/lib/theme/surfaces'
*
* // Using the full surfaces object
* <div className={surfaces.card.default}>
* <h2>Card Title</h2>
* </div>
*
* // Using individual imports
* <div className={card.domain}>
* <span>example.com</span>
* </div>
* ```
*
* @category Theme
* @public
*/
export const surfaces = {
card,
section,
panel,
button,
badge,
spacing,
layout,
} as const
/**
* TypeScript type representing all available surface tokens.
*
* Use this type for type-safe access to surface styles in components.
*
* @example
* ```tsx
* import type { SurfaceTokens } from '@/lib/theme/surfaces'
*
* function CustomComponent(props: { surfaces: SurfaceTokens }) {
* return <div className={props.surfaces.card.default}>Content</div>
* }
* ```
*
* @category Theme
* @public
*/
export type SurfaceTokens = typeof surfaces

362
lib/types/ai.ts Normal file
View file

@ -0,0 +1,362 @@
/**
* Type definitions for AI usage analytics and token tracking.
*
* @remarks
* This module contains interfaces for Claude AI usage data, including:
* - Token consumption metrics (input, output, cache)
* - Cost calculations
* - Daily aggregations
* - Trend analysis
* - Model-specific breakdowns
*
* @module lib/types/ai
* @category Types
*/
/**
* Breakdown of AI usage metrics for a specific model.
*
* @remarks
* Contains token counts and cost for a single AI model within a time period.
* Used to track usage across different models (e.g., Claude Sonnet, Opus).
*
* @example
* ```ts
* const breakdown: ModelBreakdown = {
* modelName: 'claude-sonnet-4-20250514',
* inputTokens: 150000,
* outputTokens: 75000,
* cacheCreationTokens: 5000,
* cacheReadTokens: 50000,
* cost: 2.45
* }
* ```
*
* @public
*/
export interface ModelBreakdown {
/** Model identifier (e.g., 'claude-sonnet-4-20250514') */
modelName: string
/** Number of input tokens consumed */
inputTokens: number
/** Number of output tokens generated */
outputTokens: number
/** Number of tokens written to cache */
cacheCreationTokens: number
/** Number of tokens read from cache */
cacheReadTokens: number
/** Total cost in USD for this model's usage */
cost: number
}
/**
* Aggregated AI usage data for a single day.
*
* @remarks
* Represents all AI interactions for a 24-hour period, including:
* - Total token counts across all models
* - Total cost in USD
* - Per-model breakdowns
* - List of models used
*
* Date format is ISO 8601 (YYYY-MM-DD).
*
* @example
* ```ts
* const dailyData: DailyData = {
* date: '2025-01-15',
* inputTokens: 500000,
* outputTokens: 250000,
* cacheCreationTokens: 10000,
* cacheReadTokens: 100000,
* totalTokens: 860000,
* totalCost: 8.50,
* modelsUsed: ['claude-sonnet-4-20250514'],
* modelBreakdowns: [...]
* }
* ```
*
* @public
*/
export interface DailyData {
/** Date in ISO 8601 format (YYYY-MM-DD) */
date: string
/** Total input tokens for the day */
inputTokens: number
/** Total output tokens for the day */
outputTokens: number
/** Total cache creation tokens for the day */
cacheCreationTokens: number
/** Total cache read tokens for the day */
cacheReadTokens: number
/** Sum of all token types */
totalTokens: number
/** Total cost in USD for the day */
totalCost: number
/** List of model identifiers used this day */
modelsUsed: string[]
/** Per-model usage breakdowns */
modelBreakdowns: ModelBreakdown[]
}
/**
* Aggregated totals across all time periods.
*
* @remarks
* Represents cumulative usage metrics, typically used for:
* - All-time totals
* - Custom date range totals
* - Filtered subset totals
*
* @example
* ```ts
* const totals: Totals = {
* inputTokens: 15000000,
* outputTokens: 7500000,
* cacheCreationTokens: 100000,
* cacheReadTokens: 1000000,
* totalCost: 250.00,
* totalTokens: 23600000
* }
* ```
*
* @public
*/
export interface Totals {
/** Cumulative input tokens */
inputTokens: number
/** Cumulative output tokens */
outputTokens: number
/** Cumulative cache creation tokens */
cacheCreationTokens: number
/** Cumulative cache read tokens */
cacheReadTokens: number
/** Cumulative cost in USD */
totalCost: number
/** Sum of all cumulative tokens */
totalTokens: number
}
/**
* Complete AI usage dataset with daily breakdowns and totals.
*
* @remarks
* Primary data structure for AI analytics, containing:
* - Daily usage history
* - Aggregate totals
*
* Typically loaded from JSON data files or API responses.
*
* @example
* ```ts
* const data: CCData = {
* daily: [...], // Array of DailyData
* totals: {
* inputTokens: 15000000,
* outputTokens: 7500000,
* // ... other totals
* }
* }
* ```
*
* @public
*/
export interface CCData {
/** Array of daily usage data, typically sorted chronologically */
daily: DailyData[]
/** Aggregated totals across all daily data */
totals: Totals
}
/**
* Extended AI usage data supporting multiple sources (Claude Code, Codex, etc.).
*
* @remarks
* Used for dashboards that display multiple AI tool usages side-by-side.
* Each tool can have its own daily history and totals.
*
* @example
* ```ts
* const extended: ExtendedCCData = {
* totals: { ... }, // Combined totals
* claudeCode: {
* daily: [...],
* totals: { ... }
* },
* codex: {
* daily: [...],
* totals: { ... }
* }
* }
* ```
*
* @public
*/
export interface ExtendedCCData {
/** Combined totals across all sources (optional) */
totals?: Totals
/** Claude Code usage data */
claudeCode?: {
daily: DailyData[]
totals: Totals
}
/** Codex usage data */
codex?: {
daily: DailyData[]
totals: Totals
}
}
/**
* Time range selector keys for filtering AI usage data.
*
* @remarks
* Used in dropdown menus and filters to select predefined time ranges:
* - '7d': Last 7 days
* - '1m': Last 1 month
* - '3m': Last 3 months
* - '6m': Last 6 months
* - '1y': Last 1 year
* - 'all': All available data
*
* @example
* ```ts
* function filterByRange(data: DailyData[], range: TimeRangeKey) {
* // Implementation
* }
* ```
*
* @public
*/
export type TimeRangeKey = '7d' | '1m' | '3m' | '6m' | '1y' | 'all'
/**
* Single day cell data for heatmap visualization.
*
* @remarks
* Contains all data needed to render one cell in a GitHub-style contribution heatmap:
* - Date information
* - Usage metrics (tokens, cost)
* - Display formatting
*
* @example
* ```ts
* const heatmapDay: HeatmapDay = {
* date: '2025-01-15',
* value: 8.50,
* tokens: 860000,
* cost: 8.50,
* day: 1, // Monday
* formattedDate: 'Jan 15, 2025'
* }
* ```
*
* @public
*/
export interface HeatmapDay {
/** Date in ISO 8601 format (YYYY-MM-DD) */
date: string
/** Primary value for heatmap intensity (typically cost) */
value: number
/** Total tokens for tooltip display */
tokens: number
/** Total cost for tooltip display */
cost: number
/** Day of week (0 = Sunday, 6 = Saturday) */
day: number
/** Human-readable date string for tooltips */
formattedDate: string
}
/**
* Daily data extended with trend analysis for chart visualizations.
*
* @remarks
* Extends {@link DailyData} with:
* - Linear regression trend lines
* - Normalized token values for charting
*
* Used for displaying trend overlays on usage charts.
*
* @example
* ```ts
* const trendData: DailyDataWithTrend = {
* ...dailyData,
* costTrend: 8.75, // Predicted cost from regression
* tokensTrend: 0.9, // Predicted tokens in millions
* inputTokensNormalized: 500, // Input tokens / 1000
* outputTokensNormalized: 250, // Output tokens / 1000
* cacheTokensNormalized: 0.11 // Cache tokens / 1000000
* }
* ```
*
* @public
*/
export interface DailyDataWithTrend extends DailyData {
/** Predicted cost from linear regression (null if not enough data) */
costTrend: number | null
/** Predicted tokens in millions from linear regression (null if not enough data) */
tokensTrend: number | null
/** Input tokens divided by 1000 for chart display */
inputTokensNormalized: number
/** Output tokens divided by 1000 for chart display */
outputTokensNormalized: number
/** Cache tokens divided by 1000000 for chart display */
cacheTokensNormalized: number
}
/**
* Model usage statistics for pie/bar charts.
*
* @remarks
* Represents usage distribution across different AI models.
* Includes both absolute values and percentages for visualization.
*
* @example
* ```ts
* const usage: ModelUsage = {
* name: 'Claude Sonnet 4',
* value: 125.50,
* percentage: 65.2
* }
* ```
*
* @public
*/
export interface ModelUsage {
/** Human-readable model name */
name: string
/** Total cost or usage value */
value: number
/** Percentage of total usage (optional) */
percentage?: number
/** Allow additional properties for chart libraries */
[key: string]: string | number | undefined
}
/**
* Token type usage statistics for composition charts.
*
* @remarks
* Breaks down usage by token type (input, output, cache creation, cache read).
* Used for pie charts showing token distribution.
*
* @example
* ```ts
* const tokenUsage: TokenTypeUsage = {
* name: 'Input',
* value: 15000000,
* percentage: 63.5
* }
* ```
*
* @public
*/
export interface TokenTypeUsage {
/** Token type name ('Input', 'Output', 'Cache Creation', 'Cache Read') */
name: string
/** Total token count */
value: number
/** Percentage of total tokens (optional) */
percentage?: number
}

83
lib/types/common.ts Normal file
View file

@ -0,0 +1,83 @@
/**
* Common utility types used across my website
*
* @remarks
* This module provides fundamental type definitions for date handling,
* filtering, and pagination. These types are used throughout
* the application to ensure type safety and consistency.
*
* @module lib/types/common
* @category Types
* @public
*/
/**
* Represents a time range with start and end dates.
*
* @remarks
* Used for filtering data by date ranges, such as analytics queries,
* domain renewal periods, or session time windows.
*
* @example
* ```ts
* import type { DateRange } from '@/lib/types/common'
*
* // Last 30 days
* const last30Days: DateRange = {
* start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
* end: new Date()
* }
*
* // Specific date range
* const Q1: DateRange = {
* start: new Date('2025-01-01'),
* end: new Date('2025-03-31')
* }
* ```
*
* @category Types
* @public
*/
export interface DateRange {
/** Start date of the range (inclusive) */
start: Date
/** End date of the range (inclusive) */
end: Date
}
/**
* Date object with pre-computed formatted strings.
*
* @remarks
* This type is useful when you need to display dates in multiple formats
* and want to avoid repeated formatting operations. Commonly used in
* components that display dates in both human-readable and machine-readable formats.
*
* @example
* ```ts
* import type { FormattedDate } from '@/lib/types/common'
* import { Formatter } from '@/lib/utils'
*
* const registrationDate: FormattedDate = {
* date: new Date('2023-05-15'),
* formatted: 'May 15, 2023',
* iso: '2023-05-15T00:00:00.000Z'
* }
*
* // Usage in components
* <time dateTime={registrationDate.iso}>
* {registrationDate.formatted}
* </time>
* ```
*
* @category Types
* @public
*/
export interface FormattedDate {
/** Original Date object */
date: Date
/** Human-readable formatted string (e.g., "May 15, 2023") */
formatted: string
/** ISO 8601 formatted string for machine readability */
iso: string
}

517
lib/types/device.ts Normal file
View file

@ -0,0 +1,517 @@
/**
* Device type definitions for portfolio showcase.
*
* Provides comprehensive type safety for device specifications, statistics,
* sections, and UI components. Supports both mobile devices and DAPs (Digital Audio Players).
*
* @module lib/types/device
* @category Types
*/
import React from 'react'
/**
* Icon component type for device-related icons.
*
* @public
*/
export type DeviceIcon = React.ComponentType<{
/** Optional className for styling */
className?: string
/** Optional size override */
size?: number
}>
/**
* Device type classification.
*
* @remarks
* - `mobile`: Smartphones and mobile devices
* - `dap`: Digital Audio Players (dedicated music players)
*
* @example
* ```ts
* const type: DeviceType = 'mobile'
* ```
*
* @public
*/
export type DeviceType = 'mobile' | 'dap'
/**
* Star rating display state.
*
* @remarks
* Used for rendering star ratings with half-star support.
*
* @public
*/
export type StarState = 'full' | 'half' | 'empty'
/**
* Type-safe external URL starting with http or https.
*
* @example
* ```ts
* const url: ExternalHref = 'https://example.com'
* ```
*
* @public
*/
export type ExternalHref = `http${string}`
/**
* Badge display configuration for device highlights.
*
* @example
* ```ts
* const badge: DeviceBadge = {
* label: 'Flagship',
* tone: 'highlight'
* }
* ```
*
* @public
*/
export interface DeviceBadge {
/** Badge text */
label: string
/** Visual tone (default: neutral, highlight: accent, muted: subtle) */
tone?: 'default' | 'highlight' | 'muted'
}
/**
* Individual stat item within a stat group.
*
* @example
* ```ts
* const stat: DeviceStatItem = {
* label: 'Display',
* value: '6.1" OLED',
* href: 'https://example.com/display-specs'
* }
* ```
*
* @public
*/
export interface DeviceStatItem {
/** Optional label for the stat */
label?: string
/** Stat value to display */
value: string
/** Optional external link for more information */
href?: string
}
/**
* Group of related device statistics.
*
* @example
* ```tsx
* import { CpuIcon } from 'lucide-react'
*
* const group: DeviceStatGroup = {
* title: 'Performance',
* icon: CpuIcon,
* accent: 'primary',
* items: [
* { label: 'Processor', value: 'Snapdragon 8 Gen 2' },
* { label: 'RAM', value: '8GB' },
* { label: 'Storage', value: '256GB' }
* ]
* }
* ```
*
* @public
*/
export interface DeviceStatGroup {
/** Group title */
title: string
/** Optional icon for visual identification */
icon?: DeviceIcon
/** List of stat items in this group */
items: DeviceStatItem[]
/** Visual accent style */
accent?: 'primary' | 'surface'
}
/**
* Row item within a device section showing key-value pairs.
*
* @example
* ```tsx
* import { BatteryIcon } from 'lucide-react'
*
* const row: DeviceSectionRow = {
* label: 'Battery',
* value: '5000 mAh',
* icon: BatteryIcon,
* note: 'Supports 65W fast charging'
* }
* ```
*
* @public
*/
export interface DeviceSectionRow {
/** Row label */
label: string
/** Row value */
value: string
/** Optional icon */
icon?: DeviceIcon
/** Optional external link */
href?: string
/** Optional additional note */
note?: string
}
/**
* List item within a device section.
*
* @example
* ```ts
* const item: DeviceSectionListItem = {
* label: 'USB-C 3.1',
* description: 'Fast data transfer and charging',
* href: 'https://example.com/usb-specs'
* }
* ```
*
* @public
*/
export interface DeviceSectionListItem {
/** Item label */
label: string
/** Optional description */
description?: string
/** Optional external link */
href?: string
}
/**
* Rating configuration for device sections.
*
* @example
* ```ts
* const rating: DeviceSectionRating = {
* value: 4.5,
* scale: 5,
* label: 'Overall Rating'
* }
* ```
*
* @public
*/
export interface DeviceSectionRating {
/** Rating value (supports half-stars) */
value: number
/** Maximum rating scale (default: 5) */
scale?: number
/** Optional rating label */
label?: string
}
/**
* Device section containing grouped information.
*
* @remarks
* Sections can contain one of: rows (key-value pairs), listItems (bullet lists),
* paragraphs (text content), or rating (star rating). Each section has an icon
* for visual identification.
*
* @example
* ```tsx
* import { CameraIcon } from 'lucide-react'
*
* const section: DeviceSection = {
* id: 'camera',
* title: 'Camera',
* icon: CameraIcon,
* rows: [
* { label: 'Main', value: '50MP f/1.8' },
* { label: 'Ultra-wide', value: '12MP f/2.2' },
* { label: 'Telephoto', value: '10MP f/2.4' }
* ],
* rating: { value: 4.5, scale: 5 }
* }
* ```
*
* @public
*/
export interface DeviceSection {
/** Unique section identifier */
id: string
/** Section title */
title: string
/** Section icon */
icon: DeviceIcon
/** Optional key-value rows */
rows?: DeviceSectionRow[]
/** Optional list items */
listItems?: DeviceSectionListItem[]
/** Optional text paragraphs */
paragraphs?: string[]
/** Optional rating */
rating?: DeviceSectionRating
}
/**
* Complete device specification.
*
* Contains all data needed to render a device page including metadata, statistics,
* sections, and related devices.
*
* @example
* ```tsx
* import { CpuIcon, BatteryIcon } from 'lucide-react'
*
* const device: DeviceSpec = {
* slug: 'pixel-8-pro',
* name: 'Google Pixel 8 Pro',
* codename: 'husky',
* type: 'mobile',
* manufacturer: 'Google',
* shortName: 'Pixel 8 Pro',
* status: 'Current',
* releaseYear: 2023,
* heroImage: {
* src: '/img/devices/pixel-8-pro.png',
* alt: 'Google Pixel 8 Pro',
* width: 800,
* height: 600
* },
* tagline: 'AI-powered flagship smartphone',
* summary: [
* 'Advanced Tensor G3 processor',
* 'Exceptional camera system',
* 'Premium build quality'
* ],
* badges: [
* { label: 'Flagship', tone: 'highlight' },
* { label: 'Current', tone: 'default' }
* ],
* stats: [
* {
* title: 'Performance',
* icon: CpuIcon,
* items: [
* { label: 'Processor', value: 'Google Tensor G3' },
* { label: 'RAM', value: '12GB' }
* ]
* }
* ],
* sections: [
* {
* id: 'battery',
* title: 'Battery',
* icon: BatteryIcon,
* rows: [{ label: 'Capacity', value: '5050 mAh' }]
* }
* ],
* related: ['pixel-7-pro', 'pixel-8'],
* updatedAt: '2024-01-15'
* }
* ```
*
* @public
*/
export interface DeviceSpec {
/** URL-friendly slug */
slug: string
/** Full device name */
name: string
/** Optional device codename */
codename?: string
/** Device type (mobile or dap) */
type: DeviceType
/** Manufacturer name */
manufacturer?: string
/** Short display name */
shortName?: string
/** Current status (e.g., 'Current', 'Retired') */
status?: string
/** Year of release */
releaseYear?: number
/** Hero image configuration */
heroImage: {
/** Image source path */
src: string
/** Alt text for accessibility */
alt: string
/** Optional width */
width?: number
/** Optional height */
height?: number
}
/** Marketing tagline */
tagline?: string
/** Summary bullet points */
summary?: string[]
/** Feature badges */
badges?: DeviceBadge[]
/** Stat groups */
stats: DeviceStatGroup[]
/** Content sections */
sections: DeviceSection[]
/** Related device slugs */
related?: string[]
/** Last update date (ISO format) */
updatedAt?: string
}
/**
* Collection of devices indexed by slug.
*
* @public
*/
export type DeviceCollection = Record<string, DeviceSpec>
/**
* Enriched device with computed metrics.
*
* Extends {@link DeviceSpec} with age calculations and display labels.
*
* @remarks
* This interface is generated by `DeviceService.enrichDevice()` method.
* All devices returned by DeviceService methods include these computed properties.
*
* @example
* ```ts
* import { DeviceService } from '@/lib/services'
*
* const enriched: DeviceWithMetrics = DeviceService.enrichDevice(device)
* console.log(enriched.ageInYears) // 1
* console.log(enriched.isCurrentYear) // false
* console.log(enriched.categoryLabel) // 'Mobile Devices'
* ```
*
* @public
*/
export interface DeviceWithMetrics extends DeviceSpec {
/** Device age in full years */
ageInYears: number
/** True if released in current year */
isCurrentYear: boolean
/** Display label for device category */
categoryLabel: string
}
/**
* Props for DevicePageShell component.
*
* @public
*/
export interface DevicePageShellProps {
/** Device data to render */
device: DeviceSpec
}
/**
* Props for DeviceHero component.
*
* @public
*/
export interface DeviceHeroProps {
/** Device data for hero section */
device: DeviceSpec
}
/**
* Props for StatsGrid component.
*
* @public
*/
export interface StatsGridProps {
/** Stat groups to display */
stats: DeviceStatGroup[]
}
/**
* Props for StatItem component.
*
* @public
*/
export interface StatItemProps {
/** Stat item to display */
item: DeviceStatItem
/** Optional group icon for fallback */
groupIcon?: DeviceStatGroup['icon']
}
/**
* Props for SectionsGrid component.
*
* @public
*/
export interface SectionsGridProps {
/** Sections to display in grid */
sections: DeviceSection[]
}
/**
* Props for SectionCard component.
*
* @public
*/
export interface SectionCardProps {
/** Section data to render */
section: DeviceSection
}
/**
* Props for SectionRow component.
*
* @public
*/
export interface SectionRowProps {
/** Row data to render */
row: NonNullable<DeviceSection['rows']>[number]
}
/**
* Props for Rating component.
*
* @public
*/
export interface RatingProps {
/** Rating data to render */
rating: NonNullable<DeviceSection['rating']>
}

380
lib/types/domain.ts Normal file
View file

@ -0,0 +1,380 @@
/**
* Domain type definitions for portfolio management.
*
* Provides comprehensive type safety for domain data, metrics, and UI components.
* All domain-related interfaces include renewal tracking, ownership metrics, and
* categorization for portfolio organization.
*
* @module lib/types/domain
* @category Types
*/
import type { ComponentType, Dispatch, SetStateAction } from 'react'
import type { IconType } from 'react-icons'
import type { LucideIcon } from 'lucide-react'
/**
* Domain status indicating current usage state.
*
* @remarks
* - `active`: Domain is actively being used for a website or service
* - `parked`: Domain is registered but not currently in use
* - `reserved`: Domain is reserved for future use
*
* @example
* ```ts
* const status: DomainStatus = 'active'
* ```
*
* @public
*/
export type DomainStatus = 'active' | 'parked' | 'reserved'
/**
* Domain category for portfolio organization.
*
* @remarks
* Categories help organize domains by purpose and importance:
* - `personal`: Personal branding or portfolio domains
* - `service`: Production services and applications
* - `project`: Project-specific or experimental domains
* - `fun`: Hobby or entertainment domains
* - `legacy`: Older domains maintained for historical reasons
*
* @example
* ```ts
* const category: DomainCategory = 'service'
* ```
*
* @public
*/
export type DomainCategory = 'personal' | 'service' | 'project' | 'fun' | 'legacy'
/**
* Supported domain registrar identifiers.
*
* @example
* ```ts
* const registrar: DomainRegistrarId = 'Namecheap'
* ```
*
* @public
*/
export type DomainRegistrarId = 'Spaceship' | 'Namecheap' | 'Name.com' | 'Dynadot'
/**
* Sort options for domain lists.
*
* @example
* ```ts
* const sortBy: DomainSortOption = 'expiration'
* ```
*
* @public
*/
export type DomainSortOption = 'name' | 'expiration' | 'ownership' | 'registrar'
/**
* Timeline event types for domain history.
*
* @example
* ```ts
* const eventType: DomainTimelineEventType = 'renewal'
* ```
*
* @public
*/
export type DomainTimelineEventType = 'registration' | 'renewal'
/**
* Domain renewal record tracking renewal history.
*
* @example
* ```ts
* const renewal: Renewal = {
* date: '2024-01-15',
* years: 2
* }
* ```
*
* @public
*/
export interface Renewal {
/** ISO date string of renewal (YYYY-MM-DD format) */
date: string
/** Number of years renewed */
years: number
}
/**
* Registrar configuration for UI display.
*
* @example
* ```tsx
* import { SiNamecheap } from 'react-icons/si'
*
* const config: RegistrarConfig = {
* name: 'Namecheap',
* icon: SiNamecheap,
* color: '#FF6C2C'
* }
* ```
*
* @public
*/
export interface RegistrarConfig {
/** Display name of the registrar */
name: string
/** Icon component (from react-icons or custom) */
icon: IconType | ComponentType<{className?: string}>
/** Brand color for visual identification */
color: string
}
/**
* Core domain data structure.
*
* Base interface containing all raw domain information without computed metrics.
* Use {@link DomainWithMetrics} for enriched data with ownership calculations.
*
* @example
* ```ts
* const domain: Domain = {
* domain: 'example.com',
* usage: 'Personal portfolio website',
* registrar: 'Namecheap',
* autoRenew: true,
* status: 'active',
* category: 'personal',
* tags: ['portfolio', 'web'],
* renewals: [
* { date: '2022-01-15', years: 1 },
* { date: '2023-01-15', years: 2 }
* ]
* }
* ```
*
* @public
*/
export interface Domain {
/** Domain name (e.g., 'example.com') */
domain: string
/** Description of domain usage/purpose */
usage: string
/** Registrar where domain is registered */
registrar: DomainRegistrarId
/** Whether auto-renewal is enabled */
autoRenew: boolean
/** Current status of the domain */
status: DomainStatus
/** Portfolio category for organization */
category: DomainCategory
/** Tags for filtering and search */
tags: string[]
/** Renewal history (chronological order) */
renewals: Renewal[]
}
/**
* Enriched domain data with computed ownership and expiration metrics.
*
* Extends {@link Domain} with calculated fields for ownership duration, expiration
* tracking, and renewal progress.
*
* @remarks
* All date calculations use UTC to ensure consistent timezone handling. The renewal
* progress percentage helps visualize time until next renewal is needed.
*
* This interface is generated by `DomainService.enrichDomain()` method.
* All domains returned by DomainService methods include these computed properties.
*
* @example
* ```ts
* import { DomainService } from '@/lib/services'
*
* const enriched: DomainWithMetrics = DomainService.enrichDomain(domain)
* console.log(enriched.ownershipYears) // 2
* console.log(enriched.daysUntilExpiration) // 347
* console.log(enriched.isExpiringSoon) // false
* console.log(enriched.renewalProgressPercent) // 15.2
* console.log(enriched.tld) // 'com'
* ```
*
* @public
*/
export interface DomainWithMetrics extends Domain {
/** Calculated expiration date based on last renewal */
expirationDate: Date
/** First renewal date (registration date) */
registrationDate: Date
/** Total days of ownership */
ownershipDays: number
/** Full years of ownership (floored) */
ownershipYears: number
/** Remaining months after full years */
ownershipMonths: number
/** Days until domain expires */
daysUntilExpiration: number
/** Percentage of current renewal period completed (0-100) */
renewalProgressPercent: number
/** True if expiring within 90 days */
isExpiringSoon: boolean
/** Date of next scheduled renewal */
nextRenewalDate: Date
/** Top-level domain extension (e.g., 'com', 'net') */
tld: string
}
/**
* Visual styling configuration for domain status/category badges.
*
* @example
* ```tsx
* import { Circle } from 'lucide-react'
*
* const option: DomainVisualOption = {
* label: 'Active',
* icon: Circle,
* color: 'text-green-400',
* bg: 'bg-green-500/20',
* border: 'border-green-500/30'
* }
* ```
*
* @public
*/
export interface DomainVisualOption {
/** Display label text */
label: string
/** Lucide icon component */
icon: LucideIcon
/** Tailwind text color class */
color: string
/** Tailwind background color class */
bg: string
/** Tailwind border color class */
border: string
}
/**
* Complete visual configuration mapping for all domain statuses and categories.
*
* @public
*/
export type DomainVisualConfig = {
/** Visual options for each status type */
status: Record<DomainStatus, DomainVisualOption>
/** Visual options for each category type */
category: Record<DomainCategory, DomainVisualOption>
}
/**
* Mapping of registrar IDs to their configuration.
*
* @public
*/
export type DomainRegistrarMap = Record<DomainRegistrarId, RegistrarConfig>
/**
* Domain timeline event for renewal/registration history display.
*
* @example
* ```ts
* const event: DomainTimelineEvent = {
* date: new Date('2024-01-15'),
* type: 'renewal',
* years: 2
* }
* ```
*
* @public
*/
export interface DomainTimelineEvent {
/** Event date */
date: Date
/** Event type (registration or renewal) */
type: DomainTimelineEventType
/** Number of years for the renewal */
years: number
}
/**
* Props for DomainCard component.
*
* @public
*/
export interface DomainCardProps {
/** Domain data to display */
domain: Domain
}
/**
* Props for DomainDetails component.
*
* @public
*/
export interface DomainDetailsProps {
/** Domain data to display */
domain: Domain
}
/**
* Props for DomainTimeline component.
*
* @public
*/
export interface DomainTimelineProps {
/** Domain data to display timeline for */
domain: Domain
}
/**
* Props for DomainFilters component.
*
* @public
*/
export interface DomainFiltersProps {
/** Search query change handler */
onSearchChange: Dispatch<SetStateAction<string>>
/** Category filter change handler */
onCategoryChange: Dispatch<SetStateAction<DomainCategory[]>>
/** Status filter change handler */
onStatusChange: Dispatch<SetStateAction<DomainStatus[]>>
/** Registrar filter change handler */
onRegistrarChange: Dispatch<SetStateAction<DomainRegistrarId[]>>
/** Sort option change handler */
onSortChange: Dispatch<SetStateAction<DomainSortOption>>
/** Available registrars for filter options */
registrars: DomainRegistrarId[]
}

6
lib/types/index.ts Normal file
View file

@ -0,0 +1,6 @@
export * from './domain'
export * from './device'
export * from './ai'
export * from './common'
export * from './navigation'
export * from './service'

71
lib/types/navigation.ts Normal file
View file

@ -0,0 +1,71 @@
import type { ComponentType, ReactNode } from 'react'
import type { GitHubRepoSummary } from '@/lib/github'
export type NavigationIcon = ComponentType<{ className?: string; size?: number } & Record<string, unknown>>
export type NavigationLink = {
label: string
href: string
icon: NavigationIcon
external?: boolean
}
export type NavigationDropdownLinkItem = NavigationLink & {
type: 'link'
}
export type NavigationDropdownGroup = {
title: string
links: NavigationDropdownLinkItem[]
}
export type NavigationDropdownNestedItem = {
type: 'nested'
label: string
icon: NavigationIcon
groups: NavigationDropdownGroup[]
}
export type NavigationDropdownItem =
| NavigationDropdownLinkItem
| NavigationDropdownNestedItem
export type NavigationDropdownConfig = {
items: NavigationDropdownItem[]
}
export type NavigationMenuLinkItem = NavigationLink & {
type: 'link'
id: string
}
export type NavigationMenuDropdownItem = {
type: 'dropdown'
id: string
label: string
href: string
icon: NavigationIcon
dropdown: NavigationDropdownConfig
}
export type NavigationMenuItem =
| NavigationMenuLinkItem
| NavigationMenuDropdownItem
export type FooterMenuRenderContext = {
githubUsername: string
githubRepos: GitHubRepoSummary[]
}
export type FooterMenuSection =
| {
type: 'links'
title: string
links: NavigationLink[]
}
| {
type: 'custom'
title: string
render: (context: FooterMenuRenderContext) => ReactNode
}

376
lib/types/service.ts Normal file
View file

@ -0,0 +1,376 @@
/**
* Shared type definitions for service layer operations.
*
* @remarks
* This module contains reusable types for common service patterns:
* - Filtering and sorting configurations
* - Statistics and aggregation results
* - Query options and pagination
*
* @module lib/types/service
* @category Types
*/
/**
* Sort order direction for list operations.
*
* @example
* ```ts
* const order: SortOrder = 'asc' // Ascending order
* const descOrder: SortOrder = 'desc' // Descending order
* ```
*
* @public
*/
export type SortOrder = 'asc' | 'desc'
/**
* Configuration for sorting operations on typed entities.
*
* @template T - The entity type being sorted
*
* @remarks
* Provides type-safe sorting configuration with:
* - Compile-time verification of sort key
* - Order direction (ascending/descending)
*
* @example
* ```ts
* interface User {
* name: string
* age: number
* createdAt: Date
* }
*
* const config: SortConfig<User> = {
* sortBy: 'age',
* order: 'desc'
* }
* ```
*
* @public
*/
export interface SortConfig<T> {
/** Property key to sort by (type-safe) */
sortBy: keyof T
/** Sort direction */
order: SortOrder
}
/**
* Generic filter configuration for entity queries.
*
* @template T - The entity type being filtered
*
* @remarks
* Provides a flexible filtering system where:
* - Keys are entity properties
* - Values can be exact matches or undefined (no filter)
*
* This enables type-safe, partial filtering of entities.
*
* @example
* ```ts
* interface Product {
* category: string
* inStock: boolean
* price: number
* }
*
* const filters: FilterConfig<Product> = {
* category: 'electronics',
* inStock: true
* // price is omitted (not filtered)
* }
* ```
*
* @public
*/
export type FilterConfig<T> = Partial<T>
/**
* Statistics result containing aggregate metrics.
*
* @remarks
* Common return type for service methods that compute statistics.
* Includes:
* - Total count
* - Category/group breakdowns
* - Additional metrics as key-value pairs
*
* @example
* ```ts
* const stats: StatsResult = {
* total: 150,
* byCategory: {
* active: 120,
* inactive: 30
* },
* averageAge: 2.5,
* newestItem: {...}
* }
* ```
*
* @public
*/
export interface StatsResult {
/** Total count of items */
total: number
/** Breakdown by categories */
byCategory?: Record<string, number>
/** Additional computed metrics */
[key: string]: number | Record<string, number> | unknown | undefined
}
/**
* Date range configuration for time-based queries.
*
* @remarks
* Supports both absolute dates and relative ranges.
* Used for filtering time-series data.
*
* @example
* ```ts
* // Absolute range
* const range: DateRangeConfig = {
* start: new Date('2025-01-01'),
* end: new Date('2025-01-31')
* }
*
* // Relative range
* const lastMonth: DateRangeConfig = {
* relativeDays: 30
* }
* ```
*
* @public
*/
export interface DateRangeConfig {
/** Start date (inclusive) */
start?: Date
/** End date (inclusive) */
end?: Date
/** Relative days from now (alternative to start/end) */
relativeDays?: number
/** Relative months from now (alternative to start/end) */
relativeMonths?: number
}
/**
* Pagination configuration for list operations.
*
* @remarks
* Standard pagination pattern with:
* - Page number (1-indexed)
* - Items per page
* - Optional offset-based pagination
*
* @example
* ```ts
* // Page-based pagination
* const config: PaginationConfig = {
* page: 2,
* pageSize: 25
* }
*
* // Offset-based pagination
* const offsetConfig: PaginationConfig = {
* page: 1,
* pageSize: 25,
* offset: 50
* }
* ```
*
* @public
*/
export interface PaginationConfig {
/** Current page number (1-indexed) */
page: number
/** Number of items per page */
pageSize: number
/** Optional offset for cursor-based pagination */
offset?: number
}
/**
* Paginated result set with metadata.
*
* @template T - Type of items in the result
*
* @remarks
* Standard response format for paginated queries, including:
* - Data items
* - Total count
* - Current page info
* - Navigation metadata
*
* @example
* ```ts
* const result: PaginatedResult<User> = {
* items: [...],
* total: 150,
* page: 2,
* pageSize: 25,
* totalPages: 6,
* hasMore: true
* }
* ```
*
* @public
*/
export interface PaginatedResult<T> {
/** Items for current page */
items: T[]
/** Total count across all pages */
total: number
/** Current page number */
page: number
/** Items per page */
pageSize: number
/** Total number of pages */
totalPages: number
/** Whether more pages exist */
hasMore: boolean
}
/**
* Query options combining common patterns.
*
* @template T - Entity type being queried
*
* @remarks
* Comprehensive query configuration supporting:
* - Filtering
* - Sorting
* - Pagination
* - Date ranges
*
* Designed for flexible, composable queries.
*
* @example
* ```ts
* interface Product {
* name: string
* price: number
* createdAt: Date
* }
*
* const options: QueryOptions<Product> = {
* filters: { price: 50 },
* sort: { sortBy: 'createdAt', order: 'desc' },
* pagination: { page: 1, pageSize: 20 },
* dateRange: { relativeDays: 30 }
* }
* ```
*
* @public
*/
export interface QueryOptions<T> {
/** Filter configuration */
filters?: FilterConfig<T>
/** Sort configuration */
sort?: SortConfig<T>
/** Pagination settings */
pagination?: PaginationConfig
/** Date range filter */
dateRange?: DateRangeConfig
}
/**
* Service method result wrapper with metadata.
*
* @template T - Type of the result data
*
* @remarks
* Standard envelope for service responses, providing:
* - Success/failure status
* - Result data or error
* - Optional metadata
*
* Enables consistent error handling across services.
*
* @example
* ```ts
* // Success result
* const success: ServiceResult<User> = {
* success: true,
* data: { id: 1, name: 'Alice' }
* }
*
* // Error result
* const error: ServiceResult<User> = {
* success: false,
* error: 'User not found'
* }
*
* // With metadata
* const withMeta: ServiceResult<User[]> = {
* success: true,
* data: [...],
* metadata: { cached: true, timestamp: Date.now() }
* }
* ```
*
* @public
*/
export interface ServiceResult<T> {
/** Whether the operation succeeded */
success: boolean
/** Result data (present if success=true) */
data?: T
/** Error message (present if success=false) */
error?: string
/** Optional metadata about the operation */
metadata?: Record<string, unknown>
}
/**
* Comparison operator for advanced filtering.
*
* @remarks
* Used in complex filter expressions to specify:
* - Equality checks
* - Range queries
* - Existence checks
*
* @example
* ```ts
* const op: FilterOperator = 'gte' // Greater than or equal
* ```
*
* @public
*/
export type FilterOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'exists'
/**
* Advanced filter expression with operators.
*
* @template T - Value type being filtered
*
* @remarks
* Enables complex query expressions beyond simple equality.
* Similar to MongoDB query syntax.
*
* @example
* ```ts
* // Range filter
* const ageFilter: FilterExpression<number> = {
* operator: 'gte',
* value: 18
* }
*
* // Array inclusion filter
* const statusFilter: FilterExpression<string> = {
* operator: 'in',
* value: ['active', 'pending']
* }
* ```
*
* @public
*/
export interface FilterExpression<T> {
/** Comparison operator */
operator: FilterOperator
/** Value to compare against */
value: T | T[]
}

317
lib/utils/formatting.ts Normal file
View file

@ -0,0 +1,317 @@
/**
* Formatter utility class providing consistent formatting functions for various data types.
*
* @remarks
* This class contains static methods for formatting numbers, dates, strings, and other common
* data types across the application. All methods are pure functions with no side effects.
*
* @example
* ```ts
* import { Formatter } from '@/lib/utils'
*
* // Format currency
* const price = Formatter.currency(1234.56) // "$1234.56"
*
* // Format large numbers
* const tokens = Formatter.tokens(1500000) // "1.5M"
*
* // Format dates
* const date = Formatter.date(new Date(), 'long') // "January 15, 2025"
* ```
*
* @category Utils
* @public
*/
export class Formatter {
/**
* Formats a number as currency with a dollar sign and fixed decimal places.
*
* @param value - The numeric value to format as currency
* @param decimals - Number of decimal places to display (default: 2)
* @returns Formatted currency string with dollar sign
*
* @example
* ```ts
* Formatter.currency(1234.567, 2) // "$1234.57"
* Formatter.currency(99.9) // "$99.90"
* Formatter.currency(1000, 0) // "$1000"
* ```
*
* @public
*/
static currency(value: number, decimals: number = 2): string {
return `$${value.toFixed(decimals)}`
}
/**
* Formats large numbers with metric suffixes (K, M, B) for readability.
*
* @param value - The numeric value to format
* @returns Formatted string with appropriate suffix
*
* @remarks
* - Values >= 1 billion use 'B' suffix
* - Values >= 1 million use 'M' suffix
* - Values >= 1 thousand use 'K' suffix
* - Smaller values return as integers
*
* @example
* ```ts
* Formatter.tokens(2_500_000_000) // "2.5B"
* Formatter.tokens(1_500_000) // "1.5M"
* Formatter.tokens(7_500) // "7.5K"
* Formatter.tokens(999) // "999"
* ```
*
* @public
*/
static tokens(value: number): string {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(1)}B`
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(1)}K`
}
return value.toFixed(0)
}
/**
* Formats a number as a percentage with a percent sign.
*
* @param value - The numeric value to format as percentage (e.g., 85.5 for 85.5%)
* @param decimals - Number of decimal places to display (default: 1)
* @returns Formatted percentage string with percent sign
*
* @example
* ```ts
* Formatter.percentage(85.5) // "85.5%"
* Formatter.percentage(100, 0) // "100%"
* Formatter.percentage(33.333, 2) // "33.33%"
* ```
*
* @public
*/
static percentage(value: number, decimals: number = 1): string {
return `${value.toFixed(decimals)}%`
}
/**
* Formats a date object or ISO string into various human-readable formats.
*
* @param date - Date object or ISO date string to format
* @param format - Output format style (default: 'short')
* @returns Formatted date string in the specified format
*
* @remarks
* Supported formats:
* - 'iso': ISO 8601 format (e.g., "2025-01-15T10:30:00.000Z")
* - 'long': Full month name (e.g., "January 15, 2025")
* - 'short': Abbreviated month (e.g., "Jan 15, 2025")
*
* @example
* ```ts
* const date = new Date('2025-01-15')
* Formatter.date(date, 'short') // "Jan 15, 2025"
* Formatter.date(date, 'long') // "January 15, 2025"
* Formatter.date(date, 'iso') // "2025-01-15T00:00:00.000Z"
*
* // Also accepts ISO strings
* Formatter.date('2025-01-15', 'short') // "Jan 15, 2025"
* ```
*
* @public
*/
static date(date: Date | string, format: 'short' | 'long' | 'iso' = 'short'): string {
const d = typeof date === 'string' ? new Date(date) : date
switch (format) {
case 'iso':
return d.toISOString()
case 'long':
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
case 'short':
default:
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
}
/**
* Formats a duration in days into a compact human-readable string.
*
* @param days - Number of days to format
* @returns Compact duration string with appropriate unit
*
* @remarks
* Uses the following conversion rules:
* - >= 365 days: years ('y')
* - >= 30 days: months ('mo')
* - >= 7 days: weeks ('w')
* - < 7 days: days ('d')
*
* @example
* ```ts
* Formatter.duration(400) // "1y"
* Formatter.duration(45) // "1mo"
* Formatter.duration(14) // "2w"
* Formatter.duration(5) // "5d"
* ```
*
* @public
*/
static duration(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`
}
/**
* Formats a file size in bytes into a human-readable string with appropriate unit.
*
* @param bytes - File size in bytes
* @returns Formatted file size string with unit (B, KB, MB, GB, TB)
*
* @remarks
* - Automatically selects the most appropriate unit based on size
* - Uses 1024 as the conversion factor (binary prefix)
* - Bytes displayed as integers, larger units with 1 decimal place
*
* @example
* ```ts
* Formatter.fileSize(512) // "512 B"
* Formatter.fileSize(1536) // "1.5 KB"
* Formatter.fileSize(1_572_864) // "1.5 MB"
* Formatter.fileSize(1_610_612_736) // "1.5 GB"
* ```
*
* @public
*/
static fileSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
/**
* Formats a number with optional decimal places and locale-specific formatting.
*
* @param value - The numeric value to format
* @param decimals - Optional number of decimal places (uses locale formatting if omitted)
* @returns Formatted number string
*
* @remarks
* - With decimals specified: Uses toFixed() for exact decimal control
* - Without decimals: Uses toLocaleString() for thousands separators
*
* @example
* ```ts
* Formatter.number(1234567) // "1,234,567" (with locale separators)
* Formatter.number(1234.567, 2) // "1234.57" (exact decimals)
* Formatter.number(100, 0) // "100"
* ```
*
* @public
*/
static number(value: number, decimals?: number): string {
if (decimals !== undefined) {
return value.toFixed(decimals)
}
return value.toLocaleString()
}
/**
* Capitalizes the first letter of a string and lowercases the rest.
*
* @param str - The string to capitalize
* @returns String with first letter uppercase and remaining letters lowercase
*
* @example
* ```ts
* Formatter.capitalize('hello') // "Hello"
* Formatter.capitalize('WORLD') // "World"
* Formatter.capitalize('hELLo WoRLd') // "Hello world"
* ```
*
* @public
*/
static capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
}
/**
* Truncates a string to a maximum length and adds a suffix if truncated.
*
* @param str - The string to truncate
* @param maxLength - Maximum length including suffix
* @param suffix - String to append when truncated (default: '...')
* @returns Original string if within limit, otherwise truncated string with suffix
*
* @remarks
* The suffix length is included in maxLength calculation, so the resulting
* string will never exceed maxLength characters.
*
* @example
* ```ts
* Formatter.truncate('Hello World', 8) // "Hello..."
* Formatter.truncate('Hi', 10) // "Hi"
* Formatter.truncate('Long text here', 10, '~') // "Long text~"
* ```
*
* @public
*/
static truncate(str: string, maxLength: number, suffix: string = '...'): string {
if (str.length <= maxLength) return str
return str.slice(0, maxLength - suffix.length) + suffix
}
/**
* Converts a string to a URL-friendly slug format.
*
* @param str - The string to convert to a slug
* @returns URL-safe slug string
*
* @remarks
* Transformation steps:
* 1. Converts to lowercase
* 2. Trims whitespace
* 3. Removes non-word characters (except spaces and hyphens)
* 4. Replaces spaces, underscores, and multiple hyphens with single hyphen
* 5. Removes leading/trailing hyphens
*
* @example
* ```ts
* Formatter.slugify('Hello World') // "hello-world"
* Formatter.slugify('My_Page Title!') // "my-page-title"
* Formatter.slugify(' trim spaces ') // "trim-spaces"
* Formatter.slugify('foo---bar') // "foo-bar"
* ```
*
* @public
*/
static slugify(str: string): string {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '')
}
}

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

@ -0,0 +1,3 @@
export { Formatter } from './formatting'
export { Validator } from './validation'
export * from './styles'

56
lib/utils/styles.ts Normal file
View file

@ -0,0 +1,56 @@
/**
* Props to apply to external links for security and UX best practices.
*
* @remarks
* These props should be spread onto anchor tags that link to external sites:
* - `target="_blank"`: Opens link in new tab
* - `rel="noopener noreferrer"`: Prevents security vulnerabilities and referrer leakage
*
* @example
* ```tsx
* import { externalLinkProps } from '@/lib/utils/styles'
*
* <a href="https://external-site.com" {...externalLinkProps}>
* Visit Site
* </a>
* ```
*
* @category Utils
* @public
*/
export const externalLinkProps = {
target: '_blank',
rel: 'noopener noreferrer'
} as const
/**
* Type guard to check if a URL string is an external link (starts with http/https).
*
* @param href - URL string to check
* @returns Type predicate narrowing href to `http${string}` if external
*
* @remarks
* This function is useful for conditional rendering of external link attributes
* or icons. It narrows the TypeScript type to indicate an HTTP(S) URL.
*
* @example
* ```tsx
* import { isExternalHref, externalLinkProps } from '@/lib/utils/styles'
*
* function Link({ href, children }) {
* const external = isExternalHref(href)
*
* return (
* <a href={href} {...(external ? externalLinkProps : {})}>
* {children}
* {external && <ExternalIcon />}
* </a>
* )
* }
* ```
*
* @category Utils
* @public
*/
export const isExternalHref = (href?: string): href is `http${string}` =>
typeof href === 'string' && href.startsWith('http')

302
lib/utils/validation.ts Normal file
View file

@ -0,0 +1,302 @@
/**
* Validator utility class providing type-safe validation functions with TypeScript type guards.
*
* @remarks
* This class contains static methods for validating various data types and formats.
* Most methods use TypeScript type predicates for runtime type checking and compile-time
* type narrowing.
*
* @example
* ```ts
* import { Validator } from '@/lib/utils'
*
* // Validate and narrow types
* if (Validator.isValidDate(value)) {
* // TypeScript knows value is Date here
* console.log(value.getTime())
* }
*
* // Validate strings
* const isValid = Validator.isValidEmail('user@example.com') // true
*
* // Validate ranges
* const inRange = Validator.isInRange(50, 0, 100) // true
* ```
*
* @category Utils
* @public
*/
export class Validator {
/**
* Validates that a value is a valid Date object with a valid timestamp.
*
* @param date - Value to check
* @returns Type predicate indicating if value is a valid Date
*
* @remarks
* Checks both that the value is a Date instance and that its internal
* timestamp is not NaN (which can occur with invalid date strings).
*
* @example
* ```ts
* Validator.isValidDate(new Date()) // true
* Validator.isValidDate(new Date('2025-01-15')) // true
* Validator.isValidDate(new Date('invalid')) // false
* Validator.isValidDate('2025-01-15') // false
* Validator.isValidDate(null) // false
* ```
*
* @public
*/
static isValidDate(date: unknown): date is Date {
return date instanceof Date && !isNaN(date.getTime())
}
/**
* Validates that a string is a properly formatted URL.
*
* @param url - String to validate as URL
* @returns True if the string is a valid URL, false otherwise
*
* @remarks
* Uses the built-in URL constructor to validate format. Accepts any protocol
* (http, https, ftp, etc.) and properly formatted URLs.
*
* @example
* ```ts
* Validator.isValidUrl('https://example.com') // true
* Validator.isValidUrl('http://localhost:3000') // true
* Validator.isValidUrl('ftp://files.example.com') // true
* Validator.isValidUrl('example.com') // false (no protocol)
* Validator.isValidUrl('not a url') // false
* ```
*
* @public
*/
static isValidUrl(url: string): boolean {
try {
new URL(url)
return true
} catch {
return false
}
}
/**
* Validates that a string matches a basic email format.
*
* @param email - String to validate as email address
* @returns True if the string matches email format, false otherwise
*
* @remarks
* Uses a basic regex pattern that checks for:
* - Non-whitespace characters before @
* - Non-whitespace characters after @
* - A dot followed by non-whitespace characters (TLD)
*
* Note: This is a basic format check and may not catch all invalid emails
* or allow all technically valid ones. For production use, consider more
* robust validation or server-side verification.
*
* @example
* ```ts
* Validator.isValidEmail('user@example.com') // true
* Validator.isValidEmail('test.user@domain.co') // true
* Validator.isValidEmail('invalid.email') // false
* Validator.isValidEmail('missing@domain') // false
* Validator.isValidEmail('@example.com') // false
* ```
*
* @public
*/
static isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
/**
* Validates that a string matches a valid domain name format.
*
* @param domain - String to validate as domain name
* @returns True if the string is a valid domain format, false otherwise
*
* @remarks
* Validates domain names according to standard rules:
* - Only alphanumeric characters and hyphens
* - Cannot start or end with hyphen
* - Maximum 63 characters per label
* - Case insensitive
*
* Accepts both top-level domains and subdomains.
*
* @example
* ```ts
* Validator.isValidDomain('example.com') // true
* Validator.isValidDomain('subdomain.example.com') // true
* Validator.isValidDomain('test-site.dev') // true
* Validator.isValidDomain('Example.COM') // true (case insensitive)
* Validator.isValidDomain('-invalid.com') // false
* Validator.isValidDomain('invalid-.com') // false
* Validator.isValidDomain('has space.com') // false
* ```
*
* @public
*/
static isValidDomain(domain: string): boolean {
const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i
return domainRegex.test(domain)
}
/**
* Validates that a number falls within a specified range (inclusive).
*
* @param value - Number to check
* @param min - Minimum allowed value (inclusive)
* @param max - Maximum allowed value (inclusive)
* @returns True if value is between min and max (inclusive), false otherwise
*
* @example
* ```ts
* Validator.isInRange(50, 0, 100) // true
* Validator.isInRange(0, 0, 100) // true (inclusive)
* Validator.isInRange(100, 0, 100) // true (inclusive)
* Validator.isInRange(-1, 0, 100) // false
* Validator.isInRange(101, 0, 100) // false
* ```
*
* @public
*/
static isInRange(value: number, min: number, max: number): boolean {
return value >= min && value <= max
}
/**
* Validates that an object contains all required keys.
*
* @template T - The expected object type
* @param obj - Value to check
* @param keys - Array of required keys
* @returns Type predicate indicating if obj has all required keys
*
* @remarks
* This is a type guard that performs runtime validation while also narrowing
* the TypeScript type. It only checks for key presence, not value types.
*
* @example
* ```ts
* interface User {
* name: string
* email: string
* age: number
* }
*
* const data: unknown = { name: 'Alice', email: 'alice@example.com', age: 30 }
*
* if (Validator.hasRequiredKeys<User>(data, ['name', 'email', 'age'])) {
* // TypeScript knows data is User here
* console.log(data.name) // OK
* }
* ```
*
* @public
*/
static hasRequiredKeys<T extends object>(
obj: unknown,
keys: (keyof T)[]
): obj is T {
if (typeof obj !== 'object' || obj === null) return false
return keys.every(key => key in obj)
}
/**
* Validates that a value is a non-empty string (after trimming).
*
* @param value - Value to check
* @returns Type predicate indicating if value is a non-empty string
*
* @remarks
* Trims whitespace before checking length, so strings with only whitespace
* are considered empty.
*
* @example
* ```ts
* Validator.isNonEmptyString('hello') // true
* Validator.isNonEmptyString(' text ') // true
* Validator.isNonEmptyString('') // false
* Validator.isNonEmptyString(' ') // false (whitespace only)
* Validator.isNonEmptyString(123) // false
* Validator.isNonEmptyString(null) // false
* ```
*
* @public
*/
static isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
/**
* Validates that a value is a positive number (> 0) and not NaN.
*
* @param value - Value to check
* @returns Type predicate indicating if value is a positive number
*
* @remarks
* Checks for:
* - Type is number
* - Value is greater than 0 (not equal to 0)
* - Value is not NaN
*
* @example
* ```ts
* Validator.isPositiveNumber(5) // true
* Validator.isPositiveNumber(0.1) // true
* Validator.isPositiveNumber(0) // false
* Validator.isPositiveNumber(-5) // false
* Validator.isPositiveNumber(NaN) // false
* Validator.isPositiveNumber('5') // false
* ```
*
* @public
*/
static isPositiveNumber(value: unknown): value is number {
return typeof value === 'number' && value > 0 && !isNaN(value)
}
/**
* Validates that a value is an array, optionally validating each item.
*
* @template T - The expected item type
* @param value - Value to check
* @param itemValidator - Optional validator function for array items
* @returns Type predicate indicating if value is an array of type T
*
* @remarks
* - Without itemValidator: Only checks if value is an array
* - With itemValidator: Checks if value is an array AND all items pass validation
*
* @example
* ```ts
* // Basic array check
* Validator.isArray([1, 2, 3]) // true
* Validator.isArray('not array') // false
*
* // With item validation
* Validator.isArray([1, 2, 3], (item): item is number => typeof item === 'number') // true
* Validator.isArray([1, '2', 3], (item): item is number => typeof item === 'number') // false
*
* // With type narrowing
* const value: unknown = ['a', 'b', 'c']
* if (Validator.isArray<string>(value, (item): item is string => typeof item === 'string')) {
* // TypeScript knows value is string[] here
* value.map(s => s.toUpperCase())
* }
* ```
*
* @public
*/
static isArray<T>(value: unknown, itemValidator?: (item: unknown) => item is T): value is T[] {
if (!Array.isArray(value)) return false
if (!itemValidator) return true
return value.every(itemValidator)
}
}