fix: comprehensive review fixes — real price data, missing components, SQL bugs, security

- Replace $0 electricity prices with real EIA retail-sales data (IND sector)
  with demand-based hourly variation (0.8x-1.2x)
- Add sparkline component and alerts feed to dashboard home
- Add animated number transitions to hero metric cards
- Fix ticker tape price direction (green/red arrows with % change)
- Fix AI milestone annotation alignment on price charts
- Fix SQL cartesian products in getDemandByRegion and getRegionPriceHeatmap
  using CTEs for independent aggregation
- Add unique composite constraints to prevent duplicate data
- Add bearer token auth to ingestion API routes
- Add 30s fetch timeouts to EIA and FRED API clients
- Add regionCode validation in server actions
- Fix docker-compose: localhost-only port binding, correct volume path
- Fix seed script to preserve ingested time-series data
This commit is contained in:
Joey Eamigh 2026-02-11 13:23:21 -05:00
parent a954e89b47
commit 7a1bbca339
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
23 changed files with 878 additions and 133 deletions

View File

@ -2,13 +2,13 @@ services:
db: db:
image: postgis/postgis:18-3.6 image: postgis/postgis:18-3.6
ports: ports:
- "5433:5432" - "127.0.0.1:5433:5432"
environment: environment:
POSTGRES_DB: energy_dashboard POSTGRES_DB: energy_dashboard
POSTGRES_USER: energy POSTGRES_USER: energy
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql/data
volumes: volumes:
pgdata: pgdata:

View File

@ -46,6 +46,7 @@ model ElectricityPrice {
timestamp DateTime @db.Timestamptz timestamp DateTime @db.Timestamptz
source String source String
@@unique([regionId, timestamp])
@@index([regionId, timestamp]) @@index([regionId, timestamp])
@@map("electricity_prices") @@map("electricity_prices")
} }
@ -58,6 +59,7 @@ model CommodityPrice {
timestamp DateTime @db.Timestamptz timestamp DateTime @db.Timestamptz
source String source String
@@unique([commodity, timestamp])
@@index([commodity, timestamp]) @@index([commodity, timestamp])
@@map("commodity_prices") @@map("commodity_prices")
} }
@ -70,6 +72,7 @@ model GenerationMix {
generationMw Float @map("generation_mw") generationMw Float @map("generation_mw")
timestamp DateTime @db.Timestamptz timestamp DateTime @db.Timestamptz
@@unique([regionId, fuelType, timestamp])
@@index([regionId, timestamp]) @@index([regionId, timestamp])
@@map("generation_mix") @@map("generation_mix")
} }

View File

@ -73,19 +73,21 @@ async function seedGridRegions() {
const geojson = readAndParse('data/grid-regions.geojson', RegionCollectionSchema); 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 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) { for (const feature of geojson.features) {
const id = randomUUID(); const id = randomUUID();
const geojsonStr = JSON.stringify(feature.geometry); const geojsonStr = JSON.stringify(feature.geometry);
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`INSERT INTO grid_regions (id, name, code, iso, boundary, created_at) `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, id,
feature.properties.name, feature.properties.name,
feature.properties.code, feature.properties.code,
@ -93,7 +95,7 @@ async function seedGridRegions() {
geojsonStr, geojsonStr,
); );
console.log(` Inserted region: ${feature.properties.code}`); console.log(` Upserted region: ${feature.properties.code}`);
} }
} }

View File

@ -1,15 +1,34 @@
-- @param {DateTime} $1:startDate -- @param {DateTime} $1:startDate
-- @param {DateTime} $2:endDate -- @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 SELECT
r.code as region_code, r.name as region_name, r.code AS region_code,
date_trunc('day', ep.timestamp) as day, r.name AS region_name,
AVG(ep.demand_mw) as avg_demand, da.day,
MAX(ep.demand_mw) as peak_demand, da.avg_demand,
COUNT(DISTINCT d.id)::INT as datacenter_count, da.peak_demand,
COALESCE(SUM(DISTINCT d.capacity_mw), 0) as total_dc_capacity_mw 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 FROM grid_regions r
LEFT JOIN electricity_prices ep ON ep.region_id = r.id INNER JOIN demand_agg da ON da.region_id = r.id
AND ep.timestamp BETWEEN $1 AND $2 LEFT JOIN dc_agg dc ON dc.region_id = r.id
LEFT JOIN datacenters d ON d.region_id = r.id WHERE ($3 = 'ALL' OR r.code = $3)
GROUP BY r.id, r.code, r.name, date_trunc('day', ep.timestamp) ORDER BY r.code, da.day
ORDER BY r.code, day

