This commit is contained in:
Joey Eamigh 2026-04-05 12:56:40 -04:00
parent 46a3416526
commit 7423b8e622
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
30 changed files with 2346 additions and 11 deletions

View File

@ -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=="],

View File

@ -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
}
]

View File

@ -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
},
},
};

View File

@ -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": {

View File

@ -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);

View File

@ -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")

View File

@ -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<T>(relativePath: string, schema: z.ZodType<T>): 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<Array<{ count: bigint }>>(
@ -293,9 +333,13 @@ async function main() {
);
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');
const eventCount = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
'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<Array<{ name: string; location_text: string }>>(

View File

@ -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<void> {
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<void> {

389
src/actions/conflict.ts Normal file
View File

@ -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<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]);
}
interface ActionSuccess<T> {
ok: true;
data: ReturnType<typeof serialize<T>>;
}
interface ActionError {
ok: false;
error: string;
}
type ActionResult<T> = ActionSuccess<T> | 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<ActionResult<OilPriceRow[]>> {
'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<ActionResult<MarketIndicatorRow[]>> {
'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<ActionResult<SupplyMetricRow[]>> {
'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<ActionResult<{ commodity: string; price: number; timestamp: Date }[]>> {
'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<ActionResult<OilPriceRow[]>> {
'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<ActionResult<GeopoliticalEventRow[]>> {
'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<ActionResult<WarPremiumRow[]>> {
'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<ActionResult<ConflictHeroMetrics>> {
'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)}`,
};
}
}

View File

@ -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 };
}

View File

@ -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 <EventTimeline data={result.data} />;
}

View File

@ -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 (
<Card className={cn(isAlert && 'border-red-500/30 bg-red-500/5')}>
<CardContent className="flex flex-col gap-1 p-4">
<span className="text-xs font-medium tracking-wider text-muted-foreground uppercase">{label}</span>
<div className="flex items-baseline gap-1.5">
<span className={cn('text-2xl font-bold tabular-nums', isAlert && 'text-red-400')}>
{value !== null ? (unit.startsWith('$') ? `$${value.toFixed(2)}` : value.toFixed(1)) : '--'}
</span>
<span className="text-xs text-muted-foreground">{unit}</span>
</div>
{change !== null && change !== undefined && (
<span className={cn('text-xs font-medium tabular-nums', change >= 0 ? 'text-red-400' : 'text-emerald-400')}>
{change >= 0 ? '\u25B2' : '\u25BC'} {Math.abs(change).toFixed(1)}%
</span>
)}
</CardContent>
</Card>
);
}
export async function ConflictHeroMetrics() {
const result = await fetchConflictHeroMetrics();
if (!result.ok) return null;
const metrics = deserialize<ConflictHeroMetrics>(result.data);
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7">
<MetricCard
label="Brent Crude"
value={metrics.brentPrice}
unit="$/bbl"
change={metrics.brentChange}
alertThreshold={100}
/>
<MetricCard label="WTI Crude" value={metrics.wtiPrice} unit="$/bbl" alertThreshold={100} />
<MetricCard label="WTI-Brent Spread" value={metrics.wtiBrentSpread} unit="$/bbl" alertThreshold={5} />
<MetricCard label="Oil Volatility" value={metrics.ovxLevel} unit="OVX" alertThreshold={40} />
<MetricCard label="US Gasoline" value={metrics.gasolinePrice} unit="$/gal" alertThreshold={4.0} />
<MetricCard label="SPR Level" value={metrics.sprLevel} unit="M bbl" alertThreshold={300} invertAlert />
<MetricCard label="Geopolitical Risk" value={metrics.gprLevel} unit="GPR" alertThreshold={200} />
</div>
);
}

View File

@ -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<MarketIndicatorRow[]>(result.data);
// Get latest value for each indicator
const latest = new Map<string, number>();
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<string, number>();
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 (
<Card>
<CardHeader>
<CardTitle>Market Fear Indicators</CardTitle>
<CardDescription>Real-time volatility and stress gauges</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<VolatilityGauge
label="Oil Fear Gauge"
value={latestByType.get('ovx') ?? null}
maxValue={100}
thresholds={[25, 40]}
unit="OVX Index"
/>
<VolatilityGauge
label="Market Fear"
value={latestByType.get('vix') ?? null}
maxValue={80}
thresholds={[20, 35]}
unit="VIX Index"
/>
<VolatilityGauge
label="Dollar Strength"
value={latestByType.get('dxy') ?? null}
maxValue={150}
thresholds={[100, 120]}
unit="DXY Index"
/>
<VolatilityGauge
label="Financial Stress"
value={latestByType.get('financial_stress') ?? null}
maxValue={10}
thresholds={[1, 3]}
unit="STLFSI Index"
/>
</div>
</CardContent>
</Card>
);
}

