diff --git a/backend/crates/atlas-server/src/api/handlers/mod.rs b/backend/crates/atlas-server/src/api/handlers/mod.rs index f5952e8..d4c2076 100644 --- a/backend/crates/atlas-server/src/api/handlers/mod.rs +++ b/backend/crates/atlas-server/src/api/handlers/mod.rs @@ -8,6 +8,7 @@ pub mod nfts; pub mod proxy; pub mod search; pub mod sse; +pub mod stats; pub mod status; pub mod tokens; pub mod transactions; diff --git a/backend/crates/atlas-server/src/api/handlers/stats.rs b/backend/crates/atlas-server/src/api/handlers/stats.rs new file mode 100644 index 0000000..5947df5 --- /dev/null +++ b/backend/crates/atlas-server/src/api/handlers/stats.rs @@ -0,0 +1,266 @@ +use axum::{ + extract::{Query, State}, + Json, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::api::error::ApiResult; +use crate::api::AppState; + +/// Time window for chart queries. +#[derive(Deserialize, Default, Clone, Copy)] +pub enum Window { + #[serde(rename = "1h")] + OneHour, + #[serde(rename = "6h")] + SixHours, + #[default] + #[serde(rename = "24h")] + TwentyFourHours, + #[serde(rename = "7d")] + SevenDays, + #[serde(rename = "1m")] + OneMonth, + #[serde(rename = "6m")] + SixMonths, + #[serde(rename = "1y")] + OneYear, +} + +impl Window { + pub fn duration_secs(self) -> i64 { + match self { + Window::OneHour => 3_600, + Window::SixHours => 6 * 3_600, + Window::TwentyFourHours => 24 * 3_600, + Window::SevenDays => 7 * 24 * 3_600, + Window::OneMonth => 30 * 24 * 3_600, + Window::SixMonths => 180 * 24 * 3_600, + Window::OneYear => 365 * 24 * 3_600, + } + } + + pub fn bucket_secs(self) -> i64 { + match self { + Window::OneHour => 300, // 5-min buckets → 12 points + Window::SixHours => 1_800, // 30-min buckets → 12 points + Window::TwentyFourHours => 3_600, // 1-hour buckets → 24 points + Window::SevenDays => 43_200, // 12-hour buckets → 14 points + Window::OneMonth => 86_400, // 1-day buckets → 30 points + Window::SixMonths => 7 * 86_400, // 1-week buckets → ~26 points + Window::OneYear => 14 * 86_400, // 2-week buckets → ~26 points + } + } +} + +#[derive(Deserialize)] +pub struct WindowQuery { + #[serde(default)] + pub window: Window, +} + +#[derive(Serialize)] +pub struct BlockChartPoint { + pub bucket: String, + pub tx_count: i64, + pub avg_gas_used: f64, +} + +#[derive(Serialize)] +pub struct DailyTxPoint { + pub day: String, + pub tx_count: i64, +} + +#[derive(Serialize)] +pub struct GasPricePoint { + pub bucket: String, + pub avg_gas_price: f64, +} + +/// GET /api/stats/blocks-chart?window=1h|6h|24h|7d +/// +/// Returns tx count and avg gas utilization bucketed over the given window. +/// Both metrics come from the `blocks` table so a single query serves both charts. +/// The window is anchored to the latest indexed block timestamp (not NOW()) so +/// charts show data even when the indexer is behind the live chain head. +pub async fn get_blocks_chart( + State(state): State>, + Query(params): Query, +) -> ApiResult>> { + let window = params.window; + let bucket_secs = window.bucket_secs(); + + // Anchor to the latest indexed block timestamp, not wall-clock NOW(). + // This ensures charts always show data regardless of how far the indexer + // is behind the live chain head. + let rows: Vec<(chrono::DateTime, i64, f64)> = sqlx::query_as( + r#" + WITH latest AS (SELECT MAX(timestamp) AS max_ts FROM blocks), + bounds AS ( + SELECT + max_ts - $2 AS start_ts, + max_ts AS end_ts + FROM latest + ), + agg AS ( + SELECT + (b.start_ts + (((blocks.timestamp - b.start_ts) / $1) * $1))::bigint AS bucket_ts, + SUM(transaction_count)::bigint AS tx_count, + COALESCE(AVG(gas_used::float8), 0.0) AS avg_gas_used + FROM blocks + CROSS JOIN bounds b + WHERE blocks.timestamp >= b.start_ts + AND blocks.timestamp <= b.end_ts + GROUP BY 1 + ) + SELECT + to_timestamp(gs::float8) AS bucket, + COALESCE(a.tx_count, 0)::bigint AS tx_count, + COALESCE(a.avg_gas_used, 0.0) AS avg_gas_used + FROM bounds b + CROSS JOIN generate_series(b.start_ts, b.end_ts - $1, $1::bigint) AS gs + LEFT JOIN agg a ON a.bucket_ts = gs + ORDER BY gs ASC + "#, + ) + .bind(bucket_secs) + .bind(window.duration_secs()) + .fetch_all(&state.pool) + .await?; + + let points = rows + .into_iter() + .map(|(bucket, tx_count, avg_gas_used)| BlockChartPoint { + bucket: bucket.to_rfc3339(), + tx_count, + avg_gas_used, + }) + .collect(); + + Ok(Json(points)) +} + +/// GET /api/stats/daily-txs +/// +/// Returns transaction counts per day for the last 14 days. Fixed window. +pub async fn get_daily_txs( + State(state): State>, +) -> ApiResult>> { + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + WITH latest AS (SELECT MAX(timestamp) AS max_ts FROM transactions) + SELECT + to_char(to_timestamp(timestamp)::date, 'YYYY-MM-DD') AS day, + COUNT(*)::bigint AS tx_count + FROM transactions, latest + WHERE timestamp >= max_ts - (14 * 86400) + GROUP BY to_timestamp(timestamp)::date + ORDER BY to_timestamp(timestamp)::date ASC + "#, + ) + .fetch_all(&state.pool) + .await?; + + let points = rows + .into_iter() + .map(|(day, tx_count)| DailyTxPoint { day, tx_count }) + .collect(); + + Ok(Json(points)) +} + +/// GET /api/stats/gas-price?window=1h|6h|24h|7d +/// +/// Returns average gas price (in wei) per bucket over the given window. +/// Anchored to the latest indexed transaction timestamp (not NOW()). +pub async fn get_gas_price_chart( + State(state): State>, + Query(params): Query, +) -> ApiResult>> { + let window = params.window; + let bucket_secs = window.bucket_secs(); + + let rows: Vec<(chrono::DateTime, Option)> = sqlx::query_as( + r#" + WITH latest AS (SELECT MAX(timestamp) AS max_ts FROM blocks), + bounds AS ( + SELECT + max_ts - $2 AS start_ts, + max_ts AS end_ts + FROM latest + ), + agg AS ( + SELECT + (b.start_ts + (((transactions.timestamp - b.start_ts) / $1) * $1))::bigint AS bucket_ts, + AVG(gas_price::float8) AS avg_gas_price + FROM transactions + CROSS JOIN bounds b + WHERE transactions.timestamp >= b.start_ts + AND transactions.timestamp <= b.end_ts + AND gas_price > 0 + GROUP BY 1 + ) + SELECT + to_timestamp(gs::float8) AS bucket, + a.avg_gas_price + FROM bounds b + CROSS JOIN generate_series(b.start_ts, b.end_ts - $1, $1::bigint) AS gs + LEFT JOIN agg a ON a.bucket_ts = gs + ORDER BY gs ASC + "#, + ) + .bind(bucket_secs) + .bind(window.duration_secs()) + .fetch_all(&state.pool) + .await?; + + let points = rows + .into_iter() + .map(|(bucket, avg_gas_price)| GasPricePoint { + bucket: bucket.to_rfc3339(), + avg_gas_price: avg_gas_price.unwrap_or(0.0), + }) + .collect(); + + Ok(Json(points)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn window_duration_secs() { + assert_eq!(Window::OneHour.duration_secs(), 3_600); + assert_eq!(Window::SixHours.duration_secs(), 6 * 3_600); + assert_eq!(Window::TwentyFourHours.duration_secs(), 24 * 3_600); + assert_eq!(Window::SevenDays.duration_secs(), 7 * 24 * 3_600); + } + + #[test] + fn window_bucket_secs_gives_reasonable_point_counts() { + // Each window should yield ~12-28 data points + for (window, expected_points) in [ + (Window::OneHour, 12), + (Window::SixHours, 12), + (Window::TwentyFourHours, 24), + (Window::SevenDays, 14), + (Window::OneMonth, 30), + (Window::SixMonths, 25), + (Window::OneYear, 26), + ] { + let points = window.duration_secs() / window.bucket_secs(); + assert_eq!(points, expected_points); + } + } + + #[test] + fn gas_price_window_supports_7d() { + // SevenDays is now supported for gas price queries + assert_eq!(Window::SevenDays.duration_secs(), 7 * 24 * 3_600); + assert_eq!(Window::SevenDays.bucket_secs(), 43_200); + } +} diff --git a/backend/crates/atlas-server/src/api/handlers/tokens.rs b/backend/crates/atlas-server/src/api/handlers/tokens.rs index ebb5cff..bd4f9c4 100644 --- a/backend/crates/atlas-server/src/api/handlers/tokens.rs +++ b/backend/crates/atlas-server/src/api/handlers/tokens.rs @@ -2,9 +2,11 @@ use axum::{ extract::{Path, Query, State}, Json, }; +use chrono::Utc; use std::sync::Arc; use crate::api::error::ApiResult; +use crate::api::handlers::stats::WindowQuery; use crate::api::AppState; use atlas_common::{ AtlasError, Erc20Balance, Erc20Contract, Erc20Holder, Erc20Transfer, PaginatedResponse, @@ -276,6 +278,97 @@ pub struct AddressTokenBalance { pub decimals: i16, } +/// Chart point returned by GET /api/tokens/:address/chart +#[derive(serde::Serialize)] +pub struct TokenChartPoint { + pub bucket: String, + pub transfer_count: i64, + pub volume: f64, +} + +/// GET /api/tokens/:address/chart?window=1h|6h|24h|7d +/// +/// Returns transfer count and volume (in human-readable token units) per time +/// bucket for the given token contract. Anchored to the latest transfer +/// timestamp so charts show data even when the indexer is catching up. +pub async fn get_token_chart( + State(state): State>, + Path(address): Path, + Query(params): Query, +) -> ApiResult>> { + let address = normalize_address(&address); + let window = params.window; + let bucket_secs = window.bucket_secs(); + + // Fetch token decimals (default 18 if not found) + let decimals: i16 = + sqlx::query_as::<_, (i16,)>("SELECT decimals FROM erc20_contracts WHERE address = $1") + .bind(&address) + .fetch_optional(&state.pool) + .await? + .map(|(d,)| d) + .unwrap_or(18); + + let rows: Vec<(chrono::DateTime, i64, bigdecimal::BigDecimal)> = sqlx::query_as( + r#" + WITH latest AS (SELECT MAX(timestamp) AS max_ts FROM blocks), + bounds AS ( + SELECT + max_ts - $3 AS start_ts, + max_ts AS end_ts + FROM latest + ), + agg AS ( + SELECT + (b.start_ts + (((erc20_transfers.timestamp - b.start_ts) / $2) * $2))::bigint AS bucket_ts, + COUNT(*)::bigint AS transfer_count, + COALESCE(SUM(value), 0) AS volume + FROM erc20_transfers + CROSS JOIN bounds b + WHERE contract_address = $1 + AND erc20_transfers.timestamp >= b.start_ts + AND erc20_transfers.timestamp <= b.end_ts + GROUP BY 1 + ) + SELECT + to_timestamp(gs::float8) AS bucket, + COALESCE(a.transfer_count, 0)::bigint AS transfer_count, + COALESCE(a.volume, 0::numeric) AS volume + FROM bounds b + CROSS JOIN generate_series(b.start_ts, b.end_ts - $2, $2::bigint) AS gs + LEFT JOIN agg a ON a.bucket_ts = gs + ORDER BY gs ASC + "#, + ) + .bind(&address) + .bind(bucket_secs) + .bind(window.duration_secs()) + .fetch_all(&state.pool) + .await?; + + let divisor = if decimals <= 18 { + bigdecimal::BigDecimal::from(10_i64.pow(decimals as u32)) + } else { + format!("1e{}", decimals) + .parse::() + .unwrap_or(bigdecimal::BigDecimal::from(1)) + }; + let points = rows + .into_iter() + .map(|(bucket, transfer_count, sum_value)| { + use bigdecimal::ToPrimitive; + let volume = (&sum_value / &divisor).to_f64().unwrap_or(0.0); + TokenChartPoint { + bucket: bucket.to_rfc3339(), + transfer_count, + volume, + } + }) + .collect(); + + Ok(Json(points)) +} + fn normalize_address(address: &str) -> String { if address.starts_with("0x") { address.to_lowercase() diff --git a/backend/crates/atlas-server/src/api/mod.rs b/backend/crates/atlas-server/src/api/mod.rs index 564d6b2..ac9e264 100644 --- a/backend/crates/atlas-server/src/api/mod.rs +++ b/backend/crates/atlas-server/src/api/mod.rs @@ -137,6 +137,10 @@ pub fn build_router(state: Arc, cors_origin: Option) -> Router "/api/tokens/{address}/transfers", get(handlers::tokens::get_token_transfers), ) + .route( + "/api/tokens/{address}/chart", + get(handlers::tokens::get_token_chart), + ) // Proxy Contracts .route("/api/proxies", get(handlers::proxy::list_proxies)) .route( @@ -151,6 +155,16 @@ pub fn build_router(state: Arc, cors_origin: Option) -> Router .route("/api", get(handlers::etherscan::etherscan_api)) // Search .route("/api/search", get(handlers::search::search)) + // Stats (charts) + .route( + "/api/stats/blocks-chart", + get(handlers::stats::get_blocks_chart), + ) + .route("/api/stats/daily-txs", get(handlers::stats::get_daily_txs)) + .route( + "/api/stats/gas-price", + get(handlers::stats::get_gas_price_chart), + ) // Status .route("/api/height", get(handlers::status::get_height)) .route("/api/status", get(handlers::status::get_status)) diff --git a/backend/crates/atlas-server/tests/integration/status.rs b/backend/crates/atlas-server/tests/integration/status.rs index 4c89703..6fea231 100644 --- a/backend/crates/atlas-server/tests/integration/status.rs +++ b/backend/crates/atlas-server/tests/integration/status.rs @@ -6,6 +6,44 @@ use tower::ServiceExt; use crate::common; +async fn seed_chart_data(pool: &sqlx::PgPool) { + sqlx::query( + "INSERT INTO blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (number) DO NOTHING", + ) + .bind(9000i64) + .bind(format!("0x{:064x}", 9000)) + .bind(format!("0x{:064x}", 8999)) + .bind(4_100_000_123i64) + .bind(100_000i64) + .bind(30_000_000i64) + .bind(1i32) + .execute(pool) + .await + .expect("seed chart block"); + + sqlx::query( + "INSERT INTO transactions (hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (hash, block_number) DO NOTHING", + ) + .bind("0x9000000000000000000000000000000000000000000000000000000000000000") + .bind(9000i64) + .bind(0i32) + .bind("0x9000000000000000000000000000000000000001") + .bind("0x9000000000000000000000000000000000000002") + .bind(0i64) + .bind(20_000_000_000i64) + .bind(21_000i64) + .bind(Vec::::new()) + .bind(true) + .bind(4_100_000_123i64) + .execute(pool) + .await + .expect("seed chart transaction"); +} + #[test] fn health_returns_ok() { common::run(async { @@ -45,3 +83,42 @@ fn status_returns_chain_info() { assert!(body["block_height"].is_i64()); }); } + +#[test] +fn stats_charts_return_exact_bucket_count_for_non_aligned_window() { + common::run(async { + let pool = common::pool(); + seed_chart_data(pool).await; + + let app = common::test_router(); + + let blocks_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/stats/blocks-chart?window=1h") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(blocks_response.status(), StatusCode::OK); + let blocks_body = common::json_body(blocks_response).await; + assert_eq!(blocks_body.as_array().unwrap().len(), 12); + + let gas_response = app + .oneshot( + Request::builder() + .uri("/api/stats/gas-price?window=1h") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(gas_response.status(), StatusCode::OK); + let gas_body = common::json_body(gas_response).await; + assert_eq!(gas_body.as_array().unwrap().len(), 12); + }); +} diff --git a/backend/crates/atlas-server/tests/integration/tokens.rs b/backend/crates/atlas-server/tests/integration/tokens.rs index 91799fd..0684a1e 100644 --- a/backend/crates/atlas-server/tests/integration/tokens.rs +++ b/backend/crates/atlas-server/tests/integration/tokens.rs @@ -113,6 +113,61 @@ async fn seed_token_data(pool: &sqlx::PgPool) { .expect("seed erc20 transfer"); } +async fn seed_token_chart_data(pool: &sqlx::PgPool) { + sqlx::query( + "INSERT INTO blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (number) DO NOTHING", + ) + .bind(9001i64) + .bind(format!("0x{:064x}", 9001)) + .bind(format!("0x{:064x}", 9000)) + .bind(4_100_100_123i64) + .bind(100_000i64) + .bind(30_000_000i64) + .bind(1i32) + .execute(pool) + .await + .expect("seed token chart block"); + + sqlx::query( + "INSERT INTO transactions (hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (hash, block_number) DO NOTHING", + ) + .bind("0x9001000000000000000000000000000000000000000000000000000000000000") + .bind(9001i64) + .bind(0i32) + .bind(HOLDER_1) + .bind(TOKEN_B) + .bind(0i64) + .bind(20_000_000_000i64) + .bind(60_000i64) + .bind(Vec::::new()) + .bind(true) + .bind(4_100_100_123i64) + .execute(pool) + .await + .expect("seed token chart transaction"); + + sqlx::query( + "INSERT INTO erc20_transfers (tx_hash, log_index, contract_address, from_address, to_address, value, block_number, timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (tx_hash, log_index, block_number) DO NOTHING", + ) + .bind("0x9001000000000000000000000000000000000000000000000000000000000000") + .bind(0i32) + .bind(TOKEN_B) + .bind(HOLDER_1) + .bind(HOLDER_2) + .bind(bigdecimal::BigDecimal::from(75_000i64)) + .bind(9001i64) + .bind(4_100_100_123i64) + .execute(pool) + .await + .expect("seed token chart transfer"); +} + #[test] fn list_tokens() { common::run(async { @@ -217,3 +272,27 @@ fn get_tx_erc20_transfers() { assert_eq!(data[0]["to_address"].as_str().unwrap(), HOLDER_2); }); } + +#[test] +fn get_token_chart_returns_exact_bucket_count_for_non_aligned_window() { + common::run(async { + let pool = common::pool(); + seed_token_data(pool).await; + seed_token_chart_data(pool).await; + + let app = common::test_router(); + let response = app + .oneshot( + Request::builder() + .uri(format!("/api/tokens/{}/chart?window=1h", TOKEN_B)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = common::json_body(response).await; + assert_eq!(body.as_array().unwrap().len(), 12); + }); +} diff --git a/frontend/bun.lock b/frontend/bun.lock index 9f48104..23246a0 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -9,6 +9,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^6.30.3", + "recharts": "^3.8.0", }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -178,6 +179,8 @@ "@prefresh/vite": ["@prefresh/vite@2.4.12", "", { "dependencies": { "@babel/core": "^7.22.1", "@prefresh/babel-plugin": "^0.5.2", "@prefresh/core": "^1.5.0", "@prefresh/utils": "^1.2.0", "@rollup/pluginutils": "^4.2.1" }, "peerDependencies": { "preact": "^10.4.0 || ^11.0.0-0", "vite": ">=2.0.0" } }, "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + "@remix-run/router": ["@remix-run/router@1.23.2", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], @@ -232,6 +235,28 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -242,6 +267,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/type-utils": "8.53.1", "@typescript-eslint/utils": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg=="], @@ -306,6 +333,8 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "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=="], @@ -326,8 +355,32 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], @@ -346,6 +399,8 @@ "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -374,6 +429,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -420,10 +477,14 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], @@ -544,6 +605,10 @@ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="], + + "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-router": ["react-router@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="], "react-router-dom": ["react-router-dom@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="], @@ -552,6 +617,14 @@ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -592,6 +665,8 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -612,8 +687,12 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite-prerender-plugin": ["vite-prerender-plugin@0.5.13", "", { "dependencies": { "kolorist": "^1.8.0", "magic-string": "0.x >= 0.26.0", "node-html-parser": "^6.1.12", "simple-code-frame": "^1.3.0", "source-map": "^0.7.4", "stack-trace": "^1.0.0-pre2" }, "peerDependencies": { "vite": "5.x || 6.x || 7.x || 8.x" } }, "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g=="], @@ -638,6 +717,8 @@ "@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], diff --git a/frontend/package.json b/frontend/package.json index 17ad6ca..d07b788 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "preact": "^10.29.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router-dom": "^6.30.3" + "react-router-dom": "^6.30.3", + "recharts": "^3.8.0" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/api/chartData.ts b/frontend/src/api/chartData.ts new file mode 100644 index 0000000..724d5b3 --- /dev/null +++ b/frontend/src/api/chartData.ts @@ -0,0 +1,41 @@ +import client from './client'; + +export type ChartWindow = '1h' | '6h' | '24h' | '7d' | '1m' | '6m' | '1y'; + +export interface BlockChartPoint { + bucket: string; // ISO timestamp + tx_count: number; + avg_gas_used: number; // average gas used per block in this bucket +} + +export interface DailyTxPoint { + day: string; // YYYY-MM-DD + tx_count: number; +} + +export interface GasPricePoint { + bucket: string; // ISO timestamp + avg_gas_price: number; // wei +} + +export function getBlocksChart(window: ChartWindow): Promise { + return client.get('/stats/blocks-chart', { params: { window } }); +} + +export function getDailyTxs(): Promise { + return client.get('/stats/daily-txs'); +} + +export function getGasPriceChart(window: ChartWindow): Promise { + return client.get('/stats/gas-price', { params: { window } }); +} + +export interface TokenChartPoint { + bucket: string; // ISO timestamp + transfer_count: number; + volume: number; // in human-readable token units (divided by 10^decimals on backend) +} + +export function getTokenChart(address: string, window: ChartWindow): Promise { + return client.get(`/tokens/${address}/chart`, { params: { window } }); +} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index be81c3b..8cb716f 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -11,3 +11,4 @@ export { default as useFaucetInfo } from './useFaucetInfo'; export { default as useEthBalance } from './useEthBalance'; export { default as useEthPrice } from './useEthPrice'; export { default as useFeatures } from './useFeatures'; +export { useTokenChart } from './useTokenChart'; diff --git a/frontend/src/hooks/useChartData.ts b/frontend/src/hooks/useChartData.ts new file mode 100644 index 0000000..d95da6d --- /dev/null +++ b/frontend/src/hooks/useChartData.ts @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react'; +import { + getBlocksChart, + getDailyTxs, + getGasPriceChart, + type BlockChartPoint, + type ChartWindow, + type DailyTxPoint, + type GasPricePoint, +} from '../api/chartData'; + +interface ChartData { + blocksChart: BlockChartPoint[]; + dailyTxs: DailyTxPoint[]; + gasPriceChart: GasPricePoint[]; + dailyTxsLoading: boolean; + blocksChartLoading: boolean; + gasPriceLoading: boolean; + dailyTxsError: string | null; + blocksChartError: string | null; + gasPriceError: string | null; +} + +export function useChartData(window: ChartWindow): ChartData { + const [blocksChart, setBlocksChart] = useState([]); + const [dailyTxs, setDailyTxs] = useState([]); + const [gasPriceChart, setGasPriceChart] = useState([]); + const [dailyTxsLoading, setDailyTxsLoading] = useState(true); + const [blocksChartLoading, setBlocksChartLoading] = useState(true); + const [gasPriceLoading, setGasPriceLoading] = useState(true); + const [dailyTxsError, setDailyTxsError] = useState(null); + const [blocksChartError, setBlocksChartError] = useState(null); + const [gasPriceError, setGasPriceError] = useState(null); + + // Daily txs are window-independent — fetch once and refresh on a slow interval + useEffect(() => { + let mounted = true; + setDailyTxsLoading(true); + const fetchDaily = async () => { + try { + setDailyTxsError(null); + const daily = await getDailyTxs(); + if (mounted) setDailyTxs(daily); + } catch (err) { + if (mounted) + setDailyTxsError(err instanceof Error ? err.message : 'Failed to load daily transactions'); + } finally { + if (mounted) setDailyTxsLoading(false); + } + }; + fetchDaily(); + const id = setInterval(fetchDaily, 60_000); + return () => { + mounted = false; + clearInterval(id); + }; + }, []); + + // Blocks chart depends on the selected window + useEffect(() => { + let mounted = true; + setBlocksChartLoading(true); + const fetch = async () => { + try { + setBlocksChartError(null); + const blocks = await getBlocksChart(window); + if (mounted) setBlocksChart(blocks); + } catch (err) { + if (mounted) + setBlocksChartError(err instanceof Error ? err.message : 'Failed to load blocks chart'); + } finally { + if (mounted) setBlocksChartLoading(false); + } + }; + fetch(); + const id = setInterval(fetch, 30_000); + return () => { + mounted = false; + clearInterval(id); + }; + }, [window]); + + // Gas price chart depends on the selected window + useEffect(() => { + let mounted = true; + setGasPriceLoading(true); + const fetch = async () => { + try { + setGasPriceError(null); + const gasPrice = await getGasPriceChart(window); + if (mounted) setGasPriceChart(gasPrice); + } catch (err) { + if (mounted) + setGasPriceError(err instanceof Error ? err.message : 'Failed to load gas price chart'); + } finally { + if (mounted) setGasPriceLoading(false); + } + }; + fetch(); + const id = setInterval(fetch, 30_000); + return () => { + mounted = false; + clearInterval(id); + }; + }, [window]); + + return { + blocksChart, + dailyTxs, + gasPriceChart, + dailyTxsLoading, + blocksChartLoading, + gasPriceLoading, + dailyTxsError, + blocksChartError, + gasPriceError, + }; +} diff --git a/frontend/src/hooks/useTokenChart.ts b/frontend/src/hooks/useTokenChart.ts new file mode 100644 index 0000000..7106177 --- /dev/null +++ b/frontend/src/hooks/useTokenChart.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { getTokenChart, type ChartWindow, type TokenChartPoint } from '../api/chartData'; + +interface TokenChartData { + data: TokenChartPoint[]; + loading: boolean; + error: string | null; +} + +export function useTokenChart(address: string | undefined, window: ChartWindow): TokenChartData { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!address) { + setData([]); + setLoading(false); + return; + } + let mounted = true; + + setData([]); + setLoading(true); + + const fetch = async () => { + try { + setError(null); + const points = await getTokenChart(address, window); + if (mounted) setData(points); + } catch (err) { + if (mounted) { + const message = + err && typeof err === 'object' && 'error' in err && typeof (err as { error: unknown }).error === 'string' + ? (err as { error: string }).error + : err instanceof Error + ? err.message + : 'Failed to load chart data'; + setError(message); + } + } finally { + if (mounted) setLoading(false); + } + }; + + fetch(); + const id = setInterval(fetch, 30_000); + return () => { + mounted = false; + clearInterval(id); + }; + }, [address, window]); + + return { data, loading, error }; +} diff --git a/frontend/src/pages/StatusPage.tsx b/frontend/src/pages/StatusPage.tsx index b5ed217..4d57030 100644 --- a/frontend/src/pages/StatusPage.tsx +++ b/frontend/src/pages/StatusPage.tsx @@ -1,14 +1,48 @@ import { useContext, useEffect, useState } from 'react'; +import { + AreaChart, + Area, + BarChart, + Bar, + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from 'recharts'; import { getChainStatus, type ChainStatusResponse } from '../api/status'; +import { type ChartWindow } from '../api/chartData'; import { formatNumber } from '../utils'; import Loading from '../components/Loading'; import { BlockStatsContext } from '../context/BlockStatsContext'; +import { useChartData } from '../hooks/useChartData'; + +// ─── theme constants for recharts (matches CSS vars) ────────────────────────── +const CHART_ACCENT = '#dc2626'; +const CHART_GRID = '#22222e'; +const CHART_AXIS_TEXT = '#94a3b8'; +const CHART_TOOLTIP_BG = '#0c0c10'; + +const WINDOWS: { label: string; value: ChartWindow }[] = [ + { label: '1H', value: '1h' }, + { label: '6H', value: '6h' }, + { label: '24H', value: '24h' }, + { label: '7D', value: '7d' }, + { label: '1M', value: '1m' }, +]; export default function StatusPage() { const blockStats = useContext(BlockStatsContext); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [window, setWindow] = useState('24h'); + + const { + blocksChart, dailyTxs, gasPriceChart, + dailyTxsLoading, blocksChartLoading, gasPriceLoading, + } = useChartData(window); useEffect(() => { let mounted = true; @@ -75,10 +109,146 @@ export default function StatusPage() { )} + + {/* Chain Activity Charts */} +
+
+

Chain Activity

+ +
+ +
+ + + + + + [formatCompact(v as number), 'Transactions']} + /> + + + + + + + + + + + + + + + formatBucketTick(v, window)} + interval="preserveStartEnd" + /> + + [formatCompact(v as number), 'Avg Gas Used']} + labelFormatter={(v) => formatBucketTooltip(v, window)} + /> + + + + + + + + + formatBucketTick(v, window)} + interval="preserveStartEnd" + /> + + [formatCompact(v as number), 'Transactions']} + labelFormatter={(v) => formatBucketTooltip(v, window)} + /> + + + + + + + + + formatBucketTick(v, window)} + interval="preserveStartEnd" + /> + + [formatGwei(v as number), 'Avg Gas Price']} + labelFormatter={(v) => formatBucketTooltip(v, window)} + /> + + + + +
+
); } +// ─── sub-components ─────────────────────────────────────────────────────────── + interface StatusStatProps { label: string; value: string; @@ -92,3 +262,100 @@ function StatusStat({ label, value }: StatusStatProps) { ); } + +function ChartCard({ title, children, loading }: { title: string; children: React.ReactNode; loading?: boolean }) { + return ( +
+

