feat(calendar): /calendar month-grid view with VEVENT/VTODO/DOC sources

Phase 5e slice A. New surface alongside /timeline (chronological spine) and
/dashboard (today/week buckets) — a 7×N month grid that answers "show me my
month at a glance." Monday-leading weeks per the German convention, with
adjacent-month lead-in/trail-out cells greyed to keep the grid rectangular.

web/calendar.go (new):
- calendarPayload / calendarWeek / calendarDay / calendarRow types.
- parseCalendarQuery: reads ?month=YYYY-MM (defaults to current month),
  ?kind=event,todo,doc (defaults to all three; creation excluded by design),
  inherits the full TreeFilter via ParseTreeFilter so ?tag=work / ?mgmt=mai
  scope identically to /timeline.
- handleCalendar: TTL-cached at 60s per (filter, month, kinds).
- buildCalendar: items → TreeFilter narrow → aggregate.{Todos,Events,Docs}
  for the grid window → bin by YYYY-MM-DD → stable per-cell sort (timed
  first, then by kind rank, then summary).
- layoutCalendarWeeks: pure function building the rectangular grid; lead
  days computed from mondayWeekday(monthStart), trailing pad from
  (totalCells % 7). Each cell caps visible rows at 3 and surfaces the
  remainder via ExtraCount so the template emits a "+N more" drill-down
  link to /timeline scoped to that single day.
- formatMonthLabel: German month names (Mai, März, Juni, Dezember).
- docSummary: prefers item_link.note, falls back to last path segment of
  ref_id, then ref_id verbatim.

web/templates/calendar.tmpl (new):
- Grid markup as a <table role="grid"> — semantically a calendar grid,
  works without JS, and the layout calc already pre-chunks weeks.
- Header carries h1 (German month label), prev/next/today nav, and the
  cached/fresh + total-rows counts line.
- Each cell: .calendar-cell, .is-today, .adjacent-month conditional
  classes; .today-pill rendered when IsToday.
- Rows: .row-event / .row-todo (+ .overdue) / .row-doc with a leading
  time slot and an <a> to /i/<itemPath>.
- "+N more" link drills into /timeline?from=YYYY-MM-DD&to=YYYY-MM-DD.

web/static/style.css:
- ~95 lines of minimal grid styling: 7-column table-fixed, 110px cell
  height, today border accent, adjacent-month opacity 0.4, per-kind row
  border-left colour. Slice B will refine cell sizing + add the mobile
  breakpoint + chip strip.

web/server.go:
- New calendar template parse (layout.tmpl + calendar.tmpl), calendar
  field on Server (cache.TTLCache[*calendarPayload]), route registration
  GET /calendar.

web/templates/layout.tmpl:
- Nav anchor added between timeline and graph.

web/server_test.go:
- TestLayoutHasViewportMeta now probes /calendar too.

Tests (web/calendar_test.go — pure unit):
- TestCalendarLayoutMondayLead, TestCalendarLayoutTrailingPad: grid math
  for Friday-leading (May 2026) and Monday-trailing (June 2026) months.
- TestCalendarTodayCell: IsToday flag lands on the right cell only.
- TestCalendarCellRowOverflow: >3 seeded rows → 3 visible + ExtraCount=2.
- TestMondayWeekday: Sunday→6, Monday→0 conversion.
- TestFormatMonthLabel: German month strings.
- TestParseCalendarQuery{Defaults,MonthParam,KindFilter}: URL parsing.

Tests (web/calendar_integration_test.go — DB integration):
- TestCalendarRendersMonthGrid: empty-data smoke through srv.Routes().
- TestCalendarSurfacesDatedLink: seeds an item_link on today, asserts
  the rendered cell carries the note text + .is-today class.
- TestCalendarFilterScopeByTag: seeds two tagged items, confirms
  ?tag=<work-tag> only renders the work-item rows.
