Add themed UI, favicon, plan deletion, regrow cycles, and crop prep/post tasks

This commit is contained in:
Kai
2026-02-16 13:27:47 +01:00
parent 51181a83c8
commit a1c1ef31a3
14 changed files with 552 additions and 127 deletions

View File

@@ -6,48 +6,30 @@ import (
"strings"
)
func buildTasksForDay(plans []Plan, month, day int) []Task {
func buildTasksForDay(plans []Plan, stepMap map[int64][]CropStep, 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})
}
tasks = append(tasks, expandPlanDayTasks(p, stepMap[p.CropID], month, day)...)
}
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
})
sortTasks(tasks)
return tasks
}
func buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths int) []CalendarMonth {
func buildCalendar(plans []Plan, stepMap map[int64][]CropStep, 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"
for m := 1; m <= 12; m++ {
for d := 1; d <= daysPerMonth; d++ {
dayTasks := expandPlanDayTasks(p, stepMap[p.CropID], m, d)
if len(dayTasks) > 0 {
key := fmt.Sprintf("%d-%d", m, d)
tasksByKey[key] = append(tasksByKey[key], dayTasks...)
}
}
}
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("Aussaat %s", p.CropName),
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("Ernte %s", p.CropName),
SortOrder: 2,
})
}
for k := range tasksByKey {
sortTasks(tasksByKey[k])
}
var out []CalendarMonth
@@ -64,16 +46,10 @@ func buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths
fromDay = startDay
}
var days []CalendarDay
for day := fromDay; day <= daysPerMonth; day++ {
key := fmt.Sprintf("%d-%d", month, day)
for d := fromDay; d <= daysPerMonth; d++ {
key := fmt.Sprintf("%d-%d", month, d)
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})
days = append(days, CalendarDay{Day: d, Tasks: items})
}
out = append(out, CalendarMonth{
Offset: offset,
@@ -85,3 +61,111 @@ func buildCalendar(plans []Plan, startMonth, startDay, daysPerMonth, spanMonths
}
return out
}
func expandPlanDayTasks(p Plan, steps []CropStep, month, day int) []Task {
field := p.TargetName
if strings.TrimSpace(field) == "" {
field = "Unbekanntes Feld"
}
var out []Task
baseHarvest := p.HarvestMonth
if p.StartMonth == month && p.StartDay == day {
out = append(out, Task{
Type: "Aussaat",
Field: field,
Message: withOptionalNote(fmt.Sprintf("Aussaat %s", p.CropName), p.Notes),
SortOrder: 10,
})
}
harvestMonths := []int{baseHarvest}
if p.RegrowEnabled {
maxExtra := p.RegrowCycles
if maxExtra == 0 {
maxExtra = 24
}
for i := 1; i <= maxExtra; i++ {
harvestMonths = append(harvestMonths, wrapMonth(baseHarvest+(i*p.HarvestDistance())))
}
}
harvestMonths = uniqueMonths(harvestMonths)
for _, hm := range harvestMonths {
if hm == month && p.HarvestDay == day {
out = append(out, Task{
Type: "Ernte",
Field: field,
Message: withOptionalNote(fmt.Sprintf("Ernte %s", p.CropName), p.Notes),
SortOrder: 20,
})
}
}
for _, s := range steps {
switch s.Phase {
case "pre":
taskMonth := wrapMonth(p.StartMonth - s.MonthOffset)
if taskMonth == month && p.StartDay == day {
out = append(out, Task{
Type: "Vorbereitung",
Field: field,
Message: s.Title,
SortOrder: 5,
})
}
case "post":
for _, hm := range harvestMonths {
taskMonth := wrapMonth(hm + s.MonthOffset)
if taskMonth == month && p.HarvestDay == day {
out = append(out, Task{
Type: "Nachbereitung",
Field: field,
Message: s.Title,
SortOrder: 30,
})
}
}
}
}
return out
}
func (p Plan) HarvestDistance() int {
if p.GrowMonths <= 0 {
return 1
}
return p.GrowMonths
}
func withOptionalNote(base, note string) string {
n := strings.TrimSpace(note)
if n == "" {
return base
}
return fmt.Sprintf("%s (Notiz: %s)", base, n)
}
func sortTasks(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
if tasks[i].SortOrder == tasks[j].SortOrder {
if tasks[i].Field == tasks[j].Field {
return tasks[i].Message < tasks[j].Message
}
return tasks[i].Field < tasks[j].Field
}
return tasks[i].SortOrder < tasks[j].SortOrder
})
}
func uniqueMonths(values []int) []int {
seen := make(map[int]bool)
var out []int
for _, v := range values {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}

View File

@@ -37,7 +37,21 @@ func ensureSchema(db *sql.DB) error {
name VARCHAR(80) NOT NULL UNIQUE,
sow_start_month TINYINT NOT NULL,
sow_end_month TINYINT NOT NULL,
grow_months TINYINT NOT NULL
grow_months TINYINT NOT NULL,
regrow_enabled TINYINT(1) NOT NULL DEFAULT 0,
regrow_cycles INT NOT NULL DEFAULT 0
)`,
`ALTER TABLE crops ADD COLUMN IF NOT EXISTS regrow_enabled TINYINT(1) NOT NULL DEFAULT 0`,
`ALTER TABLE crops ADD COLUMN IF NOT EXISTS regrow_cycles INT NOT NULL DEFAULT 0`,
`UPDATE crops SET regrow_cycles=0 WHERE regrow_cycles < 0`,
`CREATE TABLE IF NOT EXISTS crop_steps(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
crop_id BIGINT NOT NULL,
phase ENUM('pre','post') NOT NULL,
month_offset INT NOT NULL DEFAULT 0,
title VARCHAR(140) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_crop_steps_crop FOREIGN KEY(crop_id) REFERENCES crops(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS plans(
@@ -114,7 +128,7 @@ func (a *App) getFieldsByIDs(ids []int64) ([]Field, error) {
}
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`)
rows, err := a.db.Query(`SELECT id,name,sow_start_month,sow_end_month,grow_months,regrow_enabled,regrow_cycles FROM crops ORDER BY name`)
if err != nil {
return nil, err
}
@@ -122,7 +136,7 @@ func (a *App) listCrops() ([]Crop, error) {
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 {
if err := rows.Scan(&c.ID, &c.Name, &c.SowStartMonth, &c.SowEndMonth, &c.GrowMonths, &c.RegrowEnabled, &c.RegrowCycles); err != nil {
return nil, err
}
out = append(out, c)
@@ -132,7 +146,7 @@ func (a *App) listCrops() ([]Crop, error) {
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,'')
SELECT p.id,p.field_id,COALESCE(p.target_ref,''),COALESCE(p.target_name,''),p.crop_id,COALESCE(c.name,''),COALESCE(c.grow_months,1),p.start_month,p.start_day,p.harvest_month,p.harvest_day,COALESCE(p.notes,''),COALESCE(c.regrow_enabled,0),COALESCE(c.regrow_cycles,0)
FROM plans p
JOIN crops c ON c.id=p.crop_id
ORDER BY p.start_month,p.start_day,p.id DESC`)
@@ -143,7 +157,7 @@ func (a *App) listPlans() ([]Plan, error) {
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 {
if err := rows.Scan(&p.ID, &p.FieldID, &p.TargetRef, &p.TargetName, &p.CropID, &p.CropName, &p.GrowMonths, &p.StartMonth, &p.StartDay, &p.HarvestMonth, &p.HarvestDay, &p.Notes, &p.RegrowEnabled, &p.RegrowCycles); err != nil {
return nil, err
}
out = append(out, p)
@@ -151,6 +165,40 @@ func (a *App) listPlans() ([]Plan, error) {
return out, rows.Err()
}
func (a *App) listCropSteps() ([]CropStep, error) {
rows, err := a.db.Query(`
SELECT s.id,s.crop_id,COALESCE(c.name,''),s.phase,s.month_offset,s.title
FROM crop_steps s
JOIN crops c ON c.id=s.crop_id
ORDER BY c.name, s.phase, s.month_offset, s.id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CropStep
for rows.Next() {
var s CropStep
if err := rows.Scan(&s.ID, &s.CropID, &s.CropName, &s.Phase, &s.MonthOffset, &s.Title); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
func (a *App) listCropStepsMap() (map[int64][]CropStep, error) {
all, err := a.listCropSteps()
if err != nil {
return nil, err
}
m := make(map[int64][]CropStep)
for _, s := range all {
m[s.CropID] = append(m[s.CropID], s)
}
return m, nil
}
func seedCrops(db *sql.DB) error {
items := []Crop{
{Name: "Weizen", SowStartMonth: 9, SowEndMonth: 11, GrowMonths: 10},
@@ -166,7 +214,7 @@ func seedCrops(db *sql.DB) error {
{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 {
if _, err := db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months,regrow_enabled,regrow_cycles) VALUES (?,?,?,?,0,0) 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
}
}

View File

@@ -105,7 +105,7 @@ func wrapMonth(v int) int {
return m
}
func validateCropInput(name string, start, end, grow int) error {
func validateCropInput(name string, start, end, grow, regrowCycles int) error {
if name == "" {
return errors.New("Name der Feldfrucht fehlt")
}
@@ -115,5 +115,8 @@ func validateCropInput(name string, start, end, grow int) error {
if grow < 1 || grow > 24 {
return errors.New("Wachstumsdauer muss 1-24 sein")
}
if regrowCycles < 0 || regrowCycles > 120 {
return errors.New("Regrow-Zyklen muessen 0-120 sein")
}
return nil
}

View File

@@ -210,11 +210,16 @@ func (a *App) handleCreateCrop(w http.ResponseWriter, r *http.Request) {
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 {
regrowEnabled := r.FormValue("regrow_enabled") == "on"
regrowCycles := mustInt(r.FormValue("regrow_cycles"), 0)
if err := validateCropInput(name, start, end, grow, regrowCycles); 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 {
if !regrowEnabled {
regrowCycles = 0
}
if _, err := a.db.Exec(`INSERT INTO crops(name,sow_start_month,sow_end_month,grow_months,regrow_enabled,regrow_cycles) VALUES (?,?,?,?,?,?)`, name, start, end, grow, regrowEnabled, regrowCycles); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht angelegt")
return
}
@@ -235,15 +240,20 @@ func (a *App) handleUpdateCrop(w http.ResponseWriter, r *http.Request) {
start := mustInt(r.FormValue("sow_start_month"), 0)
end := mustInt(r.FormValue("sow_end_month"), 0)
grow := mustInt(r.FormValue("grow_months"), 0)
regrowEnabled := r.FormValue("regrow_enabled") == "on"
regrowCycles := mustInt(r.FormValue("regrow_cycles"), 0)
if id <= 0 {
redirectWithMessage(w, r, "/crops", "error", "Feldfrucht-ID ungueltig")
return
}
if err := validateCropInput(name, start, end, grow); err != nil {
if err := validateCropInput(name, start, end, grow, regrowCycles); 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 {
if !regrowEnabled {
regrowCycles = 0
}
if _, err := a.db.Exec(`UPDATE crops SET name=?,sow_start_month=?,sow_end_month=?,grow_months=?,regrow_enabled=?,regrow_cycles=? WHERE id=?`, name, start, end, grow, regrowEnabled, regrowCycles, id); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Feldfrucht nicht aktualisiert")
return
}
@@ -331,6 +341,72 @@ func (a *App) handleCreatePlan(w http.ResponseWriter, r *http.Request) {
redirectWithMessage(w, r, "/planning", "info", "Plan gespeichert")
}
func (a *App) handleDeletePlan(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
}
id := mustInt64(r.FormValue("id"), 0)
if id <= 0 {
redirectWithMessage(w, r, "/planning", "error", "Plan-ID ungueltig")
return
}
if _, err := a.db.Exec(`DELETE FROM plans WHERE id=?`, id); err != nil {
redirectWithMessage(w, r, "/planning", "error", "Plan nicht geloescht")
return
}
redirectWithMessage(w, r, "/planning", "info", "Plan geloescht")
}
func (a *App) handleCreateCropStep(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
}
cropID := mustInt64(r.FormValue("crop_id"), 0)
phase := strings.TrimSpace(r.FormValue("phase"))
offset := mustInt(r.FormValue("month_offset"), -1)
title := strings.TrimSpace(r.FormValue("title"))
if cropID <= 0 || (phase != "pre" && phase != "post") || offset < 0 || offset > 24 || title == "" {
redirectWithMessage(w, r, "/crops", "error", "Vor/Nachbereitung ungueltig")
return
}
if _, err := a.db.Exec(`INSERT INTO crop_steps(crop_id,phase,month_offset,title) VALUES (?,?,?,?)`, cropID, phase, offset, title); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Schritt nicht gespeichert")
return
}
redirectWithMessage(w, r, "/crops", "info", "Schritt gespeichert")
}
func (a *App) handleDeleteCropStep(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", "Schritt-ID ungueltig")
return
}
if _, err := a.db.Exec(`DELETE FROM crop_steps WHERE id=?`, id); err != nil {
redirectWithMessage(w, r, "/crops", "error", "Schritt nicht geloescht")
return
}
redirectWithMessage(w, r, "/crops", "info", "Schritt geloescht")
}
func (a *App) resolvePlanningTarget(ref string) (sql.NullInt64, string, error) {
if strings.HasPrefix(ref, "f:") {
id := mustInt64(strings.TrimPrefix(ref, "f:"), 0)

View File

@@ -31,6 +31,11 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "plans read failed", http.StatusInternalServerError)
return
}
stepMap, err := a.listCropStepsMap()
if err != nil {
http.Error(w, "steps read failed", http.StatusInternalServerError)
return
}
data := DashboardPage{
BasePage: BasePage{
ActivePath: "/",
@@ -39,8 +44,8 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
},
Settings: settings,
CurrentMonth: monthNames[settings.CurrentMonth-1],
TodayTasks: buildTasksForDay(plans, settings.CurrentMonth, settings.CurrentDay),
Calendar: buildCalendar(plans, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14),
TodayTasks: buildTasksForDay(plans, stepMap, settings.CurrentMonth, settings.CurrentDay),
Calendar: buildCalendar(plans, stepMap, settings.CurrentMonth, settings.CurrentDay, settings.DaysPerMonth, 14),
PlanningCount: len(plans),
}
a.renderTemplate(w, "templates/dashboard.html", data)
@@ -78,13 +83,19 @@ func (a *App) handleCropsPage(w http.ResponseWriter, r *http.Request) {
http.Error(w, "crops read failed", http.StatusInternalServerError)
return
}
steps, err := a.listCropSteps()
if err != nil {
http.Error(w, "steps 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,
Crops: crops,
CropSteps: steps,
}
a.renderTemplate(w, "templates/crops.html", data)
}

View File

@@ -59,8 +59,11 @@ func main() {
mux.HandleFunc("/crops/create", app.handleCreateCrop)
mux.HandleFunc("/crops/update", app.handleUpdateCrop)
mux.HandleFunc("/crops/delete", app.handleDeleteCrop)
mux.HandleFunc("/crop-steps/create", app.handleCreateCropStep)
mux.HandleFunc("/crop-steps/delete", app.handleDeleteCropStep)
mux.HandleFunc("/plans/create", app.handleCreatePlan)
mux.HandleFunc("/plans/delete", app.handleDeletePlan)
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
srv := &http.Server{

View File

@@ -29,6 +29,8 @@ type Crop struct {
SowStartMonth int
SowEndMonth int
GrowMonths int
RegrowEnabled bool
RegrowCycles int
}
type Plan struct {
@@ -38,11 +40,23 @@ type Plan struct {
TargetName string
CropID int64
CropName string
GrowMonths int
StartMonth int
StartDay int
HarvestMonth int
HarvestDay int
Notes string
RegrowEnabled bool
RegrowCycles int
}
type CropStep struct {
ID int64
CropID int64
CropName string
Phase string
MonthOffset int
Title string
}
type Task struct {
@@ -98,7 +112,8 @@ type FieldsPage struct {
type CropsPage struct {
BasePage
Crops []Crop
Crops []Crop
CropSteps []CropStep
}
type PlanningPage struct {