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": {
"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
latest.id, latest.region_id, latest.price_mwh, latest.demand_mw,
latest.timestamp, latest.source,
r.code as region_code, r.name as region_name,
stats.avg_price_7d, stats.stddev_price_7d
FROM (
SELECT DISTINCT ON (ep.region_id)
ep.id, ep.region_id, ep.price_mwh, ep.demand_mw, ep.timestamp, ep.source,
r.code as region_code, r.name as region_name
ep.id, ep.region_id, ep.price_mwh, ep.demand_mw, ep.timestamp, ep.source
FROM electricity_prices ep
JOIN grid_regions r ON ep.region_id = r.id
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 { 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 {
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
const now = new Date();
const ms: Record<TimeRange, number> = {
const ms: Record<Exclude<TimeRange, 'all'>, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000,
'1y': 365 * 24 * 60 * 60 * 1000,
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
};
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 { 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 {
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
const now = new Date();
const ms: Record<TimeRange, number> = {
const ms: Record<Exclude<TimeRange, 'all'>, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000,
'1y': 365 * 24 * 60 * 60 * 1000,
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
};
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 { 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 {
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
const now = new Date();
const ms: Record<TimeRange, number> = {
const ms: Record<Exclude<TimeRange, 'all'>, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000,
'1y': 365 * 24 * 60 * 60 * 1000,
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
};
return new Date(now.getTime() - ms[range]);
}
@ -293,56 +295,69 @@ export async function fetchRecentAlerts(): Promise<
timestamp: Date;
}> = [];
// Detect price spikes (above $80/MWh) and demand peaks
const regionAvgs = new Map<string, { sum: number; count: number }>();
// Compute per-region mean and stddev for statistical anomaly detection
const regionStats = new Map<string, { sum: number; sumSq: number; count: number }>();
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.sumSq += row.priceMwh * row.priceMwh;
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) {
const avg = regionAvgs.get(row.region.code);
const avgPrice = avg ? avg.sum / avg.count : 0;
const thresholds = getRegionThresholds(row.region.code);
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({
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`,
description: `${row.region.code} at $${row.priceMwh.toFixed(2)}/MWh${sigmas.toFixed(1)}σ above avg ($${avg.toFixed(0)})`,
value: row.priceMwh,
unit: '$/MWh',
timestamp: row.timestamp,
});
} else if (row.priceMwh >= 80) {
} else if (sigmas >= 1.8) {
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`,
description: `${row.region.code} at $${row.priceMwh.toFixed(2)}/MWh${sigmas.toFixed(1)}σ above avg ($${avg.toFixed(0)})`,
value: row.priceMwh,
unit: '$/MWh',
timestamp: row.timestamp,
});
} else if (avgPrice > 0 && row.priceMwh < avgPrice * 0.7) {
} else if (sigmas <= -1.8) {
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)})`,
description: `${row.region.code} dropped to $${row.priceMwh.toFixed(2)}/MWh ${Math.abs(sigmas).toFixed(1)}σ below avg ($${avg.toFixed(0)})`,
value: row.priceMwh,
unit: '$/MWh',
timestamp: row.timestamp,
});
}
}
if (row.demandMw >= 50000) {
alerts.push({

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 (
<Card>
<CardHeader className="pb-3">
@ -113,6 +119,31 @@ export async function DemandSummary() {
</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>
</Card>
);

View File

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

View File

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

View File

@ -61,7 +61,6 @@ export async function StressGauges() {
const rightColumn = regions.slice(midpoint);
return (
<div className="mt-8">
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
@ -97,6 +96,5 @@ export async function StressGauges() {
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -118,9 +118,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
if (authError) return authError;
const searchParams = request.nextUrl.searchParams;
const start = searchParams.get('start') ?? 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 { 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 regionParam = searchParams.get('region');
const start = searchParams.get('start') ?? 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[];
if (regionParam) {
if (!isRegionCode(regionParam)) {
@ -73,7 +83,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
// 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>();
for (const [key, price] of retailPriceByRegionMonth) {
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) {
const regionId = regionIdByCode.get(regionCode);
if (!regionId) {
@ -92,7 +125,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
try {
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;

View File

@ -38,9 +38,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
const searchParams = request.nextUrl.searchParams;
const regionParam = searchParams.get('region');
const start = searchParams.get('start') ?? 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[];
if (regionParam) {
if (!isRegionCode(regionParam)) {

View File

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

View File

@ -2,7 +2,7 @@ import { fetchGenerationMix } from '@/actions/generation.js';
import { GenerationChart } from '@/components/charts/generation-chart.js';
const DEFAULT_REGION = 'PJM';
const DEFAULT_TIME_RANGE = '30d' as const;
const DEFAULT_TIME_RANGE = 'all' as const;
export async function GenerationChartSection() {
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 }) {
return (
<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>
<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 />
<Toaster theme="dark" richColors position="bottom-right" />
</ThemeProvider>

View File

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

View File

@ -16,7 +16,7 @@ function MapSkeleton() {
export default function MapPage() {
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 />}>
<MapContent />
</Suspense>

View File

@ -2,7 +2,7 @@ import { Suspense } from 'react';
import { ChartSkeleton } from '@/components/charts/chart-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 { Activity, ArrowRight, Map as MapIcon } from 'lucide-react';
import Link from 'next/link';
@ -69,7 +69,6 @@ function AlertsSkeleton() {
function GaugesSkeleton() {
return (
<div className="mt-8">
<Card>
<CardHeader className="pb-3">
<Skeleton className="h-5 w-44" />
@ -91,7 +90,6 @@ function GaugesSkeleton() {
</div>
</CardContent>
</Card>
</div>
);
}
@ -122,58 +120,51 @@ function DemandSummarySkeleton() {
export default function DashboardHome() {
return (
<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">
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Dashboard</h1>
<p className="mt-1 text-muted-foreground">
<p className="mt-1 text-sm text-muted-foreground">
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>
{/* Hero metric cards */}
<Suspense fallback={<MetricCardsSkeleton />}>
<HeroMetrics />
</Suspense>
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Link href="/map">
<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>
{/* Row 2: Prices + Demand Highlights + Alerts — three-column on large screens */}
<div className="mt-6 grid gap-4 lg:grid-cols-3">
<Suspense fallback={<PricesByRegionSkeleton />}>
<PricesByRegion />
</Suspense>
<Suspense fallback={<DemandSummarySkeleton />}>
<DemandSummary />
</Suspense>
<Suspense fallback={<AlertsSkeleton />}>
<AlertsSection />
</Suspense>
</div>
<Suspense fallback={<ChartSkeleton className="mt-8" />}>
{/* 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 className="mt-8 grid gap-4 lg:grid-cols-2">
<Suspense fallback={<AlertsSkeleton />}>
<AlertsSection />
</Suspense>
<Suspense fallback={<DemandSummarySkeleton />}>
<DemandSummary />
</Suspense>
</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() {
const defaultRange = '30d' as const;
const defaultRange = 'all' as const;
const [priceResult, commodityResult, milestones] = await Promise.all([
fetchAllRegionPriceTrends(defaultRange),

View File

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

View File

@ -143,7 +143,13 @@ function CustomDot(props: unknown): React.JSX.Element {
return (
<g>
<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}
</text>
</g>
@ -239,7 +245,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
type="number"
dataKey="total_capacity_mw"
name="DC Capacity"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v} MW`}>
@ -247,14 +253,14 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
value="Total DC Capacity (MW)"
offset={-10}
position="insideBottom"
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
</XAxis>
<YAxis
type="number"
dataKey="avg_price"
name="Avg Price"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}
@ -263,7 +269,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
value="Avg Price ($/MWh)"
angle={-90}
position="insideLeft"
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
</YAxis>
<ZAxis dataKey="z" range={[100, 1600]} />
@ -285,7 +291,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
<Line
type="linear"
dataKey="trendPrice"
stroke="hsl(var(--foreground) / 0.3)"
stroke="color-mix(in oklch, var(--color-foreground) 30%, transparent)"
strokeWidth={2}
strokeDasharray="8 4"
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 { 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 {
region_code: string;
@ -37,6 +37,8 @@ const TIME_RANGES: { value: TimeRange; label: string }[] = [
{ value: '30d', label: '30D' },
{ value: '90d', label: '90D' },
{ value: '1y', label: '1Y' },
{ value: '5y', label: '5Y' },
{ value: 'all', label: 'ALL' },
];
const REGION_COLORS: Record<string, string> = {
@ -47,6 +49,13 @@ const REGION_COLORS: Record<string, string> = {
ISONE: 'hsl(350, 70%, 55%)',
MISO: 'hsl(60, 70%, 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 {
@ -59,11 +68,12 @@ function formatDateLabel(date: Date): string {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
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 [chartData, setChartData] = useState<DemandRow[]>(initialData);
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);
for (const region of activeRegions) {
config[region.code] = {
label: region.name,
label: region.code,
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">
<ComposedChart data={trendChartData}>
<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
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => formatDemandValue(value)}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<ChartTooltip
content={
@ -343,7 +360,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
{idx + 1}
</span>
<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>
</div>
</div>
@ -379,8 +396,16 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
tickLine={false}
axisLine={false}
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
content={
<ChartTooltipContent
@ -401,7 +426,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
<div className="mt-4 space-y-2">
{dcImpactData.map(row => (
<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="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div

View File

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

View File

@ -119,10 +119,27 @@ interface PivotedRow {
[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(
priceRows: PriceTrendRow[],
commodityRows: CommodityRow[],
showCommodities: boolean,
timeRange: TimeRange,
): { pivoted: PivotedRow[]; regions: string[]; commodities: string[] } {
const regionSet = new Set<string>();
const commoditySet = new Set<string>();
@ -135,12 +152,7 @@ function pivotData(
if (!byTimestamp.has(ts)) {
byTimestamp.set(ts, {
timestamp: ts,
timestampDisplay: row.timestamp.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
timestampDisplay: formatTimestamp(row.timestamp, timeRange),
});
}
@ -156,12 +168,7 @@ function pivotData(
if (!byTimestamp.has(ts)) {
byTimestamp.set(ts, {
timestamp: ts,
timestampDisplay: row.timestamp.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
timestampDisplay: formatTimestamp(row.timestamp, timeRange),
});
}
@ -228,8 +235,8 @@ export function PriceChart({
const commodityRows = useMemo(() => deserialize<CommodityRow[]>(commoditiesSerialized), [commoditiesSerialized]);
const { pivoted, regions, commodities } = useMemo(
() => pivotData(priceRows, commodityRows, showCommodities),
[priceRows, commodityRows, showCommodities],
() => pivotData(priceRows, commodityRows, showCommodities, timeRange),
[priceRows, commodityRows, showCommodities, timeRange],
);
// 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"
style={{
backgroundColor: disabledRegions.has(region)
? 'hsl(var(--muted-foreground))'
? 'var(--color-muted-foreground)'
: REGION_COLORS[region],
}}
/>
@ -372,7 +379,7 @@ export function PriceChart({
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: disabledRegions.has(region)
? 'hsl(var(--muted-foreground))'
? 'var(--color-muted-foreground)'
: REGION_COLORS[region],
}}
/>
@ -413,14 +420,14 @@ export function PriceChart({
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="timestampDisplay"
tick={{ fontSize: 11 }}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 11 }}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `$${value}`}
@ -428,14 +435,14 @@ export function PriceChart({
value: '$/MWh',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' },
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}}
/>
{showCommodities && (
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 11 }}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `$${value}`}
@ -443,7 +450,7 @@ export function PriceChart({
value: 'Commodity Price',
angle: 90,
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%)'}
strokeDasharray="3 3"
strokeWidth={1}
label={{
value: milestone.title,
position: 'top',
style: {
fontSize: 9,
fill: MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)',
},
}}
/>
))}
</LineChart>

View File

@ -2,7 +2,7 @@
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 }> = [
{ value: '24h', label: '24H' },
@ -10,6 +10,8 @@ const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [
{ value: '30d', label: '1M' },
{ value: '90d', label: '3M' },
{ value: '1y', label: '1Y' },
{ value: '5y', label: '5Y' },
{ value: 'all', label: 'ALL' },
];
interface TimeRangeSelectorProps {

View File

@ -135,7 +135,9 @@ export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
const [gpuCount, setGpuCount] = useState(1000);
const [gpuCountInput, setGpuCountInput] = useState('1,000');
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;

View File

@ -21,15 +21,20 @@ function formatGw(mw: number): string {
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 { color, label, barClass } = getStressLevel(pct);
return (
<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="truncate text-[10px] text-muted-foreground">{regionName}</div>
</div>
<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">

View File

@ -6,7 +6,11 @@ import { deserialize } from '@/lib/superjson.js';
import { useEffect, useRef } from 'react';
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;
export function PriceAlertMonitor() {
@ -25,14 +29,29 @@ export function PriceAlertMonitor() {
const prevPrice = prevPrices.get(p.region_code);
if (!initialLoadRef.current) {
if (p.price_mwh >= PRICE_SPIKE_THRESHOLD_MWH) {
const avg = p.avg_price_7d;
const sd = p.stddev_price_7d;
if (avg !== null && sd !== null && sd > 0) {
const sigmas = (p.price_mwh - avg) / sd;
if (sigmas >= SPIKE_CRITICAL_SIGMA) {
toast.error(`Price Spike: ${p.region_code}`, {
description: `${p.region_name} hit $${p.price_mwh.toFixed(2)}/MWh — above $${PRICE_SPIKE_THRESHOLD_MWH} threshold`,
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
duration: 8000,
});
} else if (prevPrice !== undefined && p.price_mwh > prevPrice * 1.15) {
} 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}`, {
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,
});
}

View File

@ -1,15 +1,7 @@
'use client';
import { MarkerClusterer } from '@googlemaps/markerclusterer';
import {
AdvancedMarker,
APIProvider,
ColorScheme,
ControlPosition,
Map,
MapControl,
useMap,
} from '@vis.gl/react-google-maps';
import { AdvancedMarker, APIProvider, ColorScheme, Map, useMap } from '@vis.gl/react-google-maps';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DatacenterDetailPanel } from './datacenter-detail-panel.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 { RegionOverlay, type RegionHeatmapData } from './region-overlay.js';
/** Geographic center of the contiguous US for a full-country view. */
const US_CENTER = { lat: 39.0, lng: -97.0 };
const DEFAULT_ZOOM = 5;
/** Center on Chapel Hill, NC — heart of the Southeast energy buildout. */
const US_CENTER = { lat: 35.9132, lng: -79.0558 };
const DEFAULT_ZOOM = 6;
/** Well-known approximate centroids for US ISO/RTO regions. */
const REGION_CENTROIDS: Record<string, { lat: number; lng: number }> = {
@ -203,12 +195,18 @@ export function EnergyMap({ datacenters, regions, powerPlants }: EnergyMapProps)
return (
<APIProvider apiKey={apiKey}>
<div className="relative h-full w-full">
{/* Unified left sidebar: filters + legend */}
<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">
<MapControls
datacenters={datacenters}
onFilterChange={handleFilterChange}
showPowerPlants={showPowerPlants}
onTogglePowerPlants={setShowPowerPlants}
/>
<div className="border-t border-zinc-700/60 p-3">
<MapLegend showPowerPlants={showPowerPlants} />
</div>
</div>
<Map
mapId={mapId}
@ -241,10 +239,6 @@ export function EnergyMap({ datacenters, regions, powerPlants }: EnergyMapProps)
selectedDatacenterId={selectedDatacenter?.id ?? null}
onDatacenterClick={handleDatacenterClick}
/>
<MapControl position={ControlPosition.LEFT_BOTTOM}>
<MapLegend showPowerPlants={showPowerPlants} />
</MapControl>
</Map>
<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;
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">
<span className="text-xs font-semibold tracking-wider text-zinc-400 uppercase">Filters</span>
{hasFilters && (

View File

@ -20,7 +20,7 @@ const FUEL_TYPE_DISPLAY_ORDER = [
export function MapLegend({ showPowerPlants = false }: MapLegendProps) {
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 */}
<div className="mb-2.5">
<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
* 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_BASE_URL = 'http://localhost:3000/api/ingest';
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 {
inserted: number;
updated: number;
@ -22,6 +43,47 @@ function isIngestionStats(value: unknown): value is IngestionStats {
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> {
const secret = process.env.INGEST_SECRET;
if (!secret) {
@ -29,37 +91,26 @@ async function runIngestion(): Promise<void> {
return;
}
const timestamp = new Date().toISOString().slice(11, 19);
console.log(`[${timestamp}] [ingest] Starting scheduled ingestion...`);
const logTimestamp = new Date().toISOString().slice(11, 19);
const start = startDateForLookback(LOOKBACK_DAYS);
const baseUrl = getIngestBaseUrl();
for (const endpoint of ENDPOINTS) {
try {
const url = `${INGEST_BASE_URL}/${endpoint}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${secret}` },
});
console.log(`[${logTimestamp}] [ingest] Starting scheduled ingestion (start=${start})...`);
if (!res.ok) {
console.error(`[${timestamp}] [ingest] ${endpoint}: HTTP ${res.status}`);
continue;
}
const body: unknown = await res.json();
if (isIngestionStats(body)) {
console.log(
`[${timestamp}] [ingest] ${endpoint}: ${body.inserted} inserted, ${body.updated} updated, ${body.errors} errors`,
// Run all endpoints in parallel — they are independent
const results = await Promise.allSettled(
ENDPOINTS.map(endpoint => fetchEndpoint(baseUrl, endpoint, secret, start, logTimestamp)),
);
} else {
console.log(`[${timestamp}] [ingest] ${endpoint}: done (unexpected response shape)`);
}
} catch (err) {
console.error(`[${timestamp}] [ingest] ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
for (let i = 0; i < results.length; i++) {
const result = results[i]!;
if (result.status === 'rejected') {
console.error(`[${logTimestamp}] [ingest] ${ENDPOINTS[i]}: unhandled rejection: ${result.reason}`);
}
}
}
export function register(): void {
if (process.env.NODE_ENV !== 'development') return;
if (typeof globalThis.setInterval === 'undefined') return;
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,
month: 'short',
day: 'numeric',
year: '2-digit',
});
}