- TestCalendarAdjacentMonthDays: May 2026 (Fri-leading) renders the
  Apr 27 lead-in cell with .adjacent-month.
- TestCalendarNavPrevNextLinks: prev → 2026-04, next → 2026-06 links
  present.

Slice B follows: refined CSS, mobile breakpoint (≤480px → vertical list
of days), HTMX filter chip strip, docs/design.md §17.
This commit is contained in:
mAi
2026-05-22 12:01:03 +02:00
parent 76efdbeb73
commit e5dd31144a
8 changed files with 1012 additions and 1 deletions

439
web/calendar.go Normal file
View File

@@ -0,0 +1,439 @@
package web
import (
"context"
"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
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()))
}
}
if v := strings.TrimSpace(r.URL.Query().Get("kind")); v != "" {
seen := map[string]bool{}
for _, k := range strings.Split(v, ",") {
k = strings.TrimSpace(strings.ToLower(k))
switch k {
case calendarKindEvent, calendarKindTodo, calendarKindDoc:
if !seen[k] {
seen[k] = true
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
data := map[string]any{
"Title": "calendar",
"P": display,
"Filter": q.Filter,
"Query": q,
"Now": now,
}
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(),
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")
}
// 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
}

View File

@@ -0,0 +1,181 @@
package web_test
import (
"context"
"strings"
"testing"
"time"
)
// TestCalendarRendersMonthGrid hits GET /calendar with the default month
// and asserts the rendered HTML carries the grid table, nav anchors, and
// weekday header. Empty-data path — no seeding required so this guards
// the route registration + template parsing on every test run.
func TestCalendarRendersMonthGrid(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/calendar")
if code != 200 {
t.Fatalf("GET /calendar → %d body=%s", code, body)
}
for _, want := range []string{
`id="calendar-section"`,
`class="calendar-grid"`,
`<th scope="col">Mon</th>`,
`<th scope="col">Sun</th>`,
`class="calendar-nav"`,
`href="/calendar?month=`, // prev/next anchors present
} {
if !strings.Contains(body, want) {
t.Errorf("calendar body missing %q", want)
}
}
}
// TestCalendarSurfacesDatedLink seeds a dated item_link on today and
// confirms the corresponding cell carries the link summary + the
// .is-today class on today's <td>. Closes the round-trip "data goes in,
// cell renders out" loop for the doc source.
func TestCalendarSurfacesDatedLink(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "cal-doc-" + stamp
var dev, id string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], 'Cal doc', $1, ARRAY[$2]::uuid[])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
noteText := "cal-marker-" + stamp
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
values ($1, 'document', $2, 'contains', $3, current_date)`,
id, "https://example.com/cal-"+stamp, noteText,
); err != nil {
t.Fatalf("seed link: %v", err)
}
code, body := get(t, h, "/calendar")
if code != 200 {
t.Fatalf("GET /calendar → %d", code)
}
if !strings.Contains(body, noteText) {
t.Errorf("calendar body missing seeded doc summary %q", noteText)
}
// Today's <td> should carry the .is-today class.
if !strings.Contains(body, "is-today") {
t.Errorf("expected today's cell to carry .is-today class, body did not include it")
}
}
// TestCalendarFilterScopeByTag seeds two items with distinct tags, drops
// a dated link on each, and asserts ?tag=work scopes the rendered rows.
// Confirms the TreeFilter integration matches the timeline cadence.
func TestCalendarFilterScopeByTag(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
var dev string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
workSlug := "cal-work-" + stamp
playSlug := "cal-play-" + stamp
var workID, playID string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, tags)
values (array['project']::text[], 'work item', $1, ARRAY[$2]::uuid[], ARRAY['cal-test-work-`+stamp+`']::text[])
returning id`, workSlug, dev).Scan(&workID); err != nil {
t.Fatalf("seed work item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, workID)
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, tags)
values (array['project']::text[], 'play item', $1, ARRAY[$2]::uuid[], ARRAY['cal-test-play-`+stamp+`']::text[])
returning id`, playSlug, dev).Scan(&playID); err != nil {
t.Fatalf("seed play item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, playID)
workNote := "cal-work-note-" + stamp
playNote := "cal-play-note-" + stamp
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
values ($1, 'document', $2, 'contains', $3, current_date),
($4, 'document', $5, 'contains', $6, current_date)`,
workID, "https://example.com/cal-work-"+stamp, workNote,
playID, "https://example.com/cal-play-"+stamp, playNote,
); err != nil {
t.Fatalf("seed links: %v", err)
}
// Unfiltered: both notes show.
_, all := get(t, h, "/calendar?refresh=1")
if !strings.Contains(all, workNote) {
t.Errorf("unfiltered calendar missing work note %q", workNote)
}
if !strings.Contains(all, playNote) {
t.Errorf("unfiltered calendar missing play note %q", playNote)
}
// Filtered: only work note shows.
_, scoped := get(t, h, "/calendar?refresh=1&tag=cal-test-work-"+stamp)
if !strings.Contains(scoped, workNote) {
t.Errorf("filtered calendar missing work note %q", workNote)
}
if strings.Contains(scoped, playNote) {
t.Errorf("filtered calendar SHOULD NOT contain play note %q", playNote)
}
}
// TestCalendarAdjacentMonthDays proves the lead-in / trail-out cells from
// the prior / next month render with the .adjacent-month class so the
// template can style them muted without losing the rectangular grid.
func TestCalendarAdjacentMonthDays(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// Pick a month whose first day is NOT a Monday so leading days appear.
// May 2026 starts on a Friday; lead = Apr 27/28/29/30.
_, body := get(t, h, "/calendar?month=2026-05&refresh=1")
if !strings.Contains(body, "adjacent-month") {
t.Errorf("expected adjacent-month class on lead-in cells for May 2026, body did not include it")
}
if !strings.Contains(body, `data-date="2026-04-27"`) {
t.Errorf("expected lead-in cell data-date=2026-04-27 for May 2026, body did not include it")
}
}
// TestCalendarNavPrevNextLinks confirms the header has working prev/next
// month links — bookmarkability is the calendar's main affordance for
// jumping around the timeline.
func TestCalendarNavPrevNextLinks(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
if !strings.Contains(body, `href="/calendar?month=2026-04"`) {
t.Errorf("expected prev link to 2026-04, body did not include it")
}
if !strings.Contains(body, `href="/calendar?month=2026-06"`) {
t.Errorf("expected next link to 2026-06, body did not include it")
}
}

