diff --git a/src/api.rs b/src/api.rs index 291fc38..ac0f8fa 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,4 +1,3 @@ -// src/api.rs use crate::db; use crate::types::{ Config, LandingDto, LookupDto, MonthlyAggregate, PriceTrend, SpeciesDto, SummaryDto, TopSpecies, @@ -52,6 +51,9 @@ const MAX_LIMIT: u32 = 10000; #[derive(Debug, Clone, Deserialize)] pub struct SummaryQuery { pub species: Option, + pub month: Option, + pub month_from: Option, + pub month_to: Option, } #[derive(Debug)] @@ -255,17 +257,38 @@ async fn get_summary( Query(params): Query, ) -> ApiResult> { let conn = state.conn.clone(); - let species_filter = params.species.clone(); let result = tokio::task::spawn_blocking(move || -> db::Result { let conn = conn.blocking_lock(); - let where_clause = if species_filter.is_some() { - " AND species_code = ?".to_string() - } else { + let mut conditions: Vec = Vec::new(); + let mut args: Vec> = 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 monthly_sql = format!( "SELECT month, \ 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" ); - let monthly: Vec = if let Some(ref sp) = species_filter { - let mut stmt = conn.prepare(&monthly_sql)?; - let rows = stmt.query_map(params![sp], |row| { + let mut stmt = conn.prepare(&monthly_sql)?; + let monthly: Vec = stmt + .query_map(arg_refs.as_slice(), |row| { Ok(MonthlyAggregate { month: row.get(0)?, total_mass: row.get(1)?, total_value: row.get(2)?, }) - })?; - Ok::, duckdb::Error>( - rows.collect::, _>>()?, - )? - } 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::, duckdb::Error>( - rows.collect::, _>>()?, - )? - }; + })? + .collect::, duckdb::Error>>()?; let top_sql = format!( "SELECT species_code, \ @@ -311,29 +319,17 @@ async fn get_summary( LIMIT 10" ); - let top_species: Vec = if let Some(ref sp) = species_filter { - let mut stmt = conn.prepare(&top_sql)?; - let rows = stmt.query_map(params![sp], |row| { + let mut stmt = conn.prepare(&top_sql)?; + let top_species: Vec = stmt + .query_map(arg_refs.as_slice(), |row| { Ok(TopSpecies { species_code: row.get(0)?, species_label: row.get(1)?, total_value: row.get(2)?, total_mass: row.get(3)?, }) - })?; - Ok::, duckdb::Error>(rows.collect::, _>>()?)? - } 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::, duckdb::Error>(rows.collect::, _>>()?)? - }; + })? + .collect::, duckdb::Error>>()?; let price_sql = format!( "WITH monthly_mass AS ( \ @@ -354,25 +350,23 @@ async fn get_summary( ORDER BY m.month" ); - let price_trend: Vec = if let Some(ref sp) = species_filter { - let mut stmt = conn.prepare(&price_sql)?; - let rows = stmt.query_map(params![sp, sp], |row| { + let mut price_args: Vec<&dyn duckdb::ToSql> = Vec::with_capacity(args.len() * 2); + for a in &args { + 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 = stmt + .query_map(price_args.as_slice(), |row| { Ok(PriceTrend { month: row.get(0)?, price_per_kg: row.get(1)?, }) - })?; - Ok::, duckdb::Error>(rows.collect::, _>>()?)? - } 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::, duckdb::Error>(rows.collect::, _>>()?)? - }; + })? + .collect::, duckdb::Error>>()?; Ok(SummaryDto { 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] async fn test_get_landings_faroese_label_preserved() { let state = test_state(); diff --git a/static/app.js b/static/app.js index 9339193..c3be181 100644 --- a/static/app.js +++ b/static/app.js @@ -196,22 +196,27 @@ async function updateCharts() { const monthFrom = document.getElementById('month_from').value; const monthTo = document.getElementById('month_to').value; - const params = new URLSearchParams(); - if (species) params.append('species', species); - if (zone) params.append('zone', zone); - if (gear) params.append('gear', gear); - if (monthFrom) params.append('month_from', monthFrom); - if (monthTo) params.append('month_to', monthTo); - params.append('limit', '10000'); + const landingsParams = new URLSearchParams(); + if (species) landingsParams.append('species', species); + if (zone) landingsParams.append('zone', zone); + if (gear) landingsParams.append('gear', gear); + if (monthFrom) landingsParams.append('month_from', monthFrom); + if (monthTo) landingsParams.append('month_to', monthTo); + 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([ - fetchData(`/api/landings?${params.toString()}`), - fetchData(`/api/summary?${species ? `species=${species}` : ''}`), + fetchData(`/api/landings?${landingsParams.toString()}`), + fetchData(`/api/summary?${summaryParams.toString()}`), ]); showLoading(false); - if (!landings || landings.length === 0) { + if ((!landings || landings.length === 0) && (!summary || !summary.monthly || summary.monthly.length === 0)) { showEmptyState(); return; } @@ -219,64 +224,63 @@ async function updateCharts() { hideEmptyState(); document.getElementById('charts-grid').style.display = 'grid'; - // --- Line Chart --- - const months = [...new Set(landings.map((l) => l.month))].sort(); - const massMap = {}; - const valueMap = {}; + setTimeout(() => { + chartLine.resize(); + chartBar.resize(); + chartDonut.resize(); + }, 0); - landings.forEach((l) => { - if (!massMap[l.month]) massMap[l.month] = 0; - if (!valueMap[l.month]) valueMap[l.month] = 0; - if (l.measure_code === 'MASS' && l.value) massMap[l.month] += l.value; - if (l.measure_code === 'VALUE' && l.value) valueMap[l.month] += l.value; - }); + // --- Line Chart (from filtered summary) --- + if (summary && summary.monthly) { + const months = summary.monthly.map((m) => m.month); + const massData = summary.monthly.map((m) => m.total_mass || 0); + const valueData = summary.monthly.map((m) => m.total_value || 0); + renderLineChart(months, massData, valueData); + } else { + renderLineChart([], [], []); + } - renderLineChart( - months, - months.map((m) => massMap[m] || 0), - months.map((m) => valueMap[m] || 0) - ); + // --- Donut Chart (from filtered summary top_species) --- + if (summary && summary.top_species && summary.top_species.length > 0) { + const donutData = summary.top_species.slice(0, 10).map((s) => ({ + name: s.species_label, + value: s.total_value || 0, + })); + renderDonutChart(donutData); + } else { + renderDonutChart([]); + } - // --- Donut Chart (Top Species by Value) --- - const speciesValueMap = {}; - landings.forEach((l) => { - if (l.measure_code === 'VALUE' && l.value) { - speciesValueMap[l.species_label] = - (speciesValueMap[l.species_label] || 0) + l.value; - } - }); + // --- Bar Chart (from filtered landings) --- + if (landings && landings.length > 0) { + const barSpeciesSet = new Set(); + landings.forEach((l) => { + if (l.measure_code === 'VALUE') barSpeciesSet.add(l.species_label); + }); + const barSpecies = [...barSpeciesSet].slice(0, 10); - const donutData = Object.entries(speciesValueMap) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([name, value]) => ({ name, value })); + const months = [...new Set(landings.map((l) => l.month))].sort(); - 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) --- - const barSpeciesSet = new Set(); - landings.forEach((l) => { - 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); + renderBarChart(months, barSeries); + } else { + renderBarChart([], []); + } } function showLoading(show) { @@ -331,5 +335,7 @@ document.getElementById('apply-filters').addEventListener('click', updateCharts) })(); // Init -initCharts(); -populateDropdowns().then(updateCharts); +document.addEventListener('DOMContentLoaded', () => { + initCharts(); + populateDropdowns().then(updateCharts); +});