Compare commits

...

10 Commits

Author SHA1 Message Date
Joey Eamigh
dd75b2fdce
add docker; update readme 2026-02-11 22:44:04 -05:00
Joey Eamigh
79850a61be
cleanup 2026-02-11 22:03:19 -05:00
Joey Eamigh
d8478ace96
fixups and such 2026-02-11 21:35:03 -05:00
Joey Eamigh
ad1a6792f5
phase 8: UI/UX overhaul — layout, charts, map, data freshness
- Fix ticker tape CLS with skeleton loader and fixed height
- Add Inter font, max-width container, responsive dvh units
- Hero metrics: trend deltas, per-metric sparkline colors, 3+2 grid
- GPU calculator: step=100 slider + text input, PUE factor, region comparison bars
- Grid stress: replace misleading arc gauges with demand status bars
- Demand summary: expand to 4-metric highlights grid
- Charts: responsive heights, ISO/non-ISO toggle, correlation R² + trend line
- Map: US-wide default view, marker clustering, enriched region panels, zoom controls
- Fix NYISO polygon (NYC), MISO polygon (Michigan), MISO south (MS/LA)
- Add automated ingestion via instrumentation.ts
- Add data freshness indicator in footer
- Fix backfill start date to 2019-01-01 (EIA RTO data availability)
2026-02-11 19:59:01 -05:00
Joey Eamigh
9e83cfead3
fix: add DUKE, SOCO, TVA centroids for floating price labels 2026-02-11 16:22:46 -05:00
Joey Eamigh
8f99f6535e
phase 7: full US coverage — grid regions, datacenters, power plants, backfill, chart perf
- Add 7 new grid regions (BPA, DUKE, SOCO, TVA, FPC, WAPA, NWMT) to cover entire continental US
- Expand datacenters from 108 to 292 facilities across 39 operators
- Add EIA power plant pipeline: download script, 3,546 plants >= 50 MW with diamond map markers
- Rewrite backfill script for 10-year data (2015-07-01) with quarterly/monthly chunking, 3-region parallelism, resumability
- Add materialized views (daily/weekly) with server-side granularity selection for chart performance
- Fix map UX: z-index tooltips, disable POI clicks, move legend via MapControl
2026-02-11 16:08:06 -05:00
Joey Eamigh
3251e30a2e
fix: use direct hex color for correlation chart axis labels
CSS variable hsl(var(--muted-foreground)) doesn't resolve in SVG
context. Use #a1a1aa (zinc-400) directly for reliable visibility
on dark background.
2026-02-11 14:48:26 -05:00
Joey Eamigh
564d212148
phase 6: post-review enhancements — data, map UX, charts, navigation
Data:
- Expand datacenter inventory from 38 to 108 facilities
- Add 14 North Carolina datacenters (Apple, Google, AWS, Microsoft, etc.)
- Add missing operators (Cloudflare, Switch, Vantage, Apple, Aligned, Iron Mountain)
- Fill geographic gaps (Southeast, Northwest, Midwest)

Map UX:
- Dark mode via ColorScheme.DARK, hide all default UI controls
- Center on Chapel Hill, NC (lat 35.91, lng -79.06) at zoom 6
- New map legend component (price gradient, marker scale, pulse/glow key)
- Floating region price labels via AdvancedMarker at region centroids
- Tune breathing animation: 8s period, only stressed regions, 5 FPS
- Enhanced markers: capacity labels on 200+ MW, status-based styling

Charts:
- Fix generation chart timestamp duplication (use epoch ms dataKey)
- Fix correlation chart black-on-black axis labels
- Context-aware tick formatting (time-only for 24h, date for longer ranges)

GPU Calculator:
- Default to B200 (1,000W), add R200 Rubin (1,800W)

Navigation:
- Granular Suspense boundaries on all 5 pages
- Extract data-fetching into async Server Components per section
- Page shells render instantly, sections stream in independently
2026-02-11 14:44:46 -05:00
Joey Eamigh
deb1cdc527
spec: add Phase 6 post-review enhancements
Data coverage expansion (80-120 DCs, power plants, SE grid regions,
2yr backfill), map UX overhaul (dark mode, legend, floating labels,
Chapel Hill center), chart fixes, GPU calculator update, granular
Suspense boundaries for navigation performance.
2026-02-11 14:37:43 -05:00
Joey Eamigh
3edb69848d
fix: region overlay visibility, ambient glow breathing, pulsing threshold
- Fix fillColor/fillOpacity conflict: return separate RGB string and
  opacity from priceToColor() instead of rgba with embedded alpha
- Implement ambient region glow via setInterval + overrideStyle with
  sine-wave opacity oscillation (faster/brighter for higher prices)
- Lower pulsing marker threshold from 10% to 3% for demand-varied prices
2026-02-11 13:36:21 -05:00
90 changed files with 84552 additions and 1199 deletions

View File

@ -1,5 +1,6 @@
{
"enabledPlugins": {
"ralph-loop@claude-plugins-official": true
"ralph-loop@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": false
}
}

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
node_modules
.next
.git
.gitignore
.env
.env.*
pgdata
*.md
docker-compose.yml
.prettierrc.js
eslint.config.js

View File

@ -2,14 +2,6 @@
This project builds a real-time interactive dashboard showing how AI datacenter buildout is driving regional electricity demand and energy prices across the US. Read `SPEC.md` in full before starting any work — it contains the business case, technical architecture, database schema, and implementation phases. Understanding *why* this dashboard exists (not just *what* it does) will make your code better.
## Rules (NON-NEGOTIABLE)
- You will NOT create a github repository, push code, or deploy ANYTHING.
- You will NOT publish the code anywhere.
- You will NOT make this code public in any way.
This is the local development portion only. No deployment. No public repos. No exceptions.
## Read the Spec
Before writing a single line of code, read `SPEC.md` cover to cover. Every agent on this project — orchestrator, builder, reviewer, tester — should understand:

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
FROM node:22 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Prisma client + TypedSQL must be pre-generated locally (needs live DB for --sql).
# src/generated/ is gitignored but included in Docker context from local dev.
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
RUN npx next build
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

211
README.md
View File

@ -1,15 +1,214 @@
# bonus4
# Energy & AI: The Datacenter Power Crunch
To install dependencies:
A real-time interactive dashboard that visualizes how AI datacenter buildout is driving regional electricity demand and energy prices across the United States.
**Live URL**: [https://energy.busi488.claiborne.soy](https://energy.busi488.claiborne.soy)
## Purpose & Functionality
AI datacenters are reshaping US energy markets. Training a single frontier model can consume as much electricity as a small town uses in a year, and every major tech company is building GPU clusters at unprecedented scale. This dashboard makes that impact visible.
The dashboard pulls real-time electricity pricing, demand, generation mix, and commodity data from the EIA and FRED APIs, overlays it on a map of US grid regions with 100+ datacenter locations, and lets users explore the relationship between AI infrastructure buildout and energy market dynamics.
**Key features:**
- **Interactive map** with datacenter locations, grid region overlays, and power plant positions (Google Maps with AdvancedMarker)
- **Real-time price ticker** showing current electricity prices across all major ISO/RTO regions
- **Price, demand, and generation charts** with hourly/daily/weekly granularity and time range selection
- **Datacenter impact analysis** showing price correlation in regions with heavy AI buildout vs. those without
- **GPU cost calculator** that translates electricity prices into per-GPU-hour operating costs
- **Grid stress gauges** showing how close regions are to capacity limits
- **Automated data ingestion** that refreshes every 30 minutes from government APIs
## Target Audience
**Energy investors** evaluating utility stocks and commodity positions who need to see where datacenter load is concentrating and how it's affecting regional prices. Today they stitch together spreadsheets from a dozen sources; this dashboard gives them a single view.
**Utility analysts** planning generation and transmission investments who need to understand where demand growth is coming from and which regions are approaching capacity constraints.
**Datacenter site selectors** choosing where to build next who need real-time visibility into regional electricity prices, grid capacity, and existing datacenter density.
**Business strategists** assessing the infrastructure costs underlying AI who need to understand the energy economics behind GPU compute.
These users currently rely on static reports, scattered government data portals, and expensive terminal subscriptions. This dashboard provides a free, unified, real-time view of the AI-energy nexus.
## Sales Pitch & Monetization
This dashboard generates value for anyone making infrastructure or investment decisions at the intersection of AI and energy. The data it unifies is publicly available but scattered across dozens of sources with inconsistent formats and no geospatial context. The value is in the integration.
**Monetization model:** Freemium SaaS.
- **Free tier**: Full dashboard with real-time data, map, charts, and the GPU cost calculator
- **Premium tier** ($49/mo): Predictive analytics (price forecasting), custom region comparisons, CSV/API data export, email alerting for price spike events
- **Enterprise tier** (custom): Embeddable widgets for trading desks and analyst reports, white-label options, dedicated support
The addressable market includes energy trading desks, utility planning departments, datacenter REITs, and infrastructure-focused hedge funds, all of whom currently spend significant analyst time on exactly this kind of cross-referencing.
## Tech Stack
| Layer | Technology | Why |
| ------------- | ----------------------------------- | ----------------------------------------------------------------------------- |
| Framework | Next.js 16 (App Router, Turbopack) | Server Components, `"use cache"` directive, Partial Prerendering |
| Styling | Tailwind CSS 4 + shadcn/ui | Dark theme, consistent component library with built-in chart wrappers |
| Maps | @vis.gl/react-google-maps | Google's official React library with AdvancedMarker support |
| Database | PostgreSQL 18 + PostGIS 3.6 | Geospatial queries (ST_DWithin, spatial joins) for datacenter/region analysis |
| ORM | Prisma 7 with TypedSQL | Type-safe CRUD + typed wrappers for raw PostGIS SQL queries |
| Charts | Recharts via shadcn/ui | Consistent theming, responsive, works with Server Components |
| Serialization | superjson | Preserves Date, BigInt, Map, Set across server-client boundary |
| Runtime | Bun (dev) / Node.js 22 (production) | Bun for fast local dev; Node.js for stable production runtime |
| Validation | Zod | Every external API response is schema-validated before ingestion |
| Container | Docker (standalone Next.js) | Multi-stage build, ~440MB production image |
## Data Sources
| Source | Data | Update Frequency |
| ---------------------------------------- | -------------------------------------------------------------------------- | ------------------------ |
| [EIA API](https://www.eia.gov/opendata/) | Regional electricity prices, demand, generation mix | Hourly |
| [FRED API](https://fred.stlouisfed.org/) | Natural gas (Henry Hub), WTI crude, coal spot prices | Daily |
| Curated seed data | 100+ datacenter locations, grid region boundaries (GeoJSON), AI milestones | Static (seeded at setup) |
Data ingestion runs automatically every 30 minutes via the Next.js instrumentation hook.
## Installation & Setup
### Prerequisites
- [Bun](https://bun.sh/) (latest)
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- API keys for: [EIA](https://www.eia.gov/opendata/register.php), [FRED](https://fred.stlouisfed.org/docs/api/api_key.html), [Google Maps](https://console.cloud.google.com/)
### 1. Clone and install dependencies
```bash
git clone https://git.claiborne.soy/joey/busi488-energy-dashboard.git
cd busi488-energy-dashboard
bun install
```
To run:
### 2. Set up environment variables
```bash
bun run
Create a `.env` file in the project root:
```env
# Google Maps
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="your-google-maps-api-key"
NEXT_PUBLIC_GOOGLE_MAP_ID="your-map-id"
GOOGLE_MAPS_STYLE_NAME="your-style-name"
GOOGLE_MAPS_STYLE_ID="your-style-id"
# Data APIs
EIA_API_KEY="your-eia-api-key"
FRED_API_KEY="your-fred-api-key"
# Database
DATABASE_URL="postgresql://energy:energydash2026@localhost:5433/energy_dashboard"
POSTGRES_PASSWORD="energydash2026"
# Ingestion auth
INGEST_SECRET="any-random-hex-string"
```
This project was created using `bun init` in bun v1.3.8. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
### 3. Start the database
```bash
docker compose up -d
```
This starts PostgreSQL 18 with PostGIS 3.6 on port 5433.
### 4. Run migrations and seed data
```bash
bunx prisma migrate deploy
bunx prisma db seed
```
This creates the schema (grid regions, datacenters, price tables) and loads seed data (datacenter locations, grid region boundaries, AI milestones).
### 5. Generate Prisma client and TypedSQL
```bash
bunx prisma generate --sql
```
This generates typed TypeScript wrappers for both the Prisma ORM client and all PostGIS SQL queries in `prisma/sql/`.
### 6. Start the development server
```bash
bun run dev
```
The dashboard is now running at [http://localhost:3000](http://localhost:3000). Data ingestion starts automatically after 10 seconds.
## Docker Deployment
The app is containerized as a standalone Next.js image for production deployment.
### Build and push
```bash
# Prisma TypedSQL must be generated locally first (requires live database)
bunx prisma generate --sql
# Build the production image
docker build -t registry.claiborne.soy/busi488energy:latest .
# Push to the internal registry
docker push registry.claiborne.soy/busi488energy:latest
```
### Database migration to production
```bash
# Dump the local database (schema + data)
docker compose exec db pg_dump -U energy -Fc energy_dashboard > energy_dashboard.dump
# Port-forward to the production PostGIS instance
kubectl port-forward -n database svc/postgis 5434:5432 &
# Restore to production
pg_restore -h localhost -p 5434 -U busi488energy -d busi488energy \
--no-owner --no-privileges energy_dashboard.dump
```
### Infrastructure
The production deployment runs on Kubernetes (k3s) with Terraform-managed infrastructure:
- **PostGIS 18** in the `database` namespace (`postgis.database.svc.cluster.local:5432`)
- **Next.js standalone** in the `random` namespace, served behind Traefik ingress with automatic TLS via cert-manager
- **Keel** watches the internal registry and auto-deploys new image pushes
## Project Structure
```
bonus4/
├── prisma/
│ ├── schema.prisma # Database schema (PostGIS models)
│ ├── migrations/ # SQL migration files
│ ├── seed.ts # Seed data loader
│ └── sql/ # TypedSQL queries (PostGIS spatial joins, aggregations)
├── src/
│ ├── app/ # Next.js App Router pages
│ │ ├── page.tsx # Dashboard home (ticker, metrics, charts)
│ │ ├── map/ # Interactive map with datacenters + grid regions
│ │ ├── trends/ # Price correlation and DC impact analysis
│ │ ├── demand/ # Regional demand charts
│ │ ├── generation/ # Generation mix breakdown
│ │ └── api/ingest/ # Data ingestion endpoints (EIA, FRED)
│ ├── actions/ # Server Actions (typed server-to-client boundary)
│ ├── components/
│ │ ├── charts/ # Price, demand, generation, correlation charts
│ │ ├── map/ # Google Maps with datacenter markers + controls
│ │ ├── dashboard/ # Ticker tape, GPU calculator, grid stress gauges
│ │ └── layout/ # Navigation, footer, data freshness indicator
│ └── lib/
│ ├── api/ # EIA and FRED API clients with Zod schemas
│ ├── schemas/ # Zod validation schemas for external data
│ ├── db.ts # Prisma client singleton
│ └── superjson.ts # Serialization utilities
├── docker-compose.yml # Local PostGIS database
├── Dockerfile # Multi-stage production build
└── next.config.ts # Next.js config (standalone output, cache policies)
```

112
SPEC.md
View File

@ -458,3 +458,115 @@ The Reviewer and Tester agents enforce this on every task. This is the real test
- [ ] Loading states (`loading.tsx`) + error boundaries (`error.tsx`)
- [ ] Disclaimer footer (educational/informational purposes, not financial advice)
- [ ] README with installation and setup docs
## Phase 6: Post-Review Enhancements
Based on comprehensive review and user feedback, the following enhancements address data coverage gaps, map UX improvements, chart fixes, and navigation performance.
### 6.1 Data Coverage Expansion
#### Datacenter Inventory (Target: 80-120 facilities)
The current 38 datacenters are insufficient. Expand to comprehensive US coverage:
- **North Carolina** (CRITICAL — this is a UNC Chapel Hill project): Add 10+ facilities including Apple Maiden, Google Lenoir, Microsoft Catawba County, AWS Richmond County ($10B campus), Digital Realty Charlotte, Compass Durham, Compass Statesville, T5 Kings Mountain, DartPoints Asheville, American Tower Raleigh.
- **Missing hyperscaler campuses**: Meta has 20+ US campuses (we have 5), Google has 23 (we have 5), AWS has hundreds (we have 5), Microsoft has 34+ (we have 6). Add at least the major 500+ MW campuses.
- **Missing operators**: Apple (5 US campuses), Cloudflare (37+ US edge locations), Switch (Las Vegas, Reno), Vantage (NoVA, Santa Clara), Compass, Stack Infrastructure, EdgeCore, T5.
- **Geographic gaps**: Fill Southeast (SC, TN, FL, KY, AL), Northwest (WA, ID), and Midwest (NE, WI, MN).
- **Sources**: Meta datacenter page (atmeta.com), Google datacenter page, DataCenterMap.com, Baxtel.com, press releases for 2024-2025 announcements, IM3 Open Source Data Center Atlas (DOE/PNNL).
#### Power Plant Data (NEW)
Add US power plants to the map from the EIA US Energy Atlas:
- **Source**: https://atlas.eia.gov/datasets/eia::power-plants/about — GeoJSON download
- **Filter**: Plants >= 50 MW nameplate capacity
- **Fields**: name, operator, fuel type, capacity MW, latitude, longitude, state
- **Schema**: New `power_plants` table in Prisma schema with `location` geography column
- **Map display**: Smaller markers (distinct from datacenters) colored by fuel type, with a different shape (e.g., diamond or triangle)
- **Seed**: Download, filter, and load into `data/power-plants.geojson`, add to seed script
#### Southeast Grid Regions (NEW)
The Southeast US has no ISO/RTO — it's served by vertically integrated utilities. Add these as grid regions:
- **DUKE** (Duke Energy Carolinas + Progress): NC/SC focus, EIA respondent codes DUK and CPLE
- **SOCO** (Southern Company): GA/AL, EIA respondent code SOCO
- **TVA** (Tennessee Valley Authority): TN and surrounding states, EIA respondent code TVA
- Requires approximate boundary polygons in `data/grid-regions.geojson`
- Add state-to-region mappings for retail price data
- Add to backfill respondent code list
#### Historical Data Depth
Extend the backfill window from 6 months to 2 years:
- EIA hourly demand data is available back to July 2015
- EIA monthly retail prices back to January 2001
- FRED commodity prices back to the 1990s
- 2 years captures the full AI boom narrative (GPT-3 June 2020, ChatGPT Nov 2022, GPT-4 March 2023)
- For chart performance, pre-aggregate to daily for data older than 3 months
### 6.2 Map UX Overhaul
#### Default Center and Zoom
- Center on Chapel Hill, NC: `{ lat: 35.9132, lng: -79.0558 }`
- Zoom level 6 (shows NC, VA, SC, GA, TN — the most data-dense region)
#### Dark Mode (2 prop changes)
- Add `colorScheme={ColorScheme.DARK}` to the `<Map>` component (Google's built-in dark theme)
- Set `disableDefaultUI={true}` to hide streetview, fullscreen, map type controls (copyright stays)
- Import `ColorScheme` from `@vis.gl/react-google-maps`
#### Map Legend (NEW component)
Create `src/components/map/map-legend.tsx`:
- Position: absolute bottom-right of map container
- Price color gradient bar: blue → cyan → yellow → red with tick labels ($0, $20, $50, $100+/MWh)
- Marker size scale: 3-4 circles with MW labels (50, 200, 500 MW)
- Pulsing indicator explanation
- Grid stress glow explanation
- Style: zinc-900/90 bg, backdrop-blur, matching existing map controls panel
#### Floating Region Price Labels (NEW)
Render `AdvancedMarker` at each region's centroid showing:
- Region code (PJM, ERCOT, etc.)
- Current average price ($XX.XX/MWh)
- Small colored border matching heatmap color
- This is the single highest-impact change for map readability
#### Breathing Animation Tuning
- Slow the breathing period from 0.5-1.25s to 6-8s (0.125 Hz)
- Only breathe regions where demand/capacity > 85% (stressed) — calm regions stay static
- Reduce animation interval from 50ms to 200ms (5 FPS)
- Reduce amplitude to very subtle (+/- 0.03 to 0.07)
#### Enhanced Datacenter Markers
- Show capacity (MW) label inside markers >= 200 MW
- Different visual treatment by status: operational (solid), under construction (dashed border), planned (hollow ring)
- Lower pulsing threshold already applied (3%)
### 6.3 Chart Fixes
#### Generation Chart Timestamp Fix
- Change X-axis `dataKey` from `dateLabel` (time-only, duplicates across days) to `timestamp` (unique epoch ms)
- Add context-aware `tickFormatter`: 24h shows "3 PM", 7d/30d shows "Jan 15 3PM", 90d/1y shows "Jan 15"
- Update tooltip `labelFormatter` to include full date + time
#### Correlation Chart Dark Theme Labels
- Add `fill: 'hsl(var(--muted-foreground))'` to XAxis and YAxis `tick` props
- Currently defaults to `#666666` which is invisible on dark background
### 6.4 GPU Calculator Update
#### Default GPU Model
- Change default from H100 SXM to B200 (1,000W TDP)
- B200 is the current-gen datacenter GPU most customers are deploying
#### NVIDIA R200 (Rubin) — Add if specs confirmed
- Announced at GTC 2025: 1,800W TDP
- Recent reports suggest revised to 2,300W TDP
- Add as "R200 (Rubin)" at 1,800W (official announced spec)
- 288 GB HBM4, NVLink 6, 2H2026 availability
### 6.5 Navigation Performance
#### Granular Suspense Boundaries
Replace full-page loading skeletons with per-section Suspense boundaries:
- Extract each data-fetching section into its own async Server Component
- Wrap each in `<Suspense fallback={<SectionSkeleton />}>`
- Page shell (headers, layout, tabs) renders instantly
- Individual sections stream in as data resolves
- Apply to all 5 pages: dashboard, map, trends, demand, generation

19
SUMMARY.md Normal file
View File

@ -0,0 +1,19 @@
# Energy & AI Dashboard — One-Page Summary
## Purpose & Functionality
The Energy & AI Dashboard is a real-time interactive tool that visualizes how the US datacenter buildout is reshaping regional electricity markets. It ingests live data from the EIA (Energy Information Administration) and FRED (Federal Reserve Economic Data) APIs, overlays 100+ datacenter locations and 5,000+ power plants onto an interactive Google Maps interface, and presents electricity prices, demand, and generation mix across 10 US grid regions. Users can explore price trends annotated with AI milestones (GPT-3, ChatGPT, GPT-4), monitor grid stress gauges that flag regions approaching capacity limits, and use a GPU Cost Calculator to compare the real-time cost of running GPU clusters across regions. A scrolling price ticker, animated metrics, pulsing map markers on price spikes, and auto-refreshing data (60-second intervals) keep the dashboard feeling live and urgent. Five core pages — Dashboard, Map, Trends, Demand, and Generation — provide layered analysis from high-level national overview down to region-specific breakdowns with correlation charts linking datacenter concentration to electricity price increases.
## Target Audience
**Primary users:** Energy investors evaluating utility stocks in AI-heavy regions, datacenter site selection teams comparing regional electricity costs for new facilities, and utility analysts planning generation and transmission capacity investments.
**How they use it:** An energy investor opens the dashboard to see that PJM (Virginia — the densest datacenter corridor in the US) electricity prices are spiking relative to the 30-day average. The map's pulsing markers confirm elevated activity. They switch to the Trends page and see the price trajectory alongside natural gas spot prices. The correlation chart confirms: regions with more datacenter capacity consistently show higher electricity prices. The GPU Cost Calculator tells them it currently costs 40% more to run 1,000 B200 GPUs in NYISO than in ERCOT. This is actionable intelligence for portfolio positioning, infrastructure planning, or site selection — synthesized in seconds instead of hours of spreadsheet work.
**Why over alternatives:** No existing tool connects datacenter infrastructure data with live energy market data in a single geospatial view. The EIA publishes raw data tables. ISOs publish regional dashboards that cover only their own territory. Datacenter trackers (Baxtel, DatacenterHawk) don't show energy prices. This dashboard is the first to unify all three — infrastructure, energy prices, and grid capacity — into one real-time interface with the AI narrative baked in.
## Sales Pitch
**Value generation:** The dashboard generates value for three groups. (1) **Energy investors and traders** gain an information edge by seeing how datacenter demand growth correlates with regional price movements before it shows up in quarterly reports. (2) **Datacenter operators and hyperscalers** (AWS, Google, Meta, Microsoft) can optimize site selection by comparing real-time electricity costs, grid stress, and generation mix across regions — a decision that determines hundreds of millions in operating costs over a facility's lifetime. (3) **Utilities and grid operators** gain visibility into where datacenter load is concentrating and which regions are approaching capacity constraints, informing capital expenditure planning for new generation and transmission infrastructure.
**Monetization:** A tiered SaaS model fits naturally. A free tier provides the public dashboard with 24-hour data and basic map views — a lead generation tool and industry reference. A professional tier ($200500/month) unlocks full historical data (2+ years), custom alerts on price spikes and grid stress events, exportable reports, and API access for integration into existing energy trading or site selection workflows. An enterprise tier provides custom region analysis, private datacenter portfolio overlays, and dedicated support for utility planning teams and hyperscaler real estate divisions. The GPU Cost Calculator alone — answering "where is it cheapest to run AI infrastructure right now?" — is a feature datacenter operators would pay for daily.

File diff suppressed because it is too large Load Diff

View File

@ -50,7 +50,6 @@
[
[-74.72, 40.15],
[-74.01, 40.07],
[-73.89, 40.57],
[-74.25, 40.53],
[-75.14, 40.68],
[-75.12, 41.85],
@ -177,14 +176,14 @@
[-73.34, 45.01],
[-73.34, 42.05],
[-73.73, 41.10],
[-74.25, 40.53],
[-73.89, 40.57],
[-74.01, 40.07],
[-73.74, 40.63],
[-72.76, 40.75],
[-71.85, 40.98],
[-73.73, 41.10],
[-73.34, 42.05],
[-72.76, 40.75],
[-73.74, 40.63],
[-74.01, 40.07],
[-74.25, 40.53],
[-75.14, 40.68],
[-75.12, 41.85],
[-76.11, 42.00],
[-79.76, 42.27]
]
]
@ -244,11 +243,16 @@
[-95.15, 48.00],
[-89.49, 48.01],
[-84.72, 46.63],
[-83.59, 46.03],
[-84.11, 45.18],
[-85.61, 44.77],
[-86.46, 43.89],
[-86.27, 42.40],
[-84.10, 46.55],
[-83.40, 46.03],
[-83.80, 45.65],
[-83.40, 45.05],
[-83.30, 44.32],
[-82.80, 43.60],
[-82.42, 43.00],
[-82.48, 42.33],
[-83.50, 41.73],
[-84.82, 41.76],
[-86.80, 41.76],
[-87.53, 41.76],
[-87.53, 38.23],
@ -274,17 +278,20 @@
[
[-89.10, 36.95],
[-89.70, 36.25],
[-89.67, 34.96],
[-90.31, 34.73],
[-90.58, 34.14],
[-91.15, 33.01],
[-91.17, 31.55],
[-91.65, 31.00],
[-93.53, 31.18],
[-93.72, 31.08],
[-88.20, 35.00],
[-88.20, 34.50],
[-88.35, 33.29],
[-88.47, 31.90],
[-88.40, 30.23],
[-89.10, 30.10],
[-89.60, 29.90],
[-90.10, 29.60],
[-91.00, 29.30],
[-91.80, 29.50],
[-93.20, 29.60],
[-93.84, 29.71],
[-93.84, 30.25],
[-94.04, 31.00],
[-93.72, 31.08],
[-93.53, 31.18],
[-94.04, 33.55],
[-94.48, 33.64],
[-94.43, 35.39],
@ -340,6 +347,245 @@
"code": "SPP",
"iso": "SPP"
}
},
{
"type": "Feature",
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
[
[-124.73, 49.00],
[-116.05, 49.00],
[-116.05, 46.00],
[-117.04, 44.30],
[-117.04, 42.00],
[-120.00, 42.00],
[-124.41, 42.00],
[-124.56, 42.80],
[-124.07, 44.60],
[-123.94, 46.18],
[-124.10, 46.86],
[-124.73, 48.40],
[-124.73, 49.00]
]
]
]
},
"properties": {
"name": "Bonneville Power Administration",
"code": "BPA",
"iso": "BPA"
}
},
{
"type": "Feature",
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
[
[-116.05, 49.00],
[-104.05, 49.00],
[-104.05, 45.94],
[-104.05, 45.00],
[-111.05, 45.00],
[-116.05, 46.00],
[-116.05, 49.00]
]
]
]
},
"properties": {
"name": "NorthWestern Energy Montana",
"code": "NWMT",
"iso": "NWMT"
}
},
{
"type": "Feature",
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
[
[-120.00, 42.00],
[-117.04, 42.00],
[-117.04, 44.30],
[-116.05, 46.00],
[-111.05, 45.00],
[-104.05, 45.00],
[-104.05, 43.00],
[-104.05, 41.00],
[-104.05, 38.00],
[-103.00, 37.00],
[-103.00, 36.50],
[-100.00, 34.56],
[-103.04, 32.00],
[-106.65, 31.75],
[-109.05, 31.33],
[-111.07, 31.33],
[-114.63, 32.72],
[-114.63, 34.87],
[-116.09, 35.98],
[-117.63, 37.43],
[-120.00, 39.00],
[-120.00, 42.00]
]
]
]
},
"properties": {
"name": "Western Area Power Administration",
"code": "WAPA",
"iso": "WAPA"
}
},
{
"type": "Feature",
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
[
[-88.07, 37.50],
[-87.69, 37.79],
[-87.10, 37.79],
[-86.52, 36.64],
[-85.98, 36.63],
[-84.86, 36.63],
[-84.22, 36.60],
[-82.30, 36.60],
[-81.65, 36.60],
[-81.65, 35.17],
[-82.78, 35.07],
[-84.32, 35.00],
[-85.61, 34.98],
[-88.20, 35.00],
[-89.70, 36.25],
[-89.10, 36.95],
[-88.47, 37.07],
[-88.07, 37.50]
]
]
]
},
"properties": {
"name": "Tennessee Valley Authority",
"code": "TVA",
"iso": "TVA"
}
},
{
"type": "Feature",
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
[
[-84.32, 35.00],
[-82.78, 35.07],
[-81.65, 35.17],
[-81.65, 36.60],
[-82.30, 36.60],
[-84.22, 36.60],
[-84.86, 36.63],
[-84.43, 38.45],
[-83.65, 38.63],
[-82.60, 38.17],
[-81.95, 37.54],
[-81.23, 37.27],
[-80.52, 37.48],
[-80.30, 37.10],
[-79.51, 36.54],
[-78.45, 35.69],
[-77.75, 36.00],
[-75.87, 36.55],
[-75.87, 35.19],
[-76.52, 34.62],
[-77.68, 33.95],
[-78.90, 33.65],
[-79.45, 33.16],
[-80.85, 32.11],
[-81.15, 32.11],
[-82.25, 33.31],
[-83.35, 34.49],
[-84.32, 35.00]
]
]
]
},
"properties": {
"name": "Duke Energy Carolinas",
"code": "DUKE",
"iso": "DUKE"
}
},
{
"type": "Feature",
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
[
[-88.20, 35.00],
[-85.61, 34.98],
[-84.32, 35.00],
[-83.35, 34.49],
[-82.25, 33.31],
[-81.15, 32.11],
[-81.15, 31.00],
[-84.86, 30.70],
[-87.60, 30.25],
[-88.40, 30.23],
[-88.47, 31.90],
[-88.35, 33.29],
[-88.20, 34.50],
[-88.20, 35.00]
]
]
]
},
"properties": {
"name": "Southern Company",
"code": "SOCO",
"iso": "SOCO"
}
},
{
"type": "Feature",
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
[
[-87.60, 30.25],
[-84.86, 30.70],
[-81.15, 31.00],
[-81.15, 32.11],
[-80.85, 32.11],
[-80.45, 31.62],
[-81.26, 30.75],
[-81.52, 29.49],
[-80.52, 28.00],
[-80.22, 26.30],
[-80.84, 25.15],
[-81.81, 24.55],
[-82.63, 27.52],
[-82.85, 27.83],
[-84.34, 29.96],
[-85.39, 29.68],
[-86.52, 30.38],
[-87.60, 30.25]
]
]
]
},
"properties": {
"name": "Florida Power",
"code": "FPC",
"iso": "FPC"
}
}
]
}

