diff --git a/bun.lock b/bun.lock index 6c93622..86c6821 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "tailwindcss": "^4.1.18", "tslib": "^2.8.1", "tw-animate-css": "^1.4.0", + "xlsx": "^0.18.5", "zod": "^4.3.6", }, "devDependencies": { @@ -514,6 +515,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -568,6 +571,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], + "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], @@ -582,6 +587,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -596,6 +603,8 @@ "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -754,6 +763,8 @@ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], + "framer-motion": ["framer-motion@12.34.0", "", { "dependencies": { "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1178,6 +1189,8 @@ "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + "ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -1272,8 +1285,14 @@ "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], + + "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], diff --git a/data/geopolitical-events.json b/data/geopolitical-events.json new file mode 100644 index 0000000..e94d944 --- /dev/null +++ b/data/geopolitical-events.json @@ -0,0 +1,218 @@ +[ + { + "title": "Operation Epic Fury Begins", + "description": "US and Israeli forces launch coordinated strikes on Iranian military and nuclear sites, killing Supreme Leader Khamenei. Markets spike on fears of immediate supply disruption from the Persian Gulf. WTI jumps 8% in after-hours trading.", + "category": "military", + "severity": "critical", + "timestamp": "2026-02-28T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Iran Retaliates with Massive Missile and Drone Barrage", + "description": "Iran launches approximately 1,200 ballistic missiles and 4,000 drones at GCC nations including Saudi Arabia, UAE, and Qatar. Multiple oil and gas facilities targeted. Brent crude futures gap up 12% at Sunday open.", + "category": "military", + "severity": "critical", + "timestamp": "2026-03-01T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Iran Declares Strait of Hormuz Closed", + "description": "IRGC Navy declares the Strait of Hormuz closed to all commercial shipping and begins deploying naval mines. Qatar halts all LNG exports from Ras Laffan terminal as a precaution. Roughly 20% of global oil supply transits the strait daily.", + "category": "energy", + "severity": "critical", + "timestamp": "2026-03-02T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Gulf State Oil and LNG Infrastructure Strikes Confirmed", + "description": "Satellite imagery and ground reports confirm Iranian missile strikes damaged oil processing facilities in Saudi Arabia and LNG infrastructure in Qatar. Initial damage assessments suggest weeks to months of reduced output from affected sites.", + "category": "infrastructure", + "severity": "critical", + "timestamp": "2026-03-03T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Strait of Hormuz Fully Closed to Commercial Shipping", + "description": "IRGC completes mine-laying operations across the Strait of Hormuz, effectively halting all tanker traffic. Lloyd's of London suspends war risk insurance for Persian Gulf transits. An estimated 17-21 million barrels per day of oil flow is disrupted.", + "category": "energy", + "severity": "critical", + "timestamp": "2026-03-04T00:00:00Z", + "sourceUrl": null + }, + { + "title": "IEA Declares Largest Supply Disruption in Oil Market History", + "description": "The International Energy Agency convenes an emergency session and formally declares the Persian Gulf closure the largest supply disruption in the history of the global oil market. The agency calls on all member nations to prepare strategic reserve releases.", + "category": "diplomatic", + "severity": "high", + "timestamp": "2026-03-05T00:00:00Z", + "sourceUrl": null + }, + { + "title": "US Activates Strategic Petroleum Reserve Emergency Release", + "description": "The Department of Energy orders the first major SPR emergency drawdown, authorizing the release of 1 million barrels per day. The SPR stood at approximately 350 million barrels prior to the order. US natural gas prices also rise on power generation demand shifting away from LNG exports.", + "category": "energy", + "severity": "high", + "timestamp": "2026-03-07T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Brent Crude Crosses $100/Barrel", + "description": "Brent crude surpasses $100 per barrel for the first time since 2022, closing at $104.30. US electricity generators reliant on oil and natural gas begin seeing sharply higher fuel costs, with wholesale power prices rising 15-20% in gas-dependent regions like ERCOT.", + "category": "energy", + "severity": "high", + "timestamp": "2026-03-08T00:00:00Z", + "sourceUrl": null + }, + { + "title": "US Navy Begins Minesweeping Operations in Strait of Hormuz", + "description": "US Fifth Fleet deploys minesweeping vessels and underwater drones to begin clearing IRGC naval mines from the Strait of Hormuz. Operations proceed under continued IRGC harassment. Full clearing estimated to take weeks.", + "category": "military", + "severity": "medium", + "timestamp": "2026-03-09T00:00:00Z", + "sourceUrl": null + }, + { + "title": "IEA Coordinates 400M Barrel Collective Strategic Reserve Release", + "description": "IEA member nations agree to a coordinated release of 400 million barrels from strategic petroleum reserves — the largest collective action in the agency's history. The announcement temporarily caps oil price rises, with Brent pulling back 4% intraday.", + "category": "diplomatic", + "severity": "high", + "timestamp": "2026-03-11T00:00:00Z", + "sourceUrl": null + }, + { + "title": "US Grid Operators Issue Fuel Security Advisories", + "description": "PJM, MISO, and ISO-NE issue fuel security advisories warning of potential natural gas supply constraints as LNG export curtailments redirect gas domestically while pipeline capacity remains limited. Power prices in the Northeast rise 25% week-over-week.", + "category": "energy", + "severity": "medium", + "timestamp": "2026-03-12T00:00:00Z", + "sourceUrl": null + }, + { + "title": "OPEC+ Emergency Meeting — Saudi Pledges Maximum Output", + "description": "OPEC+ holds an emergency meeting in which Saudi Arabia pledges to increase production to maximum capacity. However, with the Strait of Hormuz still closed, most Gulf production increases cannot reach global markets by sea, limiting the practical impact.", + "category": "diplomatic", + "severity": "high", + "timestamp": "2026-03-14T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Cyberattacks Target US Energy Infrastructure", + "description": "DHS and CISA report a surge in state-sponsored cyberattacks targeting US power grid SCADA systems and pipeline operators, attributed to Iranian-aligned threat actors. No major disruptions confirmed, but several utilities activate emergency cybersecurity protocols.", + "category": "cyber", + "severity": "medium", + "timestamp": "2026-03-16T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Ras Laffan LNG Complex Confirmed Devastated", + "description": "QatarEnergy confirms that the Ras Laffan LNG complex — responsible for approximately 17% of global LNG supply — sustained catastrophic damage and will be offline for an estimated 3-5 years. European and Asian LNG spot prices surge to record highs overnight.", + "category": "infrastructure", + "severity": "critical", + "timestamp": "2026-03-18T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Dubai Crude Hits Record $166/Barrel Intraday", + "description": "Dubai crude reaches an intraday record of $166 per barrel as markets price in prolonged Gulf supply disruption. US wholesale electricity prices in ERCOT and CAISO spike to levels not seen since the 2021 Texas freeze and 2020 California rolling blackouts respectively.", + "category": "energy", + "severity": "critical", + "timestamp": "2026-03-19T00:00:00Z", + "sourceUrl": null + }, + { + "title": "DOE Orders Emergency Coal Plant Life Extensions", + "description": "The Department of Energy invokes emergency authority to order coal plants scheduled for retirement to remain operational, citing grid reliability concerns. Several plants in PJM and MISO that were weeks from decommissioning receive orders to continue operating indefinitely.", + "category": "energy", + "severity": "high", + "timestamp": "2026-03-21T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Trump Announces Pause on Strikes Against Iranian Energy Infrastructure", + "description": "President Trump announces a temporary pause on US strikes targeting Iranian oil and gas infrastructure, citing a desire to prevent further global energy price escalation. Oil prices drop 11% in a single session — the largest single-day decline since 2020.", + "category": "diplomatic", + "severity": "critical", + "timestamp": "2026-03-23T00:00:00Z", + "sourceUrl": null + }, + { + "title": "QatarEnergy Declares Force Majeure on All LNG Contracts", + "description": "QatarEnergy formally declares force majeure on all long-term LNG supply contracts, affecting buyers across Europe and Asia. European nations scramble to secure alternative supply, driving up US Henry Hub natural gas prices as LNG export demand surges.", + "category": "energy", + "severity": "high", + "timestamp": "2026-03-24T00:00:00Z", + "sourceUrl": null + }, + { + "title": "US SPR Falls Below 300M Barrels — Lowest Since 1984", + "description": "The US Strategic Petroleum Reserve drops below 300 million barrels for the first time since 1984, raising concerns about the nation's ability to sustain emergency releases. Congress begins debating emergency authorization for accelerated SPR refill once prices stabilize.", + "category": "energy", + "severity": "high", + "timestamp": "2026-03-25T00:00:00Z", + "sourceUrl": null + }, + { + "title": "IRGC Strikes Prince Sultan Air Base in Saudi Arabia", + "description": "IRGC launches a ballistic missile strike on Prince Sultan Air Base in Saudi Arabia, a key staging area for US air operations. The attack damages runway infrastructure and temporarily disrupts coalition air operations over the Persian Gulf.", + "category": "military", + "severity": "high", + "timestamp": "2026-03-27T00:00:00Z", + "sourceUrl": null + }, + { + "title": "AI Datacenter Operators Activate Backup Power Protocols", + "description": "Major hyperscale datacenter operators in Virginia (PJM territory) and Texas (ERCOT) activate diesel backup generators and demand response agreements as wholesale power prices hit sustained highs. Several operators report fuel delivery delays due to diesel supply constraints.", + "category": "energy", + "severity": "medium", + "timestamp": "2026-03-28T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Kuwait Desalination Plant Attacked — Water Crisis Begins", + "description": "Iranian-aligned forces strike a major desalination plant in Kuwait, triggering a humanitarian water crisis. Trump threatens retaliatory strikes on Iranian desalination infrastructure. Oil prices spike 6% on escalation fears.", + "category": "infrastructure", + "severity": "critical", + "timestamp": "2026-03-30T00:00:00Z", + "sourceUrl": null + }, + { + "title": "US Gasoline Surpasses $4/Gallon National Average", + "description": "The national average gasoline price surpasses $4 per gallon, with California averaging over $5.50. Rising fuel costs ripple through the economy, increasing transportation and logistics costs for all sectors including power plant fuel delivery.", + "category": "energy", + "severity": "high", + "timestamp": "2026-04-01T00:00:00Z", + "sourceUrl": null + }, + { + "title": "US Bombs B1 Bridge in Iran — First Civilian Infrastructure Target", + "description": "US forces strike the B1 bridge in Iran, marking the first deliberate targeting of civilian infrastructure in the conflict. The strike is widely condemned internationally and raises fears of further escalation. Oil prices jump 4% on the news.", + "category": "military", + "severity": "high", + "timestamp": "2026-04-02T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Iran Retaliates with Strikes on Gulf Refinery Infrastructure", + "description": "Iran launches retaliatory missile strikes targeting refinery infrastructure across the Persian Gulf, hitting facilities in Saudi Arabia and the UAE. Refined product supply tightens further as regional refining capacity drops an estimated 2 million barrels per day.", + "category": "infrastructure", + "severity": "critical", + "timestamp": "2026-04-03T00:00:00Z", + "sourceUrl": null + }, + { + "title": "European Natural Gas Prices Surge to 18-Month High", + "description": "European TTF natural gas prices surge to an 18-month high as the loss of Qatari LNG supply and ongoing Persian Gulf disruption force European buyers to compete aggressively for remaining global LNG cargoes. US LNG export terminals operate at maximum capacity.", + "category": "energy", + "severity": "high", + "timestamp": "2026-04-04T00:00:00Z", + "sourceUrl": null + }, + { + "title": "Trump Deadline for Iranian Surrender Expires", + "description": "President Trump's ultimatum for Iran to surrender expires without compliance. The administration threatens to escalate strikes against Iranian civilian infrastructure including power plants and water treatment facilities. Global markets brace for further escalation.", + "category": "diplomatic", + "severity": "high", + "timestamp": "2026-04-05T00:00:00Z", + "sourceUrl": null + } +] diff --git a/next.config.ts b/next.config.ts index f1544cb..fa101f1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -41,6 +41,12 @@ const nextConfig: NextConfig = { revalidate: 600, // 10 minutes expire: 1800, // 30 minutes }, + // Conflict / geopolitical data — moderate frequency + conflict: { + stale: 300, // 5 minutes + revalidate: 1800, // 30 minutes + expire: 3600, // 1 hour + }, }, }; diff --git a/package.json b/package.json index 2c30d7b..696c31a 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "tailwindcss": "^4.1.18", "tslib": "^2.8.1", "tw-animate-css": "^1.4.0", + "xlsx": "^0.18.5", "zod": "^4.3.6" }, "overrides": { diff --git a/prisma/migrations/20260405120000_add_geopolitical_events_and_commodity_views/migration.sql b/prisma/migrations/20260405120000_add_geopolitical_events_and_commodity_views/migration.sql new file mode 100644 index 0000000..3a277a8 --- /dev/null +++ b/prisma/migrations/20260405120000_add_geopolitical_events_and_commodity_views/migration.sql @@ -0,0 +1,41 @@ +-- Geopolitical events table +CREATE TABLE geopolitical_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT NOT NULL, + category TEXT NOT NULL, + severity TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + source_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX geopolitical_events_timestamp_idx ON geopolitical_events (timestamp); +CREATE INDEX geopolitical_events_category_idx ON geopolitical_events (category); + +-- Daily commodity price aggregates (for charts spanning months) +CREATE MATERIALIZED VIEW commodity_prices_daily AS +SELECT + commodity, + date_trunc('day', timestamp) AS day, + AVG(price) AS avg_price, + MIN(price) AS min_price, + MAX(price) AS max_price +FROM commodity_prices +GROUP BY commodity, date_trunc('day', timestamp); + +CREATE UNIQUE INDEX commodity_prices_daily_commodity_day + ON commodity_prices_daily (commodity, day); + +-- WTI-Brent spread (the geopolitical risk premium on oil) +CREATE MATERIALIZED VIEW oil_spread_daily AS +SELECT + b.day, + b.avg_price AS brent, + w.avg_price AS wti, + (b.avg_price - w.avg_price) AS spread +FROM commodity_prices_daily b +JOIN commodity_prices_daily w ON b.day = w.day +WHERE b.commodity = 'brent_crude' AND w.commodity = 'wti_crude'; + +CREATE UNIQUE INDEX oil_spread_daily_day ON oil_spread_daily (day); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 328a8b3..c4856b8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -77,6 +77,21 @@ model GenerationMix { @@map("generation_mix") } +model GeopoliticalEvent { + id String @id @default(uuid()) @db.Uuid + title String + description String + category String + severity String + timestamp DateTime @db.Timestamptz + sourceUrl String? @map("source_url") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + + @@index([timestamp]) + @@index([category]) + @@map("geopolitical_events") +} + model PowerPlant { id String @id @default(uuid()) @db.Uuid plantCode Int @unique @map("plant_code") diff --git a/prisma/seed.ts b/prisma/seed.ts index 52fcf7f..cd63d98 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -82,6 +82,15 @@ const AIMilestoneSchema = z.object({ category: z.string(), }); +const GeopoliticalEventSeedSchema = z.object({ + title: z.string(), + description: z.string(), + category: z.string(), + severity: z.string(), + timestamp: z.string(), + sourceUrl: z.string().nullable().optional(), +}); + function readAndParse(relativePath: string, schema: z.ZodType): T { const fullPath = resolve(import.meta.dirname, '..', relativePath); const raw: unknown = JSON.parse(readFileSync(fullPath, 'utf-8')); @@ -272,6 +281,34 @@ function validateAIMilestones() { console.log(' All milestones valid'); } +async function seedGeopoliticalEvents() { + console.log('Seeding geopolitical events...'); + + const events = readAndParse('data/geopolitical-events.json', z.array(GeopoliticalEventSeedSchema)); + + // Clear existing events + await prisma.$executeRawUnsafe('DELETE FROM geopolitical_events'); + + let inserted = 0; + for (const event of events) { + const id = randomUUID(); + await prisma.$executeRawUnsafe( + `INSERT INTO geopolitical_events (id, title, description, category, severity, timestamp, source_url, created_at) + VALUES ($1::uuid, $2, $3, $4, $5, $6::timestamptz, $7, NOW())`, + id, + event.title, + event.description, + event.category, + event.severity, + new Date(event.timestamp), + event.sourceUrl ?? null, + ); + inserted++; + } + + console.log(` Inserted ${inserted} geopolitical events`); +} + async function main() { console.log('Starting seed...\n'); @@ -286,6 +323,9 @@ async function main() { validateAIMilestones(); + await seedGeopoliticalEvents(); + console.log(''); + // Print summary console.log('\n--- Seed Summary ---'); const regionCount = await prisma.$queryRawUnsafe>( @@ -293,9 +333,13 @@ async function main() { ); const dcCount = await prisma.$queryRawUnsafe>('SELECT count(*) as count FROM datacenters'); const ppCount = await prisma.$queryRawUnsafe>('SELECT count(*) as count FROM power_plants'); + const eventCount = await prisma.$queryRawUnsafe>( + 'SELECT count(*) as count FROM geopolitical_events', + ); console.log(`Grid regions: ${regionCount[0]!.count.toString()}`); console.log(`Datacenters: ${dcCount[0]!.count.toString()}`); console.log(`Power plants: ${ppCount[0]!.count.toString()}`); + console.log(`Geopolitical events: ${eventCount[0]!.count.toString()}`); // Show sample spatial data const sample = await prisma.$queryRawUnsafe>( diff --git a/scripts/backfill.ts b/scripts/backfill.ts index db53554..ace82ab 100644 --- a/scripts/backfill.ts +++ b/scripts/backfill.ts @@ -20,6 +20,7 @@ import { PrismaClient } from '../src/generated/prisma/client.js'; import * as eia from '../src/lib/api/eia.js'; import { getRetailElectricityPrices } from '../src/lib/api/eia.js'; import * as fred from '../src/lib/api/fred.js'; +import { getGeopoliticalRiskIndex } from '../src/lib/api/gpr.js'; import { type RegionCode } from '../src/lib/schemas/electricity.js'; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); @@ -579,6 +580,121 @@ async function backfillCommodities(): Promise { log(` ERROR FRED coal: ${fredCoal.error}`); } + await sleep(REQUEST_DELAY_MS); + + // --- New conflict-era commodity series --- + + // EIA: Brent Crude + log(' Fetching EIA Brent crude prices...'); + try { + const brentData = await eia.getBrentCrudePrice({ start, end }); + for (const p of brentData) { + if (p.price === null) continue; + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + log(` EIA Brent crude: ${brentData.length} raw records`); + } catch (err) { + log(` ERROR EIA Brent crude: ${err instanceof Error ? err.message : String(err)}`); + } + + await sleep(REQUEST_DELAY_MS); + + // EIA: SPR Levels + log(' Fetching EIA SPR levels...'); + try { + const sprData = await eia.getSPRLevels({ start, end }); + for (const p of sprData) { + if (p.price === null) continue; + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + log(` EIA SPR levels: ${sprData.length} records`); + } catch (err) { + log(` ERROR EIA SPR levels: ${err instanceof Error ? err.message : String(err)}`); + } + + await sleep(REQUEST_DELAY_MS); + + // EIA: US Crude Production + log(' Fetching EIA US crude production...'); + try { + const prodData = await eia.getUSCrudeProduction({ start, end }); + for (const p of prodData) { + if (p.price === null) continue; + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + log(` EIA US crude production: ${prodData.length} records`); + } catch (err) { + log(` ERROR EIA US crude production: ${err instanceof Error ? err.message : String(err)}`); + } + + await sleep(REQUEST_DELAY_MS); + + // FRED: Brent Crude (backup) + log(' Fetching FRED Brent crude...'); + const fredBrent = await fred.getBrentCrudePrice(start, end); + if (fredBrent.ok) { + for (const p of fredBrent.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + log(` FRED Brent crude: ${fredBrent.data.length} records`); + } else { + log(` ERROR FRED Brent crude: ${fredBrent.error}`); + } + + await sleep(REQUEST_DELAY_MS); + + // FRED: Gasoline, Diesel, Heating Oil + for (const [label, fetcher] of [ + ['gasoline', fred.getGasolinePrice], + ['diesel', fred.getDieselPrice], + ['heating oil', fred.getHeatingOilPrice], + ] as const) { + log(` Fetching FRED ${label}...`); + const result = await fetcher(start, end); + if (result.ok) { + for (const p of result.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + log(` FRED ${label}: ${result.data.length} records`); + } else { + log(` ERROR FRED ${label}: ${result.error}`); + } + await sleep(REQUEST_DELAY_MS); + } + + // FRED: Market indicators (OVX, VIX, DXY, Financial Stress) + for (const [label, fetcher] of [ + ['OVX', fred.getOilVolatility], + ['VIX', fred.getMarketVolatility], + ['DXY', fred.getDollarIndex], + ['financial stress', fred.getFinancialStress], + ] as const) { + log(` Fetching FRED ${label}...`); + const result = await fetcher(start, end); + if (result.ok) { + for (const p of result.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + log(` FRED ${label}: ${result.data.length} records`); + } else { + log(` ERROR FRED ${label}: ${result.error}`); + } + await sleep(REQUEST_DELAY_MS); + } + + // GPR: Geopolitical Risk Index + log(' Fetching GPR index...'); + try { + const gprData = await getGeopoliticalRiskIndex({ start, end }); + for (const p of gprData) { + if (p.price === null) continue; + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + log(` GPR index: ${gprData.length} records`); + } catch (err) { + log(` ERROR GPR index: ${err instanceof Error ? err.message : String(err)}`); + } + if (rows.length === 0) { log(' No commodity data fetched'); return; @@ -655,6 +771,8 @@ const MATERIALIZED_VIEWS = [ 'electricity_prices_weekly', 'generation_mix_daily', 'generation_mix_weekly', + 'commodity_prices_daily', + 'oil_spread_daily', ] as const; async function refreshMaterializedViews(): Promise { diff --git a/src/actions/conflict.ts b/src/actions/conflict.ts new file mode 100644 index 0000000..c76edc0 --- /dev/null +++ b/src/actions/conflict.ts @@ -0,0 +1,389 @@ +'use server'; + +import { prisma } from '@/lib/db.js'; +import { serialize } from '@/lib/superjson.js'; +import { cacheLife, cacheTag } from 'next/cache'; + +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, 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]); +} + +interface ActionSuccess { + ok: true; + data: ReturnType>; +} + +interface ActionError { + ok: false; + error: string; +} + +type ActionResult = ActionSuccess | ActionError; + +// --------------------------------------------------------------------------- +// Oil Prices (WTI + Brent + spread) +// --------------------------------------------------------------------------- + +export interface OilPriceRow { + commodity: string; + price: number; + unit: string; + timestamp: Date; +} + +export async function fetchOilPrices(timeRange: TimeRange = '90d'): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag(`oil-prices-${timeRange}`); + + try { + const startDate = timeRangeToStartDate(timeRange); + const rows = await prisma.commodityPrice.findMany({ + where: { + commodity: { in: ['wti_crude', 'brent_crude'] }, + timestamp: { gte: startDate }, + }, + orderBy: { timestamp: 'asc' }, + select: { commodity: true, price: true, unit: true, timestamp: true }, + }); + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { ok: false, error: `Failed to fetch oil prices: ${err instanceof Error ? err.message : String(err)}` }; + } +} + +// --------------------------------------------------------------------------- +// Market Indicators (OVX, VIX, DXY, Financial Stress) +// --------------------------------------------------------------------------- + +export interface MarketIndicatorRow { + commodity: string; + price: number; + unit: string; + timestamp: Date; +} + +export async function fetchMarketIndicators(timeRange: TimeRange = '90d'): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag(`market-indicators-${timeRange}`); + + try { + const startDate = timeRangeToStartDate(timeRange); + const rows = await prisma.commodityPrice.findMany({ + where: { + commodity: { in: ['ovx', 'vix', 'dxy', 'financial_stress'] }, + timestamp: { gte: startDate }, + }, + orderBy: { timestamp: 'asc' }, + select: { commodity: true, price: true, unit: true, timestamp: true }, + }); + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch market indicators: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// --------------------------------------------------------------------------- +// Supply Metrics (SPR, US Crude Production) +// --------------------------------------------------------------------------- + +export interface SupplyMetricRow { + commodity: string; + price: number; + unit: string; + timestamp: Date; +} + +export async function fetchSupplyMetrics(timeRange: TimeRange = '1y'): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag(`supply-metrics-${timeRange}`); + + try { + const startDate = timeRangeToStartDate(timeRange); + const rows = await prisma.commodityPrice.findMany({ + where: { + commodity: { in: ['spr_level', 'us_crude_production'] }, + timestamp: { gte: startDate }, + }, + orderBy: { timestamp: 'asc' }, + select: { commodity: true, price: true, unit: true, timestamp: true }, + }); + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch supply metrics: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// --------------------------------------------------------------------------- +// Geopolitical Risk Index +// --------------------------------------------------------------------------- + +export async function fetchGeopoliticalRisk( + timeRange: TimeRange = '1y', +): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag(`gpr-${timeRange}`); + + try { + const startDate = timeRangeToStartDate(timeRange); + const rows = await prisma.commodityPrice.findMany({ + where: { + commodity: 'gpr_daily', + timestamp: { gte: startDate }, + }, + orderBy: { timestamp: 'asc' }, + select: { commodity: true, price: true, timestamp: true }, + }); + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch geopolitical risk: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// --------------------------------------------------------------------------- +// Petroleum Prices (Gasoline, Diesel, Heating Oil) +// --------------------------------------------------------------------------- + +export async function fetchPetroleumPrices(timeRange: TimeRange = '90d'): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag(`petroleum-prices-${timeRange}`); + + try { + const startDate = timeRangeToStartDate(timeRange); + const rows = await prisma.commodityPrice.findMany({ + where: { + commodity: { in: ['gasoline', 'diesel', 'heating_oil'] }, + timestamp: { gte: startDate }, + }, + orderBy: { timestamp: 'asc' }, + select: { commodity: true, price: true, unit: true, timestamp: true }, + }); + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch petroleum prices: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// --------------------------------------------------------------------------- +// Geopolitical Events +// --------------------------------------------------------------------------- + +export interface GeopoliticalEventRow { + id: string; + title: string; + description: string; + category: string; + severity: string; + timestamp: Date; + sourceUrl: string | null; +} + +export async function fetchGeopoliticalEvents( + timeRange: TimeRange = 'all', +): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag(`geopolitical-events-${timeRange}`); + + try { + const startDate = timeRangeToStartDate(timeRange); + const rows = await prisma.geopoliticalEvent.findMany({ + where: { timestamp: { gte: startDate } }, + orderBy: { timestamp: 'desc' }, + }); + return { ok: true, data: serialize(rows) }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch geopolitical events: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// --------------------------------------------------------------------------- +// War Premium Calculator +// --------------------------------------------------------------------------- + +export interface WarPremiumRow { + regionCode: string; + regionName: string; + gasGenerationShare: number; + currentGasPrice: number; + baselineGasPrice: number; + premiumMwh: number; +} + +/** + * Calculate the war premium for each region. + * Premium = (currentGas - baselineGas) * gasShare * heatRate + * where heatRate ≈ 7 MMBtu/MWh for efficient CCGT. + */ +export async function fetchWarPremium(): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag('war-premium'); + + try { + const HEAT_RATE_MMBTU_PER_MWH = 7; + const BASELINE_GAS_PRICE = 4.0; // $/MMBtu pre-war baseline (Feb 2026) + + // Get current natural gas price (latest) + const latestGas = await prisma.commodityPrice.findFirst({ + where: { commodity: 'natural_gas' }, + orderBy: { timestamp: 'desc' }, + select: { price: true }, + }); + const currentGasPrice = latestGas?.price ?? BASELINE_GAS_PRICE; + + // Get gas generation share per region from the last 7 days + const since = new Date(); + since.setUTCDate(since.getUTCDate() - 7); + + const regions = await prisma.gridRegion.findMany({ + select: { code: true, name: true, id: true }, + }); + + const results: WarPremiumRow[] = []; + + for (const region of regions) { + // Get total generation and gas generation for this region + const genData = await prisma.generationMix.findMany({ + where: { + regionId: region.id, + timestamp: { gte: since }, + }, + select: { fuelType: true, generationMw: true }, + }); + + if (genData.length === 0) continue; + + let totalGen = 0; + let gasGen = 0; + for (const row of genData) { + totalGen += row.generationMw; + if (row.fuelType === 'NG') { + gasGen += row.generationMw; + } + } + + const gasShare = totalGen > 0 ? gasGen / totalGen : 0; + const premium = (currentGasPrice - BASELINE_GAS_PRICE) * gasShare * HEAT_RATE_MMBTU_PER_MWH; + + results.push({ + regionCode: region.code, + regionName: region.name, + gasGenerationShare: gasShare, + currentGasPrice, + baselineGasPrice: BASELINE_GAS_PRICE, + premiumMwh: Math.max(0, premium), + }); + } + + // Sort by premium descending (highest impact first) + results.sort((a, b) => b.premiumMwh - a.premiumMwh); + + return { ok: true, data: serialize(results) }; + } catch (err) { + return { ok: false, error: `Failed to calculate war premium: ${err instanceof Error ? err.message : String(err)}` }; + } +} + +// --------------------------------------------------------------------------- +// Conflict Hero Metrics (latest values for key indicators) +// --------------------------------------------------------------------------- + +export interface ConflictHeroMetrics { + brentPrice: number | null; + brentChange: number | null; + wtiPrice: number | null; + wtiBrentSpread: number | null; + ovxLevel: number | null; + gasolinePrice: number | null; + sprLevel: number | null; + gprLevel: number | null; +} + +export async function fetchConflictHeroMetrics(): Promise> { + 'use cache'; + cacheLife('conflict'); + cacheTag('conflict-hero-metrics'); + + try { + const commoditiesToFetch = ['brent_crude', 'wti_crude', 'ovx', 'gasoline', 'spr_level', 'gpr_daily']; + + const latest = await prisma.commodityPrice.findMany({ + where: { commodity: { in: commoditiesToFetch } }, + orderBy: { timestamp: 'desc' }, + distinct: ['commodity'], + select: { commodity: true, price: true, timestamp: true }, + }); + + const byType = new Map(latest.map(r => [r.commodity, r])); + + // Get previous Brent price for change calculation + const brent = byType.get('brent_crude'); + let brentChange: number | null = null; + if (brent) { + const prevBrent = await prisma.commodityPrice.findFirst({ + where: { + commodity: 'brent_crude', + timestamp: { lt: brent.timestamp }, + }, + orderBy: { timestamp: 'desc' }, + select: { price: true }, + }); + if (prevBrent) { + brentChange = ((brent.price - prevBrent.price) / prevBrent.price) * 100; + } + } + + const wti = byType.get('wti_crude'); + + return { + ok: true, + data: serialize({ + brentPrice: brent?.price ?? null, + brentChange, + wtiPrice: wti?.price ?? null, + wtiBrentSpread: brent && wti ? brent.price - wti.price : null, + ovxLevel: byType.get('ovx')?.price ?? null, + gasolinePrice: byType.get('gasoline')?.price ?? null, + sprLevel: byType.get('spr_level')?.price ?? null, + gprLevel: byType.get('gpr_daily')?.price ?? null, + }), + }; + } catch (err) { + return { + ok: false, + error: `Failed to fetch conflict hero metrics: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} diff --git a/src/app/api/ingest/commodities/route.ts b/src/app/api/ingest/commodities/route.ts index 0d322b6..29ade20 100644 --- a/src/app/api/ingest/commodities/route.ts +++ b/src/app/api/ingest/commodities/route.ts @@ -3,6 +3,7 @@ import { NextResponse, type NextRequest } from 'next/server.js'; import { checkIngestAuth } from '@/app/api/ingest/auth.js'; import * as eia from '@/lib/api/eia.js'; import * as fred from '@/lib/api/fred.js'; +import { getGeopoliticalRiskIndex } from '@/lib/api/gpr.js'; import { prisma } from '@/lib/db.js'; interface IngestionStats { @@ -110,6 +111,144 @@ async function fetchAllCommodities(start?: string, end?: string): Promise<{ rows errors++; } + // --- New conflict-era commodity series --- + + // EIA: Brent Crude + try { + const brentData = await eia.getBrentCrudePrice({ start, end }); + for (const p of brentData) { + if (p.price === null) continue; + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } catch (err) { + console.error('Failed to fetch EIA Brent crude:', err); + errors++; + } + + // EIA: SPR Levels + try { + const sprData = await eia.getSPRLevels({ start, end }); + for (const p of sprData) { + if (p.price === null) continue; + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } catch (err) { + console.error('Failed to fetch EIA SPR levels:', err); + errors++; + } + + // EIA: US Crude Production + try { + const prodData = await eia.getUSCrudeProduction({ start, end }); + for (const p of prodData) { + if (p.price === null) continue; + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } catch (err) { + console.error('Failed to fetch EIA US crude production:', err); + errors++; + } + + // FRED: Brent Crude (backup/supplement to EIA) + const fredBrent = await fred.getBrentCrudePrice(start, end); + if (fredBrent.ok) { + for (const p of fredBrent.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } else { + console.error('Failed to fetch FRED Brent crude:', fredBrent.error); + errors++; + } + + // FRED: Gasoline + const fredGasoline = await fred.getGasolinePrice(start, end); + if (fredGasoline.ok) { + for (const p of fredGasoline.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } else { + console.error('Failed to fetch FRED gasoline:', fredGasoline.error); + errors++; + } + + // FRED: Diesel + const fredDiesel = await fred.getDieselPrice(start, end); + if (fredDiesel.ok) { + for (const p of fredDiesel.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } else { + console.error('Failed to fetch FRED diesel:', fredDiesel.error); + errors++; + } + + // FRED: Heating Oil + const fredHeatingOil = await fred.getHeatingOilPrice(start, end); + if (fredHeatingOil.ok) { + for (const p of fredHeatingOil.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } else { + console.error('Failed to fetch FRED heating oil:', fredHeatingOil.error); + errors++; + } + + // FRED: OVX (Oil Volatility) + const fredOvx = await fred.getOilVolatility(start, end); + if (fredOvx.ok) { + for (const p of fredOvx.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } else { + console.error('Failed to fetch FRED OVX:', fredOvx.error); + errors++; + } + + // FRED: VIX + const fredVix = await fred.getMarketVolatility(start, end); + if (fredVix.ok) { + for (const p of fredVix.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } else { + console.error('Failed to fetch FRED VIX:', fredVix.error); + errors++; + } + + // FRED: DXY (Dollar Index) + const fredDxy = await fred.getDollarIndex(start, end); + if (fredDxy.ok) { + for (const p of fredDxy.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } else { + console.error('Failed to fetch FRED DXY:', fredDxy.error); + errors++; + } + + // FRED: Financial Stress Index + const fredStress = await fred.getFinancialStress(start, end); + if (fredStress.ok) { + for (const p of fredStress.data) { + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } else { + console.error('Failed to fetch FRED financial stress:', fredStress.error); + errors++; + } + + // GPR: Geopolitical Risk Index + try { + const gprData = await getGeopoliticalRiskIndex({ start, end }); + for (const p of gprData) { + if (p.price === null) continue; + rows.push({ commodity: p.commodity, price: p.price, unit: p.unit, timestamp: p.timestamp, source: p.source }); + } + } catch (err) { + console.error('Failed to fetch GPR index:', err); + errors++; + } + return { rows, errors }; } diff --git a/src/app/conflict/_sections/events-section.tsx b/src/app/conflict/_sections/events-section.tsx new file mode 100644 index 0000000..3a7e0f5 --- /dev/null +++ b/src/app/conflict/_sections/events-section.tsx @@ -0,0 +1,9 @@ +import { fetchGeopoliticalEvents } from '@/actions/conflict.js'; +import { EventTimeline } from '@/components/charts/event-timeline.js'; + +export async function EventsSection() { + const result = await fetchGeopoliticalEvents('all'); + if (!result.ok) return null; + + return ; +} diff --git a/src/app/conflict/_sections/hero-metrics.tsx b/src/app/conflict/_sections/hero-metrics.tsx new file mode 100644 index 0000000..9c500f7 --- /dev/null +++ b/src/app/conflict/_sections/hero-metrics.tsx @@ -0,0 +1,67 @@ +import { fetchConflictHeroMetrics, type ConflictHeroMetrics } from '@/actions/conflict.js'; +import { Card, CardContent } from '@/components/ui/card.js'; +import { deserialize } from '@/lib/superjson.js'; +import { cn } from '@/lib/utils.js'; + +function MetricCard({ + label, + value, + unit, + change, + alertThreshold, + invertAlert, +}: { + label: string; + value: number | null; + unit: string; + change?: number | null; + alertThreshold?: number; + invertAlert?: boolean; +}) { + const isAlert = + alertThreshold !== undefined && value !== null && (invertAlert ? value < alertThreshold : value > alertThreshold); + + return ( + + + {label} +
+ + {value !== null ? (unit.startsWith('$') ? `$${value.toFixed(2)}` : value.toFixed(1)) : '--'} + + {unit} +
+ {change !== null && change !== undefined && ( + = 0 ? 'text-red-400' : 'text-emerald-400')}> + {change >= 0 ? '\u25B2' : '\u25BC'} {Math.abs(change).toFixed(1)}% + + )} +
+
+ ); +} + +export async function ConflictHeroMetrics() { + const result = await fetchConflictHeroMetrics(); + if (!result.ok) return null; + + const metrics = deserialize(result.data); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/src/app/conflict/_sections/market-fear-section.tsx b/src/app/conflict/_sections/market-fear-section.tsx new file mode 100644 index 0000000..29bd860 --- /dev/null +++ b/src/app/conflict/_sections/market-fear-section.tsx @@ -0,0 +1,72 @@ +import { fetchMarketIndicators } from '@/actions/conflict.js'; +import { VolatilityGauge } from '@/components/charts/volatility-gauge.js'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { deserialize } from '@/lib/superjson.js'; + +import type { MarketIndicatorRow } from '@/actions/conflict.js'; + +export async function MarketFearSection() { + const result = await fetchMarketIndicators('90d'); + if (!result.ok) return null; + + const rows = deserialize(result.data); + + // Get latest value for each indicator + const latest = new Map(); + for (const row of rows) { + const existing = latest.get(row.commodity); + if (existing === undefined) { + latest.set(row.commodity, row.price); + } + } + // The data is ordered by timestamp asc, so the last value per commodity is already there. + // But we need to get the LAST occurrence. Let's reverse iterate. + const latestByType = new Map(); + for (let i = rows.length - 1; i >= 0; i--) { + const row = rows[i]!; + if (!latestByType.has(row.commodity)) { + latestByType.set(row.commodity, row.price); + } + } + + return ( + + + Market Fear Indicators + Real-time volatility and stress gauges + + +
+ + + + +
+
+
+ ); +} diff --git a/src/app/conflict/_sections/oil-section.tsx b/src/app/conflict/_sections/oil-section.tsx new file mode 100644 index 0000000..b6e507b --- /dev/null +++ b/src/app/conflict/_sections/oil-section.tsx @@ -0,0 +1,10 @@ +import { fetchGeopoliticalEvents, fetchOilPrices } from '@/actions/conflict.js'; +import { OilChart } from '@/components/charts/oil-chart.js'; + +export async function OilSection() { + const [oilResult, eventsResult] = await Promise.all([fetchOilPrices('90d'), fetchGeopoliticalEvents('all')]); + + if (!oilResult.ok) return null; + + return ; +} diff --git a/src/app/conflict/_sections/supply-section.tsx b/src/app/conflict/_sections/supply-section.tsx new file mode 100644 index 0000000..b65a0fa --- /dev/null +++ b/src/app/conflict/_sections/supply-section.tsx @@ -0,0 +1,9 @@ +import { fetchSupplyMetrics } from '@/actions/conflict.js'; +import { SPRChart } from '@/components/charts/spr-chart.js'; + +export async function SupplySection() { + const result = await fetchSupplyMetrics('1y'); + if (!result.ok) return null; + + return ; +} diff --git a/src/app/conflict/_sections/war-premium-section.tsx b/src/app/conflict/_sections/war-premium-section.tsx new file mode 100644 index 0000000..d3e1b41 --- /dev/null +++ b/src/app/conflict/_sections/war-premium-section.tsx @@ -0,0 +1,9 @@ +import { fetchWarPremium } from '@/actions/conflict.js'; +import { WarPremiumCard } from '@/components/dashboard/war-premium-card.js'; + +export async function WarPremiumSection() { + const result = await fetchWarPremium(); + if (!result.ok) return null; + + return ; +} diff --git a/src/app/conflict/page.tsx b/src/app/conflict/page.tsx new file mode 100644 index 0000000..5765aef --- /dev/null +++ b/src/app/conflict/page.tsx @@ -0,0 +1,109 @@ +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 { AlertTriangle } from 'lucide-react'; +import type { Metadata } from 'next'; + +import { EventsSection } from './_sections/events-section.js'; +import { ConflictHeroMetrics } from './_sections/hero-metrics.js'; +import { MarketFearSection } from './_sections/market-fear-section.js'; +import { OilSection } from './_sections/oil-section.js'; +import { SupplySection } from './_sections/supply-section.js'; +import { WarPremiumSection } from './_sections/war-premium-section.js'; + +export const metadata: Metadata = { + title: 'Conflict Impact | Energy & AI Dashboard', + description: + 'Iran conflict energy market impact — oil prices, supply disruptions, war premium, and geopolitical events', +}; + +function HeroSkeleton() { + return ( +
+ {Array.from({ length: 7 }).map((_, i) => ( + + ))} +
+ ); +} + +function GaugeSkeleton() { + return ( + + + + + + +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+
+ ); +} + +export default function ConflictPage() { + return ( +
+ {/* Page header with alert banner */} +
+
+ +
+

Active Conflict — Iran-US War (Week 6)

+

+ Strait of Hormuz closed. Qatar LNG offline 3-5 years. SPR at historic lows. Data updates every 30 minutes. +

+
+
+ +

Conflict Energy Impact

+

+ How the Iran-US conflict is reshaping energy markets — oil prices, supply disruptions, and the cost to every + grid region. +

+
+ + {/* Hero metrics row */} + }> + + + + {/* War Premium */} + }> + + + + {/* Oil prices chart */} + }> + + + + {/* Supply + Market Fear side by side on large screens */} +
+ }> + + + + }> + + +
+ + {/* Event Timeline */} + }> + + +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index be8d4e7..66453d7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,7 +4,7 @@ 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 { Activity, AlertTriangle, ArrowRight, Map as MapIcon } from 'lucide-react'; import Link from 'next/link'; import { AlertsSection } from './_sections/alerts-section.js'; @@ -136,6 +136,20 @@ export default function DashboardHome() { + {/* Conflict alert banner */} + + +
+

Iran-US Conflict Active

+

+ Strait of Hormuz closed. Oil markets disrupted. Click to view conflict energy impact analysis. +

+
+ + + {/* Hero metric cards */} }> diff --git a/src/components/charts/event-timeline.tsx b/src/components/charts/event-timeline.tsx new file mode 100644 index 0000000..6e1d950 --- /dev/null +++ b/src/components/charts/event-timeline.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useMemo } from 'react'; +import type { SuperJSONResult } from 'superjson'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { getCategoryColor, getSeverityOrder } from '@/lib/schemas/geopolitical.js'; +import { deserialize } from '@/lib/superjson.js'; +import { cn } from '@/lib/utils.js'; + +import type { GeopoliticalEventRow } from '@/actions/conflict.js'; + +interface EventTimelineProps { + data: SuperJSONResult; +} + +const SEVERITY_STYLES: Record = { + critical: 'border-red-500/40 bg-red-500/10', + high: 'border-orange-500/40 bg-orange-500/10', + medium: 'border-yellow-500/40 bg-yellow-500/10', + low: 'border-border bg-muted/30', +}; + +const SEVERITY_DOT: Record = { + critical: 'bg-red-500', + high: 'bg-orange-500', + medium: 'bg-yellow-500', + low: 'bg-muted-foreground', +}; + +export function EventTimeline({ data }: EventTimelineProps) { + const events = useMemo(() => { + const rows = deserialize(data); + return rows.sort((a, b) => { + const timeDiff = b.timestamp.getTime() - a.timestamp.getTime(); + if (timeDiff !== 0) return timeDiff; + return getSeverityOrder(b.severity) - getSeverityOrder(a.severity); + }); + }, [data]); + + if (events.length === 0) { + return ( + + + Conflict Timeline + No events recorded yet. + + + ); + } + + return ( + + + Conflict Timeline + Key geopolitical events impacting energy markets + + +
+ {/* Timeline line */} +
+ + {events.map(event => { + const catColor = getCategoryColor(event.category); + return ( +
+ {/* Timeline dot */} +
+ +
+
+
+ + {event.category} + + + {event.timestamp.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + +
+

{event.title}

+

{event.description}

+
+ {event.sourceUrl && ( + + Source + + )} +
+
+ ); + })} +
+ + + ); +} diff --git a/src/components/charts/oil-chart.tsx b/src/components/charts/oil-chart.tsx new file mode 100644 index 0000000..9152e6e --- /dev/null +++ b/src/components/charts/oil-chart.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'; +import type { SuperJSONResult } from 'superjson'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from '@/components/ui/chart.js'; +import { getCategoryColor } from '@/lib/schemas/geopolitical.js'; +import { deserialize } from '@/lib/superjson.js'; +import { cn } from '@/lib/utils.js'; + +import type { GeopoliticalEventRow, OilPriceRow } from '@/actions/conflict.js'; + +interface OilChartProps { + oilData: SuperJSONResult; + events?: SuperJSONResult; +} + +interface PivotedRow { + timestamp: number; + label: string; + wti_crude?: number; + brent_crude?: number; + spread?: number; +} + +const chartConfig: ChartConfig = { + brent_crude: { label: 'Brent Crude', color: 'hsl(0, 70%, 55%)' }, + wti_crude: { label: 'WTI Crude', color: 'hsl(210, 80%, 55%)' }, + spread: { label: 'WTI-Brent Spread', color: 'hsl(45, 80%, 55%)' }, +}; + +export function OilChart({ oilData, events }: OilChartProps) { + const [showSpread, setShowSpread] = useState(false); + const rows = useMemo(() => deserialize(oilData), [oilData]); + const eventRows = useMemo(() => (events ? deserialize(events) : []), [events]); + + const pivoted = useMemo(() => { + const byDay = new Map(); + + for (const row of rows) { + const dayKey = row.timestamp.toISOString().slice(0, 10); + if (!byDay.has(dayKey)) { + byDay.set(dayKey, { + timestamp: row.timestamp.getTime(), + label: row.timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + }); + } + const pivot = byDay.get(dayKey)!; + if (row.commodity === 'wti_crude') pivot.wti_crude = row.price; + if (row.commodity === 'brent_crude') pivot.brent_crude = row.price; + } + + const result = Array.from(byDay.values()).sort((a, b) => a.timestamp - b.timestamp); + + // Calculate spread + for (const row of result) { + if (row.brent_crude !== undefined && row.wti_crude !== undefined) { + row.spread = row.brent_crude - row.wti_crude; + } + } + + return result; + }, [rows]); + + // Find event timestamps within data range for annotation markers + const eventMarkers = useMemo(() => { + if (pivoted.length === 0 || eventRows.length === 0) return []; + const minTs = pivoted[0]!.timestamp; + const maxTs = pivoted[pivoted.length - 1]!.timestamp; + return eventRows.filter(e => { + const ts = e.timestamp.getTime(); + return ts >= minTs && ts <= maxTs && (e.severity === 'critical' || e.severity === 'high'); + }); + }, [pivoted, eventRows]); + + if (pivoted.length === 0) { + return ( + + + Oil Prices + No oil price data available yet. + + + ); + } + + return ( + + +
+
+ Oil Prices + WTI & Brent crude with geopolitical event annotations +
+ +
+
+ + + {showSpread ? ( + + + + `$${v}`} + /> + [`$${Number(value).toFixed(2)}`, undefined]} />} + /> + } /> + + + ) : ( + + + + `$${v}`} + /> + [`$${Number(value).toFixed(2)}/bbl`, undefined]} />} + /> + } /> + + + + )} + + + {/* Event annotations below chart */} + {eventMarkers.length > 0 && ( +
+ {eventMarkers.map(e => { + const catColor = getCategoryColor(e.category); + return ( + + + {e.timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}: {e.title} + + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/charts/spr-chart.tsx b/src/components/charts/spr-chart.tsx new file mode 100644 index 0000000..25db8be --- /dev/null +++ b/src/components/charts/spr-chart.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useMemo } from 'react'; +import { Area, AreaChart, CartesianGrid, 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 { deserialize } from '@/lib/superjson.js'; + +import type { SupplyMetricRow } from '@/actions/conflict.js'; + +interface SPRChartProps { + data: SuperJSONResult; +} + +const chartConfig: ChartConfig = { + spr_level: { label: 'SPR Level', color: 'hsl(210, 80%, 55%)' }, +}; + +/** Historical reference levels (million barrels) */ +const SPR_CAPACITY = 714; +const SPR_1982_LOW = 264; + +export function SPRChart({ data }: SPRChartProps) { + const rows = useMemo(() => { + const all = deserialize(data); + return all + .filter(r => r.commodity === 'spr_level') + .map(r => ({ + timestamp: r.timestamp.getTime(), + label: r.timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }), + spr_level: r.price, + })) + .sort((a, b) => a.timestamp - b.timestamp); + }, [data]); + + if (rows.length === 0) { + return ( + + + Strategic Petroleum Reserve + No SPR data available yet. + + + ); + } + + return ( + + + Strategic Petroleum Reserve + US SPR stock levels (million barrels) + + + + + + + `${v}M`} + domain={[0, SPR_CAPACITY + 50]} + /> + [`${Number(value).toFixed(1)}M barrels`, undefined]} />} + /> + + + + + + + + ); +} diff --git a/src/components/charts/volatility-gauge.tsx b/src/components/charts/volatility-gauge.tsx new file mode 100644 index 0000000..691ad64 --- /dev/null +++ b/src/components/charts/volatility-gauge.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { cn } from '@/lib/utils.js'; + +interface VolatilityGaugeProps { + label: string; + value: number | null; + maxValue: number; + /** Color zone thresholds: [green → yellow boundary, yellow → red boundary] */ + thresholds: [number, number]; + unit?: string; +} + +function getColor(value: number, thresholds: [number, number]): string { + if (value < thresholds[0]) return 'hsl(142, 71%, 45%)'; // green + if (value < thresholds[1]) return 'hsl(45, 93%, 47%)'; // yellow/amber + return 'hsl(0, 72%, 51%)'; // red +} + +function getLabel(value: number, thresholds: [number, number]): string { + if (value < thresholds[0]) return 'LOW'; + if (value < thresholds[1]) return 'ELEVATED'; + return 'EXTREME'; +} + +export function VolatilityGauge({ label, value, maxValue, thresholds, unit = 'Index' }: VolatilityGaugeProps) { + const displayValue = value ?? 0; + const percentage = Math.min((displayValue / maxValue) * 100, 100); + const color = getColor(displayValue, thresholds); + const levelLabel = getLabel(displayValue, thresholds); + + return ( +
+ {label} + + {/* Semicircular gauge using SVG */} +
+ + {/* Background arc */} + + {/* Colored arc (filled portion) */} + + + {/* Value display in center */} +
+ + {value !== null ? displayValue.toFixed(1) : '--'} + +
+
+ +
+ + + {value !== null ? levelLabel : 'NO DATA'} + +
+ {unit} +
+ ); +} diff --git a/src/components/dashboard/ticker-tape.tsx b/src/components/dashboard/ticker-tape.tsx index 87bdd35..a63d9e5 100644 --- a/src/components/dashboard/ticker-tape.tsx +++ b/src/components/dashboard/ticker-tape.tsx @@ -27,16 +27,28 @@ function computeChangePercent(current: number, previous: number | null): number return ((current - previous) / previous) * 100; } +/** Highlight certain commodity values in red when they cross alert thresholds */ +function isAlertValue(label: string, price: string): boolean { + const num = parseFloat(price.replace('$', '')); + if (isNaN(num)) return false; + if (label === 'OVX' && num > 40) return true; + if (label === 'Gasoline' && num > 4.0) return true; + if (label === 'Brent' && num > 100) return true; + if (label === 'WTI Crude' && num > 100) return true; + return false; +} + function TickerItemDisplay({ item }: { item: TickerItem }) { const changeColor = item.change === null ? 'text-muted-foreground' : item.change >= 0 ? 'text-emerald-400' : 'text-red-400'; const changeSymbol = item.change === null ? '' : item.change >= 0 ? '\u25B2' : '\u25BC'; + const alertValue = isAlertValue(item.label, item.price); return ( {item.label} - {item.price} + {item.price} {item.unit} {item.change !== null && ( @@ -82,6 +94,17 @@ const COMMODITY_LABELS: Record = { natural_gas: 'Nat Gas', wti_crude: 'WTI Crude', coal: 'Coal', + brent_crude: 'Brent', + gasoline: 'Gasoline', + diesel: 'Diesel', + heating_oil: 'Heat Oil', + ovx: 'OVX', + vix: 'VIX', + dxy: 'DXY', + financial_stress: 'Fin Stress', + spr_level: 'SPR', + us_crude_production: 'US Prod', + gpr_daily: 'GPR', }; export function TickerTape() { diff --git a/src/components/dashboard/war-premium-card.tsx b/src/components/dashboard/war-premium-card.tsx new file mode 100644 index 0000000..ae9e8ef --- /dev/null +++ b/src/components/dashboard/war-premium-card.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useMemo } from 'react'; +import type { SuperJSONResult } from 'superjson'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { deserialize } from '@/lib/superjson.js'; +import { cn } from '@/lib/utils.js'; + +import type { WarPremiumRow } from '@/actions/conflict.js'; + +interface WarPremiumCardProps { + data: SuperJSONResult; +} + +function severityColor(premiumMwh: number): string { + if (premiumMwh >= 10) return 'text-red-400'; + if (premiumMwh >= 5) return 'text-amber-400'; + if (premiumMwh >= 2) return 'text-yellow-400'; + return 'text-emerald-400'; +} + +function severityBg(premiumMwh: number): string { + if (premiumMwh >= 10) return 'bg-red-500/10 border-red-500/20'; + if (premiumMwh >= 5) return 'bg-amber-500/10 border-amber-500/20'; + if (premiumMwh >= 2) return 'bg-yellow-500/10 border-yellow-500/20'; + return 'bg-emerald-500/10 border-emerald-500/20'; +} + +export function WarPremiumCard({ data }: WarPremiumCardProps) { + const rows = useMemo(() => deserialize(data), [data]); + + if (rows.length === 0) { + return ( + + + War Premium by Region + No generation data available to calculate premium. + + + ); + } + + return ( + + + Conflict Energy Premium + + Estimated $/MWh premium per region from elevated gas prices. Based on gas generation share and current vs. + pre-war baseline ($4/MMBtu). + + + +
+ {rows.map(row => ( +
+
+ {row.regionCode} + + +${row.premiumMwh.toFixed(2)} + +
+ {row.regionName} +
+ Gas share: {(row.gasGenerationShare * 100).toFixed(0)}% + | + $/MWh premium +
+ {/* Mini bar showing gas share */} +
+
0.4 ? 'hsl(0, 70%, 55%)' : 'hsl(210, 70%, 55%)', + }} + /> +
+
+ ))} +
+ + + ); +} diff --git a/src/components/layout/nav.tsx b/src/components/layout/nav.tsx index 61681cd..58f34d1 100644 --- a/src/components/layout/nav.tsx +++ b/src/components/layout/nav.tsx @@ -5,13 +5,14 @@ import { PriceAlertMonitor } from '@/components/dashboard/price-alert.js'; import { TickerTape } from '@/components/dashboard/ticker-tape.js'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet.js'; import { cn } from '@/lib/utils.js'; -import { Activity, BarChart3, Flame, LayoutDashboard, Map, Menu, TrendingUp } from 'lucide-react'; +import { Activity, AlertTriangle, BarChart3, Flame, LayoutDashboard, Map, Menu, TrendingUp } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation.js'; import { useState } from 'react'; const NAV_LINKS = [ { href: '/', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/conflict', label: 'Conflict', icon: AlertTriangle, alert: true }, { href: '/map', label: 'Map', icon: Map }, { href: '/trends', label: 'Trends', icon: TrendingUp }, { href: '/demand', label: 'Demand', icon: Activity }, @@ -34,21 +35,26 @@ export function Nav() { {/* Desktop navigation */}