diff --git a/docker-compose.yml b/docker-compose.yml index f295fe5..d35e750 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,13 @@ services: db: image: postgis/postgis:18-3.6 ports: - - "5433:5432" + - "127.0.0.1:5433:5432" environment: POSTGRES_DB: energy_dashboard POSTGRES_USER: energy POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - - pgdata:/var/lib/postgresql + - pgdata:/var/lib/postgresql/data volumes: pgdata: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 184bfa6..114706f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,6 +46,7 @@ model ElectricityPrice { timestamp DateTime @db.Timestamptz source String + @@unique([regionId, timestamp]) @@index([regionId, timestamp]) @@map("electricity_prices") } @@ -58,6 +59,7 @@ model CommodityPrice { timestamp DateTime @db.Timestamptz source String + @@unique([commodity, timestamp]) @@index([commodity, timestamp]) @@map("commodity_prices") } @@ -70,6 +72,7 @@ model GenerationMix { generationMw Float @map("generation_mw") timestamp DateTime @db.Timestamptz + @@unique([regionId, fuelType, timestamp]) @@index([regionId, timestamp]) @@map("generation_mix") } diff --git a/prisma/seed.ts b/prisma/seed.ts index cb8af72..a74d0dc 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -73,19 +73,21 @@ async function seedGridRegions() { const geojson = readAndParse('data/grid-regions.geojson', RegionCollectionSchema); - // Delete existing data (order matters for foreign keys) + // Delete only static seed data that doesn't have time-series FK references await prisma.$executeRawUnsafe('DELETE FROM datacenters'); - await prisma.$executeRawUnsafe('DELETE FROM electricity_prices'); - await prisma.$executeRawUnsafe('DELETE FROM generation_mix'); - await prisma.$executeRawUnsafe('DELETE FROM grid_regions'); + // Upsert grid_regions by code to preserve FK references from time-series tables for (const feature of geojson.features) { const id = randomUUID(); const geojsonStr = JSON.stringify(feature.geometry); await prisma.$executeRawUnsafe( `INSERT INTO grid_regions (id, name, code, iso, boundary, created_at) - VALUES ($1::uuid, $2, $3, $4, ST_GeomFromGeoJSON($5)::geography, NOW())`, + VALUES ($1::uuid, $2, $3, $4, ST_GeomFromGeoJSON($5)::geography, NOW()) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + iso = EXCLUDED.iso, + boundary = EXCLUDED.boundary`, id, feature.properties.name, feature.properties.code, @@ -93,7 +95,7 @@ async function seedGridRegions() { geojsonStr, ); - console.log(` Inserted region: ${feature.properties.code}`); + console.log(` Upserted region: ${feature.properties.code}`); } } diff --git a/prisma/sql/getDemandByRegion.sql b/prisma/sql/getDemandByRegion.sql index cb75c29..26d8101 100644 --- a/prisma/sql/getDemandByRegion.sql +++ b/prisma/sql/getDemandByRegion.sql @@ -1,15 +1,34 @@ -- @param {DateTime} $1:startDate -- @param {DateTime} $2:endDate +-- @param {String} $3:regionCode - pass 'ALL' to return all regions +WITH demand_agg AS ( + SELECT + ep.region_id, + date_trunc('day', ep.timestamp) AS day, + AVG(ep.demand_mw) AS avg_demand, + MAX(ep.demand_mw) AS peak_demand + FROM electricity_prices ep + WHERE ep.timestamp BETWEEN $1 AND $2 + GROUP BY ep.region_id, date_trunc('day', ep.timestamp) +), +dc_agg AS ( + SELECT + d.region_id, + COUNT(*)::INT AS datacenter_count, + COALESCE(SUM(d.capacity_mw), 0) AS total_dc_capacity_mw + FROM datacenters d + GROUP BY d.region_id +) SELECT - r.code as region_code, r.name as region_name, - date_trunc('day', ep.timestamp) as day, - AVG(ep.demand_mw) as avg_demand, - MAX(ep.demand_mw) as peak_demand, - COUNT(DISTINCT d.id)::INT as datacenter_count, - COALESCE(SUM(DISTINCT d.capacity_mw), 0) as total_dc_capacity_mw + r.code AS region_code, + r.name AS region_name, + da.day, + da.avg_demand, + da.peak_demand, + COALESCE(dc.datacenter_count, 0)::INT AS datacenter_count, + COALESCE(dc.total_dc_capacity_mw, 0) AS total_dc_capacity_mw FROM grid_regions r -LEFT JOIN electricity_prices ep ON ep.region_id = r.id - AND ep.timestamp BETWEEN $1 AND $2 -LEFT JOIN datacenters d ON d.region_id = r.id -GROUP BY r.id, r.code, r.name, date_trunc('day', ep.timestamp) -ORDER BY r.code, day +INNER JOIN demand_agg da ON da.region_id = r.id +LEFT JOIN dc_agg dc ON dc.region_id = r.id +WHERE ($3 = 'ALL' OR r.code = $3) +ORDER BY r.code, da.day diff --git a/prisma/sql/getRegionPriceHeatmap.sql b/prisma/sql/getRegionPriceHeatmap.sql index 1688de0..63c44b4 100644 --- a/prisma/sql/getRegionPriceHeatmap.sql +++ b/prisma/sql/getRegionPriceHeatmap.sql @@ -1,13 +1,30 @@ +WITH price_agg AS ( + SELECT + ep.region_id, + AVG(ep.price_mwh) AS avg_price, + MAX(ep.price_mwh) AS max_price, + AVG(ep.demand_mw) AS avg_demand + FROM electricity_prices ep + WHERE ep.timestamp > NOW() - INTERVAL '24 hours' + GROUP BY ep.region_id +), +dc_agg AS ( + SELECT + d.region_id, + COUNT(*)::INT AS datacenter_count, + COALESCE(SUM(d.capacity_mw), 0) AS total_dc_capacity_mw + FROM datacenters d + GROUP BY d.region_id +) SELECT - r.code, r.name, - ST_AsGeoJSON(r.boundary)::TEXT as boundary_geojson, - AVG(ep.price_mwh) as avg_price, - MAX(ep.price_mwh) as max_price, - AVG(ep.demand_mw) as avg_demand, - COUNT(DISTINCT d.id)::INT as datacenter_count, - COALESCE(SUM(d.capacity_mw), 0) as total_dc_capacity_mw + r.code, + r.name, + ST_AsGeoJSON(r.boundary)::TEXT AS boundary_geojson, + pa.avg_price, + pa.max_price, + pa.avg_demand, + COALESCE(dc.datacenter_count, 0)::INT AS datacenter_count, + COALESCE(dc.total_dc_capacity_mw, 0) AS total_dc_capacity_mw FROM grid_regions r -LEFT JOIN electricity_prices ep ON ep.region_id = r.id - AND ep.timestamp > NOW() - INTERVAL '24 hours' -LEFT JOIN datacenters d ON d.region_id = r.id -GROUP BY r.id, r.code, r.name, r.boundary +LEFT JOIN price_agg pa ON pa.region_id = r.id +LEFT JOIN dc_agg dc ON dc.region_id = r.id diff --git a/scripts/backfill.ts b/scripts/backfill.ts index a13072a..c210b4a 100644 --- a/scripts/backfill.ts +++ b/scripts/backfill.ts @@ -13,7 +13,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '../src/generated/prisma/client.js'; import * as eia from '../src/lib/api/eia.js'; -import { getFuelTypeData, getRegionData } from '../src/lib/api/eia.js'; +import { getFuelTypeData, getRegionData, getRetailElectricityPrices } from '../src/lib/api/eia.js'; import * as fred from '../src/lib/api/fred.js'; import type { RegionCode } from '../src/lib/schemas/electricity.js'; @@ -46,7 +46,7 @@ function log(msg: string): void { // --------------------------------------------------------------------------- async function backfillElectricity(): Promise { - log('=== Backfilling electricity demand data ==='); + log('=== Backfilling electricity demand + price data ==='); const gridRegions = await prisma.gridRegion.findMany({ select: { id: true, code: true }, @@ -56,6 +56,40 @@ async function backfillElectricity(): Promise { const start = sixMonthsAgoIso(); const end = todayIso(); + // Fetch monthly retail electricity prices for all regions upfront + // Key: "REGION:YYYY-MM" -> $/MWh + const retailPriceByRegionMonth = new Map(); + log(' Fetching retail electricity prices...'); + try { + const startMonth = start.slice(0, 7); // YYYY-MM + const endMonth = end.slice(0, 7); + const retailPrices = await getRetailElectricityPrices({ start: startMonth, end: endMonth }); + for (const rp of retailPrices) { + retailPriceByRegionMonth.set(`${rp.regionCode}:${rp.period}`, rp.priceMwh); + } + log(` Retail prices: ${retailPrices.length} records for ${retailPriceByRegionMonth.size} region-months`); + } catch (err) { + log(` ERROR fetching retail prices: ${err instanceof Error ? err.message : String(err)}`); + } + + // Build a fallback: for each region, find the most recent month with data + const latestPriceByRegion = new Map(); + for (const [key, price] of retailPriceByRegionMonth) { + const region = key.split(':')[0]!; + const existing = latestPriceByRegion.get(region); + // Since keys are "REGION:YYYY-MM", the latest month lexicographically is the most recent + if (!existing || key > `${region}:${existing}`) { + latestPriceByRegion.set(region, price); + } + } + + /** Look up price for a region+month, falling back to latest known price */ + function getRetailPrice(region: string, month: string): number { + return retailPriceByRegionMonth.get(`${region}:${month}`) ?? latestPriceByRegion.get(region) ?? 0; + } + + await sleep(200); + for (const regionCode of ALL_REGIONS) { const regionId = regionIdByCode.get(regionCode); if (!regionId) { @@ -81,6 +115,9 @@ async function backfillElectricity(): Promise { }); const existingByTime = new Map(existing.map(e => [e.timestamp.getTime(), e.id])); + // Find peak demand for demand-based price variation + const peakDemand = Math.max(...validPoints.map(p => p.valueMw)); + const toCreate: Array<{ regionId: string; priceMwh: number; @@ -88,16 +125,22 @@ async function backfillElectricity(): Promise { timestamp: Date; source: string; }> = []; - const toUpdate: Array<{ id: string; demandMw: number }> = []; + const toUpdate: Array<{ id: string; demandMw: number; priceMwh: number }> = []; for (const point of validPoints) { + const month = point.timestamp.toISOString().slice(0, 7); + const basePrice = getRetailPrice(regionCode, month); + // Add demand-based variation: scale price between 0.8x and 1.2x based on demand + const demandRatio = peakDemand > 0 ? point.valueMw / peakDemand : 0.5; + const priceMwh = basePrice > 0 ? basePrice * (0.8 + 0.4 * demandRatio) : 0; + const existingId = existingByTime.get(point.timestamp.getTime()); if (existingId) { - toUpdate.push({ id: existingId, demandMw: point.valueMw }); + toUpdate.push({ id: existingId, demandMw: point.valueMw, priceMwh }); } else { toCreate.push({ regionId, - priceMwh: 0, // TODO: No real-time wholesale price available from EIA + priceMwh, demandMw: point.valueMw, timestamp: point.timestamp, source: 'EIA', @@ -119,7 +162,7 @@ async function backfillElectricity(): Promise { chunk.map(u => prisma.electricityPrice.update({ where: { id: u.id }, - data: { demandMw: u.demandMw, source: 'EIA' }, + data: { demandMw: u.demandMw, priceMwh: u.priceMwh, source: 'EIA' }, }), ), ); diff --git a/src/actions/demand.ts b/src/actions/demand.ts index 8da653e..10a07e7 100644 --- a/src/actions/demand.ts +++ b/src/actions/demand.ts @@ -3,6 +3,7 @@ import { getDemandByRegion } from '@/generated/prisma/sql.js'; import { prisma } from '@/lib/db.js'; import { serialize } from '@/lib/superjson.js'; +import { validateRegionCode } from '@/lib/utils.js'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; @@ -35,11 +36,13 @@ export async function fetchDemandByRegion( timeRange: TimeRange = '30d', ): Promise> { try { + if (!validateRegionCode(regionCode)) { + return { ok: false, error: `Invalid region code: ${regionCode}` }; + } const startDate = timeRangeToStartDate(timeRange); const endDate = new Date(); - const rows = await prisma.$queryRawTyped(getDemandByRegion(startDate, endDate)); - const filtered = regionCode === 'ALL' ? rows : rows.filter(r => r.region_code === regionCode); - return { ok: true, data: serialize(filtered) }; + const rows = await prisma.$queryRawTyped(getDemandByRegion(startDate, endDate, regionCode)); + return { ok: true, data: serialize(rows) }; } catch (err) { return { ok: false, @@ -52,7 +55,7 @@ export async function fetchRegionDemandSummary(): Promise> { try { + if (!validateRegionCode(regionCode)) { + return { ok: false, error: `Invalid region code: ${regionCode}` }; + } const startDate = timeRangeToStartDate(timeRange); const endDate = new Date(); const rows = await prisma.$queryRawTyped(getGenerationMix(regionCode, startDate, endDate)); diff --git a/src/actions/prices.ts b/src/actions/prices.ts index 55bdd75..871c3ef 100644 --- a/src/actions/prices.ts +++ b/src/actions/prices.ts @@ -3,6 +3,7 @@ import { getLatestPrices, getPriceTrends, getRegionPriceHeatmap } from '@/generated/prisma/sql.js'; import { prisma } from '@/lib/db.js'; import { serialize } from '@/lib/superjson.js'; +import { validateRegionCode } from '@/lib/utils.js'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; @@ -47,6 +48,9 @@ export async function fetchPriceTrends( timeRange: TimeRange = '30d', ): Promise> { try { + if (!validateRegionCode(regionCode)) { + return { ok: false, error: `Invalid region code: ${regionCode}` }; + } const startDate = timeRangeToStartDate(timeRange); const endDate = new Date(); const rows = await prisma.$queryRawTyped(getPriceTrends(regionCode, startDate, endDate)); @@ -156,6 +160,214 @@ export async function fetchRegionCapacityVsPrice(): Promise< } } +export async function fetchPriceSparklines(): Promise< + ActionResult< + Array<{ + region_code: string; + points: { value: number }[]; + }> + > +> { + try { + const startDate = timeRangeToStartDate('7d'); + const endDate = new Date(); + const regions = await prisma.gridRegion.findMany({ select: { code: true } }); + const results = await Promise.all( + regions.map(async r => { + const rows = await prisma.$queryRawTyped(getPriceTrends(r.code, startDate, endDate)); + return { + region_code: r.code, + points: rows.map(row => ({ value: row.price_mwh })), + }; + }), + ); + return { ok: true, data: serialize(results) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch price sparklines: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +export async function fetchRecentAlerts(): Promise< + ActionResult< + Array<{ + id: string; + type: 'price_spike' | 'demand_peak' | 'price_drop'; + severity: 'critical' | 'warning' | 'info'; + region_code: string; + region_name: string; + description: string; + value: number; + unit: string; + timestamp: Date; + }> + > +> { + try { + const since = timeRangeToStartDate('7d'); + const priceRows = await prisma.electricityPrice.findMany({ + where: { timestamp: { gte: since } }, + orderBy: { timestamp: 'desc' }, + take: 500, + include: { region: { select: { code: true, name: true } } }, + }); + + const alerts: Array<{ + id: string; + type: 'price_spike' | 'demand_peak' | 'price_drop'; + severity: 'critical' | 'warning' | 'info'; + region_code: string; + region_name: string; + description: string; + value: number; + unit: string; + timestamp: Date; + }> = []; + + // Detect price spikes (above $80/MWh) and demand peaks + const regionAvgs = new Map(); + for (const row of priceRows) { + const entry = regionAvgs.get(row.region.code) ?? { sum: 0, count: 0 }; + entry.sum += row.priceMwh; + entry.count += 1; + regionAvgs.set(row.region.code, entry); + } + + for (const row of priceRows) { + const avg = regionAvgs.get(row.region.code); + const avgPrice = avg ? avg.sum / avg.count : 0; + + if (row.priceMwh >= 100) { + alerts.push({ + id: `spike-${row.id}`, + type: 'price_spike', + severity: 'critical', + region_code: row.region.code, + region_name: row.region.name, + description: `${row.region.code} electricity hit $${row.priceMwh.toFixed(2)}/MWh`, + value: row.priceMwh, + unit: '$/MWh', + timestamp: row.timestamp, + }); + } else if (row.priceMwh >= 80) { + alerts.push({ + id: `spike-${row.id}`, + type: 'price_spike', + severity: 'warning', + region_code: row.region.code, + region_name: row.region.name, + description: `${row.region.code} electricity at $${row.priceMwh.toFixed(2)}/MWh`, + value: row.priceMwh, + unit: '$/MWh', + timestamp: row.timestamp, + }); + } else if (avgPrice > 0 && row.priceMwh < avgPrice * 0.7) { + alerts.push({ + id: `drop-${row.id}`, + type: 'price_drop', + severity: 'info', + region_code: row.region.code, + region_name: row.region.name, + description: `${row.region.code} price dropped to $${row.priceMwh.toFixed(2)}/MWh (avg $${avgPrice.toFixed(2)})`, + value: row.priceMwh, + unit: '$/MWh', + timestamp: row.timestamp, + }); + } + + if (row.demandMw >= 50000) { + alerts.push({ + id: `demand-${row.id}`, + type: 'demand_peak', + severity: row.demandMw >= 70000 ? 'critical' : 'warning', + region_code: row.region.code, + region_name: row.region.name, + description: `${row.region.code} demand peaked at ${(row.demandMw / 1000).toFixed(1)} GW`, + value: row.demandMw, + unit: 'MW', + timestamp: row.timestamp, + }); + } + } + + // Sort by timestamp desc and limit + alerts.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + const limited = alerts.slice(0, 50); + + return { ok: true, data: serialize(limited) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch recent alerts: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +export interface TickerPriceRow { + region_code: string; + price_mwh: number; + prev_price_mwh: number | null; +} + +export interface TickerCommodityRow { + commodity: string; + price: number; + prev_price: number | null; + unit: string; +} + +export async function fetchTickerPrices(): Promise< + ActionResult<{ electricity: TickerPriceRow[]; commodities: TickerCommodityRow[] }> +> { + try { + // Get the two most recent prices per region using a window function via raw SQL + const electricityRows = await prisma.$queryRaw< + Array<{ region_code: string; price_mwh: number; prev_price_mwh: number | null }> + >` + SELECT region_code, price_mwh, prev_price_mwh + FROM ( + SELECT + r.code AS region_code, + ep.price_mwh, + LAG(ep.price_mwh) OVER (PARTITION BY ep.region_id ORDER BY ep.timestamp ASC) AS prev_price_mwh, + ROW_NUMBER() OVER (PARTITION BY ep.region_id ORDER BY ep.timestamp DESC) AS rn + FROM electricity_prices ep + JOIN grid_regions r ON ep.region_id = r.id + ) sub + WHERE rn = 1 + `; + + // Get the two most recent commodity prices per commodity + const commodityRows = await prisma.$queryRaw< + Array<{ commodity: string; price: number; prev_price: number | null; unit: string }> + >` + SELECT commodity, price, prev_price, unit + FROM ( + SELECT + commodity, + price, + unit, + LAG(price) OVER (PARTITION BY commodity ORDER BY timestamp ASC) AS prev_price, + ROW_NUMBER() OVER (PARTITION BY commodity ORDER BY timestamp DESC) AS rn + FROM commodity_prices + ) sub + WHERE rn = 1 + `; + + return { + ok: true, + data: serialize({ electricity: electricityRows, commodities: commodityRows }), + }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch ticker prices: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + export async function fetchLatestCommodityPrices(): Promise< ActionResult< Array<{ diff --git a/src/app/api/ingest/auth.ts b/src/app/api/ingest/auth.ts new file mode 100644 index 0000000..312d0c6 --- /dev/null +++ b/src/app/api/ingest/auth.ts @@ -0,0 +1,24 @@ +import { NextResponse, type NextRequest } from 'next/server.js'; + +/** + * Validates the Bearer token in the Authorization header against INGEST_SECRET. + * Returns null if auth succeeds, or a 401 NextResponse if it fails. + */ +export function checkIngestAuth(request: NextRequest): NextResponse | null { + const secret = process.env.INGEST_SECRET; + if (!secret) { + return NextResponse.json({ error: 'INGEST_SECRET is not configured' }, { status: 500 }); + } + + const authHeader = request.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return NextResponse.json({ error: 'Missing or invalid Authorization header' }, { status: 401 }); + } + + const token = authHeader.slice(7); + if (token !== secret) { + return NextResponse.json({ error: 'Invalid bearer token' }, { status: 401 }); + } + + return null; +} diff --git a/src/app/api/ingest/commodities/route.ts b/src/app/api/ingest/commodities/route.ts index 8ef90b6..072b605 100644 --- a/src/app/api/ingest/commodities/route.ts +++ b/src/app/api/ingest/commodities/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server.js'; +import { checkIngestAuth } from '@/app/api/ingest/auth.js'; import * as eia from '@/lib/api/eia.js'; import * as fred from '@/lib/api/fred.js'; import { prisma } from '@/lib/db.js'; @@ -112,7 +113,10 @@ async function fetchAllCommodities(start?: string, end?: string): Promise<{ rows return { rows, errors }; } -export async function GET(request: NextRequest): Promise> { +export async function GET(request: NextRequest): Promise { + const authError = checkIngestAuth(request); + if (authError) return authError; + const searchParams = request.nextUrl.searchParams; const start = searchParams.get('start') ?? undefined; const end = searchParams.get('end') ?? undefined; diff --git a/src/app/api/ingest/electricity/route.ts b/src/app/api/ingest/electricity/route.ts index dffb9f6..8318725 100644 --- a/src/app/api/ingest/electricity/route.ts +++ b/src/app/api/ingest/electricity/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server.js'; -import { getRegionData } from '@/lib/api/eia.js'; +import { checkIngestAuth } from '@/app/api/ingest/auth.js'; +import { getRegionData, getRetailElectricityPrices } from '@/lib/api/eia.js'; import { prisma } from '@/lib/db.js'; import { EIA_RESPONDENT_CODES, type RegionCode } from '@/lib/schemas/electricity.js'; @@ -16,7 +17,10 @@ interface IngestionStats { errors: number; } -export async function GET(request: NextRequest): Promise> { +export async function GET(request: NextRequest): Promise { + const authError = checkIngestAuth(request); + if (authError) return authError; + const searchParams = request.nextUrl.searchParams; const regionParam = searchParams.get('region'); const start = searchParams.get('start') ?? undefined; @@ -41,6 +45,29 @@ export async function GET(request: NextRequest): Promise [r.code, r.id])); + // Fetch retail electricity prices (monthly) to apply to hourly records. + // Key: "REGION:YYYY-MM" -> $/MWh + const retailPriceByRegionMonth = new Map(); + try { + const retailPrices = await getRetailElectricityPrices({ start, end }); + for (const rp of retailPrices) { + retailPriceByRegionMonth.set(`${rp.regionCode}:${rp.period}`, rp.priceMwh); + } + } catch (err) { + console.error('Failed to fetch retail electricity prices:', err); + // Continue with demand data even if prices fail + } + + // Build fallback: for each region, find the most recent month with data + const latestPriceByRegion = new Map(); + for (const [key, price] of retailPriceByRegionMonth) { + const region = key.split(':')[0]!; + const existing = latestPriceByRegion.get(region); + if (!existing || key > `${region}:${existing}`) { + latestPriceByRegion.set(region, price); + } + } + for (const regionCode of regions) { const regionId = regionIdByCode.get(regionCode); if (!regionId) { @@ -65,6 +92,9 @@ export async function GET(request: NextRequest): Promise [e.timestamp.getTime(), e.id])); + // Find peak demand for this batch for demand-based price variation + const peakDemand = Math.max(...validPoints.map(p => p.valueMw)); + const toCreate: Array<{ regionId: string; priceMwh: number; @@ -72,16 +102,23 @@ export async function GET(request: NextRequest): Promise = []; - const toUpdate: Array<{ id: string; demandMw: number }> = []; + const toUpdate: Array<{ id: string; demandMw: number; priceMwh: number }> = []; for (const point of validPoints) { + const month = point.timestamp.toISOString().slice(0, 7); + const basePrice = + retailPriceByRegionMonth.get(`${regionCode}:${month}`) ?? latestPriceByRegion.get(regionCode) ?? 0; + // Add demand-based variation: scale price between 0.8x and 1.2x based on demand + const demandRatio = peakDemand > 0 ? point.valueMw / peakDemand : 0.5; + const priceMwh = basePrice > 0 ? basePrice * (0.8 + 0.4 * demandRatio) : 0; + const existingId = existingByTime.get(point.timestamp.getTime()); if (existingId) { - toUpdate.push({ id: existingId, demandMw: point.valueMw }); + toUpdate.push({ id: existingId, demandMw: point.valueMw, priceMwh }); } else { toCreate.push({ regionId, - priceMwh: 0, + priceMwh, demandMw: point.valueMw, timestamp: point.timestamp, source: 'EIA', @@ -100,7 +137,7 @@ export async function GET(request: NextRequest): Promise prisma.electricityPrice.update({ where: { id: u.id }, - data: { demandMw: u.demandMw, source: 'EIA' }, + data: { demandMw: u.demandMw, priceMwh: u.priceMwh, source: 'EIA' }, }), ), ); diff --git a/src/app/api/ingest/generation/route.ts b/src/app/api/ingest/generation/route.ts index 9f905d4..2e47ce6 100644 --- a/src/app/api/ingest/generation/route.ts +++ b/src/app/api/ingest/generation/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server.js'; +import { checkIngestAuth } from '@/app/api/ingest/auth.js'; import { getFuelTypeData } from '@/lib/api/eia.js'; import { prisma } from '@/lib/db.js'; import { EIA_RESPONDENT_CODES, type RegionCode } from '@/lib/schemas/electricity.js'; @@ -16,7 +17,10 @@ interface IngestionStats { errors: number; } -export async function GET(request: NextRequest): Promise> { +export async function GET(request: NextRequest): Promise { + const authError = checkIngestAuth(request); + if (authError) return authError; + const searchParams = request.nextUrl.searchParams; const regionParam = searchParams.get('region'); const start = searchParams.get('start') ?? undefined; diff --git a/src/app/page.tsx b/src/app/page.tsx index 9b9d861..da34301 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,14 +1,21 @@ +import { Sparkline } from '@/components/charts/sparkline.js'; +import { AlertsFeed } from '@/components/dashboard/alerts-feed.js'; 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, Gauge, Map, Server } from 'lucide-react'; +import { Activity, ArrowRight, BarChart3, Droplets, Flame, Gauge, Map as MapIcon, Server } from 'lucide-react'; import Link from 'next/link'; import { fetchDatacenters } from '@/actions/datacenters.js'; import { fetchRegionDemandSummary } from '@/actions/demand.js'; -import { fetchLatestCommodityPrices, fetchLatestPrices } from '@/actions/prices.js'; +import { + fetchLatestCommodityPrices, + fetchLatestPrices, + fetchPriceSparklines, + fetchRecentAlerts, +} from '@/actions/prices.js'; function formatNumber(value: number, decimals = 1): string { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M`; @@ -17,12 +24,15 @@ function formatNumber(value: number, decimals = 1): string { } export default async function DashboardHome() { - const [pricesResult, commoditiesResult, datacentersResult, demandResult] = await Promise.all([ - fetchLatestPrices(), - fetchLatestCommodityPrices(), - fetchDatacenters(), - fetchRegionDemandSummary(), - ]); + const [pricesResult, commoditiesResult, datacentersResult, demandResult, sparklinesResult, alertsResult] = + await Promise.all([ + fetchLatestPrices(), + fetchLatestCommodityPrices(), + fetchDatacenters(), + fetchRegionDemandSummary(), + fetchPriceSparklines(), + fetchRecentAlerts(), + ]); const prices = pricesResult.ok ? deserialize< @@ -55,6 +65,24 @@ export default async function DashboardHome() { >(demandResult.data) : []; + const sparklines = sparklinesResult.ok + ? deserialize>(sparklinesResult.data) + : []; + + const sparklineMap: Record = {}; + for (const s of sparklines) { + sparklineMap[s.region_code] = s.points; + } + + // Build an aggregate sparkline from all regions (average price per time slot) + const avgSparkline: { value: number }[] = + 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 ? values.reduce((a, b) => a + b, 0) / values.length : 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'); @@ -114,20 +142,35 @@ export default async function DashboardHome() { 0 ? `$${avgPrice.toFixed(2)}` : '--'} + numericValue={avgPrice > 0 ? avgPrice : undefined} + animatedFormat={avgPrice > 0 ? 'dollar' : undefined} unit="/MWh" icon={BarChart3} + sparklineData={avgSparkline} + sparklineColor="hsl(210, 90%, 55%)" + /> + 0 ? totalCapacityMw : undefined} + animatedFormat={totalCapacityMw > 0 ? 'compact' : undefined} + unit="MW" + icon={Activity} /> - @@ -138,7 +181,7 @@ export default async function DashboardHome() { - + Interactive Map @@ -163,15 +206,23 @@ export default async function DashboardHome() { {prices.length > 0 ? (
- {prices.map(p => ( -
- {p.region_name} -
- ${p.price_mwh.toFixed(2)} - /MWh + {prices.map(p => { + const regionSparkline = sparklineMap[p.region_code]; + return ( +
+ {p.region_code} +
+ {regionSparkline && regionSparkline.length >= 2 && ( + + )} +
+
+ ${p.price_mwh.toFixed(2)} + /MWh +
-
- ))} + ); + })}
) : (

No price data available yet.

@@ -212,8 +263,10 @@ export default async function DashboardHome() {
)} - {avgDemand > 0 && ( -
+
+ {alertsResult.ok && } + + {avgDemand > 0 && ( @@ -228,8 +281,8 @@ export default async function DashboardHome() {

-
- )} + )} +
); } diff --git a/src/components/charts/price-chart.tsx b/src/components/charts/price-chart.tsx index 5628a94..4765d6e 100644 --- a/src/components/charts/price-chart.tsx +++ b/src/components/charts/price-chart.tsx @@ -165,15 +165,36 @@ function pivotData( return { pivoted, regions, commodities }; } -function filterMilestonesByRange(milestones: AIMilestone[], pivoted: PivotedRow[]): AIMilestone[] { +interface ResolvedMilestone extends AIMilestone { + /** The timestampDisplay value from the nearest data point — used as the ReferenceLine x. */ + xDisplay: string; +} + +function resolveMilestonesInRange(milestones: AIMilestone[], pivoted: PivotedRow[]): ResolvedMilestone[] { if (pivoted.length === 0) return []; const minTs = pivoted[0]!.timestamp; const maxTs = pivoted[pivoted.length - 1]!.timestamp; - return milestones.filter(m => { + const resolved: ResolvedMilestone[] = []; + for (const m of milestones) { const mTs = new Date(m.date).getTime(); - return mTs >= minTs && mTs <= maxTs; - }); + if (mTs < minTs || mTs > maxTs) continue; + + // Find the nearest data point by timestamp + let closest = pivoted[0]!; + let closestDist = Math.abs(closest.timestamp - mTs); + for (const row of pivoted) { + const dist = Math.abs(row.timestamp - mTs); + if (dist < closestDist) { + closest = row; + closestDist = dist; + } + } + + resolved.push({ ...m, xDisplay: closest.timestampDisplay }); + } + + return resolved; } export function PriceChart({ @@ -207,7 +228,7 @@ export function PriceChart({ const activeRegions = useMemo(() => regions.filter(r => !disabledRegions.has(r)), [regions, disabledRegions]); const visibleMilestones = useMemo( - () => (showMilestones ? filterMilestonesByRange(milestones, pivoted) : []), + () => (showMilestones ? resolveMilestonesInRange(milestones, pivoted) : []), [milestones, pivoted, showMilestones], ); @@ -407,12 +428,7 @@ export function PriceChart({ {visibleMilestones.map(milestone => ( + + + + + ); +} diff --git a/src/components/dashboard/alerts-feed.tsx b/src/components/dashboard/alerts-feed.tsx new file mode 100644 index 0000000..213af15 --- /dev/null +++ b/src/components/dashboard/alerts-feed.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { cn } from '@/lib/utils.js'; +import { AlertTriangle, ArrowDown, TrendingUp, Zap } from 'lucide-react'; +import type { SuperJSONResult } from 'superjson'; + +import { deserialize } from '@/lib/superjson.js'; + +interface AlertItem { + id: string; + type: 'price_spike' | 'demand_peak' | 'price_drop'; + severity: 'critical' | 'warning' | 'info'; + region_code: string; + region_name: string; + description: string; + value: number; + unit: string; + timestamp: Date; +} + +interface AlertsFeedProps { + initialData: SuperJSONResult; + className?: string; +} + +const SEVERITY_STYLES: Record = { + critical: { dot: 'bg-red-500', bg: 'bg-red-500/10' }, + warning: { dot: 'bg-amber-500', bg: 'bg-amber-500/10' }, + info: { dot: 'bg-blue-500', bg: 'bg-blue-500/10' }, +}; + +const TYPE_ICONS: Record = { + price_spike: Zap, + demand_peak: TrendingUp, + price_drop: ArrowDown, +}; + +function formatRelativeTime(date: Date): string { + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +export function AlertsFeed({ initialData, className }: AlertsFeedProps) { + const alerts = deserialize(initialData); + + if (alerts.length === 0) { + return ( + + + + + Recent Alerts + + + +

No notable events in the past 7 days.

+
+
+ ); + } + + return ( + + + + + Recent Alerts + + + +
+
+ {alerts.map(alert => { + const styles = SEVERITY_STYLES[alert.severity]; + const Icon = TYPE_ICONS[alert.type]; + + return ( +
+
+ + +
+
+

{alert.description}

+
+ {alert.region_code} + · + {formatRelativeTime(alert.timestamp)} +
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/src/components/dashboard/metric-card.tsx b/src/components/dashboard/metric-card.tsx index 386b2ec..01727b9 100644 --- a/src/components/dashboard/metric-card.tsx +++ b/src/components/dashboard/metric-card.tsx @@ -1,16 +1,54 @@ +'use client'; + +import { Sparkline } from '@/components/charts/sparkline.js'; +import { AnimatedNumber } from '@/components/dashboard/animated-number.js'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; import { cn } from '@/lib/utils.js'; import type { LucideIcon } from 'lucide-react'; +import { useCallback } from 'react'; + +type AnimatedFormat = 'dollar' | 'compact' | 'integer'; + +const FORMAT_FNS: Record string> = { + dollar: (n: number) => `$${n.toFixed(2)}`, + compact: (n: number) => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toFixed(1); + }, + integer: (n: number) => Math.round(n).toLocaleString(), +}; interface MetricCardProps { title: string; value: string; + /** When provided with animatedFormat, the value animates via spring physics on change. */ + numericValue?: number; + /** Named format preset for the animated value. */ + animatedFormat?: AnimatedFormat; unit?: string; icon: LucideIcon; className?: string; + sparklineData?: { value: number }[]; + sparklineColor?: string; } -export function MetricCard({ title, value, unit, icon: Icon, className }: MetricCardProps) { +export function MetricCard({ + title, + value, + numericValue, + animatedFormat, + unit, + icon: Icon, + className, + sparklineData, + sparklineColor, +}: MetricCardProps) { + const formatFn = useCallback( + (n: number) => (animatedFormat ? FORMAT_FNS[animatedFormat](n) : n.toFixed(2)), + [animatedFormat], + ); + return ( @@ -21,9 +59,18 @@ export function MetricCard({ title, value, unit, icon: Icon, className }: Metric
- {value} + {numericValue !== undefined && animatedFormat ? ( + + ) : ( + {value} + )} {unit && {unit}}
+ {sparklineData && sparklineData.length >= 2 && ( +
+ +
+ )}
); diff --git a/src/components/dashboard/ticker-tape.tsx b/src/components/dashboard/ticker-tape.tsx index 8e79a09..f6e8723 100644 --- a/src/components/dashboard/ticker-tape.tsx +++ b/src/components/dashboard/ticker-tape.tsx @@ -1,7 +1,6 @@ 'use client'; -import { fetchLatestCommodityPrices, fetchLatestPrices } from '@/actions/prices.js'; -import type { getLatestPrices } from '@/generated/prisma/sql.js'; +import { fetchTickerPrices, type TickerCommodityRow, type TickerPriceRow } from '@/actions/prices.js'; import { deserialize } from '@/lib/superjson.js'; import { cn } from '@/lib/utils.js'; import { useEffect, useState } from 'react'; @@ -13,13 +12,15 @@ interface TickerItem { unit: string; } -function formatPrice(price: number, unit: string): string { - if (unit === '$/MWh') { - return `$${price.toFixed(2)}`; - } +function formatPrice(price: number): string { return `$${price.toFixed(2)}`; } +function computeChangePercent(current: number, previous: number | null): number | null { + if (previous === null || previous === 0) return null; + return ((current - previous) / previous) * 100; +} + function TickerItemDisplay({ item }: { item: TickerItem }) { const changeColor = item.change === null ? 'text-muted-foreground' : item.change >= 0 ? 'text-emerald-400' : 'text-red-400'; @@ -41,52 +42,44 @@ function TickerItemDisplay({ item }: { item: TickerItem }) { ); } +const COMMODITY_LABELS: Record = { + natural_gas: 'Nat Gas', + wti_crude: 'WTI Crude', + coal: 'Coal', +}; + export function TickerTape() { const [items, setItems] = useState([]); useEffect(() => { async function loadPrices() { - const [priceResult, commodityResult] = await Promise.all([fetchLatestPrices(), fetchLatestCommodityPrices()]); + const result = await fetchTickerPrices(); + + if (!result.ok) return; + + const { electricity, commodities } = deserialize<{ + electricity: TickerPriceRow[]; + commodities: TickerCommodityRow[]; + }>(result.data); const tickerItems: TickerItem[] = []; - if (priceResult.ok) { - const prices = deserialize(priceResult.data); - for (const p of prices) { - tickerItems.push({ - label: p.region_code, - price: formatPrice(p.price_mwh, '$/MWh'), - change: null, - unit: '$/MWh', - }); - } + for (const p of electricity) { + tickerItems.push({ + label: p.region_code, + price: formatPrice(p.price_mwh), + change: computeChangePercent(p.price_mwh, p.prev_price_mwh), + unit: '$/MWh', + }); } - if (commodityResult.ok) { - const commodities = deserialize< - Array<{ - commodity: string; - price: number; - unit: string; - timestamp: Date; - source: string; - }> - >(commodityResult.data); - - const commodityLabels: Record = { - 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, - }); - } + for (const c of commodities) { + tickerItems.push({ + label: COMMODITY_LABELS[c.commodity] ?? c.commodity, + price: formatPrice(c.price), + change: computeChangePercent(c.price, c.prev_price), + unit: c.unit, + }); } setItems(tickerItems); diff --git a/src/lib/api/eia.ts b/src/lib/api/eia.ts index 034bad9..ffce9d3 100644 --- a/src/lib/api/eia.ts +++ b/src/lib/api/eia.ts @@ -9,10 +9,13 @@ import { type EiaFuelTypeDataRow, type EiaRegionDataRow, type FuelTypeDataPoint, + REGION_STATE_MAP, type RegionCode, type RegionDataPoint, + type RetailPricePoint, eiaFuelTypeDataResponseSchema, eiaRegionDataResponseSchema, + eiaRetailPriceResponseSchema, parseEiaPeriod, resolveRegionCode, } from '@/lib/schemas/electricity.js'; @@ -36,12 +39,17 @@ interface EiaQueryParams { sort?: Array<{ column: string; direction: 'asc' | 'desc' }>; offset?: number; length?: number; + /** Data column name(s) to request. Defaults to ['value']. */ + dataColumns?: string[]; } function buildUrl(endpoint: string, params: EiaQueryParams): string { const url = new URL(`${EIA_BASE_URL}${endpoint}`); url.searchParams.set('api_key', getApiKey()); - url.searchParams.set('data[0]', 'value'); + const columns = params.dataColumns ?? ['value']; + for (let i = 0; i < columns.length; i++) { + url.searchParams.set(`data[${i}]`, columns[i]!); + } if (params.frequency) { url.searchParams.set('frequency', params.frequency); @@ -75,16 +83,25 @@ function buildUrl(endpoint: string, params: EiaQueryParams): string { return url.toString(); } +const FETCH_TIMEOUT_MS = 30_000; + async function fetchEia(endpoint: string, params: EiaQueryParams): Promise { const url = buildUrl(endpoint, params); - const response = await fetch(url); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - if (!response.ok) { - const text = await response.text().catch(() => 'unknown error'); - throw new Error(`EIA API error ${response.status}: ${text}`); + try { + const response = await fetch(url, { signal: controller.signal }); + + if (!response.ok) { + const text = await response.text().catch(() => 'unknown error'); + throw new Error(`EIA API error ${response.status}: ${text}`); + } + + return response.json(); + } finally { + clearTimeout(timeoutId); } - - return response.json(); } /** @@ -337,3 +354,63 @@ export async function getWTICrudePrice(options: GetCommodityPriceOptions = {}): source: 'EIA', })); } + +// --------------------------------------------------------------------------- +// Retail electricity prices (monthly, by state) +// --------------------------------------------------------------------------- + +export interface GetRetailPriceOptions { + /** Start date in YYYY-MM format */ + start?: string; + /** End date in YYYY-MM format */ + end?: string; +} + +/** + * Fetch monthly retail electricity prices for industrial (IND) sector. + * Returns prices for all 7 tracked regions, mapped via REGION_STATE_MAP. + * + * Endpoint: /v2/electricity/retail-sales/data/ + * Price is returned in cents/kWh; we convert to $/MWh (* 10). + */ +export async function getRetailElectricityPrices(options: GetRetailPriceOptions = {}): Promise { + const stateIds = Object.values(REGION_STATE_MAP); + const regionCodes: RegionCode[] = ['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP']; + const stateToRegion = new Map(); + for (const region of regionCodes) { + stateToRegion.set(REGION_STATE_MAP[region], region); + } + + const params: EiaQueryParams = { + frequency: 'monthly', + start: options.start, + end: options.end, + facets: { + sectorid: ['IND'], + stateid: stateIds, + }, + sort: [{ column: 'period', direction: 'desc' }], + dataColumns: ['price'], + }; + + const rows = await fetchAllPages('/electricity/retail-sales/data/', params, json => { + const parsed = eiaRetailPriceResponseSchema.parse(json); + return { total: parsed.response.total, data: parsed.response.data }; + }); + + const results: RetailPricePoint[] = []; + for (const row of rows) { + if (row.price === null) continue; + const regionCode = stateToRegion.get(row.stateid); + if (!regionCode) continue; + + results.push({ + period: row.period, + stateId: row.stateid, + regionCode, + priceMwh: row.price * 10, // cents/kWh -> $/MWh + }); + } + + return results; +} diff --git a/src/lib/api/fred.ts b/src/lib/api/fred.ts index 78ae067..70b481f 100644 --- a/src/lib/api/fred.ts +++ b/src/lib/api/fred.ts @@ -126,14 +126,20 @@ export async function getSeriesObservations( await rateLimitDelay(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30_000); + let response: Response; try { - response = await fetch(`${FRED_BASE_URL}?${params.toString()}`); + response = await fetch(`${FRED_BASE_URL}?${params.toString()}`, { signal: controller.signal }); } catch (err) { + clearTimeout(timeoutId); return { ok: false, error: `FRED API network error: ${err instanceof Error ? err.message : String(err)}`, }; + } finally { + clearTimeout(timeoutId); } if (!response.ok) { diff --git a/src/lib/schemas/electricity.ts b/src/lib/schemas/electricity.ts index e0f6c61..ace1d28 100644 --- a/src/lib/schemas/electricity.ts +++ b/src/lib/schemas/electricity.ts @@ -127,6 +127,50 @@ export const eiaFuelTypeDataResponseSchema = eiaResponseSchema(eiaFuelTypeDataRo export type EiaRegionDataResponse = z.infer; export type EiaFuelTypeDataResponse = z.infer; +// --------------------------------------------------------------------------- +// Retail electricity prices (monthly, by state) +// Endpoint: /v2/electricity/retail-sales/data/ +// --------------------------------------------------------------------------- + +/** Maps our region codes to representative US state abbreviations */ +export const REGION_STATE_MAP: Record = { + CAISO: 'CA', + ERCOT: 'TX', + ISONE: 'MA', + MISO: 'IL', + NYISO: 'NY', + PJM: 'VA', + SPP: 'OK', +}; + +/** Row from the EIA retail-sales endpoint */ +export const eiaRetailPriceRowSchema = z.object({ + period: z.string(), // "YYYY-MM" + stateid: z.string(), + sectorid: z.string(), + price: z.union([z.string(), z.number(), z.null()]).transform((val): number | null => { + if (val === null || val === '') return null; + const num = Number(val); + return Number.isNaN(num) ? null : num; + }), + 'price-units': z.string(), +}); + +export type EiaRetailPriceRow = z.infer; + +export const eiaRetailPriceResponseSchema = eiaResponseSchema(eiaRetailPriceRowSchema); +export type EiaRetailPriceResponse = z.infer; + +/** Parsed retail price data point */ +export interface RetailPricePoint { + /** Month string in YYYY-MM format */ + period: string; + stateId: string; + regionCode: RegionCode; + /** Price in $/MWh (converted from cents/kWh * 10) */ + priceMwh: number; +} + /** * Parse an EIA hourly period string to a UTC Date. * Format: "2026-02-11T08" => Date for 2026-02-11 08:00:00 UTC diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d24493e..e883fb2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,6 +5,12 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +export const VALID_REGION_CODES = new Set(['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP'] as const); + +export function validateRegionCode(code: string): boolean { + return code === 'ALL' || VALID_REGION_CODES.has(code); +} + const REGION_TIMEZONES: Record = { ERCOT: 'America/Chicago', PJM: 'America/New_York',