diff --git a/src/actions/prices.ts b/src/actions/prices.ts index 7b8af10..55bdd75 100644 --- a/src/actions/prices.ts +++ b/src/actions/prices.ts @@ -71,6 +71,91 @@ export async function fetchPriceHeatmapData(): Promise> { + try { + const startDate = timeRangeToStartDate(timeRange); + const endDate = new Date(); + const regions = await prisma.gridRegion.findMany({ select: { code: true } }); + const results = await Promise.all( + regions.map(r => prisma.$queryRawTyped(getPriceTrends(r.code, startDate, endDate))), + ); + return { ok: true, data: serialize(results.flat()) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch all region price trends: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +export async function fetchCommodityTrends(timeRange: TimeRange = '30d'): Promise< + ActionResult< + Array<{ + commodity: string; + price: number; + unit: string; + timestamp: Date; + source: string; + }> + > +> { + try { + const startDate = timeRangeToStartDate(timeRange); + const rows = await prisma.commodityPrice.findMany({ + where: { timestamp: { gte: startDate } }, + orderBy: { timestamp: 'asc' }, + }); + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch commodity trends: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +export async function fetchRegionCapacityVsPrice(): Promise< + ActionResult< + Array<{ + region_code: string; + avg_price: number; + total_capacity_mw: number; + }> + > +> { + try { + const regions = await prisma.gridRegion.findMany({ + select: { + code: true, + datacenters: { select: { capacityMw: true } }, + electricityPrices: { + select: { priceMwh: true }, + orderBy: { timestamp: 'desc' }, + take: 100, + }, + }, + }); + + const result = regions.map(r => ({ + region_code: r.code, + total_capacity_mw: r.datacenters.reduce((sum, d) => sum + d.capacityMw, 0), + avg_price: + r.electricityPrices.length > 0 + ? r.electricityPrices.reduce((sum, p) => sum + p.priceMwh, 0) / r.electricityPrices.length + : 0, + })); + + return { ok: true, data: serialize(result) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch region capacity vs price: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + export async function fetchLatestCommodityPrices(): Promise< ActionResult< Array<{ diff --git a/src/app/demand/page.tsx b/src/app/demand/page.tsx index eb232b7..b275e55 100644 --- a/src/app/demand/page.tsx +++ b/src/app/demand/page.tsx @@ -1,3 +1,7 @@ +import { fetchDemandByRegion, fetchRegionDemandSummary } from '@/actions/demand.js'; +import { DemandChart } from '@/components/charts/demand-chart.js'; +import type { getDemandByRegion } from '@/generated/prisma/sql.js'; +import { deserialize } from '@/lib/superjson.js'; import type { Metadata } from 'next'; export const metadata: Metadata = { @@ -5,18 +9,27 @@ export const metadata: Metadata = { description: 'Regional electricity demand growth, peak tracking, and datacenter load impact', }; -export default function DemandPage() { +export default async function DemandPage() { + const [demandResult, summaryResult] = await Promise.all([ + fetchDemandByRegion('ALL', '30d'), + fetchRegionDemandSummary(), + ]); + + const demandData = demandResult.ok ? deserialize(demandResult.data) : []; + + const summaryData = summaryResult.ok ? deserialize(summaryResult.data) : []; + return (
-

Demand Analysis

-

- Regional demand growth trends, peak demand tracking, and estimated datacenter load as a percentage of regional - demand. -

- -
-

Charts coming in Phase 4

+
+

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/page.tsx b/src/app/generation/page.tsx index 4926c96..4c71eaa 100644 --- a/src/app/generation/page.tsx +++ b/src/app/generation/page.tsx @@ -1,11 +1,33 @@ import type { Metadata } from 'next'; +import { fetchGenerationMix } from '@/actions/generation.js'; +import { GenerationChart } from '@/components/charts/generation-chart.js'; + export const metadata: Metadata = { title: 'Generation Mix | Energy & AI Dashboard', description: 'Generation by fuel type per region, renewable vs fossil splits, and carbon intensity', }; -export default function GenerationPage() { +const DEFAULT_REGION = 'PJM'; +const DEFAULT_TIME_RANGE = '30d' as const; + +export default async function GenerationPage() { + const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE); + + if (!result.ok) { + return ( +
+

Generation Mix

+

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

+
+

{result.error}

+
+
+ ); + } + return (

Generation Mix

@@ -14,8 +36,12 @@ export default function GenerationPage() { intensity indicators.

-
-

Charts coming in Phase 4

+
+
); diff --git a/src/app/trends/page.tsx b/src/app/trends/page.tsx index 842f309..d2a86e0 100644 --- a/src/app/trends/page.tsx +++ b/src/app/trends/page.tsx @@ -1,21 +1,87 @@ +import { readFile } from 'fs/promises'; import type { Metadata } from 'next'; +import { join } from 'path'; +import { z } from 'zod'; + +import { fetchAllRegionPriceTrends, fetchCommodityTrends, fetchRegionCapacityVsPrice } from '@/actions/prices.js'; +import { CorrelationChart } from '@/components/charts/correlation-chart.js'; +import type { AIMilestone } from '@/components/charts/price-chart.js'; +import { PriceChart } from '@/components/charts/price-chart.js'; export const metadata: Metadata = { title: 'Price Trends | Energy & AI Dashboard', description: 'Regional electricity price trends, commodity overlays, and AI milestone annotations', }; -export default function TrendsPage() { - return ( -
-

Price Trends

-

- Multi-line regional electricity price charts with commodity overlays and AI milestone annotations. -

+const AIMilestoneSchema = z.object({ + date: z.string(), + title: z.string(), + description: z.string(), + category: z.string(), +}) satisfies z.ZodType; -
-

Charts coming in Phase 4

+async function loadMilestones(): Promise { + try { + const filePath = join(process.cwd(), 'data', 'ai-milestones.json'); + const raw = await readFile(filePath, 'utf-8'); + const parsed: unknown = JSON.parse(raw); + return z.array(AIMilestoneSchema).parse(parsed); + } catch { + return []; + } +} + +export default async function TrendsPage() { + const defaultRange = '30d' as const; + + const [priceResult, commodityResult, correlationResult, milestones] = await Promise.all([ + fetchAllRegionPriceTrends(defaultRange), + fetchCommodityTrends(defaultRange), + fetchRegionCapacityVsPrice(), + loadMilestones(), + ]); + + return ( +
+
+

Price Trends

+

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

+ + {priceResult.ok && commodityResult.ok ? ( + { + 'use server'; + const [prices, commodities] = await Promise.all([ + fetchAllRegionPriceTrends(range), + fetchCommodityTrends(range), + ]); + if (!prices.ok) throw new Error(prices.error); + if (!commodities.ok) throw new Error(commodities.error); + return { prices: prices.data, commodities: commodities.data }; + }} + /> + ) : ( +
+

+ {!priceResult.ok ? priceResult.error : !commodityResult.ok ? commodityResult.error : ''} +

+
+ )} + + {correlationResult.ok ? ( + + ) : ( +
+

{correlationResult.error}

+
+ )}
); } diff --git a/src/components/charts/correlation-chart.tsx b/src/components/charts/correlation-chart.tsx new file mode 100644 index 0000000..c1d50ce --- /dev/null +++ b/src/components/charts/correlation-chart.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useMemo } from 'react'; +import { CartesianGrid, Label, Scatter, ScatterChart, XAxis, YAxis, ZAxis } from 'recharts'; +import type { SuperJSONResult } from 'superjson'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js'; +import { deserialize } from '@/lib/superjson.js'; + +interface RegionCapacityPrice { + region_code: string; + avg_price: number; + total_capacity_mw: number; +} + +interface CorrelationChartProps { + data: SuperJSONResult; +} + +const REGION_COLORS: Record = { + PJM: 'hsl(210, 90%, 55%)', + ERCOT: 'hsl(25, 90%, 55%)', + CAISO: 'hsl(140, 70%, 45%)', + NYISO: 'hsl(280, 70%, 55%)', + ISONE: 'hsl(340, 70%, 55%)', + MISO: 'hsl(55, 80%, 50%)', + SPP: 'hsl(180, 60%, 45%)', +}; + +const chartConfig: ChartConfig = { + correlation: { + label: 'DC Capacity vs. Avg Price', + color: 'hsl(210, 90%, 55%)', + }, +}; + +interface ScatterPoint { + region_code: string; + total_capacity_mw: number; + avg_price: number; + fill: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function getNumberProp(obj: Record, key: string): number { + const val = obj[key]; + return typeof val === 'number' ? val : 0; +} + +function getScatterPayload(obj: Record): ScatterPoint | null { + const payload = obj['payload']; + if (!isRecord(payload)) return null; + const regionCode = typeof payload['region_code'] === 'string' ? payload['region_code'] : ''; + const fill = typeof payload['fill'] === 'string' ? payload['fill'] : 'hsl(0,0%,50%)'; + return { + region_code: regionCode, + total_capacity_mw: getNumberProp(payload, 'total_capacity_mw'), + avg_price: getNumberProp(payload, 'avg_price'), + fill, + }; +} + +function CustomDot(props: unknown): React.JSX.Element { + if (!isRecord(props)) return ; + const cx = getNumberProp(props, 'cx'); + const cy = getNumberProp(props, 'cy'); + const payload = getScatterPayload(props); + if (!payload) return ; + + return ( + + + + {payload.region_code} + + + ); +} + +export function CorrelationChart({ data }: CorrelationChartProps) { + const rows = useMemo(() => deserialize(data), [data]); + + const scatterData: ScatterPoint[] = useMemo( + () => + rows + .filter(r => r.total_capacity_mw > 0 || r.avg_price > 0) + .map(r => ({ + region_code: r.region_code, + total_capacity_mw: Math.round(r.total_capacity_mw), + avg_price: Number(r.avg_price.toFixed(2)), + fill: REGION_COLORS[r.region_code] ?? 'hsl(0, 0%, 50%)', + })), + [rows], + ); + + if (scatterData.length === 0) { + return ( + + + DC Capacity vs. Electricity Price + No data available for correlation analysis. + + +
+

+ No data available. Ingest price data and seed datacenters first. +

+
+
+
+ ); + } + + return ( + + + DC Capacity vs. Electricity Price + Datacenter capacity (MW) versus average electricity price per region + + + + + + `${v} MW`}> + + `$${v}`}> + + + { + const nameStr = String(name); + const valStr = String(value); + if (nameStr === 'DC Capacity') return [`${valStr} MW`, undefined]; + if (nameStr === 'Avg Price') return [`$${valStr}/MWh`, undefined]; + return [valStr, undefined]; + }} + /> + } + /> + + + + + + ); +} diff --git a/src/components/charts/demand-chart.tsx b/src/components/charts/demand-chart.tsx new file mode 100644 index 0000000..8aa92b5 --- /dev/null +++ b/src/components/charts/demand-chart.tsx @@ -0,0 +1,470 @@ +'use client'; + +import { fetchDemandByRegion } from '@/actions/demand.js'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart.js'; +import { deserialize } from '@/lib/superjson.js'; +import { Activity, Server, TrendingUp, Zap } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { Bar, BarChart, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts'; + +type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; + +interface DemandRow { + region_code: string; + region_name: string; + day: Date | null; + avg_demand: number | null; + peak_demand: number | null; + datacenter_count: number | null; + total_dc_capacity_mw: number | null; +} + +interface DemandChartProps { + initialData: DemandRow[]; + summaryData: DemandRow[]; +} + +const TIME_RANGES: { value: TimeRange; label: string }[] = [ + { value: '7d', label: '7D' }, + { value: '30d', label: '30D' }, + { value: '90d', label: '90D' }, + { value: '1y', label: '1Y' }, +]; + +const REGION_COLORS: Record = { + PJM: 'hsl(210, 80%, 60%)', + ERCOT: 'hsl(30, 80%, 55%)', + CAISO: 'hsl(145, 65%, 50%)', + NYISO: 'hsl(280, 70%, 60%)', + ISONE: 'hsl(350, 70%, 55%)', + MISO: 'hsl(60, 70%, 50%)', + SPP: 'hsl(180, 60%, 50%)', +}; + +function formatDemandValue(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return value.toFixed(0); +} + +function formatDateLabel(date: Date): string { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); +} + +export function DemandChart({ initialData, summaryData }: DemandChartProps) { + const [timeRange, setTimeRange] = useState('30d'); + const [selectedRegion, setSelectedRegion] = useState('ALL'); + const [chartData, setChartData] = useState(initialData); + const [loading, setLoading] = useState(false); + + const regions = useMemo(() => { + const regionSet = new Map(); + for (const row of summaryData) { + if (!regionSet.has(row.region_code)) { + regionSet.set(row.region_code, row.region_name); + } + } + return Array.from(regionSet.entries()).map(([code, name]) => ({ + code, + name, + })); + }, [summaryData]); + + const handleTimeRangeChange = useCallback( + async (range: TimeRange) => { + setTimeRange(range); + setLoading(true); + try { + const result = await fetchDemandByRegion(selectedRegion, range); + if (result.ok) { + setChartData(deserialize(result.data)); + } + } finally { + setLoading(false); + } + }, + [selectedRegion], + ); + + const handleRegionChange = useCallback( + async (region: string) => { + setSelectedRegion(region); + setLoading(true); + try { + const result = await fetchDemandByRegion(region, timeRange); + if (result.ok) { + setChartData(deserialize(result.data)); + } + } finally { + setLoading(false); + } + }, + [timeRange], + ); + + const trendChartData = useMemo(() => { + const filtered = selectedRegion === 'ALL' ? chartData : chartData.filter(r => r.region_code === selectedRegion); + + const byDay = new Map(); + + for (const row of filtered) { + if (!row.day || row.avg_demand === null) continue; + const dateKey = row.day.toISOString().split('T')[0] ?? ''; + if (!byDay.has(dateKey)) { + byDay.set(dateKey, { date: formatDateLabel(row.day), dateObj: row.day }); + } + const entry = byDay.get(dateKey); + if (entry) { + entry[row.region_code] = Math.round(row.avg_demand); + } + } + + return Array.from(byDay.values()).sort((a, b) => a.dateObj.getTime() - b.dateObj.getTime()); + }, [chartData, selectedRegion]); + + const trendChartConfig = useMemo(() => { + const config: ChartConfig = {}; + const activeRegions = selectedRegion === 'ALL' ? regions : regions.filter(r => r.code === selectedRegion); + for (const region of activeRegions) { + config[region.code] = { + label: region.name, + color: REGION_COLORS[region.code] ?? 'hsl(0, 0%, 60%)', + }; + } + return config; + }, [regions, selectedRegion]); + + const peakDemandByRegion = useMemo(() => { + const peaks = new Map(); + for (const row of chartData) { + if (!row.day || row.peak_demand === null) continue; + const existing = peaks.get(row.region_code); + if (!existing || row.peak_demand > existing.peakDemand) { + peaks.set(row.region_code, { + regionCode: row.region_code, + regionName: row.region_name, + peakDemand: row.peak_demand, + day: row.day, + }); + } + } + return Array.from(peaks.values()).sort((a, b) => b.peakDemand - a.peakDemand); + }, [chartData]); + + const dcImpactData = useMemo(() => { + const regionAgg = new Map< + string, + { + regionCode: string; + regionName: string; + totalDemand: number; + count: number; + dcCapacityMw: number; + } + >(); + for (const row of summaryData) { + if (row.avg_demand === null) continue; + const existing = regionAgg.get(row.region_code); + if (existing) { + existing.totalDemand += row.avg_demand; + existing.count += 1; + existing.dcCapacityMw = row.total_dc_capacity_mw ?? existing.dcCapacityMw; + } else { + regionAgg.set(row.region_code, { + regionCode: row.region_code, + regionName: row.region_name, + totalDemand: row.avg_demand, + count: 1, + dcCapacityMw: row.total_dc_capacity_mw ?? 0, + }); + } + } + + return Array.from(regionAgg.values()) + .map(r => { + const avgDemand = r.totalDemand / r.count; + const dcPercent = avgDemand > 0 ? (r.dcCapacityMw / avgDemand) * 100 : 0; + return { + region: r.regionCode, + regionName: r.regionName, + avgDemand: Math.round(avgDemand), + dcCapacityMw: Math.round(r.dcCapacityMw), + dcPercent: Math.round(dcPercent * 10) / 10, + }; + }) + .sort((a, b) => b.dcPercent - a.dcPercent); + }, [summaryData]); + + const dcImpactConfig = { + avgDemand: { + label: 'Avg Demand (MW)', + color: 'hsl(210, 80%, 60%)', + }, + dcCapacityMw: { + label: 'DC Capacity (MW)', + color: 'hsl(340, 75%, 55%)', + }, + } satisfies ChartConfig; + + const hasData = trendChartData.length > 0; + const hasDcImpact = dcImpactData.length > 0; + + return ( +
+ {/* Controls */} +
+
+ {TIME_RANGES.map(range => ( + + ))} +
+ +
+ + {regions.map(region => ( + + ))} +
+ + {loading && Loading...} +
+ + {/* Demand Trend Chart */} + + + + + Regional Demand Trends + + Average daily electricity demand by ISO region (MW) + + + {hasData ? ( + + + + + formatDemandValue(value)} + /> + { + const numVal = typeof value === 'number' ? value : Number(value); + return `${formatDemandValue(numVal)} MW`; + }} + /> + } + /> + } /> + {Object.keys(trendChartConfig).map(regionCode => ( + + ))} + + + ) : ( + + )} + + + +
+ {/* Peak Demand Records */} + + + + + Peak Demand Records + + Highest recorded demand in selected period + + + {peakDemandByRegion.length > 0 ? ( +
+ {peakDemandByRegion.map((peak, idx) => ( +
+
+ + {idx + 1} + +
+

{peak.regionName}

+

{formatDateLabel(peak.day)}

+
+
+
+

{formatDemandValue(peak.peakDemand)} MW

+
+
+ ))} +
+ ) : ( + + )} +
+
+ + {/* DC Impact Chart */} + + + + + Datacenter Load Impact + + Estimated datacenter capacity as % of regional demand + + + {hasDcImpact ? ( + <> + + + + formatDemandValue(value)} + /> + + { + const numVal = typeof value === 'number' ? value : Number(value); + return `${formatDemandValue(numVal)} MW`; + }} + /> + } + /> + } /> + + + + + + {/* DC % Table */} +
+ {dcImpactData.map(row => ( +
+ {row.regionName} +
+
+
+
+ {row.dcPercent}% +
+
+ ))} +
+ + ) : ( + + )} + + +
+ + {/* Summary Stats Row */} + {hasDcImpact && ( +
+ } + label="Total Avg Demand" + value={`${formatDemandValue(dcImpactData.reduce((sum, r) => sum + r.avgDemand, 0))} MW`} + /> + } + label="Total DC Capacity" + value={`${formatDemandValue(dcImpactData.reduce((sum, r) => sum + r.dcCapacityMw, 0))} MW`} + /> + } + label="Regions Tracked" + value={`${dcImpactData.length}`} + /> +
+ )} +
+ ); +} + +function EmptyState({ message }: { message: string }) { + return ( +
+

{message}

+
+ ); +} + +function SummaryCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { + return ( + + + {icon} +
+

{label}

+

{value}

+
+
+
+ ); +} diff --git a/src/components/charts/generation-chart.tsx b/src/components/charts/generation-chart.tsx new file mode 100644 index 0000000..238a299 --- /dev/null +++ b/src/components/charts/generation-chart.tsx @@ -0,0 +1,387 @@ +'use client'; + +import { useCallback, useMemo, useState, useTransition } from 'react'; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; + +import { fetchGenerationMix } from '@/actions/generation.js'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { + type ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart.js'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js'; +import type { getGenerationMix } from '@/generated/prisma/sql.js'; +import { deserialize } from '@/lib/superjson.js'; +import { formatMarketTime } from '@/lib/utils.js'; + +type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; + +const REGIONS = [ + { code: 'PJM', name: 'PJM (Mid-Atlantic)' }, + { code: 'ERCOT', name: 'ERCOT (Texas)' }, + { code: 'CAISO', name: 'CAISO (California)' }, + { code: 'NYISO', name: 'NYISO (New York)' }, + { code: 'ISONE', name: 'ISO-NE (New England)' }, + { code: 'MISO', name: 'MISO (Midwest)' }, + { code: 'SPP', name: 'SPP (South Central)' }, +] as const; + +const TIME_RANGES: { value: TimeRange; label: string }[] = [ + { value: '24h', label: '24H' }, + { value: '7d', label: '7D' }, + { value: '30d', label: '30D' }, + { value: '90d', label: '90D' }, + { value: '1y', label: '1Y' }, +]; + +const FUEL_TYPES = ['gas', 'nuclear', 'wind', 'solar', 'coal', 'hydro', 'other'] as const; +type FuelType = (typeof FUEL_TYPES)[number]; + +const FUEL_COLORS: Record = { + gas: 'hsl(25, 95%, 53%)', + nuclear: 'hsl(270, 70%, 60%)', + wind: 'hsl(190, 90%, 50%)', + solar: 'hsl(45, 93%, 58%)', + coal: 'hsl(0, 0%, 55%)', + hydro: 'hsl(210, 80%, 55%)', + other: 'hsl(0, 0%, 40%)', +}; + +const FUEL_LABELS: Record = { + gas: 'Natural Gas', + nuclear: 'Nuclear', + wind: 'Wind', + solar: 'Solar', + coal: 'Coal', + hydro: 'Hydro', + other: 'Other', +}; + +const RENEWABLE_FUELS = new Set(['wind', 'solar', 'hydro']); +const FOSSIL_FUELS = new Set(['gas', 'coal']); + +const FUEL_TYPE_SET: Set = new Set(FUEL_TYPES); + +function isFuelType(value: string): value is FuelType { + return FUEL_TYPE_SET.has(value); +} + +const TIME_RANGE_SET: Set = new Set(['24h', '7d', '30d', '90d', '1y']); + +function isTimeRange(value: string): value is TimeRange { + return TIME_RANGE_SET.has(value); +} + +const chartConfig: ChartConfig = Object.fromEntries( + FUEL_TYPES.map(fuel => [fuel, { label: FUEL_LABELS[fuel], color: FUEL_COLORS[fuel] }]), +); + +interface PivotedRow { + timestamp: number; + dateLabel: string; + gas: number; + nuclear: number; + wind: number; + solar: number; + coal: number; + hydro: number; + other: number; +} + +function pivotGenerationData(rows: getGenerationMix.Result[], regionCode: string): PivotedRow[] { + const byTimestamp = new Map(); + + for (const row of rows) { + const ts = row.timestamp.getTime(); + let pivot = byTimestamp.get(ts); + if (!pivot) { + pivot = { + timestamp: ts, + dateLabel: formatMarketTime(row.timestamp, regionCode), + gas: 0, + nuclear: 0, + wind: 0, + solar: 0, + coal: 0, + hydro: 0, + other: 0, + }; + byTimestamp.set(ts, pivot); + } + + const fuelKey = isFuelType(row.fuel_type) ? row.fuel_type : 'other'; + pivot[fuelKey] += row.generation_mw; + } + + return Array.from(byTimestamp.values()).sort((a, b) => a.timestamp - b.timestamp); +} + +interface GenerationSplit { + renewable: number; + fossil: number; + nuclear: number; + other: number; + total: number; +} + +function computeGenerationSplit(data: PivotedRow[]): GenerationSplit { + const totals: GenerationSplit = { renewable: 0, fossil: 0, nuclear: 0, other: 0, total: 0 }; + + for (const row of data) { + for (const fuel of FUEL_TYPES) { + const mw = row[fuel]; + totals.total += mw; + if (RENEWABLE_FUELS.has(fuel)) { + totals.renewable += mw; + } else if (FOSSIL_FUELS.has(fuel)) { + totals.fossil += mw; + } else if (fuel === 'nuclear') { + totals.nuclear += mw; + } else { + totals.other += mw; + } + } + } + + return totals; +} + +interface GenerationChartProps { + initialData: ReturnType>; + initialRegion: string; + initialTimeRange: TimeRange; +} + +export function GenerationChart({ initialData, initialRegion, initialTimeRange }: GenerationChartProps) { + const [regionCode, setRegionCode] = useState(initialRegion); + const [timeRange, setTimeRange] = useState(initialTimeRange); + const [serializedData, setSerializedData] = useState(initialData); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const rows = useMemo(() => deserialize(serializedData), [serializedData]); + const chartData = useMemo(() => pivotGenerationData(rows, regionCode), [rows, regionCode]); + const split = useMemo(() => computeGenerationSplit(chartData), [chartData]); + + const refetch = useCallback((newRegion: string, newTimeRange: TimeRange) => { + startTransition(async () => { + setError(null); + const result = await fetchGenerationMix(newRegion, newTimeRange); + if (result.ok) { + setSerializedData(result.data); + } else { + setError(result.error); + } + }); + }, []); + + const handleRegionChange = useCallback( + (value: string) => { + setRegionCode(value); + refetch(value, timeRange); + }, + [refetch, timeRange], + ); + + const handleTimeRangeChange = useCallback( + (value: string) => { + if (!isTimeRange(value)) return; + setTimeRange(value); + refetch(regionCode, value); + }, + [refetch, regionCode], + ); + + const formatPercent = (value: number, total: number) => { + if (total === 0) return '0%'; + return `${((value / total) * 100).toFixed(1)}%`; + }; + + return ( +
+ + +
+
+ Generation by Fuel Type + + Stacked generation mix over time for {REGIONS.find(r => r.code === regionCode)?.name ?? regionCode} + +
+
+ + +
+
+
+ + {error &&
{error}
} + + {chartData.length === 0 && !isPending ? ( +
+

+ No generation data available for this region and time range. +

+
+ ) : ( +
+ + + + {FUEL_TYPES.map(fuel => ( + + + + + ))} + + + + (value >= 1000 ? `${(value / 1000).toFixed(0)}GW` : `${value}MW`)} + className="text-xs" + /> + { + const firstPayload: unknown = payload?.[0]?.payload; + if ( + firstPayload && + typeof firstPayload === 'object' && + 'timestamp' in firstPayload && + typeof firstPayload.timestamp === 'number' + ) { + return formatMarketTime(new Date(firstPayload.timestamp), regionCode); + } + return ''; + }} + formatter={(value, name) => { + const mw = typeof value === 'number' ? value : Number(value); + const nameStr = String(name); + const label = isFuelType(nameStr) ? FUEL_LABELS[nameStr] : nameStr; + return ( + + {label}: {mw.toLocaleString(undefined, { maximumFractionDigits: 0 })} MW + + ); + }} + /> + } + /> + {FUEL_TYPES.map(fuel => ( + + ))} + } /> + + +
+ )} +
+
+ + {chartData.length > 0 && ( +
+ + + + +
+ )} +
+ ); +} + +function SplitCard({ + label, + value, + total, + formatPercent, + colorClass, +}: { + label: string; + value: number; + total: number; + formatPercent: (v: number, t: number) => string; + colorClass: string; +}) { + return ( + + +

{label}

+

{formatPercent(value, total)}

+

+ {(value / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} GWh total +

+
+
+ ); +} diff --git a/src/components/charts/price-chart.tsx b/src/components/charts/price-chart.tsx new file mode 100644 index 0000000..5628a94 --- /dev/null +++ b/src/components/charts/price-chart.tsx @@ -0,0 +1,436 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts'; +import type { SuperJSONResult } from 'superjson'; + +import { TimeRangeSelector, type TimeRange } from '@/components/charts/time-range-selector.js'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart.js'; +import { deserialize } from '@/lib/superjson.js'; +import { cn } from '@/lib/utils.js'; + +interface PriceTrendRow { + timestamp: Date; + price_mwh: number; + demand_mw: number; + region_code: string; + region_name: string; +} + +interface CommodityRow { + commodity: string; + price: number; + unit: string; + timestamp: Date; + source: string; +} + +export interface AIMilestone { + date: string; + title: string; + description: string; + category: string; +} + +interface TimeRangeChangeResult { + prices: SuperJSONResult; + commodities: SuperJSONResult; +} + +interface PriceChartProps { + initialPriceData: SuperJSONResult; + initialCommodityData: SuperJSONResult; + milestones: AIMilestone[]; + initialTimeRange: TimeRange; + onTimeRangeChange: (range: TimeRange) => Promise; +} + +const REGION_COLORS: Record = { + PJM: 'hsl(210, 90%, 55%)', + ERCOT: 'hsl(25, 90%, 55%)', + CAISO: 'hsl(140, 70%, 45%)', + NYISO: 'hsl(280, 70%, 55%)', + ISONE: 'hsl(340, 70%, 55%)', + MISO: 'hsl(55, 80%, 50%)', + SPP: 'hsl(180, 60%, 45%)', +}; + +const COMMODITY_COLORS: Record = { + natural_gas: 'hsl(30, 100%, 70%)', + wti_crude: 'hsl(0, 70%, 60%)', + coal: 'hsl(0, 0%, 60%)', +}; + +const COMMODITY_LABELS: Record = { + natural_gas: 'Natural Gas ($/MMBtu)', + wti_crude: 'WTI Crude ($/bbl)', + coal: 'Coal ($/short ton)', +}; + +const MILESTONE_COLORS: Record = { + model_launch: 'hsl(280, 60%, 60%)', + infrastructure: 'hsl(200, 80%, 60%)', + market: 'hsl(140, 60%, 50%)', + policy: 'hsl(40, 80%, 55%)', +}; + +function buildChartConfig(regions: string[], commodities: string[], showCommodities: boolean): ChartConfig { + const config: ChartConfig = {}; + for (const region of regions) { + config[region] = { + label: region, + color: REGION_COLORS[region] ?? 'hsl(0, 0%, 50%)', + }; + } + if (showCommodities) { + for (const commodity of commodities) { + config[commodity] = { + label: COMMODITY_LABELS[commodity] ?? commodity, + color: COMMODITY_COLORS[commodity] ?? 'hsl(0, 0%, 60%)', + }; + } + } + return config; +} + +interface PivotedRow { + timestamp: number; + timestampDisplay: string; + [key: string]: number | string; +} + +function pivotData( + priceRows: PriceTrendRow[], + commodityRows: CommodityRow[], + showCommodities: boolean, +): { pivoted: PivotedRow[]; regions: string[]; commodities: string[] } { + const regionSet = new Set(); + const commoditySet = new Set(); + const byTimestamp = new Map(); + + for (const row of priceRows) { + regionSet.add(row.region_code); + const ts = row.timestamp.getTime(); + + if (!byTimestamp.has(ts)) { + byTimestamp.set(ts, { + timestamp: ts, + timestampDisplay: row.timestamp.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }), + }); + } + + const pivotRow = byTimestamp.get(ts)!; + pivotRow[row.region_code] = row.price_mwh; + } + + if (showCommodities) { + for (const row of commodityRows) { + commoditySet.add(row.commodity); + const ts = row.timestamp.getTime(); + + if (!byTimestamp.has(ts)) { + byTimestamp.set(ts, { + timestamp: ts, + timestampDisplay: row.timestamp.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }), + }); + } + + const pivotRow = byTimestamp.get(ts)!; + pivotRow[row.commodity] = row.price; + } + } + + const regions = Array.from(regionSet).sort(); + const commodities = Array.from(commoditySet).sort(); + const pivoted = Array.from(byTimestamp.values()).sort((a, b) => a.timestamp - b.timestamp); + + return { pivoted, regions, commodities }; +} + +function filterMilestonesByRange(milestones: AIMilestone[], pivoted: PivotedRow[]): AIMilestone[] { + if (pivoted.length === 0) return []; + const minTs = pivoted[0]!.timestamp; + const maxTs = pivoted[pivoted.length - 1]!.timestamp; + + return milestones.filter(m => { + const mTs = new Date(m.date).getTime(); + return mTs >= minTs && mTs <= maxTs; + }); +} + +export function PriceChart({ + initialPriceData, + initialCommodityData, + milestones, + initialTimeRange, + onTimeRangeChange, +}: PriceChartProps) { + const [pricesSerialized, setPricesSerialized] = useState(initialPriceData); + const [commoditiesSerialized, setCommoditiesSerialized] = useState(initialCommodityData); + const [timeRange, setTimeRange] = useState(initialTimeRange); + const [loading, setLoading] = useState(false); + const [disabledRegions, setDisabledRegions] = useState>(new Set()); + const [showCommodities, setShowCommodities] = useState(false); + const [showMilestones, setShowMilestones] = useState(true); + + const priceRows = useMemo(() => deserialize(pricesSerialized), [pricesSerialized]); + const commodityRows = useMemo(() => deserialize(commoditiesSerialized), [commoditiesSerialized]); + + const { pivoted, regions, commodities } = useMemo( + () => pivotData(priceRows, commodityRows, showCommodities), + [priceRows, commodityRows, showCommodities], + ); + + const chartConfig = useMemo( + () => buildChartConfig(regions, commodities, showCommodities), + [regions, commodities, showCommodities], + ); + + const activeRegions = useMemo(() => regions.filter(r => !disabledRegions.has(r)), [regions, disabledRegions]); + + const visibleMilestones = useMemo( + () => (showMilestones ? filterMilestonesByRange(milestones, pivoted) : []), + [milestones, pivoted, showMilestones], + ); + + async function handleTimeRangeChange(range: TimeRange) { + setTimeRange(range); + setLoading(true); + try { + const result = await onTimeRangeChange(range); + setPricesSerialized(result.prices); + setCommoditiesSerialized(result.commodities); + } finally { + setLoading(false); + } + } + + function toggleRegion(region: string) { + setDisabledRegions(prev => { + const next = new Set(prev); + if (next.has(region)) { + next.delete(region); + } else { + next.add(region); + } + return next; + }); + } + + if (pivoted.length === 0 && !showCommodities) { + return ( + + + Regional Electricity Prices + No price data available for the selected time range. + + +
+ +
+
+

+ No data available. Try ingesting electricity price data first. +

+
+
+
+ ); + } + + return ( + + +
+
+ Regional Electricity Prices + Price per MWh across ISO/RTO regions over time +
+ +
+
+ +
+ {regions.map(region => ( + + ))} + +
+ + + + +
+ +
+ + + + + `$${value}`} + label={{ + value: '$/MWh', + angle: -90, + position: 'insideLeft', + style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' }, + }} + /> + {showCommodities && ( + `$${value}`} + label={{ + value: 'Commodity Price', + angle: 90, + position: 'insideRight', + style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' }, + }} + /> + )} + (typeof label === 'string' || typeof label === 'number' ? label : '')} + formatter={(value, name) => { + const numVal = Number(value); + const nameStr = String(name); + if (nameStr in COMMODITY_LABELS) { + return [`$${numVal.toFixed(2)}`, undefined]; + } + return [`$${numVal.toFixed(2)}/MWh`, undefined]; + }} + /> + } + /> + } /> + + {activeRegions.map(region => ( + + ))} + + {showCommodities && + commodities.map(commodity => ( + + ))} + + {visibleMilestones.map(milestone => ( + + ))} + + +
+ + + ); +} diff --git a/src/components/charts/time-range-selector.tsx b/src/components/charts/time-range-selector.tsx new file mode 100644 index 0000000..8b25f68 --- /dev/null +++ b/src/components/charts/time-range-selector.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { cn } from '@/lib/utils.js'; + +export type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; + +const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [ + { value: '24h', label: '24H' }, + { value: '7d', label: '1W' }, + { value: '30d', label: '1M' }, + { value: '90d', label: '3M' }, + { value: '1y', label: '1Y' }, +]; + +interface TimeRangeSelectorProps { + value: TimeRange; + onChange: (range: TimeRange) => void; +} + +export function TimeRangeSelector({ value, onChange }: TimeRangeSelectorProps) { + return ( +
+ {TIME_RANGES.map(range => ( + + ))} +
+ ); +} diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 0000000..ee57737 --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,344 @@ +'use client'; + +import * as React from 'react'; +import type { LegendPayload, TooltipPayloadEntry } from 'recharts'; +import * as RechartsPrimitive from 'recharts'; + +import { cn } from '@/lib/utils.js'; + +type VerticalAlignmentType = 'top' | 'bottom' | 'middle'; +type ValueType = number | string | ReadonlyArray; +type NameType = number | string; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: '', dark: '.dark' } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ({ color?: string; theme?: never } | { color?: never; theme: Record }); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error('useChart must be used within a '); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps['children']; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; + + return ( + +
+ + {children} +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color); + + if (!colorConfig.length) { + return null; + } + + return ( +