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.
This commit is contained in:
mAi
2026-05-22 12:07:25 +02:00
parent e5dd31144a
commit 28ac919e01
8 changed files with 315 additions and 61 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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"`,
`<input type="hidden" name="month" value="2026-05">`, // 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 <nav class="calendar-nav"> month label) lives
// OUTSIDE the section in calendar.tmpl, so an HTMX fragment must not
// include it. If it does, the chip-strip swap would double up the nav.
if strings.Contains(bs, `class="calendar-nav"`) {
t.Errorf("HTMX response should not include the page chrome (.calendar-nav)")
}
if strings.Contains(bs, `<!doctype html>`) || strings.Contains(bs, "<body>") {
t.Errorf("HTMX response should be a fragment, not a full document: %s", bs)
}
}
// TestCalendarCellCarriesLongLabel proves the per-cell long German label
// is in the rendered HTML so the mobile breakpoint CSS (≤480px) can
// reveal it. The label compensates for the column-header weekday strip
// that the mobile breakpoint removes.
func TestCalendarCellCarriesLongLabel(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/calendar?month=2026-05")
// May 4 2026 is a Monday → "Mo., 4. Mai".
if !strings.Contains(body, `Mo., 4. Mai`) {
t.Errorf("expected long label 'Mo., 4. Mai' for 2026-05-04 cell, body did not include it")
}
}

View File

@@ -156,6 +156,27 @@ func TestMondayWeekday(t *testing.T) {
}
}
// 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.

View File

@@ -274,16 +274,23 @@ 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.
// Calendar page — month grid view, Phase 5e. Bundles the section
// partial so HTMX swaps (filter chip strip) and the full-page render
// share definitions.
calTmpl, err := template.New("calendar").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/calendar.tmpl",
"templates/calendar_section.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse calendar: %w", err)
}
pages["calendar"] = calTmpl
calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS, "templates/calendar_section.tmpl")
if err != nil {
return nil, fmt.Errorf("parse calendar_section: %w", err)
}
pages["calendar_section"] = calSection
// Bulk-edit page + its fragment + per-row chip cells. The chip cells share
// definitions with bulk_section so we parse them together every time.
@@ -903,6 +910,8 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
entry = "dashboard-section"
case "timeline_section":
entry = "timeline-section"
case "calendar_section":
entry = "calendar-section"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, entry, data); err != nil {

View File

@@ -761,18 +761,32 @@ details.proj-section > summary.proj-section-summary small { font-weight: 400; }
.calendar-header h1 { margin: 0; }
.calendar-nav {
display: inline-flex;
gap: 8px;
gap: 6px;
align-items: baseline;
}
.calendar-nav a {
color: var(--muted);
text-decoration: none;
padding: 2px 8px;
padding: 2px 10px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.9em;
font-variant-numeric: tabular-nums;
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.calendar-nav a:hover { color: var(--accent); border-color: var(--accent); }
.calendar-nav .today { background: var(--surface-alt, transparent); }
.calendar-nav .today { font-weight: 600; }
#calendar-filterbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
margin: 8px 0 12px;
}
#calendar-filterbar .counts {
margin: 0 0 0 auto;
align-self: center;
}
.calendar-grid {
width: 100%;
border-collapse: collapse;
@@ -792,8 +806,11 @@ details.proj-section > summary.proj-section-summary small { font-weight: 400; }
padding: 4px 6px;
height: 110px;
width: 14.2857%; /* 1/7 */
transition: background 0.12s, border-color 0.12s;
}
.calendar-cell.adjacent-month { opacity: 0.4; }
.calendar-cell:hover { background: var(--surface-alt, rgba(127, 127, 127, 0.06)); }
.calendar-cell.adjacent-month { opacity: 0.45; }
.calendar-cell.adjacent-month:hover { opacity: 0.7; }
.calendar-cell.is-today {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
@@ -802,35 +819,44 @@ details.proj-section > summary.proj-section-summary small { font-weight: 400; }
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 4px;
margin-bottom: 4px;
}
.cell-header .day-num {
font-size: 0.95em;
font-weight: 600;
}
.cell-header .day-label {
/* Hidden on desktop — only the day number is needed when the weekday
header runs across the top of the grid. The mobile breakpoint shows
it. */
display: none;
}
.today-pill {
font-size: 0.7em;
font-size: 0.65em;
background: var(--accent);
color: var(--accent-fg);
padding: 1px 6px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.05em;
line-height: 1.4;
}
.cell-rows { list-style: none; padding: 0; margin: 0; }
.cell-row {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 0.85em;
font-size: 0.82em;
padding: 1px 4px;
border-radius: 3px;
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
}
.cell-row .time { color: var(--muted); font-variant-numeric: tabular-nums; }
.cell-row .time { color: var(--muted); font-variant-numeric: tabular-nums; min-width: 32px; }
.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; }
@@ -844,3 +870,37 @@ details.proj-section > summary.proj-section-summary small { font-weight: 400; }
text-decoration: none;
}
.cell-more:hover { color: var(--accent); text-decoration: underline; }
/* Mobile breakpoint — collapse the 7-column grid to a vertical list. A
horizontal week grid on a 360px-wide phone is unreadable; stacking each
day as its own row keeps the rows full-width and surfaces the long
weekday label that was hidden on desktop. Adjacent-month cells drop
out completely on mobile so the list is calendar-scoped. */
@media (max-width: 480px) {
.calendar-grid, .calendar-grid thead, .calendar-grid tbody,
.calendar-grid tr, .calendar-grid th, .calendar-grid td {
display: block;
width: 100%;
box-sizing: border-box;
}
.calendar-grid thead { display: none; }
.calendar-week { margin-bottom: 4px; }
.calendar-cell {
height: auto;
min-height: 48px;
padding: 6px 10px;
margin-bottom: 4px;
}
.calendar-cell.adjacent-month { display: none; }
.cell-header .day-label {
display: inline;
font-size: 0.95em;
color: var(--muted);
margin-left: 6px;
}
.cell-header .day-num { font-size: 1.1em; }
.cell-row { font-size: 0.95em; padding: 4px 6px; }
.calendar-nav { flex-wrap: wrap; }
#calendar-filterbar { gap: 8px; }
#calendar-filterbar .counts { margin-left: 0; }
}

