diff --git a/src/app/demand/loading.tsx b/src/app/demand/loading.tsx new file mode 100644 index 0000000..ef695e5 --- /dev/null +++ b/src/app/demand/loading.tsx @@ -0,0 +1,15 @@ +import { ChartSkeleton } from '@/components/charts/chart-skeleton.js'; +import { Skeleton } from '@/components/ui/skeleton.js'; + +export default function DemandLoading() { + return ( +
+
+ + +
+ + +
+ ); +} diff --git a/src/app/demand/page.tsx b/src/app/demand/page.tsx index b275e55..6d7459b 100644 --- a/src/app/demand/page.tsx +++ b/src/app/demand/page.tsx @@ -20,9 +20,9 @@ export default async function DemandPage() { const summaryData = summaryResult.ok ? deserialize(summaryResult.data) : []; return ( -
-
-

Demand Analysis

+
+
+

Demand Analysis

Regional demand growth trends, peak demand tracking, and estimated datacenter load as a percentage of regional demand. diff --git a/src/app/generation/loading.tsx b/src/app/generation/loading.tsx new file mode 100644 index 0000000..1741134 --- /dev/null +++ b/src/app/generation/loading.tsx @@ -0,0 +1,15 @@ +import { ChartSkeleton } from '@/components/charts/chart-skeleton.js'; +import { Skeleton } from '@/components/ui/skeleton.js'; + +export default function GenerationLoading() { + return ( +

+ + + +
+ +
+
+ ); +} diff --git a/src/app/generation/page.tsx b/src/app/generation/page.tsx index 4c71eaa..2ac12df 100644 --- a/src/app/generation/page.tsx +++ b/src/app/generation/page.tsx @@ -16,8 +16,8 @@ export default async function GenerationPage() { if (!result.ok) { return ( -
-

Generation Mix

+
+

Generation Mix

Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons.

@@ -29,8 +29,8 @@ export default async function GenerationPage() { } return ( -
-

Generation Mix

+
+

Generation Mix

Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons and carbon intensity indicators. diff --git a/src/app/globals.css b/src/app/globals.css index de25554..26db8cc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..943ed01 --- /dev/null +++ b/src/app/loading.tsx @@ -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 ( +

+
+ + +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ +
+ + + + + + + + + + + + + + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/app/map/loading.tsx b/src/app/map/loading.tsx new file mode 100644 index 0000000..3ad50ec --- /dev/null +++ b/src/app/map/loading.tsx @@ -0,0 +1,9 @@ +import { Skeleton } from '@/components/ui/skeleton.js'; + +export default function MapLoading() { + return ( +
+ +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9433904..9b9d861 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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>(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 = {}; + 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 ( -
-
-

Dashboard

+
+
+

Dashboard

Real-time overview of AI datacenter energy impact across US grid regions.

@@ -137,6 +180,38 @@ export default async function DashboardHome() {
+ {regionPrices.length > 0 && ( +
+ +
+ )} + + {regionDemandList.length > 0 && ( +
+ + + + + Grid Stress by Region + + + +
+ {regionDemandList.map(r => ( + + ))} +
+
+
+
+ )} + {avgDemand > 0 && (
diff --git a/src/app/trends/loading.tsx b/src/app/trends/loading.tsx new file mode 100644 index 0000000..d1b269d --- /dev/null +++ b/src/app/trends/loading.tsx @@ -0,0 +1,16 @@ +import { ChartSkeleton } from '@/components/charts/chart-skeleton.js'; +import { Skeleton } from '@/components/ui/skeleton.js'; + +export default function TrendsLoading() { + return ( +
+
+ + +
+ + + +
+ ); +} diff --git a/src/app/trends/page.tsx b/src/app/trends/page.tsx index d2a86e0..e8fa1d2 100644 --- a/src/app/trends/page.tsx +++ b/src/app/trends/page.tsx @@ -42,9 +42,9 @@ export default async function TrendsPage() { ]); return ( -
+
-

Price Trends

+

Price Trends

Regional electricity price charts with commodity overlays and AI milestone annotations.

diff --git a/src/components/charts/chart-skeleton.tsx b/src/components/charts/chart-skeleton.tsx new file mode 100644 index 0000000..5892862 --- /dev/null +++ b/src/components/charts/chart-skeleton.tsx @@ -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 ( + + {title && ( + + + + + )} + + + + + ); +} diff --git a/src/components/charts/demand-chart.tsx b/src/components/charts/demand-chart.tsx index 8aa92b5..8562b71 100644 --- a/src/components/charts/demand-chart.tsx +++ b/src/components/charts/demand-chart.tsx @@ -239,10 +239,10 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) { ))}
-
+
+ + +
+ ); + } + + return this.props.children; + } +} diff --git a/src/components/layout/nav.tsx b/src/components/layout/nav.tsx index d572100..4f6975a 100644 --- a/src/components/layout/nav.tsx +++ b/src/components/layout/nav.tsx @@ -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 (
- {/* Ticker tape slot — Phase 5 */} -
+ -
- +
+ Energy & AI Dashboard -
); } diff --git a/src/components/map/datacenter-marker.tsx b/src/components/map/datacenter-marker.tsx index 30adb7f..0476e30 100644 --- a/src/components/map/datacenter-marker.tsx +++ b/src/components/map/datacenter-marker.tsx @@ -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 && ( +
+ )}
- {filteredDatacenters.map(dc => ( - - ))} + {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 ( + + ); + })} setSelectedDatacenter(null)} /> diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..51bbb95 --- /dev/null +++ b/src/components/ui/button.tsx @@ -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 = { + 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 = { + 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 ( + + ); +} + +export { Button }; +export type { ButtonProps }; diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..f2ef6ff --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from '@/lib/utils.js'; + +function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +export { Skeleton }; diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000..e7a09c7 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -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) { + const _values = React.useMemo( + () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]), + [value, defaultValue, min, max], + ); + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ); +} + +export { Slider };