From 30ef34c3bb85ba65867cf7cec2bc86618982c2b3 Mon Sep 17 00:00:00 2001 From: pthmas <9058370+pthmas@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:49:43 +0100 Subject: [PATCH 1/3] feat: add activity charts to status and token detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /api/stats endpoints: blocks-chart, daily-txs, gas-price with configurable time windows (1H/6H/24H/7D/1M) - Status page: daily transactions (14d fixed), avg gas used, tx count, avg gas price charts with window toggle - Token detail page: transfer count and volume charts with window toggle - Per-chart independent loading states; no grid lines; animations disabled for instant render - Tooltip shows exact time range per bucket (e.g. "Mar 25, 10:00 – 11:00") - 7D window uses 12-hour buckets (14 bars); axis labels show dates not times for day+ windows --- .../atlas-server/src/api/handlers/mod.rs | 1 + .../atlas-server/src/api/handlers/stats.rs | 260 +++++++++++++++++ .../atlas-server/src/api/handlers/tokens.rs | 84 ++++++ backend/crates/atlas-server/src/api/mod.rs | 14 + frontend/bun.lock | 81 ++++++ frontend/package.json | 3 +- frontend/src/api/chartData.ts | 41 +++ frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useChartData.ts | 94 +++++++ frontend/src/hooks/useTokenChart.ts | 43 +++ frontend/src/pages/StatusPage.tsx | 264 ++++++++++++++++++ frontend/src/pages/TokenDetailPage.tsx | 174 +++++++++++- 12 files changed, 1058 insertions(+), 2 deletions(-) create mode 100644 backend/crates/atlas-server/src/api/handlers/stats.rs create mode 100644 frontend/src/api/chartData.ts create mode 100644 frontend/src/hooks/useChartData.ts create mode 100644 frontend/src/hooks/useTokenChart.ts 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..614c4e9 --- /dev/null +++ b/backend/crates/atlas-server/src/api/handlers/stats.rs @@ -0,0 +1,260 @@ +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), + agg AS ( + SELECT + (timestamp - (timestamp % $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, latest + WHERE timestamp >= max_ts - $2 + AND timestamp <= max_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 generate_series( + (SELECT (max_ts - $2) - ((max_ts - $2) % $1) FROM latest), + (SELECT max_ts - (max_ts % $1) FROM latest), + $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), + agg AS ( + SELECT + (timestamp - (timestamp % $1))::bigint AS bucket_ts, + AVG(gas_price::float8) AS avg_gas_price + FROM transactions, latest + WHERE timestamp >= max_ts - $2 + AND timestamp <= max_ts + AND gas_price > 0 + GROUP BY 1 + ) + SELECT + to_timestamp(gs::float8) AS bucket, + a.avg_gas_price + FROM generate_series( + (SELECT (max_ts - $2) - ((max_ts - $2) % $1) FROM latest), + (SELECT max_ts - (max_ts % $1) FROM latest), + $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() + .filter_map(|(bucket, avg_gas_price)| { + avg_gas_price.map(|p| GasPricePoint { + bucket: bucket.to_rfc3339(), + avg_gas_price: p, + }) + }) + .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..9c2aded 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::{Window, WindowQuery}; use crate::api::AppState; use atlas_common::{ AtlasError, Erc20Balance, Erc20Contract, Erc20Holder, Erc20Transfer, PaginatedResponse, @@ -276,6 +278,88 @@ 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), + agg AS ( + SELECT + (timestamp - (timestamp % $2))::bigint AS bucket_ts, + COUNT(*)::bigint AS transfer_count, + COALESCE(SUM(value), 0) AS volume + FROM erc20_transfers, latest + WHERE contract_address = $1 + AND timestamp >= max_ts - $3 + AND timestamp <= max_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 generate_series( + (SELECT (max_ts - $3) - ((max_ts - $3) % $2) FROM latest), + (SELECT max_ts - (max_ts % $2) FROM latest), + $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 = bigdecimal::BigDecimal::from(10_i64.pow(decimals.clamp(0, 18) as u32)); + 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/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..dea8ed9 --- /dev/null +++ b/frontend/src/hooks/useChartData.ts @@ -0,0 +1,94 @@ +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[]; + blocksChartLoading: boolean; + gasPriceLoading: boolean; + error: string | null; +} + +export function useChartData(window: ChartWindow): ChartData { + const [blocksChart, setBlocksChart] = useState([]); + const [dailyTxs, setDailyTxs] = useState([]); + const [gasPriceChart, setGasPriceChart] = useState([]); + const [blocksChartLoading, setBlocksChartLoading] = useState(true); + const [gasPriceLoading, setGasPriceLoading] = useState(true); + const [error, setError] = useState(null); + + // Daily txs are window-independent — fetch once and refresh on a slow interval + useEffect(() => { + let mounted = true; + const fetchDaily = async () => { + try { + const daily = await getDailyTxs(); + if (mounted) setDailyTxs(daily); + } catch { + // non-critical, ignore + } + }; + 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 { + setError(null); + const blocks = await getBlocksChart(window); + if (mounted) setBlocksChart(blocks); + } catch (err) { + if (mounted) setError(err instanceof Error ? err.message : 'Failed to load chart data'); + } 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 { + const gasPrice = await getGasPriceChart(window); + if (mounted) setGasPriceChart(gasPrice); + } catch { + // non-critical, ignore + } finally { + if (mounted) setGasPriceLoading(false); + } + }; + fetch(); + const id = setInterval(fetch, 30_000); + return () => { + mounted = false; + clearInterval(id); + }; + }, [window]); + + return { blocksChart, dailyTxs, gasPriceChart, blocksChartLoading, gasPriceLoading, error }; +} diff --git a/frontend/src/hooks/useTokenChart.ts b/frontend/src/hooks/useTokenChart.ts new file mode 100644 index 0000000..7ef6fff --- /dev/null +++ b/frontend/src/hooks/useTokenChart.ts @@ -0,0 +1,43 @@ +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) 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) setError(err instanceof Error ? err.message : 'Failed to load chart data'); + } 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..3f86f6d 100644 --- a/frontend/src/pages/StatusPage.tsx +++ b/frontend/src/pages/StatusPage.tsx @@ -1,14 +1,45 @@ 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, blocksChartLoading, gasPriceLoading } = useChartData(window); useEffect(() => { let mounted = true; @@ -75,10 +106,146 @@ export default function StatusPage() { )} + + {/* Chain Activity Charts */} +
+
+

Chain Activity

+ +
+ +
+ + + + + + [formatCompact(v), 'Transactions']} + /> + + + + + + + + + + + + + + + formatBucketTick(v, window)} + interval="preserveStartEnd" + /> + + [formatCompact(v), 'Avg Gas Used']} + labelFormatter={(v) => formatBucketTooltip(v, window)} + /> + + + + + + + + + formatBucketTick(v, window)} + interval="preserveStartEnd" + /> + + [formatCompact(v), 'Transactions']} + labelFormatter={(v) => formatBucketTooltip(v, window)} + /> + + + + + + + + + formatBucketTick(v, window)} + interval="preserveStartEnd" + /> + + [formatGwei(v), 'Avg Gas Price']} + labelFormatter={(v) => formatBucketTooltip(v, window)} + /> + + + + +
+
); } +// ─── sub-components ─────────────────────────────────────────────────────────── + interface StatusStatProps { label: string; value: string; @@ -92,3 +259,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)}Kgwei`; + if (gwei >= 1) return `${gwei.toFixed(2)}gwei`; + return `${wei.toFixed(0)}wei`; +} + +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..77c02f0 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), 'Transfers']} + labelFormatter={(v) => formatBucketTooltip(v, chartWindow)} + /> + + + + + + {/* Transfer Volume */} + + + + + + + + + + formatBucketTick(v, chartWindow)} + interval="preserveStartEnd" + /> + + [formatCompact(v), `Volume (${token?.symbol || 'tokens'})`]} + labelFormatter={(v) => formatBucketTooltip(v, chartWindow)} + /> + + + + +
+ )} +
+ {/* Tabs */}
- + [formatCompact(v), 'Transactions']} + formatter={(v: unknown) => [formatCompact(v as number), 'Transactions']} /> @@ -164,7 +167,7 @@ export default function StatusPage() { contentStyle={{ background: CHART_TOOLTIP_BG, border: `1px solid ${CHART_GRID}`, borderRadius: 8 }} labelStyle={{ color: CHART_AXIS_TEXT }} itemStyle={{ color: '#f8fafc' }} - formatter={(v: number) => [formatCompact(v), 'Avg Gas Used']} + formatter={(v: unknown) => [formatCompact(v as number), 'Avg Gas Used']} labelFormatter={(v) => formatBucketTooltip(v, window)} /> [formatCompact(v), 'Transactions']} + formatter={(v: unknown) => [formatCompact(v as number), 'Transactions']} labelFormatter={(v) => formatBucketTooltip(v, window)} /> @@ -224,7 +227,7 @@ export default function StatusPage() { contentStyle={{ background: CHART_TOOLTIP_BG, border: `1px solid ${CHART_GRID}`, borderRadius: 8 }} labelStyle={{ color: CHART_AXIS_TEXT }} itemStyle={{ color: '#f8fafc' }} - formatter={(v: number) => [formatGwei(v), 'Avg Gas Price']} + formatter={(v: unknown) => [formatGwei(v as number), 'Avg Gas Price']} labelFormatter={(v) => formatBucketTooltip(v, window)} /> = 1_000) return `${(gwei / 1_000).toFixed(1)}Kgwei`; - if (gwei >= 1) return `${gwei.toFixed(2)}gwei`; - return `${wei.toFixed(0)}wei`; + 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 { diff --git a/frontend/src/pages/TokenDetailPage.tsx b/frontend/src/pages/TokenDetailPage.tsx index 77c02f0..f8a4fc1 100644 --- a/frontend/src/pages/TokenDetailPage.tsx +++ b/frontend/src/pages/TokenDetailPage.tsx @@ -196,7 +196,7 @@ export default function TokenDetailPage() { contentStyle={{ background: CHART_TOOLTIP_BG, border: `1px solid ${CHART_GRID}`, borderRadius: 8 }} labelStyle={{ color: CHART_AXIS_TEXT }} itemStyle={{ color: '#f8fafc' }} - formatter={(v: number) => [formatCompact(v), 'Transfers']} + formatter={(v: unknown) => [formatCompact(v as number), 'Transfers']} labelFormatter={(v) => formatBucketTooltip(v, chartWindow)} /> @@ -225,7 +225,7 @@ export default function TokenDetailPage() { contentStyle={{ background: CHART_TOOLTIP_BG, border: `1px solid ${CHART_GRID}`, borderRadius: 8 }} labelStyle={{ color: CHART_AXIS_TEXT }} itemStyle={{ color: '#f8fafc' }} - formatter={(v: number) => [formatCompact(v), `Volume (${token?.symbol || 'tokens'})`]} + formatter={(v: unknown) => [formatCompact(v as number), `Volume (${token?.symbol || 'tokens'})`]} labelFormatter={(v) => formatBucketTooltip(v, chartWindow)} /> Date: Wed, 25 Mar 2026 16:49:05 +0100 Subject: [PATCH 3/3] fix: isolate token chart integration data --- backend/crates/atlas-server/tests/integration/tokens.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/crates/atlas-server/tests/integration/tokens.rs b/backend/crates/atlas-server/tests/integration/tokens.rs index 32dc140..0684a1e 100644 --- a/backend/crates/atlas-server/tests/integration/tokens.rs +++ b/backend/crates/atlas-server/tests/integration/tokens.rs @@ -139,7 +139,7 @@ async fn seed_token_chart_data(pool: &sqlx::PgPool) { .bind(9001i64) .bind(0i32) .bind(HOLDER_1) - .bind(TOKEN_A) + .bind(TOKEN_B) .bind(0i64) .bind(20_000_000_000i64) .bind(60_000i64) @@ -157,7 +157,7 @@ async fn seed_token_chart_data(pool: &sqlx::PgPool) { ) .bind("0x9001000000000000000000000000000000000000000000000000000000000000") .bind(0i32) - .bind(TOKEN_A) + .bind(TOKEN_B) .bind(HOLDER_1) .bind(HOLDER_2) .bind(bigdecimal::BigDecimal::from(75_000i64)) @@ -284,7 +284,7 @@ fn get_token_chart_returns_exact_bucket_count_for_non_aligned_window() { let response = app .oneshot( Request::builder() - .uri(format!("/api/tokens/{}/chart?window=1h", TOKEN_A)) + .uri(format!("/api/tokens/{}/chart?window=1h", TOKEN_B)) .body(Body::empty()) .unwrap(), )