Files
verdande/internal/httpapi/views_handlers.go
T
Kristian WindandClaude Opus 5 dff86dd23d feat(api): projects, sections, tasks, quick add, views and live sync
Completes the MVP surface: project and section CRUD, task CRUD with
sub-trees, drag-and-drop ordering, the quick-add endpoint over the
natural-language parser, Today and Upcoming, search, sharing with roles,
an activity log and a WebSocket for live updates.

Permission checks are route middleware rather than a first line in each
handler, so an endpoint is guarded by where it sits instead of by the
author remembering. Anything the caller may not see answers 404, never
403 — a 403 confirms the thing exists. Visibility is enforced inside the
list query, so another user's project cannot be reached by filtering or
searching at it.

Ordering is a float, so a drop writes one row instead of renumbering the
list. When repeated drops into one gap exhaust the precision, the
section is spread out again and the move retried — in a separate
transaction, since the attempt that discovered the problem rolls back.

The connection pool is not 1: a single connection makes the pool a lock,
and a query run while an earlier result set is still open then waits for
a connection it is itself holding. The store now closes its rows before
querying again, and the pool leaves that mistake slow rather than fatal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:44:55 +02:00

195 lines
5.5 KiB
Go

package httpapi
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
// The date views are computed against the *user's* timezone, not the server's.
// "Today" is a question about where the person is standing, and a container running
// in UTC would otherwise call it tomorrow for a Dane from 22:00 onwards — quietly
// emptying the Today view every evening.
type todayResponse struct {
Date string `json:"date"`
Overdue []taskJSON `json:"overdue"`
Today []taskJSON `json:"today"`
}
func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
// Everything due on or before today, in one query, split in Go. Two queries
// would be two round trips to answer one screen.
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
DueBefore: today,
Limit: 500,
})
if err != nil {
s.internal(w, "today", err)
return
}
resp := todayResponse{Date: today, Overdue: []taskJSON{}, Today: []taskJSON{}}
for _, t := range tasks {
if t.DueDate < today {
resp.Overdue = append(resp.Overdue, toTaskJSON(t))
} else {
resp.Today = append(resp.Today, toTaskJSON(t))
}
}
writeJSON(w, http.StatusOK, resp)
}
type upcomingDay struct {
Date string `json:"date"`
Tasks []taskJSON `json:"tasks"`
}
// handleUpcoming returns the next seven days, one entry per day.
//
// Empty days are included on purpose: the view is a calendar strip, and a Thursday
// missing from the response because nothing is due would collapse the layout and
// make an empty day indistinguishable from one that was not returned.
func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
loc := userLocation(user.Timezone)
start := time.Now().In(loc)
days := parseLimit(r.URL.Query().Get("days"), 7, 31)
from := start.Format("2006-01-02")
to := start.AddDate(0, 0, days-1).Format("2006-01-02")
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
DueFrom: from,
DueBefore: to,
Limit: 1000,
})
if err != nil {
s.internal(w, "upcoming", err)
return
}
byDate := map[string][]taskJSON{}
for _, t := range tasks {
byDate[t.DueDate] = append(byDate[t.DueDate], toTaskJSON(t))
}
out := make([]upcomingDay, 0, days)
for i := 0; i < days; i++ {
date := start.AddDate(0, 0, i).Format("2006-01-02")
day := upcomingDay{Date: date, Tasks: byDate[date]}
if day.Tasks == nil {
day.Tasks = []taskJSON{}
}
out = append(out, day)
}
writeJSON(w, http.StatusOK, map[string]any{"days": out})
}
// handleSearch is what Cmd+K calls. It searches across every project the user can
// see, which is the point — the thing you are looking for is usually somewhere you
// were not looking.
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
query := r.URL.Query().Get("q")
if query == "" {
writeJSON(w, http.StatusOK, map[string]any{"tasks": []taskJSON{}, "projects": []projectJSON{}})
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
Search: query,
// Completed tasks are included: "what was that thing I did last week" is
// a search, and excluding them makes the box feel broken.
IncludeCompleted: true,
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
})
if err != nil {
s.internal(w, "search tasks", err)
return
}
// Projects are matched in Go: there are tens of them, not thousands, and a
// second FTS index for that would be more machinery than the problem needs.
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
if err != nil {
s.internal(w, "search projects", err)
return
}
matched := []projectJSON{}
folded := store.FoldDanish(lower(query))
for _, p := range projects {
name := lower(p.Name)
if contains(name, lower(query)) || contains(store.FoldDanish(name), folded) {
matched = append(matched, toProjectJSON(p))
}
}
writeJSON(w, http.StatusOK, map[string]any{
"tasks": toTaskList(tasks),
"projects": matched,
})
}
func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) {
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
parseLimit(r.URL.Query().Get("limit"), 50, 200))
if err != nil {
s.internal(w, "list activity", err)
return
}
type activityJSON struct {
ID string `json:"id"`
TaskID string `json:"task_id,omitempty"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Event string `json:"event"`
Payload map[string]any `json:"payload,omitempty"`
CreatedAt string `json:"created_at"`
}
out := make([]activityJSON, 0, len(entries))
for _, a := range entries {
out = append(out, activityJSON{
ID: a.ID, TaskID: a.TaskID, UserID: a.UserID, UserName: a.UserName,
Event: a.Event, Payload: a.Payload,
CreatedAt: a.CreatedAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"activity": out})
}
func lower(s string) string {
out := []rune(s)
for i, r := range out {
if r >= 'A' && r <= 'Z' {
out[i] = r + 32
} else if r == 'Æ' {
out[i] = 'æ'
} else if r == 'Ø' {
out[i] = 'ø'
} else if r == 'Å' {
out[i] = 'å'
}
}
return string(out)
}
func contains(haystack, needle string) bool {
if needle == "" {
return true
}
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}