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:
parent
2dddbe78cb
commit
7d20d4b484
15
src/app/demand/loading.tsx
Normal file
15
src/app/demand/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
15
src/app/generation/loading.tsx
Normal file
15
src/app/generation/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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
48
src/app/loading.tsx
Normal 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
9
src/app/map/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
16
src/app/trends/loading.tsx
Normal file
16
src/app/trends/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
24
src/components/charts/chart-skeleton.tsx
Normal file
24
src/components/charts/chart-skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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'
|
||||
|
||||
48
src/components/dashboard/ambient-glow.tsx
Normal file
48
src/components/dashboard/ambient-glow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/dashboard/animated-number.tsx
Normal file
26
src/components/dashboard/animated-number.tsx
Normal 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>;
|
||||
}
|
||||
61
src/components/dashboard/auto-refresh.tsx
Normal file
61
src/components/dashboard/auto-refresh.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
src/components/dashboard/gpu-calculator.tsx
Normal file
197
src/components/dashboard/gpu-calculator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/dashboard/grid-stress-gauge.tsx
Normal file
87
src/components/dashboard/grid-stress-gauge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/dashboard/metric-card-skeleton.tsx
Normal file
21
src/components/dashboard/metric-card-skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/dashboard/price-alert.tsx
Normal file
54
src/components/dashboard/price-alert.tsx
Normal 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;
|
||||
}
|
||||
122
src/components/dashboard/ticker-tape.tsx
Normal file
122
src/components/dashboard/ticker-tape.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/components/error-boundary.tsx
Normal file
65
src/components/error-boundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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)} />
|
||||
|
||||
50
src/components/ui/button.tsx
Normal file
50
src/components/ui/button.tsx
Normal 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 };
|
||||
7
src/components/ui/skeleton.tsx
Normal file
7
src/components/ui/skeleton.tsx
Normal 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 };
|
||||
54
src/components/ui/slider.tsx
Normal file
54
src/components/ui/slider.tsx
Normal 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 };
|
||||
Loading…
x
Reference in New Issue
Block a user