Add 52-week planting calendar with month fallback

Add indoor_sowing_weeks, direct_sowing_weeks, transplanting_weeks,
glasshouse_weeks, harvesting_weeks (integer[]) to backend Cultivar and
CreateCultivar structs with INSERT/UPDATE SQL bindings.

Frontend PlantingCalendar component rewritten as a compact 52-column
Gantt-style grid grouped by month headers. Prefers week data when
available, falls back to expanding month data into week ranges.
Calendar displayed full-width on cultivar detail page with color legend.
This commit is contained in:
2026-03-15 14:36:17 +01:00
parent 3ecfdfadf2
commit 170aa84a0f
6 changed files with 245 additions and 39 deletions
+25 -11
View File
@@ -127,7 +127,10 @@ pub async fn create(pool: &PgPool, req: &CreateCultivar) -> Result<Cultivar> {
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<Cultivar> {
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<Cultivar> {
.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<Cul
min_light_hours_day=$32, optimal_light_hours_day=$33, greenhouse_min_temp_c=$34,
indoor_season_extension_weeks=$35, ventilation_requirement=$36, heating_required=$37,
indoor_sowing_months=$38, direct_sowing_months=$39, transplanting_months=$40,
glasshouse_months=$41, harvesting_months=$42, succession_planting_days=$43,
planting_notes=$44, propagation_methods=$45, cutting_season=$46,
rootstock_species_id=$47, years_to_first_harvest=$48, productive_lifespan_years=$49,
expected_yield_kg_per_m2=$50, yield_unit=$51, expected_yield_value=$52,
harvest_window_days=$53, storage_method=$54, shelf_life_days=$55, cold_storage_days=$56,
pollination_group=$57, self_fertile=$58, rootstock_compatibility=$59,
wikidata_qid=$60, gbif_id=$61, pfaf_url=$62, source_urls=$63, updated_at=NOW()
glasshouse_months=$41, harvesting_months=$42,
indoor_sowing_weeks=$43, direct_sowing_weeks=$44, transplanting_weeks=$45,
glasshouse_weeks=$46, harvesting_weeks=$47,
succession_planting_days=$48,
planting_notes=$49, propagation_methods=$50, cutting_season=$51,
rootstock_species_id=$52, years_to_first_harvest=$53, productive_lifespan_years=$54,
expected_yield_kg_per_m2=$55, yield_unit=$56, expected_yield_value=$57,
harvest_window_days=$58, storage_method=$59, shelf_life_days=$60, cold_storage_days=$61,
pollination_group=$62, self_fertile=$63, rootstock_compatibility=$64,
wikidata_qid=$65, gbif_id=$66, pfaf_url=$67, source_urls=$68, updated_at=NOW()
WHERE id=$1 RETURNING *"
)
.bind(id).bind(&slug).bind(req.species_id).bind(&req.name)
@@ -216,7 +226,11 @@ pub async fn update(pool: &PgPool, id: Uuid, req: &CreateCultivar) -> Result<Cul
.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)
+10
View File
@@ -250,6 +250,11 @@ pub struct Cultivar {
pub transplanting_months: Option<Vec<i32>>,
pub glasshouse_months: Option<Vec<i32>>,
pub harvesting_months: Option<Vec<i32>>,
pub indoor_sowing_weeks: Option<Vec<i32>>,
pub direct_sowing_weeks: Option<Vec<i32>>,
pub transplanting_weeks: Option<Vec<i32>>,
pub glasshouse_weeks: Option<Vec<i32>>,
pub harvesting_weeks: Option<Vec<i32>>,
pub succession_planting_days: Option<i32>,
pub planting_notes: Option<String>,
pub propagation_methods: Option<Vec<String>>,
@@ -318,6 +323,11 @@ pub struct CreateCultivar {
pub transplanting_months: Option<Vec<i32>>,
pub glasshouse_months: Option<Vec<i32>>,
pub harvesting_months: Option<Vec<i32>>,
pub indoor_sowing_weeks: Option<Vec<i32>>,
pub direct_sowing_weeks: Option<Vec<i32>>,
pub transplanting_weeks: Option<Vec<i32>>,
pub glasshouse_weeks: Option<Vec<i32>>,
pub harvesting_weeks: Option<Vec<i32>>,
pub succession_planting_days: Option<i32>,
pub planting_notes: Option<String>,
pub propagation_methods: Option<Vec<String>>,
+80
View File
@@ -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 {
+105 -28
View File
@@ -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<Vec<i32>>) -> Option<Vec<i32>> {
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<Vec<i32>>, months: &Option<Vec<i32>>) -> Option<Vec<i32>> {
if let Some(w) = weeks {
if !w.is_empty() {
return Some(w.clone());
}
}
months_to_weeks(months)
}
#[component]
pub fn PlantingCalendar(
indoor_sowing: Option<Vec<i32>>,
direct_sowing: Option<Vec<i32>>,
transplanting: Option<Vec<i32>>,
glasshouse: Option<Vec<i32>>,
harvesting: Option<Vec<i32>>,
indoor_sowing_months: Option<Vec<i32>>,
direct_sowing_months: Option<Vec<i32>>,
transplanting_months: Option<Vec<i32>>,
glasshouse_months: Option<Vec<i32>>,
harvesting_months: Option<Vec<i32>>,
indoor_sowing_weeks: Option<Vec<i32>>,
direct_sowing_weeks: Option<Vec<i32>>,
transplanting_weeks: Option<Vec<i32>>,
glasshouse_weeks: Option<Vec<i32>>,
harvesting_weeks: Option<Vec<i32>>,
) -> Element {
let rows: Vec<(&str, &str, &Option<Vec<i32>>)> = 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<i32>>)> = 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" }
}
}
}
}
}
+20
View File
@@ -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",
+5
View File
@@ -117,6 +117,11 @@ pub struct Cultivar {
pub transplanting_months: Option<Vec<i32>>,
pub glasshouse_months: Option<Vec<i32>>,
pub harvesting_months: Option<Vec<i32>>,
pub indoor_sowing_weeks: Option<Vec<i32>>,
pub direct_sowing_weeks: Option<Vec<i32>>,
pub transplanting_weeks: Option<Vec<i32>>,
pub glasshouse_weeks: Option<Vec<i32>>,
pub harvesting_weeks: Option<Vec<i32>>,
pub succession_planting_days: Option<i32>,
pub planting_notes: Option<String>,
pub propagation_methods: Option<Vec<String>>,