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:
parent
7a1bbca339
commit
224a9046fc
@ -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;
|
||||
|
||||
@ -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) };
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}` };
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user