View File

@ -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 <OilChart oilData={oilResult.data} events={eventsResult.ok ? eventsResult.data : undefined} />;
}

View File

@ -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 <SPRChart data={result.data} />;
}

View File

@ -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 <WarPremiumCard data={result.data} />;
}

109
src/app/conflict/page.tsx Normal file
View File

@ -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 (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7">
{Array.from({ length: 7 }).map((_, i) => (
<MetricCardSkeleton key={i} />
))}
</div>
);
}
function GaugeSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-1 h-3 w-64" />
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex flex-col items-center gap-2 rounded-lg border border-border/50 p-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-20 w-36 rounded" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
export default function ConflictPage() {
return (
<div className="space-y-6 px-4 py-6 sm:px-6 sm:py-8">
{/* Page header with alert banner */}
<div>
<div className="mb-4 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-4 py-3">
<AlertTriangle className="h-5 w-5 shrink-0 text-red-400" />
<div>
<p className="text-sm font-semibold text-red-400">Active Conflict Iran-US War (Week 6)</p>
<p className="text-xs text-muted-foreground">
Strait of Hormuz closed. Qatar LNG offline 3-5 years. SPR at historic lows. Data updates every 30 minutes.
</p>
</div>
</div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Conflict Energy Impact</h1>
<p className="mt-2 text-muted-foreground">
How the Iran-US conflict is reshaping energy markets oil prices, supply disruptions, and the cost to every
grid region.
</p>
</div>
{/* Hero metrics row */}
<Suspense fallback={<HeroSkeleton />}>
<ConflictHeroMetrics />
</Suspense>
{/* War Premium */}
<Suspense fallback={<ChartSkeleton />}>
<WarPremiumSection />
</Suspense>
{/* Oil prices chart */}
<Suspense fallback={<ChartSkeleton />}>
<OilSection />
</Suspense>
{/* Supply + Market Fear side by side on large screens */}
<div className="grid gap-6 lg:grid-cols-2">
<Suspense fallback={<ChartSkeleton />}>
<SupplySection />
</Suspense>
<Suspense fallback={<GaugeSkeleton />}>
<MarketFearSection />
</Suspense>
</div>
{/* Event Timeline */}
<Suspense fallback={<ChartSkeleton />}>
<EventsSection />
</Suspense>
</div>
);
}

View File

