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>
195 lines
5.5 KiB
Go
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
|
|
}
|