fixups and such

This commit is contained in:
Joey Eamigh 2026-02-11 21:35:03 -05:00
parent ad1a6792f5
commit d8478ace96
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
39 changed files with 1097 additions and 278 deletions

View File

@ -1,5 +1,6 @@
{ {
"enabledPlugins": { "enabledPlugins": {
"ralph-loop@claude-plugins-official": true "ralph-loop@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": false
} }
} }

View File

@ -0,0 +1,39 @@
-- @param {String} $1:regionCode
-- Monthly average electricity price and cumulative datacenter capacity for a region
WITH months AS (
SELECT generate_series(
'2019-01-01'::timestamptz,
date_trunc('month', now()),
'1 month'
) AS month
),
monthly_prices AS (
SELECT
date_trunc('month', ep.timestamp) AS month,
AVG(ep.price_mwh) AS avg_price
FROM electricity_prices ep
JOIN grid_regions r ON ep.region_id = r.id
WHERE r.code = $1
GROUP BY 1
),
dc_capacity AS (
SELECT
make_timestamptz(d.year_opened, 1, 1, 0, 0, 0) AS opened_month,
SUM(d.capacity_mw) AS added_mw
FROM datacenters d
JOIN grid_regions r ON d.region_id = r.id
WHERE r.code = $1
GROUP BY d.year_opened
)
SELECT
m.month,
mp.avg_price,
COALESCE(
SUM(dc.added_mw) OVER (ORDER BY m.month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),
0
)::float AS cumulative_capacity_mw
FROM months m
LEFT JOIN monthly_prices mp ON mp.month = m.month
LEFT JOIN dc_capacity dc ON dc.opened_month <= m.month
AND dc.opened_month > m.month - INTERVAL '1 month'
ORDER BY m.month ASC

View File

@ -0,0 +1,54 @@
-- Before/after price comparison for each datacenter opening event (year_opened >= 2019)
WITH dc_events AS (
SELECT
d.name AS dc_name,
d.capacity_mw,
d.year_opened,
r.code AS region_code,
r.name AS region_name,
make_timestamptz(d.year_opened, 1, 1, 0, 0, 0) AS event_date
FROM datacenters d
JOIN grid_regions r ON d.region_id = r.id
WHERE d.year_opened >= 2019
),
price_before AS (
SELECT
de.dc_name,
AVG(ep.price_mwh) AS avg_price_before
FROM dc_events de
JOIN grid_regions r ON r.code = de.region_code
JOIN electricity_prices ep ON ep.region_id = r.id
AND ep.timestamp >= de.event_date - INTERVAL '6 months'
AND ep.timestamp < de.event_date
GROUP BY de.dc_name
),
price_after AS (
SELECT
de.dc_name,
AVG(ep.price_mwh) AS avg_price_after
FROM dc_events de
JOIN grid_regions r ON r.code = de.region_code
JOIN electricity_prices ep ON ep.region_id = r.id
AND ep.timestamp >= de.event_date
AND ep.timestamp < de.event_date + INTERVAL '6 months'
GROUP BY de.dc_name
)
SELECT
de.dc_name,
de.capacity_mw,
de.year_opened,
de.region_code,
de.region_name,
pb.avg_price_before,
pa.avg_price_after,
CASE
WHEN pb.avg_price_before > 0
THEN ((pa.avg_price_after - pb.avg_price_before) / pb.avg_price_before * 100)
ELSE NULL
END AS pct_change
FROM dc_events de
LEFT JOIN price_before pb ON pb.dc_name = de.dc_name
LEFT JOIN price_after pa ON pa.dc_name = de.dc_name
WHERE pb.avg_price_before IS NOT NULL
AND pa.avg_price_after IS NOT NULL
ORDER BY ABS(COALESCE(pa.avg_price_after - pb.avg_price_before, 0)) DESC

View File

@ -1,6 +1,20 @@
SELECT DISTINCT ON (ep.region_id) SELECT
ep.id, ep.region_id, ep.price_mwh, ep.demand_mw, ep.timestamp, ep.source, latest.id, latest.region_id, latest.price_mwh, latest.demand_mw,
r.code as region_code, r.name as region_name latest.timestamp, latest.source,
FROM electricity_prices ep r.code as region_code, r.name as region_name,
JOIN grid_regions r ON ep.region_id = r.id stats.avg_price_7d, stats.stddev_price_7d
ORDER BY ep.region_id, ep.timestamp DESC FROM (
SELECT DISTINCT ON (ep.region_id)
ep.id, ep.region_id, ep.price_mwh, ep.demand_mw, ep.timestamp, ep.source
FROM electricity_prices ep
ORDER BY ep.region_id, ep.timestamp DESC
) latest
JOIN grid_regions r ON latest.region_id = r.id
LEFT JOIN LATERAL (
SELECT
AVG(ep2.price_mwh)::double precision as avg_price_7d,
STDDEV(ep2.price_mwh)::double precision as stddev_price_7d
FROM electricity_prices ep2
WHERE ep2.region_id = latest.region_id
AND ep2.timestamp >= NOW() - INTERVAL '7 days'
) stats ON true

56
src/actions/dc-impact.ts Normal file
View File

