- Fix ticker tape CLS with skeleton loader and fixed height - Add Inter font, max-width container, responsive dvh units - Hero metrics: trend deltas, per-metric sparkline colors, 3+2 grid - GPU calculator: step=100 slider + text input, PUE factor, region comparison bars - Grid stress: replace misleading arc gauges with demand status bars - Demand summary: expand to 4-metric highlights grid - Charts: responsive heights, ISO/non-ISO toggle, correlation R² + trend line - Map: US-wide default view, marker clustering, enriched region panels, zoom controls - Fix NYISO polygon (NYC), MISO polygon (Michigan), MISO south (MS/LA) - Add automated ingestion via instrumentation.ts - Add data freshness indicator in footer - Fix backfill start date to 2019-01-01 (EIA RTO data availability)
120 lines
3.7 KiB
TypeScript
120 lines
3.7 KiB
TypeScript
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
|
|
import { Activity } from 'lucide-react';
|
|
|
|
import { fetchRegionDemandSummary } from '@/actions/demand.js';
|
|
import { deserialize } from '@/lib/superjson.js';
|
|
|
|
interface RegionSummary {
|
|
regionCode: string;
|
|
latestDemandMw: number;
|
|
peakDemandMw: number;
|
|
avgDemandMw: number;
|
|
}
|
|
|
|
function formatGw(mw: number): string {
|
|
if (mw >= 1000) return `${(mw / 1000).toFixed(1)}`;
|
|
return `${Math.round(mw)}`;
|
|
}
|
|
|
|
function formatGwUnit(mw: number): string {
|
|
return mw >= 1000 ? 'GW' : 'MW';
|
|
}
|
|
|
|
export async function DemandSummary() {
|
|
const demandResult = await fetchRegionDemandSummary();
|
|
|
|
const demandRows = demandResult.ok
|
|
? deserialize<
|
|
Array<{
|
|
avg_demand: number | null;
|
|
peak_demand: number | null;
|
|
region_code: string;
|
|
region_name: string;
|
|
day: Date;
|
|
}>
|
|
>(demandResult.data)
|
|
: [];
|
|
|
|
// Aggregate per region: latest demand, peak demand, avg demand
|
|
const regionMap = new Map<string, RegionSummary>();
|
|
for (const row of demandRows) {
|
|
const demand = row.avg_demand ?? 0;
|
|
const peak = row.peak_demand ?? 0;
|
|
const existing = regionMap.get(row.region_code);
|
|
|
|
if (!existing) {
|
|
regionMap.set(row.region_code, {
|
|
regionCode: row.region_code,
|
|
latestDemandMw: demand,
|
|
peakDemandMw: peak,
|
|
avgDemandMw: demand,
|
|
});
|
|
} else {
|
|
// Query is ordered by day ASC, so later rows are more recent
|
|
existing.latestDemandMw = demand;
|
|
if (peak > existing.peakDemandMw) existing.peakDemandMw = peak;
|
|
// Running average approximation
|
|
existing.avgDemandMw = (existing.avgDemandMw + demand) / 2;
|
|
}
|
|
}
|
|
|
|
const regions = [...regionMap.values()].filter(r => r.peakDemandMw > 0);
|
|
if (regions.length === 0) return null;
|
|
|
|
const totalDemandMw = regions.reduce((sum, r) => sum + r.latestDemandMw, 0);
|
|
const peakRegion = regions.reduce((max, r) => (r.latestDemandMw > max.latestDemandMw ? r : max), regions[0]!);
|
|
const regionCount = regions.length;
|
|
|
|
const totalAvg = regions.reduce((sum, r) => sum + r.avgDemandMw, 0);
|
|
const totalPeak = regions.reduce((sum, r) => sum + r.peakDemandMw, 0);
|
|
const loadFactor = totalPeak > 0 ? (totalAvg / totalPeak) * 100 : 0;
|
|
|
|
const highlights = [
|
|
{
|
|
label: 'Total US Demand',
|
|
value: formatGw(totalDemandMw),
|
|
unit: formatGwUnit(totalDemandMw),
|
|
},
|
|
{
|
|
label: 'Peak Region',
|
|
value: peakRegion.regionCode,
|
|
unit: `${formatGw(peakRegion.latestDemandMw)} ${formatGwUnit(peakRegion.latestDemandMw)}`,
|
|
},
|
|
{
|
|
label: 'Regions Tracked',
|
|
value: regionCount.toString(),
|
|
unit: 'ISOs',
|
|
},
|
|
{
|
|
label: 'Avg Load Factor',
|
|
value: `${loadFactor.toFixed(0)}`,
|
|
unit: '%',
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Activity className="h-5 w-5 text-chart-3" />
|
|
Demand Highlights
|
|
</CardTitle>
|
|
<p className="text-xs text-muted-foreground">7-day summary across all grid regions</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{highlights.map(h => (
|
|
<div key={h.label} className="flex flex-col gap-0.5">
|
|
<span className="text-[11px] font-medium tracking-wide text-muted-foreground">{h.label}</span>
|
|
<div className="flex items-baseline gap-1">
|
|
<span className="font-mono text-lg font-bold tabular-nums">{h.value}</span>
|
|
<span className="text-xs text-muted-foreground">{h.unit}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|