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) : [];
return (
<div className="px-6 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Demand Analysis</h1>
<div className="px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 sm:mb-8">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Demand Analysis</h1>
<p className="mt-2 text-muted-foreground">
Regional demand growth trends, peak demand tracking, and estimated datacenter load as a percentage of regional
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) {
return (
<div className="px-6 py-8">
<h1 className="text-3xl font-bold tracking-tight">Generation Mix</h1>
<div className="px-4 py-6 sm:px-6 sm:py-8">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Generation Mix</h1>
<p className="mt-2 text-muted-foreground">
Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons.
</p>
@ -29,8 +29,8 @@ export default async function GenerationPage() {
}
return (
<div className="px-6 py-8">
<h1 className="text-3xl font-bold tracking-tight">Generation Mix</h1>
<div className="px-4 py-6 sm:px-6 sm:py-8">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Generation Mix</h1>
<p className="mt-2 text-muted-foreground">
Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons and carbon
intensity indicators.

View File

@ -121,3 +121,94 @@
@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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.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 { fetchDatacenters } from '@/actions/datacenters.js';
@ -43,7 +45,14 @@ export default async function DashboardHome() {
: [];
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;
@ -58,10 +67,44 @@ export default async function DashboardHome() {
const avgDemand =
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 (
<div className="px-6 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<div className="px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 sm:mb-8">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Dashboard</h1>
<p className="mt-1 text-muted-foreground">
Real-time overview of AI datacenter energy impact across US grid regions.
</p>
@ -137,6 +180,38 @@ export default async function DashboardHome() {
</Card>
</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 && (
<div className="mt-4">
<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 (
<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>
<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">
Regional electricity price charts with commodity overlays and AI milestone annotations.
</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 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
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'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
@ -253,7 +253,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
<button
key={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
? 'bg-primary text-primary-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';
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 { 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 { usePathname } from 'next/navigation.js';
import { useState } from 'react';
const NAV_LINKS = [
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
@ -15,19 +20,20 @@ const NAV_LINKS = [
export function Nav() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<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 */}
<div id="ticker-tape-slot" />
<TickerTape />
<div className="flex h-14 items-center px-6">
<Link href="/" className="mr-8 flex items-center gap-2">
<div className="flex h-14 items-center px-4 sm:px-6">
<Link href="/" className="mr-4 flex items-center gap-2 sm:mr-8">
<BarChart3 className="h-5 w-5 text-chart-1" />
<span className="text-lg font-semibold tracking-tight">Energy & AI Dashboard</span>
</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 }) => {
const isActive = href === '/' ? pathname === '/' : pathname.startsWith(href);
@ -48,9 +54,54 @@ export function Nav() {
})}
</nav>
{/* Auto-refresh countdown slot — Phase 5 */}
<div id="auto-refresh-slot" className="ml-auto" />
<div 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>
<PriceAlertMonitor />
</header>
);
}

View File

@ -30,6 +30,13 @@ function getMarkerSize(capacityMw: number): number {
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 {
id: string;
name: string;
@ -47,12 +54,14 @@ export interface DatacenterMarkerData {
interface DatacenterMarkerProps {
datacenter: DatacenterMarkerData;
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 size = getMarkerSize(datacenter.capacity_mw);
const color = getOperatorColor(datacenter.operator);
const pulseDuration = getPulseDuration(datacenter.capacity_mw);
const handleClick = useCallback(() => {
onClick(datacenter);
@ -68,6 +77,17 @@ export function DatacenterMarker({ datacenter, onClick }: DatacenterMarkerProps)
style={{ transform: hovered ? 'scale(1.3)' : 'scale(1)' }}
onMouseEnter={() => setHovered(true)}
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
className="rounded-full border-2 border-white/80 shadow-lg"
style={{

View File

@ -58,9 +58,18 @@ export function EnergyMap({ datacenters, regions }: EnergyMapProps) {
className="h-full w-full">
<RegionOverlay regions={regions} onRegionClick={handleRegionClick} />
{filteredDatacenters.map(dc => (
<DatacenterMarker key={dc.id} datacenter={dc} onClick={handleDatacenterClick} />
))}
{filteredDatacenters.map(dc => {
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>
<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 };