commit 745c93c77b5a206f3e5fd3bd5d564323b77b68bd Author: Kai Date: Mon Feb 16 11:22:50 2026 +0100 Initial FarmCal MVP diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1fdfb6c --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +APP_PORT=8080 + +# Beispiel fuer externe MariaDB im Netzwerk: +# DB_DSN=farmcal:DEIN_PASSWORT@tcp(192.168.178.20:3306)/farmcal?parseTime=true +DB_DSN=farmcal:CHANGE_ME@tcp(127.0.0.1:3306)/farmcal?parseTime=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99db6b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +bin/ +dist/ +*.exe +*.out +*.test +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c5142f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.23-alpine AS build +WORKDIR /app + +COPY go.mod ./ +RUN go mod download + +COPY cmd ./cmd +COPY templates ./templates +COPY static ./static + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /farmcal ./cmd/server + +FROM alpine:3.21 +WORKDIR /app + +RUN adduser -D -H appuser +USER appuser + +COPY --from=build /farmcal /app/farmcal +COPY templates ./templates +COPY static ./static + +EXPOSE 8080 +ENV APP_ADDR=:8080 + +CMD ["/app/farmcal"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..644a1b0 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# FarmCal + +FarmCal ist eine schlanke Web-App zur Ingame-Planung fuer Farming Simulator: +- Felder verwalten (Feldnummer + optionaler Name) +- Aussaat- und Ernteplanung pro Feld +- Ingame-Zeitmodell mit konfigurierbaren `Tagen pro Monat` +- Tagesansicht mit konkreten Aufgaben (Aussaat/Ernte) + +Die App ist fuer Desktop und Smartphone ausgelegt (responsive UI) und nutzt MariaDB. + +## Tech-Stack + +- Backend: Go (net/http + html/template) +- Datenbank: MariaDB (auch externe DB im Netzwerk) +- Deployment: Docker (ein Container fuer die App) + +## Voraussetzungen + +- MariaDB erreichbar im Netzwerk +- Datenbank und Benutzer vorhanden, z. B.: + - DB: `farmcal` + - User: `farmcal` + - Rechte auf `farmcal.*` + +## Konfiguration + +1. `.env.example` nach `.env` kopieren +2. `DB_DSN` auf deine Netzwerk-MariaDB anpassen + +Beispiel: + +```env +APP_PORT=8080 +DB_DSN=farmcal:SEHR_SICHERES_PASSWORT@tcp(192.168.1.40:3306)/farmcal?parseTime=true +``` + +## Start mit Docker Compose + +```bash +docker compose --env-file .env up -d --build +``` + +Danach ist FarmCal unter `http://:8080` erreichbar. + +## Als Image bauen und verteilen + +Image lokal bauen: + +```bash +docker build -t farmcal:latest . +``` + +Tag fuer Registry: + +```bash +docker tag farmcal:latest //farmcal:latest +``` + +Push: + +```bash +docker push //farmcal:latest +``` + +## Datenmodell + +- `settings`: globale Spielparameter (`days_per_month`) +- `fields`: Feldliste +- `crops`: Feldfruechte + Aussaatfenster + Wachstumsdauer +- `plans`: geplanter Anbau je Feld mit berechnetem Erntetermin + +Beim ersten Start werden Tabellen automatisch erstellt und Standardfruechte vorgeladen. + +## Lokale Entwicklung (ohne Docker) + +```bash +set DB_DSN=farmcal:PASS@tcp(127.0.0.1:3306)/farmcal?parseTime=true +go run ./cmd/server +``` + +## Neues Gitea-Repo anbinden + +```bash +git init +git add . +git commit -m "Initial FarmCal MVP" +git branch -M main +git remote add origin +git push -u origin main +``` + +## Nächste sinnvolle Ausbaustufen + +- Bearbeiten/Loeschen von Feldern und Plaenen +- Kalenderansicht fuer mehrere Monate +- Mehrspieler-/Benutzerverwaltung +- Import/Export fuer bestehende Tabellen diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..54592bf --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,453 @@ +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 +} + +type Crop struct { + ID int64 + Name string + SowStartMonth int + SowEndMonth int + GrowMonths int +} + +type Plan struct { + ID int64 + FieldID int64 + FieldNumber int + FieldName 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 MonthOption struct { + Value int + Label string +} + +type PageData struct { + NowMonth int + NowDay int + DaysPerMonth int + Months []MonthOption + Fields []Field + Crops []Crop + Plans []Plan + Tasks []Task + 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("/fields", app.handleCreateField) + mux.HandleFunc("/plans", app.handleCreatePlan) + mux.HandleFunc("/settings", app.handleSettings) + 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 + } + 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, + Crops: crops, + Plans: plans, + Tasks: buildTasksForDay(plans, nowMonth, nowDay), + 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) 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 { + http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther) + return + } + number := mustInt(r.FormValue("number"), 0) + name := strings.TrimSpace(r.FormValue("name")) + if number <= 0 { + http.Redirect(w, r, "/?error=Feldnummer+ungueltig", http.StatusSeeOther) + return + } + if _, err := a.db.Exec(`INSERT INTO fields(number,name) VALUES (?,?)`, number, name); err != nil { + http.Redirect(w, r, "/?error=Feld+nicht+angelegt", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/?info=Feld+angelegt", http.StatusSeeOther) +} + +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 { + http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther) + return + } + + fieldID := mustInt64(r.FormValue("field_id"), 0) + 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 fieldID <= 0 || cropID <= 0 { + http.Redirect(w, r, "/?error=Feld+oder+Frucht+ungueltig", http.StatusSeeOther) + return + } + + daysPerMonth, err := a.getDaysPerMonth() + if err != nil { + http.Redirect(w, r, "/?error=Einstellungen+nicht+lesbar", http.StatusSeeOther) + return + } + if startMonth < 1 || startMonth > 12 || startDay < 1 || startDay > daysPerMonth { + http.Redirect(w, r, "/?error=Startdatum+ungueltig", http.StatusSeeOther) + 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 { + http.Redirect(w, r, "/?error=Feldfrucht+nicht+gefunden", http.StatusSeeOther) + return + } + + if !monthInWindow(startMonth, c.SowStartMonth, c.SowEndMonth) { + http.Redirect(w, r, "/?error=Aussaat+ausserhalb+des+Zeitfensters", http.StatusSeeOther) + return + } + + harvestMonth := wrapMonth(startMonth + c.GrowMonths) + harvestDay := startDay + _, err = a.db.Exec( + `INSERT INTO plans(field_id,crop_id,start_month,start_day,harvest_month,harvest_day,notes) VALUES (?,?,?,?,?,?,?)`, + fieldID, cropID, startMonth, startDay, harvestMonth, harvestDay, notes, + ) + if err != nil { + http.Redirect(w, r, "/?error=Plan+nicht+gespeichert", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/?info=Plan+gespeichert", http.StatusSeeOther) +} + +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 { + http.Redirect(w, r, "/?error=form+ungueltig", http.StatusSeeOther) + return + } + daysPerMonth := mustInt(r.FormValue("days_per_month"), 2) + if daysPerMonth < 1 || daysPerMonth > 31 { + http.Redirect(w, r, "/?error=Tage+pro+Monat+1-31", http.StatusSeeOther) + return + } + if _, err := a.db.Exec(`UPDATE settings SET days_per_month=? WHERE id=1`, daysPerMonth); err != nil { + http.Redirect(w, r, "/?error=Einstellungen+nicht+gespeichert", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/?info=Einstellungen+gespeichert", http.StatusSeeOther) +} + +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 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); 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,f.number,COALESCE(f.name,''),p.crop_id,c.name,p.start_month,p.start_day,p.harvest_month,p.harvest_day,COALESCE(p.notes,'') + FROM plans p + JOIN fields f ON f.id=p.field_id + JOIN crops c ON c.id=p.crop_id + ORDER BY p.start_month,p.start_day,f.number`) + 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.FieldNumber, &x.FieldName, &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 '',created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`, + `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 NOT NULL,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)`, + } + 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 buildTasksForDay(plans []Plan, month, day int) []Task { + var tasks []Task + for _, p := range plans { + field := fmt.Sprintf("Feld %d", p.FieldNumber) + if p.FieldName != "" { + field = fmt.Sprintf("Feld %d (%s)", p.FieldNumber, p.FieldName) + } + 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 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 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)) + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ce0ccf5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + farmcal: + build: . + image: farmcal:latest + container_name: farmcal + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:8080" + environment: + APP_ADDR: ":8080" + DB_DSN: "${DB_DSN}" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..16084e5 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module farmcal + +go 1.23 + +require github.com/go-sql-driver/mysql v1.8.1 diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..ea44250 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,113 @@ +:root { + --bg: #f2f5ef; + --card: #ffffff; + --text: #1f2a1d; + --muted: #5d6a5a; + --accent: #365f2c; + --accent-2: #93b877; + --danger: #9b2f2f; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "Segoe UI", Tahoma, sans-serif; + background: radial-gradient(circle at top, #e8f1df, var(--bg)); + color: var(--text); +} + +.top { + padding: 1.2rem 1rem; + background: linear-gradient(90deg, #2f4f2a, #4d7a40); + color: #fff; +} +.top h1 { margin: 0; } +.top p { margin: .35rem 0 0; opacity: .9; } + +.layout { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1rem; + padding: 1rem; + max-width: 1280px; + margin: 0 auto; +} + +.card { + background: var(--card); + border: 1px solid #dbe6d5; + border-radius: 12px; + padding: 1rem; + box-shadow: 0 8px 20px rgba(0,0,0,0.05); +} +.card h2 { + margin-top: 0; + font-size: 1.05rem; +} + +.grid { + display: grid; + gap: .8rem; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} +.full { grid-column: 1 / -1; } +.mt { margin-top: .8rem; } + +label { + display: grid; + gap: .35rem; + font-weight: 600; + color: var(--muted); +} +input, select, button { + font: inherit; + padding: .55rem .65rem; + border-radius: 8px; + border: 1px solid #c9d8c2; +} +button { + background: var(--accent); + color: #fff; + border: none; + cursor: pointer; + font-weight: 700; +} +button:hover { background: #2d5124; } + +.tasks { + margin: 0; + padding-left: 1.1rem; +} +.tasks li { margin: .35rem 0; } + +.table-wrap { overflow: auto; } +table { + width: 100%; + border-collapse: collapse; +} +th, td { + text-align: left; + padding: .5rem; + border-bottom: 1px solid #e2eadf; + white-space: nowrap; +} +th { + background: #eef5e8; + color: #384534; +} + +.toast { + position: fixed; + right: 1rem; + bottom: 1rem; + padding: .7rem .9rem; + border-radius: 8px; + color: #fff; + max-width: 380px; +} +.toast.error { background: var(--danger); } +.toast.info { background: #2f6d7a; } + +@media (max-width: 700px) { + .layout { grid-template-columns: 1fr; } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..8134690 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,145 @@ + + + + + + FarmCal + + + +
+

