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 /).
465 lines
14 KiB
Go
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
|
|
}
|