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:
439
web/calendar.go
Normal file
439
web/calendar.go
Normal 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
|
||||
}
|
||||
181
web/calendar_integration_test.go
Normal file
181
web/calendar_integration_test.go
Normal 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
223
web/calendar_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
58
web/templates/calendar.tmpl
Normal file
58
web/templates/calendar.tmpl
Normal 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}}&{{.}}{{end}}">< {{.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}}&{{.}}{{end}}">{{.P.NextMonth}} ></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}}&to={{.DateKey}}">+{{.ExtraCount}} more</a>
|
||||
{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user