View File

@ -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 SELECT
r.code, r.name, r.code,
ST_AsGeoJSON(r.boundary)::TEXT as boundary_geojson, r.name,
AVG(ep.price_mwh) as avg_price, ST_AsGeoJSON(r.boundary)::TEXT AS boundary_geojson,
MAX(ep.price_mwh) as max_price, pa.avg_price,
AVG(ep.demand_mw) as avg_demand, pa.max_price,
COUNT(DISTINCT d.id)::INT as datacenter_count, pa.avg_demand,
COALESCE(SUM(d.capacity_mw), 0) as total_dc_capacity_mw 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 FROM grid_regions r
LEFT JOIN electricity_prices ep ON ep.region_id = r.id LEFT JOIN price_agg pa ON pa.region_id = r.id
AND ep.timestamp > NOW() - INTERVAL '24 hours' LEFT JOIN dc_agg dc ON dc.region_id = r.id
LEFT JOIN datacenters d ON d.region_id = r.id
GROUP BY r.id, r.code, r.name, r.boundary

View File

@ -13,7 +13,7 @@ import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/generated/prisma/client.js'; import { PrismaClient } from '../src/generated/prisma/client.js';
import * as eia from '../src/lib/api/eia.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 * as fred from '../src/lib/api/fred.js';
import type { RegionCode } from '../src/lib/schemas/electricity.js'; import type { RegionCode } from '../src/lib/schemas/electricity.js';
@ -46,7 +46,7 @@ function log(msg: string): void {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function backfillElectricity(): Promise<void> { async function backfillElectricity(): Promise<void> {
log('=== Backfilling electricity demand data ==='); log('=== Backfilling electricity demand + price data ===');
const gridRegions = await prisma.gridRegion.findMany({ const gridRegions = await prisma.gridRegion.findMany({
select: { id: true, code: true }, select: { id: true, code: true },
@ -56,6 +56,40 @@ async function backfillElectricity(): Promise<void> {
const start = sixMonthsAgoIso(); const start = sixMonthsAgoIso();
const end = todayIso(); const end = todayIso();
// Fetch monthly retail electricity prices for all regions upfront
// Key: "REGION:YYYY-MM" -> $/MWh
const retailPriceByRegionMonth = new Map<string, number>();
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<string, number>();
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) { for (const regionCode of ALL_REGIONS) {
const regionId = regionIdByCode.get(regionCode); const regionId = regionIdByCode.get(regionCode);
if (!regionId) { if (!regionId) {
@ -81,6 +115,9 @@ async function backfillElectricity(): Promise<void> {
}); });
const existingByTime = new Map(existing.map(e => [e.timestamp.getTime(), e.id])); 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<{ const toCreate: Array<{
regionId: string; regionId: string;
priceMwh: number; priceMwh: number;
@ -88,16 +125,22 @@ async function backfillElectricity(): Promise<void> {
timestamp: Date; timestamp: Date;
source: string; source: string;
}> = []; }> = [];
const toUpdate: Array<{ id: string; demandMw: number }> = []; const toUpdate: Array<{ id: string; demandMw: number; priceMwh: number }> = [];
for (const point of validPoints) { 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()); const existingId = existingByTime.get(point.timestamp.getTime());
if (existingId) { if (existingId) {
toUpdate.push({ id: existingId, demandMw: point.valueMw }); toUpdate.push({ id: existingId, demandMw: point.valueMw, priceMwh });
} else { } else {
toCreate.push({ toCreate.push({
regionId, regionId,
priceMwh: 0, // TODO: No real-time wholesale price available from EIA priceMwh,
demandMw: point.valueMw, demandMw: point.valueMw,
timestamp: point.timestamp, timestamp: point.timestamp,
source: 'EIA', source: 'EIA',
@ -119,7 +162,7 @@ async function backfillElectricity(): Promise<void> {
chunk.map(u => chunk.map(u =>
prisma.electricityPrice.update({ prisma.electricityPrice.update({
where: { id: u.id }, where: { id: u.id },
data: { demandMw: u.demandMw, source: 'EIA' }, data: { demandMw: u.demandMw, priceMwh: u.priceMwh, source: 'EIA' },
}), }),
), ),
); );

View File

@ -3,6 +3,7 @@
import { getDemandByRegion } from '@/generated/prisma/sql.js'; import { getDemandByRegion } from '@/generated/prisma/sql.js';
import { prisma } from '@/lib/db.js'; import { prisma } from '@/lib/db.js';
import { serialize } from '@/lib/superjson.js'; import { serialize } from '@/lib/superjson.js';
import { validateRegionCode } from '@/lib/utils.js';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
@ -35,11 +36,13 @@ export async function fetchDemandByRegion(
timeRange: TimeRange = '30d', timeRange: TimeRange = '30d',
): Promise<ActionResult<getDemandByRegion.Result[]>> { ): Promise<ActionResult<getDemandByRegion.Result[]>> {
try { try {
if (!validateRegionCode(regionCode)) {
return { ok: false, error: `Invalid region code: ${regionCode}` };
}
const startDate = timeRangeToStartDate(timeRange); const startDate = timeRangeToStartDate(timeRange);
const endDate = new Date(); const endDate = new Date();
const rows = await prisma.$queryRawTyped(getDemandByRegion(startDate, endDate)); const rows = await prisma.$queryRawTyped(getDemandByRegion(startDate, endDate, regionCode));
const filtered = regionCode === 'ALL' ? rows : rows.filter(r => r.region_code === regionCode); return { ok: true, data: serialize(rows) };
return { ok: true, data: serialize(filtered) };
} catch (err) { } catch (err) {
return { return {
ok: false, ok: false,
@ -52,7 +55,7 @@ export async function fetchRegionDemandSummary(): Promise<ActionResult<getDemand
try { try {
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const endDate = new Date(); const endDate = new Date();
const rows = await prisma.$queryRawTyped(getDemandByRegion(startDate, endDate)); const rows = await prisma.$queryRawTyped(getDemandByRegion(startDate, endDate, 'ALL'));
return { ok: true, data: serialize(rows) }; return { ok: true, data: serialize(rows) };
} catch (err) { } catch (err) {
return { return {

View File

@ -3,6 +3,7 @@
import { getGenerationMix } from '@/generated/prisma/sql.js'; import { getGenerationMix } from '@/generated/prisma/sql.js';
import { prisma } from '@/lib/db.js'; import { prisma } from '@/lib/db.js';
import { serialize } from '@/lib/superjson.js'; import { serialize } from '@/lib/superjson.js';
import { validateRegionCode } from '@/lib/utils.js';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
@ -35,6 +36,9 @@ export async function fetchGenerationMix(
timeRange: TimeRange = '30d', timeRange: TimeRange = '30d',
): Promise<ActionResult<getGenerationMix.Result[]>> { ): Promise<ActionResult<getGenerationMix.Result[]>> {
try { try {
if (!validateRegionCode(regionCode)) {
return { ok: false, error: `Invalid region code: ${regionCode}` };
}
const startDate = timeRangeToStartDate(timeRange); const startDate = timeRangeToStartDate(timeRange);
const endDate = new Date(); const endDate = new Date();
const rows = await prisma.$queryRawTyped(getGenerationMix(regionCode, startDate, endDate)); const rows = await prisma.$queryRawTyped(getGenerationMix(regionCode, startDate, endDate));

View File

@ -3,6 +3,7 @@
import { getLatestPrices, getPriceTrends, getRegionPriceHeatmap } from '@/generated/prisma/sql.js'; import { getLatestPrices, getPriceTrends, getRegionPriceHeatmap } from '@/generated/prisma/sql.js';
import { prisma } from '@/lib/db.js'; import { prisma } from '@/lib/db.js';
import { serialize } from '@/lib/superjson.js'; import { serialize } from '@/lib/superjson.js';
import { validateRegionCode } from '@/lib/utils.js';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
@ -47,6 +48,9 @@ export async function fetchPriceTrends(
timeRange: TimeRange = '30d', timeRange: TimeRange = '30d',
): Promise<ActionResult<getPriceTrends.Result[]>> { ): Promise<ActionResult<getPriceTrends.Result[]>> {
try { try {
if (!validateRegionCode(regionCode)) {
return { ok: false, error: `Invalid region code: ${regionCode}` };
}
const startDate = timeRangeToStartDate(timeRange); const startDate = timeRangeToStartDate(timeRange);
const endDate = new Date(); const endDate = new Date();
const rows = await prisma.$queryRawTyped(getPriceTrends(regionCode, startDate, endDate)); 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<string, { sum: number; count: number }>();
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< export async function fetchLatestCommodityPrices(): Promise<
ActionResult< ActionResult<
Array<{ Array<{

View File

@ -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;
}

View File

@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from 'next/server.js'; 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 eia from '@/lib/api/eia.js';
import * as fred from '@/lib/api/fred.js'; import * as fred from '@/lib/api/fred.js';
import { prisma } from '@/lib/db.js'; import { prisma } from '@/lib/db.js';
@ -112,7 +113,10 @@ async function fetchAllCommodities(start?: string, end?: string): Promise<{ rows
return { rows, errors }; return { rows, errors };
} }
export async function GET(request: NextRequest): Promise<NextResponse<IngestionStats>> { export async function GET(request: NextRequest): Promise<NextResponse> {
const authError = checkIngestAuth(request);
if (authError) return authError;
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const start = searchParams.get('start') ?? undefined; const start = searchParams.get('start') ?? undefined;
const end = searchParams.get('end') ?? undefined; const end = searchParams.get('end') ?? undefined;

View File

@ -1,6 +1,7 @@
import { NextResponse, type NextRequest } from 'next/server.js'; 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 { prisma } from '@/lib/db.js';
import { EIA_RESPONDENT_CODES, type RegionCode } from '@/lib/schemas/electricity.js'; import { EIA_RESPONDENT_CODES, type RegionCode } from '@/lib/schemas/electricity.js';
@ -16,7 +17,10 @@ interface IngestionStats {
errors: number; errors: number;
} }
export async function GET(request: NextRequest): Promise<NextResponse<IngestionStats>> { export async function GET(request: NextRequest): Promise<NextResponse> {
const authError = checkIngestAuth(request);
if (authError) return authError;
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const regionParam = searchParams.get('region'); const regionParam = searchParams.get('region');
const start = searchParams.get('start') ?? undefined; const start = searchParams.get('start') ?? undefined;
@ -41,6 +45,29 @@ export async function GET(request: NextRequest): Promise<NextResponse<IngestionS
}); });
const regionIdByCode = new Map(gridRegions.map(r => [r.code, r.id])); const regionIdByCode = new Map(gridRegions.map(r => [r.code, r.id]));
// Fetch retail electricity prices (monthly) to apply to hourly records.
// Key: "REGION:YYYY-MM" -> $/MWh
const retailPriceByRegionMonth = new Map<string, number>();
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<string, number>();
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) { for (const regionCode of regions) {
const regionId = regionIdByCode.get(regionCode); const regionId = regionIdByCode.get(regionCode);
if (!regionId) { if (!regionId) {
@ -65,6 +92,9 @@ export async function GET(request: NextRequest): Promise<NextResponse<IngestionS
}); });
const existingByTime = new Map(existing.map(e => [e.timestamp.getTime(), e.id])); const existingByTime = new Map(existing.map(e => [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<{ const toCreate: Array<{
regionId: string; regionId: string;
priceMwh: number; priceMwh: number;
@ -72,16 +102,23 @@ export async function GET(request: NextRequest): Promise<NextResponse<IngestionS
timestamp: Date; timestamp: Date;
source: string; source: string;
}> = []; }> = [];
const toUpdate: Array<{ id: string; demandMw: number }> = []; const toUpdate: Array<{ id: string; demandMw: number; priceMwh: number }> = [];
for (const point of validPoints) { 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()); const existingId = existingByTime.get(point.timestamp.getTime());
if (existingId) { if (existingId) {
toUpdate.push({ id: existingId, demandMw: point.valueMw }); toUpdate.push({ id: existingId, demandMw: point.valueMw, priceMwh });
} else { } else {
toCreate.push({ toCreate.push({
regionId, regionId,
priceMwh: 0, priceMwh,
demandMw: point.valueMw, demandMw: point.valueMw,
timestamp: point.timestamp, timestamp: point.timestamp,
source: 'EIA', source: 'EIA',
@ -100,7 +137,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<IngestionS
toUpdate.map(u => toUpdate.map(u =>
prisma.electricityPrice.update({ prisma.electricityPrice.update({
where: { id: u.id }, where: { id: u.id },
data: { demandMw: u.demandMw, source: 'EIA' }, data: { demandMw: u.demandMw, priceMwh: u.priceMwh, source: 'EIA' },
}), }),
), ),
); );

View File

@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from 'next/server.js'; import { NextResponse, type NextRequest } from 'next/server.js';
import { checkIngestAuth } from '@/app/api/ingest/auth.js';
import { getFuelTypeData } from '@/lib/api/eia.js'; import { getFuelTypeData } from '@/lib/api/eia.js';
import { prisma } from '@/lib/db.js'; import { prisma } from '@/lib/db.js';
import { EIA_RESPONDENT_CODES, type RegionCode } from '@/lib/schemas/electricity.js'; import { EIA_RESPONDENT_CODES, type RegionCode } from '@/lib/schemas/electricity.js';
@ -16,7 +17,10 @@ interface IngestionStats {
errors: number; errors: number;
} }
export async function GET(request: NextRequest): Promise<NextResponse<IngestionStats>> { export async function GET(request: NextRequest): Promise<NextResponse> {
const authError = checkIngestAuth(request);
if (authError) return authError;
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const regionParam = searchParams.get('region'); const regionParam = searchParams.get('region');
const start = searchParams.get('start') ?? undefined; const start = searchParams.get('start') ?? undefined;

View File

@ -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 { GpuCalculator } from '@/components/dashboard/gpu-calculator.js';
import { GridStressGauge } from '@/components/dashboard/grid-stress-gauge.js'; import { GridStressGauge } from '@/components/dashboard/grid-stress-gauge.js';
import { MetricCard } from '@/components/dashboard/metric-card.js'; import { MetricCard } from '@/components/dashboard/metric-card.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { deserialize } from '@/lib/superjson.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 Link from 'next/link';
import { fetchDatacenters } from '@/actions/datacenters.js'; import { fetchDatacenters } from '@/actions/datacenters.js';
import { fetchRegionDemandSummary } from '@/actions/demand.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 { function formatNumber(value: number, decimals = 1): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M`; 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() { export default async function DashboardHome() {
const [pricesResult, commoditiesResult, datacentersResult, demandResult] = await Promise.all([ const [pricesResult, commoditiesResult, datacentersResult, demandResult, sparklinesResult, alertsResult] =
fetchLatestPrices(), await Promise.all([
fetchLatestCommodityPrices(), fetchLatestPrices(),
fetchDatacenters(), fetchLatestCommodityPrices(),
fetchRegionDemandSummary(), fetchDatacenters(),
]); fetchRegionDemandSummary(),
fetchPriceSparklines(),
fetchRecentAlerts(),
]);
const prices = pricesResult.ok const prices = pricesResult.ok
? deserialize< ? deserialize<
@ -55,6 +65,24 @@ export default async function DashboardHome() {
>(demandResult.data) >(demandResult.data)
: []; : [];
const sparklines = sparklinesResult.ok
? deserialize<Array<{ region_code: string; points: { value: number }[] }>>(sparklinesResult.data)
: [];
const sparklineMap: Record<string, { value: number }[]> = {};
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 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 natGas = commodities.find(c => c.commodity === 'natural_gas');
@ -114,20 +142,35 @@ export default async function DashboardHome() {
<MetricCard <MetricCard
title="Avg Electricity Price" title="Avg Electricity Price"
value={avgPrice > 0 ? `$${avgPrice.toFixed(2)}` : '--'} value={avgPrice > 0 ? `$${avgPrice.toFixed(2)}` : '--'}
numericValue={avgPrice > 0 ? avgPrice : undefined}
animatedFormat={avgPrice > 0 ? 'dollar' : undefined}
unit="/MWh" unit="/MWh"
icon={BarChart3} icon={BarChart3}
sparklineData={avgSparkline}
sparklineColor="hsl(210, 90%, 55%)"
/>
<MetricCard
title="Total DC Capacity"
value={formatNumber(totalCapacityMw)}
numericValue={totalCapacityMw > 0 ? totalCapacityMw : undefined}
animatedFormat={totalCapacityMw > 0 ? 'compact' : undefined}
unit="MW"
icon={Activity}
/> />
<MetricCard title="Total DC Capacity" value={formatNumber(totalCapacityMw)} unit="MW" icon={Activity} />
<MetricCard title="Datacenters Tracked" value={datacenterCount.toLocaleString()} icon={Server} /> <MetricCard title="Datacenters Tracked" value={datacenterCount.toLocaleString()} icon={Server} />
<MetricCard <MetricCard
title="Natural Gas Spot" title="Natural Gas Spot"
value={natGas ? `$${natGas.price.toFixed(2)}` : '--'} value={natGas ? `$${natGas.price.toFixed(2)}` : '--'}
numericValue={natGas?.price}
animatedFormat={natGas ? 'dollar' : undefined}
unit={natGas?.unit ?? '/MMBtu'} unit={natGas?.unit ?? '/MMBtu'}
icon={Flame} icon={Flame}
/> />
<MetricCard <MetricCard
title="WTI Crude Oil" title="WTI Crude Oil"
value={wtiCrude ? `$${wtiCrude.price.toFixed(2)}` : '--'} value={wtiCrude ? `$${wtiCrude.price.toFixed(2)}` : '--'}
numericValue={wtiCrude?.price}
animatedFormat={wtiCrude ? 'dollar' : undefined}
unit={wtiCrude?.unit ?? '/bbl'} unit={wtiCrude?.unit ?? '/bbl'}
icon={Droplets} icon={Droplets}
/> />
@ -138,7 +181,7 @@ export default async function DashboardHome() {
<Card className="group cursor-pointer transition-colors hover:border-primary/50"> <Card className="group cursor-pointer transition-colors hover:border-primary/50">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Map className="h-5 w-5 text-chart-1" /> <MapIcon className="h-5 w-5 text-chart-1" />
Interactive Map Interactive Map
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -163,15 +206,23 @@ export default async function DashboardHome() {
<CardContent> <CardContent>
{prices.length > 0 ? ( {prices.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{prices.map(p => ( {prices.map(p => {
<div key={p.region_code} className="flex items-center justify-between text-sm"> const regionSparkline = sparklineMap[p.region_code];
<span className="font-medium">{p.region_name}</span> return (
<div className="flex items-baseline gap-1.5"> <div key={p.region_code} className="flex items-center gap-3 text-sm">
<span className="font-mono font-semibold">${p.price_mwh.toFixed(2)}</span> <span className="w-16 shrink-0 font-medium">{p.region_code}</span>
<span className="text-xs text-muted-foreground">/MWh</span> <div className="min-w-0 flex-1">
{regionSparkline && regionSparkline.length >= 2 && (
<Sparkline data={regionSparkline} color="hsl(210, 90%, 55%)" height={24} />
)}
</div>
<div className="flex shrink-0 items-baseline gap-1.5">
<span className="font-mono font-semibold">${p.price_mwh.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">/MWh</span>
</div>
</div> </div>
</div> );
))} })}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground">No price data available yet.</p> <p className="text-sm text-muted-foreground">No price data available yet.</p>
@ -212,8 +263,10 @@ export default async function DashboardHome() {
</div> </div>
)} )}
{avgDemand > 0 && ( <div className="mt-8 grid gap-4 lg:grid-cols-2">
<div className="mt-4"> {alertsResult.ok && <AlertsFeed initialData={alertsResult.data} />}
{avgDemand > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@ -228,8 +281,8 @@ export default async function DashboardHome() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div> )}
)} </div>
</div> </div>
); );
} }

View File

@ -165,15 +165,36 @@ function pivotData(
return { pivoted, regions, commodities }; 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 []; if (pivoted.length === 0) return [];
const minTs = pivoted[0]!.timestamp; const minTs = pivoted[0]!.timestamp;
const maxTs = pivoted[pivoted.length - 1]!.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(); 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({ export function PriceChart({
@ -207,7 +228,7 @@ export function PriceChart({
const activeRegions = useMemo(() => regions.filter(r => !disabledRegions.has(r)), [regions, disabledRegions]); const activeRegions = useMemo(() => regions.filter(r => !disabledRegions.has(r)), [regions, disabledRegions]);
const visibleMilestones = useMemo( const visibleMilestones = useMemo(
() => (showMilestones ? filterMilestonesByRange(milestones, pivoted) : []), () => (showMilestones ? resolveMilestonesInRange(milestones, pivoted) : []),
[milestones, pivoted, showMilestones], [milestones, pivoted, showMilestones],
); );
@ -407,12 +428,7 @@ export function PriceChart({
{visibleMilestones.map(milestone => ( {visibleMilestones.map(milestone => (
<ReferenceLine <ReferenceLine
key={milestone.date} key={milestone.date}
x={new Date(milestone.date).toLocaleDateString('en-US', { x={milestone.xDisplay}
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
yAxisId="left" yAxisId="left"
stroke={MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)'} stroke={MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)'}
strokeDasharray="3 3" strokeDasharray="3 3"

View File

@ -0,0 +1,21 @@
'use client';
import { Line, LineChart, ResponsiveContainer } from 'recharts';
interface SparklineProps {
data: { value: number }[];
color?: string;
height?: number;
}
export function Sparkline({ data, color = 'hsl(210, 90%, 55%)', height = 32 }: SparklineProps) {
if (data.length < 2) return null;
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data}>
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={1.5} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
);
}

View File

@ -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<AlertItem['severity'], { dot: string; bg: string }> = {
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<AlertItem['type'], typeof Zap> = {
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<AlertItem[]>(initialData);
if (alerts.length === 0) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-chart-5" />
Recent Alerts
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No notable events in the past 7 days.</p>
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-chart-5" />
Recent Alerts
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="max-h-[400px] overflow-y-auto px-6 pb-6">
<div className="space-y-1">
{alerts.map(alert => {
const styles = SEVERITY_STYLES[alert.severity];
const Icon = TYPE_ICONS[alert.type];
return (
<div key={alert.id} className={cn('flex items-start gap-3 rounded-lg px-3 py-2.5', styles.bg)}>
<div className="mt-0.5 flex shrink-0 items-center gap-2">
<span className={cn('h-2 w-2 rounded-full', styles.dot)} />
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug">{alert.description}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium">{alert.region_code}</span>
<span>&middot;</span>
<span>{formatRelativeTime(alert.timestamp)}</span>
</div>
</div>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { cn } from '@/lib/utils.js'; import { cn } from '@/lib/utils.js';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { useCallback } from 'react';
type AnimatedFormat = 'dollar' | 'compact' | 'integer';
const FORMAT_FNS: Record<AnimatedFormat, (n: number) => 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 { interface MetricCardProps {
title: string; title: string;
value: 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; unit?: string;
icon: LucideIcon; icon: LucideIcon;
className?: string; 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 ( return (
<Card className={cn('gap-0 py-4', className)}> <Card className={cn('gap-0 py-4', className)}>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
@ -21,9 +59,18 @@ export function MetricCard({ title, value, unit, icon: Icon, className }: Metric
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-3xl font-bold tracking-tight">{value}</span> {numericValue !== undefined && animatedFormat ? (
<AnimatedNumber value={numericValue} format={formatFn} className="text-3xl font-bold tracking-tight" />
) : (
<span className="text-3xl font-bold tracking-tight">{value}</span>
)}
{unit && <span className="text-sm text-muted-foreground">{unit}</span>} {unit && <span className="text-sm text-muted-foreground">{unit}</span>}
</div> </div>
{sparklineData && sparklineData.length >= 2 && (
<div className="mt-2">
<Sparkline data={sparklineData} color={sparklineColor} height={28} />
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { fetchLatestCommodityPrices, fetchLatestPrices } from '@/actions/prices.js'; import { fetchTickerPrices, type TickerCommodityRow, type TickerPriceRow } from '@/actions/prices.js';
import type { getLatestPrices } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js'; import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js'; import { cn } from '@/lib/utils.js';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -13,13 +12,15 @@ interface TickerItem {
unit: string; unit: string;
} }
function formatPrice(price: number, unit: string): string { function formatPrice(price: number): string {
if (unit === '$/MWh') {
return `$${price.toFixed(2)}`;
}
return `$${price.toFixed(2)}`; 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 }) { function TickerItemDisplay({ item }: { item: TickerItem }) {
const changeColor = const changeColor =
item.change === null ? 'text-muted-foreground' : item.change >= 0 ? 'text-emerald-400' : 'text-red-400'; 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<string, string> = {
natural_gas: 'Nat Gas',
wti_crude: 'WTI Crude',
coal: 'Coal',
};
export function TickerTape() { export function TickerTape() {
const [items, setItems] = useState<TickerItem[]>([]); const [items, setItems] = useState<TickerItem[]>([]);
useEffect(() => { useEffect(() => {
async function loadPrices() { 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[] = []; const tickerItems: TickerItem[] = [];
if (priceResult.ok) { for (const p of electricity) {
const prices = deserialize<getLatestPrices.Result[]>(priceResult.data); tickerItems.push({
for (const p of prices) { label: p.region_code,
tickerItems.push({ price: formatPrice(p.price_mwh),
label: p.region_code, change: computeChangePercent(p.price_mwh, p.prev_price_mwh),
price: formatPrice(p.price_mwh, '$/MWh'), unit: '$/MWh',
change: null, });
unit: '$/MWh',
});
}
} }
if (commodityResult.ok) { for (const c of commodities) {
const commodities = deserialize< tickerItems.push({
Array<{ label: COMMODITY_LABELS[c.commodity] ?? c.commodity,
commodity: string; price: formatPrice(c.price),
price: number; change: computeChangePercent(c.price, c.prev_price),
unit: string; unit: c.unit,
timestamp: Date; });
source: string;
}>
>(commodityResult.data);
const commodityLabels: Record<string, string> = {
natural_gas: 'Nat Gas',
wti_crude: 'WTI Crude',
coal: 'Coal',
};
for (const c of commodities) {
tickerItems.push({
label: commodityLabels[c.commodity] ?? c.commodity,
price: formatPrice(c.price, c.unit),
change: null,
unit: c.unit,
});
}
} }
setItems(tickerItems); setItems(tickerItems);

View File

@ -9,10 +9,13 @@ import {
type EiaFuelTypeDataRow, type EiaFuelTypeDataRow,
type EiaRegionDataRow, type EiaRegionDataRow,
type FuelTypeDataPoint, type FuelTypeDataPoint,
REGION_STATE_MAP,
type RegionCode, type RegionCode,
type RegionDataPoint, type RegionDataPoint,
type RetailPricePoint,
eiaFuelTypeDataResponseSchema, eiaFuelTypeDataResponseSchema,
eiaRegionDataResponseSchema, eiaRegionDataResponseSchema,
eiaRetailPriceResponseSchema,
parseEiaPeriod, parseEiaPeriod,
resolveRegionCode, resolveRegionCode,
} from '@/lib/schemas/electricity.js'; } from '@/lib/schemas/electricity.js';
@ -36,12 +39,17 @@ interface EiaQueryParams {
sort?: Array<{ column: string; direction: 'asc' | 'desc' }>; sort?: Array<{ column: string; direction: 'asc' | 'desc' }>;
offset?: number; offset?: number;
length?: number; length?: number;
/** Data column name(s) to request. Defaults to ['value']. */
dataColumns?: string[];
} }
function buildUrl(endpoint: string, params: EiaQueryParams): string { function buildUrl(endpoint: string, params: EiaQueryParams): string {
const url = new URL(`${EIA_BASE_URL}${endpoint}`); const url = new URL(`${EIA_BASE_URL}${endpoint}`);
url.searchParams.set('api_key', getApiKey()); 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) { if (params.frequency) {
url.searchParams.set('frequency', params.frequency); url.searchParams.set('frequency', params.frequency);
@ -75,16 +83,25 @@ function buildUrl(endpoint: string, params: EiaQueryParams): string {
return url.toString(); return url.toString();
} }
const FETCH_TIMEOUT_MS = 30_000;
async function fetchEia(endpoint: string, params: EiaQueryParams): Promise<unknown> { async function fetchEia(endpoint: string, params: EiaQueryParams): Promise<unknown> {
const url = buildUrl(endpoint, params); 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) { try {
const text = await response.text().catch(() => 'unknown error'); const response = await fetch(url, { signal: controller.signal });
throw new Error(`EIA API error ${response.status}: ${text}`);
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', 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<RetailPricePoint[]> {
const stateIds = Object.values(REGION_STATE_MAP);
const regionCodes: RegionCode[] = ['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP'];
const stateToRegion = new Map<string, RegionCode>();
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;
}

View File

@ -126,14 +126,20 @@ export async function getSeriesObservations(
await rateLimitDelay(); await rateLimitDelay();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30_000);
let response: Response; let response: Response;
try { try {
response = await fetch(`${FRED_BASE_URL}?${params.toString()}`); response = await fetch(`${FRED_BASE_URL}?${params.toString()}`, { signal: controller.signal });
} catch (err) { } catch (err) {
clearTimeout(timeoutId);
return { return {
ok: false, ok: false,
error: `FRED API network error: ${err instanceof Error ? err.message : String(err)}`, error: `FRED API network error: ${err instanceof Error ? err.message : String(err)}`,
}; };
} finally {
clearTimeout(timeoutId);
} }
if (!response.ok) { if (!response.ok) {

View File

@ -127,6 +127,50 @@ export const eiaFuelTypeDataResponseSchema = eiaResponseSchema(eiaFuelTypeDataRo
export type EiaRegionDataResponse = z.infer<typeof eiaRegionDataResponseSchema>; export type EiaRegionDataResponse = z.infer<typeof eiaRegionDataResponseSchema>;
export type EiaFuelTypeDataResponse = z.infer<typeof eiaFuelTypeDataResponseSchema>; export type EiaFuelTypeDataResponse = z.infer<typeof eiaFuelTypeDataResponseSchema>;
// ---------------------------------------------------------------------------
// 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<RegionCode, string> = {
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<typeof eiaRetailPriceRowSchema>;
export const eiaRetailPriceResponseSchema = eiaResponseSchema(eiaRetailPriceRowSchema);
export type EiaRetailPriceResponse = z.infer<typeof eiaRetailPriceResponseSchema>;
/** 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. * Parse an EIA hourly period string to a UTC Date.
* Format: "2026-02-11T08" => Date for 2026-02-11 08:00:00 UTC * Format: "2026-02-11T08" => Date for 2026-02-11 08:00:00 UTC

View File

@ -5,6 +5,12 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); 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<string, string> = { const REGION_TIMEZONES: Record<string, string> = {
ERCOT: 'America/Chicago', ERCOT: 'America/Chicago',
PJM: 'America/New_York', PJM: 'America/New_York',