initial commit

This commit is contained in:
Bartal Læarsson
2026-06-18 21:41:47 +01:00
commit 8e60f0472b
29 changed files with 2872 additions and 0 deletions
+15
View File
@@ -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.
+12
View File
@@ -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)
+84
View File
@@ -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.
+18
View File
@@ -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),
})
}
BIN
View File
Binary file not shown.
+13
View File
@@ -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
}
+49
View File
@@ -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
}
+13
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
module zeitkort-web
go 1.26
require codeberg.org/go-pdf/fpdf v0.11.1
+2
View File
@@ -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=
+493
View File
@@ -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.", "")
}
+509
View File
@@ -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()
}
+152
View File
@@ -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))
})
}
+543
View File
@@ -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
}
+102
View File
@@ -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"`
}
+48
View File
@@ -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})
}
+279
View File
@@ -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;
}
+1
View File
File diff suppressed because one or more lines are too long
+72
View File
@@ -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);
})();
+66
View File
@@ -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)
}
}
+20
View File
@@ -0,0 +1,20 @@
{{define "about_page"}}
<h2>About this POC</h2>
<section class="about-card">
<p><strong>zeitkort</strong> is a consultant time-tracking web application bundled with a PDF invoice creator.</p>
<p>At <strong>FLÓ</strong>, I use this for tracking all my consultancy work, generating and sending invoices.</p>
<p>So the hierarchy is like this: at the top level I create companies I do work for.</p>
<p>For example, add new company, and add the company info. The company info will automatically be used for generating the invoice</p>
<p>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.</p>
<p>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'.</p>
<p>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.</p>
<p>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.</p>
<br>
<p>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).</p>
<p>It is served via Caddy (I love Caddy) to my FLÓ subdomain.</p>
<p>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.</p>
<p>I mean, come on, this things ships <strong>as a single binary</strong>, 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).</p>
<p class="poc-warning"><strong>POC reset:</strong> all in-memory data is wiped every {{.ResetIntervalMinutes}} minutes and whenever the process restarts.</p>
<p><button hx-get="/main" hx-target="#main">Back to timer</button></p>
</section>
{{end}}
+23
View File
@@ -0,0 +1,23 @@
{{define "assignment_form"}}
<h2>{{if .Assignment}}Edit assignment{{else}}New assignment{{end}}</h2>
<form hx-post="{{if .Assignment}}/assignments/{{.AssignID}}{{else}}/assignments{{end}}" hx-target="#main">
<input type="hidden" name="company_id" value="{{.CompanyID}}">
<p><label>Name<br><input name="name" value="{{with .Assignment}}{{.Name}}{{end}}" required></label></p>
<p><label>Description<br><textarea name="description" rows="3">{{with .Assignment}}{{.Description}}{{end}}</textarea></label></p>
<p><label>Hourly rate (DKK)<br><input name="hourly_rate" type="number" step="0.01" value="{{with .Assignment}}{{money .HourlyRate}}{{end}}"></label>
<br><small class="muted">Leave 0 to use the company's default rate.</small></p>
{{if .Assignment}}
<p><label>Status<br>
<select name="status">
<option value="active" {{if eq .Assignment.Status "active"}}selected{{end}}>active</option>
<option value="archived" {{if eq .Assignment.Status "archived"}}selected{{end}}>archived</option>
</select></label></p>
{{end}}
<p>
<button type="submit">Save</button>
<button type="button"
hx-get="/main?company_id={{.CompanyID}}{{if .Assignment}}&assignment_id={{.AssignID}}{{end}}"
hx-target="#main">Cancel</button>
</p>
</form>
{{end}}
+14
View File
@@ -0,0 +1,14 @@
{{define "company_form"}}
<h2>{{if .Company}}Edit company{{else}}New company{{end}}</h2>
<form hx-post="{{if .Company}}/companies/{{.CompanyID}}{{else}}/companies{{end}}" hx-target="#main">
<p><label>Name<br><input name="name" value="{{with .Company}}{{.Name}}{{end}}" required></label></p>
<p><label>Address<br><input name="address" value="{{with .Company}}{{.Address}}{{end}}"></label></p>
<p><label>Email<br><input name="contact_email" value="{{with .Company}}{{.ContactEmail}}{{end}}"></label></p>
<p><label>Phone<br><input name="contact_phone" value="{{with .Company}}{{.ContactPhone}}{{end}}"></label></p>
<p><label>Hourly rate (DKK)<br><input name="hourly_rate" type="number" step="0.01" value="{{with .Company}}{{money .HourlyRate}}{{end}}"></label></p>
<p>
<button type="submit">Save</button>
<button type="button" hx-get="/main?company_id={{.CompanyID}}" hx-target="#main">Cancel</button>
</p>
</form>
{{end}}
+52
View File
@@ -0,0 +1,52 @@
{{define "invoice_form"}}
<h2>Invoice — {{.Assignment.Name}}</h2>
<p class="muted">{{.Company.Name}} · {{money .TotalHours}} hrs logged = {{money .TotalAmount}} DKK before MVG</p>
<form method="post" action="/invoice">
<input type="hidden" name="company_id" value="{{.CompanyID}}">
<input type="hidden" name="assignment_id" value="{{.AssignmentID}}">
<fieldset>
<legend>Invoice</legend>
<p><label>Invoice number<br><input name="invoice_number" value="{{.InvoiceNumber}}" required></label></p>
<p><label>Date<br><input value="{{.Today}}" disabled></label> <span class="muted">(today)</span></p>
<p><label>Payment terms (days)<br><input name="due_days" type="number" value="{{.DueDays}}"></label></p>
<p><label>MVG %<br><input name="mvg" type="number" step="0.01" value="{{money .MVGPercent}}"></label></p>
<p><label>Comment (optional)<br><textarea name="comment" rows="2"></textarea></label></p>
</fieldset>
<fieldset>
<legend>Sender</legend>
<p><label>Name<br><input name="from_name" value="{{.Cfg.CompanyName}}"></label></p>
<p><label>Address<br><input name="from_address" value="{{.Cfg.CompanyAddress}}"></label></p>
<p><label>Phone<br><input name="from_phone" value="{{.Cfg.CompanyPhone}}"></label></p>
<p><label>Email<br><input name="from_email" value="{{.Cfg.CompanyEmail}}"></label></p>
<p><label>Web<br><input name="from_web" value="{{.Cfg.CompanyWeb}}"></label></p>
<p><label>V-tal<br><input name="from_vat" value="{{.Cfg.TaxID}}"></label></p>
<p><label>Bank info<br><textarea name="bank_info" rows="3">{{.Cfg.BankInfo}}</textarea></label></p>
</fieldset>
<fieldset>
<legend>Extra lines (optional)</legend>
<div id="extra-lines"></div>
<button type="button" hx-get="/invoice/line" hx-target="#extra-lines" hx-swap="beforeend">Add line</button>
</fieldset>
<p>
<button type="submit">Generate PDF</button>
<button type="button"
hx-get="/main?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}"
hx-target="#main">Back</button>
</p>
</form>
{{end}}
{{define "invoice_line"}}
<div class="line">
<input name="extra_desc" placeholder="description">
<input name="extra_qty" type="number" step="0.01" placeholder="qty">
<select name="extra_unit"><option value="stk">stk</option><option value="tímar">tímar</option></select>
<input name="extra_price" type="number" step="0.01" placeholder="unit price">
<button type="button" hx-get="/void" hx-target="closest .line" hx-swap="outerHTML">×</button>
</div>
{{end}}
+178
View File
@@ -0,0 +1,178 @@
{{define "main"}}
{{if .AppError}}<p class="error">{{.AppError}}</p>{{end}}
{{if .Notice}}<p class="notice">{{.Notice}}</p>{{end}}
{{if .Running}}
<div class="running-bar">
<span class="clock" id="clock" data-start="{{rfc3339 .Running.StartTime}}">00:00:00</span>
<span class="rb-task">Working on <strong>{{.Running.TaskDescription}}</strong></span>
<button class="go stop" hx-post="/sessions/{{.Running.ID}}/stop"
hx-vals='{"company_id":"{{.CompanyID}}","assignment_id":"{{.AssignmentID}}"}'
hx-target="#main">■ Stop</button>
</div>
{{end}}
<fieldset>
<legend>Company</legend>
<select name="company_id" hx-get="/main" hx-target="#main">
<option value="0">— select company —</option>
{{range .Companies}}
<option value="{{.ID}}" {{if eq .ID $.CompanyID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<button hx-get="/companies/new" hx-target="#main">New</button>
{{if .CompanyID}}
<button hx-get="/companies/{{.CompanyID}}/edit" hx-target="#main">Edit</button>
<details class="confirm">
<summary>Delete</summary>
<div class="confirm-pop">
Delete this company? You must remove its assignments first. This cannot be undone.
<button hx-post="/companies/{{.CompanyID}}/delete" hx-target="#main">Yes, delete</button>
<button class="cancel" hx-get="/main?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}" hx-target="#main">Cancel</button>
</div>
</details>
{{end}}
</fieldset>
{{if .CompanyID}}
<fieldset>
<legend>Assignment</legend>
<select name="assignment_id" hx-get="/main" hx-target="#main"
hx-vals='{"company_id":"{{.CompanyID}}"}'>
<option value="0">— select assignment —</option>
{{range .Assignments}}
<option value="{{.ID}}" {{if eq .ID $.AssignmentID}}selected{{end}}>{{.Name}} ({{.Status}})</option>
{{end}}
</select>
<button hx-get="/assignments/new?company_id={{.CompanyID}}" hx-target="#main">New</button>
{{if .AssignmentID}}
<button hx-get="/assignments/{{.AssignmentID}}/edit?company_id={{.CompanyID}}" hx-target="#main">Edit</button>
<details class="confirm">
<summary>Delete</summary>
<div class="confirm-pop">
Delete this assignment? You must remove its tasks first. This cannot be undone.
<button hx-post="/assignments/{{.AssignmentID}}/delete?company_id={{.CompanyID}}" hx-target="#main">Yes, delete</button>
<button class="cancel" hx-get="/main?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}" hx-target="#main">Cancel</button>
</div>
</details>
{{end}}
</fieldset>
{{end}}
{{if .Assignment}}
<div class="columns">
<div class="col">
<fieldset>
<legend>Tasks</legend>
{{range .Tasks}}
{{if eq .ID $.EditingTaskID}}
<div class="task">
<form class="inline" hx-post="/tasks/{{.ID}}" hx-target="#main">
<input type="hidden" name="company_id" value="{{$.CompanyID}}">
<input type="hidden" name="assignment_id" value="{{$.AssignmentID}}">
<span class="muted">{{.Number}} -</span>
<input type="text" name="name" value="{{.Name}}">
<button type="submit">Save</button>
<button type="button" class="cancel"
hx-get="/main?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}"
hx-target="#main">Cancel</button>
</form>
</div>
{{else}}
<div class="task">
{{if $.Running}}
{{if eq .ID $.Running.TaskID}}
<span class="go-badge">● running</span>
{{else}}
<button class="go" disabled title="Stop the running timer first">▶ Start</button>
{{end}}
{{else}}
<button class="go" hx-post="/sessions/start"
hx-vals='{"company_id":"{{$.CompanyID}}","assignment_id":"{{$.AssignmentID}}","task_id":"{{.ID}}"}'
hx-target="#main">▶ Start</button>
{{end}}
<span class="pick">{{.Number}} - {{.Name}}
<span class="muted">({{hours .TotalMinutes}} hrs)</span>
</span>
<button hx-get="/tasks/{{.ID}}/edit?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}"
hx-target="#main">Edit</button>
<details class="confirm">
<summary>Delete</summary>
<div class="confirm-pop">
Delete this task? You must remove its work sessions first. This cannot be undone.
<button hx-post="/tasks/{{.ID}}/delete?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}" hx-target="#main">Yes, delete</button>
<button class="cancel" hx-get="/main?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}" hx-target="#main">Cancel</button>
</div>
</details>
</div>
{{end}}
{{else}}
<p class="muted">No tasks yet.</p>
{{end}}
{{if .AddingTask}}
<form class="inline" hx-post="/tasks" hx-target="#main">
<input type="hidden" name="company_id" value="{{.CompanyID}}">
<input type="hidden" name="assignment_id" value="{{.AssignmentID}}">
<input type="text" name="name" placeholder="new task name">
<button type="submit">Add</button>
<button type="button" class="cancel"
hx-get="/main?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}"
hx-target="#main">Cancel</button>
</form>
{{else}}
<button class="add-toggle"
hx-get="/tasks/new?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}"
hx-target="#main">+ New task</button>
{{end}}
</fieldset>
</div>
<div class="col">
<fieldset>
<legend>Recent sessions</legend>
<p>Total: <strong>{{money .TotalHours}} hrs</strong> = <strong>{{money .TotalAmount}} DKK</strong></p>
<div class="table-wrap">
<table>
<thead><tr><th>Date</th><th>Time</th><th>Hrs</th><th>Task</th><th></th></tr></thead>
<tbody>
{{range .Sessions}}
<tr {{if not .EndTime}}class="active"{{end}}>
<td>{{datef .StartTime}}</td>
<td>{{hm .StartTime}}{{timef .EndTime}}</td>
<td>{{if .EndTime}}{{hours .DurationMinutes}}{{else}}—{{end}}</td>
<td>{{.TaskDescription}}</td>
<td class="row-actions">
{{if .EndTime}}
<button hx-get="/sessions/{{.ID}}/edit?company_id={{$.CompanyID}}&assignment_id={{$.AssignmentID}}"
hx-target="#main">Edit</button>
<details class="confirm">
<summary>Del</summary>
<div class="confirm-pop">
Delete this work session? This cannot be undone.
<button hx-post="/sessions/{{.ID}}/delete?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}" hx-target="#main">Yes, delete</button>
<button class="cancel" hx-get="/main?company_id={{$.CompanyID}}&amp;assignment_id={{$.AssignmentID}}" hx-target="#main">Cancel</button>
</div>
</details>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">No sessions yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
<p>
<button hx-get="/sessions/new?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}"
hx-target="#main">Add manual entry</button>
<button hx-get="/invoice?company_id={{.CompanyID}}&amp;assignment_id={{.AssignmentID}}"
hx-target="#main">Generate invoice (PDF)</button>
</p>
</fieldset>
</div>
</div>
{{end}}
{{end}}
+34
View File
@@ -0,0 +1,34 @@
{{define "page"}}<!doctype html>
<html lang="fo">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>zeitkort</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header>
<span class="brand">zeitkort</span>
<nav>
<a href="/">Timer</a>
<a hx-get="/settings" hx-target="#main" href="javascript:void(0)">Settings</a>
<a hx-get="/about" hx-target="#main" href="javascript:void(0)">About</a>
</nav>
</header>
<div class="reset-banner" role="status" aria-live="polite">
<span class="reset-label">POC reset in</span>
<span id="reset-countdown" class="reset-countdown" data-reset-at="{{rfc3339 .ResetAt}}">--:--</span>
<span class="reset-note">All in-memory data clears every {{.ResetIntervalMinutes}} minutes.</span>
</div>
<main id="main">
{{template "main" .}}
</main>
<footer>zeitkort-web {{.Version}} · web-only POC · resets every 10 minutes</footer>
<script src="/static/htmx.min.js"></script>
<script src="/static/timer.js"></script>
</body>
</html>{{end}}
+28
View File
@@ -0,0 +1,28 @@
{{define "session_form"}}
<h2>{{if .Session}}Edit session{{else}}Add manual session{{end}}</h2>
<form hx-post="{{if .Session}}/sessions/{{.SessionID}}{{else}}/sessions/manual{{end}}" hx-target="#main">
<input type="hidden" name="company_id" value="{{.CompanyID}}">
<input type="hidden" name="assignment_id" value="{{.AssignmentID}}">
<p><label>Task<br>
<select name="task_id">
{{range .Tasks}}
<option value="{{.ID}}" {{if and $.Session (eq .ID $.Session.TaskID)}}selected{{end}}>{{.Number}} - {{.Name}}</option>
{{end}}
<option value="new">+ new task…</option>
</select></label></p>
<p><label>New task name <span class="muted">(only if “+ new task” selected)</span><br>
<input name="new_task_name"></label></p>
<p><label>Date (DD-MM-YYYY)<br>
<input name="date" value="{{if .Session}}{{dateInput .Session.StartTime}}{{else}}{{today}}{{end}}" required></label></p>
<p><label>Start (HH:MM)<br>
<input name="start" value="{{if .Session}}{{hm .Session.StartTime}}{{else}}09:00{{end}}" required></label></p>
<p><label>End (HH:MM)<br>
<input name="end" value="{{if .Session}}{{hmp .Session.EndTime}}{{else}}17:00{{end}}" required></label></p>
<p>
<button type="submit">Save</button>
<button type="button"
hx-get="/main?company_id={{.CompanyID}}&assignment_id={{.AssignmentID}}"
hx-target="#main">Cancel</button>
</p>
</form>
{{end}}
+26
View File
@@ -0,0 +1,26 @@
{{define "settings_page"}}
<h2>Settings</h2>
{{if .Saved}}<p class="notice">Saved.</p>{{end}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form hx-post="/settings" hx-target="#main">
<fieldset>
<legend>Sender / company details (used on invoices)</legend>
<p><label>Company name<br><input name="company_name" value="{{.Cfg.CompanyName}}"></label></p>
<p><label>Address<br><input name="company_address" value="{{.Cfg.CompanyAddress}}"></label></p>
<p><label>Phone<br><input name="company_phone" value="{{.Cfg.CompanyPhone}}"></label></p>
<p><label>Email<br><input name="company_email" value="{{.Cfg.CompanyEmail}}"></label></p>
<p><label>Web<br><input name="company_web" value="{{.Cfg.CompanyWeb}}"></label></p>
<p><label>V-tal (tax ID)<br><input name="tax_id" value="{{.Cfg.TaxID}}"></label></p>
<p><label>Bank info<br><textarea name="bank_info" rows="3">{{.Cfg.BankInfo}}</textarea></label></p>
</fieldset>
<fieldset>
<legend>Invoice defaults</legend>
<p><label>Payment terms (days)<br><input name="payment_terms_days" type="number" value="{{.Cfg.PaymentTermsDays}}"></label></p>
<p><label>MVG %<br><input name="mvg_percent" type="number" step="0.01" value="{{money .Cfg.MVGPercent}}"></label></p>
</fieldset>
<p>
<button type="submit">Save</button>
<button type="button" hx-get="/main" hx-target="#main">Back</button>
</p>
</form>
{{end}}
+21
View File
@@ -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