commit 8e60f0472b965cdb454d24c739e294795db53bbb Author: Bartal Læarsson Date: Thu Jun 18 21:41:47 2026 +0100 initial commit diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..a639524 --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,15 @@ +# Example Caddyfile site block for the zeitkort web app. +# Caddy handles TLS automatically via Let's Encrypt. +# +# Access control is done HERE (the app itself has no login screen): only the +# listed IPs/ranges reach it. Everyone else gets 403. + +zeit.example.com { + @allowed remote_ip 203.0.113.4 198.51.100.0/24 + handle @allowed { + reverse_proxy 127.0.0.1:8081 + } + respond "Forbidden" 403 +} + +# This web-only POC has no separate API/backend service. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ce77bfb --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +BINARY = zeitkort-web + +.PHONY: build run clean + +build: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./bin/$(BINARY) . + +run: build + ./$(BINARY) -config config.json + +clean: + rm -f $(BINARY) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4b254f --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# zeitkort-web-only POC + +A self-contained web-only proof-of-concept version of **zeitkort**. + +This package does **not** use the `zeitkort-api` service, PostgreSQL, SQLite, or +any other backend database. The Go web process serves the UI and stores all POC +records in memory. + +## Important POC behavior + +- Companies, assignments, tasks, work sessions, and invoice-number records are + held in process memory only. +- Settings edited from the UI are also in-memory only. +- Everything resets automatically every **10 minutes** after the process starts. +- A sticky, high-visibility countdown banner shows exactly when the next reset will happen. +- Restarting the process also clears everything immediately. +- `config.json` is read only at startup for the listen address and initial + invoice defaults; the app never writes it. + +## What it does + +- Pick a company → assignment → task, run a live timer, and stop it manually. +- Add / edit / delete companies, assignments, tasks, and work sessions. +- Add manual time entries. +- Generate the Faroese invoice PDF directly from the in-memory data. +- Edit sender/company invoice defaults for the current 10-minute in-memory run. +- Open the **About** menu item for a concise explanation of the POC features and implementation. + +## Build + +Requires Go 1.26+ as declared by the original project. + +```sh +go mod download +go build -o zeitkort-web . +``` + +## Configure + +```sh +cp config.example.json config.json +# edit config.json if needed +``` + +Only these fields are used: + +- `listen_addr` +- `company_*` +- `tax_id` +- `bank_info` +- `payment_terms_days` +- `mvg_percent` + +There are no API URL, username, or password fields in this version. + +## Run + +```sh +./zeitkort-web -config config.json +``` + +The app logs a reset message every 10 minutes when the in-memory POC state is +cleared. The browser also shows a large countdown and reloads shortly after the +reset so the cleared state is visible. + +## As a service + +```sh +sudo useradd --system --no-create-home zeitkort # if not already present +sudo mkdir -p /opt/zeitkort-web +sudo cp zeitkort-web config.json /opt/zeitkort-web/ +sudo chown -R zeitkort:zeitkort /opt/zeitkort-web +sudo cp zeitkort-web.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now zeitkort-web +``` + +## Notes + +- This is intentionally not durable. Do not use it for real time tracking. +- Deletes are not cascading: remove work sessions before tasks, tasks before + assignments, and assignments before companies. +- The timer stops only when you click **Stop**, unless the 10-minute POC reset + happens first. diff --git a/about.go b/about.go new file mode 100644 index 0000000..e4051dc --- /dev/null +++ b/about.go @@ -0,0 +1,18 @@ +package main + +import ( + "net/http" + "time" +) + +type aboutView struct { + Version string + ResetIntervalMinutes int +} + +func (s *Server) handleAbout(w http.ResponseWriter, r *http.Request) { + s.render(w, "about_page", aboutView{ + Version: Version, + ResetIntervalMinutes: int(resetInterval / time.Minute), + }) +} diff --git a/bin/zeitkort-web b/bin/zeitkort-web new file mode 100755 index 0000000..f7f1d7b Binary files /dev/null and b/bin/zeitkort-web differ diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..3d8115f --- /dev/null +++ b/config.example.json @@ -0,0 +1,13 @@ +{ + "listen_addr": "127.0.0.1:8081", + + "company_name": "Your Company / Name", + "company_address": "Street 1, FO-100 Tórshavn", + "company_phone": "+298 000000", + "company_email": "you@example.fo", + "company_web": "example.fo", + "tax_id": "FO000000", + "bank_info": "Banki: Banking AS\nReg.nr.: 0000\nKonta: 0000000000", + "payment_terms_days": 8, + "mvg_percent": 25 +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..9f981b6 --- /dev/null +++ b/config.go @@ -0,0 +1,49 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" +) + +// Config holds the web-only proof-of-concept server settings plus invoice +// defaults. It is read at startup only; edits made on the Settings page are +// kept in memory and are reset with the rest of the POC data every 10 minutes. +type Config struct { + // Where the web server listens. Caddy can reverse-proxy to this. + ListenAddr string `json:"listen_addr"` + + // Invoice / company details (editable in Settings for the current in-memory run). + CompanyName string `json:"company_name"` + CompanyAddress string `json:"company_address"` + CompanyPhone string `json:"company_phone"` + CompanyEmail string `json:"company_email"` + CompanyWeb string `json:"company_web"` + TaxID string `json:"tax_id"` + BankInfo string `json:"bank_info"` + PaymentTermsDays int `json:"payment_terms_days"` + MVGPercent float64 `json:"mvg_percent"` +} + +func loadConfig(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("cannot read config %s: %w", path, err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("cannot parse config: %w", err) + } + + if cfg.ListenAddr == "" { + cfg.ListenAddr = "127.0.0.1:8081" + } + if cfg.PaymentTermsDays <= 0 { + cfg.PaymentTermsDays = 8 + } + if cfg.MVGPercent <= 0 { + cfg.MVGPercent = 25 + } + return cfg, nil +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..3d8115f --- /dev/null +++ b/config.json @@ -0,0 +1,13 @@ +{ + "listen_addr": "127.0.0.1:8081", + + "company_name": "Your Company / Name", + "company_address": "Street 1, FO-100 Tórshavn", + "company_phone": "+298 000000", + "company_email": "you@example.fo", + "company_web": "example.fo", + "tax_id": "FO000000", + "bank_info": "Banki: Banking AS\nReg.nr.: 0000\nKonta: 0000000000", + "payment_terms_days": 8, + "mvg_percent": 25 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9430b7f --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module zeitkort-web + +go 1.26 + +require codeberg.org/go-pdf/fpdf v0.11.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..baa7902 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs= +codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..e4782ef --- /dev/null +++ b/handlers.go @@ -0,0 +1,493 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "zeitkort-web/repository" +) + +// mainView is the data backing the working area (#main). +type mainView struct { + Version string + ResetAt time.Time + ResetIntervalMinutes int + Companies []repository.Company + CompanyID int64 + Assignments []repository.Assignment + AssignmentID int64 + Assignment *repository.Assignment + Tasks []repository.Task + Sessions []repository.WorkSession + TotalHours float64 + TotalAmount float64 + Running *repository.WorkSession + EditingTaskID int64 + AddingTask bool + Notice string + AppError string +} + +func qInt64(r *http.Request, key string) int64 { + n, _ := strconv.ParseInt(r.URL.Query().Get(key), 10, 64) + return n +} + +func formInt64(r *http.Request, key string) int64 { + n, _ := strconv.ParseInt(r.FormValue(key), 10, 64) + return n +} + +func pathInt64(r *http.Request, key string) int64 { + n, _ := strconv.ParseInt(r.PathValue(key), 10, 64) + return n +} + +// buildMainView assembles everything shown for the current selection. +func (s *Server) buildMainView(companyID, assignmentID int64) mainView { + v := mainView{ + Version: Version, + CompanyID: companyID, + AssignmentID: assignmentID, + ResetAt: s.nextResetAt(), + ResetIntervalMinutes: int(resetInterval / time.Minute), + } + + companies, err := s.repo.AllCompanies() + if err != nil { + v.AppError = err.Error() + return v + } + v.Companies = companies + + if companyID == 0 { + return v + } + + assignments, err := s.repo.AssignmentsByCompany(companyID) + if err != nil { + v.AppError = err.Error() + return v + } + v.Assignments = assignments + + if assignmentID == 0 { + return v + } + for i := range assignments { + if assignments[i].ID == assignmentID { + v.Assignment = &assignments[i] + break + } + } + if v.Assignment == nil { + // Assignment does not belong to this company; treat as unselected. + v.AssignmentID = 0 + return v + } + + if tasks, err := s.repo.TasksByAssignment(assignmentID); err == nil { + v.Tasks = tasks + } + if sessions, err := s.repo.WorkSessionsByAssignment(assignmentID, 20); err == nil { + v.Sessions = sessions + for i := range sessions { + if sessions[i].EndTime == nil { + v.Running = &sessions[i] + break + } + } + } + v.TotalHours, _ = s.repo.TotalHoursForAssignment(assignmentID) + v.TotalAmount = v.TotalHours * v.Assignment.HourlyRate + return v +} + +// ── Main working area ─────────────────────────────────────────────────────── + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + v := s.buildMainView(qInt64(r, "company_id"), qInt64(r, "assignment_id")) + s.render(w, "page", v) +} + +func (s *Server) handleMain(w http.ResponseWriter, r *http.Request) { + v := s.buildMainView(qInt64(r, "company_id"), qInt64(r, "assignment_id")) + s.render(w, "main", v) +} + +// renderMainWith re-renders the working area fragment for the given selection, +// optionally carrying a notice or error to show the user. +func (s *Server) renderMainWith(w http.ResponseWriter, companyID, assignmentID int64, notice, appErr string) { + v := s.buildMainView(companyID, assignmentID) + if notice != "" { + v.Notice = notice + } + if appErr != "" { + v.AppError = appErr + } + s.render(w, "main", v) +} + +// ── Companies ─────────────────────────────────────────────────────────────── + +type companyFormView struct { + Company *repository.Company + CompanyID int64 +} + +func (s *Server) handleCompanyForm(w http.ResponseWriter, r *http.Request) { + view := companyFormView{} + if id := pathInt64(r, "id"); id > 0 { + c, err := s.repo.GetCompanyByID(id) + if err != nil { + s.renderMainWith(w, 0, 0, "", err.Error()) + return + } + view.Company = c + view.CompanyID = id + } + s.render(w, "company_form", view) +} + +func (s *Server) handleCompanySave(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + rate, _ := strconv.ParseFloat(r.FormValue("hourly_rate"), 64) + c := repository.Company{ + Name: r.FormValue("name"), + Address: r.FormValue("address"), + ContactEmail: r.FormValue("contact_email"), + ContactPhone: r.FormValue("contact_phone"), + HourlyRate: rate, + } + + if id := pathInt64(r, "id"); id > 0 { + if err := s.repo.UpdateCompany(id, c); err != nil { + s.renderMainWith(w, 0, 0, "", err.Error()) + return + } + s.renderMainWith(w, id, 0, "Company updated.", "") + return + } + + created, err := s.repo.InsertCompany(c) + if err != nil { + s.renderMainWith(w, 0, 0, "", err.Error()) + return + } + s.renderMainWith(w, created.ID, 0, "Company created.", "") +} + +func (s *Server) handleCompanyDelete(w http.ResponseWriter, r *http.Request) { + id := pathInt64(r, "id") + if err := s.repo.DeleteCompany(id); err != nil { + s.renderMainWith(w, id, 0, "", "Cannot delete: remove its assignments first.") + return + } + s.renderMainWith(w, 0, 0, "Company deleted.", "") +} + +// ── Assignments ───────────────────────────────────────────────────────────── + +type assignmentFormView struct { + Assignment *repository.Assignment + CompanyID int64 + AssignID int64 +} + +func (s *Server) handleAssignmentForm(w http.ResponseWriter, r *http.Request) { + view := assignmentFormView{CompanyID: qInt64(r, "company_id")} + if id := pathInt64(r, "id"); id > 0 { + a, err := s.repo.GetAssignmentByID(id) + if err != nil { + s.renderMainWith(w, view.CompanyID, 0, "", err.Error()) + return + } + view.Assignment = a + view.AssignID = id + view.CompanyID = a.CompanyID + } + s.render(w, "assignment_form", view) +} + +func (s *Server) handleAssignmentSave(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + companyID := formInt64(r, "company_id") + rate, _ := strconv.ParseFloat(r.FormValue("hourly_rate"), 64) + + // fall back to the company's default rate if none given + if rate == 0 { + if c, err := s.repo.GetCompanyByID(companyID); err == nil { + rate = c.HourlyRate + } + } + + if id := pathInt64(r, "id"); id > 0 { + status := r.FormValue("status") + if status == "" { + status = "active" + } + a := repository.Assignment{ + Name: r.FormValue("name"), + Description: r.FormValue("description"), + HourlyRate: rate, + Status: status, + } + if err := s.repo.UpdateAssignment(id, a); err != nil { + s.renderMainWith(w, companyID, 0, "", err.Error()) + return + } + s.renderMainWith(w, companyID, id, "Assignment updated.", "") + return + } + + a := repository.Assignment{ + CompanyID: companyID, + Name: r.FormValue("name"), + Description: r.FormValue("description"), + HourlyRate: rate, + } + created, err := s.repo.InsertAssignment(a) + if err != nil { + s.renderMainWith(w, companyID, 0, "", err.Error()) + return + } + s.renderMainWith(w, companyID, created.ID, "Assignment created.", "") +} + +func (s *Server) handleAssignmentDelete(w http.ResponseWriter, r *http.Request) { + id := pathInt64(r, "id") + companyID := qInt64(r, "company_id") + if err := s.repo.DeleteAssignment(id); err != nil { + s.renderMainWith(w, companyID, id, "", "Cannot delete: remove its tasks first.") + return + } + s.renderMainWith(w, companyID, 0, "Assignment deleted.", "") +} + +// ── Tasks ─────────────────────────────────────────────────────────────────── + +func (s *Server) handleTaskNewForm(w http.ResponseWriter, r *http.Request) { + companyID := qInt64(r, "company_id") + assignmentID := qInt64(r, "assignment_id") + v := s.buildMainView(companyID, assignmentID) + v.AddingTask = true + s.render(w, "main", v) +} + +func (s *Server) handleTaskEditForm(w http.ResponseWriter, r *http.Request) { + companyID := qInt64(r, "company_id") + assignmentID := qInt64(r, "assignment_id") + v := s.buildMainView(companyID, assignmentID) + v.EditingTaskID = pathInt64(r, "id") + s.render(w, "main", v) +} + +func (s *Server) handleTaskCreate(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + companyID := formInt64(r, "company_id") + assignmentID := formInt64(r, "assignment_id") + name := r.FormValue("name") + if name == "" { + s.renderMainWith(w, companyID, assignmentID, "", "Enter a task name.") + return + } + if _, err := s.repo.InsertTask(repository.Task{AssignmentID: assignmentID, Name: name}); err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + s.renderMainWith(w, companyID, assignmentID, "", "") +} + +func (s *Server) handleTaskUpdate(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + id := pathInt64(r, "id") + companyID := formInt64(r, "company_id") + assignmentID := formInt64(r, "assignment_id") + name := r.FormValue("name") + if name == "" { + s.renderMainWith(w, companyID, assignmentID, "", "Task name cannot be empty.") + return + } + if err := s.repo.UpdateTask(id, name); err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + s.renderMainWith(w, companyID, assignmentID, "Task renamed.", "") +} + +func (s *Server) handleTaskDelete(w http.ResponseWriter, r *http.Request) { + id := pathInt64(r, "id") + companyID := qInt64(r, "company_id") + assignmentID := qInt64(r, "assignment_id") + if err := s.repo.DeleteTask(id); err != nil { + s.renderMainWith(w, companyID, assignmentID, "", "Cannot delete: remove its work sessions first.") + return + } + s.renderMainWith(w, companyID, assignmentID, "Task deleted.", "") +} + +// ── Work sessions: timer ────────────────────────────────────────────────────── + +func (s *Server) handleSessionStart(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + companyID := formInt64(r, "company_id") + assignmentID := formInt64(r, "assignment_id") + taskID := formInt64(r, "task_id") + + if assignmentID == 0 { + s.renderMainWith(w, companyID, assignmentID, "", "Pick an assignment first.") + return + } + if taskID == 0 { + s.renderMainWith(w, companyID, assignmentID, "", "Select a task to start the timer.") + return + } + task, err := s.repo.GetTaskByID(taskID) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + desc := fmt.Sprintf("%d - %s", task.Number, task.Name) + if _, err := s.repo.InsertWorkSession(repository.WorkSession{ + AssignmentID: assignmentID, + TaskID: taskID, + TaskDescription: desc, + }); err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + s.renderMainWith(w, companyID, assignmentID, "", "") +} + +func (s *Server) handleSessionStop(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + id := pathInt64(r, "id") + companyID := formInt64(r, "company_id") + assignmentID := formInt64(r, "assignment_id") + if err := s.repo.StopWorkSession(id); err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + s.renderMainWith(w, companyID, assignmentID, "", "") +} + +// ── Work sessions: manual entry + edit ──────────────────────────────────────── + +type sessionFormView struct { + Session *repository.WorkSession + SessionID int64 + CompanyID int64 + AssignmentID int64 + Tasks []repository.Task +} + +func (s *Server) handleSessionForm(w http.ResponseWriter, r *http.Request) { + view := sessionFormView{ + CompanyID: qInt64(r, "company_id"), + AssignmentID: qInt64(r, "assignment_id"), + } + if id := pathInt64(r, "id"); id > 0 { + // editing: load the session via its assignment's session list + view.SessionID = id + // We already know the assignment from the query string. + sessions, _ := s.repo.WorkSessionsByAssignment(view.AssignmentID, 1000) + for i := range sessions { + if sessions[i].ID == id { + view.Session = &sessions[i] + break + } + } + } + if view.AssignmentID > 0 { + view.Tasks, _ = s.repo.TasksByAssignment(view.AssignmentID) + } + s.render(w, "session_form", view) +} + +func (s *Server) handleSessionSave(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + companyID := formInt64(r, "company_id") + assignmentID := formInt64(r, "assignment_id") + + // Resolve the task (existing id, or a new task by name). + var task *repository.Task + if r.FormValue("task_id") == "new" { + name := r.FormValue("new_task_name") + if name == "" { + s.renderMainWith(w, companyID, assignmentID, "", "Enter a name for the new task.") + return + } + created, err := s.repo.InsertTask(repository.Task{AssignmentID: assignmentID, Name: name}) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + task = created + } else { + t, err := s.repo.GetTaskByID(formInt64(r, "task_id")) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", "Select a task.") + return + } + task = t + } + + loc := time.Now().Location() + dateParsed, err := time.ParseInLocation("02-01-2006", r.FormValue("date"), loc) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", "Invalid date (use DD-MM-YYYY).") + return + } + startParsed, err := time.Parse("15:04", r.FormValue("start")) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", "Invalid start time (use HH:MM).") + return + } + endParsed, err := time.Parse("15:04", r.FormValue("end")) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", "Invalid end time (use HH:MM).") + return + } + startTime := time.Date(dateParsed.Year(), dateParsed.Month(), dateParsed.Day(), + startParsed.Hour(), startParsed.Minute(), 0, 0, loc) + endTime := time.Date(dateParsed.Year(), dateParsed.Month(), dateParsed.Day(), + endParsed.Hour(), endParsed.Minute(), 0, 0, loc) + if endTime.Before(startTime) { + endTime = endTime.Add(24 * time.Hour) + } + + desc := fmt.Sprintf("%d - %s", task.Number, task.Name) + + if id := pathInt64(r, "id"); id > 0 { + if err := s.repo.UpdateWorkSession(id, repository.WorkSession{ + TaskID: task.ID, TaskDescription: desc, StartTime: startTime, EndTime: &endTime, + }); err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + s.renderMainWith(w, companyID, assignmentID, "Session updated.", "") + return + } + + if _, err := s.repo.InsertManualWorkSession(repository.WorkSession{ + AssignmentID: assignmentID, TaskID: task.ID, + TaskDescription: desc, StartTime: startTime, EndTime: &endTime, + }); err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + s.renderMainWith(w, companyID, assignmentID, "Session added.", "") +} + +func (s *Server) handleSessionDelete(w http.ResponseWriter, r *http.Request) { + id := pathInt64(r, "id") + companyID := qInt64(r, "company_id") + assignmentID := qInt64(r, "assignment_id") + if err := s.repo.DeleteWorkSession(id); err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + s.renderMainWith(w, companyID, assignmentID, "Session deleted.", "") +} diff --git a/invoice.go b/invoice.go new file mode 100644 index 0000000..4a920d6 --- /dev/null +++ b/invoice.go @@ -0,0 +1,509 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "codeberg.org/go-pdf/fpdf" + "zeitkort-web/repository" +) + +// ExtraLine is a manual invoice line item (e.g. "1 stk" of something). +type ExtraLine struct { + Description string + Quantity float64 + Unit string // "tímar" or "stk" + UnitPrice float64 +} + +type invoiceInput struct { + InvoiceNumber string + Date time.Time + DueDate time.Time + DueDays int + Comment string + MVGPercent float64 + + FromName, FromAddress, FromPhone, FromEmail, FromWeb, FromVAT string + BankInfo string + + Company repository.Company + Assignment repository.Assignment + HourlyRate float64 + Sessions []repository.WorkSession + ExtraLines []ExtraLine +} + +// ── Handlers ───────────────────────────────────────────────────────────────── + +type invoiceFormView struct { + CompanyID int64 + AssignmentID int64 + Company *repository.Company + Assignment *repository.Assignment + TotalHours float64 + TotalAmount float64 + InvoiceNumber string + Today string + DueDays int + MVGPercent float64 + Cfg Config +} + +func (s *Server) handleInvoiceForm(w http.ResponseWriter, r *http.Request) { + companyID := qInt64(r, "company_id") + assignmentID := qInt64(r, "assignment_id") + if assignmentID == 0 { + s.renderMainWith(w, companyID, 0, "", "Select an assignment before making an invoice.") + return + } + + assignment, err := s.repo.GetAssignmentByID(assignmentID) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + company, err := s.repo.GetCompanyByID(assignment.CompanyID) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + hours, _ := s.repo.TotalHoursForAssignment(assignmentID) + rate := assignment.HourlyRate + if rate == 0 { + rate = company.HourlyRate + } + + now := time.Now() + nextNum, _ := s.repo.NextInvoiceNumber(now) + + s.mu.Lock() + cfg := s.Cfg + s.mu.Unlock() + + s.render(w, "invoice_form", invoiceFormView{ + CompanyID: assignment.CompanyID, + AssignmentID: assignmentID, + Company: company, + Assignment: assignment, + TotalHours: hours, + TotalAmount: hours * rate, + InvoiceNumber: nextNum, + Today: now.Format("02-01-2006"), + DueDays: cfg.PaymentTermsDays, + MVGPercent: cfg.MVGPercent, + Cfg: cfg, + }) +} + +// handleInvoiceLine returns one empty extra-line input row (htmx append). +func (s *Server) handleInvoiceLine(w http.ResponseWriter, r *http.Request) { + s.render(w, "invoice_line", nil) +} + +func (s *Server) handleInvoiceGenerate(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + companyID := formInt64(r, "company_id") + assignmentID := formInt64(r, "assignment_id") + + assignment, err := s.repo.GetAssignmentByID(assignmentID) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + company, err := s.repo.GetCompanyByID(assignment.CompanyID) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + sessions, err := s.repo.WorkSessionsByAssignment(assignmentID, 1000) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", err.Error()) + return + } + + rate := assignment.HourlyRate + if rate == 0 { + rate = company.HourlyRate + } + + mvg, _ := strconv.ParseFloat(r.FormValue("mvg"), 64) + dueDays, _ := strconv.Atoi(r.FormValue("due_days")) + if dueDays <= 0 { + dueDays = 8 + } + now := time.Now() + + in := invoiceInput{ + InvoiceNumber: r.FormValue("invoice_number"), + Date: now, + DueDate: now.AddDate(0, 0, dueDays), + DueDays: dueDays, + Comment: r.FormValue("comment"), + MVGPercent: mvg, + FromName: r.FormValue("from_name"), + FromAddress: r.FormValue("from_address"), + FromPhone: r.FormValue("from_phone"), + FromEmail: r.FormValue("from_email"), + FromWeb: r.FormValue("from_web"), + FromVAT: r.FormValue("from_vat"), + BankInfo: r.FormValue("bank_info"), + Company: *company, + Assignment: *assignment, + HourlyRate: rate, + Sessions: sessions, + ExtraLines: parseExtraLines(r), + } + + pdfBytes, total, err := generateInvoicePDF(in) + if err != nil { + s.renderMainWith(w, companyID, assignmentID, "", "PDF generation failed: "+err.Error()) + return + } + + // Record the invoice so numbering keeps incrementing (best effort). + if _, err := s.repo.InsertInvoice(repository.Invoice{ + AssignmentID: assignmentID, + InvoiceNumber: in.InvoiceNumber, + TotalAmount: total, + }); err != nil { + s.errl.Printf("could not record invoice %s: %v", in.InvoiceNumber, err) + } + + filename := fmt.Sprintf("faktura_%s_%s.pdf", in.InvoiceNumber, sanitize(assignment.Name)) + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + w.Header().Set("Content-Length", strconv.Itoa(len(pdfBytes))) + w.Write(pdfBytes) +} + +func parseExtraLines(r *http.Request) []ExtraLine { + descs := r.Form["extra_desc"] + qtys := r.Form["extra_qty"] + units := r.Form["extra_unit"] + prices := r.Form["extra_price"] + + var lines []ExtraLine + for i := range descs { + desc := strings.TrimSpace(descs[i]) + if desc == "" { + continue + } + qty, unit, price := 0.0, "stk", 0.0 + if i < len(qtys) { + qty, _ = strconv.ParseFloat(qtys[i], 64) + } + if i < len(units) && units[i] != "" { + unit = units[i] + } + if i < len(prices) { + price, _ = strconv.ParseFloat(prices[i], 64) + } + if qty == 0 || price == 0 { + continue + } + lines = append(lines, ExtraLine{Description: desc, Quantity: qty, Unit: unit, UnitPrice: price}) + } + return lines +} + +// ── PDF generation (ported from the desktop app) ───────────────────────────── + +// stripTaskNumber removes the "N - " prefix from task descriptions. +// "1 - Frontend work" → "Frontend work" +func stripTaskNumber(desc string) string { + if idx := strings.Index(desc, " - "); idx >= 0 { + prefix := desc[:idx] + allDigits := true + for _, c := range prefix { + if c < '0' || c > '9' { + allDigits = false + break + } + } + if allDigits { + return desc[idx+3:] + } + } + return desc +} + +// generateInvoicePDF builds the invoice and returns the PDF bytes and the +// grand total (incl. MVG) for record keeping. +func generateInvoicePDF(d invoiceInput) ([]byte, float64, error) { + pdf := fpdf.New("P", "mm", "A4", "") + pdf.SetMargins(10, 15, 10) + pdf.SetAutoPageBreak(true, 30) + + fontFamily := "Helvetica" + txt := pdf.UnicodeTranslatorFromDescriptor("") + + footer1 := d.FromName + if d.FromAddress != "" { + footer1 += " / " + d.FromAddress + } + var footer2Parts []string + if d.FromVAT != "" { + footer2Parts = append(footer2Parts, "V-tal: "+d.FromVAT) + } + if d.FromPhone != "" { + footer2Parts = append(footer2Parts, "tlf.: "+d.FromPhone) + } + if d.FromEmail != "" { + footer2Parts = append(footer2Parts, "teldupostur: "+d.FromEmail) + } + if d.FromWeb != "" { + footer2Parts = append(footer2Parts, "heimas\u00ed\u00f0a: "+d.FromWeb) + } + footer2 := strings.Join(footer2Parts, " / ") + + pdf.SetFooterFunc(func() { + pdf.SetY(-20) + pdf.SetTextColor(100, 100, 100) + pdf.SetFont(fontFamily, "", 8) + pdf.SetDrawColor(100, 100, 100) + pdf.SetLineWidth(0.2) + pdf.Line(10, pdf.GetY(), 200, pdf.GetY()) + pdf.Ln(2) + pdf.CellFormat(190, 4, txt(footer1), "", 0, "C", false, 0, "") + pdf.Ln(-1) + if footer2 != "" { + pdf.CellFormat(190, 4, txt(footer2), "", 0, "C", false, 0, "") + } + }) + + pdf.AddPage() + + darkBlue := func() { pdf.SetTextColor(68, 84, 106) } + black := func() { pdf.SetTextColor(0, 0, 0) } + setFont := func(style string, size float64) { pdf.SetFont(fontFamily, style, size) } + + // Header + darkBlue() + setFont("B", 20) + pdf.SetXY(10, 20) + pdf.CellFormat(190, 10, txt(d.FromName), "", 0, "R", false, 0, "") + + // Customer info + pdf.SetXY(10, 35) + darkBlue() + setFont("B", 11) + pdf.Cell(190, 5, txt(d.Company.Name)) + pdf.Ln(5) + + setFont("", 10) + darkBlue() + if d.Company.Address != "" { + pdf.Cell(190, 5, txt(d.Company.Address)) + pdf.Ln(5) + } + if d.Company.ContactEmail != "" { + pdf.Cell(190, 5, txt(d.Company.ContactEmail)) + pdf.Ln(5) + } + if d.Company.ContactPhone != "" { + pdf.Cell(190, 5, txt(d.Company.ContactPhone)) + pdf.Ln(5) + } + pdf.Ln(10) + + // Invoice meta + yMeta := pdf.GetY() + black() + setFont("", 10) + pdf.CellFormat(12, 5, txt("Dato: "), "", 0, "L", false, 0, "") + setFont("B", 10) + pdf.CellFormat(50, 5, d.Date.Format("02/01/2006"), "", 0, "L", false, 0, "") + + pdf.SetXY(140, yMeta) + setFont("", 10) + pdf.CellFormat(30, 5, txt("Fakturanr. "), "", 0, "R", false, 0, "") + setFont("B", 10) + pdf.CellFormat(20, 5, txt(d.InvoiceNumber), "", 0, "R", false, 0, "") + + pdf.SetXY(10, yMeta+6) + if d.Comment != "" { + setFont("", 10) + pdf.MultiCell(190, 5, txt(d.Comment), "", "L", false) + } + pdf.Ln(10) + + // Title + darkBlue() + setFont("B", 16) + pdf.Cell(190, 8, txt("Faktura")) + pdf.Ln(10) + + // Table + colW := []float64{80, 20, 20, 25, 20, 25} + + pdf.SetDrawColor(68, 84, 106) + pdf.SetLineWidth(0.6) + pdf.Line(10, pdf.GetY(), 200, pdf.GetY()) + pdf.SetY(pdf.GetY() + 1) + + darkBlue() + setFont("", 10) + pdf.CellFormat(colW[0], 7, txt("Lýsing"), "", 0, "L", false, 0, "") + pdf.CellFormat(colW[1], 7, txt("Nøgd"), "", 0, "R", false, 0, "") + pdf.CellFormat(colW[2], 7, txt("Eind"), "", 0, "L", false, 0, "") + pdf.CellFormat(colW[3], 7, txt("Stk. prísur"), "", 0, "R", false, 0, "") + pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "") + pdf.CellFormat(colW[5], 7, txt("Upphædd"), "", 0, "R", false, 0, "") + pdf.Ln(-1) + + pdf.Line(10, pdf.GetY(), 200, pdf.GetY()) + pdf.SetY(pdf.GetY() + 1) + + // group sessions by task + type taskLine struct { + desc string + hours float64 + amount float64 + } + taskMap := make(map[int64]*taskLine) + var taskOrder []int64 + + for _, ws := range d.Sessions { + if ws.EndTime == nil { + continue + } + hours := ws.DurationMinutes / 60.0 + amount := hours * d.HourlyRate + if tl, ok := taskMap[ws.TaskID]; ok { + tl.hours += hours + tl.amount += amount + } else { + taskMap[ws.TaskID] = &taskLine{desc: ws.TaskDescription, hours: hours, amount: amount} + taskOrder = append(taskOrder, ws.TaskID) + } + } + + setFont("", 10) + pdf.SetTextColor(50, 50, 50) + + subtotal := 0.0 + for _, tid := range taskOrder { + tl := taskMap[tid] + desc := stripTaskNumber(tl.desc) + if len(desc) > 45 { + desc = desc[:42] + "..." + } + pdf.CellFormat(colW[0], 7, txt(desc), "", 0, "L", false, 0, "") + pdf.CellFormat(colW[1], 7, fmtNum(tl.hours), "", 0, "R", false, 0, "") + pdf.CellFormat(colW[2], 7, txt(" tímar"), "", 0, "L", false, 0, "") + pdf.CellFormat(colW[3], 7, fmtNum(d.HourlyRate), "", 0, "R", false, 0, "") + pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "") + pdf.CellFormat(colW[5], 7, fmtNum(tl.amount), "", 0, "R", false, 0, "") + pdf.Ln(-1) + subtotal += tl.amount + } + + // Extra lines + for _, el := range d.ExtraLines { + desc := el.Description + if len(desc) > 45 { + desc = desc[:42] + "..." + } + amount := el.Quantity * el.UnitPrice + pdf.CellFormat(colW[0], 7, txt(desc), "", 0, "L", false, 0, "") + pdf.CellFormat(colW[1], 7, fmtNum(el.Quantity), "", 0, "R", false, 0, "") + pdf.CellFormat(colW[2], 7, txt(" "+el.Unit), "", 0, "L", false, 0, "") + pdf.CellFormat(colW[3], 7, fmtNum(el.UnitPrice), "", 0, "R", false, 0, "") + pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "") + pdf.CellFormat(colW[5], 7, fmtNum(amount), "", 0, "R", false, 0, "") + pdf.Ln(-1) + subtotal += amount + } + + pdf.Ln(5) + + // Totals + offset := colW[0] + colW[1] + colW[2] + totW := colW[3] + colW[4] + colW[5] + vat := subtotal * (d.MVGPercent / 100.0) + total := subtotal + vat + + darkBlue() + setFont("", 10) + pdf.SetX(10 + offset) + pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY()) + pdf.SetY(pdf.GetY() + 1) + + pdf.SetX(10 + offset) + pdf.CellFormat(totW-colW[5], 6, txt("Í alt DKK uttan MVG"), "", 0, "L", false, 0, "") + black() + pdf.CellFormat(colW[5], 6, fmtNum(subtotal), "", 0, "R", false, 0, "") + pdf.Ln(-1) + + darkBlue() + pdf.SetX(10 + offset) + pdf.CellFormat(totW-colW[5], 6, txt(fmt.Sprintf("%.0f%% MVG", d.MVGPercent)), "", 0, "L", false, 0, "") + black() + pdf.CellFormat(colW[5], 6, fmtNum(vat), "", 0, "R", false, 0, "") + pdf.Ln(-1) + + darkBlue() + setFont("B", 10) + pdf.SetX(10 + offset) + pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY()) + pdf.SetY(pdf.GetY() + 1) + + pdf.SetX(10 + offset) + pdf.CellFormat(totW-colW[5], 6, txt("Í alt DKK vid MVG"), "", 0, "L", false, 0, "") + black() + pdf.CellFormat(colW[5], 6, fmtNum(total), "", 0, "R", false, 0, "") + pdf.Ln(-1) + + pdf.SetX(10 + offset) + pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY()) + pdf.Ln(20) + + // Payment terms + darkBlue() + setFont("", 9) + pdf.Cell(190, 5, txt(fmt.Sprintf( + "Gjaldstreytir: Netto %d dagar - Fellir á dato %s", + d.DueDays, d.DueDate.Format("02/01/2006"), + ))) + pdf.Ln(8) + + if d.BankInfo != "" { + pdf.Cell(190, 5, txt("Upphæddin skal setast inn á bankakontu:")) + pdf.Ln(5) + for _, line := range strings.Split(d.BankInfo, "\n") { + pdf.Cell(190, 5, txt(strings.TrimRight(line, "\r"))) + pdf.Ln(5) + } + } + + pdf.Cell(190, 5, txt(fmt.Sprintf("Biði verður um Fakturanr. %s við flytingini", d.InvoiceNumber))) + pdf.Ln(8) + pdf.Cell(190, 5, txt("Verður ikki goldið innan lokna gjaldsfrest, verður renta tilskriva á 0,70%, umframt eitt gjald uppá 100,00 DKK")) + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, 0, err + } + return buf.Bytes(), total, nil +} + +func fmtNum(v float64) string { + return fmt.Sprintf("%.2f", v) +} + +func sanitize(name string) string { + var b strings.Builder + for _, c := range name { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { + b.WriteRune(c) + } else if c == ' ' { + b.WriteByte('_') + } + } + return b.String() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..17319c4 --- /dev/null +++ b/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "flag" + "log" + "net/http" + "os" + "sync" + "time" + + "zeitkort-web/repository" +) + +// Version is set at build time via -ldflags. +var Version = "dev" + +const resetInterval = 10 * time.Minute + +// Server holds shared dependencies. +type Server struct { + mu sync.Mutex // guards Cfg/resetAt when Settings are saved/reset + Cfg Config + initialCfg Config + resetAt time.Time + repo repository.Repository + info *log.Logger + errl *log.Logger +} + +func main() { + cfgPath := flag.String("config", "config.json", "path to config.json") + flag.Parse() + + info := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime) + errl := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile) + + cfg, err := loadConfig(*cfgPath) + if err != nil { + errl.Fatalf("config: %v", err) + } + + repo := repository.NewMemoryRepository() + if err := repo.Migrate(); err != nil { + errl.Fatalf("repository: %v", err) + } + + s := &Server{ + Cfg: cfg, + initialCfg: cfg, + resetAt: time.Now().Add(resetInterval), + repo: repo, + info: info, + errl: errl, + } + + if err := parseTemplates(); err != nil { + errl.Fatalf("templates: %v", err) + } + + go s.resetLoop(resetInterval) + + info.Printf("zeitkort-web %s listening on %s (web-only POC; in-memory state resets every %s)", Version, cfg.ListenAddr, resetInterval) + if err := http.ListenAndServe(cfg.ListenAddr, s.routes()); err != nil { + errl.Fatal(err) + } +} + +func (s *Server) nextResetAt() time.Time { + s.mu.Lock() + defer s.mu.Unlock() + return s.resetAt +} + +func (s *Server) resetLoop(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + if resetter, ok := s.repo.(interface{ Reset() }); ok { + resetter.Reset() + } + s.mu.Lock() + s.Cfg = s.initialCfg + s.resetAt = time.Now().Add(interval) + s.mu.Unlock() + s.info.Printf("in-memory proof-of-concept state reset after %s", interval) + } +} + +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + + // Static assets (css + the one small timer script), embedded in the binary. + mux.Handle("GET /static/", staticHandler()) + + // Main working area. + mux.HandleFunc("GET /", s.handleIndex) + mux.HandleFunc("GET /main", s.handleMain) + + // Companies. + mux.HandleFunc("GET /companies/new", s.handleCompanyForm) + mux.HandleFunc("GET /companies/{id}/edit", s.handleCompanyForm) + mux.HandleFunc("POST /companies", s.handleCompanySave) + mux.HandleFunc("POST /companies/{id}", s.handleCompanySave) + mux.HandleFunc("POST /companies/{id}/delete", s.handleCompanyDelete) + + // Assignments. + mux.HandleFunc("GET /assignments/new", s.handleAssignmentForm) + mux.HandleFunc("GET /assignments/{id}/edit", s.handleAssignmentForm) + mux.HandleFunc("POST /assignments", s.handleAssignmentSave) + mux.HandleFunc("POST /assignments/{id}", s.handleAssignmentSave) + mux.HandleFunc("POST /assignments/{id}/delete", s.handleAssignmentDelete) + + // Tasks. + mux.HandleFunc("GET /tasks/new", s.handleTaskNewForm) + mux.HandleFunc("GET /tasks/{id}/edit", s.handleTaskEditForm) + mux.HandleFunc("POST /tasks", s.handleTaskCreate) + mux.HandleFunc("POST /tasks/{id}", s.handleTaskUpdate) + mux.HandleFunc("POST /tasks/{id}/delete", s.handleTaskDelete) + + // Work sessions. + mux.HandleFunc("POST /sessions/start", s.handleSessionStart) + mux.HandleFunc("POST /sessions/{id}/stop", s.handleSessionStop) + mux.HandleFunc("GET /sessions/new", s.handleSessionForm) + mux.HandleFunc("GET /sessions/{id}/edit", s.handleSessionForm) + mux.HandleFunc("POST /sessions/manual", s.handleSessionSave) + mux.HandleFunc("POST /sessions/{id}", s.handleSessionSave) + mux.HandleFunc("POST /sessions/{id}/delete", s.handleSessionDelete) + + // Invoice. + mux.HandleFunc("GET /invoice", s.handleInvoiceForm) + mux.HandleFunc("GET /invoice/line", s.handleInvoiceLine) + mux.HandleFunc("POST /invoice", s.handleInvoiceGenerate) + + // Settings and POC information. + mux.HandleFunc("GET /settings", s.handleSettings) + mux.HandleFunc("POST /settings", s.handleSettingsSave) + mux.HandleFunc("GET /about", s.handleAbout) + + // Used by htmx to remove a row from the page (returns empty body). + mux.HandleFunc("GET /void", func(w http.ResponseWriter, r *http.Request) {}) + + return s.logging(mux) +} + +func (s *Server) logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + s.info.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) + }) +} diff --git a/repository/memory.go b/repository/memory.go new file mode 100644 index 0000000..fa04c28 --- /dev/null +++ b/repository/memory.go @@ -0,0 +1,543 @@ +package repository + +import ( + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// MemoryRepository implements Repository with process-local memory only. +// It is intended for web-only proof-of-concept deployments where data should +// disappear periodically and never be written to disk. +type MemoryRepository struct { + mu sync.RWMutex + + nextCompanyID int64 + nextAssignmentID int64 + nextTaskID int64 + nextSessionID int64 + nextInvoiceID int64 + + companies map[int64]Company + assignments map[int64]Assignment + tasks map[int64]Task + sessions map[int64]WorkSession + invoices map[int64]Invoice +} + +func NewMemoryRepository() *MemoryRepository { + repo := &MemoryRepository{} + repo.Reset() + return repo +} + +// Reset wipes every in-memory record and restarts all ids at 1. +func (repo *MemoryRepository) Reset() { + repo.mu.Lock() + defer repo.mu.Unlock() + + repo.nextCompanyID = 1 + repo.nextAssignmentID = 1 + repo.nextTaskID = 1 + repo.nextSessionID = 1 + repo.nextInvoiceID = 1 + + repo.companies = make(map[int64]Company) + repo.assignments = make(map[int64]Assignment) + repo.tasks = make(map[int64]Task) + repo.sessions = make(map[int64]WorkSession) + repo.invoices = make(map[int64]Invoice) +} + +func (repo *MemoryRepository) Migrate() error { return nil } + +// ── Companies ────────────────────────────────────────────────────────────────── + +func (repo *MemoryRepository) InsertCompany(c Company) (*Company, error) { + repo.mu.Lock() + defer repo.mu.Unlock() + + c.ID = repo.nextCompanyID + repo.nextCompanyID++ + c.CreatedAt = time.Now() + repo.companies[c.ID] = c + return copyCompany(c), nil +} + +func (repo *MemoryRepository) AllCompanies() ([]Company, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + all := make([]Company, 0, len(repo.companies)) + for _, c := range repo.companies { + all = append(all, c) + } + sort.Slice(all, func(i, j int) bool { + return strings.ToLower(all[i].Name) < strings.ToLower(all[j].Name) + }) + return all, nil +} + +func (repo *MemoryRepository) GetCompanyByID(id int64) (*Company, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + c, ok := repo.companies[id] + if !ok { + return nil, ErrNotFound + } + return copyCompany(c), nil +} + +func (repo *MemoryRepository) UpdateCompany(id int64, updated Company) error { + repo.mu.Lock() + defer repo.mu.Unlock() + + c, ok := repo.companies[id] + if !ok || id == 0 { + return ErrUpdateFailed + } + c.Name = updated.Name + c.Address = updated.Address + c.ContactEmail = updated.ContactEmail + c.ContactPhone = updated.ContactPhone + c.HourlyRate = updated.HourlyRate + repo.companies[id] = c + + for assignmentID, a := range repo.assignments { + if a.CompanyID == id { + a.CompanyName = c.Name + repo.assignments[assignmentID] = a + } + } + return nil +} + +func (repo *MemoryRepository) DeleteCompany(id int64) error { + repo.mu.Lock() + defer repo.mu.Unlock() + + for _, a := range repo.assignments { + if a.CompanyID == id { + return ErrHasChildren + } + } + if _, ok := repo.companies[id]; !ok { + return ErrDeleteFailed + } + delete(repo.companies, id) + return nil +} + +// ── Assignments ──────────────────────────────────────────────────────────────── + +func (repo *MemoryRepository) InsertAssignment(a Assignment) (*Assignment, error) { + repo.mu.Lock() + defer repo.mu.Unlock() + + company, ok := repo.companies[a.CompanyID] + if !ok { + return nil, ErrNotFound + } + a.ID = repo.nextAssignmentID + repo.nextAssignmentID++ + a.CompanyName = company.Name + if a.Status == "" { + a.Status = "active" + } + a.CreatedAt = time.Now() + repo.assignments[a.ID] = a + return copyAssignment(a), nil +} + +func (repo *MemoryRepository) AssignmentsByCompany(companyID int64) ([]Assignment, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + all := make([]Assignment, 0) + for _, a := range repo.assignments { + if a.CompanyID == companyID { + all = append(all, a) + } + } + sort.Slice(all, func(i, j int) bool { + return all[i].CreatedAt.After(all[j].CreatedAt) + }) + return all, nil +} + +func (repo *MemoryRepository) AllActiveAssignments() ([]Assignment, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + all := make([]Assignment, 0) + for _, a := range repo.assignments { + if a.Status == "active" { + all = append(all, a) + } + } + sort.Slice(all, func(i, j int) bool { + if strings.ToLower(all[i].CompanyName) == strings.ToLower(all[j].CompanyName) { + return strings.ToLower(all[i].Name) < strings.ToLower(all[j].Name) + } + return strings.ToLower(all[i].CompanyName) < strings.ToLower(all[j].CompanyName) + }) + return all, nil +} + +func (repo *MemoryRepository) GetAssignmentByID(id int64) (*Assignment, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + a, ok := repo.assignments[id] + if !ok { + return nil, ErrNotFound + } + return copyAssignment(a), nil +} + +func (repo *MemoryRepository) UpdateAssignment(id int64, updated Assignment) error { + repo.mu.Lock() + defer repo.mu.Unlock() + + a, ok := repo.assignments[id] + if !ok || id == 0 { + return ErrUpdateFailed + } + a.Name = updated.Name + a.Description = updated.Description + a.HourlyRate = updated.HourlyRate + if updated.Status != "" { + a.Status = updated.Status + } + repo.assignments[id] = a + return nil +} + +func (repo *MemoryRepository) DeleteAssignment(id int64) error { + repo.mu.Lock() + defer repo.mu.Unlock() + + for _, t := range repo.tasks { + if t.AssignmentID == id { + return ErrHasChildren + } + } + if _, ok := repo.assignments[id]; !ok { + return ErrDeleteFailed + } + for sessionID, s := range repo.sessions { + if s.AssignmentID == id { + delete(repo.sessions, sessionID) + } + } + delete(repo.assignments, id) + return nil +} + +// ── Tasks ────────────────────────────────────────────────────────────────────── + +func (repo *MemoryRepository) InsertTask(t Task) (*Task, error) { + repo.mu.Lock() + defer repo.mu.Unlock() + + if _, ok := repo.assignments[t.AssignmentID]; !ok { + return nil, ErrNotFound + } + maxNum := 0 + for _, existing := range repo.tasks { + if existing.AssignmentID == t.AssignmentID && existing.Number > maxNum { + maxNum = existing.Number + } + } + t.ID = repo.nextTaskID + repo.nextTaskID++ + t.Number = maxNum + 1 + repo.tasks[t.ID] = t + withTotals := repo.taskWithTotalLocked(t) + return copyTask(withTotals), nil +} + +func (repo *MemoryRepository) TasksByAssignment(assignmentID int64) ([]Task, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + all := make([]Task, 0) + for _, t := range repo.tasks { + if t.AssignmentID == assignmentID { + all = append(all, repo.taskWithTotalLocked(t)) + } + } + sort.Slice(all, func(i, j int) bool { return all[i].Number < all[j].Number }) + return all, nil +} + +func (repo *MemoryRepository) GetTaskByID(id int64) (*Task, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + t, ok := repo.tasks[id] + if !ok { + return nil, ErrNotFound + } + withTotals := repo.taskWithTotalLocked(t) + return copyTask(withTotals), nil +} + +func (repo *MemoryRepository) UpdateTask(id int64, name string) error { + repo.mu.Lock() + defer repo.mu.Unlock() + + t, ok := repo.tasks[id] + if !ok || id == 0 || name == "" { + return ErrUpdateFailed + } + t.Name = name + repo.tasks[id] = t + + newDesc := fmt.Sprintf("%d - %s", t.Number, t.Name) + for sessionID, s := range repo.sessions { + if s.TaskID == id { + s.TaskDescription = newDesc + repo.sessions[sessionID] = s + } + } + return nil +} + +func (repo *MemoryRepository) DeleteTask(id int64) error { + repo.mu.Lock() + defer repo.mu.Unlock() + + for _, s := range repo.sessions { + if s.TaskID == id { + return ErrHasChildren + } + } + if _, ok := repo.tasks[id]; !ok { + return ErrDeleteFailed + } + delete(repo.tasks, id) + return nil +} + +func (repo *MemoryRepository) TotalMinutesForTask(taskID int64) (float64, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + if _, ok := repo.tasks[taskID]; !ok { + return 0, ErrNotFound + } + return repo.totalMinutesForTaskLocked(taskID), nil +} + +// ── Work Sessions ────────────────────────────────────────────────────────────── + +func (repo *MemoryRepository) InsertWorkSession(s WorkSession) (*WorkSession, error) { + repo.mu.Lock() + defer repo.mu.Unlock() + + if _, ok := repo.assignments[s.AssignmentID]; !ok { + return nil, ErrNotFound + } + if _, ok := repo.tasks[s.TaskID]; !ok { + return nil, ErrNotFound + } + s.ID = repo.nextSessionID + repo.nextSessionID++ + s.StartTime = time.Now() + s.EndTime = nil + s.DurationMinutes = 0 + repo.sessions[s.ID] = s + return copyWorkSession(s), nil +} + +func (repo *MemoryRepository) StopWorkSession(id int64) error { + repo.mu.Lock() + defer repo.mu.Unlock() + + s, ok := repo.sessions[id] + if !ok { + return ErrUpdateFailed + } + now := time.Now() + s.EndTime = &now + s.DurationMinutes = now.Sub(s.StartTime).Minutes() + repo.sessions[id] = s + return nil +} + +func (repo *MemoryRepository) InsertManualWorkSession(s WorkSession) (*WorkSession, error) { + repo.mu.Lock() + defer repo.mu.Unlock() + + if s.EndTime == nil { + return nil, ErrUpdateFailed + } + if _, ok := repo.assignments[s.AssignmentID]; !ok { + return nil, ErrNotFound + } + if _, ok := repo.tasks[s.TaskID]; !ok { + return nil, ErrNotFound + } + s.ID = repo.nextSessionID + repo.nextSessionID++ + end := *s.EndTime + s.EndTime = &end + s.DurationMinutes = end.Sub(s.StartTime).Minutes() + repo.sessions[s.ID] = s + return copyWorkSession(s), nil +} + +func (repo *MemoryRepository) UpdateWorkSession(id int64, updated WorkSession) error { + repo.mu.Lock() + defer repo.mu.Unlock() + + s, ok := repo.sessions[id] + if !ok || id == 0 { + return ErrUpdateFailed + } + if _, ok := repo.tasks[updated.TaskID]; !ok { + return ErrNotFound + } + s.TaskID = updated.TaskID + s.TaskDescription = updated.TaskDescription + s.StartTime = updated.StartTime + if updated.EndTime != nil { + end := *updated.EndTime + s.EndTime = &end + s.DurationMinutes = end.Sub(updated.StartTime).Minutes() + } else { + s.EndTime = nil + s.DurationMinutes = 0 + } + repo.sessions[id] = s + return nil +} + +func (repo *MemoryRepository) DeleteWorkSession(id int64) error { + repo.mu.Lock() + defer repo.mu.Unlock() + + if _, ok := repo.sessions[id]; !ok { + return ErrDeleteFailed + } + delete(repo.sessions, id) + return nil +} + +func (repo *MemoryRepository) WorkSessionsByAssignment(assignmentID int64, limit int) ([]WorkSession, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + all := make([]WorkSession, 0) + for _, s := range repo.sessions { + if s.AssignmentID == assignmentID { + all = append(all, *copyWorkSession(s)) + } + } + sort.Slice(all, func(i, j int) bool { return all[i].StartTime.After(all[j].StartTime) }) + if limit > 0 && len(all) > limit { + all = all[:limit] + } + return all, nil +} + +func (repo *MemoryRepository) TotalHoursForAssignment(assignmentID int64) (float64, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + if _, ok := repo.assignments[assignmentID]; !ok { + return 0, ErrNotFound + } + var minutes float64 + for _, s := range repo.sessions { + if s.AssignmentID == assignmentID && s.EndTime != nil { + minutes += s.DurationMinutes + } + } + return minutes / 60.0, nil +} + +// ── Invoices ─────────────────────────────────────────────────────────────────── + +func (repo *MemoryRepository) NextInvoiceNumber(date time.Time) (string, error) { + repo.mu.RLock() + defer repo.mu.RUnlock() + + prefix := date.Format("2006-01-02") + count := 0 + for _, inv := range repo.invoices { + if strings.HasPrefix(inv.InvoiceNumber, prefix+"_") { + count++ + } + } + return fmt.Sprintf("%s_%d", prefix, count+1), nil +} + +func (repo *MemoryRepository) InsertInvoice(inv Invoice) (*Invoice, error) { + repo.mu.Lock() + defer repo.mu.Unlock() + + if _, ok := repo.assignments[inv.AssignmentID]; !ok { + return nil, ErrNotFound + } + for _, existing := range repo.invoices { + if existing.InvoiceNumber == inv.InvoiceNumber { + return nil, fmt.Errorf("invoice number already exists") + } + } + inv.ID = repo.nextInvoiceID + repo.nextInvoiceID++ + inv.GeneratedAt = time.Now() + repo.invoices[inv.ID] = inv + return copyInvoice(inv), nil +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +func (repo *MemoryRepository) taskWithTotalLocked(t Task) Task { + t.TotalMinutes = repo.totalMinutesForTaskLocked(t.ID) + return t +} + +func (repo *MemoryRepository) totalMinutesForTaskLocked(taskID int64) float64 { + var total float64 + for _, s := range repo.sessions { + if s.TaskID == taskID && s.EndTime != nil { + total += s.DurationMinutes + } + } + return total +} + +func copyCompany(c Company) *Company { + v := c + return &v +} + +func copyAssignment(a Assignment) *Assignment { + v := a + return &v +} + +func copyTask(t Task) *Task { + v := t + return &v +} + +func copyWorkSession(s WorkSession) *WorkSession { + v := s + if s.EndTime != nil { + end := *s.EndTime + v.EndTime = &end + } + return &v +} + +func copyInvoice(inv Invoice) *Invoice { + v := inv + return &v +} diff --git a/repository/repository.go b/repository/repository.go new file mode 100644 index 0000000..9b9da66 --- /dev/null +++ b/repository/repository.go @@ -0,0 +1,102 @@ +package repository + +import ( + "errors" + "time" +) + +var ( + ErrUpdateFailed = errors.New("update failed") + ErrDeleteFailed = errors.New("delete failed") + ErrNotFound = errors.New("record not found") + ErrHasChildren = errors.New("cannot delete: has dependent records that must be removed first") +) + +// Repository is the interface which must be satisfied in order to +// connect to a database. +type Repository interface { + Migrate() error + + InsertCompany(c Company) (*Company, error) + AllCompanies() ([]Company, error) + GetCompanyByID(id int64) (*Company, error) + UpdateCompany(id int64, updated Company) error + DeleteCompany(id int64) error + + InsertAssignment(a Assignment) (*Assignment, error) + AssignmentsByCompany(companyID int64) ([]Assignment, error) + AllActiveAssignments() ([]Assignment, error) + GetAssignmentByID(id int64) (*Assignment, error) + UpdateAssignment(id int64, updated Assignment) error + DeleteAssignment(id int64) error + + InsertTask(t Task) (*Task, error) + TasksByAssignment(assignmentID int64) ([]Task, error) + GetTaskByID(id int64) (*Task, error) + UpdateTask(id int64, name string) error + DeleteTask(id int64) error + TotalMinutesForTask(taskID int64) (float64, error) + + InsertWorkSession(s WorkSession) (*WorkSession, error) + InsertManualWorkSession(s WorkSession) (*WorkSession, error) + StopWorkSession(id int64) error + UpdateWorkSession(id int64, updated WorkSession) error + DeleteWorkSession(id int64) error + WorkSessionsByAssignment(assignmentID int64, limit int) ([]WorkSession, error) + TotalHoursForAssignment(assignmentID int64) (float64, error) + + NextInvoiceNumber(date time.Time) (string, error) + InsertInvoice(inv Invoice) (*Invoice, error) +} + +type Company struct { + ID int64 `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + ContactEmail string `json:"contact_email"` + ContactPhone string `json:"contact_phone"` + HourlyRate float64 `json:"hourly_rate"` + CreatedAt time.Time `json:"created_at"` +} + +type Assignment struct { + ID int64 `json:"id"` + CompanyID int64 `json:"company_id"` + CompanyName string `json:"company_name"` + Name string `json:"name"` + Description string `json:"description"` + HourlyRate float64 `json:"hourly_rate"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +// Task is a reusable task within an assignment. Each task gets an +// incremental number scoped to its assignment (1, 2, 3...). +// Time spent on sessions with this task stacks up. +type Task struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + Number int `json:"number"` + Name string `json:"name"` + TotalMinutes float64 `json:"total_minutes"` +} + +// WorkSession is a single timed block of work on a task. +type WorkSession struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + TaskID int64 `json:"task_id"` + TaskDescription string `json:"task_description"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` + DurationMinutes float64 `json:"duration_minutes"` +} + +// Invoice is a generated invoice record, used for tracking invoice numbers. +type Invoice struct { + ID int64 `json:"id"` + AssignmentID int64 `json:"assignment_id"` + InvoiceNumber string `json:"invoice_number"` + TotalAmount float64 `json:"total_amount"` + GeneratedAt time.Time `json:"generated_at"` +} diff --git a/settings.go b/settings.go new file mode 100644 index 0000000..212bee3 --- /dev/null +++ b/settings.go @@ -0,0 +1,48 @@ +package main + +import ( + "net/http" + "strconv" +) + +type settingsView struct { + Cfg Config + Saved bool + Error string + Version string +} + +func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + cfg := s.Cfg + s.mu.Unlock() + s.render(w, "settings_page", settingsView{Cfg: cfg, Version: Version}) +} + +func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + + dueDays, _ := strconv.Atoi(r.FormValue("payment_terms_days")) + mvg, _ := strconv.ParseFloat(r.FormValue("mvg_percent"), 64) + + s.mu.Lock() + // Web-only POC: settings edits stay in memory only and are reset with the + // rest of the POC state every 10 minutes. config.json is never rewritten. + s.Cfg.CompanyName = r.FormValue("company_name") + s.Cfg.CompanyAddress = r.FormValue("company_address") + s.Cfg.CompanyPhone = r.FormValue("company_phone") + s.Cfg.CompanyEmail = r.FormValue("company_email") + s.Cfg.CompanyWeb = r.FormValue("company_web") + s.Cfg.TaxID = r.FormValue("tax_id") + s.Cfg.BankInfo = r.FormValue("bank_info") + if dueDays > 0 { + s.Cfg.PaymentTermsDays = dueDays + } + if mvg > 0 { + s.Cfg.MVGPercent = mvg + } + cfg := s.Cfg + s.mu.Unlock() + + s.render(w, "settings_page", settingsView{Cfg: cfg, Saved: true, Version: Version}) +} diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..60003fb --- /dev/null +++ b/static/app.css @@ -0,0 +1,279 @@ +/* zeitkort-web — deliberately plain. Light + dark (follows the browser). */ + +:root { + color-scheme: light dark; + --bg: #fff; + --fg: #000; + --muted: #777; + --border: #999; + --border-soft: #ccc; + --dotted: #ccc; + --field-bg: #fff; + --field-border: #888; + --btn-bg: #eee; + --btn-bg-hover: #ddd; + --btn-border: #555; + --header-border: #000; + --th-bg: #f0f0f0; + --row-active: #ffd; + --link: #003; + --accent-bg: #e3ecff; + --accent-border: #357; + --notice-fg: #060; --notice-bg: #efe; --notice-border: #060; + --error-fg: #a00; --error-bg: #fee; --error-border: #a00; + --go-bg: #e7f5e7; --go-border: #2a8a2a; --go-bg-hover: #d6eed6; + --stop-bg: #fbe4e4; --stop-border: #a00; --stop-bg-hover: #f6d2d2; + --card-bg: #fff; --card-border: #a00; + --danger-btn-bg: #fee; --danger-btn-bg-hover: #fdd; + --backdrop: rgba(0, 0, 0, .35); + --shadow: rgba(0, 0, 0, .4); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1d1f21; + --fg: #e6e6e6; + --muted: #9aa0a6; + --border: #555; + --border-soft: #3a3d40; + --dotted: #3a3d40; + --field-bg: #2a2d30; + --field-border: #5a5e62; + --btn-bg: #34383c; + --btn-bg-hover: #3f4348; + --btn-border: #6a6e72; + --header-border: #6a6e72; + --th-bg: #2a2d30; + --row-active: #3a3a20; + --link: #9bbcff; + --accent-bg: #21344f; + --accent-border: #5a86c0; + --notice-fg: #8fe08f; --notice-bg: #142a14; --notice-border: #3f7a3f; + --error-fg: #ff9b9b; --error-bg: #3a1616; --error-border: #a85050; + --go-bg: #1f3a1f; --go-border: #4caf4c; --go-bg-hover: #264826; + --stop-bg: #3a1a1a; --stop-border: #cc5555; --stop-bg-hover: #4a2222; + --card-bg: #26292c; --card-border: #cc5555; + --danger-btn-bg: #3a1a1a; --danger-btn-bg-hover: #4a2222; + --backdrop: rgba(0, 0, 0, .6); + --shadow: rgba(0, 0, 0, .75); + } +} + +* { box-sizing: border-box; } +html, body { max-width: 100%; overflow-x: hidden; } +body { + font-family: -apple-system, Helvetica, Arial, sans-serif; + font-size: 15px; + line-height: 1.4; + color: var(--fg); + background: var(--bg); + margin: 0; + padding: 0; +} + +/* Long, unbreakable names must never widen the page on mobile. */ +main { overflow-wrap: break-word; padding: 16px; max-width: 1040px; margin: 0 auto; } +input, select, textarea { max-width: 100%; } +.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } + +header { + border-bottom: 2px solid var(--header-border); + padding: 8px 16px; + display: flex; + align-items: baseline; + gap: 20px; +} +.brand { font-weight: bold; font-size: 18px; } +nav a { margin-right: 14px; text-decoration: none; color: var(--link); } +nav a:hover { text-decoration: underline; } +footer { padding: 12px 16px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--border-soft); margin-top: 24px; } + +h2 { margin: 0 0 12px; } + +fieldset { border: 1px solid var(--border); margin: 0 0 14px; padding: 10px 12px; min-width: 0; } +legend { font-weight: bold; padding: 0 4px; } + +select, input[type=text], input[type=number], input:not([type]), textarea { + font: inherit; + padding: 3px 5px; + border: 1px solid var(--field-border); + background: var(--field-bg); + color: var(--fg); +} +textarea { width: 100%; max-width: 460px; } +label { display: inline-block; } + +button { + font: inherit; + padding: 3px 10px; + border: 1px solid var(--btn-border); + background: var(--btn-bg); + color: var(--fg); + cursor: pointer; +} +button:hover { background: var(--btn-bg-hover); } + +.muted { color: var(--muted); font-size: 13px; } +.error { color: var(--error-fg); border: 1px solid var(--error-border); padding: 6px 10px; background: var(--error-bg); } +.notice { color: var(--notice-fg); border: 1px solid var(--notice-border); padding: 6px 10px; background: var(--notice-bg); } + +.columns { display: flex; gap: 16px; flex-wrap: wrap; } +.col { flex: 1 1 340px; min-width: 0; } + +.task { padding: 4px 0; border-bottom: 1px dotted var(--dotted); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.task .pick { flex: 1 1 140px; min-width: 0; } + +button.go { font-weight: bold; min-width: 84px; } +button.go:not(.stop):not([disabled]) { background: var(--go-bg); border-color: var(--go-border); } +button.go:not(.stop):not([disabled]):hover { background: var(--go-bg-hover); } +button.go.stop { background: var(--stop-bg); border-color: var(--stop-border); } +button.go.stop:hover { background: var(--stop-bg-hover); } +button.go[disabled] { opacity: .45; cursor: not-allowed; } + +.inline { display: inline-flex; gap: 6px; align-items: center; margin: 0; } +fieldset > .inline { margin-top: 10px; } +.add-toggle { margin-top: 10px; } + +.clock { font-family: monospace; font-size: 26px; font-weight: bold; margin: 0; } + +/* Sticky bar shown only while a timer runs. */ +.running-bar { + position: sticky; + top: 0; + z-index: 50; + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + background: var(--go-bg); + border: 1px solid var(--go-border); + padding: 8px 12px; + margin: 0 0 14px; +} +.rb-task { flex: 1 1 auto; } +.running-bar .go { margin-left: auto; } + +.go-badge { + display: inline-block; + min-width: 84px; + text-align: center; + font-weight: bold; + padding: 4px 10px; + border: 1px solid var(--go-border); + background: var(--go-bg); + color: var(--fg); +} + +table { border-collapse: collapse; width: 100%; } +th, td { border: 1px solid var(--border-soft); padding: 3px 6px; text-align: left; font-size: 13px; vertical-align: top; } +th { background: var(--th-bg); } +tr.active { background: var(--row-active); } +.row-actions { white-space: nowrap; } +.row-actions button { padding: 1px 6px; font-size: 12px; } + +.line { display: flex; gap: 6px; margin: 4px 0; flex-wrap: wrap; } +.line input { width: 110px; } +.line input[name=extra_desc] { width: 220px; } + +/* Two-tap delete confirm — a centered card, always fully on-screen (mobile-safe). + No native dialog and no JS, so it works in Brave/Chrome/Safari alike. */ +details.confirm { display: inline-block; } +details.confirm > summary { + list-style: none; + cursor: pointer; + font: inherit; + padding: 3px 10px; + border: 1px solid var(--btn-border); + background: var(--btn-bg); + color: var(--fg); + display: inline-block; +} +details.confirm > summary::-webkit-details-marker { display: none; } +details.confirm > summary::marker { content: ""; } +details.confirm > summary:hover { background: var(--btn-bg-hover); } +details.confirm[open] > summary { background: var(--stop-bg); border-color: var(--stop-border); } + +/* Dim the page behind the open confirm card. */ +details.confirm[open]::before { + content: ""; + position: fixed; + inset: 0; + background: var(--backdrop); + z-index: 900; +} + +.confirm-pop { + position: fixed; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + width: min(360px, 92vw); + padding: 16px 18px; + border: 1px solid var(--card-border); + background: var(--card-bg); + color: var(--fg); + white-space: normal; + box-shadow: 0 6px 30px var(--shadow); + font-size: 14px; + line-height: 1.45; + text-align: left; +} +.confirm-pop button { margin-top: 12px; margin-right: 6px; } +.confirm-pop button:not(.cancel) { border-color: var(--stop-border); background: var(--danger-btn-bg); } +.confirm-pop button:not(.cancel):hover { background: var(--danger-btn-bg-hover); } + +.row-actions details.confirm > summary { padding: 1px 6px; font-size: 12px; } + +/* Very visible countdown for the proof-of-concept reset. */ +.reset-banner { + position: sticky; + top: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + padding: 10px 16px; + border-bottom: 3px solid var(--error-border); + background: var(--error-bg); + color: var(--error-fg); + text-align: center; +} +.reset-label { + font-weight: bold; + text-transform: uppercase; + letter-spacing: .04em; +} +.reset-countdown { + font-family: monospace; + font-size: clamp(34px, 8vw, 64px); + font-weight: 900; + line-height: 1; + padding: 2px 10px; + border: 2px solid currentColor; + background: var(--bg); +} +.reset-note { + flex-basis: 100%; + font-size: 13px; + font-weight: bold; +} +.reset-imminent .reset-banner { + outline: 4px solid var(--error-border); + outline-offset: -4px; +} + +.about-card { + max-width: 760px; + border: 1px solid var(--border); + background: var(--field-bg); + padding: 12px 14px; +} +.poc-warning { + border: 1px solid var(--error-border); + background: var(--error-bg); + color: var(--error-fg); + padding: 8px 10px; +} diff --git a/static/htmx.min.js b/static/htmx.min.js new file mode 100644 index 0000000..3b7ac1a --- /dev/null +++ b/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.10"};Q.onLoad=j;Q.process=Ft;Q.on=ye;Q.off=xe;Q.trigger=ae;Q.ajax=Nn;Q.find=f;Q.findAll=y;Q.closest=g;Q.remove=z;Q.addClass=w;Q.removeClass=b;Q.toggleClass=G;Q.takeClass=W;Q.swap=_e;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=$;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:U,findThisElement:we,filterValues:yn,swap:_e,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:A,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Se,makeFragment:P,mergeObjects:le,makeSettleInfo:Sn,oobSwap:He,querySelectorExt:ce,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function A(e,t){while(e&&!t(e)){e=c(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;A(t,function(e){return!!(r=o(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function D(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){D(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function M(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){H(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function V(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function y(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return y(te(),e)}}function x(){return window}function z(e,t){e=S(e);if(t){x().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=ue(S(e));if(!e){return}if(n){x().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function b(e,t,n){let r=ue(S(e));if(!r){return}if(n){x().setTimeout(function(){b(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=S(e);e.classList.toggle(t)}function W(e,t){e=S(e);ie(e.parentElement.children,function(e){b(e,t)});w(ue(e),t)}function g(e,t){e=ue(S(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function Y(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=S(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=Y(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),Y(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),Y(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,Y(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,Y(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const u=p(q(t,!!n));i.push(...F(u.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function S(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(k(t)){return{target:te().body,event:K(e),listener:t,options:n}}else{return{target:S(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Gn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function xe(t,n,r){Gn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const be=te().createElement("output");function ve(t,n){const e=ne(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ue(A(t,function(e){return e!==t&&s(ue(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){H('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return ue(A(e,function(e){return a(ue(e),t)!=null}))}function Se(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ee(e){return Q.config.attributesToSettle.includes(e)}function Ce(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Jn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);je(s,e,e,t,i);Te()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function Te(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){ie(y(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","
");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){b(e,Q.config.addedClass);Ft(ue(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function _e(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=S(h);const r=g.contextElement?q(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t0){x().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){x().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,C)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,Ze);const l=o.length;const u=O(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};O(o,Ze);c.pollInterval=d(O(o,/[,\[\s]/));O(o,Ze);var i=nt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,Ze);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,C))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,C);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,C))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,C)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,C)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ut(e,t,n){const r=oe(e);r.timeout=x().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function ct(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ct(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,u,e,c,f){const a=oe(l);let t;if(c.from){t=m(l,c.from)}else{t=[l]}if(c.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(c)){a.lastValue.set(c,new WeakMap)}a.lastValue.get(c).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(c.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(c,l,e)){return}const t=oe(e);t.triggerSpec=c;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(c.consume){e.stopPropagation()}if(c.target&&e.target){if(!h(ue(e.target),c.target)){return}}if(c.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(c.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(c);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(c.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");u(l,e);a.throttle=x().setTimeout(function(){a.throttle=null},c.throttle)}}else if(c.delay>0){a.delayed=x().setTimeout(function(){ae(l,"htmx:trigger");u(l,e)},c.delay)}else{ae(l,"htmx:trigger");u(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:c.trigger,listener:s,on:i});i.addEventListener(c.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){x().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ut(ue(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Rt(e){const t=At(e.target);const n=It(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=It(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ue(e),"button, input[type='submit']")}function Nt(e){return e.form||g(e,"form")}function It(e){const t=At(e.target);if(!t){return}const n=Nt(t);if(!n){return}return oe(n)}function Lt(e){e.addEventListener("click",Rt);e.addEventListener("focusin",Rt);e.addEventListener("focusout",qt)}function Dt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){De(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!U()){return null}t=V(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);_e(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){_e(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;w(e,Q.config.requestClass)});return t}function nn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){b(e,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function cn(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,cn(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,cn(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Nt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");ln(f,c.value,o)}const u=ve(e,"hx-include");ie(u,function(e){fn(n,r,i,ue(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{H("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Dn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){x().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const u in n){if(n.hasOwnProperty(u)){if(i[u]==null){i[u]=n[u]}}}}return Cn(ue(c(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Tn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Rn(e,t){return le(Hn(e,t),Tn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function T(e,t){return t.test(e.getAllResponseHeaders())}function Nn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:S(r)||be,returnPromise:true})}else{let e=S(r.target);if(r.target&&!e||r.source&&!e&&!S(r.source)){e=be}return he(t,n,S(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return he(t,n,null,null,{returnPromise:true})}}function In(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Ln(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const M=i.handler||Vn;const F=i.select||null;if(!se(r)){re(s);return e}const u=i.targetOverride||ue(Se(r));if(u==null||u==be){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let c=oe(r);const f=c.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const N=ee(f,"formmethod");if(N!=null){if(de.includes(N.toLowerCase())){t=N}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=ue(ce(r,L))}d=(I[1]||"drop").trim();c=oe(h);if(d==="drop"&&c.xhr&&c.abortable!==true){re(s);return e}else if(d==="abort"){if(c.xhr){re(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(c.xhr){if(c.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const D=oe(o);if(D&&D.triggerSpec&&D.triggerSpec.queue){p=D.triggerSpec.queue}}if(p==null){p="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(p==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;c.xhr=g;c.abortable=B;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const X=ne(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:u})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,u,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Dn(i.values))}const j=Dn(Rn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let H=n;if(E){H=z;const Z=!w.keys().next().done;if(Z){if(H.indexOf("?")<0){H+="?"}else{H+="&"}H+=gn(w);if(O){H+="#"+O}}}if(!Ln(r,H,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),H,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const P in x){if(x.hasOwnProperty(P)){const Y=x[P];qn(g,P,Y)}}}const T={xhr:g,target:u,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:H,responsePath:null,anchor:O}};g.onload=function(){try{const t=In(r);T.pathInfo.responsePath=An(g);M(r,T);if(T.keepIndicators!==true){rn(R,q)}ae(r,"htmx:afterRequest",T);ae(r,"htmx:afterOnLoad",T);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",T);ae(e,"htmx:afterOnLoad",T)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},T));throw e}finally{m()}};g.onerror=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendError",T);re(l);m()};g.onabort=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendAbort",T);re(l);m()};g.ontimeout=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:timeout",T);re(l);m()};if(!ae(r,"htmx:beforeRequest",T)){re(s);m();return e}var R=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",T);const J=E?null:wn(g,r,w);g.send(J);return e}function Mn(e,t){const n=t.xhr;let r=null;let o=null;if(T(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(T(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(T(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;let l=t.etc.push||ne(e,"hx-push-url");let u=t.etc.replace||ne(e,"hx-replace-url");if(l==="false")l=null;if(u==="false")u=null;const c=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Fn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Bn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};x().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/static/timer.js b/static/timer.js new file mode 100644 index 0000000..0321640 --- /dev/null +++ b/static/timer.js @@ -0,0 +1,72 @@ +// The only JavaScript in the app: tick the live work timer and the visible POC reset countdown. +// Everything else is plain HTML + htmx. +(function () { + var workInterval = null; + var resetInterval = null; + var reloadingForReset = false; + + function pad(n) { return (n < 10 ? "0" : "") + n; } + + function tickWorkTimer() { + var el = document.getElementById("clock"); + if (!el || !el.dataset.start) { + if (workInterval) { clearInterval(workInterval); workInterval = null; } + return; + } + var start = new Date(el.dataset.start).getTime(); + var diff = Math.max(0, Math.floor((Date.now() - start) / 1000)); + var h = Math.floor(diff / 3600); + var m = Math.floor((diff % 3600) / 60); + var s = diff % 60; + el.textContent = pad(h) + ":" + pad(m) + ":" + pad(s); + } + + function formatReset(seconds) { + var m = Math.floor(seconds / 60); + var s = seconds % 60; + return pad(m) + ":" + pad(s); + } + + function tickResetCountdown() { + var el = document.getElementById("reset-countdown"); + if (!el || !el.dataset.resetAt) { + if (resetInterval) { clearInterval(resetInterval); resetInterval = null; } + return; + } + + var resetAt = new Date(el.dataset.resetAt).getTime(); + var remaining = Math.ceil((resetAt - Date.now()) / 1000); + if (remaining <= 0) { + el.textContent = "resetting…"; + document.body.classList.add("reset-imminent"); + if (!reloadingForReset) { + reloadingForReset = true; + window.setTimeout(function () { window.location.reload(); }, 1500); + } + return; + } + + el.textContent = formatReset(remaining); + document.body.classList.toggle("reset-imminent", remaining <= 30); + } + + function restartWorkTimer() { + if (workInterval) clearInterval(workInterval); + tickWorkTimer(); + workInterval = setInterval(tickWorkTimer, 1000); + } + + function startResetCountdown() { + if (resetInterval) clearInterval(resetInterval); + tickResetCountdown(); + resetInterval = setInterval(tickResetCountdown, 1000); + } + + document.addEventListener("DOMContentLoaded", function () { + restartWorkTimer(); + startResetCountdown(); + }); + + // Re-evaluate the work timer after htmx swaps the working area in/out. + document.body.addEventListener("htmx:afterSettle", restartWorkTimer); +})(); diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..a311441 --- /dev/null +++ b/templates.go @@ -0,0 +1,66 @@ +package main + +import ( + "embed" + "html/template" + "io/fs" + "net/http" + "time" +) + +//go:embed templates/*.html +var templateFS embed.FS + +//go:embed static +var staticFS embed.FS + +// staticHandler serves the embedded static/ directory at /static/. +func staticHandler() http.Handler { + sub, err := fs.Sub(staticFS, "static") + if err != nil { + panic(err) + } + return http.StripPrefix("/static/", http.FileServer(http.FS(sub))) +} + +var tmpl *template.Template + +var funcMap = template.FuncMap{ + "money": func(v float64) string { return fmtNum(v) }, + "hours": func(minutes float64) string { return fmtNum(minutes / 60.0) }, + "datef": func(t time.Time) string { return t.Format("02 Jan") }, + "hm": func(t time.Time) string { return t.Format("15:04") }, + "hmp": func(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("15:04") + }, + "timef": func(t *time.Time) string { + if t == nil { + return "running…" + } + return t.Format("15:04") + }, + "dateInput": func(t time.Time) string { return t.Format("02-01-2006") }, + "today": func() string { return time.Now().Format("02-01-2006") }, + "rfc3339": func(t time.Time) string { return t.Format(time.RFC3339) }, +} + +func parseTemplates() error { + t, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html") + if err != nil { + return err + } + tmpl = t + return nil +} + +// render executes a named template and writes any error to the log/response. +func (s *Server) render(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, name, data); err != nil { + s.errl.Printf("render %s: %v", name, err) + http.Error(w, "template error", http.StatusInternalServerError) + } +} diff --git a/templates/about_page.html b/templates/about_page.html new file mode 100644 index 0000000..770cb52 --- /dev/null +++ b/templates/about_page.html @@ -0,0 +1,20 @@ +{{define "about_page"}} +

About this POC

+
+

zeitkort is a consultant time-tracking web application bundled with a PDF invoice creator.

+

At FLÓ, I use this for tracking all my consultancy work, generating and sending invoices.

+

So the hierarchy is like this: at the top level I create companies I do work for.

+

For example, add new company, and add the company info. The company info will automatically be used for generating the invoice

+

One company can have multiple assignments. That could be 'Create backend for webservice A' or 'Build ETL process for billing pipeline'. Like a large chunk of work for a company, a logical border.

+

One assignment can ahve many tasks. For example 'Set up HTTP server', 'Administrative work', or 'CI/CD Pipeline' under the assignment 'Create backend for web service A'.

+

From here on you can either start a timer for a certain task, or add tasks manually. Everything can be edited or deleted, but not cascading deletes upwards in the hierarchy.

+

When you are done with an assignment for the company, create the invoice. When pressing the button, you can change the basic info about the company, and you can add lines that are not time, but quantity or other measures. For example, 2 pcs. routers at 200 kr a piece. Or whatever you know what I mean. Or like perhaps it is not an hourly rate, but just a one time price for the whole assignment.

+
+

This dummy version runs in-memory and is reset every 10 minutes (When I run it I have an API to my Nixos home server which uses sqlite as data store).

+

It is served via Caddy (I love Caddy) to my FLÓ subdomain.

+

It is one static Go binary, it uses the golang built-in server-rendered HTML templating language, htmx for page updates (Javascript for the dummy timer), embedded static files.

+

I mean, come on, this things ships as a single binary, like all of this right? And when I use the API, it is all still a single binary. Could not be easier (I love Go).

+

POC reset: all in-memory data is wiped every {{.ResetIntervalMinutes}} minutes and whenever the process restarts.

+

+
+{{end}} diff --git a/templates/assignment_form.html b/templates/assignment_form.html new file mode 100644 index 0000000..814c7ec --- /dev/null +++ b/templates/assignment_form.html @@ -0,0 +1,23 @@ +{{define "assignment_form"}} +

{{if .Assignment}}Edit assignment{{else}}New assignment{{end}}

+
+ +

+

+

+
Leave 0 to use the company's default rate.

+ {{if .Assignment}} +

+ {{end}} +

+ + +

+
+{{end}} diff --git a/templates/company_form.html b/templates/company_form.html new file mode 100644 index 0000000..45c95d6 --- /dev/null +++ b/templates/company_form.html @@ -0,0 +1,14 @@ +{{define "company_form"}} +

{{if .Company}}Edit company{{else}}New company{{end}}

+
+

+

+

+

+

+

+ + +

+
+{{end}} diff --git a/templates/invoice_form.html b/templates/invoice_form.html new file mode 100644 index 0000000..4f4c4a9 --- /dev/null +++ b/templates/invoice_form.html @@ -0,0 +1,52 @@ +{{define "invoice_form"}} +

Invoice — {{.Assignment.Name}}

+

{{.Company.Name}} · {{money .TotalHours}} hrs logged = {{money .TotalAmount}} DKK before MVG

+ +
+ + + +
+ Invoice +

+

(today)

+

+

+

+
+ +
+ Sender +

+

+

+

+

+

+

+
+ +
+ Extra lines (optional) +
+ +
+ +

+ + +

+
+{{end}} + +{{define "invoice_line"}} +
+ + + + + +
+{{end}} diff --git a/templates/main.html b/templates/main.html new file mode 100644 index 0000000..8a72294 --- /dev/null +++ b/templates/main.html @@ -0,0 +1,178 @@ +{{define "main"}} +{{if .AppError}}

{{.AppError}}

{{end}} +{{if .Notice}}

{{.Notice}}

{{end}} + +{{if .Running}} +
+ 00:00:00 + Working on {{.Running.TaskDescription}} + +
+{{end}} + +
+ Company + + + {{if .CompanyID}} + +
+ Delete +
+ Delete this company? You must remove its assignments first. This cannot be undone. + + +
+
+ {{end}} +
+ +{{if .CompanyID}} +
+ Assignment + + + {{if .AssignmentID}} + +
+ Delete +
+ Delete this assignment? You must remove its tasks first. This cannot be undone. + + +
+
+ {{end}} +
+{{end}} + +{{if .Assignment}} +
+
+ +
+ Tasks + {{range .Tasks}} + {{if eq .ID $.EditingTaskID}} +
+
+ + + {{.Number}} - + + + +
+
+ {{else}} +
+ {{if $.Running}} + {{if eq .ID $.Running.TaskID}} + ● running + {{else}} + + {{end}} + {{else}} + + {{end}} + {{.Number}} - {{.Name}} + ({{hours .TotalMinutes}} hrs) + + +
+ Delete +
+ Delete this task? You must remove its work sessions first. This cannot be undone. + + +
+
+
+ {{end}} + {{else}} +

No tasks yet.

+ {{end}} + {{if .AddingTask}} +
+ + + + + +
+ {{else}} + + {{end}} +
+ +
+
+ +
+ Recent sessions +

Total: {{money .TotalHours}} hrs = {{money .TotalAmount}} DKK

+
+ + + + {{range .Sessions}} + + + + + + + + {{else}} + + {{end}} + +
DateTimeHrsTask
{{datef .StartTime}}{{hm .StartTime}}–{{timef .EndTime}}{{if .EndTime}}{{hours .DurationMinutes}}{{else}}—{{end}}{{.TaskDescription}} + {{if .EndTime}} + +
+ Del +
+ Delete this work session? This cannot be undone. + + +
+
+ {{end}} +
No sessions yet.
+
+

+ + +

+
+ +
+
+{{end}} +{{end}} diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..00119dc --- /dev/null +++ b/templates/page.html @@ -0,0 +1,34 @@ +{{define "page"}} + + + + +zeitkort + + + +
+ zeitkort + +
+ +
+ POC reset in + --:-- + All in-memory data clears every {{.ResetIntervalMinutes}} minutes. +
+ +
+{{template "main" .}} +
+ +
zeitkort-web {{.Version}} · web-only POC · resets every 10 minutes
+ + + + +{{end}} diff --git a/templates/session_form.html b/templates/session_form.html new file mode 100644 index 0000000..802ef20 --- /dev/null +++ b/templates/session_form.html @@ -0,0 +1,28 @@ +{{define "session_form"}} +

{{if .Session}}Edit session{{else}}Add manual session{{end}}

+
+ + +

+

+

+

+

+

+ + +

+
+{{end}} diff --git a/templates/settings_page.html b/templates/settings_page.html new file mode 100644 index 0000000..b09e440 --- /dev/null +++ b/templates/settings_page.html @@ -0,0 +1,26 @@ +{{define "settings_page"}} +

Settings

+{{if .Saved}}

Saved.

{{end}} +{{if .Error}}

{{.Error}}

{{end}} +
+
+ Sender / company details (used on invoices) +

+

+

+

+

+

+

+
+
+ Invoice defaults +

+

+
+

+ + +

+
+{{end}} diff --git a/zeitkort-web.service b/zeitkort-web.service new file mode 100644 index 0000000..f5bb3f1 --- /dev/null +++ b/zeitkort-web.service @@ -0,0 +1,21 @@ +[Unit] +Description=zeitkort web-only proof of concept +After=network.target + +[Service] +Type=simple +User=zeitkort +Group=zeitkort +WorkingDirectory=/opt/zeitkort-web +ExecStart=/opt/zeitkort-web/zeitkort-web -config /opt/zeitkort-web/config.json +Restart=on-failure +RestartSec=5 + +# Hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target