Compare commits
4
Commits
afbf86dcf2
...
2061df63d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2061df63d4 | ||
|
|
a594183310 | ||
|
|
b311a355fd | ||
|
|
79b0821837 |
@@ -45,12 +45,15 @@ run-dev: build
|
|||||||
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
|
TASK_MASTER_HOME=$(DEV_HOME) ./bin/$(BINARY)
|
||||||
|
|
||||||
# Web test environment: SQLite demo against tm_server only (no YouTrack).
|
# Web test environment: SQLite demo against tm_server only (no YouTrack).
|
||||||
# Seeds 50 demo projects (Test + samples); login admin/admin (alice/alice as member).
|
# Seeds 50 demo projects; wires TUI projects Test + Website Relaunch with write tokens.
|
||||||
|
# Login admin/admin (alice/alice as member). Then: make run-dev → Y pull.
|
||||||
run-server-dev: web-build build-server-go
|
run-server-dev: web-build build-server-go
|
||||||
@rm -f $(DEV_HOME)/.local/share/task-master/task-master.db \
|
@rm -f $(DEV_HOME)/.local/share/task-master/task-master.db \
|
||||||
$(DEV_HOME)/.local/share/task-master/task-master.db-wal \
|
$(DEV_HOME)/.local/share/task-master/task-master.db-wal \
|
||||||
$(DEV_HOME)/.local/share/task-master/task-master.db-shm
|
$(DEV_HOME)/.local/share/task-master/task-master.db-shm
|
||||||
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack
|
@rm -rf $(DEV_HOME)/.local/share/task-master/projects/youtrack \
|
||||||
|
"$(DEV_HOME)/.local/share/task-master/projects/Test" \
|
||||||
|
"$(DEV_HOME)/.local/share/task-master/projects/Website Relaunch"
|
||||||
TASK_MASTER_HOME=$(DEV_HOME) TM_ADMIN_USER=admin TM_ADMIN_PASSWORD=admin \
|
TASK_MASTER_HOME=$(DEV_HOME) TM_ADMIN_USER=admin TM_ADMIN_PASSWORD=admin \
|
||||||
TM_SEED_DEMO=1 TASK_MASTER_SECRET=dev-secret \
|
TM_SEED_DEMO=1 TASK_MASTER_SECRET=dev-secret \
|
||||||
./bin/$(BINARY_SERVER) -listen :8080
|
./bin/$(BINARY_SERVER) -listen :8080
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ tm help
|
|||||||
### Web-Server (`tm_server`)
|
### Web-Server (`tm_server`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Dev/Test gegen lokales tm_server (kein YouTrack): seedet Projekt „Test“ + Samples
|
# Dev/Test gegen lokales tm_server (kein YouTrack): seedet Projekte + verdrahtet TUI
|
||||||
make run-server-dev # :8080, Data unter ./.dev-home, Login admin/admin
|
make run-server-dev # :8080, Data unter ./.dev-home, Login admin/admin
|
||||||
|
make run-dev # TUI: Projekte „Test“ + „Website Relaunch“ schon mit Token connected
|
||||||
|
|
||||||
# Produktion / manuell:
|
# Produktion / manuell:
|
||||||
export TM_ADMIN_USER=admin
|
export TM_ADMIN_USER=admin
|
||||||
@@ -60,7 +61,8 @@ export TASK_MASTER_SECRET='long-random-secret' # verschlüsselt Remote-Tokens
|
|||||||
```
|
```
|
||||||
|
|
||||||
UI: http://localhost:8080 — Dashboard, Board, Report, Members, Settings (Tokens), Users (Admin), Ticket-Zeit & History.
|
UI: http://localhost:8080 — Dashboard, Board, Report, Members, Settings (Tokens), Users (Admin), Ticket-Zeit & History.
|
||||||
Das Test-Environment (`run-server-dev`) spricht nur mit diesem Backend; YouTrack ist dort nicht verdrahtet.
|
Das Test-Environment (`run-server-dev`) spricht nur mit diesem Backend; YouTrack ist dort nicht verdrahtet.
|
||||||
|
Beim Seed werden für **Test** und **Website Relaunch** Write-Tokens erzeugt und unter `.dev-home` als lokale TUI-Projekte (`backend=taskmaster`) hinterlegt — danach `make run-dev` und Sync (Y → Pull).
|
||||||
|
|
||||||
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`.
|
Dev-Frontend (Vite, API-Proxy): `cd web && npm run dev` parallel zu `tm_server`.
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func bootstrapAdmin(db *sqlite.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runServer(listen, dbPath string, secure bool) error {
|
func runServer(listen, dbPath string, secure bool) error {
|
||||||
db, _, err := openDB(dbPath)
|
db, cfg, err := openDB(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,10 @@ func runServer(listen, dbPath string, secure bool) error {
|
|||||||
srv.Secure = secure
|
srv.Secure = secure
|
||||||
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
|
if strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "1") ||
|
||||||
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
|
strings.EqualFold(os.Getenv("TM_SEED_DEMO"), "true") {
|
||||||
if err := srv.SeedDemo(); err != nil {
|
if err := srv.SeedDemo(server.SeedDemoOptions{
|
||||||
|
ClientDataDir: cfg.DataDir,
|
||||||
|
BaseURL: server.SeedBaseURL(listen),
|
||||||
|
}); err != nil {
|
||||||
return fmt.Errorf("seed: %w", err)
|
return fmt.Errorf("seed: %w", err)
|
||||||
}
|
}
|
||||||
projects, _ := srv.Projects.List()
|
projects, _ := srv.Projects.List()
|
||||||
@@ -183,6 +186,7 @@ Env:
|
|||||||
TASK_MASTER_HOME / TASK_MASTER_DATA_DIR same as tm
|
TASK_MASTER_HOME / TASK_MASTER_DATA_DIR same as tm
|
||||||
TASK_MASTER_SECRET encrypts remote tokens
|
TASK_MASTER_SECRET encrypts remote tokens
|
||||||
TM_ADMIN_USER / TM_ADMIN_PASSWORD bootstrap first admin
|
TM_ADMIN_USER / TM_ADMIN_PASSWORD bootstrap first admin
|
||||||
TM_SEED_DEMO=1 seed local Test projects when DB empty (no YouTrack)
|
TM_SEED_DEMO=1 seed demo projects + wire TUI (Test, Website Relaunch)
|
||||||
|
TM_SEED_BASE_URL override base URL written into TUI meta.json
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-2
@@ -3,16 +3,31 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"task/internal/config"
|
||||||
|
"task/internal/project"
|
||||||
"task/internal/sqlite"
|
"task/internal/sqlite"
|
||||||
"task/internal/ticket"
|
"task/internal/ticket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SeedDemoOptions controls optional client wiring during demo seed.
|
||||||
|
type SeedDemoOptions struct {
|
||||||
|
// ClientDataDir, when set, writes local TUI projects (meta.json + tokens)
|
||||||
|
// for "Test" and "Website Relaunch" under that data directory.
|
||||||
|
ClientDataDir string
|
||||||
|
// BaseURL is the tm_server URL stored in client meta (default http://127.0.0.1:8080).
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuiSeedProjects are server project names that get a write token and local TUI wiring.
|
||||||
|
var tuiSeedProjects = []string{"Test", "Website Relaunch"}
|
||||||
|
|
||||||
// SeedDemo populates sample users/projects/tickets when the DB has no projects yet.
|
// SeedDemo populates sample users/projects/tickets when the DB has no projects yet.
|
||||||
// No external backends (YouTrack etc.) — the web UI talks only to this tm_server.
|
// No external backends (YouTrack etc.) — the web UI talks only to this tm_server.
|
||||||
func (s *Server) SeedDemo() error {
|
func (s *Server) SeedDemo(opts SeedDemoOptions) error {
|
||||||
projects, err := s.Projects.List()
|
projects, err := s.Projects.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -58,7 +73,7 @@ func (s *Server) SeedDemo() error {
|
|||||||
{Title: "Board & Login prüfen", Desc: "Mit admin/admin einloggen, Board öffnen, Status ändern.\n\n- [ ] Login\n- [ ] Ticket editieren\n- [ ] Timer starten", Status: ticket.StatusInProgress},
|
{Title: "Board & Login prüfen", Desc: "Mit admin/admin einloggen, Board öffnen, Status ändern.\n\n- [ ] Login\n- [ ] Ticket editieren\n- [ ] Timer starten", Status: ticket.StatusInProgress},
|
||||||
{Title: "Zeit auf Ticket buchen", Desc: "Im Ticket-Detail Timer oder manuelle Minuten buchen, dann Report prüfen.", Status: ticket.StatusTodo},
|
{Title: "Zeit auf Ticket buchen", Desc: "Im Ticket-Detail Timer oder manuelle Minuten buchen, dann Report prüfen.", Status: ticket.StatusTodo},
|
||||||
{Title: "Members & Rechte", Desc: "alice als Member ist schon angelegt — Memberships-Tab ansehen.", Status: ticket.StatusTodo},
|
{Title: "Members & Rechte", Desc: "alice als Member ist schon angelegt — Memberships-Tab ansehen.", Status: ticket.StatusTodo},
|
||||||
{Title: "TUI anbinden", Desc: "Settings → Token (write) erzeugen → in `tm` Projekt-Edit backend=taskmaster eintragen (URL, Token, diese Project-ID).", Status: ticket.StatusTodo},
|
{Title: "TUI Sync", Desc: "Lokal unter `.dev-home` schon mit Token verdrahtet — in `tm` (`make run-dev`) Pull/Push testen.", Status: ticket.StatusTodo},
|
||||||
{Title: "Seed abgeschlossen", Desc: "Beispiel-Ticket im Status DONE.", Status: ticket.StatusDone},
|
{Title: "Seed abgeschlossen", Desc: "Beispiel-Ticket im Status DONE.", Status: ticket.StatusDone},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -157,5 +172,99 @@ func (s *Server) SeedDemo() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "seeded demo: %d projects, 2 groups (users %s + alice/alice), sync=none\n", len(demos), admin.Username)
|
fmt.Fprintf(os.Stderr, "seeded demo: %d projects, 2 groups (users %s + alice/alice), sync=none\n", len(demos), admin.Username)
|
||||||
|
|
||||||
|
if dir := strings.TrimSpace(opts.ClientDataDir); dir != "" {
|
||||||
|
if err := s.wireDemoTUI(dir, opts.BaseURL, created, admin.ID); err != nil {
|
||||||
|
return fmt.Errorf("seed tui: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) wireDemoTUI(dataDir, baseURL string, created []struct{ ID, Name string }, adminID string) error {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "http://127.0.0.1:8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
byName := make(map[string]string, len(created))
|
||||||
|
for _, c := range created {
|
||||||
|
byName[c.Name] = c.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
store := project.NewDirStore(dataDir)
|
||||||
|
wired := make([]string, 0, len(tuiSeedProjects))
|
||||||
|
for _, name := range tuiSeedProjects {
|
||||||
|
remoteID, ok := byName[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("missing seeded project %q", name)
|
||||||
|
}
|
||||||
|
tok, err := s.DB.CreateProjectToken(remoteID, "dev-tui", sqlite.TokenScopeWrite, adminID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("token for %s: %w", name, err)
|
||||||
|
}
|
||||||
|
// Fresh local dir so stale tickets from a previous DB wipe cannot confuse sync.
|
||||||
|
_ = os.RemoveAll(filepath.Join(dataDir, "projects", name))
|
||||||
|
meta := project.Meta{
|
||||||
|
Backend: project.BackendTaskMaster,
|
||||||
|
TaskMaster: &project.TaskMaster{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
Token: tok.Token,
|
||||||
|
ProjectID: remoteID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := store.SaveMeta(name, meta); err != nil {
|
||||||
|
return fmt.Errorf("meta %s: %w", name, err)
|
||||||
|
}
|
||||||
|
wired = append(wired, name)
|
||||||
|
fmt.Fprintf(os.Stderr, "tui wired: %s → %s (project %s, token %s…)\n",
|
||||||
|
name, baseURL, remoteID, trimTokenPrefix(tok.Token))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setActiveProjectIfSameDataDir(dataDir, tuiSeedProjects[0]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "tui ready: %s (make run-dev → Y pull)\n", strings.Join(wired, ", "))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimTokenPrefix(tok string) string {
|
||||||
|
if len(tok) <= 10 {
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
return tok[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setActiveProjectIfSameDataDir(dataDir, active string) error {
|
||||||
|
cfg, err := config.LoadOrCreate()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config: %w", err)
|
||||||
|
}
|
||||||
|
if filepath.Clean(cfg.DataDir) != filepath.Clean(dataDir) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg.ActiveProject = active
|
||||||
|
if err := config.Save(cfg); err != nil {
|
||||||
|
return fmt.Errorf("save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedBaseURL derives the public base URL for seeded TUI meta from -listen / env.
|
||||||
|
func SeedBaseURL(listen string) string {
|
||||||
|
if u := strings.TrimSpace(os.Getenv("TM_SEED_BASE_URL")); u != "" {
|
||||||
|
return strings.TrimRight(u, "/")
|
||||||
|
}
|
||||||
|
host := strings.TrimSpace(listen)
|
||||||
|
if host == "" {
|
||||||
|
host = ":8080"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(host, "http://"), strings.HasPrefix(host, "https://"):
|
||||||
|
return strings.TrimRight(host, "/")
|
||||||
|
case strings.HasPrefix(host, ":"):
|
||||||
|
return "http://127.0.0.1" + host
|
||||||
|
default:
|
||||||
|
return "http://" + host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"task/internal/project"
|
||||||
"task/internal/sqlite"
|
"task/internal/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ func TestSeedDemo(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
srv := New(db)
|
srv := New(db)
|
||||||
if err := srv.SeedDemo(); err != nil {
|
if err := srv.SeedDemo(SeedDemoOptions{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
projects, err := srv.Projects.List()
|
projects, err := srv.Projects.List()
|
||||||
@@ -55,7 +57,7 @@ func TestSeedDemo(t *testing.T) {
|
|||||||
t.Fatalf("expected no remote connections, got %v", conns)
|
t.Fatalf("expected no remote connections, got %v", conns)
|
||||||
}
|
}
|
||||||
// idempotent when projects already exist
|
// idempotent when projects already exist
|
||||||
if err := srv.SeedDemo(); err != nil {
|
if err := srv.SeedDemo(SeedDemoOptions{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
projects2, _ := srv.Projects.List()
|
projects2, _ := srv.Projects.List()
|
||||||
@@ -63,3 +65,73 @@ func TestSeedDemo(t *testing.T) {
|
|||||||
t.Fatalf("re-seed changed project count")
|
t.Fatalf("re-seed changed project count")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSeedDemoWiresTUI(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("TASK_MASTER_HOME", home)
|
||||||
|
dataDir := filepath.Join(home, ".local", "share", "task-master")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sqlite.Open(filepath.Join(dataDir, "seed.db"), []byte("s"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.CreateUser("admin", "admin", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := New(db)
|
||||||
|
if err := srv.SeedDemo(SeedDemoOptions{
|
||||||
|
ClientDataDir: dataDir,
|
||||||
|
BaseURL: "http://127.0.0.1:8080",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := project.NewDirStore(dataDir)
|
||||||
|
for _, name := range tuiSeedProjects {
|
||||||
|
meta, err := store.LoadMeta(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadMeta %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if meta.EffectiveBackend() != project.BackendTaskMaster {
|
||||||
|
t.Fatalf("%s backend=%q", name, meta.EffectiveBackend())
|
||||||
|
}
|
||||||
|
if meta.TaskMaster == nil || !meta.TaskMaster.Configured() {
|
||||||
|
t.Fatalf("%s taskmaster not configured: %+v", name, meta.TaskMaster)
|
||||||
|
}
|
||||||
|
if meta.TaskMaster.BaseURL != "http://127.0.0.1:8080" {
|
||||||
|
t.Fatalf("baseURL=%q", meta.TaskMaster.BaseURL)
|
||||||
|
}
|
||||||
|
auth, err := db.ResolveBearerToken(meta.TaskMaster.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token resolve %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if auth.ProjectID != meta.TaskMaster.ProjectID {
|
||||||
|
t.Fatalf("token project mismatch")
|
||||||
|
}
|
||||||
|
if !sqlite.TokenAllows(auth.Scope, sqlite.TokenScopeWrite) {
|
||||||
|
t.Fatalf("want write scope, got %q", auth.Scope)
|
||||||
|
}
|
||||||
|
tokens, err := db.ListProjectTokens(meta.TaskMaster.ProjectID)
|
||||||
|
if err != nil || len(tokens) != 1 || tokens[0].Name != "dev-tui" {
|
||||||
|
t.Fatalf("tokens=%v err=%v", tokens, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedBaseURL(t *testing.T) {
|
||||||
|
t.Setenv("TM_SEED_BASE_URL", "")
|
||||||
|
if got := SeedBaseURL(":8080"); got != "http://127.0.0.1:8080" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
if got := SeedBaseURL("0.0.0.0:9000"); got != "http://0.0.0.0:9000" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
t.Setenv("TM_SEED_BASE_URL", "http://localhost:8080/")
|
||||||
|
if got := SeedBaseURL(":9999"); got != "http://localhost:8080" {
|
||||||
|
t.Fatalf("env override got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Duration int64 `json:"durationNs"`
|
Start *time.Time `json:"start"`
|
||||||
Note string `json:"note"`
|
End *time.Time `json:"end"`
|
||||||
|
Duration int64 `json:"durationNs"`
|
||||||
|
Note string `json:"note"`
|
||||||
}
|
}
|
||||||
if err := readJSON(r, &body); err != nil {
|
if err := readJSON(r, &body); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid json")
|
writeError(w, http.StatusBadRequest, "invalid json")
|
||||||
@@ -86,8 +88,21 @@ func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusNotFound, "not found")
|
writeError(w, http.StatusNotFound, "not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body.Duration > 0 {
|
if body.Start != nil || body.End != nil {
|
||||||
|
if body.Start != nil {
|
||||||
|
found.Start = body.Start.UTC()
|
||||||
|
}
|
||||||
|
if body.End != nil {
|
||||||
|
found.End = body.End.UTC()
|
||||||
|
}
|
||||||
|
if !found.End.After(found.Start) {
|
||||||
|
writeError(w, http.StatusBadRequest, "end must be after start")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found.Duration = found.End.Sub(found.Start)
|
||||||
|
} else if body.Duration > 0 {
|
||||||
found.Duration = time.Duration(body.Duration)
|
found.Duration = time.Duration(body.Duration)
|
||||||
|
found.End = found.Start.Add(found.Duration)
|
||||||
}
|
}
|
||||||
found.Note = body.Note
|
found.Note = body.Note
|
||||||
if err := s.Times.Update(*found); err != nil {
|
if err := s.Times.Update(*found); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"task/internal/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateTimeStartEnd(t *testing.T) {
|
||||||
|
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.CreateUser("admin", "pass", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := New(db)
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
cookie := rec.Result().Cookies()[0]
|
||||||
|
|
||||||
|
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var proj map[string]any
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||||
|
pid, _ := proj["id"].(string)
|
||||||
|
|
||||||
|
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var ticket map[string]any
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
|
||||||
|
tid, _ := ticket["id"].(string)
|
||||||
|
|
||||||
|
timeBody, _ := json.Marshal(map[string]any{
|
||||||
|
"ticketId": tid,
|
||||||
|
"durationNs": int64(30 * time.Minute),
|
||||||
|
"source": "manual",
|
||||||
|
})
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/time", bytes.NewReader(timeBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create time %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var entry map[string]any
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
|
||||||
|
eid, _ := entry["id"].(string)
|
||||||
|
if eid == "" {
|
||||||
|
t.Fatal("no entry id")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
end := start.Add(90 * time.Minute)
|
||||||
|
patchBody, _ := json.Marshal(map[string]any{
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
})
|
||||||
|
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch start/end %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
|
||||||
|
dur, _ := entry["duration"].(float64)
|
||||||
|
if time.Duration(dur) != 90*time.Minute {
|
||||||
|
t.Fatalf("duration want 90m got %v", time.Duration(dur))
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration-only still works
|
||||||
|
patchBody, _ = json.Marshal(map[string]any{"durationNs": int64(45 * time.Minute)})
|
||||||
|
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch duration %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
|
||||||
|
dur, _ = entry["duration"].(float64)
|
||||||
|
if time.Duration(dur) != 45*time.Minute {
|
||||||
|
t.Fatalf("duration want 45m got %v", time.Duration(dur))
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalid range
|
||||||
|
badEnd := start.Add(-time.Minute)
|
||||||
|
patchBody, _ = json.Marshal(map[string]any{"start": start, "end": badEnd})
|
||||||
|
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid range want 400 got %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-21
@@ -88,6 +88,7 @@ type Model struct {
|
|||||||
timeEntries views.TimeEntriesView
|
timeEntries views.TimeEntriesView
|
||||||
timeEntriesTicket string // ticket ID for ModeTimeEntries
|
timeEntriesTicket string // ticket ID for ModeTimeEntries
|
||||||
editingTimeEntry ticket.TimeEntry
|
editingTimeEntry ticket.TimeEntry
|
||||||
|
timeEdit views.TimeEditForm
|
||||||
activeTimer *ticket.ActiveTimer
|
activeTimer *ticket.ActiveTimer
|
||||||
spentByID map[string]time.Duration
|
spentByID map[string]time.Duration
|
||||||
width int
|
width int
|
||||||
@@ -207,8 +208,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
case ModeCreate, ModeSearch, ModeTimeManual:
|
||||||
return m.updateInput(msg)
|
return m.updateInput(msg)
|
||||||
|
case ModeTimeEdit:
|
||||||
|
return m.updateTimeEdit(msg)
|
||||||
case ModeEditProject:
|
case ModeEditProject:
|
||||||
return m.updateEditProject(msg)
|
return m.updateEditProject(msg)
|
||||||
case ModeProjects:
|
case ModeProjects:
|
||||||
@@ -238,8 +241,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch m.mode {
|
switch m.mode {
|
||||||
case ModeProjects, ModeMoveTicket:
|
case ModeProjects, ModeMoveTicket:
|
||||||
m.projectList, cmd = m.projectList.Update(msg)
|
m.projectList, cmd = m.projectList.Update(msg)
|
||||||
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
case ModeCreate, ModeSearch, ModeTimeManual:
|
||||||
m.input, cmd = m.input.Update(msg)
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
case ModeTimeEdit:
|
||||||
|
cmd = m.timeEdit.Update(msg)
|
||||||
case ModeEditProject:
|
case ModeEditProject:
|
||||||
cmd = m.projectEdit.Update(msg)
|
cmd = m.projectEdit.Update(msg)
|
||||||
}
|
}
|
||||||
@@ -585,8 +590,6 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
switch {
|
switch {
|
||||||
case wasProject:
|
case wasProject:
|
||||||
m.mode = ModeProjects
|
m.mode = ModeProjects
|
||||||
case m.mode == ModeTimeEdit:
|
|
||||||
m.mode = ModeTimeEntries
|
|
||||||
default:
|
default:
|
||||||
m.mode = ModeList
|
m.mode = ModeList
|
||||||
}
|
}
|
||||||
@@ -608,16 +611,6 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case key.Matches(msg, m.keys.Enter):
|
case key.Matches(msg, m.keys.Enter):
|
||||||
value := strings.TrimSpace(m.input.Value())
|
value := strings.TrimSpace(m.input.Value())
|
||||||
if m.mode == ModeTimeEdit {
|
|
||||||
if err := m.saveEditedTime(value); err != nil {
|
|
||||||
m.setError(err)
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.input.Blur()
|
|
||||||
m.mode = ModeTimeEntries
|
|
||||||
m.setStatus("time updated")
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if m.mode == ModeTimeManual {
|
if m.mode == ModeTimeManual {
|
||||||
if err := m.addManualTime(value); err != nil {
|
if err := m.addManualTime(value); err != nil {
|
||||||
m.setError(err)
|
m.setError(err)
|
||||||
@@ -708,10 +701,8 @@ func (m Model) updateTimeEntries(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.editingTimeEntry = e
|
m.editingTimeEntry = e
|
||||||
|
m.timeEdit = views.NewTimeEditForm(e, m.width)
|
||||||
m.mode = ModeTimeEdit
|
m.mode = ModeTimeEdit
|
||||||
m.input = views.NewTimeManualInput()
|
|
||||||
m.input.SetValue(ticket.FormatDurationHuman(e.Duration))
|
|
||||||
m.input.Focus()
|
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Undo):
|
case key.Matches(msg, m.keys.Undo):
|
||||||
@@ -1717,18 +1708,49 @@ func (m *Model) deleteRemoteWorkItem(e ticket.TimeEntry) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) saveEditedTime(raw string) error {
|
func (m Model) updateTimeEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Escape):
|
||||||
|
m.mode = ModeTimeEntries
|
||||||
|
m.clearStatus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Tab), msg.Type == tea.KeyDown:
|
||||||
|
m.timeEdit.NextField()
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.ShiftTab), msg.Type == tea.KeyUp:
|
||||||
|
m.timeEdit.PrevField()
|
||||||
|
return m, textinput.Blink
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Enter):
|
||||||
|
m.clearStatus()
|
||||||
|
if err := m.saveEditedTime(); err != nil {
|
||||||
|
m.setError(err)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.mode = ModeTimeEntries
|
||||||
|
m.setStatus("time updated")
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := m.timeEdit.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) saveEditedTime() error {
|
||||||
if m.timeStore == nil {
|
if m.timeStore == nil {
|
||||||
return fmt.Errorf("time store not configured")
|
return fmt.Errorf("time store not configured")
|
||||||
}
|
}
|
||||||
dur, err := ticket.ParseDurationFlexible(raw)
|
start, end, err := m.timeEdit.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
before := m.editingTimeEntry
|
before := m.editingTimeEntry
|
||||||
after := before
|
after := before
|
||||||
after.Duration = dur
|
after.Start = start
|
||||||
after.End = after.Start.Add(dur)
|
after.End = end
|
||||||
|
after.Duration = end.Sub(start)
|
||||||
if err := m.exec.Do(&command.UpdateTimeEntry{
|
if err := m.exec.Do(&command.UpdateTimeEntry{
|
||||||
Store: m.timeStore,
|
Store: m.timeStore,
|
||||||
Before: before,
|
Before: before,
|
||||||
|
|||||||
@@ -87,13 +87,14 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
case ModeTimeEntries:
|
case ModeTimeEntries:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "navigate"},
|
{"j/k", "navigate"},
|
||||||
{"e", "edit"},
|
{"e", "edit times"},
|
||||||
{"x", "delete"},
|
{"x", "delete"},
|
||||||
{"u/U", "undo/redo"},
|
{"u/U", "undo/redo"},
|
||||||
{"esc", "back"},
|
{"esc", "back"},
|
||||||
}
|
}
|
||||||
case ModeTimeEdit:
|
case ModeTimeEdit:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
|
{"tab/↑↓", "fields"},
|
||||||
{"enter", "save"},
|
{"enter", "save"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,9 +60,7 @@ func (m Model) renderBody(height int) string {
|
|||||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||||
|
|
||||||
case ModeTimeEdit:
|
case ModeTimeEdit:
|
||||||
prompt := m.styles.InputPrompt.Render("Edit duration:")
|
return lipgloss.NewStyle().Height(height).Width(m.width).Render(m.timeEdit.View())
|
||||||
content := prompt + "\n" + m.input.View()
|
|
||||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
|
||||||
|
|
||||||
case ModeTimeEntries:
|
case ModeTimeEntries:
|
||||||
m.timeEntries.SetSize(m.width, height)
|
m.timeEntries.SetSize(m.width, height)
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"task/internal/ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Time edit field indices.
|
||||||
|
const (
|
||||||
|
FieldTimeStart = iota
|
||||||
|
FieldTimeEnd
|
||||||
|
TimeEditFieldCount
|
||||||
|
)
|
||||||
|
|
||||||
|
const timeEditLayout = "2006-01-02 15:04"
|
||||||
|
|
||||||
|
// TimeEditStyles styles the time-entry edit form.
|
||||||
|
type TimeEditStyles struct {
|
||||||
|
Label lipgloss.Style
|
||||||
|
Focused lipgloss.Style
|
||||||
|
Blurred lipgloss.Style
|
||||||
|
Hint lipgloss.Style
|
||||||
|
Title lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTimeEditStyles returns sensible defaults.
|
||||||
|
func DefaultTimeEditStyles() TimeEditStyles {
|
||||||
|
return TimeEditStyles{
|
||||||
|
Label: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
|
||||||
|
Focused: lipgloss.NewStyle().Foreground(lipgloss.Color("205")),
|
||||||
|
Blurred: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
|
||||||
|
Hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||||
|
Title: lipgloss.NewStyle().Bold(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeEditForm edits start and end of a time entry.
|
||||||
|
type TimeEditForm struct {
|
||||||
|
Inputs []textinput.Model
|
||||||
|
Focus int
|
||||||
|
Styles TimeEditStyles
|
||||||
|
Width int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTimeEditForm builds a form prefilled from an entry.
|
||||||
|
func NewTimeEditForm(e ticket.TimeEntry, width int) TimeEditForm {
|
||||||
|
styles := DefaultTimeEditStyles()
|
||||||
|
inputs := make([]textinput.Model, TimeEditFieldCount)
|
||||||
|
|
||||||
|
mk := func(placeholder string) textinput.Model {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = placeholder
|
||||||
|
ti.CharLimit = 16
|
||||||
|
ti.Width = max(20, width-10)
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs[FieldTimeStart] = mk(timeEditLayout)
|
||||||
|
inputs[FieldTimeEnd] = mk(timeEditLayout)
|
||||||
|
if !e.Start.IsZero() {
|
||||||
|
inputs[FieldTimeStart].SetValue(e.Start.Local().Format(timeEditLayout))
|
||||||
|
}
|
||||||
|
if !e.End.IsZero() {
|
||||||
|
inputs[FieldTimeEnd].SetValue(e.End.Local().Format(timeEditLayout))
|
||||||
|
} else if !e.Start.IsZero() && e.Duration > 0 {
|
||||||
|
inputs[FieldTimeEnd].SetValue(e.Start.Add(e.Duration).Local().Format(timeEditLayout))
|
||||||
|
}
|
||||||
|
|
||||||
|
f := TimeEditForm{
|
||||||
|
Inputs: inputs,
|
||||||
|
Focus: FieldTimeStart,
|
||||||
|
Styles: styles,
|
||||||
|
Width: width,
|
||||||
|
}
|
||||||
|
f.focusIndex(FieldTimeStart)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *TimeEditForm) focusIndex(i int) {
|
||||||
|
f.Focus = i
|
||||||
|
for idx := range f.Inputs {
|
||||||
|
if idx == i {
|
||||||
|
f.Inputs[idx].Focus()
|
||||||
|
f.Inputs[idx].PromptStyle = f.Styles.Focused
|
||||||
|
f.Inputs[idx].TextStyle = f.Styles.Focused
|
||||||
|
} else {
|
||||||
|
f.Inputs[idx].Blur()
|
||||||
|
f.Inputs[idx].PromptStyle = f.Styles.Blurred
|
||||||
|
f.Inputs[idx].TextStyle = f.Styles.Blurred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextField moves focus to the next field.
|
||||||
|
func (f *TimeEditForm) NextField() {
|
||||||
|
f.focusIndex((f.Focus + 1) % TimeEditFieldCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrevField moves focus to the previous field.
|
||||||
|
func (f *TimeEditForm) PrevField() {
|
||||||
|
f.focusIndex((f.Focus - 1 + TimeEditFieldCount) % TimeEditFieldCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update forwards a message to the focused text input.
|
||||||
|
func (f *TimeEditForm) Update(msg tea.Msg) tea.Cmd {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse returns start and end in UTC, or an error.
|
||||||
|
func (f TimeEditForm) Parse() (start, end time.Time, err error) {
|
||||||
|
startRaw := strings.TrimSpace(f.Inputs[FieldTimeStart].Value())
|
||||||
|
endRaw := strings.TrimSpace(f.Inputs[FieldTimeEnd].Value())
|
||||||
|
if startRaw == "" || endRaw == "" {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("start and end are required")
|
||||||
|
}
|
||||||
|
start, err = time.ParseInLocation(timeEditLayout, startRaw, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("start: use %s", timeEditLayout)
|
||||||
|
}
|
||||||
|
end, err = time.ParseInLocation(timeEditLayout, endRaw, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("end: use %s", timeEditLayout)
|
||||||
|
}
|
||||||
|
if !end.After(start) {
|
||||||
|
return time.Time{}, time.Time{}, fmt.Errorf("end must be after start")
|
||||||
|
}
|
||||||
|
return start.UTC(), end.UTC(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the form.
|
||||||
|
func (f TimeEditForm) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(f.Styles.Title.Render("Edit time entry"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
labels := []string{"Start", "End"}
|
||||||
|
for i, label := range labels {
|
||||||
|
b.WriteString(f.Styles.Label.Render(label+":"))
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(f.Inputs[i].View())
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(f.Styles.Hint.Render("format " + timeEditLayout + " · tab fields · enter save · esc cancel"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"task/internal/ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTimeEditFormParse(t *testing.T) {
|
||||||
|
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.Local)
|
||||||
|
end := start.Add(90 * time.Minute)
|
||||||
|
f := NewTimeEditForm(ticket.TimeEntry{Start: start, End: end, Duration: 90 * time.Minute}, 80)
|
||||||
|
|
||||||
|
gotStart, gotEnd, err := f.Parse()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !gotStart.Equal(start.UTC()) {
|
||||||
|
t.Fatalf("start: got %v want %v", gotStart, start.UTC())
|
||||||
|
}
|
||||||
|
if !gotEnd.Equal(end.UTC()) {
|
||||||
|
t.Fatalf("end: got %v want %v", gotEnd, end.UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeEditFormRejectsBadRange(t *testing.T) {
|
||||||
|
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.Local)
|
||||||
|
f := NewTimeEditForm(ticket.TimeEntry{Start: start, End: start, Duration: time.Minute}, 80)
|
||||||
|
f.Inputs[FieldTimeEnd].SetValue(start.Format(timeEditLayout))
|
||||||
|
if _, _, err := f.Parse(); err == nil {
|
||||||
|
t.Fatal("expected error for end == start")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ func (v TimeEntriesView) View() string {
|
|||||||
title = "ticket"
|
title = "ticket"
|
||||||
}
|
}
|
||||||
head := v.Styles.Title.Render(fmt.Sprintf("Time entries · %s", truncateOneLine(title, max(8, v.Width-20))))
|
head := v.Styles.Title.Render(fmt.Sprintf("Time entries · %s", truncateOneLine(title, max(8, v.Width-20))))
|
||||||
hint := v.Styles.Hint.Render("j/k navigate · e edit · x delete · esc back")
|
hint := v.Styles.Hint.Render("j/k navigate · e edit start/end · x delete · esc back")
|
||||||
|
|
||||||
innerW := max(20, v.Width-2)
|
innerW := max(20, v.Width-2)
|
||||||
var lines []string
|
var lines []string
|
||||||
|
|||||||
+259
-34
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api } from './lib/api.js'
|
import { api } from './lib/api.js'
|
||||||
|
import { parseRoute, buildHash, currentHash } from './lib/route.js'
|
||||||
|
|
||||||
let user = $state(null)
|
let user = $state(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
let newTokenSecret = $state('')
|
let newTokenSecret = $state('')
|
||||||
let renameName = $state('')
|
let renameName = $state('')
|
||||||
let editGroupId = $state('')
|
let editGroupId = $state('')
|
||||||
|
let applyingRoute = false
|
||||||
|
|
||||||
let loginUser = $state('')
|
let loginUser = $state('')
|
||||||
let loginPass = $state('')
|
let loginPass = $state('')
|
||||||
@@ -43,19 +45,101 @@
|
|||||||
let memberUserId = $state('')
|
let memberUserId = $state('')
|
||||||
let memberRole = $state('member')
|
let memberRole = $state('member')
|
||||||
let timeMinutes = $state(30)
|
let timeMinutes = $state(30)
|
||||||
|
let editingTimeId = $state('')
|
||||||
|
let editTimeStart = $state('')
|
||||||
|
let editTimeEnd = $state('')
|
||||||
let tokenName = $state('tui')
|
let tokenName = $state('tui')
|
||||||
let tokenScope = $state('write')
|
let tokenScope = $state('write')
|
||||||
|
|
||||||
|
function desiredHash() {
|
||||||
|
return buildHash({
|
||||||
|
topTab,
|
||||||
|
projectId,
|
||||||
|
projectView,
|
||||||
|
ticketId: selected?.id || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeMatches(r) {
|
||||||
|
if (r.topTab !== topTab) return false
|
||||||
|
if (r.topTab !== 'projects') return true
|
||||||
|
if (r.projectId !== projectId) return false
|
||||||
|
if (r.projectView === 'ticket') {
|
||||||
|
return projectView === 'ticket' && selected?.id === r.ticketId
|
||||||
|
}
|
||||||
|
return projectView === r.projectView
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRoute() {
|
||||||
|
if (applyingRoute || !user) return
|
||||||
|
const next = desiredHash()
|
||||||
|
if (currentHash() === next) return
|
||||||
|
location.hash = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyRoute() {
|
||||||
|
if (!user || applyingRoute) return
|
||||||
|
const r = parseRoute()
|
||||||
|
if (routeMatches(r) && !loading) return
|
||||||
|
applyingRoute = true
|
||||||
|
error = ''
|
||||||
|
try {
|
||||||
|
if (r.topTab === 'dashboard') {
|
||||||
|
await showDashboard()
|
||||||
|
} else if (r.topTab === 'users') {
|
||||||
|
if (user.isAdmin) await showAdmin()
|
||||||
|
else await showDashboard()
|
||||||
|
} else if (r.topTab === 'settings') {
|
||||||
|
showGlobalSettings()
|
||||||
|
} else if (r.topTab === 'projects') {
|
||||||
|
if (!r.projectId) {
|
||||||
|
await showProjects()
|
||||||
|
} else if (r.projectView === 'ticket' && r.ticketId) {
|
||||||
|
await showProject(r.projectId)
|
||||||
|
const t = tickets.find((x) => x.id === r.ticketId)
|
||||||
|
if (t) await showTicket(t)
|
||||||
|
else await showProject(r.projectId)
|
||||||
|
} else if (r.projectView === 'report') {
|
||||||
|
await showProject(r.projectId)
|
||||||
|
await showReport()
|
||||||
|
} else if (r.projectView === 'members') {
|
||||||
|
await showProject(r.projectId)
|
||||||
|
await showMembers()
|
||||||
|
} else if (r.projectView === 'settings') {
|
||||||
|
await showProject(r.projectId)
|
||||||
|
await showProjectSettings()
|
||||||
|
} else {
|
||||||
|
await showProject(r.projectId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await showDashboard()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message || String(e)
|
||||||
|
await showDashboard()
|
||||||
|
} finally {
|
||||||
|
const next = desiredHash()
|
||||||
|
if (currentHash() !== next) history.replaceState(null, '', next)
|
||||||
|
applyingRoute = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
const onHash = () => {
|
||||||
|
if (!user) return
|
||||||
|
applyRoute()
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', onHash)
|
||||||
try {
|
try {
|
||||||
user = await api.me()
|
user = await api.me()
|
||||||
await openDashboard()
|
await applyRoute()
|
||||||
await refreshTimer()
|
await refreshTimer()
|
||||||
} catch {
|
} catch {
|
||||||
user = null
|
user = null
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
return () => window.removeEventListener('hashchange', onHash)
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDuration(ns) {
|
function formatDuration(ns) {
|
||||||
@@ -67,11 +151,70 @@
|
|||||||
return `${m}m`
|
return `${m}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toLocalInput(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTicketTime() {
|
||||||
|
if (!selected) return
|
||||||
|
const all = await api.listTime(projectId)
|
||||||
|
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginEditTime(e) {
|
||||||
|
editingTimeId = e.id
|
||||||
|
editTimeStart = toLocalInput(e.start)
|
||||||
|
editTimeEnd = toLocalInput(e.end || (e.start && e.duration
|
||||||
|
? new Date(new Date(e.start).getTime() + Number(e.duration)).toISOString()
|
||||||
|
: ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditTime() {
|
||||||
|
editingTimeId = ''
|
||||||
|
editTimeStart = ''
|
||||||
|
editTimeEnd = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTimeEntry() {
|
||||||
|
if (!selected || !editingTimeId) return
|
||||||
|
if (!editTimeStart || !editTimeEnd) {
|
||||||
|
error = 'Start and end are required'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = new Date(editTimeStart)
|
||||||
|
const end = new Date(editTimeEnd)
|
||||||
|
if (!(end > start)) {
|
||||||
|
error = 'End must be after start'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error = ''
|
||||||
|
await api.patchTime(projectId, editingTimeId, {
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
})
|
||||||
|
cancelEditTime()
|
||||||
|
await refreshTicketTime()
|
||||||
|
msg = 'Time updated'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTimeEntry(eid) {
|
||||||
|
if (!confirm('Delete time entry?')) return
|
||||||
|
await api.deleteTime(projectId, eid)
|
||||||
|
if (editingTimeId === eid) cancelEditTime()
|
||||||
|
await refreshTicketTime()
|
||||||
|
msg = 'Time deleted'
|
||||||
|
}
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
error = ''
|
error = ''
|
||||||
try {
|
try {
|
||||||
user = await api.login(loginUser, loginPass)
|
user = await api.login(loginUser, loginPass)
|
||||||
await openDashboard()
|
await applyRoute()
|
||||||
|
await refreshTimer()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message
|
error = e.message
|
||||||
}
|
}
|
||||||
@@ -82,6 +225,10 @@
|
|||||||
user = null
|
user = null
|
||||||
projects = []
|
projects = []
|
||||||
groups = []
|
groups = []
|
||||||
|
selected = null
|
||||||
|
projectId = ''
|
||||||
|
projectView = ''
|
||||||
|
topTab = 'dashboard'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshProjects() {
|
async function refreshProjects() {
|
||||||
@@ -127,7 +274,7 @@
|
|||||||
timer = data.timer
|
timer = data.timer
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openDashboard() {
|
async function showDashboard() {
|
||||||
topTab = 'dashboard'
|
topTab = 'dashboard'
|
||||||
projectId = ''
|
projectId = ''
|
||||||
projectView = ''
|
projectView = ''
|
||||||
@@ -136,16 +283,30 @@
|
|||||||
await refreshProjects()
|
await refreshProjects()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProjects() {
|
async function openDashboard() {
|
||||||
|
await showDashboard()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showProjects() {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
|
projectId = ''
|
||||||
|
projectView = ''
|
||||||
|
selected = null
|
||||||
error = ''
|
error = ''
|
||||||
msg = ''
|
msg = ''
|
||||||
await refreshProjects()
|
await refreshProjects()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProject(id, name = '') {
|
async function openProjects() {
|
||||||
|
await showProjects()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showProject(id, name = '') {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
projectId = id
|
projectId = id
|
||||||
|
if (!projects.length) await refreshProjects()
|
||||||
projectName = name || projects.find((p) => p.id === id)?.name || id
|
projectName = name || projects.find((p) => p.id === id)?.name || id
|
||||||
projectView = 'board'
|
projectView = 'board'
|
||||||
selected = null
|
selected = null
|
||||||
@@ -153,6 +314,11 @@
|
|||||||
tickets = await api.listTickets(id)
|
tickets = await api.listTickets(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openProject(id, name = '') {
|
||||||
|
await showProject(id, name)
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function createProject() {
|
async function createProject() {
|
||||||
error = ''
|
error = ''
|
||||||
const name = newProjectName.trim()
|
const name = newProjectName.trim()
|
||||||
@@ -177,11 +343,12 @@
|
|||||||
tickets = await api.listTickets(projectId)
|
tickets = await api.listTickets(projectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openTicket(t) {
|
async function showTicket(t) {
|
||||||
selected = t
|
selected = t
|
||||||
editTitle = t.title
|
editTitle = t.title
|
||||||
editDesc = t.description || ''
|
editDesc = t.description || ''
|
||||||
editStatus = t.status
|
editStatus = t.status
|
||||||
|
cancelEditTime()
|
||||||
revisions = await api.listRevisions(projectId, t.id)
|
revisions = await api.listRevisions(projectId, t.id)
|
||||||
const all = await api.listTime(projectId)
|
const all = await api.listTime(projectId)
|
||||||
ticketTime = all.filter((e) => e.ticketId === t.id)
|
ticketTime = all.filter((e) => e.ticketId === t.id)
|
||||||
@@ -190,6 +357,11 @@
|
|||||||
projectView = 'ticket'
|
projectView = 'ticket'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openTicket(t) {
|
||||||
|
await showTicket(t)
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTicket() {
|
async function saveTicket() {
|
||||||
selected = await api.patchTicket(projectId, selected.id, {
|
selected = await api.patchTicket(projectId, selected.id, {
|
||||||
title: editTitle,
|
title: editTitle,
|
||||||
@@ -207,6 +379,7 @@
|
|||||||
selected = null
|
selected = null
|
||||||
projectView = 'board'
|
projectView = 'board'
|
||||||
tickets = await api.listTickets(projectId)
|
tickets = await api.listTickets(projectId)
|
||||||
|
syncRoute()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreRev(rid) {
|
async function restoreRev(rid) {
|
||||||
@@ -226,8 +399,7 @@
|
|||||||
durationNs: timeMinutes * 60 * 1e9,
|
durationNs: timeMinutes * 60 * 1e9,
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
})
|
})
|
||||||
const all = await api.listTime(projectId)
|
await refreshTicketTime()
|
||||||
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
|
||||||
msg = `Booked ${timeMinutes}m`
|
msg = `Booked ${timeMinutes}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,23 +411,27 @@
|
|||||||
await api.startTimer(selected.id, projectId)
|
await api.startTimer(selected.id, projectId)
|
||||||
}
|
}
|
||||||
await refreshTimer()
|
await refreshTimer()
|
||||||
const all = await api.listTime(projectId)
|
await refreshTicketTime()
|
||||||
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openReport() {
|
async function showReport() {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
projectView = 'report'
|
projectView = 'report'
|
||||||
error = ''
|
error = ''
|
||||||
report = await api.report(projectId, reportGroupBy)
|
report = await api.report(projectId, reportGroupBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openReport() {
|
||||||
|
await showReport()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function changeReportGroup(g) {
|
async function changeReportGroup(g) {
|
||||||
reportGroupBy = g
|
reportGroupBy = g
|
||||||
report = await api.report(projectId, reportGroupBy)
|
report = await api.report(projectId, reportGroupBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProjectSettings() {
|
async function showProjectSettings() {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
projectView = 'settings'
|
projectView = 'settings'
|
||||||
renameName = projectName
|
renameName = projectName
|
||||||
@@ -265,6 +441,11 @@
|
|||||||
await refreshProjects()
|
await refreshProjects()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openProjectSettings() {
|
||||||
|
await showProjectSettings()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function saveProjectGroup() {
|
async function saveProjectGroup() {
|
||||||
const p = await api.patchProject(projectId, { groupId: editGroupId })
|
const p = await api.patchProject(projectId, { groupId: editGroupId })
|
||||||
msg = 'Gruppe gespeichert'
|
msg = 'Gruppe gespeichert'
|
||||||
@@ -272,12 +453,20 @@
|
|||||||
if (p.groupId !== undefined) editGroupId = p.groupId || ''
|
if (p.groupId !== undefined) editGroupId = p.groupId || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function openGlobalSettings() {
|
function showGlobalSettings() {
|
||||||
topTab = 'settings'
|
topTab = 'settings'
|
||||||
|
projectId = ''
|
||||||
|
projectView = ''
|
||||||
|
selected = null
|
||||||
error = ''
|
error = ''
|
||||||
msg = ''
|
msg = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openGlobalSettings() {
|
||||||
|
showGlobalSettings()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function saveRename() {
|
async function saveRename() {
|
||||||
const name = renameName.trim()
|
const name = renameName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
@@ -299,13 +488,21 @@
|
|||||||
tokens = await api.listTokens(projectId)
|
tokens = await api.listTokens(projectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAdmin() {
|
async function showAdmin() {
|
||||||
topTab = 'users'
|
topTab = 'users'
|
||||||
|
projectId = ''
|
||||||
|
projectView = ''
|
||||||
|
selected = null
|
||||||
error = ''
|
error = ''
|
||||||
msg = ''
|
msg = ''
|
||||||
users = await api.listUsers()
|
users = await api.listUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAdmin() {
|
||||||
|
await showAdmin()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function createUser() {
|
async function createUser() {
|
||||||
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
|
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
|
||||||
newUserName = ''
|
newUserName = ''
|
||||||
@@ -330,13 +527,18 @@
|
|||||||
users = await api.listUsers()
|
users = await api.listUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openMembers() {
|
async function showMembers() {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
projectView = 'members'
|
projectView = 'members'
|
||||||
members = await api.listMembers(projectId)
|
members = await api.listMembers(projectId)
|
||||||
users = await api.listUsers()
|
users = await api.listUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openMembers() {
|
||||||
|
await showMembers()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function addMember() {
|
async function addMember() {
|
||||||
await api.setMember(projectId, memberUserId, memberRole)
|
await api.setMember(projectId, memberUserId, memberRole)
|
||||||
members = await api.listMembers(projectId)
|
members = await api.listMembers(projectId)
|
||||||
@@ -385,21 +587,23 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">task-master</div>
|
<div class="topbar-start">
|
||||||
<nav class="nav">
|
<button type="button" class="brand" onclick={openDashboard}>task-master</button>
|
||||||
<button class:active={topTab === 'dashboard'} onclick={openDashboard}>Dashboard</button>
|
<nav class="nav">
|
||||||
<button class:active={topTab === 'projects'} onclick={openProjects}>Projekte</button>
|
<button type="button" class:active={topTab === 'dashboard'} onclick={openDashboard}>Dashboard</button>
|
||||||
{#if user.isAdmin}
|
<button type="button" class:active={topTab === 'projects'} onclick={openProjects}>Projekte</button>
|
||||||
<button class:active={topTab === 'users'} onclick={openAdmin}>User</button>
|
{#if user.isAdmin}
|
||||||
{/if}
|
<button type="button" class:active={topTab === 'users'} onclick={openAdmin}>User</button>
|
||||||
<button class:active={topTab === 'settings'} onclick={openGlobalSettings}>Settings</button>
|
{/if}
|
||||||
</nav>
|
<button type="button" class:active={topTab === 'settings'} onclick={openGlobalSettings}>Settings</button>
|
||||||
<div class="row">
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-meta row">
|
||||||
{#if timer}
|
{#if timer}
|
||||||
<span class="muted">⏱ {timer.ticketId.slice(0, 8)}…</span>
|
<span class="muted">⏱ {timer.ticketId.slice(0, 8)}…</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="muted">{user.username}{user.isAdmin ? ' (admin)' : ''}</span>
|
<span class="muted">{user.username}{user.isAdmin ? ' (admin)' : ''}</span>
|
||||||
<button onclick={doLogout}>Logout</button>
|
<button type="button" onclick={doLogout}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -545,7 +749,7 @@
|
|||||||
{:else if topTab === 'projects' && projectView === 'ticket' && selected}
|
{:else if topTab === 'projects' && projectView === 'ticket' && selected}
|
||||||
<section class="panel stack">
|
<section class="panel stack">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button onclick={() => (projectView = 'board')}>← Board</button>
|
<button onclick={() => openProject(projectId, projectName)}>← Board</button>
|
||||||
<button class="danger" onclick={removeTicket}>Delete</button>
|
<button class="danger" onclick={removeTicket}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<label>Title<input bind:value={editTitle} /></label>
|
<label>Title<input bind:value={editTitle} /></label>
|
||||||
@@ -570,16 +774,37 @@
|
|||||||
<button onclick={bookTime}>Book</button>
|
<button onclick={bookTime}>Book</button>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>When</th><th>Duration</th><th>Source</th></tr></thead>
|
<thead><tr><th>When</th><th>Duration</th><th>Source</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each ticketTime as e}
|
{#each ticketTime as e}
|
||||||
<tr>
|
{#if editingTimeId === e.id}
|
||||||
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
|
<tr>
|
||||||
<td>{formatDuration(e.duration)}</td>
|
<td colspan="4">
|
||||||
<td>{e.source}</td>
|
<div class="row" style="flex-wrap: wrap; gap: 0.5rem;">
|
||||||
</tr>
|
<label class="row" style="width: auto;">Start
|
||||||
|
<input type="datetime-local" bind:value={editTimeStart} style="width: auto;" />
|
||||||
|
</label>
|
||||||
|
<label class="row" style="width: auto;">End
|
||||||
|
<input type="datetime-local" bind:value={editTimeEnd} style="width: auto;" />
|
||||||
|
</label>
|
||||||
|
<button class="primary" onclick={saveTimeEntry}>Save</button>
|
||||||
|
<button onclick={cancelEditTime}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
|
||||||
|
<td>{formatDuration(e.duration)}</td>
|
||||||
|
<td>{e.source}</td>
|
||||||
|
<td class="row" style="width: auto; justify-content: flex-end;">
|
||||||
|
<button onclick={() => beginEditTime(e)}>Edit</button>
|
||||||
|
<button class="danger" onclick={() => removeTimeEntry(e.id)}>Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<tr><td colspan="3" class="muted">No time yet.</td></tr>
|
<tr><td colspan="4" class="muted">No time yet.</td></tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+154
-37
@@ -30,24 +30,66 @@ button, input, select, textarea {
|
|||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid var(--line);
|
border: none;
|
||||||
background: var(--panel-2);
|
border-radius: 0;
|
||||||
border-radius: 6px;
|
background: transparent;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.45rem 0.85rem;
|
||||||
color: var(--ink);
|
color: var(--muted);
|
||||||
}
|
|
||||||
button:hover { border-color: #3d4a58; }
|
|
||||||
button.primary {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-ink);
|
|
||||||
border-color: var(--accent);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
button.primary:hover { filter: brightness(1.08); }
|
button:hover {
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button:disabled:hover {
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
color-mix(in srgb, var(--accent) 65%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--accent) 22%, transparent) 55%,
|
||||||
|
color-mix(in srgb, var(--accent) 8%, transparent) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
button.primary:hover {
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
color-mix(in srgb, var(--accent) 80%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--accent) 32%, transparent) 55%,
|
||||||
|
color-mix(in srgb, var(--accent) 12%, transparent) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
button.danger {
|
button.danger {
|
||||||
|
border: none;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border-color: #5a3030;
|
background: linear-gradient(
|
||||||
background: #241818;
|
to right,
|
||||||
|
color-mix(in srgb, var(--danger) 35%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--danger) 10%, transparent) 55%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
button.danger:hover {
|
||||||
|
border: none;
|
||||||
|
color: #f0a0a0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
color-mix(in srgb, var(--danger) 50%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--danger) 16%, transparent) 55%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
input, select, textarea {
|
input, select, textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -75,30 +117,86 @@ code {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.topbar {
|
.topbar {
|
||||||
|
--topbar-height: 3.25rem;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
align-items: stretch;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: var(--topbar-height);
|
||||||
margin: 0 0 1.25rem;
|
margin: 0 0 1.25rem;
|
||||||
padding: 0.85rem 1.25rem;
|
/* Same inset as .app-body — nav tabs start on the sidebar’s left edge. */
|
||||||
|
padding: 0 1.25rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
.topbar-start {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.brand {
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1.1rem 0 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: #c9d6e0;
|
color: #c9d6e0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.brand:hover {
|
||||||
|
border: none;
|
||||||
|
color: #f0f4f8;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.nav button {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1.15rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.nav button:hover {
|
||||||
|
border: none;
|
||||||
|
color: var(--ink);
|
||||||
|
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
|
||||||
}
|
}
|
||||||
.nav { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
||||||
.nav button.active {
|
.nav button.active {
|
||||||
background: var(--accent);
|
border: none;
|
||||||
color: var(--accent-ink);
|
color: var(--ink);
|
||||||
border-color: var(--accent);
|
background: linear-gradient(
|
||||||
|
to top,
|
||||||
|
color-mix(in srgb, var(--accent) 65%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--accent) 18%, transparent) 42%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.topbar-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-left: 1rem;
|
||||||
}
|
}
|
||||||
.app-body {
|
.app-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -150,15 +248,16 @@ code {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: transparent;
|
border: none;
|
||||||
padding: 0.3rem 0.5rem;
|
border-radius: 0;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
.sidebar-group:hover {
|
.sidebar-group:hover {
|
||||||
background: var(--panel-2);
|
border: none;
|
||||||
border-color: var(--line);
|
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.sidebar-twist {
|
.sidebar-twist {
|
||||||
@@ -198,17 +297,27 @@ code {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: transparent;
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.sidebar-item:hover {
|
.sidebar-item:hover {
|
||||||
background: var(--panel-2);
|
border: none;
|
||||||
border-color: var(--line);
|
color: var(--ink);
|
||||||
|
background: color-mix(in srgb, var(--panel-2) 70%, transparent);
|
||||||
}
|
}
|
||||||
.sidebar-item.active {
|
.sidebar-item.active {
|
||||||
background: var(--accent);
|
border: none;
|
||||||
color: var(--accent-ink);
|
color: var(--ink);
|
||||||
border-color: var(--accent);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
color-mix(in srgb, var(--accent) 65%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--accent) 18%, transparent) 42%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.sidebar-create {
|
.sidebar-create {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -242,17 +351,25 @@ code {
|
|||||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||||
.stack { display: grid; gap: 0.6rem; }
|
.stack { display: grid; gap: 0.6rem; }
|
||||||
.ticket {
|
.ticket {
|
||||||
border: 1px solid var(--line);
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
padding: 0.65rem 0.75rem;
|
padding: 0.65rem 0.85rem;
|
||||||
background: var(--panel-2);
|
background: color-mix(in srgb, var(--panel-2) 55%, transparent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.ticket:hover {
|
.ticket:hover {
|
||||||
border-color: var(--accent);
|
border: none;
|
||||||
background: #222b34;
|
color: var(--ink);
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
color-mix(in srgb, var(--accent) 35%, transparent) 0%,
|
||||||
|
color-mix(in srgb, var(--accent) 10%, transparent) 45%,
|
||||||
|
color-mix(in srgb, var(--panel-2) 70%, transparent) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ export const api = {
|
|||||||
listTime: (projectId) => request(`/projects/${projectId}/time`),
|
listTime: (projectId) => request(`/projects/${projectId}/time`),
|
||||||
createTime: (projectId, body) =>
|
createTime: (projectId, body) =>
|
||||||
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),
|
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
patchTime: (projectId, eid, body) =>
|
||||||
|
request(`/projects/${projectId}/time/${eid}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
deleteTime: (projectId, eid) =>
|
||||||
|
request(`/projects/${projectId}/time/${eid}`, { method: 'DELETE' }),
|
||||||
getTimer: () => request('/timer'),
|
getTimer: () => request('/timer'),
|
||||||
startTimer: (ticketId, projectId) =>
|
startTimer: (ticketId, projectId) =>
|
||||||
request('/timer/start', { method: 'POST', body: JSON.stringify({ ticketId, projectId }) }),
|
request('/timer/start', { method: 'POST', body: JSON.stringify({ ticketId, projectId }) }),
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/** Hash routes for the SPA. Examples:
|
||||||
|
* #/dashboard
|
||||||
|
* #/projects
|
||||||
|
* #/projects/:id
|
||||||
|
* #/projects/:id/tickets/:ticketId
|
||||||
|
* #/projects/:id/report|members|settings
|
||||||
|
* #/users
|
||||||
|
* #/settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function parseRoute(hash = typeof location !== 'undefined' ? location.hash : '') {
|
||||||
|
const raw = String(hash || '')
|
||||||
|
.replace(/^#\/?/, '')
|
||||||
|
.replace(/\/+$/, '')
|
||||||
|
const parts = raw ? raw.split('/').filter(Boolean) : []
|
||||||
|
|
||||||
|
if (parts.length === 0 || parts[0] === 'dashboard') {
|
||||||
|
return { topTab: 'dashboard', projectId: '', projectView: '', ticketId: '' }
|
||||||
|
}
|
||||||
|
if (parts[0] === 'users') {
|
||||||
|
return { topTab: 'users', projectId: '', projectView: '', ticketId: '' }
|
||||||
|
}
|
||||||
|
if (parts[0] === 'settings') {
|
||||||
|
return { topTab: 'settings', projectId: '', projectView: '', ticketId: '' }
|
||||||
|
}
|
||||||
|
if (parts[0] === 'projects') {
|
||||||
|
const projectId = parts[1] || ''
|
||||||
|
if (!projectId) {
|
||||||
|
return { topTab: 'projects', projectId: '', projectView: '', ticketId: '' }
|
||||||
|
}
|
||||||
|
const sub = parts[2] || 'board'
|
||||||
|
if (sub === 'tickets' && parts[3]) {
|
||||||
|
return { topTab: 'projects', projectId, projectView: 'ticket', ticketId: parts[3] }
|
||||||
|
}
|
||||||
|
if (sub === 'report' || sub === 'members' || sub === 'settings' || sub === 'board') {
|
||||||
|
return { topTab: 'projects', projectId, projectView: sub, ticketId: '' }
|
||||||
|
}
|
||||||
|
return { topTab: 'projects', projectId, projectView: 'board', ticketId: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { topTab: 'dashboard', projectId: '', projectView: '', ticketId: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHash({ topTab, projectId, projectView, ticketId }) {
|
||||||
|
if (topTab === 'users') return '#/users'
|
||||||
|
if (topTab === 'settings') return '#/settings'
|
||||||
|
if (topTab === 'projects') {
|
||||||
|
if (!projectId) return '#/projects'
|
||||||
|
if (projectView === 'ticket' && ticketId) {
|
||||||
|
return `#/projects/${projectId}/tickets/${ticketId}`
|
||||||
|
}
|
||||||
|
if (projectView === 'report') return `#/projects/${projectId}/report`
|
||||||
|
if (projectView === 'members') return `#/projects/${projectId}/members`
|
||||||
|
if (projectView === 'settings') return `#/projects/${projectId}/settings`
|
||||||
|
return `#/projects/${projectId}`
|
||||||
|
}
|
||||||
|
return '#/dashboard'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentHash() {
|
||||||
|
const h = location.hash || ''
|
||||||
|
if (!h || h === '#') return '#/dashboard'
|
||||||
|
return h.startsWith('#/') ? h : `#/${h.replace(/^#/, '')}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user