{{.Label}}
+-
+ {{range .Days}}
+
-
+ Tag {{.Day}}
+ {{if .Tasks}}
+
-
+ {{range .Tasks}}
+
- {{.Type}}: {{.Message}} + {{end}} +
+ {{end}}
+
diff --git a/cmd/server/calendar.go b/cmd/server/calendar.go new file mode 100644 index 0000000..e037bdd --- /dev/null +++ b/cmd/server/calendar.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + "sort" + "strings" +) + +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 buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths int) []CalendarMonth { + tasksByKey := make(map[string][]Task) + for _, p := range plans { + field := p.TargetName + if strings.TrimSpace(field) == "" { + field = "Unbekanntes Feld" + } + tasksByKey[fmt.Sprintf("%d-%d", p.StartMonth, p.StartDay)] = append(tasksByKey[fmt.Sprintf("%d-%d", p.StartMonth, p.StartDay)], Task{ + Type: "Aussaat", + Field: field, + Message: fmt.Sprintf("%s auf %s", p.CropName, field), + SortOrder: 1, + }) + tasksByKey[fmt.Sprintf("%d-%d", p.HarvestMonth, p.HarvestDay)] = append(tasksByKey[fmt.Sprintf("%d-%d", p.HarvestMonth, p.HarvestDay)], Task{ + Type: "Ernte", + Field: field, + Message: fmt.Sprintf("%s auf %s", p.CropName, field), + SortOrder: 2, + }) + } + + var out []CalendarMonth + for offset := 0; offset < spanMonths; offset++ { + month := wrapMonth(startMonth + offset) + yearOffset := (startMonth - 1 + offset) / 12 + label := monthNames[month-1] + if yearOffset > 0 { + label = fmt.Sprintf("%s (+%d Jahr)", label, yearOffset) + } + + fromDay := 1 + if offset == 0 { + fromDay = startDay + } + var days []CalendarDay + for day := fromDay; day <= daysPerMonth; day++ { + key := fmt.Sprintf("%d-%d", month, day) + items := append([]Task(nil), tasksByKey[key]...) + sort.Slice(items, func(i, j int) bool { + if items[i].SortOrder == items[j].SortOrder { + return items[i].Field < items[j].Field + } + return items[i].SortOrder < items[j].SortOrder + }) + days = append(days, CalendarDay{Day: day, Tasks: items}) + } + out = append(out, CalendarMonth{ + Offset: offset, + Month: month, + Label: label, + YearOffset: yearOffset, + Days: days, + }) + } + return out +} diff --git a/cmd/server/db.go b/cmd/server/db.go new file mode 100644 index 0000000..413dd96 --- /dev/null +++ b/cmd/server/db.go @@ -0,0 +1,172 @@ +package main + +import ( + "database/sql" + "fmt" +) + +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, + current_month TINYINT NOT NULL DEFAULT 1, + current_day TINYINT NOT NULL DEFAULT 1 + )`, + `INSERT INTO settings(id,days_per_month,current_month,current_day) VALUES (1,2,1,1) ON DUPLICATE KEY UPDATE id=id`, + `ALTER TABLE settings ADD COLUMN IF NOT EXISTS current_month TINYINT NOT NULL DEFAULT 1`, + `ALTER TABLE settings ADD COLUMN IF NOT EXISTS current_day TINYINT NOT NULL DEFAULT 1`, + + `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 (a *App) getSettings() (Settings, error) { + var s Settings + err := a.db.QueryRow(`SELECT days_per_month,current_month,current_day FROM settings WHERE id=1`). + Scan(&s.DaysPerMonth, &s.CurrentMonth, &s.CurrentDay) + return s, 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 f Field + if err := rows.Scan(&f.ID, &f.Number, &f.Name, &f.Owned, &f.GroupKey, &f.GroupName); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + +func (a *App) getFieldsByIDs(ids []int64) ([]Field, error) { + args := make([]any, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + } + q := fmt.Sprintf(`SELECT id,number,name,owned,COALESCE(group_key,''),COALESCE(group_name,'') FROM fields WHERE id IN (%s)`, placeholders(len(ids))) + rows, err := a.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Field + for rows.Next() { + var f Field + if err := rows.Scan(&f.ID, &f.Number, &f.Name, &f.Owned, &f.GroupKey, &f.GroupName); err != nil { + return nil, err + } + out = append(out, f) + } + 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 c Crop + if err := rows.Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths); err != nil { + return nil, err + } + out = append(out, c) + } + 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 p Plan + if err := rows.Scan(&p.ID, &p.FieldID, &p.TargetRef, &p.TargetName, &p.CropID, &p.CropName, &p.StartMonth, &p.StartDay, &p.HarvestMonth, &p.HarvestDay, &p.Notes); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +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 +} diff --git a/cmd/server/domain.go b/cmd/server/domain.go new file mode 100644 index 0000000..4c246d6 --- /dev/null +++ b/cmd/server/domain.go @@ -0,0 +1,119 @@ +package main + +import ( + "errors" + "fmt" + "sort" + "strconv" + "strings" +) + +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: fieldLabel(f)}) + 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 }) + name := members[0].GroupName + if strings.TrimSpace(name) == "" { + name = autoGroupName(members) + } + out = append(out, PlanningTarget{Ref: "g:" + k, Label: name}) + } + 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 strings.TrimSpace(f.GroupKey) == "" { + continue + } + groupMap[f.GroupKey] = append(groupMap[f.GroupKey], f) + } + + var out []FieldGroup + for key, 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: key, 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 fieldLabel(f Field) string { + base := fmt.Sprintf("Feld %d", f.Number) + if strings.TrimSpace(f.Name) != "" { + base += " (" + f.Name + ")" + } + return base +} + +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 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 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 +} diff --git a/cmd/server/handlers_actions.go b/cmd/server/handlers_actions.go new file mode 100644 index 0000000..d151962 --- /dev/null +++ b/cmd/server/handlers_actions.go @@ -0,0 +1,379 @@ +package main + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +func (a *App) handleSetDaysPerMonth(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, "/general", "error", "Form ungueltig") + return + } + days := mustInt(r.FormValue("days_per_month"), 2) + if days < 1 || days > 31 { + redirectWithMessage(w, r, "/general", "error", "Tage pro Monat 1-31") + return + } + if _, err := a.db.Exec(`UPDATE settings SET days_per_month=? WHERE id=1`, days); err != nil { + redirectWithMessage(w, r, "/general", "error", "Einstellung nicht gespeichert") + return + } + redirectWithMessage(w, r, "/general", "info", "Tage pro Monat gespeichert") +} + +func (a *App) handleSetCurrentTime(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 + } + settings, err := a.getSettings() + if err != nil { + redirectWithMessage(w, r, "/", "error", "Einstellungen nicht lesbar") + return + } + month := mustInt(r.FormValue("current_month"), 1) + day := mustInt(r.FormValue("current_day"), 1) + if month < 1 || month > 12 || day < 1 || day > settings.DaysPerMonth { + redirectWithMessage(w, r, "/", "error", "Ingame-Zeit ungueltig") + return + } + if _, err := a.db.Exec(`UPDATE settings SET current_month=?, current_day=? WHERE id=1`, month, day); err != nil { + redirectWithMessage(w, r, "/", "error", "Ingame-Zeit nicht gespeichert") + return + } + redirectWithMessage(w, r, "/", "info", "Ingame-Zeit 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, "/fields", "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, "/fields", "error", "Feldnummer ungueltig") + return + } + if _, err := a.db.Exec(`INSERT INTO fields(number,name,owned) VALUES (?,?,?)`, number, name, owned); err != nil { + redirectWithMessage(w, r, "/fields", "error", "Feld nicht angelegt") + return + } + redirectWithMessage(w, r, "/fields", "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, "/fields", "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, "/fields", "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, "/fields", "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, "/fields", "error", "Feld nicht aktualisiert") + return + } + } + redirectWithMessage(w, r, "/fields", "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, "/fields", "error", "Form ungueltig") + return + } + id := mustInt64(r.FormValue("id"), 0) + if id <= 0 { + redirectWithMessage(w, r, "/fields", "error", "Feld-ID ungueltig") + return + } + if _, err := a.db.Exec(`DELETE FROM fields WHERE id=?`, id); err != nil { + redirectWithMessage(w, r, "/fields", "error", "Feld nicht geloescht") + return + } + redirectWithMessage(w, r, "/fields", "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, "/fields", "error", "Form ungueltig") + return + } + ids, err := parseInt64List(r.Form["field_ids"]) + if err != nil || len(ids) == 0 { + redirectWithMessage(w, r, "/fields", "error", "Mindestens ein Feld auswaehlen") + return + } + fields, err := a.getFieldsByIDs(ids) + if err != nil || len(fields) != len(ids) { + redirectWithMessage(w, r, "/fields", "error", "Felder nicht gefunden") + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + name = autoGroupName(fields) + } + groupKey := fmt.Sprintf("g%d", time.Now().UnixNano()) + + tx, err := a.db.Begin() + if err != nil { + redirectWithMessage(w, r, "/fields", "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, name, f.ID); err != nil { + redirectWithMessage(w, r, "/fields", "error", "Gruppierung fehlgeschlagen") + return + } + } + if err := tx.Commit(); err != nil { + redirectWithMessage(w, r, "/fields", "error", "Gruppierung fehlgeschlagen") + return + } + redirectWithMessage(w, r, "/fields", "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, "/fields", "error", "Form ungueltig") + return + } + key := strings.TrimSpace(r.FormValue("group_key")) + if key == "" { + redirectWithMessage(w, r, "/fields", "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, "/fields", "error", "Gruppe nicht aufgeloest") + return + } + redirectWithMessage(w, r, "/fields", "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, "/crops", "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, "/crops", "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, "/crops", "error", "Feldfrucht nicht angelegt") + return + } + redirectWithMessage(w, r, "/crops", "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, "/crops", "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, "/crops", "error", "Feldfrucht-ID ungueltig") + return + } + if err := validateCropInput(name, start, end, grow); err != nil { + redirectWithMessage(w, r, "/crops", "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, "/crops", "error", "Feldfrucht nicht aktualisiert") + return + } + redirectWithMessage(w, r, "/crops", "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, "/crops", "error", "Form ungueltig") + return + } + id := mustInt64(r.FormValue("id"), 0) + if id <= 0 { + redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungueltig") + return + } + if _, err := a.db.Exec(`DELETE FROM crops WHERE id=?`, id); err != nil { + redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht geloescht") + return + } + redirectWithMessage(w, r, "/crops", "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, "/planning", "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, "/planning", "error", "Planungsziel oder Feldfrucht ungueltig") + return + } + + settings, err := a.getSettings() + if err != nil { + redirectWithMessage(w, r, "/planning", "error", "Einstellungen nicht lesbar") + return + } + if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > settings.DaysPerMonth { + redirectWithMessage(w, r, "/planning", "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, "/planning", "error", "Feldfrucht nicht gefunden") + return + } + if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) { + redirectWithMessage(w, r, "/planning", "error", "Aussaat ausserhalb des Zeitfensters") + return + } + + fieldID, targetName, err := a.resolvePlanningTarget(targetRef) + if err != nil { + redirectWithMessage(w, r, "/planning", "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, "/planning", "error", "Plan nicht gespeichert") + return + } + redirectWithMessage(w, r, "/planning", "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}, fieldLabel(f), nil + } + if strings.HasPrefix(ref, "g:") { + key := strings.TrimPrefix(ref, "g:") + if key == "" { + 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`, key) + 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`, key).Scan(&groupName) + if strings.TrimSpace(groupName) == "" { + groupName = "Feld " + strings.Join(nums, "+") + } + return sql.NullInt64{}, groupName, nil + } + return sql.NullInt64{}, "", errors.New("Planungsziel ungueltig") +} diff --git a/cmd/server/handlers_pages.go b/cmd/server/handlers_pages.go new file mode 100644 index 0000000..042538b --- /dev/null +++ b/cmd/server/handlers_pages.go @@ -0,0 +1,152 @@ +package main + +import ( + "html/template" + "net/http" +) + +func (a *App) renderTemplate(w http.ResponseWriter, path string, data any) { + tmpl, err := template.ParseFiles(path) + if err != nil { + http.Error(w, "template parse failed", http.StatusInternalServerError) + return + } + if err := tmpl.Execute(w, data); err != nil { + http.Error(w, "template render failed", http.StatusInternalServerError) + } +} + +func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + settings, err := a.getSettings() + if err != nil { + http.Error(w, "settings read failed", http.StatusInternalServerError) + return + } + plans, err := a.listPlans() + if err != nil { + http.Error(w, "plans read failed", http.StatusInternalServerError) + return + } + data := DashboardPage{ + BasePage: BasePage{ + ActivePath: "/", + Error: r.URL.Query().Get("error"), + Info: r.URL.Query().Get("info"), + }, + Settings: settings, + CurrentMonth: monthNames[settings.CurrentMonth-1], + TodayTasks: buildTasksForDay(plans, settings.CurrentMonth, settings.CurrentDay), + Calendar: buildCalendar(plans, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14), + PlanningCount: len(plans), + } + a.renderTemplate(w, "templates/dashboard.html", data) +} + +func (a *App) handleFieldsPage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + fields, err := a.listFields() + if err != nil { + http.Error(w, "fields read failed", http.StatusInternalServerError) + return + } + data := FieldsPage{ + BasePage: BasePage{ + ActivePath: "/fields", + Error: r.URL.Query().Get("error"), + Info: r.URL.Query().Get("info"), + }, + Fields: fields, + Groups: buildFieldGroups(fields), + } + a.renderTemplate(w, "templates/fields.html", data) +} + +func (a *App) handleCropsPage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + crops, err := a.listCrops() + if err != nil { + http.Error(w, "crops read failed", http.StatusInternalServerError) + return + } + data := CropsPage{ + BasePage: BasePage{ + ActivePath: "/crops", + Error: r.URL.Query().Get("error"), + Info: r.URL.Query().Get("info"), + }, + Crops: crops, + } + a.renderTemplate(w, "templates/crops.html", data) +} + +func (a *App) handlePlanningPage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + settings, err := a.getSettings() + if err != nil { + http.Error(w, "settings read failed", http.StatusInternalServerError) + return + } + crops, err := a.listCrops() + if err != nil { + http.Error(w, "crops read failed", http.StatusInternalServerError) + return + } + fields, err := a.listFields() + if err != nil { + http.Error(w, "fields read failed", http.StatusInternalServerError) + return + } + plans, err := a.listPlans() + if err != nil { + http.Error(w, "plans read failed", http.StatusInternalServerError) + return + } + data := PlanningPage{ + BasePage: BasePage{ + ActivePath: "/planning", + Error: r.URL.Query().Get("error"), + Info: r.URL.Query().Get("info"), + }, + Settings: settings, + Months: monthOptions(), + Crops: crops, + Plans: plans, + PlanningTargets: buildPlanningTargets(fields), + } + a.renderTemplate(w, "templates/planning.html", data) +} + +func (a *App) handleGeneralPage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + settings, err := a.getSettings() + if err != nil { + http.Error(w, "settings read failed", http.StatusInternalServerError) + return + } + data := GeneralPage{ + BasePage: BasePage{ + ActivePath: "/general", + Error: r.URL.Query().Get("error"), + Info: r.URL.Query().Get("info"), + }, + Settings: settings, + Months: monthOptions(), + } + a.renderTemplate(w, "templates/general.html", data) +} diff --git a/cmd/server/helpers.go b/cmd/server/helpers.go new file mode 100644 index 0000000..f30b673 --- /dev/null +++ b/cmd/server/helpers.go @@ -0,0 +1,49 @@ +package main + +import ( + "errors" + "net/http" + "net/url" + "strconv" + "strings" +) + +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 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 placeholders(n int) string { + parts := make([]string, 0, n) + for i := 0; i < n; i++ { + parts = append(parts, "?") + } + return strings.Join(parts, ",") +} + +func redirectWithMessage(w http.ResponseWriter, r *http.Request, path, key, val string) { + http.Redirect(w, r, path+"?"+key+"="+url.QueryEscape(val), http.StatusSeeOther) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index e0cfb45..2b4837b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,13 +3,9 @@ package main import ( "database/sql" "errors" - "fmt" - "html/template" "log" "net/http" "os" - "sort" - "strconv" "strings" "time" @@ -22,85 +18,7 @@ var monthNames = []string{ } 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 + db *sql.DB } func main() { @@ -120,23 +38,28 @@ func main() { 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} + app := &App{db: db} mux := http.NewServeMux() - mux.HandleFunc("/", app.handleIndex) - mux.HandleFunc("/settings", app.handleSettings) + + mux.HandleFunc("/", app.handleDashboard) + mux.HandleFunc("/fields", app.handleFieldsPage) + mux.HandleFunc("/crops", app.handleCropsPage) + mux.HandleFunc("/planning", app.handlePlanningPage) + mux.HandleFunc("/general", app.handleGeneralPage) + + mux.HandleFunc("/settings/days", app.handleSetDaysPerMonth) + mux.HandleFunc("/settings/time", app.handleSetCurrentTime) + 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")))) @@ -151,763 +74,6 @@ func main() { } } -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 == "" { diff --git a/cmd/server/types.go b/cmd/server/types.go new file mode 100644 index 0000000..fcc1b17 --- /dev/null +++ b/cmd/server/types.go @@ -0,0 +1,117 @@ +package main + +import "database/sql" + +type Settings struct { + DaysPerMonth int + CurrentMonth int + CurrentDay int +} + +type Field struct { + ID int64 + Number int + Name string + Owned bool + GroupKey string + GroupName string +} + +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 CalendarDay struct { + Day int + Tasks []Task +} + +type CalendarMonth struct { + Offset int + Month int + Label string + YearOffset int + Days []CalendarDay +} + +type BasePage struct { + ActivePath string + Error string + Info string +} + +type DashboardPage struct { + BasePage + Settings Settings + CurrentMonth string + TodayTasks []Task + Calendar []CalendarMonth + PlanningCount int +} + +type FieldsPage struct { + BasePage + Fields []Field + Groups []FieldGroup +} + +type CropsPage struct { + BasePage + Crops []Crop +} + +type PlanningPage struct { + BasePage + Settings Settings + Months []MonthOption + Crops []Crop + Plans []Plan + PlanningTargets []PlanningTarget +} + +type GeneralPage struct { + BasePage + Settings Settings + Months []MonthOption +} diff --git a/static/styles.css b/static/styles.css index 79e52d6..9dcb391 100644 --- a/static/styles.css +++ b/static/styles.css @@ -24,6 +24,29 @@ body { .top h1 { margin: 0; } .top p { margin: .35rem 0 0; opacity: .9; } +.tabs { + display: flex; + gap: .5rem; + flex-wrap: wrap; + padding: .7rem 1rem; + max-width: 1280px; + margin: 0 auto; +} +.tabs a { + text-decoration: none; + color: #234020; + background: #e7efdf; + border: 1px solid #cbd8c3; + border-radius: 999px; + padding: .35rem .75rem; + font-weight: 600; +} +.tabs a.active { + color: #fff; + background: var(--accent); + border-color: var(--accent); +} + .layout { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); @@ -44,6 +67,7 @@ body { margin-top: 0; font-size: 1.05rem; } +.full-width { grid-column: 1 / -1; } .grid { display: grid; @@ -109,6 +133,34 @@ button:hover { background: #2d5124; } padding-left: 1.1rem; } .tasks li { margin: .35rem 0; } +.task-sublist { + margin: .3rem 0 0; + padding-left: 1rem; +} +.muted { color: var(--muted); } + +.calendar-grid { + display: grid; + gap: .75rem; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); +} +.month-card { + border: 1px solid #dbe6d5; + border-radius: 10px; + padding: .6rem; + background: #f9fbf7; +} +.month-card h3 { + margin: 0 0 .4rem; + font-size: .98rem; +} +.month-days { + margin: 0; + padding-left: 1rem; + max-height: 260px; + overflow: auto; +} +.month-days li { margin: .25rem 0; } .table-wrap { overflow: auto; } table { diff --git a/templates/crops.html b/templates/crops.html new file mode 100644 index 0000000..4d4e31e --- /dev/null +++ b/templates/crops.html @@ -0,0 +1,66 @@ + + +
+ + +Feldfruechte verwalten
| Name | Aussaat | Wachstum | Aktionen |
|---|---|---|---|
| Keine Feldfruechte vorhanden. | |||
Dashboard
+{{.CurrentMonth}} Tag {{.Settings.CurrentDay}} bei {{.Settings.DaysPerMonth}} Tagen pro Monat.
+{{.PlanningCount}} Plan-Eintraege insgesamt.
+ +Keine Aufgaben fuer den aktuellen Ingame-Tag.
+ {{end}} +Felder und Feldgruppen
| Nummer | Name | Besitz | Gruppe | Aktionen |
|---|---|---|---|---|
| Keine Felder vorhanden. | ||||
Mehrfachauswahl mit Strg/Cmd.
+ +| Name | Felder | Aktion |
|---|---|---|
| {{.Name}} | +{{.Numbers}} | ++ + | +
| Keine Gruppen vorhanden. | ||
Allgemeine Einstellungen
Planung fuer Felder, Feldgruppen, Aussaat und Ernte
-Keine Aufgaben fuer diesen Ingame-Tag.
- {{end}} -| Nummer | -Name | -Besitz | -Gruppe | -Aktionen | -
|---|---|---|---|---|
| Noch keine Felder vorhanden. | ||||
Mehrfachauswahl: mit Strg/Cmd klicken.
- -| Name | -Felder | -Aktion | -
|---|---|---|
| {{.Name}} | -{{.Numbers}} | -
- |
-
| Noch keine Feldgruppen vorhanden. | ||
| Name | -Aussaat | -Wachstum | -Aktionen | -
|---|---|---|---|
| - - - | -- - bis - - | -- - | -
-
-
- |
-
| Keine Feldfruechte vorhanden. | |||
| Ziel | -Frucht | -Aussaat | -Ernte | -Notiz | -
|---|---|---|---|---|
| {{.TargetName}} | -{{.CropName}} | -Monat {{.StartMonth}} Tag {{.StartDay}} | -Monat {{.HarvestMonth}} Tag {{.HarvestDay}} | -{{.Notes}} | -
| Noch keine Planung vorhanden. | ||||
Anbau planen
| Ziel | Frucht | Aussaat | Ernte | Notiz |
|---|---|---|---|---|
| {{.TargetName}} | +{{.CropName}} | +Monat {{.StartMonth}} Tag {{.StartDay}} | +Monat {{.HarvestMonth}} Tag {{.HarvestDay}} | +{{.Notes}} | +
| Keine Planung vorhanden. | ||||