View File

@@ -1,58 +1,13 @@
{{define "content"}}
<section class="calendar" id="calendar-section">
<section class="calendar">
<header class="calendar-header">
<h1>{{.P.MonthLabel}}</h1>
<nav class="calendar-nav" aria-label="Monthsnavigation">
<nav class="calendar-nav" aria-label="Monatsnavigation">
<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>
{{template "calendar-section" .}}
</section>
{{end}}

View File

@@ -0,0 +1,91 @@
{{define "calendar-section"}}
<div id="calendar-section" class="calendar-section">
<section class="tagbar" id="calendar-filterbar">
<form id="calendar-filter" class="search"
hx-get="/calendar"
hx-target="#calendar-section"
hx-swap="outerHTML"
hx-trigger="change from:select"
hx-push-url="true">
<input type="hidden" name="month" value="{{.P.MonthKey}}">
<label>tag&nbsp;
<select name="tag" multiple size="3">
{{range $.Filter.Tags}}<option value="{{.}}" selected>{{.}}</option>{{end}}
</select>
</label>
<label>mgmt&nbsp;
<select name="mgmt" multiple size="3">
{{$selM := .Filter.Management}}
<option value="mai" {{if contains $selM "mai"}}selected{{end}}>mai</option>
<option value="self" {{if contains $selM "self"}}selected{{end}}>self</option>
<option value="external" {{if contains $selM "external"}}selected{{end}}>external</option>
</select>
</label>
<label>has&nbsp;
<select name="has" multiple size="2">
{{$selH := .Filter.HasLinks}}
<option value="caldav-list" {{if contains $selH "caldav-list"}}selected{{end}}>caldav</option>
<option value="gitea-repo" {{if contains $selH "gitea-repo"}}selected{{end}}>gitea</option>
</select>
</label>
<label>kind&nbsp;
<select name="kind" multiple size="3">
{{$selK := .P.Kinds}}
<option value="event" {{if contains $selK "event"}}selected{{end}}>event</option>
<option value="todo" {{if contains $selK "todo"}}selected{{end}}>todo</option>
<option value="doc" {{if contains $selK "doc"}}selected{{end}}>doc</option>
</select>
</label>
{{if .Filter.Active}}<a class="clear" href="/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
</form>
<p class="counts muted">
<small>{{.P.TotalRows}} {{if eq .P.TotalRows 1}}row{{else}}rows{{end}}</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}}
</p>
</section>
<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>
<span class="day-label">{{.LongLabel}}</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>
</div>
{{end}}