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:
parent
a954e89b47
commit
7a1bbca339
@ -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:
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
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
|
||||
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,
|
||||
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
|
||||
|
||||
@ -1,13 +1,30 @@
|
||||
WITH price_agg AS (
|
||||
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
|
||||
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,
|
||||
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
|
||||
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
const start = sixMonthsAgoIso();
|
||||
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) {
|
||||
const regionId = regionIdByCode.get(regionCode);
|
||||
if (!regionId) {
|
||||
@ -81,6 +115,9 @@ async function backfillElectricity(): Promise<void> {
|
||||
});
|
||||
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<void> {
|
||||
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<void> {
|
||||
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' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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<ActionResult<getDemandByRegion.Result[]>> {
|
||||
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<ActionResult<getDemand
|
||||
try {
|
||||
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
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) };
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { getGenerationMix } 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,6 +36,9 @@ export async function fetchGenerationMix(
|
||||
timeRange: TimeRange = '30d',
|
||||
): Promise<ActionResult<getGenerationMix.Result[]>> {
|
||||
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));
|
||||
|
||||
@ -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<ActionResult<getPriceTrends.Result[]>> {
|
||||
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<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<
|
||||
ActionResult<
|
||||
Array<{
|
||||
|
||||
24
src/app/api/ingest/auth.ts
Normal file
24
src/app/api/ingest/auth.ts
Normal 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;
|
||||
}
|
||||
@ -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<NextResponse<IngestionStats>> {
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
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;
|
||||
|
||||
@ -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<NextResponse<IngestionStats>> {
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
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<NextResponse<IngestionS
|
||||
});
|
||||
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) {
|
||||
const regionId = regionIdByCode.get(regionCode);
|
||||
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]));
|
||||
|
||||
// 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<NextResponse<IngestionS
|
||||
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 =
|
||||
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<NextResponse<IngestionS
|
||||
toUpdate.map(u =>
|
||||
prisma.electricityPrice.update({
|
||||
where: { id: u.id },
|
||||
data: { demandMw: u.demandMw, source: 'EIA' },
|
||||
data: { demandMw: u.demandMw, priceMwh: u.priceMwh, source: 'EIA' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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<NextResponse<IngestionStats>> {
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const authError = checkIngestAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const regionParam = searchParams.get('region');
|
||||
const start = searchParams.get('start') ?? undefined;
|
||||
|
||||
@ -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,11 +24,14 @@ function formatNumber(value: number, decimals = 1): string {
|
||||
}
|
||||
|
||||
export default async function DashboardHome() {
|
||||
const [pricesResult, commoditiesResult, datacentersResult, demandResult] = await Promise.all([
|
||||
const [pricesResult, commoditiesResult, datacentersResult, demandResult, sparklinesResult, alertsResult] =
|
||||
await Promise.all([
|
||||
fetchLatestPrices(),
|
||||
fetchLatestCommodityPrices(),
|
||||
fetchDatacenters(),
|
||||
fetchRegionDemandSummary(),
|
||||
fetchPriceSparklines(),
|
||||
fetchRecentAlerts(),
|
||||
]);
|
||||
|
||||
const prices = pricesResult.ok
|
||||
@ -55,6 +65,24 @@ export default async function DashboardHome() {
|
||||
>(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 natGas = commodities.find(c => c.commodity === 'natural_gas');
|
||||
@ -114,20 +142,35 @@ export default async function DashboardHome() {
|
||||
<MetricCard
|
||||
title="Avg Electricity Price"
|
||||
value={avgPrice > 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%)"
|
||||
/>
|
||||
<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="Natural Gas Spot"
|
||||
value={natGas ? `$${natGas.price.toFixed(2)}` : '--'}
|
||||
numericValue={natGas?.price}
|
||||
animatedFormat={natGas ? 'dollar' : undefined}
|
||||
unit={natGas?.unit ?? '/MMBtu'}
|
||||
icon={Flame}
|
||||
/>
|
||||
<MetricCard
|
||||
title="WTI Crude Oil"
|
||||
value={wtiCrude ? `$${wtiCrude.price.toFixed(2)}` : '--'}
|
||||
numericValue={wtiCrude?.price}
|
||||
animatedFormat={wtiCrude ? 'dollar' : undefined}
|
||||
unit={wtiCrude?.unit ?? '/bbl'}
|
||||
icon={Droplets}
|
||||
/>
|
||||
@ -138,7 +181,7 @@ export default async function DashboardHome() {
|
||||
<Card className="group cursor-pointer transition-colors hover:border-primary/50">
|
||||
<CardHeader>
|
||||
<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
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@ -163,15 +206,23 @@ export default async function DashboardHome() {
|
||||
<CardContent>
|
||||
{prices.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{prices.map(p => (
|
||||
<div key={p.region_code} className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{p.region_name}</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
{prices.map(p => {
|
||||
const regionSparkline = sparklineMap[p.region_code];
|
||||
return (
|
||||
<div key={p.region_code} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-16 shrink-0 font-medium">{p.region_code}</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>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No price data available yet.</p>
|
||||
@ -212,8 +263,10 @@ export default async function DashboardHome() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
||||
{alertsResult.ok && <AlertsFeed initialData={alertsResult.data} />}
|
||||
|
||||
{avgDemand > 0 && (
|
||||
<div className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@ -228,8 +281,8 @@ export default async function DashboardHome() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 => (
|
||||
<ReferenceLine
|
||||
key={milestone.date}
|
||||
x={new Date(milestone.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
x={milestone.xDisplay}
|
||||
yAxisId="left"
|
||||
stroke={MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)'}
|
||||
strokeDasharray="3 3"
|
||||
|
||||
21
src/components/charts/sparkline.tsx
Normal file
21
src/components/charts/sparkline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/dashboard/alerts-feed.tsx
Normal file
110
src/components/dashboard/alerts-feed.tsx
Normal 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>·</span>
|
||||
<span>{formatRelativeTime(alert.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -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<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 {
|
||||
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 (
|
||||
<Card className={cn('gap-0 py-4', className)}>
|
||||
<CardHeader className="pb-2">
|
||||
@ -21,9 +59,18 @@ export function MetricCard({ title, value, unit, icon: Icon, className }: Metric
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
{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>}
|
||||
</div>
|
||||
{sparklineData && sparklineData.length >= 2 && (
|
||||
<div className="mt-2">
|
||||
<Sparkline data={sparklineData} color={sparklineColor} height={28} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -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,11 +12,13 @@ interface TickerItem {
|
||||
unit: string;
|
||||
}
|
||||
|
||||
function formatPrice(price: number, unit: string): string {
|
||||
if (unit === '$/MWh') {
|
||||
function formatPrice(price: number): string {
|
||||
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 }) {
|
||||
@ -41,52 +42,44 @@ function TickerItemDisplay({ item }: { item: TickerItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TickerTape() {
|
||||
const [items, setItems] = useState<TickerItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPrices() {
|
||||
const [priceResult, commodityResult] = await Promise.all([fetchLatestPrices(), fetchLatestCommodityPrices()]);
|
||||
|
||||
const tickerItems: TickerItem[] = [];
|
||||
|
||||
if (priceResult.ok) {
|
||||
const prices = deserialize<getLatestPrices.Result[]>(priceResult.data);
|
||||
for (const p of prices) {
|
||||
tickerItems.push({
|
||||
label: p.region_code,
|
||||
price: formatPrice(p.price_mwh, '$/MWh'),
|
||||
change: null,
|
||||
unit: '$/MWh',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (commodityResult.ok) {
|
||||
const commodities = deserialize<
|
||||
Array<{
|
||||
commodity: string;
|
||||
price: number;
|
||||
unit: string;
|
||||
timestamp: Date;
|
||||
source: string;
|
||||
}>
|
||||
>(commodityResult.data);
|
||||
|
||||
const commodityLabels: Record<string, string> = {
|
||||
const COMMODITY_LABELS: Record<string, string> = {
|
||||
natural_gas: 'Nat Gas',
|
||||
wti_crude: 'WTI Crude',
|
||||
coal: 'Coal',
|
||||
};
|
||||
|
||||
for (const c of commodities) {
|
||||
export function TickerTape() {
|
||||
const [items, setItems] = useState<TickerItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPrices() {
|
||||
const result = await fetchTickerPrices();
|
||||
|
||||
if (!result.ok) return;
|
||||
|
||||
const { electricity, commodities } = deserialize<{
|
||||
electricity: TickerPriceRow[];
|
||||
commodities: TickerCommodityRow[];
|
||||
}>(result.data);
|
||||
|
||||
const tickerItems: TickerItem[] = [];
|
||||
|
||||
for (const p of electricity) {
|
||||
tickerItems.push({
|
||||
label: commodityLabels[c.commodity] ?? c.commodity,
|
||||
price: formatPrice(c.price, c.unit),
|
||||
change: null,
|
||||
unit: c.unit,
|
||||
label: p.region_code,
|
||||
price: formatPrice(p.price_mwh),
|
||||
change: computeChangePercent(p.price_mwh, p.prev_price_mwh),
|
||||
unit: '$/MWh',
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -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,9 +83,15 @@ function buildUrl(endpoint: string, params: EiaQueryParams): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
async function fetchEia(endpoint: string, params: EiaQueryParams): Promise<unknown> {
|
||||
const url = buildUrl(endpoint, params);
|
||||
const response = await fetch(url);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'unknown error');
|
||||
@ -85,6 +99,9 @@ async function fetchEia(endpoint: string, params: EiaQueryParams): Promise<unkno
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -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<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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -127,6 +127,50 @@ export const eiaFuelTypeDataResponseSchema = eiaResponseSchema(eiaFuelTypeDataRo
|
||||
export type EiaRegionDataResponse = z.infer<typeof eiaRegionDataResponseSchema>;
|
||||
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.
|
||||
* Format: "2026-02-11T08" => Date for 2026-02-11 08:00:00 UTC
|
||||
|
||||
@ -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<string, string> = {
|
||||
ERCOT: 'America/Chicago',
|
||||
PJM: 'America/New_York',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user