fix: add "use cache" directives, fix server-client icon serialization

- 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
This commit is contained in:
Joey Eamigh 2026-02-11 13:29:47 -05:00
parent 7a1bbca339
commit 224a9046fc
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
7 changed files with 124 additions and 9 deletions

View File

@ -2,6 +2,45 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
typedRoutes: true,
cacheComponents: true,
cacheLife: {
// Seed data (datacenters, regions) — rarely changes
seedData: {
stale: 3600, // 1 hour
revalidate: 7200, // 2 hours
expire: 86400, // 1 day
},
// Electricity prices — update frequently
prices: {
stale: 300, // 5 minutes
revalidate: 1800, // 30 minutes
expire: 3600, // 1 hour
},
// Demand / generation data — moderate frequency
demand: {
stale: 300, // 5 minutes
revalidate: 1800, // 30 minutes
expire: 3600, // 1 hour
},
// Commodity prices — update less frequently
commodities: {
stale: 1800, // 30 minutes
revalidate: 21600, // 6 hours
expire: 86400, // 1 day
},
// Ticker tape — very short cache for near-real-time feel
ticker: {
stale: 60, // 1 minute
revalidate: 300, // 5 minutes
expire: 600, // 10 minutes
},
// Alerts — short cache
alerts: {
stale: 120, // 2 minutes
revalidate: 600, // 10 minutes
expire: 1800, // 30 minutes
},
},
};
export default nextConfig;

View File

