Compare commits
10 Commits
224a9046fc
...
dd75b2fdce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd75b2fdce | ||
|
|
79850a61be | ||
|
|
d8478ace96 | ||
|
|
ad1a6792f5 | ||
|
|
9e83cfead3 | ||
|
|
8f99f6535e | ||
|
|
3251e30a2e | ||
|
|
564d212148 | ||
|
|
deb1cdc527 | ||
|
|
3edb69848d |
@ -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
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
pgdata
|
||||
*.md
|
||||
docker-compose.yml
|
||||
.prettierrc.js
|
||||
eslint.config.js
|
||||
@ -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
25
Dockerfile
Normal 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
211
README.md
@ -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
112
SPEC.md
@ -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
19
SUMMARY.md
Normal 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 ($200–500/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
@ -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
74471
data/power-plants.geojson
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
typedRoutes: true,
|
||||
cacheComponents: true,
|
||||
cacheLife: {
|
||||
|
||||
@ -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);
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 }>>(
|
||||
|
||||
4
prisma/sql/getAllPowerPlants.sql
Normal file
4
prisma/sql/getAllPowerPlants.sql
Normal 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
|
||||
39
prisma/sql/getCapacityPriceTimeline.sql
Normal file
39
prisma/sql/getCapacityPriceTimeline.sql
Normal 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
|
||||
54
prisma/sql/getDcPriceImpact.sql
Normal file
54
prisma/sql/getDcPriceImpact.sql
Normal 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
|
||||
21
prisma/sql/getDemandDaily.sql
Normal file
21
prisma/sql/getDemandDaily.sql
Normal 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
|
||||
21
prisma/sql/getDemandHourly.sql
Normal file
21
prisma/sql/getDemandHourly.sql
Normal 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
|
||||
21
prisma/sql/getDemandWeekly.sql
Normal file
21
prisma/sql/getDemandWeekly.sql
Normal 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
|
||||
14
prisma/sql/getGenerationDaily.sql
Normal file
14
prisma/sql/getGenerationDaily.sql
Normal 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
|
||||
11
prisma/sql/getGenerationHourly.sql
Normal file
11
prisma/sql/getGenerationHourly.sql
Normal 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
|
||||
14
prisma/sql/getGenerationWeekly.sql
Normal file
14
prisma/sql/getGenerationWeekly.sql
Normal 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
|
||||
@ -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
|
||||
|
||||
14
prisma/sql/getPricesDaily.sql
Normal file
14
prisma/sql/getPricesDaily.sql
Normal 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
|
||||
14
prisma/sql/getPricesHourly.sql
Normal file
14
prisma/sql/getPricesHourly.sql
Normal 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
|
||||
14
prisma/sql/getPricesWeekly.sql
Normal file
14
prisma/sql/getPricesWeekly.sql
Normal 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
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
|
||||
104
scripts/download-power-plants.ts
Normal file
104
scripts/download-power-plants.ts
Normal 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
56
src/actions/dc-impact.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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
49
src/actions/freshness.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
22
src/actions/power-plants.ts
Normal file
22
src/actions/power-plants.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
10
src/app/_sections/alerts-section.tsx
Normal file
10
src/app/_sections/alerts-section.tsx
Normal 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} />;
|
||||
}
|
||||
150
src/app/_sections/demand-summary.tsx
Normal file
150
src/app/_sections/demand-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/_sections/gpu-calculator-section.tsx
Normal file
28
src/app/_sections/gpu-calculator-section.tsx
Normal 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} />;
|
||||
}
|
||||
172
src/app/_sections/hero-metrics.tsx
Normal file
172
src/app/_sections/hero-metrics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/app/_sections/prices-by-region.tsx
Normal file
65
src/app/_sections/prices-by-region.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/app/_sections/stress-gauges.tsx
Normal file
100
src/app/_sections/stress-gauges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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)) {
|
||||
|
||||
16
src/app/demand/_sections/demand-chart-section.tsx
Normal file
16
src/app/demand/_sections/demand-chart-section.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/app/generation/_sections/generation-chart-section.tsx
Normal file
21
src/app/generation/_sections/generation-chart-section.tsx
Normal 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} />
|
||||
);
|
||||
}
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
124
src/app/map/_sections/map-content.tsx
Normal file
124
src/app/map/_sections/map-content.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
425
src/app/page.tsx
425
src/app/page.tsx
@ -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>
|
||||
);
|
||||
|
||||
16
src/app/trends/_sections/correlation-section.tsx
Normal file
16
src/app/trends/_sections/correlation-section.tsx
Normal 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} />;
|
||||
}
|
||||
42
src/app/trends/_sections/dc-impact-section.tsx
Normal file
42
src/app/trends/_sections/dc-impact-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/app/trends/_sections/price-chart-section.tsx
Normal file
64
src/app/trends/_sections/price-chart-section.tsx
Normal 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 };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
233
src/components/charts/dc-impact-timeline.tsx
Normal file
233
src/components/charts/dc-impact-timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
src/components/charts/dc-price-impact-bars.tsx
Normal file
175
src/components/charts/dc-price-impact-bars.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
82
src/components/layout/data-freshness.tsx
Normal file
82
src/components/layout/data-freshness.tsx
Normal 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 & 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 & FRED</span>
|
||||
<span className="text-muted-foreground/40">|</span>
|
||||
<span className={TEXT_CLASS[display.staleness]}>Updated {display.relativeTime}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
103
src/components/map/map-legend.tsx
Normal file
103
src/components/map/map-legend.tsx
Normal 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 >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>
|
||||
);
|
||||
}
|
||||
83
src/components/map/power-plant-marker.tsx
Normal file
83
src/components/map/power-plant-marker.tsx
Normal 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} · {plant.capacity_mw} MW
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdvancedMarker>
|
||||
);
|
||||
}
|
||||
|
||||
export { FUEL_TYPE_COLORS };
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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
123
src/instrumentation.ts
Normal 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);
|
||||
}
|
||||
@ -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
17
src/lib/granularity.ts
Normal 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';
|
||||
}
|
||||
@ -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 */
|
||||
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user