223
web/calendar_test.go Normal file
View File

@@ -0,0 +1,223 @@
package web
import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/m/projax/store"
)
// TestCalendarLayoutMondayLead seeds a synthetic month whose first day is a
// Friday (May 2026) and confirms the layout function:
// - First cell of week 0 is Monday April 27 2026 (adjacent-month).
// - Last cell of the final week is Sunday May 31 2026 — the May 31st
// itself falls on a Sunday, so no trailing pad days are needed.
// - Total weeks = 5 (4 spillover Mondays + 31 days = 35 cells / 7).
func TestCalendarLayoutMondayLead(t *testing.T) {
loc := time.Local
may := time.Date(2026, 5, 1, 0, 0, 0, 0, loc)
gridStart := may.AddDate(0, 0, -mondayWeekday(may)) // Apr 27 2026
gridEnd := time.Date(2026, 6, 1, 0, 0, 0, 0, loc) // Jun 1 2026 (exclusive — May 31 is the final cell)
weeks := layoutCalendarWeeks(may, gridStart, gridEnd, may, nil)
if len(weeks) != 5 {
t.Fatalf("expected 5 weeks for May 2026 (Fri-leading, Sun-trailing), got %d", len(weeks))
}
first := weeks[0].Days[0]
if first.DateKey != "2026-04-27" {
t.Errorf("first cell DateKey = %q, want 2026-04-27", first.DateKey)
}
if !first.IsAdjacent {
t.Errorf("first cell should be IsAdjacent (Apr 27 lives in April)")
}
last := weeks[4].Days[6]
if last.DateKey != "2026-05-31" {
t.Errorf("last cell DateKey = %q, want 2026-05-31", last.DateKey)
}
if last.IsAdjacent {
t.Errorf("last cell should NOT be adjacent — May 31 is within May")
}
}
// TestCalendarLayoutTrailingPad picks a month whose last day is a Monday
// (June 2026) — the rectangular grid requires six trailing pad days to
// close the week, yielding six rows total.
func TestCalendarLayoutTrailingPad(t *testing.T) {
loc := time.Local
june := time.Date(2026, 6, 1, 0, 0, 0, 0, loc) // Monday
gridStart := june // monthStart already a Monday → no lead
// June has 30 days, ends on Tuesday. Pad to Sunday = +5 days → grid runs through Jul 5.
gridEnd := time.Date(2026, 7, 6, 0, 0, 0, 0, loc)
weeks := layoutCalendarWeeks(june, gridStart, gridEnd, june, nil)
if len(weeks) != 5 {
t.Fatalf("expected 5 weeks for June 2026 (Mon-leading), got %d", len(weeks))
}
if weeks[0].Days[0].DateKey != "2026-06-01" {
t.Errorf("first cell = %q, want 2026-06-01", weeks[0].Days[0].DateKey)
}
if weeks[4].Days[6].DateKey != "2026-07-05" {
t.Errorf("last trailing cell = %q, want 2026-07-05", weeks[4].Days[6].DateKey)
}
if !weeks[4].Days[6].IsAdjacent {
t.Errorf("July 5 cell should be IsAdjacent (lives in next month)")
}
}
// TestCalendarTodayCell proves the IsToday flag fires on the right cell.
func TestCalendarTodayCell(t *testing.T) {
loc := time.Local
may := time.Date(2026, 5, 1, 0, 0, 0, 0, loc)
today := time.Date(2026, 5, 14, 0, 0, 0, 0, loc)
gridStart := may.AddDate(0, 0, -mondayWeekday(may))
gridEnd := time.Date(2026, 6, 1, 0, 0, 0, 0, loc)
weeks := layoutCalendarWeeks(may, gridStart, gridEnd, today, nil)
found := false
for _, wk := range weeks {
for _, d := range wk.Days {
if d.DateKey == "2026-05-14" {
found = true
if !d.IsToday {
t.Errorf("cell for today (2026-05-14) should be IsToday")
}
} else if d.IsToday {
t.Errorf("cell %s should NOT be IsToday", d.DateKey)
}
}
}
if !found {
t.Fatalf("did not find cell for today in the grid")
}
}
// TestCalendarCellRowOverflow proves that a day with more than three rows
// renders three visible rows and surfaces the remainder via ExtraCount so
// the template can emit a "+N more" link.
func TestCalendarCellRowOverflow(t *testing.T) {
loc := time.Local
may := time.Date(2026, 5, 1, 0, 0, 0, 0, loc)
gridStart := may.AddDate(0, 0, -mondayWeekday(may))
gridEnd := time.Date(2026, 6, 1, 0, 0, 0, 0, loc)
dummy := &store.Item{ID: "id", Title: "t", Slug: "t"}
rows := []calendarRow{
{Kind: "event", Item: dummy, ItemPath: "t", Summary: "a"},
{Kind: "event", Item: dummy, ItemPath: "t", Summary: "b"},
{Kind: "event", Item: dummy, ItemPath: "t", Summary: "c"},
{Kind: "event", Item: dummy, ItemPath: "t", Summary: "d"},
{Kind: "event", Item: dummy, ItemPath: "t", Summary: "e"},
}
byDay := map[string][]calendarRow{"2026-05-15": rows}
weeks := layoutCalendarWeeks(may, gridStart, gridEnd, may, byDay)
var cell *calendarDay
for _, wk := range weeks {
for i, d := range wk.Days {
if d.DateKey == "2026-05-15" {
cell = &wk.Days[i]
}
}
}
if cell == nil {
t.Fatalf("did not find May 15 cell")
}
if len(cell.Rows) != calendarMaxRowsPerCell {
t.Errorf("len(Rows) = %d, want %d (cap)", len(cell.Rows), calendarMaxRowsPerCell)
}
if cell.ExtraCount != 2 {
t.Errorf("ExtraCount = %d, want 2 (5 seeded - 3 visible)", cell.ExtraCount)
}
if cell.TotalRows != 5 {
t.Errorf("TotalRows = %d, want 5", cell.TotalRows)
}
}
// TestMondayWeekday pins the Monday=0 weekday conversion that drives the
// lead-day count. The Sunday=0 default in Go's time package would put
// Sunday at index 0 of the grid — wrong for the German week convention.
func TestMondayWeekday(t *testing.T) {
cases := []struct {
day time.Weekday
want int
}{
{time.Monday, 0}, {time.Tuesday, 1}, {time.Wednesday, 2},
{time.Thursday, 3}, {time.Friday, 4}, {time.Saturday, 5}, {time.Sunday, 6},
}
for _, c := range cases {
// Pick a reference Monday and offset to get the weekday under test.
ref := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Mon
probe := ref.AddDate(0, 0, int(c.day-time.Monday+7)%7)
if probe.Weekday() != c.day {
t.Fatalf("setup bug: probe weekday = %v, want %v", probe.Weekday(), c.day)
}
if got := mondayWeekday(probe); got != c.want {
t.Errorf("mondayWeekday(%v) = %d, want %d", c.day, got, c.want)
}
}
}
// TestFormatMonthLabel confirms the German month names render — m reads
// /calendar primarily in the German register and the label is the most
// visible piece of chrome.
func TestFormatMonthLabel(t *testing.T) {
cases := map[string]string{
"2026-01-01": "Januar 2026",
"2026-03-15": "März 2026",
"2026-05-01": "Mai 2026",
"2026-12-31": "Dezember 2026",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
d, _ := time.Parse("2006-01-02", in)
if got := formatMonthLabel(d); got != want {
t.Errorf("formatMonthLabel(%s) = %q, want %q", in, got, want)
}
})
}
}
// TestParseCalendarQueryDefaults proves month + kinds default to current
// month + all-three.
func TestParseCalendarQueryDefaults(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
r := httptest.NewRequest("GET", "/calendar", nil)
q := parseCalendarQuery(r, now)
if q.Month.Format("2006-01") != "2026-05" {
t.Errorf("default month = %s, want 2026-05", q.Month.Format("2006-01"))
}
kinds := q.activeKinds()
if len(kinds) != 3 {
t.Errorf("default activeKinds len = %d, want 3 (event/todo/doc)", len(kinds))
}
wantKinds := strings.Join(kinds, ",")
for _, k := range []string{"event", "todo", "doc"} {
if !strings.Contains(wantKinds, k) {
t.Errorf("activeKinds missing %s: %v", k, kinds)
}
}
}
// TestParseCalendarQueryMonthParam proves a `?month=YYYY-MM` URL param
// overrides the default. Bookmarkability matters because the prev/next
// nav writes to this exact key.
func TestParseCalendarQueryMonthParam(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
r := httptest.NewRequest("GET", "/calendar?month=2026-08", nil)
q := parseCalendarQuery(r, now)
if q.Month.Format("2006-01") != "2026-08" {
t.Errorf("parsed month = %s, want 2026-08", q.Month.Format("2006-01"))
}
}
// TestParseCalendarQueryKindFilter proves `?kind=event,doc` narrows the
// kind set and drops unknown values.
func TestParseCalendarQueryKindFilter(t *testing.T) {
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
r := httptest.NewRequest("GET", "/calendar?kind=event,doc,junk,creation", nil)
q := parseCalendarQuery(r, now)
got := strings.Join(q.activeKinds(), ",")
want := "doc,event" // sorted alphabetically; creation is excluded by design, junk dropped
if got != want {
t.Errorf("activeKinds = %q, want %q", got, want)
}
}

