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.
245 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|