Season view: filter the editor to a year (#54) (#66)
Build image / build-and-push (push) Successful in 17s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #66.
This commit is contained in:
2026-07-21 05:47:34 +00:00
committed by steve
parent b96ca1ec0a
commit 8c5ddb21ec
17 changed files with 556 additions and 36 deletions
+62
View File
@@ -62,6 +62,68 @@ func queryPlantings(ctx context.Context, q queryer, query string, args ...any) (
return plantings, nil
}
// ListPlantingsForGardenYear returns every plop in a garden whose time in the
// ground overlaps the given calendar year — the season view (#54).
//
// The interval is [planted_at, removed_at]: planted before the year ended, and
// either still in the ground or removed after the year began. Garlic planted in
// October and pulled the following July therefore appears in BOTH years, which
// is what actually happened.
//
// Undated plantings are always included. Every planting that predates this
// feature has a null planted_at, and a rule that excluded them would empty every
// existing garden the moment a year was selected — an unhelpful way to be
// technically correct.
func (d *DB) ListPlantingsForGardenYear(ctx context.Context, gardenID int64, year int) ([]domain.Planting, error) {
start := fmt.Sprintf("%04d-01-01", year)
end := fmt.Sprintf("%04d-12-31", year)
return queryPlantings(ctx, d.sql,
`SELECT `+qualifyColumns("pl", plantingColumns)+` FROM plantings pl
JOIN garden_objects o ON o.id = pl.object_id
WHERE o.garden_id = ?
AND (pl.planted_at IS NULL OR pl.planted_at <= ?)
AND (pl.removed_at IS NULL OR pl.removed_at >= ?)
ORDER BY pl.id`,
gardenID, end, start)
}
// GardenPlantingYears lists the calendar years a garden has planting data for,
// newest first — every year touched by a [planted_at, removed_at] interval.
//
// Undated plantings contribute no year, deliberately: they belong to every year
// the selector offers, so adding one here would be noise.
func (d *DB) GardenPlantingYears(ctx context.Context, gardenID int64) ([]int, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT DISTINCT CAST(substr(y, 1, 4) AS INTEGER) AS year FROM (
SELECT pl.planted_at AS y FROM plantings pl
JOIN garden_objects o ON o.id = pl.object_id
WHERE o.garden_id = ? AND pl.planted_at IS NOT NULL
UNION
SELECT pl.removed_at AS y FROM plantings pl
JOIN garden_objects o ON o.id = pl.object_id
WHERE o.garden_id = ? AND pl.removed_at IS NOT NULL
)
ORDER BY year DESC`,
gardenID, gardenID)
if err != nil {
return nil, fmt.Errorf("store: garden planting years: %w", err)
}
defer rows.Close()
years := []int{}
for rows.Next() {
var y int
if err := rows.Scan(&y); err != nil {
return nil, fmt.Errorf("store: scan planting year: %w", err)
}
years = append(years, y)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate planting years: %w", err)
}
return years, nil
}
// ListActivePlantingsForObject returns an object's currently-planted plops
// (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid
// stacking new plops inside existing ones.
+19 -7
View File
@@ -24,18 +24,30 @@ func scanPlant(s scanner) (*domain.Plant, error) {
return &p, nil
}
// ListReferencedPlants returns the distinct plants used by a garden's active
// plantings — the catalog subset the editor needs to render them. Always a
// non-nil slice (empty when the garden has no active plantings). This is the
// /full read side; full plant CRUD/seeding lives in plants.go / plantings.go.
func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64) ([]domain.Plant, error) {
// ListReferencedPlants returns the distinct plants used by a garden's plantings
// — the catalog subset the editor needs to render them. Always a non-nil slice.
//
// year nil means the active plops only. A year widens it to the plops that were
// in the ground THAT YEAR, which is what a past season needs: a plant pulled
// last July is not active, but its plops still have to render with the right
// icon and colour. It is scoped to the same year as the plops rather than to the
// garden's whole history, so the two selections always agree and a decade-old
// garden doesn't load a decade of catalog to draw one season.
func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64, year *int) ([]domain.Plant, error) {
where := ` AND pl.removed_at IS NULL`
args := []any{gardenID}
if year != nil {
where = ` AND (pl.planted_at IS NULL OR pl.planted_at <= ?)
AND (pl.removed_at IS NULL OR pl.removed_at >= ?)`
args = append(args, fmt.Sprintf("%04d-12-31", *year), fmt.Sprintf("%04d-01-01", *year))
}
rows, err := d.sql.QueryContext(ctx,
`SELECT DISTINCT `+qualifyColumns("p", plantColumns)+` FROM plants p
JOIN plantings pl ON pl.plant_id = p.id
JOIN garden_objects o ON o.id = pl.object_id
WHERE o.garden_id = ? AND pl.removed_at IS NULL
WHERE o.garden_id = ?`+where+`
ORDER BY p.id`,
gardenID,
args...,
)
if err != nil {
return nil, fmt.Errorf("store: list referenced plants: %w", err)