@ -0,0 +1,56 @@
'use server';
import { getCapacityPriceTimeline, getDcPriceImpact } from '@/generated/prisma/sql.js';
import { prisma } from '@/lib/db.js';
import { serialize } from '@/lib/superjson.js';
import { validateRegionCode } from '@/lib/utils.js';
import { cacheLife, cacheTag } from 'next/cache';
interface ActionSuccess<T> {
ok: true;
data: ReturnType<typeof serialize<T>>;
}
interface ActionError {
ok: false;
error: string;
}
type ActionResult<T> = ActionSuccess<T> | ActionError;
export async function fetchCapacityPriceTimeline(
regionCode: string,
): Promise<ActionResult<getCapacityPriceTimeline.Result[]>> {
'use cache';
cacheLife('prices');
cacheTag(`capacity-price-timeline-${regionCode}`);
try {
if (!validateRegionCode(regionCode)) {
return { ok: false, error: `Invalid region code: ${regionCode}` };
}
const rows = await prisma.$queryRawTyped(getCapacityPriceTimeline(regionCode));
return { ok: true, data: serialize(rows) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch capacity/price timeline: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
export async function fetchDcPriceImpact(): Promise<ActionResult<getDcPriceImpact.Result[]>> {
'use cache';
cacheLife('prices');
cacheTag('dc-price-impact');
try {
const rows = await prisma.$queryRawTyped(getDcPriceImpact());
return { ok: true, data: serialize(rows) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch DC price impact: ${err instanceof Error ? err.message : String(err)}`,
};
}
}

View File

@ -7,16 +7,18 @@ import { serialize } from '@/lib/superjson.js';
import { validateRegionCode } from '@/lib/utils.js'; import { validateRegionCode } from '@/lib/utils.js';
import { cacheLife, cacheTag } from 'next/cache'; import { cacheLife, cacheTag } from 'next/cache';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
function timeRangeToStartDate(range: TimeRange): Date { function timeRangeToStartDate(range: TimeRange): Date {
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
const now = new Date(); const now = new Date();
const ms: Record<TimeRange, number> = { const ms: Record<Exclude<TimeRange, 'all'>, number> = {
'24h': 24 * 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000, '90d': 90 * 24 * 60 * 60 * 1000,
'1y': 365 * 24 * 60 * 60 * 1000, '1y': 365 * 24 * 60 * 60 * 1000,
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
}; };
return new Date(now.getTime() - ms[range]); return new Date(now.getTime() - ms[range]);
} }

View File

@ -7,16 +7,18 @@ import { serialize } from '@/lib/superjson.js';
import { validateRegionCode } from '@/lib/utils.js'; import { validateRegionCode } from '@/lib/utils.js';
import { cacheLife, cacheTag } from 'next/cache'; import { cacheLife, cacheTag } from 'next/cache';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
function timeRangeToStartDate(range: TimeRange): Date { function timeRangeToStartDate(range: TimeRange): Date {
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
const now = new Date(); const now = new Date();
const ms: Record<TimeRange, number> = { const ms: Record<Exclude<TimeRange, 'all'>, number> = {
'24h': 24 * 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000, '90d': 90 * 24 * 60 * 60 * 1000,
'1y': 365 * 24 * 60 * 60 * 1000, '1y': 365 * 24 * 60 * 60 * 1000,
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
}; };
return new Date(now.getTime() - ms[range]); return new Date(now.getTime() - ms[range]);
} }

View File

@ -13,16 +13,18 @@ import { serialize } from '@/lib/superjson.js';
import { validateRegionCode } from '@/lib/utils.js'; import { validateRegionCode } from '@/lib/utils.js';
import { cacheLife, cacheTag } from 'next/cache'; import { cacheLife, cacheTag } from 'next/cache';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
function timeRangeToStartDate(range: TimeRange): Date { function timeRangeToStartDate(range: TimeRange): Date {
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
const now = new Date(); const now = new Date();
const ms: Record<TimeRange, number> = { const ms: Record<Exclude<TimeRange, 'all'>, number> = {
'24h': 24 * 60 * 60 * 1000, '24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000, '90d': 90 * 24 * 60 * 60 * 1000,
'1y': 365 * 24 * 60 * 60 * 1000, '1y': 365 * 24 * 60 * 60 * 1000,
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
}; };
return new Date(now.getTime() - ms[range]); return new Date(now.getTime() - ms[range]);
} }
@ -293,55 +295,68 @@ export async function fetchRecentAlerts(): Promise<
timestamp: Date; timestamp: Date;
}> = []; }> = [];
// Detect price spikes (above $80/MWh) and demand peaks // Compute per-region mean and stddev for statistical anomaly detection
const regionAvgs = new Map<string, { sum: number; count: number }>(); const regionStats = new Map<string, { sum: number; sumSq: number; count: number }>();
for (const row of priceRows) { for (const row of priceRows) {
const entry = regionAvgs.get(row.region.code) ?? { sum: 0, count: 0 }; const entry = regionStats.get(row.region.code) ?? { sum: 0, sumSq: 0, count: 0 };
entry.sum += row.priceMwh; entry.sum += row.priceMwh;
entry.sumSq += row.priceMwh * row.priceMwh;
entry.count += 1; entry.count += 1;
regionAvgs.set(row.region.code, entry); regionStats.set(row.region.code, entry);
}
function getRegionThresholds(regionCode: string) {
const stats = regionStats.get(regionCode);
if (!stats || stats.count < 2) return null;
const avg = stats.sum / stats.count;
const variance = stats.sumSq / stats.count - avg * avg;
const sd = Math.sqrt(Math.max(0, variance));
return { avg, sd };
} }
for (const row of priceRows) { for (const row of priceRows) {
const avg = regionAvgs.get(row.region.code); const thresholds = getRegionThresholds(row.region.code);
const avgPrice = avg ? avg.sum / avg.count : 0; if (thresholds && thresholds.sd > 0) {
const { avg, sd } = thresholds;
const sigmas = (row.priceMwh - avg) / sd;
if (row.priceMwh >= 100) { if (sigmas >= 2.5) {
alerts.push({ alerts.push({
id: `spike-${row.id}`, id: `spike-${row.id}`,
type: 'price_spike', type: 'price_spike',
severity: 'critical', severity: 'critical',
region_code: row.region.code, region_code: row.region.code,
region_name: row.region.name, region_name: row.region.name,
description: `${row.region.code} electricity hit $${row.priceMwh.toFixed(2)}/MWh`, description: `${row.region.code} at $${row.priceMwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above avg ($${avg.toFixed(0)})`,
value: row.priceMwh, value: row.priceMwh,
unit: '$/MWh', unit: '$/MWh',
timestamp: row.timestamp, timestamp: row.timestamp,
}); });
} else if (row.priceMwh >= 80) { } else if (sigmas >= 1.8) {
alerts.push({ alerts.push({
id: `spike-${row.id}`, id: `spike-${row.id}`,
type: 'price_spike', type: 'price_spike',
severity: 'warning', severity: 'warning',
region_code: row.region.code, region_code: row.region.code,
region_name: row.region.name, region_name: row.region.name,
description: `${row.region.code} electricity at $${row.priceMwh.toFixed(2)}/MWh`, description: `${row.region.code} at $${row.priceMwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above avg ($${avg.toFixed(0)})`,
value: row.priceMwh, value: row.priceMwh,
unit: '$/MWh', unit: '$/MWh',
timestamp: row.timestamp, timestamp: row.timestamp,
}); });
} else if (avgPrice > 0 && row.priceMwh < avgPrice * 0.7) { } else if (sigmas <= -1.8) {
alerts.push({ alerts.push({
id: `drop-${row.id}`, id: `drop-${row.id}`,
type: 'price_drop', type: 'price_drop',
severity: 'info', severity: 'info',
region_code: row.region.code, region_code: row.region.code,
region_name: row.region.name, region_name: row.region.name,
description: `${row.region.code} price dropped to $${row.priceMwh.toFixed(2)}/MWh (avg $${avgPrice.toFixed(2)})`, description: `${row.region.code} dropped to $${row.priceMwh.toFixed(2)}/MWh — ${Math.abs(sigmas).toFixed(1)}σ below avg ($${avg.toFixed(0)})`,
value: row.priceMwh, value: row.priceMwh,
unit: '$/MWh', unit: '$/MWh',
timestamp: row.timestamp, timestamp: row.timestamp,
}); });
}
} }
if (row.demandMw >= 50000) { if (row.demandMw >= 50000) {

View File

@ -92,6 +92,12 @@ export async function DemandSummary() {
}, },
]; ];
// Top 5 regions by demand for a quick leaderboard
const topRegions = [...regionMap.values()]
.filter(r => r.latestDemandMw > 0)
.sort((a, b) => b.latestDemandMw - a.latestDemandMw)
.slice(0, 5);
return ( return (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@ -113,6 +119,31 @@ export async function DemandSummary() {
</div> </div>
))} ))}
</div> </div>
{topRegions.length > 0 && (
<>
<div className="my-4 h-px bg-border/50" />
<p className="mb-2.5 text-[11px] font-medium tracking-wide text-muted-foreground">Top Regions by Demand</p>
<div className="space-y-2">
{topRegions.map((r, i) => {
const maxDemand = topRegions[0]!.latestDemandMw;
const pct = maxDemand > 0 ? (r.latestDemandMw / maxDemand) * 100 : 0;
return (
<div key={r.regionCode} className="flex items-center gap-2.5 text-sm">
<span className="w-4 text-right text-[10px] font-bold text-muted-foreground">{i + 1}</span>
<span className="w-12 shrink-0 font-mono text-xs font-semibold">{r.regionCode}</span>
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted/30">
<div className="h-full rounded-full bg-chart-3 transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="w-14 shrink-0 text-right font-mono text-xs text-muted-foreground tabular-nums">
{formatGw(r.latestDemandMw)} {formatGwUnit(r.latestDemandMw)}
</span>
</div>
);
})}
</div>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -24,9 +24,5 @@ export async function GpuCalculatorSection() {
if (regionPrices.length === 0) return null; if (regionPrices.length === 0) return null;
return ( return <GpuCalculator regionPrices={regionPrices} />;
<div className="mt-8">
<GpuCalculator regionPrices={regionPrices} />
</div>
);
} }

View File

@ -37,7 +37,7 @@ export async function PricesByRegion() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{prices.length > 0 ? ( {prices.length > 0 ? (
<div className="space-y-3"> <div className="max-h-[400px] space-y-3 overflow-y-auto pr-1">
{prices.map(p => { {prices.map(p => {
const regionSparkline = sparklineMap[p.region_code]; const regionSparkline = sparklineMap[p.region_code];
return ( return (

View File

@ -61,42 +61,40 @@ export async function StressGauges() {
const rightColumn = regions.slice(midpoint); const rightColumn = regions.slice(midpoint);
return ( return (
<div className="mt-8"> <Card>
<Card> <CardHeader className="pb-3">
<CardHeader className="pb-3"> <CardTitle className="flex items-center gap-2">
<CardTitle className="flex items-center gap-2"> <Radio className="h-5 w-5 text-chart-4" />
<Radio className="h-5 w-5 text-chart-4" /> Region Grid Status
Region Grid Status </CardTitle>
</CardTitle> <p className="text-xs text-muted-foreground">Current demand vs. 7-day peak by region</p>
<p className="text-xs text-muted-foreground">Current demand vs. 7-day peak by region</p> </CardHeader>
</CardHeader> <CardContent>
<CardContent> <div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2">
<div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2"> <div className="flex flex-col gap-2.5">
<div className="flex flex-col gap-2.5"> {leftColumn.map(r => (
{leftColumn.map(r => ( <GridStressGauge
<GridStressGauge key={r.regionCode}
key={r.regionCode} regionCode={r.regionCode}
regionCode={r.regionCode} regionName={r.regionName}
regionName={r.regionName} demandMw={r.latestDemandMw}
demandMw={r.latestDemandMw} peakDemandMw={r.peakDemandMw}
peakDemandMw={r.peakDemandMw} />
/> ))}
))}
</div>
<div className="flex flex-col gap-2.5">
{rightColumn.map(r => (
<GridStressGauge
key={r.regionCode}
regionCode={r.regionCode}
regionName={r.regionName}
demandMw={r.latestDemandMw}
peakDemandMw={r.peakDemandMw}
/>
))}
</div>
</div> </div>
</CardContent> <div className="flex flex-col gap-2.5">
</Card> {rightColumn.map(r => (
</div> <GridStressGauge
key={r.regionCode}
regionCode={r.regionCode}
regionName={r.regionName}
demandMw={r.latestDemandMw}
peakDemandMw={r.peakDemandMw}
/>
))}
</div>
</div>
</CardContent>
</Card>
); );
} }

View File

@ -118,9 +118,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
if (authError) return authError; if (authError) return authError;
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const start = searchParams.get('start') ?? undefined;
const end = searchParams.get('end') ?? undefined; const end = searchParams.get('end') ?? undefined;
// Default to last 30 days if no start date provided — commodity data
// is daily/monthly so a wider window is fine and still bounded.
const startParam = searchParams.get('start');
const start =
startParam ??
(() => {
const d = new Date();
d.setUTCDate(d.getUTCDate() - 30);
return d.toISOString().slice(0, 10);
})();
const stats: IngestionStats = { inserted: 0, updated: 0, errors: 0 }; const stats: IngestionStats = { inserted: 0, updated: 0, errors: 0 };
const { rows, errors: fetchErrors } = await fetchAllCommodities(start, end); const { rows, errors: fetchErrors } = await fetchAllCommodities(start, end);

View File

@ -38,9 +38,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
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 end = searchParams.get('end') ?? undefined; const end = searchParams.get('end') ?? undefined;
// Default to last 7 days if no start date provided — prevents
// auto-paginating through ALL historical EIA data (causes timeouts).
const startParam = searchParams.get('start');
const start =
startParam ??
(() => {
const d = new Date();
d.setUTCDate(d.getUTCDate() - 7);
return d.toISOString().slice(0, 10);
})();
let regions: RegionCode[]; let regions: RegionCode[];
if (regionParam) { if (regionParam) {
if (!isRegionCode(regionParam)) { if (!isRegionCode(regionParam)) {
@ -73,7 +83,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
// Continue with demand data even if prices fail // Continue with demand data even if prices fail
} }
// Build fallback: for each region, find the most recent month with data // Build fallback: for each region, find the most recent month with data from the API
const latestPriceByRegion = new Map<string, number>(); const latestPriceByRegion = new Map<string, number>();
for (const [key, price] of retailPriceByRegionMonth) { for (const [key, price] of retailPriceByRegionMonth) {
const region = key.split(':')[0]!; const region = key.split(':')[0]!;
@ -83,6 +93,29 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
} }
} }
// If API returned no prices (e.g. recent months not yet reported),
// fall back to the last known non-zero price per region from the database.
if (retailPriceByRegionMonth.size === 0) {
const dbFallbacks = await prisma.$queryRaw<Array<{ code: string; price_mwh: number }>>`
SELECT r.code, ep.price_mwh
FROM electricity_prices ep
JOIN grid_regions r ON ep.region_id = r.id
WHERE ep.price_mwh > 0
AND (r.code, ep.timestamp) IN (
SELECT r2.code, MAX(ep2.timestamp)
FROM electricity_prices ep2
JOIN grid_regions r2 ON ep2.region_id = r2.id
WHERE ep2.price_mwh > 0
GROUP BY r2.code
)
`;
for (const row of dbFallbacks) {
if (!latestPriceByRegion.has(row.code)) {
latestPriceByRegion.set(row.code, row.price_mwh);
}
}
}
for (const regionCode of regions) { for (const regionCode of regions) {
const regionId = regionIdByCode.get(regionCode); const regionId = regionIdByCode.get(regionCode);
if (!regionId) { if (!regionId) {
@ -92,7 +125,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
try { try {
const demandData = await getRegionData(regionCode, 'D', { start, end }); const demandData = await getRegionData(regionCode, 'D', { start, end });
const validPoints = demandData.filter((p): p is typeof p & { valueMw: number } => p.valueMw !== null); // Reject values outside a reasonable range — the EIA API occasionally returns
// garbage (e.g. 2^31 overflow, deeply negative values). PJM peak is ~150K MW.
const MAX_DEMAND_MW = 500_000;
const validPoints = demandData.filter(
(p): p is typeof p & { valueMw: number } => p.valueMw !== null && p.valueMw >= 0 && p.valueMw <= MAX_DEMAND_MW,
);
if (validPoints.length === 0) continue; if (validPoints.length === 0) continue;

View File

@ -38,9 +38,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
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 end = searchParams.get('end') ?? undefined; const end = searchParams.get('end') ?? undefined;
// Default to last 7 days if no start date provided — prevents
// auto-paginating through ALL historical EIA data (causes timeouts).
const startParam = searchParams.get('start');
const start =
startParam ??
(() => {
const d = new Date();
d.setUTCDate(d.getUTCDate() - 7);
return d.toISOString().slice(0, 10);
})();
let regions: RegionCode[]; let regions: RegionCode[];
if (regionParam) { if (regionParam) {
if (!isRegionCode(regionParam)) { if (!isRegionCode(regionParam)) {

View File

@ -5,7 +5,7 @@ import { deserialize } from '@/lib/superjson.js';
export async function DemandChartSection() { export async function DemandChartSection() {
const [demandResult, summaryResult] = await Promise.all([ const [demandResult, summaryResult] = await Promise.all([
fetchDemandByRegion('ALL', '30d'), fetchDemandByRegion('ALL', 'all'),
fetchRegionDemandSummary(), fetchRegionDemandSummary(),
]); ]);

View File

@ -2,7 +2,7 @@ import { fetchGenerationMix } from '@/actions/generation.js';
import { GenerationChart } from '@/components/charts/generation-chart.js'; import { GenerationChart } from '@/components/charts/generation-chart.js';
const DEFAULT_REGION = 'PJM'; const DEFAULT_REGION = 'PJM';
const DEFAULT_TIME_RANGE = '30d' as const; const DEFAULT_TIME_RANGE = 'all' as const;
export async function GenerationChartSection() { export async function GenerationChartSection() {
const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE); const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE);

View File

@ -23,10 +23,10 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className={inter.variable} suppressHydrationWarning> <html lang="en" className={inter.variable} suppressHydrationWarning>
<body className="flex min-h-dvh flex-col font-sans antialiased"> <body className="flex min-h-dvh flex-col overflow-x-hidden font-sans antialiased">
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange> <ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
<Nav /> <Nav />
<main className="mx-auto w-full max-w-[1920px] flex-1">{children}</main> <main className="mx-auto w-full max-w-[1400px] flex-1">{children}</main>
<Footer /> <Footer />
<Toaster theme="dark" richColors position="bottom-right" /> <Toaster theme="dark" richColors position="bottom-right" />
</ThemeProvider> </ThemeProvider>

View File

@ -85,6 +85,7 @@ export async function MapContent() {
if (priceResult.ok) { if (priceResult.ok) {
const rows = deserialize<getRegionPriceHeatmap.Result[]>(priceResult.data); const rows = deserialize<getRegionPriceHeatmap.Result[]>(priceResult.data);
for (const row of rows) { for (const row of rows) {
if (!row.code || !row.name) continue;
regions.push({ regions.push({
code: row.code, code: row.code,
name: row.name, name: row.name,

View File

@ -16,7 +16,7 @@ function MapSkeleton() {
export default function MapPage() { export default function MapPage() {
return ( return (
<div className="h-[calc(100dvh-var(--header-h)-var(--footer-h))]"> <div className="[margin-right:calc(50%-50vw)] [margin-left:calc(50%-50vw)] h-[calc(100dvh-var(--header-h)-var(--footer-h))] [width:100vw] !max-w-none overflow-hidden">
<Suspense fallback={<MapSkeleton />}> <Suspense fallback={<MapSkeleton />}>
<MapContent /> <MapContent />
</Suspense> </Suspense>

View File

@ -2,7 +2,7 @@ import { Suspense } from 'react';
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js'; import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import { MetricCardSkeleton } from '@/components/dashboard/metric-card-skeleton.js'; import { MetricCardSkeleton } from '@/components/dashboard/metric-card-skeleton.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; import { Card, CardContent, CardHeader } from '@/components/ui/card.js';
import { Skeleton } from '@/components/ui/skeleton.js'; import { Skeleton } from '@/components/ui/skeleton.js';
import { Activity, ArrowRight, Map as MapIcon } from 'lucide-react'; import { Activity, ArrowRight, Map as MapIcon } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
@ -69,29 +69,27 @@ function AlertsSkeleton() {
function GaugesSkeleton() { function GaugesSkeleton() {
return ( return (
<div className="mt-8"> <Card>
<Card> <CardHeader className="pb-3">
<CardHeader className="pb-3"> <Skeleton className="h-5 w-44" />
<Skeleton className="h-5 w-44" /> <Skeleton className="mt-1 h-3 w-64" />
<Skeleton className="mt-1 h-3 w-64" /> </CardHeader>
</CardHeader> <CardContent>
<CardContent> <div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2">
<div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2"> {Array.from({ length: 2 }).map((_, col) => (
{Array.from({ length: 2 }).map((_, col) => ( <div key={col} className="flex flex-col gap-2.5">
<div key={col} className="flex flex-col gap-2.5"> {Array.from({ length: 5 }).map((_, i) => (
{Array.from({ length: 5 }).map((_, i) => ( <div key={i} className="flex items-center gap-3">
<div key={i} className="flex items-center gap-3"> <Skeleton className="h-4 w-14" />
<Skeleton className="h-4 w-14" /> <Skeleton className="h-2 flex-1 rounded-full" />
<Skeleton className="h-2 flex-1 rounded-full" /> <Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-20" /> </div>
</div> ))}
))} </div>
</div> ))}
))} </div>
</div> </CardContent>
</CardContent> </Card>
</Card>
</div>
); );
} }
@ -122,58 +120,51 @@ function DemandSummarySkeleton() {
export default function DashboardHome() { export default function DashboardHome() {
return ( return (
<div className="px-4 py-6 sm:px-6 sm:py-8"> <div className="px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 sm:mb-8"> <div className="mb-6 flex items-end justify-between">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Dashboard</h1> <div>
<p className="mt-1 text-muted-foreground"> <h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Dashboard</h1>
Real-time overview of AI datacenter energy impact across US grid regions. <p className="mt-1 text-sm text-muted-foreground">
</p> Real-time overview of AI datacenter energy impact across US grid regions.
</p>
</div>
<Link
href="/map"
className="group hidden items-center gap-1.5 rounded-md border border-border/50 bg-muted/30 px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground sm:inline-flex">
<MapIcon className="h-3.5 w-3.5" />
Open Map
<ArrowRight className="h-3 w-3 transition-transform group-hover:translate-x-0.5" />
</Link>
</div> </div>
{/* Hero metric cards */}
<Suspense fallback={<MetricCardsSkeleton />}> <Suspense fallback={<MetricCardsSkeleton />}>
<HeroMetrics /> <HeroMetrics />
</Suspense> </Suspense>
<div className="mt-8 grid gap-4 lg:grid-cols-2"> {/* Row 2: Prices + Demand Highlights + Alerts — three-column on large screens */}
<Link href="/map"> <div className="mt-6 grid gap-4 lg:grid-cols-3">
<Card className="group cursor-pointer transition-colors hover:border-primary/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapIcon className="h-5 w-5 text-chart-1" />
Interactive Map
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Explore datacenter locations, grid region overlays, and real-time price heatmaps.
</p>
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary transition-transform group-hover:translate-x-1">
View Map <ArrowRight className="h-4 w-4" />
</span>
</CardContent>
</Card>
</Link>
<Suspense fallback={<PricesByRegionSkeleton />}> <Suspense fallback={<PricesByRegionSkeleton />}>
<PricesByRegion /> <PricesByRegion />
</Suspense> </Suspense>
</div>
<Suspense fallback={<ChartSkeleton className="mt-8" />}>
<GpuCalculatorSection />
</Suspense>
<Suspense fallback={<GaugesSkeleton />}>
<StressGauges />
</Suspense>
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Suspense fallback={<AlertsSkeleton />}>
<AlertsSection />
</Suspense>
<Suspense fallback={<DemandSummarySkeleton />}> <Suspense fallback={<DemandSummarySkeleton />}>
<DemandSummary /> <DemandSummary />
</Suspense> </Suspense>
<Suspense fallback={<AlertsSkeleton />}>
<AlertsSection />
</Suspense>
</div>
{/* Row 3: GPU Calculator + Grid Stress side by side */}
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_1fr]">
<Suspense fallback={<ChartSkeleton />}>
<GpuCalculatorSection />
</Suspense>
<Suspense fallback={<GaugesSkeleton />}>
<StressGauges />
</Suspense>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,42 @@
import { fetchCapacityPriceTimeline, fetchDcPriceImpact } from '@/actions/dc-impact.js';
import { DcImpactTimeline } from '@/components/charts/dc-impact-timeline.js';
import { DcPriceImpactBars } from '@/components/charts/dc-price-impact-bars.js';
const DEFAULT_REGION = 'DUKE';
export async function DcImpactSection() {
const [timelineResult, impactResult] = await Promise.all([
fetchCapacityPriceTimeline(DEFAULT_REGION),
fetchDcPriceImpact(),
]);
if (!timelineResult.ok) {
return (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{timelineResult.error}</p>
</div>
);
}
return (
<div className="space-y-6">
<DcImpactTimeline
initialData={timelineResult.data}
initialRegion={DEFAULT_REGION}
onRegionChange={async (region: string) => {
'use server';
const result = await fetchCapacityPriceTimeline(region);
if (!result.ok) throw new Error(result.error);
return result.data;
}}
/>
{impactResult.ok ? (
<DcPriceImpactBars data={impactResult.data} />
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{impactResult.error}</p>
</div>
)}
</div>
);
}

View File

@ -25,7 +25,7 @@ async function loadMilestones(): Promise<AIMilestone[]> {
} }
export async function PriceChartSection() { export async function PriceChartSection() {
const defaultRange = '30d' as const; const defaultRange = 'all' as const;
const [priceResult, commodityResult, milestones] = await Promise.all([ const [priceResult, commodityResult, milestones] = await Promise.all([
fetchAllRegionPriceTrends(defaultRange), fetchAllRegionPriceTrends(defaultRange),

View File

@ -4,6 +4,7 @@ import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { CorrelationSection } from './_sections/correlation-section.js'; import { CorrelationSection } from './_sections/correlation-section.js';
import { DcImpactSection } from './_sections/dc-impact-section.js';
import { PriceChartSection } from './_sections/price-chart-section.js'; import { PriceChartSection } from './_sections/price-chart-section.js';
export const metadata: Metadata = { export const metadata: Metadata = {
@ -25,6 +26,10 @@ export default function TrendsPage() {
<PriceChartSection /> <PriceChartSection />
</Suspense> </Suspense>
<Suspense fallback={<ChartSkeleton />}>
<DcImpactSection />
</Suspense>
<Suspense fallback={<ChartSkeleton />}> <Suspense fallback={<ChartSkeleton />}>
<CorrelationSection /> <CorrelationSection />
</Suspense> </Suspense>

View File

@ -143,7 +143,13 @@ function CustomDot(props: unknown): React.JSX.Element {
return ( return (
<g> <g>
<circle cx={cx} cy={cy} r={radius} fill={payload.fill} fillOpacity={0.6} stroke={payload.fill} strokeWidth={2} /> <circle cx={cx} cy={cy} r={radius} fill={payload.fill} fillOpacity={0.6} stroke={payload.fill} strokeWidth={2} />
<text x={cx} y={cy - radius - 6} textAnchor="middle" fill="hsl(var(--foreground))" fontSize={11} fontWeight={600}> <text
x={cx}
y={cy - radius - 6}
textAnchor="middle"
fill="var(--color-foreground)"
fontSize={11}
fontWeight={600}>
{payload.region_code} {payload.region_code}
</text> </text>
</g> </g>
@ -239,7 +245,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
type="number" type="number"
dataKey="total_capacity_mw" dataKey="total_capacity_mw"
name="DC Capacity" name="DC Capacity"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(v: number) => `${v} MW`}> tickFormatter={(v: number) => `${v} MW`}>
@ -247,14 +253,14 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
value="Total DC Capacity (MW)" value="Total DC Capacity (MW)"
offset={-10} offset={-10}
position="insideBottom" position="insideBottom"
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/> />
</XAxis> </XAxis>
<YAxis <YAxis
type="number" type="number"
dataKey="avg_price" dataKey="avg_price"
name="Avg Price" name="Avg Price"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(v: number) => `$${v}`} tickFormatter={(v: number) => `$${v}`}
@ -263,7 +269,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
value="Avg Price ($/MWh)" value="Avg Price ($/MWh)"
angle={-90} angle={-90}
position="insideLeft" position="insideLeft"
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/> />
</YAxis> </YAxis>
<ZAxis dataKey="z" range={[100, 1600]} /> <ZAxis dataKey="z" range={[100, 1600]} />
@ -285,7 +291,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
<Line <Line
type="linear" type="linear"
dataKey="trendPrice" dataKey="trendPrice"
stroke="hsl(var(--foreground) / 0.3)" stroke="color-mix(in oklch, var(--color-foreground) 30%, transparent)"
strokeWidth={2} strokeWidth={2}
strokeDasharray="8 4" strokeDasharray="8 4"
dot={false} dot={false}

View File

@ -0,0 +1,233 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { Area, CartesianGrid, ComposedChart, Line, ReferenceLine, XAxis, YAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
import type { getCapacityPriceTimeline } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { cn, VALID_REGION_CODES } from '@/lib/utils.js';
interface DcImpactTimelineProps {
initialData: SuperJSONResult;
initialRegion: string;
onRegionChange: (region: string) => Promise<SuperJSONResult>;
}
const REGION_LABELS: Record<string, string> = {
PJM: 'PJM (Mid-Atlantic)',
ERCOT: 'ERCOT (Texas)',
CAISO: 'CAISO (California)',
NYISO: 'NYISO (New York)',
ISONE: 'ISO-NE (New England)',
MISO: 'MISO (Midwest)',
SPP: 'SPP (Central)',
BPA: 'BPA (Pacific NW)',
DUKE: 'Duke (Southeast)',
SOCO: 'SOCO (Southern)',
TVA: 'TVA (Tennessee Valley)',
FPC: 'FPC (Florida)',
WAPA: 'WAPA (Western)',
NWMT: 'NWMT (Montana)',
};
const chartConfig: ChartConfig = {
avg_price: {
label: 'Avg Price ($/MWh)',
color: 'hsl(210, 90%, 55%)',
},
cumulative_capacity_mw: {
label: 'DC Capacity (MW)',
color: 'hsl(160, 70%, 45%)',
},
};
interface ChartRow {
monthLabel: string;
monthTs: number;
avg_price: number | null;
cumulative_capacity_mw: number;
}
function transformData(rows: getCapacityPriceTimeline.Result[]): {
chartData: ChartRow[];
dcEventMonths: { label: string; capacityMw: number }[];
} {
const chartData: ChartRow[] = [];
let prevCapacity = 0;
const dcEventMonths: { label: string; capacityMw: number }[] = [];
for (const row of rows) {
if (!row.month) continue;
const ts = row.month.getTime();
const label = row.month.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
const capacity = row.cumulative_capacity_mw ?? 0;
if (capacity > prevCapacity && prevCapacity > 0) {
dcEventMonths.push({ label, capacityMw: capacity });
} else if (capacity > 0 && prevCapacity === 0) {
dcEventMonths.push({ label, capacityMw: capacity });
}
chartData.push({
monthLabel: label,
monthTs: ts,
avg_price: row.avg_price,
cumulative_capacity_mw: capacity,
});
prevCapacity = capacity;
}
return { chartData, dcEventMonths };
}
export function DcImpactTimeline({ initialData, initialRegion, onRegionChange }: DcImpactTimelineProps) {
const [dataSerialized, setDataSerialized] = useState<SuperJSONResult>(initialData);
const [region, setRegion] = useState(initialRegion);
const [loading, setLoading] = useState(false);
const rows = useMemo(() => deserialize<getCapacityPriceTimeline.Result[]>(dataSerialized), [dataSerialized]);
const { chartData, dcEventMonths } = useMemo(() => transformData(rows), [rows]);
const handleRegionChange = useCallback(
async (newRegion: string) => {
setRegion(newRegion);
setLoading(true);
try {
const result = await onRegionChange(newRegion);
setDataSerialized(result);
} finally {
setLoading(false);
}
},
[onRegionChange],
);
const regions = Array.from(VALID_REGION_CODES);
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Datacenter Capacity vs. Electricity Price</CardTitle>
<CardDescription>Monthly electricity price overlaid with cumulative datacenter capacity</CardDescription>
</div>
<Select value={region} onValueChange={handleRegionChange}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Select region" />
</SelectTrigger>
<SelectContent>
{regions.map(code => (
<SelectItem key={code} value={code}>
{REGION_LABELS[code] ?? code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<div className={cn('relative', loading && 'opacity-50 transition-opacity')}>
{chartData.length === 0 ? (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-muted-foreground">No data available for {REGION_LABELS[region] ?? region}.</p>
</div>
) : (
<ChartContainer config={chartConfig} className="h-[50vh] max-h-[600px] min-h-[300px] w-full">
<ComposedChart data={chartData} margin={{ top: 20, right: 60, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="price"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `$${value}`}
label={{
value: '$/MWh',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}}
/>
<YAxis
yAxisId="capacity"
orientation="right"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `${value} MW`}
label={{
value: 'DC Capacity (MW)',
angle: 90,
position: 'insideRight',
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => {
const numVal = Number(value);
if (name === 'avg_price') {
return [`$${numVal.toFixed(2)}/MWh`, undefined];
}
return [`${numVal.toFixed(0)} MW`, undefined];
}}
/>
}
/>
<Area
yAxisId="capacity"
type="stepAfter"
dataKey="cumulative_capacity_mw"
fill="var(--color-cumulative_capacity_mw)"
fillOpacity={0.15}
stroke="var(--color-cumulative_capacity_mw)"
strokeWidth={1.5}
isAnimationActive={chartData.length <= 200}
/>
<Line
yAxisId="price"
type="monotone"
dataKey="avg_price"
stroke="var(--color-avg_price)"
strokeWidth={2}
dot={false}
connectNulls
isAnimationActive={chartData.length <= 200}
/>
{dcEventMonths.map(event => (
<ReferenceLine
key={event.label}
x={event.label}
yAxisId="price"
stroke="hsl(40, 80%, 55%)"
strokeDasharray="4 4"
strokeWidth={1}
label={{
value: `+${event.capacityMw.toFixed(0)} MW`,
position: 'top',
style: { fontSize: 9, fill: 'hsl(40, 80%, 55%)' },
}}
/>
))}
</ComposedChart>
</ChartContainer>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,175 @@
'use client';
import { useMemo } from 'react';
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js';
import type { getDcPriceImpact } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
interface DcPriceImpactBarsProps {
data: SuperJSONResult;
}
const chartConfig: ChartConfig = {
avg_price_before: {
label: 'Avg Price Before',
color: 'hsl(210, 70%, 50%)',
},
avg_price_after: {
label: 'Avg Price After',
color: 'hsl(25, 80%, 55%)',
},
};
interface BarRow {
dc_name: string;
region_code: string;
capacity_mw: number;
year_opened: number;
avg_price_before: number;
avg_price_after: number;
pct_change: number;
increased: boolean;
}
function transformImpactData(rows: getDcPriceImpact.Result[]): BarRow[] {
return rows
.filter(r => r.avg_price_before !== null && r.avg_price_after !== null)
.map(r => ({
dc_name: r.dc_name,
region_code: r.region_code,
capacity_mw: r.capacity_mw,
year_opened: r.year_opened,
avg_price_before: r.avg_price_before!,
avg_price_after: r.avg_price_after!,
pct_change: r.pct_change ?? 0,
increased: (r.avg_price_after ?? 0) >= (r.avg_price_before ?? 0),
}))
.sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change));
}
export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
const rawRows = useMemo(() => deserialize<getDcPriceImpact.Result[]>(data), [data]);
const barData = useMemo(() => transformImpactData(rawRows), [rawRows]);
const avgPctChange = useMemo(() => {
if (barData.length === 0) return 0;
return barData.reduce((sum, r) => sum + r.pct_change, 0) / barData.length;
}, [barData]);
if (barData.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Price Impact by Datacenter</CardTitle>
<CardDescription>No datacenter opening events with sufficient price data.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Price Impact by Datacenter</CardTitle>
<CardDescription>
Average electricity price 6 months before vs. 6 months after datacenter opening
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-3 py-2">
<span className="text-xs text-muted-foreground">Avg price change:</span>
<span
className={`text-sm font-semibold tabular-nums ${avgPctChange >= 0 ? 'text-red-400' : 'text-emerald-400'}`}>
{avgPctChange >= 0 ? '+' : ''}
{avgPctChange.toFixed(1)}%
</span>
</div>
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[40vh] max-h-[500px] min-h-[250px] w-full">
<BarChart
data={barData}
margin={{ top: 10, right: 20, left: 10, bottom: 40 }}
barCategoryGap="20%"
barGap={2}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" vertical={false} />
<XAxis
dataKey="dc_name"
tick={{ fontSize: 10, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
angle={-35}
textAnchor="end"
interval={0}
height={60}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `$${value}`}
label={{
value: '$/MWh',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={label => {
const labelStr = typeof label === 'string' || typeof label === 'number' ? String(label) : '';
const item = barData.find(d => d.dc_name === labelStr);
if (!item) return labelStr;
return `${item.dc_name} (${item.region_code}, ${item.year_opened}) — ${item.capacity_mw} MW`;
}}
formatter={(value, name) => {
const numVal = Number(value);
const nameStr = String(name);
if (nameStr === 'avg_price_before') return [`$${numVal.toFixed(2)}/MWh`, undefined];
if (nameStr === 'avg_price_after') return [`$${numVal.toFixed(2)}/MWh`, undefined];
return [`${numVal}`, undefined];
}}
/>
}
/>
<Bar dataKey="avg_price_before" fill="var(--color-avg_price_before)" radius={[3, 3, 0, 0]} />
<Bar dataKey="avg_price_after" radius={[3, 3, 0, 0]}>
{barData.map(entry => (
<Cell key={entry.dc_name} fill={entry.increased ? 'hsl(0, 70%, 55%)' : 'hsl(145, 60%, 45%)'} />
))}
</Bar>
</BarChart>
</ChartContainer>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(210, 70%, 50%)' }} />
Before DC Opening
</div>
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(0, 70%, 55%)' }} />
After (Price Increased)
</div>
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(145, 60%, 45%)' }} />
After (Price Decreased)
</div>
</div>
<p className="mt-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs leading-relaxed text-amber-300/80">
Correlation does not imply causation. Electricity prices are influenced by many factors including fuel costs,
weather, grid congestion, regulatory changes, and seasonal demand patterns. Datacenter openings are one of
many concurrent variables.
</p>
</CardContent>
</Card>
);
}

View File

@ -15,7 +15,7 @@ import { Activity, Server, TrendingUp, Zap } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { Bar, BarChart, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
interface DemandRow { interface DemandRow {
region_code: string; region_code: string;
@ -37,6 +37,8 @@ const TIME_RANGES: { value: TimeRange; label: string }[] = [
{ value: '30d', label: '30D' }, { value: '30d', label: '30D' },
{ value: '90d', label: '90D' }, { value: '90d', label: '90D' },
{ value: '1y', label: '1Y' }, { value: '1y', label: '1Y' },
{ value: '5y', label: '5Y' },
{ value: 'all', label: 'ALL' },
]; ];
const REGION_COLORS: Record<string, string> = { const REGION_COLORS: Record<string, string> = {
@ -47,6 +49,13 @@ const REGION_COLORS: Record<string, string> = {
ISONE: 'hsl(350, 70%, 55%)', ISONE: 'hsl(350, 70%, 55%)',
MISO: 'hsl(60, 70%, 50%)', MISO: 'hsl(60, 70%, 50%)',
SPP: 'hsl(180, 60%, 50%)', SPP: 'hsl(180, 60%, 50%)',
BPA: 'hsl(95, 55%, 50%)',
NWMT: 'hsl(310, 50%, 55%)',
WAPA: 'hsl(165, 50%, 50%)',
TVA: 'hsl(15, 70%, 50%)',
DUKE: 'hsl(240, 55%, 60%)',
SOCO: 'hsl(350, 55%, 50%)',
FPC: 'hsl(45, 60%, 45%)',
}; };
function formatDemandValue(value: number): string { function formatDemandValue(value: number): string {
@ -59,11 +68,12 @@ function formatDateLabel(date: Date): string {
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric',
}); });
} }
export function DemandChart({ initialData, summaryData }: DemandChartProps) { export function DemandChart({ initialData, summaryData }: DemandChartProps) {
const [timeRange, setTimeRange] = useState<TimeRange>('30d'); const [timeRange, setTimeRange] = useState<TimeRange>('all');
const [selectedRegion, setSelectedRegion] = useState<string>('ALL'); const [selectedRegion, setSelectedRegion] = useState<string>('ALL');
const [chartData, setChartData] = useState<DemandRow[]>(initialData); const [chartData, setChartData] = useState<DemandRow[]>(initialData);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -138,7 +148,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
const activeRegions = selectedRegion === 'ALL' ? regions : regions.filter(r => r.code === selectedRegion); const activeRegions = selectedRegion === 'ALL' ? regions : regions.filter(r => r.code === selectedRegion);
for (const region of activeRegions) { for (const region of activeRegions) {
config[region.code] = { config[region.code] = {
label: region.name, label: region.code,
color: REGION_COLORS[region.code] ?? 'hsl(0, 0%, 60%)', color: REGION_COLORS[region.code] ?? 'hsl(0, 0%, 60%)',
}; };
} }
@ -280,12 +290,19 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
<ChartContainer config={trendChartConfig} className="h-[50vh] max-h-[600px] min-h-[300px] w-full"> <ChartContainer config={trendChartConfig} className="h-[50vh] max-h-[600px] min-h-[300px] w-full">
<ComposedChart data={trendChartData}> <ComposedChart data={trendChartData}>
<CartesianGrid vertical={false} strokeDasharray="3 3" /> <CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} /> <XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<YAxis <YAxis
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickMargin={8} tickMargin={8}
tickFormatter={(value: number) => formatDemandValue(value)} tickFormatter={(value: number) => formatDemandValue(value)}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/> />
<ChartTooltip <ChartTooltip
content={ content={
@ -343,7 +360,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
{idx + 1} {idx + 1}
</span> </span>
<div> <div>
<p className="text-sm font-medium">{peak.regionName}</p> <p className="font-mono text-sm font-medium">{peak.regionCode}</p>
<p className="text-xs text-muted-foreground">{formatDateLabel(peak.day)}</p> <p className="text-xs text-muted-foreground">{formatDateLabel(peak.day)}</p>
</div> </div>
</div> </div>
@ -379,8 +396,16 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(value: number) => formatDemandValue(value)} tickFormatter={(value: number) => formatDemandValue(value)}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<YAxis
type="category"
dataKey="region"
tickLine={false}
axisLine={false}
width={55}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/> />
<YAxis type="category" dataKey="region" tickLine={false} axisLine={false} width={55} />
<ChartTooltip <ChartTooltip
content={ content={
<ChartTooltipContent <ChartTooltipContent
@ -401,7 +426,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{dcImpactData.map(row => ( {dcImpactData.map(row => (
<div key={row.region} className="flex items-center justify-between text-sm"> <div key={row.region} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{row.regionName}</span> <span className="font-mono text-xs font-medium text-muted-foreground">{row.region}</span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted"> <div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div <div

View File

@ -18,7 +18,7 @@ import type { getGenerationHourly } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js'; import { deserialize } from '@/lib/superjson.js';
import { formatMarketDate, formatMarketDateTime, formatMarketTime } from '@/lib/utils.js'; import { formatMarketDate, formatMarketDateTime, formatMarketTime } from '@/lib/utils.js';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
const REGIONS = [ const REGIONS = [
{ code: 'PJM', name: 'PJM (Mid-Atlantic)' }, { code: 'PJM', name: 'PJM (Mid-Atlantic)' },
@ -36,6 +36,8 @@ const TIME_RANGES: { value: TimeRange; label: string }[] = [
{ value: '30d', label: '30D' }, { value: '30d', label: '30D' },
{ value: '90d', label: '90D' }, { value: '90d', label: '90D' },
{ value: '1y', label: '1Y' }, { value: '1y', label: '1Y' },
{ value: '5y', label: '5Y' },
{ value: 'all', label: 'ALL' },
]; ];
const FUEL_TYPES = ['gas', 'nuclear', 'wind', 'solar', 'coal', 'hydro', 'other'] as const; const FUEL_TYPES = ['gas', 'nuclear', 'wind', 'solar', 'coal', 'hydro', 'other'] as const;
@ -84,7 +86,7 @@ function resolveFuelType(raw: string): FuelType {
return EIA_FUEL_MAP[raw] ?? (isFuelType(raw) ? raw : 'other'); return EIA_FUEL_MAP[raw] ?? (isFuelType(raw) ? raw : 'other');
} }
const TIME_RANGE_SET: Set<string> = new Set(['24h', '7d', '30d', '90d', '1y']); const TIME_RANGE_SET: Set<string> = new Set(['24h', '7d', '30d', '90d', '1y', '5y', 'all']);
function isTimeRange(value: string): value is TimeRange { function isTimeRange(value: string): value is TimeRange {
return TIME_RANGE_SET.has(value); return TIME_RANGE_SET.has(value);
@ -285,7 +287,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
axisLine={false} axisLine={false}
tickMargin={8} tickMargin={8}
minTickGap={40} minTickGap={40}
className="text-xs" tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickFormatter={(ts: number) => { tickFormatter={(ts: number) => {
const d = new Date(ts); const d = new Date(ts);
if (timeRange === '24h') return formatMarketTime(d, regionCode); if (timeRange === '24h') return formatMarketTime(d, regionCode);
@ -298,7 +300,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
axisLine={false} axisLine={false}
tickMargin={8} tickMargin={8}
tickFormatter={(value: number) => (value >= 1000 ? `${(value / 1000).toFixed(0)}GW` : `${value}MW`)} tickFormatter={(value: number) => (value >= 1000 ? `${(value / 1000).toFixed(0)}GW` : `${value}MW`)}
className="text-xs" tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/> />
<ChartTooltip <ChartTooltip
content={ content={

View File

@ -119,10 +119,27 @@ interface PivotedRow {
[key: string]: number | string; [key: string]: number | string;
} }
/** Choose date format based on the data span — short ranges need time-of-day. */
function formatTimestamp(date: Date, timeRange: TimeRange): string {
if (timeRange === '24h' || timeRange === '7d') {
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
if (timeRange === '30d' || timeRange === '90d') {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
}
function pivotData( function pivotData(
priceRows: PriceTrendRow[], priceRows: PriceTrendRow[],
commodityRows: CommodityRow[], commodityRows: CommodityRow[],
showCommodities: boolean, showCommodities: boolean,
timeRange: TimeRange,
): { pivoted: PivotedRow[]; regions: string[]; commodities: string[] } { ): { pivoted: PivotedRow[]; regions: string[]; commodities: string[] } {
const regionSet = new Set<string>(); const regionSet = new Set<string>();
const commoditySet = new Set<string>(); const commoditySet = new Set<string>();
@ -135,12 +152,7 @@ function pivotData(
if (!byTimestamp.has(ts)) { if (!byTimestamp.has(ts)) {
byTimestamp.set(ts, { byTimestamp.set(ts, {
timestamp: ts, timestamp: ts,
timestampDisplay: row.timestamp.toLocaleDateString('en-US', { timestampDisplay: formatTimestamp(row.timestamp, timeRange),
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
}); });
} }
@ -156,12 +168,7 @@ function pivotData(
if (!byTimestamp.has(ts)) { if (!byTimestamp.has(ts)) {
byTimestamp.set(ts, { byTimestamp.set(ts, {
timestamp: ts, timestamp: ts,
timestampDisplay: row.timestamp.toLocaleDateString('en-US', { timestampDisplay: formatTimestamp(row.timestamp, timeRange),
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
}); });
} }
@ -228,8 +235,8 @@ export function PriceChart({
const commodityRows = useMemo(() => deserialize<CommodityRow[]>(commoditiesSerialized), [commoditiesSerialized]); const commodityRows = useMemo(() => deserialize<CommodityRow[]>(commoditiesSerialized), [commoditiesSerialized]);
const { pivoted, regions, commodities } = useMemo( const { pivoted, regions, commodities } = useMemo(
() => pivotData(priceRows, commodityRows, showCommodities), () => pivotData(priceRows, commodityRows, showCommodities, timeRange),
[priceRows, commodityRows, showCommodities], [priceRows, commodityRows, showCommodities, timeRange],
); );
// Default: hide non-ISO regions so the chart isn't spaghetti // Default: hide non-ISO regions so the chart isn't spaghetti
@ -338,7 +345,7 @@ export function PriceChart({
className="h-2 w-2 rounded-full" className="h-2 w-2 rounded-full"
style={{ style={{
backgroundColor: disabledRegions.has(region) backgroundColor: disabledRegions.has(region)
? 'hsl(var(--muted-foreground))' ? 'var(--color-muted-foreground)'
: REGION_COLORS[region], : REGION_COLORS[region],
}} }}
/> />
@ -372,7 +379,7 @@ export function PriceChart({
className="h-1.5 w-1.5 rounded-full" className="h-1.5 w-1.5 rounded-full"
style={{ style={{
backgroundColor: disabledRegions.has(region) backgroundColor: disabledRegions.has(region)
? 'hsl(var(--muted-foreground))' ? 'var(--color-muted-foreground)'
: REGION_COLORS[region], : REGION_COLORS[region],
}} }}
/> />
@ -413,14 +420,14 @@ export function PriceChart({
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" /> <CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis <XAxis
dataKey="timestampDisplay" dataKey="timestampDisplay"
tick={{ fontSize: 11 }} tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
interval="preserveStartEnd" interval="preserveStartEnd"
/> />
<YAxis <YAxis
yAxisId="left" yAxisId="left"
tick={{ fontSize: 11 }} tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(value: number) => `$${value}`} tickFormatter={(value: number) => `$${value}`}
@ -428,14 +435,14 @@ export function PriceChart({
value: '$/MWh', value: '$/MWh',
angle: -90, angle: -90,
position: 'insideLeft', position: 'insideLeft',
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' }, style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}} }}
/> />
{showCommodities && ( {showCommodities && (
<YAxis <YAxis
yAxisId="right" yAxisId="right"
orientation="right" orientation="right"
tick={{ fontSize: 11 }} tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
tickFormatter={(value: number) => `$${value}`} tickFormatter={(value: number) => `$${value}`}
@ -443,7 +450,7 @@ export function PriceChart({
value: 'Commodity Price', value: 'Commodity Price',
angle: 90, angle: 90,
position: 'insideRight', position: 'insideRight',
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' }, style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}} }}
/> />
)} )}
@ -502,14 +509,6 @@ export function PriceChart({
stroke={MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)'} stroke={MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)'}
strokeDasharray="3 3" strokeDasharray="3 3"
strokeWidth={1} strokeWidth={1}
label={{
value: milestone.title,
position: 'top',
style: {
fontSize: 9,
fill: MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)',
},
}}
/> />
))} ))}
</LineChart> </LineChart>

View File

@ -2,7 +2,7 @@
import { cn } from '@/lib/utils.js'; import { cn } from '@/lib/utils.js';
export type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y'; export type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [ const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [
{ value: '24h', label: '24H' }, { value: '24h', label: '24H' },
@ -10,6 +10,8 @@ const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [
{ value: '30d', label: '1M' }, { value: '30d', label: '1M' },
{ value: '90d', label: '3M' }, { value: '90d', label: '3M' },
{ value: '1y', label: '1Y' }, { value: '1y', label: '1Y' },
{ value: '5y', label: '5Y' },
{ value: 'all', label: 'ALL' },
]; ];
interface TimeRangeSelectorProps { interface TimeRangeSelectorProps {

View File

@ -135,7 +135,9 @@ export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
const [gpuCount, setGpuCount] = useState(1000); const [gpuCount, setGpuCount] = useState(1000);
const [gpuCountInput, setGpuCountInput] = useState('1,000'); const [gpuCountInput, setGpuCountInput] = useState('1,000');
const [pue, setPue] = useState(PUE_DEFAULT); const [pue, setPue] = useState(PUE_DEFAULT);
const [selectedRegion, setSelectedRegion] = useState<string>(regionPrices[0]?.regionCode ?? ''); const [selectedRegion, setSelectedRegion] = useState<string>(
regionPrices.find(r => r.regionCode === 'DUKE')?.regionCode ?? regionPrices[0]?.regionCode ?? '',
);
const priceMwh = regionPrices.find(r => r.regionCode === selectedRegion)?.priceMwh ?? 0; const priceMwh = regionPrices.find(r => r.regionCode === selectedRegion)?.priceMwh ?? 0;

View File

@ -21,15 +21,20 @@ function formatGw(mw: number): string {
return `${Math.round(mw)} MW`; return `${Math.round(mw)} MW`;
} }
export function GridStressGauge({ regionCode, regionName, demandMw, peakDemandMw, className }: GridStressGaugeProps) { export function GridStressGauge({
regionCode,
regionName: _,
demandMw,
peakDemandMw,
className,
}: GridStressGaugeProps) {
const pct = peakDemandMw > 0 ? Math.min((demandMw / peakDemandMw) * 100, 100) : 0; const pct = peakDemandMw > 0 ? Math.min((demandMw / peakDemandMw) * 100, 100) : 0;
const { color, label, barClass } = getStressLevel(pct); const { color, label, barClass } = getStressLevel(pct);
return ( return (
<div className={cn('group flex items-center gap-3', className)}> <div className={cn('group flex items-center gap-3', className)}>
<div className="w-14 shrink-0"> <div className="w-12 shrink-0">
<div className="font-mono text-xs font-semibold tracking-wide">{regionCode}</div> <div className="font-mono text-xs font-semibold tracking-wide">{regionCode}</div>
<div className="truncate text-[10px] text-muted-foreground">{regionName}</div>
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-1"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-muted/30"> <div className="h-2 w-full overflow-hidden rounded-full bg-muted/30">

View File

@ -6,7 +6,11 @@ import { deserialize } from '@/lib/superjson.js';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
const PRICE_SPIKE_THRESHOLD_MWH = 100; /** Alert when price exceeds regional 7-day average by this many standard deviations */
const SPIKE_CRITICAL_SIGMA = 2.5;
const SPIKE_WARNING_SIGMA = 1.8;
/** Fallback: alert on >15% jump between consecutive readings */
const JUMP_PCT_THRESHOLD = 0.15;
const CHECK_INTERVAL_MS = 60_000; const CHECK_INTERVAL_MS = 60_000;
export function PriceAlertMonitor() { export function PriceAlertMonitor() {
@ -25,14 +29,29 @@ export function PriceAlertMonitor() {
const prevPrice = prevPrices.get(p.region_code); const prevPrice = prevPrices.get(p.region_code);
if (!initialLoadRef.current) { if (!initialLoadRef.current) {
if (p.price_mwh >= PRICE_SPIKE_THRESHOLD_MWH) { const avg = p.avg_price_7d;
toast.error(`Price Spike: ${p.region_code}`, { const sd = p.stddev_price_7d;
description: `${p.region_name} hit $${p.price_mwh.toFixed(2)}/MWh — above $${PRICE_SPIKE_THRESHOLD_MWH} threshold`,
duration: 8000, if (avg !== null && sd !== null && sd > 0) {
}); const sigmas = (p.price_mwh - avg) / sd;
} else if (prevPrice !== undefined && p.price_mwh > prevPrice * 1.15) {
if (sigmas >= SPIKE_CRITICAL_SIGMA) {
toast.error(`Price Spike: ${p.region_code}`, {
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
duration: 8000,
});
} else if (sigmas >= SPIKE_WARNING_SIGMA) {
toast.warning(`Elevated Price: ${p.region_code}`, {
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
duration: 6000,
});
}
}
if (prevPrice !== undefined && prevPrice > 0 && p.price_mwh > prevPrice * (1 + JUMP_PCT_THRESHOLD)) {
const jumpPct = ((p.price_mwh - prevPrice) / prevPrice) * 100;
toast.warning(`Price Jump: ${p.region_code}`, { toast.warning(`Price Jump: ${p.region_code}`, {
description: `${p.region_name} jumped to $${p.price_mwh.toFixed(2)}/MWh (+${(((p.price_mwh - prevPrice) / prevPrice) * 100).toFixed(1)}%)`, description: `$${p.price_mwh.toFixed(2)}/MWh — up ${jumpPct.toFixed(1)}% from last reading`,
duration: 6000, duration: 6000,
}); });
} }

View File

@ -1,15 +1,7 @@
'use client'; 'use client';
import { MarkerClusterer } from '@googlemaps/markerclusterer'; import { MarkerClusterer } from '@googlemaps/markerclusterer';
import { import { AdvancedMarker, APIProvider, ColorScheme, Map, useMap } from '@vis.gl/react-google-maps';
AdvancedMarker,
APIProvider,
ColorScheme,
ControlPosition,
Map,
MapControl,
useMap,
} from '@vis.gl/react-google-maps';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DatacenterDetailPanel } from './datacenter-detail-panel.js'; import { DatacenterDetailPanel } from './datacenter-detail-panel.js';
import { DatacenterMarker, type DatacenterMarkerData } from './datacenter-marker.js'; import { DatacenterMarker, type DatacenterMarkerData } from './datacenter-marker.js';
@ -19,9 +11,9 @@ import { PowerPlantMarker, type PowerPlantMarkerData } from './power-plant-marke
import { RegionDetailPanel } from './region-detail-panel.js'; import { RegionDetailPanel } from './region-detail-panel.js';
import { RegionOverlay, type RegionHeatmapData } from './region-overlay.js'; import { RegionOverlay, type RegionHeatmapData } from './region-overlay.js';
/** Geographic center of the contiguous US for a full-country view. */ /** Center on Chapel Hill, NC — heart of the Southeast energy buildout. */
const US_CENTER = { lat: 39.0, lng: -97.0 }; const US_CENTER = { lat: 35.9132, lng: -79.0558 };
const DEFAULT_ZOOM = 5; const DEFAULT_ZOOM = 6;
/** Well-known approximate centroids for US ISO/RTO regions. */ /** Well-known approximate centroids for US ISO/RTO regions. */
const REGION_CENTROIDS: Record<string, { lat: number; lng: number }> = { const REGION_CENTROIDS: Record<string, { lat: number; lng: number }> = {
@ -203,12 +195,18 @@ export function EnergyMap({ datacenters, regions, powerPlants }: EnergyMapProps)
return ( return (
<APIProvider apiKey={apiKey}> <APIProvider apiKey={apiKey}>
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<MapControls {/* Unified left sidebar: filters + legend */}
datacenters={datacenters} <div className="absolute top-4 left-4 z-10 flex max-h-[calc(100vh-12rem)] w-64 flex-col overflow-y-auto rounded-lg border border-zinc-700/60 bg-zinc-900/90 shadow-xl backdrop-blur">
onFilterChange={handleFilterChange} <MapControls
showPowerPlants={showPowerPlants} datacenters={datacenters}
onTogglePowerPlants={setShowPowerPlants} onFilterChange={handleFilterChange}
/> showPowerPlants={showPowerPlants}
onTogglePowerPlants={setShowPowerPlants}
/>
<div className="border-t border-zinc-700/60 p-3">
<MapLegend showPowerPlants={showPowerPlants} />
</div>
</div>
<Map <Map
mapId={mapId} mapId={mapId}
@ -241,10 +239,6 @@ export function EnergyMap({ datacenters, regions, powerPlants }: EnergyMapProps)
selectedDatacenterId={selectedDatacenter?.id ?? null} selectedDatacenterId={selectedDatacenter?.id ?? null}
onDatacenterClick={handleDatacenterClick} onDatacenterClick={handleDatacenterClick}
/> />
<MapControl position={ControlPosition.LEFT_BOTTOM}>
<MapLegend showPowerPlants={showPowerPlants} />
</MapControl>
</Map> </Map>
<DatacenterDetailPanel datacenter={selectedDatacenter} onClose={() => setSelectedDatacenter(null)} /> <DatacenterDetailPanel datacenter={selectedDatacenter} onClose={() => setSelectedDatacenter(null)} />

View File

@ -66,7 +66,7 @@ export function MapControls({ datacenters, onFilterChange, showPowerPlants, onTo
const hasFilters = selectedOperators.size > 0 || minCapacity > 0; const hasFilters = selectedOperators.size > 0 || minCapacity > 0;
return ( return (
<div className="absolute top-4 left-4 z-10 flex max-h-[calc(100vh-12rem)] w-64 flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-700/60 bg-zinc-900/90 p-3 shadow-xl backdrop-blur"> <div className="flex flex-col gap-3 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-semibold tracking-wider text-zinc-400 uppercase">Filters</span> <span className="text-xs font-semibold tracking-wider text-zinc-400 uppercase">Filters</span>
{hasFilters && ( {hasFilters && (

View File

@ -20,7 +20,7 @@ const FUEL_TYPE_DISPLAY_ORDER = [
export function MapLegend({ showPowerPlants = false }: MapLegendProps) { export function MapLegend({ showPowerPlants = false }: MapLegendProps) {
return ( return (
<div className="z-10 rounded-lg border border-zinc-700/60 bg-zinc-900/90 p-3 text-xs backdrop-blur-sm"> <div className="text-xs">
{/* Price heatmap gradient */} {/* Price heatmap gradient */}
<div className="mb-2.5"> <div className="mb-2.5">
<div className="mb-1 font-medium text-zinc-300">Price Heatmap</div> <div className="mb-1 font-medium text-zinc-300">Price Heatmap</div>

View File

@ -3,12 +3,33 @@
* *
* In development, sets up a 30-minute interval that calls the ingestion * In development, sets up a 30-minute interval that calls the ingestion
* endpoints to keep electricity, generation, and commodity data fresh. * endpoints to keep electricity, generation, and commodity data fresh.
*
* Each scheduled run fetches only the last 2 days of data so that
* requests complete quickly instead of auto-paginating through
* ALL historical EIA data (which causes timeouts).
*/ */
const INGEST_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes const INGEST_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
const INGEST_BASE_URL = 'http://localhost:3000/api/ingest';
const ENDPOINTS = ['electricity', 'generation', 'commodities'] as const; const ENDPOINTS = ['electricity', 'generation', 'commodities'] as const;
/** Per-endpoint fetch timeout — 2 minutes is generous for a bounded date range. */
const ENDPOINT_TIMEOUT_MS = 120_000;
/** How many days of history to fetch on each scheduled run. */
const LOOKBACK_DAYS = 2;
function getIngestBaseUrl(): string {
const port = process.env.PORT ?? '3000';
return `http://localhost:${port}/api/ingest`;
}
/** Build an ISO date string N days in the past (YYYY-MM-DD). */
function startDateForLookback(days: number): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() - days);
return d.toISOString().slice(0, 10);
}
interface IngestionStats { interface IngestionStats {
inserted: number; inserted: number;
updated: number; updated: number;
@ -22,6 +43,47 @@ function isIngestionStats(value: unknown): value is IngestionStats {
return typeof inserted === 'number' && typeof updated === 'number' && typeof errors === 'number'; return typeof inserted === 'number' && typeof updated === 'number' && typeof errors === 'number';
} }
async function fetchEndpoint(
baseUrl: string,
endpoint: string,
secret: string,
start: string,
logTimestamp: string,
): Promise<void> {
const url = `${baseUrl}/${endpoint}?start=${start}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ENDPOINT_TIMEOUT_MS);
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${secret}` },
signal: controller.signal,
});
if (!res.ok) {
console.error(`[${logTimestamp}] [ingest] ${endpoint}: HTTP ${res.status}`);
return;
}
const body: unknown = await res.json();
if (isIngestionStats(body)) {
console.log(
`[${logTimestamp}] [ingest] ${endpoint}: ${body.inserted} inserted, ${body.updated} updated, ${body.errors} errors`,
);
} else {
console.log(`[${logTimestamp}] [ingest] ${endpoint}: done (unexpected response shape)`);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
console.error(`[${logTimestamp}] [ingest] ${endpoint}: timed out after ${ENDPOINT_TIMEOUT_MS / 1000}s`);
} else {
console.error(`[${logTimestamp}] [ingest] ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
}
} finally {
clearTimeout(timeoutId);
}
}
async function runIngestion(): Promise<void> { async function runIngestion(): Promise<void> {
const secret = process.env.INGEST_SECRET; const secret = process.env.INGEST_SECRET;
if (!secret) { if (!secret) {
@ -29,37 +91,26 @@ async function runIngestion(): Promise<void> {
return; return;
} }
const timestamp = new Date().toISOString().slice(11, 19); const logTimestamp = new Date().toISOString().slice(11, 19);
console.log(`[${timestamp}] [ingest] Starting scheduled ingestion...`); const start = startDateForLookback(LOOKBACK_DAYS);
const baseUrl = getIngestBaseUrl();
for (const endpoint of ENDPOINTS) { console.log(`[${logTimestamp}] [ingest] Starting scheduled ingestion (start=${start})...`);
try {
const url = `${INGEST_BASE_URL}/${endpoint}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${secret}` },
});
if (!res.ok) { // Run all endpoints in parallel — they are independent
console.error(`[${timestamp}] [ingest] ${endpoint}: HTTP ${res.status}`); const results = await Promise.allSettled(
continue; ENDPOINTS.map(endpoint => fetchEndpoint(baseUrl, endpoint, secret, start, logTimestamp)),
} );
const body: unknown = await res.json(); for (let i = 0; i < results.length; i++) {
if (isIngestionStats(body)) { const result = results[i]!;
console.log( if (result.status === 'rejected') {
`[${timestamp}] [ingest] ${endpoint}: ${body.inserted} inserted, ${body.updated} updated, ${body.errors} errors`, console.error(`[${logTimestamp}] [ingest] ${ENDPOINTS[i]}: unhandled rejection: ${result.reason}`);
);
} else {
console.log(`[${timestamp}] [ingest] ${endpoint}: done (unexpected response shape)`);
}
} catch (err) {
console.error(`[${timestamp}] [ingest] ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
} }
} }
} }
export function register(): void { export function register(): void {
if (process.env.NODE_ENV !== 'development') return;
if (typeof globalThis.setInterval === 'undefined') return; if (typeof globalThis.setInterval === 'undefined') return;
console.log('[ingest] Scheduling automated ingestion every 30 minutes'); console.log('[ingest] Scheduling automated ingestion every 30 minutes');

View File

@ -73,5 +73,6 @@ export function formatMarketDate(utcDate: Date, regionCode: string): string {
timeZone: timezone, timeZone: timezone,
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: '2-digit',
}); });
} }