phase 4 ready for review

This commit is contained in:
2026-08-17 16:57:38 +01:00
parent 5e80204e34
commit 1a6840d16c
5 changed files with 933 additions and 4 deletions
+80 -3
View File
@@ -31,7 +31,9 @@ struct StaticAssets;
#[derive(Debug, Clone, Deserialize)]
pub struct LandingsQuery {
pub month: Option<String>,
pub month: Option<String>, // Kept for backward compatibility or single-month quick lookups
pub month_from: Option<String>,
pub month_to: Option<String>,
pub species: Option<String>,
pub gear: Option<String>,
pub zone: Option<String>,
@@ -88,6 +90,8 @@ pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(healthz))
.route("/api/species", get(get_species))
.route("/api/zones", get(get_zones))
.route("/api/gear", get(get_gear))
.route("/api/landings", get(get_landings))
.route("/api/summary", get(get_summary))
.route("/api/export.parquet", get(export_parquet))
@@ -139,11 +143,25 @@ async fn get_landings(
let mut args: Vec<Box<dyn duckdb::ToSql>> = Vec::new();
let mut idx = 1;
// Single month filter (legacy)
if let Some(ref month) = params.month {
sql.push_str(&format!(" AND month = ${idx}"));
args.push(Box::new(month.clone()));
idx += 1;
}
// Range filter (new)
if let Some(ref month_from) = params.month_from {
sql.push_str(&format!(" AND month >= ${idx}"));
args.push(Box::new(month_from.clone()));
idx += 1;
}
if let Some(ref month_to) = params.month_to {
sql.push_str(&format!(" AND month <= ${idx}"));
args.push(Box::new(month_to.clone()));
idx += 1;
}
if let Some(ref species) = params.species {
sql.push_str(&format!(" AND species_code = ${idx}"));
args.push(Box::new(species.clone()));
@@ -403,11 +421,26 @@ async fn export_parquet(State(state): State<AppState>) -> ApiResult<Response> {
async fn static_handler(uri: axum::http::Uri) -> Response {
let path = uri.path().trim_start_matches('/');
let asset = StaticAssets::get(path).or_else(|| StaticAssets::get("index.html"));
// Strip "static/" prefix if present
let asset_path = if let Some(stripped) = path.strip_prefix("static/") {
stripped
} else {
path
};
// If empty path (e.g., visiting "/"), serve index.html directly
let asset_path = if asset_path.is_empty() {
"index.html"
} else {
asset_path
};
let asset = StaticAssets::get(asset_path).or_else(|| StaticAssets::get("index.html"));
match asset {
Some(file) => {
let mime = mime_guess::from_path(path)
let mime = mime_guess::from_path(asset_path)
.first_or_octet_stream()
.as_ref()
.to_string();
@@ -425,6 +458,50 @@ async fn static_handler(uri: axum::http::Uri) -> Response {
}
}
async fn get_zones(State(state): State<AppState>) -> ApiResult<Json<Vec<SpeciesDto>>> {
let conn = state.conn.clone();
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<SpeciesDto>> {
let conn = conn.blocking_lock();
let mut stmt = conn.prepare("SELECT code, label FROM zone ORDER BY code")?;
let rows = stmt.query_map([], |row| {
Ok(SpeciesDto {
code: row.get(0)?,
label: row.get(1)?,
})
})?;
Ok(rows.collect::<std::result::Result<Vec<_>, duckdb::Error>>()?)
})
.await
.map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("Task join error: {e}"),
})??;
Ok(Json(rows))
}
async fn get_gear(State(state): State<AppState>) -> ApiResult<Json<Vec<SpeciesDto>>> {
let conn = state.conn.clone();
let rows = tokio::task::spawn_blocking(move || -> db::Result<Vec<SpeciesDto>> {
let conn = conn.blocking_lock();
let mut stmt = conn.prepare("SELECT code, label FROM gear ORDER BY code")?;
let rows = stmt.query_map([], |row| {
Ok(SpeciesDto {
code: row.get(0)?,
label: row.get(1)?,
})
})?;
Ok(rows.collect::<std::result::Result<Vec<_>, duckdb::Error>>()?)
})
.await
.map_err(|e| ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: format!("Task join error: {e}"),
})??;
Ok(Json(rows))
}
#[cfg(test)]
mod tests {
use super::*;