diff --git a/src/api.rs b/src/api.rs index e6fc9a8..3b4c7c1 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,7 @@ use crate::db; use crate::types::{ - Config, LandingDto, LookupDto, MonthlyAggregate, PriceTrend, SpeciesDto, SummaryDto, TopSpecies, + Config, LandingDto, LookupDto, MonthlyAggregate, MonthlySpeciesBreakdown, PriceTrend, + SpeciesDto, SummaryDto, TopSpecies, }; use axum::{ Json, Router, @@ -10,7 +11,6 @@ use axum::{ response::{IntoResponse, Response}, routing::get, }; -use duckdb::params; use rust_embed::Embed; use serde::Deserialize; use std::sync::Arc; @@ -129,6 +129,7 @@ pub fn build_router(state: AppState) -> Router { .route("/api/gear", get(get_gear)) .route("/api/landings", get(get_landings)) .route("/api/summary", get(get_summary)) + .route("/api/summary/monthly-breakdown", get(get_monthly_breakdown)) .route("/api/export.parquet", get(export_parquet)) .fallback(static_handler) .layer(cors) @@ -308,7 +309,6 @@ async fn get_summary( })? .collect::, duckdb::Error>>()?; - // Filter out "TOTAL" species from top species let top_sql = format!( "SELECT species_code, \ COALESCE(MAX(species_label), species_code) AS species_label, \ @@ -384,6 +384,77 @@ async fn get_summary( Ok(Json(result)) } +async fn get_monthly_breakdown( + State(state): State, + Query(params): Query, +) -> ApiResult>> { + let conn = state.conn.clone(); + + let result = + tokio::task::spawn_blocking(move || -> db::Result> { + let conn = conn.blocking_lock(); + + 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!(" WHERE {}", conditions.join(" AND ")) + }; + + let sql = format!( + "SELECT month, species_code, MAX(species_label) as species_label, \ + SUM(CASE WHEN measure_code = 'VALUE' THEN value END) as total_value, \ + SUM(CASE WHEN measure_code = 'MASS' THEN value END) as total_mass \ + FROM landings{where_clause} \ + GROUP BY month, species_code \ + ORDER BY month ASC, total_value DESC NULLS LAST" + ); + + let arg_refs: Vec<&dyn duckdb::ToSql> = args.iter().map(|b| b.as_ref()).collect(); + + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(arg_refs.as_slice(), |row| { + Ok(MonthlySpeciesBreakdown { + month: row.get(0)?, + species_code: row.get(1)?, + species_label: row.get(2)?, + total_value: row.get(3)?, + total_mass: row.get(4)?, + }) + })? + .collect::, duckdb::Error>>()?; + + Ok(rows) + }) + .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) -> ApiResult { let conn = state.conn.clone(); @@ -548,6 +619,7 @@ mod tests { use crate::types::Landing; use duckdb::Connection; use std::collections::HashMap; + use std::collections::HashSet; fn test_state() -> AppState { let conn = Connection::open_in_memory().unwrap(); @@ -1053,4 +1125,67 @@ mod tests { assert_eq!(count, 6); } + + #[tokio::test] + async fn test_get_monthly_breakdown_returns_species_by_month() { + let state = test_state(); + let base = spawn_test_server(state).await; + + let resp = reqwest::get(format!("{base}/api/summary/monthly-breakdown")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body: Vec = resp.json().await.unwrap(); + assert!(!body.is_empty(), "Should have breakdown rows"); + + let months: HashSet<_> = body.iter().map(|r| r.month.as_str()).collect(); + let species: HashSet<_> = body.iter().map(|r| r.species_code.as_str()).collect(); + + assert!(months.len() >= 1); + assert!(species.len() >= 1); + + for row in &body { + assert!(!row.species_code.is_empty()); + assert!(!row.species_label.is_empty()); + assert!(row.total_value.is_some() || row.total_mass.is_some()); + } + } + + #[tokio::test] + async fn test_get_monthly_breakdown_filtered_by_month_range() { + let state = test_state(); + let base = spawn_test_server(state).await; + + let resp = reqwest::get(format!( + "{base}/api/summary/monthly-breakdown?month_from=2024M01&month_to=2024M01" + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body: Vec = resp.json().await.unwrap(); + + for row in &body { + assert_eq!(row.month, "2024M01"); + } + } + + #[tokio::test] + async fn test_get_monthly_breakdown_filtered_by_species() { + let state = test_state(); + let base = spawn_test_server(state).await; + + let resp = reqwest::get(format!("{base}/api/summary/monthly-breakdown?species=COD")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let body: Vec = resp.json().await.unwrap(); + + for row in &body { + assert_eq!(row.species_code, "COD"); + assert_eq!(row.species_label, "Toskur"); + } + } } diff --git a/src/types.rs b/src/types.rs index 61faed0..f2eb27b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -241,6 +241,15 @@ pub struct SummaryDto { pub price_trend: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonthlySpeciesBreakdown { + pub month: String, + pub species_code: String, + pub species_label: String, + pub total_value: Option, + pub total_mass: Option, +} + #[derive(Debug, thiserror::Error)] pub enum IngestError { #[error("HTTP request failed: {0}")] diff --git a/static/app.js b/static/app.js index 4505084..187cae4 100644 --- a/static/app.js +++ b/static/app.js @@ -12,6 +12,20 @@ function formatMonthDisplay(monthCode) { return `${monthNames[monthIdx]} ${year}`; } +function formatDKK(value) { + return new Intl.NumberFormat('da-DK', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value) + ' DKK'; +} + +function formatDKKShort(value) { + if (value >= 1e9) return (value / 1e9).toFixed(1).replace('.', ',') + ' mia.'; + if (value >= 1e6) return (value / 1e6).toFixed(1).replace('.', ',') + ' mio.'; + if (value >= 1e3) return (value / 1e3).toFixed(0) + ' tus.'; + return value.toString(); +} + function getThemeColors() { const theme = document.documentElement.getAttribute('data-theme'); const isDark = theme === 'tokyo'; @@ -39,7 +53,7 @@ function initCharts() { renderLineChart([], [], []); renderBarChart([], []); - renderDonutChart([]); + renderTopSpeciesChart([]); window.addEventListener('resize', () => { chartLine.resize(); @@ -52,7 +66,6 @@ function renderLineChart(months, massData, valueData) { const c = getThemeColors(); const formattedMonths = months.map(m => formatMonthDisplay(m)); - // Show every nth month label to prevent crowding const step = months.length > 24 ? 6 : (months.length > 12 ? 3 : 1); const xLabels = formattedMonths.map((label, idx) => @@ -137,49 +150,85 @@ function renderLineChart(months, massData, valueData) { }); } -function renderDonutChart(data) { +function renderTopSpeciesChart(data) { const c = getThemeColors(); - // Limit to top 8 for clarity - const limitedData = data.slice(0, 8); - if (data.length > 8) { - const otherSum = data.slice(8).reduce((sum, d) => sum + (d.value || 0), 0); - if (otherSum > 0) { - limitedData.push({ name: 'Other', value: otherSum }); - } + const top5 = data.slice(0, 5); + + if (top5.length === 0) { + chartDonut.setOption({ series: [] }); + return; } + const sorted = [...top5].sort((a, b) => a.value - b.value); + const speciesLabels = sorted.map(d => d.name); + const values = sorted.map(d => d.value); + chartDonut.setOption({ backgroundColor: c.bgTransparent, textStyle: { fontFamily: 'JetBrains Mono', color: c.textColor }, tooltip: { - trigger: 'item', - formatter: '{b}: {c} DKK' + trigger: 'axis', + axisPointer: { type: 'shadow' }, + formatter: (params) => { + const p = params[0]; + return `${p.name}
${formatDKK(p.value)}`; + } }, - legend: { - orient: 'horizontal', - bottom: 0, - textStyle: { color: c.textColor }, - type: 'scroll', - padding: [20, 0, 0, 0] + grid: { + left: '3%', + right: '8%', + bottom: '8%', + top: '5%', + containLabel: true, + }, + xAxis: { + type: 'value', + splitLine: { lineStyle: { color: c.gridColor, type: 'dashed' } }, + axisLabel: { + color: c.textColor, + formatter: (val) => formatDKKShort(val), + fontSize: 9, + hideOverlap: true, + }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + yAxis: { + type: 'category', + data: speciesLabels, + axisLine: { lineStyle: { color: c.gridColor } }, + axisTick: { show: false }, + axisLabel: { + color: c.textColor, + fontSize: 11, + fontWeight: 'bold', + width: 120, + overflow: 'truncate', + }, }, - grid: { bottom: '30%' }, series: [ { - type: 'pie', - radius: ['50%', '70%'], - avoidLabelOverlap: true, + type: 'bar', + data: values, + barMaxWidth: 28, itemStyle: { - borderRadius: 3, - borderColor: c.isDark ? '#07070d' : '#f4f1ec', - borderWidth: 1, + borderRadius: [0, 4, 4, 0], + }, + label: { + show: true, + position: 'right', + formatter: (params) => formatDKKShort(params.value), + color: c.textColor, + fontSize: 11, + distance: 6, }, - label: { show: false }, emphasis: { - label: { show: false }, + itemStyle: { + shadowBlur: 10, + shadowColor: 'rgba(0,0,0,0.3)', + }, }, - data: limitedData, - color: c.palette, }, ], }); @@ -188,14 +237,20 @@ function renderDonutChart(data) { function renderBarChart(months, seriesData) { const c = getThemeColors(); + if (months.length === 0 || seriesData.length === 0) { + chartBar.setOption({ series: [] }); + return; + } + const formattedMonths = months.map(m => formatMonthDisplay(m)); - const step = months.length > 24 ? 6 : (months.length > 12 ? 3 : 1); + const step = months.length > 36 ? 12 : (months.length > 18 ? 6 : (months.length > 12 ? 3 : 1)); + const xLabels = formattedMonths.map((label, idx) => idx % step === 0 ? label : '' ); - // Limit to top 5 species for readability - const limitedSeries = seriesData.slice(0, 5); + const chartWidth = chartBar.getWidth(); + const barMaxWidth = Math.max(15, Math.floor((chartWidth * 0.7) / (months.length * 1.5))); chartBar.setOption({ backgroundColor: c.bgTransparent, @@ -203,14 +258,32 @@ function renderBarChart(months, seriesData) { tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, + formatter: (params) => { + const month = params[0]?.axisValue || ''; + let html = `${month}
`; + params.forEach(p => { + const val = p.value || 0; + html += `${p.marker} ${p.seriesName}: ${formatDKK(val)}
`; + }); + return html; + }, + extraCssText: 'background: rgba(0,0,0,0.8); padding: 8px;' }, legend: { textStyle: { color: c.textColor }, top: 0, type: 'scroll', - maxItems: 5 + maxItems: 5, + padding: [0, 0, 0, 0], + itemGap: 10 + }, + grid: { + left: '3%', + right: '4%', + bottom: '15%', + containLabel: true, + top: '20%' }, - grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true }, xAxis: { type: 'category', data: xLabels, @@ -218,23 +291,28 @@ function renderBarChart(months, seriesData) { axisLabel: { color: c.textColor, interval: 0, - fontSize: 10, - rotate: 0 + fontSize: 9, + rotate: 0, + margin: 12 }, + axisTick: { show: false } }, yAxis: { type: 'value', - splitLine: { lineStyle: { color: c.gridColor } }, + splitLine: { lineStyle: { color: c.gridColor, type: 'dashed' } }, axisLabel: { color: c.textColor, - formatter: (val) => val >= 1e6 ? (val / 1e6).toFixed(1) + 'M' : val.toLocaleString() + formatter: (val) => formatDKKShort(val) }, + nameTextStyle: { padding: [0, 0, 0, 10] } }, - series: limitedSeries.map(s => ({ + series: seriesData.map((s, idx) => ({ ...s, - barMaxWidth: 30 + barMaxWidth: barMaxWidth, + itemStyle: { + color: c.palette[idx % c.palette.length] + } })), - color: c.palette, }); } @@ -298,7 +376,6 @@ async function populateDropdowns() { populateMonthSelect('month_from', allMonths); populateMonthSelect('month_to', allMonths); - // Default to last 6 months const fromIdx = Math.max(0, allMonths.length - MONTHS_TO_SHOW_DEFAULT); document.getElementById('month_from').value = allMonths[fromIdx]; document.getElementById('month_to').value = allMonths[allMonths.length - 1]; @@ -317,38 +394,84 @@ async function updateCharts() { const monthFrom = document.getElementById('month_from').value; const monthTo = document.getElementById('month_to').value; - // Validation: prevent from > to if (monthFrom && monthTo && monthFrom > monthTo) { alert('From date cannot be greater than To date.'); showLoading(false); return; } - 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?${landingsParams.toString()}`), + const [summary, monthlyBreakdown] = await Promise.all([ fetchData(`/api/summary?${summaryParams.toString()}`), + fetchData(`/api/summary/monthly-breakdown?${summaryParams.toString()}`), ]); - showLoading(false); - - if ((!landings || landings.length === 0) && (!summary || !summary.monthly || summary.monthly.length === 0)) { + if (!summary || !summary.monthly || summary.monthly.length === 0) { + showLoading(false); showEmptyState(); return; } + const months = summary.monthly.map(m => m.month).sort(); + const topSpecies = summary.top_species ? summary.top_species.slice(0, 5) : []; + + if (monthlyBreakdown && monthlyBreakdown.length > 0 && topSpecies.length > 0) { + const speciesMonthlyMap = {}; + + monthlyBreakdown.forEach(row => { + if (!speciesMonthlyMap[row.species_code]) { + speciesMonthlyMap[row.species_code] = {}; + } + speciesMonthlyMap[row.species_code][row.month] = row.total_value || 0; + }); + + const topSpeciesCodes = topSpecies.map(s => s.species_code); + + const filteredBreakdown = monthlyBreakdown.filter(row => + topSpeciesCodes.includes(row.species_code) + ); + + const barSeries = topSpecies.map(sp => { + const data = months.map(month => { + const matching = filteredBreakdown.find( + row => row.month === month && row.species_code === sp.species_code + ); + return matching ? matching.total_value || 0 : 0; + }); + + return { + name: sp.species_label, + type: 'bar', + stack: 'total', + emphasis: { focus: 'series' }, + data: data, + }; + }); + + renderBarChart(months, barSeries); + } else { + renderBarChart([], []); + } + + const massData = summary.monthly.map((m) => m.total_mass || 0); + const valueData = summary.monthly.map((m) => m.total_value || 0); + renderLineChart(months, massData, valueData); + + if (summary.top_species && summary.top_species.length > 0) { + const topData = summary.top_species.map((s) => ({ + name: s.species_label, + value: s.total_value || 0, + })); + renderTopSpeciesChart(topData); + } else { + renderTopSpeciesChart([]); + } + + showLoading(false); hideEmptyState(); document.getElementById('charts-grid').style.display = 'grid'; @@ -357,53 +480,6 @@ async function updateCharts() { chartBar.resize(); chartDonut.resize(); }, 0); - - // --- Line Chart --- - 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([], [], []); - } - - // --- Donut Chart --- - if (summary && summary.top_species && summary.top_species.length > 0) { - const donutData = summary.top_species.map((s) => ({ - name: s.species_label, - value: s.total_value || 0, - })); - renderDonutChart(donutData); - } else { - renderDonutChart([]); - } - - // --- Bar Chart --- - if (summary && summary.top_species && summary.top_species.length > 0 && landings && landings.length > 0) { - const months = [...new Set(landings.map((l) => l.month))].sort(); - const topSpecies = summary.top_species; - - const barSeries = topSpecies.map((sp) => ({ - name: sp.species_label, - type: 'bar', - stack: 'total', - emphasis: { focus: 'series' }, - data: months.map((m) => { - const row = landings.find( - (l) => - l.month === m && - l.species_code === sp.species_code && - l.measure_code === 'VALUE' - ); - return row && row.value ? row.value : 0; - }), - })); - - renderBarChart(months, barSeries); - } else { - renderBarChart([], []); - } } function showLoading(show) { @@ -426,7 +502,6 @@ function hideEmptyState() { document.getElementById('apply-filters').addEventListener('click', updateCharts); -// Theme Logic (() => { function currentSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches @@ -457,7 +532,6 @@ document.getElementById('apply-filters').addEventListener('click', updateCharts) }); })(); -// Init document.addEventListener('DOMContentLoaded', () => { initCharts(); populateDropdowns().then(updateCharts);