@ -7,6 +7,7 @@ import {
} from '@/generated/prisma/sql.js';
import { prisma } from '@/lib/db.js';
import { serialize } from '@/lib/superjson.js';
import { cacheLife, cacheTag } from 'next/cache';
interface ActionSuccess<T> {
ok: true;
@ -34,6 +35,10 @@ export async function fetchDatacenters(): Promise<
}>
>
> {
'use cache';
cacheLife('seedData');
cacheTag('datacenters');
try {
const rows = await prisma.datacenter.findMany({
orderBy: { capacityMw: 'desc' },
@ -50,6 +55,10 @@ export async function fetchDatacenters(): Promise<
export async function fetchDatacentersInRegion(
regionCode: string,
): Promise<ActionResult<findDatacentersInRegion.Result[]>> {
'use cache';
cacheLife('seedData');
cacheTag(`datacenters-region-${regionCode}`);
try {
const rows = await prisma.$queryRawTyped(findDatacentersInRegion(regionCode));
return { ok: true, data: serialize(rows) };
@ -62,6 +71,10 @@ export async function fetchDatacentersInRegion(
}
export async function fetchAllDatacentersWithLocation(): Promise<ActionResult<getAllDatacentersWithLocation.Result[]>> {
'use cache';
cacheLife('seedData');
cacheTag('datacenters-locations');
try {
const rows = await prisma.$queryRawTyped(getAllDatacentersWithLocation());
return { ok: true, data: serialize(rows) };
@ -78,6 +91,10 @@ export async function fetchNearbyDatacenters(
lng: number,
radiusKm: number,
): Promise<ActionResult<findNearbyDatacenters.Result[]>> {
'use cache';
cacheLife('seedData');
cacheTag('datacenters-nearby');
try {
const rows = await prisma.$queryRawTyped(findNearbyDatacenters(lat, lng, radiusKm));
return { ok: true, data: serialize(rows) };

View File

@ -4,6 +4,7 @@ import { getDemandByRegion } 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';
@ -35,6 +36,10 @@ export async function fetchDemandByRegion(
regionCode: string,
timeRange: TimeRange = '30d',
): Promise<ActionResult<getDemandByRegion.Result[]>> {
'use cache';
cacheLife('demand');
cacheTag(`demand-${regionCode}-${timeRange}`);
try {
if (!validateRegionCode(regionCode)) {
return { ok: false, error: `Invalid region code: ${regionCode}` };
@ -52,6 +57,10 @@ export async function fetchDemandByRegion(
}
export async function fetchRegionDemandSummary(): Promise<ActionResult<getDemandByRegion.Result[]>> {
'use cache';
cacheLife('demand');
cacheTag('demand-summary');
try {
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const endDate = new Date();

View File

@ -4,6 +4,7 @@ import { getGenerationMix } 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';
@ -35,6 +36,10 @@ export async function fetchGenerationMix(
regionCode: string,
timeRange: TimeRange = '30d',
): Promise<ActionResult<getGenerationMix.Result[]>> {
'use cache';
cacheLife('demand');
cacheTag(`generation-${regionCode}-${timeRange}`);
try {
if (!validateRegionCode(regionCode)) {
return { ok: false, error: `Invalid region code: ${regionCode}` };

View File

@ -4,6 +4,7 @@ import { getLatestPrices, getPriceTrends, getRegionPriceHeatmap } from '@/genera
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';
@ -32,6 +33,10 @@ interface ActionError {
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) };
@ -47,6 +52,10 @@ 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}` };
@ -64,6 +73,10 @@ export async function fetchPriceTrends(
}
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) };
@ -78,6 +91,10 @@ export async function fetchPriceHeatmapData(): Promise<ActionResult<getRegionPri
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();
@ -105,6 +122,10 @@ export async function fetchCommodityTrends(timeRange: TimeRange = '30d'): Promis
}>
>
> {
'use cache';
cacheLife('commodities');
cacheTag(`commodity-trends-${timeRange}`);
try {
const startDate = timeRangeToStartDate(timeRange);
const rows = await prisma.commodityPrice.findMany({
@ -129,6 +150,10 @@ export async function fetchRegionCapacityVsPrice(): Promise<
}>
>
> {
'use cache';
cacheLife('prices');
cacheTag('capacity-vs-price');
try {
const regions = await prisma.gridRegion.findMany({
select: {
@ -168,6 +193,10 @@ export async function fetchPriceSparklines(): Promise<
}>
>
> {
'use cache';
cacheLife('prices');
cacheTag('price-sparklines');
try {
const startDate = timeRangeToStartDate('7d');
const endDate = new Date();
@ -205,6 +234,10 @@ export async function fetchRecentAlerts(): Promise<
}>
>
> {
'use cache';
cacheLife('alerts');
cacheTag('recent-alerts');
try {
const since = timeRangeToStartDate('7d');
const priceRows = await prisma.electricityPrice.findMany({
@ -321,6 +354,10 @@ export interface TickerCommodityRow {
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<
@ -379,6 +416,10 @@ export async function fetchLatestCommodityPrices(): Promise<
}>
>
> {
'use cache';
cacheLife('commodities');
cacheTag('latest-commodities');
try {
const rows = await prisma.commodityPrice.findMany({
orderBy: { timestamp: 'desc' },

View File

@ -145,7 +145,7 @@ export default async function DashboardHome() {
numericValue={avgPrice > 0 ? avgPrice : undefined}
animatedFormat={avgPrice > 0 ? 'dollar' : undefined}
unit="/MWh"
icon={BarChart3}
icon={<BarChart3 className="h-4 w-4" />}
sparklineData={avgSparkline}
sparklineColor="hsl(210, 90%, 55%)"
/>
@ -155,16 +155,20 @@ export default async function DashboardHome() {
numericValue={totalCapacityMw > 0 ? totalCapacityMw : undefined}
animatedFormat={totalCapacityMw > 0 ? 'compact' : undefined}
unit="MW"
icon={Activity}
icon={<Activity className="h-4 w-4" />}
/>
<MetricCard
title="Datacenters Tracked"
value={datacenterCount.toLocaleString()}
icon={<Server className="h-4 w-4" />}
/>
<MetricCard title="Datacenters Tracked" value={datacenterCount.toLocaleString()} icon={Server} />
<MetricCard
title="Natural Gas Spot"
value={natGas ? `$${natGas.price.toFixed(2)}` : '--'}
numericValue={natGas?.price}
animatedFormat={natGas ? 'dollar' : undefined}
unit={natGas?.unit ?? '/MMBtu'}
icon={Flame}
icon={<Flame className="h-4 w-4" />}
/>
<MetricCard
title="WTI Crude Oil"
@ -172,7 +176,7 @@ export default async function DashboardHome() {
numericValue={wtiCrude?.price}
animatedFormat={wtiCrude ? 'dollar' : undefined}
unit={wtiCrude?.unit ?? '/bbl'}
icon={Droplets}
icon={<Droplets className="h-4 w-4" />}
/>
</div>

View File

@ -4,7 +4,7 @@ import { Sparkline } from '@/components/charts/sparkline.js';
import { AnimatedNumber } from '@/components/dashboard/animated-number.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { cn } from '@/lib/utils.js';
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { useCallback } from 'react';
type AnimatedFormat = 'dollar' | 'compact' | 'integer';
@ -27,7 +27,7 @@ interface MetricCardProps {
/** Named format preset for the animated value. */
animatedFormat?: AnimatedFormat;
unit?: string;
icon: LucideIcon;
icon: ReactNode;
className?: string;
sparklineData?: { value: number }[];
sparklineColor?: string;
@ -39,7 +39,7 @@ export function MetricCard({
numericValue,
animatedFormat,
unit,
icon: Icon,
icon,
className,
sparklineData,
sparklineColor,
@ -53,7 +53,7 @@ export function MetricCard({
<Card className={cn('gap-0 py-4', className)}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Icon className="h-4 w-4" />
{icon}
{title}
</CardTitle>
</CardHeader>