feat(stats): add pre-computed stats cache with background recomputation
- Add CachedStats domain types (roast summary, consumption, brewing) - Extend StatsRepository with summary queries and cache get/store - Add StatsInvalidator + background task with 2s debounce - Add invalidate() calls to all entity create/update/delete handlers - Add POST /api/v1/stats/recompute endpoint for manual refresh - Add stats_cache migration and include in database reset - Add GeoStats::from_counts() and Serialize/Deserialize derives - Add 6 integration tests for stats API - Document Stats Cache pattern in CLAUDE.md
This commit is contained in:
parent
0b321068aa
commit
97c00f2e33
26 changed files with 735 additions and 3 deletions
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -226,6 +226,26 @@ Use `QueryBuilder` for dynamic queries. For UPDATE, use `push_update_field!` (se
|
||||||
|
|
||||||
**`format_weight`** — displays grams up to 999g, switches to kg for 1000g+. Whole-gram values omit the decimal ("250g"), fractional values show one decimal ("15.5g"). Kilogram values always show one decimal ("1.5kg"). All weight values in the database are stored in grams.
|
**`format_weight`** — displays grams up to 999g, switches to kg for 1000g+. Whole-gram values omit the decimal ("250g"), fractional values show one decimal ("15.5g"). Kilogram values always show one decimal ("1.5kg"). All weight values in the database are stored in grams.
|
||||||
|
|
||||||
|
### Stats Cache
|
||||||
|
|
||||||
|
Statistics are pre-computed and stored as a single JSON row in `stats_cache`. A background `tokio::spawn` task (`stats_recomputation_task` in `application/services/stats.rs`) recomputes all stats when signalled, with 2-second debouncing to collapse rapid mutations (e.g., bootstrap script).
|
||||||
|
|
||||||
|
**`StatsInvalidator`** — lives on `AppState`, provides `invalidate()` which sends a non-blocking signal to the background task. Every entity create/update/delete handler must call `state.stats_invalidator.invalidate()` after a successful mutation. The `define_delete_handler!` macro does this automatically.
|
||||||
|
|
||||||
|
**Stats page** (`application/routes/app/stats.rs`) — reads from cache via `stats_repo.get_cached()`, falling back to live computation on cache miss (first startup before background task completes).
|
||||||
|
|
||||||
|
**Force recompute** — `POST /api/v1/stats/recompute` (authenticated) bypasses the debounce and recomputes synchronously. Available on the admin page.
|
||||||
|
|
||||||
|
**Database reset** clears `stats_cache` along with all other coffee data (see `infrastructure/backup.rs`).
|
||||||
|
|
||||||
|
**Adding new stats:**
|
||||||
|
1. Add the field to the relevant domain struct (`RoastSummaryStats`, `ConsumptionStats`, `BrewingSummaryStats`, or `GeoStats`)
|
||||||
|
2. Add the query in `SqlStatsRepository`
|
||||||
|
3. The `CachedStats` struct inherits the change via serde
|
||||||
|
4. The stats page template can reference the new field — it will be populated from the cache
|
||||||
|
|
||||||
|
**Gotcha:** If a new entity type is added that affects stats, its create/update/delete handlers must call `state.stats_invalidator.invalidate()`.
|
||||||
|
|
||||||
### Error Handling & Logging
|
### Error Handling & Logging
|
||||||
|
|
||||||
**Error types**: `RepositoryError` (domain), `AppError` (HTTP with status code mapping), `anyhow::Result` (CLI).
|
**Error types**: `RepositoryError` (domain), `AppError` (HTTP with status code mapping), `anyhow::Result` (CLI).
|
||||||
|
|
|
||||||
5
migrations/0006_stats_cache.sql
Normal file
5
migrations/0006_stats_cache.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
CREATE TABLE stats_cache (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
computed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
@ -70,6 +70,7 @@ pub(crate) async fn create_bag(
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
info!(bag_id = %bag.id, "bag created");
|
info!(bag_id = %bag.id, "bag created");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
render_bag_list_fragment(state, request, search, true)
|
render_bag_list_fragment(state, request, search, true)
|
||||||
|
|
@ -153,6 +154,7 @@ pub(crate) async fn update_bag(
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(%id, closed = ?update.closed, "bag updated");
|
info!(%id, closed = ?update.closed, "bag updated");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
render_bag_list_fragment(state, request, search, true)
|
render_bag_list_fragment(state, request, search, true)
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,7 @@ pub(crate) async fn create_brew(
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
info!(brew_id = %enriched.brew.id, "brew created");
|
info!(brew_id = %enriched.brew.id, "brew created");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
// If the request came from a page that has #brew-list, return the updated fragment.
|
// If the request came from a page that has #brew-list, return the updated fragment.
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ pub(crate) async fn create_cafe(
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
info!(cafe_id = %cafe.id, name = %cafe.name, "cafe created");
|
info!(cafe_id = %cafe.id, name = %cafe.name, "cafe created");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
render_cafe_list_fragment(state, request, search, true)
|
render_cafe_list_fragment(state, request, search, true)
|
||||||
|
|
@ -113,6 +114,7 @@ pub(crate) async fn update_cafe(
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
info!(%id, "cafe updated");
|
info!(%id, "cafe updated");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
Ok(Json(cafe))
|
Ok(Json(cafe))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ pub(crate) async fn create_cup(
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
info!(cup_id = %cup.id, "cup created");
|
info!(cup_id = %cup.id, "cup created");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
render_cup_list_fragment(state, request, search, true)
|
render_cup_list_fragment(state, request, search, true)
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ pub(crate) async fn create_gear(
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
info!(gear_id = %gear.id, make = %gear.make, model = %gear.model, "gear created");
|
info!(gear_id = %gear.id, make = %gear.make, model = %gear.model, "gear created");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
render_gear_list_fragment(state, request, search, true)
|
render_gear_list_fragment(state, request, search, true)
|
||||||
|
|
@ -121,6 +122,7 @@ pub(crate) async fn update_gear(
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
info!(%id, "gear updated");
|
info!(%id, "gear updated");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
render_gear_list_fragment(state, request, search, true)
|
render_gear_list_fragment(state, request, search, true)
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ macro_rules! define_delete_handler {
|
||||||
.map_err(crate::application::errors::AppError::from)?;
|
.map_err(crate::application::errors::AppError::from)?;
|
||||||
|
|
||||||
tracing::info!(%id, "entity deleted");
|
tracing::info!(%id, "entity deleted");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
if crate::application::routes::support::is_datastar_request(&headers) {
|
if crate::application::routes::support::is_datastar_request(&headers) {
|
||||||
$render_fragment(state, request, search, true)
|
$render_fragment(state, request, search, true)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ mod macros;
|
||||||
pub(crate) mod roasters;
|
pub(crate) mod roasters;
|
||||||
pub(crate) mod roasts;
|
pub(crate) mod roasts;
|
||||||
pub(crate) mod scan;
|
pub(crate) mod scan;
|
||||||
|
pub(crate) mod stats;
|
||||||
pub(crate) mod tokens;
|
pub(crate) mod tokens;
|
||||||
pub(crate) mod webauthn;
|
pub(crate) mod webauthn;
|
||||||
|
|
||||||
|
|
@ -91,6 +92,7 @@ pub(super) fn router() -> axum::Router<AppState> {
|
||||||
post(backup::restore_backup).layer(DefaultBodyLimit::max(50 * 1024 * 1024)),
|
post(backup::restore_backup).layer(DefaultBodyLimit::max(50 * 1024 * 1024)),
|
||||||
)
|
)
|
||||||
.route("/backup/reset", post(backup::reset_database))
|
.route("/backup/reset", post(backup::reset_database))
|
||||||
|
.route("/stats/recompute", post(stats::recompute_stats))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn webauthn_router() -> axum::Router<AppState> {
|
pub(super) fn webauthn_router() -> axum::Router<AppState> {
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ pub(crate) async fn create_roaster(
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
info!(roaster_id = %roaster.id, name = %roaster.name, "roaster created");
|
info!(roaster_id = %roaster.id, name = %roaster.name, "roaster created");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
render_roaster_list_fragment(state, request, search, true)
|
render_roaster_list_fragment(state, request, search, true)
|
||||||
|
|
@ -113,6 +114,7 @@ pub(crate) async fn update_roaster(
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
info!(%id, "roaster updated");
|
info!(%id, "roaster updated");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
Ok(Json(roaster))
|
Ok(Json(roaster))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ pub(crate) async fn create_roast(
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
info!(roast_id = %roast.id, name = %roast.name, "roast created");
|
info!(roast_id = %roast.id, name = %roast.name, "roast created");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
render_roast_list_fragment(state, request, search, true)
|
render_roast_list_fragment(state, request, search, true)
|
||||||
|
|
@ -175,6 +176,7 @@ pub(crate) async fn update_roast(
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
info!(%id, "roast updated");
|
info!(%id, "roast updated");
|
||||||
|
state.stats_invalidator.invalidate();
|
||||||
|
|
||||||
let enriched = state
|
let enriched = state
|
||||||
.roast_repo
|
.roast_repo
|
||||||
|
|
|
||||||
27
src/application/routes/api/stats.rs
Normal file
27
src/application/routes/api/stats.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
|
||||||
|
use crate::application::auth::AuthenticatedUser;
|
||||||
|
use crate::application::errors::{ApiError, AppError};
|
||||||
|
use crate::application::services::stats::compute_all_stats;
|
||||||
|
use crate::application::state::AppState;
|
||||||
|
use crate::domain::stats::CachedStats;
|
||||||
|
|
||||||
|
/// Force an immediate stats recomputation, bypassing the debounce timer.
|
||||||
|
#[tracing::instrument(skip(state, _auth_user))]
|
||||||
|
pub(crate) async fn recompute_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth_user: AuthenticatedUser,
|
||||||
|
) -> Result<Json<CachedStats>, ApiError> {
|
||||||
|
let cached = compute_all_stats(&*state.stats_repo)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.stats_repo
|
||||||
|
.store_cached(&cached)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
Ok(Json(cached))
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ use tracing::info;
|
||||||
use webauthn_rs::prelude::*;
|
use webauthn_rs::prelude::*;
|
||||||
|
|
||||||
use crate::application::routes::app_router;
|
use crate::application::routes::app_router;
|
||||||
|
use crate::application::services::StatsInvalidator;
|
||||||
|
use crate::application::services::stats::stats_recomputation_task;
|
||||||
use crate::application::state::{AppState, AppStateConfig};
|
use crate::application::state::{AppState, AppStateConfig};
|
||||||
use crate::domain::registration_tokens::NewRegistrationToken;
|
use crate::domain::registration_tokens::NewRegistrationToken;
|
||||||
use crate::domain::repositories::{RegistrationTokenRepository, UserRepository};
|
use crate::domain::repositories::{RegistrationTokenRepository, UserRepository};
|
||||||
|
|
@ -39,6 +41,9 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
.context("failed to build WebAuthn instance")?,
|
.context("failed to build WebAuthn instance")?,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let (stats_tx, stats_rx) = tokio::sync::mpsc::channel::<()>(32);
|
||||||
|
let stats_invalidator = StatsInvalidator::new(stats_tx);
|
||||||
|
|
||||||
let state = AppState::from_database(
|
let state = AppState::from_database(
|
||||||
&database,
|
&database,
|
||||||
AppStateConfig {
|
AppStateConfig {
|
||||||
|
|
@ -48,9 +53,21 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
|
||||||
openrouter_url: crate::infrastructure::ai::OPENROUTER_URL.to_string(),
|
openrouter_url: crate::infrastructure::ai::OPENROUTER_URL.to_string(),
|
||||||
openrouter_api_key: config.openrouter_api_key,
|
openrouter_api_key: config.openrouter_api_key,
|
||||||
openrouter_model: config.openrouter_model,
|
openrouter_model: config.openrouter_model,
|
||||||
|
stats_invalidator: stats_invalidator.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Spawn background stats recomputation task
|
||||||
|
let stats_repo = Arc::clone(&state.stats_repo);
|
||||||
|
tokio::spawn(stats_recomputation_task(
|
||||||
|
stats_rx,
|
||||||
|
stats_repo,
|
||||||
|
std::time::Duration::from_secs(2),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Seed the stats cache on startup
|
||||||
|
stats_invalidator.invalidate();
|
||||||
|
|
||||||
// Clean up expired sessions on startup
|
// Clean up expired sessions on startup
|
||||||
if let Err(err) = state.session_repo.delete_expired().await {
|
if let Err(err) = state.session_repo.delete_expired().await {
|
||||||
tracing::warn!(error = %err, "failed to clean up expired sessions on startup");
|
tracing::warn!(error = %err, "failed to clean up expired sessions on startup");
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ mod bags;
|
||||||
mod brews;
|
mod brews;
|
||||||
mod cups;
|
mod cups;
|
||||||
mod roasts;
|
mod roasts;
|
||||||
|
pub mod stats;
|
||||||
|
|
||||||
pub use bags::BagService;
|
pub use bags::BagService;
|
||||||
pub use brews::BrewService;
|
pub use brews::BrewService;
|
||||||
pub use cups::CupService;
|
pub use cups::CupService;
|
||||||
pub use roasts::RoastService;
|
pub use roasts::RoastService;
|
||||||
|
pub use stats::StatsInvalidator;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
|
||||||
94
src/application/services/stats.rs
Normal file
94
src/application/services/stats.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::domain::country_stats::GeoStats;
|
||||||
|
use crate::domain::repositories::StatsRepository;
|
||||||
|
use crate::domain::stats::CachedStats;
|
||||||
|
|
||||||
|
/// Sends invalidation signals to the background stats recomputer.
|
||||||
|
/// Non-blocking and fire-and-forget — safe to call from any handler.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StatsInvalidator {
|
||||||
|
tx: mpsc::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsInvalidator {
|
||||||
|
pub fn new(tx: mpsc::Sender<()>) -> Self {
|
||||||
|
Self { tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal that stats need recomputation.
|
||||||
|
pub fn invalidate(&self) {
|
||||||
|
let _ = self.tx.try_send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listens for invalidation signals, debounces, and recomputes all stats.
|
||||||
|
/// Runs as a long-lived background task — spawn with `tokio::spawn`.
|
||||||
|
pub async fn stats_recomputation_task(
|
||||||
|
mut rx: mpsc::Receiver<()>,
|
||||||
|
stats_repo: Arc<dyn StatsRepository>,
|
||||||
|
debounce: Duration,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
if rx.recv().await.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: wait then drain any accumulated signals
|
||||||
|
tokio::time::sleep(debounce).await;
|
||||||
|
while rx.try_recv().is_ok() {}
|
||||||
|
|
||||||
|
match compute_all_stats(&*stats_repo).await {
|
||||||
|
Ok(cached) => {
|
||||||
|
if let Err(err) = stats_repo.store_cached(&cached).await {
|
||||||
|
error!(error = %err, "failed to store stats cache");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => error!(error = %err, "stats recomputation failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs all stats queries and assembles a complete `CachedStats` snapshot.
|
||||||
|
/// Logs the total computation time on success.
|
||||||
|
pub async fn compute_all_stats(
|
||||||
|
repo: &dyn StatsRepository,
|
||||||
|
) -> Result<CachedStats, crate::domain::RepositoryError> {
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let (
|
||||||
|
roast_summary,
|
||||||
|
consumption,
|
||||||
|
brewing_summary,
|
||||||
|
roaster_counts,
|
||||||
|
roast_counts,
|
||||||
|
cup_counts,
|
||||||
|
cafe_counts,
|
||||||
|
) = tokio::join!(
|
||||||
|
repo.roast_summary(),
|
||||||
|
repo.consumption_summary(),
|
||||||
|
repo.brewing_summary(),
|
||||||
|
repo.roaster_country_counts(),
|
||||||
|
repo.roast_origin_counts(),
|
||||||
|
repo.cup_country_counts(),
|
||||||
|
repo.cafe_country_counts(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let cached = CachedStats {
|
||||||
|
roast_summary: roast_summary?,
|
||||||
|
consumption: consumption?,
|
||||||
|
brewing_summary: brewing_summary?,
|
||||||
|
geo_roasters: GeoStats::from_counts(roaster_counts?),
|
||||||
|
geo_roasts: GeoStats::from_counts(roast_counts?),
|
||||||
|
geo_cups: GeoStats::from_counts(cup_counts?),
|
||||||
|
geo_cafes: GeoStats::from_counts(cafe_counts?),
|
||||||
|
computed_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(duration_ms = start.elapsed().as_millis(), "stats computed");
|
||||||
|
Ok(cached)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ use webauthn_rs::prelude::*;
|
||||||
|
|
||||||
use crate::application::services::{
|
use crate::application::services::{
|
||||||
BagService, BrewService, CafeService, CupService, GearService, RoastService, RoasterService,
|
BagService, BrewService, CafeService, CupService, GearService, RoastService, RoasterService,
|
||||||
|
StatsInvalidator,
|
||||||
};
|
};
|
||||||
use crate::domain::repositories::{
|
use crate::domain::repositories::{
|
||||||
AiUsageRepository, BagRepository, BrewRepository, CafeRepository, CupRepository,
|
AiUsageRepository, BagRepository, BrewRepository, CafeRepository, CupRepository,
|
||||||
|
|
@ -40,6 +41,7 @@ pub struct AppStateConfig {
|
||||||
pub openrouter_url: String,
|
pub openrouter_url: String,
|
||||||
pub openrouter_api_key: String,
|
pub openrouter_api_key: String,
|
||||||
pub openrouter_model: String,
|
pub openrouter_model: String,
|
||||||
|
pub stats_invalidator: StatsInvalidator,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -75,6 +77,7 @@ pub struct AppState {
|
||||||
pub gear_service: GearService,
|
pub gear_service: GearService,
|
||||||
pub cafe_service: CafeService,
|
pub cafe_service: CafeService,
|
||||||
pub cup_service: CupService,
|
pub cup_service: CupService,
|
||||||
|
pub stats_invalidator: StatsInvalidator,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
|
@ -161,6 +164,7 @@ impl AppState {
|
||||||
gear_service,
|
gear_service,
|
||||||
cafe_service,
|
cafe_service,
|
||||||
cup_service,
|
cup_service,
|
||||||
|
stats_invalidator: config.stats_invalidator,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A single country's count for geographic statistics.
|
/// A single country's count for geographic statistics.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CountryStat {
|
pub struct CountryStat {
|
||||||
pub country_name: String,
|
pub country_name: String,
|
||||||
pub iso_code: String,
|
pub iso_code: String,
|
||||||
|
|
@ -11,13 +13,45 @@ pub struct CountryStat {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aggregated geographic stats for one entity type.
|
/// Aggregated geographic stats for one entity type.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct GeoStats {
|
pub struct GeoStats {
|
||||||
pub entries: Vec<CountryStat>,
|
pub entries: Vec<CountryStat>,
|
||||||
pub total_countries: usize,
|
pub total_countries: usize,
|
||||||
pub max_count: u64,
|
pub max_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl GeoStats {
|
||||||
|
/// Build from raw (`country_name`, count) pairs, resolving ISO codes and flag emoji.
|
||||||
|
pub fn from_counts(raw: Vec<(String, u64)>) -> Self {
|
||||||
|
let entries: Vec<CountryStat> = raw
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, count)| {
|
||||||
|
let iso = country_to_iso(&name).unwrap_or("").to_string();
|
||||||
|
let flag = if iso.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
iso_to_flag_emoji(&iso)
|
||||||
|
};
|
||||||
|
CountryStat {
|
||||||
|
country_name: name,
|
||||||
|
iso_code: iso,
|
||||||
|
flag_emoji: flag,
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total_countries = entries.len();
|
||||||
|
let max_count = entries.iter().map(|e| e.count).max().unwrap_or(0);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
entries,
|
||||||
|
total_countries,
|
||||||
|
max_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static COUNTRY_MAP: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
|
static COUNTRY_MAP: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
|
||||||
HashMap::from([
|
HashMap::from([
|
||||||
// Coffee-producing countries
|
// Coffee-producing countries
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ pub mod repositories;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
pub mod roasts;
|
pub mod roasts;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
pub mod stats;
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
pub mod tokens;
|
pub mod tokens;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
|
||||||
|
|
@ -275,4 +275,20 @@ pub trait StatsRepository: Send + Sync {
|
||||||
async fn roast_origin_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
|
async fn roast_origin_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
|
||||||
async fn cup_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
|
async fn cup_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
|
||||||
async fn cafe_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
|
async fn cafe_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError>;
|
||||||
|
async fn roast_summary(
|
||||||
|
&self,
|
||||||
|
) -> Result<crate::domain::stats::RoastSummaryStats, RepositoryError>;
|
||||||
|
async fn consumption_summary(
|
||||||
|
&self,
|
||||||
|
) -> Result<crate::domain::stats::ConsumptionStats, RepositoryError>;
|
||||||
|
async fn brewing_summary(
|
||||||
|
&self,
|
||||||
|
) -> Result<crate::domain::stats::BrewingSummaryStats, RepositoryError>;
|
||||||
|
async fn get_cached(
|
||||||
|
&self,
|
||||||
|
) -> Result<Option<crate::domain::stats::CachedStats>, RepositoryError>;
|
||||||
|
async fn store_cached(
|
||||||
|
&self,
|
||||||
|
stats: &crate::domain::stats::CachedStats,
|
||||||
|
) -> Result<(), RepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
src/domain/stats.rs
Normal file
50
src/domain/stats.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::country_stats::GeoStats;
|
||||||
|
|
||||||
|
/// Summary statistics for roasts: origins, flavours, and roasters.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RoastSummaryStats {
|
||||||
|
pub unique_origins: u64,
|
||||||
|
pub top_origin: Option<String>,
|
||||||
|
pub top_roaster: Option<String>,
|
||||||
|
pub origin_counts: Vec<(String, u64)>,
|
||||||
|
pub max_origin_count: u64,
|
||||||
|
pub flavour_counts: Vec<(String, u64)>,
|
||||||
|
pub max_flavour_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coffee consumption totals.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConsumptionStats {
|
||||||
|
pub last_30_days_grams: f64,
|
||||||
|
pub all_time_grams: f64,
|
||||||
|
pub brews_last_30_days: u64,
|
||||||
|
pub brews_all_time: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brewing equipment statistics.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BrewingSummaryStats {
|
||||||
|
pub brewer_counts: Vec<(String, u64)>,
|
||||||
|
pub grinder_counts: Vec<(String, u64)>,
|
||||||
|
#[serde(alias = "grinder_kg_counts")]
|
||||||
|
pub grinder_weight_counts: Vec<(String, f64)>,
|
||||||
|
#[serde(alias = "max_grinder_kg")]
|
||||||
|
pub max_grinder_weight: f64,
|
||||||
|
pub brew_time_distribution: Vec<(String, u64)>,
|
||||||
|
pub max_brew_time_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-computed snapshot of all statistics, stored as JSON in the cache table.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CachedStats {
|
||||||
|
pub roast_summary: RoastSummaryStats,
|
||||||
|
pub consumption: ConsumptionStats,
|
||||||
|
pub brewing_summary: BrewingSummaryStats,
|
||||||
|
pub geo_roasters: GeoStats,
|
||||||
|
pub geo_roasts: GeoStats,
|
||||||
|
pub geo_cups: GeoStats,
|
||||||
|
pub geo_cafes: GeoStats,
|
||||||
|
pub computed_at: String,
|
||||||
|
}
|
||||||
|
|
@ -138,6 +138,7 @@ impl BackupService {
|
||||||
"gear",
|
"gear",
|
||||||
"cafes",
|
"cafes",
|
||||||
"roasters",
|
"roasters",
|
||||||
|
"stats_cache",
|
||||||
];
|
];
|
||||||
|
|
||||||
for table in tables {
|
for table in tables {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sqlx::query_as;
|
use sqlx::{Row, query_as, query_scalar};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use crate::domain::RepositoryError;
|
use crate::domain::RepositoryError;
|
||||||
use crate::domain::repositories::StatsRepository;
|
use crate::domain::repositories::StatsRepository;
|
||||||
|
use crate::domain::stats::{BrewingSummaryStats, CachedStats, ConsumptionStats, RoastSummaryStats};
|
||||||
use crate::infrastructure::database::DatabasePool;
|
use crate::infrastructure::database::DatabasePool;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -28,6 +30,20 @@ impl CountryCount {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct NameCount {
|
||||||
|
name: String,
|
||||||
|
count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct NameWeight {
|
||||||
|
name: String,
|
||||||
|
total_grams: f64,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl StatsRepository for SqlStatsRepository {
|
impl StatsRepository for SqlStatsRepository {
|
||||||
async fn roaster_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError> {
|
async fn roaster_country_counts(&self) -> Result<Vec<(String, u64)>, RepositoryError> {
|
||||||
|
|
@ -87,4 +103,232 @@ impl StatsRepository for SqlStatsRepository {
|
||||||
|
|
||||||
Ok(rows.into_iter().map(CountryCount::into_tuple).collect())
|
Ok(rows.into_iter().map(CountryCount::into_tuple).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn roast_summary(&self) -> Result<RoastSummaryStats, RepositoryError> {
|
||||||
|
let unique_origins: i64 = query_scalar(
|
||||||
|
r"SELECT COUNT(DISTINCT origin) FROM roasts
|
||||||
|
WHERE origin IS NOT NULL AND origin != ''",
|
||||||
|
)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let top_origin = query_as::<_, NameCount>(
|
||||||
|
r"SELECT origin as name, COUNT(*) as count FROM roasts
|
||||||
|
WHERE origin IS NOT NULL AND origin != ''
|
||||||
|
GROUP BY origin ORDER BY count DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
|
||||||
|
.map(|r| r.name);
|
||||||
|
|
||||||
|
let top_roaster = query_as::<_, NameCount>(
|
||||||
|
r"SELECT ro.name as name, COUNT(*) as count
|
||||||
|
FROM roasts r JOIN roasters ro ON r.roaster_id = ro.id
|
||||||
|
GROUP BY ro.id ORDER BY count DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
|
||||||
|
.map(|r| r.name);
|
||||||
|
|
||||||
|
let all_origin_counts = self.roast_origin_counts().await?;
|
||||||
|
let origin_counts: Vec<(String, u64)> = all_origin_counts.into_iter().take(5).collect();
|
||||||
|
let max_origin_count = origin_counts.iter().map(|(_, c)| *c).max().unwrap_or(0);
|
||||||
|
|
||||||
|
let all_flavour_counts: Vec<(String, u64)> = query_as::<_, NameCount>(
|
||||||
|
r"WITH RECURSIVE raw(val) AS (
|
||||||
|
SELECT TRIM(j.value)
|
||||||
|
FROM roasts, json_each(roasts.tasting_notes) j
|
||||||
|
WHERE roasts.tasting_notes IS NOT NULL AND roasts.tasting_notes != '[]'
|
||||||
|
),
|
||||||
|
split(note, rest) AS (
|
||||||
|
SELECT TRIM(SUBSTR(val, 1, INSTR(val || ',', ',') - 1)),
|
||||||
|
TRIM(SUBSTR(val, INSTR(val || ',', ',') + 1))
|
||||||
|
FROM raw
|
||||||
|
UNION ALL
|
||||||
|
SELECT TRIM(SUBSTR(rest, 1, INSTR(rest || ',', ',') - 1)),
|
||||||
|
TRIM(SUBSTR(rest, INSTR(rest || ',', ',') + 1))
|
||||||
|
FROM split WHERE rest != ''
|
||||||
|
)
|
||||||
|
SELECT note as name, COUNT(*) as count
|
||||||
|
FROM split WHERE note != ''
|
||||||
|
GROUP BY LOWER(note) ORDER BY count DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.name, r.count as u64))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let flavour_counts: Vec<(String, u64)> = all_flavour_counts.into_iter().take(5).collect();
|
||||||
|
let max_flavour_count = flavour_counts.iter().map(|(_, c)| *c).max().unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(RoastSummaryStats {
|
||||||
|
unique_origins: unique_origins as u64,
|
||||||
|
top_origin,
|
||||||
|
top_roaster,
|
||||||
|
origin_counts,
|
||||||
|
max_origin_count,
|
||||||
|
flavour_counts,
|
||||||
|
max_flavour_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consumption_summary(&self) -> Result<ConsumptionStats, RepositoryError> {
|
||||||
|
let last_30_days_grams: f64 = query_scalar(
|
||||||
|
r"SELECT COALESCE(SUM(coffee_weight), 0.0) FROM brews
|
||||||
|
WHERE created_at >= datetime('now', '-30 days')",
|
||||||
|
)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let all_time_grams: f64 =
|
||||||
|
query_scalar(r"SELECT COALESCE(SUM(coffee_weight), 0.0) FROM brews")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let brews_last_30_days: i64 = query_scalar(
|
||||||
|
r"SELECT COUNT(*) FROM brews
|
||||||
|
WHERE created_at >= datetime('now', '-30 days')",
|
||||||
|
)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
let brews_all_time: i64 = query_scalar(r"SELECT COUNT(*) FROM brews")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
Ok(ConsumptionStats {
|
||||||
|
last_30_days_grams,
|
||||||
|
all_time_grams,
|
||||||
|
brews_last_30_days: brews_last_30_days as u64,
|
||||||
|
brews_all_time: brews_all_time as u64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn brewing_summary(&self) -> Result<BrewingSummaryStats, RepositoryError> {
|
||||||
|
let brewer_counts: Vec<(String, u64)> = query_as::<_, NameCount>(
|
||||||
|
r"SELECT g.make || ' ' || g.model as name, COUNT(*) as count
|
||||||
|
FROM brews b JOIN gear g ON b.brewer_id = g.id
|
||||||
|
GROUP BY g.id ORDER BY count DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.name, r.count as u64))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let grinder_counts: Vec<(String, u64)> = query_as::<_, NameCount>(
|
||||||
|
r"SELECT g.make || ' ' || g.model as name, COUNT(*) as count
|
||||||
|
FROM brews b JOIN gear g ON b.grinder_id = g.id
|
||||||
|
GROUP BY g.id ORDER BY count DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.name, r.count as u64))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let grinder_weight_counts: Vec<(String, f64)> = query_as::<_, NameWeight>(
|
||||||
|
r"SELECT g.make || ' ' || g.model as name,
|
||||||
|
ROUND(COALESCE(SUM(b.coffee_weight), 0), 1) as total_grams
|
||||||
|
FROM brews b JOIN gear g ON b.grinder_id = g.id
|
||||||
|
GROUP BY g.id ORDER BY total_grams DESC
|
||||||
|
LIMIT 5",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.name, r.total_grams))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let max_grinder_weight = grinder_weight_counts
|
||||||
|
.iter()
|
||||||
|
.map(|(_, g)| *g)
|
||||||
|
.fold(0.0_f64, f64::max);
|
||||||
|
|
||||||
|
let brew_time_distribution: Vec<(String, u64)> = query_as::<_, NameCount>(
|
||||||
|
r"SELECT
|
||||||
|
CASE
|
||||||
|
WHEN brew_time < 60 THEN '< 1:00'
|
||||||
|
WHEN brew_time < 90 THEN '1:00–1:30'
|
||||||
|
WHEN brew_time < 120 THEN '1:30–2:00'
|
||||||
|
WHEN brew_time < 150 THEN '2:00–2:30'
|
||||||
|
WHEN brew_time < 180 THEN '2:30–3:00'
|
||||||
|
ELSE '3:00+'
|
||||||
|
END as name,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM brews
|
||||||
|
WHERE brew_time IS NOT NULL
|
||||||
|
GROUP BY name
|
||||||
|
ORDER BY MIN(brew_time)",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.name, r.count as u64))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let max_brew_time_count = brew_time_distribution
|
||||||
|
.iter()
|
||||||
|
.map(|(_, c)| *c)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(BrewingSummaryStats {
|
||||||
|
brewer_counts,
|
||||||
|
grinder_counts,
|
||||||
|
grinder_weight_counts,
|
||||||
|
max_grinder_weight,
|
||||||
|
brew_time_distribution,
|
||||||
|
max_brew_time_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_cached(&self) -> Result<Option<CachedStats>, RepositoryError> {
|
||||||
|
let row = sqlx::query(r"SELECT data FROM stats_cache WHERE id = 1")
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Some(row) => {
|
||||||
|
let json: String = row
|
||||||
|
.try_get("data")
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
let stats: CachedStats = serde_json::from_str(&json)
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
Ok(Some(stats))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_cached(&self, stats: &CachedStats) -> Result<(), RepositoryError> {
|
||||||
|
let json = serde_json::to_string(stats)
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r"INSERT OR REPLACE INTO stats_cache (id, data, computed_at)
|
||||||
|
VALUES (1, ?, datetime('now'))",
|
||||||
|
)
|
||||||
|
.bind(&json)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||||
|
|
||||||
|
info!("stats cache updated");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ fn ensure_server_started() -> Result<(String, String), String> {
|
||||||
.await
|
.await
|
||||||
.expect("Failed to connect to test database");
|
.expect("Failed to connect to test database");
|
||||||
|
|
||||||
|
let (stats_tx, _stats_rx) = tokio::sync::mpsc::channel(1);
|
||||||
let state = AppState::from_database(
|
let state = AppState::from_database(
|
||||||
&database,
|
&database,
|
||||||
AppStateConfig {
|
AppStateConfig {
|
||||||
|
|
@ -95,6 +96,9 @@ fn ensure_server_started() -> Result<(String, String), String> {
|
||||||
openrouter_url: brewlog::infrastructure::ai::OPENROUTER_URL.to_string(),
|
openrouter_url: brewlog::infrastructure::ai::OPENROUTER_URL.to_string(),
|
||||||
openrouter_api_key: String::new(),
|
openrouter_api_key: String::new(),
|
||||||
openrouter_model: "openrouter/free".to_string(),
|
openrouter_model: "openrouter/free".to_string(),
|
||||||
|
stats_invalidator: brewlog::application::services::StatsInvalidator::new(
|
||||||
|
stats_tx,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ pub async fn spawn_app() -> TestApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_state_config() -> AppStateConfig {
|
fn test_state_config() -> AppStateConfig {
|
||||||
|
let (tx, _rx) = tokio::sync::mpsc::channel(1);
|
||||||
AppStateConfig {
|
AppStateConfig {
|
||||||
webauthn: test_webauthn(),
|
webauthn: test_webauthn(),
|
||||||
foursquare_url: brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(),
|
foursquare_url: brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(),
|
||||||
|
|
@ -80,6 +81,7 @@ fn test_state_config() -> AppStateConfig {
|
||||||
openrouter_url: brewlog::infrastructure::ai::OPENROUTER_URL.to_string(),
|
openrouter_url: brewlog::infrastructure::ai::OPENROUTER_URL.to_string(),
|
||||||
openrouter_api_key: String::new(),
|
openrouter_api_key: String::new(),
|
||||||
openrouter_model: "openrouter/free".to_string(),
|
openrouter_model: "openrouter/free".to_string(),
|
||||||
|
stats_invalidator: brewlog::application::services::StatsInvalidator::new(tx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,5 +14,6 @@ pub mod pages;
|
||||||
pub mod roasters_api;
|
pub mod roasters_api;
|
||||||
pub mod roasts_api;
|
pub mod roasts_api;
|
||||||
pub mod scan_api;
|
pub mod scan_api;
|
||||||
|
pub mod stats_api;
|
||||||
pub mod test_macros;
|
pub mod test_macros;
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
|
|
|
||||||
195
tests/server/stats_api.rs
Normal file
195
tests/server/stats_api.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
use crate::helpers::{
|
||||||
|
assert_datastar_headers_with_mode, assert_full_page, assert_html_fragment, create_default_cafe,
|
||||||
|
create_default_roast, create_default_roaster, spawn_app, spawn_app_with_auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stats_page_returns_200_with_empty_database() {
|
||||||
|
let app = spawn_app().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(app.page_url("/stats"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let body = response.text().await.expect("Failed to read body");
|
||||||
|
assert_full_page(&body);
|
||||||
|
assert!(body.contains("Stats"), "Page should contain title");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stats_page_returns_200_with_data() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
|
||||||
|
let roaster = create_default_roaster(&app).await;
|
||||||
|
let _roast = create_default_roast(&app, roaster.id).await;
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(app.page_url("/stats"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let body = response.text().await.expect("Failed to read body");
|
||||||
|
assert_full_page(&body);
|
||||||
|
assert!(
|
||||||
|
body.contains("Ethiopia"),
|
||||||
|
"Stats page should contain roast origin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stats_page_datastar_tab_switch_returns_fragment() {
|
||||||
|
let app = spawn_app().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(app.page_url("/stats?type=roasts"))
|
||||||
|
.header("datastar-request", "true")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
assert_datastar_headers_with_mode(&response, "#stats-content", "inner");
|
||||||
|
|
||||||
|
let body = response.text().await.expect("Failed to read body");
|
||||||
|
assert_html_fragment(&body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recompute_stats_requires_authentication() {
|
||||||
|
let app = spawn_app().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(app.api_url("/stats/recompute"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recompute_stats_returns_json() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(app.api_url("/stats/recompute"))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
|
||||||
|
assert!(
|
||||||
|
body.get("computed_at").is_some(),
|
||||||
|
"Response should contain computed_at"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.get("roast_summary").is_some(),
|
||||||
|
"Response should contain roast_summary"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.get("consumption").is_some(),
|
||||||
|
"Response should contain consumption"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.get("brewing_summary").is_some(),
|
||||||
|
"Response should contain brewing_summary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recompute_stats_reflects_created_data() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
|
||||||
|
let roaster = create_default_roaster(&app).await;
|
||||||
|
let _roast = create_default_roast(&app, roaster.id).await;
|
||||||
|
let _cafe = create_default_cafe(&app).await;
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(app.api_url("/stats/recompute"))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
|
||||||
|
|
||||||
|
// Roaster is in UK
|
||||||
|
let geo_roasters = &body["geo_roasters"]["entries"];
|
||||||
|
assert!(
|
||||||
|
geo_roasters
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|e| e["country_name"] == "UK"),
|
||||||
|
"geo_roasters should contain UK: {geo_roasters}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Roast origin is Ethiopia
|
||||||
|
let geo_roasts = &body["geo_roasts"]["entries"];
|
||||||
|
assert!(
|
||||||
|
geo_roasts
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|e| e["country_name"] == "Ethiopia"),
|
||||||
|
"geo_roasts should contain Ethiopia: {geo_roasts}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cafe is in US
|
||||||
|
let geo_cafes = &body["geo_cafes"]["entries"];
|
||||||
|
assert!(
|
||||||
|
geo_cafes
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|e| e["country_name"] == "US"),
|
||||||
|
"geo_cafes should contain US: {geo_cafes}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stats_page_loads_after_recompute() {
|
||||||
|
let app = spawn_app_with_auth().await;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// Populate the cache
|
||||||
|
let recompute_response = client
|
||||||
|
.post(app.api_url("/stats/recompute"))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to recompute");
|
||||||
|
assert_eq!(recompute_response.status(), 200);
|
||||||
|
|
||||||
|
// Load the page — should read from cache
|
||||||
|
let response = client
|
||||||
|
.get(app.page_url("/stats"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let body = response.text().await.expect("Failed to read body");
|
||||||
|
assert_full_page(&body);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue