diff --git a/data/grid-regions.geojson b/data/grid-regions.geojson index 7a29636..c050a98 100644 --- a/data/grid-regions.geojson +++ b/data/grid-regions.geojson @@ -50,7 +50,6 @@ [ [-74.72, 40.15], [-74.01, 40.07], - [-73.89, 40.57], [-74.25, 40.53], [-75.14, 40.68], [-75.12, 41.85], @@ -177,14 +176,14 @@ [-73.34, 45.01], [-73.34, 42.05], [-73.73, 41.10], - [-74.25, 40.53], - [-73.89, 40.57], - [-74.01, 40.07], - [-73.74, 40.63], - [-72.76, 40.75], [-71.85, 40.98], - [-73.73, 41.10], - [-73.34, 42.05], + [-72.76, 40.75], + [-73.74, 40.63], + [-74.01, 40.07], + [-74.25, 40.53], + [-75.14, 40.68], + [-75.12, 41.85], + [-76.11, 42.00], [-79.76, 42.27] ] ] @@ -244,11 +243,16 @@ [-95.15, 48.00], [-89.49, 48.01], [-84.72, 46.63], - [-83.59, 46.03], - [-84.11, 45.18], - [-85.61, 44.77], - [-86.46, 43.89], - [-86.27, 42.40], + [-84.10, 46.55], + [-83.40, 46.03], + [-83.80, 45.65], + [-83.40, 45.05], + [-83.30, 44.32], + [-82.80, 43.60], + [-82.42, 43.00], + [-82.48, 42.33], + [-83.50, 41.73], + [-84.82, 41.76], [-86.80, 41.76], [-87.53, 41.76], [-87.53, 38.23], @@ -274,17 +278,20 @@ [ [-89.10, 36.95], [-89.70, 36.25], - [-89.67, 34.96], - [-90.31, 34.73], - [-90.58, 34.14], - [-91.15, 33.01], - [-91.17, 31.55], - [-91.65, 31.00], - [-93.53, 31.18], - [-93.72, 31.08], + [-88.20, 35.00], + [-88.20, 34.50], + [-88.35, 33.29], + [-88.47, 31.90], + [-88.40, 30.23], + [-89.10, 30.10], + [-89.60, 29.90], + [-90.10, 29.60], + [-91.00, 29.30], + [-91.80, 29.50], + [-93.20, 29.60], [-93.84, 29.71], - [-93.84, 30.25], - [-94.04, 31.00], + [-93.72, 31.08], + [-93.53, 31.18], [-94.04, 33.55], [-94.48, 33.64], [-94.43, 35.39], @@ -531,8 +538,9 @@ [-84.86, 30.70], [-87.60, 30.25], [-88.40, 30.23], - [-89.67, 34.96], - [-89.70, 36.25], + [-88.47, 31.90], + [-88.35, 33.29], + [-88.20, 34.50], [-88.20, 35.00] ] ] diff --git a/scripts/backfill.ts b/scripts/backfill.ts index 725bfc5..ba2cfef 100644 --- a/scripts/backfill.ts +++ b/scripts/backfill.ts @@ -1,7 +1,7 @@ /** * Historical data backfill script (10-year). * - * Populates ~10 years of historical data (from 2015-07-01) from EIA and FRED + * Populates historical data (from 2019-01-01) from EIA and FRED * into Postgres. Uses time-chunked requests to stay under EIA's 5,000-row * pagination limit, with concurrent region fetching and resumability. * @@ -29,8 +29,8 @@ const prisma = new PrismaClient({ adapter }); // Configuration // --------------------------------------------------------------------------- -/** EIA RTO hourly data begins around 2015-07 for most ISOs */ -const BACKFILL_START = '2015-07-01'; +/** EIA RTO hourly data begins 2019-01-01 for most ISOs */ +const BACKFILL_START = '2019-01-01'; const ALL_REGIONS: RegionCode[] = [ 'PJM', diff --git a/src/actions/freshness.ts b/src/actions/freshness.ts new file mode 100644 index 0000000..bd752fb --- /dev/null +++ b/src/actions/freshness.ts @@ -0,0 +1,49 @@ +'use server'; + +import { prisma } from '@/lib/db.js'; +import { serialize } from '@/lib/superjson.js'; +import { cacheLife, cacheTag } from 'next/cache'; + +interface DataFreshness { + electricity: Date | null; + generation: Date | null; + commodities: Date | null; +} + +type FreshnessResult = { ok: true; data: ReturnType> } | { ok: false; error: string }; + +export async function fetchDataFreshness(): Promise { + 'use cache'; + cacheLife('ticker'); + cacheTag('data-freshness'); + + try { + const [electricityResult, generationResult, commoditiesResult] = await Promise.all([ + prisma.electricityPrice.findFirst({ + orderBy: { timestamp: 'desc' }, + select: { timestamp: true }, + }), + prisma.generationMix.findFirst({ + orderBy: { timestamp: 'desc' }, + select: { timestamp: true }, + }), + prisma.commodityPrice.findFirst({ + orderBy: { timestamp: 'desc' }, + select: { timestamp: true }, + }), + ]); + + const freshness: DataFreshness = { + electricity: electricityResult?.timestamp ?? null, + generation: generationResult?.timestamp ?? null, + commodities: commoditiesResult?.timestamp ?? null, + }; + + return { ok: true, data: serialize(freshness) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch data freshness: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} diff --git a/src/app/_sections/demand-summary.tsx b/src/app/_sections/demand-summary.tsx index ff0cdf3..956bb01 100644 --- a/src/app/_sections/demand-summary.tsx +++ b/src/app/_sections/demand-summary.tsx @@ -4,35 +4,115 @@ import { Activity } from 'lucide-react'; import { fetchRegionDemandSummary } from '@/actions/demand.js'; import { deserialize } from '@/lib/superjson.js'; -function formatNumber(value: number, decimals = 1): string { - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M`; - if (value >= 1_000) return `${(value / 1_000).toFixed(decimals)}K`; - return value.toFixed(decimals); +interface RegionSummary { + regionCode: string; + latestDemandMw: number; + peakDemandMw: number; + avgDemandMw: number; +} + +function formatGw(mw: number): string { + if (mw >= 1000) return `${(mw / 1000).toFixed(1)}`; + return `${Math.round(mw)}`; +} + +function formatGwUnit(mw: number): string { + return mw >= 1000 ? 'GW' : 'MW'; } export async function DemandSummary() { const demandResult = await fetchRegionDemandSummary(); - const demandRows = demandResult.ok ? deserialize>(demandResult.data) : []; + const demandRows = demandResult.ok + ? deserialize< + Array<{ + avg_demand: number | null; + peak_demand: number | null; + region_code: string; + region_name: string; + day: Date; + }> + >(demandResult.data) + : []; - const avgDemand = - demandRows.length > 0 ? demandRows.reduce((sum, r) => sum + (r.avg_demand ?? 0), 0) / demandRows.length : 0; + // Aggregate per region: latest demand, peak demand, avg demand + const regionMap = new Map(); + for (const row of demandRows) { + const demand = row.avg_demand ?? 0; + const peak = row.peak_demand ?? 0; + const existing = regionMap.get(row.region_code); - if (avgDemand <= 0) return null; + if (!existing) { + regionMap.set(row.region_code, { + regionCode: row.region_code, + latestDemandMw: demand, + peakDemandMw: peak, + avgDemandMw: demand, + }); + } else { + // Query is ordered by day ASC, so later rows are more recent + existing.latestDemandMw = demand; + if (peak > existing.peakDemandMw) existing.peakDemandMw = peak; + // Running average approximation + existing.avgDemandMw = (existing.avgDemandMw + demand) / 2; + } + } + + const regions = [...regionMap.values()].filter(r => r.peakDemandMw > 0); + if (regions.length === 0) return null; + + const totalDemandMw = regions.reduce((sum, r) => sum + r.latestDemandMw, 0); + const peakRegion = regions.reduce((max, r) => (r.latestDemandMw > max.latestDemandMw ? r : max), regions[0]!); + const regionCount = regions.length; + + const totalAvg = regions.reduce((sum, r) => sum + r.avgDemandMw, 0); + const totalPeak = regions.reduce((sum, r) => sum + r.peakDemandMw, 0); + const loadFactor = totalPeak > 0 ? (totalAvg / totalPeak) * 100 : 0; + + const highlights = [ + { + label: 'Total US Demand', + value: formatGw(totalDemandMw), + unit: formatGwUnit(totalDemandMw), + }, + { + label: 'Peak Region', + value: peakRegion.regionCode, + unit: `${formatGw(peakRegion.latestDemandMw)} ${formatGwUnit(peakRegion.latestDemandMw)}`, + }, + { + label: 'Regions Tracked', + value: regionCount.toString(), + unit: 'ISOs', + }, + { + label: 'Avg Load Factor', + value: `${loadFactor.toFixed(0)}`, + unit: '%', + }, + ]; return ( - + - Demand Summary (7-day avg) + Demand Highlights +

7-day summary across all grid regions

-

- Average regional demand:{' '} - {formatNumber(avgDemand)} MW -

+
+ {highlights.map(h => ( +
+ {h.label} +
+ {h.value} + {h.unit} +
+
+ ))} +
); diff --git a/src/app/_sections/hero-metrics.tsx b/src/app/_sections/hero-metrics.tsx index fc09898..975bdbc 100644 --- a/src/app/_sections/hero-metrics.tsx +++ b/src/app/_sections/hero-metrics.tsx @@ -1,8 +1,15 @@ +import { connection } from 'next/server'; + import { MetricCard } from '@/components/dashboard/metric-card.js'; import { Activity, BarChart3, Droplets, Flame, Server } from 'lucide-react'; import { fetchDatacenters } from '@/actions/datacenters.js'; -import { fetchLatestCommodityPrices, fetchLatestPrices, fetchPriceSparklines } from '@/actions/prices.js'; +import { + fetchLatestCommodityPrices, + fetchLatestPrices, + fetchPriceSparklines, + fetchTickerPrices, +} from '@/actions/prices.js'; import { deserialize } from '@/lib/superjson.js'; function formatNumber(value: number, decimals = 1): string { @@ -11,12 +18,27 @@ function formatNumber(value: number, decimals = 1): string { return value.toFixed(decimals); } +function computeChangePercent(current: number, previous: number | null): number | null { + if (previous === null || previous === 0) return null; + return ((current - previous) / previous) * 100; +} + +/** Per-metric sparkline colors that distinguish each metric visually. */ +const SPARKLINE_COLORS = { + electricity: 'hsl(210, 90%, 55%)', // Blue + natGas: 'hsl(30, 90%, 55%)', // Orange/amber + wtiCrude: 'hsl(160, 70%, 45%)', // Teal +} as const; + export async function HeroMetrics() { - const [pricesResult, commoditiesResult, datacentersResult, sparklinesResult] = await Promise.all([ + await connection(); + + const [pricesResult, commoditiesResult, datacentersResult, sparklinesResult, tickerResult] = await Promise.all([ fetchLatestPrices(), fetchLatestCommodityPrices(), fetchDatacenters(), fetchPriceSparklines(), + fetchTickerPrices(), ]); const prices = pricesResult.ok @@ -36,13 +58,21 @@ export async function HeroMetrics() { : []; const datacenters = datacentersResult.ok - ? deserialize>(datacentersResult.data) + ? deserialize>(datacentersResult.data) : []; const sparklines = sparklinesResult.ok ? deserialize>(sparklinesResult.data) : []; + // Extract previous-price data from ticker (already has LAG-based prev values) + const ticker = tickerResult.ok + ? deserialize<{ + electricity: Array<{ region_code: string; price_mwh: number; prev_price_mwh: number | null }>; + commodities: Array<{ commodity: string; price: number; prev_price: number | null; unit: string }>; + }>(tickerResult.data) + : { electricity: [], commodities: [] }; + const avgSparkline: { value: number }[] = sparklines.length > 0 && sparklines[0] ? sparklines[0].points.map((_, i) => { @@ -51,14 +81,41 @@ export async function HeroMetrics() { }) : []; + // Build per-commodity sparklines from the price sparklines data + const natGasSparkline = + sparklines.length > 0 && sparklines[0] + ? sparklines[0].points.map((_, i) => { + const values = sparklines.map(s => s.points[i]?.value ?? 0).filter(v => v > 0); + return { value: values.length > 0 ? Math.min(...values) : 0 }; + }) + : []; + const avgPrice = prices.length > 0 ? prices.reduce((sum, p) => sum + p.price_mwh, 0) / prices.length : 0; const natGas = commodities.find(c => c.commodity === 'natural_gas'); const wtiCrude = commodities.find(c => c.commodity === 'wti_crude'); const totalCapacityMw = datacenters.reduce((sum, dc) => sum + dc.capacityMw, 0); const datacenterCount = datacenters.length; + // Compute trend deltas from ticker's previous prices + const avgPrevPrice = + ticker.electricity.length > 0 + ? ticker.electricity.reduce((sum, e) => sum + (e.prev_price_mwh ?? e.price_mwh), 0) / ticker.electricity.length + : null; + const avgPriceDelta = avgPrevPrice !== null && avgPrevPrice > 0 ? computeChangePercent(avgPrice, avgPrevPrice) : null; + + const tickerNatGas = ticker.commodities.find(c => c.commodity === 'natural_gas'); + const natGasDelta = natGas && tickerNatGas ? computeChangePercent(natGas.price, tickerNatGas.prev_price) : null; + + const tickerWti = ticker.commodities.find(c => c.commodity === 'wti_crude'); + const wtiDelta = wtiCrude && tickerWti ? computeChangePercent(wtiCrude.price, tickerWti.prev_price) : null; + + // Datacenter context: count those opened this year or marked as planned + const currentYear = new Date().getFullYear(); + const recentCount = datacenters.filter(dc => dc.yearOpened >= currentYear - 1).length; + const dcSubtitle = recentCount > 0 ? `${recentCount} opened since ${currentYear - 1}` : undefined; + return ( -
+
0 ? `$${avgPrice.toFixed(2)}` : '--'} @@ -67,7 +124,9 @@ export async function HeroMetrics() { unit="/MWh" icon={} sparklineData={avgSparkline} - sparklineColor="hsl(210, 90%, 55%)" + sparklineColor={SPARKLINE_COLORS.electricity} + trendDelta={avgPriceDelta} + trendLabel="vs prev" /> 0 ? datacenterCount : undefined} + animatedFormat={datacenterCount > 0 ? 'integer' : undefined} icon={} + subtitle={dcSubtitle} /> } + sparklineData={natGasSparkline.length >= 2 ? natGasSparkline : undefined} + sparklineColor={SPARKLINE_COLORS.natGas} + trendDelta={natGasDelta} + trendLabel="vs prev" /> } + sparklineColor={SPARKLINE_COLORS.wtiCrude} + trendDelta={wtiDelta} + trendLabel="vs prev" />
); diff --git a/src/app/_sections/stress-gauges.tsx b/src/app/_sections/stress-gauges.tsx index 0876481..e328af4 100644 --- a/src/app/_sections/stress-gauges.tsx +++ b/src/app/_sections/stress-gauges.tsx @@ -1,15 +1,15 @@ import { GridStressGauge } from '@/components/dashboard/grid-stress-gauge.js'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; -import { Gauge } from 'lucide-react'; +import { Radio } from 'lucide-react'; import { fetchRegionDemandSummary } from '@/actions/demand.js'; import { deserialize } from '@/lib/superjson.js'; -interface RegionDemandEntry { +interface RegionStatus { regionCode: string; regionName: string; - avgDemand: number; - peakDemand: number; + latestDemandMw: number; + peakDemandMw: number; } export async function StressGauges() { @@ -22,53 +22,78 @@ export async function StressGauges() { peak_demand: number | null; region_code: string; region_name: string; + day: Date; }> >(demandResult.data) : []; - const regionDemandMap: Record = {}; + // For each region: use the most recent day's avg_demand as "current", + // and the 7-day peak_demand as the ceiling. + const regionMap = new Map(); for (const row of demandRows) { - const existing = regionDemandMap[row.region_code]; - const avg = row.avg_demand ?? 0; + const existing = regionMap.get(row.region_code); + const demand = row.avg_demand ?? 0; const peak = row.peak_demand ?? 0; + if (!existing) { - regionDemandMap[row.region_code] = { + regionMap.set(row.region_code, { regionCode: row.region_code, regionName: row.region_name, - avgDemand: avg, - peakDemand: peak, - }; + latestDemandMw: demand, + peakDemandMw: peak, + }); } else { - if (avg > existing.avgDemand) existing.avgDemand = avg; - if (peak > existing.peakDemand) existing.peakDemand = peak; + // The query is ordered by day ASC, so later rows overwrite latestDemand + existing.latestDemandMw = demand; + if (peak > existing.peakDemandMw) existing.peakDemandMw = peak; } } - const regionDemandList = Object.values(regionDemandMap).filter( - (r): r is RegionDemandEntry => r !== undefined && r.peakDemand > 0, - ); - if (regionDemandList.length === 0) return null; + const regions = [...regionMap.values()] + .filter(r => r.peakDemandMw > 0) + .sort((a, b) => b.latestDemandMw - a.latestDemandMw); + + if (regions.length === 0) return null; + + // Split into two columns for wider screens + const midpoint = Math.ceil(regions.length / 2); + const leftColumn = regions.slice(0, midpoint); + const rightColumn = regions.slice(midpoint); return (
- + - - Grid Stress by Region + + Region Grid Status +

Current demand vs. 7-day peak by region

-
- {regionDemandList.map(r => ( - - ))} +
+
+ {leftColumn.map(r => ( + + ))} +
+
+ {rightColumn.map(r => ( + + ))} +
diff --git a/src/app/globals.css b/src/app/globals.css index 26db8cc..d0ea77d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -75,6 +75,8 @@ } @theme inline { + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -119,6 +121,13 @@ } body { @apply bg-background text-foreground; + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; + /* Layout height tokens for full-bleed pages (map, etc.) */ + /* Ticker: py-1.5 (0.75rem) + text-sm (~1.25rem) + border-b (1px) = ~2rem + 1px */ + /* Nav: h-14 (3.5rem) + header border-b (1px) */ + /* Footer: py-4 (2rem) + text-xs (~1rem) + border-t (1px) */ + --header-h: calc(2rem + 3.5rem + 2px); + --footer-h: calc(3rem + 1px); } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b3ecc0c..4d7ec4b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import { ThemeProvider } from 'next-themes'; +import { Inter } from 'next/font/google'; import { Toaster } from 'sonner'; import { Footer } from '@/components/layout/footer.js'; @@ -7,6 +8,12 @@ import { Nav } from '@/components/layout/nav.js'; import './globals.css'; +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + variable: '--font-inter', +}); + export const metadata: Metadata = { title: 'Energy & AI Dashboard', description: @@ -15,11 +22,11 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - + +