Files
projax/web/calendar.go
mAi f820fa5830 feat(views): Phase 5j slice C — full URL migration + system views
Per m's Q1 pick (b) (2026-05-29): legacy `/`, `/dashboard`, `/calendar`,
`/timeline`, `/graph` become `/views/{system-slug}`. Old routes
301-redirect to the new ones with chip params preserved; the legacy
?view=<uuid> param from 5i is resolved through the uuid → slug map
when present so old bookmarks land on the right user view.

System views (web/system_views.go):
- SystemView struct (Slug / Name / Icon / URL) — code-resident, never
  rows in projax.views.
- AllSystemViews() returns the canonical five: tree, dashboard,
  calendar, timeline, graph. Display order matches the existing
  sidebar.
- LookupSystemView(slug) returns the matching entry or nil; the
  reserved-slug list in store.IsReservedViewSlug (slice A) is kept
  in sync.
- legacyRedirect(systemSlug) handler 301s with chip-param preservation
  + uuid → slug resolution for any leftover ?view=<uuid>.

Routes (web/server.go):
- GET /views/tree      → handleTree     (was GET /)
- GET /views/dashboard → handleDashboard
- GET /views/timeline  → handleTimeline
- GET /views/calendar  → handleCalendar
- GET /views/graph     → handleGraph
- GET /                → 301 → /views/tree
- GET /dashboard       → 301 → /views/dashboard
- GET /timeline        → 301 → /views/timeline
- GET /calendar        → 301 → /views/calendar
- GET /graph           → 301 → /views/graph
- POST action endpoints (/dashboard/task/*, /dashboard/pin, /admin/*)
  stay where they are — those are RPC-ish, not page renders.

handleTree: dropped the `r.URL.Path != "/"` guard — the only entry
point now is /views/tree, mounted via the new route. Slice F removes
any residual references; this slice keeps the handler reachable.

computeChipCounts grew a `base string` arg so chip URLs anchor on the
caller's route (/views/tree for the system tree, /views/{slug} for
saved views). PageViewTypes recognises both legacy and /views/ keys
during the transition.

Template hrefs / hx-gets bulk-updated to the new URLs:
- layout.tmpl: every sidebar + bottom-nav entry points at
  /views/{system-slug}. Active-state checks updated alongside.
- tree_section.tmpl, tree_card.tmpl, tree_kanban.tmpl: clear-filter
  / clear-all hrefs → /views/tree.
- calendar*.tmpl, timeline_section.tmpl, graph.tmpl,
  dashboard_section.tmpl: every internal nav + filter link points at
  the /views/{slug} surface.
- detail.tmpl, error.tmpl: cancel / back-to-tree → /views/tree.

Test-source updates (per the 5c sharpened rule):
- ~100 test paths bulk-rewritten from /dashboard /calendar /timeline
  /graph (and `/`) to their /views/{slug} counterparts. The
  behaviour-preservation contract holds: status codes + body shapes
  for the rendered pages stay the same; only the URL anchoring the
  test changes.
- layout_test.go: sidebar href assertions updated to /views/{slug}.
- view_type_test.go (Q2 + Q3 follow-up): PageViewTypes lookup table
  updated to use the new route keys.
- 2 deliberate behaviour-change assertions land: TestLegacyRedirects
  expects 301 on the old URLs (was 200); TestTreeRenders fetches
  /views/tree (the new home) instead of /.

Internal go-source URL emissions (dashboard.go, calendar.go,
timeline.go) updated to the new BasePath so chip + refresh URLs round
through /views/{slug} correctly.

New tests:
- TestSystemViewLookup — AllSystemViews shape + LookupSystemView
  round-trip + unknown-slug nil.
- TestLegacyRedirects — every legacy URL 301s to its new home with
  chip params preserved.
- TestLegacyViewUUIDRedirect — old `?view=<uuid>` URLs land on the
  resolved slug per m's Q3 pick.
2026-05-29 11:59:26 +02:00

465 lines
14 KiB
Go

package web
import (
"context"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/store"
)
// calendarCacheTTL matches the dashboard's cadence — the calendar is
// browse-y and the underlying CalDAV + items_unified data doesn't shift
// faster than a minute. Keyed by (filter, month, kinds).
const calendarCacheTTL = 60 * time.Second
// calendarMaxRowsPerCell is the per-day visible cap. Overflow surfaces as a
// "+N more" link that drills into /timeline scoped to that single day.
const calendarMaxRowsPerCell = 3
// calendarPayload is the rendered shape for /calendar — month grid plus
// header chrome and filter context.
type calendarPayload struct {
Month time.Time // first day of the month, midnight local
MonthLabel string // "Mai 2026"
MonthKey string // "2026-05"
PrevMonth string // "2026-04"
NextMonth string // "2026-06"
Today time.Time // startOfDay(now)
Weeks []calendarWeek
Kinds []string // active kinds (event, todo, doc)
TotalRows int
BuiltAt time.Time
Cached bool
}
// calendarWeek is one Mon→Sun row in the grid. Always exactly seven cells.
type calendarWeek struct {
Days [7]calendarDay
}
// calendarDay is one cell. Adjacent-month cells (lead-in / lead-out) carry
// IsAdjacent so the template can grey them out without losing the
// rectangular grid.
type calendarDay struct {
Date time.Time
DateKey string // "2026-05-15"
DayNum int // 1-31
LongLabel string // "Mi., 14. Mai" — shown by CSS only at the mobile breakpoint
IsToday bool
IsAdjacent bool // belongs to prev/next month
Rows []calendarRow // capped at calendarMaxRowsPerCell
ExtraCount int // rows hidden under the +N more link
TotalRows int // total before capping (Rows + ExtraCount)
}
// calendarRow is one stack-able marker rendered inside a cell. Kind drives
// the colour/badge in the template.
type calendarRow struct {
Kind string // event | todo | doc
Item *store.Item
ItemPath string
Time string // "10:00" / "" for all-day or untimed
Summary string
Event *aggregate.EventRow
Todo *aggregate.TodoRow
Doc *aggregate.DocRow
Link *store.ItemLink
Overdue bool // VTODO only: DUE before today and still open
}
// calendarKind constants — narrow subset of timeline kinds. Creation
// markers are explicitly excluded (too noisy for a month grid per the
// design doc) and the timeline-only kinds (issues, untimed) likewise.
const (
calendarKindEvent = aggregate.KindEvent
calendarKindTodo = aggregate.KindTodo
calendarKindDoc = aggregate.KindDoc
)
// calendarQuery is the parsed user input. Month always normalises to the
// first day of the month at midnight local time.
type calendarQuery struct {
Filter TreeFilter
Month time.Time // first day of month, midnight local
Kinds []string // sorted, lower-case; empty means "all three"
}
func (q calendarQuery) activeKinds() []string {
if len(q.Kinds) == 0 {
return []string{calendarKindEvent, calendarKindTodo, calendarKindDoc}
}
return q.Kinds
}
func (q calendarQuery) wantKind(k string) bool {
for _, x := range q.activeKinds() {
if x == k {
return true
}
}
return false
}
func (q calendarQuery) cacheKey() string {
parts := []string{
"f=" + q.Filter.QueryString(),
"m=" + q.Month.Format("2006-01"),
}
if len(q.Kinds) > 0 {
parts = append(parts, "kinds="+strings.Join(q.Kinds, ","))
}
return strings.Join(parts, "|")
}
// parseCalendarQuery folds URL params into a calendarQuery. Defaults:
// current month (local), all three kinds.
func parseCalendarQuery(r *http.Request, now time.Time) calendarQuery {
q := calendarQuery{
Filter: ParseTreeFilter(r.URL.Query()),
Month: startOfMonth(now),
}
if v := strings.TrimSpace(r.URL.Query().Get("month")); v != "" {
if t, err := time.Parse("2006-01", v); err == nil {
q.Month = startOfMonth(t.In(now.Location()))
}
}
// Accept both `?kind=event,doc` (single param, comma-joined) and
// `?kind=event&kind=doc` (repeated param, HTMX multi-select form
// submission). The latter is what the calendar_section.tmpl form
// emits when the user clicks more than one option in the kind chip;
// the prior q.Get call dropped everything past the first value.
for _, k := range parseValues(r.URL.Query(), "kind") {
switch k {
case calendarKindEvent, calendarKindTodo, calendarKindDoc:
q.Kinds = append(q.Kinds, k)
}
}
sort.Strings(q.Kinds)
return q
}
// startOfMonth returns the first day of t's month at midnight in t's
// location.
func startOfMonth(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
}
// mondayWeekday converts time.Weekday (Sunday=0) to Monday-based (Monday=0).
// Used to figure out how many days from the previous month lead into the
// grid so the first cell is always a Monday.
func mondayWeekday(t time.Time) int {
w := int(t.Weekday())
if w == 0 {
return 6
}
return w - 1
}
// handleCalendar renders the month-grid view at /calendar.
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
now := time.Now()
q := parseCalendarQuery(r, now)
if r.URL.Query().Get("refresh") == "1" {
s.calendar.InvalidateAll()
}
key := q.cacheKey()
payload, hit := s.calendar.Get(key)
if !hit {
built, err := s.buildCalendar(r.Context(), q, now)
if err != nil {
s.fail(w, r, err)
return
}
s.calendar.Set(key, built)
payload = built
}
display := *payload
display.Cached = hit
projects, err := s.parentOptions(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
data := map[string]any{
"Title": "calendar",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
"Projects": projects,
"BasePath": "/views/calendar",
"ProjectChipTarget": "#calendar-section",
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "calendar_section", data)
return
}
s.render(w, r, "calendar", data)
}
// buildCalendar gathers events / todos / docs whose anchor date falls in
// the displayed month window (which extends across adjacent-month
// lead/trail cells), bins them into per-day cells, and caps each cell at
// calendarMaxRowsPerCell with the overflow count.
func (s *Server) buildCalendar(ctx context.Context, q calendarQuery, now time.Time) (*calendarPayload, 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 via the tree filter so /calendar?tag=work scopes the
// data fan-out — same shape as /timeline.
matched := items[:0:0]
matchedSet := map[string]struct{}{}
for _, it := range items {
if !q.Filter.Active() || q.Filter.Matches(it, linkKinds[it.ID]) {
matched = append(matched, it)
matchedSet[it.ID] = struct{}{}
}
}
// Grid window = first visible cell (Monday on or before month_start)
// through last visible cell (Sunday on or after month_end - 1day).
monthStart := q.Month
monthEnd := monthStart.AddDate(0, 1, 0)
leadDays := mondayWeekday(monthStart)
gridStart := monthStart.AddDate(0, 0, -leadDays)
daysInMonth := int(monthEnd.Sub(monthStart) / (24 * time.Hour))
totalCellsBeforePad := leadDays + daysInMonth
pad := 0
if rem := totalCellsBeforePad % 7; rem != 0 {
pad = 7 - rem
}
gridEnd := monthStart.AddDate(0, 0, daysInMonth+pad) // exclusive
window := aggregate.Window{From: gridStart, To: gridEnd}
agg := s.Aggregator()
// Bin rows by YYYY-MM-DD key in the local zone.
byDay := map[string][]calendarRow{}
addRow := func(date time.Time, row calendarRow) {
key := startOfDay(date.Local()).Format("2006-01-02")
byDay[key] = append(byDay[key], row)
}
today := startOfDay(now)
if q.wantKind(calendarKindTodo) && s.CalDAV != nil {
for _, tr := range agg.Todos(ctx, matched, window) {
open := tr.Todo.Status != "COMPLETED" && tr.Todo.Status != "CANCELLED"
var anchor *time.Time
if open {
anchor = tr.Todo.Due
} else if tr.Todo.LastModified != nil {
anchor = tr.Todo.LastModified
} else if tr.Todo.Due != nil {
anchor = tr.Todo.Due
}
if anchor == nil {
continue
}
t := *anchor
row := tr // copy so &row below doesn't alias the loop var
cr := calendarRow{
Kind: calendarKindTodo,
Item: tr.Item,
ItemPath: tr.Item.PrimaryPath(),
Summary: tr.Todo.Summary,
Todo: &row,
Overdue: open && tr.Todo.Due != nil && tr.Todo.Due.Before(today),
}
if t.Hour() != 0 || t.Minute() != 0 {
cr.Time = t.Local().Format("15:04")
}
addRow(t, cr)
}
}
if q.wantKind(calendarKindEvent) && s.CalDAV != nil {
for _, ev := range agg.Events(ctx, matched, window) {
row := ev
cr := calendarRow{
Kind: calendarKindEvent,
Item: ev.Item,
ItemPath: ev.Item.PrimaryPath(),
Summary: ev.Event.Summary,
Event: &row,
}
if !ev.Event.AllDay {
cr.Time = ev.Event.Start.Local().Format("15:04")
}
addRow(ev.Event.Start, cr)
}
}
if q.wantKind(calendarKindDoc) {
for _, d := range agg.Docs(ctx, matched, window) {
if d.Link == nil || d.Link.EventDate == nil {
continue
}
if _, in := matchedSet[d.Item.ID]; q.Filter.Active() && !in {
continue
}
row := d
cr := calendarRow{
Kind: calendarKindDoc,
Item: d.Item,
ItemPath: d.Item.PrimaryPath(),
Summary: docSummary(d),
Doc: &row,
Link: d.Link,
}
addRow(*d.Link.EventDate, cr)
}
}
// Stable per-day order: timed events/todos first by Time, then docs,
// then todos with no time. Stable secondary sort by summary.
for k := range byDay {
rows := byDay[k]
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].Time != rows[j].Time {
if rows[i].Time == "" {
return false
}
if rows[j].Time == "" {
return true
}
return rows[i].Time < rows[j].Time
}
if rows[i].Kind != rows[j].Kind {
return calendarKindRank(rows[i].Kind) < calendarKindRank(rows[j].Kind)
}
return rows[i].Summary < rows[j].Summary
})
byDay[k] = rows
}
weeks := layoutCalendarWeeks(monthStart, gridStart, gridEnd, today, byDay)
total := 0
for _, ws := range weeks {
for _, d := range ws.Days {
total += d.TotalRows
}
}
return &calendarPayload{
Month: monthStart,
MonthLabel: formatMonthLabel(monthStart),
MonthKey: monthStart.Format("2006-01"),
PrevMonth: monthStart.AddDate(0, -1, 0).Format("2006-01"),
NextMonth: monthStart.AddDate(0, 1, 0).Format("2006-01"),
Today: today,
Weeks: weeks,
Kinds: q.activeKinds(),
TotalRows: total,
BuiltAt: time.Now(),
}, nil
}
// layoutCalendarWeeks assembles the rectangular grid: every cell from
// gridStart (Monday on/before month start) through gridEnd-1 (Sunday on/
// after month end), then chunks into 7-cell weeks. byDay keys are
// YYYY-MM-DD in the local zone.
func layoutCalendarWeeks(monthStart, gridStart, gridEnd, today time.Time, byDay map[string][]calendarRow) []calendarWeek {
monthEnd := monthStart.AddDate(0, 1, 0)
var weeks []calendarWeek
day := gridStart
for day.Before(gridEnd) {
var week calendarWeek
for i := 0; i < 7; i++ {
d := calendarDay{
Date: day,
DateKey: day.Format("2006-01-02"),
DayNum: day.Day(),
LongLabel: formatCalendarLongLabel(day),
IsToday: day.Equal(today),
IsAdjacent: day.Before(monthStart) || !day.Before(monthEnd),
}
if rows, ok := byDay[d.DateKey]; ok {
d.TotalRows = len(rows)
if len(rows) > calendarMaxRowsPerCell {
d.Rows = rows[:calendarMaxRowsPerCell]
d.ExtraCount = len(rows) - calendarMaxRowsPerCell
} else {
d.Rows = rows
}
}
week.Days[i] = d
day = day.AddDate(0, 0, 1)
}
weeks = append(weeks, week)
}
return weeks
}
// calendarKindRank sorts ties: timed entries already sort by time, so the
// rank kicks in only for untimed rows. Events render before todos before
// docs — same visual hierarchy the dashboard uses.
func calendarKindRank(k string) int {
switch k {
case calendarKindEvent:
return 0
case calendarKindTodo:
return 1
case calendarKindDoc:
return 2
}
return 3
}
// formatMonthLabel returns a German month + year string ("Mai 2026"). The
// rest of the app stays in English; calendars are one of the surfaces
// where the German register reads more naturally to m.
func formatMonthLabel(t time.Time) string {
months := []string{
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
}
return months[int(t.Month())-1] + " " + t.Format("2006")
}
// formatCalendarLongLabel renders the per-cell long German label —
// "Mi., 14. Mai" — used by the mobile breakpoint where each cell becomes a
// stacked block and the bare day number no longer carries enough context.
func formatCalendarLongLabel(t time.Time) string {
weekdays := []string{"So.", "Mo.", "Di.", "Mi.", "Do.", "Fr.", "Sa."}
months := []string{
"Jan", "Feb", "März", "Apr", "Mai", "Juni",
"Juli", "Aug", "Sept", "Okt", "Nov", "Dez",
}
return fmt.Sprintf("%s, %d. %s", weekdays[int(t.Weekday())], t.Day(), months[int(t.Month())-1])
}
// docSummary picks a human-readable single-line summary for a dated
// item_link. Prefers the note, then ref_id's last path segment, then
// ref_id verbatim.
func docSummary(d aggregate.DocRow) string {
if d.Link == nil {
return ""
}
if d.Link.Note != nil {
n := strings.TrimSpace(*d.Link.Note)
if n != "" {
return n
}
}
ref := d.Link.RefID
if i := strings.LastIndex(ref, "/"); i >= 0 && i < len(ref)-1 {
return ref[i+1:]
}
return ref
}