FarmCal

+

Planung fuer Felder, Aussaat und Ernte

+
+ +
+
+

Ingame-Zeit

+
+ + + +
+ +
+ + +
+
+ +
+

Heutige Aufgaben

+ {{if .Tasks}} +
    + {{range .Tasks}} +
  • + {{.Type}}: {{.Message}} +
  • + {{end}} +
+ {{else}} +

Keine Aufgaben fuer diesen Ingame-Tag.

+ {{end}} +
+ +
+

Feld anlegen

+
+ + + +
+
+ +
+

Anbau planen

+
+ + + + + + + + + + + +
+
+ +
+

Geplante Durchlaeufe

+
+ + + + + + + + + + + + {{if .Plans}} + {{range .Plans}} + + + + + + + + {{end}} + {{else}} + + {{end}} + +
FeldFruchtAussaatErnteNotiz
Feld {{.FieldNumber}}{{if .FieldName}} ({{.FieldName}}){{end}}{{.CropName}}Monat {{.StartMonth}} Tag {{.StartDay}}Monat {{.HarvestMonth}} Tag {{.HarvestDay}}{{.Notes}}
Noch keine Planung vorhanden.
+
+
+
+ + {{if .Error}}
{{.Error}}
{{end}} + {{if .Info}}
{{.Info}}
{{end}} + +