From 224a9046fcafc0a610f2b82b37f5454db6351d73 Mon Sep 17 00:00:00 2001 From: Joey Eamigh <55670930+JoeyEamigh@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:29:47 -0500 Subject: [PATCH] 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 --- next.config.ts | 39 ++++++++++++++++++++++ src/actions/datacenters.ts | 17 ++++++++++ src/actions/demand.ts | 9 ++++++ src/actions/generation.ts | 5 +++ src/actions/prices.ts | 41 ++++++++++++++++++++++++ src/app/page.tsx | 14 +++++--- src/components/dashboard/metric-card.tsx | 8 ++--- 7 files changed, 124 insertions(+), 9 deletions(-) diff --git a/next.config.ts b/next.config.ts index afc1378..1782ddc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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; diff --git a/src/actions/datacenters.ts b/src/actions/datacenters.ts index 629f9d2..11dee74 100644 --- a/src/actions/datacenters.ts +++ b/src/actions/datacenters.ts @@ -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 { 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> { + '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> { + '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> { + 'use cache'; + cacheLife('seedData'); + cacheTag('datacenters-nearby'); + try { const rows = await prisma.$queryRawTyped(findNearbyDatacenters(lat, lng, radiusKm)); return { ok: true, data: serialize(rows) }; diff --git a/src/actions/demand.ts b/src/actions/demand.ts index 10a07e7..2e85c8c 100644 --- a/src/actions/demand.ts +++ b/src/actions/demand.ts @@ -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> { + '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> { + 'use cache'; + cacheLife('demand'); + cacheTag('demand-summary'); + try { const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const endDate = new Date(); diff --git a/src/actions/generation.ts b/src/actions/generation.ts index 66f9d7a..65faf5e 100644 --- a/src/actions/generation.ts +++ b/src/actions/generation.ts @@ -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> { + 'use cache'; + cacheLife('demand'); + cacheTag(`generation-${regionCode}-${timeRange}`); + try { if (!validateRegionCode(regionCode)) { return { ok: false, error: `Invalid region code: ${regionCode}` }; diff --git a/src/actions/prices.ts b/src/actions/prices.ts index 871c3ef..ca62fb0 100644 --- a/src/actions/prices.ts +++ b/src/actions/prices.ts @@ -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 = ActionSuccess | ActionError; export async function fetchLatestPrices(): Promise> { + '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> { + '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> { + '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> { + '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' }, diff --git a/src/app/page.tsx b/src/app/page.tsx index da34301..8721547 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -145,7 +145,7 @@ export default async function DashboardHome() { numericValue={avgPrice > 0 ? avgPrice : undefined} animatedFormat={avgPrice > 0 ? 'dollar' : undefined} unit="/MWh" - icon={BarChart3} + icon={} 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={} + /> + } /> - } /> } /> diff --git a/src/components/dashboard/metric-card.tsx b/src/components/dashboard/metric-card.tsx index 01727b9..4996ecf 100644 --- a/src/components/dashboard/metric-card.tsx +++ b/src/components/dashboard/metric-card.tsx @@ -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({ - + {icon} {title}