- gitea pkg: CloseIssue, ReopenIssue, CreateIssue, AddComment + ErrForbidden
classification on 401/403. Client.do sets Content-Type on non-empty bodies.
- web handler: POST /i/{path}/issues/{close|reopen|comment|create}
- authorisation guard: repo form value must match a gitea-repo item_link
on the target item (rejects form-crafted writes to unrelated repos)
- HTMX re-renders issues_section partial after each action
- busts gitea per-repo cache (open + closed-recent) and dashboard 60s TTL
- templates: ✓ close button + reopen + collapsible comment box on every
issue row; "+ new issue" disclosure per repo
- design.md §6 retitled "Phase 2.d read; 3h writeback" with auth/perm
semantics + parked list
- 5 unit tests in gitea/, 5 integration tests in web/ covering happy paths
+ 403 → inline banner fallback
709 lines
19 KiB
Go
709 lines
19 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/m/projax/caldav"
|
|
"github.com/m/projax/gitea"
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
// dashboardCache holds the aggregated dashboard payload for up to TTL. Per
|
|
// design.md §9 every cache entry is keyed by the encoded TreeFilter (so
|
|
// `?tag=work` cache is independent of unfiltered), and the TTL is 60s.
|
|
type dashboardCache struct {
|
|
ttl time.Duration
|
|
mu sync.Mutex
|
|
rows map[string]cachedDashboard
|
|
}
|
|
|
|
type cachedDashboard struct {
|
|
at time.Time
|
|
payload *dashboardPayload
|
|
}
|
|
|
|
func newDashboardCache(ttl time.Duration) *dashboardCache {
|
|
return &dashboardCache{ttl: ttl, rows: map[string]cachedDashboard{}}
|
|
}
|
|
|
|
func (c *dashboardCache) get(key string) (*dashboardPayload, bool) {
|
|
if c == nil {
|
|
return nil, false
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
v, ok := c.rows[key]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if time.Since(v.at) > c.ttl {
|
|
delete(c.rows, key)
|
|
return nil, false
|
|
}
|
|
return v.payload, true
|
|
}
|
|
|
|
func (c *dashboardCache) invalidate(key string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.rows, key)
|
|
}
|
|
|
|
// invalidateAll wipes every cached payload. Used by writeback paths (Gitea
|
|
// close/comment/create, CalDAV completion) that can change content under any
|
|
// filter.
|
|
func (c *dashboardCache) invalidateAll() {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.rows = map[string]cachedDashboard{}
|
|
}
|
|
|
|
func (c *dashboardCache) set(key string, p *dashboardPayload) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.rows[key] = cachedDashboard{at: time.Now(), payload: p}
|
|
}
|
|
|
|
// dashboardPayload is the full aggregated view rendered into the page. Each
|
|
// slice is already sorted and capped at the per-card limit.
|
|
type dashboardPayload struct {
|
|
Tasks []dashboardTask
|
|
TaskGroups dashboardTaskGroups
|
|
TaskTotal int
|
|
|
|
Issues []dashboardIssue
|
|
IssueTotal int
|
|
|
|
RecentDocs []dashboardDoc
|
|
RecentDocsTotal int
|
|
|
|
Stale []dashboardStale
|
|
StaleTotal int
|
|
|
|
BuiltAt time.Time
|
|
Cached bool
|
|
}
|
|
|
|
// dashboardTask is one open VTODO from one linked calendar, with the project
|
|
// it belongs to already resolved for the row link.
|
|
type dashboardTask struct {
|
|
Item *store.Item
|
|
CalendarURL string
|
|
Todo caldav.Todo
|
|
DueRel string // "today" / "tomorrow" / "in 3d" / "overdue 2d" / ""
|
|
Bucket string // today | tomorrow | week | overdue | no-due
|
|
}
|
|
|
|
// dashboardTaskGroups holds per-bucket counts for the section header.
|
|
type dashboardTaskGroups struct {
|
|
Today int
|
|
Tomorrow int
|
|
Week int
|
|
Overdue int
|
|
NoDue int
|
|
}
|
|
|
|
type dashboardIssue struct {
|
|
Item *store.Item
|
|
Repo string
|
|
Issue gitea.Issue
|
|
UpdRel string
|
|
}
|
|
|
|
type dashboardDoc struct {
|
|
Item *store.Item
|
|
Link store.ItemLink
|
|
PER string
|
|
ItemPath string
|
|
}
|
|
|
|
// dashboardStale is one mai-managed item whose linked repo is quiet, has no
|
|
// open issues, and whose linked CalDAV lists hold no open VTODOs. The
|
|
// "consider archiving?" candidate.
|
|
type dashboardStale struct {
|
|
Item *store.Item
|
|
Repo string // owner/repo of the quiet linked repo (first one wins)
|
|
LastActive time.Time
|
|
StaleDays int // floor(days since LastActive)
|
|
StaleRel string // "62d", "120d", "no recent activity"
|
|
}
|
|
|
|
// handleDashboard renders the cross-project landing page. Filters reuse the
|
|
// tree-page TreeFilter; the per-card aggregation runs sequentially with a
|
|
// small worker pool to avoid hammering DAV / Gitea.
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
filter := ParseTreeFilter(r.URL.Query())
|
|
// Dashboard treats status=active as the meaningful default — same as tree.
|
|
cacheKey := filter.QueryString()
|
|
if cacheKey == "" {
|
|
cacheKey = "__empty__"
|
|
}
|
|
|
|
// ?refresh=1 busts this filter's cache entry so the next aggregation
|
|
// runs fresh — used by the ↻ button on the dashboard chrome.
|
|
if r.URL.Query().Get("refresh") == "1" {
|
|
s.dashboard.invalidate(cacheKey)
|
|
}
|
|
|
|
payload, hit := s.dashboard.get(cacheKey)
|
|
if !hit {
|
|
built, err := s.buildDashboard(r.Context(), filter)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
s.dashboard.set(cacheKey, built)
|
|
payload = built
|
|
}
|
|
displayPayload := *payload
|
|
displayPayload.Cached = hit
|
|
// Updated-relative label: how long since the cached payload was built.
|
|
updatedRel := relativeTime(time.Now(), payload.BuiltAt)
|
|
|
|
// Refresh URL: clone the current query, drop ?refresh, prepend it back.
|
|
refreshURL := "/dashboard?refresh=1"
|
|
if cacheKey != "__empty__" {
|
|
refreshURL = "/dashboard?" + cacheKey + "&refresh=1"
|
|
}
|
|
|
|
data := map[string]any{
|
|
"Title": "dashboard",
|
|
"P": displayPayload,
|
|
"Filter": filter,
|
|
"UpdatedRel": updatedRel,
|
|
"RefreshURL": refreshURL,
|
|
"FilterActive": filter.Active(),
|
|
}
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
s.render(w, "dashboard_section", data)
|
|
return
|
|
}
|
|
s.render(w, "dashboard", data)
|
|
}
|
|
|
|
// buildDashboard does the actual aggregation work. Items are filtered first
|
|
// (by the same TreeFilter as /), then each linked calendar / repo / dated
|
|
// link is fanned out to a worker pool.
|
|
func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashboardPayload, error) {
|
|
items, err := s.Store.ListAll(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
linkKinds, err := s.linkKindsByItem(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Filter items by the same rules as the tree (direct match only, no
|
|
// branch-keep — dashboard cards never look at ancestors).
|
|
dashItems := []*store.Item{}
|
|
byID := map[string]*store.Item{}
|
|
for _, it := range items {
|
|
// Reuse TreeFilter.Matches with one tweak: when no filter is active we
|
|
// include every item regardless of status so a "done" project's
|
|
// recently-dated docs still surface.
|
|
if !filter.Active() || filter.Matches(it, linkKinds[it.ID]) {
|
|
dashItems = append(dashItems, it)
|
|
byID[it.ID] = it
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
p := &dashboardPayload{BuiltAt: now}
|
|
|
|
// --- Tasks card ---
|
|
if s.CalDAV != nil {
|
|
tasks, groups, total := s.collectTasks(ctx, dashItems, now)
|
|
p.Tasks = tasks
|
|
p.TaskGroups = groups
|
|
p.TaskTotal = total
|
|
}
|
|
|
|
// --- Issues card ---
|
|
if s.Gitea != nil {
|
|
issues, total := s.collectIssues(ctx, dashItems, now)
|
|
p.Issues = issues
|
|
p.IssueTotal = total
|
|
}
|
|
|
|
// --- Recent documents card ---
|
|
docs, total, err := s.collectRecentDocs(ctx, byID, dashItems, filter, now)
|
|
if err != nil {
|
|
s.Logger.Warn("dashboard docs", "err", err)
|
|
}
|
|
p.RecentDocs = docs
|
|
p.RecentDocsTotal = total
|
|
|
|
// --- Stale projects card ---
|
|
// "Stale" = mai-managed AND linked-repo quiet 60d+ AND 0 open tasks AND
|
|
// 0 open issues. Reuses what the task/issue cards already aggregated so
|
|
// we don't refetch CalDAV/Gitea per item.
|
|
openTasksByItem := map[string]int{}
|
|
for _, t := range p.Tasks {
|
|
openTasksByItem[t.Item.ID]++
|
|
}
|
|
openIssuesByItem := map[string]int{}
|
|
for _, i := range p.Issues {
|
|
openIssuesByItem[i.Item.ID]++
|
|
}
|
|
// Note: the 30-row cap on Tasks/Issues lists may hide entries for the
|
|
// count above. We deliberately use the trimmed view: an item that has so
|
|
// many open tasks/issues that it pushes past the 30 cap is clearly NOT
|
|
// stale, and the per-item count is only used as "is this zero?".
|
|
stale, staleTotal := s.collectStale(ctx, dashItems, openTasksByItem, openIssuesByItem, now)
|
|
p.Stale = stale
|
|
p.StaleTotal = staleTotal
|
|
|
|
return p, nil
|
|
}
|
|
|
|
// collectStale walks every mai-managed item whose only signals are quiet:
|
|
// no open tasks (in the aggregated map), no open issues (in the aggregated
|
|
// map), AND the linked Gitea repo's updated_at is older than 60d. Items
|
|
// with NO linked repo at all are skipped — we can't judge staleness without
|
|
// a signal. Returns at most 20 rows, longest-stale first.
|
|
func (s *Server) collectStale(ctx context.Context, items []*store.Item, openTasks, openIssues map[string]int, now time.Time) ([]dashboardStale, int) {
|
|
if s.Gitea == nil {
|
|
return nil, 0
|
|
}
|
|
const staleCutoffDays = 60
|
|
type job struct {
|
|
item *store.Item
|
|
link *store.ItemLink
|
|
}
|
|
jobs := []job{}
|
|
for _, it := range items {
|
|
if !it.HasManagement("mai") {
|
|
continue
|
|
}
|
|
if openTasks[it.ID] > 0 || openIssues[it.ID] > 0 {
|
|
continue
|
|
}
|
|
links, err := s.Store.LinksByType(ctx, it.ID, refTypeGiteaRepo)
|
|
if err != nil || len(links) == 0 {
|
|
continue
|
|
}
|
|
// First linked repo wins for the staleness probe — if an item has
|
|
// multiple linked repos and ANY is recent we treat the item as not
|
|
// stale, so the candidate-list pass is conservative on the "stale"
|
|
// side. Implemented by emitting jobs for every link + filtering on
|
|
// "every link is stale" in the result reduce.
|
|
for _, l := range links {
|
|
jobs = append(jobs, job{item: it, link: l})
|
|
}
|
|
}
|
|
if len(jobs) == 0 {
|
|
return nil, 0
|
|
}
|
|
|
|
type res struct {
|
|
itemID string
|
|
repo string
|
|
updated time.Time
|
|
err bool
|
|
}
|
|
results := make(chan res, len(jobs))
|
|
in := make(chan job, len(jobs))
|
|
const workers = 4
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := range in {
|
|
owner, repo := gitea.ParseRepoRef(j.link.RefID)
|
|
if owner == "" || repo == "" {
|
|
results <- res{itemID: j.item.ID, repo: j.link.RefID, err: true}
|
|
continue
|
|
}
|
|
r, err := s.Gitea.Client.GetRepo(ctx, owner, repo)
|
|
if err != nil {
|
|
s.Logger.Warn("dashboard stale get repo", "repo", j.link.RefID, "err", err)
|
|
results <- res{itemID: j.item.ID, repo: j.link.RefID, err: true}
|
|
continue
|
|
}
|
|
results <- res{itemID: j.item.ID, repo: j.link.RefID, updated: r.UpdatedAt}
|
|
}
|
|
}()
|
|
}
|
|
for _, j := range jobs {
|
|
in <- j
|
|
}
|
|
close(in)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
// Reduce by item: track the most-recent updated_at across the item's
|
|
// repos. Stale only if ALL probed repos are older than the cutoff.
|
|
type acc struct {
|
|
newest time.Time
|
|
repo string // newest-updated repo wins the display slot
|
|
anyErr bool
|
|
}
|
|
byItem := map[string]*acc{}
|
|
for r := range results {
|
|
a, ok := byItem[r.itemID]
|
|
if !ok {
|
|
a = &acc{}
|
|
byItem[r.itemID] = a
|
|
}
|
|
if r.err {
|
|
a.anyErr = true
|
|
continue
|
|
}
|
|
if r.updated.After(a.newest) {
|
|
a.newest = r.updated
|
|
a.repo = r.repo
|
|
}
|
|
}
|
|
|
|
byID := map[string]*store.Item{}
|
|
for _, it := range items {
|
|
byID[it.ID] = it
|
|
}
|
|
cutoff := now.AddDate(0, 0, -staleCutoffDays)
|
|
out := []dashboardStale{}
|
|
for id, a := range byItem {
|
|
if a.anyErr || a.newest.IsZero() {
|
|
continue
|
|
}
|
|
if a.newest.After(cutoff) {
|
|
continue
|
|
}
|
|
it := byID[id]
|
|
if it == nil {
|
|
continue
|
|
}
|
|
days := int(now.Sub(a.newest).Hours() / 24)
|
|
out = append(out, dashboardStale{
|
|
Item: it,
|
|
Repo: a.repo,
|
|
LastActive: a.newest,
|
|
StaleDays: days,
|
|
StaleRel: relDays(days),
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].StaleDays > out[j].StaleDays
|
|
})
|
|
total := len(out)
|
|
if len(out) > 20 {
|
|
out = out[:20]
|
|
}
|
|
return out, total
|
|
}
|
|
|
|
// collectTasks fans out across every (item, caldav-list link) pair using a
|
|
// small worker pool. Per-calendar errors are logged and skipped so one down
|
|
// calendar doesn't blank the whole card.
|
|
func (s *Server) collectTasks(ctx context.Context, items []*store.Item, now time.Time) ([]dashboardTask, dashboardTaskGroups, int) {
|
|
type job struct {
|
|
item *store.Item
|
|
link *store.ItemLink
|
|
}
|
|
jobs := []job{}
|
|
for _, it := range items {
|
|
links, err := s.Store.LinksByType(ctx, it.ID, refTypeCalDAV)
|
|
if err != nil {
|
|
s.Logger.Warn("dashboard caldav links", "item", it.PrimaryPath(), "err", err)
|
|
continue
|
|
}
|
|
for _, l := range links {
|
|
jobs = append(jobs, job{item: it, link: l})
|
|
}
|
|
}
|
|
type result struct {
|
|
item *store.Item
|
|
calendarURL string
|
|
todos []caldav.Todo
|
|
}
|
|
results := make(chan result, len(jobs))
|
|
in := make(chan job, len(jobs))
|
|
const workers = 4
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := range in {
|
|
todos, err := s.CalDAV.Client.ListTodos(ctx, j.link.RefID)
|
|
if err != nil {
|
|
s.Logger.Warn("dashboard list todos", "calendar", j.link.RefID, "err", err)
|
|
continue
|
|
}
|
|
open := []caldav.Todo{}
|
|
for _, t := range todos {
|
|
if t.Status == "COMPLETED" || t.Status == "CANCELLED" {
|
|
continue
|
|
}
|
|
open = append(open, t)
|
|
}
|
|
results <- result{item: j.item, calendarURL: j.link.RefID, todos: open}
|
|
}
|
|
}()
|
|
}
|
|
for _, j := range jobs {
|
|
in <- j
|
|
}
|
|
close(in)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
out := []dashboardTask{}
|
|
groups := dashboardTaskGroups{}
|
|
for r := range results {
|
|
for _, td := range r.todos {
|
|
dt := dashboardTask{Item: r.item, CalendarURL: r.calendarURL, Todo: td}
|
|
dt.Bucket, dt.DueRel = classifyDue(td.Due, now)
|
|
switch dt.Bucket {
|
|
case "overdue":
|
|
groups.Overdue++
|
|
case "today":
|
|
groups.Today++
|
|
case "tomorrow":
|
|
groups.Tomorrow++
|
|
case "week":
|
|
groups.Week++
|
|
default:
|
|
groups.NoDue++
|
|
}
|
|
out = append(out, dt)
|
|
}
|
|
}
|
|
// Sort: overdue first, then due asc, no-due last; ties by priority desc, summary asc.
|
|
sort.Slice(out, func(i, j int) bool {
|
|
a, b := out[i], out[j]
|
|
// Overdue precedes everything.
|
|
if (a.Bucket == "overdue") != (b.Bucket == "overdue") {
|
|
return a.Bucket == "overdue"
|
|
}
|
|
// No-due sinks below dated.
|
|
ad := a.Todo.Due != nil
|
|
bd := b.Todo.Due != nil
|
|
if ad != bd {
|
|
return ad
|
|
}
|
|
if ad && bd && !a.Todo.Due.Equal(*b.Todo.Due) {
|
|
return a.Todo.Due.Before(*b.Todo.Due)
|
|
}
|
|
if a.Todo.Priority != b.Todo.Priority {
|
|
return a.Todo.Priority > b.Todo.Priority
|
|
}
|
|
return a.Todo.Summary < b.Todo.Summary
|
|
})
|
|
total := len(out)
|
|
if len(out) > 30 {
|
|
out = out[:30]
|
|
}
|
|
return out, groups, total
|
|
}
|
|
|
|
// classifyDue buckets a VTODO by its DUE date relative to now.
|
|
// overdue: due strictly before today
|
|
// today: due == today
|
|
// tomorrow: due == today+1
|
|
// week: due in (today+2 ... today+7)
|
|
// no-due: no due at all
|
|
// Returns (bucket, relative-text).
|
|
func classifyDue(due *time.Time, now time.Time) (string, string) {
|
|
if due == nil {
|
|
return "no-due", ""
|
|
}
|
|
today := startOfDay(now)
|
|
dueDay := startOfDay(due.Local())
|
|
days := int(dueDay.Sub(today).Hours() / 24)
|
|
switch {
|
|
case days < 0:
|
|
return "overdue", "overdue " + relDays(-days)
|
|
case days == 0:
|
|
return "today", "today"
|
|
case days == 1:
|
|
return "tomorrow", "tomorrow"
|
|
case days <= 7:
|
|
return "week", "in " + relDays(days)
|
|
default:
|
|
return "later", dueDay.Format("2006-01-02")
|
|
}
|
|
}
|
|
|
|
func startOfDay(t time.Time) time.Time {
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
|
}
|
|
|
|
func relDays(n int) string { return strconv.Itoa(n) + "d" }
|
|
|
|
// collectIssues fans out across every (item, gitea-repo link) pair, reusing
|
|
// the Gitea client's existing TTL cache (set by NewGiteaDeps).
|
|
func (s *Server) collectIssues(ctx context.Context, items []*store.Item, now time.Time) ([]dashboardIssue, int) {
|
|
type job struct {
|
|
item *store.Item
|
|
link *store.ItemLink
|
|
}
|
|
jobs := []job{}
|
|
for _, it := range items {
|
|
links, err := s.Store.LinksByType(ctx, it.ID, refTypeGiteaRepo)
|
|
if err != nil {
|
|
s.Logger.Warn("dashboard gitea links", "item", it.PrimaryPath(), "err", err)
|
|
continue
|
|
}
|
|
for _, l := range links {
|
|
jobs = append(jobs, job{item: it, link: l})
|
|
}
|
|
}
|
|
type result struct {
|
|
item *store.Item
|
|
repo string
|
|
open []gitea.Issue
|
|
}
|
|
results := make(chan result, len(jobs))
|
|
in := make(chan job, len(jobs))
|
|
const workers = 4
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := range in {
|
|
owner, repo := gitea.ParseRepoRef(j.link.RefID)
|
|
if owner == "" || repo == "" {
|
|
continue
|
|
}
|
|
key := j.link.RefID + "|open"
|
|
open, ok := s.Gitea.Cache.get(key)
|
|
if !ok {
|
|
var err error
|
|
open, err = s.Gitea.Client.ListIssues(ctx, owner, repo, gitea.ListOpts{State: "open"})
|
|
if err != nil {
|
|
s.Logger.Warn("dashboard gitea list", "repo", j.link.RefID, "err", err)
|
|
continue
|
|
}
|
|
s.Gitea.Cache.set(key, open)
|
|
}
|
|
results <- result{item: j.item, repo: j.link.RefID, open: open}
|
|
}
|
|
}()
|
|
}
|
|
for _, j := range jobs {
|
|
in <- j
|
|
}
|
|
close(in)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
out := []dashboardIssue{}
|
|
for r := range results {
|
|
for _, iss := range r.open {
|
|
out = append(out, dashboardIssue{
|
|
Item: r.item,
|
|
Repo: r.repo,
|
|
Issue: iss,
|
|
UpdRel: relativeTime(now, iss.UpdatedAt),
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].Issue.UpdatedAt.After(out[j].Issue.UpdatedAt)
|
|
})
|
|
total := len(out)
|
|
if len(out) > 30 {
|
|
out = out[:30]
|
|
}
|
|
return out, total
|
|
}
|
|
|
|
// collectRecentDocs reads the last-30-days dated item_links and joins them to
|
|
// the filtered items. Items the filter dropped don't contribute docs.
|
|
func (s *Server) collectRecentDocs(ctx context.Context, byID map[string]*store.Item, _ []*store.Item, _ TreeFilter, now time.Time) ([]dashboardDoc, int, error) {
|
|
since := now.AddDate(0, 0, -30)
|
|
rows, err := s.Store.RecentDocuments(ctx, since, 200)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
out := []dashboardDoc{}
|
|
for _, r := range rows {
|
|
it := byID[r.Link.ItemID]
|
|
if it == nil {
|
|
continue
|
|
}
|
|
base := it.PrimaryPath()
|
|
per := base
|
|
if r.Link.EventDate != nil {
|
|
per = base + "." + formatPERDate(*r.Link.EventDate)
|
|
}
|
|
out = append(out, dashboardDoc{
|
|
Item: it,
|
|
Link: r.Link,
|
|
PER: per,
|
|
ItemPath: base,
|
|
})
|
|
}
|
|
total := len(out)
|
|
if len(out) > 30 {
|
|
out = out[:30]
|
|
}
|
|
return out, total, nil
|
|
}
|
|
|
|
// handleDashboardTaskDone is the inline ✓-checkbox handler on the Tasks card.
|
|
// It POSTs the calendar URL + UID, marks the VTODO COMPLETED via the same
|
|
// PutTodo path the detail page uses, then re-renders the dashboard section
|
|
// so the row disappears and the count decrements.
|
|
func (s *Server) handleDashboardTaskDone(w http.ResponseWriter, r *http.Request) {
|
|
if s.CalDAV == nil {
|
|
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
calURL := strings.TrimSpace(r.FormValue("calendar_url"))
|
|
uid := strings.TrimSpace(r.FormValue("uid"))
|
|
if calURL == "" || uid == "" {
|
|
http.Error(w, "calendar_url and uid required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
todos, err := s.CalDAV.Client.ListTodos(r.Context(), calURL)
|
|
if err != nil {
|
|
http.Error(w, "list todos: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
var current *caldav.Todo
|
|
for i := range todos {
|
|
if todos[i].UID == uid {
|
|
current = &todos[i]
|
|
break
|
|
}
|
|
}
|
|
if current == nil {
|
|
// Task already gone — drop cache + re-render so the row vanishes.
|
|
s.dashboard = newDashboardCache(s.dashboard.ttl)
|
|
s.handleDashboard(w, r)
|
|
return
|
|
}
|
|
st := "COMPLETED"
|
|
updated := caldav.ApplyVTodoEdit(current.Raw, caldav.VTodoEdit{Status: &st})
|
|
if _, err := s.CalDAV.Client.PutTodo(r.Context(), current.URL, updated, current.ETag, ""); err != nil {
|
|
http.Error(w, "complete: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
// Bust the dashboard cache so the row disappears on next render.
|
|
s.dashboard = newDashboardCache(s.dashboard.ttl)
|
|
s.handleDashboard(w, r)
|
|
}
|