- Add Next.js 16 "use cache" with cacheLife profiles: seedData (1h), prices (5min), demand (5min), commodities (30min), ticker (1min), alerts (2min) - Add cacheTag for parameterized server actions - Fix MetricCard icon prop: pass rendered JSX instead of component references across the server-client boundary
436 lines
12 KiB
TypeScript
436 lines
12 KiB
TypeScript
'use server';
|
|
|
|
import { getLatestPrices, getPriceTrends, getRegionPriceHeatmap } 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';
|
|
|
|
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
|
|
|
|
function timeRangeToStartDate(range: TimeRange): Date {
|
|
const now = new Date();
|
|
const ms: Record<TimeRange, number> = {
|
|
'24h': 24 * 60 * 60 * 1000,
|
|
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
'30d': 30 * 24 * 60 * 60 * 1000,
|
|
'90d': 90 * 24 * 60 * 60 * 1000,
|
|
'1y': 365 * 24 * 60 * 60 * 1000,
|
|
};
|
|
return new Date(now.getTime() - ms[range]);
|
|
}
|
|
|
|
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 fetchLatestPrices(): Promise<ActionResult<getLatestPrices.Result[]>> {
|
|
'use cache';
|
|
cacheLife('prices');
|
|
cacheTag('latest-prices');
|
|
|
|
try {
|
|
const rows = await prisma.$queryRawTyped(getLatestPrices());
|
|
return { ok: true, data: serialize(rows) };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: `Failed to fetch latest prices: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function fetchPriceTrends(
|
|
regionCode: string,
|
|
timeRange: TimeRange = '30d',
|
|
): Promise<ActionResult<getPriceTrends.Result[]>> {
|
|
'use cache';
|
|
cacheLife('prices');
|
|
cacheTag(`price-trends-${regionCode}-${timeRange}`);
|
|
|
|
try {
|
|
if (!validateRegionCode(regionCode)) {
|
|
return { ok: false, error: `Invalid region code: ${regionCode}` };
|
|
}
|
|
const startDate = timeRangeToStartDate(timeRange);
|
|
const endDate = new Date();
|
|
const rows = await prisma.$queryRawTyped(getPriceTrends(regionCode, startDate, endDate));
|
|
return { ok: true, data: serialize(rows) };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: `Failed to fetch price trends: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function fetchPriceHeatmapData(): Promise<ActionResult<getRegionPriceHeatmap.Result[]>> {
|
|
'use cache';
|
|
cacheLife('prices');
|
|
cacheTag('price-heatmap');
|
|
|
|
try {
|
|
const rows = await prisma.$queryRawTyped(getRegionPriceHeatmap());
|
|
return { ok: true, data: serialize(rows) };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: `Failed to fetch price heatmap data: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function fetchAllRegionPriceTrends(
|
|
timeRange: TimeRange = '30d',
|
|
): Promise<ActionResult<getPriceTrends.Result[]>> {
|
|
'use cache';
|
|
cacheLife('prices');
|
|
cacheTag(`all-price-trends-${timeRange}`);
|
|
|
|
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;
|
|
}>
|
|
>
|
|
> {
|
|
'use cache';
|
|
cacheLife('commodities');
|
|
cacheTag(`commodity-trends-${timeRange}`);
|
|
|
|
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;
|
|
}>
|
|
>
|
|
> {
|
|
'use cache';
|
|
cacheLife('prices');
|
|
cacheTag('capacity-vs-price');
|
|
|
|
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 fetchPriceSparklines(): Promise<
|
|
ActionResult<
|
|
Array<{
|
|
region_code: string;
|
|
points: { value: number }[];
|
|
}>
|
|
>
|
|
> {
|
|
'use cache';
|
|
cacheLife('prices');
|
|
cacheTag('price-sparklines');
|
|
|
|
try {
|
|
const startDate = timeRangeToStartDate('7d');
|
|
const endDate = new Date();
|
|
const regions = await prisma.gridRegion.findMany({ select: { code: true } });
|
|
const results = await Promise.all(
|
|
regions.map(async r => {
|
|
const rows = await prisma.$queryRawTyped(getPriceTrends(r.code, startDate, endDate));
|
|
return {
|
|
region_code: r.code,
|
|
points: rows.map(row => ({ value: row.price_mwh })),
|
|
};
|
|
}),
|
|
);
|
|
return { ok: true, data: serialize(results) };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: `Failed to fetch price sparklines: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function fetchRecentAlerts(): Promise<
|
|
ActionResult<
|
|
Array<{
|
|
id: string;
|
|
type: 'price_spike' | 'demand_peak' | 'price_drop';
|
|
severity: 'critical' | 'warning' | 'info';
|
|
region_code: string;
|
|
region_name: string;
|
|
description: string;
|
|
value: number;
|
|
unit: string;
|
|
timestamp: Date;
|
|
}>
|
|
>
|
|
> {
|
|
'use cache';
|
|
cacheLife('alerts');
|
|
cacheTag('recent-alerts');
|
|
|
|
try {
|
|
const since = timeRangeToStartDate('7d');
|
|
const priceRows = await prisma.electricityPrice.findMany({
|
|
where: { timestamp: { gte: since } },
|
|
orderBy: { timestamp: 'desc' },
|
|
take: 500,
|
|
include: { region: { select: { code: true, name: true } } },
|
|
});
|
|
|
|
const alerts: Array<{
|
|
id: string;
|
|
type: 'price_spike' | 'demand_peak' | 'price_drop';
|
|
severity: 'critical' | 'warning' | 'info';
|
|
region_code: string;
|
|
region_name: string;
|
|
description: string;
|
|
value: number;
|
|
unit: string;
|
|
timestamp: Date;
|
|
}> = [];
|
|
|
|
// Detect price spikes (above $80/MWh) and demand peaks
|
|
const regionAvgs = new Map<string, { sum: number; count: number }>();
|
|
for (const row of priceRows) {
|
|
const entry = regionAvgs.get(row.region.code) ?? { sum: 0, count: 0 };
|
|
entry.sum += row.priceMwh;
|
|
entry.count += 1;
|
|
regionAvgs.set(row.region.code, entry);
|
|
}
|
|
|
|
for (const row of priceRows) {
|
|
const avg = regionAvgs.get(row.region.code);
|
|
const avgPrice = avg ? avg.sum / avg.count : 0;
|
|
|
|
if (row.priceMwh >= 100) {
|
|
alerts.push({
|
|
id: `spike-${row.id}`,
|
|
type: 'price_spike',
|
|
severity: 'critical',
|
|
region_code: row.region.code,
|
|
region_name: row.region.name,
|
|
description: `${row.region.code} electricity hit $${row.priceMwh.toFixed(2)}/MWh`,
|
|
value: row.priceMwh,
|
|
unit: '$/MWh',
|
|
timestamp: row.timestamp,
|
|
});
|
|
} else if (row.priceMwh >= 80) {
|
|
alerts.push({
|
|
id: `spike-${row.id}`,
|
|
type: 'price_spike',
|
|
severity: 'warning',
|
|
region_code: row.region.code,
|
|
region_name: row.region.name,
|
|
description: `${row.region.code} electricity at $${row.priceMwh.toFixed(2)}/MWh`,
|
|
value: row.priceMwh,
|
|
unit: '$/MWh',
|
|
timestamp: row.timestamp,
|
|
});
|
|
} else if (avgPrice > 0 && row.priceMwh < avgPrice * 0.7) {
|
|
alerts.push({
|
|
id: `drop-${row.id}`,
|
|
type: 'price_drop',
|
|
severity: 'info',
|
|
region_code: row.region.code,
|
|
region_name: row.region.name,
|
|
description: `${row.region.code} price dropped to $${row.priceMwh.toFixed(2)}/MWh (avg $${avgPrice.toFixed(2)})`,
|
|
value: row.priceMwh,
|
|
unit: '$/MWh',
|
|
timestamp: row.timestamp,
|
|
});
|
|
}
|
|
|
|
if (row.demandMw >= 50000) {
|
|
alerts.push({
|
|
id: `demand-${row.id}`,
|
|
type: 'demand_peak',
|
|
severity: row.demandMw >= 70000 ? 'critical' : 'warning',
|
|
region_code: row.region.code,
|
|
region_name: row.region.name,
|
|
description: `${row.region.code} demand peaked at ${(row.demandMw / 1000).toFixed(1)} GW`,
|
|
value: row.demandMw,
|
|
unit: 'MW',
|
|
timestamp: row.timestamp,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by timestamp desc and limit
|
|
alerts.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
const limited = alerts.slice(0, 50);
|
|
|
|
return { ok: true, data: serialize(limited) };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: `Failed to fetch recent alerts: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
export interface TickerPriceRow {
|
|
region_code: string;
|
|
price_mwh: number;
|
|
prev_price_mwh: number | null;
|
|
}
|
|
|
|
export interface TickerCommodityRow {
|
|
commodity: string;
|
|
price: number;
|
|
prev_price: number | null;
|
|
unit: string;
|
|
}
|
|
|
|
export async function fetchTickerPrices(): Promise<
|
|
ActionResult<{ electricity: TickerPriceRow[]; commodities: TickerCommodityRow[] }>
|
|
> {
|
|
'use cache';
|
|
cacheLife('ticker');
|
|
cacheTag('ticker-prices');
|
|
|
|
try {
|
|
// Get the two most recent prices per region using a window function via raw SQL
|
|
const electricityRows = await prisma.$queryRaw<
|
|
Array<{ region_code: string; price_mwh: number; prev_price_mwh: number | null }>
|
|
>`
|
|
SELECT region_code, price_mwh, prev_price_mwh
|
|
FROM (
|
|
SELECT
|
|
r.code AS region_code,
|
|
ep.price_mwh,
|
|
LAG(ep.price_mwh) OVER (PARTITION BY ep.region_id ORDER BY ep.timestamp ASC) AS prev_price_mwh,
|
|
ROW_NUMBER() OVER (PARTITION BY ep.region_id ORDER BY ep.timestamp DESC) AS rn
|
|
FROM electricity_prices ep
|
|
JOIN grid_regions r ON ep.region_id = r.id
|
|
) sub
|
|
WHERE rn = 1
|
|
`;
|
|
|
|
// Get the two most recent commodity prices per commodity
|
|
const commodityRows = await prisma.$queryRaw<
|
|
Array<{ commodity: string; price: number; prev_price: number | null; unit: string }>
|
|
>`
|
|
SELECT commodity, price, prev_price, unit
|
|
FROM (
|
|
SELECT
|
|
commodity,
|
|
price,
|
|
unit,
|
|
LAG(price) OVER (PARTITION BY commodity ORDER BY timestamp ASC) AS prev_price,
|
|
ROW_NUMBER() OVER (PARTITION BY commodity ORDER BY timestamp DESC) AS rn
|
|
FROM commodity_prices
|
|
) sub
|
|
WHERE rn = 1
|
|
`;
|
|
|
|
return {
|
|
ok: true,
|
|
data: serialize({ electricity: electricityRows, commodities: commodityRows }),
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: `Failed to fetch ticker prices: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function fetchLatestCommodityPrices(): Promise<
|
|
ActionResult<
|
|
Array<{
|
|
commodity: string;
|
|
price: number;
|
|
unit: string;
|
|
timestamp: Date;
|
|
source: string;
|
|
}>
|
|
>
|
|
> {
|
|
'use cache';
|
|
cacheLife('commodities');
|
|
cacheTag('latest-commodities');
|
|
|
|
try {
|
|
const rows = await prisma.commodityPrice.findMany({
|
|
orderBy: { timestamp: 'desc' },
|
|
distinct: ['commodity'],
|
|
});
|
|
return { ok: true, data: serialize(rows) };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: `Failed to fetch commodity prices: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
}
|