Initial FarmCal MVP
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -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
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
*.exe
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
*.log
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -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"]
|
||||||
97
README.md
Normal file
97
README.md
Normal file
@@ -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://<host>:8080` erreichbar.
|
||||||
|
|
||||||
|
## Als Image bauen und verteilen
|
||||||
|
|
||||||
|
Image lokal bauen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t farmcal:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
Tag fuer Registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker tag farmcal:latest <deine-registry>/<dein-user>/farmcal:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker push <deine-registry>/<dein-user>/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 <DEIN_GITEA_REPO_URL>
|
||||||
|
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
|
||||||
453
cmd/server/main.go
Normal file
453
cmd/server/main.go
Normal file
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -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}"
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module farmcal
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require github.com/go-sql-driver/mysql v1.8.1
|
||||||
113
static/styles.css
Normal file
113
static/styles.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
145
templates/index.html
Normal file
145
templates/index.html
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>FarmCal</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="top">
|
||||||
|
<h1>FarmCal</h1>
|
||||||
|
<p>Planung fuer Felder, Aussaat und Ernte</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="card">
|
||||||
|
<h2>Ingame-Zeit</h2>
|
||||||
|
<form method="get" action="/" class="grid">
|
||||||
|
<label>Monat
|
||||||
|
<select name="month">
|
||||||
|
{{range .Months}}
|
||||||
|
<option value="{{.Value}}" {{if eq $.NowMonth .Value}}selected{{end}}>{{.Label}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Tag
|
||||||
|
<input type="number" name="day" min="1" max="{{.DaysPerMonth}}" value="{{.NowDay}}">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Anzeigen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/settings" class="grid mt">
|
||||||
|
<label>Tage pro Monat
|
||||||
|
<input type="number" name="days_per_month" min="1" max="31" value="{{.DaysPerMonth}}">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Heutige Aufgaben</h2>
|
||||||
|
{{if .Tasks}}
|
||||||
|
<ul class="tasks">
|
||||||
|
{{range .Tasks}}
|
||||||
|
<li>
|
||||||
|
<strong>{{.Type}}:</strong> {{.Message}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p>Keine Aufgaben fuer diesen Ingame-Tag.</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Feld anlegen</h2>
|
||||||
|
<form method="post" action="/fields" class="grid">
|
||||||
|
<label>Feldnummer
|
||||||
|
<input type="number" name="number" min="1" required>
|
||||||
|
</label>
|
||||||
|
<label>Name (optional)
|
||||||
|
<input type="text" name="name" maxlength="120">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Feld speichern</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Anbau planen</h2>
|
||||||
|
<form method="post" action="/plans" class="grid">
|
||||||
|
<label>Feld
|
||||||
|
<select name="field_id" required>
|
||||||
|
<option value="">Bitte waehlen</option>
|
||||||
|
{{range .Fields}}
|
||||||
|
<option value="{{.ID}}">Feld {{.Number}}{{if .Name}} ({{.Name}}){{end}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Feldfrucht
|
||||||
|
<select name="crop_id" required>
|
||||||
|
<option value="">Bitte waehlen</option>
|
||||||
|
{{range .Crops}}
|
||||||
|
<option value="{{.ID}}">{{.Name}} (Aussaat Monat {{.SowStartMonth}}-{{.SowEndMonth}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Startmonat
|
||||||
|
<select name="start_month" required>
|
||||||
|
{{range .Months}}
|
||||||
|
<option value="{{.Value}}" {{if eq $.NowMonth .Value}}selected{{end}}>{{.Label}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Starttag
|
||||||
|
<input type="number" name="start_day" min="1" max="{{.DaysPerMonth}}" value="{{.NowDay}}" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="full">Notiz
|
||||||
|
<input type="text" name="notes" maxlength="255">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Plan speichern</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Geplante Durchlaeufe</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Feld</th>
|
||||||
|
<th>Frucht</th>
|
||||||
|
<th>Aussaat</th>
|
||||||
|
<th>Ernte</th>
|
||||||
|
<th>Notiz</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{if .Plans}}
|
||||||
|
{{range .Plans}}
|
||||||
|
<tr>
|
||||||
|
<td>Feld {{.FieldNumber}}{{if .FieldName}} ({{.FieldName}}){{end}}</td>
|
||||||
|
<td>{{.CropName}}</td>
|
||||||
|
<td>Monat {{.StartMonth}} Tag {{.StartDay}}</td>
|
||||||
|
<td>Monat {{.HarvestMonth}} Tag {{.HarvestDay}}</td>
|
||||||
|
<td>{{.Notes}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="5">Noch keine Planung vorhanden.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{if .Error}}<div class="toast error">{{.Error}}</div>{{end}}
|
||||||
|
{{if .Info}}<div class="toast info">{{.Info}}</div>{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user