diff --git a/herbapi-api/src/db/cultivars.rs b/herbapi-api/src/db/cultivars.rs index 25a02a6..666cd25 100644 --- a/herbapi-api/src/db/cultivars.rs +++ b/herbapi-api/src/db/cultivars.rs @@ -127,7 +127,10 @@ pub async fn create(pool: &PgPool, req: &CreateCultivar) -> Result { min_light_hours_day, optimal_light_hours_day, greenhouse_min_temp_c, indoor_season_extension_weeks, ventilation_requirement, heating_required, indoor_sowing_months, direct_sowing_months, transplanting_months, glasshouse_months, - harvesting_months, succession_planting_days, planting_notes, + harvesting_months, + indoor_sowing_weeks, direct_sowing_weeks, transplanting_weeks, glasshouse_weeks, + harvesting_weeks, + succession_planting_days, planting_notes, propagation_methods, cutting_season, rootstock_species_id, years_to_first_harvest, productive_lifespan_years, expected_yield_kg_per_m2, yield_unit, expected_yield_value, @@ -137,7 +140,7 @@ pub async fn create(pool: &PgPool, req: &CreateCultivar) -> Result { VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, $21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31::frost_tolerance,$32,$33,$34,$35,$36,$37, $38,$39,$40,$41,$42,$43,$44,$45,$46,$47,$48,$49,$50,$51,$52,$53,$54,$55,$56, - $57,$58,$59,$60,$61,$62,$63) + $57,$58,$59,$60,$61,$62,$63,$64,$65,$66,$67,$68) RETURNING *" ) .bind(id).bind(&slug).bind(req.species_id).bind(&req.name) @@ -155,7 +158,11 @@ pub async fn create(pool: &PgPool, req: &CreateCultivar) -> Result { .bind(req.indoor_season_extension_weeks).bind(&req.ventilation_requirement).bind(req.heating_required) .bind(&req.indoor_sowing_months).bind(&req.direct_sowing_months) .bind(&req.transplanting_months).bind(&req.glasshouse_months) - .bind(&req.harvesting_months).bind(req.succession_planting_days).bind(&req.planting_notes) + .bind(&req.harvesting_months) + .bind(&req.indoor_sowing_weeks).bind(&req.direct_sowing_weeks) + .bind(&req.transplanting_weeks).bind(&req.glasshouse_weeks) + .bind(&req.harvesting_weeks) + .bind(req.succession_planting_days).bind(&req.planting_notes) .bind(&req.propagation_methods).bind(&req.cutting_season).bind(req.rootstock_species_id) .bind(req.years_to_first_harvest).bind(req.productive_lifespan_years) .bind(req.expected_yield_kg_per_m2).bind(&req.yield_unit).bind(req.expected_yield_value) @@ -192,13 +199,16 @@ pub async fn update(pool: &PgPool, id: Uuid, req: &CreateCultivar) -> Result Result>, pub glasshouse_months: Option>, pub harvesting_months: Option>, + pub indoor_sowing_weeks: Option>, + pub direct_sowing_weeks: Option>, + pub transplanting_weeks: Option>, + pub glasshouse_weeks: Option>, + pub harvesting_weeks: Option>, pub succession_planting_days: Option, pub planting_notes: Option, pub propagation_methods: Option>, @@ -318,6 +323,11 @@ pub struct CreateCultivar { pub transplanting_months: Option>, pub glasshouse_months: Option>, pub harvesting_months: Option>, + pub indoor_sowing_weeks: Option>, + pub direct_sowing_weeks: Option>, + pub transplanting_weeks: Option>, + pub glasshouse_weeks: Option>, + pub harvesting_weeks: Option>, pub succession_planting_days: Option, pub planting_notes: Option, pub propagation_methods: Option>, diff --git a/herbapi-ui/assets/herbapi.css b/herbapi-ui/assets/herbapi.css index eb2af0f..ed39227 100644 --- a/herbapi-ui/assets/herbapi.css +++ b/herbapi-ui/assets/herbapi.css @@ -468,6 +468,86 @@ tr:hover td { background: var(--cal-harvest); } +/* 52-Week Planting Calendar */ + +.week-calendar { + padding: 0.5rem; + border: none; + box-shadow: none; + background: transparent; +} + +.wcal-month-row { + display: grid; + grid-template-columns: 100px repeat(52, 1fr); + gap: 1px; + margin-bottom: 2px; +} + +.wcal-month-header { + font-size: 0.7rem; + font-weight: 600; + color: var(--text-muted); + text-align: center; + padding: 0.2rem 0; + border-bottom: 1px solid var(--border); +} + +.wcal-label { + font-size: 0.75rem; + padding: 0.15rem 0.4rem; + white-space: nowrap; + display: flex; + align-items: center; +} + +.wcal-row { + display: grid; + grid-template-columns: 100px repeat(52, 1fr); + gap: 1px; + margin-bottom: 1px; +} + +.wcal-cell { + height: 18px; + border-radius: 2px; + background: #f0f0f0; +} + +.wcal-cell.active.cal-indoor { background: var(--cal-indoor); } +.wcal-cell.active.cal-direct { background: var(--cal-direct); } +.wcal-cell.active.cal-transplant { background: var(--cal-transplant); } +.wcal-cell.active.cal-glass { background: var(--cal-glass); } +.wcal-cell.active.cal-harvest { background: var(--cal-harvest); } + +.wcal-legend { + display: flex; + gap: 1rem; + margin-top: 0.5rem; + padding-left: 100px; + flex-wrap: wrap; +} + +.wcal-legend-item { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.7rem; + color: var(--text-muted); +} + +.wcal-legend-swatch { + width: 14px; + height: 10px; + border-radius: 2px; +} + +.wcal-legend-swatch.cal-indoor { background: var(--cal-indoor); } +.wcal-legend-swatch.cal-direct { background: var(--cal-direct); } +.wcal-legend-swatch.cal-transplant { background: var(--cal-transplant); } +.wcal-legend-swatch.cal-glass { background: var(--cal-glass); } +.wcal-legend-swatch.cal-harvest { background: var(--cal-harvest); } + /* Pagination */ .pagination { diff --git a/herbapi-ui/src/components/planting_calendar.rs b/herbapi-ui/src/components/planting_calendar.rs index 1e72d5e..9958108 100644 --- a/herbapi-ui/src/components/planting_calendar.rs +++ b/herbapi-ui/src/components/planting_calendar.rs @@ -1,51 +1,105 @@ use dioxus::prelude::*; -const MONTH_LABELS: [&str; 12] = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"]; +/// Month boundaries as week ranges (approximate ISO weeks). +/// Jan=1-4, Feb=5-8, Mar=9-13, Apr=14-17, May=18-22, Jun=23-26, +/// Jul=27-30, Aug=31-35, Sep=36-39, Oct=40-44, Nov=45-48, Dec=49-52 +const MONTH_WEEK_RANGES: [(u8, u8); 12] = [ + (1, 4), (5, 8), (9, 13), (14, 17), (18, 22), (23, 26), + (27, 30), (31, 35), (36, 39), (40, 44), (45, 48), (49, 52), +]; + +const MONTH_LABELS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +/// Expand month data (1-12) into week numbers (1-52) for fallback. +fn months_to_weeks(months: &Option>) -> Option> { + let m = months.as_ref()?; + if m.is_empty() { return None; } + let mut weeks = Vec::new(); + for &month in m { + if month >= 1 && month <= 12 { + let (start, end) = MONTH_WEEK_RANGES[(month - 1) as usize]; + for w in start..=end { + weeks.push(w as i32); + } + } + } + weeks.sort(); + weeks.dedup(); + Some(weeks) +} + +/// Resolve: prefer week data, fall back to expanding month data. +fn resolve_weeks(weeks: &Option>, months: &Option>) -> Option> { + if let Some(w) = weeks { + if !w.is_empty() { + return Some(w.clone()); + } + } + months_to_weeks(months) +} #[component] pub fn PlantingCalendar( - indoor_sowing: Option>, - direct_sowing: Option>, - transplanting: Option>, - glasshouse: Option>, - harvesting: Option>, + indoor_sowing_months: Option>, + direct_sowing_months: Option>, + transplanting_months: Option>, + glasshouse_months: Option>, + harvesting_months: Option>, + indoor_sowing_weeks: Option>, + direct_sowing_weeks: Option>, + transplanting_weeks: Option>, + glasshouse_weeks: Option>, + harvesting_weeks: Option>, ) -> Element { - let rows: Vec<(&str, &str, &Option>)> = vec![ - ("Indoor Sowing", "cal-indoor", &indoor_sowing), - ("Direct Sowing", "cal-direct", &direct_sowing), - ("Transplanting", "cal-transplant", &transplanting), - ("Glasshouse", "cal-glass", &glasshouse), - ("Harvesting", "cal-harvest", &harvesting), + let rows: Vec<(&str, &str, Option>)> = vec![ + ("Indoor Sowing", "cal-indoor", resolve_weeks(&indoor_sowing_weeks, &indoor_sowing_months)), + ("Direct Sowing", "cal-direct", resolve_weeks(&direct_sowing_weeks, &direct_sowing_months)), + ("Transplanting", "cal-transplant", resolve_weeks(&transplanting_weeks, &transplanting_months)), + ("Glasshouse", "cal-glass", resolve_weeks(&glasshouse_weeks, &glasshouse_months)), + ("Harvesting", "cal-harvest", resolve_weeks(&harvesting_weeks, &harvesting_months)), ]; - // Check if any data exists - let has_data = rows.iter().any(|(_, _, months)| months.is_some()); + let has_data = rows.iter().any(|(_, _, w)| w.is_some()); if !has_data { return rsx! { p { class: "empty", "No planting calendar data." } }; } rsx! { - div { class: "planting-calendar", - // Header row with month labels - div { class: "cal-row cal-header", - div { class: "cal-label" } - for label in MONTH_LABELS.iter() { - div { class: "cal-cell", "{label}" } + div { class: "planting-calendar week-calendar", + // Month header spanning groups of weeks + div { class: "wcal-month-row", + div { class: "wcal-label" } + for (i, label) in MONTH_LABELS.iter().enumerate() { + { + let (start, end) = MONTH_WEEK_RANGES[i]; + let span = (end - start + 1) as usize; + rsx! { + div { + class: "wcal-month-header", + style: "grid-column: span {span};", + "{label}" + } + } + } } } // Data rows - for (name, class, months) in rows.iter() { - if months.is_some() { - div { class: "cal-row", - div { class: "cal-label", "{name}" } - for month in 1..=12i32 { + for (name, class, weeks) in rows.iter() { + if weeks.is_some() { + div { class: "wcal-row", + div { class: "wcal-label", "{name}" } + for week in 1..=52i32 { { - let active = months.as_ref() - .map(|m| m.contains(&month)) + let active = weeks.as_ref() + .map(|w| w.contains(&week)) .unwrap_or(false); rsx! { div { - class: if active { format!("cal-cell {class} active") } else { "cal-cell".to_string() }, + class: if active { format!("wcal-cell {class} active") } else { "wcal-cell".to_string() }, + title: "W{week}", } } } @@ -53,6 +107,29 @@ pub fn PlantingCalendar( } } } + // Legend + div { class: "wcal-legend", + div { class: "wcal-legend-item", + div { class: "wcal-legend-swatch cal-indoor" } + span { "Indoor Sowing" } + } + div { class: "wcal-legend-item", + div { class: "wcal-legend-swatch cal-direct" } + span { "Direct Sowing" } + } + div { class: "wcal-legend-item", + div { class: "wcal-legend-swatch cal-transplant" } + span { "Transplanting" } + } + div { class: "wcal-legend-item", + div { class: "wcal-legend-swatch cal-glass" } + span { "Glasshouse" } + } + div { class: "wcal-legend-item", + div { class: "wcal-legend-swatch cal-harvest" } + span { "Harvesting" } + } + } } } } diff --git a/herbapi-ui/src/pages/cultivars.rs b/herbapi-ui/src/pages/cultivars.rs index 12d66de..7974e55 100644 --- a/herbapi-ui/src/pages/cultivars.rs +++ b/herbapi-ui/src/pages/cultivars.rs @@ -4,6 +4,7 @@ use uuid::Uuid; use crate::api; use crate::app::{Lang, Route}; +use crate::components::planting_calendar::PlantingCalendar; use crate::components::table_controls::*; use crate::i18n::{pick_desc, pick_name}; @@ -378,6 +379,25 @@ pub fn CultivarDetail(slug: String) -> Element { p { class: "name-common", "{common_name}" } } + // Week-based planting calendar (full width) + div { class: "detail-card", style: "margin-top: 1.25rem;", + div { class: "detail-card-header", "Planting Calendar" } + div { style: "padding: 0.75rem;", + PlantingCalendar { + indoor_sowing_months: c.indoor_sowing_months.clone(), + direct_sowing_months: c.direct_sowing_months.clone(), + transplanting_months: c.transplanting_months.clone(), + glasshouse_months: c.glasshouse_months.clone(), + harvesting_months: c.harvesting_months.clone(), + indoor_sowing_weeks: c.indoor_sowing_weeks.clone(), + direct_sowing_weeks: c.direct_sowing_weeks.clone(), + transplanting_weeks: c.transplanting_weeks.clone(), + glasshouse_weeks: c.glasshouse_weeks.clone(), + harvesting_weeks: c.harvesting_weeks.clone(), + } + } + } + div { class: "detail-row", // === LEFT COLUMN === div { class: "detail-col", diff --git a/herbapi-ui/src/types.rs b/herbapi-ui/src/types.rs index 012a0bf..dfc8542 100644 --- a/herbapi-ui/src/types.rs +++ b/herbapi-ui/src/types.rs @@ -117,6 +117,11 @@ pub struct Cultivar { pub transplanting_months: Option>, pub glasshouse_months: Option>, pub harvesting_months: Option>, + pub indoor_sowing_weeks: Option>, + pub direct_sowing_weeks: Option>, + pub transplanting_weeks: Option>, + pub glasshouse_weeks: Option>, + pub harvesting_weeks: Option>, pub succession_planting_days: Option, pub planting_notes: Option, pub propagation_methods: Option>,