package main import ( "database/sql" "errors" "fmt" "html/template" "log" "net/http" "os" "sort" "strconv" "strings" "time" _ "github.com/go-sql-driver/mysql" ) var monthNames = []string{ "Januar", "Februar", "Maerz", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", } type App struct { db *sql.DB tmpl *template.Template } type Field struct { ID int64 Number int Name string Owned bool GroupKey string GroupName string } func (f Field) Label() string { base := fmt.Sprintf("Feld %d", f.Number) if f.Name != "" { base += " (" + f.Name + ")" } return base } type FieldGroup struct { Key string Name string Numbers string } type Crop struct { ID int64 Name string SowStartMonth int SowEndMonth int GrowMonths int } type Plan struct { ID int64 FieldID sql.NullInt64 TargetRef string TargetName string CropID int64 CropName string StartMonth int StartDay int HarvestMonth int HarvestDay int Notes string } type Task struct { Type string Field string Message string SortOrder int } type PlanningTarget struct { Ref string Label string } type MonthOption struct { Value int Label string } type PageData struct { NowMonth int NowDay int DaysPerMonth int Months []MonthOption Fields []Field Groups []FieldGroup Crops []Crop Plans []Plan Tasks []Task PlanningTargets []PlanningTarget Error string Info string } func main() { addr := readEnv("APP_ADDR", ":8080") dsn := readEnv("DB_DSN", "farmcal:farmcal@tcp(127.0.0.1:3306)/farmcal?parseTime=true") db, err := sql.Open("mysql", dsn) if err != nil { log.Fatalf("db open failed: %v", err) } defer db.Close() if err := db.Ping(); err != nil { log.Fatalf("db ping failed: %v", err) } if err := ensureSchema(db); err != nil { log.Fatalf("schema setup failed: %v", err) } tmpl, err := template.ParseFiles("templates/index.html") if err != nil { log.Fatalf("template parse failed: %v", err) } app := &App{db: db, tmpl: tmpl} mux := http.NewServeMux() mux.HandleFunc("/", app.handleIndex) mux.HandleFunc("/settings", app.handleSettings) mux.HandleFunc("/fields/create", app.handleCreateField) mux.HandleFunc("/fields/update", app.handleUpdateField) mux.HandleFunc("/fields/delete", app.handleDeleteField) mux.HandleFunc("/field-groups/create", app.handleCreateFieldGroup) mux.HandleFunc("/field-groups/delete", app.handleDeleteFieldGroup) mux.HandleFunc("/crops/create", app.handleCreateCrop) mux.HandleFunc("/crops/update", app.handleUpdateCrop) mux.HandleFunc("/crops/delete", app.handleDeleteCrop) mux.HandleFunc("/plans/create", app.handleCreatePlan) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) srv := &http.Server{ Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 5 * time.Second, } log.Printf("FarmCal listening on %s", addr) if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { log.Fatalf("server stopped: %v", err) } } func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } daysPerMonth, err := a.getDaysPerMonth() if err != nil { http.Error(w, "settings read failed", http.StatusInternalServerError) return } nowMonth := mustInt(r.URL.Query().Get("month"), 1) nowDay := mustInt(r.URL.Query().Get("day"), 1) if nowMonth < 1 || nowMonth > 12 { nowMonth = 1 } if nowDay < 1 || nowDay > daysPerMonth { nowDay = 1 } fields, err := a.listFields() if err != nil { http.Error(w, "fields read failed", http.StatusInternalServerError) return } groups := buildFieldGroups(fields) crops, err := a.listCrops() if err != nil { http.Error(w, "crops read failed", http.StatusInternalServerError) return } plans, err := a.listPlans() if err != nil { http.Error(w, "plans read failed", http.StatusInternalServerError) return } data := PageData{ NowMonth: nowMonth, NowDay: nowDay, DaysPerMonth: daysPerMonth, Months: monthOptions(), Fields: fields, Groups: groups, Crops: crops, Plans: plans, Tasks: buildTasksForDay(plans, nowMonth, nowDay), PlanningTargets: buildPlanningTargets(fields), Error: r.URL.Query().Get("error"), Info: r.URL.Query().Get("info"), } if err := a.tmpl.Execute(w, data); err != nil { http.Error(w, "template render failed", http.StatusInternalServerError) } } func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { redirectWithMessage(w, r, "error", "Form ungueltig") return } daysPerMonth := mustInt(r.FormValue("days_per_month"), 2) if daysPerMonth < 1 || daysPerMonth > 31 { redirectWithMessage(w, r, "error", "Tage pro Monat 1-31") return } if _, err := a.db.Exec(`UPDATE settings SET days_per_month=? WHERE id=1`, daysPerMonth); err != nil { redirectWithMessage(w, r, "error", "Einstellungen nicht gespeichert") return } redirectWithMessage(w, r, "info", "Einstellungen gespeichert") } func (a *App) handleCreateField(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { redirectWithMessage(w, r, "error", "Form ungueltig") return } number := mustInt(r.FormValue("number"), 0) name := strings.TrimSpace(r.FormValue("name")) owned := r.FormValue("owned") == "on" if number <= 0 { redirectWithMessage(w, r, "error", "Feldnummer ungueltig") return } if _, err := a.db.Exec(`INSERT INTO fields(number,name,owned) VALUES (?,?,?)`, number, name, owned); err != nil { redirectWithMessage(w, r, "error", "Feld nicht angelegt (Nummer evtl. schon vorhanden)") return } redirectWithMessage(w, r, "info", "Feld angelegt") } func (a *App) handleUpdateField(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { redirectWithMessage(w, r, "error", "Form ungueltig") return } id := mustInt64(r.FormValue("id"), 0) name := strings.TrimSpace(r.FormValue("name")) owned := r.FormValue("owned") == "on" if id <= 0 { redirectWithMessage(w, r, "error", "Feld-ID ungueltig") return } if owned { if _, err := a.db.Exec(`UPDATE fields SET name=?,owned=? WHERE id=?`, name, owned, id); err != nil { redirectWithMessage(w, r, "error", "Feld nicht aktualisiert") return } } else { if _, err := a.db.Exec(`UPDATE fields SET name=?,owned=?,group_key='',group_name='' WHERE id=?`, name, owned, id); err != nil { redirectWithMessage(w, r, "error", "Feld nicht aktualisiert") return } } redirectWithMessage(w, r, "info", "Feld aktualisiert") } func (a *App) handleDeleteField(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { redirectWithMessage(w, r, "error", "Form ungueltig") return } id := mustInt64(r.FormValue("id"), 0) if id <= 0 { redirectWithMessage(w, r, "error", "Feld-ID ungueltig") return } if _, err := a.db.Exec(`DELETE FROM fields WHERE id=?`, id); err != nil { redirectWithMessage(w, r, "error", "Feld nicht geloescht") return } redirectWithMessage(w, r, "info", "Feld geloescht") } func (a *App) handleCreateFieldGroup(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { redirectWithMessage(w, r, "error", "Form ungueltig") return } ids, err := parseInt64List(r.Form["field_ids"]) if err != nil || len(ids) == 0 { redirectWithMessage(w, r, "error", "Mindestens ein Feld auswaehlen") return } fields, err := a.getFieldsByIDs(ids) if err != nil || len(fields) != len(ids) { redirectWithMessage(w, r, "error", "Felder nicht eindeutig gefunden") return } groupName := strings.TrimSpace(r.FormValue("name")) if groupName == "" { groupName = autoGroupName(fields) } groupKey := fmt.Sprintf("g%d", time.Now().UnixNano()) tx, err := a.db.Begin() if err != nil { redirectWithMessage(w, r, "error", "Gruppierung fehlgeschlagen") return } defer tx.Rollback() for _, f := range fields { if _, err := tx.Exec(`UPDATE fields SET group_key=?,group_name=? WHERE id=?`, groupKey, groupName, f.ID); err != nil { redirectWithMessage(w, r, "error", "Gruppierung fehlgeschlagen") return } } if err := tx.Commit(); err != nil { redirectWithMessage(w, r, "error", "Gruppierung fehlgeschlagen") return } redirectWithMessage(w, r, "info", "Feldgruppe gespeichert") } func (a *App) handleDeleteFieldGroup(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { redirectWithMessage(w, r, "error", "Form ungueltig") return } key := strings.TrimSpace(r.FormValue("group_key")) if key == "" { redirectWithMessage(w, r, "error", "Gruppen-ID ungueltig") return } if _, err := a.db.Exec(`UPDATE fields SET group_key='',group_name='' WHERE group_key=?`, key); err != nil { redirectWithMessage(w, r, "error", "Gruppe nicht aufgeloest") return } redirectWithMessage(w, r, "info", "Gruppe aufgeloest") } func (a *App) handleCreateCrop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { redirectWithMessage(w, r, "error", "Form ungueltig") return } name := strings.TrimSpace(r.FormValue("name")) start := mustInt(r.FormValue("sow_start_month"), 0) end := mustInt(r.FormValue("sow_end_month"), 0) grow := mustInt(r.FormValue("grow_months"), 0) if err := validateCropInput(name, start, end, grow); err != nil { redirectWithMessage(w, r, "error", err.Error()) return } if _, err := a.db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months) VALUES (?,?,?,?)`, name, start, end, grow); err != nil { redirectWithMessage(w, r, "error", "Feldfrucht nicht angelegt") return } redirectWithMessage(w, r, "info", "Feldfrucht angelegt") } func (a *App) handleUpdateCrop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { redirectWithMessage(w, r, "error", "Form ungueltig") return } id := mustInt64(r.FormValue("id"), 0) name := strings.TrimSpace(r.FormValue("name")) start := mustInt(r.FormValue("sow_start_month"), 0) end := mustInt(r.FormValue("sow_end_month"), 0) grow := mustInt(r.FormValue("grow_months"), 0) if id <= 0 { redirectWithMessage(w, r, "error", "Feldfrucht-ID ungueltig") return } if err := validateCropInput(name, start, end, grow); err != nil { redirectWithMessage(w, r, "error", err.Error()) return } if _, err := a.db.Exec(`UPDATE crops SET name=?,sow_start_month=?,sow_end_month=?,grow_months=? WHERE id=?`, name, start, end, grow, id); err != nil { redirectWithMessage(w, r, "error", "Feldfrucht nicht aktualisiert") return } redirectWithMessage(w, r, "info", "Feldfrucht aktualisiert") } func (a *App) handleDeleteCrop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { redirectWithMessage(w, r, "error", "Form ungueltig") return } id := mustInt64(r.FormValue("id"), 0) if id <= 0 { redirectWithMessage(w, r, "error", "Feldfrucht-ID ungueltig") return } if _, err := a.db.Exec(`DELETE FROM crops WHERE id=?`, id); err != nil { redirectWithMessage(w, r, "error", "Feldfrucht nicht geloescht (ggf. in Verwendung)") return } redirectWithMessage(w, r, "info", "Feldfrucht geloescht") } func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { redirectWithMessage(w, r, "error", "Form ungueltig") return } targetRef := strings.TrimSpace(r.FormValue("target_ref")) cropID := mustInt64(r.FormValue("crop_id"), 0) startMonth := mustInt(r.FormValue("start_month"), 1) startDay := mustInt(r.FormValue("start_day"), 1) notes := strings.TrimSpace(r.FormValue("notes")) if targetRef == "" || cropID <= 0 { redirectWithMessage(w, r, "error", "Ziel oder Feldfrucht ungueltig") return } daysPerMonth, err := a.getDaysPerMonth() if err != nil { redirectWithMessage(w, r, "error", "Einstellungen nicht lesbar") return } if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > daysPerMonth { redirectWithMessage(w, r, "error", "Startdatum ungueltig") return } var c Crop err = a.db.QueryRow(`SELECT id,name,sow_start_month,sow_end_month,grow_months FROM crops WHERE id=?`, cropID). Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths) if err != nil { redirectWithMessage(w, r, "error", "Feldfrucht nicht gefunden") return } if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) { redirectWithMessage(w, r, "error", "Aussaat ausserhalb des Zeitfensters") return } fieldID, targetName, err := a.resolvePlanningTarget(targetRef) if err != nil { redirectWithMessage(w, r, "error", err.Error()) return } harvestMonth := wrapMonth(startMonth + c.GrowMonths) harvestDay := startDay _, err = a.db.Exec( `INSERT INTO plans(field_id,target_ref,target_name,crop_id,start_month,start_day,harvest_month,harvest_day,notes) VALUES (?,?,?,?,?,?,?,?,?)`, fieldID, targetRef, targetName, cropID, startMonth, startDay, harvestMonth, harvestDay, notes, ) if err != nil { redirectWithMessage(w, r, "error", "Plan nicht gespeichert") return } redirectWithMessage(w, r, "info", "Plan gespeichert") } func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) { if strings.HasPrefix(ref, "f:") { id := mustInt64(strings.TrimPrefix(ref, "f:"), 0) if id <= 0 { return sql.NullInt64{}, "", errors.New("Feld-Ziel ungueltig") } var f Field err := a.db.QueryRow(`SELECT id,number,name,owned FROM fields WHERE id=?`, id). Scan(&f.ID, &f.Number, &f.Name, &f.Owned) if err != nil { return sql.NullInt64{}, "", errors.New("Feld nicht gefunden") } if !f.Owned { return sql.NullInt64{}, "", errors.New("Feld ist nicht im Besitz") } return sql.NullInt64{Int64: f.ID, Valid: true}, f.Label(), nil } if strings.HasPrefix(ref, "g:") { groupKey := strings.TrimPrefix(ref, "g:") if groupKey == "" { return sql.NullInt64{}, "", errors.New("Gruppen-Ziel ungueltig") } rows, err := a.db.Query(`SELECT number FROM fields WHERE group_key=? AND owned=1 ORDER BY number`, groupKey) if err != nil { return sql.NullInt64{}, "", errors.New("Gruppe nicht lesbar") } defer rows.Close() var nums []string for rows.Next() { var n int if err := rows.Scan(&n); err != nil { return sql.NullInt64{}, "", errors.New("Gruppe nicht lesbar") } nums = append(nums, strconv.Itoa(n)) } if len(nums) == 0 { return sql.NullInt64{}, "", errors.New("Gruppe hat keine Felder im Besitz") } var groupName string _ = a.db.QueryRow(`SELECT COALESCE(group_name,'') FROM fields WHERE group_key=? LIMIT 1`, groupKey).Scan(&groupName) if strings.TrimSpace(groupName) == "" { groupName = "Feld " + strings.Join(nums, "+") } return sql.NullInt64{}, groupName, nil } return sql.NullInt64{}, "", errors.New("Planungsziel ungueltig") } func (a *App) getDaysPerMonth() (int, error) { var v int err := a.db.QueryRow(`SELECT days_per_month FROM settings WHERE id=1`).Scan(&v) return v, err } func (a *App) listFields() ([]Field, error) { rows, err := a.db.Query(`SELECT id,number,name,owned,COALESCE(group_key,''),COALESCE(group_name,'') FROM fields ORDER BY number`) if err != nil { return nil, err } defer rows.Close() var out []Field for rows.Next() { var x Field if err := rows.Scan(&x.ID, &x.Number, &x.Name, &x.Owned, &x.GroupKey, &x.GroupName); err != nil { return nil, err } out = append(out, x) } return out, rows.Err() } func (a *App) getFieldsByIDs(ids []int64) ([]Field, error) { if len(ids) == 0 { return nil, nil } ph := placeholders(len(ids)) args := make([]any, 0, len(ids)) for _, id := range ids { args = append(args, id) } rows, err := a.db.Query( fmt.Sprintf(`SELECT id,number,name,owned,COALESCE(group_key,''),COALESCE(group_name,'') FROM fields WHERE id IN (%s)`, ph), args..., ) if err != nil { return nil, err } defer rows.Close() var out []Field for rows.Next() { var x Field if err := rows.Scan(&x.ID, &x.Number, &x.Name, &x.Owned, &x.GroupKey, &x.GroupName); err != nil { return nil, err } out = append(out, x) } return out, rows.Err() } func (a *App) listCrops() ([]Crop, error) { rows, err := a.db.Query(`SELECT id,name,sow_start_month,sow_end_month,grow_months FROM crops ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []Crop for rows.Next() { var x Crop if err := rows.Scan(&x.ID, &x.Name, &x.SowStartMonth, &x.SowEndMonth, &x.GrowMonths); err != nil { return nil, err } out = append(out, x) } return out, rows.Err() } func (a *App) listPlans() ([]Plan, error) { rows, err := a.db.Query(` SELECT p.id,p.field_id,COALESCE(p.target_ref,''),COALESCE(p.target_name,''),p.crop_id,COALESCE(c.name,''),p.start_month,p.start_day,p.harvest_month,p.harvest_day,COALESCE(p.notes,'') FROM plans p JOIN crops c ON c.id=p.crop_id ORDER BY p.start_month,p.start_day,p.id DESC`) if err != nil { return nil, err } defer rows.Close() var out []Plan for rows.Next() { var x Plan if err := rows.Scan(&x.ID, &x.FieldID, &x.TargetRef, &x.TargetName, &x.CropID, &x.CropName, &x.StartMonth, &x.StartDay, &x.HarvestMonth, &x.HarvestDay, &x.Notes); err != nil { return nil, err } out = append(out, x) } return out, rows.Err() } func ensureSchema(db *sql.DB) error { stmts := []string{ `CREATE TABLE IF NOT EXISTS settings(id TINYINT PRIMARY KEY,days_per_month INT NOT NULL DEFAULT 2)`, `INSERT INTO settings(id,days_per_month) VALUES (1,2) ON DUPLICATE KEY UPDATE id=id`, `CREATE TABLE IF NOT EXISTS fields( id BIGINT AUTO_INCREMENT PRIMARY KEY, number INT NOT NULL UNIQUE, name VARCHAR(120) NOT NULL DEFAULT '', owned TINYINT(1) NOT NULL DEFAULT 0, group_key VARCHAR(64) NOT NULL DEFAULT '', group_name VARCHAR(120) NOT NULL DEFAULT '', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `ALTER TABLE fields ADD COLUMN IF NOT EXISTS owned TINYINT(1) NOT NULL DEFAULT 0`, `ALTER TABLE fields ADD COLUMN IF NOT EXISTS group_key VARCHAR(64) NOT NULL DEFAULT ''`, `ALTER TABLE fields ADD COLUMN IF NOT EXISTS group_name VARCHAR(120) NOT NULL DEFAULT ''`, `CREATE TABLE IF NOT EXISTS crops( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(80) NOT NULL UNIQUE, sow_start_month TINYINT NOT NULL, sow_end_month TINYINT NOT NULL, grow_months TINYINT NOT NULL )`, `CREATE TABLE IF NOT EXISTS plans( id BIGINT AUTO_INCREMENT PRIMARY KEY, field_id BIGINT NULL, target_ref VARCHAR(80) NOT NULL DEFAULT '', target_name VARCHAR(140) NOT NULL DEFAULT '', crop_id BIGINT NOT NULL, start_month TINYINT NOT NULL, start_day TINYINT NOT NULL, harvest_month TINYINT NOT NULL, harvest_day TINYINT NOT NULL, notes VARCHAR(255) NOT NULL DEFAULT '', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_plans_fields FOREIGN KEY(field_id) REFERENCES fields(id) ON DELETE CASCADE, CONSTRAINT fk_plans_crops FOREIGN KEY(crop_id) REFERENCES crops(id) ON DELETE RESTRICT )`, `ALTER TABLE plans MODIFY COLUMN field_id BIGINT NULL`, `ALTER TABLE plans ADD COLUMN IF NOT EXISTS target_ref VARCHAR(80) NOT NULL DEFAULT '' AFTER field_id`, `ALTER TABLE plans ADD COLUMN IF NOT EXISTS target_name VARCHAR(140) NOT NULL DEFAULT '' AFTER target_ref`, } for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { return err } } return seedCrops(db) } func seedCrops(db *sql.DB) error { items := []Crop{ {Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10}, {Name: "Gerste", SowStartMonth: 9, SowEndMonth: 10, GrowMonths: 9}, {Name: "Hafer", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 4}, {Name: "Raps", SowStartMonth: 8, SowEndMonth: 9, GrowMonths: 11}, {Name: "Mais", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 6}, {Name: "Sorghum", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5}, {Name: "Sojabohnen", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5}, {Name: "Sonnenblumen", SowStartMonth: 4, SowEndMonth: 5, GrowMonths: 5}, {Name: "Kartoffeln", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 7}, {Name: "Zuckerrueben", SowStartMonth: 3, SowEndMonth: 4, GrowMonths: 8}, {Name: "Baumwolle", SowStartMonth: 2, SowEndMonth: 3, GrowMonths: 8}, } for _, c := range items { if _, err := db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE sow_start_month=VALUES(sow_start_month),sow_end_month=VALUES(sow_end_month),grow_months=VALUES(grow_months)`, c.Name, c.SowStartMonth, c.SowEndMonth, c.GrowMonths); err != nil { return err } } return nil } func buildPlanningTargets(fields []Field) []PlanningTarget { groupMap := make(map[string][]Field) var out []PlanningTarget for _, f := range fields { if !f.Owned { continue } if f.GroupKey == "" { out = append(out, PlanningTarget{ Ref: fmt.Sprintf("f:%d", f.ID), Label: f.Label(), }) continue } groupMap[f.GroupKey] = append(groupMap[f.GroupKey], f) } groupKeys := make([]string, 0, len(groupMap)) for k := range groupMap { groupKeys = append(groupKeys, k) } sort.Strings(groupKeys) for _, k := range groupKeys { members := groupMap[k] sort.Slice(members, func(i, j int) bool { return members[i].Number < members[j].Number }) label := members[0].GroupName if strings.TrimSpace(label) == "" { label = autoGroupName(members) } out = append(out, PlanningTarget{ Ref: "g:" + k, Label: label, }) } sort.Slice(out, func(i, j int) bool { return out[i].Label < out[j].Label }) return out } func buildFieldGroups(fields []Field) []FieldGroup { groupMap := make(map[string][]Field) for _, f := range fields { if f.GroupKey == "" { continue } groupMap[f.GroupKey] = append(groupMap[f.GroupKey], f) } var out []FieldGroup for k, members := range groupMap { sort.Slice(members, func(i, j int) bool { return members[i].Number < members[j].Number }) nums := make([]string, 0, len(members)) for _, m := range members { nums = append(nums, strconv.Itoa(m.Number)) } name := members[0].GroupName if strings.TrimSpace(name) == "" { name = autoGroupName(members) } out = append(out, FieldGroup{ Key: k, Name: name, Numbers: strings.Join(nums, "+"), }) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } func autoGroupName(fields []Field) string { sort.Slice(fields, func(i, j int) bool { return fields[i].Number < fields[j].Number }) parts := make([]string, 0, len(fields)) for _, f := range fields { parts = append(parts, strconv.Itoa(f.Number)) } return "Feld " + strings.Join(parts, "+") } func buildTasksForDay(plans []Plan, month, day int) []Task { var tasks []Task for _, p := range plans { field := p.TargetName if strings.TrimSpace(field) == "" { field = "Unbekanntes Feld" } if p.StartMonth == month && p.StartDay == day { tasks = append(tasks, Task{Type: "Aussaat", Field: field, Message: fmt.Sprintf("%s auf %s aussaeen", p.CropName, field), SortOrder: 1}) } if p.HarvestMonth == month && p.HarvestDay == day { tasks = append(tasks, Task{Type: "Ernte", Field: field, Message: fmt.Sprintf("%s auf %s ernten", p.CropName, field), SortOrder: 2}) } } sort.Slice(tasks, func(i, j int) bool { if tasks[i].SortOrder == tasks[j].SortOrder { return tasks[i].Field < tasks[j].Field } return tasks[i].SortOrder < tasks[j].SortOrder }) return tasks } func monthOptions() []MonthOption { out := make([]MonthOption, 0, 12) for i, m := range monthNames { out = append(out, MonthOption{Value: i + 1, Label: m}) } return out } func validateCropInput(name string, start, end, grow int) error { if name == "" { return errors.New("Name der Feldfrucht fehlt") } if start < 1 || start > 12 || end < 1 || end > 12 { return errors.New("Aussaatmonat muss 1-12 sein") } if grow < 1 || grow > 24 { return errors.New("Wachstumsdauer muss 1-24 sein") } return nil } func monthInWindow(month, start, end int) bool { if start <= end { return month >= start && month <= end } return month >= start || month <= end } func wrapMonth(v int) int { m := v % 12 if m == 0 { return 12 } return m } func placeholders(n int) string { if n <= 0 { return "" } parts := make([]string, 0, n) for i := 0; i < n; i++ { parts = append(parts, "?") } return strings.Join(parts, ",") } func parseInt64List(values []string) ([]int64, error) { var out []int64 for _, v := range values { id := mustInt64(v, 0) if id <= 0 { return nil, errors.New("invalid id") } out = append(out, id) } return out, nil } func redirectWithMessage(w http.ResponseWriter, r *http.Request, key, value string) { target := fmt.Sprintf("/?%s=%s", key, strings.ReplaceAll(value, " ", "+")) http.Redirect(w, r, target, http.StatusSeeOther) } func mustInt(v string, fallback int) int { n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil { return fallback } return n } func mustInt64(v string, fallback int64) int64 { n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) if err != nil { return fallback } return n } func readEnv(key, fallback string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } return v } func withLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) }) }