@ -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() {
</Link>
</div>
{/* Conflict alert banner */}
<Link
href="/conflict"
className="mb-4 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-4 py-3 transition-colors hover:border-red-500/50 hover:bg-red-500/10">
<AlertTriangle className="h-4 w-4 shrink-0 text-red-400" />
<div className="flex-1">
<p className="text-sm font-semibold text-red-400">Iran-US Conflict Active</p>
<p className="text-xs text-muted-foreground">
Strait of Hormuz closed. Oil markets disrupted. Click to view conflict energy impact analysis.
</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-red-400" />
</Link>
{/* Hero metric cards */}
<Suspense fallback={<MetricCardsSkeleton />}>
<HeroMetrics />

View File

@ -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<string, string> = {
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<string, string> = {
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<GeopoliticalEventRow[]>(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 (
<Card>
<CardHeader>
<CardTitle>Conflict Timeline</CardTitle>
<CardDescription>No events recorded yet.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Conflict Timeline</CardTitle>
<CardDescription>Key geopolitical events impacting energy markets</CardDescription>
</CardHeader>
<CardContent>
<div className="relative max-h-[60vh] space-y-2 overflow-y-auto pr-2">
{/* Timeline line */}
<div className="absolute top-0 bottom-0 left-3 w-px bg-border" />
{events.map(event => {
const catColor = getCategoryColor(event.category);
return (
<div
key={event.id}
className={cn(
'relative ml-7 rounded-lg border p-3 transition-colors',
SEVERITY_STYLES[event.severity] ?? SEVERITY_STYLES.low,
)}>
{/* Timeline dot */}
<div
className={cn(
'absolute top-4 -left-[22px] h-2.5 w-2.5 rounded-full ring-2 ring-background',
SEVERITY_DOT[event.severity] ?? SEVERITY_DOT.low,
)}
/>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="inline-block rounded px-1.5 py-0.5 text-[9px] font-bold tracking-wider uppercase"
style={{
backgroundColor: `${catColor}20`,
color: catColor,
}}>
{event.category}
</span>
<span className="text-[10px] text-muted-foreground">
{event.timestamp.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</div>
<h4 className="mt-1 text-sm font-semibold">{event.title}</h4>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{event.description}</p>
</div>
{event.sourceUrl && (
<a
href={event.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-[10px] text-muted-foreground hover:text-foreground">
Source
</a>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@ -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<OilPriceRow[]>(oilData), [oilData]);
const eventRows = useMemo(() => (events ? deserialize<GeopoliticalEventRow[]>(events) : []), [events]);
const pivoted = useMemo(() => {
const byDay = new Map<string, PivotedRow>();
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 (
<Card>
<CardHeader>
<CardTitle>Oil Prices</CardTitle>
<CardDescription>No oil price data available yet.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Oil Prices</CardTitle>
<CardDescription>WTI & Brent crude with geopolitical event annotations</CardDescription>
</div>
<button
onClick={() => setShowSpread(prev => !prev)}
className={cn(
'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
showSpread
? 'border-yellow-500/30 bg-yellow-500/10 text-yellow-300'
: 'border-border text-muted-foreground',
)}>
Show Spread
</button>
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[40vh] max-h-100 min-h-60 w-full">
{showSpread ? (
<AreaChart data={pivoted} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}
/>
<ChartTooltip
content={<ChartTooltipContent formatter={value => [`$${Number(value).toFixed(2)}`, undefined]} />}
/>
<ChartLegend content={<ChartLegendContent />} />
<Area
type="monotone"
dataKey="spread"
fill="var(--color-spread)"
fillOpacity={0.3}
stroke="var(--color-spread)"
strokeWidth={2}
/>
</AreaChart>
) : (
<LineChart data={pivoted} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}
/>
<ChartTooltip
content={<ChartTooltipContent formatter={value => [`$${Number(value).toFixed(2)}/bbl`, undefined]} />}
/>
<ChartLegend content={<ChartLegendContent />} />
<Line
type="monotone"
dataKey="brent_crude"
stroke="var(--color-brent_crude)"
strokeWidth={2}
dot={false}
connectNulls
/>
<Line
type="monotone"
dataKey="wti_crude"
stroke="var(--color-wti_crude)"
strokeWidth={2}
dot={false}
connectNulls
/>
</LineChart>
)}
</ChartContainer>
{/* Event annotations below chart */}
{eventMarkers.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{eventMarkers.map(e => {
const catColor = getCategoryColor(e.category);
return (
<span
key={e.id}
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium"
style={{
borderColor: `${catColor}40`,
backgroundColor: `${catColor}15`,
color: catColor,
}}
title={e.description}>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: catColor }} />
{e.timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}: {e.title}
</span>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -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<SupplyMetricRow[]>(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 (
<Card>
<CardHeader>
<CardTitle>Strategic Petroleum Reserve</CardTitle>
<CardDescription>No SPR data available yet.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Strategic Petroleum Reserve</CardTitle>
<CardDescription>US SPR stock levels (million barrels)</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[30vh] max-h-80 min-h-50 w-full">
<AreaChart data={rows} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v}M`}
domain={[0, SPR_CAPACITY + 50]}
/>
<ChartTooltip
content={<ChartTooltipContent formatter={value => [`${Number(value).toFixed(1)}M barrels`, undefined]} />}
/>
<ReferenceLine
y={SPR_CAPACITY}
stroke="hsl(210, 50%, 40%)"
strokeDasharray="6 3"
label={{ value: 'Capacity (714M)', position: 'insideTopRight', fontSize: 10, fill: 'hsl(210, 50%, 60%)' }}
/>
<ReferenceLine
y={SPR_1982_LOW}
stroke="hsl(0, 60%, 50%)"
strokeDasharray="6 3"
label={{
value: '1982 Low (264M)',
position: 'insideBottomRight',
fontSize: 10,
fill: 'hsl(0, 60%, 60%)',
}}
/>
<Area
type="monotone"
dataKey="spr_level"
fill="var(--color-spr_level)"
fillOpacity={0.2}
stroke="var(--color-spr_level)"
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@ -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 (
<div className="flex flex-col items-center gap-2 rounded-lg border border-border/50 bg-card p-4">
<span className="text-xs font-medium tracking-wider text-muted-foreground uppercase">{label}</span>
{/* Semicircular gauge using SVG */}
<div className="relative h-20 w-36">
<svg viewBox="0 0 140 80" className="h-full w-full">
{/* Background arc */}
<path
d="M 10 70 A 60 60 0 0 1 130 70"
fill="none"
stroke="hsl(var(--border))"
strokeWidth="8"
strokeLinecap="round"
/>
{/* Colored arc (filled portion) */}
<path
d="M 10 70 A 60 60 0 0 1 130 70"
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${(percentage / 100) * 188} 188`}
className="transition-all duration-700 ease-out"
/>
</svg>
{/* Value display in center */}
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1">
<span className="text-2xl font-bold tabular-nums" style={{ color }}>
{value !== null ? displayValue.toFixed(1) : '--'}
</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className={cn('h-2 w-2 rounded-full')} style={{ backgroundColor: color }} />
<span className="text-[10px] font-semibold tracking-widest" style={{ color }}>
{value !== null ? levelLabel : 'NO DATA'}
</span>
</div>
<span className="text-[10px] text-muted-foreground">{unit}</span>
</div>
);
}

View File

@ -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 (
<span className="inline-flex items-center gap-1.5 px-4 text-sm whitespace-nowrap">
<span className="font-medium text-muted-foreground">{item.label}</span>
<span className="font-bold tabular-nums">{item.price}</span>
<span className={cn('font-bold tabular-nums', alertValue && 'text-red-400')}>{item.price}</span>
<span className="text-xs text-muted-foreground">{item.unit}</span>
{item.change !== null && (
<span className={cn('text-xs font-medium tabular-nums', changeColor)}>
@ -82,6 +94,17 @@ const COMMODITY_LABELS: Record<string, string> = {
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() {

View File

@ -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<WarPremiumRow[]>(data), [data]);
if (rows.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>War Premium by Region</CardTitle>
<CardDescription>No generation data available to calculate premium.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Conflict Energy Premium</CardTitle>
<CardDescription>
Estimated $/MWh premium per region from elevated gas prices. Based on gas generation share and current vs.
pre-war baseline ($4/MMBtu).
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{rows.map(row => (
<div
key={row.regionCode}
className={cn('flex flex-col gap-1 rounded-lg border p-3 transition-colors', severityBg(row.premiumMwh))}>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{row.regionCode}</span>
<span className={cn('text-lg font-bold tabular-nums', severityColor(row.premiumMwh))}>
+${row.premiumMwh.toFixed(2)}
</span>
</div>
<span className="text-[10px] text-muted-foreground">{row.regionName}</span>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
<span>Gas share: {(row.gasGenerationShare * 100).toFixed(0)}%</span>
<span className="text-border">|</span>
<span>$/MWh premium</span>
</div>
{/* Mini bar showing gas share */}
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(row.gasGenerationShare * 100, 100)}%`,
backgroundColor: row.gasGenerationShare > 0.4 ? 'hsl(0, 70%, 55%)' : 'hsl(210, 70%, 55%)',
}}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@ -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 */}
<nav className="hidden items-center gap-1 md:flex">
{NAV_LINKS.map(({ href, label, icon: Icon }) => {
{NAV_LINKS.map(({ href, label, icon: Icon, ...rest }) => {
const isActive = href === '/' ? pathname === '/' : pathname.startsWith(href);
const hasAlert = 'alert' in rest && rest.alert;
return (
<Link
key={href}
href={href}
className={cn(
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
'relative flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground',
hasAlert && !isActive && 'text-red-400 hover:text-red-300',
)}>
<Icon className="h-4 w-4" />
{label}
{hasAlert && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background" />
)}
</Link>
);
})}
@ -77,8 +83,9 @@ export function Nav() {
</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 px-4">
{NAV_LINKS.map(({ href, label, icon: Icon }) => {
{NAV_LINKS.map(({ href, label, icon: Icon, ...rest }) => {
const isActive = href === '/' ? pathname === '/' : pathname.startsWith(href);
const hasAlert = 'alert' in rest && rest.alert;
return (
<Link
@ -86,13 +93,15 @@ export function Nav() {
href={href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'relative flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground',
hasAlert && !isActive && 'text-red-400 hover:text-red-300',
)}>
<Icon className="h-4 w-4" />
{label}
{hasAlert && <span className="ml-auto h-2 w-2 rounded-full bg-red-500" />}
</Link>
);
})}

View File

@ -15,10 +15,12 @@ import {
type RetailPricePoint,
eiaFuelTypeDataResponseSchema,
eiaRegionDataResponseSchema,
eiaResponseSchema,
eiaRetailPriceResponseSchema,
parseEiaPeriod,
resolveRegionCode,
} from '@/lib/schemas/electricity.js';
import { z } from 'zod';
const EIA_BASE_URL = 'https://api.eia.gov/v2';
const MAX_ROWS_PER_REQUEST = 5000;
@ -355,6 +357,129 @@ export async function getWTICrudePrice(options: GetCommodityPriceOptions = {}):
}));
}
// ---------------------------------------------------------------------------
// Petroleum data endpoints (new — conflict integration)
// ---------------------------------------------------------------------------
/**
* Zod schema for EIA petroleum data rows.
* Used for SPR levels, crude production, Brent spot prices, etc.
*/
const eiaPetroleumRowSchema = z.object({
period: z.string(),
value: z.union([z.string(), z.number(), z.null()]).transform((val): number | null => {
if (val === null || val === '') return null;
const num = Number(val);
return Number.isNaN(num) ? null : num;
}),
'series-description': z.string().optional(),
units: z.string().optional(),
});
const eiaPetroleumResponseSchema = eiaResponseSchema(eiaPetroleumRowSchema);
/**
* Fetch Strategic Petroleum Reserve (SPR) stock levels.
* Endpoint: /v2/petroleum/stoc/wstk/data/
* Facets: process=SAS (SPR), duoarea=NUS (national), product=EPC0 (crude)
* Returns weekly data in thousands of barrels we convert to million barrels.
*/
export async function getSPRLevels(options: GetCommodityPriceOptions = {}): Promise<CommodityPricePoint[]> {
const params: EiaQueryParams = {
frequency: 'weekly',
start: options.start,
end: options.end,
facets: {
process: ['SAS'],
duoarea: ['NUS'],
product: ['EPC0'],
},
sort: [{ column: 'period', direction: 'desc' }],
length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST,
};
const rows = await fetchAllPages('/petroleum/stoc/wstk/data/', params, json => {
const parsed = eiaPetroleumResponseSchema.parse(json);
return { total: parsed.response.total, data: parsed.response.data };
});
return rows
.filter(row => row.value !== null)
.map(row => ({
timestamp: parseEiaCommodityPeriod(row.period),
commodity: 'spr_level' as const,
price: row.value! / 1000, // thousands of barrels -> million barrels
unit: 'Million Barrels',
source: 'EIA',
}));
}
/**
* Fetch US crude oil production (weekly).
* Endpoint: /v2/petroleum/sum/sndw/data/
* Facets: process=YPT, duoarea=NUS, product=EPC0
* Returns thousands of barrels per day we convert to million barrels/day.
*/
export async function getUSCrudeProduction(options: GetCommodityPriceOptions = {}): Promise<CommodityPricePoint[]> {
const params: EiaQueryParams = {
frequency: 'weekly',
start: options.start,
end: options.end,
facets: {
process: ['YPT'],
duoarea: ['NUS'],
product: ['EPC0'],
},
sort: [{ column: 'period', direction: 'desc' }],
length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST,
};
const rows = await fetchAllPages('/petroleum/sum/sndw/data/', params, json => {
const parsed = eiaPetroleumResponseSchema.parse(json);
return { total: parsed.response.total, data: parsed.response.data };
});
return rows
.filter(row => row.value !== null)
.map(row => ({
timestamp: parseEiaCommodityPeriod(row.period),
commodity: 'us_crude_production' as const,
price: row.value! / 1000, // thousands bbl/day -> million bbl/day
unit: 'Million Barrels/Day',
source: 'EIA',
}));
}
/**
* Fetch Brent crude spot price (daily).
* Endpoint: /v2/petroleum/pri/spt/data/ with facets[series][]=RBRTE
*/
export async function getBrentCrudePrice(options: GetCommodityPriceOptions = {}): Promise<CommodityPricePoint[]> {
const params: EiaQueryParams = {
frequency: 'daily',
start: options.start,
end: options.end,
facets: {
series: ['RBRTE'],
},
sort: [{ column: 'period', direction: 'desc' }],
length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST,
};
const rows = await fetchAllPages('/petroleum/pri/spt/data/', params, json => {
const parsed = eiaWtiCrudeResponseSchema.parse(json);
return { total: parsed.response.total, data: parsed.response.data };
});
return rows.map(row => ({
timestamp: parseEiaCommodityPeriod(row.period),
commodity: 'brent_crude' as const,
price: row.value,
unit: row.units ?? '$/Barrel',
source: 'EIA',
}));
}
// ---------------------------------------------------------------------------
// Retail electricity prices (monthly, by state)
// ---------------------------------------------------------------------------

View File

@ -1,6 +1,6 @@
import { z } from 'zod';
import { COMMODITY_UNITS, type CommodityPrice, type CommodityType } from '@/lib/schemas/commodities.js';
import { COMMODITY_UNITS, type CommodityPrice, CommodityType } from '@/lib/schemas/commodities.js';
// ---------------------------------------------------------------------------
// FRED Series IDs
@ -10,6 +10,17 @@ const FRED_SERIES = {
natural_gas: 'DHHNGSP',
wti_crude: 'DCOILWTICO',
coal: 'PCOALAUUSDM',
brent_crude: 'DCOILBRENTEU',
gasoline: 'GASREGW',
diesel: 'GASDESW',
heating_oil: 'DHOILNYH',
ovx: 'OVXCLS',
vix: 'VIXCLS',
dxy: 'DTWEXBGS',
financial_stress: 'STLFSI4',
spr_level: '',
us_crude_production: '',
gpr_daily: '',
} as const satisfies Record<CommodityType, string>;
// ---------------------------------------------------------------------------
@ -224,6 +235,68 @@ export async function getCoalPrice(
return getCommodityPrices('coal', startDate, endDate);
}
export async function getBrentCrudePrice(
startDate?: Date | string,
endDate?: Date | string,
): Promise<FredApiResult<CommodityPrice[]>> {
return getCommodityPrices('brent_crude', startDate, endDate);
}
export async function getGasolinePrice(
startDate?: Date | string,
endDate?: Date | string,
): Promise<FredApiResult<CommodityPrice[]>> {
return getCommodityPrices('gasoline', startDate, endDate);
}
export async function getDieselPrice(
startDate?: Date | string,
endDate?: Date | string,
): Promise<FredApiResult<CommodityPrice[]>> {
return getCommodityPrices('diesel', startDate, endDate);
}
export async function getHeatingOilPrice(
startDate?: Date | string,
endDate?: Date | string,
): Promise<FredApiResult<CommodityPrice[]>> {
return getCommodityPrices('heating_oil', startDate, endDate);
}
export async function getOilVolatility(
startDate?: Date | string,
endDate?: Date | string,
): Promise<FredApiResult<CommodityPrice[]>> {
return getCommodityPrices('ovx', startDate, endDate);
}
export async function getMarketVolatility(
startDate?: Date | string,
endDate?: Date | string,
): Promise<FredApiResult<CommodityPrice[]>> {
return getCommodityPrices('vix', startDate, endDate);
}
export async function getDollarIndex(
startDate?: Date | string,
endDate?: Date | string,
): Promise<FredApiResult<CommodityPrice[]>> {
return getCommodityPrices('dxy', startDate, endDate);
}
export async function getFinancialStress(
startDate?: Date | string,
endDate?: Date | string,
): Promise<FredApiResult<CommodityPrice[]>> {
return getCommodityPrices('financial_stress', startDate, endDate);
}
/** All FRED-sourced commodity types (excludes SPR, production, GPR which come from EIA/other sources) */
const FRED_SERIES_STRING: Record<string, string> = FRED_SERIES;
export const FRED_COMMODITY_TYPES: CommodityType[] = CommodityType.options.filter(
key => key in FRED_SERIES_STRING && FRED_SERIES_STRING[key] !== '',
);
// ---------------------------------------------------------------------------
// Shared commodity fetcher
// ---------------------------------------------------------------------------
@ -234,6 +307,9 @@ async function getCommodityPrices(
endDate?: Date | string,
): Promise<FredApiResult<CommodityPrice[]>> {
const seriesId = FRED_SERIES[commodity];
if (!seriesId) {
return { ok: false, error: `No FRED series for commodity: ${commodity}` };
}
const result = await getSeriesObservations(seriesId, {
observationStart: formatDateParam(startDate),
observationEnd: formatDateParam(endDate),

98
src/lib/api/gpr.ts Normal file
View File

@ -0,0 +1,98 @@
/**
* Geopolitical Risk (GPR) Index client.
*
* Source: Dario Caldara and Matteo Iacoviello (2022),
* "Measuring Geopolitical Risk," American Economic Review.
* https://www.matteoiacoviello.com/gpr.htm
*
* Data is a daily Excel file (CC-BY-4.0), updated daily.
* We download it, parse with xlsx, and return CommodityPricePoint[].
*/
import { type CommodityPricePoint } from '@/lib/schemas/commodities.js';
import * as XLSX from 'xlsx';
const GPR_DAILY_URL = 'https://www.matteoiacoviello.com/gpr_files/data_gpr_daily_recent.xls';
const FETCH_TIMEOUT_MS = 30_000;
/**
* Fetch and parse the daily GPR index.
* Returns CommodityPricePoint[] with commodity='gpr_daily'.
*/
export async function getGeopoliticalRiskIndex(
options: {
start?: string;
end?: string;
} = {},
): Promise<CommodityPricePoint[]> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let buffer: ArrayBuffer;
try {
const response = await fetch(GPR_DAILY_URL, { signal: controller.signal });
if (!response.ok) {
throw new Error(`GPR download failed: HTTP ${response.status}`);
}
buffer = await response.arrayBuffer();
} finally {
clearTimeout(timeoutId);
}
const workbook = XLSX.read(buffer, { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]!];
if (!sheet) {
throw new Error('GPR Excel file has no sheets');
}
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet);
const startDate = options.start ? new Date(`${options.start}T00:00:00Z`) : null;
const endDate = options.end ? new Date(`${options.end}T00:00:00Z`) : null;
const results: CommodityPricePoint[] = [];
for (const row of rows) {
// The date column may be named "date" or "DATE" depending on the file version
const dateVal = row['date'] ?? row['DATE'];
const gprVal = row['GPRD'] ?? row['GPR_Daily'] ?? row['gprd'];
if (dateVal === undefined || gprVal === undefined) continue;
let timestamp: Date;
if (typeof dateVal === 'number') {
// Excel serial date number
timestamp = excelDateToUTC(dateVal);
} else if (typeof dateVal === 'string') {
timestamp = new Date(`${dateVal}T00:00:00Z`);
} else {
continue;
}
if (isNaN(timestamp.getTime())) continue;
const price = Number(gprVal);
if (isNaN(price)) continue;
if (startDate && timestamp < startDate) continue;
if (endDate && timestamp > endDate) continue;
results.push({
timestamp,
commodity: 'gpr_daily',
price,
unit: 'Index',
source: 'GPR',
});
}
return results;
}
/** Convert Excel serial date number to UTC Date */
function excelDateToUTC(serial: number): Date {
// Excel epoch is 1900-01-01, but has a leap year bug (day 60 = Feb 29, 1900 which didn't exist)
const utcDays = serial - 25569; // Days since Unix epoch
return new Date(utcDays * 86400 * 1000);
}

View File

@ -1,6 +1,26 @@
import { z } from 'zod';
export const CommodityType = z.enum(['natural_gas', 'wti_crude', 'coal']);
export const CommodityType = z.enum([
// Energy commodities
'natural_gas',
'wti_crude',
'coal',
// Petroleum (new)
'brent_crude',
'gasoline',
'diesel',
'heating_oil',
// Market indicators (new)
'ovx',
'vix',
'dxy',
'financial_stress',
// Supply metrics (new)
'spr_level',
'us_crude_production',
// Geopolitical risk (new)
'gpr_daily',
]);
export type CommodityType = z.infer<typeof CommodityType>;
export const CommodityPriceSchema = z.object({
@ -16,8 +36,44 @@ export const COMMODITY_UNITS: Record<CommodityType, string> = {
natural_gas: '$/Million BTU',
wti_crude: '$/Barrel',
coal: '$/Metric Ton',
brent_crude: '$/Barrel',
gasoline: '$/Gallon',
diesel: '$/Gallon',
heating_oil: '$/Gallon',
ovx: 'Index',
vix: 'Index',
dxy: 'Index',
financial_stress: 'Index',
spr_level: 'Million Barrels',
us_crude_production: 'Million Barrels/Day',
gpr_daily: 'Index',
};
export const COMMODITY_DISPLAY_NAMES: Record<CommodityType, string> = {
natural_gas: 'Natural Gas',
wti_crude: 'WTI Crude',
coal: 'Coal',
brent_crude: 'Brent Crude',
gasoline: 'Gasoline',
diesel: 'Diesel',
heating_oil: 'Heating Oil',
ovx: 'Oil Volatility (OVX)',
vix: 'Market Volatility (VIX)',
dxy: 'US Dollar Index',
financial_stress: 'Financial Stress',
spr_level: 'SPR Level',
us_crude_production: 'US Crude Production',
gpr_daily: 'Geopolitical Risk',
};
export const COMMODITY_CATEGORIES = {
petroleum: ['wti_crude', 'brent_crude', 'gasoline', 'diesel', 'heating_oil'],
energy_commodity: ['natural_gas', 'coal'],
market_indicator: ['ovx', 'vix', 'dxy', 'financial_stress'],
supply: ['spr_level', 'us_crude_production'],
geopolitical: ['gpr_daily'],
} as const;
/** Coerce EIA string values to numbers, treating empty/null as null */
const eiaNumericValue = z.union([z.string(), z.number(), z.null()]).transform((val): number | null => {
if (val === null || val === '') return null;
@ -54,11 +110,11 @@ export const eiaWtiCrudeRowSchema = z.object({
export type EiaWtiCrudeRow = z.infer<typeof eiaWtiCrudeRowSchema>;
/**
* Parsed commodity price data point (from EIA).
* Parsed commodity price data point (from EIA/FRED/GPR).
*/
export interface CommodityPricePoint {
timestamp: Date;
commodity: 'natural_gas' | 'wti_crude';
commodity: CommodityType;
price: number | null;
unit: string;
source: string;

View File

@ -0,0 +1,60 @@
import { z } from 'zod';
export const EventCategory = z.enum([
'military',
'sanctions',
'infrastructure',
'energy',
'cyber',
'diplomatic',
'humanitarian',
]);
export type EventCategory = z.infer<typeof EventCategory>;
export const EventSeverity = z.enum(['low', 'medium', 'high', 'critical']);
export type EventSeverity = z.infer<typeof EventSeverity>;
export const GeopoliticalEventSchema = z.object({
title: z.string(),
description: z.string(),
category: EventCategory,
severity: EventSeverity,
timestamp: z.coerce.date(),
sourceUrl: z.string().nullable().optional(),
});
export type GeopoliticalEvent = z.infer<typeof GeopoliticalEventSchema>;
export const GeopoliticalEventSeedSchema = z.array(GeopoliticalEventSchema);
/** Color mapping for event categories (used for chart annotations) */
export const EVENT_CATEGORY_COLORS: Record<EventCategory, string> = {
military: '#ef4444', // red-500
sanctions: '#f97316', // orange-500
infrastructure: '#f59e0b', // amber-500
energy: '#eab308', // yellow-500
cyber: '#a855f7', // purple-500
diplomatic: '#3b82f6', // blue-500
humanitarian: '#6b7280', // gray-500
};
/** Severity level numeric values for sorting */
export const SEVERITY_ORDER: Record<EventSeverity, number> = {
low: 0,
medium: 1,
high: 2,
critical: 3,
};
/** String-keyed versions for safe runtime lookup without type assertions */
const CATEGORY_COLOR_MAP: Record<string, string> = EVENT_CATEGORY_COLORS;
const SEVERITY_ORDER_MAP: Record<string, number> = SEVERITY_ORDER;
/** Safe lookup for event category color — returns fallback for unknown categories */
export function getCategoryColor(category: string): string {
return CATEGORY_COLOR_MAP[category] ?? '#6b7280';
}
/** Safe lookup for severity order — returns 0 for unknown severities */
export function getSeverityOrder(severity: string): number {
return SEVERITY_ORDER_MAP[severity] ?? 0;
}