fixups and such
This commit is contained in:
parent
ad1a6792f5
commit
d8478ace96
@ -1,5 +1,6 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"ralph-loop@claude-plugins-official": true
|
||||
"ralph-loop@claude-plugins-official": true,
|
||||
"typescript-lsp@claude-plugins-official": false
|
||||
}
|
||||
}
|
||||
|
||||
39
prisma/sql/getCapacityPriceTimeline.sql
Normal file
39
prisma/sql/getCapacityPriceTimeline.sql
Normal 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
|
||||
54
prisma/sql/getDcPriceImpact.sql
Normal file
54
prisma/sql/getDcPriceImpact.sql
Normal 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
|
||||
@ -1,6 +1,20 @@
|
||||
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
|
||||
FROM electricity_prices ep
|
||||
JOIN grid_regions r ON ep.region_id = r.id
|
||||
ORDER BY ep.region_id, ep.timestamp DESC
|
||||
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
|
||||
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
56
src/actions/dc-impact.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
42
src/app/trends/_sections/dc-impact-section.tsx
Normal file
42
src/app/trends/_sections/dc-impact-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
233
src/components/charts/dc-impact-timeline.tsx
Normal file
233
src/components/charts/dc-impact-timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
src/components/charts/dc-price-impact-bars.tsx
Normal file
175
src/components/charts/dc-price-impact-bars.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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)} />
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -73,5 +73,6 @@ export function formatMarketDate(utcDate: Date, regionCode: string): string {
|
||||
timeZone: timezone,
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user