From ef608551ada74289606ec21ca278c70dd4f1bf8a Mon Sep 17 00:00:00 2001 From: Alexander Klein Date: Wed, 16 Sep 2026 20:21:41 +0200 Subject: [PATCH] Support species height and width ranges --- cmd/cli/demo_store.go | 2 +- internal/api/species.go | 28 ++++++++- internal/api/species_dimensions_test.go | 52 ++++++++++++++++ .../000002_species_dimensions.down.sql | 6 ++ .../000002_species_dimensions.up.sql | 6 ++ internal/storage/postgres/species.go | 36 +++++++---- internal/storage/species.go | 44 +++++++++---- internal/storage/species_test.go | 39 ++++++++++++ internal/web/edit_flows_test.go | 8 ++- internal/web/species.go | 39 +++++++----- internal/web/static/css/main.css | 4 ++ .../web/templates/pages/species_form.tmpl | 2 +- internal/web/templates_test.go | 12 +++- lib/client/models.go | 62 ++++++++++++------- 14 files changed, 271 insertions(+), 69 deletions(-) create mode 100644 internal/api/species_dimensions_test.go create mode 100644 internal/storage/postgres/migrations/000002_species_dimensions.down.sql create mode 100644 internal/storage/postgres/migrations/000002_species_dimensions.up.sql create mode 100644 internal/storage/species_test.go diff --git a/cmd/cli/demo_store.go b/cmd/cli/demo_store.go index 2499ba7..4b34ec7 100644 --- a/cmd/cli/demo_store.go +++ b/cmd/cli/demo_store.go @@ -259,7 +259,7 @@ func seedDemoSpecies(ctx context.Context, tx *sql.Tx, gardenID, userID int64, im err := tx.QueryRowContext(ctx, ` INSERT INTO species ( garden_id, category_id, common_name, cultivar, botanical_name, - sun_exposure, soil_condition, soil_reaction, spacing_cm, height_cm, + sun_exposure, soil_condition, soil_reaction, spacing_cm, height_cm_to, sow_month_from, sow_month_to, planting_month_from, planting_month_to, harvest_month_from, harvest_month_to, notes, created_by, updated_by) SELECT $1, id, $2, $3, $4, 'sunny', 'moist', 'neutral', $5, $6, diff --git a/internal/api/species.go b/internal/api/species.go index 7ca5f94..0cb21dd 100644 --- a/internal/api/species.go +++ b/internal/api/species.go @@ -25,6 +25,14 @@ type speciesInput struct { WinterProtection *string `json:"winter_protection"` SpacingCM *int `json:"spacing_cm"` HeightCM *int `json:"height_cm"` + HeightCMFrom *int `json:"height_cm_from"` + HeightCMTo *int `json:"height_cm_to"` + WidthCMFrom *int `json:"width_cm_from"` + WidthCMTo *int `json:"width_cm_to"` + ClearHeightCMFrom bool `json:"clear_height_cm_from"` + ClearHeightCMTo bool `json:"clear_height_cm_to"` + ClearWidthCMFrom bool `json:"clear_width_cm_from"` + ClearWidthCMTo bool `json:"clear_width_cm_to"` SowMonthFrom *int `json:"sow_month_from"` SowDayFrom *int `json:"sow_day_from"` ClearSowDayFrom bool `json:"clear_sow_day_from"` @@ -70,7 +78,19 @@ func (input speciesInput) apply(species *storage.Species) { assignStringPointer(input.SoilReaction, &species.SoilReaction) assignStringPointer(input.WinterProtection, &species.WinterProtection) assignIntPointer(input.SpacingCM, &species.SpacingCM) - assignIntPointer(input.HeightCM, &species.HeightCM) + assignIntPointer(input.HeightCMFrom, &species.HeightCMFrom) + if input.HeightCMTo != nil { + assignIntPointer(input.HeightCMTo, &species.HeightCMTo) + } else { + assignIntPointer(input.HeightCM, &species.HeightCMTo) + } + assignIntPointer(input.WidthCMFrom, &species.WidthCMFrom) + assignIntPointer(input.WidthCMTo, &species.WidthCMTo) + clearIntPointer(input.ClearHeightCMFrom, &species.HeightCMFrom) + clearIntPointer(input.ClearHeightCMTo, &species.HeightCMTo) + clearIntPointer(input.ClearWidthCMFrom, &species.WidthCMFrom) + clearIntPointer(input.ClearWidthCMTo, &species.WidthCMTo) + species.HeightCM = species.HeightCMTo assignIntPointer(input.SowMonthFrom, &species.SowMonthFrom) assignIntPointer(input.SowDayFrom, &species.SowDayFrom) assignIntPointer(input.SowMonthTo, &species.SowMonthTo) @@ -138,6 +158,12 @@ func assignIntPointer(input *int, destination **int) { } } +func clearIntPointer(clear bool, destination **int) { + if clear { + *destination = nil + } +} + func (app *application) createSpeciesHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) var input speciesInput diff --git a/internal/api/species_dimensions_test.go b/internal/api/species_dimensions_test.go new file mode 100644 index 0000000..7e77648 --- /dev/null +++ b/internal/api/species_dimensions_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestSpeciesInputAppliesAndClearsDimensions(t *testing.T) { + heightFrom, heightTo, widthFrom, widthTo := 20, 80, 30, 60 + species := storage.Species{ + HeightCMFrom: &heightFrom, + HeightCMTo: &heightTo, + WidthCMFrom: &widthFrom, + WidthCMTo: &widthTo, + } + newHeightFrom, newWidthTo := 40, 70 + + speciesInput{ + HeightCMFrom: &newHeightFrom, + ClearHeightCMTo: true, + ClearWidthCMFrom: true, + WidthCMTo: &newWidthTo, + }.apply(&species) + + if species.HeightCMFrom == nil || *species.HeightCMFrom != 40 { + t.Fatalf("height from = %v, want 40", species.HeightCMFrom) + } + if species.HeightCMTo != nil { + t.Fatalf("height to = %v, want nil", species.HeightCMTo) + } + if species.WidthCMFrom != nil { + t.Fatalf("width from = %v, want nil", species.WidthCMFrom) + } + if species.WidthCMTo == nil || *species.WidthCMTo != 70 { + t.Fatalf("width to = %v, want 70", species.WidthCMTo) + } +} + +func TestSpeciesInputAcceptsDeprecatedHeight(t *testing.T) { + height := 90 + var species storage.Species + + speciesInput{HeightCM: &height}.apply(&species) + + if species.HeightCMTo == nil || *species.HeightCMTo != height { + t.Fatalf("height to = %v, want %d", species.HeightCMTo, height) + } + if species.HeightCM == nil || *species.HeightCM != height { + t.Fatalf("deprecated height alias = %v, want %d", species.HeightCM, height) + } +} diff --git a/internal/storage/postgres/migrations/000002_species_dimensions.down.sql b/internal/storage/postgres/migrations/000002_species_dimensions.down.sql new file mode 100644 index 0000000..ad1bea1 --- /dev/null +++ b/internal/storage/postgres/migrations/000002_species_dimensions.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE species + DROP COLUMN width_cm_to, + DROP COLUMN width_cm_from, + DROP COLUMN height_cm_from; + +ALTER TABLE species RENAME COLUMN height_cm_to TO height_cm; diff --git a/internal/storage/postgres/migrations/000002_species_dimensions.up.sql b/internal/storage/postgres/migrations/000002_species_dimensions.up.sql new file mode 100644 index 0000000..c97babc --- /dev/null +++ b/internal/storage/postgres/migrations/000002_species_dimensions.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE species RENAME COLUMN height_cm TO height_cm_to; + +ALTER TABLE species + ADD COLUMN height_cm_from integer, + ADD COLUMN width_cm_from integer, + ADD COLUMN width_cm_to integer; diff --git a/internal/storage/postgres/species.go b/internal/storage/postgres/species.go index cd8b4e1..dd97e9a 100644 --- a/internal/storage/postgres/species.go +++ b/internal/storage/postgres/species.go @@ -13,7 +13,8 @@ type SpeciesModel struct{ DB *sql.DB } const speciesColumns = `s.id, s.garden_id, s.common_name, s.cultivar, s.botanical_name, s.category_id, COALESCE(c.name, ''), s.sun_exposure, s.soil_condition, s.soil_reaction, - s.winter_protection, s.spacing_cm, s.height_cm, + s.winter_protection, s.spacing_cm, s.height_cm_from, s.height_cm_to, + s.width_cm_from, s.width_cm_to, s.sow_month_from, s.sow_day_from, s.sow_month_to, s.sow_day_to, s.planting_month_from, s.planting_day_from, s.planting_month_to, s.planting_day_to, s.harvest_month_from, s.harvest_day_from, s.harvest_month_to, s.harvest_day_to, @@ -26,19 +27,26 @@ func scanSpecies(s scanner) (storage.Species, error) { &species.ID, &species.GardenID, &species.CommonName, &species.Cultivar, &species.BotanicalName, &species.CategoryID, &species.Category, &species.SunExposure, &species.SoilCondition, &species.SoilReaction, &species.WinterProtection, &species.SpacingCM, - &species.HeightCM, &species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo, + &species.HeightCMFrom, &species.HeightCMTo, &species.WidthCMFrom, &species.WidthCMTo, + &species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo, &species.SowDayTo, &species.PlantingMonthFrom, &species.PlantingDayFrom, &species.PlantingMonthTo, &species.PlantingDayTo, &species.HarvestMonthFrom, &species.HarvestDayFrom, &species.HarvestMonthTo, &species.HarvestDayTo, &species.Notes, &species.Attributes, &species.ImageData, &species.ImageID, &species.CreatedAt, &species.UpdatedAt, &species.Version, &species.CreatedBy, &species.UpdatedBy, ) + species.HeightCM = species.HeightCMTo return species, err } func speciesArgs(species storage.Species) []any { + heightTo := species.HeightCMTo + if heightTo == nil { + heightTo = species.HeightCM + } return []any{ species.GardenID, species.CommonName, species.Cultivar, species.BotanicalName, species.CategoryID, species.SunExposure, species.SoilCondition, species.SoilReaction, - species.WinterProtection, species.SpacingCM, species.HeightCM, + species.WinterProtection, species.SpacingCM, species.HeightCMFrom, heightTo, + species.WidthCMFrom, species.WidthCMTo, species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo, species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo, species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, @@ -53,13 +61,15 @@ func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) { query := ` INSERT INTO species ( garden_id, common_name, cultivar, botanical_name, category_id, sun_exposure, - soil_condition, soil_reaction, winter_protection, spacing_cm, height_cm, + soil_condition, soil_reaction, winter_protection, spacing_cm, + height_cm_from, height_cm_to, width_cm_from, width_cm_to, sow_month_from, sow_day_from, sow_month_to, sow_day_to, planting_month_from, planting_day_from, planting_month_to, planting_day_to, harvest_month_from, harvest_day_from, harvest_month_to, harvest_day_to, notes, attributes, image_data, image_id, created_by, updated_by) 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) + $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, + $25, $26, $27, $28, '', $29, $30, $31) RETURNING id, created_at, updated_at, version` err := m.DB.QueryRowContext(ctx, query, speciesArgs(species)...).Scan( &species.ID, &species.CreatedAt, &species.UpdatedAt, &species.Version, @@ -67,6 +77,7 @@ func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) { if err != nil { return storage.Species{}, recordError(err) } + species.HeightCM = species.HeightCMTo return species, nil } @@ -78,6 +89,7 @@ func (m SpeciesModel) Get(gardenID, id int) (storage.Species, error) { if err != nil { return storage.Species{}, recordError(err) } + species.HeightCM = species.HeightCMTo return species, nil } @@ -113,18 +125,18 @@ func (m SpeciesModel) Update(gardenID int, species storage.Species) (storage.Spe // created_by is immutable and is intentionally not part of the UPDATE. // Remove it from the shared insert argument list so PostgreSQL does not // receive an unused, untyped parameter between image_id and updated_by. - args = append(args[:26], args[27]) + args = append(args[:29], args[30]) args = append(args, gardenID, species.ID, species.Version) err := m.DB.QueryRowContext(ctx, ` UPDATE species SET garden_id = $1, common_name = $2, cultivar = $3, botanical_name = $4, category_id = $5, sun_exposure = $6, soil_condition = $7, soil_reaction = $8, winter_protection = $9, spacing_cm = $10, - height_cm = $11, sow_month_from = $12, sow_day_from = $13, - sow_month_to = $14, sow_day_to = $15, planting_month_from=$16, - planting_day_from=$17, planting_month_to=$18, planting_day_to=$19, - harvest_month_from = $20, harvest_day_from = $21, harvest_month_to = $22, harvest_day_to = $23, - notes = $24, attributes = $25, image_data = '', image_id = $26, updated_by = $27, updated_at = CURRENT_TIMESTAMP, version = version + 1 - WHERE garden_id IS NOT DISTINCT FROM NULLIF($28, 0) AND id = $29 AND version = $30 + height_cm_from = $11, height_cm_to = $12, width_cm_from = $13, width_cm_to = $14, + sow_month_from = $15, sow_day_from = $16, sow_month_to = $17, sow_day_to = $18, + planting_month_from=$19, planting_day_from=$20, planting_month_to=$21, planting_day_to=$22, + harvest_month_from = $23, harvest_day_from = $24, harvest_month_to = $25, harvest_day_to = $26, + notes = $27, attributes = $28, image_data = '', image_id = $29, updated_by = $30, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE garden_id IS NOT DISTINCT FROM NULLIF($31, 0) AND id = $32 AND version = $33 RETURNING updated_at, version`, args..., ).Scan(&species.UpdatedAt, &species.Version) if err != nil { diff --git a/internal/storage/species.go b/internal/storage/species.go index 786f12b..e9887fd 100644 --- a/internal/storage/species.go +++ b/internal/storage/species.go @@ -19,19 +19,24 @@ type SpeciesModelInterface interface { // Species contains reusable botanical and cultivation master data. type Species struct { - ID int `json:"id"` - GardenID *int `json:"garden_id,omitempty"` - CommonName string `json:"common_name"` - Cultivar string `json:"cultivar"` - BotanicalName string `json:"botanical_name"` - CategoryID *int `json:"category_id,omitempty"` - Category string `json:"category"` - SunExposure *string `json:"sun_exposure,omitempty"` - SoilCondition *string `json:"soil_condition,omitempty"` - SoilReaction *string `json:"soil_reaction,omitempty"` - WinterProtection *string `json:"winter_protection,omitempty"` - SpacingCM *int `json:"spacing_cm,omitempty"` + ID int `json:"id"` + GardenID *int `json:"garden_id,omitempty"` + CommonName string `json:"common_name"` + Cultivar string `json:"cultivar"` + BotanicalName string `json:"botanical_name"` + CategoryID *int `json:"category_id,omitempty"` + Category string `json:"category"` + SunExposure *string `json:"sun_exposure,omitempty"` + SoilCondition *string `json:"soil_condition,omitempty"` + SoilReaction *string `json:"soil_reaction,omitempty"` + WinterProtection *string `json:"winter_protection,omitempty"` + SpacingCM *int `json:"spacing_cm,omitempty"` + // HeightCM is retained as a deprecated API alias for HeightCMTo. HeightCM *int `json:"height_cm,omitempty"` + HeightCMFrom *int `json:"height_cm_from,omitempty"` + HeightCMTo *int `json:"height_cm_to,omitempty"` + WidthCMFrom *int `json:"width_cm_from,omitempty"` + WidthCMTo *int `json:"width_cm_to,omitempty"` SowMonthFrom *int `json:"sow_month_from,omitempty"` SowDayFrom *int `json:"sow_day_from,omitempty"` SowMonthTo *int `json:"sow_month_to,omitempty"` @@ -72,7 +77,12 @@ func ValidateSpecies(v *validate.Validator, species Species) { validateOptionalEnum(v, "soil_condition", species.SoilCondition, "dry", "moist", "boggy") validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral") validateOptionalPositive(v, "spacing_cm", species.SpacingCM) - validateOptionalPositive(v, "height_cm", species.HeightCM) + heightTo := species.HeightCMTo + if heightTo == nil { + heightTo = species.HeightCM + } + validateOptionalRange(v, "height_cm", species.HeightCMFrom, heightTo) + validateOptionalRange(v, "width_cm", species.WidthCMFrom, species.WidthCMTo) validateOptionalCalendarPart(v, "sow_month_from", species.SowMonthFrom, 12) validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31) validateOptionalCalendarPart(v, "sow_month_to", species.SowMonthTo, 12) @@ -99,6 +109,14 @@ func validateOptionalPositive(v *validate.Validator, field string, value *int) { } } +func validateOptionalRange(v *validate.Validator, field string, from, to *int) { + validateOptionalPositive(v, field+"_from", from) + validateOptionalPositive(v, field+"_to", to) + if from != nil && to != nil { + v.Check(*from <= *to, field+"_from", "must not be greater than "+field+"_to") + } +} + func validateOptionalCalendarPart(v *validate.Validator, field string, value *int, maximum int) { if value != nil { v.Check(*value >= 1 && *value <= maximum, field, "is outside the valid range") diff --git a/internal/storage/species_test.go b/internal/storage/species_test.go new file mode 100644 index 0000000..477fbbd --- /dev/null +++ b/internal/storage/species_test.go @@ -0,0 +1,39 @@ +package storage + +import ( + "testing" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +func TestValidateSpeciesDimensions(t *testing.T) { + tests := []struct { + name string + species Species + wantField string + }{ + {name: "valid ranges", species: Species{CommonName: "Rose", HeightCMFrom: intPointer(40), HeightCMTo: intPointer(80), WidthCMFrom: intPointer(30), WidthCMTo: intPointer(60)}}, + {name: "height starts above end", species: Species{CommonName: "Rose", HeightCMFrom: intPointer(81), HeightCMTo: intPointer(80)}, wantField: "height_cm_from"}, + {name: "width starts above end", species: Species{CommonName: "Rose", WidthCMFrom: intPointer(61), WidthCMTo: intPointer(60)}, wantField: "width_cm_from"}, + {name: "non-positive height", species: Species{CommonName: "Rose", HeightCMTo: intPointer(0)}, wantField: "height_cm_to"}, + {name: "deprecated height alias", species: Species{CommonName: "Rose", HeightCM: intPointer(80)}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := validate.New() + ValidateSpecies(v, tt.species) + _, hasError := v.Errors[tt.wantField] + if tt.wantField == "" && !v.Valid() { + t.Fatalf("unexpected validation errors: %v", v.Errors) + } + if tt.wantField != "" && !hasError { + t.Fatalf("expected validation error for %s, got %v", tt.wantField, v.Errors) + } + }) + } +} + +func intPointer(value int) *int { + return &value +} diff --git a/internal/web/edit_flows_test.go b/internal/web/edit_flows_test.go index 4d943bf..f450dfd 100644 --- a/internal/web/edit_flows_test.go +++ b/internal/web/edit_flows_test.go @@ -94,7 +94,7 @@ func TestGardenAndSpeciesCanBeEdited(t *testing.T) { if gardenResponse.Code != http.StatusSeeOther || gardenInput.Name == nil || *gardenInput.Name != "Neuer Garten" { t.Fatalf("garden edit: status=%d input=%+v", gardenResponse.Code, gardenInput) } - speciesForm := url.Values{"common_name": {"Neue Rose"}, "sow_month_from": {"0"}, "sow_month_to": {"0"}} + speciesForm := url.Values{"common_name": {"Neue Rose"}, "height_cm_from": {"40"}, "height_cm_to": {"80"}, "width_cm_to": {"60"}, "sow_month_from": {"0"}, "sow_month_to": {"0"}} speciesRequest := httptest.NewRequest(http.MethodPost, "/g/3/species/edit/7", strings.NewReader(speciesForm.Encode())) speciesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") speciesRequest = taskWebRequest(speciesRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "speciesID", Value: "7"}}) @@ -103,6 +103,12 @@ func TestGardenAndSpeciesCanBeEdited(t *testing.T) { if speciesResponse.Code != http.StatusSeeOther || speciesInput.CommonName == nil || *speciesInput.CommonName != "Neue Rose" || !speciesInput.ClearSowRange { t.Fatalf("species edit: status=%d input=%+v", speciesResponse.Code, speciesInput) } + if speciesInput.HeightCMFrom == nil || *speciesInput.HeightCMFrom != 40 || speciesInput.HeightCMTo == nil || *speciesInput.HeightCMTo != 80 || speciesInput.WidthCMTo == nil || *speciesInput.WidthCMTo != 60 { + t.Fatalf("species dimensions were not submitted: %+v", speciesInput) + } + if speciesInput.ClearHeightCMFrom || speciesInput.ClearHeightCMTo || !speciesInput.ClearWidthCMFrom || speciesInput.ClearWidthCMTo { + t.Fatalf("species dimension clear flags are incorrect: %+v", speciesInput) + } } func TestGardenCanBeDeleted(t *testing.T) { diff --git a/internal/web/species.go b/internal/web/species.go index ce41d78..6ac0ef5 100644 --- a/internal/web/species.go +++ b/internal/web/species.go @@ -22,7 +22,10 @@ type speciesForm struct { SoilReaction string `form:"soil_reaction"` WinterProtection string `form:"winter_protection"` SpacingCM int `form:"spacing_cm"` - HeightCM int `form:"height_cm"` + HeightCMFrom int `form:"height_cm_from"` + HeightCMTo int `form:"height_cm_to"` + WidthCMFrom int `form:"width_cm_from"` + WidthCMTo int `form:"width_cm_to"` Notes string `form:"notes"` ImageData string `form:"image_data"` ImageID int `form:"image_id"` @@ -133,7 +136,13 @@ func speciesFormFromSpecies(value client.Species) speciesForm { form.WinterProtection = *value.WinterProtection } copyOptionalInt(value.SpacingCM, &form.SpacingCM) - copyOptionalInt(value.HeightCM, &form.HeightCM) + copyOptionalInt(value.HeightCMFrom, &form.HeightCMFrom) + copyOptionalInt(value.HeightCMTo, &form.HeightCMTo) + if value.HeightCMTo == nil { + copyOptionalInt(value.HeightCM, &form.HeightCMTo) + } + copyOptionalInt(value.WidthCMFrom, &form.WidthCMFrom) + copyOptionalInt(value.WidthCMTo, &form.WidthCMTo) copyOptionalInt(value.CategoryID, &form.CategoryID) copyOptionalInt(value.SowMonthFrom, &form.SowMonthFrom) copyOptionalInt(value.SowDayFrom, &form.SowDayFrom) @@ -219,9 +228,10 @@ func speciesInput(form speciesForm) client.SpeciesInput { if form.SpacingCM > 0 { input.SpacingCM = &form.SpacingCM } - if form.HeightCM > 0 { - input.HeightCM = &form.HeightCM - } + input.HeightCMFrom, input.HeightCMTo = optionalPositive(form.HeightCMFrom), optionalPositive(form.HeightCMTo) + input.WidthCMFrom, input.WidthCMTo = optionalPositive(form.WidthCMFrom), optionalPositive(form.WidthCMTo) + input.ClearHeightCMFrom, input.ClearHeightCMTo = form.HeightCMFrom == 0, form.HeightCMTo == 0 + input.ClearWidthCMFrom, input.ClearWidthCMTo = form.WidthCMFrom == 0, form.WidthCMTo == 0 if json.Valid([]byte(form.Attributes)) { input.Attributes = json.RawMessage(form.Attributes) } @@ -230,18 +240,12 @@ func speciesInput(form speciesForm) client.SpeciesInput { } else { input.ClearCategoryID = true } - assignPositive := func(value int) *int { - if value > 0 { - return &value - } - return nil - } form.SowMonthTo, form.SowDayTo = rangeEnd(form.SowMonthFrom, form.SowDayFrom, form.SowDuration, form.SowDurationUnit) form.PlantingMonthTo, form.PlantingDayTo = rangeEnd(form.PlantingMonthFrom, form.PlantingDayFrom, form.PlantingDuration, form.PlantingDurationUnit) form.HarvestMonthTo, form.HarvestDayTo = rangeEnd(form.HarvestMonthFrom, form.HarvestDayFrom, form.HarvestDuration, form.HarvestDurationUnit) - input.SowMonthFrom, input.SowDayFrom, input.SowMonthTo, input.SowDayTo = assignPositive(form.SowMonthFrom), assignPositive(form.SowDayFrom), assignPositive(form.SowMonthTo), assignPositive(form.SowDayTo) - input.PlantingMonthFrom, input.PlantingDayFrom, input.PlantingMonthTo, input.PlantingDayTo = assignPositive(form.PlantingMonthFrom), assignPositive(form.PlantingDayFrom), assignPositive(form.PlantingMonthTo), assignPositive(form.PlantingDayTo) - input.HarvestMonthFrom, input.HarvestDayFrom, input.HarvestMonthTo, input.HarvestDayTo = assignPositive(form.HarvestMonthFrom), assignPositive(form.HarvestDayFrom), assignPositive(form.HarvestMonthTo), assignPositive(form.HarvestDayTo) + input.SowMonthFrom, input.SowDayFrom, input.SowMonthTo, input.SowDayTo = optionalPositive(form.SowMonthFrom), optionalPositive(form.SowDayFrom), optionalPositive(form.SowMonthTo), optionalPositive(form.SowDayTo) + input.PlantingMonthFrom, input.PlantingDayFrom, input.PlantingMonthTo, input.PlantingDayTo = optionalPositive(form.PlantingMonthFrom), optionalPositive(form.PlantingDayFrom), optionalPositive(form.PlantingMonthTo), optionalPositive(form.PlantingDayTo) + input.HarvestMonthFrom, input.HarvestDayFrom, input.HarvestMonthTo, input.HarvestDayTo = optionalPositive(form.HarvestMonthFrom), optionalPositive(form.HarvestDayFrom), optionalPositive(form.HarvestMonthTo), optionalPositive(form.HarvestDayTo) input.ClearSowRange = form.SowMonthFrom == 0 && form.SowMonthTo == 0 input.ClearPlantingRange = form.PlantingMonthFrom == 0 && form.PlantingMonthTo == 0 input.ClearHarvestRange = form.HarvestMonthFrom == 0 && form.HarvestMonthTo == 0 @@ -251,6 +255,13 @@ func speciesInput(form speciesForm) client.SpeciesInput { return input } +func optionalPositive(value int) *int { + if value > 0 { + return &value + } + return nil +} + func rangeEnd(month, day, duration int, unit string) (int, int) { if month < 1 { return 0, 0 diff --git a/internal/web/static/css/main.css b/internal/web/static/css/main.css index be92f82..4fb621a 100644 --- a/internal/web/static/css/main.css +++ b/internal/web/static/css/main.css @@ -165,6 +165,10 @@ button, .button { display: inline-block; width: auto; padding: .75rem 1rem; colo .danger:hover, .template-remove:hover { background: #7f291f; } .species-template-add { width: 2.5rem; height: 2.5rem; margin-top: .75rem; margin-left: auto; } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 1rem; } +.dimension-range { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: .55rem; } +.dimension-range:focus-within { border-color: var(--leaf); box-shadow: 0 0 0 .15rem var(--leaf-light); } +.dimension-range input { min-width: 0; border: 0; border-radius: 0; outline: 0; } +.dimension-range > span { color: var(--muted); font-weight: 400; } .filter-bar { width: 100%; max-width: none; margin-bottom: 2rem; } .image-library-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:1rem; } .image-library-item { margin:0; overflow:hidden; padding:0; } diff --git a/internal/web/templates/pages/species_form.tmpl b/internal/web/templates/pages/species_form.tmpl index fd25cb9..78ce465 100644 --- a/internal/web/templates/pages/species_form.tmpl +++ b/internal/web/templates/pages/species_form.tmpl @@ -30,7 +30,7 @@ {{range .SpeciesCategories}}{{if or .Active (eqInt $form.CategoryID .ID)}}{{end}}{{end}} {{with index $form.Errors "category_id"}}

{{.}}

{{end}} -
+
{{template "season_range" (dict "Legend" "Aussaat" "Prefix" "sow" "Month" $form.SowMonthFrom "Day" $form.SowDayFrom "Duration" $form.SowDuration "Unit" $form.SowDurationUnit)}} {{template "season_range" (dict "Legend" "Pflanzzeit" "Prefix" "planting" "Month" $form.PlantingMonthFrom "Day" $form.PlantingDayFrom "Duration" $form.PlantingDuration "Unit" $form.PlantingDurationUnit)}} {{template "season_range" (dict "Legend" "Ernte" "Prefix" "harvest" "Month" $form.HarvestMonthFrom "Day" $form.HarvestDayFrom "Duration" $form.HarvestDuration "Unit" $form.HarvestDurationUnit)}} diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go index b57490f..a299d83 100644 --- a/internal/web/templates_test.go +++ b/internal/web/templates_test.go @@ -371,11 +371,19 @@ func TestEmptyOptionalSpeciesDimensionsDoNotBlockSubmit(t *testing.T) { response := httptest.NewRecorder() app.render(response, http.StatusOK, "species_form.tmpl", data) body := response.Body.String() - for _, field := range []string{"spacing_cm", "height_cm"} { - if !strings.Contains(body, "name='"+field+"' value=''") { + for _, field := range []string{"spacing_cm", "height_cm_from", "height_cm_to", "width_cm_from", "width_cm_to"} { + fieldStart := strings.Index(body, "name='"+field+"'") + fieldEnd := -1 + if fieldStart >= 0 { + fieldEnd = strings.Index(body[fieldStart:], ">") + } + if fieldStart < 0 || fieldEnd < 0 || !strings.Contains(body[fieldStart:fieldStart+fieldEnd], "value=''") { t.Errorf("optional field %s should render empty: %s", field, body) } } + if got := strings.Count(body, "class='dimension-range'"); got != 2 { + t.Errorf("dimension ranges: got %d composite controls, want 2: %s", got, body) + } } func TestTaskDueFormatsWindows(t *testing.T) { diff --git a/lib/client/models.go b/lib/client/models.go index 2577e09..18bc32b 100644 --- a/lib/client/models.go +++ b/lib/client/models.go @@ -177,19 +177,24 @@ type UpdateGardenInput struct { // Species describes global or garden-specific plant master data. type Species struct { - ID int `json:"id"` - GardenID *int `json:"garden_id,omitempty"` - CommonName string `json:"common_name"` - Cultivar string `json:"cultivar"` - BotanicalName string `json:"botanical_name"` - CategoryID *int `json:"category_id,omitempty"` - Category string `json:"category"` - SunExposure *string `json:"sun_exposure,omitempty"` - SoilCondition *string `json:"soil_condition,omitempty"` - SoilReaction *string `json:"soil_reaction,omitempty"` - WinterProtection *string `json:"winter_protection,omitempty"` - SpacingCM *int `json:"spacing_cm,omitempty"` + ID int `json:"id"` + GardenID *int `json:"garden_id,omitempty"` + CommonName string `json:"common_name"` + Cultivar string `json:"cultivar"` + BotanicalName string `json:"botanical_name"` + CategoryID *int `json:"category_id,omitempty"` + Category string `json:"category"` + SunExposure *string `json:"sun_exposure,omitempty"` + SoilCondition *string `json:"soil_condition,omitempty"` + SoilReaction *string `json:"soil_reaction,omitempty"` + WinterProtection *string `json:"winter_protection,omitempty"` + SpacingCM *int `json:"spacing_cm,omitempty"` + // HeightCM is retained as a deprecated API alias for HeightCMTo. HeightCM *int `json:"height_cm,omitempty"` + HeightCMFrom *int `json:"height_cm_from,omitempty"` + HeightCMTo *int `json:"height_cm_to,omitempty"` + WidthCMFrom *int `json:"width_cm_from,omitempty"` + WidthCMTo *int `json:"width_cm_to,omitempty"` SowMonthFrom *int `json:"sow_month_from,omitempty"` SowDayFrom *int `json:"sow_day_from,omitempty"` SowMonthTo *int `json:"sow_month_to,omitempty"` @@ -216,19 +221,28 @@ type Species struct { // SpeciesInput contains optional species fields used for creation and updates. type SpeciesInput struct { - Global bool `json:"global,omitempty"` - Tags []string `json:"tags,omitempty"` - CommonName *string `json:"common_name,omitempty"` - Cultivar *string `json:"cultivar,omitempty"` - BotanicalName *string `json:"botanical_name,omitempty"` - CategoryID *int `json:"category_id,omitempty"` - ClearCategoryID bool `json:"clear_category_id,omitempty"` - SunExposure *string `json:"sun_exposure,omitempty"` - SoilCondition *string `json:"soil_condition,omitempty"` - SoilReaction *string `json:"soil_reaction,omitempty"` - WinterProtection *string `json:"winter_protection,omitempty"` - SpacingCM *int `json:"spacing_cm,omitempty"` + Global bool `json:"global,omitempty"` + Tags []string `json:"tags,omitempty"` + CommonName *string `json:"common_name,omitempty"` + Cultivar *string `json:"cultivar,omitempty"` + BotanicalName *string `json:"botanical_name,omitempty"` + CategoryID *int `json:"category_id,omitempty"` + ClearCategoryID bool `json:"clear_category_id,omitempty"` + SunExposure *string `json:"sun_exposure,omitempty"` + SoilCondition *string `json:"soil_condition,omitempty"` + SoilReaction *string `json:"soil_reaction,omitempty"` + WinterProtection *string `json:"winter_protection,omitempty"` + SpacingCM *int `json:"spacing_cm,omitempty"` + // HeightCM is retained for compatibility; new code should use HeightCMTo. HeightCM *int `json:"height_cm,omitempty"` + HeightCMFrom *int `json:"height_cm_from,omitempty"` + HeightCMTo *int `json:"height_cm_to,omitempty"` + WidthCMFrom *int `json:"width_cm_from,omitempty"` + WidthCMTo *int `json:"width_cm_to,omitempty"` + ClearHeightCMFrom bool `json:"clear_height_cm_from,omitempty"` + ClearHeightCMTo bool `json:"clear_height_cm_to,omitempty"` + ClearWidthCMFrom bool `json:"clear_width_cm_from,omitempty"` + ClearWidthCMTo bool `json:"clear_width_cm_to,omitempty"` SowMonthFrom *int `json:"sow_month_from,omitempty"` SowDayFrom *int `json:"sow_day_from,omitempty"` ClearSowDayFrom bool `json:"clear_sow_day_from,omitempty"`