From 28ac919e01fa7800c94c46490f192b58e5c6d6cf Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 22 May 2026 12:07:25 +0200 Subject: [PATCH] feat(calendar): polish grid styling + mobile breakpoint + design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . - 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 , no .calendar-nav chrome). - TestCalendarCellCarriesLongLabel — May 4 cell ("Mo., 4. Mai") present in HTML so the mobile breakpoint CSS reveal works. Net: +315 / -61. --- docs/design.md | 30 +++++++++- web/calendar.go | 25 +++++++- web/calendar_integration_test.go | 71 ++++++++++++++++++++++ web/calendar_test.go | 21 +++++++ web/server.go | 13 ++++- web/static/style.css | 74 ++++++++++++++++++++--- web/templates/calendar.tmpl | 51 +--------------- web/templates/calendar_section.tmpl | 91 +++++++++++++++++++++++++++++ 8 files changed, 315 insertions(+), 61 deletions(-) create mode 100644 web/templates/calendar_section.tmpl diff --git a/docs/design.md b/docs/design.md index 0e42ffe..97a4efe 100644 --- a/docs/design.md +++ b/docs/design.md @@ -695,7 +695,35 @@ flexsiebels' Go (or Deno) backend POSTs to `https://projax.msbls.de/mcp/rpc` wit - Asset hosting for screenshots — projax stores URLs; m hosts images wherever already-deployed (Imgur, S3, static-asset endpoint, …). - A publish workflow with approval stages — single boolean is enough. -## 9. Phase-1 deliverable checklist +## 17. Calendar view (Phase 5e) + +Month grid at `/calendar?month=YYYY-MM` — the fourth dated surface, sibling to `/timeline` (chronological spine), `/dashboard` (today/week buckets), and `/graph` (DAG topology). Same `internal/aggregate.Aggregator` data pipeline as timeline; different presentation. + +**Sources** (per cell, anchor date is local-zone midnight of the row's date): + +1. **CalDAV VEVENTs** in the grid window (`[gridStart, gridEnd)`). Event start used as the anchor; the cell shows `HH:MM Summary` (or just `Summary` for all-day). +2. **CalDAV VTODOs** with `DUE` in the window. Open todos anchor on `DUE`; completed/cancelled todos in the last 14 days anchor on `LastModified`. Overdue (DUE before today, still open) renders with a warn-coloured border accent. +3. **Dated `projax.item_links`** with `event_date` in the window. Note text is the row summary; ref_id's last path segment is the fallback. Muted border accent. + +Not surfaced: item-creation markers (too noisy for a month grid), Gitea issues (no date anchor), untimed items (calendar is fundamentally date-scoped). + +**Layout** — `web/calendar.go layoutCalendarWeeks` builds the rectangular grid: + +- 7 columns Mon→Sun. `mondayWeekday(t)` converts Go's Sunday=0 default to the German Monday=0 convention. +- Leading days from the previous month fill the first row's gap before the 1st. Trailing days from the next month pad to the last row's Sunday. Both carry `IsAdjacent` so CSS can grey them out. +- Each cell caps visible rows at `calendarMaxRowsPerCell` (3). Overflow becomes "+N more" linking to `/timeline?from=YYYY-MM-DD&to=YYYY-MM-DD` for a focused single-day view. +- Today's cell carries `IsToday` → CSS adds an accent border + "Heute" pill. +- Per-cell rows sort: timed first (by `HH:MM`), then by kind rank (event < todo < doc), then by summary. + +**Filter integration** — reuses `TreeFilter` from `web/tree_filter.go`. Same query keys (`q`, `tag`, `mgmt`, `has`) plus a calendar-specific `kind=event,todo,doc` multi-select. The chip strip uses HTMX `hx-target=#calendar-section` for in-place swaps; the page chrome (month label + prev/next nav) stays outside the swap because chip filtering doesn't change month. + +**Cache** — `cache.TTLCache[*calendarPayload]` keyed by `(filter, month, kinds)` at 60s, matching the dashboard's cadence. `?refresh=1` invalidates the entire calendar cache. + +**Mobile breakpoint** (≤480px) — the 7-column grid is unreadable on a 360px-wide phone, so the CSS collapses to a vertical list-of-days. The `LongLabel` field (e.g. "Mi., 14. Mai") is hidden on desktop and revealed at the breakpoint to compensate for the absent weekday column header. Adjacent-month cells drop out entirely on mobile so the list is calendar-scoped. + +**German register** — month and weekday labels in German throughout (`Mai 2026`, `heute`, `Heute`, `Mi., 14. Mai`). Rest of the app stays English; the calendar surface reads more naturally in German for m's usage. + + - [ ] `projax.items` + `projax.item_links` migrations in `db/migrations/` - [ ] Path trigger + tests diff --git a/web/calendar.go b/web/calendar.go index 776c577..54e9a45 100644 --- a/web/calendar.go +++ b/web/calendar.go @@ -2,6 +2,7 @@ package web import ( "context" + "fmt" "net/http" "sort" "strings" @@ -48,11 +49,12 @@ 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 + 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) + 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 @@ -189,6 +191,10 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) { "Query": q, "Now": now, } + if r.Header.Get("HX-Request") == "true" { + s.render(w, r, "calendar_section", data) + return + } s.render(w, r, "calendar", data) } @@ -372,6 +378,7 @@ func layoutCalendarWeeks(monthStart, gridStart, gridEnd, today time.Time, byDay 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), } @@ -418,6 +425,18 @@ func formatMonthLabel(t time.Time) string { 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. diff --git a/web/calendar_integration_test.go b/web/calendar_integration_test.go index c8fcc04..79849b2 100644 --- a/web/calendar_integration_test.go +++ b/web/calendar_integration_test.go @@ -2,6 +2,8 @@ package web_test import ( "context" + "io" + "net/http/httptest" "strings" "testing" "time" @@ -179,3 +181,72 @@ func TestCalendarNavPrevNextLinks(t *testing.T) { t.Errorf("expected next link to 2026-06, body did not include it") } } + +// TestCalendarFilterChipStripRenders proves the HTMX filter chip strip +// (Phase 5e slice B) is rendered above the grid with the hx-target +// pointing at #calendar-section so chip changes swap only the data and +// leave the month-label chrome alone. +func TestCalendarFilterChipStripRenders(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + _, body := get(t, h, "/calendar?month=2026-05") + for _, want := range []string{ + `id="calendar-filterbar"`, + `hx-target="#calendar-section"`, + `hx-get="/calendar"`, + ``, // preserves month across chip changes + `name="kind"`, + `name="tag"`, + `name="mgmt"`, + } { + if !strings.Contains(body, want) { + t.Errorf("calendar body missing %q", want) + } + } +} + +// TestCalendarHTMXReturnsSectionOnly proves an HX-Request returns just +// the calendar-section fragment (no layout chrome) so the filter chip +// strip can swap the grid in place without re-rendering the page shell. +func TestCalendarHTMXReturnsSectionOnly(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + req := httptest.NewRequest("GET", "/calendar?month=2026-05", nil) + req.Header.Set("HX-Request", "true") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + body, _ := io.ReadAll(w.Result().Body) + bs := string(body) + if w.Result().StatusCode != 200 { + t.Fatalf("HTMX /calendar → %d body=%s", w.Result().StatusCode, bs) + } + if !strings.Contains(bs, `id="calendar-section"`) { + t.Errorf("HTMX response missing #calendar-section: %s", bs) + } + // Layout chrome (e.g. the
- - - - - - - - - - - - - {{range .P.Weeks}} - - {{range .Days}} - - {{end}} - - {{end}} - -
MonTueWedThuFriSatSun
-
- {{.DayNum}} - {{if .IsToday}}Heute{{end}} -
- {{if .Rows}} -
    - {{range .Rows}} -
  • - {{if .Time}}{{.Time}}{{end}} - {{.Summary}} -
  • - {{end}} -
- {{end}} - {{if gt .ExtraCount 0}} - +{{.ExtraCount}} more - {{end}} -
+ {{template "calendar-section" .}} {{end}} diff --git a/web/templates/calendar_section.tmpl b/web/templates/calendar_section.tmpl new file mode 100644 index 0000000..6aaea52 --- /dev/null +++ b/web/templates/calendar_section.tmpl @@ -0,0 +1,91 @@ +{{define "calendar-section"}} +
+ +
+ +

+ {{.P.TotalRows}} {{if eq .P.TotalRows 1}}row{{else}}rows{{end}} + {{if .P.Cached}}· cached{{else}}· fresh{{end}} +

+
+ + + + + + + + + + + + + + + {{range .P.Weeks}} + + {{range .Days}} + + {{end}} + + {{end}} + +
MonTueWedThuFriSatSun
+
+ {{.DayNum}} + {{.LongLabel}} + {{if .IsToday}}Heute{{end}} +
+ {{if .Rows}} +
    + {{range .Rows}} +
  • + {{if .Time}}{{.Time}}{{end}} + {{.Summary}} +
  • + {{end}} +
+ {{end}} + {{if gt .ExtraCount 0}} + +{{.ExtraCount}} more + {{end}} +
+ +
+{{end}}