tracker: single-binary project tracker with auth, api keys, and json api

Go + SQLite web app, server-rendered, hackerman theme. Projects with
descriptions, searchable notes, todo/doing/done task tracking. Bcrypt
login sessions, hashed api keys, full /api/v1 crud. Deploys on straylight
via configs/tracker.nix (prebuilt binary).
This commit is contained in:
2026-09-18 21:28:40 -07:00
commit c9e74b236d
20 changed files with 2489 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
tracker
tracker.db
tracker.db-shm
tracker.db-wal
+93
View File
@@ -0,0 +1,93 @@
# tracker
Single-binary project tracker with a Hackerman/terminal aesthetic. Go + SQLite,
server-rendered — no runtime dependencies, no build step, no JS.
## Features
- **Auth required** — username/passphrase login (bcrypt), server-side sessions
- **API keys** for remote/CLI access, managed from the web UI
- **Full JSON API** under `/api/v1/`
- **Projects** with name + description
- **Notes** per project, searchable via the global `grep the grid…` bar
- **Tasks** per project with `todo → doing → done` cycling (click the badge)
- **Global search** across project names, descriptions, notes, and task titles
- SQLite storage, WAL mode, everything in one file
## Setup
```
go build -o tracker .
./tracker -adduser kyle # prompts for a passphrase (min 8 chars)
./tracker
```
Non-interactive variant: `TRACKER_PASSWORD=... ./tracker -adduser kyle`.
Defaults: listens on `:8420` (all interfaces, so it's reachable over Tailscale),
database at `~/.local/share/tracker/tracker.db`.
```
./tracker -addr :9000 -db /path/to/tracker.db
```
## API keys
Log in, go to **keys**, generate a key. It's shown once (only a SHA-256 hash is
stored). Keys grant full API access and track last-used time; revoke anytime.
## API
Authenticate with `X-API-Key: <key>`, `Authorization: Bearer <key>`,
`Authorization: Basic`, or just your browser session cookie.
```
GET /api/v1/projects list
POST /api/v1/projects {"name": "...", "description": "..."}
GET /api/v1/projects/{id}
PATCH /api/v1/projects/{id} partial update (name/description)
DELETE /api/v1/projects/{id} cascades tasks + notes
GET /api/v1/projects/{id}/notes
POST /api/v1/projects/{id}/notes {"body": "..."}
GET /api/v1/notes/{id}
PATCH /api/v1/notes/{id} {"body": "..."}
DELETE /api/v1/notes/{id}
GET /api/v1/projects/{id}/tasks
POST /api/v1/projects/{id}/tasks {"title": "...", "status": "todo"}
GET /api/v1/tasks/{id}
PATCH /api/v1/tasks/{id} {"title": "...", "status": "doing"}
DELETE /api/v1/tasks/{id}
GET /api/v1/search?q=term
```
```
curl -H "X-API-Key: trk_..." http://host:8420/api/v1/projects
curl -u kyle -X PATCH -d '{"status":"done"}' http://host:8420/api/v1/tasks/3
```
## Autostart (systemd user unit)
```
cp tracker.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now tracker
```
Reach it from any device on your tailnet at `http://<tailscale-ip>:8420`.
## Theme
Palette lifted from omarchy's Hackerman theme (`hackerman.nvim`):
| token | hex |
|--------|-----------|
| bg | `#0B0C16` |
| panel | `#080911` |
| line | `#1a1d2b` |
| fg | `#ddf7ff` |
| muted | `#6a6e95` |
| neon | `#50f872` |
| cyan | `#7cf8f7` |
+25
View File
@@ -0,0 +1,25 @@
module tracker
go 1.27
require (
golang.org/x/crypto v0.57.0
golang.org/x/term v0.46.0
modernc.org/sqlite v1.34.4
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.48.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+53
View File
@@ -0,0 +1,53 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+437
View File
@@ -0,0 +1,437 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"tracker/internal/store"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func apiError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func readJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
if err := dec.Decode(dst); err != nil {
apiError(w, http.StatusBadRequest, "invalid json body: "+err.Error())
return false
}
return true
}
func (s *Server) apiProjectID(w http.ResponseWriter, r *http.Request) (int64, bool) {
return s.apiID(w, r)
}
func (s *Server) apiID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id < 1 {
apiError(w, http.StatusBadRequest, "invalid id")
return 0, false
}
return id, true
}
func (s *Server) apiNotFound(w http.ResponseWriter, err error) bool {
if errors.Is(err, store.ErrNotFound) {
apiError(w, http.StatusNotFound, "not found")
return true
}
return false
}
// --- projects ---
type projectPayload struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
func (s *Server) handleAPIProjectList(w http.ResponseWriter, r *http.Request) {
projects, err := s.store.Projects()
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if projects == nil {
projects = []store.Project{}
}
writeJSON(w, http.StatusOK, projects)
}
func (s *Server) handleAPIProjectCreate(w http.ResponseWriter, r *http.Request) {
var p projectPayload
if !readJSON(w, r, &p) {
return
}
if p.Name == nil || strings.TrimSpace(*p.Name) == "" {
apiError(w, http.StatusBadRequest, "name is required")
return
}
desc := ""
if p.Description != nil {
desc = strings.TrimSpace(*p.Description)
}
id, err := s.store.CreateProject(strings.TrimSpace(*p.Name), desc)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
project, err := s.store.Project(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, project)
}
func (s *Server) handleAPIProjectGet(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
project, err := s.store.Project(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, project)
}
func (s *Server) handleAPIProjectUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
var p projectPayload
if !readJSON(w, r, &p) {
return
}
project, err := s.store.Project(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if p.Name != nil {
if strings.TrimSpace(*p.Name) == "" {
apiError(w, http.StatusBadRequest, "name cannot be empty")
return
}
project.Name = strings.TrimSpace(*p.Name)
}
if p.Description != nil {
project.Description = strings.TrimSpace(*p.Description)
}
if err := s.store.UpdateProject(id, project.Name, project.Description); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, project)
}
func (s *Server) handleAPIProjectDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
if err := s.store.DeleteProject(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- notes ---
type notePayload struct {
Body *string `json:"body"`
}
func (s *Server) handleAPINoteList(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
notes, err := s.store.Notes(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if notes == nil {
notes = []store.Note{}
}
writeJSON(w, http.StatusOK, notes)
}
func (s *Server) handleAPINoteCreate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
var n notePayload
if !readJSON(w, r, &n) {
return
}
if n.Body == nil || strings.TrimSpace(*n.Body) == "" {
apiError(w, http.StatusBadRequest, "body is required")
return
}
if _, err := s.store.Project(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
noteID, err := s.store.CreateNote(id, strings.TrimSpace(*n.Body))
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
notes, err := s.store.Notes(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
for _, note := range notes {
if note.ID == noteID {
writeJSON(w, http.StatusCreated, note)
return
}
}
writeJSON(w, http.StatusCreated, map[string]int64{"id": noteID})
}
func (s *Server) handleAPINoteGet(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
note, err := s.store.Note(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, note)
}
func (s *Server) handleAPINoteUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
var n notePayload
if !readJSON(w, r, &n) {
return
}
if n.Body == nil || strings.TrimSpace(*n.Body) == "" {
apiError(w, http.StatusBadRequest, "body is required")
return
}
if err := s.store.UpdateNote(id, strings.TrimSpace(*n.Body)); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
func (s *Server) handleAPINoteDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
if err := s.store.DeleteNote(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- tasks ---
type taskPayload struct {
Title *string `json:"title"`
Status *string `json:"status"`
}
var validStatuses = map[string]bool{"todo": true, "doing": true, "done": true}
func (s *Server) handleAPITaskList(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
tasks, err := s.store.Tasks(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if tasks == nil {
tasks = []store.Task{}
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleAPITaskCreate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiProjectID(w, r)
if !ok {
return
}
var t taskPayload
if !readJSON(w, r, &t) {
return
}
if t.Title == nil || strings.TrimSpace(*t.Title) == "" {
apiError(w, http.StatusBadRequest, "title is required")
return
}
if t.Status != nil && !validStatuses[*t.Status] {
apiError(w, http.StatusBadRequest, "status must be todo, doing, or done")
return
}
if _, err := s.store.Project(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
taskID, err := s.store.CreateTask(id, strings.TrimSpace(*t.Title))
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if t.Status != nil && *t.Status != "todo" {
if err := s.store.SetTaskStatus(taskID, *t.Status); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
}
tasks, err := s.store.Tasks(id)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
for _, task := range tasks {
if task.ID == taskID {
writeJSON(w, http.StatusCreated, task)
return
}
}
writeJSON(w, http.StatusCreated, map[string]int64{"id": taskID})
}
func (s *Server) handleAPITaskGet(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
task, err := s.store.Task(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) handleAPITaskUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
var t taskPayload
if !readJSON(w, r, &t) {
return
}
if t.Status != nil && !validStatuses[*t.Status] {
apiError(w, http.StatusBadRequest, "status must be todo, doing, or done")
return
}
task, err := s.store.Task(id)
if s.apiNotFound(w, err) {
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if t.Title != nil {
if strings.TrimSpace(*t.Title) == "" {
apiError(w, http.StatusBadRequest, "title cannot be empty")
return
}
task.Title = strings.TrimSpace(*t.Title)
if err := s.store.UpdateTaskTitle(id, task.Title); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
}
if t.Status != nil {
task.Status = *t.Status
if err := s.store.SetTaskStatus(id, task.Status); err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, task)
}
func (s *Server) handleAPITaskDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.apiID(w, r)
if !ok {
return
}
if err := s.store.DeleteTask(id); s.apiNotFound(w, err) {
return
} else if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// --- search ---
func (s *Server) handleAPISearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
apiError(w, http.StatusBadRequest, "q parameter is required")
return
}
hits, err := s.store.Search(q)
if err != nil {
apiError(w, http.StatusInternalServerError, err.Error())
return
}
if hits == nil {
hits = []store.Hit{}
}
writeJSON(w, http.StatusOK, hits)
}
+230
View File
@@ -0,0 +1,230 @@
package server
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"tracker/internal/store"
)
const (
sessionCookie = "tracker_session"
sessionLifetime = 30 * 24 * time.Hour
keyPrefix = "trk_"
)
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand unavailable: " + err.Error())
}
return hex.EncodeToString(b)
}
func (s *Server) checkPassword(username, password string) bool {
u, err := s.store.UserByName(username)
if err != nil {
// burn comparable time so missing users aren't distinguishable
bcrypt.CompareHashAndPassword([]byte("$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5B0X0PYmVlSkC0pR0uW9QlY0S3zKe"), []byte(password))
return false
}
return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil
}
// --- session helpers ---
func (s *Server) sessionUser(r *http.Request) (string, bool) {
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return "", false
}
username, err := s.store.SessionByTokenHash(hashToken(c.Value))
if err != nil {
return "", false
}
return username, true
}
func (s *Server) startSession(w http.ResponseWriter, username string) error {
token := randomHex(32)
expires := time.Now().Add(sessionLifetime).Format("2006-01-02 15:04:05")
if err := s.store.CreateSession(hashToken(token), username, expires); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionLifetime.Seconds()),
})
return nil
}
// --- login / logout ---
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if _, ok := s.sessionUser(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, http.StatusOK, "login.html", map[string]any{
"Title": "login",
})
}
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if !s.checkPassword(username, password) {
time.Sleep(400 * time.Millisecond)
s.render(w, http.StatusUnauthorized, "login.html", map[string]any{
"Title": "login",
"Error": "access denied: unknown handle or bad passphrase",
})
return
}
s.store.DeleteExpiredSessions()
if err := s.startSession(w, username); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
s.store.DeleteSession(hashToken(c.Value))
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- api keys page ---
func (s *Server) handleKeys(w http.ResponseWriter, r *http.Request) {
s.renderKeys(w, r, http.StatusOK, nil, "")
}
func (s *Server) renderKeys(w http.ResponseWriter, r *http.Request, status int, keys []store.APIKey, newKey string) {
if keys == nil {
var err error
keys, err = s.store.APIKeys()
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
}
s.render(w, status, "keys.html", map[string]any{
"Title": "api keys",
"Keys": keys,
"NewKey": newKey,
})
}
func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
s.renderKeys(w, r, http.StatusBadRequest, nil, "")
return
}
plain := keyPrefix + randomHex(24)
if _, err := s.store.CreateAPIKey(name, hashToken(plain)); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.renderKeys(w, r, http.StatusOK, nil, plain)
}
func (s *Server) handleKeyDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
if err := s.store.DeleteAPIKey(id); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
http.Redirect(w, r, "/keys", http.StatusSeeOther)
}
// --- credential checks ---
func apiKeyFromRequest(r *http.Request) string {
if key := r.Header.Get("X-API-Key"); key != "" {
return key
}
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return ""
}
func (s *Server) apiAuthorized(r *http.Request) bool {
if key := apiKeyFromRequest(r); key != "" {
k, err := s.store.KeyByHash(hashToken(key))
if err != nil {
return false
}
s.store.TouchKey(k.ID)
return true
}
if user, pass, ok := r.BasicAuth(); ok {
return s.checkPassword(user, pass)
}
if _, ok := s.sessionUser(r); ok {
return true
}
return false
}
// --- middleware ---
func (s *Server) auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
switch {
case strings.HasPrefix(p, "/static/"):
next.ServeHTTP(w, r)
return
case p == "/favicon.ico":
http.NotFound(w, r)
return
case strings.HasPrefix(p, "/api/"):
if s.apiAuthorized(r) {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
return
case p == "/login":
next.ServeHTTP(w, r)
return
}
if _, ok := s.sessionUser(r); ok {
next.ServeHTTP(w, r)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
})
}
+360
View File
@@ -0,0 +1,360 @@
package server
import (
"embed"
"encoding/json"
"errors"
"html/template"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"tracker/internal/store"
)
//go:embed all:templates
var templateFS embed.FS
type Server struct {
store *store.Store
tmpl *template.Template
}
func New(st *store.Store) (*Server, error) {
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
}
return &Server{store: st, tmpl: tmpl}, nil
}
var funcMap = template.FuncMap{
"statusLabel": func(s string) string {
switch s {
case "doing":
return "▶ DOING"
case "done":
return "✓ DONE"
default:
return "○ TODO"
}
},
"dateFmt": func(s string) string {
t, err := time.Parse("2006-01-02 15:04:05", s)
if err != nil {
return s
}
return t.Format("2006-01-02 15:04")
},
"json": func(v any) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
},
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /static/", s.serveStatic)
mux.HandleFunc("GET /{$}", s.handleHome)
mux.HandleFunc("GET /search", s.handleSearch)
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginPost)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /keys", s.handleKeys)
mux.HandleFunc("POST /keys/create", s.handleKeyCreate)
mux.HandleFunc("POST /keys/{id}/delete", s.handleKeyDelete)
mux.HandleFunc("POST /projects", s.handleCreateProject)
mux.HandleFunc("GET /projects/{id}", s.handleProject)
mux.HandleFunc("GET /projects/{id}/edit", s.handleProjectEdit)
mux.HandleFunc("POST /projects/{id}/update", s.handleProjectUpdate)
mux.HandleFunc("POST /projects/{id}/delete", s.handleProjectDelete)
mux.HandleFunc("POST /projects/{id}/notes", s.handleCreateNote)
mux.HandleFunc("POST /notes/{id}/delete", s.handleDeleteNote)
mux.HandleFunc("POST /projects/{id}/tasks", s.handleCreateTask)
mux.HandleFunc("POST /tasks/{id}/cycle", s.handleCycleTask)
mux.HandleFunc("POST /tasks/{id}/delete", s.handleDeleteTask)
// json api (behind api-key / basic auth / session)
mux.HandleFunc("GET /api/v1/projects", s.handleAPIProjectList)
mux.HandleFunc("POST /api/v1/projects", s.handleAPIProjectCreate)
mux.HandleFunc("GET /api/v1/projects/{id}", s.handleAPIProjectGet)
mux.HandleFunc("PATCH /api/v1/projects/{id}", s.handleAPIProjectUpdate)
mux.HandleFunc("PUT /api/v1/projects/{id}", s.handleAPIProjectUpdate)
mux.HandleFunc("DELETE /api/v1/projects/{id}", s.handleAPIProjectDelete)
mux.HandleFunc("GET /api/v1/projects/{id}/notes", s.handleAPINoteList)
mux.HandleFunc("POST /api/v1/projects/{id}/notes", s.handleAPINoteCreate)
mux.HandleFunc("GET /api/v1/notes/{id}", s.handleAPINoteGet)
mux.HandleFunc("PATCH /api/v1/notes/{id}", s.handleAPINoteUpdate)
mux.HandleFunc("PUT /api/v1/notes/{id}", s.handleAPINoteUpdate)
mux.HandleFunc("DELETE /api/v1/notes/{id}", s.handleAPINoteDelete)
mux.HandleFunc("GET /api/v1/projects/{id}/tasks", s.handleAPITaskList)
mux.HandleFunc("POST /api/v1/projects/{id}/tasks", s.handleAPITaskCreate)
mux.HandleFunc("GET /api/v1/tasks/{id}", s.handleAPITaskGet)
mux.HandleFunc("PATCH /api/v1/tasks/{id}", s.handleAPITaskUpdate)
mux.HandleFunc("PUT /api/v1/tasks/{id}", s.handleAPITaskUpdate)
mux.HandleFunc("DELETE /api/v1/tasks/{id}", s.handleAPITaskDelete)
mux.HandleFunc("GET /api/v1/search", s.handleAPISearch)
return s.auth(mux)
}
// --- static ---
//go:embed static
var staticFS embed.FS
func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
http.FileServerFS(staticFS).ServeHTTP(w, r)
}
// --- pages ---
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
projects, err := s.store.Projects()
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
stats, err := s.store.Stats()
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.render(w, http.StatusOK, "home.html", map[string]any{
"Title": "projects",
"Projects": projects,
"Stats": stats,
})
}
func (s *Server) handleProject(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
project, err := s.store.Project(id)
if errors.Is(err, store.ErrNotFound) {
s.error(w, http.StatusNotFound, errors.New("project not found"))
return
}
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
tasks, err := s.store.Tasks(id)
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
notes, err := s.store.Notes(id)
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.render(w, http.StatusOK, "project.html", map[string]any{
"Title": project.Name,
"Project": project,
"Tasks": tasks,
"Notes": notes,
})
}
func (s *Server) handleProjectEdit(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
project, err := s.store.Project(id)
if errors.Is(err, store.ErrNotFound) {
s.error(w, http.StatusNotFound, errors.New("project not found"))
return
}
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.render(w, http.StatusOK, "edit.html", map[string]any{
"Title": "edit: " + project.Name,
"Project": project,
})
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
var hits []store.Hit
if q != "" {
var err error
hits, err = s.store.Search(q)
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
}
s.render(w, http.StatusOK, "search.html", map[string]any{
"Title": "search",
"Query": q,
"Hits": hits,
})
}
// --- actions ---
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
name, description := r.FormValue("name"), r.FormValue("description")
if strings.TrimSpace(name) == "" {
s.redirectBack(w, r, "/")
return
}
id, err := s.store.CreateProject(strings.TrimSpace(name), strings.TrimSpace(description))
if err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
http.Redirect(w, r, projectURL(id), http.StatusSeeOther)
}
func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
name, description := r.FormValue("name"), r.FormValue("description")
if strings.TrimSpace(name) == "" {
http.Redirect(w, r, projectURL(id)+"/edit", http.StatusSeeOther)
return
}
if err := s.store.UpdateProject(id, strings.TrimSpace(name), strings.TrimSpace(description)); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
http.Redirect(w, r, projectURL(id), http.StatusSeeOther)
}
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
if err := s.store.DeleteProject(id); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
body := strings.TrimSpace(r.FormValue("body"))
if body != "" {
if _, err := s.store.CreateNote(id, body); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
}
http.Redirect(w, r, projectURL(id), http.StatusSeeOther)
}
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
if err := s.store.DeleteNote(id); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.redirectBack(w, r, "/")
}
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
title := strings.TrimSpace(r.FormValue("title"))
if title != "" {
if _, err := s.store.CreateTask(id, title); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
}
http.Redirect(w, r, projectURL(id), http.StatusSeeOther)
}
func (s *Server) handleCycleTask(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
if err := s.store.CycleTask(id); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.redirectBack(w, r, "/")
}
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
id, ok := s.pathID(w, r)
if !ok {
return
}
if err := s.store.DeleteTask(id); err != nil {
s.error(w, http.StatusInternalServerError, err)
return
}
s.redirectBack(w, r, "/")
}
// --- plumbing ---
func (s *Server) pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id < 1 {
s.error(w, http.StatusNotFound, errors.New("bad id"))
return 0, false
}
return id, true
}
func projectURL(id int64) string { return "/projects/" + strconv.FormatInt(id, 10) }
// redirectBack returns the user to where they came from (Referer) so task
// toggles from the search page land back on the search page.
func (s *Server) redirectBack(w http.ResponseWriter, r *http.Request, fallback string) {
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil && u.Path != "" {
http.Redirect(w, r, u.Path, http.StatusSeeOther)
return
}
}
http.Redirect(w, r, fallback, http.StatusSeeOther)
}
func (s *Server) render(w http.ResponseWriter, status int, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("template %s: %v", name, err)
}
}
func (s *Server) error(w http.ResponseWriter, status int, err error) {
log.Printf("%d: %v", status, err)
s.render(w, status, "error.html", map[string]any{
"Title": "error",
"Status": status,
"Message": err.Error(),
})
}
+368
View File
@@ -0,0 +1,368 @@
/* ── tracker :: hackerman theme ─────────────────────────────
palette lifted from omarchy/hackerman.nvim
bg #0B0C16 · panel #080911 · line #1a1d2b
fg #ddf7ff · muted #6a6e95 · neon #50f872 · cyan #7cf8f7
─────────────────────────────────────────────────────────── */
:root {
--bg: #0B0C16;
--bg-deep: #080911;
--bg-line: #1a1d2b;
--fg: #ddf7ff;
--muted: #6a6e95;
--neon: #50f872;
--cyan: #7cf8f7;
--yellow: #7cf8d4;
--glow: 0 0 8px rgba(80, 248, 114, .55);
--glow-cyan: 0 0 8px rgba(124, 248, 247, .45);
}
* { box-sizing: border-box; }
html { color-scheme: dark; }
body {
margin: 0;
background: radial-gradient(ellipse at 50% -20%, #101426 0%, var(--bg) 55%, #05060c 100%);
color: var(--fg);
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 15px;
line-height: 1.55;
min-height: 100vh;
}
/* crt scanlines + flicker */
.scanlines {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999;
background:
repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 1px, transparent 1px 3px);
mix-blend-mode: multiply;
animation: flicker 4s infinite;
}
@keyframes flicker {
0%, 100% { opacity: .9; }
92% { opacity: .9; }
93% { opacity: .55; }
94% { opacity: .9; }
97% { opacity: .75; }
98% { opacity: .9; }
}
::selection { background: var(--bg-line); color: var(--neon); }
a { color: var(--cyan); text-decoration: none; }
a:hover { text-shadow: var(--glow-cyan); }
/* ── topbar ── */
.topbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: .8rem 1.5rem;
border-bottom: 1px solid var(--bg-line);
background: rgba(8, 9, 17, .85);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
z-index: 10;
}
.logo {
font-weight: 700;
color: var(--neon);
text-shadow: var(--glow);
letter-spacing: .05em;
white-space: nowrap;
}
.cursor { animation: blink 1.1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.searchbar { flex: 1; }
.searchbar input {
width: 100%;
max-width: 420px;
}
/* ── layout ── */
.wrap {
max-width: 860px;
margin: 2rem auto 4rem;
padding: 0 1.25rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.panel {
background: linear-gradient(180deg, var(--bg-deep), rgba(11,12,22,.6));
border: 1px solid var(--bg-line);
border-radius: 6px;
padding: 1.25rem 1.5rem 1.5rem;
box-shadow: 0 0 0 1px rgba(80,248,114,.04), 0 12px 40px rgba(0,0,0,.5);
position: relative;
}
.panel::before {
content: "";
position: absolute;
top: -1px; left: 1.5rem;
width: 4rem; height: 1px;
background: var(--neon);
box-shadow: var(--glow);
}
h1, h2 {
font-size: 1rem;
text-transform: lowercase;
letter-spacing: .08em;
margin: 0 0 1rem;
}
.glow { color: var(--neon); text-shadow: var(--glow); }
.muted { color: var(--muted); }
.description {
white-space: pre-wrap;
margin: 0;
color: var(--fg);
}
.row { display: flex; align-items: center; gap: .75rem; }
.spread { justify-content: space-between; }
.push { margin-left: auto; }
/* ── stats ── */
.stats {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.stat {
flex: 1;
min-width: 90px;
border: 1px solid var(--bg-line);
border-radius: 6px;
background: var(--bg-deep);
padding: .75rem 1rem;
text-align: center;
}
.stat .num {
display: block;
font-size: 1.6rem;
font-weight: 700;
color: var(--neon);
text-shadow: var(--glow);
}
.stat.doing .num { color: var(--yellow); text-shadow: 0 0 8px rgba(124,248,212,.5); animation: pulse 2s infinite; }
.stat.done .num { color: var(--cyan); text-shadow: var(--glow-cyan); }
.stat .label { color: var(--muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .15em; }
@keyframes pulse { 50% { opacity: .55; } }
/* ── forms ── */
input, textarea {
width: 100%;
background: #05060c;
border: 1px solid var(--bg-line);
border-radius: 4px;
color: var(--fg);
font: inherit;
padding: .55rem .8rem;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
input:focus, textarea:focus {
border-color: var(--neon);
box-shadow: var(--glow), inset 0 0 12px rgba(80,248,114,.06);
}
input::placeholder, textarea::placeholder { color: #3d4160; }
textarea { resize: vertical; }
.stack { display: flex; flex-direction: column; gap: .75rem; }
.inline-form {
display: flex;
gap: .75rem;
margin-bottom: 1.25rem;
}
.inline-form input { flex: 1; }
.big-search input { font-size: 1.1rem; }
label {
display: flex;
flex-direction: column;
gap: .4rem;
color: var(--muted);
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .1em;
}
/* ── buttons ── */
.btn {
display: inline-block;
background: transparent;
border: 1px solid var(--bg-line);
border-radius: 4px;
color: var(--cyan);
font: inherit;
font-size: .85rem;
padding: .45rem 1rem;
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
.btn:hover {
border-color: var(--cyan);
box-shadow: var(--glow-cyan);
text-shadow: var(--glow-cyan);
}
.btn.primary { color: var(--neon); border-color: rgba(80,248,114,.35); }
.btn.primary:hover { border-color: var(--neon); box-shadow: var(--glow); text-shadow: var(--glow); }
.btn.danger { color: #ff5c8a; border-color: rgba(255,92,138,.35); }
.btn.danger:hover { border-color: #ff5c8a; box-shadow: 0 0 8px rgba(255,92,138,.5); }
.btn.ghost { border: none; padding: .2rem .5rem; color: var(--muted); font-size: .8rem; }
.btn.ghost:hover { color: #ff5c8a; text-shadow: none; box-shadow: none; }
/* ── project list ── */
.project-list { list-style: none; margin: 0; padding: 0; }
.project-list li {
padding: .8rem .25rem;
border-bottom: 1px dashed var(--bg-line);
}
.project-list li:last-child { border-bottom: none; }
.project-link {
color: var(--fg);
font-weight: 700;
}
.project-link:hover { color: var(--neon); text-shadow: var(--glow); }
.project-link .arrow { color: var(--neon); }
.project-list p { margin: .2rem 0 0; font-size: .85rem; }
/* ── tasks ── */
.task-list { list-style: none; margin: 0; padding: 0; }
.task {
display: flex;
align-items: center;
gap: .75rem;
padding: .45rem .25rem;
border-bottom: 1px dashed var(--bg-line);
}
.task:last-child { border-bottom: none; }
.task form { margin: 0; }
.task-title { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.task-title.done { color: var(--muted); text-decoration: line-through; }
.status {
border: 1px solid var(--bg-line);
border-radius: 3px;
background: transparent;
font: inherit;
font-size: .72rem;
letter-spacing: .08em;
padding: .25rem .55rem;
cursor: pointer;
transition: all .15s;
min-width: 5.2rem;
text-align: left;
}
.status.todo { color: var(--muted); }
.status.doing { color: var(--yellow); border-color: rgba(124,248,212,.4); text-shadow: 0 0 6px rgba(124,248,212,.5); animation: pulse 2s infinite; }
.status.done { color: var(--neon); border-color: rgba(80,248,114,.4); text-shadow: 0 0 6px rgba(80,248,114,.5); }
.status:hover { border-color: currentColor; }
/* ── notes ── */
.note-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .9rem; }
.note {
border: 1px solid var(--bg-line);
border-left: 2px solid var(--cyan);
border-radius: 4px;
background: #05060c;
padding: .7rem 1rem;
}
.note .row { margin-bottom: .2rem; }
.note-body {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* ── search hits ── */
.hit-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1rem; }
.hit {
border-bottom: 1px dashed var(--bg-line);
padding-bottom: 1rem;
}
.hit:last-child { border-bottom: none; padding-bottom: 0; }
.hit .badge {
font-size: .68rem;
text-transform: uppercase;
letter-spacing: .12em;
border: 1px solid;
border-radius: 3px;
padding: .1rem .5rem;
margin-right: .6rem;
}
.badge.project { color: var(--neon); border-color: rgba(80,248,114,.4); }
.badge.note { color: var(--cyan); border-color: rgba(124,248,247,.4); }
.badge.task { color: var(--yellow); border-color: rgba(124,248,212,.4); }
.hit-title { color: var(--fg); font-weight: 700; }
.hit-title:hover { color: var(--neon); text-shadow: var(--glow); }
.snippet { margin: .3rem 0 0; font-size: .85rem; }
/* ── auth / keys ── */
.nav { display: flex; align-items: center; gap: .9rem; }
.nav a { color: var(--muted); font-size: .85rem; }
.nav a:hover { color: var(--neon); }
.nav form { margin: 0; }
.login-panel { max-width: 420px; margin: 15vh auto 0; }
.error-msg {
color: #ff5c8a;
border: 1px solid rgba(255,92,138,.4);
border-left: 2px solid #ff5c8a;
border-radius: 4px;
background: rgba(255,92,138,.06);
padding: .6rem 1rem;
margin: 0 0 1rem;
font-size: .85rem;
}
.keybox {
background: #05060c;
border: 1px solid rgba(80,248,114,.4);
box-shadow: var(--glow), inset 0 0 20px rgba(80,248,114,.05);
border-radius: 4px;
padding: .9rem 1.2rem;
color: var(--neon);
text-shadow: var(--glow);
overflow-wrap: anywhere;
user-select: all;
}
.key-list { list-style: none; margin: 1rem 0 0; padding: 0; }
.key-row {
display: flex;
align-items: center;
gap: 1rem;
padding: .55rem .25rem;
border-bottom: 1px dashed var(--bg-line);
font-size: .88rem;
}
.key-row:last-child { border-bottom: none; }
.key-row form { margin: 0; }
.key-name { color: var(--cyan); text-shadow: var(--glow-cyan); }
code {
background: #05060c;
border: 1px solid var(--bg-line);
border-radius: 3px;
padding: .05rem .35rem;
font-size: .85em;
color: var(--yellow);
}
/* ── footer ── */
.foot {
text-align: center;
color: var(--muted);
font-size: .75rem;
padding: 1rem 0 2rem;
letter-spacing: .1em;
}
+19
View File
@@ -0,0 +1,19 @@
{{template "header" .}}
<section class="panel">
<h1 class="glow">// edit: {{.Project.Name}}</h1>
<form method="post" action="/projects/{{.Project.ID}}/update" class="stack">
<label>name
<input type="text" name="name" value="{{.Project.Name}}" required maxlength="120">
</label>
<label>description
<textarea name="description" rows="6">{{.Project.Description}}</textarea>
</label>
<div class="row">
<button type="submit" class="btn primary">save ▸</button>
<a class="btn" href="/projects/{{.Project.ID}}">cancel</a>
</div>
</form>
</section>
{{template "footer" .}}
+9
View File
@@ -0,0 +1,9 @@
{{template "header" .}}
<section class="panel">
<h1 class="glow">// error {{.Status}}</h1>
<p class="description">{{.Message}}</p>
<p><a class="btn" href="/">◂ back to base</a></p>
</section>
{{template "footer" .}}
+35
View File
@@ -0,0 +1,35 @@
{{template "header" .}}
<section class="stats">
<div class="stat"><span class="num">{{.Stats.Projects}}</span><span class="label">projects</span></div>
<div class="stat todo"><span class="num">{{.Stats.Tasks.Todo}}</span><span class="label">todo</span></div>
<div class="stat doing"><span class="num">{{.Stats.Tasks.Doing}}</span><span class="label">doing</span></div>
<div class="stat done"><span class="num">{{.Stats.Tasks.Done}}</span><span class="label">done</span></div>
<div class="stat"><span class="num">{{.Stats.Notes}}</span><span class="label">notes</span></div>
</section>
<section class="panel">
<h1 class="glow">// new project</h1>
<form method="post" action="/projects" class="stack">
<input type="text" name="name" placeholder="project name" required maxlength="120">
<textarea name="description" placeholder="description…" rows="3"></textarea>
<button type="submit" class="btn primary">init ▸</button>
</form>
</section>
<section class="panel">
<h1 class="glow">// projects</h1>
{{if not .Projects}}
<p class="muted">no projects on the grid. create one above ▲</p>
{{end}}
<ul class="project-list">
{{range .Projects}}
<li>
<a class="project-link" href="/projects/{{.ID}}">{{.Name}}<span class="arrow"></span></a>
{{if .Description}}<p class="muted">{{.Description}}</p>{{end}}
</li>
{{end}}
</ul>
</section>
{{template "footer" .}}
+33
View File
@@ -0,0 +1,33 @@
{{template "header" .}}
{{if .NewKey}}
<section class="panel">
<h1 class="glow">// key generated</h1>
<p>copy it now — it is stored hashed and will not be shown again:</p>
<pre class="keybox">{{.NewKey}}</pre>
</section>
{{end}}
<section class="panel">
<h1 class="glow">// api keys</h1>
<p class="muted">keys grant full API access via <code>X-API-Key</code> or <code>Authorization: Bearer</code>.</p>
<form method="post" action="/keys/create" class="inline-form">
<input type="text" name="name" placeholder="key name (e.g. laptop-cli)" required maxlength="60">
<button type="submit" class="btn primary">generate ▸</button>
</form>
{{if not .Keys}}<p class="muted">no keys issued.</p>{{end}}
<ul class="key-list">
{{range .Keys}}
<li class="key-row">
<span class="key-name">{{.Name}}</span>
<span class="muted">created {{dateFmt .CreatedAt}}</span>
<span class="muted push">{{if .LastUsed.Valid}}last used {{dateFmt .LastUsed.String}}{{else}}never used{{end}}</span>
<form method="post" action="/keys/{{.ID}}/delete" onsubmit="return confirm('revoke this key?')">
<button type="submit" class="btn ghost">revoke ✕</button>
</form>
</li>
{{end}}
</ul>
</section>
{{template "footer" .}}
+17
View File
@@ -0,0 +1,17 @@
{{template "header" .}}
<section class="panel login-panel">
<h1 class="glow">// access</h1>
{{if .Error}}<p class="error-msg">{{.Error}}</p>{{end}}
<form method="post" action="/login" class="stack">
<label>handle
<input type="text" name="username" required autofocus autocomplete="username">
</label>
<label>passphrase
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit" class="btn primary">jack in ▸</button>
</form>
</section>
{{template "footer" .}}
+29
View File
@@ -0,0 +1,29 @@
{{define "header"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} ∷ TRACKER</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="scanlines" aria-hidden="true"></div>
<nav class="topbar">
<a class="logo" href="/"> tracker<span class="cursor"></span></a>
<form class="searchbar" action="/search" method="get">
<input type="search" name="q" placeholder="grep the grid…" value="" autocomplete="off">
</form>
<div class="nav">
<a href="/keys">keys</a>
<form method="post" action="/logout"><button type="submit" class="btn ghost">logout</button></form>
</div>
</nav>
<main class="wrap">
{{end}}
{{define "footer"}}
</main>
<footer class="foot">// signal intact · {{.Title}}</footer>
</body>
</html>
{{end}}
+61
View File
@@ -0,0 +1,61 @@
{{template "header" .}}
<section class="panel">
<div class="row spread">
<h1 class="glow">{{.Project.Name}}</h1>
<div class="row">
<a class="btn" href="/projects/{{.Project.ID}}/edit">edit</a>
<form method="post" action="/projects/{{.Project.ID}}/delete" onsubmit="return confirm('delete project and all its data?')">
<button type="submit" class="btn danger">rm -rf</button>
</form>
</div>
</div>
{{if .Project.Description}}<p class="description">{{.Project.Description}}</p>
{{else}}<p class="muted">no description.</p>{{end}}
</section>
<section class="panel">
<h2 class="glow">// tasks</h2>
<form method="post" action="/projects/{{.Project.ID}}/tasks" class="inline-form">
<input type="text" name="title" placeholder="new task…" required maxlength="240">
<button type="submit" class="btn primary">add ▸</button>
</form>
{{if not .Tasks}}<p class="muted">no tasks queued.</p>{{end}}
<ul class="task-list">
{{range .Tasks}}
<li class="task">
<form method="post" action="/tasks/{{.ID}}/cycle">
<button type="submit" class="status {{.Status}}" title="cycle status">{{statusLabel .Status}}</button>
</form>
<span class="task-title {{.Status}}">{{.Title}}</span>
<form method="post" action="/tasks/{{.ID}}/delete" class="push">
<button type="submit" class="btn ghost" title="delete"></button>
</form>
</li>
{{end}}
</ul>
</section>
<section class="panel">
<h2 class="glow">// notes</h2>
<form method="post" action="/projects/{{.Project.ID}}/notes" class="stack">
<textarea name="body" placeholder="log an entry…" rows="3" required></textarea>
<div class="row"><button type="submit" class="btn primary">log ▸</button></div>
</form>
{{if not .Notes}}<p class="muted">no entries logged.</p>{{end}}
<ul class="note-list">
{{range .Notes}}
<li class="note">
<div class="row spread">
<span class="muted">[{{dateFmt .CreatedAt}}]</span>
<form method="post" action="/notes/{{.ID}}/delete">
<button type="submit" class="btn ghost" title="delete"></button>
</form>
</div>
<p class="note-body">{{.Body}}</p>
</li>
{{end}}
</ul>
</section>
{{template "footer" .}}
+27
View File
@@ -0,0 +1,27 @@
{{template "header" .}}
<section class="panel">
<h1 class="glow">// search</h1>
<form action="/search" method="get" class="inline-form big-search">
<input type="search" name="q" value="{{.Query}}" placeholder="grep the grid…" autofocus autocomplete="off">
<button type="submit" class="btn primary">run ▸</button>
</form>
</section>
{{if .Query}}
<section class="panel">
<h2 class="muted">{{len .Hits}} hit(s) for “{{.Query}}”</h2>
{{if not .Hits}}<p class="muted">nothing found in the grid.</p>{{end}}
<ul class="hit-list">
{{range .Hits}}
<li class="hit">
<span class="badge {{.Kind}}">{{.Kind}}</span>
<a href="/projects/{{.ProjectID}}" class="hit-title">{{.Title}}</a>
<p class="muted snippet">{{.Snippet}}</p>
</li>
{{end}}
</ul>
</section>
{{end}}
{{template "footer" .}}
+127
View File
@@ -0,0 +1,127 @@
package store
import (
"database/sql"
"errors"
"strings"
)
var ErrConflict = errors.New("already exists")
type User struct {
ID int64
Username string
PasswordHash string
CreatedAt string
}
type APIKey struct {
ID int64 `json:"id"`
Name string `json:"name"`
KeyHash string `json:"-"`
CreatedAt string `json:"created_at"`
LastUsed sql.NullString `json:"-"`
}
// --- users ---
func (s *Store) CreateUser(username, passwordHash string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, passwordHash)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return 0, ErrConflict
}
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UserByName(username string) (User, error) {
var u User
err := s.db.QueryRow(`SELECT id, username, password_hash, created_at FROM users WHERE username = ?`, username).
Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
return u, err
}
// --- api keys ---
func (s *Store) CreateAPIKey(name, keyHash string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO api_keys (name, key_hash) VALUES (?, ?)`, name, keyHash)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return 0, ErrConflict
}
return 0, err
}
return res.LastInsertId()
}
func (s *Store) APIKeys() ([]APIKey, error) {
rows, err := s.db.Query(`SELECT id, name, key_hash, created_at, last_used FROM api_keys ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []APIKey
for rows.Next() {
var k APIKey
if err := rows.Scan(&k.ID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsed); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
func (s *Store) KeyByHash(keyHash string) (APIKey, error) {
var k APIKey
err := s.db.QueryRow(`SELECT id, name, key_hash, created_at, last_used FROM api_keys WHERE key_hash = ?`, keyHash).
Scan(&k.ID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsed)
if errors.Is(err, sql.ErrNoRows) {
return k, ErrNotFound
}
return k, err
}
func (s *Store) TouchKey(id int64) {
s.db.Exec(`UPDATE api_keys SET last_used = datetime('now','localtime') WHERE id = ?`, id)
}
func (s *Store) DeleteAPIKey(id int64) error {
res, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ?`, id)
if err != nil {
return err
}
return checkAffected(res)
}
// --- sessions ---
func (s *Store) CreateSession(tokenHash, username string, expiresAt string) error {
_, err := s.db.Exec(`INSERT INTO sessions (token_hash, username, expires_at) VALUES (?, ?, ?)`, tokenHash, username, expiresAt)
return err
}
func (s *Store) SessionByTokenHash(tokenHash string) (string, error) {
var username string
err := s.db.QueryRow(
`SELECT username FROM sessions WHERE token_hash = ? AND expires_at > datetime('now','localtime')`,
tokenHash,
).Scan(&username)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return username, err
}
func (s *Store) DeleteSession(tokenHash string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return err
}
func (s *Store) DeleteExpiredSessions() {
s.db.Exec(`DELETE FROM sessions WHERE expires_at <= datetime('now','localtime')`)
}
+444
View File
@@ -0,0 +1,444 @@
package store
import (
"database/sql"
"errors"
_ "modernc.org/sqlite"
)
var (
ErrNotFound = errors.New("not found")
)
type Project struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
}
type Note struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
}
type Task struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
type Hit struct {
Kind string `json:"kind"`
ProjectID int64 `json:"project_id"`
RefID int64 `json:"ref_id"`
Title string `json:"title"`
Snippet string `json:"snippet"`
}
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','done')),
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_notes_project ON notes(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
last_used TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
expires_at TEXT NOT NULL
);
`)
return err
}
// --- projects ---
func (s *Store) Projects() ([]Project, error) {
rows, err := s.db.Query(`SELECT id, name, description, created_at FROM projects ORDER BY name COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProjects(rows)
}
func (s *Store) Project(id int64) (Project, error) {
var p Project
err := s.db.QueryRow(`SELECT id, name, description, created_at FROM projects WHERE id = ?`, id).
Scan(&p.ID, &p.Name, &p.Description, &p.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return p, ErrNotFound
}
return p, err
}
func (s *Store) CreateProject(name, description string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO projects (name, description) VALUES (?, ?)`, name, description)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdateProject(id int64, name, description string) error {
res, err := s.db.Exec(`UPDATE projects SET name = ?, description = ? WHERE id = ?`, name, description, id)
if err != nil {
return err
}
return checkAffected(res)
}
func (s *Store) DeleteProject(id int64) error {
res, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return err
}
return checkAffected(res)
}
// --- notes ---
func (s *Store) Notes(projectID int64) ([]Note, error) {
rows, err := s.db.Query(`SELECT id, project_id, body, created_at FROM notes WHERE project_id = ? ORDER BY id DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var notes []Note
for rows.Next() {
var n Note
if err := rows.Scan(&n.ID, &n.ProjectID, &n.Body, &n.CreatedAt); err != nil {
return nil, err
}
notes = append(notes, n)
}
return notes, rows.Err()
}
func (s *Store) Note(id int64) (Note, error) {
var n Note
err := s.db.QueryRow(`SELECT id, project_id, body, created_at FROM notes WHERE id = ?`, id).
Scan(&n.ID, &n.ProjectID, &n.Body, &n.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return n, ErrNotFound
}
return n, err
}
func (s *Store) CreateNote(projectID int64, body string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO notes (project_id, body) VALUES (?, ?)`, projectID, body)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) DeleteNote(id int64) error {
res, err := s.db.Exec(`DELETE FROM notes WHERE id = ?`, id)
if err != nil {
return err
}
return checkAffected(res)
}
func (s *Store) UpdateNote(id int64, body string) error {
res, err := s.db.Exec(`UPDATE notes SET body = ? WHERE id = ?`, body, id)
if err != nil {
return err
}
return checkAffected(res)
}
// --- tasks ---
func (s *Store) Tasks(projectID int64) ([]Task, error) {
rows, err := s.db.Query(`SELECT id, project_id, title, status, created_at FROM tasks WHERE project_id = ? ORDER BY id`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.ProjectID, &t.Title, &t.Status, &t.CreatedAt); err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err()
}
func (s *Store) Task(id int64) (Task, error) {
var t Task
err := s.db.QueryRow(`SELECT id, project_id, title, status, created_at FROM tasks WHERE id = ?`, id).
Scan(&t.ID, &t.ProjectID, &t.Title, &t.Status, &t.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return t, ErrNotFound
}
return t, err
}
func (s *Store) CreateTask(projectID int64, title string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO tasks (project_id, title) VALUES (?, ?)`, projectID, title)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// CycleTask advances status todo -> doing -> done -> todo.
func (s *Store) CycleTask(id int64) error {
_, err := s.db.Exec(`UPDATE tasks SET status = CASE status WHEN 'todo' THEN 'doing' WHEN 'doing' THEN 'done' ELSE 'todo' END WHERE id = ?`, id)
return err
}
func (s *Store) SetTaskStatus(id int64, status string) error {
res, err := s.db.Exec(`UPDATE tasks SET status = ? WHERE id = ?`, status, id)
if err != nil {
return err
}
return checkAffected(res)
}
func (s *Store) UpdateTaskTitle(id int64, title string) error {
res, err := s.db.Exec(`UPDATE tasks SET title = ? WHERE id = ?`, title, id)
if err != nil {
return err
}
return checkAffected(res)
}
func (s *Store) DeleteTask(id int64) error {
res, err := s.db.Exec(`DELETE FROM tasks WHERE id = ?`, id)
if err != nil {
return err
}
return checkAffected(res)
}
// --- search ---
func (s *Store) Search(q string) ([]Hit, error) {
like := "%" + q + "%"
var hits []Hit
rows, err := s.db.Query(`SELECT id, name, description FROM projects WHERE name LIKE ?1 OR description LIKE ?1 ORDER BY name COLLATE NOCASE`, like)
if err != nil {
return nil, err
}
for rows.Next() {
var id int64
var name, desc string
if err := rows.Scan(&id, &name, &desc); err != nil {
rows.Close()
return nil, err
}
title, snip := name, desc
if snip == "" {
snip = name
}
hits = append(hits, Hit{Kind: "project", ProjectID: id, RefID: id, Title: title, Snippet: snip})
}
rows.Close()
rows, err = s.db.Query(`SELECT n.id, n.project_id, n.body, p.name FROM notes n JOIN projects p ON p.id = n.project_id WHERE n.body LIKE ? ORDER BY n.id DESC`, like)
if err != nil {
return nil, err
}
for rows.Next() {
var id, pid int64
var body, pname string
if err := rows.Scan(&id, &pid, &body, &pname); err != nil {
rows.Close()
return nil, err
}
hits = append(hits, Hit{Kind: "note", ProjectID: pid, RefID: id, Title: pname, Snippet: snippet(body, q)})
}
rows.Close()
rows, err = s.db.Query(`SELECT t.id, t.project_id, t.title, p.name FROM tasks t JOIN projects p ON p.id = t.project_id WHERE t.title LIKE ? ORDER BY t.id DESC`, like)
if err != nil {
return nil, err
}
for rows.Next() {
var id, pid int64
var title, pname string
if err := rows.Scan(&id, &pid, &title, &pname); err != nil {
rows.Close()
return nil, err
}
hits = append(hits, Hit{Kind: "task", ProjectID: pid, RefID: id, Title: title, Snippet: pname})
}
rows.Close()
return hits, nil
}
// --- stats ---
type Stats struct {
Projects int
Tasks struct {
Todo, Doing, Done int
}
Notes int
}
func (s *Store) Stats() (Stats, error) {
var st Stats
if err := s.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&st.Projects); err != nil {
return st, err
}
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes`).Scan(&st.Notes); err != nil {
return st, err
}
rows, err := s.db.Query(`SELECT status, COUNT(*) FROM tasks GROUP BY status`)
if err != nil {
return st, err
}
defer rows.Close()
for rows.Next() {
var status string
var n int
if err := rows.Scan(&status, &n); err != nil {
return st, err
}
switch status {
case "todo":
st.Tasks.Todo = n
case "doing":
st.Tasks.Doing = n
case "done":
st.Tasks.Done = n
}
}
return st, rows.Err()
}
// --- helpers ---
func scanProjects(rows *sql.Rows) ([]Project, error) {
var projects []Project
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.CreatedAt); err != nil {
return nil, err
}
projects = append(projects, p)
}
return projects, rows.Err()
}
func checkAffected(res sql.Result) error {
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// snippet returns body trimmed to a window around the first match of q.
func snippet(body, q string) string {
lb, lq := lower(body), lower(q)
i := index(lb, lq)
if i < 0 {
if len(body) > 160 {
return body[:160] + "…"
}
return body
}
start := i - 40
if start < 0 {
start = 0
}
end := i + len(q) + 80
if end > len(body) {
end = len(body)
}
out := body[start:end]
if start > 0 {
out = "…" + out
}
if end < len(body) {
out += "…"
}
return out
}
// ASCII-only helpers to avoid pulling unicode/strings machinery into hot path readability.
func lower(s string) string {
b := []byte(s)
for i := range b {
if b[i] >= 'A' && b[i] <= 'Z' {
b[i] += 'a' - 'A'
}
}
return string(b)
}
func index(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"golang.org/x/crypto/bcrypt"
"golang.org/x/term"
"tracker/internal/server"
"tracker/internal/store"
)
func main() {
defaultDB := filepath.Join(os.Getenv("HOME"), ".local", "share", "tracker", "tracker.db")
dbPath := flag.String("db", defaultDB, "sqlite database path")
addr := flag.String("addr", ":8420", "listen address")
addUser := flag.String("adduser", "", "create a login user, then exit")
flag.Parse()
if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil {
log.Fatal(err)
}
if *addUser != "" {
if err := runAddUser(*dbPath, *addUser); err != nil {
log.Fatal(err)
}
return
}
st, err := store.Open(*dbPath)
if err != nil {
log.Fatal(err)
}
defer st.Close()
srv, err := server.New(st)
if err != nil {
log.Fatal(err)
}
log.Printf("tracker online :: listening on %s", *addr)
if err := http.ListenAndServe(*addr, srv.Routes()); err != nil {
log.Fatal(err)
}
}
func runAddUser(dbPath, username string) error {
username = strings.TrimSpace(username)
if username == "" {
return errors.New("username cannot be empty")
}
if strings.ContainsAny(username, " \t") {
return errors.New("username cannot contain whitespace")
}
password := os.Getenv("TRACKER_PASSWORD")
confirm := password
if password == "" {
fmt.Print("passphrase: ")
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return err
}
fmt.Print("confirm: ")
confirmPw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return err
}
password, confirm = string(pw), string(confirmPw)
}
if len(password) < 8 {
return errors.New("passphrase must be at least 8 characters")
}
if password != confirm {
return errors.New("passphrases do not match")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
st, err := store.Open(dbPath)
if err != nil {
return err
}
defer st.Close()
if _, err := st.CreateUser(username, string(hash)); err != nil {
if errors.Is(err, store.ErrConflict) {
return fmt.Errorf("user %q already exists", username)
}
return err
}
fmt.Printf("user %q created. log in at /login\n", username)
return nil
}
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=project tracker (hackerman edition)
After=network.target
[Service]
ExecStart=%h/Projects/tracker/tracker -addr :8420
Restart=on-failure
[Install]
WantedBy=default.target