Files
projax/web/calendar_test.go
mAi 28ac919e01 feat(calendar): polish grid styling + mobile breakpoint + design doc
Phase 5e slice B. Polish pass on the month grid: HTMX-swappable filter
chip strip, mobile breakpoint that collapses the 7-column table into a
vertical list of days, refined CSS for hover/today/adjacent-month, and
the docs/design.md §17 entry that pins the contract.

Templates:
- web/templates/calendar_section.tmpl (new) — extracted #calendar-section
  partial. Houses the filter chip strip (form with hx-get=/calendar
  hx-target=#calendar-section), counts line, and the grid <table>.
- web/templates/calendar.tmpl trimmed to the page chrome (h1, prev/next
  nav, today link) + {{template "calendar-section" .}}. Chrome stays
  outside the HTMX swap because chip filtering preserves the month
  context.

web/calendar.go:
- handleCalendar now branches on HX-Request: HTMX → calendar_section
  fragment, full GET → calendar (chrome + section). Same pattern as
  /timeline and /dashboard.
- calendarDay gains LongLabel ("Mi., 14. Mai") — populated by new
  formatCalendarLongLabel helper. Hidden on desktop via CSS; revealed at
  the ≤480px breakpoint where the column header drops out.

web/server.go:
- Calendar template now bundles the section partial. New calendar_section
  template registered as a standalone fragment for HTMX swaps. New
  render() entry case "calendar_section" → "calendar-section".

web/static/style.css:
- Refined .calendar-nav (tabular numerals, transition, no surface-alt
  fallback fighting the theme).
- New #calendar-filterbar layout (flex, gap, counts pushed right).
- .calendar-cell hover background, adjacent-month opacity bump (0.4→0.45
  + 0.7 on hover so it doesn't disappear when reading lead-in days).
- .today-pill line-height fix so it sits flush in the cell header.
- .cell-row min-width on .time slot, tighter line-height, 0.82em font.
- @media (max-width: 480px) breakpoint: grid + thead + tbody + tr + th +
  td all → display:block. Thead hidden; .day-label revealed. Adjacent-
  month cells DISPLAY:NONE on mobile (their value on desktop is grid
  rectangularity; on a vertical list they're just confusing). Cell rows
  bump to 0.95em for readability.

docs/design.md:
- New §17 Calendar view (Phase 5e). Documents sources (VEVENT/VTODO/
  dated item_links), what's excluded (creation markers + Gitea + untimed),
  the layout calculation, filter integration via TreeFilter, cache key,
  the mobile breakpoint, and the German register choice.

Tests (additive, all passing):
- TestFormatCalendarLongLabel — pins the German weekday + day + month
  abbreviation (Mo./Di./.../So., 1.–31., Jan/Feb/März/.../Dez).
- TestCalendarFilterChipStripRenders — chip strip present + hx-target +
  hx-get + hidden month input + tag/mgmt/kind multi-selects.
- TestCalendarHTMXReturnsSectionOnly — HX-Request returns #calendar-
  section only (no <body>, no .calendar-nav chrome).
- TestCalendarCellCarriesLongLabel — May 4 cell ("Mo., 4. Mai") present
  in HTML so the mobile breakpoint CSS reveal works.

Net: +315 / -61.
2026-05-22 12:07:25 +02:00

245 lines
8.7 KiB
Go

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)
}
}
}
// TestFormatCalendarLongLabel exercises the per-cell long German label
// used by the mobile breakpoint (hidden on desktop via CSS). The label
// matters because on a 360px-wide phone the bare day number no longer
// carries weekday context — the CSS collapses the column header.
func TestFormatCalendarLongLabel(t *testing.T) {
cases := map[string]string{
"2026-05-04": "Mo., 4. Mai", // Monday
"2026-05-14": "Do., 14. Mai", // Thursday
"2026-03-15": "So., 15. März", // Sunday in March (uses März)
"2026-12-31": "Do., 31. Dez",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
d, _ := time.Parse("2006-01-02", in)
if got := formatCalendarLongLabel(d); got != want {
t.Errorf("formatCalendarLongLabel(%s) = %q, want %q", in, got, 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)
}
}