feat (v1.0.0): initial refactor and redesign
This commit is contained in:
parent
3058aa1ab4
commit
fe9b50b30e
134 changed files with 17792 additions and 3670 deletions
41
lib/config/featured-repos.ts
Normal file
41
lib/config/featured-repos.ts
Normal 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
61
lib/devices/config.ts
Normal 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
465
lib/devices/data.ts
Normal 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
13
lib/devices/index.ts
Normal 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
48
lib/docs/loader.ts
Normal 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
614
lib/docs/parser.ts
Normal 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
344
lib/docs/search.ts
Normal 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
225
lib/docs/types.ts
Normal 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
178
lib/domains/config.ts
Normal 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
222
lib/domains/data.ts
Normal 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
590
lib/domains/utils.ts
Normal 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
486
lib/github.ts
Normal 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
919
lib/services/ai.service.ts
Normal file
|
|
@ -0,0 +1,919 @@
|
|||
import {
|
||||
CCData,
|
||||
DailyData,
|
||||
HeatmapDay,
|
||||
DailyDataWithTrend,
|
||||
ModelUsage,
|
||||
TokenTypeUsage,
|
||||
TimeRangeKey,
|
||||
} from '@/lib/types'
|
||||
|
||||
/**
|
||||
* Configuration for heatmap color palette.
|
||||
*
|
||||
* @remarks
|
||||
* Defines the color scheme used for GitHub-style activity heatmaps,
|
||||
* with support for empty days and multi-step gradient scales.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const palette: HeatmapPalette = {
|
||||
* empty: '#1f2937', // Gray for zero activity
|
||||
* steps: ['#4a3328', '#6b4530', '#8d5738', '#c15f3c'] // Brown gradient
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @category Services
|
||||
* @public
|
||||
*/
|
||||
export interface HeatmapPalette {
|
||||
/** Color for days with no activity (value = 0) */
|
||||
empty: string
|
||||
/** Array of colors representing increasing activity levels */
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive AI usage statistics and analytics.
|
||||
*
|
||||
* @remarks
|
||||
* Provides a complete snapshot of AI usage including streaks, costs,
|
||||
* token consumption, time-based averages, and token distribution breakdowns.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const stats = AIService.getAIStats(ccData)
|
||||
* console.log(`Streak: ${stats.streakFormatted}`)
|
||||
* console.log(`Total cost: $${stats.totalCost.toFixed(2)}`)
|
||||
* console.log(`Last 7 days: $${stats.last7Days.cost.toFixed(2)}`)
|
||||
* ```
|
||||
*
|
||||
* @category Services
|
||||
* @public
|
||||
*/
|
||||
export interface AIStatsResult {
|
||||
/** Current activity streak in days */
|
||||
streak: number
|
||||
/** Formatted streak string (e.g., '2y', '3mo', '5d') */
|
||||
streakFormatted: string
|
||||
/** Total cost across all time */
|
||||
totalCost: number
|
||||
/** Total tokens consumed across all time */
|
||||
totalTokens: number
|
||||
/** Average daily cost across all recorded days */
|
||||
dailyAverage: number
|
||||
/** Statistics for the last 7 days */
|
||||
last7Days: {
|
||||
cost: number
|
||||
tokens: number
|
||||
dailyAverage: number
|
||||
}
|
||||
/** Statistics for the last 30 days */
|
||||
last30Days: {
|
||||
cost: number
|
||||
tokens: number
|
||||
dailyAverage: number
|
||||
}
|
||||
/** Token type breakdown */
|
||||
tokenBreakdown: {
|
||||
input: number
|
||||
output: number
|
||||
cache: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for AI usage analytics, token tracking, and cost calculations.
|
||||
*
|
||||
* @remarks
|
||||
* Provides comprehensive utilities for analyzing Claude API usage including:
|
||||
* - **Activity streaks** - Track consecutive days of usage
|
||||
* - **Trend analysis** - Linear regression for cost and token projections
|
||||
* - **Heatmap generation** - GitHub-style activity visualization
|
||||
* - **Time-range filtering** - Support for 7d, 1m, 3m, 6m, 1y, all
|
||||
* - **Model analytics** - Usage breakdown by model
|
||||
* - **Token composition** - Input, output, and cache token analysis
|
||||
* - **Statistics** - Comprehensive metrics and aggregations
|
||||
*
|
||||
* All date operations use UTC to ensure consistency across timezones.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { AIService } from '@/lib/services'
|
||||
*
|
||||
* // Get activity streak
|
||||
* const streak = AIService.computeStreak(ccData.daily)
|
||||
* console.log(`${streak} day streak`)
|
||||
*
|
||||
* // Filter data by time range
|
||||
* const last30Days = AIService.filterDailyByRange(ccData.daily, '1m')
|
||||
*
|
||||
* // Build trend data with linear regression
|
||||
* const trendData = AIService.buildDailyTrendData(ccData.daily)
|
||||
*
|
||||
* // Generate heatmap for current year
|
||||
* const heatmap = AIService.prepareHeatmapData(ccData.daily)
|
||||
*
|
||||
* // Get comprehensive statistics
|
||||
* const stats = AIService.getAIStats(ccData)
|
||||
* ```
|
||||
*
|
||||
* @category Services
|
||||
* @public
|
||||
*/
|
||||
export class AIService {
|
||||
private static readonly MODEL_LABELS: Record<string, string> = {
|
||||
'claude-sonnet-4-20250514': 'Claude Sonnet 4',
|
||||
'claude-sonnet-4-5-20250929': 'Claude Sonnet 4.5',
|
||||
'claude-opus-4-1-20250805': 'Claude Opus 4.1',
|
||||
'gpt-5': 'GPT-5',
|
||||
'gpt-5-codex': 'GPT-5 Codex',
|
||||
}
|
||||
|
||||
private static readonly RANGE_CONFIG: Record<Exclude<TimeRangeKey, 'all'>, { days?: number; months?: number }> = {
|
||||
'7d': { days: 7 },
|
||||
'1m': { months: 1 },
|
||||
'3m': { months: 3 },
|
||||
'6m': { months: 6 },
|
||||
'1y': { months: 12 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a model ID to a human-readable label.
|
||||
*
|
||||
* @param modelName - The model identifier (e.g., 'claude-sonnet-4-20250514')
|
||||
* @returns {string} Human-readable model name or original modelName if not found
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const label = AIService.getModelLabel('claude-sonnet-4-20250514')
|
||||
* console.log(label) // 'Claude Sonnet 4'
|
||||
* ```
|
||||
*/
|
||||
static getModelLabel(modelName: string): string {
|
||||
return this.MODEL_LABELS[modelName] || modelName
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the current activity streak in days.
|
||||
* A streak is broken if there's any day without usage between the latest day and today.
|
||||
*
|
||||
* @param daily - Array of daily usage data (doesn't need to be sorted)
|
||||
* @returns {number} Consecutive days with activity from most recent date
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const streak = AIService.computeStreak(dailyData)
|
||||
* console.log(`Current streak: ${streak} days`)
|
||||
* ```
|
||||
*/
|
||||
static computeStreak(daily: DailyData[]): number {
|
||||
if (!daily.length) return 0
|
||||
|
||||
const datesSet = new Set(daily.map(d => d.date))
|
||||
const latest = daily
|
||||
.map(d => new Date(d.date + 'T00:00:00Z'))
|
||||
.reduce((a, b) => (a > b ? a : b))
|
||||
|
||||
const toKey = (d: Date) => {
|
||||
const y = d.getUTCFullYear()
|
||||
const m = (d.getUTCMonth() + 1).toString().padStart(2, '0')
|
||||
const day = d.getUTCDate().toString().padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
let count = 0
|
||||
for (
|
||||
let d = new Date(latest.getTime());
|
||||
;
|
||||
d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - 1))
|
||||
) {
|
||||
const key = toKey(d)
|
||||
if (datesSet.has(key)) count++
|
||||
else break
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number of days into a compact string representation.
|
||||
*
|
||||
* @param days - Number of days to format
|
||||
* @returns {`${number}${'y' | 'mo' | 'w' | 'd'}`} Compact string like '2y', '3mo', '5w', or '10d'
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* console.log(AIService.formatStreakCompact(400)) // '1y'
|
||||
* console.log(AIService.formatStreakCompact(45)) // '1mo'
|
||||
* console.log(AIService.formatStreakCompact(14)) // '2w'
|
||||
* console.log(AIService.formatStreakCompact(5)) // '5d'
|
||||
* ```
|
||||
*/
|
||||
static formatStreakCompact(days: number): string {
|
||||
if (days >= 365) return `${Math.floor(days / 365)}y`
|
||||
if (days >= 30) return `${Math.floor(days / 30)}mo`
|
||||
if (days >= 7) return `${Math.floor(days / 7)}w`
|
||||
return `${days}d`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a date to UTC midnight (start of day).
|
||||
*
|
||||
* @param date - Date to normalize
|
||||
* @returns {Date} Date set to UTC midnight
|
||||
* @internal
|
||||
*/
|
||||
private static startOfDay(date: Date): Date {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Date to YYYY-MM-DD string format in UTC.
|
||||
*
|
||||
* @param date - Date to convert
|
||||
* @returns {string} Date string like '2025-01-15'
|
||||
* @internal
|
||||
*/
|
||||
private static toDateKey(date: Date): string {
|
||||
return `${date.getUTCFullYear()}-${(date.getUTCMonth() + 1).toString().padStart(2, '0')}-${date.getUTCDate().toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtracts months from a date in UTC, handling month-end edge cases.
|
||||
*
|
||||
* @param date - Starting date
|
||||
* @param months - Number of months to subtract
|
||||
* @returns {Date} Date with months subtracted, clamped to valid days
|
||||
* @internal
|
||||
*/
|
||||
private static subtractUtcMonths(date: Date, months: number): Date {
|
||||
const targetMonthIndex = date.getUTCMonth() - months
|
||||
const anchor = new Date(Date.UTC(date.getUTCFullYear(), targetMonthIndex, 1))
|
||||
const endOfAnchorMonth = new Date(Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth() + 1, 0))
|
||||
const clampedDay = Math.min(date.getUTCDate(), endOfAnchorMonth.getUTCDate())
|
||||
anchor.setUTCDate(clampedDay)
|
||||
return this.startOfDay(anchor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty DailyData object for dates with no activity.
|
||||
*
|
||||
* @param dateKey - Date string in YYYY-MM-DD format
|
||||
* @returns {DailyData} Object with all metrics set to zero
|
||||
* @internal
|
||||
*/
|
||||
private static emptyDay(dateKey: string): DailyData {
|
||||
return {
|
||||
date: dateKey,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
modelsUsed: [],
|
||||
modelBreakdowns: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a continuous daily series from start to end date, filling gaps with empty days.
|
||||
*
|
||||
* @param start - Start date (inclusive)
|
||||
* @param end - End date (inclusive)
|
||||
* @param byDate - Map of date keys to DailyData
|
||||
* @returns {DailyData[]} Continuous array with one entry per day
|
||||
* @internal
|
||||
*/
|
||||
private static buildFilledRange(
|
||||
start: Date,
|
||||
end: Date,
|
||||
byDate: Map<string, DailyData>
|
||||
): DailyData[] {
|
||||
const series: DailyData[] = []
|
||||
for (
|
||||
let cursor = new Date(start.getTime());
|
||||
cursor <= end;
|
||||
cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), cursor.getUTCDate() + 1))
|
||||
) {
|
||||
const key = this.toDateKey(cursor)
|
||||
series.push(byDate.get(key) ?? this.emptyDay(key))
|
||||
}
|
||||
return series
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills gaps in daily data with empty days between the first and last date.
|
||||
* Ensures a continuous time series for visualization.
|
||||
*
|
||||
* @param daily - Array of daily usage data (will be sorted internally)
|
||||
* @returns {DailyData[]} Continuous array with empty days filled between first and last date
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const filled = AIService.computeFilledDailyRange(sparseData)
|
||||
* // Now every day from first to last has an entry
|
||||
* ```
|
||||
*/
|
||||
static computeFilledDailyRange(daily: DailyData[]): DailyData[] {
|
||||
if (!daily.length) return []
|
||||
|
||||
const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
|
||||
const start = this.startOfDay(new Date(sorted[0].date + 'T00:00:00Z'))
|
||||
const end = this.startOfDay(new Date(sorted[sorted.length - 1].date + 'T00:00:00Z'))
|
||||
const byDate = new Map(sorted.map(d => [d.date, d] as const))
|
||||
|
||||
return this.buildFilledRange(start, end, byDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms daily token data into normalized values for charting.
|
||||
* Converts token counts to thousands (K) or millions (M) for readability.
|
||||
*
|
||||
* @param daily - Array of daily usage data
|
||||
* @returns {Array<{ date: string; inputTokens: number; outputTokens: number; cacheTokens: number }>}
|
||||
* Array of objects with date and normalized token counts (inputTokens & outputTokens in thousands, cacheTokens in millions)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const chartData = AIService.buildTokenCompositionData(dailyData)
|
||||
* // [{ date: '2025-01-01', inputTokens: 150.5, outputTokens: 75.2, cacheTokens: 2.3 }]
|
||||
* ```
|
||||
*/
|
||||
static buildTokenCompositionData(daily: DailyData[]) {
|
||||
return daily.map(day => ({
|
||||
date: day.date,
|
||||
inputTokens: day.inputTokens / 1000, // Convert to K
|
||||
outputTokens: day.outputTokens / 1000, // Convert to K
|
||||
cacheTokens: (day.cacheCreationTokens + day.cacheReadTokens) / 1000000, // Convert to M
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds daily data with linear regression trend lines for cost and token usage.
|
||||
* Uses least-squares method to compute trend from first non-zero data point.
|
||||
*
|
||||
* @param daily - Array of daily usage data
|
||||
* @returns {DailyDataWithTrend[]} Array of daily data with added trend properties:
|
||||
* - `costTrend: number | null` - Linear regression projected cost
|
||||
* - `tokensTrend: number | null` - Linear regression projected tokens (in millions)
|
||||
* - `inputTokensNormalized: number` - Input tokens / 1000
|
||||
* - `outputTokensNormalized: number` - Output tokens / 1000
|
||||
* - `cacheTokensNormalized: number` - Cache tokens / 1000000
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const trendData = AIService.buildDailyTrendData(dailyData)
|
||||
* // Each day includes costTrend and tokensTrend for visualization
|
||||
* ```
|
||||
*/
|
||||
static buildDailyTrendData(daily: DailyData[]): DailyDataWithTrend[] {
|
||||
const filled = this.computeFilledDailyRange(daily)
|
||||
const rows = filled.map(day => ({
|
||||
...day,
|
||||
costTrend: null as number | null,
|
||||
tokensTrend: null as number | null,
|
||||
inputTokensNormalized: day.inputTokens / 1000,
|
||||
outputTokensNormalized: day.outputTokens / 1000,
|
||||
cacheTokensNormalized: (day.cacheCreationTokens + day.cacheReadTokens) / 1000000,
|
||||
}))
|
||||
|
||||
const applyTrend = (
|
||||
startIndex: number,
|
||||
valueAccessor: (row: typeof rows[number]) => number,
|
||||
assign: (row: typeof rows[number], value: number | null) => void,
|
||||
) => {
|
||||
if (startIndex === -1 || startIndex >= rows.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const subset = rows.slice(startIndex)
|
||||
if (!subset.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (subset.length === 1) {
|
||||
const value = Math.max(valueAccessor(subset[0]), 0)
|
||||
assign(subset[0], value)
|
||||
return
|
||||
}
|
||||
|
||||
const n = subset.length
|
||||
const sumX = (n * (n - 1)) / 2
|
||||
const sumX2 = ((n - 1) * n * (2 * n - 1)) / 6
|
||||
|
||||
let sumY = 0
|
||||
let sumXY = 0
|
||||
|
||||
subset.forEach((row, idx) => {
|
||||
const y = valueAccessor(row)
|
||||
sumY += y
|
||||
sumXY += idx * y
|
||||
})
|
||||
|
||||
const denom = n * sumX2 - sumX * sumX
|
||||
const slope = denom !== 0 ? (n * sumXY - sumX * sumY) / denom : 0
|
||||
const intercept = (sumY - slope * sumX) / n
|
||||
|
||||
subset.forEach((row, idx) => {
|
||||
const projected = Math.max(intercept + slope * idx, 0)
|
||||
assign(row, projected)
|
||||
})
|
||||
}
|
||||
|
||||
const firstCostIndex = rows.findIndex(row => row.totalCost > 0)
|
||||
const firstTokenIndex = rows.findIndex(row => row.totalTokens > 0)
|
||||
|
||||
applyTrend(firstCostIndex, row => row.totalCost, (row, value) => {
|
||||
row.costTrend = value
|
||||
})
|
||||
|
||||
applyTrend(firstTokenIndex, row => row.totalTokens / 1000000, (row, value) => {
|
||||
row.tokensTrend = value
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a heatmap grid for the current year.
|
||||
*
|
||||
* @param daily - Array of daily usage data
|
||||
* @returns {(HeatmapDay | null)[][]} 2D array where each inner array is a week (Sunday-Saturday),
|
||||
* null represents days before year start
|
||||
*
|
||||
* @remarks
|
||||
* Creates a calendar-style heatmap visualization:
|
||||
* - Covers January 1 through today of current year
|
||||
* - Organized by weeks (Sunday-Saturday)
|
||||
* - Fills missing days before year start with null
|
||||
* - Pads incomplete final week with null
|
||||
* - Uses UTC for consistent timezone handling
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const heatmap = AIService.prepareHeatmapData(ccData.daily)
|
||||
* // Returns: [[null, null, {day1}, {day2}, ...], [{day8}, ...]]
|
||||
*
|
||||
* // Use with getHeatmapColor for visualization
|
||||
* heatmap.forEach(week => {
|
||||
* week.forEach(day => {
|
||||
* if (day) {
|
||||
* const color = AIService.getHeatmapColor(maxCost, day.cost)
|
||||
* // Render day cell with color
|
||||
* }
|
||||
* })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
static prepareHeatmapData(daily: DailyData[]): (HeatmapDay | null)[][] {
|
||||
const dayMap = new Map<string, DailyData>()
|
||||
daily.forEach(day => {
|
||||
dayMap.set(day.date, day)
|
||||
})
|
||||
|
||||
const today = new Date()
|
||||
const startOfYear = new Date(Date.UTC(today.getUTCFullYear(), 0, 1))
|
||||
const endDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()))
|
||||
|
||||
const weeks: (HeatmapDay | null)[][] = []
|
||||
let currentWeek: (HeatmapDay | null)[] = []
|
||||
|
||||
const firstDay = startOfYear.getUTCDay()
|
||||
const startDate = new Date(startOfYear)
|
||||
startDate.setUTCDate(startDate.getUTCDate() - firstDay)
|
||||
|
||||
for (
|
||||
let d = new Date(startDate);
|
||||
d <= endDate;
|
||||
d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1))
|
||||
) {
|
||||
if (d < startOfYear) {
|
||||
currentWeek.push(null)
|
||||
if (d.getUTCDay() === 6) {
|
||||
weeks.push(currentWeek)
|
||||
currentWeek = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
const dateStr = `${d.getUTCFullYear()}-${(d.getUTCMonth() + 1).toString().padStart(2, '0')}-${d.getUTCDate().toString().padStart(2, '0')}`
|
||||
const dayData = dayMap.get(dateStr)
|
||||
|
||||
currentWeek.push({
|
||||
date: dateStr,
|
||||
value: dayData ? dayData.totalCost : 0,
|
||||
tokens: dayData ? dayData.totalTokens : 0,
|
||||
cost: dayData ? dayData.totalCost : 0,
|
||||
day: d.getUTCDay(),
|
||||
formattedDate: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
|
||||
})
|
||||
|
||||
if (d.getUTCDay() === 6 || d.getTime() === endDate.getTime()) {
|
||||
while (currentWeek.length < 7) {
|
||||
currentWeek.push(null)
|
||||
}
|
||||
weeks.push(currentWeek)
|
||||
currentWeek = []
|
||||
}
|
||||
}
|
||||
|
||||
return weeks
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the heatmap color for a given activity value.
|
||||
*
|
||||
* @param maxCost - Maximum cost value in the dataset (for normalization)
|
||||
* @param value - Current day's cost value
|
||||
* @param palette - Color palette configuration (empty color + gradient steps)
|
||||
* @returns {string} Hex color string
|
||||
*
|
||||
* @remarks
|
||||
* Maps activity values to colors using a multi-step gradient:
|
||||
* - Values ≤ 0 return the empty color
|
||||
* - Values are normalized to 0-1 range using maxCost
|
||||
* - Normalized values are mapped to palette steps
|
||||
* - Handles edge cases (division by zero, infinite values)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const maxCost = 10.50
|
||||
* const dayCost = 7.25
|
||||
*
|
||||
* // With default palette (brown gradient)
|
||||
* const color = AIService.getHeatmapColor(maxCost, dayCost)
|
||||
* // Returns: '#8d5738' (upper-mid range color)
|
||||
*
|
||||
* // With custom palette
|
||||
* const customColor = AIService.getHeatmapColor(maxCost, dayCost, {
|
||||
* empty: '#1a1a1a',
|
||||
* steps: ['#fee', '#fcc', '#faa', '#f88', '#f00']
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Zero activity
|
||||
* AIService.getHeatmapColor(100, 0) // Returns empty color
|
||||
*
|
||||
* // Low activity (0-25% of max)
|
||||
* AIService.getHeatmapColor(100, 20) // Returns steps[0]
|
||||
*
|
||||
* // High activity (75-100% of max)
|
||||
* AIService.getHeatmapColor(100, 95) // Returns steps[3]
|
||||
* ```
|
||||
*/
|
||||
static getHeatmapColor(
|
||||
maxCost: number,
|
||||
value: number,
|
||||
palette: HeatmapPalette = {
|
||||
empty: '#1f2937',
|
||||
steps: ['#4a3328', '#6b4530', '#8d5738', '#c15f3c']
|
||||
}
|
||||
): string {
|
||||
if (value <= 0 || maxCost <= 0) return palette.empty
|
||||
|
||||
const ratio = value / maxCost
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) {
|
||||
return palette.empty
|
||||
}
|
||||
|
||||
const steps = palette.steps.length ? palette.steps : ['#4a3328', '#6b4530', '#8d5738', '#c15f3c']
|
||||
const clampedRatio = Math.min(Math.max(ratio, 0), 1)
|
||||
const index = Math.min(Math.floor(clampedRatio * steps.length), steps.length - 1)
|
||||
|
||||
return steps[index]
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates model usage data across all daily records.
|
||||
*
|
||||
* @param daily - Array of daily usage data
|
||||
* @returns {ModelUsage[]} Array of model usage with cost, sorted by cost (descending)
|
||||
*
|
||||
* @remarks
|
||||
* Processes model breakdowns to create a cost-based usage summary:
|
||||
* - Converts model IDs to human-readable labels
|
||||
* - Aggregates costs by model across all days
|
||||
* - Calculates percentage of total cost for each model
|
||||
* - Sorts by cost (highest first)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const modelData = AIService.buildModelUsageData(ccData.daily)
|
||||
* // Returns: [
|
||||
* // { name: 'Claude Sonnet 4.5', value: 45.30, percentage: 65.2 },
|
||||
* // { name: 'Claude Opus 4.1', value: 24.15, percentage: 34.8 }
|
||||
* // ]
|
||||
*
|
||||
* // Use for pie charts or model comparison
|
||||
* modelData.forEach(model => {
|
||||
* console.log(`${model.name}: $${model.value.toFixed(2)} (${model.percentage.toFixed(1)}%)`)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
static buildModelUsageData(daily: DailyData[]): ModelUsage[] {
|
||||
const raw = daily.reduce((acc, day) => {
|
||||
day.modelBreakdowns.forEach(model => {
|
||||
const label = this.getModelLabel(model.modelName)
|
||||
const existing = acc.find(m => m.name === label)
|
||||
if (existing) {
|
||||
existing.value += model.cost
|
||||
} else {
|
||||
acc.push({ name: label, value: model.cost })
|
||||
}
|
||||
})
|
||||
return acc
|
||||
}, [] as ModelUsage[])
|
||||
|
||||
const sorted = raw.sort((a, b) => b.value - a.value)
|
||||
const total = sorted.reduce((sum, m) => sum + m.value, 0)
|
||||
|
||||
return sorted.map(m => ({
|
||||
...m,
|
||||
percentage: total > 0 ? (m.value / total) * 100 : 0
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks down token usage by type (input, output, cache creation, cache read).
|
||||
*
|
||||
* @param totals - Aggregated totals from CCData
|
||||
* @returns {TokenTypeUsage[]} Array of token types with counts and percentages
|
||||
*
|
||||
* @remarks
|
||||
* Creates a distribution analysis of token consumption:
|
||||
* - Input tokens (user prompts)
|
||||
* - Output tokens (model responses)
|
||||
* - Cache creation tokens (new cache entries)
|
||||
* - Cache read tokens (cache hits)
|
||||
*
|
||||
* Useful for understanding token usage patterns and cache effectiveness.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const breakdown = AIService.buildTokenTypeData(ccData.totals)
|
||||
* // Returns: [
|
||||
* // { name: 'Input', value: 1500000, percentage: 45.5 },
|
||||
* // { name: 'Output', value: 850000, percentage: 25.8 },
|
||||
* // { name: 'Cache Creation', value: 500000, percentage: 15.2 },
|
||||
* // { name: 'Cache Read', value: 450000, percentage: 13.7 }
|
||||
* // ]
|
||||
*
|
||||
* // Calculate cache efficiency
|
||||
* const cacheCreation = breakdown.find(t => t.name === 'Cache Creation')
|
||||
* const cacheRead = breakdown.find(t => t.name === 'Cache Read')
|
||||
* const cacheHitRate = cacheRead / (cacheCreation + cacheRead)
|
||||
* ```
|
||||
*/
|
||||
static buildTokenTypeData(totals: CCData['totals']): TokenTypeUsage[] {
|
||||
const data = [
|
||||
{ name: 'Input', value: totals.inputTokens },
|
||||
{ name: 'Output', value: totals.outputTokens },
|
||||
{ name: 'Cache Creation', value: totals.cacheCreationTokens },
|
||||
{ name: 'Cache Read', value: totals.cacheReadTokens },
|
||||
]
|
||||
|
||||
const total = data.reduce((sum, t) => sum + t.value, 0)
|
||||
|
||||
return data.map(t => ({
|
||||
...t,
|
||||
percentage: total > 0 ? (t.value / total) * 100 : 0
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters daily data to a specific time range with gap filling.
|
||||
*
|
||||
* @param daily - Array of daily usage data
|
||||
* @param range - Time range key ('7d', '1m', '3m', '6m', '1y', 'all')
|
||||
* @param options - Optional configuration
|
||||
* @param options.endDate - Custom end date (defaults to last non-empty day)
|
||||
* @returns {DailyData[]} Filtered and filled daily data for the specified range
|
||||
*
|
||||
* @remarks
|
||||
* Advanced time-range filtering with intelligent date handling:
|
||||
* - **'all'**: Returns all data from first to last day
|
||||
* - **'7d'**: Last 7 days (day-based calculation)
|
||||
* - **'1m', '3m', '6m', '1y'**: Month-based calculation (handles month-end edge cases)
|
||||
* - Fills gaps with empty days for continuous series
|
||||
* - Uses last non-empty day as default end date
|
||||
* - All date operations in UTC
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Get last 30 days of data
|
||||
* const last30Days = AIService.filterDailyByRange(ccData.daily, '1m')
|
||||
*
|
||||
* // Get last 7 days
|
||||
* const lastWeek = AIService.filterDailyByRange(ccData.daily, '7d')
|
||||
*
|
||||
* // Get all historical data
|
||||
* const allData = AIService.filterDailyByRange(ccData.daily, 'all')
|
||||
*
|
||||
* // Custom end date (e.g., for historical analysis)
|
||||
* const customRange = AIService.filterDailyByRange(ccData.daily, '1m', {
|
||||
* endDate: new Date('2024-12-31')
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Compare costs across different time ranges
|
||||
* const ranges: TimeRangeKey[] = ['7d', '1m', '3m']
|
||||
* ranges.forEach(range => {
|
||||
* const data = AIService.filterDailyByRange(ccData.daily, range)
|
||||
* const totals = AIService.computeTotalsFromDaily(data)
|
||||
* console.log(`${range}: $${totals.totalCost.toFixed(2)}`)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
static filterDailyByRange(
|
||||
daily: DailyData[],
|
||||
range: TimeRangeKey,
|
||||
options: { endDate?: Date } = {},
|
||||
): DailyData[] {
|
||||
if (!daily.length) return []
|
||||
|
||||
const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
let effectiveEnd: Date
|
||||
if (options.endDate) {
|
||||
effectiveEnd = this.startOfDay(options.endDate)
|
||||
} else {
|
||||
const lastNonEmptyDay = sorted.filter(d => d.totalCost > 0).pop()
|
||||
const lastDate = lastNonEmptyDay?.date || sorted[sorted.length - 1]?.date
|
||||
if (!lastDate) return []
|
||||
effectiveEnd = this.startOfDay(new Date(lastDate + 'T00:00:00Z'))
|
||||
}
|
||||
|
||||
const trimmed = sorted.filter(day => {
|
||||
const current = new Date(day.date + 'T00:00:00Z')
|
||||
return current <= effectiveEnd
|
||||
})
|
||||
|
||||
if (!trimmed.length) return []
|
||||
|
||||
const byDate = new Map(trimmed.map(day => [day.date, day] as const))
|
||||
const earliest = this.startOfDay(new Date(trimmed[0].date + 'T00:00:00Z'))
|
||||
|
||||
if (range === 'all') {
|
||||
return this.buildFilledRange(earliest, effectiveEnd, byDate)
|
||||
}
|
||||
|
||||
const config = this.RANGE_CONFIG[range as Exclude<TimeRangeKey, 'all'>]
|
||||
if (!config) {
|
||||
return this.buildFilledRange(earliest, effectiveEnd, byDate)
|
||||
}
|
||||
|
||||
let start: Date
|
||||
if (config.days) {
|
||||
start = new Date(Date.UTC(
|
||||
effectiveEnd.getUTCFullYear(),
|
||||
effectiveEnd.getUTCMonth(),
|
||||
effectiveEnd.getUTCDate() - (config.days - 1)
|
||||
))
|
||||
} else {
|
||||
start = this.subtractUtcMonths(effectiveEnd, config.months ?? 0)
|
||||
}
|
||||
|
||||
if (start > effectiveEnd) {
|
||||
start = new Date(effectiveEnd)
|
||||
}
|
||||
|
||||
return this.buildFilledRange(start, effectiveEnd, byDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes aggregate totals from an array of daily data.
|
||||
*
|
||||
* @param daily - Array of daily usage data
|
||||
* @returns {CCData['totals']} Aggregated totals for all metrics
|
||||
*
|
||||
* @remarks
|
||||
* Sums all token and cost metrics across the provided daily data:
|
||||
* - Input tokens
|
||||
* - Output tokens
|
||||
* - Cache creation tokens
|
||||
* - Cache read tokens
|
||||
* - Total tokens
|
||||
* - Total cost
|
||||
*
|
||||
* Useful for computing subtotals after filtering by time range.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Compute totals for last 30 days
|
||||
* const last30Days = AIService.filterDailyByRange(ccData.daily, '1m')
|
||||
* const totals = AIService.computeTotalsFromDaily(last30Days)
|
||||
*
|
||||
* console.log(`Total cost: $${totals.totalCost.toFixed(2)}`)
|
||||
* console.log(`Total tokens: ${totals.totalTokens.toLocaleString()}`)
|
||||
* console.log(`Cache hit rate: ${(totals.cacheReadTokens / totals.totalTokens * 100).toFixed(1)}%`)
|
||||
* ```
|
||||
*/
|
||||
static computeTotalsFromDaily(daily: DailyData[]): CCData['totals'] {
|
||||
return daily.reduce<CCData['totals']>((acc, day) => ({
|
||||
inputTokens: acc.inputTokens + day.inputTokens,
|
||||
outputTokens: acc.outputTokens + day.outputTokens,
|
||||
cacheCreationTokens: acc.cacheCreationTokens + day.cacheCreationTokens,
|
||||
cacheReadTokens: acc.cacheReadTokens + day.cacheReadTokens,
|
||||
totalCost: acc.totalCost + day.totalCost,
|
||||
totalTokens: acc.totalTokens + day.totalTokens,
|
||||
}), {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalCost: 0,
|
||||
totalTokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes AI usage statistics and analytics.
|
||||
*
|
||||
* @param data - Complete CCData object with daily data and totals
|
||||
* @returns {AIStatsResult} Comprehensive usage statistics
|
||||
*
|
||||
* @remarks
|
||||
* Generates a complete analytics snapshot including:
|
||||
* - Current activity streak
|
||||
* - Overall totals (cost, tokens)
|
||||
* - Time-based averages (daily, last 7 days, last 30 days)
|
||||
* - Token type breakdown
|
||||
*
|
||||
* This is the primary method for dashboard and summary views.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const stats = AIService.getAIStats(ccData)
|
||||
*
|
||||
* // Display overview
|
||||
* console.log(`Streak: ${stats.streakFormatted}`)
|
||||
* console.log(`Total spent: $${stats.totalCost.toFixed(2)}`)
|
||||
* console.log(`Daily average: $${stats.dailyAverage.toFixed(2)}`)
|
||||
*
|
||||
* // Last 7 days analysis
|
||||
* console.log(`Last 7 days: $${stats.last7Days.cost.toFixed(2)}`)
|
||||
* console.log(`7-day daily avg: $${stats.last7Days.dailyAverage.toFixed(2)}`)
|
||||
*
|
||||
* // Token distribution
|
||||
* const { input, output, cache } = stats.tokenBreakdown
|
||||
* const total = input + output + cache
|
||||
* console.log(`Input: ${(input/total*100).toFixed(1)}%`)
|
||||
* console.log(`Output: ${(output/total*100).toFixed(1)}%`)
|
||||
* console.log(`Cache: ${(cache/total*100).toFixed(1)}%`)
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Use for dashboard cards
|
||||
* const stats = AIService.getAIStats(ccData)
|
||||
* return (
|
||||
* <Dashboard>
|
||||
* <StatCard title="Streak" value={stats.streakFormatted} />
|
||||
* <StatCard title="Total Cost" value={`$${stats.totalCost.toFixed(2)}`} />
|
||||
* <StatCard title="Last 7 Days" value={`$${stats.last7Days.cost.toFixed(2)}`} />
|
||||
* </Dashboard>
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
static getAIStats(data: CCData): AIStatsResult {
|
||||
const streak = this.computeStreak(data.daily)
|
||||
const dailyAverage = data.daily.length > 0
|
||||
? data.totals.totalCost / data.daily.length
|
||||
: 0
|
||||
|
||||
const last7Days = data.daily.slice(-7)
|
||||
const last7DaysCost = last7Days.reduce((sum, d) => sum + d.totalCost, 0)
|
||||
const last7DaysTokens = last7Days.reduce((sum, d) => sum + d.totalTokens, 0)
|
||||
|
||||
const last30Days = data.daily.slice(-30)
|
||||
const last30DaysCost = last30Days.reduce((sum, d) => sum + d.totalCost, 0)
|
||||
const last30DaysTokens = last30Days.reduce((sum, d) => sum + d.totalTokens, 0)
|
||||
|
||||
return {
|
||||
streak,
|
||||
streakFormatted: this.formatStreakCompact(streak),
|
||||
totalCost: data.totals.totalCost,
|
||||
totalTokens: data.totals.totalTokens,
|
||||
dailyAverage,
|
||||
last7Days: {
|
||||
cost: last7DaysCost,
|
||||
tokens: last7DaysTokens,
|
||||
dailyAverage: last7Days.length > 0 ? last7DaysCost / last7Days.length : 0
|
||||
},
|
||||
last30Days: {
|
||||
cost: last30DaysCost,
|
||||
tokens: last30DaysTokens,
|
||||
dailyAverage: last30Days.length > 0 ? last30DaysCost / last30Days.length : 0
|
||||
},
|
||||
tokenBreakdown: {
|
||||
input: data.totals.inputTokens,
|
||||
output: data.totals.outputTokens,
|
||||
cache: data.totals.cacheCreationTokens + data.totals.cacheReadTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
454
lib/services/device.service.ts
Normal file
454
lib/services/device.service.ts
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
import type { DeviceSpec, DeviceType, DeviceWithMetrics } from '@/lib/types'
|
||||
import type { SortOrder } from '@/lib/types/service'
|
||||
import { devices as deviceData } from '@/lib/devices/data'
|
||||
|
||||
/**
|
||||
* Statistics and aggregated metrics for the device portfolio.
|
||||
*
|
||||
* @remarks
|
||||
* Provides a comprehensive snapshot of device collection metrics including
|
||||
* type distribution, manufacturer breakdown, age statistics, and device lifecycle data.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const stats = DeviceService.getDeviceStats()
|
||||
* console.log(`Total devices: ${stats.total}`)
|
||||
* console.log(`Mobile devices: ${stats.mobile}`)
|
||||
* console.log(`Average age: ${stats.averageAge.toFixed(1)} years`)
|
||||
* ```
|
||||
*
|
||||
* @category Services
|
||||
* @public
|
||||
*/
|
||||
export interface DevicePortfolioStats {
|
||||
/** Total number of devices in the portfolio */
|
||||
total: number
|
||||
/** Number of mobile devices */
|
||||
mobile: number
|
||||
/** Number of digital audio players (DAPs) */
|
||||
dap: number
|
||||
/** Device count grouped by manufacturer name */
|
||||
byManufacturer: Record<string, number>
|
||||
/** Average age of all devices in years */
|
||||
averageAge: number
|
||||
/** Most recently released device */
|
||||
newestDevice: DeviceWithMetrics
|
||||
/** Oldest device by release year */
|
||||
oldestDevice: DeviceWithMetrics
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing device data and computing device metrics.
|
||||
*
|
||||
* @remarks
|
||||
* Provides a centralized API for device portfolio management including:
|
||||
* - Device enrichment with computed metrics (age, category labels)
|
||||
* - Filtering by type, manufacturer, status, and release year
|
||||
* - Sorting with type-safe key selection
|
||||
* - Portfolio statistics and analytics
|
||||
* - Related device discovery
|
||||
*
|
||||
* All methods are static and operate on the device data store without side effects.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { DeviceService } from '@/lib/services'
|
||||
*
|
||||
* // Get all enriched devices
|
||||
* const devices = DeviceService.getAllDevicesEnriched()
|
||||
*
|
||||
* // Filter mobile devices only
|
||||
* const mobile = DeviceService.getMobileDevices()
|
||||
*
|
||||
* // Get statistics
|
||||
* const stats = DeviceService.getDeviceStats()
|
||||
* console.log(`You have ${stats.total} devices`)
|
||||
*
|
||||
* // Find related devices
|
||||
* const device = DeviceService.getDeviceBySlug('bonito')
|
||||
* const related = DeviceService.getRelatedDevices(device!)
|
||||
* ```
|
||||
*
|
||||
* @category Services
|
||||
* @public
|
||||
*/
|
||||
export class DeviceService {
|
||||
/**
|
||||
* Computes the age of a device in years since release.
|
||||
*
|
||||
* @param device - Device specification
|
||||
* @returns {number} Years since release, or 0 if no release year
|
||||
* @internal
|
||||
*/
|
||||
private static computeAgeInYears(device: DeviceSpec): number {
|
||||
if (!device.releaseYear) return 0
|
||||
return new Date().getFullYear() - device.releaseYear
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a device was released in the current year.
|
||||
*
|
||||
* @param device - Device specification
|
||||
* @returns {boolean} True if released this year
|
||||
* @internal
|
||||
*/
|
||||
private static isCurrentYear(device: DeviceSpec): boolean {
|
||||
if (!device.releaseYear) return false
|
||||
return device.releaseYear === new Date().getFullYear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines a human-readable category label based on device type and status.
|
||||
*
|
||||
* @param device - Device specification
|
||||
* @returns {string} Label like 'Daily Driver', 'Beta Testing', 'Digital Audio Player'
|
||||
* @internal
|
||||
*/
|
||||
private static getCategoryLabel(device: DeviceSpec): string {
|
||||
if (device.type === 'mobile') {
|
||||
if (device.status?.toLowerCase().includes('daily')) return 'Daily Driver'
|
||||
if (device.status?.toLowerCase().includes('beta')) return 'Beta Testing'
|
||||
if (device.status?.toLowerCase().includes('experiment')) return 'Experimental'
|
||||
return 'Mobile Device'
|
||||
}
|
||||
if (device.type === 'dap') {
|
||||
return 'Digital Audio Player'
|
||||
}
|
||||
return 'Device'
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches a device specification with computed metrics.
|
||||
*
|
||||
* @param device - The device specification to enrich
|
||||
* @returns {DeviceWithMetrics} Device with added properties:
|
||||
* - `ageInYears: number` - Years since release
|
||||
* - `isCurrentYear: boolean` - Released in current year
|
||||
* - `categoryLabel: string` - Human-readable category ('Daily Driver', 'Digital Audio Player', etc.)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const rawDevice = { slug: 'bonito', name: 'Pixel 3a XL', releaseYear: 2019, type: 'mobile', ... }
|
||||
* const enriched = DeviceService.enrichDevice(rawDevice)
|
||||
* console.log(enriched.ageInYears) // 6 (as of 2025)
|
||||
* console.log(enriched.categoryLabel) // 'Mobile Device'
|
||||
* ```
|
||||
*/
|
||||
static enrichDevice(device: DeviceSpec): DeviceWithMetrics {
|
||||
return {
|
||||
...device,
|
||||
ageInYears: this.computeAgeInYears(device),
|
||||
isCurrentYear: this.isCurrentYear(device),
|
||||
categoryLabel: this.getCategoryLabel(device)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all devices from the data store.
|
||||
*
|
||||
* @returns {DeviceSpec[]} Array of all device specifications
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const devices = DeviceService.getAllDevices()
|
||||
* console.log(`Found ${devices.length} devices`)
|
||||
* ```
|
||||
*/
|
||||
static getAllDevices(): DeviceSpec[] {
|
||||
return Object.values(deviceData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all devices with enriched metrics.
|
||||
*
|
||||
* @returns {DeviceWithMetrics[]} Array of all devices with computed properties
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const enriched = DeviceService.getAllDevicesEnriched()
|
||||
* enriched.forEach(device => {
|
||||
* console.log(`${device.name} is ${device.ageInYears} years old`)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
static getAllDevicesEnriched(): DeviceWithMetrics[] {
|
||||
return this.getAllDevices().map(device => this.enrichDevice(device))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single device by its slug identifier.
|
||||
*
|
||||
* @param slug - The device slug (e.g., 'bonito', 'cheetah')
|
||||
* @returns {DeviceWithMetrics | null} Enriched device or null if not found
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const device = DeviceService.getDeviceBySlug('bonito')
|
||||
* if (device) {
|
||||
* console.log(`Found: ${device.name}`)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static getDeviceBySlug(slug: string): DeviceWithMetrics | null {
|
||||
const device = deviceData[slug]
|
||||
return device ? this.enrichDevice(device) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters devices based on multiple criteria.
|
||||
*
|
||||
* @param filters - Filter criteria
|
||||
* @param filters.type - Device type to filter by
|
||||
* @param filters.manufacturer - Manufacturer name to filter by
|
||||
* @param filters.status - Status string to filter by
|
||||
* @param filters.releaseYear - Release year to filter by
|
||||
* @returns {DeviceWithMetrics[]} Array of enriched devices matching all specified filters
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const mobileDevices = DeviceService.filterDevices({ type: 'mobile' })
|
||||
* const googleDevices = DeviceService.filterDevices({ manufacturer: 'Google' })
|
||||
* ```
|
||||
*/
|
||||
static filterDevices(filters: {
|
||||
type?: DeviceType
|
||||
manufacturer?: string
|
||||
status?: string
|
||||
releaseYear?: number
|
||||
}): DeviceWithMetrics[] {
|
||||
let filtered = this.getAllDevices()
|
||||
|
||||
if (filters.type) {
|
||||
filtered = filtered.filter(d => d.type === filters.type)
|
||||
}
|
||||
|
||||
if (filters.manufacturer) {
|
||||
filtered = filtered.filter(d => d.manufacturer === filters.manufacturer)
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
filtered = filtered.filter(d => d.status === filters.status)
|
||||
}
|
||||
|
||||
if (filters.releaseYear) {
|
||||
filtered = filtered.filter(d => d.releaseYear === filters.releaseYear)
|
||||
}
|
||||
|
||||
return filtered.map(d => this.enrichDevice(d))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves devices filtered by device type.
|
||||
*
|
||||
* @param type - Device type ('mobile' or 'dap')
|
||||
* @returns {DeviceWithMetrics[]} Array of enriched devices matching the type
|
||||
*
|
||||
* @remarks
|
||||
* This is a convenience wrapper around `filterDevices()` for type-based filtering.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const mobileDevices = DeviceService.getDevicesByType('mobile')
|
||||
* const daps = DeviceService.getDevicesByType('dap')
|
||||
* ```
|
||||
*/
|
||||
static getDevicesByType(type: DeviceType): DeviceWithMetrics[] {
|
||||
return this.filterDevices({ type })
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all mobile devices from the portfolio.
|
||||
*
|
||||
* @returns {DeviceWithMetrics[]} Array of enriched mobile devices
|
||||
*
|
||||
* @remarks
|
||||
* Convenience method equivalent to `getDevicesByType('mobile')`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const mobile = DeviceService.getMobileDevices()
|
||||
* console.log(`You have ${mobile.length} mobile devices`)
|
||||
* ```
|
||||
*/
|
||||
static getMobileDevices(): DeviceWithMetrics[] {
|
||||
return this.getDevicesByType('mobile')
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all digital audio players (DAPs) from the portfolio.
|
||||
*
|
||||
* @returns {DeviceWithMetrics[]} Array of enriched DAP devices
|
||||
*
|
||||
* @remarks
|
||||
* Convenience method equivalent to `getDevicesByType('dap')`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const daps = DeviceService.getDAPDevices()
|
||||
* console.log(`You have ${daps.length} DAPs`)
|
||||
* ```
|
||||
*/
|
||||
static getDAPDevices(): DeviceWithMetrics[] {
|
||||
return this.getDevicesByType('dap')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts devices by a specified property in ascending or descending order.
|
||||
*
|
||||
* @param devices - Array of devices to sort
|
||||
* @param sortBy - Property key to sort by (type-safe, must be valid DeviceWithMetrics key)
|
||||
* @param order - Sort direction ('asc' or 'desc'), defaults to 'asc'
|
||||
* @returns {DeviceWithMetrics[]} New sorted array (original array is not modified)
|
||||
*
|
||||
* @remarks
|
||||
* - Creates a shallow copy to avoid mutating the input array
|
||||
* - Handles undefined values by placing them at the end (asc) or start (desc)
|
||||
* - Works with any comparable property (strings, numbers, etc.)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const devices = DeviceService.getAllDevicesEnriched()
|
||||
*
|
||||
* // Sort by release year, newest first
|
||||
* const newest = DeviceService.sortDevices(devices, 'releaseYear', 'desc')
|
||||
*
|
||||
* // Sort by name alphabetically
|
||||
* const alphabetical = DeviceService.sortDevices(devices, 'name', 'asc')
|
||||
*
|
||||
* // Sort by age
|
||||
* const oldest = DeviceService.sortDevices(devices, 'ageInYears', 'desc')
|
||||
* ```
|
||||
*/
|
||||
static sortDevices(
|
||||
devices: DeviceWithMetrics[],
|
||||
sortBy: keyof DeviceWithMetrics,
|
||||
order: SortOrder = 'asc'
|
||||
): DeviceWithMetrics[] {
|
||||
return [...devices].sort((a, b) => {
|
||||
const aVal = a[sortBy]
|
||||
const bVal = b[sortBy]
|
||||
|
||||
if (aVal === undefined || bVal === undefined) {
|
||||
if (aVal === undefined && bVal === undefined) return 0
|
||||
if (aVal === undefined) return order === 'asc' ? 1 : -1
|
||||
return order === 'asc' ? -1 : 1
|
||||
}
|
||||
|
||||
if (aVal === bVal) return 0
|
||||
|
||||
const comparison = aVal < bVal ? -1 : 1
|
||||
return order === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds related devices based on shared type and manufacturer.
|
||||
*
|
||||
* @param device - The reference device to find related devices for
|
||||
* @returns {DeviceWithMetrics[]} Up to 3 related devices (excludes the input device)
|
||||
*
|
||||
* @remarks
|
||||
* The algorithm prioritizes devices that share:
|
||||
* 1. Same device type (mobile or DAP)
|
||||
* 2. Same manufacturer
|
||||
*
|
||||
* The method deduplicates results using a Map and returns up to 3 devices.
|
||||
* Useful for "You might also like" or related device suggestions.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const pixel3a = DeviceService.getDeviceBySlug('bonito')
|
||||
* if (pixel3a) {
|
||||
* const related = DeviceService.getRelatedDevices(pixel3a)
|
||||
* console.log(`Related devices: ${related.map(d => d.name).join(', ')}`)
|
||||
* // Example output: "Pixel 7a, Pixel 3, Pixel 4a"
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // For a Samsung DAP, finds other DAPs and Samsung devices
|
||||
* const dap = DeviceService.getDeviceBySlug('komodo')
|
||||
* const similar = DeviceService.getRelatedDevices(dap!)
|
||||
* ```
|
||||
*/
|
||||
static getRelatedDevices(device: DeviceSpec): DeviceWithMetrics[] {
|
||||
const sameType = this.filterDevices({ type: device.type })
|
||||
.filter(d => d.slug !== device.slug)
|
||||
|
||||
const sameManufacturer = device.manufacturer
|
||||
? this.filterDevices({ manufacturer: device.manufacturer })
|
||||
.filter(d => d.slug !== device.slug)
|
||||
: []
|
||||
|
||||
const combined = new Map<string, DeviceWithMetrics>()
|
||||
|
||||
sameType.forEach(d => combined.set(d.slug, d))
|
||||
sameManufacturer.forEach(d => combined.set(d.slug, d))
|
||||
|
||||
return Array.from(combined.values()).slice(0, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes comprehensive statistics for the entire device portfolio.
|
||||
*
|
||||
* @returns {DevicePortfolioStats} Aggregated portfolio metrics
|
||||
*
|
||||
* @remarks
|
||||
* Calculates portfolio-wide statistics including:
|
||||
* - Type distribution (mobile vs DAP)
|
||||
* - Manufacturer breakdown
|
||||
* - Average device age
|
||||
* - Newest and oldest devices
|
||||
*
|
||||
* All devices are enriched with metrics before calculations.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const stats = DeviceService.getDeviceStats()
|
||||
*
|
||||
* console.log(`Portfolio Overview:`)
|
||||
* console.log(`Total: ${stats.total} devices`)
|
||||
* console.log(`Mobile: ${stats.mobile}, DAP: ${stats.dap}`)
|
||||
* console.log(`Average age: ${stats.averageAge.toFixed(1)} years`)
|
||||
* console.log(`Newest: ${stats.newestDevice.name}`)
|
||||
* console.log(`Oldest: ${stats.oldestDevice.name}`)
|
||||
*
|
||||
* // Manufacturer breakdown
|
||||
* Object.entries(stats.byManufacturer).forEach(([mfr, count]) => {
|
||||
* console.log(`${mfr}: ${count} devices`)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Use for dashboard summary cards
|
||||
* const stats = DeviceService.getDeviceStats()
|
||||
* return (
|
||||
* <div>
|
||||
* <StatsCard label="Total Devices" value={stats.total} />
|
||||
* <StatsCard label="Mobile" value={stats.mobile} />
|
||||
* <StatsCard label="Average Age" value={`${stats.averageAge.toFixed(1)}y`} />
|
||||
* </div>
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
static getDeviceStats(): DevicePortfolioStats {
|
||||
const enriched = this.getAllDevicesEnriched()
|
||||
|
||||
return {
|
||||
total: enriched.length,
|
||||
mobile: enriched.filter(d => d.type === 'mobile').length,
|
||||
dap: enriched.filter(d => d.type === 'dap').length,
|
||||
byManufacturer: enriched.reduce((acc, d) => {
|
||||
if (d.manufacturer) {
|
||||
acc[d.manufacturer] = (acc[d.manufacturer] || 0) + 1
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, number>),
|
||||
averageAge: enriched.reduce((sum, d) => sum + d.ageInYears, 0) / enriched.length,
|
||||
newestDevice: this.sortDevices(enriched, 'releaseYear', 'desc')[0],
|
||||
oldestDevice: this.sortDevices(enriched, 'releaseYear', 'asc')[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
419
lib/services/domain.service.ts
Normal file
419
lib/services/domain.service.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
import type { Domain, DomainWithMetrics } from '@/lib/types'
|
||||
import { domains as domainData } from '@/lib/domains/data'
|
||||
import type { SortOrder } from '@/lib/types/service'
|
||||
|
||||
/**
|
||||
* Service for managing domain portfolio data and computing domain metrics.
|
||||
*
|
||||
* @remarks
|
||||
* This service provides comprehensive domain portfolio management:
|
||||
* - Automatic metric calculation (expiration, ownership duration)
|
||||
* - Flexible filtering and sorting
|
||||
* - Statistical analysis
|
||||
* - Renewal tracking
|
||||
*
|
||||
* All date calculations use UTC to ensure consistency across timezones.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { DomainService } from '@/lib/services'
|
||||
*
|
||||
* // Get all domains with computed metrics
|
||||
* const domains = DomainService.getAllDomainsEnriched()
|
||||
*
|
||||
* // Find expiring domains
|
||||
* const expiring = DomainService.filterDomains({ expiringSoon: true })
|
||||
*
|
||||
* // Get portfolio statistics
|
||||
* const stats = DomainService.getDomainStats()
|
||||
* console.log(`Total domains: ${stats.total}`)
|
||||
* ```
|
||||
*
|
||||
* @category Services
|
||||
* @public
|
||||
*/
|
||||
export class DomainService {
|
||||
private static computeExpirationDate(domain: Domain): Date {
|
||||
let expirationDate = new Date(domain.renewals[0].date)
|
||||
|
||||
for (const renewal of domain.renewals) {
|
||||
const renewalDate = new Date(renewal.date)
|
||||
expirationDate = new Date(renewalDate)
|
||||
expirationDate.setFullYear(expirationDate.getFullYear() + renewal.years)
|
||||
}
|
||||
|
||||
return expirationDate
|
||||
}
|
||||
|
||||
private static computeRegistrationDate(domain: Domain): Date {
|
||||
return new Date(domain.renewals[0].date)
|
||||
}
|
||||
|
||||
private static computeOwnershipDays(domain: Domain): number {
|
||||
const registrationDate = this.computeRegistrationDate(domain)
|
||||
const now = new Date()
|
||||
const diffTime = Math.abs(now.getTime() - registrationDate.getTime())
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
private static computeOwnershipYears(domain: Domain): number {
|
||||
const days = this.computeOwnershipDays(domain)
|
||||
return Math.floor(days / 365.25)
|
||||
}
|
||||
|
||||
private static computeOwnershipMonths(domain: Domain): number {
|
||||
const registrationDate = this.computeRegistrationDate(domain)
|
||||
const now = new Date()
|
||||
|
||||
let months = (now.getFullYear() - registrationDate.getFullYear()) * 12
|
||||
months += now.getMonth() - registrationDate.getMonth()
|
||||
|
||||
if (now.getDate() < registrationDate.getDate()) {
|
||||
months--
|
||||
}
|
||||
|
||||
return Math.max(0, months)
|
||||
}
|
||||
|
||||
private static computeDaysUntilExpiration(domain: Domain): number {
|
||||
const expirationDate = this.computeExpirationDate(domain)
|
||||
const now = new Date()
|
||||
const diffTime = expirationDate.getTime() - now.getTime()
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
private static computeRenewalProgressPercent(domain: Domain): number {
|
||||
const ownershipDays = this.computeOwnershipDays(domain)
|
||||
const registrationDate = this.computeRegistrationDate(domain)
|
||||
const expirationDate = this.computeExpirationDate(domain)
|
||||
const totalDays = Math.ceil((expirationDate.getTime() - registrationDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
return Math.min(100, Math.max(0, (ownershipDays / totalDays) * 100))
|
||||
}
|
||||
|
||||
private static isExpiringSoon(domain: Domain, thresholdDays: number = 90): boolean {
|
||||
return this.computeDaysUntilExpiration(domain) <= thresholdDays
|
||||
}
|
||||
|
||||
private static extractTLD(domain: Domain): string {
|
||||
const lastDot = domain.domain.lastIndexOf('.')
|
||||
return lastDot !== -1 ? domain.domain.substring(lastDot) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches a domain with computed metrics including expiration dates,
|
||||
* ownership duration, and renewal progress.
|
||||
*
|
||||
* @param domain - The domain to enrich
|
||||
* @returns {DomainWithMetrics} Domain with added properties:
|
||||
* - `expirationDate: Date` - Computed expiration date
|
||||
* - `registrationDate: Date` - Initial registration date
|
||||
* - `ownershipDays: number` - Total days owned
|
||||
* - `ownershipYears: number` - Total years owned (floor)
|
||||
* - `ownershipMonths: number` - Total months owned
|
||||
* - `daysUntilExpiration: number` - Days until domain expires
|
||||
* - `renewalProgressPercent: number` - 0-100 percent through renewal period
|
||||
* - `isExpiringSoon: boolean` - Expires within 90 days
|
||||
* - `nextRenewalDate: Date` - Date of next renewal (same as expirationDate)
|
||||
* - `tld: string` - Top-level domain (e.g., '.com', '.dev')
|
||||
*/
|
||||
static enrichDomain(domain: Domain): DomainWithMetrics {
|
||||
const expirationDate = this.computeExpirationDate(domain)
|
||||
const registrationDate = this.computeRegistrationDate(domain)
|
||||
|
||||
return {
|
||||
...domain,
|
||||
expirationDate,
|
||||
registrationDate,
|
||||
ownershipDays: this.computeOwnershipDays(domain),
|
||||
ownershipYears: this.computeOwnershipYears(domain),
|
||||
ownershipMonths: this.computeOwnershipMonths(domain),
|
||||
daysUntilExpiration: this.computeDaysUntilExpiration(domain),
|
||||
renewalProgressPercent: this.computeRenewalProgressPercent(domain),
|
||||
isExpiringSoon: this.isExpiringSoon(domain),
|
||||
nextRenewalDate: expirationDate,
|
||||
tld: this.extractTLD(domain)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all domains from the data store.
|
||||
*
|
||||
* @returns {Domain[]} Array of all domain specifications
|
||||
*/
|
||||
static getAllDomains(): Domain[] {
|
||||
return domainData
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all domains with enriched metrics.
|
||||
*
|
||||
* @returns {DomainWithMetrics[]} Array of all domains with computed properties
|
||||
*/
|
||||
static getAllDomainsEnriched(): DomainWithMetrics[] {
|
||||
return domainData.map(domain => this.enrichDomain(domain))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single domain by its full domain name.
|
||||
*
|
||||
* @param domainName - The full domain name (e.g., 'aidxn.com')
|
||||
* @returns {DomainWithMetrics | null} Enriched domain or null if not found
|
||||
*/
|
||||
static getDomainByName(domainName: string): DomainWithMetrics | null {
|
||||
const domain = domainData.find(d => d.domain === domainName)
|
||||
return domain ? this.enrichDomain(domain) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters domains based on multiple criteria.
|
||||
*
|
||||
* @param filters - Filter criteria
|
||||
* @param filters.status - Domain status to filter by ('active' | 'parked' | 'reserved')
|
||||
* @param filters.category - Domain category to filter by ('personal' | 'service' | 'project' | 'fun' | 'legacy')
|
||||
* @param filters.registrar - Registrar name to filter by
|
||||
* @param filters.expiringSoon - Filter by expiration status (within 90 days)
|
||||
* @param filters.autoRenew - Filter by auto-renewal setting
|
||||
* @returns {DomainWithMetrics[]} Array of enriched domains matching all specified filters
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const activeDomains = DomainService.filterDomains({ status: 'active' })
|
||||
* const expiringDomains = DomainService.filterDomains({ expiringSoon: true })
|
||||
* ```
|
||||
*/
|
||||
static filterDomains(
|
||||
filters: {
|
||||
status?: Domain['status']
|
||||
category?: Domain['category']
|
||||
registrar?: string
|
||||
expiringSoon?: boolean
|
||||
autoRenew?: boolean
|
||||
}
|
||||
): DomainWithMetrics[] {
|
||||
let filtered = domainData
|
||||
|
||||
if (filters.status) {
|
||||
filtered = filtered.filter(d => d.status === filters.status)
|
||||
}
|
||||
|
||||
if (filters.category) {
|
||||
filtered = filtered.filter(d => d.category === filters.category)
|
||||
}
|
||||
|
||||
if (filters.registrar) {
|
||||
filtered = filtered.filter(d => d.registrar === filters.registrar)
|
||||
}
|
||||
|
||||
if (filters.autoRenew !== undefined) {
|
||||
filtered = filtered.filter(d => d.autoRenew === filters.autoRenew)
|
||||
}
|
||||
|
||||
const enriched = filtered.map(d => this.enrichDomain(d))
|
||||
|
||||
if (filters.expiringSoon !== undefined) {
|
||||
return enriched.filter(d => d.isExpiringSoon === filters.expiringSoon)
|
||||
}
|
||||
|
||||
return enriched
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts an array of domains by a specified property.
|
||||
*
|
||||
* @param domains - Array of domains to sort
|
||||
* @param sortBy - Property key to sort by (type-safe)
|
||||
* @param order - Sort direction: 'asc' (ascending) or 'desc' (descending), default: 'asc'
|
||||
* @returns {DomainWithMetrics[]} New sorted array (does not mutate original)
|
||||
*
|
||||
* @remarks
|
||||
* Creates a shallow copy before sorting to avoid mutating the original array.
|
||||
* Handles comparison of all value types (string, number, Date, boolean).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const domains = DomainService.getAllDomainsEnriched()
|
||||
*
|
||||
* // Sort by expiration date (soonest first)
|
||||
* const byExpiration = DomainService.sortDomains(domains, 'daysUntilExpiration', 'asc')
|
||||
*
|
||||
* // Sort by domain name alphabetically
|
||||
* const byName = DomainService.sortDomains(domains, 'domain', 'asc')
|
||||
*
|
||||
* // Sort by ownership duration (longest first)
|
||||
* const byOwnership = DomainService.sortDomains(domains, 'ownershipDays', 'desc')
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
static sortDomains(
|
||||
domains: DomainWithMetrics[],
|
||||
sortBy: keyof DomainWithMetrics,
|
||||
order: SortOrder = 'asc'
|
||||
): DomainWithMetrics[] {
|
||||
return [...domains].sort((a, b) => {
|
||||
const aVal = a[sortBy]
|
||||
const bVal = b[sortBy]
|
||||
|
||||
if (aVal === bVal) return 0
|
||||
|
||||
const comparison = aVal < bVal ? -1 : 1
|
||||
return order === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups all domains by their category.
|
||||
*
|
||||
* @returns {Record<Domain['category'], DomainWithMetrics[]>} Object mapping each category to its domains
|
||||
*
|
||||
* @remarks
|
||||
* Returns an object with category keys ('personal', 'service', 'project', 'fun', 'legacy'),
|
||||
* each containing an array of enriched domains in that category.
|
||||
*
|
||||
* All categories are present in the result, even if empty.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const grouped = DomainService.groupDomainsByCategory()
|
||||
*
|
||||
* console.log(`Personal domains: ${grouped.personal.length}`)
|
||||
* console.log(`Service domains: ${grouped.service.length}`)
|
||||
*
|
||||
* // Iterate through personal domains
|
||||
* grouped.personal.forEach(domain => {
|
||||
* console.log(`${domain.domain} - ${domain.description}`)
|
||||
* })
|
||||
*
|
||||
* // Find most expensive category
|
||||
* const categoryCosts = Object.entries(grouped).map(([category, domains]) => ({
|
||||
* category,
|
||||
* totalDomains: domains.length
|
||||
* }))
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
static groupDomainsByCategory(): Record<Domain['category'], DomainWithMetrics[]> {
|
||||
const grouped: Record<Domain['category'], DomainWithMetrics[]> = {
|
||||
personal: [],
|
||||
service: [],
|
||||
project: [],
|
||||
fun: [],
|
||||
legacy: []
|
||||
}
|
||||
|
||||
const enriched = this.getAllDomainsEnriched()
|
||||
|
||||
enriched.forEach(domain => {
|
||||
grouped[domain.category].push(domain)
|
||||
})
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes statistics about my domain portfolio.
|
||||
*
|
||||
* @returns {DomainPortfolioStats} Statistics object with counts, breakdowns, and aggregates
|
||||
*
|
||||
* @remarks
|
||||
* Provides a complete statistical overview including:
|
||||
* - Total domain count
|
||||
* - Counts by status (active, parked, reserved)
|
||||
* - Domains expiring within 90 days
|
||||
* - Auto-renewal counts
|
||||
* - Breakdown by category
|
||||
* - Breakdown by registrar
|
||||
*
|
||||
* This method performs a single pass through the domain list for efficiency.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const stats = DomainService.getDomainStats()
|
||||
*
|
||||
* console.log(`Portfolio Overview:`)
|
||||
* console.log(` Total: ${stats.total}`)
|
||||
* console.log(` Active: ${stats.active}`)
|
||||
* console.log(` Expiring Soon: ${stats.expiringSoon}`)
|
||||
* console.log(` Auto-Renew Enabled: ${stats.autoRenew}`)
|
||||
*
|
||||
* // Category breakdown
|
||||
* console.log(`\nBy Category:`)
|
||||
* Object.entries(stats.byCategory).forEach(([category, count]) => {
|
||||
* console.log(` ${category}: ${count}`)
|
||||
* })
|
||||
*
|
||||
* // Registrar analysis
|
||||
* console.log(`\nBy Registrar:`)
|
||||
* Object.entries(stats.byRegistrar)
|
||||
* .sort(([,a], [,b]) => b - a)
|
||||
* .forEach(([registrar, count]) => {
|
||||
* console.log(` ${registrar}: ${count}`)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
static getDomainStats(): DomainPortfolioStats {
|
||||
const enriched = this.getAllDomainsEnriched()
|
||||
|
||||
return {
|
||||
total: enriched.length,
|
||||
active: enriched.filter(d => d.status === 'active').length,
|
||||
parked: enriched.filter(d => d.status === 'parked').length,
|
||||
reserved: enriched.filter(d => d.status === 'reserved').length,
|
||||
expiringSoon: enriched.filter(d => d.isExpiringSoon).length,
|
||||
autoRenew: enriched.filter(d => d.autoRenew).length,
|
||||
byCategory: {
|
||||
personal: enriched.filter(d => d.category === 'personal').length,
|
||||
service: enriched.filter(d => d.category === 'service').length,
|
||||
project: enriched.filter(d => d.category === 'project').length,
|
||||
fun: enriched.filter(d => d.category === 'fun').length,
|
||||
legacy: enriched.filter(d => d.category === 'legacy').length,
|
||||
},
|
||||
byRegistrar: enriched.reduce((acc, d) => {
|
||||
acc[d.registrar] = (acc[d.registrar] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain portfolio statistics result.
|
||||
*
|
||||
* @remarks
|
||||
* Breakdown of domain metrics returned by {@link DomainService.getDomainStats}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const stats: DomainPortfolioStats = DomainService.getDomainStats()
|
||||
* ```
|
||||
*
|
||||
* @category Types
|
||||
* @public
|
||||
*/
|
||||
export interface DomainPortfolioStats {
|
||||
/** Total number of domains in portfolio */
|
||||
total: number
|
||||
/** Number of active domains */
|
||||
active: number
|
||||
/** Number of parked domains */
|
||||
parked: number
|
||||
/** Number of reserved domains */
|
||||
reserved: number
|
||||
/** Number of domains expiring within 90 days */
|
||||
expiringSoon: number
|
||||
/** Number of domains with auto-renew enabled */
|
||||
autoRenew: number
|
||||
/** Domain counts by category */
|
||||
byCategory: {
|
||||
personal: number
|
||||
service: number
|
||||
project: number
|
||||
fun: number
|
||||
legacy: number
|
||||
}
|
||||
/** Domain counts by registrar */
|
||||
byRegistrar: Record<string, number>
|
||||
}
|
||||
3
lib/services/index.ts
Normal file
3
lib/services/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { DomainService } from './domain.service'
|
||||
export { DeviceService } from './device.service'
|
||||
export { AIService } from './ai.service'
|
||||
306
lib/theme/colors.ts
Normal file
306
lib/theme/colors.ts
Normal 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
310
lib/theme/effects.ts
Normal 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
253
lib/theme/index.ts
Normal 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
382
lib/theme/surfaces.ts
Normal 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
362
lib/types/ai.ts
Normal 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
83
lib/types/common.ts
Normal 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
517
lib/types/device.ts
Normal 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
380
lib/types/domain.ts
Normal 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
6
lib/types/index.ts
Normal 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
71
lib/types/navigation.ts
Normal 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
376
lib/types/service.ts
Normal 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
317
lib/utils/formatting.ts
Normal 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
3
lib/utils/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { Formatter } from './formatting'
|
||||
export { Validator } from './validation'
|
||||
export * from './styles'
|
||||
56
lib/utils/styles.ts
Normal file
56
lib/utils/styles.ts
Normal 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
302
lib/utils/validation.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue