moving closer to something useful
This commit is contained in:
+347
-53
@@ -1,7 +1,7 @@
|
|||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
Config, LandingDto, LookupDto, MonthlyAggregate, MonthlySpeciesBreakdown, PriceTrend,
|
AvailableFiltersDto, Config, LandingDto, LookupDto, MonthlyAggregate,
|
||||||
SpeciesDto, SummaryDto, TopSpecies,
|
MonthlySpeciesBreakdown, PriceTrend, SpeciesDto, SummaryDto, TopSpecies,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
@@ -48,12 +48,189 @@ fn default_limit() -> u32 {
|
|||||||
|
|
||||||
const MAX_LIMIT: u32 = 10000;
|
const MAX_LIMIT: u32 = 10000;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
pub struct SummaryQuery {
|
pub struct SummaryQuery {
|
||||||
pub species: Option<String>,
|
pub species: Option<String>,
|
||||||
|
pub species_multi: Option<String>,
|
||||||
pub month: Option<String>,
|
pub month: Option<String>,
|
||||||
pub month_from: Option<String>,
|
pub month_from: Option<String>,
|
||||||
pub month_to: Option<String>,
|
pub month_to: Option<String>,
|
||||||
|
pub zone: Option<String>,
|
||||||
|
pub zone_multi: Option<String>,
|
||||||
|
pub gear: Option<String>,
|
||||||
|
pub gear_multi: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SummaryQuery {
|
||||||
|
fn parse_multi(value: &Option<String>) -> Vec<String> {
|
||||||
|
match value {
|
||||||
|
Some(v) if !v.is_empty() => {
|
||||||
|
v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_conditions(&self) -> (Vec<String>, Vec<Box<dyn duckdb::ToSql>>) {
|
||||||
|
let mut conditions: Vec<String> = Vec::new();
|
||||||
|
let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(ref month) = self.month {
|
||||||
|
conditions.push("month = ?".to_string());
|
||||||
|
args.push(Box::new(month.clone()));
|
||||||
|
}
|
||||||
|
if let Some(ref month_from) = self.month_from {
|
||||||
|
conditions.push("month >= ?".to_string());
|
||||||
|
args.push(Box::new(month_from.clone()));
|
||||||
|
}
|
||||||
|
if let Some(ref month_to) = self.month_to {
|
||||||
|
conditions.push("month <= ?".to_string());
|
||||||
|
args.push(Box::new(month_to.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let species_list = Self::parse_multi(&self.species_multi);
|
||||||
|
if !species_list.is_empty() {
|
||||||
|
let placeholders: Vec<String> = (0..species_list.len())
|
||||||
|
.map(|_| "?".to_string())
|
||||||
|
.collect();
|
||||||
|
conditions.push(format!("species_code IN ({})", placeholders.join(", ")));
|
||||||
|
for s in &species_list {
|
||||||
|
args.push(Box::new(s.clone()));
|
||||||
|
}
|
||||||
|
} else if let Some(ref species) = self.species {
|
||||||
|
conditions.push("species_code = ?".to_string());
|
||||||
|
args.push(Box::new(species.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let zone_list = Self::parse_multi(&self.zone_multi);
|
||||||
|
if !zone_list.is_empty() {
|
||||||
|
let placeholders: Vec<String> = (0..zone_list.len())
|
||||||
|
.map(|_| "?".to_string())
|
||||||
|
.collect();
|
||||||
|
conditions.push(format!("zone_code IN ({})", placeholders.join(", ")));
|
||||||
|
for z in &zone_list {
|
||||||
|
args.push(Box::new(z.clone()));
|
||||||
|
}
|
||||||
|
} else if let Some(ref zone) = self.zone {
|
||||||
|
conditions.push("zone_code = ?".to_string());
|
||||||
|
args.push(Box::new(zone.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let gear_list = Self::parse_multi(&self.gear_multi);
|
||||||
|
if !gear_list.is_empty() {
|
||||||
|
let placeholders: Vec<String> = (0..gear_list.len())
|
||||||
|
.map(|_| "?".to_string())
|
||||||
|
.collect();
|
||||||
|
conditions.push(format!("gear_code IN ({})", placeholders.join(", ")));
|
||||||
|
for g in &gear_list {
|
||||||
|
args.push(Box::new(g.clone()));
|
||||||
|
}
|
||||||
|
} else if let Some(ref gear) = self.gear {
|
||||||
|
conditions.push("gear_code = ?".to_string());
|
||||||
|
args.push(Box::new(gear.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
(conditions, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_where_and(&self) -> (String, Vec<Box<dyn duckdb::ToSql>>) {
|
||||||
|
let (conditions, args) = self.build_conditions();
|
||||||
|
let clause = if conditions.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" AND {}", conditions.join(" AND "))
|
||||||
|
};
|
||||||
|
(clause, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_where_prefix(&self) -> (String, Vec<Box<dyn duckdb::ToSql>>) {
|
||||||
|
let (conditions, args) = self.build_conditions();
|
||||||
|
let clause = if conditions.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" WHERE {}", conditions.join(" AND "))
|
||||||
|
};
|
||||||
|
(clause, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct AvailableFiltersQuery {
|
||||||
|
pub month_from: Option<String>,
|
||||||
|
pub month_to: Option<String>,
|
||||||
|
pub species_multi: Option<String>,
|
||||||
|
pub zone_multi: Option<String>,
|
||||||
|
pub gear_multi: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AvailableFiltersQuery {
|
||||||
|
fn parse_multi(value: &Option<String>) -> Vec<String> {
|
||||||
|
match value {
|
||||||
|
Some(v) if !v.is_empty() => {
|
||||||
|
v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_conditions_excluding(&self, exclude: &str) -> (Vec<String>, Vec<Box<dyn duckdb::ToSql>>) {
|
||||||
|
let mut conditions: Vec<String> = Vec::new();
|
||||||
|
let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(ref month_from) = self.month_from {
|
||||||
|
conditions.push("month >= ?".to_string());
|
||||||
|
args.push(Box::new(month_from.clone()));
|
||||||
|
}
|
||||||
|
if let Some(ref month_to) = self.month_to {
|
||||||
|
conditions.push("month <= ?".to_string());
|
||||||
|
args.push(Box::new(month_to.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if exclude != "species" {
|
||||||
|
let species_list = Self::parse_multi(&self.species_multi);
|
||||||
|
if !species_list.is_empty() {
|
||||||
|
let placeholders: Vec<String> = species_list.iter().map(|_| "?".to_string()).collect();
|
||||||
|
conditions.push(format!("species_code IN ({})", placeholders.join(", ")));
|
||||||
|
for s in &species_list {
|
||||||
|
args.push(Box::new(s.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if exclude != "zone" {
|
||||||
|
let zone_list = Self::parse_multi(&self.zone_multi);
|
||||||
|
if !zone_list.is_empty() {
|
||||||
|
let placeholders: Vec<String> = zone_list.iter().map(|_| "?".to_string()).collect();
|
||||||
|
conditions.push(format!("zone_code IN ({})", placeholders.join(", ")));
|
||||||
|
for z in &zone_list {
|
||||||
|
args.push(Box::new(z.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if exclude != "gear" {
|
||||||
|
let gear_list = Self::parse_multi(&self.gear_multi);
|
||||||
|
if !gear_list.is_empty() {
|
||||||
|
let placeholders: Vec<String> = gear_list.iter().map(|_| "?".to_string()).collect();
|
||||||
|
conditions.push(format!("gear_code IN ({})", placeholders.join(", ")));
|
||||||
|
for g in &gear_list {
|
||||||
|
args.push(Box::new(g.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(conditions, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_where_prefix_excluding(&self, exclude: &str) -> (String, Vec<Box<dyn duckdb::ToSql>>) {
|
||||||
|
let (conditions, args) = self.build_conditions_excluding(exclude);
|
||||||
|
let clause = if conditions.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" WHERE {}", conditions.join(" AND "))
|
||||||
|
};
|
||||||
|
(clause, args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -127,6 +304,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/species", get(get_species))
|
.route("/api/species", get(get_species))
|
||||||
.route("/api/zones", get(get_zones))
|
.route("/api/zones", get(get_zones))
|
||||||
.route("/api/gear", get(get_gear))
|
.route("/api/gear", get(get_gear))
|
||||||
|
.route("/api/available-filters", get(get_available_filters))
|
||||||
.route("/api/landings", get(get_landings))
|
.route("/api/landings", get(get_landings))
|
||||||
.route("/api/summary", get(get_summary))
|
.route("/api/summary", get(get_summary))
|
||||||
.route("/api/summary/monthly-breakdown", get(get_monthly_breakdown))
|
.route("/api/summary/monthly-breakdown", get(get_monthly_breakdown))
|
||||||
@@ -262,31 +440,7 @@ async fn get_summary(
|
|||||||
let result = tokio::task::spawn_blocking(move || -> db::Result<SummaryDto> {
|
let result = tokio::task::spawn_blocking(move || -> db::Result<SummaryDto> {
|
||||||
let conn = conn.blocking_lock();
|
let conn = conn.blocking_lock();
|
||||||
|
|
||||||
let mut conditions: Vec<String> = Vec::new();
|
let (where_clause, args) = params.build_where_and();
|
||||||
let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(ref month) = params.month {
|
|
||||||
conditions.push("month = ?".to_string());
|
|
||||||
args.push(Box::new(month.clone()));
|
|
||||||
}
|
|
||||||
if let Some(ref month_from) = params.month_from {
|
|
||||||
conditions.push("month >= ?".to_string());
|
|
||||||
args.push(Box::new(month_from.clone()));
|
|
||||||
}
|
|
||||||
if let Some(ref month_to) = params.month_to {
|
|
||||||
conditions.push("month <= ?".to_string());
|
|
||||||
args.push(Box::new(month_to.clone()));
|
|
||||||
}
|
|
||||||
if let Some(ref species) = params.species {
|
|
||||||
conditions.push("species_code = ?".to_string());
|
|
||||||
args.push(Box::new(species.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_clause = if conditions.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!(" AND {}", conditions.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
let arg_refs: Vec<&dyn duckdb::ToSql> = args.iter().map(|b| b.as_ref()).collect();
|
let arg_refs: Vec<&dyn duckdb::ToSql> = args.iter().map(|b| b.as_ref()).collect();
|
||||||
|
|
||||||
@@ -394,31 +548,7 @@ async fn get_monthly_breakdown(
|
|||||||
tokio::task::spawn_blocking(move || -> db::Result<Vec<MonthlySpeciesBreakdown>> {
|
tokio::task::spawn_blocking(move || -> db::Result<Vec<MonthlySpeciesBreakdown>> {
|
||||||
let conn = conn.blocking_lock();
|
let conn = conn.blocking_lock();
|
||||||
|
|
||||||
let mut conditions: Vec<String> = Vec::new();
|
let (where_clause, args) = params.build_where_prefix();
|
||||||
let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(ref month) = params.month {
|
|
||||||
conditions.push("month = ?".to_string());
|
|
||||||
args.push(Box::new(month.clone()));
|
|
||||||
}
|
|
||||||
if let Some(ref month_from) = params.month_from {
|
|
||||||
conditions.push("month >= ?".to_string());
|
|
||||||
args.push(Box::new(month_from.clone()));
|
|
||||||
}
|
|
||||||
if let Some(ref month_to) = params.month_to {
|
|
||||||
conditions.push("month <= ?".to_string());
|
|
||||||
args.push(Box::new(month_to.clone()));
|
|
||||||
}
|
|
||||||
if let Some(ref species) = params.species {
|
|
||||||
conditions.push("species_code = ?".to_string());
|
|
||||||
args.push(Box::new(species.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_clause = if conditions.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!(" WHERE {}", conditions.join(" AND "))
|
|
||||||
};
|
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT month, species_code, MAX(species_label) as species_label, \
|
"SELECT month, species_code, MAX(species_label) as species_label, \
|
||||||
@@ -455,6 +585,77 @@ async fn get_monthly_breakdown(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_available_filters(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<AvailableFiltersQuery>,
|
||||||
|
) -> ApiResult<Json<AvailableFiltersDto>> {
|
||||||
|
let conn = state.conn.clone();
|
||||||
|
|
||||||
|
let result = tokio::task::spawn_blocking(move || -> db::Result<AvailableFiltersDto> {
|
||||||
|
let conn = conn.blocking_lock();
|
||||||
|
|
||||||
|
let (species_clause, species_args) = params.build_where_prefix_excluding("species");
|
||||||
|
let species_arg_refs: Vec<&dyn duckdb::ToSql> = species_args.iter().map(|b| b.as_ref()).collect();
|
||||||
|
|
||||||
|
let species_sql = if species_clause.is_empty() {
|
||||||
|
"SELECT DISTINCT species_code FROM landings WHERE species_code != 'TOTAL' ORDER BY species_code"
|
||||||
|
} else {
|
||||||
|
"SELECT DISTINCT species_code FROM landings WHERE species_code != 'TOTAL'"
|
||||||
|
};
|
||||||
|
let species_sql = if species_clause.is_empty() {
|
||||||
|
species_sql.to_string()
|
||||||
|
} else {
|
||||||
|
format!("SELECT DISTINCT species_code FROM landings{species_clause} AND species_code != 'TOTAL' ORDER BY species_code")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(&species_sql)?;
|
||||||
|
let species: Vec<String> = stmt
|
||||||
|
.query_map(species_arg_refs.as_slice(), |row| row.get(0))?
|
||||||
|
.collect::<std::result::Result<Vec<_>, duckdb::Error>>()?;
|
||||||
|
|
||||||
|
let (zone_clause, zone_args) = params.build_where_prefix_excluding("zone");
|
||||||
|
let zone_arg_refs: Vec<&dyn duckdb::ToSql> = zone_args.iter().map(|b| b.as_ref()).collect();
|
||||||
|
|
||||||
|
let zone_sql = if zone_clause.is_empty() {
|
||||||
|
"SELECT DISTINCT zone_code FROM landings ORDER BY zone_code".to_string()
|
||||||
|
} else {
|
||||||
|
format!("SELECT DISTINCT zone_code FROM landings{zone_clause} ORDER BY zone_code")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(&zone_sql)?;
|
||||||
|
let zones: Vec<String> = stmt
|
||||||
|
.query_map(zone_arg_refs.as_slice(), |row| row.get(0))?
|
||||||
|
.collect::<std::result::Result<Vec<_>, duckdb::Error>>()?;
|
||||||
|
|
||||||
|
let (gear_clause, gear_args) = params.build_where_prefix_excluding("gear");
|
||||||
|
let gear_arg_refs: Vec<&dyn duckdb::ToSql> = gear_args.iter().map(|b| b.as_ref()).collect();
|
||||||
|
|
||||||
|
let gear_sql = if gear_clause.is_empty() {
|
||||||
|
"SELECT DISTINCT gear_code FROM landings ORDER BY gear_code".to_string()
|
||||||
|
} else {
|
||||||
|
format!("SELECT DISTINCT gear_code FROM landings{gear_clause} ORDER BY gear_code")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(&gear_sql)?;
|
||||||
|
let gear: Vec<String> = stmt
|
||||||
|
.query_map(gear_arg_refs.as_slice(), |row| row.get(0))?
|
||||||
|
.collect::<std::result::Result<Vec<_>, duckdb::Error>>()?;
|
||||||
|
|
||||||
|
Ok(AvailableFiltersDto {
|
||||||
|
species,
|
||||||
|
zones,
|
||||||
|
gear,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError {
|
||||||
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
message: format!("Task join error: {e}"),
|
||||||
|
})??;
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
|
async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
|
||||||
let conn = state.conn.clone();
|
let conn = state.conn.clone();
|
||||||
|
|
||||||
@@ -929,6 +1130,52 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_summary_with_multi_species_filter() {
|
||||||
|
let state = test_state();
|
||||||
|
let base = spawn_test_server(state).await;
|
||||||
|
|
||||||
|
let resp = reqwest::get(format!("{base}/api/summary?species_multi=COD,HER"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: SummaryDto = resp.json().await.unwrap();
|
||||||
|
assert_eq!(body.monthly.len(), 2);
|
||||||
|
|
||||||
|
let jan = body.monthly.iter().find(|m| m.month == "2024M01").unwrap();
|
||||||
|
assert!((jan.total_mass.unwrap() - 3000.0).abs() < f64::EPSILON);
|
||||||
|
assert!((jan.total_value.unwrap() - 8000.0).abs() < f64::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_summary_with_zone_filter() {
|
||||||
|
let state = test_state();
|
||||||
|
let base = spawn_test_server(state).await;
|
||||||
|
|
||||||
|
let resp = reqwest::get(format!("{base}/api/summary?zone=TOTAL"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: SummaryDto = resp.json().await.unwrap();
|
||||||
|
assert!(!body.monthly.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_summary_with_gear_filter() {
|
||||||
|
let state = test_state();
|
||||||
|
let base = spawn_test_server(state).await;
|
||||||
|
|
||||||
|
let resp = reqwest::get(format!("{base}/api/summary?gear=TOTAL"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: SummaryDto = resp.json().await.unwrap();
|
||||||
|
assert!(!body.monthly.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_summary_with_month_range_filter() {
|
async fn test_get_summary_with_month_range_filter() {
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
@@ -1188,4 +1435,51 @@ mod tests {
|
|||||||
assert_eq!(row.species_label, "Toskur");
|
assert_eq!(row.species_label, "Toskur");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_available_filters_no_constraints() {
|
||||||
|
let state = test_state();
|
||||||
|
let base = spawn_test_server(state).await;
|
||||||
|
|
||||||
|
let resp = reqwest::get(format!("{base}/api/available-filters"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: AvailableFiltersDto = resp.json().await.unwrap();
|
||||||
|
assert!(body.species.contains(&"COD".to_string()));
|
||||||
|
assert!(body.zones.contains(&"TOTAL".to_string()));
|
||||||
|
assert!(body.gear.contains(&"TOTAL".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_available_filters_with_species_constraint() {
|
||||||
|
let state = test_state();
|
||||||
|
let base = spawn_test_server(state).await;
|
||||||
|
|
||||||
|
let resp = reqwest::get(format!("{base}/api/available-filters?species_multi=COD"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: AvailableFiltersDto = resp.json().await.unwrap();
|
||||||
|
assert!(body.species.contains(&"COD".to_string()));
|
||||||
|
assert!(body.zones.contains(&"TOTAL".to_string()));
|
||||||
|
assert!(body.gear.contains(&"TOTAL".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_available_filters_excludes_unrelated_species() {
|
||||||
|
let state = test_state();
|
||||||
|
let base = spawn_test_server(state).await;
|
||||||
|
|
||||||
|
let resp = reqwest::get(format!("{base}/api/available-filters?zone_multi=TOTAL"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body: AvailableFiltersDto = resp.json().await.unwrap();
|
||||||
|
assert!(body.species.contains(&"COD".to_string()));
|
||||||
|
assert!(body.species.contains(&"HER".to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ pub struct MetadataResponse {
|
|||||||
pub variables: Vec<VariableMeta>,
|
pub variables: Vec<VariableMeta>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AvailableFiltersDto {
|
||||||
|
pub species: Vec<String>,
|
||||||
|
pub zones: Vec<String>,
|
||||||
|
pub gear: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct VariableMeta {
|
pub struct VariableMeta {
|
||||||
pub code: String,
|
pub code: String,
|
||||||
|
|||||||
+174
-14
@@ -351,15 +351,92 @@ async function fetchData(endpoint) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateSelect(id, items, codeKey, labelKey) {
|
function populateDropdown(containerId, items, codeKey, labelKey) {
|
||||||
const sel = document.getElementById(id);
|
const container = document.getElementById(containerId);
|
||||||
sel.innerHTML = '<option value="">All</option>';
|
if (!container) return;
|
||||||
|
|
||||||
|
const checkboxList = container.querySelector('.checkbox-dropdown-list');
|
||||||
|
checkboxList.innerHTML = '';
|
||||||
|
|
||||||
|
const selectAllLabel = document.createElement('label');
|
||||||
|
selectAllLabel.className = 'checkbox-item select-all-wrapper';
|
||||||
|
selectAllLabel.innerHTML = `
|
||||||
|
<input type="checkbox" class="select-all" data-target="${containerId}" checked>
|
||||||
|
<span>All</span>
|
||||||
|
`;
|
||||||
|
checkboxList.appendChild(selectAllLabel);
|
||||||
|
|
||||||
|
const separator = document.createElement('div');
|
||||||
|
separator.className = 'dropdown-separator';
|
||||||
|
checkboxList.appendChild(separator);
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const opt = document.createElement('option');
|
const label = document.createElement('label');
|
||||||
opt.value = item[codeKey];
|
label.className = 'checkbox-item';
|
||||||
opt.textContent = item[labelKey];
|
label.innerHTML = `
|
||||||
sel.appendChild(opt);
|
<input type="checkbox"
|
||||||
|
name="${containerId}"
|
||||||
|
value="${item[codeKey]}"
|
||||||
|
data-label="${item[labelKey]}">
|
||||||
|
<span>${item[labelKey]}</span>
|
||||||
|
`;
|
||||||
|
checkboxList.appendChild(label);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectAllCb = selectAllLabel.querySelector('.select-all');
|
||||||
|
selectAllCb.addEventListener('change', (e) => {
|
||||||
|
const cbs = checkboxList.querySelectorAll(`input[name="${containerId}"]`);
|
||||||
|
cbs.forEach(cb => {
|
||||||
|
if (!cb.disabled) {
|
||||||
|
cb.checked = e.target.checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateDropdownLabel(containerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
checkboxList.querySelectorAll(`input[name="${containerId}"]`).forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => updateDropdownLabel(containerId));
|
||||||
|
});
|
||||||
|
|
||||||
|
updateDropdownLabel(containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDropdownLabel(containerId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const cbs = container.querySelectorAll(`input[name="${containerId}"]`);
|
||||||
|
const checked = Array.from(cbs).filter(cb => cb.checked && !cb.disabled);
|
||||||
|
const labelEl = container.querySelector('.dropdown-label');
|
||||||
|
|
||||||
|
if (checked.length === 0) {
|
||||||
|
labelEl.textContent = 'None selected';
|
||||||
|
} else if (checked.length === cbs.length) {
|
||||||
|
labelEl.textContent = 'All selected';
|
||||||
|
} else {
|
||||||
|
labelEl.textContent = `${checked.length} selected`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDropdown(containerId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const list = container.querySelector('.checkbox-dropdown-list');
|
||||||
|
const isOpen = list.classList.contains('open');
|
||||||
|
|
||||||
|
document.querySelectorAll('.checkbox-dropdown-list.open').forEach(el => {
|
||||||
|
el.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
list.classList.add('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedValues(groupName) {
|
||||||
|
const checkboxes = document.querySelectorAll(`input[name="${groupName}"]:checked`);
|
||||||
|
return Array.from(checkboxes).filter(cb => !cb.disabled).map(cb => cb.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateMonthSelect(selectId, months) {
|
function populateMonthSelect(selectId, months) {
|
||||||
@@ -383,15 +460,15 @@ async function populateDropdowns() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (speciesList) {
|
if (speciesList) {
|
||||||
populateSelect('species', speciesList, 'code', 'label');
|
populateDropdown('species', speciesList, 'code', 'label');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (zonesList) {
|
if (zonesList) {
|
||||||
populateSelect('zone', zonesList, 'code', 'label');
|
populateDropdown('zone', zonesList, 'code', 'label');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gearList) {
|
if (gearList) {
|
||||||
populateSelect('gear', gearList, 'code', 'label');
|
populateDropdown('gear', gearList, 'code', 'label');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (summaryData && summaryData.monthly) {
|
if (summaryData && summaryData.monthly) {
|
||||||
@@ -405,16 +482,62 @@ async function populateDropdowns() {
|
|||||||
document.getElementById('month_to').value = allMonths[allMonths.length - 1];
|
document.getElementById('month_to').value = allMonths[allMonths.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updateAvailableFilters();
|
||||||
|
|
||||||
showLoading(false);
|
showLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateAvailableFilters() {
|
||||||
|
const species = getSelectedValues('species');
|
||||||
|
const zone = getSelectedValues('zone');
|
||||||
|
const gear = getSelectedValues('gear');
|
||||||
|
const monthFrom = document.getElementById('month_from').value;
|
||||||
|
const monthTo = document.getElementById('month_to').value;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (species.length > 0) params.append('species_multi', species.join(','));
|
||||||
|
if (zone.length > 0) params.append('zone_multi', zone.join(','));
|
||||||
|
if (gear.length > 0) params.append('gear_multi', gear.join(','));
|
||||||
|
if (monthFrom) params.append('month_from', monthFrom);
|
||||||
|
if (monthTo) params.append('month_to', monthTo);
|
||||||
|
|
||||||
|
const available = await fetchData(`/api/available-filters?${params.toString()}`);
|
||||||
|
|
||||||
|
if (available) {
|
||||||
|
disableUnavailableOptions('species', available.species);
|
||||||
|
disableUnavailableOptions('zone', available.zones);
|
||||||
|
disableUnavailableOptions('gear', available.gear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableUnavailableOptions(containerId, availableCodes) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const checkboxes = container.querySelectorAll(`input[type="checkbox"]`);
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
if (cb.classList.contains('select-all')) return;
|
||||||
|
|
||||||
|
const isAvailable = availableCodes.includes(cb.value);
|
||||||
|
cb.disabled = !isAvailable;
|
||||||
|
cb.parentElement.style.opacity = isAvailable ? '1' : '0.4';
|
||||||
|
cb.parentElement.style.cursor = isAvailable ? 'pointer' : 'not-allowed';
|
||||||
|
|
||||||
|
if (!isAvailable && cb.checked) {
|
||||||
|
cb.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateDropdownLabel(containerId);
|
||||||
|
}
|
||||||
|
|
||||||
async function updateCharts() {
|
async function updateCharts() {
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
hideEmptyState();
|
hideEmptyState();
|
||||||
|
|
||||||
const species = document.getElementById('species').value;
|
const species = getSelectedValues('species');
|
||||||
const zone = document.getElementById('zone').value;
|
const zone = getSelectedValues('zone');
|
||||||
const gear = document.getElementById('gear').value;
|
const gear = getSelectedValues('gear');
|
||||||
const monthFrom = document.getElementById('month_from').value;
|
const monthFrom = document.getElementById('month_from').value;
|
||||||
const monthTo = document.getElementById('month_to').value;
|
const monthTo = document.getElementById('month_to').value;
|
||||||
|
|
||||||
@@ -425,7 +548,9 @@ async function updateCharts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summaryParams = new URLSearchParams();
|
const summaryParams = new URLSearchParams();
|
||||||
if (species) summaryParams.append('species', species);
|
if (species.length > 0) summaryParams.append('species_multi', species.join(','));
|
||||||
|
if (zone.length > 0) summaryParams.append('zone_multi', zone.join(','));
|
||||||
|
if (gear.length > 0) summaryParams.append('gear_multi', gear.join(','));
|
||||||
if (monthFrom) summaryParams.append('month_from', monthFrom);
|
if (monthFrom) summaryParams.append('month_from', monthFrom);
|
||||||
if (monthTo) summaryParams.append('month_to', monthTo);
|
if (monthTo) summaryParams.append('month_to', monthTo);
|
||||||
|
|
||||||
@@ -526,6 +651,15 @@ function hideEmptyState() {
|
|||||||
|
|
||||||
document.getElementById('apply-filters').addEventListener('click', updateCharts);
|
document.getElementById('apply-filters').addEventListener('click', updateCharts);
|
||||||
|
|
||||||
|
// Close dropdowns when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.filter-group.multi-select')) {
|
||||||
|
document.querySelectorAll('.checkbox-dropdown-list.open').forEach(el => {
|
||||||
|
el.classList.remove('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
function currentSystemTheme() {
|
function currentSystemTheme() {
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
@@ -558,5 +692,31 @@ document.getElementById('apply-filters').addEventListener('click', updateCharts)
|
|||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initCharts();
|
initCharts();
|
||||||
|
|
||||||
|
// Setup click handlers for dropdown toggles
|
||||||
|
['species', 'zone', 'gear'].forEach(id => {
|
||||||
|
const container = document.getElementById(id);
|
||||||
|
if (container) {
|
||||||
|
const label = container.querySelector('.dropdown-label');
|
||||||
|
if (label) {
|
||||||
|
label.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleDropdown(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for changes inside the dropdown list
|
||||||
|
const list = container.querySelector('.checkbox-dropdown-list');
|
||||||
|
if (list) {
|
||||||
|
list.addEventListener('change', () => {
|
||||||
|
updateAvailableFilters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('month_from').addEventListener('change', updateAvailableFilters);
|
||||||
|
document.getElementById('month_to').addEventListener('change', updateAvailableFilters);
|
||||||
|
|
||||||
populateDropdowns().then(updateCharts);
|
populateDropdowns().then(updateCharts);
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-9
@@ -36,17 +36,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<div class="filter-group">
|
<div class="filter-group multi-select" id="species">
|
||||||
<label for="species">Species</label>
|
<label>Species</label>
|
||||||
<select id="species"><option value="">All</option></select>
|
<div class="dropdown-trigger dropdown-label">All</div>
|
||||||
|
<div class="checkbox-dropdown-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group multi-select" id="zone">
|
||||||
<label for="zone">Zone</label>
|
<label>Zone</label>
|
||||||
<select id="zone"><option value="">All</option></select>
|
<div class="dropdown-trigger dropdown-label">All</div>
|
||||||
|
<div class="checkbox-dropdown-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group multi-select" id="gear">
|
||||||
<label for="gear">Gear</label>
|
<label>Gear</label>
|
||||||
<select id="gear"><option value="">All</option></select>
|
<div class="dropdown-trigger dropdown-label">All</div>
|
||||||
|
<div class="checkbox-dropdown-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label for="month_from">From</label>
|
<label for="month_from">From</label>
|
||||||
|
|||||||
+88
-2
@@ -207,6 +207,7 @@ nav {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-group label {
|
.filter-group label {
|
||||||
@@ -231,6 +232,91 @@ nav {
|
|||||||
}
|
}
|
||||||
.filter-group select:focus { border-color: var(--accent); }
|
.filter-group select:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- Multi-Select Dropdown ---- */
|
||||||
|
.filter-group.multi-select {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger::after {
|
||||||
|
content: '▼';
|
||||||
|
font-size: 0.6rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-dropdown-list {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 2px 2px;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
display: none;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-dropdown-list.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-separator {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-all-wrapper {
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.apply-btn {
|
.apply-btn {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
padding: 0.5rem 1.2rem;
|
padding: 0.5rem 1.2rem;
|
||||||
@@ -269,8 +355,8 @@ nav {
|
|||||||
height: 450px;
|
height: 450px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-container:nth-child(2), /* Donut */
|
.chart-container:nth-child(2),
|
||||||
.chart-container:nth-child(3) { /* Bar */
|
.chart-container:nth-child(3) {
|
||||||
height: 350px;
|
height: 350px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user