{title}

+ {children} + {loading && ( +
+
+
+ )} +
+ ); +} + +function WindowToggle({ value, onChange }: { value: ChartWindow; onChange: (w: ChartWindow) => void }) { + return ( +
+ {WINDOWS.map(({ label, value: w }) => ( + + ))} +
+ ); +} + +// ─── formatting helpers ─────────────────────────────────────────────────────── + +function formatCompact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +function formatGwei(wei: number): string { + const gwei = wei / 1e9; + if (gwei >= 1_000) return `${(gwei / 1_000).toFixed(1)}K gwei`; + if (gwei >= 1) return `${gwei.toFixed(2)} gwei`; + return `${gwei.toFixed(3)} gwei`; +} + +function formatDayLabel(day: string): string { + // day is "YYYY-MM-DD" — show "MMM D" + const d = new Date(day + 'T00:00:00'); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +function formatBucketTick(bucket: string, window: ChartWindow): string { + const d = new Date(bucket); + if (isNaN(d.getTime())) return bucket; + if (window === '1m') { + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + if (window === '6m' || window === '1y') { + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + if (window === '7d') { + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +const BUCKET_MS: Record = { + '1h': 5 * 60 * 1000, + '6h': 30 * 60 * 1000, + '24h': 60 * 60 * 1000, + '7d': 12 * 60 * 60 * 1000, + '1m': 24 * 60 * 60 * 1000, + '6m': 7 * 24 * 60 * 60 * 1000, + '1y': 14 * 24 * 60 * 60 * 1000, +}; + +function formatBucketTooltip(bucket: string, window: ChartWindow): string { + const start = new Date(bucket); + if (isNaN(start.getTime())) return bucket; + const end = new Date(start.getTime() + BUCKET_MS[window]); + + if (BUCKET_MS[window] < 24 * 60 * 60 * 1000) { + const date = start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const t0 = start.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const t1 = end.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + return `${date}, ${t0} – ${t1}`; + } + + const d0 = start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const d1 = new Date(end.getTime() - 1).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return d0 === d1 ? d0 : `${d0} – ${d1}`; +} diff --git a/frontend/src/pages/TokenDetailPage.tsx b/frontend/src/pages/TokenDetailPage.tsx index 493ea3f..f8a4fc1 100644 --- a/frontend/src/pages/TokenDetailPage.tsx +++ b/frontend/src/pages/TokenDetailPage.tsx @@ -1,8 +1,92 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import { useParams, Link } from 'react-router-dom'; +import { + AreaChart, Area, BarChart, Bar, + XAxis, YAxis, Tooltip, ResponsiveContainer, +} from 'recharts'; import { useToken, useTokenHolders, useTokenTransfers } from '../hooks'; +import { useTokenChart } from '../hooks/useTokenChart'; import { Pagination, AddressLink, TxHashLink, CopyButton } from '../components'; +import Loading from '../components/Loading'; import { formatNumber, formatTokenAmount, formatPercentage, formatTimeAgo, truncateHash } from '../utils'; +import { type ChartWindow } from '../api/chartData'; + +const CHART_ACCENT = '#dc2626'; +const CHART_GRID = '#22222e'; +const CHART_AXIS_TEXT = '#94a3b8'; +const CHART_TOOLTIP_BG = '#0c0c10'; + +const WINDOWS: { label: string; value: ChartWindow }[] = [ + { label: '1H', value: '1h' }, + { label: '6H', value: '6h' }, + { label: '24H', value: '24h' }, + { label: '7D', value: '7d' }, + { label: '1M', value: '1m' }, + { label: '6M', value: '6m' }, + { label: '1Y', value: '1y' }, +]; + +function formatCompact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n % 1 === 0 ? String(n) : n.toFixed(2); +} + +function formatBucketTick(bucket: string, window: ChartWindow): string { + const d = new Date(bucket); + if (isNaN(d.getTime())) return bucket; + if (window === '1m') { + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + if (window === '6m' || window === '1y') { + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + if (window === '7d') { + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +function TokenChartCard({ title, children, loading }: { title: string; children: React.ReactNode; loading?: boolean }) { + return ( +
+

