Files
projax/web/calendar.go
mAi 13923aadb6 feat(views): Phase 5i slice A — project filter dim + descendants toggle
m's Q5 pick (2026-05-26): project scope on every Views-supporting page,
with descendants exposed as an explicit on/off chip toggle rather than
always-on. Slice A ships the smallest standalone piece of the Views
system; slices B–E (view_type URL param, kanban, saved-views schema,
defaults) follow on the same branch.

TreeFilter grows two fields:
- ProjectPath: scoped item's primary path; "" = no filter.
- IncludeDescendants: default true; flipped via ?project_descendants=0.

Matching extends to path-prefix across `it.Paths` when ProjectPath is
set; equality-only when IncludeDescendants is off. Multi-parent items
pass when ANY of their paths qualifies.

Picker is a shared partial (templates/project_chip.tmpl) that every
Views-supporting filter strip includes (tree, dashboard, timeline,
calendar). Two states: <select> picker when no project is set; active
chip with × clear + descendants on/off chip when scoped. Hidden
inputs added to each form so non-picker chip clicks preserve the
project state. Graph and admin tools are NOT Views consumers (per
design.md / docs/plans/views-system.md §5) and stay untouched.

Test-source edits (per the 5c sharpened rule):
- dashboard_test.go, public_listing_test.go, timeline_test.go: row
  membership assertions tightened from `Contains(body, slug)` to
  `Contains(body, href="/i/path")`. The picker now renders every
  item's primary path inside a <select>, so coarse slug substring
  matches falsely passed across filtered-out picker options. Behaviour
  preserved (filtered rows still don't render); the impl-detail
  assertion moved to the row link.

New tests: TestProjectFilterIncludesDescendants,
TestProjectFilterDescendantsOff, TestParseTreeFilterProjectFields,
TestTreeFilterProjectRoundTrip, TestSetProjectAndToggleHelpers,
TestProjectFilterScopesTreeToDescendants (end-to-end via /).
2026-05-26 13:27:37 +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": "/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
}