74471
data/power-plants.geojson Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
typedRoutes: true,
cacheComponents: true,
cacheLife: {

View File

@ -0,0 +1,66 @@
-- Daily aggregation of electricity prices + demand
CREATE MATERIALIZED VIEW electricity_prices_daily AS
SELECT
region_id,
date_trunc('day', timestamp) AS day,
AVG(price_mwh) AS avg_price,
MAX(price_mwh) AS max_price,
MIN(price_mwh) AS min_price,
AVG(demand_mw) AS avg_demand,
MAX(demand_mw) AS peak_demand
FROM electricity_prices
GROUP BY region_id, date_trunc('day', timestamp);
CREATE UNIQUE INDEX electricity_prices_daily_region_day
ON electricity_prices_daily (region_id, day);
-- Weekly aggregation of electricity prices + demand
CREATE MATERIALIZED VIEW electricity_prices_weekly AS
SELECT
region_id,
date_trunc('week', timestamp) AS week,
AVG(price_mwh) AS avg_price,
MAX(price_mwh) AS max_price,
MIN(price_mwh) AS min_price,
AVG(demand_mw) AS avg_demand,
MAX(demand_mw) AS peak_demand
FROM electricity_prices
GROUP BY region_id, date_trunc('week', timestamp);
CREATE UNIQUE INDEX electricity_prices_weekly_region_week
ON electricity_prices_weekly (region_id, week);
-- Daily aggregation of generation mix
CREATE MATERIALIZED VIEW generation_mix_daily AS
SELECT
region_id,
fuel_type,
date_trunc('day', timestamp) AS day,
AVG(generation_mw) AS avg_generation,
MAX(generation_mw) AS peak_generation
FROM generation_mix
GROUP BY region_id, fuel_type, date_trunc('day', timestamp);
CREATE UNIQUE INDEX generation_mix_daily_region_fuel_day
ON generation_mix_daily (region_id, fuel_type, day);
-- Weekly aggregation of generation mix
CREATE MATERIALIZED VIEW generation_mix_weekly AS
SELECT
region_id,
fuel_type,
date_trunc('week', timestamp) AS week,
AVG(generation_mw) AS avg_generation,
MAX(generation_mw) AS peak_generation
FROM generation_mix
GROUP BY region_id, fuel_type, date_trunc('week', timestamp);
CREATE UNIQUE INDEX generation_mix_weekly_region_fuel_week
ON generation_mix_weekly (region_id, fuel_type, week);
-- BRIN index for time-series range scans on large tables
CREATE INDEX electricity_prices_timestamp_brin
ON electricity_prices USING brin (timestamp);
CREATE INDEX generation_mix_timestamp_brin
ON generation_mix USING brin (timestamp);

View File

@ -76,3 +76,17 @@ model GenerationMix {
@@index([regionId, timestamp])
@@map("generation_mix")
}
model PowerPlant {
id String @id @default(uuid()) @db.Uuid
plantCode Int @unique @map("plant_code")
name String
operator String
location Unsupported("geography(Point, 4326)")
capacityMw Float @map("capacity_mw")
fuelType String @map("fuel_type")
state String
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@map("power_plants")
}

View File

@ -55,6 +55,26 @@ const DatacenterCollectionSchema = z.object({
features: z.array(DatacenterFeatureSchema),
});
const PowerPlantPropertiesSchema = z.object({
Plant_Name: z.string(),
Plant_Code: z.number(),
Utility_Na: z.string(),
State: z.string(),
PrimSource: z.string(),
Total_MW: z.number(),
});
const PowerPlantFeatureSchema = z.object({
type: z.literal('Feature'),
geometry: PointGeometrySchema,
properties: PowerPlantPropertiesSchema,
});
const PowerPlantCollectionSchema = z.object({
type: z.literal('FeatureCollection'),
features: z.array(PowerPlantFeatureSchema),
});
const AIMilestoneSchema = z.object({
date: z.string(),
title: z.string(),
@ -176,6 +196,69 @@ async function seedDatacenters() {
console.log(` Total: ${inserted.toString()} inserted, ${skipped.toString()} skipped`);
}
/** Normalize ArcGIS PrimSource strings to consistent capitalized fuel types. */
function normalizeFuelType(primSource: string): string {
const lower = primSource.toLowerCase().trim();
if (lower === 'natural gas') return 'Natural Gas';
if (lower === 'coal') return 'Coal';
if (lower === 'nuclear') return 'Nuclear';
if (lower.includes('hydro') || lower === 'hydroelectric conventional' || lower === 'pumped storage')
return 'Hydroelectric';
if (lower === 'wind') return 'Wind';
if (lower === 'solar') return 'Solar';
if (lower.includes('petroleum') || lower === 'petroleum') return 'Petroleum';
if (lower.includes('biomass') || lower === 'wood' || lower === 'wood and wood derived fuels') return 'Biomass';
if (lower === 'geothermal') return 'Geothermal';
return 'Other';
}
async function seedPowerPlants() {
console.log('Seeding power plants...');
const geojson = readAndParse('data/power-plants.geojson', PowerPlantCollectionSchema);
let upserted = 0;
let skipped = 0;
for (const feature of geojson.features) {
const props = feature.properties;
const [lng, lat] = feature.geometry.coordinates;
// Skip features with invalid coordinates
if (!lng || !lat || lat === 0 || lng === 0) {
skipped++;
continue;
}
const id = randomUUID();
const fuelType = normalizeFuelType(props.PrimSource);
await prisma.$executeRawUnsafe(
`INSERT INTO power_plants (id, plant_code, name, operator, location, capacity_mw, fuel_type, state, created_at)
VALUES ($1::uuid, $2, $3, $4, ST_SetSRID(ST_MakePoint($5, $6), 4326)::geography, $7, $8, $9, NOW())
ON CONFLICT (plant_code) DO UPDATE SET
name = EXCLUDED.name,
operator = EXCLUDED.operator,
location = EXCLUDED.location,
capacity_mw = EXCLUDED.capacity_mw,
fuel_type = EXCLUDED.fuel_type,
state = EXCLUDED.state`,
id,
props.Plant_Code,
props.Plant_Name,
props.Utility_Na,
lng,
lat,
props.Total_MW,
fuelType,
props.State,
);
upserted++;
}
console.log(` Total: ${upserted.toString()} upserted, ${skipped.toString()} skipped`);
}
function validateAIMilestones() {
console.log('Validating AI milestones...');
const milestones = readAndParse('data/ai-milestones.json', z.array(AIMilestoneSchema));
@ -198,6 +281,9 @@ async function main() {
await seedDatacenters();
console.log('');
await seedPowerPlants();
console.log('');
validateAIMilestones();
// Print summary
@ -206,8 +292,10 @@ async function main() {
'SELECT count(*) as count FROM grid_regions',
);
const dcCount = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>('SELECT count(*) as count FROM datacenters');
const ppCount = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>('SELECT count(*) as count FROM power_plants');
console.log(`Grid regions: ${regionCount[0]!.count.toString()}`);
console.log(`Datacenters: ${dcCount[0]!.count.toString()}`);
console.log(`Power plants: ${ppCount[0]!.count.toString()}`);
// Show sample spatial data
const sample = await prisma.$queryRawUnsafe<Array<{ name: string; location_text: string }>>(

View File

@ -0,0 +1,4 @@
SELECT id, plant_code, name, operator, capacity_mw, fuel_type, state,
ST_AsGeoJSON(location)::TEXT as location_geojson
FROM power_plants
ORDER BY capacity_mw DESC

View File

@ -0,0 +1,39 @@
-- @param {String} $1:regionCode
-- Monthly average electricity price and cumulative datacenter capacity for a region
WITH months AS (
SELECT generate_series(
'2019-01-01'::timestamptz,
date_trunc('month', now()),
'1 month'
) AS month
),
monthly_prices AS (
SELECT
date_trunc('month', ep.timestamp) AS month,
AVG(ep.price_mwh) AS avg_price
FROM electricity_prices ep
JOIN grid_regions r ON ep.region_id = r.id
WHERE r.code = $1
GROUP BY 1
),
dc_capacity AS (
SELECT
make_timestamptz(d.year_opened, 1, 1, 0, 0, 0) AS opened_month,
SUM(d.capacity_mw) AS added_mw
FROM datacenters d
JOIN grid_regions r ON d.region_id = r.id
WHERE r.code = $1
GROUP BY d.year_opened
)
SELECT
m.month,
mp.avg_price,
COALESCE(
SUM(dc.added_mw) OVER (ORDER BY m.month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),
0
)::float AS cumulative_capacity_mw
FROM months m
LEFT JOIN monthly_prices mp ON mp.month = m.month
LEFT JOIN dc_capacity dc ON dc.opened_month <= m.month
AND dc.opened_month > m.month - INTERVAL '1 month'
ORDER BY m.month ASC

View File

@ -0,0 +1,54 @@
-- Before/after price comparison for each datacenter opening event (year_opened >= 2019)
WITH dc_events AS (
SELECT
d.name AS dc_name,
d.capacity_mw,
d.year_opened,
r.code AS region_code,
r.name AS region_name,
make_timestamptz(d.year_opened, 1, 1, 0, 0, 0) AS event_date
FROM datacenters d
JOIN grid_regions r ON d.region_id = r.id
WHERE d.year_opened >= 2019
),
price_before AS (
SELECT
de.dc_name,
AVG(ep.price_mwh) AS avg_price_before
FROM dc_events de
JOIN grid_regions r ON r.code = de.region_code
JOIN electricity_prices ep ON ep.region_id = r.id
AND ep.timestamp >= de.event_date - INTERVAL '6 months'
AND ep.timestamp < de.event_date
GROUP BY de.dc_name
),
price_after AS (
SELECT
de.dc_name,
AVG(ep.price_mwh) AS avg_price_after
FROM dc_events de
JOIN grid_regions r ON r.code = de.region_code
JOIN electricity_prices ep ON ep.region_id = r.id
AND ep.timestamp >= de.event_date
AND ep.timestamp < de.event_date + INTERVAL '6 months'
GROUP BY de.dc_name
)
SELECT
de.dc_name,
de.capacity_mw,
de.year_opened,
de.region_code,
de.region_name,
pb.avg_price_before,
pa.avg_price_after,
CASE
WHEN pb.avg_price_before > 0
THEN ((pa.avg_price_after - pb.avg_price_before) / pb.avg_price_before * 100)
ELSE NULL
END AS pct_change
FROM dc_events de
LEFT JOIN price_before pb ON pb.dc_name = de.dc_name
LEFT JOIN price_after pa ON pa.dc_name = de.dc_name
WHERE pb.avg_price_before IS NOT NULL
AND pa.avg_price_after IS NOT NULL
ORDER BY ABS(COALESCE(pa.avg_price_after - pb.avg_price_before, 0)) DESC

View File

@ -0,0 +1,21 @@
-- @param {DateTime} $1:startDate
-- @param {DateTime} $2:endDate
-- @param {String} $3:regionCode - pass 'ALL' to return all regions
SELECT
r.code AS region_code,
r.name AS region_name,
d.day,
d.avg_demand,
d.peak_demand,
COALESCE(dc.datacenter_count, 0)::INT AS datacenter_count,
COALESCE(dc.total_dc_capacity_mw, 0) AS total_dc_capacity_mw
FROM electricity_prices_daily d
JOIN grid_regions r ON d.region_id = r.id
LEFT JOIN (
SELECT region_id, COUNT(*)::INT AS datacenter_count,
COALESCE(SUM(capacity_mw), 0) AS total_dc_capacity_mw
FROM datacenters GROUP BY region_id
) dc ON dc.region_id = r.id
WHERE d.day BETWEEN $1 AND $2
AND ($3 = 'ALL' OR r.code = $3)
ORDER BY r.code, d.day

View File

@ -0,0 +1,21 @@
-- @param {DateTime} $1:startDate
-- @param {DateTime} $2:endDate
-- @param {String} $3:regionCode - pass 'ALL' to return all regions
SELECT
r.code AS region_code,
r.name AS region_name,
ep.timestamp AS day,
ep.demand_mw AS avg_demand,
ep.demand_mw AS peak_demand,
COALESCE(dc.datacenter_count, 0)::INT AS datacenter_count,
COALESCE(dc.total_dc_capacity_mw, 0) AS total_dc_capacity_mw
FROM electricity_prices ep
JOIN grid_regions r ON ep.region_id = r.id
LEFT JOIN (
SELECT region_id, COUNT(*)::INT AS datacenter_count,
COALESCE(SUM(capacity_mw), 0) AS total_dc_capacity_mw
FROM datacenters GROUP BY region_id
) dc ON dc.region_id = r.id
WHERE ep.timestamp BETWEEN $1 AND $2
AND ($3 = 'ALL' OR r.code = $3)
ORDER BY r.code, ep.timestamp

View File

@ -0,0 +1,21 @@
-- @param {DateTime} $1:startDate
-- @param {DateTime} $2:endDate
-- @param {String} $3:regionCode - pass 'ALL' to return all regions
SELECT
r.code AS region_code,
r.name AS region_name,
w.week AS day,
w.avg_demand,
w.peak_demand,
COALESCE(dc.datacenter_count, 0)::INT AS datacenter_count,
COALESCE(dc.total_dc_capacity_mw, 0) AS total_dc_capacity_mw
FROM electricity_prices_weekly w
JOIN grid_regions r ON w.region_id = r.id
LEFT JOIN (
SELECT region_id, COUNT(*)::INT AS datacenter_count,
COALESCE(SUM(capacity_mw), 0) AS total_dc_capacity_mw
FROM datacenters GROUP BY region_id
) dc ON dc.region_id = r.id
WHERE w.week BETWEEN $1 AND $2
AND ($3 = 'ALL' OR r.code = $3)
ORDER BY r.code, w.week

View File

@ -0,0 +1,14 @@
-- @param {String} $1:regionCode
-- @param {DateTime} $2:startDate
-- @param {DateTime} $3:endDate
SELECT
gd.fuel_type,
gd.day AS timestamp,
gd.avg_generation AS generation_mw,
r.code AS region_code,
r.name AS region_name
FROM generation_mix_daily gd
JOIN grid_regions r ON gd.region_id = r.id
WHERE r.code = $1
AND gd.day BETWEEN $2 AND $3
ORDER BY gd.day ASC, gd.fuel_type

View File

@ -0,0 +1,11 @@
-- @param {String} $1:regionCode
-- @param {DateTime} $2:startDate
-- @param {DateTime} $3:endDate
SELECT
gm.fuel_type, gm.timestamp, gm.generation_mw,
r.code AS region_code, r.name AS region_name
FROM generation_mix gm
JOIN grid_regions r ON gm.region_id = r.id
WHERE r.code = $1
AND gm.timestamp BETWEEN $2 AND $3
ORDER BY gm.timestamp ASC, gm.fuel_type

View File

@ -0,0 +1,14 @@
-- @param {String} $1:regionCode
-- @param {DateTime} $2:startDate
-- @param {DateTime} $3:endDate
SELECT
gw.fuel_type,
gw.week AS timestamp,
gw.avg_generation AS generation_mw,
r.code AS region_code,
r.name AS region_name
FROM generation_mix_weekly gw
JOIN grid_regions r ON gw.region_id = r.id
WHERE r.code = $1
AND gw.week BETWEEN $2 AND $3
ORDER BY gw.week ASC, gw.fuel_type

View File

@ -1,6 +1,20 @@
SELECT DISTINCT ON (ep.region_id)
ep.id, ep.region_id, ep.price_mwh, ep.demand_mw, ep.timestamp, ep.source,
r.code as region_code, r.name as region_name
FROM electricity_prices ep
JOIN grid_regions r ON ep.region_id = r.id
ORDER BY ep.region_id, ep.timestamp DESC
SELECT
latest.id, latest.region_id, latest.price_mwh, latest.demand_mw,
latest.timestamp, latest.source,
r.code as region_code, r.name as region_name,
stats.avg_price_7d, stats.stddev_price_7d
FROM (
SELECT DISTINCT ON (ep.region_id)
ep.id, ep.region_id, ep.price_mwh, ep.demand_mw, ep.timestamp, ep.source
FROM electricity_prices ep
ORDER BY ep.region_id, ep.timestamp DESC
) latest
JOIN grid_regions r ON latest.region_id = r.id
LEFT JOIN LATERAL (
SELECT
AVG(ep2.price_mwh)::double precision as avg_price_7d,
STDDEV(ep2.price_mwh)::double precision as stddev_price_7d
FROM electricity_prices ep2
WHERE ep2.region_id = latest.region_id
AND ep2.timestamp >= NOW() - INTERVAL '7 days'
) stats ON true

View File

@ -0,0 +1,14 @@
-- @param {String} $1:regionCode
-- @param {DateTime} $2:startDate
-- @param {DateTime} $3:endDate
SELECT
d.day AS timestamp,
d.avg_price AS price_mwh,
d.avg_demand AS demand_mw,
r.code AS region_code,
r.name AS region_name
FROM electricity_prices_daily d
JOIN grid_regions r ON d.region_id = r.id
WHERE r.code = $1
AND d.day BETWEEN $2 AND $3
ORDER BY d.day ASC

View File

@ -0,0 +1,14 @@
-- @param {String} $1:regionCode
-- @param {DateTime} $2:startDate
-- @param {DateTime} $3:endDate
SELECT
ep.timestamp,
ep.price_mwh,
ep.demand_mw,
r.code AS region_code,
r.name AS region_name
FROM electricity_prices ep
JOIN grid_regions r ON ep.region_id = r.id
WHERE r.code = $1
AND ep.timestamp BETWEEN $2 AND $3
ORDER BY ep.timestamp ASC

View File

@ -0,0 +1,14 @@
-- @param {String} $1:regionCode
-- @param {DateTime} $2:startDate
-- @param {DateTime} $3:endDate
SELECT
w.week AS timestamp,
w.avg_price AS price_mwh,
w.avg_demand AS demand_mw,
r.code AS region_code,
r.name AS region_name
FROM electricity_prices_weekly w
JOIN grid_regions r ON w.region_id = r.id
WHERE r.code = $1
AND w.week BETWEEN $2 AND $3
ORDER BY w.week ASC

View File

@ -1,10 +1,15 @@
/**
* Historical data backfill script.
* Historical data backfill script (10-year).
*
* Populates 6 months of historical data from EIA and FRED into Postgres.
* Idempotent safe to re-run; existing records are updated, not duplicated.
* Populates historical data (from 2019-01-01) from EIA and FRED
* into Postgres. Uses time-chunked requests to stay under EIA's 5,000-row
* pagination limit, with concurrent region fetching and resumability.
*
* Idempotent safe to re-run; uses ON CONFLICT upserts.
*
* Usage: bun run scripts/backfill.ts
* bun run scripts/backfill.ts --skip-demand --skip-generation
* bun run scripts/backfill.ts --only-commodities
*/
import 'dotenv/config';
@ -13,24 +18,59 @@ import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../src/generated/prisma/client.js';
import * as eia from '../src/lib/api/eia.js';
import { getFuelTypeData, getRegionData, getRetailElectricityPrices } from '../src/lib/api/eia.js';
import { getRetailElectricityPrices } from '../src/lib/api/eia.js';
import * as fred from '../src/lib/api/fred.js';
import type { RegionCode } from '../src/lib/schemas/electricity.js';
import { type RegionCode } from '../src/lib/schemas/electricity.js';
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
const ALL_REGIONS: RegionCode[] = ['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP'];
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000;
/** EIA RTO hourly data begins 2019-01-01 for most ISOs */
const BACKFILL_START = '2019-01-01';
function sixMonthsAgoIso(): string {
return new Date(Date.now() - SIX_MONTHS_MS).toISOString().slice(0, 10);
}
const ALL_REGIONS: RegionCode[] = [
'PJM',
'ERCOT',
'CAISO',
'NYISO',
'ISONE',
'MISO',
'SPP',
'BPA',
'DUKE',
'SOCO',
'TVA',
'FPC',
'WAPA',
'NWMT',
];
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
/** Number of regions to fetch concurrently */
const CONCURRENCY = 3;
/** Minimum delay between sequential API requests (ms) */
const REQUEST_DELAY_MS = 200;
/** DB upsert batch size */
const UPSERT_BATCH_SIZE = 2000;
// ---------------------------------------------------------------------------
// CLI flags
// ---------------------------------------------------------------------------
const args = new Set(process.argv.slice(2));
const skipDemand = args.has('--skip-demand');
const skipGeneration = args.has('--skip-generation');
const skipCommodities = args.has('--skip-commodities');
const onlyCommodities = args.has('--only-commodities');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
@ -41,27 +81,253 @@ function log(msg: string): void {
console.log(`[${ts}] ${msg}`);
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
/** Generate quarterly date ranges: [start, end] pairs as YYYY-MM-DD strings */
function generateQuarterChunks(startDate: string, endDate: string): Array<[string, string]> {
const chunks: Array<[string, string]> = [];
const start = new Date(`${startDate}T00:00:00Z`);
const end = new Date(`${endDate}T00:00:00Z`);
const cursor = new Date(start);
while (cursor < end) {
const chunkStart = cursor.toISOString().slice(0, 10);
// Advance 3 months
cursor.setUTCMonth(cursor.getUTCMonth() + 3);
const chunkEnd = cursor < end ? cursor.toISOString().slice(0, 10) : endDate;
chunks.push([chunkStart, chunkEnd]);
}
return chunks;
}
/** Generate monthly date ranges: [start, end] pairs as YYYY-MM-DD strings */
function generateMonthChunks(startDate: string, endDate: string): Array<[string, string]> {
const chunks: Array<[string, string]> = [];
const start = new Date(`${startDate}T00:00:00Z`);
const end = new Date(`${endDate}T00:00:00Z`);
const cursor = new Date(start);
while (cursor < end) {
const chunkStart = cursor.toISOString().slice(0, 10);
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
const chunkEnd = cursor < end ? cursor.toISOString().slice(0, 10) : endDate;
chunks.push([chunkStart, chunkEnd]);
}
return chunks;
}
/** Format a quarter label like "Q3 2015" */
function quarterLabel(dateStr: string): string {
const d = new Date(`${dateStr}T00:00:00Z`);
const q = Math.floor(d.getUTCMonth() / 3) + 1;
return `Q${q} ${d.getUTCFullYear()}`;
}
/** Format a month label like "Jul 2015" */
function monthLabel(dateStr: string): string {
const d = new Date(`${dateStr}T00:00:00Z`);
return d.toLocaleString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
}
/** Run async tasks with limited concurrency */
async function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, limit: number): Promise<T[]> {
const results: T[] = [];
let index = 0;
async function worker(): Promise<void> {
while (index < tasks.length) {
const currentIndex = index++;
const task = tasks[currentIndex]!;
results[currentIndex] = await task();
}
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker());
await Promise.all(workers);
return results;
}
// ---------------------------------------------------------------------------
// Electricity demand backfill
// Progress tracker — stores completed region+chunk combos in memory
// ---------------------------------------------------------------------------
const completedChunks = new Set<string>();
function chunkKey(phase: string, region: string, chunkStart: string): string {
return `${phase}:${region}:${chunkStart}`;
}
function isChunkDone(phase: string, region: string, chunkStart: string): boolean {
return completedChunks.has(chunkKey(phase, region, chunkStart));
}
function markChunkDone(phase: string, region: string, chunkStart: string): void {
completedChunks.add(chunkKey(phase, region, chunkStart));
}
// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------
interface BackfillStats {
demandInserted: number;
demandUpdated: number;
demandErrors: number;
genInserted: number;
genUpdated: number;
genErrors: number;
commodityInserted: number;
commodityUpdated: number;
}
const stats: BackfillStats = {
demandInserted: 0,
demandUpdated: 0,
demandErrors: 0,
genInserted: 0,
genUpdated: 0,
genErrors: 0,
commodityInserted: 0,
commodityUpdated: 0,
};
// ---------------------------------------------------------------------------
// Electricity demand backfill — chunked by quarter
// ---------------------------------------------------------------------------
async function backfillDemandForRegion(
regionCode: RegionCode,
regionId: string,
chunks: Array<[string, string]>,
retailPrices: Map<string, number>,
latestPriceByRegion: Map<string, number>,
): Promise<void> {
for (let i = 0; i < chunks.length; i++) {
const [start, end] = chunks[i]!;
const label = `[DEMAND] ${regionCode}: ${quarterLabel(start)} (${i + 1}/${chunks.length})`;
if (isChunkDone('demand', regionCode, start)) {
continue;
}
try {
log(` ${label} — fetching...`);
const demandData = await eia.getRegionData(regionCode, 'D', { start, end });
const validPoints = demandData.filter((p): p is typeof p & { valueMw: number } => p.valueMw !== null);
if (validPoints.length === 0) {
log(` ${label} — 0 data points, skipping`);
markChunkDone('demand', regionCode, start);
await sleep(REQUEST_DELAY_MS);
continue;
}
// Compute peak demand for price variation within this chunk
const peakDemand = Math.max(...validPoints.map(p => p.valueMw));
// Build upsert rows
const rows = validPoints.map(point => {
const month = point.timestamp.toISOString().slice(0, 7);
const basePrice = retailPrices.get(`${regionCode}:${month}`) ?? latestPriceByRegion.get(regionCode) ?? 0;
const demandRatio = peakDemand > 0 ? point.valueMw / peakDemand : 0.5;
const priceMwh = basePrice > 0 ? basePrice * (0.8 + 0.4 * demandRatio) : 0;
return {
regionId,
priceMwh,
demandMw: point.valueMw,
timestamp: point.timestamp,
};
});
// Batch upsert using raw SQL for speed
let inserted = 0;
let updated = 0;
for (let j = 0; j < rows.length; j += UPSERT_BATCH_SIZE) {
const batch = rows.slice(j, j + UPSERT_BATCH_SIZE);
const result = await upsertDemandBatch(batch);
inserted += result.inserted;
updated += result.updated;
}
stats.demandInserted += inserted;
stats.demandUpdated += updated;
log(` ${label}${inserted} inserted, ${updated} updated (${validPoints.length} points)`);
markChunkDone('demand', regionCode, start);
} catch (err) {
stats.demandErrors++;
log(` ${label} — ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
await sleep(REQUEST_DELAY_MS);
}
}
interface UpsertResult {
inserted: number;
updated: number;
}
async function upsertDemandBatch(
rows: Array<{ regionId: string; priceMwh: number; demandMw: number; timestamp: Date }>,
): Promise<UpsertResult> {
if (rows.length === 0) return { inserted: 0, updated: 0 };
// Build VALUES clause with parameterized placeholders
const values: unknown[] = [];
const placeholders: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const offset = i * 4;
placeholders.push(
`(gen_random_uuid(), $${offset + 1}::uuid, $${offset + 2}, $${offset + 3}, $${offset + 4}::timestamptz, 'EIA')`,
);
values.push(row.regionId, row.priceMwh, row.demandMw, row.timestamp);
}
const sql = `
WITH upserted AS (
INSERT INTO electricity_prices (id, region_id, price_mwh, demand_mw, timestamp, source)
VALUES ${placeholders.join(',\n')}
ON CONFLICT (region_id, timestamp) DO UPDATE SET
price_mwh = EXCLUDED.price_mwh,
demand_mw = EXCLUDED.demand_mw,
source = EXCLUDED.source
RETURNING (xmax = 0) AS is_insert
)
SELECT
COUNT(*) FILTER (WHERE is_insert) AS inserted,
COUNT(*) FILTER (WHERE NOT is_insert) AS updated
FROM upserted
`;
const result = await prisma.$queryRawUnsafe<Array<{ inserted: bigint; updated: bigint }>>(sql, ...values);
const row = result[0]!;
return { inserted: Number(row.inserted), updated: Number(row.updated) };
}
async function backfillElectricity(): Promise<void> {
log('=== Backfilling electricity demand + price data ===');
log('=== Backfilling electricity demand + price data (10-year) ===');
const gridRegions = await prisma.gridRegion.findMany({
select: { id: true, code: true },
});
const regionIdByCode = new Map(gridRegions.map(r => [r.code, r.id]));
const start = sixMonthsAgoIso();
const end = todayIso();
const chunks = generateQuarterChunks(BACKFILL_START, end);
log(` ${chunks.length} quarterly chunks from ${BACKFILL_START} to ${end}`);
// Fetch monthly retail electricity prices for all regions upfront
// Key: "REGION:YYYY-MM" -> $/MWh
// Fetch retail prices upfront (one call covers all months + all states)
const retailPriceByRegionMonth = new Map<string, number>();
log(' Fetching retail electricity prices...');
try {
const startMonth = start.slice(0, 7); // YYYY-MM
const startMonth = BACKFILL_START.slice(0, 7);
const endMonth = end.slice(0, 7);
const retailPrices = await getRetailElectricityPrices({ start: startMonth, end: endMonth });
for (const rp of retailPrices) {
@ -72,208 +338,152 @@ async function backfillElectricity(): Promise<void> {
log(` ERROR fetching retail prices: ${err instanceof Error ? err.message : String(err)}`);
}
// Build a fallback: for each region, find the most recent month with data
// Build fallback: latest known price per region
const latestPriceByRegion = new Map<string, number>();
for (const [key, price] of retailPriceByRegionMonth) {
const region = key.split(':')[0]!;
const existing = latestPriceByRegion.get(region);
// Since keys are "REGION:YYYY-MM", the latest month lexicographically is the most recent
if (!existing || key > `${region}:${existing}`) {
latestPriceByRegion.set(region, price);
}
}
/** Look up price for a region+month, falling back to latest known price */
function getRetailPrice(region: string, month: string): number {
return retailPriceByRegionMonth.get(`${region}:${month}`) ?? latestPriceByRegion.get(region) ?? 0;
}
await sleep(REQUEST_DELAY_MS);
await sleep(200);
for (const regionCode of ALL_REGIONS) {
const regionId = regionIdByCode.get(regionCode);
if (!regionId) {
log(` SKIP ${regionCode} — no grid_region row found`);
continue;
}
log(` Fetching demand for ${regionCode}...`);
try {
const demandData = await getRegionData(regionCode, 'D', { start, end });
const validPoints = demandData.filter((p): p is typeof p & { valueMw: number } => p.valueMw !== null);
if (validPoints.length === 0) {
log(` ${regionCode}: 0 valid data points`);
continue;
// Build tasks for each region
const regionTasks = ALL_REGIONS.map(regionCode => {
return async () => {
const regionId = regionIdByCode.get(regionCode);
if (!regionId) {
log(` SKIP ${regionCode} — no grid_region row found`);
return;
}
await backfillDemandForRegion(regionCode, regionId, chunks, retailPriceByRegionMonth, latestPriceByRegion);
};
});
// Check existing records to decide create vs update
const timestamps = validPoints.map(p => p.timestamp);
const existing = await prisma.electricityPrice.findMany({
where: { regionId, timestamp: { in: timestamps } },
select: { id: true, timestamp: true },
});
const existingByTime = new Map(existing.map(e => [e.timestamp.getTime(), e.id]));
// Find peak demand for demand-based price variation
const peakDemand = Math.max(...validPoints.map(p => p.valueMw));
const toCreate: Array<{
regionId: string;
priceMwh: number;
demandMw: number;
timestamp: Date;
source: string;
}> = [];
const toUpdate: Array<{ id: string; demandMw: number; priceMwh: number }> = [];
for (const point of validPoints) {
const month = point.timestamp.toISOString().slice(0, 7);
const basePrice = getRetailPrice(regionCode, month);
// Add demand-based variation: scale price between 0.8x and 1.2x based on demand
const demandRatio = peakDemand > 0 ? point.valueMw / peakDemand : 0.5;
const priceMwh = basePrice > 0 ? basePrice * (0.8 + 0.4 * demandRatio) : 0;
const existingId = existingByTime.get(point.timestamp.getTime());
if (existingId) {
toUpdate.push({ id: existingId, demandMw: point.valueMw, priceMwh });
} else {
toCreate.push({
regionId,
priceMwh,
demandMw: point.valueMw,
timestamp: point.timestamp,
source: 'EIA',
});
}
}
if (toCreate.length > 0) {
const result = await prisma.electricityPrice.createMany({ data: toCreate });
log(` ${regionCode}: ${result.count} records inserted`);
}
if (toUpdate.length > 0) {
// Batch updates in chunks of 100 to avoid transaction timeouts
const chunkSize = 100;
for (let i = 0; i < toUpdate.length; i += chunkSize) {
const chunk = toUpdate.slice(i, i + chunkSize);
await prisma.$transaction(
chunk.map(u =>
prisma.electricityPrice.update({
where: { id: u.id },
data: { demandMw: u.demandMw, priceMwh: u.priceMwh, source: 'EIA' },
}),
),
);
}
log(` ${regionCode}: ${toUpdate.length} records updated`);
}
if (toCreate.length === 0 && toUpdate.length === 0) {
log(` ${regionCode}: no changes needed`);
}
} catch (err) {
log(` ERROR ${regionCode}: ${err instanceof Error ? err.message : String(err)}`);
}
// Rate limit: 200ms between regions
await sleep(200);
}
await runWithConcurrency(regionTasks, CONCURRENCY);
}
// ---------------------------------------------------------------------------
// Generation mix backfill
// Generation mix backfill — chunked by month
// ---------------------------------------------------------------------------
async function backfillGenerationForRegion(
regionCode: RegionCode,
regionId: string,
chunks: Array<[string, string]>,
): Promise<void> {
for (let i = 0; i < chunks.length; i++) {
const [start, end] = chunks[i]!;
const label = `[GEN] ${regionCode}: ${monthLabel(start)} (${i + 1}/${chunks.length})`;
if (isChunkDone('gen', regionCode, start)) {
continue;
}
try {
log(` ${label} — fetching...`);
const fuelData = await eia.getFuelTypeData(regionCode, { start, end });
const validPoints = fuelData.filter((p): p is typeof p & { generationMw: number } => p.generationMw !== null);
if (validPoints.length === 0) {
log(` ${label} — 0 data points, skipping`);
markChunkDone('gen', regionCode, start);
await sleep(REQUEST_DELAY_MS);
continue;
}
// Build upsert rows
const rows = validPoints.map(point => ({
regionId,
fuelType: point.fuelType,
generationMw: point.generationMw,
timestamp: point.timestamp,
}));
let inserted = 0;
let updated = 0;
for (let j = 0; j < rows.length; j += UPSERT_BATCH_SIZE) {
const batch = rows.slice(j, j + UPSERT_BATCH_SIZE);
const result = await upsertGenerationBatch(batch);
inserted += result.inserted;
updated += result.updated;
}
stats.genInserted += inserted;
stats.genUpdated += updated;
log(` ${label}${inserted} inserted, ${updated} updated (${validPoints.length} points)`);
markChunkDone('gen', regionCode, start);
} catch (err) {
stats.genErrors++;
log(` ${label} — ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
await sleep(REQUEST_DELAY_MS);
}
}
async function upsertGenerationBatch(
rows: Array<{ regionId: string; fuelType: string; generationMw: number; timestamp: Date }>,
): Promise<UpsertResult> {
if (rows.length === 0) return { inserted: 0, updated: 0 };
const values: unknown[] = [];
const placeholders: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const offset = i * 4;
placeholders.push(
`(gen_random_uuid(), $${offset + 1}::uuid, $${offset + 2}, $${offset + 3}, $${offset + 4}::timestamptz)`,
);
values.push(row.regionId, row.fuelType, row.generationMw, row.timestamp);
}
const sql = `
WITH upserted AS (
INSERT INTO generation_mix (id, region_id, fuel_type, generation_mw, timestamp)
VALUES ${placeholders.join(',\n')}
ON CONFLICT (region_id, fuel_type, timestamp) DO UPDATE SET
generation_mw = EXCLUDED.generation_mw
RETURNING (xmax = 0) AS is_insert
)
SELECT
COUNT(*) FILTER (WHERE is_insert) AS inserted,
COUNT(*) FILTER (WHERE NOT is_insert) AS updated
FROM upserted
`;
const result = await prisma.$queryRawUnsafe<Array<{ inserted: bigint; updated: bigint }>>(sql, ...values);
const row = result[0]!;
return { inserted: Number(row.inserted), updated: Number(row.updated) };
}
async function backfillGeneration(): Promise<void> {
log('=== Backfilling generation mix data ===');
log('=== Backfilling generation mix data (10-year) ===');
const gridRegions = await prisma.gridRegion.findMany({
select: { id: true, code: true },
});
const regionIdByCode = new Map(gridRegions.map(r => [r.code, r.id]));
const start = sixMonthsAgoIso();
const end = todayIso();
const chunks = generateMonthChunks(BACKFILL_START, end);
log(` ${chunks.length} monthly chunks from ${BACKFILL_START} to ${end}`);
for (const regionCode of ALL_REGIONS) {
const regionId = regionIdByCode.get(regionCode);
if (!regionId) {
log(` SKIP ${regionCode} — no grid_region row found`);
continue;
}
log(` Fetching generation mix for ${regionCode}...`);
try {
const fuelData = await getFuelTypeData(regionCode, { start, end });
const validPoints = fuelData.filter((p): p is typeof p & { generationMw: number } => p.generationMw !== null);
if (validPoints.length === 0) {
log(` ${regionCode}: 0 valid data points`);
continue;
const regionTasks = ALL_REGIONS.map(regionCode => {
return async () => {
const regionId = regionIdByCode.get(regionCode);
if (!regionId) {
log(` SKIP ${regionCode} — no grid_region row found`);
return;
}
await backfillGenerationForRegion(regionCode, regionId, chunks);
};
});
const timestamps = validPoints.map(p => p.timestamp);
const existing = await prisma.generationMix.findMany({
where: { regionId, timestamp: { in: timestamps } },
select: { id: true, timestamp: true, fuelType: true },
});
const existingKeys = new Map(existing.map(e => [`${e.fuelType}:${e.timestamp.getTime()}`, e.id]));
const toCreate: Array<{
regionId: string;
fuelType: string;
generationMw: number;
timestamp: Date;
}> = [];
const toUpdate: Array<{ id: string; generationMw: number }> = [];
for (const point of validPoints) {
const key = `${point.fuelType}:${point.timestamp.getTime()}`;
const existingId = existingKeys.get(key);
if (existingId) {
toUpdate.push({ id: existingId, generationMw: point.generationMw });
} else {
toCreate.push({
regionId,
fuelType: point.fuelType,
generationMw: point.generationMw,
timestamp: point.timestamp,
});
}
}
if (toCreate.length > 0) {
const result = await prisma.generationMix.createMany({ data: toCreate });
log(` ${regionCode}: ${result.count} generation records inserted`);
}
if (toUpdate.length > 0) {
const chunkSize = 100;
for (let i = 0; i < toUpdate.length; i += chunkSize) {
const chunk = toUpdate.slice(i, i + chunkSize);
await prisma.$transaction(
chunk.map(u =>
prisma.generationMix.update({
where: { id: u.id },
data: { generationMw: u.generationMw },
}),
),
);
}
log(` ${regionCode}: ${toUpdate.length} generation records updated`);
}
if (toCreate.length === 0 && toUpdate.length === 0) {
log(` ${regionCode}: no changes needed`);
}
} catch (err) {
log(` ERROR ${regionCode}: ${err instanceof Error ? err.message : String(err)}`);
}
await sleep(200);
}
await runWithConcurrency(regionTasks, CONCURRENCY);
}
// ---------------------------------------------------------------------------
@ -289,9 +499,9 @@ interface CommodityRow {
}
async function backfillCommodities(): Promise<void> {
log('=== Backfilling commodity prices ===');
log('=== Backfilling commodity prices (10-year) ===');
const start = sixMonthsAgoIso();
const start = BACKFILL_START;
const end = todayIso();
const rows: CommodityRow[] = [];
@ -308,7 +518,7 @@ async function backfillCommodities(): Promise<void> {
log(` ERROR EIA natural gas: ${err instanceof Error ? err.message : String(err)}`);
}
await sleep(200);
await sleep(REQUEST_DELAY_MS);
// EIA: WTI Crude
log(' Fetching EIA WTI crude prices...');
@ -323,7 +533,7 @@ async function backfillCommodities(): Promise<void> {
log(` ERROR EIA WTI crude: ${err instanceof Error ? err.message : String(err)}`);
}
await sleep(200);
await sleep(REQUEST_DELAY_MS);
// FRED: Natural Gas (DHHNGSP)
log(' Fetching FRED natural gas prices...');
@ -337,7 +547,7 @@ async function backfillCommodities(): Promise<void> {
log(` ERROR FRED natural gas: ${fredGas.error}`);
}
await sleep(200);
await sleep(REQUEST_DELAY_MS);
// FRED: WTI Crude (DCOILWTICO)
log(' Fetching FRED WTI crude prices...');
@ -351,7 +561,7 @@ async function backfillCommodities(): Promise<void> {
log(` ERROR FRED WTI crude: ${fredOil.error}`);
}
await sleep(200);
await sleep(REQUEST_DELAY_MS);
// FRED: Coal (PCOALAUUSDM)
log(' Fetching FRED coal prices...');
@ -381,59 +591,79 @@ async function backfillCommodities(): Promise<void> {
}
const uniqueRows = [...deduped.values()];
// Upsert into database
const timestamps = uniqueRows.map(r => r.timestamp);
const commodities = [...new Set(uniqueRows.map(r => r.commodity))];
// Batch upsert commodities
let totalInserted = 0;
let totalUpdated = 0;
for (let i = 0; i < uniqueRows.length; i += UPSERT_BATCH_SIZE) {
const batch = uniqueRows.slice(i, i + UPSERT_BATCH_SIZE);
const result = await upsertCommodityBatch(batch);
totalInserted += result.inserted;
totalUpdated += result.updated;
}
const existing = await prisma.commodityPrice.findMany({
where: { commodity: { in: commodities }, timestamp: { in: timestamps } },
select: { id: true, commodity: true, timestamp: true },
});
const existingKeys = new Map(existing.map(e => [`${e.commodity}:${e.timestamp.getTime()}`, e.id]));
stats.commodityInserted = totalInserted;
stats.commodityUpdated = totalUpdated;
log(` Commodities: ${totalInserted} inserted, ${totalUpdated} updated (${uniqueRows.length} unique rows)`);
}
const toCreate: Array<{ commodity: string; price: number; unit: string; timestamp: Date; source: string }> = [];
const toUpdate: Array<{ id: string; price: number; unit: string; source: string }> = [];
async function upsertCommodityBatch(rows: CommodityRow[]): Promise<UpsertResult> {
if (rows.length === 0) return { inserted: 0, updated: 0 };
for (const row of uniqueRows) {
const key = `${row.commodity}:${row.timestamp.getTime()}`;
const existingId = existingKeys.get(key);
if (existingId) {
toUpdate.push({ id: existingId, price: row.price, unit: row.unit, source: row.source });
} else {
toCreate.push({
commodity: row.commodity,
price: row.price,
unit: row.unit,
timestamp: row.timestamp,
source: row.source,
});
const values: unknown[] = [];
const placeholders: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const offset = i * 5;
placeholders.push(
`(gen_random_uuid(), $${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}::timestamptz, $${offset + 5})`,
);
values.push(row.commodity, row.price, row.unit, row.timestamp, row.source);
}
const sql = `
WITH upserted AS (
INSERT INTO commodity_prices (id, commodity, price, unit, timestamp, source)
VALUES ${placeholders.join(',\n')}
ON CONFLICT (commodity, timestamp) DO UPDATE SET
price = EXCLUDED.price,
unit = EXCLUDED.unit,
source = EXCLUDED.source
RETURNING (xmax = 0) AS is_insert
)
SELECT
COUNT(*) FILTER (WHERE is_insert) AS inserted,
COUNT(*) FILTER (WHERE NOT is_insert) AS updated
FROM upserted
`;
const result = await prisma.$queryRawUnsafe<Array<{ inserted: bigint; updated: bigint }>>(sql, ...values);
const row = result[0]!;
return { inserted: Number(row.inserted), updated: Number(row.updated) };
}
// ---------------------------------------------------------------------------
// Materialized view refresh
// ---------------------------------------------------------------------------
const MATERIALIZED_VIEWS = [
'electricity_prices_daily',
'electricity_prices_weekly',
'generation_mix_daily',
'generation_mix_weekly',
] as const;
async function refreshMaterializedViews(): Promise<void> {
log('=== Refreshing materialized views ===');
for (const view of MATERIALIZED_VIEWS) {
try {
log(` Refreshing ${view}...`);
await prisma.$executeRawUnsafe(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${view}`);
log(` ${view} refreshed`);
} catch (err) {
log(` ERROR refreshing ${view}: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (toCreate.length > 0) {
const result = await prisma.commodityPrice.createMany({ data: toCreate });
log(` Commodities: ${result.count} records inserted`);
}
if (toUpdate.length > 0) {
const chunkSize = 100;
for (let i = 0; i < toUpdate.length; i += chunkSize) {
const chunk = toUpdate.slice(i, i + chunkSize);
await prisma.$transaction(
chunk.map(u =>
prisma.commodityPrice.update({
where: { id: u.id },
data: { price: u.price, unit: u.unit, source: u.source },
}),
),
);
}
log(` Commodities: ${toUpdate.length} records updated`);
}
if (toCreate.length === 0 && toUpdate.length === 0) {
log(' Commodities: no changes needed');
}
}
// ---------------------------------------------------------------------------
@ -441,19 +671,36 @@ async function backfillCommodities(): Promise<void> {
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
log('Starting historical backfill (6 months)...');
log(`Date range: ${sixMonthsAgoIso()} to ${todayIso()}`);
const end = todayIso();
log(`Starting 10-year historical backfill...`);
log(`Date range: ${BACKFILL_START} to ${end}`);
log(`Regions: ${ALL_REGIONS.join(', ')} (${ALL_REGIONS.length} total)`);
log(`Concurrency: ${CONCURRENCY} regions in parallel`);
log('');
await backfillElectricity();
log('');
await backfillGeneration();
log('');
await backfillCommodities();
if (!onlyCommodities && !skipDemand) {
await backfillElectricity();
log('');
}
if (!onlyCommodities && !skipGeneration) {
await backfillGeneration();
log('');
}
if (!skipCommodities) {
await backfillCommodities();
log('');
}
// Refresh materialized views after data load
await refreshMaterializedViews();
log('');
log('=== Backfill Summary ===');
log(` Demand: ${stats.demandInserted} inserted, ${stats.demandUpdated} updated, ${stats.demandErrors} errors`);
log(` Generation: ${stats.genInserted} inserted, ${stats.genUpdated} updated, ${stats.genErrors} errors`);
log(` Commodities: ${stats.commodityInserted} inserted, ${stats.commodityUpdated} updated`);
log('Backfill complete.');
}

View File

@ -0,0 +1,104 @@
/**
* Downloads power plant data from the EIA ArcGIS FeatureServer.
*
* Fetches all US power plants >= 50 MW with pagination,
* then saves the combined result as data/power-plants.geojson.
*
* Usage: bun run scripts/download-power-plants.ts
*/
import { mkdirSync, writeFileSync } from 'fs';
import { resolve } from 'path';
import { z } from 'zod/v4';
const BASE_URL =
'https://services2.arcgis.com/FiaPA4ga0iQKduv3/ArcGIS/rest/services/Power_Plants_in_the_US/FeatureServer/0/query';
const OUT_FIELDS = [
'Plant_Name',
'Plant_Code',
'Utility_Na',
'State',
'County',
'Latitude',
'Longitude',
'PrimSource',
'Total_MW',
].join(',');
const PAGE_SIZE = 2000;
const ArcGISFeatureSchema = z.object({
type: z.literal('Feature'),
geometry: z.object({
type: z.literal('Point'),
coordinates: z.tuple([z.number(), z.number()]),
}),
properties: z.record(z.string(), z.unknown()),
});
const ArcGISResponseSchema = z.object({
type: z.literal('FeatureCollection'),
features: z.array(ArcGISFeatureSchema),
properties: z.object({ exceededTransferLimit: z.boolean().optional() }).optional(),
});
type ArcGISFeature = z.infer<typeof ArcGISFeatureSchema>;
type ArcGISGeoJSONResponse = z.infer<typeof ArcGISResponseSchema>;
async function fetchPage(offset: number): Promise<ArcGISGeoJSONResponse> {
const params = new URLSearchParams({
where: 'Total_MW >= 50',
outFields: OUT_FIELDS,
f: 'geojson',
resultRecordCount: String(PAGE_SIZE),
resultOffset: String(offset),
});
const url = `${BASE_URL}?${params.toString()}`;
console.log(`Fetching offset=${offset}...`);
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const json: unknown = await res.json();
return ArcGISResponseSchema.parse(json);
}
async function main() {
const allFeatures: ArcGISFeature[] = [];
let offset = 0;
while (true) {
const page = await fetchPage(offset);
const count = page.features.length;
console.log(` Got ${count} features`);
allFeatures.push(...page.features);
// ArcGIS signals more data via exceededTransferLimit or by returning a full page
const hasMore = page.properties?.exceededTransferLimit === true || count >= PAGE_SIZE;
if (!hasMore || count === 0) break;
offset += PAGE_SIZE;
}
console.log(`\nTotal features: ${allFeatures.length}`);
const collection: ArcGISGeoJSONResponse = {
type: 'FeatureCollection',
features: allFeatures,
};
const outDir = resolve(import.meta.dirname, '..', 'data');
mkdirSync(outDir, { recursive: true });
const outPath = resolve(outDir, 'power-plants.geojson');
writeFileSync(outPath, JSON.stringify(collection, null, 2));
console.log(`Saved to ${outPath}`);
}
main().catch((err: unknown) => {
console.error('Download failed:', err);
process.exit(1);
});

56
src/actions/dc-impact.ts Normal file
View File

@ -0,0 +1,56 @@
'use server';
import { getCapacityPriceTimeline, getDcPriceImpact } 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';
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 fetchCapacityPriceTimeline(
regionCode: string,
): Promise<ActionResult<getCapacityPriceTimeline.Result[]>> {
'use cache';
cacheLife('prices');
cacheTag(`capacity-price-timeline-${regionCode}`);
try {
if (!validateRegionCode(regionCode)) {
return { ok: false, error: `Invalid region code: ${regionCode}` };
}
const rows = await prisma.$queryRawTyped(getCapacityPriceTimeline(regionCode));
return { ok: true, data: serialize(rows) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch capacity/price timeline: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
export async function fetchDcPriceImpact(): Promise<ActionResult<getDcPriceImpact.Result[]>> {
'use cache';
cacheLife('prices');
cacheTag('dc-price-impact');
try {
const rows = await prisma.$queryRawTyped(getDcPriceImpact());
return { ok: true, data: serialize(rows) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch DC price impact: ${err instanceof Error ? err.message : String(err)}`,
};
}
}

View File

@ -1,21 +1,24 @@
'use server';
import { getDemandByRegion } from '@/generated/prisma/sql.js';
import { getDemandByRegion, getDemandDaily, getDemandHourly, getDemandWeekly } from '@/generated/prisma/sql.js';
import { prisma } from '@/lib/db.js';
import { getGranularity } from '@/lib/granularity.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';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
function timeRangeToStartDate(range: TimeRange): Date {
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
const now = new Date();
const ms: Record<TimeRange, number> = {
const ms: Record<Exclude<TimeRange, 'all'>, 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,
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
};
return new Date(now.getTime() - ms[range]);
}
@ -32,10 +35,43 @@ interface ActionError {
type ActionResult<T> = ActionSuccess<T> | ActionError;
/** Unified demand row returned to the client */
interface DemandRow {
region_code: string;
region_name: string;
day: Date;
avg_demand: number;
peak_demand: number;
datacenter_count: number | null;
total_dc_capacity_mw: number | null;
}
async function queryDemand(startDate: Date, endDate: Date, regionCode: string): Promise<DemandRow[]> {
const granularity = getGranularity(startDate, endDate);
switch (granularity) {
case 'hourly':
return prisma.$queryRawTyped(getDemandHourly(startDate, endDate, regionCode));
case 'daily': {
const rows = await prisma.$queryRawTyped(getDemandDaily(startDate, endDate, regionCode));
return rows.filter(
(r): r is typeof r & { day: Date; avg_demand: number; peak_demand: number } =>
r.day !== null && r.avg_demand !== null && r.peak_demand !== null,
);
}
case 'weekly': {
const rows = await prisma.$queryRawTyped(getDemandWeekly(startDate, endDate, regionCode));
return rows.filter(
(r): r is typeof r & { day: Date; avg_demand: number; peak_demand: number } =>
r.day !== null && r.avg_demand !== null && r.peak_demand !== null,
);
}
}
}
export async function fetchDemandByRegion(
regionCode: string,
timeRange: TimeRange = '30d',
): Promise<ActionResult<getDemandByRegion.Result[]>> {
): Promise<ActionResult<DemandRow[]>> {
'use cache';
cacheLife('demand');
cacheTag(`demand-${regionCode}-${timeRange}`);
@ -46,7 +82,7 @@ export async function fetchDemandByRegion(
}
const startDate = timeRangeToStartDate(timeRange);
const endDate = new Date();
const rows = await prisma.$queryRawTyped(getDemandByRegion(startDate, endDate, regionCode));
const rows = await queryDemand(startDate, endDate, regionCode);
return { ok: true, data: serialize(rows) };
} catch (err) {
return {
@ -64,6 +100,7 @@ export async function fetchRegionDemandSummary(): Promise<ActionResult<getDemand
try {
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const endDate = new Date();
// Summary always uses the original daily-aggregating query for 7-day window
const rows = await prisma.$queryRawTyped(getDemandByRegion(startDate, endDate, 'ALL'));
return { ok: true, data: serialize(rows) };
} catch (err) {

49
src/actions/freshness.ts Normal file
View File

@ -0,0 +1,49 @@
'use server';
import { prisma } from '@/lib/db.js';
import { serialize } from '@/lib/superjson.js';
import { cacheLife, cacheTag } from 'next/cache';
interface DataFreshness {
electricity: Date | null;
generation: Date | null;
commodities: Date | null;
}
type FreshnessResult = { ok: true; data: ReturnType<typeof serialize<DataFreshness>> } | { ok: false; error: string };
export async function fetchDataFreshness(): Promise<FreshnessResult> {
'use cache';
cacheLife('ticker');
cacheTag('data-freshness');
try {
const [electricityResult, generationResult, commoditiesResult] = await Promise.all([
prisma.electricityPrice.findFirst({
orderBy: { timestamp: 'desc' },
select: { timestamp: true },
}),
prisma.generationMix.findFirst({
orderBy: { timestamp: 'desc' },
select: { timestamp: true },
}),
prisma.commodityPrice.findFirst({
orderBy: { timestamp: 'desc' },
select: { timestamp: true },
}),
]);
const freshness: DataFreshness = {
electricity: electricityResult?.timestamp ?? null,
generation: generationResult?.timestamp ?? null,
commodities: commoditiesResult?.timestamp ?? null,
};
return { ok: true, data: serialize(freshness) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch data freshness: ${err instanceof Error ? err.message : String(err)}`,
};
}
}

View File

@ -1,21 +1,24 @@
'use server';
import { getGenerationMix } from '@/generated/prisma/sql.js';
import { getGenerationDaily, getGenerationHourly, getGenerationWeekly } from '@/generated/prisma/sql.js';
import { prisma } from '@/lib/db.js';
import { getGranularity } from '@/lib/granularity.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';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
function timeRangeToStartDate(range: TimeRange): Date {
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
const now = new Date();
const ms: Record<TimeRange, number> = {
const ms: Record<Exclude<TimeRange, 'all'>, 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,
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
};
return new Date(now.getTime() - ms[range]);
}
@ -32,10 +35,41 @@ interface ActionError {
type ActionResult<T> = ActionSuccess<T> | ActionError;
/** Unified generation row returned to the client */
interface GenerationRow {
fuel_type: string;
timestamp: Date;
generation_mw: number;
region_code: string;
region_name: string;
}
async function queryGeneration(regionCode: string, startDate: Date, endDate: Date): Promise<GenerationRow[]> {
const granularity = getGranularity(startDate, endDate);
switch (granularity) {
case 'hourly':
return prisma.$queryRawTyped(getGenerationHourly(regionCode, startDate, endDate));
case 'daily': {
const rows = await prisma.$queryRawTyped(getGenerationDaily(regionCode, startDate, endDate));
return rows.filter(
(r): r is typeof r & { fuel_type: string; timestamp: Date; generation_mw: number } =>
r.fuel_type !== null && r.timestamp !== null && r.generation_mw !== null,
);
}
case 'weekly': {
const rows = await prisma.$queryRawTyped(getGenerationWeekly(regionCode, startDate, endDate));
return rows.filter(
(r): r is typeof r & { fuel_type: string; timestamp: Date; generation_mw: number } =>
r.fuel_type !== null && r.timestamp !== null && r.generation_mw !== null,
);
}
}
}
export async function fetchGenerationMix(
regionCode: string,
timeRange: TimeRange = '30d',
): Promise<ActionResult<getGenerationMix.Result[]>> {
): Promise<ActionResult<GenerationRow[]>> {
'use cache';
cacheLife('demand');
cacheTag(`generation-${regionCode}-${timeRange}`);
@ -46,7 +80,7 @@ export async function fetchGenerationMix(
}
const startDate = timeRangeToStartDate(timeRange);
const endDate = new Date();
const rows = await prisma.$queryRawTyped(getGenerationMix(regionCode, startDate, endDate));
const rows = await queryGeneration(regionCode, startDate, endDate);
return { ok: true, data: serialize(rows) };
} catch (err) {
return {

View File

@ -0,0 +1,22 @@
'use server';
import { getAllPowerPlants } from '@/generated/prisma/sql.js';
import { prisma } from '@/lib/db.js';
import { serialize } from '@/lib/superjson.js';
import { cacheLife, cacheTag } from 'next/cache';
export async function fetchAllPowerPlants() {
'use cache';
cacheLife('seedData');
cacheTag('power-plants');
try {
const rows = await prisma.$queryRawTyped(getAllPowerPlants());
return { ok: true as const, data: serialize(rows) };
} catch (err) {
return {
ok: false as const,
error: `Failed to fetch power plants: ${err instanceof Error ? err.message : String(err)}`,
};
}
}

View File

@ -1,21 +1,30 @@
'use server';
import { getLatestPrices, getPriceTrends, getRegionPriceHeatmap } from '@/generated/prisma/sql.js';
import {
getLatestPrices,
getPricesDaily,
getPricesHourly,
getPricesWeekly,
getRegionPriceHeatmap,
} from '@/generated/prisma/sql.js';
import { prisma } from '@/lib/db.js';
import { getGranularity } from '@/lib/granularity.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';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
function timeRangeToStartDate(range: TimeRange): Date {
if (range === 'all') return new Date('2019-01-01T00:00:00Z');
const now = new Date();
const ms: Record<TimeRange, number> = {
const ms: Record<Exclude<TimeRange, 'all'>, 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,
'5y': 5 * 365 * 24 * 60 * 60 * 1000,
};
return new Date(now.getTime() - ms[range]);
}
@ -48,10 +57,41 @@ export async function fetchLatestPrices(): Promise<ActionResult<getLatestPrices.
}
}
/** Unified price trend row returned to the client */
interface PriceTrendRow {
timestamp: Date;
price_mwh: number;
demand_mw: number;
region_code: string;
region_name: string;
}
async function queryPriceTrends(regionCode: string, startDate: Date, endDate: Date): Promise<PriceTrendRow[]> {
const granularity = getGranularity(startDate, endDate);
switch (granularity) {
case 'hourly':
return prisma.$queryRawTyped(getPricesHourly(regionCode, startDate, endDate));
case 'daily': {
const rows = await prisma.$queryRawTyped(getPricesDaily(regionCode, startDate, endDate));
return rows.filter(
(r): r is typeof r & { timestamp: Date; price_mwh: number; demand_mw: number } =>
r.timestamp !== null && r.price_mwh !== null && r.demand_mw !== null,
);
}
case 'weekly': {
const rows = await prisma.$queryRawTyped(getPricesWeekly(regionCode, startDate, endDate));
return rows.filter(
(r): r is typeof r & { timestamp: Date; price_mwh: number; demand_mw: number } =>
r.timestamp !== null && r.price_mwh !== null && r.demand_mw !== null,
);
}
}
}
export async function fetchPriceTrends(
regionCode: string,
timeRange: TimeRange = '30d',
): Promise<ActionResult<getPriceTrends.Result[]>> {
): Promise<ActionResult<PriceTrendRow[]>> {
'use cache';
cacheLife('prices');
cacheTag(`price-trends-${regionCode}-${timeRange}`);
@ -62,7 +102,7 @@ export async function fetchPriceTrends(
}
const startDate = timeRangeToStartDate(timeRange);
const endDate = new Date();
const rows = await prisma.$queryRawTyped(getPriceTrends(regionCode, startDate, endDate));
const rows = await queryPriceTrends(regionCode, startDate, endDate);
return { ok: true, data: serialize(rows) };
} catch (err) {
return {
@ -88,9 +128,7 @@ export async function fetchPriceHeatmapData(): Promise<ActionResult<getRegionPri
}
}
export async function fetchAllRegionPriceTrends(
timeRange: TimeRange = '30d',
): Promise<ActionResult<getPriceTrends.Result[]>> {
export async function fetchAllRegionPriceTrends(timeRange: TimeRange = '30d'): Promise<ActionResult<PriceTrendRow[]>> {
'use cache';
cacheLife('prices');
cacheTag(`all-price-trends-${timeRange}`);
@ -99,9 +137,7 @@ export async function fetchAllRegionPriceTrends(
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))),
);
const results = await Promise.all(regions.map(r => queryPriceTrends(r.code, startDate, endDate)));
return { ok: true, data: serialize(results.flat()) };
} catch (err) {
return {
@ -203,7 +239,7 @@ export async function fetchPriceSparklines(): Promise<
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));
const rows = await queryPriceTrends(r.code, startDate, endDate);
return {
region_code: r.code,
points: rows.map(row => ({ value: row.price_mwh })),
@ -259,55 +295,68 @@ export async function fetchRecentAlerts(): Promise<
timestamp: Date;
}> = [];
// Detect price spikes (above $80/MWh) and demand peaks
const regionAvgs = new Map<string, { sum: number; count: number }>();
// Compute per-region mean and stddev for statistical anomaly detection
const regionStats = new Map<string, { sum: number; sumSq: number; count: number }>();
for (const row of priceRows) {
const entry = regionAvgs.get(row.region.code) ?? { sum: 0, count: 0 };
const entry = regionStats.get(row.region.code) ?? { sum: 0, sumSq: 0, count: 0 };
entry.sum += row.priceMwh;
entry.sumSq += row.priceMwh * row.priceMwh;
entry.count += 1;
regionAvgs.set(row.region.code, entry);
regionStats.set(row.region.code, entry);
}
function getRegionThresholds(regionCode: string) {
const stats = regionStats.get(regionCode);
if (!stats || stats.count < 2) return null;
const avg = stats.sum / stats.count;
const variance = stats.sumSq / stats.count - avg * avg;
const sd = Math.sqrt(Math.max(0, variance));
return { avg, sd };
}
for (const row of priceRows) {
const avg = regionAvgs.get(row.region.code);
const avgPrice = avg ? avg.sum / avg.count : 0;
const thresholds = getRegionThresholds(row.region.code);
if (thresholds && thresholds.sd > 0) {
const { avg, sd } = thresholds;
const sigmas = (row.priceMwh - avg) / sd;
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 (sigmas >= 2.5) {
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} at $${row.priceMwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above avg ($${avg.toFixed(0)})`,
value: row.priceMwh,
unit: '$/MWh',
timestamp: row.timestamp,
});
} else if (sigmas >= 1.8) {
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} at $${row.priceMwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above avg ($${avg.toFixed(0)})`,
value: row.priceMwh,
unit: '$/MWh',
timestamp: row.timestamp,
});
} else if (sigmas <= -1.8) {
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} dropped to $${row.priceMwh.toFixed(2)}/MWh — ${Math.abs(sigmas).toFixed(1)}σ below avg ($${avg.toFixed(0)})`,
value: row.priceMwh,
unit: '$/MWh',
timestamp: row.timestamp,
});
}
}
if (row.demandMw >= 50000) {

View File

@ -0,0 +1,10 @@
import { fetchRecentAlerts } from '@/actions/prices.js';
import { AlertsFeed } from '@/components/dashboard/alerts-feed.js';
export async function AlertsSection() {
const alertsResult = await fetchRecentAlerts();
if (!alertsResult.ok) return null;
return <AlertsFeed initialData={alertsResult.data} />;
}

View File

@ -0,0 +1,150 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { Activity } from 'lucide-react';
import { fetchRegionDemandSummary } from '@/actions/demand.js';
import { deserialize } from '@/lib/superjson.js';
interface RegionSummary {
regionCode: string;
latestDemandMw: number;
peakDemandMw: number;
avgDemandMw: number;
}
function formatGw(mw: number): string {
if (mw >= 1000) return `${(mw / 1000).toFixed(1)}`;
return `${Math.round(mw)}`;
}
function formatGwUnit(mw: number): string {
return mw >= 1000 ? 'GW' : 'MW';
}
export async function DemandSummary() {
const demandResult = await fetchRegionDemandSummary();
const demandRows = demandResult.ok
? deserialize<
Array<{
avg_demand: number | null;
peak_demand: number | null;
region_code: string;
region_name: string;
day: Date;
}>
>(demandResult.data)
: [];
// Aggregate per region: latest demand, peak demand, avg demand
const regionMap = new Map<string, RegionSummary>();
for (const row of demandRows) {
const demand = row.avg_demand ?? 0;
const peak = row.peak_demand ?? 0;
const existing = regionMap.get(row.region_code);
if (!existing) {
regionMap.set(row.region_code, {
regionCode: row.region_code,
latestDemandMw: demand,
peakDemandMw: peak,
avgDemandMw: demand,
});
} else {
// Query is ordered by day ASC, so later rows are more recent
existing.latestDemandMw = demand;
if (peak > existing.peakDemandMw) existing.peakDemandMw = peak;
// Running average approximation
existing.avgDemandMw = (existing.avgDemandMw + demand) / 2;
}
}
const regions = [...regionMap.values()].filter(r => r.peakDemandMw > 0);
if (regions.length === 0) return null;
const totalDemandMw = regions.reduce((sum, r) => sum + r.latestDemandMw, 0);
const peakRegion = regions.reduce((max, r) => (r.latestDemandMw > max.latestDemandMw ? r : max), regions[0]!);
const regionCount = regions.length;
const totalAvg = regions.reduce((sum, r) => sum + r.avgDemandMw, 0);
const totalPeak = regions.reduce((sum, r) => sum + r.peakDemandMw, 0);
const loadFactor = totalPeak > 0 ? (totalAvg / totalPeak) * 100 : 0;
const highlights = [
{
label: 'Total US Demand',
value: formatGw(totalDemandMw),
unit: formatGwUnit(totalDemandMw),
},
{
label: 'Peak Region',
value: peakRegion.regionCode,
unit: `${formatGw(peakRegion.latestDemandMw)} ${formatGwUnit(peakRegion.latestDemandMw)}`,
},
{
label: 'Regions Tracked',
value: regionCount.toString(),
unit: 'ISOs',
},
{
label: 'Avg Load Factor',
value: `${loadFactor.toFixed(0)}`,
unit: '%',
},
];
// Top 5 regions by demand for a quick leaderboard
const topRegions = [...regionMap.values()]
.filter(r => r.latestDemandMw > 0)
.sort((a, b) => b.latestDemandMw - a.latestDemandMw)
.slice(0, 5);
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-chart-3" />
Demand Highlights
</CardTitle>
<p className="text-xs text-muted-foreground">7-day summary across all grid regions</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{highlights.map(h => (
<div key={h.label} className="flex flex-col gap-0.5">
<span className="text-[11px] font-medium tracking-wide text-muted-foreground">{h.label}</span>
<div className="flex items-baseline gap-1">
<span className="font-mono text-lg font-bold tabular-nums">{h.value}</span>
<span className="text-xs text-muted-foreground">{h.unit}</span>
</div>
</div>
))}
</div>
{topRegions.length > 0 && (
<>
<div className="my-4 h-px bg-border/50" />
<p className="mb-2.5 text-[11px] font-medium tracking-wide text-muted-foreground">Top Regions by Demand</p>
<div className="space-y-2">
{topRegions.map((r, i) => {
const maxDemand = topRegions[0]!.latestDemandMw;
const pct = maxDemand > 0 ? (r.latestDemandMw / maxDemand) * 100 : 0;
return (
<div key={r.regionCode} className="flex items-center gap-2.5 text-sm">
<span className="w-4 text-right text-[10px] font-bold text-muted-foreground">{i + 1}</span>
<span className="w-12 shrink-0 font-mono text-xs font-semibold">{r.regionCode}</span>
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted/30">
<div className="h-full rounded-full bg-chart-3 transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="w-14 shrink-0 text-right font-mono text-xs text-muted-foreground tabular-nums">
{formatGw(r.latestDemandMw)} {formatGwUnit(r.latestDemandMw)}
</span>
</div>
);
})}
</div>
</>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,28 @@
import { GpuCalculator } from '@/components/dashboard/gpu-calculator.js';
import { fetchLatestPrices } from '@/actions/prices.js';
import { deserialize } from '@/lib/superjson.js';
export async function GpuCalculatorSection() {
const pricesResult = await fetchLatestPrices();
const prices = pricesResult.ok
? deserialize<
Array<{
price_mwh: number;
region_code: string;
region_name: string;
}>
>(pricesResult.data)
: [];
const regionPrices = prices.map(p => ({
regionCode: p.region_code,
regionName: p.region_name,
priceMwh: p.price_mwh,
}));
if (regionPrices.length === 0) return null;
return <GpuCalculator regionPrices={regionPrices} />;
}

View File

@ -0,0 +1,172 @@
import { connection } from 'next/server';
import { MetricCard } from '@/components/dashboard/metric-card.js';
import { Activity, BarChart3, Droplets, Flame, Server } from 'lucide-react';
import { fetchDatacenters } from '@/actions/datacenters.js';
import {
fetchLatestCommodityPrices,
fetchLatestPrices,
fetchPriceSparklines,
fetchTickerPrices,
} from '@/actions/prices.js';
import { deserialize } from '@/lib/superjson.js';
function formatNumber(value: number, decimals = 1): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(decimals)}K`;
return value.toFixed(decimals);
}
function computeChangePercent(current: number, previous: number | null): number | null {
if (previous === null || previous === 0) return null;
return ((current - previous) / previous) * 100;
}
/** Per-metric sparkline colors that distinguish each metric visually. */
const SPARKLINE_COLORS = {
electricity: 'hsl(210, 90%, 55%)', // Blue
natGas: 'hsl(30, 90%, 55%)', // Orange/amber
wtiCrude: 'hsl(160, 70%, 45%)', // Teal
} as const;
export async function HeroMetrics() {
await connection();
const [pricesResult, commoditiesResult, datacentersResult, sparklinesResult, tickerResult] = await Promise.all([
fetchLatestPrices(),
fetchLatestCommodityPrices(),
fetchDatacenters(),
fetchPriceSparklines(),
fetchTickerPrices(),
]);
const prices = pricesResult.ok
? deserialize<
Array<{
price_mwh: number;
demand_mw: number;
region_code: string;
region_name: string;
timestamp: Date;
}>
>(pricesResult.data)
: [];
const commodities = commoditiesResult.ok
? deserialize<Array<{ commodity: string; price: number; unit: string; timestamp: Date }>>(commoditiesResult.data)
: [];
const datacenters = datacentersResult.ok
? deserialize<Array<{ id: string; capacityMw: number; status: string; yearOpened: number }>>(datacentersResult.data)
: [];
const sparklines = sparklinesResult.ok
? deserialize<Array<{ region_code: string; points: { value: number }[] }>>(sparklinesResult.data)
: [];
// Extract previous-price data from ticker (already has LAG-based prev values)
const ticker = tickerResult.ok
? deserialize<{
electricity: Array<{ region_code: string; price_mwh: number; prev_price_mwh: number | null }>;
commodities: Array<{ commodity: string; price: number; prev_price: number | null; unit: string }>;
}>(tickerResult.data)
: { electricity: [], commodities: [] };
const avgSparkline: { value: number }[] =
sparklines.length > 0 && sparklines[0]
? sparklines[0].points.map((_, i) => {
const values = sparklines.map(s => s.points[i]?.value ?? 0).filter(v => v > 0);
return { value: values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0 };
})
: [];
// Build per-commodity sparklines from the price sparklines data
const natGasSparkline =
sparklines.length > 0 && sparklines[0]
? sparklines[0].points.map((_, i) => {
const values = sparklines.map(s => s.points[i]?.value ?? 0).filter(v => v > 0);
return { value: values.length > 0 ? Math.min(...values) : 0 };
})
: [];
const avgPrice = prices.length > 0 ? prices.reduce((sum, p) => sum + p.price_mwh, 0) / prices.length : 0;
const natGas = commodities.find(c => c.commodity === 'natural_gas');
const wtiCrude = commodities.find(c => c.commodity === 'wti_crude');
const totalCapacityMw = datacenters.reduce((sum, dc) => sum + dc.capacityMw, 0);
const datacenterCount = datacenters.length;
// Compute trend deltas from ticker's previous prices
const avgPrevPrice =
ticker.electricity.length > 0
? ticker.electricity.reduce((sum, e) => sum + (e.prev_price_mwh ?? e.price_mwh), 0) / ticker.electricity.length
: null;
const avgPriceDelta = avgPrevPrice !== null && avgPrevPrice > 0 ? computeChangePercent(avgPrice, avgPrevPrice) : null;
const tickerNatGas = ticker.commodities.find(c => c.commodity === 'natural_gas');
const natGasDelta = natGas && tickerNatGas ? computeChangePercent(natGas.price, tickerNatGas.prev_price) : null;
const tickerWti = ticker.commodities.find(c => c.commodity === 'wti_crude');
const wtiDelta = wtiCrude && tickerWti ? computeChangePercent(wtiCrude.price, tickerWti.prev_price) : null;
// Datacenter context: count those opened this year or marked as planned
const currentYear = new Date().getFullYear();
const recentCount = datacenters.filter(dc => dc.yearOpened >= currentYear - 1).length;
const dcSubtitle = recentCount > 0 ? `${recentCount} opened since ${currentYear - 1}` : undefined;
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<MetricCard
title="Avg Electricity Price"
value={avgPrice > 0 ? `$${avgPrice.toFixed(2)}` : '--'}
numericValue={avgPrice > 0 ? avgPrice : undefined}
animatedFormat={avgPrice > 0 ? 'dollar' : undefined}
unit="/MWh"
icon={<BarChart3 className="h-4 w-4" />}
sparklineData={avgSparkline}
sparklineColor={SPARKLINE_COLORS.electricity}
trendDelta={avgPriceDelta}
trendLabel="vs prev"
/>
<MetricCard
title="Total DC Capacity"
value={formatNumber(totalCapacityMw)}
numericValue={totalCapacityMw > 0 ? totalCapacityMw : undefined}
animatedFormat={totalCapacityMw > 0 ? 'compact' : undefined}
unit="MW"
icon={<Activity className="h-4 w-4" />}
/>
<MetricCard
title="Datacenters Tracked"
value={datacenterCount.toLocaleString()}
numericValue={datacenterCount > 0 ? datacenterCount : undefined}
animatedFormat={datacenterCount > 0 ? 'integer' : undefined}
icon={<Server className="h-4 w-4" />}
subtitle={dcSubtitle}
/>
<MetricCard
title="Natural Gas Spot"
value={natGas ? `$${natGas.price.toFixed(2)}` : '--'}
numericValue={natGas?.price}
animatedFormat={natGas ? 'dollar' : undefined}
unit={natGas?.unit ?? '/MMBtu'}
icon={<Flame className="h-4 w-4" />}
sparklineData={natGasSparkline.length >= 2 ? natGasSparkline : undefined}
sparklineColor={SPARKLINE_COLORS.natGas}
trendDelta={natGasDelta}
trendLabel="vs prev"
/>
<MetricCard
title="WTI Crude Oil"
value={wtiCrude ? `$${wtiCrude.price.toFixed(2)}` : '--'}
numericValue={wtiCrude?.price}
animatedFormat={wtiCrude ? 'dollar' : undefined}
unit={wtiCrude?.unit ?? '/bbl'}
icon={<Droplets className="h-4 w-4" />}
sparklineColor={SPARKLINE_COLORS.wtiCrude}
trendDelta={wtiDelta}
trendLabel="vs prev"
/>
</div>
);
}

View File

@ -0,0 +1,65 @@
import { Sparkline } from '@/components/charts/sparkline.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { BarChart3 } from 'lucide-react';
import { fetchLatestPrices, fetchPriceSparklines } from '@/actions/prices.js';
import { deserialize } from '@/lib/superjson.js';
export async function PricesByRegion() {
const [pricesResult, sparklinesResult] = await Promise.all([fetchLatestPrices(), fetchPriceSparklines()]);
const prices = pricesResult.ok
? deserialize<
Array<{
price_mwh: number;
region_code: string;
region_name: string;
}>
>(pricesResult.data)
: [];
const sparklines = sparklinesResult.ok
? deserialize<Array<{ region_code: string; points: { value: number }[] }>>(sparklinesResult.data)
: [];
const sparklineMap: Record<string, { value: number }[]> = {};
for (const s of sparklines) {
sparklineMap[s.region_code] = s.points;
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-chart-2" />
Recent Prices by Region
</CardTitle>
</CardHeader>
<CardContent>
{prices.length > 0 ? (
<div className="max-h-100 space-y-3 overflow-y-auto pr-1">
{prices.map(p => {
const regionSparkline = sparklineMap[p.region_code];
return (
<div key={p.region_code} className="flex items-center gap-3 text-sm">
<span className="w-16 shrink-0 font-medium">{p.region_code}</span>
<div className="min-w-0 flex-1">
{regionSparkline && regionSparkline.length >= 2 && (
<Sparkline data={regionSparkline} color="hsl(210, 90%, 55%)" height={24} />
)}
</div>
<div className="flex shrink-0 items-baseline gap-1.5">
<span className="font-mono font-semibold">${p.price_mwh.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">/MWh</span>
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No price data available yet.</p>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,100 @@
import { GridStressGauge } from '@/components/dashboard/grid-stress-gauge.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { Radio } from 'lucide-react';
import { fetchRegionDemandSummary } from '@/actions/demand.js';
import { deserialize } from '@/lib/superjson.js';
interface RegionStatus {
regionCode: string;
regionName: string;
latestDemandMw: number;
peakDemandMw: number;
}
export async function StressGauges() {
const demandResult = await fetchRegionDemandSummary();
const demandRows = demandResult.ok
? deserialize<
Array<{
avg_demand: number | null;
peak_demand: number | null;
region_code: string;
region_name: string;
day: Date;
}>
>(demandResult.data)
: [];
// For each region: use the most recent day's avg_demand as "current",
// and the 7-day peak_demand as the ceiling.
const regionMap = new Map<string, RegionStatus>();
for (const row of demandRows) {
const existing = regionMap.get(row.region_code);
const demand = row.avg_demand ?? 0;
const peak = row.peak_demand ?? 0;
if (!existing) {
regionMap.set(row.region_code, {
regionCode: row.region_code,
regionName: row.region_name,
latestDemandMw: demand,
peakDemandMw: peak,
});
} else {
// The query is ordered by day ASC, so later rows overwrite latestDemand
existing.latestDemandMw = demand;
if (peak > existing.peakDemandMw) existing.peakDemandMw = peak;
}
}
const regions = [...regionMap.values()]
.filter(r => r.peakDemandMw > 0)
.sort((a, b) => b.latestDemandMw - a.latestDemandMw);
if (regions.length === 0) return null;
// Split into two columns for wider screens
const midpoint = Math.ceil(regions.length / 2);
const leftColumn = regions.slice(0, midpoint);
const rightColumn = regions.slice(midpoint);
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2">
<Radio className="h-5 w-5 text-chart-4" />
Region Grid Status
</CardTitle>
<p className="text-xs text-muted-foreground">Current demand vs. 7-day peak by region</p>
</CardHeader>
<CardContent>
<div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2">
<div className="flex flex-col gap-2.5">
{leftColumn.map(r => (
<GridStressGauge
key={r.regionCode}
regionCode={r.regionCode}
regionName={r.regionName}
demandMw={r.latestDemandMw}
peakDemandMw={r.peakDemandMw}
/>
))}
</div>
<div className="flex flex-col gap-2.5">
{rightColumn.map(r => (
<GridStressGauge
key={r.regionCode}
regionCode={r.regionCode}
regionName={r.regionName}
demandMw={r.latestDemandMw}
peakDemandMw={r.peakDemandMw}
/>
))}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -118,9 +118,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
if (authError) return authError;
const searchParams = request.nextUrl.searchParams;
const start = searchParams.get('start') ?? undefined;
const end = searchParams.get('end') ?? undefined;
// Default to last 30 days if no start date provided — commodity data
// is daily/monthly so a wider window is fine and still bounded.
const startParam = searchParams.get('start');
const start =
startParam ??
(() => {
const d = new Date();
d.setUTCDate(d.getUTCDate() - 30);
return d.toISOString().slice(0, 10);
})();
const stats: IngestionStats = { inserted: 0, updated: 0, errors: 0 };
const { rows, errors: fetchErrors } = await fetchAllCommodities(start, end);

View File

@ -5,7 +5,22 @@ import { getRegionData, getRetailElectricityPrices } from '@/lib/api/eia.js';
import { prisma } from '@/lib/db.js';
import { EIA_RESPONDENT_CODES, type RegionCode } from '@/lib/schemas/electricity.js';
const ALL_REGIONS: RegionCode[] = ['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP'];
const ALL_REGIONS: RegionCode[] = [
'PJM',
'ERCOT',
'CAISO',
'NYISO',
'ISONE',
'MISO',
'SPP',
'BPA',
'DUKE',
'SOCO',
'TVA',
'FPC',
'WAPA',
'NWMT',
];
function isRegionCode(value: string): value is RegionCode {
return value in EIA_RESPONDENT_CODES;
@ -23,9 +38,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
const searchParams = request.nextUrl.searchParams;
const regionParam = searchParams.get('region');
const start = searchParams.get('start') ?? undefined;
const end = searchParams.get('end') ?? undefined;
// Default to last 7 days if no start date provided — prevents
// auto-paginating through ALL historical EIA data (causes timeouts).
const startParam = searchParams.get('start');
const start =
startParam ??
(() => {
const d = new Date();
d.setUTCDate(d.getUTCDate() - 7);
return d.toISOString().slice(0, 10);
})();
let regions: RegionCode[];
if (regionParam) {
if (!isRegionCode(regionParam)) {
@ -58,7 +83,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
// Continue with demand data even if prices fail
}
// Build fallback: for each region, find the most recent month with data
// Build fallback: for each region, find the most recent month with data from the API
const latestPriceByRegion = new Map<string, number>();
for (const [key, price] of retailPriceByRegionMonth) {
const region = key.split(':')[0]!;
@ -68,6 +93,29 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
}
}
// If API returned no prices (e.g. recent months not yet reported),
// fall back to the last known non-zero price per region from the database.
if (retailPriceByRegionMonth.size === 0) {
const dbFallbacks = await prisma.$queryRaw<Array<{ code: string; price_mwh: number }>>`
SELECT r.code, ep.price_mwh
FROM electricity_prices ep
JOIN grid_regions r ON ep.region_id = r.id
WHERE ep.price_mwh > 0
AND (r.code, ep.timestamp) IN (
SELECT r2.code, MAX(ep2.timestamp)
FROM electricity_prices ep2
JOIN grid_regions r2 ON ep2.region_id = r2.id
WHERE ep2.price_mwh > 0
GROUP BY r2.code
)
`;
for (const row of dbFallbacks) {
if (!latestPriceByRegion.has(row.code)) {
latestPriceByRegion.set(row.code, row.price_mwh);
}
}
}
for (const regionCode of regions) {
const regionId = regionIdByCode.get(regionCode);
if (!regionId) {
@ -77,7 +125,12 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
try {
const demandData = await getRegionData(regionCode, 'D', { start, end });
const validPoints = demandData.filter((p): p is typeof p & { valueMw: number } => p.valueMw !== null);
// Reject values outside a reasonable range — the EIA API occasionally returns
// garbage (e.g. 2^31 overflow, deeply negative values). PJM peak is ~150K MW.
const MAX_DEMAND_MW = 500_000;
const validPoints = demandData.filter(
(p): p is typeof p & { valueMw: number } => p.valueMw !== null && p.valueMw >= 0 && p.valueMw <= MAX_DEMAND_MW,
);
if (validPoints.length === 0) continue;

View File

@ -5,7 +5,22 @@ import { getFuelTypeData } from '@/lib/api/eia.js';
import { prisma } from '@/lib/db.js';
import { EIA_RESPONDENT_CODES, type RegionCode } from '@/lib/schemas/electricity.js';
const ALL_REGIONS: RegionCode[] = ['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP'];
const ALL_REGIONS: RegionCode[] = [
'PJM',
'ERCOT',
'CAISO',
'NYISO',
'ISONE',
'MISO',
'SPP',
'BPA',
'DUKE',
'SOCO',
'TVA',
'FPC',
'WAPA',
'NWMT',
];
function isRegionCode(value: string): value is RegionCode {
return value in EIA_RESPONDENT_CODES;
@ -23,9 +38,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
const searchParams = request.nextUrl.searchParams;
const regionParam = searchParams.get('region');
const start = searchParams.get('start') ?? undefined;
const end = searchParams.get('end') ?? undefined;
// Default to last 7 days if no start date provided — prevents
// auto-paginating through ALL historical EIA data (causes timeouts).
const startParam = searchParams.get('start');
const start =
startParam ??
(() => {
const d = new Date();
d.setUTCDate(d.getUTCDate() - 7);
return d.toISOString().slice(0, 10);
})();
let regions: RegionCode[];
if (regionParam) {
if (!isRegionCode(regionParam)) {

View File

@ -0,0 +1,16 @@
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';
export async function DemandChartSection() {
const [demandResult, summaryResult] = await Promise.all([
fetchDemandByRegion('ALL', 'all'),
fetchRegionDemandSummary(),
]);
const demandData = demandResult.ok ? deserialize<getDemandByRegion.Result[]>(demandResult.data) : [];
const summaryData = summaryResult.ok ? deserialize<getDemandByRegion.Result[]>(summaryResult.data) : [];
return <DemandChart initialData={demandData} summaryData={summaryData} />;
}

View File

@ -6,7 +6,7 @@ export default function DemandLoading() {
<div className="px-6 py-8">
<div className="mb-8">
<Skeleton className="h-9 w-52" />
<Skeleton className="mt-2 h-5 w-[32rem] max-w-full" />
<Skeleton className="mt-2 h-5 w-lg max-w-full" />
</div>
<ChartSkeleton />

View File

@ -1,24 +1,16 @@
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 { Suspense } from 'react';
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import type { Metadata } from 'next';
import { DemandChartSection } from './_sections/demand-chart-section.js';
export const metadata: Metadata = {
title: 'Demand Analysis | Energy & AI Dashboard',
description: 'Regional electricity demand growth, peak tracking, and datacenter load impact',
};
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) : [];
export default function DemandPage() {
return (
<div className="px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 sm:mb-8">
@ -29,7 +21,9 @@ export default async function DemandPage() {
</p>
</div>
<DemandChart initialData={demandData} summaryData={summaryData} />
<Suspense fallback={<ChartSkeleton />}>
<DemandChartSection />
</Suspense>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { fetchGenerationMix } from '@/actions/generation.js';
import { GenerationChart } from '@/components/charts/generation-chart.js';
const DEFAULT_REGION = 'PJM';
const DEFAULT_TIME_RANGE = 'all' as const;
export async function GenerationChartSection() {
const result = await fetchGenerationMix(DEFAULT_REGION, DEFAULT_TIME_RANGE);
if (!result.ok) {
return (
<div className="flex h-96 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{result.error}</p>
</div>
);
}
return (
<GenerationChart initialData={result.data} initialRegion={DEFAULT_REGION} initialTimeRange={DEFAULT_TIME_RANGE} />
);
}

View File

@ -5,7 +5,7 @@ export default function GenerationLoading() {
return (
<div className="px-6 py-8">
<Skeleton className="h-9 w-48" />
<Skeleton className="mt-2 h-5 w-[30rem] max-w-full" />
<Skeleton className="mt-2 h-5 w-120 max-w-full" />
<div className="mt-8">
<ChartSkeleton />

View File

@ -1,33 +1,16 @@
import { Suspense } from 'react';
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import type { Metadata } from 'next';
import { fetchGenerationMix } from '@/actions/generation.js';
import { GenerationChart } from '@/components/charts/generation-chart.js';
import { GenerationChartSection } from './_sections/generation-chart-section.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',
};
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-4 py-6 sm:px-6 sm:py-8">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">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>
);
}
export default function GenerationPage() {
return (
<div className="px-4 py-6 sm:px-6 sm:py-8">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Generation Mix</h1>
@ -37,11 +20,9 @@ export default async function GenerationPage() {
</p>
<div className="mt-8">
<GenerationChart
initialData={result.data}
initialRegion={DEFAULT_REGION}
initialTimeRange={DEFAULT_TIME_RANGE}
/>
<Suspense fallback={<ChartSkeleton />}>
<GenerationChartSection />
</Suspense>
</div>
</div>
);

View File

@ -75,6 +75,8 @@
}
@theme inline {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@ -119,6 +121,13 @@
}
body {
@apply bg-background text-foreground;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
/* Layout height tokens for full-bleed pages (map, etc.) */
/* Ticker: py-1.5 (0.75rem) + text-sm (~1.25rem) + border-b (1px) = ~2rem + 1px */
/* Nav: h-14 (3.5rem) + header border-b (1px) */
/* Footer: py-4 (2rem) + text-xs (~1rem) + border-t (1px) */
--header-h: calc(2rem + 3.5rem + 2px);
--footer-h: calc(3rem + 1px);
}
}

View File

@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import { ThemeProvider } from 'next-themes';
import { Inter } from 'next/font/google';
import { Toaster } from 'sonner';
import { Footer } from '@/components/layout/footer.js';
@ -7,6 +8,12 @@ import { Nav } from '@/components/layout/nav.js';
import './globals.css';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export const metadata: Metadata = {
title: 'Energy & AI Dashboard',
description:
@ -15,11 +22,11 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className="flex min-h-screen flex-col antialiased">
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body className="flex min-h-dvh flex-col overflow-x-hidden font-sans antialiased">
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
<Nav />
<main className="flex-1">{children}</main>
<main className="mx-auto w-full max-w-350 flex-1">{children}</main>
<Footer />
<Toaster theme="dark" richColors position="bottom-right" />
</ThemeProvider>

View File

@ -0,0 +1,124 @@
import { fetchAllDatacentersWithLocation } from '@/actions/datacenters.js';
import { fetchAllPowerPlants } from '@/actions/power-plants.js';
import { fetchPriceHeatmapData } from '@/actions/prices.js';
import type { DatacenterMarkerData } from '@/components/map/datacenter-marker.js';
import { EnergyMapLoader } from '@/components/map/energy-map-loader.js';
import type { PowerPlantMarkerData } from '@/components/map/power-plant-marker.js';
import type { RegionHeatmapData } from '@/components/map/region-overlay.js';
import type {
getAllDatacentersWithLocation,
getAllPowerPlants,
getRegionPriceHeatmap,
} from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
interface GeoJsonPoint {
type: string;
coordinates: [number, number];
}
function isGeoJsonPoint(val: unknown): val is GeoJsonPoint {
if (typeof val !== 'object' || val === null || !('coordinates' in val)) return false;
const obj = val as Record<string, unknown>;
const coords = obj['coordinates'];
if (!Array.isArray(coords) || coords.length < 2) return false;
return typeof coords[0] === 'number' && typeof coords[1] === 'number';
}
function parseLocationGeoJson(geojson: string | null): { lat: number; lng: number } | null {
if (!geojson) return null;
try {
const parsed: unknown = JSON.parse(geojson);
if (isGeoJsonPoint(parsed)) {
return { lat: parsed.coordinates[1], lng: parsed.coordinates[0] };
}
} catch {
// Invalid JSON
}
return null;
}
function parseBoundaryGeoJson(geojsonStr: string | null): object | null {
if (!geojsonStr) return null;
try {
const parsed: unknown = JSON.parse(geojsonStr);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
} catch {
// Invalid JSON
}
return null;
}
export async function MapContent() {
const [dcResult, priceResult, ppResult] = await Promise.all([
fetchAllDatacentersWithLocation(),
fetchPriceHeatmapData(),
fetchAllPowerPlants(),
]);
const datacenters: DatacenterMarkerData[] = [];
if (dcResult.ok) {
const rows = deserialize<getAllDatacentersWithLocation.Result[]>(dcResult.data);
for (const row of rows) {
const loc = parseLocationGeoJson(row.location_geojson);
if (loc) {
datacenters.push({
id: row.id,
name: row.name,
operator: row.operator,
capacity_mw: row.capacity_mw,
status: row.status,
year_opened: row.year_opened,
region_id: row.region_id,
region_code: row.region_code,
region_name: row.region_name,
lat: loc.lat,
lng: loc.lng,
});
}
}
}
const regions: RegionHeatmapData[] = [];
if (priceResult.ok) {
const rows = deserialize<getRegionPriceHeatmap.Result[]>(priceResult.data);
for (const row of rows) {
if (!row.code || !row.name) continue;
regions.push({
code: row.code,
name: row.name,
boundaryGeoJson: parseBoundaryGeoJson(row.boundary_geojson),
avgPrice: row.avg_price,
maxPrice: row.max_price,
avgDemand: row.avg_demand,
datacenterCount: row.datacenter_count,
totalDcCapacityMw: row.total_dc_capacity_mw,
});
}
}
const powerPlants: PowerPlantMarkerData[] = [];
if (ppResult.ok) {
const rows = deserialize<getAllPowerPlants.Result[]>(ppResult.data);
for (const row of rows) {
const loc = parseLocationGeoJson(row.location_geojson);
if (loc) {
powerPlants.push({
id: row.id,
plant_code: row.plant_code,
name: row.name,
operator: row.operator,
capacity_mw: row.capacity_mw,
fuel_type: row.fuel_type,
state: row.state,
lat: loc.lat,
lng: loc.lng,
});
}
}
}
return <EnergyMapLoader datacenters={datacenters} regions={regions} powerPlants={powerPlants} />;
}

View File

@ -1,102 +1,25 @@
import { fetchAllDatacentersWithLocation } from '@/actions/datacenters.js';
import { fetchPriceHeatmapData } from '@/actions/prices.js';
import type { DatacenterMarkerData } from '@/components/map/datacenter-marker.js';
import { EnergyMapLoader } from '@/components/map/energy-map-loader.js';
import type { RegionHeatmapData } from '@/components/map/region-overlay.js';
import type { getAllDatacentersWithLocation, getRegionPriceHeatmap } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { Suspense } from 'react';
import { Skeleton } from '@/components/ui/skeleton.js';
import type { Metadata } from 'next';
import { MapContent } from './_sections/map-content.js';
export const metadata: Metadata = {
title: 'Interactive Map | Energy & AI Dashboard',
description: 'Datacenter locations, grid region overlays, and real-time electricity price heatmap',
};
interface GeoJsonPoint {
type: string;
coordinates: [number, number];
function MapSkeleton() {
return <Skeleton className="h-full w-full rounded-none" />;
}
function isGeoJsonPoint(val: unknown): val is GeoJsonPoint {
if (typeof val !== 'object' || val === null || !('coordinates' in val)) return false;
const obj = val as Record<string, unknown>;
const coords = obj['coordinates'];
if (!Array.isArray(coords) || coords.length < 2) return false;
return typeof coords[0] === 'number' && typeof coords[1] === 'number';
}
function parseLocationGeoJson(geojson: string | null): { lat: number; lng: number } | null {
if (!geojson) return null;
try {
const parsed: unknown = JSON.parse(geojson);
if (isGeoJsonPoint(parsed)) {
return { lat: parsed.coordinates[1], lng: parsed.coordinates[0] };
}
} catch {
// Invalid JSON
}
return null;
}
function parseBoundaryGeoJson(geojsonStr: string | null): object | null {
if (!geojsonStr) return null;
try {
const parsed: unknown = JSON.parse(geojsonStr);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
} catch {
// Invalid JSON
}
return null;
}
export default async function MapPage() {
const [dcResult, priceResult] = await Promise.all([fetchAllDatacentersWithLocation(), fetchPriceHeatmapData()]);
const datacenters: DatacenterMarkerData[] = [];
if (dcResult.ok) {
const rows = deserialize<getAllDatacentersWithLocation.Result[]>(dcResult.data);
for (const row of rows) {
const loc = parseLocationGeoJson(row.location_geojson);
if (loc) {
datacenters.push({
id: row.id,
name: row.name,
operator: row.operator,
capacity_mw: row.capacity_mw,
status: row.status,
year_opened: row.year_opened,
region_id: row.region_id,
region_code: row.region_code,
region_name: row.region_name,
lat: loc.lat,
lng: loc.lng,
});
}
}
}
const regions: RegionHeatmapData[] = [];
if (priceResult.ok) {
const rows = deserialize<getRegionPriceHeatmap.Result[]>(priceResult.data);
for (const row of rows) {
regions.push({
code: row.code,
name: row.name,
boundaryGeoJson: parseBoundaryGeoJson(row.boundary_geojson),
avgPrice: row.avg_price,
maxPrice: row.max_price,
avgDemand: row.avg_demand,
datacenterCount: row.datacenter_count,
totalDcCapacityMw: row.total_dc_capacity_mw,
});
}
}
export default function MapPage() {
return (
<div className="h-[calc(100vh-3.5rem-3rem)]">
<EnergyMapLoader datacenters={datacenters} regions={regions} />
<div className="mr-[calc(50%-50vw)] ml-[calc(50%-50vw)] h-[calc(100dvh-var(--header-h)-var(--footer-h))] w-screen max-w-none! overflow-hidden">
<Suspense fallback={<MapSkeleton />}>
<MapContent />
</Suspense>
</div>
);
}

View File

@ -1,291 +1,170 @@
import { Sparkline } from '@/components/charts/sparkline.js';
import { AlertsFeed } from '@/components/dashboard/alerts-feed.js';
import { GpuCalculator } from '@/components/dashboard/gpu-calculator.js';
import { GridStressGauge } from '@/components/dashboard/grid-stress-gauge.js';
import { MetricCard } from '@/components/dashboard/metric-card.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { deserialize } from '@/lib/superjson.js';
import { Activity, ArrowRight, BarChart3, Droplets, Flame, Gauge, Map as MapIcon, Server } from 'lucide-react';
import { Suspense } from 'react';
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import { MetricCardSkeleton } from '@/components/dashboard/metric-card-skeleton.js';
import { Card, CardContent, CardHeader } from '@/components/ui/card.js';
import { Skeleton } from '@/components/ui/skeleton.js';
import { Activity, ArrowRight, Map as MapIcon } from 'lucide-react';
import Link from 'next/link';
import { fetchDatacenters } from '@/actions/datacenters.js';
import { fetchRegionDemandSummary } from '@/actions/demand.js';
import {
fetchLatestCommodityPrices,
fetchLatestPrices,
fetchPriceSparklines,
fetchRecentAlerts,
} from '@/actions/prices.js';
import { AlertsSection } from './_sections/alerts-section.js';
import { DemandSummary } from './_sections/demand-summary.js';
import { GpuCalculatorSection } from './_sections/gpu-calculator-section.js';
import { HeroMetrics } from './_sections/hero-metrics.js';
import { PricesByRegion } from './_sections/prices-by-region.js';
import { StressGauges } from './_sections/stress-gauges.js';
function formatNumber(value: number, decimals = 1): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(decimals)}K`;
return value.toFixed(decimals);
function MetricCardsSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<MetricCardSkeleton key={i} />
))}
</div>
);
}
export default async function DashboardHome() {
const [pricesResult, commoditiesResult, datacentersResult, demandResult, sparklinesResult, alertsResult] =
await Promise.all([
fetchLatestPrices(),
fetchLatestCommodityPrices(),
fetchDatacenters(),
fetchRegionDemandSummary(),
fetchPriceSparklines(),
fetchRecentAlerts(),
]);
const prices = pricesResult.ok
? deserialize<
Array<{
price_mwh: number;
demand_mw: number;
region_code: string;
region_name: string;
timestamp: Date;
}>
>(pricesResult.data)
: [];
const commodities = commoditiesResult.ok
? deserialize<Array<{ commodity: string; price: number; unit: string; timestamp: Date }>>(commoditiesResult.data)
: [];
const datacenters = datacentersResult.ok
? deserialize<Array<{ id: string; capacityMw: number }>>(datacentersResult.data)
: [];
const demandRows = demandResult.ok
? deserialize<
Array<{
avg_demand: number | null;
peak_demand: number | null;
region_code: string;
region_name: string;
}>
>(demandResult.data)
: [];
const sparklines = sparklinesResult.ok
? deserialize<Array<{ region_code: string; points: { value: number }[] }>>(sparklinesResult.data)
: [];
const sparklineMap: Record<string, { value: number }[]> = {};
for (const s of sparklines) {
sparklineMap[s.region_code] = s.points;
}
// Build an aggregate sparkline from all regions (average price per time slot)
const avgSparkline: { value: number }[] =
sparklines.length > 0 && sparklines[0]
? sparklines[0].points.map((_, i) => {
const values = sparklines.map(s => s.points[i]?.value ?? 0).filter(v => v > 0);
return { value: values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0 };
})
: [];
const avgPrice = prices.length > 0 ? prices.reduce((sum, p) => sum + p.price_mwh, 0) / prices.length : 0;
const natGas = commodities.find(c => c.commodity === 'natural_gas');
const wtiCrude = commodities.find(c => c.commodity === 'wti_crude');
const totalCapacityMw = datacenters.reduce((sum, dc) => sum + dc.capacityMw, 0);
const datacenterCount = datacenters.length;
const avgDemand =
demandRows.length > 0 ? demandRows.reduce((sum, r) => sum + (r.avg_demand ?? 0), 0) / demandRows.length : 0;
const regionPrices = prices.map(p => ({
regionCode: p.region_code,
regionName: p.region_name,
priceMwh: p.price_mwh,
}));
// Aggregate demand by region for stress gauges: track latest avg_demand and peak_demand
interface RegionDemandEntry {
regionCode: string;
regionName: string;
avgDemand: number;
peakDemand: number;
}
const regionDemandMap: Record<string, RegionDemandEntry> = {};
for (const row of demandRows) {
const existing = regionDemandMap[row.region_code];
const avg = row.avg_demand ?? 0;
const peak = row.peak_demand ?? 0;
if (!existing) {
regionDemandMap[row.region_code] = {
regionCode: row.region_code,
regionName: row.region_name,
avgDemand: avg,
peakDemand: peak,
};
} else {
if (avg > existing.avgDemand) existing.avgDemand = avg;
if (peak > existing.peakDemand) existing.peakDemand = peak;
}
}
const regionDemandList = Object.values(regionDemandMap).filter(
(r): r is RegionDemandEntry => r !== undefined && r.peakDemand > 0,
function PricesByRegionSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
function AlertsSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
function GaugesSkeleton() {
return (
<Card>
<CardHeader className="pb-3">
<Skeleton className="h-5 w-44" />
<Skeleton className="mt-1 h-3 w-64" />
</CardHeader>
<CardContent>
<div className="grid gap-x-8 gap-y-2.5 md:grid-cols-2">
{Array.from({ length: 2 }).map((_, col) => (
<div key={col} className="flex flex-col gap-2.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-4 w-14" />
<Skeleton className="h-2 flex-1 rounded-full" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
))}
</div>
</CardContent>
</Card>
);
}
function DemandSummarySkeleton() {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-chart-3" />
<Skeleton className="h-5 w-40" />
</div>
<Skeleton className="mt-1 h-3 w-56" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex flex-col gap-1">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-6 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
export default function DashboardHome() {
return (
<div className="px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 sm:mb-8">
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Dashboard</h1>
<p className="mt-1 text-muted-foreground">
Real-time overview of AI datacenter energy impact across US grid regions.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<MetricCard
title="Avg Electricity Price"
value={avgPrice > 0 ? `$${avgPrice.toFixed(2)}` : '--'}
numericValue={avgPrice > 0 ? avgPrice : undefined}
animatedFormat={avgPrice > 0 ? 'dollar' : undefined}
unit="/MWh"
icon={<BarChart3 className="h-4 w-4" />}
sparklineData={avgSparkline}
sparklineColor="hsl(210, 90%, 55%)"
/>
<MetricCard
title="Total DC Capacity"
value={formatNumber(totalCapacityMw)}
numericValue={totalCapacityMw > 0 ? totalCapacityMw : undefined}
animatedFormat={totalCapacityMw > 0 ? 'compact' : undefined}
unit="MW"
icon={<Activity className="h-4 w-4" />}
/>
<MetricCard
title="Datacenters Tracked"
value={datacenterCount.toLocaleString()}
icon={<Server className="h-4 w-4" />}
/>
<MetricCard
title="Natural Gas Spot"
value={natGas ? `$${natGas.price.toFixed(2)}` : '--'}
numericValue={natGas?.price}
animatedFormat={natGas ? 'dollar' : undefined}
unit={natGas?.unit ?? '/MMBtu'}
icon={<Flame className="h-4 w-4" />}
/>
<MetricCard
title="WTI Crude Oil"
value={wtiCrude ? `$${wtiCrude.price.toFixed(2)}` : '--'}
numericValue={wtiCrude?.price}
animatedFormat={wtiCrude ? 'dollar' : undefined}
unit={wtiCrude?.unit ?? '/bbl'}
icon={<Droplets className="h-4 w-4" />}
/>
</div>
<div className="mt-8 grid gap-4 lg:grid-cols-2">
<Link href="/map">
<Card className="group cursor-pointer transition-colors hover:border-primary/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapIcon className="h-5 w-5 text-chart-1" />
Interactive Map
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Explore datacenter locations, grid region overlays, and real-time price heatmaps.
</p>
<span className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary transition-transform group-hover:translate-x-1">
View Map <ArrowRight className="h-4 w-4" />
</span>
</CardContent>
</Card>
<div className="mb-6 flex items-end justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Dashboard</h1>
<p className="mt-1 text-sm text-muted-foreground">
Real-time overview of AI datacenter energy impact across US grid regions.
</p>
</div>
<Link
href="/map"
className="group hidden items-center gap-1.5 rounded-md border border-border/50 bg-muted/30 px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground sm:inline-flex">
<MapIcon className="h-3.5 w-3.5" />
Open Map
<ArrowRight className="h-3 w-3 transition-transform group-hover:translate-x-0.5" />
</Link>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-chart-2" />
Recent Prices by Region
</CardTitle>
</CardHeader>
<CardContent>
{prices.length > 0 ? (
<div className="space-y-3">
{prices.map(p => {
const regionSparkline = sparklineMap[p.region_code];
return (
<div key={p.region_code} className="flex items-center gap-3 text-sm">
<span className="w-16 shrink-0 font-medium">{p.region_code}</span>
<div className="min-w-0 flex-1">
{regionSparkline && regionSparkline.length >= 2 && (
<Sparkline data={regionSparkline} color="hsl(210, 90%, 55%)" height={24} />
)}
</div>
<div className="flex shrink-0 items-baseline gap-1.5">
<span className="font-mono font-semibold">${p.price_mwh.toFixed(2)}</span>
<span className="text-xs text-muted-foreground">/MWh</span>
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No price data available yet.</p>
)}
</CardContent>
</Card>
</div>
{regionPrices.length > 0 && (
<div className="mt-8">
<GpuCalculator regionPrices={regionPrices} />
</div>
)}
{/* Hero metric cards */}
<Suspense fallback={<MetricCardsSkeleton />}>
<HeroMetrics />
</Suspense>
{regionDemandList.length > 0 && (
<div className="mt-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gauge className="h-5 w-5 text-chart-4" />
Grid Stress by Region
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7">
{regionDemandList.map(r => (
<GridStressGauge
key={r.regionCode}
regionCode={r.regionCode}
regionName={r.regionName}
demandMw={r.avgDemand}
capacityMw={r.peakDemand}
/>
))}
</div>
</CardContent>
</Card>
</div>
)}
{/* Row 2: Prices + Demand Highlights + Alerts — three-column on large screens */}
<div className="mt-6 grid gap-4 lg:grid-cols-3">
<Suspense fallback={<PricesByRegionSkeleton />}>
<PricesByRegion />
</Suspense>
<div className="mt-8 grid gap-4 lg:grid-cols-2">
{alertsResult.ok && <AlertsFeed initialData={alertsResult.data} />}
<Suspense fallback={<DemandSummarySkeleton />}>
<DemandSummary />
</Suspense>
{avgDemand > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-chart-3" />
Demand Summary (7-day avg)
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Average regional demand:{' '}
<span className="font-mono font-semibold text-foreground">{formatNumber(avgDemand)} MW</span>
</p>
</CardContent>
</Card>
)}
<Suspense fallback={<AlertsSkeleton />}>
<AlertsSection />
</Suspense>
</div>
{/* Row 3: GPU Calculator + Grid Stress side by side */}
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_1fr]">
<Suspense fallback={<ChartSkeleton />}>
<GpuCalculatorSection />
</Suspense>
<Suspense fallback={<GaugesSkeleton />}>
<StressGauges />
</Suspense>
</div>
</div>
);

View File

@ -0,0 +1,16 @@
import { fetchRegionCapacityVsPrice } from '@/actions/prices.js';
import { CorrelationChart } from '@/components/charts/correlation-chart.js';
export async function CorrelationSection() {
const correlationResult = await fetchRegionCapacityVsPrice();
if (!correlationResult.ok) {
return (
<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>
);
}
return <CorrelationChart data={correlationResult.data} />;
}

View File

@ -0,0 +1,42 @@
import { fetchCapacityPriceTimeline, fetchDcPriceImpact } from '@/actions/dc-impact.js';
import { DcImpactTimeline } from '@/components/charts/dc-impact-timeline.js';
import { DcPriceImpactBars } from '@/components/charts/dc-price-impact-bars.js';
const DEFAULT_REGION = 'DUKE';
export async function DcImpactSection() {
const [timelineResult, impactResult] = await Promise.all([
fetchCapacityPriceTimeline(DEFAULT_REGION),
fetchDcPriceImpact(),
]);
if (!timelineResult.ok) {
return (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{timelineResult.error}</p>
</div>
);
}
return (
<div className="space-y-6">
<DcImpactTimeline
initialData={timelineResult.data}
initialRegion={DEFAULT_REGION}
onRegionChange={async (region: string) => {
'use server';
const result = await fetchCapacityPriceTimeline(region);
if (!result.ok) throw new Error(result.error);
return result.data;
}}
/>
{impactResult.ok ? (
<DcPriceImpactBars data={impactResult.data} />
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{impactResult.error}</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,64 @@
import { readFile } from 'fs/promises';
import { join } from 'path';
import { z } from 'zod';
import { fetchAllRegionPriceTrends, fetchCommodityTrends } from '@/actions/prices.js';
import type { AIMilestone } from '@/components/charts/price-chart.js';
import { PriceChart } from '@/components/charts/price-chart.js';
const AIMilestoneSchema = z.object({
date: z.string(),
title: z.string(),
description: z.string(),
category: z.string(),
}) satisfies z.ZodType<AIMilestone>;
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 async function PriceChartSection() {
const defaultRange = 'all' as const;
const [priceResult, commodityResult, milestones] = await Promise.all([
fetchAllRegionPriceTrends(defaultRange),
fetchCommodityTrends(defaultRange),
loadMilestones(),
]);
if (!priceResult.ok || !commodityResult.ok) {
return (
<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>
);
}
return (
<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 };
}}
/>
);
}

View File

@ -1,46 +1,18 @@
import { readFile } from 'fs/promises';
import type { Metadata } from 'next';
import { join } from 'path';
import { z } from 'zod';
import { Suspense } from 'react';
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';
import { ChartSkeleton } from '@/components/charts/chart-skeleton.js';
import type { Metadata } from 'next';
import { CorrelationSection } from './_sections/correlation-section.js';
import { DcImpactSection } from './_sections/dc-impact-section.js';
import { PriceChartSection } from './_sections/price-chart-section.js';
export const metadata: Metadata = {
title: 'Price Trends | Energy & AI Dashboard',
description: 'Regional electricity price trends, commodity overlays, and AI milestone annotations',
};
const AIMilestoneSchema = z.object({
date: z.string(),
title: z.string(),
description: z.string(),
category: z.string(),
}) satisfies z.ZodType<AIMilestone>;
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(),
]);
export default function TrendsPage() {
return (
<div className="space-y-6 px-4 py-6 sm:px-6 sm:py-8">
<div>
@ -50,38 +22,17 @@ export default async function TrendsPage() {
</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>
)}
<Suspense fallback={<ChartSkeleton />}>
<PriceChartSection />
</Suspense>
{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>
)}
<Suspense fallback={<ChartSkeleton />}>
<DcImpactSection />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<CorrelationSection />
</Suspense>
</div>
);
}

View File

@ -1,7 +1,7 @@
'use client';
import { useMemo } from 'react';
import { CartesianGrid, Label, Scatter, ScatterChart, XAxis, YAxis, ZAxis } from 'recharts';
import { CartesianGrid, ComposedChart, Label, Line, Scatter, XAxis, YAxis, ZAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
@ -26,6 +26,13 @@ const REGION_COLORS: Record<string, string> = {
ISONE: 'hsl(340, 70%, 55%)',
MISO: 'hsl(55, 80%, 50%)',
SPP: 'hsl(180, 60%, 45%)',
BPA: 'hsl(95, 55%, 50%)',
NWMT: 'hsl(310, 50%, 55%)',
WAPA: 'hsl(165, 50%, 50%)',
TVA: 'hsl(15, 70%, 50%)',
DUKE: 'hsl(240, 55%, 60%)',
SOCO: 'hsl(350, 55%, 50%)',
FPC: 'hsl(45, 60%, 45%)',
};
const chartConfig: ChartConfig = {
@ -40,6 +47,64 @@ interface ScatterPoint {
total_capacity_mw: number;
avg_price: number;
fill: string;
/** Dot radius scales with capacity — used as ZAxis value */
z: number;
}
interface TrendLinePoint {
total_capacity_mw: number;
trendPrice: number;
}
interface RegressionResult {
slope: number;
intercept: number;
r2: number;
line: TrendLinePoint[];
}
/** Simple OLS linear regression: y = slope * x + intercept */
function linearRegression(points: ScatterPoint[]): RegressionResult | null {
const n = points.length;
if (n < 2) return null;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumX2 = 0;
for (const p of points) {
sumX += p.total_capacity_mw;
sumY += p.avg_price;
sumXY += p.total_capacity_mw * p.avg_price;
sumX2 += p.total_capacity_mw * p.total_capacity_mw;
}
const denom = n * sumX2 - sumX * sumX;
if (denom === 0) return null;
const slope = (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
// Pearson R squared
const ssRes = points.reduce((s, p) => {
const predicted = slope * p.total_capacity_mw + intercept;
return s + (p.avg_price - predicted) ** 2;
}, 0);
const meanY = sumY / n;
const ssTot = points.reduce((s, p) => s + (p.avg_price - meanY) ** 2, 0);
const r2 = ssTot === 0 ? 0 : 1 - ssRes / ssTot;
// Build line points from min to max X
const xs = points.map(p => p.total_capacity_mw);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const line: TrendLinePoint[] = [
{ total_capacity_mw: minX, trendPrice: slope * minX + intercept },
{ total_capacity_mw: maxX, trendPrice: slope * maxX + intercept },
];
return { slope, intercept, r2, line };
}
function isRecord(value: unknown): value is Record<string, unknown> {
@ -61,6 +126,7 @@ function getScatterPayload(obj: Record<string, unknown>): ScatterPoint | null {
total_capacity_mw: getNumberProp(payload, 'total_capacity_mw'),
avg_price: getNumberProp(payload, 'avg_price'),
fill,
z: getNumberProp(payload, 'z'),
};
}
@ -71,10 +137,19 @@ function CustomDot(props: unknown): React.JSX.Element {
const payload = getScatterPayload(props);
if (!payload) return <g />;
// Radius derived from ZAxis mapping — use z value to compute a proportional radius
const radius = Math.max(6, Math.min(20, payload.z / 80));
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}>
<circle cx={cx} cy={cy} r={radius} fill={payload.fill} fillOpacity={0.6} stroke={payload.fill} strokeWidth={2} />
<text
x={cx}
y={cy - radius - 6}
textAnchor="middle"
fill="var(--color-foreground)"
fontSize={11}
fontWeight={600}>
{payload.region_code}
</text>
</g>
@ -93,10 +168,32 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
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%)',
z: Math.round(r.total_capacity_mw),
})),
[rows],
);
const regression = useMemo(() => linearRegression(scatterData), [scatterData]);
// Merge scatter + trend line data for ComposedChart
const combinedData = useMemo(() => {
const scattered = scatterData.map(p => ({
...p,
trendPrice: undefined as number | undefined,
}));
if (!regression) return scattered;
// Add two trend-line-only points
const trendPoints = regression.line.map(t => ({
region_code: '',
avg_price: undefined as number | undefined,
total_capacity_mw: t.total_capacity_mw,
fill: '',
z: 0,
trendPrice: Number(t.trendPrice.toFixed(2)),
}));
return [...scattered, ...trendPoints].sort((a, b) => a.total_capacity_mw - b.total_capacity_mw);
}, [scatterData, regression]);
if (scatterData.length === 0) {
return (
<Card>
@ -118,18 +215,37 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
return (
<Card>
<CardHeader>
<CardTitle>DC Capacity vs. Electricity Price</CardTitle>
<CardDescription>Datacenter capacity (MW) versus average electricity price per region</CardDescription>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>DC Capacity vs. Electricity Price</CardTitle>
<CardDescription>Datacenter capacity (MW) versus average electricity price per region</CardDescription>
</div>
{regression && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 px-3 py-2">
<div className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">R² = {regression.r2.toFixed(3)}</span>
<span className="ml-2">
{regression.r2 >= 0.7 ? 'Strong' : regression.r2 >= 0.4 ? 'Moderate' : 'Weak'} correlation
</span>
</div>
<div className="h-3 w-px bg-border" />
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-block h-0.5 w-4 bg-foreground/40" />
Trend line
</div>
</div>
)}
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[350px] w-full">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<ChartContainer config={chartConfig} className="h-[40vh] max-h-125 min-h-75 w-full">
<ComposedChart data={combinedData} 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 }}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v} MW`}>
@ -137,25 +253,26 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
value="Total DC Capacity (MW)"
offset={-10}
position="insideBottom"
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
</XAxis>
<YAxis
type="number"
dataKey="avg_price"
name="Avg Price"
tick={{ fontSize: 11 }}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}>
tickFormatter={(v: number) => `$${v}`}
allowDataOverflow>
<Label
value="Avg Price ($/MWh)"
angle={-90}
position="insideLeft"
style={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
</YAxis>
<ZAxis range={[200, 200]} />
<ZAxis dataKey="z" range={[100, 1600]} />
<ChartTooltip
content={
<ChartTooltipContent
@ -169,8 +286,21 @@ export function CorrelationChart({ data }: CorrelationChartProps) {
/>
}
/>
<Scatter name="Regions" data={scatterData} shape={CustomDot} />
</ScatterChart>
{/* Trend line */}
{regression && (
<Line
type="linear"
dataKey="trendPrice"
stroke="color-mix(in oklch, var(--color-foreground) 30%, transparent)"
strokeWidth={2}
strokeDasharray="8 4"
dot={false}
connectNulls
isAnimationActive={false}
/>
)}
<Scatter name="Regions" dataKey="avg_price" data={scatterData} shape={CustomDot} />
</ComposedChart>
</ChartContainer>
</CardContent>
</Card>

View File

@ -0,0 +1,233 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { Area, CartesianGrid, ComposedChart, Line, ReferenceLine, XAxis, YAxis } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
import type { getCapacityPriceTimeline } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { cn, VALID_REGION_CODES } from '@/lib/utils.js';
interface DcImpactTimelineProps {
initialData: SuperJSONResult;
initialRegion: string;
onRegionChange: (region: string) => Promise<SuperJSONResult>;
}
const REGION_LABELS: Record<string, string> = {
PJM: 'PJM (Mid-Atlantic)',
ERCOT: 'ERCOT (Texas)',
CAISO: 'CAISO (California)',
NYISO: 'NYISO (New York)',
ISONE: 'ISO-NE (New England)',
MISO: 'MISO (Midwest)',
SPP: 'SPP (Central)',
BPA: 'BPA (Pacific NW)',
DUKE: 'Duke (Southeast)',
SOCO: 'SOCO (Southern)',
TVA: 'TVA (Tennessee Valley)',
FPC: 'FPC (Florida)',
WAPA: 'WAPA (Western)',
NWMT: 'NWMT (Montana)',
};
const chartConfig: ChartConfig = {
avg_price: {
label: 'Avg Price ($/MWh)',
color: 'hsl(210, 90%, 55%)',
},
cumulative_capacity_mw: {
label: 'DC Capacity (MW)',
color: 'hsl(160, 70%, 45%)',
},
};
interface ChartRow {
monthLabel: string;
monthTs: number;
avg_price: number | null;
cumulative_capacity_mw: number;
}
function transformData(rows: getCapacityPriceTimeline.Result[]): {
chartData: ChartRow[];
dcEventMonths: { label: string; capacityMw: number }[];
} {
const chartData: ChartRow[] = [];
let prevCapacity = 0;
const dcEventMonths: { label: string; capacityMw: number }[] = [];
for (const row of rows) {
if (!row.month) continue;
const ts = row.month.getTime();
const label = row.month.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
const capacity = row.cumulative_capacity_mw ?? 0;
if (capacity > prevCapacity && prevCapacity > 0) {
dcEventMonths.push({ label, capacityMw: capacity });
} else if (capacity > 0 && prevCapacity === 0) {
dcEventMonths.push({ label, capacityMw: capacity });
}
chartData.push({
monthLabel: label,
monthTs: ts,
avg_price: row.avg_price,
cumulative_capacity_mw: capacity,
});
prevCapacity = capacity;
}
return { chartData, dcEventMonths };
}
export function DcImpactTimeline({ initialData, initialRegion, onRegionChange }: DcImpactTimelineProps) {
const [dataSerialized, setDataSerialized] = useState<SuperJSONResult>(initialData);
const [region, setRegion] = useState(initialRegion);
const [loading, setLoading] = useState(false);
const rows = useMemo(() => deserialize<getCapacityPriceTimeline.Result[]>(dataSerialized), [dataSerialized]);
const { chartData, dcEventMonths } = useMemo(() => transformData(rows), [rows]);
const handleRegionChange = useCallback(
async (newRegion: string) => {
setRegion(newRegion);
setLoading(true);
try {
const result = await onRegionChange(newRegion);
setDataSerialized(result);
} finally {
setLoading(false);
}
},
[onRegionChange],
);
const regions = Array.from(VALID_REGION_CODES);
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Datacenter Capacity vs. Electricity Price</CardTitle>
<CardDescription>Monthly electricity price overlaid with cumulative datacenter capacity</CardDescription>
</div>
<Select value={region} onValueChange={handleRegionChange}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Select region" />
</SelectTrigger>
<SelectContent>
{regions.map(code => (
<SelectItem key={code} value={code}>
{REGION_LABELS[code] ?? code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<div className={cn('relative', loading && 'opacity-50 transition-opacity')}>
{chartData.length === 0 ? (
<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 for {REGION_LABELS[region] ?? region}.</p>
</div>
) : (
<ChartContainer config={chartConfig} className="h-[50vh] max-h-[600px] min-h-[300px] w-full">
<ComposedChart data={chartData} margin={{ top: 20, right: 60, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="monthLabel"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="price"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `$${value}`}
label={{
value: '$/MWh',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}}
/>
<YAxis
yAxisId="capacity"
orientation="right"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `${value} MW`}
label={{
value: 'DC Capacity (MW)',
angle: 90,
position: 'insideRight',
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => {
const numVal = Number(value);
if (name === 'avg_price') {
return [`$${numVal.toFixed(2)}/MWh`, undefined];
}
return [`${numVal.toFixed(0)} MW`, undefined];
}}
/>
}
/>
<Area
yAxisId="capacity"
type="stepAfter"
dataKey="cumulative_capacity_mw"
fill="var(--color-cumulative_capacity_mw)"
fillOpacity={0.15}
stroke="var(--color-cumulative_capacity_mw)"
strokeWidth={1.5}
isAnimationActive={chartData.length <= 200}
/>
<Line
yAxisId="price"
type="monotone"
dataKey="avg_price"
stroke="var(--color-avg_price)"
strokeWidth={2}
dot={false}
connectNulls
isAnimationActive={chartData.length <= 200}
/>
{dcEventMonths.map(event => (
<ReferenceLine
key={event.label}
x={event.label}
yAxisId="price"
stroke="hsl(40, 80%, 55%)"
strokeDasharray="4 4"
strokeWidth={1}
label={{
value: `+${event.capacityMw.toFixed(0)} MW`,
position: 'top',
style: { fontSize: 9, fill: 'hsl(40, 80%, 55%)' },
}}
/>
))}
</ComposedChart>
</ChartContainer>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,175 @@
'use client';
import { useMemo } from 'react';
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } 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 type { getDcPriceImpact } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
interface DcPriceImpactBarsProps {
data: SuperJSONResult;
}
const chartConfig: ChartConfig = {
avg_price_before: {
label: 'Avg Price Before',
color: 'hsl(210, 70%, 50%)',
},
avg_price_after: {
label: 'Avg Price After',
color: 'hsl(25, 80%, 55%)',
},
};
interface BarRow {
dc_name: string;
region_code: string;
capacity_mw: number;
year_opened: number;
avg_price_before: number;
avg_price_after: number;
pct_change: number;
increased: boolean;
}
function transformImpactData(rows: getDcPriceImpact.Result[]): BarRow[] {
return rows
.filter(r => r.avg_price_before !== null && r.avg_price_after !== null)
.map(r => ({
dc_name: r.dc_name,
region_code: r.region_code,
capacity_mw: r.capacity_mw,
year_opened: r.year_opened,
avg_price_before: r.avg_price_before!,
avg_price_after: r.avg_price_after!,
pct_change: r.pct_change ?? 0,
increased: (r.avg_price_after ?? 0) >= (r.avg_price_before ?? 0),
}))
.sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change));
}
export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
const rawRows = useMemo(() => deserialize<getDcPriceImpact.Result[]>(data), [data]);
const barData = useMemo(() => transformImpactData(rawRows), [rawRows]);
const avgPctChange = useMemo(() => {
if (barData.length === 0) return 0;
return barData.reduce((sum, r) => sum + r.pct_change, 0) / barData.length;
}, [barData]);
if (barData.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Price Impact by Datacenter</CardTitle>
<CardDescription>No datacenter opening events with sufficient price data.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Price Impact by Datacenter</CardTitle>
<CardDescription>
Average electricity price 6 months before vs. 6 months after datacenter opening
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-3 py-2">
<span className="text-xs text-muted-foreground">Avg price change:</span>
<span
className={`text-sm font-semibold tabular-nums ${avgPctChange >= 0 ? 'text-red-400' : 'text-emerald-400'}`}>
{avgPctChange >= 0 ? '+' : ''}
{avgPctChange.toFixed(1)}%
</span>
</div>
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[40vh] max-h-[500px] min-h-[250px] w-full">
<BarChart
data={barData}
margin={{ top: 10, right: 20, left: 10, bottom: 40 }}
barCategoryGap="20%"
barGap={2}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" vertical={false} />
<XAxis
dataKey="dc_name"
tick={{ fontSize: 10, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
angle={-35}
textAnchor="end"
interval={0}
height={60}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `$${value}`}
label={{
value: '$/MWh',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={label => {
const labelStr = typeof label === 'string' || typeof label === 'number' ? String(label) : '';
const item = barData.find(d => d.dc_name === labelStr);
if (!item) return labelStr;
return `${item.dc_name} (${item.region_code}, ${item.year_opened}) — ${item.capacity_mw} MW`;
}}
formatter={(value, name) => {
const numVal = Number(value);
const nameStr = String(name);
if (nameStr === 'avg_price_before') return [`$${numVal.toFixed(2)}/MWh`, undefined];
if (nameStr === 'avg_price_after') return [`$${numVal.toFixed(2)}/MWh`, undefined];
return [`${numVal}`, undefined];
}}
/>
}
/>
<Bar dataKey="avg_price_before" fill="var(--color-avg_price_before)" radius={[3, 3, 0, 0]} />
<Bar dataKey="avg_price_after" radius={[3, 3, 0, 0]}>
{barData.map(entry => (
<Cell key={entry.dc_name} fill={entry.increased ? 'hsl(0, 70%, 55%)' : 'hsl(145, 60%, 45%)'} />
))}
</Bar>
</BarChart>
</ChartContainer>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(210, 70%, 50%)' }} />
Before DC Opening
</div>
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(0, 70%, 55%)' }} />
After (Price Increased)
</div>
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(145, 60%, 45%)' }} />
After (Price Decreased)
</div>
</div>
<p className="mt-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs leading-relaxed text-amber-300/80">
Correlation does not imply causation. Electricity prices are influenced by many factors including fuel costs,
weather, grid congestion, regulatory changes, and seasonal demand patterns. Datacenter openings are one of
many concurrent variables.
</p>
</CardContent>
</Card>
);
}

View File

@ -15,7 +15,7 @@ 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';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
interface DemandRow {
region_code: string;
@ -37,6 +37,8 @@ const TIME_RANGES: { value: TimeRange; label: string }[] = [
{ value: '30d', label: '30D' },
{ value: '90d', label: '90D' },
{ value: '1y', label: '1Y' },
{ value: '5y', label: '5Y' },
{ value: 'all', label: 'ALL' },
];
const REGION_COLORS: Record<string, string> = {
@ -47,6 +49,13 @@ const REGION_COLORS: Record<string, string> = {
ISONE: 'hsl(350, 70%, 55%)',
MISO: 'hsl(60, 70%, 50%)',
SPP: 'hsl(180, 60%, 50%)',
BPA: 'hsl(95, 55%, 50%)',
NWMT: 'hsl(310, 50%, 55%)',
WAPA: 'hsl(165, 50%, 50%)',
TVA: 'hsl(15, 70%, 50%)',
DUKE: 'hsl(240, 55%, 60%)',
SOCO: 'hsl(350, 55%, 50%)',
FPC: 'hsl(45, 60%, 45%)',
};
function formatDemandValue(value: number): string {
@ -59,11 +68,12 @@ function formatDateLabel(date: Date): string {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export function DemandChart({ initialData, summaryData }: DemandChartProps) {
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
const [timeRange, setTimeRange] = useState<TimeRange>('all');
const [selectedRegion, setSelectedRegion] = useState<string>('ALL');
const [chartData, setChartData] = useState<DemandRow[]>(initialData);
const [loading, setLoading] = useState(false);
@ -138,7 +148,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
const activeRegions = selectedRegion === 'ALL' ? regions : regions.filter(r => r.code === selectedRegion);
for (const region of activeRegions) {
config[region.code] = {
label: region.name,
label: region.code,
color: REGION_COLORS[region.code] ?? 'hsl(0, 0%, 60%)',
};
}
@ -277,15 +287,22 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
</CardHeader>
<CardContent>
{hasData ? (
<ChartContainer config={trendChartConfig} className="h-[400px] w-full">
<ChartContainer config={trendChartConfig} className="h-[50vh] max-h-150 min-h-75 w-full">
<ComposedChart data={trendChartData}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => formatDemandValue(value)}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<ChartTooltip
content={
@ -307,6 +324,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
strokeWidth={2}
dot={false}
connectNulls
isAnimationActive={trendChartData.length <= 200}
/>
))}
</ComposedChart>
@ -342,7 +360,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
{idx + 1}
</span>
<div>
<p className="text-sm font-medium">{peak.regionName}</p>
<p className="font-mono text-sm font-medium">{peak.regionCode}</p>
<p className="text-xs text-muted-foreground">{formatDateLabel(peak.day)}</p>
</div>
</div>
@ -370,7 +388,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
<CardContent>
{hasDcImpact ? (
<>
<ChartContainer config={dcImpactConfig} className="h-[280px] w-full">
<ChartContainer config={dcImpactConfig} className="h-70 w-full">
<BarChart data={dcImpactData} layout="vertical">
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis
@ -378,8 +396,16 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => formatDemandValue(value)}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<YAxis
type="category"
dataKey="region"
tickLine={false}
axisLine={false}
width={55}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<YAxis type="category" dataKey="region" tickLine={false} axisLine={false} width={55} />
<ChartTooltip
content={
<ChartTooltipContent
@ -400,7 +426,7 @@ export function DemandChart({ initialData, summaryData }: DemandChartProps) {
<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>
<span className="font-mono text-xs font-medium text-muted-foreground">{row.region}</span>
<div className="flex items-center gap-3">
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
<div

View File

@ -14,11 +14,11 @@ import {
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 type { getGenerationHourly } from '@/generated/prisma/sql.js';
import { deserialize } from '@/lib/superjson.js';
import { formatMarketTime } from '@/lib/utils.js';
import { formatMarketDate, formatMarketDateTime, formatMarketTime } from '@/lib/utils.js';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
const REGIONS = [
{ code: 'PJM', name: 'PJM (Mid-Atlantic)' },
@ -36,6 +36,8 @@ const TIME_RANGES: { value: TimeRange; label: string }[] = [
{ value: '30d', label: '30D' },
{ value: '90d', label: '90D' },
{ value: '1y', label: '1Y' },
{ value: '5y', label: '5Y' },
{ value: 'all', label: 'ALL' },
];
const FUEL_TYPES = ['gas', 'nuclear', 'wind', 'solar', 'coal', 'hydro', 'other'] as const;
@ -84,7 +86,7 @@ function resolveFuelType(raw: string): FuelType {
return EIA_FUEL_MAP[raw] ?? (isFuelType(raw) ? raw : 'other');
}
const TIME_RANGE_SET: Set<string> = new Set(['24h', '7d', '30d', '90d', '1y']);
const TIME_RANGE_SET: Set<string> = new Set(['24h', '7d', '30d', '90d', '1y', '5y', 'all']);
function isTimeRange(value: string): value is TimeRange {
return TIME_RANGE_SET.has(value);
@ -106,7 +108,7 @@ interface PivotedRow {
other: number;
}
function pivotGenerationData(rows: getGenerationMix.Result[], regionCode: string): PivotedRow[] {
function pivotGenerationData(rows: getGenerationHourly.Result[], regionCode: string): PivotedRow[] {
const byTimestamp = new Map<number, PivotedRow>();
for (const row of rows) {
@ -165,7 +167,7 @@ function computeGenerationSplit(data: PivotedRow[]): GenerationSplit {
}
interface GenerationChartProps {
initialData: ReturnType<typeof import('@/lib/superjson.js').serialize<getGenerationMix.Result[]>>;
initialData: ReturnType<typeof import('@/lib/superjson.js').serialize<getGenerationHourly.Result[]>>;
initialRegion: string;
initialTimeRange: TimeRange;
}
@ -177,7 +179,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const rows = useMemo(() => deserialize<getGenerationMix.Result[]>(serializedData), [serializedData]);
const rows = useMemo(() => deserialize<getGenerationHourly.Result[]>(serializedData), [serializedData]);
const chartData = useMemo(() => pivotGenerationData(rows, regionCode), [rows, regionCode]);
const split = useMemo(() => computeGenerationSplit(chartData), [chartData]);
@ -228,7 +230,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
</div>
<div className="flex gap-2">
<Select value={regionCode} onValueChange={handleRegionChange}>
<SelectTrigger className="w-[200px]">
<SelectTrigger className="w-50">
<SelectValue placeholder="Select region" />
</SelectTrigger>
<SelectContent>
@ -240,7 +242,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="w-[90px]">
<SelectTrigger className="w-22.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -258,14 +260,14 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
{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">
<div className="flex h-64 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">
<ChartContainer config={chartConfig} className="h-[50vh] max-h-150 min-h-80 w-full">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
{FUEL_TYPES.map(fuel => (
@ -277,19 +279,28 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/50" />
<XAxis
dataKey="dateLabel"
dataKey="timestamp"
type="number"
domain={['dataMin', 'dataMax']}
scale="time"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={40}
className="text-xs"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickFormatter={(ts: number) => {
const d = new Date(ts);
if (timeRange === '24h') return formatMarketTime(d, regionCode);
if (timeRange === '7d') return formatMarketDateTime(d, regionCode);
return formatMarketDate(d, regionCode);
}}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value: number) => (value >= 1000 ? `${(value / 1000).toFixed(0)}GW` : `${value}MW`)}
className="text-xs"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
<ChartTooltip
content={
@ -302,7 +313,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
'timestamp' in firstPayload &&
typeof firstPayload.timestamp === 'number'
) {
return formatMarketTime(new Date(firstPayload.timestamp), regionCode);
return formatMarketDateTime(new Date(firstPayload.timestamp), regionCode);
}
return '';
}}
@ -328,6 +339,7 @@ export function GenerationChart({ initialData, initialRegion, initialTimeRange }
fill={`url(#fill-${fuel})`}
stroke={`var(--color-${fuel})`}
strokeWidth={1.5}
isAnimationActive={chartData.length <= 200}
/>
))}
<ChartLegend content={<ChartLegendContent />} />

View File

@ -1,6 +1,6 @@
'use client';
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson';
@ -53,7 +53,11 @@ interface PriceChartProps {
onTimeRangeChange: (range: TimeRange) => Promise<TimeRangeChangeResult>;
}
/** The 7 ISO/RTO regions shown by default */
const ISO_REGIONS = new Set(['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP']);
const REGION_COLORS: Record<string, string> = {
// ISO regions — high-contrast, well-separated hues
PJM: 'hsl(210, 90%, 55%)',
ERCOT: 'hsl(25, 90%, 55%)',
CAISO: 'hsl(140, 70%, 45%)',
@ -61,6 +65,14 @@ const REGION_COLORS: Record<string, string> = {
ISONE: 'hsl(340, 70%, 55%)',
MISO: 'hsl(55, 80%, 50%)',
SPP: 'hsl(180, 60%, 45%)',
// Non-ISO regions — secondary palette
BPA: 'hsl(95, 55%, 50%)',
NWMT: 'hsl(310, 50%, 55%)',
WAPA: 'hsl(165, 50%, 50%)',
TVA: 'hsl(15, 70%, 50%)',
DUKE: 'hsl(240, 55%, 60%)',
SOCO: 'hsl(350, 55%, 50%)',
FPC: 'hsl(45, 60%, 45%)',
};
const COMMODITY_COLORS: Record<string, string> = {
@ -107,10 +119,27 @@ interface PivotedRow {
[key: string]: number | string;
}
/** Choose date format based on the data span — short ranges need time-of-day. */
function formatTimestamp(date: Date, timeRange: TimeRange): string {
if (timeRange === '24h' || timeRange === '7d') {
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
if (timeRange === '30d' || timeRange === '90d') {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
}
function pivotData(
priceRows: PriceTrendRow[],
commodityRows: CommodityRow[],
showCommodities: boolean,
timeRange: TimeRange,
): { pivoted: PivotedRow[]; regions: string[]; commodities: string[] } {
const regionSet = new Set<string>();
const commoditySet = new Set<string>();
@ -123,12 +152,7 @@ function pivotData(
if (!byTimestamp.has(ts)) {
byTimestamp.set(ts, {
timestamp: ts,
timestampDisplay: row.timestamp.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
timestampDisplay: formatTimestamp(row.timestamp, timeRange),
});
}
@ -144,12 +168,7 @@ function pivotData(
if (!byTimestamp.has(ts)) {
byTimestamp.set(ts, {
timestamp: ts,
timestampDisplay: row.timestamp.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}),
timestampDisplay: formatTimestamp(row.timestamp, timeRange),
});
}
@ -208,18 +227,29 @@ export function PriceChart({
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 [hoveredMilestone, setHoveredMilestone] = useState<string | null>(null);
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],
() => pivotData(priceRows, commodityRows, showCommodities, timeRange),
[priceRows, commodityRows, showCommodities, timeRange],
);
// Default: hide non-ISO regions so the chart isn't spaghetti
const [disabledRegions, setDisabledRegions] = useState<Set<string>>(() => {
const initialRows = deserialize<PriceTrendRow[]>(initialPriceData);
const allRegions = new Set(initialRows.map(r => r.region_code));
const nonIso = new Set<string>();
for (const code of allRegions) {
if (!ISO_REGIONS.has(code)) nonIso.add(code);
}
return nonIso;
});
const chartConfig = useMemo(
() => buildChartConfig(regions, commodities, showCommodities),
[regions, commodities, showCommodities],
@ -232,19 +262,22 @@ export function PriceChart({
[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);
}
}
const handleTimeRangeChange = useCallback(
async (range: TimeRange) => {
setTimeRange(range);
setLoading(true);
try {
const result = await onTimeRangeChange(range);
setPricesSerialized(result.prices);
setCommoditiesSerialized(result.commodities);
} finally {
setLoading(false);
}
},
[onTimeRangeChange],
);
function toggleRegion(region: string) {
const toggleRegion = useCallback((region: string) => {
setDisabledRegions(prev => {
const next = new Set(prev);
if (next.has(region)) {
@ -254,7 +287,7 @@ export function PriceChart({
}
return next;
});
}
}, []);
if (pivoted.length === 0 && !showCommodities) {
return (
@ -290,30 +323,71 @@ export function PriceChart({
</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>
))}
{/* ISO regions first */}
{regions
.filter(r => ISO_REGIONS.has(r))
.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)
? 'var(--color-muted-foreground)'
: REGION_COLORS[region],
}}
/>
{region}
</button>
))}
{/* Non-ISO regions after a divider */}
{regions.some(r => !ISO_REGIONS.has(r)) && (
<>
<div className="mx-1 h-4 w-px bg-border" />
<span className="text-[10px] tracking-wider text-muted-foreground/60 uppercase">Other</span>
{regions
.filter(r => !ISO_REGIONS.has(r))
.map(region => (
<button
key={region}
onClick={() => toggleRegion(region)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[11px] 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-1.5 w-1.5 rounded-full"
style={{
backgroundColor: disabledRegions.has(region)
? 'var(--color-muted-foreground)'
: REGION_COLORS[region],
}}
/>
{region}
</button>
))}
</>
)}
<div className="mx-2 h-4 w-px bg-border" />
@ -341,19 +415,19 @@ export function PriceChart({
</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 }}>
<ChartContainer config={chartConfig} className="h-[50vh] max-h-150 min-h-75 w-full">
<LineChart data={pivoted} margin={{ top: 20, right: 60, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="timestampDisplay"
tick={{ fontSize: 11 }}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 11 }}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `$${value}`}
@ -361,14 +435,14 @@ export function PriceChart({
value: '$/MWh',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' },
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}}
/>
{showCommodities && (
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 11 }}
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => `$${value}`}
@ -376,7 +450,7 @@ export function PriceChart({
value: 'Commodity Price',
angle: 90,
position: 'insideRight',
style: { fontSize: 11, fill: 'hsl(var(--muted-foreground))' },
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}}
/>
)}
@ -407,6 +481,7 @@ export function PriceChart({
strokeWidth={2}
dot={false}
connectNulls
isAnimationActive={pivoted.length <= 200}
/>
))}
@ -422,6 +497,7 @@ export function PriceChart({
strokeDasharray="6 3"
dot={false}
connectNulls
isAnimationActive={pivoted.length <= 200}
/>
))}
@ -433,18 +509,44 @@ export function PriceChart({
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>
{/* Milestone legend — pill badges below the chart */}
{visibleMilestones.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{visibleMilestones.map(m => (
<div
key={m.date}
className="group relative"
onMouseEnter={() => setHoveredMilestone(m.date)}
onMouseLeave={() => setHoveredMilestone(null)}>
<span
className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] leading-none font-medium"
style={{
borderColor: `${MILESTONE_COLORS[m.category] ?? 'hsl(280,60%,60%)'}50`,
backgroundColor: `${MILESTONE_COLORS[m.category] ?? 'hsl(280,60%,60%)'}15`,
color: MILESTONE_COLORS[m.category] ?? 'hsl(280,60%,60%)',
}}>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: MILESTONE_COLORS[m.category] ?? 'hsl(280,60%,60%)' }}
/>
{m.title}
</span>
{hoveredMilestone === m.date && (
<div className="absolute bottom-full left-1/2 z-10 mb-2 w-60 -translate-x-1/2 rounded-lg border border-border bg-popover p-3 shadow-lg">
<p className="text-xs font-semibold text-foreground">{m.title}</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{m.date}</p>
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{m.description}</p>
</div>
)}
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>

View File

@ -2,7 +2,7 @@
import { cn } from '@/lib/utils.js';
export type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y';
export type TimeRange = '24h' | '7d' | '30d' | '90d' | '1y' | '5y' | 'all';
const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [
{ value: '24h', label: '24H' },
@ -10,6 +10,8 @@ const TIME_RANGES: Array<{ value: TimeRange; label: string }> = [
{ value: '30d', label: '1M' },
{ value: '90d', label: '3M' },
{ value: '1y', label: '1Y' },
{ value: '5y', label: '5Y' },
{ value: 'all', label: 'ALL' },
];
interface TimeRangeSelectorProps {

View File

@ -79,7 +79,7 @@ export function AlertsFeed({ initialData, className }: AlertsFeedProps) {
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="max-h-[400px] overflow-y-auto px-6 pb-6">
<div className="max-h-100 overflow-y-auto px-6 pb-6">
<div className="space-y-1">
{alerts.map(alert => {
const styles = SEVERITY_STYLES[alert.severity];

View File

@ -4,23 +4,24 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
import { Slider } from '@/components/ui/slider.js';
import { cn } from '@/lib/utils.js';
import { Cpu } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Cpu, Zap } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { AnimatedNumber } from './animated-number.js';
const GPU_MODELS = {
B200: { watts: 1000, label: 'NVIDIA B200' },
R200: { watts: 1800, label: 'R200 (Rubin)' },
'H100 SXM': { watts: 700, label: 'NVIDIA H100 SXM' },
'H100 PCIe': { watts: 350, label: 'NVIDIA H100 PCIe' },
H200: { watts: 700, label: 'NVIDIA H200' },
B200: { watts: 1000, label: 'NVIDIA B200' },
'A100 SXM': { watts: 400, label: 'NVIDIA A100 SXM' },
'A100 PCIe': { watts: 300, label: 'NVIDIA A100 PCIe' },
} as const;
type GpuModelKey = keyof typeof GPU_MODELS;
const GPU_MODEL_KEYS: GpuModelKey[] = ['H100 SXM', 'H100 PCIe', 'H200', 'B200', 'A100 SXM', 'A100 PCIe'];
const GPU_MODEL_KEYS: GpuModelKey[] = ['B200', 'R200', 'H100 SXM', 'H100 PCIe', 'H200', 'A100 SXM', 'A100 PCIe'];
function isGpuModelKey(value: string): value is GpuModelKey {
return value in GPU_MODELS;
@ -37,11 +38,13 @@ interface GpuCalculatorProps {
className?: string;
}
function formatCurrency(n: number): string {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `$${n.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
return `$${n.toFixed(2)}`;
}
const GPU_COUNT_MIN = 100;
const GPU_COUNT_MAX = 10_000;
const GPU_COUNT_STEP = 100;
const PUE_MIN = 1.1;
const PUE_MAX = 2.0;
const PUE_STEP = 0.1;
const PUE_DEFAULT = 1.3;
function formatCurrencyAnimated(n: number): string {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`;
@ -49,33 +52,169 @@ function formatCurrencyAnimated(n: number): string {
return `$${n.toFixed(2)}`;
}
export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
const [gpuModel, setGpuModel] = useState<GpuModelKey>('H100 SXM');
const [gpuCount, setGpuCount] = useState(1000);
const [selectedRegion, setSelectedRegion] = useState<string>(regionPrices[0]?.regionCode ?? '');
function formatCurrencyCompact(n: number): string {
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}k`;
return `$${n.toFixed(0)}`;
}
const selectedPrice = regionPrices.find(r => r.regionCode === selectedRegion);
const priceMwh = selectedPrice?.priceMwh ?? 0;
function clampGpuCount(value: number): number {
const rounded = Math.round(value / GPU_COUNT_STEP) * GPU_COUNT_STEP;
return Math.max(GPU_COUNT_MIN, Math.min(GPU_COUNT_MAX, rounded));
}
interface RegionCost {
regionCode: string;
regionName: string;
hourlyCost: number;
priceMwh: number;
}
function RegionComparisonBar({
region,
maxCost,
isSelected,
isCheapest,
isMostExpensive,
}: {
region: RegionCost;
maxCost: number;
isSelected: boolean;
isCheapest: boolean;
isMostExpensive: boolean;
}) {
const widthPercent = maxCost > 0 ? (region.hourlyCost / maxCost) * 100 : 0;
return (
<div className="group flex items-center gap-2">
<span
className={cn(
'w-16 shrink-0 text-right font-mono text-xs',
isSelected ? 'font-bold text-foreground' : 'text-muted-foreground',
)}>
{region.regionCode}
</span>
<div className="relative h-5 flex-1 overflow-hidden rounded-sm bg-muted/40">
<div
className={cn(
'absolute inset-y-0 left-0 rounded-sm transition-all duration-500',
isSelected
? 'bg-chart-1'
: isCheapest
? 'bg-emerald-500/70'
: isMostExpensive
? 'bg-red-500/70'
: 'bg-muted-foreground/30',
)}
style={{ width: `${widthPercent}%` }}
/>
<span
className={cn(
'absolute inset-y-0 flex items-center px-2 text-xs tabular-nums',
isSelected ? 'font-semibold text-foreground' : 'text-muted-foreground',
)}>
{formatCurrencyCompact(region.hourlyCost)}/hr
</span>
</div>
{isCheapest && (
<span className="shrink-0 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
LOW
</span>
)}
{isMostExpensive && (
<span className="shrink-0 rounded-full bg-red-500/15 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
HIGH
</span>
)}
</div>
);
}
export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
const [gpuModel, setGpuModel] = useState<GpuModelKey>('B200');
const [gpuCount, setGpuCount] = useState(1000);
const [gpuCountInput, setGpuCountInput] = useState('1,000');
const [pue, setPue] = useState(PUE_DEFAULT);
const [selectedRegion, setSelectedRegion] = useState<string>(
regionPrices.find(r => r.regionCode === 'DUKE')?.regionCode ?? regionPrices[0]?.regionCode ?? '',
);
const priceMwh = regionPrices.find(r => r.regionCode === selectedRegion)?.priceMwh ?? 0;
const costs = useMemo(() => {
const wattsPerGpu = GPU_MODELS[gpuModel].watts;
const totalMw = (wattsPerGpu * gpuCount) / 1_000_000;
const rawMw = (wattsPerGpu * gpuCount) / 1_000_000;
const totalMw = rawMw * pue;
const hourlyCost = totalMw * priceMwh;
const dailyCost = hourlyCost * 24;
const monthlyCost = dailyCost * 30;
return { hourlyCost, dailyCost, monthlyCost, totalMw };
}, [gpuModel, gpuCount, priceMwh]);
return { hourlyCost, dailyCost, monthlyCost, totalMw, rawMw };
}, [gpuModel, gpuCount, priceMwh, pue]);
const regionCosts: RegionCost[] = useMemo(() => {
const wattsPerGpu = GPU_MODELS[gpuModel].watts;
const totalMw = ((wattsPerGpu * gpuCount) / 1_000_000) * pue;
return regionPrices
.map(r => ({
regionCode: r.regionCode,
regionName: r.regionName,
hourlyCost: totalMw * r.priceMwh,
priceMwh: r.priceMwh,
}))
.sort((a, b) => a.hourlyCost - b.hourlyCost);
}, [gpuModel, gpuCount, pue, regionPrices]);
const cheapestRegion = regionCosts[0]?.regionCode ?? '';
const mostExpensiveRegion = regionCosts[regionCosts.length - 1]?.regionCode ?? '';
const maxCost = regionCosts[regionCosts.length - 1]?.hourlyCost ?? 0;
const handleGpuCountSlider = useCallback(([v]: number[]) => {
if (v !== undefined) {
setGpuCount(v);
setGpuCountInput(v.toLocaleString());
}
}, []);
const handleGpuCountInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
setGpuCountInput(raw);
}, []);
const commitGpuCountInput = useCallback(() => {
const parsed = parseInt(gpuCountInput.replace(/,/g, ''), 10);
if (Number.isFinite(parsed)) {
const clamped = clampGpuCount(parsed);
setGpuCount(clamped);
setGpuCountInput(clamped.toLocaleString());
} else {
setGpuCountInput(gpuCount.toLocaleString());
}
}, [gpuCountInput, gpuCount]);
const handleGpuCountKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') commitGpuCountInput();
},
[commitGpuCountInput],
);
return (
<Card className={cn('gap-0 py-4', className)}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Cpu className="h-4 w-4" />
<Card className={cn('relative gap-0 overflow-hidden py-0', className)}>
{/* Accent top border */}
<div className="h-0.5 bg-linear-to-r from-chart-1 via-chart-4 to-chart-2" />
<CardHeader className="pt-5 pb-2">
<CardTitle className="flex items-center gap-2.5 text-base font-semibold">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-chart-1/15">
<Cpu className="h-4.5 w-4.5 text-chart-1" />
</div>
GPU Power Cost Calculator
</CardTitle>
<CardDescription>Estimate real-time electricity cost for GPU clusters</CardDescription>
<CardDescription>Real-time electricity cost estimates for GPU clusters by grid region</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<CardContent className="space-y-5 pb-6">
{/* GPU Model + Region selectors */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">GPU Model</label>
@ -114,29 +253,65 @@ export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
</div>
</div>
{/* GPU Count: slider + text input */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">GPU Count</label>
<span className="font-mono text-sm font-semibold">{gpuCount.toLocaleString()}</span>
<input
type="text"
inputMode="numeric"
value={gpuCountInput}
onChange={handleGpuCountInput}
onBlur={commitGpuCountInput}
onKeyDown={handleGpuCountKeyDown}
className="w-20 rounded-md border border-border/50 bg-muted/30 px-2 py-0.5 text-right font-mono text-sm font-semibold text-foreground transition-colors outline-none focus:border-chart-1/50 focus:ring-1 focus:ring-chart-1/20"
aria-label="GPU count"
/>
</div>
<Slider
value={[gpuCount]}
onValueChange={([v]) => {
if (v !== undefined) setGpuCount(v);
}}
min={1}
max={10000}
step={1}
onValueChange={handleGpuCountSlider}
min={GPU_COUNT_MIN}
max={GPU_COUNT_MAX}
step={GPU_COUNT_STEP}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1</span>
<span>10,000</span>
<span>{GPU_COUNT_MIN.toLocaleString()}</span>
<span>{GPU_COUNT_MAX.toLocaleString()}</span>
</div>
</div>
{/* PUE Factor */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
PUE Factor
<span className="ml-1.5 text-muted-foreground/60">(datacenter overhead)</span>
</label>
<span className="font-mono text-sm font-semibold">{pue.toFixed(1)}x</span>
</div>
<Slider
value={[pue * 10]}
onValueChange={([v]) => {
if (v !== undefined) setPue(v / 10);
}}
min={PUE_MIN * 10}
max={PUE_MAX * 10}
step={PUE_STEP * 10}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{PUE_MIN.toFixed(1)} (efficient)</span>
<span>{PUE_MAX.toFixed(1)} (legacy)</span>
</div>
</div>
{/* Cost results */}
<div className="rounded-lg border border-border/50 bg-muted/30 p-4">
<div className="mb-3 flex items-baseline justify-between">
<span className="text-xs text-muted-foreground">Total Power Draw</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Zap className="h-3 w-3" />
Total Power Draw (incl. PUE)
</span>
<span className="font-mono text-sm font-semibold">{costs.totalMw.toFixed(2)} MW</span>
</div>
@ -168,27 +343,22 @@ export function GpuCalculator({ regionPrices, className }: GpuCalculatorProps) {
</div>
</div>
{regionPrices.length >= 2 && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">Compare: </span>
Running {gpuCount.toLocaleString()} {gpuModel} GPUs costs{' '}
<span className="font-semibold text-foreground">{formatCurrency(costs.hourlyCost)}/hr</span> in{' '}
{selectedPrice?.regionName ?? selectedRegion}
{regionPrices
.filter(r => r.regionCode !== selectedRegion)
.slice(0, 1)
.map(other => {
const otherWatts = GPU_MODELS[gpuModel].watts;
const otherMw = (otherWatts * gpuCount) / 1_000_000;
const otherHourly = otherMw * other.priceMwh;
return (
<span key={other.regionCode}>
{' '}
vs <span className="font-semibold text-foreground">{formatCurrency(otherHourly)}/hr</span> in{' '}
{other.regionName}
</span>
);
})}
{/* Region comparison bars */}
{regionCosts.length >= 2 && (
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground">Cost by Region</div>
<div className="space-y-1.5">
{regionCosts.map(region => (
<RegionComparisonBar
key={region.regionCode}
region={region}
maxCost={maxCost}
isSelected={region.regionCode === selectedRegion}
isCheapest={region.regionCode === cheapestRegion}
isMostExpensive={region.regionCode === mostExpensiveRegion && regionCosts.length > 1}
/>
))}
</div>
</div>
)}
</CardContent>

View File

@ -1,87 +1,53 @@
'use client';
import { cn } from '@/lib/utils.js';
interface GridStressGaugeProps {
regionCode: string;
regionName: string;
demandMw: number;
capacityMw: number;
peakDemandMw: number;
className?: string;
}
function getStressColor(pct: number): string {
if (pct >= 90) return '#ef4444';
if (pct >= 80) return '#f97316';
if (pct >= 60) return '#eab308';
return '#22c55e';
function getStressLevel(pct: number): { color: string; label: string; barClass: string } {
if (pct >= 95) return { color: 'text-red-400', label: 'Critical', barClass: 'bg-red-500' };
if (pct >= 85) return { color: 'text-orange-400', label: 'High', barClass: 'bg-orange-500' };
if (pct >= 70) return { color: 'text-amber-400', label: 'Elevated', barClass: 'bg-amber-500' };
if (pct >= 50) return { color: 'text-emerald-400', label: 'Normal', barClass: 'bg-emerald-500' };
return { color: 'text-emerald-500/70', label: 'Low', barClass: 'bg-emerald-500/60' };
}
function getStressLabel(pct: number): string {
if (pct >= 90) return 'Critical';
if (pct >= 80) return 'High';
if (pct >= 60) return 'Moderate';
return 'Normal';
function formatGw(mw: number): string {
if (mw >= 1000) return `${(mw / 1000).toFixed(1)} GW`;
return `${Math.round(mw)} MW`;
}
export function GridStressGauge({ regionCode, regionName, demandMw, capacityMw, className }: GridStressGaugeProps) {
const pct = capacityMw > 0 ? Math.min((demandMw / capacityMw) * 100, 100) : 0;
const color = getStressColor(pct);
const label = getStressLabel(pct);
const radius = 40;
const circumference = Math.PI * radius;
const offset = circumference - (pct / 100) * circumference;
const isCritical = pct >= 85;
export function GridStressGauge({
regionCode,
regionName: _,
demandMw,
peakDemandMw,
className,
}: GridStressGaugeProps) {
const pct = peakDemandMw > 0 ? Math.min((demandMw / peakDemandMw) * 100, 100) : 0;
const { color, label, barClass } = getStressLevel(pct);
return (
<div className={cn('flex flex-col items-center gap-2', className)}>
<svg
viewBox="0 0 100 55"
className="w-full max-w-[140px]"
style={{
filter: isCritical ? `drop-shadow(0 0 8px ${color}80)` : undefined,
}}>
{/* Background arc */}
<path
d="M 10 50 A 40 40 0 0 1 90 50"
fill="none"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
className="text-muted/40"
/>
{/* Filled arc */}
<path
d="M 10 50 A 40 40 0 0 1 90 50"
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
style={{
transition: 'stroke-dashoffset 1s ease-in-out, stroke 0.5s ease',
}}
/>
{/* Percentage text */}
<text
x="50"
y="45"
textAnchor="middle"
className="fill-foreground text-[14px] font-bold"
style={{ fontFamily: 'ui-monospace, monospace' }}>
{pct.toFixed(0)}%
</text>
</svg>
<div className="text-center">
<div className="text-xs font-semibold">{regionCode}</div>
<div className="text-[10px] text-muted-foreground">{regionName}</div>
<div className="mt-0.5 text-[10px] font-medium" style={{ color }}>
{label}
<div className={cn('group flex items-center gap-3', className)}>
<div className="w-12 shrink-0">
<div className="font-mono text-xs font-semibold tracking-wide">{regionCode}</div>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-muted/30">
<div
className={cn('h-full rounded-full transition-all duration-700 ease-out', barClass)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
<div className="flex w-20 shrink-0 items-baseline justify-end gap-1.5">
<span className="font-mono text-xs font-medium tabular-nums">{formatGw(demandMw)}</span>
<span className={cn('text-[10px] font-medium', color)}>{label}</span>
</div>
</div>
);
}

View File

@ -4,6 +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 { TrendingDown, TrendingUp } from 'lucide-react';
import type { ReactNode } from 'react';
import { useCallback } from 'react';
@ -31,6 +32,12 @@ interface MetricCardProps {
className?: string;
sparklineData?: { value: number }[];
sparklineColor?: string;
/** Percentage change vs previous period (e.g., +3.2 or -1.5). */
trendDelta?: number | null;
/** Label for the trend period (e.g., "vs yesterday"). */
trendLabel?: string;
/** Contextual subtitle (e.g., "3 new in last 30 days"). */
subtitle?: string;
}
export function MetricCard({
@ -43,6 +50,9 @@ export function MetricCard({
className,
sparklineData,
sparklineColor,
trendDelta,
trendLabel,
subtitle,
}: MetricCardProps) {
const formatFn = useCallback(
(n: number) => (animatedFormat ? FORMAT_FNS[animatedFormat](n) : n.toFixed(2)),
@ -66,6 +76,22 @@ export function MetricCard({
)}
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
</div>
{trendDelta !== undefined && trendDelta !== null && (
<div className="mt-1 flex items-center gap-1">
{trendDelta >= 0 ? (
<TrendingUp className="h-3.5 w-3.5 text-emerald-400" />
) : (
<TrendingDown className="h-3.5 w-3.5 text-red-400" />
)}
<span
className={cn('text-xs font-medium tabular-nums', trendDelta >= 0 ? 'text-emerald-400' : 'text-red-400')}>
{trendDelta >= 0 ? '+' : ''}
{trendDelta.toFixed(1)}%
</span>
{trendLabel && <span className="text-xs text-muted-foreground">{trendLabel}</span>}
</div>
)}
{subtitle && <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>}
{sparklineData && sparklineData.length >= 2 && (
<div className="mt-2">
<Sparkline data={sparklineData} color={sparklineColor} height={28} />

View File

@ -6,7 +6,11 @@ import { deserialize } from '@/lib/superjson.js';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
const PRICE_SPIKE_THRESHOLD_MWH = 100;
/** Alert when price exceeds regional 7-day average by this many standard deviations */
const SPIKE_CRITICAL_SIGMA = 2.5;
const SPIKE_WARNING_SIGMA = 1.8;
/** Fallback: alert on >15% jump between consecutive readings */
const JUMP_PCT_THRESHOLD = 0.15;
const CHECK_INTERVAL_MS = 60_000;
export function PriceAlertMonitor() {
@ -25,14 +29,29 @@ export function PriceAlertMonitor() {
const prevPrice = prevPrices.get(p.region_code);
if (!initialLoadRef.current) {
if (p.price_mwh >= PRICE_SPIKE_THRESHOLD_MWH) {
toast.error(`Price Spike: ${p.region_code}`, {
description: `${p.region_name} hit $${p.price_mwh.toFixed(2)}/MWh — above $${PRICE_SPIKE_THRESHOLD_MWH} threshold`,
duration: 8000,
});
} else if (prevPrice !== undefined && p.price_mwh > prevPrice * 1.15) {
const avg = p.avg_price_7d;
const sd = p.stddev_price_7d;
if (avg !== null && sd !== null && sd > 0) {
const sigmas = (p.price_mwh - avg) / sd;
if (sigmas >= SPIKE_CRITICAL_SIGMA) {
toast.error(`Price Spike: ${p.region_code}`, {
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
duration: 8000,
});
} else if (sigmas >= SPIKE_WARNING_SIGMA) {
toast.warning(`Elevated Price: ${p.region_code}`, {
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
duration: 6000,
});
}
}
if (prevPrice !== undefined && prevPrice > 0 && p.price_mwh > prevPrice * (1 + JUMP_PCT_THRESHOLD)) {
const jumpPct = ((p.price_mwh - prevPrice) / prevPrice) * 100;
toast.warning(`Price Jump: ${p.region_code}`, {
description: `${p.region_name} jumped to $${p.price_mwh.toFixed(2)}/MWh (+${(((p.price_mwh - prevPrice) / prevPrice) * 100).toFixed(1)}%)`,
description: `$${p.price_mwh.toFixed(2)}/MWh — up ${jumpPct.toFixed(1)}% from last reading`,
duration: 6000,
});
}

View File

@ -5,6 +5,12 @@ import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js';
import { useEffect, useState } from 'react';
/** Height of the ticker tape in pixels — use to reserve layout space and prevent CLS. */
export const TICKER_HEIGHT = 32;
/** Number of skeleton items to display while loading. */
const SKELETON_ITEM_COUNT = 8;
interface TickerItem {
label: string;
price: string;
@ -42,6 +48,36 @@ function TickerItemDisplay({ item }: { item: TickerItem }) {
);
}
function TickerSkeletonItem({ index }: { index: number }) {
return (
<span className="inline-flex items-center gap-1.5 px-4 whitespace-nowrap">
<span
className="h-3 w-10 animate-pulse rounded bg-muted-foreground/15"
style={{ animationDelay: `${index * 150}ms` }}
/>
<span
className="h-3.5 w-14 animate-pulse rounded bg-muted-foreground/20"
style={{ animationDelay: `${index * 150 + 50}ms` }}
/>
<span
className="h-3 w-9 animate-pulse rounded bg-muted-foreground/10"
style={{ animationDelay: `${index * 150 + 100}ms` }}
/>
<span className="ml-3 text-border/30">|</span>
</span>
);
}
function TickerSkeleton() {
return (
<div className="flex items-center overflow-hidden">
{Array.from({ length: SKELETON_ITEM_COUNT }, (_, i) => (
<TickerSkeletonItem key={i} index={i} />
))}
</div>
);
}
const COMMODITY_LABELS: Record<string, string> = {
natural_gas: 'Nat Gas',
wti_crude: 'WTI Crude',
@ -91,25 +127,29 @@ export function TickerTape() {
return () => clearInterval(interval);
}, []);
if (items.length === 0) {
return null;
}
const isLoading = items.length === 0;
return (
<div className="overflow-hidden border-b border-border/40 bg-muted/30">
<div className="ticker-scroll-container flex py-1.5">
{/* Duplicate items for seamless looping */}
<div className="ticker-scroll flex shrink-0">
{items.map((item, i) => (
<TickerItemDisplay key={`a-${i}`} item={item} />
))}
<div className="overflow-hidden border-b border-border/40 bg-muted/30" style={{ height: `${TICKER_HEIGHT}px` }}>
{isLoading ? (
<div className="flex items-center py-1.5">
<TickerSkeleton />
</div>
<div className="ticker-scroll flex shrink-0" aria-hidden>
{items.map((item, i) => (
<TickerItemDisplay key={`b-${i}`} item={item} />
))}
) : (
<div className="ticker-scroll-container flex py-1.5">
{/* Duplicate items for seamless looping */}
<div className="ticker-scroll flex shrink-0">
{items.map((item, i) => (
<TickerItemDisplay key={`a-${i}`} item={item} />
))}
</div>
<div className="ticker-scroll flex shrink-0" aria-hidden>
{items.map((item, i) => (
<TickerItemDisplay key={`b-${i}`} item={item} />
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { useEffect, useState } from 'react';
interface DataFreshnessProps {
/** Most recent timestamp across all data sources, as ISO string */
latestTimestamp: string | null;
}
type Staleness = 'fresh' | 'stale' | 'warning';
function formatRelativeTime(isoString: string): string {
const now = Date.now();
const then = new Date(isoString).getTime();
const diffMs = now - then;
if (diffMs < 0) return 'just now';
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days === 1) return '1 day ago';
return `${days} days ago`;
}
function getStaleness(isoString: string): Staleness {
const diffMs = Date.now() - new Date(isoString).getTime();
if (diffMs > 6 * 60 * 60 * 1000) return 'warning';
if (diffMs > 2 * 60 * 60 * 1000) return 'stale';
return 'fresh';
}
const DOT_CLASS: Record<Staleness, string> = {
fresh: 'bg-emerald-500/70',
stale: 'bg-yellow-500/60',
warning: 'bg-amber-500/80',
};
const TEXT_CLASS: Record<Staleness, string> = {
fresh: 'text-muted-foreground/50',
stale: 'text-yellow-500/50',
warning: 'text-amber-500/70',
};
export function DataFreshness({ latestTimestamp }: DataFreshnessProps) {
const [display, setDisplay] = useState<{ relativeTime: string; staleness: Staleness } | null>(null);
useEffect(() => {
if (!latestTimestamp) return;
function update() {
setDisplay({
relativeTime: formatRelativeTime(latestTimestamp!),
staleness: getStaleness(latestTimestamp!),
});
}
update();
const interval = setInterval(update, 30_000);
return () => clearInterval(interval);
}, [latestTimestamp]);
if (!latestTimestamp || !display) {
return <span className="text-muted-foreground/50">Data from EIA &amp; FRED</span>;
}
return (
<span className="inline-flex items-center gap-1.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${DOT_CLASS[display.staleness]}`} />
<span className="text-muted-foreground/60">Data from EIA &amp; FRED</span>
<span className="text-muted-foreground/40">|</span>
<span className={TEXT_CLASS[display.staleness]}>Updated {display.relativeTime}</span>
</span>
);
}

View File

@ -1,8 +1,36 @@
export function Footer() {
import { fetchDataFreshness } from '@/actions/freshness.js';
import { deserialize } from '@/lib/superjson.js';
import { DataFreshness } from './data-freshness.js';
export async function Footer() {
const result = await fetchDataFreshness();
let latestTimestamp: string | null = null;
if (result.ok) {
const freshness = deserialize<{
electricity: Date | null;
generation: Date | null;
commodities: Date | null;
}>(result.data);
const timestamps = [freshness.electricity, freshness.generation, freshness.commodities].filter(
(t): t is Date => t !== null,
);
if (timestamps.length > 0) {
const latest = new Date(Math.max(...timestamps.map(t => t.getTime())));
latestTimestamp = latest.toISOString();
}
}
return (
<footer className="border-t border-border/40 py-4">
<div className="px-6 text-center text-xs text-muted-foreground">
For educational and informational purposes only. Not financial advice.
<div className="flex flex-col items-center gap-1 px-6 text-xs">
<DataFreshness latestTimestamp={latestTimestamp} />
<span className="text-muted-foreground/40">
For educational and informational purposes only. Not financial advice.
</span>
</div>
</footer>
);

View File

@ -23,7 +23,7 @@ export function Nav() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<header className="sticky top-0 z-50 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<header className="sticky top-0 z-50 border-b border-border/40 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<TickerTape />
<div className="flex h-14 items-center px-4 sm:px-6">

View File

@ -1,7 +1,7 @@
'use client';
import { AdvancedMarker } from '@vis.gl/react-google-maps';
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
const OPERATOR_COLORS: Record<string, string> = {
AWS: '#FF9900',
@ -55,22 +55,48 @@ interface DatacenterMarkerProps {
datacenter: DatacenterMarkerData;
onClick: (datacenter: DatacenterMarkerData) => void;
isPulsing?: boolean;
isSelected?: boolean;
/** Callback to register/unregister the underlying AdvancedMarkerElement for clustering. */
setMarkerRef?: (marker: google.maps.marker.AdvancedMarkerElement | null, id: string) => void;
}
export function DatacenterMarker({ datacenter, onClick, isPulsing = false }: DatacenterMarkerProps) {
export function DatacenterMarker({
datacenter,
onClick,
isPulsing = false,
isSelected = false,
setMarkerRef,
}: DatacenterMarkerProps) {
const [hovered, setHovered] = useState(false);
const size = getMarkerSize(datacenter.capacity_mw);
const color = getOperatorColor(datacenter.operator);
const pulseDuration = getPulseDuration(datacenter.capacity_mw);
const registeredRef = useRef(false);
const handleClick = useCallback(() => {
onClick(datacenter);
}, [datacenter, onClick]);
const refCallback = useCallback(
(marker: google.maps.marker.AdvancedMarkerElement | null) => {
if (!setMarkerRef) return;
if (marker && !registeredRef.current) {
registeredRef.current = true;
setMarkerRef(marker, datacenter.id);
} else if (!marker && registeredRef.current) {
registeredRef.current = false;
setMarkerRef(null, datacenter.id);
}
},
[datacenter.id, setMarkerRef],
);
return (
<AdvancedMarker
ref={refCallback}
position={{ lat: datacenter.lat, lng: datacenter.lng }}
onClick={handleClick}
zIndex={isSelected ? 1000 : hovered ? 999 : undefined}
title={`${datacenter.name} (${datacenter.operator}) - ${datacenter.capacity_mw} MW`}>
<div
className="relative flex cursor-pointer items-center justify-center transition-transform duration-150"
@ -89,14 +115,36 @@ export function DatacenterMarker({ datacenter, onClick, isPulsing = false }: Dat
/>
)}
<div
className="rounded-full border-2 border-white/80 shadow-lg"
className={`rounded-full shadow-lg ${
datacenter.status === 'under_construction'
? 'border-2 border-dashed border-white/80'
: datacenter.status === 'planned'
? 'border-2 border-white/80'
: 'border-2 border-white/80'
}`}
style={{
width: size,
height: size,
backgroundColor: color,
backgroundColor: datacenter.status === 'planned' ? 'transparent' : color,
boxShadow: hovered ? `0 0 12px ${color}80` : `0 2px 4px rgba(0,0,0,0.3)`,
...(datacenter.status === 'planned' ? { borderColor: color } : {}),
}}
/>
{size >= 26 && (
<div
className="pointer-events-none absolute text-white"
style={{
fontSize: '7px',
fontWeight: 700,
lineHeight: 1,
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}>
{datacenter.capacity_mw}
</div>
)}
{hovered && (
<div className="absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 rounded-md bg-zinc-900/95 px-3 py-1.5 text-xs whitespace-nowrap text-zinc-100 shadow-xl">
<div className="font-semibold">{datacenter.name}</div>

View File

@ -3,6 +3,7 @@
import dynamic from 'next/dynamic';
import type { DatacenterMarkerData } from './datacenter-marker.js';
import type { PowerPlantMarkerData } from './power-plant-marker.js';
import type { RegionHeatmapData } from './region-overlay.js';
const EnergyMap = dynamic(() => import('./energy-map.js').then(m => m.EnergyMap), {
@ -17,8 +18,9 @@ const EnergyMap = dynamic(() => import('./energy-map.js').then(m => m.EnergyMap)
interface EnergyMapLoaderProps {
datacenters: DatacenterMarkerData[];
regions: RegionHeatmapData[];
powerPlants: PowerPlantMarkerData[];
}
export function EnergyMapLoader({ datacenters, regions }: EnergyMapLoaderProps) {
return <EnergyMap datacenters={datacenters} regions={regions} />;
export function EnergyMapLoader({ datacenters, regions, powerPlants }: EnergyMapLoaderProps) {
return <EnergyMap datacenters={datacenters} regions={regions} powerPlants={powerPlants} />;
}

View File

@ -1,28 +1,163 @@
'use client';
import { APIProvider, Map } from '@vis.gl/react-google-maps';
import { useCallback, useState } from 'react';
import { MarkerClusterer } from '@googlemaps/markerclusterer';
import { AdvancedMarker, APIProvider, ColorScheme, Map, useMap } from '@vis.gl/react-google-maps';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DatacenterDetailPanel } from './datacenter-detail-panel.js';
import { DatacenterMarker, type DatacenterMarkerData } from './datacenter-marker.js';
import { MapControls } from './map-controls.js';
import { MapLegend } from './map-legend.js';
import { PowerPlantMarker, type PowerPlantMarkerData } from './power-plant-marker.js';
import { RegionDetailPanel } from './region-detail-panel.js';
import { RegionOverlay, type RegionHeatmapData } from './region-overlay.js';
const US_CENTER = { lat: 39.8, lng: -98.5 };
const DEFAULT_ZOOM = 4;
/** Center on Chapel Hill, NC — heart of the Southeast energy buildout. */
const US_CENTER = { lat: 35.9132, lng: -79.0558 };
const DEFAULT_ZOOM = 6;
/** Well-known approximate centroids for US ISO/RTO regions. */
const REGION_CENTROIDS: Record<string, { lat: number; lng: number }> = {
PJM: { lat: 39.5, lng: -77.5 },
ERCOT: { lat: 31.5, lng: -98.5 },
CAISO: { lat: 37.0, lng: -119.5 },
MISO: { lat: 41.5, lng: -90.0 },
SPP: { lat: 36.5, lng: -98.0 },
ISONE: { lat: 42.5, lng: -71.8 },
NYISO: { lat: 42.5, lng: -75.5 },
DUKE: { lat: 35.5, lng: -80.0 },
SOCO: { lat: 32.8, lng: -84.5 },
TVA: { lat: 35.8, lng: -86.5 },
};
function priceToLabelBorderColor(price: number | null): string {
if (price === null || price <= 0) return 'rgba(150, 150, 150, 0.5)';
const clamped = Math.min(price, 100);
const ratio = clamped / 100;
if (ratio < 0.3) return 'rgba(59, 130, 246, 0.7)';
if (ratio < 0.6) return 'rgba(245, 158, 11, 0.7)';
return 'rgba(239, 68, 68, 0.7)';
}
/** Custom cluster renderer for dark-themed datacenter clusters. */
function createClusterRenderer() {
return {
render({ count, position }: { count: number; position: google.maps.LatLng }) {
const size = Math.min(60, 30 + Math.log2(count) * 8);
const div = document.createElement('div');
div.style.cssText = [
`width: ${size}px`,
`height: ${size}px`,
'display: flex',
'align-items: center',
'justify-content: center',
'border-radius: 50%',
'background: rgba(59, 130, 246, 0.25)',
'border: 2px solid rgba(59, 130, 246, 0.6)',
'color: #e2e8f0',
'font-size: 12px',
'font-weight: 700',
'font-family: monospace',
'backdrop-filter: blur(4px)',
'box-shadow: 0 0 12px rgba(59, 130, 246, 0.3)',
].join('; ');
div.textContent = String(count);
return new google.maps.marker.AdvancedMarkerElement({
position,
content: div,
zIndex: Number(google.maps.Marker.MAX_ZINDEX) + count,
});
},
};
}
interface ClusteredMarkersProps {
datacenters: DatacenterMarkerData[];
regions: RegionHeatmapData[];
selectedDatacenterId: string | null;
onDatacenterClick: (dc: DatacenterMarkerData) => void;
}
/** Manages a MarkerClusterer instance and registers/unregisters datacenter markers. */
function ClusteredMarkers({ datacenters, regions, selectedDatacenterId, onDatacenterClick }: ClusteredMarkersProps) {
const map = useMap();
const clustererRef = useRef<MarkerClusterer | null>(null);
const markerRefs = useRef(new globalThis.Map<string, google.maps.marker.AdvancedMarkerElement>());
useEffect(() => {
if (!map) return;
if (!clustererRef.current) {
clustererRef.current = new MarkerClusterer({
map,
renderer: createClusterRenderer(),
});
}
return () => {
clustererRef.current?.clearMarkers();
clustererRef.current?.setMap(null);
clustererRef.current = null;
};
}, [map]);
const setMarkerRef = useCallback((marker: google.maps.marker.AdvancedMarkerElement | null, id: string) => {
const clusterer = clustererRef.current;
if (!clusterer) return;
if (marker) {
if (!markerRefs.current.has(id)) {
markerRefs.current.set(id, marker);
clusterer.addMarker(marker);
}
} else {
const existing = markerRefs.current.get(id);
if (existing) {
clusterer.removeMarker(existing);
markerRefs.current.delete(id);
}
}
}, []);
return (
<>
{datacenters.map(dc => {
const dcRegion = regions.find(r => r.code === dc.region_code);
const isPulsing =
dcRegion !== undefined &&
dcRegion.avgPrice !== null &&
dcRegion.maxPrice !== null &&
dcRegion.avgPrice > 0 &&
dcRegion.maxPrice > dcRegion.avgPrice * 1.03;
return (
<DatacenterMarker
key={dc.id}
datacenter={dc}
onClick={onDatacenterClick}
isPulsing={isPulsing}
isSelected={selectedDatacenterId === dc.id}
setMarkerRef={setMarkerRef}
/>
);
})}
</>
);
}
interface EnergyMapProps {
datacenters: DatacenterMarkerData[];
regions: RegionHeatmapData[];
powerPlants: PowerPlantMarkerData[];
}
export function EnergyMap({ datacenters, regions }: EnergyMapProps) {
export function EnergyMap({ datacenters, regions, powerPlants }: EnergyMapProps) {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? '';
const mapId = process.env.NEXT_PUBLIC_GOOGLE_MAP_ID ?? '';
const [filteredDatacenters, setFilteredDatacenters] = useState(datacenters);
const [selectedDatacenter, setSelectedDatacenter] = useState<DatacenterMarkerData | null>(null);
const [selectedRegion, setSelectedRegion] = useState<RegionHeatmapData | null>(null);
const [showPowerPlants, setShowPowerPlants] = useState(false);
const handleDatacenterClick = useCallback((dc: DatacenterMarkerData) => {
setSelectedDatacenter(dc);
@ -44,37 +179,76 @@ export function EnergyMap({ datacenters, regions }: EnergyMapProps) {
setFilteredDatacenters(filtered);
}, []);
const regionLabels = useMemo(
() =>
regions
.filter((r): r is RegionHeatmapData & { avgPrice: number } => r.avgPrice !== null && r.code in REGION_CENTROIDS)
.map(r => ({
code: r.code,
price: r.avgPrice,
position: REGION_CENTROIDS[r.code],
borderColor: priceToLabelBorderColor(r.avgPrice),
})),
[regions],
);
return (
<APIProvider apiKey={apiKey}>
<div className="relative h-full w-full">
<MapControls datacenters={datacenters} onFilterChange={handleFilterChange} />
{/* Unified left sidebar: filters + legend */}
<div className="absolute top-4 left-4 z-10 flex max-h-[calc(100vh-12rem)] w-64 flex-col overflow-y-auto rounded-lg border border-zinc-700/60 bg-zinc-900/90 shadow-xl backdrop-blur">
<MapControls
datacenters={datacenters}
onFilterChange={handleFilterChange}
showPowerPlants={showPowerPlants}
onTogglePowerPlants={setShowPowerPlants}
/>
<div className="border-t border-zinc-700/60 p-3">
<MapLegend showPowerPlants={showPowerPlants} />
</div>
</div>
<Map
mapId={mapId}
defaultCenter={US_CENTER}
defaultZoom={DEFAULT_ZOOM}
gestureHandling="greedy"
disableDefaultUI={false}
colorScheme={ColorScheme.DARK}
disableDefaultUI={true}
zoomControl={true}
clickableIcons={false}
className="h-full w-full">
<RegionOverlay regions={regions} onRegionClick={handleRegionClick} />
{filteredDatacenters.map(dc => {
const dcRegion = regions.find(r => r.code === dc.region_code);
const isPulsing =
dcRegion !== undefined &&
dcRegion.avgPrice !== null &&
dcRegion.maxPrice !== null &&
dcRegion.avgPrice > 0 &&
dcRegion.maxPrice > dcRegion.avgPrice * 1.1;
return (
<DatacenterMarker key={dc.id} datacenter={dc} onClick={handleDatacenterClick} isPulsing={isPulsing} />
);
})}
{regionLabels.map(label => (
<AdvancedMarker key={`label-${label.code}`} position={label.position} zIndex={0}>
<div
className="pointer-events-none rounded bg-zinc-900/80 px-2 py-1 text-xs backdrop-blur"
style={{ borderLeft: `3px solid ${label.borderColor}` }}>
<div className="font-bold text-zinc-100">{label.code}</div>
<div className="text-zinc-400">${Math.round(label.price)}/MWh</div>
</div>
</AdvancedMarker>
))}
{showPowerPlants && powerPlants.map(pp => <PowerPlantMarker key={pp.id} plant={pp} />)}
<ClusteredMarkers
datacenters={filteredDatacenters}
regions={regions}
selectedDatacenterId={selectedDatacenter?.id ?? null}
onDatacenterClick={handleDatacenterClick}
/>
</Map>
<DatacenterDetailPanel datacenter={selectedDatacenter} onClose={() => setSelectedDatacenter(null)} />
<RegionDetailPanel region={selectedRegion} datacenters={datacenters} onClose={() => setSelectedRegion(null)} />
<RegionDetailPanel
region={selectedRegion}
datacenters={datacenters}
powerPlants={powerPlants}
onClose={() => setSelectedRegion(null)}
/>
</div>
</APIProvider>
);

View File

@ -7,9 +7,11 @@ import type { DatacenterMarkerData } from './datacenter-marker.js';
interface MapControlsProps {
datacenters: DatacenterMarkerData[];
onFilterChange: (filtered: DatacenterMarkerData[]) => void;
showPowerPlants: boolean;
onTogglePowerPlants: (show: boolean) => void;
}
export function MapControls({ datacenters, onFilterChange }: MapControlsProps) {
export function MapControls({ datacenters, onFilterChange, showPowerPlants, onTogglePowerPlants }: MapControlsProps) {
const operators = useMemo(() => {
const set = new Set(datacenters.map(d => d.operator));
return Array.from(set).sort();
@ -64,7 +66,7 @@ export function MapControls({ datacenters, onFilterChange }: MapControlsProps) {
const hasFilters = selectedOperators.size > 0 || minCapacity > 0;
return (
<div className="absolute top-4 left-4 z-10 flex max-h-[calc(100vh-12rem)] w-64 flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-700/60 bg-zinc-900/90 p-3 shadow-xl backdrop-blur">
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold tracking-wider text-zinc-400 uppercase">Filters</span>
{hasFilters && (
@ -105,6 +107,18 @@ export function MapControls({ datacenters, onFilterChange }: MapControlsProps) {
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-zinc-700 accent-blue-500"
/>
</div>
<div className="border-t border-zinc-700/60 pt-2">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-zinc-400">
<input
type="checkbox"
checked={showPowerPlants}
onChange={e => onTogglePowerPlants(e.target.checked)}
className="h-3.5 w-3.5 cursor-pointer rounded border-zinc-600 bg-zinc-800 accent-blue-500"
/>
Show power plants
</label>
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
'use client';
import { FUEL_TYPE_COLORS } from './power-plant-marker.js';
interface MapLegendProps {
showPowerPlants?: boolean;
}
const FUEL_TYPE_DISPLAY_ORDER = [
'Natural Gas',
'Coal',
'Nuclear',
'Hydroelectric',
'Wind',
'Solar',
'Petroleum',
'Biomass',
'Geothermal',
];
export function MapLegend({ showPowerPlants = false }: MapLegendProps) {
return (
<div className="text-xs">
{/* Price heatmap gradient */}
<div className="mb-2.5">
<div className="mb-1 font-medium text-zinc-300">Price Heatmap</div>
<div
className="h-3 w-30 rounded-sm"
style={{
background: 'linear-gradient(to right, rgb(30,80,220), rgb(60,220,200), rgb(255,160,30), rgb(220,40,110))',
}}
/>
<div className="mt-0.5 flex w-30 justify-between text-zinc-500">
<span>$0</span>
<span>$50</span>
<span>$100+</span>
</div>
<div className="mt-0.5 text-zinc-500">$/MWh</div>
</div>
{/* Marker size scale */}
<div className="mb-2.5">
<div className="mb-1 font-medium text-zinc-300">Datacenter Size</div>
<div className="flex items-end gap-2.5">
<div className="flex flex-col items-center gap-0.5">
<div className="h-3.5 w-3.5 rounded-full border border-white/60 bg-zinc-500" />
<span className="text-zinc-500">50</span>
</div>
<div className="flex flex-col items-center gap-0.5">
<div className="h-5 w-5 rounded-full border border-white/60 bg-zinc-500" />
<span className="text-zinc-500">200</span>
</div>
<div className="flex flex-col items-center gap-0.5">
<div className="h-7 w-7 rounded-full border border-white/60 bg-zinc-500" />
<span className="text-zinc-500">500+</span>
</div>
<span className="pb-0.5 text-zinc-500">MW</span>
</div>
</div>
{/* Pulsing icon */}
<div className="mb-1.5 flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-amber-500" />
</span>
<span className="text-zinc-400">Price spike</span>
</div>
{/* Grid stress glow icon */}
<div className={showPowerPlants ? 'mb-2.5 flex items-center gap-2' : 'flex items-center gap-2'}>
<span className="relative flex h-3 w-3">
<span className="ambient-glow-slow absolute inline-flex h-full w-full rounded-full bg-red-500/60" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500/80" />
</span>
<span className="text-zinc-400">Grid stress &gt;85%</span>
</div>
{/* Power plant fuel type legend */}
{showPowerPlants && (
<div className="border-t border-zinc-700/60 pt-2">
<div className="mb-1.5 font-medium text-zinc-300">Power Plants</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
{FUEL_TYPE_DISPLAY_ORDER.map(fuel => (
<div key={fuel} className="flex items-center gap-1.5">
<div
className="h-2.5 w-2.5 shrink-0"
style={{
backgroundColor: FUEL_TYPE_COLORS[fuel] ?? '#9CA3AF',
transform: 'rotate(45deg)',
borderRadius: 1,
opacity: 0.7,
}}
/>
<span className="truncate text-zinc-400">{fuel}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,83 @@
'use client';
import { AdvancedMarker } from '@vis.gl/react-google-maps';
import { useCallback, useState } from 'react';
const FUEL_TYPE_COLORS: Record<string, string> = {
Coal: '#4A4A4A',
'Natural Gas': '#F59E0B',
Nuclear: '#8B5CF6',
Hydroelectric: '#3B82F6',
Wind: '#06B6D4',
Solar: '#FBBF24',
Petroleum: '#78716C',
Biomass: '#22C55E',
Geothermal: '#EF4444',
};
function getFuelColor(fuelType: string): string {
return FUEL_TYPE_COLORS[fuelType] ?? '#9CA3AF';
}
function getDiamondSize(capacityMw: number): number {
if (capacityMw >= 2000) return 20;
if (capacityMw >= 1000) return 16;
if (capacityMw >= 500) return 13;
if (capacityMw >= 200) return 10;
return 8;
}
export interface PowerPlantMarkerData {
id: string;
plant_code: number;
name: string;
operator: string;
capacity_mw: number;
fuel_type: string;
state: string;
lat: number;
lng: number;
}
interface PowerPlantMarkerProps {
plant: PowerPlantMarkerData;
}
export function PowerPlantMarker({ plant }: PowerPlantMarkerProps) {
const [hovered, setHovered] = useState(false);
const size = getDiamondSize(plant.capacity_mw);
const color = getFuelColor(plant.fuel_type);
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
return (
<AdvancedMarker position={{ lat: plant.lat, lng: plant.lng }} zIndex={1}>
<div
className="relative flex items-center justify-center"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
<div
style={{
width: size,
height: size,
backgroundColor: color,
opacity: 0.7,
transform: 'rotate(45deg)',
borderRadius: 2,
}}
/>
{hovered && (
<div className="absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 rounded-md bg-zinc-900/95 px-3 py-1.5 text-xs whitespace-nowrap text-zinc-100 shadow-xl">
<div className="font-semibold">{plant.name}</div>
<div className="text-zinc-400">
{plant.fuel_type} &middot; {plant.capacity_mw} MW
</div>
</div>
)}
</div>
</AdvancedMarker>
);
}
export { FUEL_TYPE_COLORS };

View File

@ -1,19 +1,63 @@
'use client';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet.js';
import { useMemo } from 'react';
import type { DatacenterMarkerData } from './datacenter-marker.js';
import type { PowerPlantMarkerData } from './power-plant-marker.js';
import type { RegionHeatmapData } from './region-overlay.js';
interface RegionDetailPanelProps {
region: RegionHeatmapData | null;
datacenters: DatacenterMarkerData[];
powerPlants: PowerPlantMarkerData[];
onClose: () => void;
}
export function RegionDetailPanel({ region, datacenters, onClose }: RegionDetailPanelProps) {
const regionDatacenters = region
? datacenters.filter(dc => dc.region_code === region.code).sort((a, b) => b.capacity_mw - a.capacity_mw)
: [];
const HOURS_PER_YEAR = 8_760;
export function RegionDetailPanel({ region, datacenters, powerPlants, onClose }: RegionDetailPanelProps) {
const regionDatacenters = useMemo(
() =>
region
? datacenters.filter(dc => dc.region_code === region.code).sort((a, b) => b.capacity_mw - a.capacity_mw)
: [],
[region, datacenters],
);
const regionPowerPlants = useMemo(
() => (region ? powerPlants.filter(pp => isInRegionApprox(pp, region.code)) : []),
[region, powerPlants],
);
const topOperators = useMemo(() => {
const capacityByOperator = new Map<string, number>();
for (const dc of regionDatacenters) {
capacityByOperator.set(dc.operator, (capacityByOperator.get(dc.operator) ?? 0) + dc.capacity_mw);
}
return Array.from(capacityByOperator.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
}, [regionDatacenters]);
const fuelMixSummary = useMemo(() => {
const capacityByFuel = new Map<string, number>();
for (const pp of regionPowerPlants) {
capacityByFuel.set(pp.fuel_type, (capacityByFuel.get(pp.fuel_type) ?? 0) + pp.capacity_mw);
}
return Array.from(capacityByFuel.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
}, [regionPowerPlants]);
const estimatedAnnualMwh =
region?.avgDemand !== null && region?.avgDemand !== undefined
? Math.round(region.avgDemand * HOURS_PER_YEAR)
: null;
const estimatedAnnualCost =
estimatedAnnualMwh !== null && region?.avgPrice !== null && region?.avgPrice !== undefined
? estimatedAnnualMwh * region.avgPrice
: null;
return (
<Sheet open={region !== null} onOpenChange={open => !open && onClose()}>
@ -26,6 +70,7 @@ export function RegionDetailPanel({ region, datacenters, onClose }: RegionDetail
</SheetHeader>
<div className="flex flex-col gap-4 p-4">
{/* Core price/demand metrics */}
<div className="grid grid-cols-2 gap-3">
<MetricItem
label="Avg Price (24h)"
@ -48,8 +93,59 @@ export function RegionDetailPanel({ region, datacenters, onClose }: RegionDetail
: '0 MW'
}
/>
<MetricItem label="Power Plants" value={String(regionPowerPlants.length)} />
</div>
{/* Estimated annual consumption */}
{estimatedAnnualMwh !== null && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
<div className="mb-1 text-xs font-medium text-zinc-500">Est. Annual Consumption</div>
<div className="text-sm font-semibold text-zinc-200">
{(estimatedAnnualMwh / 1_000_000).toFixed(1)} TWh/yr
</div>
{estimatedAnnualCost !== null && (
<div className="mt-0.5 text-xs text-zinc-500">
~${(estimatedAnnualCost / 1_000_000_000).toFixed(1)}B at current avg price
</div>
)}
</div>
)}
{/* Top operators */}
{topOperators.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-zinc-300">Top Operators</h3>
<div className="flex flex-col gap-1.5">
{topOperators.map(([operator, capacity]) => (
<div
key={operator}
className="flex items-center justify-between rounded-md bg-zinc-900/50 px-3 py-2">
<span className="text-xs font-medium text-zinc-300">{operator}</span>
<span className="font-mono text-xs text-zinc-500">{capacity.toLocaleString()} MW</span>
</div>
))}
</div>
</div>
)}
{/* Generation mix */}
{fuelMixSummary.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-zinc-300">Generation Mix (by capacity)</h3>
<div className="flex flex-col gap-1.5">
{fuelMixSummary.map(([fuel, capacity]) => (
<div key={fuel} className="flex items-center justify-between rounded-md bg-zinc-900/50 px-3 py-2">
<span className="text-xs font-medium text-zinc-300">{fuel}</span>
<span className="font-mono text-xs text-zinc-500">
{capacity >= 1000 ? `${(capacity / 1000).toFixed(1)} GW` : `${Math.round(capacity)} MW`}
</span>
</div>
))}
</div>
</div>
)}
{/* Datacenter list */}
{regionDatacenters.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-zinc-300">Datacenters in Region</h3>
@ -81,3 +177,26 @@ function MetricItem({ label, value }: { label: string; value: string }) {
</div>
);
}
/**
* Approximate region membership for power plants based on state.
* This is a rough heuristic since we don't have region_id on power_plants.
*/
const REGION_STATES: Record<string, string[]> = {
PJM: ['PA', 'NJ', 'MD', 'DE', 'VA', 'WV', 'OH', 'DC', 'NC', 'KY', 'IN', 'IL'],
ERCOT: ['TX'],
CAISO: ['CA'],
MISO: ['MN', 'WI', 'IA', 'MO', 'AR', 'LA', 'MS', 'MI', 'IN', 'IL', 'ND', 'SD', 'MT'],
SPP: ['KS', 'OK', 'NE', 'NM'],
ISONE: ['CT', 'MA', 'ME', 'NH', 'RI', 'VT'],
NYISO: ['NY'],
DUKE: ['NC', 'SC'],
SOCO: ['GA', 'AL'],
TVA: ['TN'],
};
function isInRegionApprox(plant: PowerPlantMarkerData, regionCode: string): boolean {
const states = REGION_STATES[regionCode];
if (!states) return false;
return states.includes(plant.state);
}

View File

@ -1,7 +1,7 @@
'use client';
import { useMap } from '@vis.gl/react-google-maps';
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
export interface RegionHeatmapData {
code: string;
@ -19,8 +19,15 @@ interface RegionOverlayProps {
onRegionClick: (regionCode: string) => void;
}
function priceToColor(price: number | null): string {
if (price === null || price <= 0) return 'rgba(100, 100, 100, 0.25)';
interface PriceColorResult {
fillColor: string;
fillOpacity: number;
}
function priceToColor(price: number | null): PriceColorResult {
if (price === null || price <= 0) {
return { fillColor: 'rgb(100, 100, 100)', fillOpacity: 0.25 };
}
// Scale: 0-20 cool blue, 20-50 yellow/orange, 50+ red/magenta
const clamped = Math.min(price, 100);
@ -29,18 +36,32 @@ function priceToColor(price: number | null): string {
if (ratio < 0.2) {
// Blue to cyan
const t = ratio / 0.2;
return `rgba(${Math.round(30 + t * 30)}, ${Math.round(80 + t * 140)}, ${Math.round(220 - t * 20)}, 0.30)`;
return {
fillColor: `rgb(${Math.round(30 + t * 30)}, ${Math.round(80 + t * 140)}, ${Math.round(220 - t * 20)})`,
fillOpacity: 0.25 + ratio * 0.25,
};
} else if (ratio < 0.5) {
// Cyan to yellow/orange
const t = (ratio - 0.2) / 0.3;
return `rgba(${Math.round(60 + t * 195)}, ${Math.round(220 - t * 60)}, ${Math.round(200 - t * 170)}, 0.35)`;
return {
fillColor: `rgb(${Math.round(60 + t * 195)}, ${Math.round(220 - t * 60)}, ${Math.round(200 - t * 170)})`,
fillOpacity: 0.3 + (ratio - 0.2) * 0.33,
};
} else {
// Orange to red/magenta
const t = (ratio - 0.5) / 0.5;
return `rgba(${Math.round(255 - t * 35)}, ${Math.round(160 - t * 120)}, ${Math.round(30 + t * 80)}, 0.40)`;
return {
fillColor: `rgb(${Math.round(255 - t * 35)}, ${Math.round(160 - t * 120)}, ${Math.round(30 + t * 80)})`,
fillOpacity: 0.35 + (ratio - 0.5) * 0.1,
};
}
}
/** Return the base fill opacity for a given price (used by breathing animation). */
function priceToBaseOpacity(price: number | null): number {
return priceToColor(price).fillOpacity;
}
function priceToBorderColor(price: number | null): string {
if (price === null || price <= 0) return 'rgba(150, 150, 150, 0.4)';
@ -56,6 +77,38 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
const map = useMap();
const dataLayerRef = useRef<google.maps.Data | null>(null);
const listenersRef = useRef<google.maps.MapsEventListener[]>([]);
const priceMapRef = useRef<Map<string, RegionHeatmapData>>(new Map());
const hoveredFeatureRef = useRef<google.maps.Data.Feature | null>(null);
const breathingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** Apply breathing opacity to notable regions (price > $40/MWh). Calm regions stay static. */
const applyBreathingFrame = useCallback((dataLayer: google.maps.Data, timestamp: number) => {
dataLayer.forEach(feature => {
// Skip the currently hovered feature — it has its own highlight style
if (feature === hoveredFeatureRef.current) return;
const rawCode = feature.getProperty('code');
const code = typeof rawCode === 'string' ? rawCode : '';
const regionData = priceMapRef.current.get(code);
const price = regionData?.avgPrice ?? null;
// Animate regions with notable prices; calm regions stay static
const isNotable = price !== null && price > 40;
if (!isNotable) return;
const baseOpacity = priceToBaseOpacity(price);
// Scale animation intensity by price: subtle at $40, pronounced at $100+
const intensity = Math.min(1, (price - 40) / 60);
const frequency = 0.125 + intensity * 0.05; // slightly faster for higher prices
const amplitude = 0.02 + intensity * 0.05;
const oscillation = Math.sin((timestamp / 1000) * frequency * 2 * Math.PI) * amplitude;
const newOpacity = Math.max(0.1, Math.min(0.5, baseOpacity + oscillation));
dataLayer.overrideStyle(feature, { fillOpacity: newOpacity });
});
}, []);
useEffect(() => {
if (!map) return;
@ -69,6 +122,10 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
listener.remove();
}
listenersRef.current = [];
if (breathingTimerRef.current !== null) {
clearInterval(breathingTimerRef.current);
breathingTimerRef.current = null;
}
const dataLayer = new google.maps.Data({ map });
dataLayerRef.current = dataLayer;
@ -90,6 +147,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
}
}
}
priceMapRef.current = priceMap;
// Style features by price
dataLayer.setStyle(feature => {
@ -97,10 +155,11 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
const code = typeof rawCode === 'string' ? rawCode : '';
const regionData = priceMap.get(code);
const price = regionData?.avgPrice ?? null;
const { fillColor, fillOpacity } = priceToColor(price);
return {
fillColor: priceToColor(price),
fillOpacity: 1,
fillColor,
fillOpacity,
strokeColor: priceToBorderColor(price),
strokeWeight: 1.5,
strokeOpacity: 0.8,
@ -111,10 +170,11 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
// Hover highlight
const overListener = dataLayer.addListener('mouseover', (event: google.maps.Data.MouseEvent) => {
if (event.feature) {
hoveredFeatureRef.current = event.feature;
dataLayer.overrideStyle(event.feature, {
strokeWeight: 3,
strokeOpacity: 1,
fillOpacity: 1,
fillOpacity: 0.55,
zIndex: 2,
});
}
@ -123,6 +183,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
const outListener = dataLayer.addListener('mouseout', (event: google.maps.Data.MouseEvent) => {
if (event.feature) {
hoveredFeatureRef.current = null;
dataLayer.revertStyle(event.feature);
}
});
@ -137,7 +198,20 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
});
listenersRef.current.push(clickListener);
// Breathing animation: 5 FPS interval driving rAF-scheduled style updates (stressed regions only)
breathingTimerRef.current = setInterval(() => {
requestAnimationFrame(timestamp => {
if (dataLayerRef.current) {
applyBreathingFrame(dataLayerRef.current, timestamp);
}
});
}, 200);
return () => {
if (breathingTimerRef.current !== null) {
clearInterval(breathingTimerRef.current);
breathingTimerRef.current = null;
}
dataLayer.setMap(null);
for (const listener of listenersRef.current) {
listener.remove();
@ -145,7 +219,7 @@ export function RegionOverlay({ regions, onRegionClick }: RegionOverlayProps) {
listenersRef.current = [];
dataLayerRef.current = null;
};
}, [map, regions, onRegionClick]);
}, [map, regions, onRegionClick, applyBreathingFrame]);
return null;
}

View File

@ -31,7 +31,7 @@ function SelectTrigger({
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",
"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}>
@ -55,7 +55,7 @@ function SelectContent({
<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',
'relative z-50 max-h-(--radix-select-content-available-height) min-w-32 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,
@ -68,7 +68,7 @@ function SelectContent({
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1',
)}>
{children}
</SelectPrimitive.Viewport>
@ -93,7 +93,7 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
<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",
"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}>

View File

@ -26,7 +26,7 @@ function Slider({
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
'relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className,
)}
{...props}>

123
src/instrumentation.ts Normal file
View File

@ -0,0 +1,123 @@
/**
* Next.js instrumentation hook runs once on server startup.
*
* In development, sets up a 30-minute interval that calls the ingestion
* endpoints to keep electricity, generation, and commodity data fresh.
*
* Each scheduled run fetches only the last 2 days of data so that
* requests complete quickly instead of auto-paginating through
* ALL historical EIA data (which causes timeouts).
*/
const INGEST_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
const ENDPOINTS = ['electricity', 'generation', 'commodities'] as const;
/** Per-endpoint fetch timeout — 2 minutes is generous for a bounded date range. */
const ENDPOINT_TIMEOUT_MS = 120_000;
/** How many days of history to fetch on each scheduled run. */
const LOOKBACK_DAYS = 2;
function getIngestBaseUrl(): string {
const port = process.env.PORT ?? '3000';
return `http://localhost:${port}/api/ingest`;
}
/** Build an ISO date string N days in the past (YYYY-MM-DD). */
function startDateForLookback(days: number): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() - days);
return d.toISOString().slice(0, 10);
}
interface IngestionStats {
inserted: number;
updated: number;
errors: number;
}
function isIngestionStats(value: unknown): value is IngestionStats {
if (typeof value !== 'object' || value === null) return false;
if (!('inserted' in value) || !('updated' in value) || !('errors' in value)) return false;
const { inserted, updated, errors } = value;
return typeof inserted === 'number' && typeof updated === 'number' && typeof errors === 'number';
}
async function fetchEndpoint(
baseUrl: string,
endpoint: string,
secret: string,
start: string,
logTimestamp: string,
): Promise<void> {
const url = `${baseUrl}/${endpoint}?start=${start}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ENDPOINT_TIMEOUT_MS);
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${secret}` },
signal: controller.signal,
});
if (!res.ok) {
console.error(`[${logTimestamp}] [ingest] ${endpoint}: HTTP ${res.status}`);
return;
}
const body: unknown = await res.json();
if (isIngestionStats(body)) {
console.log(
`[${logTimestamp}] [ingest] ${endpoint}: ${body.inserted} inserted, ${body.updated} updated, ${body.errors} errors`,
);
} else {
console.log(`[${logTimestamp}] [ingest] ${endpoint}: done (unexpected response shape)`);
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
console.error(`[${logTimestamp}] [ingest] ${endpoint}: timed out after ${ENDPOINT_TIMEOUT_MS / 1000}s`);
} else {
console.error(`[${logTimestamp}] [ingest] ${endpoint}: ${err instanceof Error ? err.message : String(err)}`);
}
} finally {
clearTimeout(timeoutId);
}
}
async function runIngestion(): Promise<void> {
const secret = process.env.INGEST_SECRET;
if (!secret) {
console.warn('[ingest] INGEST_SECRET not set, skipping automated ingestion');
return;
}
const logTimestamp = new Date().toISOString().slice(11, 19);
const start = startDateForLookback(LOOKBACK_DAYS);
const baseUrl = getIngestBaseUrl();
console.log(`[${logTimestamp}] [ingest] Starting scheduled ingestion (start=${start})...`);
// Run all endpoints in parallel — they are independent
const results = await Promise.allSettled(
ENDPOINTS.map(endpoint => fetchEndpoint(baseUrl, endpoint, secret, start, logTimestamp)),
);
for (let i = 0; i < results.length; i++) {
const result = results[i]!;
if (result.status === 'rejected') {
console.error(`[${logTimestamp}] [ingest] ${ENDPOINTS[i]}: unhandled rejection: ${result.reason}`);
}
}
}
export function register(): void {
if (typeof globalThis.setInterval === 'undefined') return;
console.log('[ingest] Scheduling automated ingestion every 30 minutes');
// Delay the first run by 10 seconds so the server is fully ready
setTimeout(() => {
void runIngestion();
setInterval(() => void runIngestion(), INGEST_INTERVAL_MS);
}, 10_000);
}

View File

@ -373,13 +373,18 @@ export interface GetRetailPriceOptions {
* Endpoint: /v2/electricity/retail-sales/data/
* Price is returned in cents/kWh; we convert to $/MWh (* 10).
*/
/** Pre-built reverse lookup: state abbreviation -> RegionCode */
const STATE_TO_REGION: ReadonlyMap<string, RegionCode> = (() => {
const map = new Map<string, RegionCode>();
let key: RegionCode;
for (key in REGION_STATE_MAP) {
map.set(REGION_STATE_MAP[key], key);
}
return map;
})();
export async function getRetailElectricityPrices(options: GetRetailPriceOptions = {}): Promise<RetailPricePoint[]> {
const stateIds = Object.values(REGION_STATE_MAP);
const regionCodes: RegionCode[] = ['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP'];
const stateToRegion = new Map<string, RegionCode>();
for (const region of regionCodes) {
stateToRegion.set(REGION_STATE_MAP[region], region);
}
const params: EiaQueryParams = {
frequency: 'monthly',
@ -401,7 +406,7 @@ export async function getRetailElectricityPrices(options: GetRetailPriceOptions
const results: RetailPricePoint[] = [];
for (const row of rows) {
if (row.price === null) continue;
const regionCode = stateToRegion.get(row.stateid);
const regionCode = STATE_TO_REGION.get(row.stateid);
if (!regionCode) continue;
results.push({

17
src/lib/granularity.ts Normal file
View File

@ -0,0 +1,17 @@
export type Granularity = 'hourly' | 'daily' | 'weekly';
const MS_PER_DAY = 86_400_000;
/**
* Select the appropriate data granularity based on the requested time range.
*
* - <= 7 days: hourly (raw data, ~168 points per region)
* - <= 2 years (730 days): daily (materialized view, ~730 points max)
* - > 2 years: weekly (materialized view, ~520 points for 10 years)
*/
export function getGranularity(startDate: Date, endDate: Date): Granularity {
const days = (endDate.getTime() - startDate.getTime()) / MS_PER_DAY;
if (days <= 7) return 'hourly';
if (days <= 730) return 'daily';
return 'weekly';
}

View File

@ -12,6 +12,13 @@ export const EIA_RESPONDENT_CODES = {
ISONE: 'ISNE',
MISO: 'MISO',
SPP: 'SWPP',
BPA: 'BPAT',
DUKE: 'DUK',
SOCO: 'SC',
TVA: 'TVA',
FPC: 'FPC',
WAPA: 'WACM',
NWMT: 'NWMT',
} as const;
export type RegionCode = keyof typeof EIA_RESPONDENT_CODES;
@ -26,6 +33,13 @@ export const RESPONDENT_TO_REGION: Record<EiaRespondentCode, RegionCode> = {
ISNE: 'ISONE',
MISO: 'MISO',
SWPP: 'SPP',
BPAT: 'BPA',
DUK: 'DUKE',
SC: 'SOCO',
TVA: 'TVA',
FPC: 'FPC',
WACM: 'WAPA',
NWMT: 'NWMT',
};
/** Type guard: check if a string is a valid EIA respondent code */
@ -141,6 +155,13 @@ export const REGION_STATE_MAP: Record<RegionCode, string> = {
NYISO: 'NY',
PJM: 'VA',
SPP: 'OK',
BPA: 'WA',
DUKE: 'NC',
SOCO: 'GA',
TVA: 'TN',
FPC: 'FL',
WAPA: 'CO',
NWMT: 'MT',
};
/** Row from the EIA retail-sales endpoint */

View File

@ -5,7 +5,22 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const VALID_REGION_CODES = new Set(['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP'] as const);
export const VALID_REGION_CODES = new Set([
'PJM',
'ERCOT',
'CAISO',
'NYISO',
'ISONE',
'MISO',
'SPP',
'BPA',
'DUKE',
'SOCO',
'TVA',
'FPC',
'WAPA',
'NWMT',
] as const);
export function validateRegionCode(code: string): boolean {
return code === 'ALL' || VALID_REGION_CODES.has(code);
@ -19,6 +34,13 @@ const REGION_TIMEZONES: Record<string, string> = {
ISONE: 'America/New_York',
MISO: 'America/Chicago',
SPP: 'America/Chicago',
BPA: 'America/Los_Angeles',
DUKE: 'America/New_York',
SOCO: 'America/New_York',
TVA: 'America/Chicago',
FPC: 'America/New_York',
WAPA: 'America/Denver',
NWMT: 'America/Denver',
};
export function formatMarketTime(utcDate: Date, regionCode: string): string {
@ -31,3 +53,26 @@ export function formatMarketTime(utcDate: Date, regionCode: string): string {
timeZoneName: 'short',
});
}
export function formatMarketDateTime(utcDate: Date, regionCode: string): string {
const timezone = REGION_TIMEZONES[regionCode] ?? 'America/New_York';
return utcDate.toLocaleString('en-US', {
timeZone: timezone,
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZoneName: 'short',
});
}
export function formatMarketDate(utcDate: Date, regionCode: string): string {
const timezone = REGION_TIMEZONES[regionCode] ?? 'America/New_York';
return utcDate.toLocaleString('en-US', {
timeZone: timezone,
month: 'short',
day: 'numeric',
year: '2-digit',
});
}