feat(m6): CSV export, Makefile, README, single-binary build
This commit is contained in:
34
Makefile
Normal file
34
Makefile
Normal file
@@ -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
|
||||||
132
README.md
Normal file
132
README.md
Normal file
@@ -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 <token>` 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.
|
||||||
120
internal/handler/export_handler.go
Normal file
120
internal/handler/export_handler.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -49,6 +49,9 @@ func NewRouter(
|
|||||||
|
|
||||||
syncH := NewSyncHandler(syncStore)
|
syncH := NewSyncHandler(syncStore)
|
||||||
syncH.Routes(r)
|
syncH.Routes(r)
|
||||||
|
|
||||||
|
exportH := NewExportHandler(entrySvc, daySvc, weekSvc)
|
||||||
|
exportH.Routes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Serve embedded SPA if available (production build)
|
// Serve embedded SPA if available (production build)
|
||||||
|
|||||||
Reference in New Issue
Block a user