phase 4: charts & analysis — price trends, demand analysis, generation mix, commodity overlay, correlation view

This commit is contained in:
Joey Eamigh 2026-02-11 05:21:25 -05:00
parent c33257092d
commit 2dddbe78cb
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
11 changed files with 2220 additions and 21 deletions

View File

@ -71,6 +71,91 @@ export async function fetchPriceHeatmapData(): Promise<ActionResult<getRegionPri
}
}
export async function fetchAllRegionPriceTrends(
timeRange: TimeRange = '30d',
): Promise<ActionResult<getPriceTrends.Result[]>> {
try {
const startDate = timeRangeToStartDate(timeRange);
const endDate = new Date();
const regions = await prisma.gridRegion.findMany({ select: { code: true } });
const results = await Promise.all(
regions.map(r => prisma.$queryRawTyped(getPriceTrends(r.code, startDate, endDate))),
);
return { ok: true, data: serialize(results.flat()) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch all region price trends: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
export async function fetchCommodityTrends(timeRange: TimeRange = '30d'): Promise<
ActionResult<
Array<{
commodity: string;
price: number;
unit: string;
timestamp: Date;
source: string;
}>
>
> {
try {
const startDate = timeRangeToStartDate(timeRange);
const rows = await prisma.commodityPrice.findMany({
where: { timestamp: { gte: startDate } },
orderBy: { timestamp: 'asc' },
});
return { ok: true, data: serialize(rows) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch commodity trends: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
export async function fetchRegionCapacityVsPrice(): Promise<
ActionResult<
Array<{
region_code: string;
avg_price: number;
total_capacity_mw: number;
}>
>
> {
try {
const regions = await prisma.gridRegion.findMany({
select: {
code: true,
datacenters: { select: { capacityMw: true } },
electricityPrices: {
select: { priceMwh: true },
orderBy: { timestamp: 'desc' },
take: 100,
},
},
});
const result = regions.map(r => ({
region_code: r.code,
total_capacity_mw: r.datacenters.reduce((sum, d) => sum + d.capacityMw, 0),
avg_price:
r.electricityPrices.length > 0
? r.electricityPrices.reduce((sum, p) => sum + p.priceMwh, 0) / r.electricityPrices.length
: 0,
}));
return { ok: true, data: serialize(result) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch region capacity vs price: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
export async function fetchLatestCommodityPrices(): Promise<
ActionResult<
Array<{

View File

@ -1,3 +1,7 @@
import { fetchDemandByRegion, fetchRegionDemandSummary } from '@/actions/demand.js';
import { DemandChart } from '@/components/charts/demand-chart.js';
import type { getDemandByRegion } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import type { Metadata } from 'next';
export const metadata: Metadata = {
@ -5,18 +9,27 @@ export const metadata: Metadata = {
description: 'Regional electricity demand growth, peak tracking, and datacenter load impact',
};
export default function DemandPage() {
export default async function DemandPage() {
const [demandResult, summaryResult] = await Promise.all([
fetchDemandByRegion('ALL', '30d'),
fetchRegionDemandSummary(),
]);
const demandData = demandResult.ok ? deserialize<getDemandByRegion.Result[]>(demandResult.data) : [];
const summaryData = summaryResult.ok ? deserialize<getDemandByRegion.Result[]>(summaryResult.data) : [];
return (
<div className="px-6 py-8">
<h1 className="text-3xl font-bold tracking-tight">Demand Analysis</h1>
<p className="mt-2 text-muted-foreground">
Regional demand growth trends, peak demand tracking, and estimated datacenter load as a percentage of regional
demand.
</p>
<div className="mt-8 flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-muted-foreground">Charts coming in Phase 4</p>
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Demand Analysis</h1>
<p className="mt-2 text-muted-foreground">
Regional demand growth trends, peak demand tracking, and estimated datacenter load as a percentage of regional
demand.
</p>
</div>
<DemandChart initialData={demandData} summaryData={summaryData} />
</div>
);
}

View File

@ -1,11 +1,33 @@
import type { Metadata } from 'next';
import { fetchGenerationMix } from '@/actions/generation.js';
import { GenerationChart } from '@/components/charts/generation-chart.js';
export const metadata: Metadata = {
title: 'Generation Mix | Energy & AI Dashboard',
description: 'Generation by fuel type per region, renewable vs fossil splits, and carbon intensity',
};
export default function GenerationPage() {
const DEFAULT_REGION = 'PJM';
const DEFAULT_TIME_RANGE = '30d' as const;
export default async function GenerationPage() {
const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE);
if (!result.ok) {
return (
<div className="px-6 py-8">
<h1 className="text-3xl font-bold tracking-tight">Generation Mix</h1>
<p className="mt-2 text-muted-foreground">
Stacked area charts showing generation by fuel type per region, with renewable vs fossil comparisons.
</p>
<div className="mt-8 flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{result.error}</p>
</div>
</div>
);
}
return (
<div className="px-6 py-8">
<h1 className="text-3xl font-bold tracking-tight">Generation Mix</h1>
@ -14,8 +36,12 @@ export default function GenerationPage() {
intensity indicators.
</p>
<div className="mt-8 flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-muted-foreground">Charts coming in Phase 4</p>
<div className="mt-8">
<GenerationChart
initialData={result.data}
initialRegion={DEFAULT_REGION}
initialTimeRange={DEFAULT_TIME_RANGE}
/>
</div>
</div>
);

View File

@ -1,21 +1,87 @@
import { readFile } from 'fs/promises';
import type { Metadata } from 'next';
import { join } from 'path';
import { z } from 'zod';
import { fetchAllRegionPriceTrends, fetchCommodityTrends, fetchRegionCapacityVsPrice } from '@/actions/prices.js';
import { CorrelationChart } from '@/components/charts/correlation-chart.js';
import type { AIMilestone } from '@/components/charts/price-chart.js';
import { PriceChart } from '@/components/charts/price-chart.js';
export const metadata: Metadata = {
title: 'Price Trends | Energy & AI Dashboard',
description: 'Regional electricity price trends, commodity overlays, and AI milestone annotations',
};
export default function TrendsPage() {
return (
<div className="px-6 py-8">
<h1 className="text-3xl font-bold tracking-tight">Price Trends</h1>
<p className="mt-2 text-muted-foreground">
Multi-line regional electricity price charts with commodity overlays and AI milestone annotations.
</p>
const AIMilestoneSchema = z.object({
date: z.string(),
title: z.string(),
description: z.string(),
category: z.string(),
}) satisfies z.ZodType<AIMilestone>;
<div className="mt-8 flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-muted-foreground">Charts coming in Phase 4</p>
async function loadMilestones(): Promise<AIMilestone[]> {
try {
const filePath = join(process.cwd(), 'data', 'ai-milestones.json');
const raw = await readFile(filePath, 'utf-8');
const parsed: unknown = JSON.parse(raw);
return z.array(AIMilestoneSchema).parse(parsed);
} catch {
return [];
}
}
export default async function TrendsPage() {
const defaultRange = '30d' as const;
const [priceResult, commodityResult, correlationResult, milestones] = await Promise.all([
fetchAllRegionPriceTrends(defaultRange),
fetchCommodityTrends(defaultRange),
fetchRegionCapacityVsPrice(),
loadMilestones(),
]);
return (
<div className="space-y-6 px-6 py-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Price Trends</h1>
<p className="mt-2 text-muted-foreground">
Regional electricity price charts with commodity overlays and AI milestone annotations.
</p>
</div>
{priceResult.ok && commodityResult.ok ? (
<PriceChart
initialPriceData={priceResult.data}
initialCommodityData={commodityResult.data}
milestones={milestones}
initialTimeRange={defaultRange}
onTimeRangeChange={async range => {
'use server';
const [prices, commodities] = await Promise.all([
fetchAllRegionPriceTrends(range),
fetchCommodityTrends(range),
]);
if (!prices.ok) throw new Error(prices.error);
if (!commodities.ok) throw new Error(commodities.error);
return { prices: prices.data, commodities: commodities.data };
}}
/>
) : (
<div className="flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">
{!priceResult.ok ? priceResult.error : !commodityResult.ok ? commodityResult.error : ''}
</p>
</div>
)}
{correlationResult.ok ? (
<CorrelationChart data={correlationResult.data} />
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{correlationResult.error}</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,178 @@
'use client';
import { useMemo } from 'react';
import { CartesianGrid, Label, Scatter, ScatterChart, XAxis, YAxis, ZAxis } 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 { deserialize } from '@/lib/superjson.js';
interface RegionCapacityPrice {
region_code: string;
avg_price: number;
total_capacity_mw: number;
}
interface CorrelationChartProps {
data: SuperJSONResult;
}
const REGION_COLORS: Record<string, string> = {
PJM: 'hsl(210, 90%, 55%)',
ERCOT: 'hsl(25, 90%, 55%)',
CAISO: 'hsl(140, 70%, 45%)',
NYISO: 'hsl(280, 70%, 55%)',
ISONE: 'hsl(340, 70%, 55%)',
MISO: 'hsl(55, 80%, 50%)',
SPP: 'hsl(180, 60%, 45%)',
};
const chartConfig: ChartConfig = {
correlation: {
label: 'DC Capacity vs. Avg Price',
color: 'hsl(210, 90%, 55%)',
},
};
interface ScatterPoint {
region_code: string;
total_capacity_mw: number;
avg_price: number;
fill: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function getNumberProp(obj: Record<string, unknown>, key: string): number {
const val = obj[key];
return typeof val === 'number' ? val : 0;
}
function getScatterPayload(obj: Record<string, unknown>): ScatterPoint | null {
const payload = obj['payload'];
if (!isRecord(payload)) return null;
const regionCode = typeof payload['region_code'] === 'string' ? payload['region_code'] : '';
const fill = typeof payload['fill'] === 'string' ? payload['fill'] : 'hsl(0,0%,50%)';
return {
region_code: regionCode,
total_capacity_mw: getNumberProp(payload, 'total_capacity_mw'),
avg_price: getNumberProp(payload, 'avg_price'),
fill,
};
}
function CustomDot(props: unknown): React.JSX.Element {
if (!isRecord(props)) return <g />;
const cx = getNumberProp(props, 'cx');
const cy = getNumberProp(props, 'cy');
const payload = getScatterPayload(props);
if (!payload) return <g />;
return (
<g>
<circle cx={cx} cy={cy} r={8} fill={payload.fill} fillOpacity={0.7} stroke={payload.fill} strokeWidth={2} />
<text x={cx} y={cy - 14} textAnchor="middle" fill="hsl(var(--foreground))" fontSize={11} fontWeight={600}>
{payload.region_code}
</text>
</g>
);
}
export function CorrelationChart({ data }: CorrelationChartProps) {
const rows = useMemo(() => deserialize<RegionCapacityPrice[]>(data), [data]);
const scatterData: ScatterPoint[] = useMemo(
() =>
rows
.filter(r => r.total_capacity_mw > 0 || r.avg_price > 0)
.map(r => ({
region_code: r.region_code,
total_capacity_mw: Math.round(r.total_capacity_mw),
avg_price: Number(r.avg_price.toFixed(2)),
fill: REGION_COLORS[r.region_code] ?? 'hsl(0, 0%, 50%)',
})),
[rows],
);
if (scatterData.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>DC Capacity vs. Electricity Price</CardTitle>
<CardDescription>No data available for correlation analysis.</CardDescription>
</CardHeader>
<CardContent>
<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. Ingest price data and seed datacenters first.
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>DC Capacity vs. Electricity Price</CardTitle>
<CardDescription>Datacenter capacity (MW) versus average electricity price per region</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[350px] w-full">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
type="number"
dataKey="total_capacity_mw"
name="DC Capacity"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v} MW`}>
<Label
value="Total DC Capacity (MW)"
offset={-10}
position="insideBottom"
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
/>
</XAxis>
<YAxis
type="number"
dataKey="avg_price"
name="Avg Price"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}>
<Label
value="Avg Price ($/MWh)"
angle={-90}
position="insideLeft"
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
/>
</YAxis>
<ZAxis range={[200, 200]} />
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => {
const nameStr = String(name);
const valStr = String(value);
if (nameStr === 'DC Capacity') return [`${valStr} MW`, undefined];
if (nameStr === 'Avg Price') return [`$${valStr}/MWh`, undefined];
return [valStr, undefined];
}}
/>
}
/>
<Scatter name="Regions" data={scatterData} shape={CustomDot} />
</ScatterChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,470 @@
'use client';
import { fetchDemandByRegion } from '@/actions/demand.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart.js';
import { deserialize } from '@/lib/superjson.js';
import { Activity, Server, TrendingUp, Zap } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Bar, BarChart, CartesianGrid, ComposedChart, Line, XAxis, YAxis } from 'recharts';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
interface DemandRow {
region_code: string;
region_name: string;
day: Date | null;
avg_demand: number | null;
peak_demand: number | null;
datacenter_count: number | null;
total_dc_capacity_mw: number | null;
}
interface DemandChartProps {
initialData: DemandRow[];
summaryData: DemandRow[];
}
const TIME_RANGES: { value: TimeRange; label: string }[] = [
{ value: '7d', label: '7D' },
{ value: '30d', label: '30D' },
{ value: '90d', label: '90D' },
{ value: '1y', label: '1Y' },
];
const REGION_COLORS: Record<string, string> = {
PJM: 'hsl(210, 80%, 60%)',
ERCOT: 'hsl(30, 80%, 55%)',
CAISO: 'hsl(145, 65%, 50%)',
NYISO: 'hsl(280, 70%, 60%)',
ISONE: 'hsl(350, 70%, 55%)',
MISO: 'hsl(60, 70%, 50%)',
SPP: 'hsl(180, 60%, 50%)',
};
function formatDemandValue(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
return value.toFixed(0);
}
function formatDateLabel(date: Date): string {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}
export function DemandChart({ initialData, summaryData }: DemandChartProps) {
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
const [selectedRegion, setSelectedRegion] = useState<string>('ALL');
const [chartData, setChartData] = useState<DemandRow[]>(initialData);
const [loading, setLoading] = useState(false);
const regions = useMemo(() => {
const regionSet = new Map<string, string>();
for (const row of summaryData) {
if (!regionSet.has(row.region_code)) {
regionSet.set(row.region_code, row.region_name);
}
}
return Array.from(regionSet.entries()).map(([code, name]) => ({
code,
name,
}));
}, [summaryData]);
const handleTimeRangeChange = useCallback(
async (range: TimeRange) => {
setTimeRange(range);
setLoading(true);
try {
const result = await fetchDemandByRegion(selectedRegion, range);
if (result.ok) {
setChartData(deserialize<DemandRow[]>(result.data));
}
} finally {
setLoading(false);
}
},
[selectedRegion],
);
const handleRegionChange = useCallback(
async (region: string) => {
setSelectedRegion(region);
setLoading(true);
try {
const result = await fetchDemandByRegion(region, timeRange);
if (result.ok) {
setChartData(deserialize<DemandRow[]>(result.data));
}
} finally {
setLoading(false);
}
},
[timeRange],
);
const trendChartData = useMemo(() => {
const filtered = selectedRegion === 'ALL' ? chartData : chartData.filter(r => r.region_code === selectedRegion);
const byDay = new Map<string, { date: string; dateObj: Date; [key: string]: number | string | Date }>();
for (const row of filtered) {
if (!row.day || row.avg_demand === null) continue;
const dateKey = row.day.toISOString().split('T')[0] ?? '';
if (!byDay.has(dateKey)) {
byDay.set(dateKey, { date: formatDateLabel(row.day), dateObj: row.day });
}
const entry = byDay.get(dateKey);
if (entry) {
entry[row.region_code] = Math.round(row.avg_demand);
}
}
return Array.from(byDay.values()).sort((a, b) => a.dateObj.getTime() - b.dateObj.getTime());
}, [chartData, selectedRegion]);
const trendChartConfig = useMemo(() => {
const config: ChartConfig = {};
const activeRegions = selectedRegion === 'ALL' ? regions : regions.filter(r => r.code === selectedRegion);
for (const region of activeRegions) {
config[region.code] = {
label: region.name,
color: REGION_COLORS[region.code] ?? 'hsl(0, 0%, 60%)',
};
}
return config;
}, [regions, selectedRegion]);
const peakDemandByRegion = useMemo(() => {
const peaks = new Map<string, { regionCode: string; regionName: string; peakDemand: number; day: Date }>();
for (const row of chartData) {
if (!row.day || row.peak_demand === null) continue;
const existing = peaks.get(row.region_code);
if (!existing || row.peak_demand > existing.peakDemand) {
peaks.set(row.region_code, {
regionCode: row.region_code,
regionName: row.region_name,
peakDemand: row.peak_demand,
day: row.day,
});
}
}
return Array.from(peaks.values()).sort((a, b) => b.peakDemand - a.peakDemand);
}, [chartData]);
const dcImpactData = useMemo(() => {
const regionAgg = new Map<
string,
{
regionCode: string;
regionName: string;
totalDemand: number;
count: number;
dcCapacityMw: number;
}
>();
for (const row of summaryData) {
if (row.avg_demand === null) continue;
const existing = regionAgg.get(row.region_code);
if (existing) {
existing.totalDemand += row.avg_demand;
existing.count += 1;
existing.dcCapacityMw = row.total_dc_capacity_mw ?? existing.dcCapacityMw;
} else {
regionAgg.set(row.region_code, {
regionCode: row.region_code,
regionName: row.region_name,
totalDemand: row.avg_demand,
count: 1,
dcCapacityMw: row.total_dc_capacity_mw ?? 0,
});
}
}
return Array.from(regionAgg.values())
.map(r => {
const avgDemand = r.totalDemand / r.count;
const dcPercent = avgDemand > 0 ? (r.dcCapacityMw / avgDemand) * 100 : 0;
return {
region: r.regionCode,
regionName: r.regionName,
avgDemand: Math.round(avgDemand),
dcCapacityMw: Math.round(r.dcCapacityMw),
dcPercent: Math.round(dcPercent * 10) / 10,
};
})
.sort((a, b) => b.dcPercent - a.dcPercent);
}, [summaryData]);
const dcImpactConfig = {
avgDemand: {
label: 'Avg Demand (MW)',
color: 'hsl(210, 80%, 60%)',
},
dcCapacityMw: {
label: 'DC Capacity (MW)',
color: 'hsl(340, 75%, 55%)',
},
} satisfies ChartConfig;
const hasData = trendChartData.length > 0;
const hasDcImpact = dcImpactData.length > 0;
return (
<div className="space-y-6">
{/* Controls */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 rounded-lg border border-border bg-card p-1">
{TIME_RANGES.map(range => (
<button
key={range.value}
onClick={() => handleTimeRangeChange(range.value)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
timeRange === range.value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}>
{range.label}
</button>
))}
</div>
<div className="flex items-center gap-2 rounded-lg border border-border bg-card p-1">
<button
onClick={() => handleRegionChange('ALL')}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
selectedRegion === 'ALL'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}>
All Regions
</button>
{regions.map(region => (
<button
key={region.code}
onClick={() => handleRegionChange(region.code)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
selectedRegion === region.code
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}>
{region.code}
</button>
))}
</div>
{loading && <span className="text-sm text-muted-foreground">Loading...</span>}
</div>
{/* Demand Trend Chart */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-chart-1" />
Regional Demand Trends
</CardTitle>
<CardDescription>Average daily electricity demand by ISO region (MW)</CardDescription>
</CardHeader>
<CardContent>
{hasData ? (
<ChartContainer config={trendChartConfig} className="h-[400px] w-full">
<ComposedChart data={trendChartData}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => formatDemandValue(value)}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={value => {
const numVal = typeof value === 'number' ? value : Number(value);
return `${formatDemandValue(numVal)} MW`;
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{Object.keys(trendChartConfig).map(regionCode => (
<Line
key={regionCode}
type="monotone"
dataKey={regionCode}
stroke={`var(--color-${regionCode})`}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
</ComposedChart>
</ChartContainer>
) : (
<EmptyState message="No demand trend data available for this time range." />
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
{/* Peak Demand Records */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-chart-2" />
Peak Demand Records
</CardTitle>
<CardDescription>Highest recorded demand in selected period</CardDescription>
</CardHeader>
<CardContent>
{peakDemandByRegion.length > 0 ? (
<div className="space-y-4">
{peakDemandByRegion.map((peak, idx) => (
<div key={peak.regionCode} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold"
style={{
backgroundColor: `${REGION_COLORS[peak.regionCode] ?? 'hsl(0,0%,40%)'}`,
color: 'white',
}}>
{idx + 1}
</span>
<div>
<p className="text-sm font-medium">{peak.regionName}</p>
<p className="text-xs text-muted-foreground">{formatDateLabel(peak.day)}</p>
</div>
</div>
<div className="text-right">
<p className="font-mono text-sm font-semibold">{formatDemandValue(peak.peakDemand)} MW</p>
</div>
</div>
))}
</div>
) : (
<EmptyState message="No peak demand records for this period." />
)}
</CardContent>
</Card>
{/* DC Impact Chart */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-chart-4" />
Datacenter Load Impact
</CardTitle>
<CardDescription>Estimated datacenter capacity as % of regional demand</CardDescription>
</CardHeader>
<CardContent>
{hasDcImpact ? (
<>
<ChartContainer config={dcImpactConfig} className="h-[280px] w-full">
<BarChart data={dcImpactData} layout="vertical">
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis
type="number"
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => formatDemandValue(value)}
/>
<YAxis type="category" dataKey="region" tickLine={false} axisLine={false} width={55} />
<ChartTooltip
content={
<ChartTooltipContent
formatter={value => {
const numVal = typeof value === 'number' ? value : Number(value);
return `${formatDemandValue(numVal)} MW`;
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
<Bar dataKey="avgDemand" fill="var(--color-avgDemand)" radius={[0, 4, 4, 0]} />
<Bar dataKey="dcCapacityMw" fill="var(--color-dcCapacityMw)" radius={[0, 4, 4, 0]} />
</BarChart>
</ChartContainer>
{/* DC % Table */}
<div className="mt-4 space-y-2">
{dcImpactData.map(row => (
<div key={row.region} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{row.regionName}</span>
<div className="flex items-center gap-3">
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-chart-4 transition-all"
style={{
width: `${Math.min(row.dcPercent, 100)}%`,
}}
/>
</div>
<span className="w-14 text-right font-mono text-xs font-semibold">{row.dcPercent}%</span>
</div>
</div>
))}
</div>
</>
) : (
<EmptyState message="No datacenter impact data available." />
)}
</CardContent>
</Card>
</div>
{/* Summary Stats Row */}
{hasDcImpact && (
<div className="grid gap-4 sm:grid-cols-3">
<SummaryCard
icon={<Zap className="h-5 w-5 text-chart-1" />}
label="Total Avg Demand"
value={`${formatDemandValue(dcImpactData.reduce((sum, r) => sum + r.avgDemand, 0))} MW`}
/>
<SummaryCard
icon={<Server className="h-5 w-5 text-chart-4" />}
label="Total DC Capacity"
value={`${formatDemandValue(dcImpactData.reduce((sum, r) => sum + r.dcCapacityMw, 0))} MW`}
/>
<SummaryCard
icon={<TrendingUp className="h-5 w-5 text-chart-2" />}
label="Regions Tracked"
value={`${dcImpactData.length}`}
/>
</div>
)}
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-48 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}
function SummaryCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Card>
<CardContent className="flex items-center gap-4 pt-6">
{icon}
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="font-mono text-lg font-semibold">{value}</p>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,387 @@
'use client';
import { useCallback, useMemo, useState, useTransition } from 'react';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { fetchGenerationMix } from '@/actions/generation.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart.js';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
import type { getGenerationMix } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { formatMarketTime } from '@/lib/utils.js';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
const REGIONS = [
{ code: 'PJM', name: 'PJM (Mid-Atlantic)' },
{ code: 'ERCOT', name: 'ERCOT (Texas)' },
{ code: 'CAISO', name: 'CAISO (California)' },
{ code: 'NYISO', name: 'NYISO (New York)' },
{ code: 'ISONE', name: 'ISO-NE (New England)' },
{ code: 'MISO', name: 'MISO (Midwest)' },
{ code: 'SPP', name: 'SPP (South Central)' },
] as const;
const TIME_RANGES: { value: TimeRange; label: string }[] = [
{ value: '24h', label: '24H' },
{ value: '7d', label: '7D' },
{ value: '30d', label: '30D' },
{ value: '90d', label: '90D' },
{ value: '1y', label: '1Y' },
];
const FUEL_TYPES = ['gas', 'nuclear', 'wind', 'solar', 'coal', 'hydro', 'other'] as const;
type FuelType = (typeof FUEL_TYPES)[number];
const FUEL_COLORS: Record<FuelType, string> = {
gas: 'hsl(25, 95%, 53%)',
nuclear: 'hsl(270, 70%, 60%)',
wind: 'hsl(190, 90%, 50%)',
solar: 'hsl(45, 93%, 58%)',
coal: 'hsl(0, 0%, 55%)',
hydro: 'hsl(210, 80%, 55%)',
other: 'hsl(0, 0%, 40%)',
};
const FUEL_LABELS: Record<FuelType, string> = {
gas: 'Natural Gas',
nuclear: 'Nuclear',
wind: 'Wind',
solar: 'Solar',
coal: 'Coal',
hydro: 'Hydro',
other: 'Other',
};
const RENEWABLE_FUELS = new Set<FuelType>(['wind', 'solar', 'hydro']);
const FOSSIL_FUELS = new Set<FuelType>(['gas', 'coal']);
const FUEL_TYPE_SET: Set<string> = new Set(FUEL_TYPES);
function isFuelType(value: string): value is FuelType {
return FUEL_TYPE_SET.has(value);
}
const TIME_RANGE_SET: Set<string> = new Set(['24h', '7d', '30d', '90d', '1y']);
function isTimeRange(value: string): value is TimeRange {
return TIME_RANGE_SET.has(value);
}
const chartConfig: ChartConfig = Object.fromEntries(
FUEL_TYPES.map(fuel => [fuel, { label: FUEL_LABELS[fuel], color: FUEL_COLORS[fuel] }]),
);
interface PivotedRow {
timestamp: number;
dateLabel: string;
gas: number;
nuclear: number;
wind: number;
solar: number;
coal: number;
hydro: number;
other: number;
}
function pivotGenerationData(rows: getGenerationMix.Result[], regionCode: string): PivotedRow[] {
const byTimestamp = new Map<number, PivotedRow>();
for (const row of rows) {
const ts = row.timestamp.getTime();
let pivot = byTimestamp.get(ts);
if (!pivot) {
pivot = {
timestamp: ts,
dateLabel: formatMarketTime(row.timestamp, regionCode),
gas: 0,
nuclear: 0,
wind: 0,
solar: 0,
coal: 0,
hydro: 0,
other: 0,
};
byTimestamp.set(ts, pivot);
}
const fuelKey = isFuelType(row.fuel_type) ? row.fuel_type : 'other';
pivot[fuelKey] += row.generation_mw;
}
return Array.from(byTimestamp.values()).sort((a, b) => a.timestamp - b.timestamp);
}
interface GenerationSplit {
renewable: number;
fossil: number;
nuclear: number;
other: number;
total: number;
}
function computeGenerationSplit(data: PivotedRow[]): GenerationSplit {
const totals: GenerationSplit = { renewable: 0, fossil: 0, nuclear: 0, other: 0, total: 0 };
for (const row of data) {
for (const fuel of FUEL_TYPES) {
const mw = row[fuel];
totals.total += mw;
if (RENEWABLE_FUELS.has(fuel)) {
totals.renewable += mw;
} else if (FOSSIL_FUELS.has(fuel)) {
totals.fossil += mw;
} else if (fuel === 'nuclear') {
totals.nuclear += mw;
} else {
totals.other += mw;
}
}
}
return totals;
}
interface GenerationChartProps {
initialData: ReturnType<typeof import('@/lib/superjson.js').serialize<getGenerationMix.Result[]>>;
initialRegion: string;
initialTimeRange: TimeRange;
}
export function GenerationChart({ initialData, initialRegion, initialTimeRange }: GenerationChartProps) {
const [regionCode, setRegionCode] = useState(initialRegion);
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange);
const [serializedData, setSerializedData] = useState(initialData);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const rows = useMemo(() => deserialize<getGenerationMix.Result[]>(serializedData), [serializedData]);
const chartData = useMemo(() => pivotGenerationData(rows, regionCode), [rows, regionCode]);
const split = useMemo(() => computeGenerationSplit(chartData), [chartData]);
const refetch = useCallback((newRegion: string, newTimeRange: TimeRange) => {
startTransition(async () => {
setError(null);
const result = await fetchGenerationMix(newRegion, newTimeRange);
if (result.ok) {
setSerializedData(result.data);
} else {
setError(result.error);
}
});
}, []);
const handleRegionChange = useCallback(
(value: string) => {
setRegionCode(value);
refetch(value, timeRange);
},
[refetch, timeRange],
);
const handleTimeRangeChange = useCallback(
(value: string) => {
if (!isTimeRange(value)) return;
setTimeRange(value);
refetch(regionCode, value);
},
[refetch, regionCode],
);
const formatPercent = (value: number, total: number) => {
if (total === 0) return '0%';
return `${((value / total) * 100).toFixed(1)}%`;
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Generation by Fuel Type</CardTitle>
<CardDescription>
Stacked generation mix over time for {REGIONS.find(r => r.code === regionCode)?.name ?? regionCode}
</CardDescription>
</div>
<div className="flex gap-2">
<Select value={regionCode} onValueChange={handleRegionChange}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select region" />
</SelectTrigger>
<SelectContent>
{REGIONS.map(r => (
<SelectItem key={r.code} value={r.code}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map(t => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
{error && <div className="mb-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">{error}</div>}
{chartData.length === 0 && !isPending ? (
<div className="flex h-80 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-muted-foreground">
No generation data available for this region and time range.
</p>
</div>
) : (
<div className={isPending ? 'opacity-50 transition-opacity' : ''}>
<ChartContainer config={chartConfig} className="h-80 w-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
{FUEL_TYPES.map(fuel => (
<linearGradient key={fuel} id={`fill-${fuel}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={`var(--color-${fuel})`} stopOpacity={0.8} />
<stop offset="95%" stopColor={`var(--color-${fuel})`} stopOpacity={0.1} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/50" />
<XAxis
dataKey="dateLabel"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={40}
className="text-xs"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => (value >= 1000 ? `${(value / 1000).toFixed(0)}GW` : `${value}MW`)}
className="text-xs"
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const firstPayload: unknown = payload?.[0]?.payload;
if (
firstPayload &&
typeof firstPayload === 'object' &&
'timestamp' in firstPayload &&
typeof firstPayload.timestamp === 'number'
) {
return formatMarketTime(new Date(firstPayload.timestamp), regionCode);
}
return '';
}}
formatter={(value, name) => {
const mw = typeof value === 'number' ? value : Number(value);
const nameStr = String(name);
const label = isFuelType(nameStr) ? FUEL_LABELS[nameStr] : nameStr;
return (
<span>
{label}: {mw.toLocaleString(undefined, { maximumFractionDigits: 0 })} MW
</span>
);
}}
/>
}
/>
{FUEL_TYPES.map(fuel => (
<Area
key={fuel}
stackId="generation"
type="monotone"
dataKey={fuel}
fill={`url(#fill-${fuel})`}
stroke={`var(--color-${fuel})`}
strokeWidth={1.5}
/>
))}
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</div>
)}
</CardContent>
</Card>
{chartData.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<SplitCard
label="Renewable"
value={split.renewable}
total={split.total}
formatPercent={formatPercent}
colorClass="text-cyan-400"
/>
<SplitCard
label="Fossil"
value={split.fossil}
total={split.total}
formatPercent={formatPercent}
colorClass="text-orange-400"
/>
<SplitCard
label="Nuclear"
value={split.nuclear}
total={split.total}
formatPercent={formatPercent}
colorClass="text-purple-400"
/>
<SplitCard
label="Other"
value={split.other}
total={split.total}
formatPercent={formatPercent}
colorClass="text-gray-400"
/>
</div>
)}
</div>
);
}
function SplitCard({
label,
value,
total,
formatPercent,
colorClass,
}: {
label: string;
value: number;
total: number;
formatPercent: (v: number, t: number) => string;
colorClass: string;
}) {
return (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">{label}</p>
<p className={`mt-1 text-2xl font-bold tabular-nums ${colorClass}`}>{formatPercent(value, total)}</p>
<p className="mt-1 text-xs text-muted-foreground">
{(value / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} GWh total
</p>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,436 @@
'use client';
import { useMemo, useState } from 'react';
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson';
import { TimeRangeSelector, type TimeRange } from '@/components/charts/time-range-selector.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart.js';
import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js';
interface PriceTrendRow {
timestamp: Date;
price_mwh: number;
demand_mw: number;
region_code: string;
region_name: string;
}
interface CommodityRow {
commodity: string;
price: number;
unit: string;
timestamp: Date;
source: string;
}
export interface AIMilestone {
date: string;
title: string;
description: string;
category: string;
}
interface TimeRangeChangeResult {
prices: SuperJSONResult;
commodities: SuperJSONResult;
}
interface PriceChartProps {
initialPriceData: SuperJSONResult;
initialCommodityData: SuperJSONResult;
milestones: AIMilestone[];
initialTimeRange: TimeRange;
onTimeRangeChange: (range: TimeRange) => Promise<TimeRangeChangeResult>;
}
const REGION_COLORS: Record<string, string> = {
PJM: 'hsl(210, 90%, 55%)',
ERCOT: 'hsl(25, 90%, 55%)',
CAISO: 'hsl(140, 70%, 45%)',
NYISO: 'hsl(280, 70%, 55%)',
ISONE: 'hsl(340, 70%, 55%)',
MISO: 'hsl(55, 80%, 50%)',
SPP: 'hsl(180, 60%, 45%)',
};
const COMMODITY_COLORS: Record<string, string> = {
natural_gas: 'hsl(30, 100%, 70%)',
wti_crude: 'hsl(0, 70%, 60%)',
coal: 'hsl(0, 0%, 60%)',
};
const COMMODITY_LABELS: Record<string, string> = {
natural_gas: 'Natural Gas ($/MMBtu)',
wti_crude: 'WTI Crude ($/bbl)',
coal: 'Coal ($/short ton)',
};
const MILESTONE_COLORS: Record<string, string> = {
model_launch: 'hsl(280, 60%, 60%)',
infrastructure: 'hsl(200, 80%, 60%)',
market: 'hsl(140, 60%, 50%)',
policy: 'hsl(40, 80%, 55%)',
};
function buildChartConfig(regions: string[], commodities: string[], showCommodities: boolean): ChartConfig {
const config: ChartConfig = {};
for (const region of regions) {
config[region] = {
label: region,
color: REGION_COLORS[region] ?? 'hsl(0, 0%, 50%)',
};
}
if (showCommodities) {
for (const commodity of commodities) {
config[commodity] = {
label: COMMODITY_LABELS[commodity] ?? commodity,
color: COMMODITY_COLORS[commodity] ?? 'hsl(0, 0%, 60%)',
};
}
}
return config;
}
interface PivotedRow {
timestamp: number;
timestampDisplay: string;
[key: string]: number | string;
}
function pivotData(
priceRows: PriceTrendRow[],
commodityRows: CommodityRow[],
showCommodities: boolean,
): { pivoted: PivotedRow[]; regions: string[]; commodities: string[] } {
const regionSet = new Set<string>();
const commoditySet = new Set<string>();
const byTimestamp = new Map<number, PivotedRow>();
for (const row of priceRows) {
regionSet.add(row.region_code);
const ts = row.timestamp.getTime();
if (!byTimestamp.has(ts)) {
byTimestamp.set(ts, {
timestamp: ts,
timestampDisplay: row.timestamp.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
});
}
const pivotRow = byTimestamp.get(ts)!;
pivotRow[row.region_code] = row.price_mwh;
}
if (showCommodities) {
for (const row of commodityRows) {
commoditySet.add(row.commodity);
const ts = row.timestamp.getTime();
if (!byTimestamp.has(ts)) {
byTimestamp.set(ts, {
timestamp: ts,
timestampDisplay: row.timestamp.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
});
}
const pivotRow = byTimestamp.get(ts)!;
pivotRow[row.commodity] = row.price;
}
}
const regions = Array.from(regionSet).sort();
const commodities = Array.from(commoditySet).sort();
const pivoted = Array.from(byTimestamp.values()).sort((a, b) => a.timestamp - b.timestamp);
return { pivoted, regions, commodities };
}
function filterMilestonesByRange(milestones: AIMilestone[], pivoted: PivotedRow[]): AIMilestone[] {
if (pivoted.length === 0) return [];
const minTs = pivoted[0]!.timestamp;
const maxTs = pivoted[pivoted.length - 1]!.timestamp;
return milestones.filter(m => {
const mTs = new Date(m.date).getTime();
return mTs >= minTs && mTs <= maxTs;
});
}
export function PriceChart({
initialPriceData,
initialCommodityData,
milestones,
initialTimeRange,
onTimeRangeChange,
}: PriceChartProps) {
const [pricesSerialized, setPricesSerialized] = useState<SuperJSONResult>(initialPriceData);
const [commoditiesSerialized, setCommoditiesSerialized] = useState<SuperJSONResult>(initialCommodityData);
const [timeRange, setTimeRange] = useState<TimeRange>(initialTimeRange);
const [loading, setLoading] = useState(false);
const [disabledRegions, setDisabledRegions] = useState<Set<string>>(new Set());
const [showCommodities, setShowCommodities] = useState(false);
const [showMilestones, setShowMilestones] = useState(true);
const priceRows = useMemo(() => deserialize<PriceTrendRow[]>(pricesSerialized), [pricesSerialized]);
const commodityRows = useMemo(() => deserialize<CommodityRow[]>(commoditiesSerialized), [commoditiesSerialized]);
const { pivoted, regions, commodities } = useMemo(
() => pivotData(priceRows, commodityRows, showCommodities),
[priceRows, commodityRows, showCommodities],
);
const chartConfig = useMemo(
() => buildChartConfig(regions, commodities, showCommodities),
[regions, commodities, showCommodities],
);
const activeRegions = useMemo(() => regions.filter(r => !disabledRegions.has(r)), [regions, disabledRegions]);
const visibleMilestones = useMemo(
() => (showMilestones ? filterMilestonesByRange(milestones, pivoted) : []),
[milestones, pivoted, showMilestones],
);
async function handleTimeRangeChange(range: TimeRange) {
setTimeRange(range);
setLoading(true);
try {
const result = await onTimeRangeChange(range);
setPricesSerialized(result.prices);
setCommoditiesSerialized(result.commodities);
} finally {
setLoading(false);
}
}
function toggleRegion(region: string) {
setDisabledRegions(prev => {
const next = new Set(prev);
if (next.has(region)) {
next.delete(region);
} else {
next.add(region);
}
return next;
});
}
if (pivoted.length === 0 && !showCommodities) {
return (
<Card>
<CardHeader>
<CardTitle>Regional Electricity Prices</CardTitle>
<CardDescription>No price data available for the selected time range.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<TimeRangeSelector value={timeRange} onChange={handleTimeRangeChange} />
</div>
<div className="mt-8 flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-muted-foreground">
No data available. Try ingesting electricity price data first.
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Regional Electricity Prices</CardTitle>
<CardDescription>Price per MWh across ISO/RTO regions over time</CardDescription>
</div>
<TimeRangeSelector value={timeRange} onChange={handleTimeRangeChange} />
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-wrap items-center gap-2">
{regions.map(region => (
<button
key={region}
onClick={() => toggleRegion(region)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
disabledRegions.has(region)
? 'border-border bg-transparent text-muted-foreground'
: 'border-transparent text-foreground',
)}
style={
disabledRegions.has(region)
? undefined
: { backgroundColor: `${REGION_COLORS[region] ?? 'hsl(0,0%,50%)'}20` }
}>
<span
className="h-2 w-2 rounded-full"
style={{
backgroundColor: disabledRegions.has(region) ? 'hsl(var(--muted-foreground))' : REGION_COLORS[region],
}}
/>
{region}
</button>
))}
<div className="mx-2 h-4 w-px bg-border" />
<button
onClick={() => setShowCommodities(prev => !prev)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
showCommodities
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
: 'border-border text-muted-foreground',
)}>
Commodities
</button>
<button
onClick={() => setShowMilestones(prev => !prev)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors',
showMilestones
? 'border-purple-500/30 bg-purple-500/10 text-purple-300'
: 'border-border text-muted-foreground',
)}>
AI Milestones
</button>
</div>
<div className={cn('relative', loading && 'opacity-50 transition-opacity')}>
<ChartContainer config={chartConfig} className="h-[400px] w-full">
<LineChart data={pivoted} margin={{ top: 5, right: 60, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="timestampDisplay"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `$${value}`}
label={{
value: '$/MWh',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' },
}}
/>
{showCommodities && (
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 11 }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `$${value}`}
label={{
value: 'Commodity Price',
angle: 90,
position: 'insideRight',
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' },
}}
/>
)}
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={label => (typeof label === 'string' || typeof label === 'number' ? label : '')}
formatter={(value, name) => {
const numVal = Number(value);
const nameStr = String(name);
if (nameStr in COMMODITY_LABELS) {
return [`$${numVal.toFixed(2)}`, undefined];
}
return [`$${numVal.toFixed(2)}/MWh`, undefined];
}}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{activeRegions.map(region => (
<Line
key={region}
type="monotone"
dataKey={region}
yAxisId="left"
stroke={`var(--color-${region})`}
strokeWidth={2}
dot={false}
connectNulls
/>
))}
{showCommodities &&
commodities.map(commodity => (
<Line
key={commodity}
type="monotone"
dataKey={commodity}
yAxisId="right"
stroke={`var(--color-${commodity})`}
strokeWidth={1.5}
strokeDasharray="6 3"
dot={false}
connectNulls
/>
))}
{visibleMilestones.map(milestone => (
<ReferenceLine
key={milestone.date}
x={new Date(milestone.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
yAxisId="left"
stroke={MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)'}
strokeDasharray="3 3"
strokeWidth={1}
label={{
value: milestone.title,
position: 'top',
style: {
fontSize: 9,
fill: MILESTONE_COLORS[milestone.category] ?? 'hsl(280, 60%, 60%)',
},
}}
/>
))}
</LineChart>
</ChartContainer>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,38 @@
'use client';
import { cn } from '@/lib/utils.js';
export type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [
{ value: '24h', label: '24H' },
{ value: '7d', label: '1W' },
{ value: '30d', label: '1M' },
{ value: '90d', label: '3M' },
{ value: '1y', label: '1Y' },
];
interface TimeRangeSelectorProps {
value: TimeRange;
onChange: (range: TimeRange) => void;
}
export function TimeRangeSelector({ value, onChange }: TimeRangeSelectorProps) {
return (
<div className="inline-flex items-center gap-1 rounded-lg bg-muted p-1">
{TIME_RANGES.map(range => (
<button
key={range.value}
onClick={() => onChange(range.value)}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
value === range.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}>
{range.label}
</button>
))}
</div>
);
}

344
src/components/ui/chart.tsx Normal file
View File

@ -0,0 +1,344 @@
'use client';
import * as React from 'react';
import type { LegendPayload, TooltipPayloadEntry } from 'recharts';
import * as RechartsPrimitive from 'recharts';
import { cn } from '@/lib/utils.js';
type VerticalAlignmentType = 'top' | 'bottom' | 'middle';
type ValueType = number | string | ReadonlyArray<number | string>;
type NameType = number | string;
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const themeKey = theme === 'light' || theme === 'dark' ? theme : undefined;
const color = (itemConfig.theme && themeKey ? itemConfig.theme[themeKey] : undefined) || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function cssVarsToStyle(vars: Record<string, string | undefined>): React.CSSProperties {
const style: Record<string, string | undefined> = {};
for (const [key, val] of Object.entries(vars)) {
style[key] = val;
}
return style;
}
function dataKeyToString(dataKey: unknown): string {
if (typeof dataKey === 'string' || typeof dataKey === 'number') {
return String(dataKey);
}
return 'value';
}
function getPayloadFill(item: TooltipPayloadEntry<ValueType, NameType>): string | undefined {
const p: unknown = item.payload;
if (typeof p === 'object' && p !== null && 'fill' in p) {
const fill = (p as { fill: unknown }).fill;
return typeof fill === 'string' ? fill : undefined;
}
return undefined;
}
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: {
active?: boolean;
payload?: ReadonlyArray<TooltipPayloadEntry<ValueType, NameType>>;
label?: string | number;
labelFormatter?: (
label: React.ReactNode,
payload: ReadonlyArray<TooltipPayloadEntry<ValueType, NameType>>,
) => React.ReactNode;
formatter?: (
value: ValueType,
name: NameType,
item: TooltipPayloadEntry<ValueType, NameType>,
index: number,
payload: ReadonlyArray<TooltipPayloadEntry<ValueType, NameType>>,
) => React.ReactNode;
} & React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
labelClassName?: string;
color?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = labelKey || dataKeyToString(item?.dataKey) || String(item?.name) || 'value';
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = !labelKey && typeof label === 'string' ? config[label]?.label || label : itemConfig?.label;
if (labelFormatter) {
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
className={cn(
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
className,
)}>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter(item => item.type !== 'none')
.map((item, index) => {
const key = nameKey || String(item.name) || dataKeyToString(item.dataKey) || 'value';
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || getPayloadFill(item) || item.color;
return (
<div
key={dataKeyToString(item.dataKey)}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn('shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', {
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
})}
style={cssVarsToStyle({
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
})}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> & {
hideIcon?: boolean;
nameKey?: string;
payload?: ReadonlyArray<LegendPayload>;
verticalAlign?: VerticalAlignmentType;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)}>
{payload
.filter(item => item.type !== 'none')
.map(item => {
const key = nameKey || dataKeyToString(item.dataKey) || 'value';
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn('flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground')}>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
function getRecordValue(obj: unknown, key: string): unknown {
if (typeof obj === 'object' && obj !== null && key in obj) {
return Object.getOwnPropertyDescriptor(obj, key)?.value;
}
return undefined;
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
const payloadValue = getRecordValue(payload, key);
if (typeof payloadValue === 'string') {
configLabelKey = payloadValue;
} else {
const nestedValue = getRecordValue(payloadPayload, key);
if (typeof nestedValue === 'string') {
configLabelKey = nestedValue;
}
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export { ChartContainer, ChartLegend, ChartLegendContent, ChartStyle, ChartTooltip, ChartTooltipContent };

View File

@ -0,0 +1,156 @@
'use client';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { Select as SelectPrimitive } from 'radix-ui';
import * as React from 'react';
import { cn } from '@/lib/utils.js';
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}>
<span data-slot="select-item-indicator" className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};