View File

@@ -83,6 +83,7 @@ type Server struct {
Version string // build-time -ldflags injection; surfaced on /admin
dashboard *cache.TTLCache[*dashboardPayload]
timeline *cache.TTLCache[*TimelinePayload]
calendar *cache.TTLCache[*calendarPayload]
adminHealth *adminHealthCache
}
@@ -273,6 +274,17 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
}
pages["timeline_section"] = timelineSection
// Calendar page — month grid view, Phase 5e. No HTMX fragment yet;
// filter/month changes are full-page nav.
calTmpl, err := template.New("calendar").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/calendar.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse calendar: %w", err)
}
pages["calendar"] = calTmpl
// Bulk-edit page + its fragment + per-row chip cells. The chip cells share
// definitions with bulk_section so we parse them together every time.
bulkTmpl, err := template.New("bulk").Funcs(funcs).ParseFS(templatesFS,
@@ -306,6 +318,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
Logger: logger,
dashboard: cache.NewTTL[*dashboardPayload](dashboardCacheTTL),
timeline: cache.NewTTL[*TimelinePayload](timelineCacheTTL),
calendar: cache.NewTTL[*calendarPayload](calendarCacheTTL),
adminHealth: newAdminHealthCache(),
}, nil
}
@@ -323,6 +336,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /admin/classify", s.handleClassify)
mux.HandleFunc("GET /dashboard", s.handleDashboard)
mux.HandleFunc("GET /timeline", s.handleTimeline)
mux.HandleFunc("GET /calendar", s.handleCalendar)
mux.HandleFunc("GET /graph", s.handleGraph)
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
mux.HandleFunc("POST /dashboard/task/edit", s.handleDashboardTaskEdit)

