fixups and such
This commit is contained in:
parent
ad1a6792f5
commit
d8478ace96
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"enabledPlugins": {
|
"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)
|
SELECT
|
||||||
ep.id, ep.region_id, ep.price_mwh, ep.demand_mw, ep.timestamp, ep.source,
|
latest.id, latest.region_id, latest.price_mwh, latest.demand_mw,
|
||||||
r.code as region_code, r.name as region_name
|
latest.timestamp, latest.source,
|
||||||
FROM electricity_prices ep
|
r.code as region_code, r.name as region_name,
|
||||||
JOIN grid_regions r ON ep.region_id = r.id
|
stats.avg_price_7d, stats.stddev_price_7d
|
||||||
ORDER BY ep.region_id, ep.timestamp DESC
|
FROM (
|
||||||
|
SELECT DISTINCT ON (ep.region_id)
|
||||||
|
ep.id, ep.region_id, ep.price_mwh, ep.demand_mw, ep.timestamp, ep.source
|
||||||
|
FROM electricity_prices ep
|
||||||
|
ORDER BY ep.region_id, ep.timestamp DESC
|
||||||
|
) latest
|
||||||
|
JOIN grid_regions r ON latest.region_id = r.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
AVG(ep2.price_mwh)::double precision as avg_price_7d,
|
||||||
|
STDDEV(ep2.price_mwh)::double precision as stddev_price_7d
|
||||||
|
FROM electricity_prices ep2
|
||||||
|
WHERE ep2.region_id = latest.region_id
|
||||||
|
AND ep2.timestamp >= NOW() - INTERVAL '7 days'
|
||||||
|
) stats ON true
|
||||||
|
|||||||
56
src/actions/dc-impact.ts
Normal file
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 { validateRegionCode } from '@/lib/utils.js';
|
||||||
import { cacheLife, cacheTag } from 'next/cache';
|
import { cacheLife, cacheTag } from 'next/cache';
|
||||||
|
|
||||||
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
|
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
|
||||||
|
|
||||||
function timeRangeToStartDate(range: TimeRange): Date {
|
function timeRangeToStartDate(range: TimeRange): Date {
|
||||||
|
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const ms: Record<TimeRange, number> = {
|
const ms: Record<Exclude<TimeRange, 'all'>, number> = {
|
||||||
'24h': 24 * 60 * 60 * 1000,
|
'24h': 24 * 60 * 60 * 1000,
|
||||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||||
'90d': 90 * 24 * 60 * 60 * 1000,
|
'90d': 90 * 24 * 60 * 60 * 1000,
|
||||||
'1y': 365 * 24 * 60 * 60 * 1000,
|
'1y': 365 * 24 * 60 * 60 * 1000,
|
||||||
|
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
|
||||||
};
|
};
|
||||||
return new Date(now.getTime() - ms[range]);
|
return new Date(now.getTime() - ms[range]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,16 +7,18 @@ import { serialize } from '@/lib/superjson.js';
|
|||||||
import { validateRegionCode } from '@/lib/utils.js';
|
import { validateRegionCode } from '@/lib/utils.js';
|
||||||
import { cacheLife, cacheTag } from 'next/cache';
|
import { cacheLife, cacheTag } from 'next/cache';
|
||||||
|
|
||||||
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
|
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
|
||||||
|
|
||||||
function timeRangeToStartDate(range: TimeRange): Date {
|
function timeRangeToStartDate(range: TimeRange): Date {
|
||||||
|
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const ms: Record<TimeRange, number> = {
|
const ms: Record<Exclude<TimeRange, 'all'>, number> = {
|
||||||
'24h': 24 * 60 * 60 * 1000,
|
'24h': 24 * 60 * 60 * 1000,
|
||||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||||
'90d': 90 * 24 * 60 * 60 * 1000,
|
'90d': 90 * 24 * 60 * 60 * 1000,
|
||||||
'1y': 365 * 24 * 60 * 60 * 1000,
|
'1y': 365 * 24 * 60 * 60 * 1000,
|
||||||
|
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
|
||||||
};
|
};
|
||||||
return new Date(now.getTime() - ms[range]);
|
return new Date(now.getTime() - ms[range]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,16 +13,18 @@ import { serialize } from '@/lib/superjson.js';
|
|||||||
import { validateRegionCode } from '@/lib/utils.js';
|
import { validateRegionCode } from '@/lib/utils.js';
|
||||||
import { cacheLife, cacheTag } from 'next/cache';
|
import { cacheLife, cacheTag } from 'next/cache';
|
||||||
|
|
||||||
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
|
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
|
||||||
|
|
||||||
function timeRangeToStartDate(range: TimeRange): Date {
|
function timeRangeToStartDate(range: TimeRange): Date {
|
||||||
|
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const ms: Record<TimeRange, number> = {
|
const ms: Record<Exclude<TimeRange, 'all'>, number> = {
|
||||||
'24h': 24 * 60 * 60 * 1000,
|
'24h': 24 * 60 * 60 * 1000,
|
||||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||||
'30d': 30 * 24 * 60 * 60 * 1000,
|
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||||
'90d': 90 * 24 * 60 * 60 * 1000,
|
'90d': 90 * 24 * 60 * 60 * 1000,
|
||||||
'1y': 365 * 24 * 60 * 60 * 1000,
|
'1y': 365 * 24 * 60 * 60 * 1000,
|
||||||
|
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
|
||||||
};
|
};
|
||||||
return new Date(now.getTime() - ms[range]);
|
return new Date(now.getTime() - ms[range]);
|
||||||
}
|
}
|
||||||
@ -293,55 +295,68 @@ export async function fetchRecentAlerts(): Promise<
|
|||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
// Detect price spikes (above $80/MWh) and demand peaks
|
// Compute per-region mean and stddev for statistical anomaly detection
|
||||||
const regionAvgs = new Map<string, { sum: number; count: number }>();
|
const regionStats = new Map<string, { sum: number; sumSq: number; count: number }>();
|
||||||
for (const row of priceRows) {
|
for (const row of priceRows) {
|
||||||
const entry = regionAvgs.get(row.region.code) ?? { sum: 0, count: 0 };
|
const entry = regionStats.get(row.region.code) ?? { sum: 0, sumSq: 0, count: 0 };
|
||||||
entry.sum += row.priceMwh;
|
entry.sum += row.priceMwh;
|
||||||
|
entry.sumSq += row.priceMwh * row.priceMwh;
|
||||||
entry.count += 1;
|
entry.count += 1;
|
||||||
regionAvgs.set(row.region.code, entry);
|
regionStats.set(row.region.code, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionThresholds(regionCode: string) {
|
||||||
|
const stats = regionStats.get(regionCode);
|
||||||
|
if (!stats || stats.count < 2) return null;
|
||||||
|
const avg = stats.sum / stats.count;
|
||||||
|
const variance = stats.sumSq / stats.count - avg * avg;
|
||||||
|
const sd = Math.sqrt(Math.max(0, variance));
|
||||||
|
return { avg, sd };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const row of priceRows) {
|
for (const row of priceRows) {
|
||||||
const avg = regionAvgs.get(row.region.code);
|
const thresholds = getRegionThresholds(row.region.code);
|
||||||
const avgPrice = avg ? avg.sum / avg.count : 0;
|
if (thresholds && thresholds.sd > 0) {
|
||||||
|
const { avg, sd } = thresholds;
|
||||||
|
const sigmas = (row.priceMwh - avg) / sd;
|
||||||
|
|
||||||
if (row.priceMwh >= 100) {
|
if (sigmas >= 2.5) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
id: `spike-${row.id}`,
|
id: `spike-${row.id}`,
|
||||||
type: 'price_spike',
|
type: 'price_spike',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
region_code: row.region.code,
|
region_code: row.region.code,
|
||||||
region_name: row.region.name,
|
region_name: row.region.name,
|
||||||
description: `${row.region.code} electricity hit $${row.priceMwh.toFixed(2)}/MWh`,
|
description: `${row.region.code} at $${row.priceMwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above avg ($${avg.toFixed(0)})`,
|
||||||
value: row.priceMwh,
|
value: row.priceMwh,
|
||||||
unit: '$/MWh',
|
unit: '$/MWh',
|
||||||
timestamp: row.timestamp,
|
timestamp: row.timestamp,
|
||||||
});
|
});
|
||||||
} else if (row.priceMwh >= 80) {
|
} else if (sigmas >= 1.8) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
id: `spike-${row.id}`,
|
id: `spike-${row.id}`,
|
||||||
type: 'price_spike',
|
type: 'price_spike',
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
region_code: row.region.code,
|
region_code: row.region.code,
|
||||||
region_name: row.region.name,
|
region_name: row.region.name,
|
||||||
description: `${row.region.code} electricity at $${row.priceMwh.toFixed(2)}/MWh`,
|
description: `${row.region.code} at $${row.priceMwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above avg ($${avg.toFixed(0)})`,
|
||||||
value: row.priceMwh,
|
value: row.priceMwh,
|
||||||
unit: '$/MWh',
|
unit: '$/MWh',
|
||||||
timestamp: row.timestamp,
|
timestamp: row.timestamp,
|
||||||
});
|
});
|
||||||
} else if (avgPrice > 0 && row.priceMwh < avgPrice * 0.7) {
|
} else if (sigmas <= -1.8) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
id: `drop-${row.id}`,
|
id: `drop-${row.id}`,
|
||||||
type: 'price_drop',
|
type: 'price_drop',
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
region_code: row.region.code,
|
region_code: row.region.code,
|
||||||
region_name: row.region.name,
|
region_name: row.region.name,
|
||||||
description: `${row.region.code} price dropped to $${row.priceMwh.toFixed(2)}/MWh (avg $${avgPrice.toFixed(2)})`,
|
description: `${row.region.code} dropped to $${row.priceMwh.toFixed(2)}/MWh — ${Math.abs(sigmas).toFixed(1)}σ below avg ($${avg.toFixed(0)})`,
|
||||||
value: row.priceMwh,
|
value: row.priceMwh,
|
||||||
unit: '$/MWh',
|
unit: '$/MWh',
|
||||||
timestamp: row.timestamp,
|
timestamp: row.timestamp,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.demandMw >= 50000) {
|
if (row.demandMw >= 50000) {
|
||||||
|
|||||||
@ -92,6 +92,12 @@ export async function DemandSummary() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Top 5 regions by demand for a quick leaderboard
|
||||||
|
const topRegions = [...regionMap.values()]
|
||||||
|
.filter(r => r.latestDemandMw > 0)
|
||||||
|
.sort((a, b) => b.latestDemandMw - a.latestDemandMw)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@ -113,6 +119,31 @@ export async function DemandSummary() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{topRegions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="my-4 h-px bg-border/50" />
|
||||||
|
<p className="mb-2.5 text-[11px] font-medium tracking-wide text-muted-foreground">Top Regions by Demand</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topRegions.map((r, i) => {
|
||||||
|
const maxDemand = topRegions[0]!.latestDemandMw;
|
||||||
|
const pct = maxDemand > 0 ? (r.latestDemandMw / maxDemand) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={r.regionCode} className="flex items-center gap-2.5 text-sm">
|
||||||
|
<span className="w-4 text-right text-[10px] font-bold text-muted-foreground">{i + 1}</span>
|
||||||
|
<span className="w-12 shrink-0 font-mono text-xs font-semibold">{r.regionCode}</span>
|
||||||
|
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted/30">
|
||||||
|
<div className="h-full rounded-full bg-chart-3 transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-14 shrink-0 text-right font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
|
{formatGw(r.latestDemandMw)} {formatGwUnit(r.latestDemandMw)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -24,9 +24,5 @@ export async function GpuCalculatorSection() {
|
|||||||
|
|
||||||
if (regionPrices.length === 0) return null;
|
if (regionPrices.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return <GpuCalculator regionPrices={regionPrices} />;
|
||||||
<div className="mt-8">
|
|
||||||
<GpuCalculator regionPrices={regionPrices} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export async function PricesByRegion() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{prices.length > 0 ? (
|
{prices.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="max-h-[400px] space-y-3 overflow-y-auto pr-1">
|
||||||
{prices.map(p => {
|
{prices.map(p => {
|
||||||
const regionSparkline = sparklineMap[p.region_code];
|
const regionSparkline = sparklineMap[p.region_code];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -61,42 +61,40 @@ export async function StressGauges() {
|
|||||||
const rightColumn = regions.slice(midpoint);
|
const rightColumn = regions.slice(midpoint);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-8">
|
<Card>
|
||||||
<Card>
|
<CardHeader className="pb-3">
|
||||||
<CardHeader className="pb-3">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<Radio className="h-5 w-5 text-chart-4" />
|
||||||
<Radio className="h-5 w-5 text-chart-4" />
|
Region Grid Status
|
||||||
Region Grid Status
|
</CardTitle>
|
||||||
</CardTitle>
|
<p className="text-xs text-muted-foreground">Current demand vs. 7-day peak by region</p>
|
||||||
<p className="text-xs text-muted-foreground">Current demand vs. 7-day peak by region</p>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
<div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2">
|
||||||
<div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2">
|
<div className="flex flex-col gap-2.5">
|
||||||
<div className="flex flex-col gap-2.5">
|
{leftColumn.map(r => (
|
||||||
{leftColumn.map(r => (
|
<GridStressGauge
|
||||||
<GridStressGauge
|
key={r.regionCode}
|
||||||
key={r.regionCode}
|
regionCode={r.regionCode}
|
||||||
regionCode={r.regionCode}
|
regionName={r.regionName}
|
||||||
regionName={r.regionName}
|
demandMw={r.latestDemandMw}
|
||||||
demandMw={r.latestDemandMw}
|
peakDemandMw={r.peakDemandMw}
|
||||||
peakDemandMw={r.peakDemandMw}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
{rightColumn.map(r => (
|
|
||||||
<GridStressGauge
|
|
||||||
key={r.regionCode}
|
|
||||||
regionCode={r.regionCode}
|
|
||||||
regionName={r.regionName}
|
|
||||||
demandMw={r.latestDemandMw}
|
|
||||||
peakDemandMw={r.peakDemandMw}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="flex flex-col gap-2.5">
|
||||||
</Card>
|
{rightColumn.map(r => (
|
||||||
</div>
|
<GridStressGauge
|
||||||
|
key={r.regionCode}
|
||||||
|
regionCode={r.regionCode}
|
||||||
|
regionName={r.regionName}
|
||||||
|
demandMw={r.latestDemandMw}
|
||||||
|
peakDemandMw={r.peakDemandMw}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -118,9 +118,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const start = searchParams.get('start') ?? undefined;
|
|
||||||
const end = searchParams.get('end') ?? undefined;
|
const end = searchParams.get('end') ?? undefined;
|
||||||
|
|
||||||
|
// Default to last 30 days if no start date provided — commodity data
|
||||||
|
// is daily/monthly so a wider window is fine and still bounded.
|
||||||
|
const startParam = searchParams.get('start');
|
||||||
|
const start =
|
||||||
|
startParam ??
|
||||||
|
(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() - 30);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
})();
|
||||||
|
|
||||||
const stats: IngestionStats = { inserted: 0, updated: 0, errors: 0 };
|
const stats: IngestionStats = { inserted: 0, updated: 0, errors: 0 };
|
||||||
|
|
||||||
const { rows, errors: fetchErrors } = await fetchAllCommodities(start, end);
|
const { rows, errors: fetchErrors } = await fetchAllCommodities(start, end);
|
||||||
|
|||||||
@ -38,9 +38,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||||||
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const regionParam = searchParams.get('region');
|
const regionParam = searchParams.get('region');
|
||||||
const start = searchParams.get('start') ?? undefined;
|
|
||||||
const end = searchParams.get('end') ?? undefined;
|
const end = searchParams.get('end') ?? undefined;
|
||||||
|
|
||||||
|
// Default to last 7 days if no start date provided — prevents
|
||||||
|
// auto-paginating through ALL historical EIA data (causes timeouts).
|
||||||
|
const startParam = searchParams.get('start');
|
||||||
|
const start =
|
||||||
|
startParam ??
|
||||||
|
(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() - 7);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
})();
|
||||||
|
|
||||||
let regions: RegionCode[];
|
let regions: RegionCode[];
|
||||||
if (regionParam) {
|
if (regionParam) {
|
||||||
if (!isRegionCode(regionParam)) {
|
if (!isRegionCode(regionParam)) {
|
||||||
@ -73,7 +83,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||||||
// Continue with demand data even if prices fail
|
// Continue with demand data even if prices fail
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build fallback: for each region, find the most recent month with data
|
// Build fallback: for each region, find the most recent month with data from the API
|
||||||
const latestPriceByRegion = new Map<string, number>();
|
const latestPriceByRegion = new Map<string, number>();
|
||||||
for (const [key, price] of retailPriceByRegionMonth) {
|
for (const [key, price] of retailPriceByRegionMonth) {
|
||||||
const region = key.split(':')[0]!;
|
const region = key.split(':')[0]!;
|
||||||
@ -83,6 +93,29 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If API returned no prices (e.g. recent months not yet reported),
|
||||||
|
// fall back to the last known non-zero price per region from the database.
|
||||||
|
if (retailPriceByRegionMonth.size === 0) {
|
||||||
|
const dbFallbacks = await prisma.$queryRaw<Array<{ code: string; price_mwh: number }>>`
|
||||||
|
SELECT r.code, ep.price_mwh
|
||||||
|
FROM electricity_prices ep
|
||||||
|
JOIN grid_regions r ON ep.region_id = r.id
|
||||||
|
WHERE ep.price_mwh > 0
|
||||||
|
AND (r.code, ep.timestamp) IN (
|
||||||
|
SELECT r2.code, MAX(ep2.timestamp)
|
||||||
|
FROM electricity_prices ep2
|
||||||
|
JOIN grid_regions r2 ON ep2.region_id = r2.id
|
||||||
|
WHERE ep2.price_mwh > 0
|
||||||
|
GROUP BY r2.code
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
for (const row of dbFallbacks) {
|
||||||
|
if (!latestPriceByRegion.has(row.code)) {
|
||||||
|
latestPriceByRegion.set(row.code, row.price_mwh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const regionCode of regions) {
|
for (const regionCode of regions) {
|
||||||
const regionId = regionIdByCode.get(regionCode);
|
const regionId = regionIdByCode.get(regionCode);
|
||||||
if (!regionId) {
|
if (!regionId) {
|
||||||
@ -92,7 +125,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const demandData = await getRegionData(regionCode, 'D', { start, end });
|
const demandData = await getRegionData(regionCode, 'D', { start, end });
|
||||||
const validPoints = demandData.filter((p): p is typeof p & { valueMw: number } => p.valueMw !== null);
|
// Reject values outside a reasonable range — the EIA API occasionally returns
|
||||||
|
// garbage (e.g. 2^31 overflow, deeply negative values). PJM peak is ~150K MW.
|
||||||
|
const MAX_DEMAND_MW = 500_000;
|
||||||
|
const validPoints = demandData.filter(
|
||||||
|
(p): p is typeof p & { valueMw: number } => p.valueMw !== null && p.valueMw >= 0 && p.valueMw <= MAX_DEMAND_MW,
|
||||||
|
);
|
||||||
|
|
||||||
if (validPoints.length === 0) continue;
|
if (validPoints.length === 0) continue;
|
||||||
|
|
||||||
|
|||||||
@ -38,9 +38,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||||||
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const regionParam = searchParams.get('region');
|
const regionParam = searchParams.get('region');
|
||||||
const start = searchParams.get('start') ?? undefined;
|
|
||||||
const end = searchParams.get('end') ?? undefined;
|
const end = searchParams.get('end') ?? undefined;
|
||||||
|
|
||||||
|
// Default to last 7 days if no start date provided — prevents
|
||||||
|
// auto-paginating through ALL historical EIA data (causes timeouts).
|
||||||
|
const startParam = searchParams.get('start');
|
||||||
|
const start =
|
||||||
|
startParam ??
|
||||||
|
(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() - 7);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
})();
|
||||||
|
|
||||||
let regions: RegionCode[];
|
let regions: RegionCode[];
|
||||||
if (regionParam) {
|
if (regionParam) {
|
||||||
if (!isRegionCode(regionParam)) {
|
if (!isRegionCode(regionParam)) {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { deserialize } from '@/lib/superjson.js';
|
|||||||
|
|
||||||
export async function DemandChartSection() {
|
export async function DemandChartSection() {
|
||||||
const [demandResult, summaryResult] = await Promise.all([
|
const [demandResult, summaryResult] = await Promise.all([
|
||||||
fetchDemandByRegion('ALL', '30d'),
|
fetchDemandByRegion('ALL', 'all'),
|
||||||
fetchRegionDemandSummary(),
|
fetchRegionDemandSummary(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { fetchGenerationMix } from '@/actions/generation.js';
|
|||||||
import { GenerationChart } from '@/components/charts/generation-chart.js';
|
import { GenerationChart } from '@/components/charts/generation-chart.js';
|
||||||
|
|
||||||
const DEFAULT_REGION = 'PJM';
|
const DEFAULT_REGION = 'PJM';
|
||||||
const DEFAULT_TIME_RANGE = '30d' as const;
|
const DEFAULT_TIME_RANGE = 'all' as const;
|
||||||
|
|
||||||
export async function GenerationChartSection() {
|
export async function GenerationChartSection() {
|
||||||
const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE);
|
const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE);
|
||||||
|
|||||||
@ -23,10 +23,10 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
||||||
<body className="flex min-h-dvh flex-col font-sans antialiased">
|
<body className="flex min-h-dvh flex-col overflow-x-hidden font-sans antialiased">
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
|
||||||
<Nav />
|
<Nav />
|
||||||
<main className="mx-auto w-full max-w-[1920px] flex-1">{children}</main>
|
<main className="mx-auto w-full max-w-[1400px] flex-1">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
<Toaster theme="dark" richColors position="bottom-right" />
|
<Toaster theme="dark" richColors position="bottom-right" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@ -85,6 +85,7 @@ export async function MapContent() {
|
|||||||
if (priceResult.ok) {
|
if (priceResult.ok) {
|
||||||
const rows = deserialize<getRegionPriceHeatmap.Result[]>(priceResult.data);
|
const rows = deserialize<getRegionPriceHeatmap.Result[]>(priceResult.data);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
if (!row.code || !row.name) continue;
|
||||||
regions.push({
|
regions.push({
|
||||||
code: row.code,
|
code: row.code,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
|||||||
@ -16,7 +16,7 @@ function MapSkeleton() {
|
|||||||
|
|
||||||
export default function MapPage() {
|
export default function MapPage() {
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100dvh-var(--header-h)-var(--footer-h))]">
|
<div className="[margin-right:calc(50%-50vw)] [margin-left:calc(50%-50vw)] h-[calc(100dvh-var(--header-h)-var(--footer-h))] [width:100vw] !max-w-none overflow-hidden">
|
||||||
<Suspense fallback={<MapSkeleton />}>
|
<Suspense fallback={<MapSkeleton />}>
|
||||||
<MapContent />
|
<MapContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
117
src/app/page.tsx
117
src/app/page.tsx
@ -2,7 +2,7 @@ import { Suspense } from 'react';
|
|||||||
|
|
||||||
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
|
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
|
||||||
import { MetricCardSkeleton } from '@/components/dashboard/metric-card-skeleton.js';
|
import { MetricCardSkeleton } from '@/components/dashboard/metric-card-skeleton.js';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card.js';
|
||||||
import { Skeleton } from '@/components/ui/skeleton.js';
|
import { Skeleton } from '@/components/ui/skeleton.js';
|
||||||
import { Activity, ArrowRight, Map as MapIcon } from 'lucide-react';
|
import { Activity, ArrowRight, Map as MapIcon } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@ -69,29 +69,27 @@ function AlertsSkeleton() {
|
|||||||
|
|
||||||
function GaugesSkeleton() {
|
function GaugesSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="mt-8">
|
<Card>
|
||||||
<Card>
|
<CardHeader className="pb-3">
|
||||||
<CardHeader className="pb-3">
|
<Skeleton className="h-5 w-44" />
|
||||||
<Skeleton className="h-5 w-44" />
|
<Skeleton className="mt-1 h-3 w-64" />
|
||||||
<Skeleton className="mt-1 h-3 w-64" />
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
<div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2">
|
||||||
<div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2">
|
{Array.from({ length: 2 }).map((_, col) => (
|
||||||
{Array.from({ length: 2 }).map((_, col) => (
|
<div key={col} className="flex flex-col gap-2.5">
|
||||||
<div key={col} className="flex flex-col gap-2.5">
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
<div key={i} className="flex items-center gap-3">
|
||||||
<div key={i} className="flex items-center gap-3">
|
<Skeleton className="h-4 w-14" />
|
||||||
<Skeleton className="h-4 w-14" />
|
<Skeleton className="h-2 flex-1 rounded-full" />
|
||||||
<Skeleton className="h-2 flex-1 rounded-full" />
|
<Skeleton className="h-4 w-20" />
|
||||||
<Skeleton className="h-4 w-20" />
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,58 +120,51 @@ function DemandSummarySkeleton() {
|
|||||||
export default function DashboardHome() {
|
export default function DashboardHome() {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-6 sm:px-6 sm:py-8">
|
<div className="px-4 py-6 sm:px-6 sm:py-8">
|
||||||
<div className="mb-6 sm:mb-8">
|
<div className="mb-6 flex items-end justify-between">
|
||||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Dashboard</h1>
|
<div>
|
||||||
<p className="mt-1 text-muted-foreground">
|
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Dashboard</h1>
|
||||||
Real-time overview of AI datacenter energy impact across US grid regions.
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
</p>
|
Real-time overview of AI datacenter energy impact across US grid regions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/map"
|
||||||
|
className="group hidden items-center gap-1.5 rounded-md border border-border/50 bg-muted/30 px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground sm:inline-flex">
|
||||||
|
<MapIcon className="h-3.5 w-3.5" />
|
||||||
|
Open Map
|
||||||
|
<ArrowRight className="h-3 w-3 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hero metric cards */}
|
||||||
<Suspense fallback={<MetricCardsSkeleton />}>
|
<Suspense fallback={<MetricCardsSkeleton />}>
|
||||||
<HeroMetrics />
|
<HeroMetrics />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
{/* Row 2: Prices + Demand Highlights + Alerts — three-column on large screens */}
|
||||||
<Link href="/map">
|
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||||
<Card className="group cursor-pointer transition-colors hover:border-primary/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<MapIcon className="h-5 w-5 text-chart-1" />
|
|
||||||
Interactive Map
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Explore datacenter locations, grid region overlays, and real-time price heatmaps.
|
|
||||||
</p>
|
|
||||||
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary transition-transform group-hover:translate-x-1">
|
|
||||||
View Map <ArrowRight className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Suspense fallback={<PricesByRegionSkeleton />}>
|
<Suspense fallback={<PricesByRegionSkeleton />}>
|
||||||
<PricesByRegion />
|
<PricesByRegion />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense fallback={<ChartSkeleton className="mt-8" />}>
|
|
||||||
<GpuCalculatorSection />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<Suspense fallback={<GaugesSkeleton />}>
|
|
||||||
<StressGauges />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
|
||||||
<Suspense fallback={<AlertsSkeleton />}>
|
|
||||||
<AlertsSection />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<Suspense fallback={<DemandSummarySkeleton />}>
|
<Suspense fallback={<DemandSummarySkeleton />}>
|
||||||
<DemandSummary />
|
<DemandSummary />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<AlertsSkeleton />}>
|
||||||
|
<AlertsSection />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: GPU Calculator + Grid Stress side by side */}
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_1fr]">
|
||||||
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
|
<GpuCalculatorSection />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<GaugesSkeleton />}>
|
||||||
|
<StressGauges />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
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() {
|
export async function PriceChartSection() {
|
||||||
const defaultRange = '30d' as const;
|
const defaultRange = 'all' as const;
|
||||||
|
|
||||||
const [priceResult, commodityResult, milestones] = await Promise.all([
|
const [priceResult, commodityResult, milestones] = await Promise.all([
|
||||||
fetchAllRegionPriceTrends(defaultRange),
|
fetchAllRegionPriceTrends(defaultRange),
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { CorrelationSection } from './_sections/correlation-section.js';
|
import { CorrelationSection } from './_sections/correlation-section.js';
|
||||||
|
import { DcImpactSection } from './_sections/dc-impact-section.js';
|
||||||
import { PriceChartSection } from './_sections/price-chart-section.js';
|
import { PriceChartSection } from './_sections/price-chart-section.js';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -25,6 +26,10 @@ export default function TrendsPage() {
|
|||||||
<PriceChartSection />
|
<PriceChartSection />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
|
<DcImpactSection />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
<Suspense fallback={<ChartSkeleton />}>
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
<CorrelationSection />
|
<CorrelationSection />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@ -143,7 +143,13 @@ function CustomDot(props: unknown): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<circle cx={cx} cy={cy} r={radius} fill={payload.fill} fillOpacity={0.6} stroke={payload.fill} strokeWidth={2} />
|
<circle cx={cx} cy={cy} r={radius} fill={payload.fill} fillOpacity={0.6} stroke={payload.fill} strokeWidth={2} />
|
||||||
<text x={cx} y={cy - radius - 6} textAnchor="middle" fill="hsl(var(--foreground))" fontSize={11} fontWeight={600}>
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy - radius - 6}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--color-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight={600}>
|
||||||
{payload.region_code}
|
{payload.region_code}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@ -239,7 +245,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
|
|||||||
type="number"
|
type="number"
|
||||||
dataKey="total_capacity_mw"
|
dataKey="total_capacity_mw"
|
||||||
name="DC Capacity"
|
name="DC Capacity"
|
||||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(v: number) => `${v} MW`}>
|
tickFormatter={(v: number) => `${v} MW`}>
|
||||||
@ -247,14 +253,14 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
|
|||||||
value="Total DC Capacity (MW)"
|
value="Total DC Capacity (MW)"
|
||||||
offset={-10}
|
offset={-10}
|
||||||
position="insideBottom"
|
position="insideBottom"
|
||||||
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
/>
|
/>
|
||||||
</XAxis>
|
</XAxis>
|
||||||
<YAxis
|
<YAxis
|
||||||
type="number"
|
type="number"
|
||||||
dataKey="avg_price"
|
dataKey="avg_price"
|
||||||
name="Avg Price"
|
name="Avg Price"
|
||||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(v: number) => `$${v}`}
|
tickFormatter={(v: number) => `$${v}`}
|
||||||
@ -263,7 +269,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
|
|||||||
value="Avg Price ($/MWh)"
|
value="Avg Price ($/MWh)"
|
||||||
angle={-90}
|
angle={-90}
|
||||||
position="insideLeft"
|
position="insideLeft"
|
||||||
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
/>
|
/>
|
||||||
</YAxis>
|
</YAxis>
|
||||||
<ZAxis dataKey="z" range={[100, 1600]} />
|
<ZAxis dataKey="z" range={[100, 1600]} />
|
||||||
@ -285,7 +291,7 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
|
|||||||
<Line
|
<Line
|
||||||
type="linear"
|
type="linear"
|
||||||
dataKey="trendPrice"
|
dataKey="trendPrice"
|
||||||
stroke="hsl(var(--foreground) / 0.3)"
|
stroke="color-mix(in oklch, var(--color-foreground) 30%, transparent)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray="8 4"
|
strokeDasharray="8 4"
|
||||||
dot={false}
|
dot={false}
|
||||||
|
|||||||
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 { useCallback, useMemo, useState } from 'react';
|
||||||
import { Bar, BarChart, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
|
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
|
||||||
|
|
||||||
interface DemandRow {
|
interface DemandRow {
|
||||||
region_code: string;
|
region_code: string;
|
||||||
@ -37,6 +37,8 @@ const TIME_RANGES: { value: TimeRange; label: string }[] = [
|
|||||||
{ value: '30d', label: '30D' },
|
{ value: '30d', label: '30D' },
|
||||||
{ value: '90d', label: '90D' },
|
{ value: '90d', label: '90D' },
|
||||||
{ value: '1y', label: '1Y' },
|
{ value: '1y', label: '1Y' },
|
||||||
|
{ value: '5y', label: '5Y' },
|
||||||
|
{ value: 'all', label: 'ALL' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const REGION_COLORS: Record<string, string> = {
|
const REGION_COLORS: Record<string, string> = {
|
||||||
@ -47,6 +49,13 @@ const REGION_COLORS: Record<string, string> = {
|
|||||||
ISONE: 'hsl(350, 70%, 55%)',
|
ISONE: 'hsl(350, 70%, 55%)',
|
||||||
MISO: 'hsl(60, 70%, 50%)',
|
MISO: 'hsl(60, 70%, 50%)',
|
||||||
SPP: 'hsl(180, 60%, 50%)',
|
SPP: 'hsl(180, 60%, 50%)',
|
||||||
|
BPA: 'hsl(95, 55%, 50%)',
|
||||||
|
NWMT: 'hsl(310, 50%, 55%)',
|
||||||
|
WAPA: 'hsl(165, 50%, 50%)',
|
||||||
|
TVA: 'hsl(15, 70%, 50%)',
|
||||||
|
DUKE: 'hsl(240, 55%, 60%)',
|
||||||
|
SOCO: 'hsl(350, 55%, 50%)',
|
||||||
|
FPC: 'hsl(45, 60%, 45%)',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDemandValue(value: number): string {
|
function formatDemandValue(value: number): string {
|
||||||
@ -59,11 +68,12 @@ function formatDateLabel(date: Date): string {
|
|||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DemandChart({ initialData, summaryData }: DemandChartProps) {
|
export function DemandChart({ initialData, summaryData }: DemandChartProps) {
|
||||||
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
const [timeRange, setTimeRange] = useState<TimeRange>('all');
|
||||||
const [selectedRegion, setSelectedRegion] = useState<string>('ALL');
|
const [selectedRegion, setSelectedRegion] = useState<string>('ALL');
|
||||||
const [chartData, setChartData] = useState<DemandRow[]>(initialData);
|
const [chartData, setChartData] = useState<DemandRow[]>(initialData);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -138,7 +148,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
|
|||||||
const activeRegions = selectedRegion === 'ALL' ? regions : regions.filter(r => r.code === selectedRegion);
|
const activeRegions = selectedRegion === 'ALL' ? regions : regions.filter(r => r.code === selectedRegion);
|
||||||
for (const region of activeRegions) {
|
for (const region of activeRegions) {
|
||||||
config[region.code] = {
|
config[region.code] = {
|
||||||
label: region.name,
|
label: region.code,
|
||||||
color: REGION_COLORS[region.code] ?? 'hsl(0, 0%, 60%)',
|
color: REGION_COLORS[region.code] ?? 'hsl(0, 0%, 60%)',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -280,12 +290,19 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
|
|||||||
<ChartContainer config={trendChartConfig} className="h-[50vh] max-h-[600px] min-h-[300px] w-full">
|
<ChartContainer config={trendChartConfig} className="h-[50vh] max-h-[600px] min-h-[300px] w-full">
|
||||||
<ComposedChart data={trendChartData}>
|
<ComposedChart data={trendChartData}>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
tickFormatter={(value: number) => formatDemandValue(value)}
|
tickFormatter={(value: number) => formatDemandValue(value)}
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={
|
content={
|
||||||
@ -343,7 +360,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
|
|||||||
{idx + 1}
|
{idx + 1}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{peak.regionName}</p>
|
<p className="font-mono text-sm font-medium">{peak.regionCode}</p>
|
||||||
<p className="text-xs text-muted-foreground">{formatDateLabel(peak.day)}</p>
|
<p className="text-xs text-muted-foreground">{formatDateLabel(peak.day)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -379,8 +396,16 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value: number) => formatDemandValue(value)}
|
tickFormatter={(value: number) => formatDemandValue(value)}
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="region"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={55}
|
||||||
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
/>
|
/>
|
||||||
<YAxis type="category" dataKey="region" tickLine={false} axisLine={false} width={55} />
|
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
@ -401,7 +426,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
|
|||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
{dcImpactData.map(row => (
|
{dcImpactData.map(row => (
|
||||||
<div key={row.region} className="flex items-center justify-between text-sm">
|
<div key={row.region} className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">{row.regionName}</span>
|
<span className="font-mono text-xs font-medium text-muted-foreground">{row.region}</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import type { getGenerationHourly } from '@/generated/prisma/sql.js';
|
|||||||
import { deserialize } from '@/lib/superjson.js';
|
import { deserialize } from '@/lib/superjson.js';
|
||||||
import { formatMarketDate, formatMarketDateTime, formatMarketTime } from '@/lib/utils.js';
|
import { formatMarketDate, formatMarketDateTime, formatMarketTime } from '@/lib/utils.js';
|
||||||
|
|
||||||
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
|
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
|
||||||
|
|
||||||
const REGIONS = [
|
const REGIONS = [
|
||||||
{ code: 'PJM', name: 'PJM (Mid-Atlantic)' },
|
{ code: 'PJM', name: 'PJM (Mid-Atlantic)' },
|
||||||
@ -36,6 +36,8 @@ const TIME_RANGES: { value: TimeRange; label: string }[] = [
|
|||||||
{ value: '30d', label: '30D' },
|
{ value: '30d', label: '30D' },
|
||||||
{ value: '90d', label: '90D' },
|
{ value: '90d', label: '90D' },
|
||||||
{ value: '1y', label: '1Y' },
|
{ value: '1y', label: '1Y' },
|
||||||
|
{ value: '5y', label: '5Y' },
|
||||||
|
{ value: 'all', label: 'ALL' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FUEL_TYPES = ['gas', 'nuclear', 'wind', 'solar', 'coal', 'hydro', 'other'] as const;
|
const FUEL_TYPES = ['gas', 'nuclear', 'wind', 'solar', 'coal', 'hydro', 'other'] as const;
|
||||||
@ -84,7 +86,7 @@ function resolveFuelType(raw: string): FuelType {
|
|||||||
return EIA_FUEL_MAP[raw] ?? (isFuelType(raw) ? raw : 'other');
|
return EIA_FUEL_MAP[raw] ?? (isFuelType(raw) ? raw : 'other');
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIME_RANGE_SET: Set<string> = new Set(['24h', '7d', '30d', '90d', '1y']);
|
const TIME_RANGE_SET: Set<string> = new Set(['24h', '7d', '30d', '90d', '1y', '5y', 'all']);
|
||||||
|
|
||||||
function isTimeRange(value: string): value is TimeRange {
|
function isTimeRange(value: string): value is TimeRange {
|
||||||
return TIME_RANGE_SET.has(value);
|
return TIME_RANGE_SET.has(value);
|
||||||
@ -285,7 +287,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
minTickGap={40}
|
minTickGap={40}
|
||||||
className="text-xs"
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
tickFormatter={(ts: number) => {
|
tickFormatter={(ts: number) => {
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
if (timeRange === '24h') return formatMarketTime(d, regionCode);
|
if (timeRange === '24h') return formatMarketTime(d, regionCode);
|
||||||
@ -298,7 +300,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
tickFormatter={(value: number) => (value >= 1000 ? `${(value / 1000).toFixed(0)}GW` : `${value}MW`)}
|
tickFormatter={(value: number) => (value >= 1000 ? `${(value / 1000).toFixed(0)}GW` : `${value}MW`)}
|
||||||
className="text-xs"
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={
|
content={
|
||||||
|
|||||||
@ -119,10 +119,27 @@ interface PivotedRow {
|
|||||||
[key: string]: number | string;
|
[key: string]: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Choose date format based on the data span — short ranges need time-of-day. */
|
||||||
|
function formatTimestamp(date: Date, timeRange: TimeRange): string {
|
||||||
|
if (timeRange === '24h' || timeRange === '7d') {
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (timeRange === '30d' || timeRange === '90d') {
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
function pivotData(
|
function pivotData(
|
||||||
priceRows: PriceTrendRow[],
|
priceRows: PriceTrendRow[],
|
||||||
commodityRows: CommodityRow[],
|
commodityRows: CommodityRow[],
|
||||||
showCommodities: boolean,
|
showCommodities: boolean,
|
||||||
|
timeRange: TimeRange,
|
||||||
): { pivoted: PivotedRow[]; regions: string[]; commodities: string[] } {
|
): { pivoted: PivotedRow[]; regions: string[]; commodities: string[] } {
|
||||||
const regionSet = new Set<string>();
|
const regionSet = new Set<string>();
|
||||||
const commoditySet = new Set<string>();
|
const commoditySet = new Set<string>();
|
||||||
@ -135,12 +152,7 @@ function pivotData(
|
|||||||
if (!byTimestamp.has(ts)) {
|
if (!byTimestamp.has(ts)) {
|
||||||
byTimestamp.set(ts, {
|
byTimestamp.set(ts, {
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
timestampDisplay: row.timestamp.toLocaleDateString('en-US', {
|
timestampDisplay: formatTimestamp(row.timestamp, timeRange),
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,12 +168,7 @@ function pivotData(
|
|||||||
if (!byTimestamp.has(ts)) {
|
if (!byTimestamp.has(ts)) {
|
||||||
byTimestamp.set(ts, {
|
byTimestamp.set(ts, {
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
timestampDisplay: row.timestamp.toLocaleDateString('en-US', {
|
timestampDisplay: formatTimestamp(row.timestamp, timeRange),
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,8 +235,8 @@ export function PriceChart({
|
|||||||
const commodityRows = useMemo(() => deserialize<CommodityRow[]>(commoditiesSerialized), [commoditiesSerialized]);
|
const commodityRows = useMemo(() => deserialize<CommodityRow[]>(commoditiesSerialized), [commoditiesSerialized]);
|
||||||
|
|
||||||
const { pivoted, regions, commodities } = useMemo(
|
const { pivoted, regions, commodities } = useMemo(
|
||||||
() => pivotData(priceRows, commodityRows, showCommodities),
|
() => pivotData(priceRows, commodityRows, showCommodities, timeRange),
|
||||||
[priceRows, commodityRows, showCommodities],
|
[priceRows, commodityRows, showCommodities, timeRange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Default: hide non-ISO regions so the chart isn't spaghetti
|
// Default: hide non-ISO regions so the chart isn't spaghetti
|
||||||
@ -338,7 +345,7 @@ export function PriceChart({
|
|||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: disabledRegions.has(region)
|
backgroundColor: disabledRegions.has(region)
|
||||||
? 'hsl(var(--muted-foreground))'
|
? 'var(--color-muted-foreground)'
|
||||||
: REGION_COLORS[region],
|
: REGION_COLORS[region],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -372,7 +379,7 @@ export function PriceChart({
|
|||||||
className="h-1.5 w-1.5 rounded-full"
|
className="h-1.5 w-1.5 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: disabledRegions.has(region)
|
backgroundColor: disabledRegions.has(region)
|
||||||
? 'hsl(var(--muted-foreground))'
|
? 'var(--color-muted-foreground)'
|
||||||
: REGION_COLORS[region],
|
: REGION_COLORS[region],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -413,14 +420,14 @@ export function PriceChart({
|
|||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestampDisplay"
|
dataKey="timestampDisplay"
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
interval="preserveStartEnd"
|
interval="preserveStartEnd"
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value: number) => `$${value}`}
|
tickFormatter={(value: number) => `$${value}`}
|
||||||
@ -428,14 +435,14 @@ export function PriceChart({
|
|||||||
value: '$/MWh',
|
value: '$/MWh',
|
||||||
angle: -90,
|
angle: -90,
|
||||||
position: 'insideLeft',
|
position: 'insideLeft',
|
||||||
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' },
|
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{showCommodities && (
|
{showCommodities && (
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value: number) => `$${value}`}
|
tickFormatter={(value: number) => `$${value}`}
|
||||||
@ -443,7 +450,7 @@ export function PriceChart({
|
|||||||
value: 'Commodity Price',
|
value: 'Commodity Price',
|
||||||
angle: 90,
|
angle: 90,
|
||||||
position: 'insideRight',
|
position: 'insideRight',
|
||||||
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' },
|
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -502,14 +509,6 @@ export function PriceChart({
|
|||||||
stroke={MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)'}
|
stroke={MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)'}
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
label={{
|
|
||||||
value: milestone.title,
|
|
||||||
position: 'top',
|
|
||||||
style: {
|
|
||||||
fontSize: 9,
|
|
||||||
fill: MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils.js';
|
import { cn } from '@/lib/utils.js';
|
||||||
|
|
||||||
export type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
|
export type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
|
||||||
|
|
||||||
const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [
|
const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [
|
||||||
{ value: '24h', label: '24H' },
|
{ value: '24h', label: '24H' },
|
||||||
@ -10,6 +10,8 @@ const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [
|
|||||||
{ value: '30d', label: '1M' },
|
{ value: '30d', label: '1M' },
|
||||||
{ value: '90d', label: '3M' },
|
{ value: '90d', label: '3M' },
|
||||||
{ value: '1y', label: '1Y' },
|
{ value: '1y', label: '1Y' },
|
||||||
|
{ value: '5y', label: '5Y' },
|
||||||
|
{ value: 'all', label: 'ALL' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface TimeRangeSelectorProps {
|
interface TimeRangeSelectorProps {
|
||||||
|
|||||||
@ -135,7 +135,9 @@ export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
|
|||||||
const [gpuCount, setGpuCount] = useState(1000);
|
const [gpuCount, setGpuCount] = useState(1000);
|
||||||
const [gpuCountInput, setGpuCountInput] = useState('1,000');
|
const [gpuCountInput, setGpuCountInput] = useState('1,000');
|
||||||
const [pue, setPue] = useState(PUE_DEFAULT);
|
const [pue, setPue] = useState(PUE_DEFAULT);
|
||||||
const [selectedRegion, setSelectedRegion] = useState<string>(regionPrices[0]?.regionCode ?? '');
|
const [selectedRegion, setSelectedRegion] = useState<string>(
|
||||||
|
regionPrices.find(r => r.regionCode === 'DUKE')?.regionCode ?? regionPrices[0]?.regionCode ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
const priceMwh = regionPrices.find(r => r.regionCode === selectedRegion)?.priceMwh ?? 0;
|
const priceMwh = regionPrices.find(r => r.regionCode === selectedRegion)?.priceMwh ?? 0;
|
||||||
|
|
||||||
|
|||||||
@ -21,15 +21,20 @@ function formatGw(mw: number): string {
|
|||||||
return `${Math.round(mw)} MW`;
|
return `${Math.round(mw)} MW`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GridStressGauge({ regionCode, regionName, demandMw, peakDemandMw, className }: GridStressGaugeProps) {
|
export function GridStressGauge({
|
||||||
|
regionCode,
|
||||||
|
regionName: _,
|
||||||
|
demandMw,
|
||||||
|
peakDemandMw,
|
||||||
|
className,
|
||||||
|
}: GridStressGaugeProps) {
|
||||||
const pct = peakDemandMw > 0 ? Math.min((demandMw / peakDemandMw) * 100, 100) : 0;
|
const pct = peakDemandMw > 0 ? Math.min((demandMw / peakDemandMw) * 100, 100) : 0;
|
||||||
const { color, label, barClass } = getStressLevel(pct);
|
const { color, label, barClass } = getStressLevel(pct);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('group flex items-center gap-3', className)}>
|
<div className={cn('group flex items-center gap-3', className)}>
|
||||||
<div className="w-14 shrink-0">
|
<div className="w-12 shrink-0">
|
||||||
<div className="font-mono text-xs font-semibold tracking-wide">{regionCode}</div>
|
<div className="font-mono text-xs font-semibold tracking-wide">{regionCode}</div>
|
||||||
<div className="truncate text-[10px] text-muted-foreground">{regionName}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted/30">
|
<div className="h-2 w-full overflow-hidden rounded-full bg-muted/30">
|
||||||
|
|||||||
@ -6,7 +6,11 @@ import { deserialize } from '@/lib/superjson.js';
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const PRICE_SPIKE_THRESHOLD_MWH = 100;
|
/** Alert when price exceeds regional 7-day average by this many standard deviations */
|
||||||
|
const SPIKE_CRITICAL_SIGMA = 2.5;
|
||||||
|
const SPIKE_WARNING_SIGMA = 1.8;
|
||||||
|
/** Fallback: alert on >15% jump between consecutive readings */
|
||||||
|
const JUMP_PCT_THRESHOLD = 0.15;
|
||||||
const CHECK_INTERVAL_MS = 60_000;
|
const CHECK_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
export function PriceAlertMonitor() {
|
export function PriceAlertMonitor() {
|
||||||
@ -25,14 +29,29 @@ export function PriceAlertMonitor() {
|
|||||||
const prevPrice = prevPrices.get(p.region_code);
|
const prevPrice = prevPrices.get(p.region_code);
|
||||||
|
|
||||||
if (!initialLoadRef.current) {
|
if (!initialLoadRef.current) {
|
||||||
if (p.price_mwh >= PRICE_SPIKE_THRESHOLD_MWH) {
|
const avg = p.avg_price_7d;
|
||||||
toast.error(`Price Spike: ${p.region_code}`, {
|
const sd = p.stddev_price_7d;
|
||||||
description: `${p.region_name} hit $${p.price_mwh.toFixed(2)}/MWh — above $${PRICE_SPIKE_THRESHOLD_MWH} threshold`,
|
|
||||||
duration: 8000,
|
if (avg !== null && sd !== null && sd > 0) {
|
||||||
});
|
const sigmas = (p.price_mwh - avg) / sd;
|
||||||
} else if (prevPrice !== undefined && p.price_mwh > prevPrice * 1.15) {
|
|
||||||
|
if (sigmas >= SPIKE_CRITICAL_SIGMA) {
|
||||||
|
toast.error(`Price Spike: ${p.region_code}`, {
|
||||||
|
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
|
||||||
|
duration: 8000,
|
||||||
|
});
|
||||||
|
} else if (sigmas >= SPIKE_WARNING_SIGMA) {
|
||||||
|
toast.warning(`Elevated Price: ${p.region_code}`, {
|
||||||
|
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevPrice !== undefined && prevPrice > 0 && p.price_mwh > prevPrice * (1 + JUMP_PCT_THRESHOLD)) {
|
||||||
|
const jumpPct = ((p.price_mwh - prevPrice) / prevPrice) * 100;
|
||||||
toast.warning(`Price Jump: ${p.region_code}`, {
|
toast.warning(`Price Jump: ${p.region_code}`, {
|
||||||
description: `${p.region_name} jumped to $${p.price_mwh.toFixed(2)}/MWh (+${(((p.price_mwh - prevPrice) / prevPrice) * 100).toFixed(1)}%)`,
|
description: `$${p.price_mwh.toFixed(2)}/MWh — up ${jumpPct.toFixed(1)}% from last reading`,
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { MarkerClusterer } from '@googlemaps/markerclusterer';
|
import { MarkerClusterer } from '@googlemaps/markerclusterer';
|
||||||
import {
|
import { AdvancedMarker, APIProvider, ColorScheme, Map, useMap } from '@vis.gl/react-google-maps';
|
||||||
AdvancedMarker,
|
|
||||||
APIProvider,
|
|
||||||
ColorScheme,
|
|
||||||
ControlPosition,
|
|
||||||
Map,
|
|
||||||
MapControl,
|
|
||||||
useMap,
|
|
||||||
} from '@vis.gl/react-google-maps';
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { DatacenterDetailPanel } from './datacenter-detail-panel.js';
|
import { DatacenterDetailPanel } from './datacenter-detail-panel.js';
|
||||||
import { DatacenterMarker, type DatacenterMarkerData } from './datacenter-marker.js';
|
import { DatacenterMarker, type DatacenterMarkerData } from './datacenter-marker.js';
|
||||||
@ -19,9 +11,9 @@ import { PowerPlantMarker, type PowerPlantMarkerData } from './power-plant-marke
|
|||||||
import { RegionDetailPanel } from './region-detail-panel.js';
|
import { RegionDetailPanel } from './region-detail-panel.js';
|
||||||
import { RegionOverlay, type RegionHeatmapData } from './region-overlay.js';
|
import { RegionOverlay, type RegionHeatmapData } from './region-overlay.js';
|
||||||
|
|
||||||
/** Geographic center of the contiguous US for a full-country view. */
|
/** Center on Chapel Hill, NC — heart of the Southeast energy buildout. */
|
||||||
const US_CENTER = { lat: 39.0, lng: -97.0 };
|
const US_CENTER = { lat: 35.9132, lng: -79.0558 };
|
||||||
const DEFAULT_ZOOM = 5;
|
const DEFAULT_ZOOM = 6;
|
||||||
|
|
||||||
/** Well-known approximate centroids for US ISO/RTO regions. */
|
/** Well-known approximate centroids for US ISO/RTO regions. */
|
||||||
const REGION_CENTROIDS: Record<string, { lat: number; lng: number }> = {
|
const REGION_CENTROIDS: Record<string, { lat: number; lng: number }> = {
|
||||||
@ -203,12 +195,18 @@ export function EnergyMap({ datacenters, regions, powerPlants }: EnergyMapProps)
|
|||||||
return (
|
return (
|
||||||
<APIProvider apiKey={apiKey}>
|
<APIProvider apiKey={apiKey}>
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<MapControls
|
{/* Unified left sidebar: filters + legend */}
|
||||||
datacenters={datacenters}
|
<div className="absolute top-4 left-4 z-10 flex max-h-[calc(100vh-12rem)] w-64 flex-col overflow-y-auto rounded-lg border border-zinc-700/60 bg-zinc-900/90 shadow-xl backdrop-blur">
|
||||||
onFilterChange={handleFilterChange}
|
<MapControls
|
||||||
showPowerPlants={showPowerPlants}
|
datacenters={datacenters}
|
||||||
onTogglePowerPlants={setShowPowerPlants}
|
onFilterChange={handleFilterChange}
|
||||||
/>
|
showPowerPlants={showPowerPlants}
|
||||||
|
onTogglePowerPlants={setShowPowerPlants}
|
||||||
|
/>
|
||||||
|
<div className="border-t border-zinc-700/60 p-3">
|
||||||
|
<MapLegend showPowerPlants={showPowerPlants} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Map
|
<Map
|
||||||
mapId={mapId}
|
mapId={mapId}
|
||||||
@ -241,10 +239,6 @@ export function EnergyMap({ datacenters, regions, powerPlants }: EnergyMapProps)
|
|||||||
selectedDatacenterId={selectedDatacenter?.id ?? null}
|
selectedDatacenterId={selectedDatacenter?.id ?? null}
|
||||||
onDatacenterClick={handleDatacenterClick}
|
onDatacenterClick={handleDatacenterClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MapControl position={ControlPosition.LEFT_BOTTOM}>
|
|
||||||
<MapLegend showPowerPlants={showPowerPlants} />
|
|
||||||
</MapControl>
|
|
||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
<DatacenterDetailPanel datacenter={selectedDatacenter} onClose={() => setSelectedDatacenter(null)} />
|
<DatacenterDetailPanel datacenter={selectedDatacenter} onClose={() => setSelectedDatacenter(null)} />
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export function MapControls({ datacenters, onFilterChange, showPowerPlants, onTo
|
|||||||
const hasFilters = selectedOperators.size > 0 || minCapacity > 0;
|
const hasFilters = selectedOperators.size > 0 || minCapacity > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-4 left-4 z-10 flex max-h-[calc(100vh-12rem)] w-64 flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-700/60 bg-zinc-900/90 p-3 shadow-xl backdrop-blur">
|
<div className="flex flex-col gap-3 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold tracking-wider text-zinc-400 uppercase">Filters</span>
|
<span className="text-xs font-semibold tracking-wider text-zinc-400 uppercase">Filters</span>
|
||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
|
|||||||
@ -20,7 +20,7 @@ const FUEL_TYPE_DISPLAY_ORDER = [
|
|||||||
|
|
||||||
export function MapLegend({ showPowerPlants = false }: MapLegendProps) {
|
export function MapLegend({ showPowerPlants = false }: MapLegendProps) {
|
||||||
return (
|
return (
|
||||||
<div className="z-10 rounded-lg border border-zinc-700/60 bg-zinc-900/90 p-3 text-xs backdrop-blur-sm">
|
<div className="text-xs">
|
||||||
{/* Price heatmap gradient */}
|
{/* Price heatmap gradient */}
|
||||||
<div className="mb-2.5">
|
<div className="mb-2.5">
|
||||||
<div className="mb-1 font-medium text-zinc-300">Price Heatmap</div>
|
<div className="mb-1 font-medium text-zinc-300">Price Heatmap</div>
|
||||||
|
|||||||
@ -3,12 +3,33 @@
|
|||||||
*
|
*
|
||||||
* In development, sets up a 30-minute interval that calls the ingestion
|
* In development, sets up a 30-minute interval that calls the ingestion
|
||||||
* endpoints to keep electricity, generation, and commodity data fresh.
|
* endpoints to keep electricity, generation, and commodity data fresh.
|
||||||
|
*
|
||||||
|
* Each scheduled run fetches only the last 2 days of data so that
|
||||||
|
* requests complete quickly instead of auto-paginating through
|
||||||
|
* ALL historical EIA data (which causes timeouts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const INGEST_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
const INGEST_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
const INGEST_BASE_URL = 'http://localhost:3000/api/ingest';
|
|
||||||
const ENDPOINTS = ['electricity', 'generation', 'commodities'] as const;
|
const ENDPOINTS = ['electricity', 'generation', 'commodities'] as const;
|
||||||
|
|
||||||
|
/** Per-endpoint fetch timeout — 2 minutes is generous for a bounded date range. */
|
||||||
|
const ENDPOINT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/** How many days of history to fetch on each scheduled run. */
|
||||||
|
const LOOKBACK_DAYS = 2;
|
||||||
|
|
||||||
|
function getIngestBaseUrl(): string {
|
||||||
|
const port = process.env.PORT ?? '3000';
|
||||||
|
return `http://localhost:${port}/api/ingest`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an ISO date string N days in the past (YYYY-MM-DD). */
|
||||||
|
function startDateForLookback(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() - days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
interface IngestionStats {
|
interface IngestionStats {
|
||||||
inserted: number;
|
inserted: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
@ -22,6 +43,47 @@ function isIngestionStats(value: unknown): value is IngestionStats {
|
|||||||
return typeof inserted === 'number' && typeof updated === 'number' && typeof errors === 'number';
|
return typeof inserted === 'number' && typeof updated === 'number' && typeof errors === 'number';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchEndpoint(
|
||||||
|
baseUrl: string,
|
||||||
|
endpoint: string,
|
||||||
|
secret: string,
|
||||||
|
start: string,
|
||||||
|
logTimestamp: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const url = `${baseUrl}/${endpoint}?start=${start}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), ENDPOINT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${secret}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`[${logTimestamp}] [ingest] ${endpoint}: HTTP ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: unknown = await res.json();
|
||||||
|
if (isIngestionStats(body)) {
|
||||||
|
console.log(
|
||||||
|
`[${logTimestamp}] [ingest] ${endpoint}: ${body.inserted} inserted, ${body.updated} updated, ${body.errors} errors`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`[${logTimestamp}] [ingest] ${endpoint}: done (unexpected response shape)`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
console.error(`[${logTimestamp}] [ingest] ${endpoint}: timed out after ${ENDPOINT_TIMEOUT_MS / 1000}s`);
|
||||||
|
} else {
|
||||||
|
console.error(`[${logTimestamp}] [ingest] ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runIngestion(): Promise<void> {
|
async function runIngestion(): Promise<void> {
|
||||||
const secret = process.env.INGEST_SECRET;
|
const secret = process.env.INGEST_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
@ -29,37 +91,26 @@ async function runIngestion(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().slice(11, 19);
|
const logTimestamp = new Date().toISOString().slice(11, 19);
|
||||||
console.log(`[${timestamp}] [ingest] Starting scheduled ingestion...`);
|
const start = startDateForLookback(LOOKBACK_DAYS);
|
||||||
|
const baseUrl = getIngestBaseUrl();
|
||||||
|
|
||||||
for (const endpoint of ENDPOINTS) {
|
console.log(`[${logTimestamp}] [ingest] Starting scheduled ingestion (start=${start})...`);
|
||||||
try {
|
|
||||||
const url = `${INGEST_BASE_URL}/${endpoint}`;
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: { Authorization: `Bearer ${secret}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
// Run all endpoints in parallel — they are independent
|
||||||
console.error(`[${timestamp}] [ingest] ${endpoint}: HTTP ${res.status}`);
|
const results = await Promise.allSettled(
|
||||||
continue;
|
ENDPOINTS.map(endpoint => fetchEndpoint(baseUrl, endpoint, secret, start, logTimestamp)),
|
||||||
}
|
);
|
||||||
|
|
||||||
const body: unknown = await res.json();
|
for (let i = 0; i < results.length; i++) {
|
||||||
if (isIngestionStats(body)) {
|
const result = results[i]!;
|
||||||
console.log(
|
if (result.status === 'rejected') {
|
||||||
`[${timestamp}] [ingest] ${endpoint}: ${body.inserted} inserted, ${body.updated} updated, ${body.errors} errors`,
|
console.error(`[${logTimestamp}] [ingest] ${ENDPOINTS[i]}: unhandled rejection: ${result.reason}`);
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(`[${timestamp}] [ingest] ${endpoint}: done (unexpected response shape)`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[${timestamp}] [ingest] ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function register(): void {
|
export function register(): void {
|
||||||
if (process.env.NODE_ENV !== 'development') return;
|
|
||||||
if (typeof globalThis.setInterval === 'undefined') return;
|
if (typeof globalThis.setInterval === 'undefined') return;
|
||||||
|
|
||||||
console.log('[ingest] Scheduling automated ingestion every 30 minutes');
|
console.log('[ingest] Scheduling automated ingestion every 30 minutes');
|
||||||
|
|||||||
@ -73,5 +73,6 @@ export function formatMarketDate(utcDate: Date, regionCode: string): string {
|
|||||||
timeZone: timezone,
|
timeZone: timezone,
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
year: '2-digit',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user