phase 5: polish & real-time candy — ticker tape, GPU calculator, grid gauges, pulsing markers, toasts, auto-refresh, loading states, responsive nav

This commit is contained in:
Joey Eamigh 2026-02-11 05:33:23 -05:00
parent 2dddbe78cb
commit 7d20d4b484
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
27 changed files with 1194 additions and 29 deletions

View File

@ -0,0 +1,15 @@
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import { Skeleton } from '@/components/ui/skeleton.js';
export default function DemandLoading() {
return (
<div className="px-6 py-8">
<div className="mb-8">
<Skeleton className="h-9 w-52" />
<Skeleton className="mt-2 h-5 w-[32rem] max-w-full" />
</div>
<ChartSkeleton />
</div>
);
}

View File

@ -20,9 +20,9 @@ export default async function DemandPage() {
const summaryData = summaryResult.ok ? deserialize<getDemandByRegion.Result[]>(summaryResult.data) : []; const summaryData = summaryResult.ok ? deserialize<getDemandByRegion.Result[]>(summaryResult.data) : [];
return ( return (
<div className="px-6 py-8"> <div className="px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8"> <div className="mb-6 sm:mb-8">
<h1 className="text-3xl font-bold tracking-tight">Demand Analysis</h1> <h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Demand Analysis</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Regional demand growth trends, peak demand tracking, and estimated datacenter load as a percentage of regional Regional demand growth trends, peak demand tracking, and estimated datacenter load as a percentage of regional
demand. demand.

View File

@ -0,0 +1,15 @@
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import { Skeleton } from '@/components/ui/skeleton.js';
export default function GenerationLoading() {
return (
<div className="px-6 py-8">
<Skeleton className="h-9 w-48" />
<Skeleton className="mt-2 h-5 w-[30rem] max-w-full" />
<div className="mt-8">
<ChartSkeleton />
</div>
</div>
);
}

View File

@ -16,8 +16,8 @@ export default async function GenerationPage() {
if (!result.ok) { if (!result.ok) {
return ( return (
<div className="px-6 py-8"> <div className="px-4 py-6 sm:px-6 sm:py-8">
<h1 className="text-3xl font-bold tracking-tight">Generation Mix</h1> <h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Generation Mix</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons. Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons.
</p> </p>
@ -29,8 +29,8 @@ export default async function GenerationPage() {
} }
return ( return (
<div className="px-6 py-8"> <div className="px-4 py-6 sm:px-6 sm:py-8">
<h1 className="text-3xl font-bold tracking-tight">Generation Mix</h1> <h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Generation Mix</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons and carbon Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons and carbon
intensity indicators. intensity indicators.

View File

@ -121,3 +121,94 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/* Ticker tape scrolling animation */
@keyframes ticker-scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
.ticker-scroll {
animation: ticker-scroll 30s linear infinite;
}
.ticker-scroll-container:hover .ticker-scroll {
animation-play-state: paused;
}
/* Ambient glow breathing animations */
@keyframes ambient-breathe-slow {
0%,
100% {
opacity: 0.7;
}
50% {
opacity: 1;
}
}
@keyframes ambient-breathe-medium {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
@keyframes ambient-breathe-fast {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
@keyframes ambient-pulse {
0%,
100% {
opacity: 0.4;
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}
50% {
opacity: 1;
box-shadow:
0 0 30px rgba(239, 68, 68, 0.5),
0 0 60px rgba(239, 68, 68, 0.2);
}
}
.ambient-glow-slow {
animation: ambient-breathe-slow 4s ease-in-out infinite;
}
.ambient-glow-medium {
animation: ambient-breathe-medium 2.5s ease-in-out infinite;
}
.ambient-glow-fast {
animation: ambient-breathe-fast 1.5s ease-in-out infinite;
}
.ambient-glow-pulse {
animation: ambient-pulse 1s ease-in-out infinite;
}
/* Datacenter marker pulsing animation */
@keyframes marker-pulse {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}

48
src/app/loading.tsx Normal file
View File

@ -0,0 +1,48 @@
import { MetricCardSkeleton } from '@/components/dashboard/metric-card-skeleton.js';
import { Card, CardContent, CardHeader } from '@/components/ui/card.js';
import { Skeleton } from '@/components/ui/skeleton.js';
export default function DashboardLoading() {
return (
<div className="px-6 py-8">
<div className="mb-8">
<Skeleton className="h-9 w-48" />
<Skeleton className="mt-2 h-5 w-96" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<MetricCardSkeleton key={i} />
))}
</div>
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Card className="group">
<CardHeader>
<Skeleton className="h-5 w-36" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-72" />
<Skeleton className="mt-3 h-4 w-24" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

9
src/app/map/loading.tsx Normal file
View File

@ -0,0 +1,9 @@
import { Skeleton } from '@/components/ui/skeleton.js';
export default function MapLoading() {
return (
<div className="h-[calc(100vh-3.5rem-3rem)]">
<Skeleton className="h-full w-full rounded-none" />
</div>
);
}

View File

@ -1,7 +1,9 @@
import { GpuCalculator } from '@/components/dashboard/gpu-calculator.js';
import { GridStressGauge } from '@/components/dashboard/grid-stress-gauge.js';
import { MetricCard } from '@/components/dashboard/metric-card.js'; import { MetricCard } from '@/components/dashboard/metric-card.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { deserialize } from '@/lib/superjson.js'; import { deserialize } from '@/lib/superjson.js';
import { Activity, ArrowRight, BarChart3, Droplets, Flame, Map, Server } from 'lucide-react'; import { Activity, ArrowRight, BarChart3, Droplets, Flame, Gauge, Map, Server } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { fetchDatacenters } from '@/actions/datacenters.js'; import { fetchDatacenters } from '@/actions/datacenters.js';
@ -43,7 +45,14 @@ export default async function DashboardHome() {
: []; : [];
const demandRows = demandResult.ok const demandRows = demandResult.ok
? deserialize<Array<{ avg_demand: number | null; region_code: string }>>(demandResult.data) ? deserialize<
Array<{
avg_demand: number | null;
peak_demand: number | null;
region_code: string;
region_name: string;
}>
>(demandResult.data)
: []; : [];
const avgPrice = prices.length > 0 ? prices.reduce((sum, p) => sum + p.price_mwh, 0) / prices.length : 0; const avgPrice = prices.length > 0 ? prices.reduce((sum, p) => sum + p.price_mwh, 0) / prices.length : 0;
@ -58,10 +67,44 @@ export default async function DashboardHome() {
const avgDemand = const avgDemand =
demandRows.length > 0 ? demandRows.reduce((sum, r) => sum + (r.avg_demand ?? 0), 0) / demandRows.length : 0; demandRows.length > 0 ? demandRows.reduce((sum, r) => sum + (r.avg_demand ?? 0), 0) / demandRows.length : 0;
const regionPrices = prices.map(p => ({
regionCode: p.region_code,
regionName: p.region_name,
priceMwh: p.price_mwh,
}));
// Aggregate demand by region for stress gauges: track latest avg_demand and peak_demand
interface RegionDemandEntry {
regionCode: string;
regionName: string;
avgDemand: number;
peakDemand: number;
}
const regionDemandMap: Record<string, RegionDemandEntry> = {};
for (const row of demandRows) {
const existing = regionDemandMap[row.region_code];
const avg = row.avg_demand ?? 0;
const peak = row.peak_demand ?? 0;
if (!existing) {
regionDemandMap[row.region_code] = {
regionCode: row.region_code,
regionName: row.region_name,
avgDemand: avg,
peakDemand: peak,
};
} else {
if (avg > existing.avgDemand) existing.avgDemand = avg;
if (peak > existing.peakDemand) existing.peakDemand = peak;
}
}
const regionDemandList = Object.values(regionDemandMap).filter(
(r): r is RegionDemandEntry => r !== undefined && r.peakDemand > 0,
);
return ( return (
<div className="px-6 py-8"> <div className="px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8"> <div className="mb-6 sm:mb-8">
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1> <h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Dashboard</h1>
<p className="mt-1 text-muted-foreground"> <p className="mt-1 text-muted-foreground">
Real-time overview of AI datacenter energy impact across US grid regions. Real-time overview of AI datacenter energy impact across US grid regions.
</p> </p>
@ -137,6 +180,38 @@ export default async function DashboardHome() {
</Card> </Card>
</div> </div>
{regionPrices.length > 0 && (
<div className="mt-8">
<GpuCalculator regionPrices={regionPrices} />
</div>
)}
{regionDemandList.length > 0 && (
<div className="mt-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gauge className="h-5 w-5 text-chart-4" />
Grid Stress by Region
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7">
{regionDemandList.map(r => (
<GridStressGauge
key={r.regionCode}
regionCode={r.regionCode}
regionName={r.regionName}
demandMw={r.avgDemand}
capacityMw={r.peakDemand}
/>
))}
</div>
</CardContent>
</Card>
</div>
)}
{avgDemand > 0 && ( {avgDemand > 0 && (
<div className="mt-4"> <div className="mt-4">
<Card> <Card>

View File

@ -0,0 +1,16 @@
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import { Skeleton } from '@/components/ui/skeleton.js';
export default function TrendsLoading() {
return (
<div className="space-y-6 px-6 py-8">
<div>
<Skeleton className="h-9 w-40" />
<Skeleton className="mt-2 h-5 w-96" />
</div>
<ChartSkeleton />
<ChartSkeleton />
</div>
);
}

View File

@ -42,9 +42,9 @@ export default async function TrendsPage() {
]); ]);
return ( return (
<div className="space-y-6 px-6 py-8"> <div className="space-y-6 px-4 py-6 sm:px-6 sm:py-8">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Price Trends</h1> <h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Price Trends</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Regional electricity price charts with commodity overlays and AI milestone annotations. Regional electricity price charts with commodity overlays and AI milestone annotations.
</p> </p>

View File

@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card.js';
import { Skeleton } from '@/components/ui/skeleton.js';
import { cn } from '@/lib/utils.js';
interface ChartSkeletonProps {
className?: string;
title?: boolean;
}
export function ChartSkeleton({ className, title = true }: ChartSkeletonProps) {
return (
<Card className={cn(className)}>
{title && (
<CardHeader>
<Skeleton className="h-5 w-40" />
<Skeleton className="mt-1 h-4 w-64" />
</CardHeader>
)}
<CardContent>
<Skeleton className="h-80 w-full rounded-lg" />
</CardContent>
</Card>
);
}

View File

@ -239,10 +239,10 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
))} ))}
</div> </div>
<div className="flex items-center gap-2 rounded-lg border border-border bg-card p-1"> <div className="flex items-center gap-2 overflow-x-auto rounded-lg border border-border bg-card p-1">
<button <button
onClick={() => handleRegionChange('ALL')} onClick={() => handleRegionChange('ALL')}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${ className={`shrink-0 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
selectedRegion === 'ALL' selectedRegion === 'ALL'
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
@ -253,7 +253,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
<button <button
key={region.code} key={region.code}
onClick={() => handleRegionChange(region.code)} onClick={() => handleRegionChange(region.code)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${ className={`shrink-0 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
selectedRegion === region.code selectedRegion === region.code
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'

View File

@ -0,0 +1,48 @@
'use client';
import { cn } from '@/lib/utils.js';
import type { ReactNode } from 'react';
type StressLevel = 'low' | 'moderate' | 'high' | 'critical';
interface AmbientGlowProps {
stressLevel: StressLevel;
children: ReactNode;
className?: string;
}
const GLOW_COLORS: Record<StressLevel, string> = {
low: '34, 197, 94', // green
moderate: '234, 179, 8', // yellow
high: '249, 115, 22', // orange
critical: '239, 68, 68', // red
};
const GLOW_ANIMATION: Record<StressLevel, string> = {
low: 'ambient-glow-slow',
moderate: 'ambient-glow-medium',
high: 'ambient-glow-fast',
critical: 'ambient-glow-pulse',
};
export function getStressLevel(utilizationPercent: number): StressLevel {
if (utilizationPercent >= 90) return 'critical';
if (utilizationPercent >= 80) return 'high';
if (utilizationPercent >= 65) return 'moderate';
return 'low';
}
export function AmbientGlow({ stressLevel, children, className }: AmbientGlowProps) {
const rgb = GLOW_COLORS[stressLevel];
const animationClass = GLOW_ANIMATION[stressLevel];
return (
<div
className={cn('rounded-lg', animationClass, className)}
style={{
boxShadow: `0 0 20px rgba(${rgb}, 0.3), 0 0 40px rgba(${rgb}, 0.1)`,
}}>
{children}
</div>
);
}

View File

@ -0,0 +1,26 @@
'use client';
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
import { useEffect } from 'react';
interface AnimatedNumberProps {
value: number;
format?: (n: number) => string;
className?: string;
}
export function AnimatedNumber({ value, format, className }: AnimatedNumberProps) {
const motionValue = useMotionValue(0);
const springValue = useSpring(motionValue, {
stiffness: 100,
damping: 30,
restDelta: 0.01,
});
const display = useTransform(springValue, current => (format ? format(current) : current.toFixed(2)));
useEffect(() => {
motionValue.set(value);
}, [motionValue, value]);
return <motion.span className={className}>{display}</motion.span>;
}

View File

@ -0,0 +1,61 @@
'use client';
import { cn } from '@/lib/utils.js';
import { RefreshCw } from 'lucide-react';
import { useRouter } from 'next/navigation.js';
import { useCallback, useEffect, useRef, useState } from 'react';
const REFRESH_INTERVAL_MS = 60_000;
export function AutoRefresh() {
const router = useRouter();
const [remainingMs, setRemainingMs] = useState(REFRESH_INTERVAL_MS);
const startTimeRef = useRef<number>(0);
const rafRef = useRef<number>(0);
const resetTimer = useCallback(() => {
startTimeRef.current = performance.now();
setRemainingMs(REFRESH_INTERVAL_MS);
}, []);
useEffect(() => {
startTimeRef.current = performance.now();
function tick() {
const elapsed = performance.now() - startTimeRef.current;
const remaining = Math.max(0, REFRESH_INTERVAL_MS - elapsed);
setRemainingMs(remaining);
if (remaining <= 0) {
router.refresh();
resetTimer();
}
rafRef.current = requestAnimationFrame(tick);
}
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, [router, resetTimer]);
const remainingSeconds = Math.ceil(remainingMs / 1000);
const progress = 1 - remainingMs / REFRESH_INTERVAL_MS;
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCw className={cn('h-3 w-3', remainingSeconds <= 5 && 'animate-spin text-chart-1')} />
<span className="tabular-nums">{remainingSeconds}s</span>
<div className="h-1 w-16 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-chart-1 transition-[width] duration-1000 ease-linear"
style={{ width: `${progress * 100}%` }}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,197 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
import { Slider } from '@/components/ui/slider.js';
import { cn } from '@/lib/utils.js';
import { Cpu } from 'lucide-react';
import { useMemo, useState } from 'react';
import { AnimatedNumber } from './animated-number.js';
const GPU_MODELS = {
'H100 SXM': { watts: 700, label: 'NVIDIA H100 SXM' },
'H100 PCIe': { watts: 350, label: 'NVIDIA H100 PCIe' },
H200: { watts: 700, label: 'NVIDIA H200' },
B200: { watts: 1000, label: 'NVIDIA B200' },
'A100 SXM': { watts: 400, label: 'NVIDIA A100 SXM' },
'A100 PCIe': { watts: 300, label: 'NVIDIA A100 PCIe' },
} as const;
type GpuModelKey = keyof typeof GPU_MODELS;
const GPU_MODEL_KEYS: GpuModelKey[] = ['H100 SXM', 'H100 PCIe', 'H200', 'B200', 'A100 SXM', 'A100 PCIe'];
function isGpuModelKey(value: string): value is GpuModelKey {
return value in GPU_MODELS;
}
interface RegionPrice {
regionCode: string;
regionName: string;
priceMwh: number;
}
interface GpuCalculatorProps {
regionPrices: RegionPrice[];
className?: string;
}
function formatCurrency(n: number): string {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `$${n.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
return `$${n.toFixed(2)}`;
}
function formatCurrencyAnimated(n: number): string {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `$${Math.round(n).toLocaleString()}`;
return `$${n.toFixed(2)}`;
}
export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
const [gpuModel, setGpuModel] = useState<GpuModelKey>('H100 SXM');
const [gpuCount, setGpuCount] = useState(1000);
const [selectedRegion, setSelectedRegion] = useState<string>(regionPrices[0]?.regionCode ?? '');
const selectedPrice = regionPrices.find(r => r.regionCode === selectedRegion);
const priceMwh = selectedPrice?.priceMwh ?? 0;
const costs = useMemo(() => {
const wattsPerGpu = GPU_MODELS[gpuModel].watts;
const totalMw = (wattsPerGpu * gpuCount) / 1_000_000;
const hourlyCost = totalMw * priceMwh;
const dailyCost = hourlyCost * 24;
const monthlyCost = dailyCost * 30;
return { hourlyCost, dailyCost, monthlyCost, totalMw };
}, [gpuModel, gpuCount, priceMwh]);
return (
<Card className={cn('gap-0 py-4', className)}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Cpu className="h-4 w-4" />
GPU Power Cost Calculator
</CardTitle>
<CardDescription>Estimate real-time electricity cost for GPU clusters</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">GPU Model</label>
<Select
value={gpuModel}
onValueChange={v => {
if (isGpuModelKey(v)) setGpuModel(v);
}}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GPU_MODEL_KEYS.map(key => (
<SelectItem key={key} value={key}>
{key} ({GPU_MODELS[key].watts}W)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">Region</label>
<Select value={selectedRegion} onValueChange={setSelectedRegion}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{regionPrices.map(r => (
<SelectItem key={r.regionCode} value={r.regionCode}>
{r.regionName} (${r.priceMwh.toFixed(2)}/MWh)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">GPU Count</label>
<span className="font-mono text-sm font-semibold">{gpuCount.toLocaleString()}</span>
</div>
<Slider
value={[gpuCount]}
onValueChange={([v]) => {
if (v !== undefined) setGpuCount(v);
}}
min={1}
max={10000}
step={1}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1</span>
<span>10,000</span>
</div>
</div>
<div className="rounded-lg border border-border/50 bg-muted/30 p-4">
<div className="mb-3 flex items-baseline justify-between">
<span className="text-xs text-muted-foreground">Total Power Draw</span>
<span className="font-mono text-sm font-semibold">{costs.totalMw.toFixed(2)} MW</span>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Hourly</div>
<AnimatedNumber
value={costs.hourlyCost}
format={formatCurrencyAnimated}
className="text-lg font-bold tracking-tight text-chart-1"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Daily</div>
<AnimatedNumber
value={costs.dailyCost}
format={formatCurrencyAnimated}
className="text-lg font-bold tracking-tight text-chart-2"
/>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Monthly</div>
<AnimatedNumber
value={costs.monthlyCost}
format={formatCurrencyAnimated}
className="text-lg font-bold tracking-tight text-chart-3"
/>
</div>
</div>
</div>
{regionPrices.length >= 2 && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">Compare: </span>
Running {gpuCount.toLocaleString()} {gpuModel} GPUs costs{' '}
<span className="font-semibold text-foreground">{formatCurrency(costs.hourlyCost)}/hr</span> in{' '}
{selectedPrice?.regionName ?? selectedRegion}
{regionPrices
.filter(r => r.regionCode !== selectedRegion)
.slice(0, 1)
.map(other => {
const otherWatts = GPU_MODELS[gpuModel].watts;
const otherMw = (otherWatts * gpuCount) / 1_000_000;
const otherHourly = otherMw * other.priceMwh;
return (
<span key={other.regionCode}>
{' '}
vs <span className="font-semibold text-foreground">{formatCurrency(otherHourly)}/hr</span> in{' '}
{other.regionName}
</span>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,87 @@
'use client';
import { cn } from '@/lib/utils.js';
interface GridStressGaugeProps {
regionCode: string;
regionName: string;
demandMw: number;
capacityMw: number;
className?: string;
}
function getStressColor(pct: number): string {
if (pct >= 90) return '#ef4444';
if (pct >= 80) return '#f97316';
if (pct >= 60) return '#eab308';
return '#22c55e';
}
function getStressLabel(pct: number): string {
if (pct >= 90) return 'Critical';
if (pct >= 80) return 'High';
if (pct >= 60) return 'Moderate';
return 'Normal';
}
export function GridStressGauge({ regionCode, regionName, demandMw, capacityMw, className }: GridStressGaugeProps) {
const pct = capacityMw > 0 ? Math.min((demandMw / capacityMw) * 100, 100) : 0;
const color = getStressColor(pct);
const label = getStressLabel(pct);
const radius = 40;
const circumference = Math.PI * radius;
const offset = circumference - (pct / 100) * circumference;
const isCritical = pct >= 85;
return (
<div className={cn('flex flex-col items-center gap-2', className)}>
<svg
viewBox="0 0 100 55"
className="w-full max-w-[140px]"
style={{
filter: isCritical ? `drop-shadow(0 0 8px ${color}80)` : undefined,
}}>
{/* Background arc */}
<path
d="M 10 50 A 40 40 0 0 1 90 50"
fill="none"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
className="text-muted/40"
/>
{/* Filled arc */}
<path
d="M 10 50 A 40 40 0 0 1 90 50"
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{
transition: 'stroke-dashoffset 1s ease-in-out, stroke 0.5s ease',
}}
/>
{/* Percentage text */}
<text
x="50"
y="45"
textAnchor="middle"
className="fill-foreground text-[14px] font-bold"
style={{ fontFamily: 'ui-monospace, monospace' }}>
{pct.toFixed(0)}%
</text>
</svg>
<div className="text-center">
<div className="text-xs font-semibold">{regionCode}</div>
<div className="text-[10px] text-muted-foreground">{regionName}</div>
<div className="mt-0.5 text-[10px] font-medium" style={{ color }}>
{label}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card.js';
import { Skeleton } from '@/components/ui/skeleton.js';
export function MetricCardSkeleton() {
return (
<Card className="gap-0 py-4">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-24" />
</div>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-1.5">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-4 w-10" />
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,54 @@
'use client';
import { fetchLatestPrices } from '@/actions/prices.js';
import type { getLatestPrices } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
const PRICE_SPIKE_THRESHOLD_MWH = 100;
const CHECK_INTERVAL_MS = 60_000;
export function PriceAlertMonitor() {
const previousPricesRef = useRef<Map<string, number>>(new Map());
const initialLoadRef = useRef(true);
useEffect(() => {
async function checkForSpikes() {
const result = await fetchLatestPrices();
if (!result.ok) return;
const prices = deserialize<getLatestPrices.Result[]>(result.data);
const prevPrices = previousPricesRef.current;
for (const p of prices) {
const prevPrice = prevPrices.get(p.region_code);
if (!initialLoadRef.current) {
if (p.price_mwh >= PRICE_SPIKE_THRESHOLD_MWH) {
toast.error(`Price Spike: ${p.region_code}`, {
description: `${p.region_name} hit $${p.price_mwh.toFixed(2)}/MWh — above $${PRICE_SPIKE_THRESHOLD_MWH} threshold`,
duration: 8000,
});
} else if (prevPrice !== undefined && p.price_mwh > prevPrice * 1.15) {
toast.warning(`Price Jump: ${p.region_code}`, {
description: `${p.region_name} jumped to $${p.price_mwh.toFixed(2)}/MWh (+${(((p.price_mwh - prevPrice) / prevPrice) * 100).toFixed(1)}%)`,
duration: 6000,
});
}
}
prevPrices.set(p.region_code, p.price_mwh);
}
initialLoadRef.current = false;
}
void checkForSpikes();
const interval = setInterval(() => void checkForSpikes(), CHECK_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
return null;
}

View File

@ -0,0 +1,122 @@
'use client';
import { fetchLatestCommodityPrices, fetchLatestPrices } from '@/actions/prices.js';
import type { getLatestPrices } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js';
import { useEffect, useState } from 'react';
interface TickerItem {
label: string;
price: string;
change: number | null;
unit: string;
}
function formatPrice(price: number, unit: string): string {
if (unit === '$/MWh') {
return `$${price.toFixed(2)}`;
}
return `$${price.toFixed(2)}`;
}
function TickerItemDisplay({ item }: { item: TickerItem }) {
const changeColor =
item.change === null ? 'text-muted-foreground' : item.change >= 0 ? 'text-emerald-400' : 'text-red-400';
const changeSymbol = item.change === null ? '' : item.change >= 0 ? '\u25B2' : '\u25BC';
return (
<span className="inline-flex items-center gap-1.5 px-4 text-sm whitespace-nowrap">
<span className="font-medium text-muted-foreground">{item.label}</span>
<span className="font-bold tabular-nums">{item.price}</span>
<span className="text-xs text-muted-foreground">{item.unit}</span>
{item.change !== null && (
<span className={cn('text-xs font-medium tabular-nums', changeColor)}>
{changeSymbol} {Math.abs(item.change).toFixed(1)}%
</span>
)}
<span className="ml-3 text-border">|</span>
</span>
);
}
export function TickerTape() {
const [items, setItems] = useState<TickerItem[]>([]);
useEffect(() => {
async function loadPrices() {
const [priceResult, commodityResult] = await Promise.all([fetchLatestPrices(), fetchLatestCommodityPrices()]);
const tickerItems: TickerItem[] = [];
if (priceResult.ok) {
const prices = deserialize<getLatestPrices.Result[]>(priceResult.data);
for (const p of prices) {
tickerItems.push({
label: p.region_code,
price: formatPrice(p.price_mwh, '$/MWh'),
change: null,
unit: '$/MWh',
});
}
}
if (commodityResult.ok) {
const commodities = deserialize<
Array<{
commodity: string;
price: number;
unit: string;
timestamp: Date;
source: string;
}>
>(commodityResult.data);
const commodityLabels: Record<string, string> = {
natural_gas: 'Nat Gas',
wti_crude: 'WTI Crude',
coal: 'Coal',
};
for (const c of commodities) {
tickerItems.push({
label: commodityLabels[c.commodity] ?? c.commodity,
price: formatPrice(c.price, c.unit),
change: null,
unit: c.unit,
});
}
}
setItems(tickerItems);
}
void loadPrices();
const interval = setInterval(() => void loadPrices(), 60_000);
return () => clearInterval(interval);
}, []);
if (items.length === 0) {
return null;
}
return (
<div className="overflow-hidden border-b border-border/40 bg-muted/30">
<div className="ticker-scroll-container flex py-1.5">
{/* Duplicate items for seamless looping */}
<div className="ticker-scroll flex shrink-0">
{items.map((item, i) => (
<TickerItemDisplay key={`a-${i}`} item={item} />
))}
</div>
<div className="ticker-scroll flex shrink-0" aria-hidden>
{items.map((item, i) => (
<TickerItemDisplay key={`b-${i}`} item={item} />
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,65 @@
'use client';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from '@/components/ui/button.js';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card.js';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('ErrorBoundary caught an error:', error, info.componentStack);
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex items-center justify-center p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle className="text-destructive">Something went wrong</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{this.state.error?.message ?? 'An unexpected error occurred while rendering this section.'}
</p>
</CardContent>
<CardFooter>
<Button variant="outline" onClick={this.handleRetry}>
Try again
</Button>
</CardFooter>
</Card>
</div>
);
}
return this.props.children;
}
}

View File

@ -1,9 +1,14 @@
'use client'; 'use client';
import { AutoRefresh } from '@/components/dashboard/auto-refresh.js';
import { PriceAlertMonitor } from '@/components/dashboard/price-alert.js';
import { TickerTape } from '@/components/dashboard/ticker-tape.js';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet.js';
import { cn } from '@/lib/utils.js'; import { cn } from '@/lib/utils.js';
import { Activity, BarChart3, Flame, LayoutDashboard, Map, TrendingUp } from 'lucide-react'; import { Activity, BarChart3, Flame, LayoutDashboard, Map, Menu, TrendingUp } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation.js'; import { usePathname } from 'next/navigation.js';
import { useState } from 'react';
const NAV_LINKS = [ const NAV_LINKS = [
{ href: '/', label: 'Dashboard', icon: LayoutDashboard }, { href: '/', label: 'Dashboard', icon: LayoutDashboard },
@ -15,19 +20,20 @@ const NAV_LINKS = [
export function Nav() { export function Nav() {
const pathname = usePathname(); const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
return ( return (
<header className="sticky top-0 z-50 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
{/* Ticker tape slot — Phase 5 */} <TickerTape />
<div id="ticker-tape-slot" />
<div className="flex h-14 items-center px-6"> <div className="flex h-14 items-center px-4 sm:px-6">
<Link href="/" className="mr-8 flex items-center gap-2"> <Link href="/" className="mr-4 flex items-center gap-2 sm:mr-8">
<BarChart3 className="h-5 w-5 text-chart-1" /> <BarChart3 className="h-5 w-5 text-chart-1" />
<span className="text-lg font-semibold tracking-tight">Energy & AI Dashboard</span> <span className="text-lg font-semibold tracking-tight">Energy & AI Dashboard</span>
</Link> </Link>
<nav className="flex items-center gap-1"> {/* Desktop navigation */}
<nav className="hidden items-center gap-1 md:flex">
{NAV_LINKS.map(({ href, label, icon: Icon }) => { {NAV_LINKS.map(({ href, label, icon: Icon }) => {
const isActive = href === '/' ? pathname === '/' : pathname.startsWith(href); const isActive = href === '/' ? pathname === '/' : pathname.startsWith(href);
@ -48,9 +54,54 @@ export function Nav() {
})} })}
</nav> </nav>
{/* Auto-refresh countdown slot — Phase 5 */} <div className="ml-auto">
<div id="auto-refresh-slot" className="ml-auto" /> <AutoRefresh />
</div>
{/* Mobile hamburger */}
<button
type="button"
className="ml-2 inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground md:hidden"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation menu">
<Menu className="h-5 w-5" />
</button>
{/* Mobile sheet */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="w-64">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-chart-1" />
Navigation
</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 px-4">
{NAV_LINKS.map(({ href, label, icon: Icon }) => {
const isActive = href === '/' ? pathname === '/' : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground',
)}>
<Icon className="h-4 w-4" />
{label}
</Link>
);
})}
</nav>
</SheetContent>
</Sheet>
</div> </div>
<PriceAlertMonitor />
</header> </header>
); );
} }

View File

@ -30,6 +30,13 @@ function getMarkerSize(capacityMw: number): number {
return 14; return 14;
} }
function getPulseDuration(capacityMw: number): number {
if (capacityMw >= 500) return 1.2;
if (capacityMw >= 200) return 1.8;
if (capacityMw >= 100) return 2.4;
return 3.0;
}
export interface DatacenterMarkerData { export interface DatacenterMarkerData {
id: string; id: string;
name: string; name: string;
@ -47,12 +54,14 @@ export interface DatacenterMarkerData {
interface DatacenterMarkerProps { interface DatacenterMarkerProps {
datacenter: DatacenterMarkerData; datacenter: DatacenterMarkerData;
onClick: (datacenter: DatacenterMarkerData) => void; onClick: (datacenter: DatacenterMarkerData) => void;
isPulsing?: boolean;
} }
export function DatacenterMarker({ datacenter, onClick }: DatacenterMarkerProps) { export function DatacenterMarker({ datacenter, onClick, isPulsing = false }: DatacenterMarkerProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const size = getMarkerSize(datacenter.capacity_mw); const size = getMarkerSize(datacenter.capacity_mw);
const color = getOperatorColor(datacenter.operator); const color = getOperatorColor(datacenter.operator);
const pulseDuration = getPulseDuration(datacenter.capacity_mw);
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onClick(datacenter); onClick(datacenter);
@ -68,6 +77,17 @@ export function DatacenterMarker({ datacenter, onClick }: DatacenterMarkerProps)
style={{ transform: hovered ? 'scale(1.3)' : 'scale(1)' }} style={{ transform: hovered ? 'scale(1.3)' : 'scale(1)' }}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}> onMouseLeave={() => setHovered(false)}>
{isPulsing && (
<div
className="absolute rounded-full"
style={{
width: size,
height: size,
backgroundColor: color,
animation: `marker-pulse ${pulseDuration}s ease-out infinite`,
}}
/>
)}
<div <div
className="rounded-full border-2 border-white/80 shadow-lg" className="rounded-full border-2 border-white/80 shadow-lg"
style={{ style={{

View File

@ -58,9 +58,18 @@ export function EnergyMap({ datacenters, regions }: EnergyMapProps) {
className="h-full w-full"> className="h-full w-full">
<RegionOverlay regions={regions} onRegionClick={handleRegionClick} /> <RegionOverlay regions={regions} onRegionClick={handleRegionClick} />
{filteredDatacenters.map(dc => ( {filteredDatacenters.map(dc => {
<DatacenterMarker key={dc.id} datacenter={dc} onClick={handleDatacenterClick} /> const dcRegion = regions.find(r => r.code === dc.region_code);
))} const isPulsing =
dcRegion !== undefined &&
dcRegion.avgPrice !== null &&
dcRegion.maxPrice !== null &&
dcRegion.avgPrice > 0 &&
dcRegion.maxPrice > dcRegion.avgPrice * 1.1;
return (
<DatacenterMarker key={dc.id} datacenter={dc} onClick={handleDatacenterClick} isPulsing={isPulsing} />
);
})}
</Map> </Map>
<DatacenterDetailPanel datacenter={selectedDatacenter} onClose={() => setSelectedDatacenter(null)} /> <DatacenterDetailPanel datacenter={selectedDatacenter} onClose={() => setSelectedDatacenter(null)} />

View File

@ -0,0 +1,50 @@
import { Slot } from 'radix-ui';
import * as React from 'react';
import { cn } from '@/lib/utils.js';
type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
const variantStyles: Record<ButtonVariant, string> = {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
};
const sizeStyles: Record<ButtonSize, string> = {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
};
interface ButtonProps extends React.ComponentProps<'button'> {
variant?: ButtonVariant;
size?: ButtonSize;
asChild?: boolean;
}
function Button({ className, variant = 'default', size = 'default', asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
data-slot="button"
className={cn(
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variantStyles[variant],
sizeStyles[size],
className,
)}
{...props}
/>
);
}
export { Button };
export type { ButtonProps };

View File

@ -0,0 +1,7 @@
import { cn } from '@/lib/utils.js';
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="skeleton" className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
}
export { Skeleton };

View File

@ -0,0 +1,54 @@
'use client';
import { Slider as SliderPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '@/lib/utils.js';
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className,
)}
{...props}>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
'relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
)}>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn('absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full')}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };