This commit is contained in:
2026-08-20 22:42:55 +01:00
parent 6bedac526f
commit 8630a61abd
2 changed files with 158 additions and 121 deletions
+88 -57
View File
@@ -1,4 +1,3 @@
// src/api.rs
use crate::db; use crate::db;
use crate::types::{ use crate::types::{
Config, LandingDto, LookupDto, MonthlyAggregate, PriceTrend, SpeciesDto, SummaryDto, TopSpecies, Config, LandingDto, LookupDto, MonthlyAggregate, PriceTrend, SpeciesDto, SummaryDto, TopSpecies,
@@ -52,6 +51,9 @@ const MAX_LIMIT: u32 = 10000;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct SummaryQuery { pub struct SummaryQuery {
pub species: Option<String>, pub species: Option<String>,
pub month: Option<String>,
pub month_from: Option<String>,
pub month_to: Option<String>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -255,17 +257,38 @@ async fn get_summary(
Query(params): Query<SummaryQuery>, Query(params): Query<SummaryQuery>,
) -> ApiResult<Json<SummaryDto>> { ) -> ApiResult<Json<SummaryDto>> {
let conn = state.conn.clone(); let conn = state.conn.clone();
let species_filter = params.species.clone();
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 where_clause = if species_filter.is_some() { let mut conditions: Vec<String> = Vec::new();
" AND species_code = ?".to_string() let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
} else {
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() String::new()
} else {
format!(" AND {}", conditions.join(" AND "))
}; };
let arg_refs: Vec<&dyn duckdb::ToSql> = args.iter().map(|b| b.as_ref()).collect();
let monthly_sql = format!( let monthly_sql = format!(
"SELECT month, \ "SELECT month, \
SUM(CASE WHEN measure_code = 'MASS' THEN value END) AS total_mass, \ SUM(CASE WHEN measure_code = 'MASS' THEN value END) AS total_mass, \
@@ -274,31 +297,16 @@ async fn get_summary(
GROUP BY month ORDER BY month" GROUP BY month ORDER BY month"
); );
let monthly: Vec<MonthlyAggregate> = if let Some(ref sp) = species_filter { let mut stmt = conn.prepare(&monthly_sql)?;
let mut stmt = conn.prepare(&monthly_sql)?; let monthly: Vec<MonthlyAggregate> = stmt
let rows = stmt.query_map(params![sp], |row| { .query_map(arg_refs.as_slice(), |row| {
Ok(MonthlyAggregate { Ok(MonthlyAggregate {
month: row.get(0)?, month: row.get(0)?,
total_mass: row.get(1)?, total_mass: row.get(1)?,
total_value: row.get(2)?, total_value: row.get(2)?,
}) })
})?; })?
Ok::<Vec<MonthlyAggregate>, duckdb::Error>( .collect::<std::result::Result<Vec<_>, duckdb::Error>>()?;
rows.collect::<std::result::Result<Vec<_>, _>>()?,
)?
} else {
let mut stmt = conn.prepare(&monthly_sql)?;
let rows = stmt.query_map([], |row| {
Ok(MonthlyAggregate {
month: row.get(0)?,
total_mass: row.get(1)?,
total_value: row.get(2)?,
})
})?;
Ok::<Vec<MonthlyAggregate>, duckdb::Error>(
rows.collect::<std::result::Result<Vec<_>, _>>()?,
)?
};
let top_sql = format!( let top_sql = format!(
"SELECT species_code, \ "SELECT species_code, \
@@ -311,29 +319,17 @@ async fn get_summary(
LIMIT 10" LIMIT 10"
); );
let top_species: Vec<TopSpecies> = if let Some(ref sp) = species_filter { let mut stmt = conn.prepare(&top_sql)?;
let mut stmt = conn.prepare(&top_sql)?; let top_species: Vec<TopSpecies> = stmt
let rows = stmt.query_map(params![sp], |row| { .query_map(arg_refs.as_slice(), |row| {
Ok(TopSpecies { Ok(TopSpecies {
species_code: row.get(0)?, species_code: row.get(0)?,
species_label: row.get(1)?, species_label: row.get(1)?,
total_value: row.get(2)?, total_value: row.get(2)?,
total_mass: row.get(3)?, total_mass: row.get(3)?,
}) })
})?; })?
Ok::<Vec<TopSpecies>, duckdb::Error>(rows.collect::<std::result::Result<Vec<_>, _>>()?)? .collect::<std::result::Result<Vec<_>, duckdb::Error>>()?;
} else {
let mut stmt = conn.prepare(&top_sql)?;
let rows = stmt.query_map([], |row| {
Ok(TopSpecies {
species_code: row.get(0)?,
species_label: row.get(1)?,
total_value: row.get(2)?,
total_mass: row.get(3)?,
})
})?;
Ok::<Vec<TopSpecies>, duckdb::Error>(rows.collect::<std::result::Result<Vec<_>, _>>()?)?
};
let price_sql = format!( let price_sql = format!(
"WITH monthly_mass AS ( \ "WITH monthly_mass AS ( \
@@ -354,25 +350,23 @@ async fn get_summary(
ORDER BY m.month" ORDER BY m.month"
); );
let price_trend: Vec<PriceTrend> = if let Some(ref sp) = species_filter { let mut price_args: Vec<&dyn duckdb::ToSql> = Vec::with_capacity(args.len() * 2);
let mut stmt = conn.prepare(&price_sql)?; for a in &args {
let rows = stmt.query_map(params![sp, sp], |row| { price_args.push(a.as_ref());
}
for a in &args {
price_args.push(a.as_ref());
}
let mut stmt = conn.prepare(&price_sql)?;
let price_trend: Vec<PriceTrend> = stmt
.query_map(price_args.as_slice(), |row| {
Ok(PriceTrend { Ok(PriceTrend {
month: row.get(0)?, month: row.get(0)?,
price_per_kg: row.get(1)?, price_per_kg: row.get(1)?,
}) })
})?; })?
Ok::<Vec<PriceTrend>, duckdb::Error>(rows.collect::<std::result::Result<Vec<_>, _>>()?)? .collect::<std::result::Result<Vec<_>, duckdb::Error>>()?;
} else {
let mut stmt = conn.prepare(&price_sql)?;
let rows = stmt.query_map([], |row| {
Ok(PriceTrend {
month: row.get(0)?,
price_per_kg: row.get(1)?,
})
})?;
Ok::<Vec<PriceTrend>, duckdb::Error>(rows.collect::<std::result::Result<Vec<_>, _>>()?)?
};
Ok(SummaryDto { Ok(SummaryDto {
monthly, monthly,
@@ -862,6 +856,43 @@ mod tests {
} }
} }
#[tokio::test]
async fn test_get_summary_with_month_range_filter() {
let state = test_state();
let base = spawn_test_server(state).await;
let resp = reqwest::get(format!(
"{base}/api/summary?month_from=2024M01&month_to=2024M01"
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: SummaryDto = resp.json().await.unwrap();
assert_eq!(body.monthly.len(), 1);
assert_eq!(body.monthly[0].month, "2024M01");
}
#[tokio::test]
async fn test_get_summary_with_combined_filters() {
let state = test_state();
let base = spawn_test_server(state).await;
let resp = reqwest::get(format!(
"{base}/api/summary?month_from=2024M01&month_to=2024M01&species=COD"
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: SummaryDto = resp.json().await.unwrap();
assert_eq!(body.monthly.len(), 1);
assert_eq!(body.monthly[0].month, "2024M01");
assert!((body.monthly[0].total_mass.unwrap() - 1000.0).abs() < f64::EPSILON);
}
#[tokio::test] #[tokio::test]
async fn test_get_landings_faroese_label_preserved() { async fn test_get_landings_faroese_label_preserved() {
let state = test_state(); let state = test_state();
+70 -64
View File
@@ -196,22 +196,27 @@ async function updateCharts() {
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;
const params = new URLSearchParams(); const landingsParams = new URLSearchParams();
if (species) params.append('species', species); if (species) landingsParams.append('species', species);
if (zone) params.append('zone', zone); if (zone) landingsParams.append('zone', zone);
if (gear) params.append('gear', gear); if (gear) landingsParams.append('gear', gear);
if (monthFrom) params.append('month_from', monthFrom); if (monthFrom) landingsParams.append('month_from', monthFrom);
if (monthTo) params.append('month_to', monthTo); if (monthTo) landingsParams.append('month_to', monthTo);
params.append('limit', '10000'); landingsParams.append('limit', '10000');
const summaryParams = new URLSearchParams();
if (species) summaryParams.append('species', species);
if (monthFrom) summaryParams.append('month_from', monthFrom);
if (monthTo) summaryParams.append('month_to', monthTo);
const [landings, summary] = await Promise.all([ const [landings, summary] = await Promise.all([
fetchData(`/api/landings?${params.toString()}`), fetchData(`/api/landings?${landingsParams.toString()}`),
fetchData(`/api/summary?${species ? `species=${species}` : ''}`), fetchData(`/api/summary?${summaryParams.toString()}`),
]); ]);
showLoading(false); showLoading(false);
if (!landings || landings.length === 0) { if ((!landings || landings.length === 0) && (!summary || !summary.monthly || summary.monthly.length === 0)) {
showEmptyState(); showEmptyState();
return; return;
} }
@@ -219,64 +224,63 @@ async function updateCharts() {
hideEmptyState(); hideEmptyState();
document.getElementById('charts-grid').style.display = 'grid'; document.getElementById('charts-grid').style.display = 'grid';
// --- Line Chart --- setTimeout(() => {
const months = [...new Set(landings.map((l) => l.month))].sort(); chartLine.resize();
const massMap = {}; chartBar.resize();
const valueMap = {}; chartDonut.resize();
}, 0);
landings.forEach((l) => { // --- Line Chart (from filtered summary) ---
if (!massMap[l.month]) massMap[l.month] = 0; if (summary && summary.monthly) {
if (!valueMap[l.month]) valueMap[l.month] = 0; const months = summary.monthly.map((m) => m.month);
if (l.measure_code === 'MASS' && l.value) massMap[l.month] += l.value; const massData = summary.monthly.map((m) => m.total_mass || 0);
if (l.measure_code === 'VALUE' && l.value) valueMap[l.month] += l.value; const valueData = summary.monthly.map((m) => m.total_value || 0);
}); renderLineChart(months, massData, valueData);
} else {
renderLineChart([], [], []);
}
renderLineChart( // --- Donut Chart (from filtered summary top_species) ---
months, if (summary && summary.top_species && summary.top_species.length > 0) {
months.map((m) => massMap[m] || 0), const donutData = summary.top_species.slice(0, 10).map((s) => ({
months.map((m) => valueMap[m] || 0) name: s.species_label,
); value: s.total_value || 0,
}));
renderDonutChart(donutData);
} else {
renderDonutChart([]);
}
// --- Donut Chart (Top Species by Value) --- // --- Bar Chart (from filtered landings) ---
const speciesValueMap = {}; if (landings && landings.length > 0) {
landings.forEach((l) => { const barSpeciesSet = new Set();
if (l.measure_code === 'VALUE' && l.value) { landings.forEach((l) => {
speciesValueMap[l.species_label] = if (l.measure_code === 'VALUE') barSpeciesSet.add(l.species_label);
(speciesValueMap[l.species_label] || 0) + l.value; });
} const barSpecies = [...barSpeciesSet].slice(0, 10);
});
const donutData = Object.entries(speciesValueMap) const months = [...new Set(landings.map((l) => l.month))].sort();
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, value]) => ({ name, value }));
renderDonutChart(donutData); const barSeries = barSpecies.map((sp) => ({
name: sp,
type: 'bar',
stack: 'total',
emphasis: { focus: 'series' },
data: months.map((m) => {
const row = landings.find(
(l) =>
l.month === m &&
l.species_label === sp &&
l.measure_code === 'VALUE'
);
return row && row.value ? row.value : 0;
}),
}));
// --- Bar Chart (Stacked by Species) --- renderBarChart(months, barSeries);
const barSpeciesSet = new Set(); } else {
landings.forEach((l) => { renderBarChart([], []);
if (l.measure_code === 'VALUE') barSpeciesSet.add(l.species_label); }
});
const barSpecies = [...barSpeciesSet].slice(0, 10);
const barSeries = barSpecies.map((sp) => ({
name: sp,
type: 'bar',
stack: 'total',
emphasis: { focus: 'series' },
data: months.map((m) => {
const row = landings.find(
(l) =>
l.month === m &&
l.species_label === sp &&
l.measure_code === 'VALUE'
);
return row && row.value ? row.value : 0;
}),
}));
renderBarChart(months, barSeries);
} }
function showLoading(show) { function showLoading(show) {
@@ -331,5 +335,7 @@ document.getElementById('apply-filters').addEventListener('click', updateCharts)
})(); })();
// Init // Init
initCharts(); document.addEventListener('DOMContentLoaded', () => {
populateDropdowns().then(updateCharts); initCharts();
populateDropdowns().then(updateCharts);
});