Files
projax/web/links.go
mAi 7ed0a4d46c feat(phase 4a): chronological timeline at /timeline + dashboard VTODO edit/delete
/timeline braids every dated thing in projax into a single chronological spine:
CalDAV VTODOs (DUE anchor), VEVENTs (DTSTART), dated item_links (event_date),
and item-creation markers. Default window past-30d to future-90d; ?order=
toggles asc/desc; ?kind= narrows by row type; tree filter (?tag/?mgmt/?has)
applies across kinds. Today / Tomorrow get sticky pills; rows > today+30d
fade. 90s in-memory TTL cache keyed by (filter, window, order, kinds);
busted on any VTODO writeback or dated-link change.

Scope expansion (per head message during 4a): the dashboard Tasks card now
has edit + delete affordances on every row, matching the detail page. New
/dashboard/task/{edit,delete} endpoints share a writeback path with /done.
Timeline VTODO rows reuse the same handlers; HX-Target=timeline-section
selects the re-render surface. Timeline item_link rows reuse the existing
/i/{path}/links/remove handler with the same surface-switch.

VEVENT rows on the timeline remain read-only at v1 (3l decision stands).
Item-creation events render as muted "added X to projax" markers.

Tests cover empty state, dated-doc surfacing, kind-filter narrowing, order
toggle, mixed CalDAV todos + all-day events (with the (2 days) duration
hint), and tag-filter cross-kind. New dashboard test asserts the edit/
delete affordances are wired up.

docs/design.md gains §12 with the full source list, layout rules, time
window, filter integration, cache TTL, and deferred items.
2026-05-16 15:52:32 +02:00

141 lines
4.2 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/m/projax/store"
)
// handleLinksAdd processes POST /i/{path}/links/add. Accepts ref_type, ref_id,
// note, event_date (YYYY-MM-DD). Anti-forgery isn't a concern at v1 since the
// trust model is Tailscale-only + cookie auth.
func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
refType := strings.TrimSpace(r.FormValue("ref_type"))
refID := strings.TrimSpace(r.FormValue("ref_id"))
noteVal := strings.TrimSpace(r.FormValue("note"))
dateStr := strings.TrimSpace(r.FormValue("event_date"))
banner := ""
if refType == "" || refID == "" {
banner = "ref_type and ref_id are required."
}
var date *time.Time
if banner == "" && dateStr != "" {
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
banner = "event_date must be YYYY-MM-DD."
} else {
date = &t
}
}
if banner == "" {
var notePtr *string
if noteVal != "" {
notePtr = &noteVal
}
if _, err := s.Store.AddLinkDated(r.Context(), it.ID, refType, refID, "", notePtr, date, nil); err != nil {
banner = fmt.Sprintf("Could not add link: %v", err)
}
}
// New dated link → bust the timeline cache so the row surfaces on next view.
s.timeline.invalidateAll()
s.renderDocumentsSection(w, r, it, nil, banner)
}
// handleLinksRemove processes POST /i/{path}/links/remove.
func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path string) {
it, err := s.Store.GetByPath(r.Context(), path)
if err != nil {
s.fail(w, r, err)
return
}
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
linkID := strings.TrimSpace(r.FormValue("link_id"))
banner := ""
if linkID == "" {
banner = "link_id required"
} else {
// Belt-and-braces: ensure the link belongs to this item before
// deleting, so a crafted form can't snipe an unrelated row.
owns, err := s.linkBelongsToItem(r.Context(), linkID, it.ID)
if err != nil {
s.fail(w, r, err)
return
}
if !owns {
banner = "Link does not belong to this item."
} else if err := s.Store.DeleteLink(r.Context(), linkID); err != nil {
banner = fmt.Sprintf("Could not remove link: %v", err)
}
}
// Bust the dashboard + timeline caches: a removed dated link should
// disappear from both surfaces on next render.
s.dashboard.invalidateAll()
s.timeline.invalidateAll()
// When the delete came from the timeline (HX-Target = timeline-section),
// re-render the timeline so the row vanishes in place instead of trying to
// swap a Documents fragment into it.
if r.Header.Get("HX-Target") == "timeline-section" {
s.handleTimeline(w, r)
return
}
s.renderDocumentsSection(w, r, it, nil, banner)
}
// renderDocumentsSection re-pulls dated links, computes PERs, and renders the
// Documents fragment for HTMX swaps. Non-HTMX requests fall back to a full
// detail-page redirect.
func (s *Server) renderDocumentsSection(w http.ResponseWriter, r *http.Request, it *store.Item, highlight *time.Time, banner string) {
if r.Header.Get("HX-Request") != "true" {
http.Redirect(w, r, "/i/"+it.PrimaryPath()+"#documents-section", http.StatusSeeOther)
return
}
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
if err != nil {
s.fail(w, r, err)
return
}
documents := computePERs(it.PrimaryPath(), docs)
s.render(w, "documents_section", map[string]any{
"Item": it,
"Documents": documents,
"HighlightDate": highlight,
"DocBanner": banner,
})
}
// linkBelongsToItem returns true when the link's item_id equals the supplied
// item id. Used as an anti-forgery check before delete.
func (s *Server) linkBelongsToItem(ctx context.Context, linkID, itemID string) (bool, error) {
var owner string
err := s.Store.Pool.QueryRow(ctx,
`select item_id from projax.item_links where id = $1`, linkID).Scan(&owner)
if err != nil {
if isNoRows(err) {
return false, nil
}
return false, err
}
return owner == itemID, nil
}
func isNoRows(err error) bool {
return err != nil && (errors.Is(err, store.ErrNotFound) || err.Error() == "no rows in result set")
}