View File

@@ -90,7 +90,7 @@ func TestLayoutHasViewportMeta(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
for _, path := range []string{"/", "/dashboard", "/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
for _, path := range []string{"/", "/dashboard", "/calendar", "/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
_, body := get(t, h, path)
if !strings.Contains(body, `name="viewport"`) {
t.Errorf("GET %s: missing <meta name=\"viewport\">", path)

View File

@@ -749,3 +749,98 @@ details.proj-section > summary.proj-section-summary small { font-weight: 400; }
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* --- Calendar month-grid view (Phase 5e) --- */
.calendar-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 16px;
margin-bottom: 12px;
}
.calendar-header h1 { margin: 0; }
.calendar-nav {
display: inline-flex;
gap: 8px;
align-items: baseline;
}
.calendar-nav a {
color: var(--muted);
text-decoration: none;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 4px;
}
.calendar-nav a:hover { color: var(--accent); border-color: var(--accent); }
.calendar-nav .today { background: var(--surface-alt, transparent); }
.calendar-grid {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.calendar-grid thead th {
text-align: left;
font-weight: 500;
color: var(--muted);
font-size: 0.85em;
padding: 4px 6px;
border-bottom: 1px solid var(--border);
}
.calendar-cell {
vertical-align: top;
border: 1px solid var(--border);
padding: 4px 6px;
height: 110px;
width: 14.2857%; /* 1/7 */
}
.calendar-cell.adjacent-month { opacity: 0.4; }
.calendar-cell.is-today {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.cell-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 4px;
}
.cell-header .day-num {
font-size: 0.95em;
font-weight: 600;
}
.today-pill {
font-size: 0.7em;
background: var(--accent);
color: var(--accent-fg);
padding: 1px 6px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cell-rows { list-style: none; padding: 0; margin: 0; }
.cell-row {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 0.85em;
padding: 1px 4px;
border-radius: 3px;
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-row .time { color: var(--muted); font-variant-numeric: tabular-nums; }
.cell-row .row-link { color: inherit; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
.cell-row .row-link:hover { color: var(--accent); }
.cell-row.row-event { border-left: 3px solid var(--accent); padding-left: 6px; }
.cell-row.row-todo { border-left: 3px solid var(--ok, #5e8ad0); padding-left: 6px; }
.cell-row.row-todo.overdue { border-left-color: var(--warn, #d08a5e); }
.cell-row.row-doc { border-left: 3px solid var(--muted); padding-left: 6px; opacity: 0.85; }
.cell-more {
display: inline-block;
font-size: 0.78em;
margin-top: 2px;
text-decoration: none;
}
.cell-more:hover { color: var(--accent); text-decoration: underline; }

View File

@@ -0,0 +1,58 @@
{{define "content"}}
<section class="calendar" id="calendar-section">
<header class="calendar-header">
<h1>{{.P.MonthLabel}}</h1>
<nav class="calendar-nav" aria-label="Monthsnavigation">
<a class="prev" href="/calendar?month={{.P.PrevMonth}}{{with .Filter.QueryString}}&amp;{{.}}{{end}}">&lt; {{.P.PrevMonth}}</a>
<a class="today" href="/calendar{{with .Filter.QueryString}}?{{.}}{{end}}">heute</a>
<a class="next" href="/calendar?month={{.P.NextMonth}}{{with .Filter.QueryString}}&amp;{{.}}{{end}}">{{.P.NextMonth}} &gt;</a>
</nav>
<p class="counts muted">
<small>{{.P.TotalRows}} {{if eq .P.TotalRows 1}}row{{else}}rows{{end}} · {{join " · " .P.Kinds}}</small>
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">· cached</small>{{else}}<small>· fresh</small>{{end}}
{{if .Filter.Active}} · <a href="/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
</p>
</header>
<table class="calendar-grid" role="grid">
<thead>
<tr>
<th scope="col">Mon</th>
<th scope="col">Tue</th>
<th scope="col">Wed</th>
<th scope="col">Thu</th>
<th scope="col">Fri</th>
<th scope="col">Sat</th>
<th scope="col">Sun</th>
</tr>
</thead>
<tbody>
{{range .P.Weeks}}
<tr class="calendar-week">
{{range .Days}}
<td class="calendar-cell{{if .IsToday}} is-today{{end}}{{if .IsAdjacent}} adjacent-month{{end}}" data-date="{{.DateKey}}">
<div class="cell-header">
<span class="day-num">{{.DayNum}}</span>
{{if .IsToday}}<span class="today-pill">Heute</span>{{end}}
</div>
{{if .Rows}}
<ul class="cell-rows">
{{range .Rows}}
<li class="cell-row row-{{.Kind}}{{if .Overdue}} overdue{{end}}">
{{if .Time}}<span class="time">{{.Time}}</span>{{end}}
<a class="row-link" href="/i/{{.ItemPath}}" title="{{.ItemPath}}">{{.Summary}}</a>
</li>
{{end}}
</ul>
{{end}}
{{if gt .ExtraCount 0}}
<a class="cell-more muted" href="/timeline?from={{.DateKey}}&amp;to={{.DateKey}}">+{{.ExtraCount}} more</a>
{{end}}
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}

View File

@@ -30,6 +30,7 @@
<a href="/" class="brand">projax</a>
<a href="/dashboard">dashboard</a>
<a href="/timeline">timeline</a>
<a href="/calendar">calendar</a>
<a href="/graph">graph</a>
<a href="/admin">admin</a>
<button type="button" id="theme-toggle" class="theme-toggle" title="Toggle dark / light"