diff --git a/content/blog/articles.json b/content/blog/articles.json
index 1ad8f9a..cd2974b 100644
--- a/content/blog/articles.json
+++ b/content/blog/articles.json
@@ -1,4 +1,19 @@
[
+ {
+ "slug": "intel-xbm-vs-hbm",
+ "title": "Intel's XBM Patent, Reality-Checked",
+ "description": "A new Intel filing describes stacked DRAM with no interposer, serial UCIe links, and transistors in the metal layers. Checked against what ships in 2026, it is a worse bet than commodity HBM, and Intel has lost this exact bet before.",
+ "date": "2026-07-10",
+ "keywords": [
+ "Intel XBM",
+ "HBM4",
+ "AI memory",
+ "high bandwidth memory",
+ "UCIe",
+ "GPU memory cost"
+ ],
+ "content": "On July 2 the US patent office published Intel application US20260191095A1, \"Ultra High Bandwidth Memory with Backend Transistors,\" filed back in December 2024. The coverage settled on calling it XBM, and the framing writes itself: Intel has a plan to kill HBM, the most expensive and most supply-constrained component in the AI buildout. Tom's Hardware, Igor's Lab, and TrendForce all ran it within the week.\n\nMy read, after checking the filing coverage against what actually ships in 2026: as a bet, XBM is worse than plain commodity HBM today, and it is not close. The engineering is the interesting part. The timeline and the business model are the problem.\n\n## What the patent actually describes\n\nThree moves, each real:\n\n1. The DRAM cell keeps the standard one-transistor, one-capacitor design, but the access transistors move into the back end of line, the metal and via layers above the silicon. That frees cell area and allows far more vertical connections through the stack.\n2. The wide parallel interface goes away. HBM4 talks over a 2,048-bit parallel bus; XBM would serialize memory traffic into UCIe chiplet links running up to 32 GT/s, the top of the current UCIe spec.\n3. Because the connection is a normal chiplet link, the silicon interposer goes away too. The stack sits on the package like any other chiplet, in roughly an HBM4 footprint, with built-in self repair. TrendForce's read of the filing has 8-high stacks carrying 96 data blocks, 16-high carrying 192, and sub-channels clocked at 2 GHz.\n\nThe target is the right one. On a modern accelerator the HBM stack is widely estimated to be the largest single line on the bill of materials, ahead of the compute die itself, and the advanced packaging that connects it has been a binding constraint on GPU supply since 2023. If you were going to attack one cost in AI hardware, you would attack this one.\n\nOne housekeeping note, because the two keep getting mixed up: XBM is not the memory project Intel runs with SAIMEMORY, the SoftBank-backed venture. That one is called ZAM, it attacks stacking and bonding rather than the cell and the interface, and it carries a 2029 target. XBM is a solo Intel filing with no partner and no date.\n\n## A patent is not a product\n\nIntel files thousands of patents a year. This one published on the ordinary 18-month schedule, which means July 2 tells us the clock ran out on the application, and nothing else. There is no test chip, no announced customer, and no roadmap slot. TrendForce, the least excitable outlet that covered it, puts plausible commercialization after 2030.\n\nHold that date. It is the whole argument.\n\n## The target is moving at full speed\n\nHBM4 went into mass production in February 2026. Samsung and SK hynix, who together produce roughly 90% of the world's HBM, are shipping it now, and Samsung has pushed pin speeds past 11 Gbps against a 2,048-bit interface. Micron ramps its own HBM4 this year with HBM4E on the roadmap for 2027 to 2028. Industry forecasts have the HBM market growing 58% this year to about $54.6 billion. NVIDIA's next platform is designed around HBM4, and every serious accelerator, including the hyperscalers' in-house chips, is an HBM design.\n\nSo run the honest comparison: a first-generation exotic memory arriving after 2030 by the friendliest estimate, against the HBM4E or HBM5 of that year, built by three suppliers who have spent a decade fighting each other on yield and price. Nobody's first silicon wins that fight on cost, and cost is the only axis XBM claims.\n\n## The interposer is dying anyway\n\nThe expense XBM aims at is already being engineered out inside the HBM ecosystem, on shipping products. TSMC's CoWoS-L replaced the full silicon interposer with an organic substrate and small silicon bridge dies. SK hynix builds HBM4 base dies on TSMC logic processes, which is the same put-logic-under-the-memory idea by another route. The suppliers have spent years on fan-out packaging and standard chiplets. And UCIe is an open standard that Intel co-founded in 2022 and donated the initial spec for, which means the one genuinely novel interface idea here, memory over UCIe, is equally available to the three companies that already run DRAM fabs.\n\nThat last point is the structural problem. Intel has not manufactured DRAM since 1985. It invented the commercial DRAM business with the 1103 in 1970 and exited when the business became a commodity knife fight. Somebody has to build XBM's exotic back-end-of-line cells at scale, at DRAM prices, and it is not obvious who. The companies with working DRAM fabs are the ones the patent is aimed at.\n\n## Intel has run this play before\n\nThe last time Intel decided the memory hierarchy was broken and only Intel could fix it, the result was Optane. 3D XPoint was announced in 2015 with claims of a thousand times NAND's speed. It was real technology. It shipped, and it worked roughly as described. It still died in July 2022, with a reported $559 million inventory writedown on the way out, because it was a single-supplier memory that required Intel platforms, never got second sources, and could not price against commodity DRAM and NAND made by the exabyte. Micron, the co-developer, walked away first and sold the fab. In the same era Intel sold its NAND business to SK hynix.\n\nEvery structural force that killed Optane is lined up against XBM: a single supplier selling against a commodity, and economics that only close if Intel's own platforms win sockets first.\n\n## The physics is not settled either\n\nSet the business aside and the technical questions stay open. Transistors built in back-end metal layers are deposited at low temperature, thin-film style, and nobody has demonstrated DRAM-grade retention and yield from them at density. Serializing memory traffic through UCIe trades HBM's short, wide, slow wires for fast narrow ones, and that trade costs latency and energy per bit, which is the reason HBM was designed wide and slow in the first place. Igor's Lab put it plainly: whether this architecture can compete with HBM4 or HBM4E on bandwidth, power efficiency, latency, and yield remains open. Those are all of the axes that matter.\n\n## Why I am still glad it exists\n\nNone of this is rooting against it. The HBM premium sits inside every AI number you look at, including ours: every dollar per GPU-hour on the [GPU Prices](/neocloud-intel) board carries it, and the training-run costs on the Economics tab are, in meaningful part, memory costs with a margin on top. A credible attack on that cost is good for the whole buildout. Intel Foundry also has a rational reason to keep filing these: it sells advanced packaging, and owning memory architectures that showcase it makes sense even if no Intel-branded memory ever ships. If ideas from this filing reach production, my guess is they arrive as foundry packaging IP or folded into a future JEDEC standard, the way Intel donated UCIe.\n\nWhat I am actually watching: whether ZAM, the Intel project that has a partner and a 2029 target, shows real silicon results; whether an XBM test chip ever surfaces; Micron's HBM4 ramp this year; and how far the incumbents get with custom HBM4E base dies, which is the establishment absorbing XBM's best idea on its own schedule. The equity side lives on [the Stack](/stack). [Micron](/stock/MU) is the US-listed way to own the HBM trade. [Intel](/stock/INTC), for now, has a patent."
+ },
{
"slug": "compute-frontier-ai-supercluster-tracker",
"title": "Tracking the Compute Frontier: Every Named AI Supercluster We Can Verify",
@@ -18,7 +33,11 @@
"title": "How Much Power Do AI Data Centers Actually Need?",
"description": "AI data centers consume 288 TWh per year, about 6.4% of US grid capacity. New AI capacity could reach 50 GW by 2030.",
"date": "2026-03-10",
- "keywords": ["AI data center power", "AI electricity consumption", "data center energy demand"],
+ "keywords": [
+ "AI data center power",
+ "AI electricity consumption",
+ "data center energy demand"
+ ],
"content": "The answer is somewhere between 35 and 75 GW of new capacity by 2030.\n\nThat range matters. At the low end, existing grid infrastructure can absorb it. At the high end, the US needs to build a UK-sized grid in five years.\n\n## Where we are today\n\nUS data centers consumed about 288 TWh in 2025. That is roughly 6.4% of total grid capacity, up from under 3% in 2022. The increase is driven by AI training and inference workloads.\n\nA single H100 GPU consumes about 700W under load. A modern training rack runs 40-80 kW. An AI data center campus draws 100-500 MW.\n\n## The variance drivers\n\nThe 35-75 GW range depends on three variables:\n\n**GPU power density**: Blackwell Ultra racks draw 120 kW, nearly double the prior generation. Per-rack power demand keeps climbing.\n\n**Hyperscaler capex execution**: The Big Four committed $328B in combined 2025 capex. If 60% translates to facilities, power demand steepens.\n\n**Grid interconnection speed**: The average PJM queue-to-energize timeline is 4+ years. New generation cannot come online faster than the grid connects it.\n\n## What this means for investors\n\nYou cannot download a power plant or 3D-print a transformer. Companies that build and operate power infrastructure face a multi-year demand cycle with no software shortcut.\n\nGridTilt's [Thesis Calculator](/trade) lets you model these scenarios directly."
},
{
@@ -26,7 +45,11 @@
"title": "AI Power Infrastructure Stocks: The Complete Guide",
"description": "60+ stocks across 8 sectors tied to the AI power buildout. GPU makers, nuclear utilities, and construction firms.",
"date": "2026-03-08",
- "keywords": ["AI power stocks", "AI infrastructure stocks", "data center stocks"],
+ "keywords": [
+ "AI power stocks",
+ "AI infrastructure stocks",
+ "data center stocks"
+ ],
"content": "The AI power buildout spans 8 sectors and over 60 public companies. It requires silicon, steel, uranium, copper, and years of construction.\n\n## The 8 sectors\n\n### Compute\nNVIDIA, TSMC, AMD, Micron. NVIDIA holds 80%+ market share in AI training accelerators. Every GPU sold requires power infrastructure to operate.\n\n### Nuclear Power\nConstellation Energy (CEG), Vistra (VST), Talen Energy (TLN), Oklo. Nuclear provides the 24/7 baseload power AI data centers need. Meta's 6.6 GW nuclear RFP is the largest corporate nuclear procurement.\n\n### Uranium and Fuel Cycle\nCameco (CCJ), NexGen Energy (NXE), Uranium Energy Corp (UEC). Every nuclear restart requires enriched uranium fuel. Western supply is constrained.\n\n### Power Hardware\nGE Vernova (GEV), Eaton (ETN), Vertiv (VRT). Transformer lead times are 4+ years. Switchgear and cooling systems are backordered industry-wide.\n\n### Utilities\nNextEra (NEE), Dominion (D), Southern Company (SO). Regulated utilities signing long-term power purchase agreements with hyperscalers.\n\n### Data Center REITs\nEquinix (EQIX), Digital Realty (DLR), IREN. The physical facilities where AI compute runs. Major market occupancy rates above 95%.\n\n### Construction and EPC\nQuanta Services (PWR), EMCOR (EME), MasTec (MTZ). Record backlogs from data center and transmission construction.\n\n### ETF Benchmarks\nURA, URNM, NLR, GRID, PAVE. Diversified exposure to uranium, nuclear, and grid infrastructure.\n\nExplore all sectors on [The Stack](/stack)."
},
{
@@ -34,7 +57,11 @@
"title": "Nuclear Energy and AI Data Centers: Why It Matters",
"description": "Nuclear power provides 24/7 baseload electricity for AI data centers. Three Mile Island restart, Meta's 6.6 GW RFP, and SMR development.",
"date": "2026-03-06",
- "keywords": ["nuclear energy AI", "nuclear data center", "nuclear power stocks AI"],
+ "keywords": [
+ "nuclear energy AI",
+ "nuclear data center",
+ "nuclear power stocks AI"
+ ],
"content": "AI data centers need reliable, 24/7 power above all else. Solar and wind are intermittent. Natural gas has carbon and price volatility concerns.\n\nNuclear is the only proven carbon-free baseload source at scale.\n\n## The baseload argument\n\nA 100,000-GPU training run costs millions per day. If power drops for seconds, the run may need to restart. Intermittent renewables cannot serve as the primary source for large AI facilities.\n\nNuclear reactors operate at 90%+ capacity factors. They run continuously for 18-24 months between refueling. No other source matches this reliability.\n\n## The restart wave\n\nMicrosoft contracted with Constellation Energy to restart Three Mile Island Unit 1 (835 MW). The plant targets 2028 return to service for Azure data centers.\n\nMeta issued a 6.6 GW nuclear RFP, the largest corporate nuclear procurement. Fulfillment would require restarting retired plants or securing existing output.\n\n## Key stocks\n\n**Constellation Energy (CEG)**: Largest US nuclear fleet with 13 plants. TMI restart and Meta contract make CEG central to the nuclear-AI thesis.\n\n**Vistra Corp (VST)**: 41 GW generation capacity including Comanche Peak nuclear. Strong merchant power exposure from rising wholesale prices.\n\n**Cameco (CCJ)**: Largest Western uranium producer. Cigar Lake mine produces about 18M lbs of U3O8 per year.\n\n**Oklo (OKLO)**: Building 15 MW Aurora microreactors. NRC approval would certify the first commercial fast spectrum reactor."
},
{
@@ -42,7 +69,11 @@
"title": "The PJM Interconnection Queue: AI's Biggest Bottleneck",
"description": "PJM's interconnection queue has 4+ year wait times and 250 GW pending. This bottleneck limits how fast AI data center power comes online.",
"date": "2026-03-04",
- "keywords": ["PJM interconnection queue", "PJM data centers", "grid interconnection backlog"],
+ "keywords": [
+ "PJM interconnection queue",
+ "PJM data centers",
+ "grid interconnection backlog"
+ ],
"content": "PJM manages the largest US electricity market. It serves 65 million customers across 13 states. It also hosts Data Center Alley in Northern Virginia.\n\nThe bottleneck is the interconnection queue.\n\n## What the queue is\n\nAny new generation source or large load must pass an interconnection study. PJM checks whether the grid can handle the connection without reliability issues.\n\nAs of early 2026, PJM's queue holds over 250 GW of requests. Average time from application to energization is 4+ years. Some projects have waited 6-7 years.\n\n## Why it matters for AI\n\nEvery new data center needs grid power. Every nuclear restart needs grid connection. The queue is the biggest constraint on AI power infrastructure buildout speed.\n\nPJM's 2025/2026 capacity auction cleared at $270/MW-day, a record. Clearing prices signal that supply is not keeping up with demand.\n\n## FERC Order 1920\n\nFERC Order 1920 mandates long-range transmission planning. Regional utilities must file 20-year expansion plans that account for AI data center load growth. The first filing deadline is July 2026.\n\n## The investment implication\n\nThe queue creates a natural moat for existing generators. If new supply takes 4+ years, companies that own operating plants (CEG, VST, NRG) benefit from scarcity pricing."
},
{
@@ -50,7 +81,11 @@
"title": "Where Are AI Data Centers Being Built? A Map",
"description": "GridTilt tracks 48 AI data center facilities across the US. Virginia, Texas, Iowa, and Oregon lead in capacity.",
"date": "2026-03-02",
- "keywords": ["AI data center locations", "data center map", "where are data centers"],
+ "keywords": [
+ "AI data center locations",
+ "data center map",
+ "where are data centers"
+ ],
"content": "GridTilt tracks 48 AI data center facilities across the US. The geographic distribution shows clear patterns in site selection and grid stress.\n\n## The geographic pattern\n\n**Northern Virginia (PJM)**: Data Center Alley is the largest cluster. Amazon, Google, Microsoft, and Meta all operate in Loudoun and Prince William counties. Over 70% of US internet traffic passes through here.\n\n**Central Texas (ERCOT)**: Texas attracts expansion from xAI, Oracle, and Amazon. Cheap electricity, available land, and minimal permitting barriers drive this. EIA projects ERCOT wholesale prices could rise 79% by 2027.\n\n**Iowa and Illinois (MISO)**: Meta and Google run some of their largest facilities here. Wind power and low land costs attract them. MISO reserve margins have dropped to 13.4%.\n\n**Oregon and Washington (WECC)**: Google and Amazon leverage Pacific Northwest hydroelectric power. These are some of the lowest-cost power locations in the US.\n\n## Capacity distribution\n\nOf the 48 tracked facilities: 28 operational, 15 under construction, 5 announced. Total tracked capacity exceeds 20 GW.\n\n## Operator strategies\n\nEach hyperscaler has a distinct geographic strategy. [Google](/operator/google) favors renewable-rich locations. [Amazon](/operator/amazon) clusters in established markets.\n\n[Meta](/operator/meta) is pursuing nuclear. [Microsoft](/operator/microsoft) signed the TMI restart deal.\n\nExplore all facilities on the [Power Map](/power-map)."
},
{
@@ -58,7 +93,11 @@
"title": "The Transformer Shortage That Could Slow AI",
"description": "The US produces about 60 large power transformers per year. AI demand could require 80-150+ annually. Lead times exceed 4 years.",
"date": "2026-02-28",
- "keywords": ["transformer shortage", "power transformer backlog", "grid hardware shortage"],
+ "keywords": [
+ "transformer shortage",
+ "power transformer backlog",
+ "grid hardware shortage"
+ ],
"content": "A large power transformer (LPT) sits between every power plant and data center. It weighs 200-400 tons and takes 12-18 months to build.\n\nEach one costs $5-15M. There are not enough of them.\n\n## The math\n\nThe US has about 2,000 LPTs in service. Domestic production is roughly 60 per year. The fleet averages 40+ years old, so replacement demand alone consumes much of production.\n\nNow add AI data centers. GridTilt estimates the base case (50 GW by 2030) needs 80-100 additional LPTs per year. The aggressive case (75 GW) needs 150+.\n\nDomestic supply cannot meet that. Importing is difficult because LPTs are custom-engineered.\n\n## Who benefits\n\n**GE Vernova (GEV)**: Manufactures transformers and grid equipment. Backlog at record levels with 4+ year lead times.\n\n**Eaton (ETN)**: Switchgear and power distribution units. Datacenter order intake is their key earnings metric.\n\n**Hubbell (HUBB)**: Utility electrical equipment. Elevated demand from grid modernization and data center construction.\n\n## The constraint timeline\n\nThis shortage is not temporary. New factories need specialized tooling, trained workers, and 2-3 years to build. Demand will exceed supply for multiple years.\n\nModel transformer demand in GridTilt's [Thesis Calculator](/trade)."
},
{
@@ -66,7 +105,11 @@
"title": "Uranium Stocks and AI: The Supply Chain Connection",
"description": "Nuclear restarts from AI demand create new uranium fuel requirements. Western supply is constrained with 40% Kazakh import dependency.",
"date": "2026-02-25",
- "keywords": ["uranium stocks AI", "uranium demand data centers", "nuclear fuel stocks"],
+ "keywords": [
+ "uranium stocks AI",
+ "uranium demand data centers",
+ "nuclear fuel stocks"
+ ],
"content": "Every nuclear reactor consumes enriched uranium fuel. Every restart creates new fuel demand. AI data centers are driving the largest nuclear procurement wave in decades.\n\n## The fuel cycle\n\nUranium goes through four steps before reaching a reactor:\n\n1. **Mining**: Extracting U3O8 (yellowcake)\n2. **Conversion**: Converting to UF6 gas\n3. **Enrichment**: Raising U-235 from 0.7% to 3-5%\n4. **Fabrication**: Building fuel assemblies\n\nEach step has its own supply chain and bottlenecks. Total timeline: 18-24 months from mine to reactor.\n\n## Supply constraints\n\nGlobal production has been below consumption since 2016. Inventory drawdowns and recycled material filled the gap. Those secondary sources are depleting.\n\nThe US imports about 40% of its uranium from Kazakhstan. Sanctions or production disruptions could tighten supply fast. Spot prices hit about $92/lb in March 2026, up from $50/lb in 2023.\n\n## Key miners\n\n**Cameco (CCJ)**: Largest Western producer. Cigar Lake produces 18M lbs/year. Strong long-term contract book.\n\n**NexGen Energy (NXE)**: Developing Rook I in Saskatchewan's Athabasca Basin. One of the highest-grade undeveloped deposits globally.\n\n**Uranium Energy Corp (UEC)**: US-focused with ISR operations in Wyoming and Texas. Benefits from domestic procurement preferences."
},
{
@@ -74,7 +117,10 @@
"title": "Behind-the-Meter Power: Why Hyperscalers Are Building Their Own Plants",
"description": "Amazon, Meta, and Microsoft contract directly with generators, bypassing the grid. This behind-the-meter strategy reshapes utility economics.",
"date": "2026-02-22",
- "keywords": ["behind the meter data center", "self-generation data center"],
+ "keywords": [
+ "behind the meter data center",
+ "self-generation data center"
+ ],
"content": "The grid queue is 4+ years. Wholesale prices are rising. Renewables cannot support 24/7 AI training.\n\nHyperscalers are building their own power.\n\n## What behind-the-meter means\n\nBehind-the-meter (BTM) places a generator at the point of consumption. The data center connects directly to a dedicated power plant, bypassing the grid.\n\nIndustrial facilities have used cogeneration for decades. What is new is the scale. Hyperscalers are signing multi-hundred-megawatt BTM agreements that did not exist five years ago.\n\n## The deals\n\n**Microsoft + Constellation**: TMI Unit 1 restart (835 MW) under a 20-year PPA. Nuclear baseload delivered directly to Azure operations.\n\n**Amazon + Talen Energy**: Data center campus adjacent to Susquehanna nuclear plant. BTM arrangement provides up to 960 MW.\n\n**Meta's 6.6 GW RFP**: The largest corporate nuclear procurement. Winning bids could include BTM and traditional PPAs.\n\n**xAI Memphis Colossus**: On-site gas turbines power 100,000+ GPUs while awaiting grid interconnection.\n\n## What it means for utilities\n\nBTM bypasses transmission and distribution charges. This creates regulatory tension. Utilities like Dominion and PPL are creating specialized data center tariffs in response.\n\n## The investor angle\n\nBTM favors companies owning generation near data center sites. CEG and VST are best-positioned in nuclear. GEV benefits from gas turbine sales.\n\nModel BTM impact in GridTilt's [Thesis Calculator](/trade)."
}
]
diff --git a/package-lock.json b/package-lock.json
index 0040908..b19614f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "rest-express",
+ "name": "gridtilt",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "rest-express",
+ "name": "gridtilt",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
@@ -15,7 +15,6 @@
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-toast": "^1.2.7",
- "@radix-ui/react-toggle": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@resvg/resvg-js": "^2.6.2",
"@tanstack/react-query": "^5.60.5",
@@ -31,7 +30,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3": "^7.9.0",
- "date-fns": "^3.6.0",
"express": "^5.0.1",
"express-rate-limit": "^7.4.1",
"helmet": "^8.0.0",
@@ -58,7 +56,6 @@
"@replit/vite-plugin-dev-banner": "^0.1.1",
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
"@tailwindcss/typography": "^0.5.15",
- "@tailwindcss/vite": "^4.1.18",
"@types/express": "^5.0.0",
"@types/node": "20.19.27",
"@types/react": "^18.3.11",
@@ -1421,30 +1418,6 @@
}
}
},
- "node_modules/@radix-ui/react-toggle": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.3.tgz",
- "integrity": "sha512-Za5HHd9nvsiZ2t3EI/dVd4Bm/JydK+D22uHKk46fPtvuPxVCJBUo5mQybN+g5sZe35y7I6GDTTfdkVv5SnxlFg==",
- "dependencies": {
- "@radix-ui/primitive": "1.1.2",
- "@radix-ui/react-primitive": "2.0.3",
- "@radix-ui/react-use-controllable-state": "1.1.1"
- },
- "peerDependencies": {
- "@types/react": "*",
- "@types/react-dom": "*",
- "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
- "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- },
- "@types/react-dom": {
- "optional": true
- }
- }
- },
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.0.tgz",
@@ -2273,286 +2246,6 @@
"node": ">= 8.0.0"
}
},
- "node_modules/@tailwindcss/node": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
- "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/remapping": "^2.3.4",
- "enhanced-resolve": "^5.18.3",
- "jiti": "^2.6.1",
- "lightningcss": "1.30.2",
- "magic-string": "^0.30.21",
- "source-map-js": "^1.2.1",
- "tailwindcss": "4.1.18"
- }
- },
- "node_modules/@tailwindcss/node/node_modules/jiti": {
- "version": "2.6.1",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
- "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jiti": "lib/jiti-cli.mjs"
- }
- },
- "node_modules/@tailwindcss/node/node_modules/tailwindcss": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
- "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@tailwindcss/oxide": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
- "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 10"
- },
- "optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.1.18",
- "@tailwindcss/oxide-darwin-arm64": "4.1.18",
- "@tailwindcss/oxide-darwin-x64": "4.1.18",
- "@tailwindcss/oxide-freebsd-x64": "4.1.18",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
- "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
- "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
- "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
- "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
- "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
- }
- },
- "node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
- "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
- "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
- "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
- "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
- "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
- "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
- "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
- "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
- "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
- "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
- "bundleDependencies": [
- "@napi-rs/wasm-runtime",
- "@emnapi/core",
- "@emnapi/runtime",
- "@tybys/wasm-util",
- "@emnapi/wasi-threads",
- "tslib"
- ],
- "cpu": [
- "wasm32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1",
- "@emnapi/wasi-threads": "^1.1.0",
- "@napi-rs/wasm-runtime": "^1.1.0",
- "@tybys/wasm-util": "^0.10.1",
- "tslib": "^2.4.0"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
- "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
- "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
"node_modules/@tailwindcss/typography": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz",
@@ -2583,28 +2276,6 @@
"node": ">=4"
}
},
- "node_modules/@tailwindcss/vite": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
- "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@tailwindcss/node": "4.1.18",
- "@tailwindcss/oxide": "4.1.18",
- "tailwindcss": "4.1.18"
- },
- "peerDependencies": {
- "vite": "^5.2.0 || ^6 || ^7"
- }
- },
- "node_modules/@tailwindcss/vite/node_modules/tailwindcss": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
- "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@tanstack/query-core": {
"version": "5.60.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.60.5.tgz",
@@ -4362,15 +4033,6 @@
"node": ">=12"
}
},
- "node_modules/date-fns": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
- "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/kossnocorp"
- }
- },
"node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
@@ -4412,15 +4074,6 @@
"node": ">= 0.8"
}
},
- "node_modules/detect-libc": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
- "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -4504,20 +4157,6 @@
"node": ">= 0.8"
}
},
- "node_modules/enhanced-resolve": {
- "version": "5.18.4",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
- "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.4",
- "tapable": "^2.2.0"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
"node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
@@ -5050,13 +4689,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/graceful-fs": {
- "version": "4.2.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -5332,273 +4964,6 @@
"leaflet": "^1.3.1"
}
},
- "node_modules/lightningcss": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
- "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
- "dev": true,
- "license": "MPL-2.0",
- "dependencies": {
- "detect-libc": "^2.0.3"
- },
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- },
- "optionalDependencies": {
- "lightningcss-android-arm64": "1.30.2",
- "lightningcss-darwin-arm64": "1.30.2",
- "lightningcss-darwin-x64": "1.30.2",
- "lightningcss-freebsd-x64": "1.30.2",
- "lightningcss-linux-arm-gnueabihf": "1.30.2",
- "lightningcss-linux-arm64-gnu": "1.30.2",
- "lightningcss-linux-arm64-musl": "1.30.2",
- "lightningcss-linux-x64-gnu": "1.30.2",
- "lightningcss-linux-x64-musl": "1.30.2",
- "lightningcss-win32-arm64-msvc": "1.30.2",
- "lightningcss-win32-x64-msvc": "1.30.2"
- }
- },
- "node_modules/lightningcss-android-arm64": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
- "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-arm64": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
- "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-darwin-x64": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
- "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-freebsd-x64": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
- "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
- "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
- "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
- "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
- "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-linux-x64-musl": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
- "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
- "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
- "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -7178,20 +6543,6 @@
"tailwindcss": ">=3.0.0 || insiders"
}
},
- "node_modules/tapable": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
- "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/webpack"
- }
- },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
diff --git a/package.json b/package.json
index 2decf29..2731d2e 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "name": "rest-express",
+ "name": "gridtilt",
"version": "1.0.0",
"type": "module",
"license": "MIT",
@@ -18,7 +18,6 @@
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-toast": "^1.2.7",
- "@radix-ui/react-toggle": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@resvg/resvg-js": "^2.6.2",
"@tanstack/react-query": "^5.60.5",
@@ -34,7 +33,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3": "^7.9.0",
- "date-fns": "^3.6.0",
"express": "^5.0.1",
"express-rate-limit": "^7.4.1",
"helmet": "^8.0.0",
@@ -61,7 +59,6 @@
"@replit/vite-plugin-dev-banner": "^0.1.1",
"@replit/vite-plugin-runtime-error-modal": "^0.0.3",
"@tailwindcss/typography": "^0.5.15",
- "@tailwindcss/vite": "^4.1.18",
"@types/express": "^5.0.0",
"@types/node": "20.19.27",
"@types/react": "^18.3.11",
diff --git a/replit.md b/replit.md
index e90fb70..63bd98f 100644
--- a/replit.md
+++ b/replit.md
@@ -1,49 +1,32 @@
-# GridTilt | AI Infrastructure & Power Economy Dashboard
-
-## Overview
-GridTilt is a full-stack web application that visualizes the economic interplay between AI compute demand, power consumption, and financial markets. It provides a comprehensive dashboard for understanding and analyzing the AI infrastructure and power economy. The project aims to offer a unique perspective on how advancements in AI drive energy demands and influence market dynamics, with a vision to become the leading platform for tracking and analyzing the AI power sector.
-
-## User Preferences
-- No em dashes anywhere in the codebase.
-- Text style: short, direct sentences. No compound AI-sounding phrasing.
-- Dark mode only, with a warm charcoal terminal aesthetic and orange branding.
-
-## System Architecture
-
-### Frontend
-The frontend is built with React and TypeScript using Vite. It leverages `wouter` for routing, `TanStack Query v5` for data fetching, and `recharts` and `react-simple-maps` for data visualization. UI components are sourced from `shadcn/ui`, and styling is managed with `TailwindCSS` using a custom dark-mode-only theme.
-
-### Backend
-The backend is a Node.js and Express application. It fetches market data using `yahoo-finance2` and aggregates news from RSS feeds (Utility Dive, Data Center Dynamics, World Nuclear News, Power Engineering) with a fallback to `NewsData.io` if an API key is provided, otherwise utilizing static JSON. All API routes are prefixed with `/api`. The system includes fallbacks to static data when external APIs are unavailable.
-
-### Key Features
-- **TiltOverview**: Dashboard with KPIs, Thesis Health, Top Movers, Sector Pulse, Catalyst Calendar, X feed, and a demand chart.
-- **TheStack**: An 8-layer sector breakdown with live stock data, timeframe toggles, and sort controls.
-- **PowerMap**: An interactive Leaflet map with CartoDB Dark Matter tiles displaying 48 data center locations with glowing animated pins, frosted-glass tooltips, floating stats overlay, collapsible filter panel, and a Grid Stress mode.
-- **TheTrade**: A Thesis Calculator offering preset and custom scenarios for infrastructure buildout, capex, and LPT outputs.
-- **PortfolioOverlay**: Provides AI Power Exposure scoring and a radar chart for portfolios.
-- **CatalystTracker**: Monthly calendar grid with colored event dots, upcoming earnings timeline with stage-colored nodes, thesis catalyst cards with category badges. Merged API (`/api/catalysts/all`) combines live Yahoo Finance earnings dates (for 8 curated large-cap tickers) with manual thesis catalysts. Seed dates serve as fallback if Yahoo is unavailable. Dashboard "Next 5 Catalysts" widget on TiltOverview.
-- **Stock, Sector, Region, Operator Pages**: Dedicated pages for detailed analysis of individual stocks, sectors, grid regions, and hyperscaler operators.
-- **Blog**: Features analysis articles on AI infrastructure and power economy topics.
-- **SupplyChain (V3)**: Org-chart tree layout (Root > 5 Systems > Sub-system icons) that fits in one viewport on desktop. CSS Grid + SVG connector lines (no React Flow). 20 sub-systems across 5 stages with data config in `supply-chain-config.ts`. Click sub-system to expand inline detail panel with description, key metrics, and company stock cards. Click system node for summary panel. Dimming/active states, staggered entrance animations. Responsive: 5-across desktop, wrapped tablet, vertical accordion mobile with 3-col subsystem grid.
-- **Subscribe / Email Capture**: Email newsletter signup at /subscribe, inline capture at bottom of TiltOverview, scroll-triggered banner. Backend stores subscribers in JSON file (server/data/subscribers.json). Newsletter send via Resend (needs RESEND_API_KEY). Unsubscribe tokens use HMAC-SHA256 with SESSION_SECRET.
-
-### Visual Design
-The application features a dark charcoal terminal aesthetic with orange as the primary brand color for UI chrome, badges, and KPIs. Blue (`#1E90FF`) is strictly reserved for chart data series. The UI emphasizes short, direct sentences and avoids complex phrasing. Card radius is set to 0.35rem for a terminal-like appearance.
-
-### Global UX
-Includes a scrolling news ticker, keyboard shortcuts (`?` for modal, `G+1-6` for navigation), dynamic KPI cards with sparklines, and a shareable portfolio scoring feature. The `PowerMap` offers detailed filtering and a "Grid Stress" view. The `Thesis Calculator` provides scenario analysis with buildout charts and company rankings.
-
-## External Dependencies
-- **yahoo-finance2**: For fetching real-time market data.
-- **NewsData.io**: (Optional, with API key) For live news feeds.
-- **Recharts**: For charting and data visualization.
-- **leaflet** and **react-leaflet**: For interactive tile-based maps (CartoDB Dark Matter).
-- **shadcn/ui**: UI component library.
-- **TailwindCSS**: For styling and theming.
-- **wouter**: For client-side routing.
-- **TanStack Query v5**: For data fetching, caching, and state management.
-- **Node.js**: Backend runtime environment.
-- **Express**: Web application framework for the backend.
-- **satori** and **@resvg/resvg-js**: For dynamic OG image generation.
-- **RSS feeds**: Utility Dive, Data Center Dynamics, World Nuclear News, Power Engineering for news aggregation.
\ No newline at end of file
+# GridTilt
+
+Research dashboard for the AI power economy: equities, infrastructure, and power data. Live at gridtilt.com.
+
+**See CLAUDE.md for working conventions, module map, data custody, and do-not-touch zones. When this file and CLAUDE.md disagree, CLAUDE.md wins; when CLAUDE.md and the code disagree, the code wins.**
+
+## Stack
+
+- Client: React 18 + TypeScript + Vite, wouter routing, TanStack Query v5, Tailwind + house-modified shadcn/ui, Recharts / visx / d3 / react-leaflet. Dark mode only.
+- Server: Express 5, one process (Vite middleware in dev, static from `dist/public` in prod), port 5000.
+- Persistence: JSON files in `server/data/` and `content/blog/`. No database, no ORM, no `shared/` directory.
+- Not Next.js. No redux or app-level context.
+
+## Commands
+
+```bash
+npm run dev # tsx server with Vite middleware, port 5000
+npm run check # tsc typecheck
+npm test # node:test suites (server + client lib)
+npm run build # vite client + esbuild server -> dist/
+npm start # serve the built bundle
+```
+
+Required env: `UNSUB_TOKEN_SECRET`, `ADMIN_API_KEY` (see `.env.example`).
+
+## Deploy gotcha
+
+Replit autoscale. A push to GitHub does NOT deploy, and autoscale does not auto-pull. Merge, then Deployments -> Redeploy, manually. After redeploy, sanity-check `/api/stack`, `/api/kpis`, `/api/clusters/metrics`. Machine-written JSON in `server/data/` sits on ephemeral disk; a redeploy can lose it.
+
+## Style
+
+Plain, direct copy. No em dashes. Data honesty: serve real observations, flag estimates, degrade with explicit errors, never fabricate numbers.
diff --git a/script/build.ts b/script/build.ts
index e561021..07c967d 100644
--- a/script/build.ts
+++ b/script/build.ts
@@ -5,7 +5,6 @@ import { rm, readFile } from "fs/promises";
// server deps to bundle to reduce openat(2) syscalls
// which helps cold start times
const allowlist = [
- "date-fns",
"express",
"rss-parser",
"zod",
diff --git a/server/__tests__/auth-boundary.test.ts b/server/__tests__/auth-boundary.test.ts
new file mode 100644
index 0000000..41a97f8
--- /dev/null
+++ b/server/__tests__/auth-boundary.test.ts
@@ -0,0 +1,138 @@
+import { test, after } from "node:test";
+import assert from "node:assert/strict";
+import express from "express";
+import { createServer } from "node:http";
+import type { AddressInfo } from "node:net";
+
+// Must be set before importing routes: registerRoutes throws at call time if
+// UNSUB_TOKEN_SECRET is unset, and requireAdmin returns 503 (not 401) when
+// ADMIN_API_KEY is unset.
+process.env.UNSUB_TOKEN_SECRET ||= "test-secret-for-auth-test";
+process.env.ADMIN_API_KEY ||= "test-admin-key";
+process.env.NODE_ENV = "test";
+const ADMIN_KEY = process.env.ADMIN_API_KEY;
+
+// Keep every test offline: stub Yahoo so no route reaches the network.
+interface YahooLike {
+ quote: (...args: unknown[]) => Promise;
+ chart: (...args: unknown[]) => Promise;
+ search?: (...args: unknown[]) => Promise;
+}
+const yahooModule = await import("yahoo-finance2");
+const YahooFinanceClass = (yahooModule as unknown as { default: new () => YahooLike }).default;
+const proto = YahooFinanceClass.prototype as YahooLike;
+const originalQuote = proto.quote;
+const originalChart = proto.chart;
+proto.quote = () => Promise.reject(new Error("offline"));
+proto.chart = () => Promise.reject(new Error("offline"));
+after(() => {
+ proto.quote = originalQuote;
+ proto.chart = originalChart;
+});
+
+const { registerRoutes } = await import("../routes");
+
+async function startTestServer(): Promise<{ url: string; close: () => Promise }> {
+ const app = express();
+ app.use(express.json());
+ const httpServer = createServer(app);
+ await registerRoutes(httpServer, app);
+ await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
+ const { port } = httpServer.address() as AddressInfo;
+ return {
+ url: `http://127.0.0.1:${port}`,
+ close: () => new Promise((resolve) => httpServer.close(() => resolve())),
+ };
+}
+
+// Every admin/mutating route plus the two that used to leak. requireAdmin runs
+// before any body handling, so empty bodies are fine for the no-key case.
+const PROTECTED_ROUTES: Array<[string, string]> = [
+ ["GET", "/api/admin/subscribers"],
+ ["GET", "/api/newsletter/preview"], // SEC-1: was public, leaked subscriber count
+ ["POST", "/api/social/generate"], // SEC-2: was public, burned Yahoo quota
+ ["DELETE", "/api/admin/subscribers/x@y.com"],
+ ["POST", "/api/newsletter/send"],
+ ["POST", "/api/admin/post-now"],
+ ["POST", "/api/admin/cron/daily-tweet"],
+ ["GET", "/api/admin/social-log"],
+ ["DELETE", "/api/admin/tweet/123"],
+ ["POST", "/api/admin/datacenters"],
+ ["DELETE", "/api/admin/datacenters/1"],
+ ["GET", "/api/admin/datacenters/pending"],
+ ["POST", "/api/admin/datacenters/ingest"],
+ ["POST", "/api/admin/datacenters/pending/1/approve"],
+ ["DELETE", "/api/admin/datacenters/pending/1"],
+ ["GET", "/api/export/daily"],
+ ["POST", "/api/admin/add-backlog-project"],
+ ["DELETE", "/api/admin/backlog-project/x"],
+ ["GET", "/api/admin/backlog-auto-updates"],
+ ["POST", "/api/admin/scan-news-now"],
+ ["POST", "/api/admin/update-backlog-headlines"],
+];
+
+test("admin auth boundary: the correct key is accepted, a missing key never authorizes", async () => {
+ const { url, close } = await startTestServer();
+ try {
+ // 1. Happy path FIRST. Successful (2xx) requests are skipped by the
+ // admin failure limiter, so they don't consume its budget.
+ const okSubs = await fetch(`${url}/api/admin/subscribers`, {
+ headers: { "x-admin-key": ADMIN_KEY },
+ });
+ assert.equal(okSubs.status, 200, "valid key should reach GET /api/admin/subscribers");
+ await okSubs.text();
+
+ const okLog = await fetch(`${url}/api/admin/social-log`, {
+ headers: { "x-admin-key": ADMIN_KEY },
+ });
+ assert.equal(okLog.status, 200, "valid key should reach GET /api/admin/social-log");
+ await okLog.text();
+
+ // 2. The three most important no-key cases come first, while the failure
+ // limiter (5/min) still lets them through to the handler → exact 401.
+ for (const path of ["/api/admin/subscribers", "/api/newsletter/preview"]) {
+ const res = await fetch(`${url}${path}`);
+ assert.equal(res.status, 401, `${path} without a key must be 401, got ${res.status}`);
+ const body = (await res.json()) as { error?: string };
+ assert.equal(body.error, "Unauthorized", `${path} should report Unauthorized`);
+ }
+ const gen = await fetch(`${url}/api/social/generate`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "{}",
+ });
+ assert.equal(gen.status, 401, `POST /api/social/generate without a key must be 401, got ${gen.status}`);
+ await gen.text();
+
+ // 3. Every protected route: a missing key must NEVER return a 2xx. Past the
+ // limiter budget the denial may be 429 instead of 401 — both are fine,
+ // the invariant is "unauthenticated requests never succeed".
+ for (const [method, path] of PROTECTED_ROUTES) {
+ const res = await fetch(`${url}${path}`, {
+ method,
+ headers: method === "POST" ? { "Content-Type": "application/json" } : undefined,
+ body: method === "POST" ? "{}" : undefined,
+ });
+ await res.text();
+ assert.ok(
+ res.status === 401 || res.status === 429,
+ `${method} ${path} without a key returned ${res.status}; expected 401 or 429 (never a success)`,
+ );
+ assert.ok(res.status < 200 || res.status >= 300, `${method} ${path} must not succeed without a key`);
+ }
+ } finally {
+ await close();
+ }
+});
+
+test("SEC-3: an unrecognized stack timeframe is handled gracefully, not errored", async () => {
+ const { url, close } = await startTestServer();
+ try {
+ const res = await fetch(`${url}/api/stack?timeframe=__junk__`);
+ assert.equal(res.status, 200, "junk timeframe should be coerced and return 200, not error");
+ const body = (await res.json()) as Record;
+ assert.ok(Array.isArray(body.compute), "stack response should still carry layer arrays");
+ } finally {
+ await close();
+ }
+});
diff --git a/server/__tests__/fundamentals.test.ts b/server/__tests__/fundamentals.test.ts
new file mode 100644
index 0000000..bcc236a
--- /dev/null
+++ b/server/__tests__/fundamentals.test.ts
@@ -0,0 +1,94 @@
+// Live fundamentals sweep: conversion guards, bounded-concurrency sweep,
+// per-ticker failure isolation, cache single-flight.
+import { describe, it, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+import {
+ clearFundamentalsCache,
+ fractionToPercent,
+ getCachedFundamentals,
+ refreshFundamentalsIfStale,
+ sweepRevenueGrowth,
+} from "../fundamentals";
+
+describe("fractionToPercent", () => {
+ it("converts Yahoo's fraction to percent", () => {
+ const near = (a: number | null, b: number) => assert.ok(a !== null && Math.abs(a - b) < 1e-9, `${a} !~ ${b}`);
+ near(fractionToPercent(0.122), 12.2);
+ near(fractionToPercent(1.224), 122.4);
+ near(fractionToPercent(-0.05), -5);
+ });
+ it("rejects junk: non-numbers, non-finite, absurd magnitudes", () => {
+ for (const bad of [null, undefined, "12%", NaN, Infinity, 51, -51]) {
+ assert.equal(fractionToPercent(bad), null, String(bad));
+ }
+ });
+});
+
+describe("sweepRevenueGrowth", () => {
+ it("collects rounded values per ticker", async () => {
+ const out = await sweepRevenueGrowth(["A", "B"], async (t) => (t === "A" ? 12.24 : 5.55));
+ assert.equal(out.A.revenueGrowth, 12.2);
+ assert.equal(out.B.revenueGrowth, 5.6);
+ });
+ it("a failing ticker yields null without sinking the sweep", async () => {
+ const out = await sweepRevenueGrowth(["A", "B"], async (t) => {
+ if (t === "A") throw new Error("throttled");
+ return 7;
+ });
+ assert.equal(out.A.revenueGrowth, null);
+ assert.equal(out.B.revenueGrowth, 7);
+ });
+ it("respects the concurrency bound", async () => {
+ let inFlight = 0;
+ let peak = 0;
+ const out = await sweepRevenueGrowth(
+ Array.from({ length: 20 }, (_, i) => `T${i}`),
+ async () => {
+ inFlight++;
+ peak = Math.max(peak, inFlight);
+ await new Promise((r) => setTimeout(r, 5));
+ inFlight--;
+ return 1;
+ },
+ 4,
+ );
+ assert.equal(Object.keys(out).length, 20);
+ assert.ok(peak <= 4, `peak concurrency ${peak}`);
+ });
+ it("empty ticker list yields empty result", async () => {
+ assert.deepEqual(await sweepRevenueGrowth([], async () => 1), {});
+ });
+});
+
+describe("refreshFundamentalsIfStale", () => {
+ beforeEach(() => clearFundamentalsCache());
+ it("populates the cache once and is single-flight while fresh", async () => {
+ let calls = 0;
+ refreshFundamentalsIfStale(["A"], async () => {
+ calls++;
+ return 3;
+ });
+ // second trigger while the first is in flight must not double-fetch
+ refreshFundamentalsIfStale(["A"], async () => {
+ calls++;
+ return 3;
+ });
+ await new Promise((r) => setTimeout(r, 20));
+ assert.equal(calls, 1);
+ assert.equal(getCachedFundamentals().A.revenueGrowth, 3);
+ // fresh cache: no new sweep
+ refreshFundamentalsIfStale(["A"], async () => {
+ calls++;
+ return 9;
+ });
+ await new Promise((r) => setTimeout(r, 20));
+ assert.equal(calls, 1);
+ });
+ it("a failed sweep leaves the cache empty and allows retry", async () => {
+ refreshFundamentalsIfStale(["A"], async () => {
+ throw new Error("down");
+ });
+ await new Promise((r) => setTimeout(r, 20));
+ assert.equal(getCachedFundamentals().A?.revenueGrowth ?? null, null);
+ });
+});
diff --git a/server/__tests__/gpu-live.test.ts b/server/__tests__/gpu-live.test.ts
new file mode 100644
index 0000000..21e9737
--- /dev/null
+++ b/server/__tests__/gpu-live.test.ts
@@ -0,0 +1,155 @@
+// Live GPU price ingestion: parsing, blending, guard rails, and the full
+// sweep against fixture provider payloads. A bad SKU match here would put a
+// wrong price on the front page, so the guard rails get their own cases.
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import {
+ RUNPOD_MODEL_IDS,
+ blendLivePrice,
+ fetchLivePrices,
+ parseRunpod,
+ parseVast,
+ type FetchLike,
+ type RunpodGpuType,
+} from "../gpu-live";
+
+describe("parseRunpod", () => {
+ const types: RunpodGpuType[] = [
+ { id: "NVIDIA H100 80GB HBM3", securePrice: 3.29, communityPrice: 2.69 },
+ { id: "NVIDIA H100 PCIe", securePrice: 2.89, communityPrice: 1.99 },
+ { id: "NVIDIA H200", securePrice: 4.39, communityPrice: 0.5 },
+ { id: "NVIDIA B200", securePrice: null, communityPrice: 5.98 },
+ ];
+ it("matches only the mapped SKU ids", () => {
+ const obs = parseRunpod(types, RUNPOD_MODEL_IDS.H100);
+ assert.deepEqual(obs.map((o) => o.price), [3.29, 2.69]);
+ });
+ it("drops placeholder community prices (under 40% of secure)", () => {
+ const obs = parseRunpod(types, ["NVIDIA H200"]);
+ assert.deepEqual(obs.map((o) => o.price), [4.39]);
+ });
+ it("community without a secure anchor is dropped entirely", () => {
+ const obs = parseRunpod(types, ["NVIDIA B200"]);
+ assert.deepEqual(obs, []);
+ });
+ it("empty/missing input yields no observations", () => {
+ assert.deepEqual(parseRunpod([], ["x"]), []);
+ assert.deepEqual(parseRunpod(undefined as never, ["x"]), []);
+ });
+});
+
+describe("parseVast", () => {
+ it("takes the median of returned offers", () => {
+ const obs = parseVast([{ dph_total: 1.74 }, { dph_total: 1.93 }, { dph_total: 2.0 }]);
+ assert.deepEqual(obs, [{ provider: "vast", price: 1.93 }]);
+ });
+ it("even count averages the middle pair", () => {
+ const obs = parseVast([{ dph_total: 1.0 }, { dph_total: 2.0 }, { dph_total: 3.0 }, { dph_total: 4.0 }]);
+ assert.equal(obs[0].price, 2.5);
+ });
+ it("ignores null/zero/negative totals; empty yields nothing", () => {
+ assert.deepEqual(parseVast([{ dph_total: null }, { dph_total: 0 }, { dph_total: -1 }]), []);
+ assert.deepEqual(parseVast([]), []);
+ });
+});
+
+describe("blendLivePrice", () => {
+ it("median blend with low/high and deduped sources", () => {
+ const { blended, dropped } = blendLivePrice(
+ [
+ { provider: "runpod-secure", price: 3.29 },
+ { provider: "runpod-community", price: 2.69 },
+ { provider: "vast", price: 1.93 },
+ ],
+ 2.79,
+ );
+ assert.equal(dropped, 0);
+ assert.ok(blended);
+ assert.equal(blended.price, 2.69);
+ assert.equal(blended.low, 1.93);
+ assert.equal(blended.high, 3.29);
+ assert.equal(blended.n, 3);
+ assert.deepEqual(blended.sources.sort(), ["runpod-community", "runpod-secure", "vast"]);
+ });
+ it("guard rail: observations 4x off the curated price are dropped as bad matches", () => {
+ const { blended, dropped } = blendLivePrice(
+ [
+ { provider: "vast", price: 0.4 }, // 10x cheaper than curated: wrong SKU
+ { provider: "runpod-secure", price: 30 }, // 7x pricier: glitch
+ { provider: "runpod-community", price: 4.1 },
+ ],
+ 4.0,
+ );
+ assert.equal(dropped, 2);
+ assert.equal(blended?.price, 4.1);
+ });
+ it("no curated reference means no ratio guard (new models still record)", () => {
+ const { blended, dropped } = blendLivePrice([{ provider: "vast", price: 42 }], null);
+ assert.equal(dropped, 0);
+ assert.equal(blended?.price, 42);
+ });
+ it("all observations dropped or empty yields null (record nothing, fabricate nothing)", () => {
+ assert.equal(blendLivePrice([], 3).blended, null);
+ assert.equal(blendLivePrice([{ provider: "vast", price: NaN }], 3).blended, null);
+ });
+});
+
+describe("fetchLivePrices (full sweep against fixtures)", () => {
+ const runpodBody = {
+ data: {
+ gpuTypes: [
+ { id: "NVIDIA H100 80GB HBM3", securePrice: 3.29, communityPrice: 2.69 },
+ { id: "NVIDIA B200", securePrice: 5.89, communityPrice: 5.98 },
+ ],
+ },
+ };
+ const vastByName: Record = {
+ "H100 SXM": { offers: [{ dph_total: 1.74 }, { dph_total: 1.93 }, { dph_total: 2.0 }] },
+ };
+ const fakeFetch: FetchLike = async (url) => {
+ if (url.includes("runpod")) return { ok: true, json: async () => runpodBody };
+ const q = JSON.parse(decodeURIComponent(url.split("?q=")[1]));
+ return { ok: true, json: async () => vastByName[q.gpu_name.eq] ?? { offers: [] } };
+ };
+
+ it("blends per model across providers; models with no source record nothing", async () => {
+ const live = await fetchLivePrices(
+ [
+ { model: "H100", currentUsdPerHr: 2.79 },
+ { model: "B200", currentUsdPerHr: 6.11 },
+ { model: "MI355X", currentUsdPerHr: 2.85 }, // no provider covers it
+ ],
+ fakeFetch,
+ );
+ assert.deepEqual(Object.keys(live).sort(), ["B200", "H100"]);
+ assert.equal(live.H100.price, 2.69); // median of 3.29, 2.69, 1.93
+ assert.equal(live.H100.n, 3);
+ assert.equal(live.B200.n, 2);
+ assert.equal("MI355X" in live, false);
+ });
+
+ it("provider failure degrades to the surviving sources, never throws", async () => {
+ const failingFetch: FetchLike = async (url) => {
+ if (url.includes("runpod")) throw new Error("network down");
+ const q = JSON.parse(decodeURIComponent(url.split("?q=")[1]));
+ return { ok: true, json: async () => vastByName[q.gpu_name.eq] ?? { offers: [] } };
+ };
+ const live = await fetchLivePrices([{ model: "H100", currentUsdPerHr: 2.79 }], failingFetch);
+ assert.equal(live.H100.price, 1.93); // vast only
+ assert.deepEqual(live.H100.sources, ["vast"]);
+ });
+
+ it("total failure yields an empty result (nothing recorded, nothing invented)", async () => {
+ const deadFetch: FetchLike = async () => {
+ throw new Error("offline");
+ };
+ const live = await fetchLivePrices([{ model: "H100", currentUsdPerHr: 2.79 }], deadFetch);
+ assert.deepEqual(live, {});
+ });
+
+ it("non-ok responses are treated as empty, not parsed", async () => {
+ const badFetch: FetchLike = async () => ({ ok: false, json: async () => ({ oops: true }) });
+ const live = await fetchLivePrices([{ model: "H100", currentUsdPerHr: 2.79 }], badFetch);
+ assert.deepEqual(live, {});
+ });
+});
diff --git a/server/__tests__/uranium-correlation.test.ts b/server/__tests__/uranium-correlation.test.ts
new file mode 100644
index 0000000..d4f9d60
--- /dev/null
+++ b/server/__tests__/uranium-correlation.test.ts
@@ -0,0 +1,108 @@
+// The real uranium correlation module - the replacement for the app's last
+// fabricated dataset. Pearson math, weekly alignment, thresholds, cache and
+// failure behavior all covered.
+import { describe, it, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+import {
+ alignWeekly,
+ buildCorrelationPayload,
+ clearCorrelationCache,
+ getUraniumCorrelation,
+ pearson,
+ type WeeklyPoint,
+} from "../uranium-correlation";
+
+const WEEK = 7 * 86_400_000;
+const series = (closes: Array, startWeek = 3000): WeeklyPoint[] =>
+ closes.map((c, i) => ({ t: (startWeek + i) * WEEK + 3 * 86_400_000, close: c as number }));
+
+describe("pearson", () => {
+ it("perfect positive and negative correlation", () => {
+ assert.equal(pearson([1, 2, 3, 4], [2, 4, 6, 8]), 1);
+ assert.equal(pearson([1, 2, 3, 4], [8, 6, 4, 2]), -1);
+ });
+ it("known value on a hand-computed set", () => {
+ const r = pearson([1, 2, 3, 4, 5], [2, 1, 4, 3, 5]);
+ assert.ok(r !== null && Math.abs(r - 0.8) < 1e-9);
+ });
+ it("null on n<3 or zero variance", () => {
+ assert.equal(pearson([1, 2], [1, 2]), null);
+ assert.equal(pearson([5, 5, 5], [1, 2, 3]), null);
+ assert.equal(pearson([1, 2, 3], [7, 7, 7]), null);
+ });
+});
+
+describe("alignWeekly", () => {
+ it("pairs closes that fall in the same calendar week", () => {
+ const a = series([10, 11, 12]);
+ const b = series([100, 110, 120]);
+ const out = alignWeekly(a, b);
+ assert.deepEqual(out.map((p) => [p.a, p.b]), [[10, 100], [11, 110], [12, 120]]);
+ });
+ it("weeks missing from either side are dropped, never interpolated", () => {
+ const a = series([10, 11, 12]);
+ const b = [series([100])[0], series([120], 3002)[0]]; // week 3001 missing
+ const out = alignWeekly(a, b);
+ assert.deepEqual(out.map((p) => [p.a, p.b]), [[10, 100], [12, 120]]);
+ });
+ it("non-finite closes are excluded", () => {
+ const a = series([10, NaN, 12]);
+ const b = series([100, 110, null]);
+ const out = alignWeekly(a, b);
+ assert.deepEqual(out.map((p) => [p.a, p.b]), [[10, 100]]);
+ });
+ it("empty inputs yield empty output", () => {
+ assert.deepEqual(alignWeekly([], series([1, 2, 3])), []);
+ assert.deepEqual(alignWeekly(series([1]), []), []);
+ });
+});
+
+describe("buildCorrelationPayload", () => {
+ const proxy = series(Array.from({ length: 52 }, (_, i) => 15 + i * 0.1));
+ const ccj = series(Array.from({ length: 52 }, (_, i) => 70 + i * 0.5));
+ const ceg = series(Array.from({ length: 52 }, (_, i) => 300 - i));
+ it("pairs, rounds, and computes real r for both relationships", () => {
+ const p = buildCorrelationPayload(proxy, ccj, ceg, "SRUUF", "2026-07-04");
+ assert.ok(p);
+ assert.equal(p.weeks, 52);
+ assert.equal(p.ccjPairs.length, 52);
+ assert.ok(p.ccjR !== null && p.ccjR > 0.999); // both strictly increasing
+ assert.ok(p.cegR !== null && p.cegR < -0.999); // proxy up, ceg down
+ assert.equal(p.proxyTicker, "SRUUF");
+ });
+ it("null when overlap is too thin to present as a year (<20 weeks)", () => {
+ const p = buildCorrelationPayload(series([1, 2, 3]), series([4, 5, 6]), [], "SRUUF", "x");
+ assert.equal(p, null);
+ });
+ it("CEG side degrades to null independently when its overlap is thin", () => {
+ const p = buildCorrelationPayload(proxy, ccj, series([1, 2, 3]), "SRUUF", "x");
+ assert.ok(p);
+ assert.equal(p.cegR, null);
+ assert.equal(p.cegPairs.length, 3);
+ });
+});
+
+describe("getUraniumCorrelation (cache + failure)", () => {
+ beforeEach(() => clearCorrelationCache());
+ const good = async (t: string) =>
+ series(Array.from({ length: 52 }, (_, i) => (t === "SRUUF" ? 15 + i * 0.1 : t === "CCJ" ? 70 + i : 300 + i)));
+
+ it("computes once and serves from cache after", async () => {
+ let calls = 0;
+ const counting = async (t: string) => {
+ calls++;
+ return good(t);
+ };
+ const p1 = await getUraniumCorrelation(counting);
+ const p2 = await getUraniumCorrelation(counting);
+ assert.ok(p1 && p2);
+ assert.equal(calls, 3); // one sweep of three tickers, second call cached
+ });
+ it("fetch failure yields null - nothing invented", async () => {
+ const dead = async () => {
+ throw new Error("yahoo down");
+ };
+ const p = await getUraniumCorrelation(dead);
+ assert.equal(p, null);
+ });
+});
diff --git a/server/__tests__/weekly-digest.test.ts b/server/__tests__/weekly-digest.test.ts
new file mode 100644
index 0000000..4449e8f
--- /dev/null
+++ b/server/__tests__/weekly-digest.test.ts
@@ -0,0 +1,86 @@
+// Weekly digest render: structure, personalization hook, escaping, honest
+// omission of missing gauges, date label math.
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { renderWeeklyEmail, weeklyDateLabel, type WeeklyDigestInput } from "../weekly-digest";
+
+const INPUT: WeeklyDigestInput = {
+ brief: {
+ title: "The Buildout Brief",
+ asOf: "2026-07-04",
+ summary: "Tracked clusters now total 118 GW planned across 77 operators.",
+ sections: [
+ { heading: "Compute", points: ["235 clusters tracked.", "Largest: Abilene at 1.2 GW."] },
+ { heading: "Grid", points: ["Queue at 2,290 GW; median wait 55 months."] },
+ ],
+ takeaway: "Power, not chips, is the binding constraint this quarter.",
+ },
+ movers: [
+ { ticker: "NVDA", name: "NVIDIA Corporation", changePercent: -1.39 },
+ { ticker: "AAPL", name: "Apple Inc.