Files
verdande/internal/httpapi/views_handlers.go
T
Kristian WindandClaude Opus 5 6b6b838167 feat(views): "Venter på andre", and a project you share stays yours
`assignee_id` has been on tasks since sharing was built and delegating has
been possible from the drawer since yesterday. What was missing was
anywhere to see it: work handed to somebody else left your lists and
appeared in nothing.

GET /delegated returns everything assigned to somebody who is not you, in
the projects you can see, grouped by the person. Grouped on the server
rather than flat, because the question is about people — "what is Anders
sitting on" — and a client cannot group it itself: it would need the name
behind an assignee id from a project it does not happen to have open, and
there is no way to ask for that. The names come back with the tasks, so
one request is one screen.

The filter is a field of its own rather than a sentinel in AssigneeID.
"What is on my plate" and "what have I handed over" are different
questions, and one string that means an id most of the time and a magic
word the rest is a field somebody eventually passes a real id to.

An assignee whose account is gone is named "Ukendt" rather than dropped.
Losing the row would make work disappear from the one view that exists to
stop work disappearing.

The sidebar entry only appears where delegating is possible at all. On an
instance with one person it could never have anything in it, and a
permanent empty entry is a question the sidebar keeps asking and answering
"no".

Building it turned up a bug in the split above it. "Projekter" and "Delt
med mig" were divided on `shared`, which only means the project has more
than one member — so inviting somebody to a project of your own moved it
under "Delt med mig", where it lost its place in the order, stopped being
draggable, and could not be filed under a group. It is divided on
ownership now, which is what the two headings have always said. Sharing
your own project is not the same event as somebody sharing theirs with
you.

The smoke test now follows the whole loop in one go: invite, accept in a
second browser, assign a task to the person who just joined, and find it
under their name — with the shared project still under "Projekter" on the
way past.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:36:37 +02:00

268 lines
7.9 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})
}
type delegatedPerson struct {
UserID string `json:"user_id"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
Tasks []taskJSON `json:"tasks"`
}
// handleDelegated is "waiting on others": everything assigned to somebody who is
// not the caller, in the projects the caller can see.
//
// Grouped by person rather than returned flat, because the question this view
// answers is about people — "what is Anders sitting on" — and a client grouping it
// itself would need every assignee's name, which it has no way to ask for. The
// names come back with the tasks, so one request is one screen.
//
// People are ordered by name, and their tasks by whatever the list query decided,
// which puts the overdue and the soonest first.
func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
DelegatedBy: user.ID,
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
})
if err != nil {
s.internal(w, "delegated", err)
return
}
byPerson := map[string][]taskJSON{}
ids := []string{}
for _, t := range tasks {
if _, seen := byPerson[t.AssigneeID]; !seen {
ids = append(ids, t.AssigneeID)
}
byPerson[t.AssigneeID] = append(byPerson[t.AssigneeID], toTaskJSON(t))
}
people, err := s.db.PeopleByIDs(r.Context(), ids)
if err != nil {
s.internal(w, "delegated people", err)
return
}
out := make([]delegatedPerson, 0, len(ids))
for _, id := range ids {
// A person who no longer has an account still has their id on the task.
// Naming them "Ukendt" beats dropping the tasks, which would make work
// disappear from the one view that exists to stop that happening.
p, ok := people[id]
if !ok {
p = store.Person{ID: id, Name: "Ukendt", AvatarColor: "#8a8f98"}
}
out = append(out, delegatedPerson{
UserID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor, Tasks: byPerson[id],
})
}
sortByName(out)
writeJSON(w, http.StatusOK, map[string]any{"people": out})
}
// sortByName orders the groups the way a list of people reads. Insertion sort:
// this is a handful of entries, and it keeps the package free of a dependency on
// sort just for this.
func sortByName(people []delegatedPerson) {
for i := 1; i < len(people); i++ {
for j := i; j > 0 && lower(people[j].Name) < lower(people[j-1].Name); j-- {
people[j], people[j-1] = people[j-1], people[j]
}
}
}
// 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
}