diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fc94470 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: build build-web build-go dev test clean + +# Build the production single binary (embeds the Svelte SPA) +build: build-web build-go + +build-web: + @echo "==> Building Svelte frontend..." + cd web && npm run build + +build-go: + @echo "==> Building Go binary (production, embeds web/build)..." + go build -tags production -o wotra ./cmd/wotra + +# Development mode: run Go server + Vite dev server concurrently +dev: + @echo "==> Starting development servers..." + @echo " Go API: http://localhost:8080" + @echo " Vite UI: http://localhost:5173" + @trap 'kill 0' INT; \ + AUTH_TOKEN=$${AUTH_TOKEN:-devtoken} go run ./cmd/wotra & \ + cd web && npm run dev + +# Run all Go tests +test: + go test ./... + +# Install frontend dependencies +web/node_modules: + cd web && npm install + +# Remove build artifacts +clean: + rm -f wotra + rm -rf web/build web/.svelte-kit diff --git a/README.md b/README.md new file mode 100644 index 0000000..a31a254 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# Wotra — Working Time Tracker + +A self-hosted working time tracker with a Go backend and a Svelte PWA frontend. Offline-capable, single-binary deploy. + +## Features + +- **Start / Stop** time tracking with an optional note. +- **Close days**: merges all entries (worked_ms = sum of durations, breaks excluded). +- **Mark days** as holiday, vacation, or sick (auto-credits expected daily hours). +- **Close weeks**: computes overtime/undertime against a frozen snapshot of your expected hours. +- **Settings history**: change hours/week with an `effective_from` date — past closed weeks are unaffected. +- **PWA + offline**: works without a network connection; syncs via IndexedDB outbox when back online. +- **CSV export**: entries, days, weeks. +- **Midnight guard**: running entries are automatically stopped at 23:59:59 if they cross midnight. + +## Quick Start + +### Development + +```bash +# 1. Set your auth token +export AUTH_TOKEN=mysecrettoken + +# 2. Start both servers +make dev +# Go API: http://localhost:8080 +# Vite UI: http://localhost:5173 (with /api proxy) +``` + +Open `http://localhost:5173`, go to **Settings**, enter your token. + +### Production (single binary) + +```bash +make build # builds web/ then embeds it in the Go binary + +# Run +AUTH_TOKEN=mysecrettoken ./wotra +# open http://localhost:8080 +``` + +## Configuration (environment variables) + +| Variable | Default | Description | +|-------------|-------------|----------------------------------------------| +| `AUTH_TOKEN` | **required** | Bearer token for API access | +| `PORT` | `8080` | HTTP port | +| `DB_PATH` | `wotra.db` | SQLite database file path | +| `TZ` | `UTC` | Timezone for day/week key calculation | + +## API Reference + +All API endpoints require `Authorization: Bearer ` except `/healthz`. + +### Entries + +| Method | Path | Description | +|--------|-----------------------------|--------------------------| +| POST | `/api/entries/start` | Start tracking | +| POST | `/api/entries/{id}/stop` | Stop a specific entry | +| GET | `/api/entries?from=&to=` | List entries by date | +| PUT | `/api/entries/{id}` | Edit start/end/note | +| DELETE | `/api/entries/{id}` | Soft-delete an entry | + +### Days + +| Method | Path | Description | +|--------|-------------------------------|--------------------------------------| +| GET | `/api/days?from=&to=` | List closed days | +| POST | `/api/days/{day}/close` | Close a day (merge entries) | +| POST | `/api/days/{day}/mark` | Mark as holiday/vacation/sick | +| DELETE | `/api/days/{day}/close` | Reopen a closed day | + +### Weeks + +| Method | Path | Description | +|--------|-------------------------------|--------------------------------------| +| GET | `/api/weeks?from=&to=` | List closed weeks | +| POST | `/api/weeks/{week}/close` | Close a week (compute overtime) | +| DELETE | `/api/weeks/{week}/close` | Reopen a closed week | + +Week key format: `YYYY-Www` (e.g. `2024-W03`). + +### Settings + +| Method | Path | Description | +|--------|-------------------------|------------------------------------| +| GET | `/api/settings` | Current effective settings | +| PUT | `/api/settings` | Add a new settings version | +| GET | `/api/settings/history` | All settings history | + +### Export + +| Method | Path | Description | +|--------|------------------------------|--------------------------| +| GET | `/api/export/entries.csv` | Export entries as CSV | +| GET | `/api/export/days.csv` | Export days as CSV | +| GET | `/api/export/weeks.csv` | Export weeks as CSV | + +### Sync + +| Method | Path | Description | +|--------|-------------------|-------------------------------------| +| POST | `/api/sync/pull` | Pull changes since a version | +| POST | `/api/sync/push` | Push local changes (advisory) | + +### Health + +| Method | Path | Description | +|--------|-------------|--------------------------| +| GET | `/healthz` | Unauthenticated health | + +## Architecture + +``` +┌─────────────────────────┐ ┌──────────────────────────┐ +│ Svelte PWA (client) │ ◄─────► │ Go service (API) │ +│ - IndexedDB (Dexie) │ HTTPS │ - REST/JSON endpoints │ +│ - Service Worker │ JSON │ - Business logic │ +│ - Sync outbox │ │ - SQLite (modernc) │ +└─────────────────────────┘ └──────────────────────────┘ +``` + +See [PLAN.md](PLAN.md) for the full design document. + +## Development Notes + +- **Go module**: `github.com/wotra/wotra` +- **SQLite driver**: `modernc.org/sqlite` (pure Go, no CGO) +- **Day boundary**: entries must not cross midnight; the server auto-stops any running entry at 23:59:59 local time. +- **Settings history**: `effective_from` lets you change hours/week without rewriting past closed weeks. +- **Overtime**: stored as a signed `delta_ms` on each `closed_weeks` row. Frozen at close time. diff --git a/internal/handler/export_handler.go b/internal/handler/export_handler.go new file mode 100644 index 0000000..6936f1e --- /dev/null +++ b/internal/handler/export_handler.go @@ -0,0 +1,120 @@ +package handler + +import ( + "encoding/csv" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/wotra/wotra/internal/service" +) + +// ExportHandler serves /api/export routes. +type ExportHandler struct { + entrySvc *service.EntryService + daySvc *service.DayService + weekSvc *service.WeekService +} + +func NewExportHandler(e *service.EntryService, d *service.DayService, w *service.WeekService) *ExportHandler { + return &ExportHandler{entrySvc: e, daySvc: d, weekSvc: w} +} + +func (h *ExportHandler) Routes(r chi.Router) { + r.Get("/export/entries.csv", h.EntriesCSV) + r.Get("/export/days.csv", h.DaysCSV) + r.Get("/export/weeks.csv", h.WeeksCSV) +} + +// EntriesCSV GET /api/export/entries.csv?from=YYYY-MM-DD&to=YYYY-MM-DD +func (h *ExportHandler) EntriesCSV(w http.ResponseWriter, r *http.Request) { + from := r.URL.Query().Get("from") + to := r.URL.Query().Get("to") + if from == "" { from = "0000-01-01" } + if to == "" { to = "9999-12-31" } + + es, err := h.entrySvc.List(r.Context(), from, to) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="entries-%s.csv"`, time.Now().Format("20060102"))) + cw := csv.NewWriter(w) + cw.Write([]string{"id", "day_key", "start_time", "end_time", "duration_ms", "note", "auto_stopped"}) + for _, e := range es { + endStr := "" + durStr := "0" + if e.EndTime != nil { + endStr = time.UnixMilli(*e.EndTime).UTC().Format(time.RFC3339) + durStr = fmt.Sprintf("%d", *e.EndTime - e.StartTime) + } + cw.Write([]string{ + e.ID, + e.DayKey, + time.UnixMilli(e.StartTime).UTC().Format(time.RFC3339), + endStr, + durStr, + e.Note, + fmt.Sprintf("%v", e.AutoStopped), + }) + } + cw.Flush() +} + +// DaysCSV GET /api/export/days.csv?from=YYYY-MM-DD&to=YYYY-MM-DD +func (h *ExportHandler) DaysCSV(w http.ResponseWriter, r *http.Request) { + from := r.URL.Query().Get("from") + to := r.URL.Query().Get("to") + if from == "" { from = "0000-01-01" } + if to == "" { to = "9999-12-31" } + + ds, err := h.daySvc.ListDays(r.Context(), from, to) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="days-%s.csv"`, time.Now().Format("20060102"))) + cw := csv.NewWriter(w) + cw.Write([]string{"day_key", "kind", "worked_ms", "worked_hours"}) + for _, d := range ds { + workedH := fmt.Sprintf("%.2f", float64(d.WorkedMs)/3_600_000) + cw.Write([]string{d.DayKey, string(d.Kind), fmt.Sprintf("%d", d.WorkedMs), workedH}) + } + cw.Flush() +} + +// WeeksCSV GET /api/export/weeks.csv +func (h *ExportHandler) WeeksCSV(w http.ResponseWriter, r *http.Request) { + from := r.URL.Query().Get("from") + to := r.URL.Query().Get("to") + if from == "" { from = "0000-W01" } + if to == "" { to = "9999-W53" } + + ws, err := h.weekSvc.ListWeeks(r.Context(), from, to) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="weeks-%s.csv"`, time.Now().Format("20060102"))) + cw := csv.NewWriter(w) + cw.Write([]string{"week_key", "expected_ms", "worked_ms", "delta_ms", "expected_h", "worked_h", "delta_h"}) + for _, wk := range ws { + cw.Write([]string{ + wk.WeekKey, + fmt.Sprintf("%d", wk.ExpectedMs), + fmt.Sprintf("%d", wk.WorkedMs), + fmt.Sprintf("%d", wk.DeltaMs), + fmt.Sprintf("%.2f", float64(wk.ExpectedMs)/3_600_000), + fmt.Sprintf("%.2f", float64(wk.WorkedMs)/3_600_000), + fmt.Sprintf("%.2f", float64(wk.DeltaMs)/3_600_000), + }) + } + cw.Flush() +} diff --git a/internal/handler/router.go b/internal/handler/router.go index f7b0573..8ab116f 100644 --- a/internal/handler/router.go +++ b/internal/handler/router.go @@ -49,6 +49,9 @@ func NewRouter( syncH := NewSyncHandler(syncStore) syncH.Routes(r) + + exportH := NewExportHandler(entrySvc, daySvc, weekSvc) + exportH.Routes(r) }) // Serve embedded SPA if available (production build)