{title}

+ {children} + {loading && ( +
+
+
+ )} +
+ ); +} + +const BUCKET_MS: Record = { + '1h': 5 * 60 * 1000, + '6h': 30 * 60 * 1000, + '24h': 60 * 60 * 1000, + '7d': 12 * 60 * 60 * 1000, + '1m': 24 * 60 * 60 * 1000, + '6m': 7 * 24 * 60 * 60 * 1000, + '1y': 14 * 24 * 60 * 60 * 1000, +}; + +function formatBucketTooltip(bucket: string, window: ChartWindow): string { + const start = new Date(bucket); + if (isNaN(start.getTime())) return bucket; + const end = new Date(start.getTime() + BUCKET_MS[window]); + + if (BUCKET_MS[window] < 24 * 60 * 60 * 1000) { + const date = start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const t0 = start.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const t1 = end.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + return `${date}, ${t0} – ${t1}`; + } + + const d0 = start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const d1 = new Date(end.getTime() - 1).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return d0 === d1 ? d0 : `${d0} – ${d1}`; +} type TabType = 'holders' | 'transfers'; @@ -11,10 +95,12 @@ export default function TokenDetailPage() { const [activeTab, setActiveTab] = useState('holders'); const [holdersPage, setHoldersPage] = useState(1); const [transfersPage, setTransfersPage] = useState(1); + const [chartWindow, setChartWindow] = useState('24h'); const { token } = useToken(address); const { holders, pagination: holdersPagination } = useTokenHolders(address, { page: holdersPage, limit: 20 }); const { transfers, pagination: transfersPagination } = useTokenTransfers(address, { page: transfersPage, limit: 20 }); + const { data: chartData, loading: chartLoading } = useTokenChart(address, chartWindow); const tabs: { id: TabType; label: string; count?: number }[] = [ { id: 'holders', label: 'Holders', count: holdersPagination?.total }, @@ -72,6 +158,92 @@ export default function TokenDetailPage() {
+ {/* Activity Charts */} +
+
+

Token Activity

+
+ {WINDOWS.map(({ label, value: w }) => ( + + ))} +
+
+ + {chartLoading && chartData.length === 0 ? ( +
+ ) : ( +
+ {/* Transfer Count */} + + + + formatBucketTick(v, chartWindow)} + interval="preserveStartEnd" + /> + + [formatCompact(v as number), 'Transfers']} + labelFormatter={(v) => formatBucketTooltip(v, chartWindow)} + /> + + + + + + {/* Transfer Volume */} + + + + + + + + + + formatBucketTick(v, chartWindow)} + interval="preserveStartEnd" + /> + + [formatCompact(v as number), `Volume (${token?.symbol || 'tokens'})`]} + labelFormatter={(v) => formatBucketTooltip(v, chartWindow)} + /> + + + + +
+ )} +
+ {/* Tabs */}