diff --git a/README.md b/README.md index 6821531..cf27f7a 100644 --- a/README.md +++ b/README.md @@ -580,11 +580,20 @@ reloads it afterwards. In Vim mode, `Ctrl+Z` suspends to the shell. `g` regroups by status, priority, due date, folder or `field:`. `:boardoptions` (also F2 → Board options) sets the folder root, column labels, custom statuses and card order. Folder boards keep cross-folder moves - disabled. On the calendar Enter lists a day's tasks and `o` opens its - daily note. Filters match the desktop's substring search over task text, + disabled. Filters match the desktop's substring search over task text, note titles, `!priority`, `#tags` and `@fields`. `:filters` / `:savefilter` manage shared named queries. Prefix terminal operators with `where:`, for example `where: is:open due:today -meeting`. + The calendar has month, week and day views; `Esc` cycles them, and a + start in `calendar` mode always opens the month grid. `h`/`j`/`k`/`l` + move the day, `[`/`]` the month, `t` goes to today, ``/`` page + by half a view, and `o` opens the day's daily note. Week and day view + show an hour list: hourly by default, half-hourly after `z` (Vim mode), + scrolling with `j`/`k` when it does not fit. `Enter` and `Tab` move focus + between the day grid, the selected day's items and the No-date strip, and + `T` on a focused item or strip task opens a due-date menu. Unlike the + desktop calendar, the terminal calendar also places a task on its + scheduled date (marked) as well as its due date. - **Tags**: `Tab` multi-selects, `a` toggles any/all, `r`/`x` rename or remove a tag everywhere. - **Databases**: every `.base` folder and every loose `.csv` file, with diff --git a/internal/tui/calendar_entries.go b/internal/tui/calendar_entries.go new file mode 100644 index 0000000..73c033b --- /dev/null +++ b/internal/tui/calendar_entries.go @@ -0,0 +1,67 @@ +package tui + +import ( + "sort" + "strings" + + "github.com/ZenNotes/tui/internal/vault" +) + +// calEntry is one task placed on a calendar day. scheduled is true when the +// task's scheduled date placed it rather than its due date. +type calEntry struct { + task vault.Task + scheduled bool +} + +// calPriorityRank orders the strip: high, medium, low, then none. +var calPriorityRank = map[string]int{"high": 0, "med": 1, "low": 2} + +func calRank(priority string) int { + if r, ok := calPriorityRank[priority]; ok { + return r + } + return len(calPriorityRank) +} + +// bucketCalendarEntries places open tasks on the days of their due and +// scheduled dates. A task whose two dates are the same day appears once, as a +// due entry. On each day due entries come before scheduled ones, and each +// group keeps the input order. Tasks with neither date are returned as +// undated, ordered by priority and then by lower-cased text, ties keeping +// the input order. Closed, cancelled and forwarded tasks are left out. +func bucketCalendarEntries(tasks []vault.Task) (map[string][]calEntry, []vault.Task) { + due := map[string][]calEntry{} + sched := map[string][]calEntry{} + var undated []vault.Task + for _, t := range tasks { + if !vault.IsTaskOpen(t) || t.Forwarded { + continue + } + if t.Due == "" && t.Scheduled == "" { + undated = append(undated, t) + continue + } + if t.Due != "" { + due[t.Due] = append(due[t.Due], calEntry{task: t}) + } + if t.Scheduled != "" && t.Scheduled != t.Due { + sched[t.Scheduled] = append(sched[t.Scheduled], calEntry{task: t, scheduled: true}) + } + } + byDay := make(map[string][]calEntry, len(due)+len(sched)) + for day, entries := range due { + byDay[day] = append(byDay[day], entries...) + } + for day, entries := range sched { + byDay[day] = append(byDay[day], entries...) + } + sort.SliceStable(undated, func(i, j int) bool { + ri, rj := calRank(undated[i].Priority), calRank(undated[j].Priority) + if ri != rj { + return ri < rj + } + return strings.ToLower(undated[i].Content) < strings.ToLower(undated[j].Content) + }) + return byDay, undated +} diff --git a/internal/tui/calendar_entries_test.go b/internal/tui/calendar_entries_test.go new file mode 100644 index 0000000..ca4dbd8 --- /dev/null +++ b/internal/tui/calendar_entries_test.go @@ -0,0 +1,199 @@ +package tui + +import ( + "reflect" + "testing" + + "github.com/ZenNotes/tui/internal/vault" +) + +// entryIDs lists the task ids of a day's entries in order. +func entryIDs(entries []calEntry) []string { + out := []string{} + for _, e := range entries { + out = append(out, e.task.ID) + } + return out +} + +func taskIDs(tasks []vault.Task) []string { + out := []string{} + for _, t := range tasks { + out = append(out, t.ID) + } + return out +} + +func TestCalendarEntriesDueOnly(t *testing.T) { + tasks := []vault.Task{{ID: "a", Content: "a", Due: "2026-11-18"}} + byDay, undated := bucketCalendarEntries(tasks) + got := byDay["2026-11-18"] + if len(got) != 1 || got[0].task.ID != "a" || got[0].scheduled { + t.Errorf("entries on 2026-11-18 = %+v, want one due entry for a", got) + } + if len(undated) != 0 { + t.Errorf("undated = %v, want none", taskIDs(undated)) + } + if len(byDay) != 1 { + t.Errorf("days with entries = %d, want 1", len(byDay)) + } +} + +func TestCalendarEntriesInferredDailyDue(t *testing.T) { + raw := []vault.Task{{ID: "daily#0", SourcePath: "inbox/2026-11-18.md", Content: "from the daily note"}} + tasks := vault.InferDailyTaskDueDates(raw, map[string]string{"inbox/2026-11-18.md": "2026-11-18"}) + byDay, undated := bucketCalendarEntries(tasks) + got := byDay["2026-11-18"] + if len(got) != 1 || got[0].task.ID != "daily#0" || got[0].scheduled { + t.Errorf("entries on 2026-11-18 = %+v, want one due entry for daily#0", got) + } + if len(undated) != 0 { + t.Errorf("undated = %v, want none", taskIDs(undated)) + } +} + +func TestCalendarEntriesDueAndScheduledOnTwoDays(t *testing.T) { + tasks := []vault.Task{{ID: "a", Content: "a", Due: "2026-11-18", Scheduled: "2026-11-16"}} + byDay, undated := bucketCalendarEntries(tasks) + due := byDay["2026-11-18"] + if len(due) != 1 || due[0].task.ID != "a" || due[0].scheduled { + t.Errorf("entries on 2026-11-18 = %+v, want one due entry", due) + } + sched := byDay["2026-11-16"] + if len(sched) != 1 || sched[0].task.ID != "a" || !sched[0].scheduled { + t.Errorf("entries on 2026-11-16 = %+v, want one scheduled entry", sched) + } + if len(undated) != 0 { + t.Errorf("undated = %v, want none", taskIDs(undated)) + } +} + +func TestCalendarEntriesSameDayShowsOnce(t *testing.T) { + tasks := []vault.Task{{ID: "a", Content: "a", Due: "2026-11-18", Scheduled: "2026-11-18"}} + byDay, _ := bucketCalendarEntries(tasks) + got := byDay["2026-11-18"] + if len(got) != 1 || got[0].task.ID != "a" { + t.Errorf("entries on 2026-11-18 = %+v, want exactly one for a", got) + } +} + +func TestCalendarEntriesScheduledOnly(t *testing.T) { + tasks := []vault.Task{{ID: "a", Content: "a", Scheduled: "2026-11-16"}} + byDay, undated := bucketCalendarEntries(tasks) + got := byDay["2026-11-16"] + if len(got) != 1 || got[0].task.ID != "a" || !got[0].scheduled { + t.Errorf("entries on 2026-11-16 = %+v, want one scheduled entry", got) + } + if len(undated) != 0 { + t.Errorf("undated = %v, want none: a scheduled task is placed", taskIDs(undated)) + } +} + +func TestCalendarEntriesHideClosedTasks(t *testing.T) { + tasks := []vault.Task{ + {ID: "checked", Checked: true, Due: "2026-11-18"}, + {ID: "cancelled", Cancelled: true, Due: "2026-11-18"}, + {ID: "forwarded", Forwarded: true, Due: "2026-11-18"}, + {ID: "closed-sched", Checked: true, Scheduled: "2026-11-16"}, + {ID: "closed-undated", Cancelled: true}, + } + byDay, undated := bucketCalendarEntries(tasks) + if len(byDay) != 0 { + t.Errorf("days with entries = %v, want none", byDay) + } + if len(undated) != 0 { + t.Errorf("undated = %v, want none", taskIDs(undated)) + } +} + +func TestCalendarEntriesUndatedGoToStrip(t *testing.T) { + tasks := []vault.Task{ + {ID: "u", Content: "u"}, + {ID: "d", Content: "d", Due: "2026-11-18"}, + } + byDay, undated := bucketCalendarEntries(tasks) + if got := taskIDs(undated); !reflect.DeepEqual(got, []string{"u"}) { + t.Errorf("undated = %v, want [u]", got) + } + for day, entries := range byDay { + for _, e := range entries { + if e.task.ID == "u" { + t.Errorf("undated task listed on %s", day) + } + } + } +} + +func TestCalendarEntriesDueBeforeScheduled(t *testing.T) { + // Scan order interleaves scheduled and due entries for one day. + tasks := []vault.Task{ + {ID: "s1", Scheduled: "2026-11-18"}, + {ID: "d1", Due: "2026-11-18"}, + {ID: "s2", Scheduled: "2026-11-18"}, + {ID: "d2", Due: "2026-11-18"}, + } + byDay, _ := bucketCalendarEntries(tasks) + if got, want := entryIDs(byDay["2026-11-18"]), []string{"d1", "d2", "s1", "s2"}; !reflect.DeepEqual(got, want) { + t.Errorf("order on 2026-11-18 = %v, want %v", got, want) + } +} + +func TestCalendarEntriesKeepScanOrderWithinGroup(t *testing.T) { + // Ids and text are deliberately not in alphabetical order. + tasks := []vault.Task{ + {ID: "z", Content: "zulu", Due: "2026-11-18"}, + {ID: "m", Content: "mike", Due: "2026-11-18"}, + {ID: "a", Content: "alpha", Due: "2026-11-18"}, + {ID: "y", Content: "yankee", Scheduled: "2026-11-18"}, + {ID: "b", Content: "bravo", Scheduled: "2026-11-18"}, + } + byDay, _ := bucketCalendarEntries(tasks) + if got, want := entryIDs(byDay["2026-11-18"]), []string{"z", "m", "a", "y", "b"}; !reflect.DeepEqual(got, want) { + t.Errorf("order on 2026-11-18 = %v, want %v", got, want) + } +} + +func TestCalendarStripOrderPriorityThenText(t *testing.T) { + tasks := []vault.Task{ + {ID: "beta", Content: "beta"}, + {ID: "Alpha", Content: "Alpha", Priority: "low"}, + {ID: "gamma", Content: "gamma", Priority: "high"}, + {ID: "delta", Content: "delta", Priority: "high"}, + } + _, undated := bucketCalendarEntries(tasks) + if got, want := taskIDs(undated), []string{"delta", "gamma", "Alpha", "beta"}; !reflect.DeepEqual(got, want) { + t.Errorf("strip order = %v, want %v", got, want) + } +} + +func TestCalendarStripOrderMedBetweenHighAndLow(t *testing.T) { + tasks := []vault.Task{ + {ID: "low", Content: "a", Priority: "low"}, + {ID: "none", Content: "a"}, + {ID: "med", Content: "z", Priority: "med"}, + {ID: "high", Content: "z", Priority: "high"}, + } + _, undated := bucketCalendarEntries(tasks) + if got, want := taskIDs(undated), []string{"high", "med", "low", "none"}; !reflect.DeepEqual(got, want) { + t.Errorf("strip order = %v, want %v", got, want) + } +} + +func TestCalendarStripOrderTiesKeepScanOrder(t *testing.T) { + tasks := []vault.Task{ + {ID: "first", Content: "same", Priority: "med"}, + {ID: "second", Content: "SAME", Priority: "med"}, + {ID: "third", Content: "same", Priority: "med"}, + } + _, undated := bucketCalendarEntries(tasks) + if got, want := taskIDs(undated), []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) { + t.Errorf("strip order = %v, want %v", got, want) + } +} + +func TestCalendarEntriesEmptyInput(t *testing.T) { + byDay, undated := bucketCalendarEntries(nil) + if len(byDay) != 0 || len(undated) != 0 { + t.Errorf("empty input gave days=%v undated=%v", byDay, taskIDs(undated)) + } +} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 7fda616..5c10ddf 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -505,8 +505,13 @@ func (a *App) mouseRight(m tea.MouseMsg) { return } if t.view != nil { + inner := a.contentRect(hit.pane) + x, y := m.X-inner.x, m.Y-inner.y + if x < 0 || y < 0 || x >= inner.w || y >= inner.h { + return + } if c, ok := t.view.(clicker); ok { - c.click(a, hit.pane, m.X-hit.pane.rect.x, m.Y-hit.pane.rect.y-a.tabBarHeight(), m, false) + c.click(a, hit.pane, x, y, m, false) } return } diff --git a/internal/tui/mouse_right_test.go b/internal/tui/mouse_right_test.go new file mode 100644 index 0000000..43f3baf --- /dev/null +++ b/internal/tui/mouse_right_test.go @@ -0,0 +1,38 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// A right-click measures from the pane's content rectangle, like a left-click, +// so a framed pane's border and padding do not shift it. +func TestRightClickMeasuresFromContentRect(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, calMonth) + cr := a.contentRect(a.activePane) + lines := strings.Split(stripAnsi(tv.render(a, cr.w, cr.h, true)), "\n") + r := tv.calc().layout.items + var x, y int + found := false + for ly := r.y; ly < r.y+r.h && ly+1 < len(lines); ly++ { + if i := strings.Index(lines[ly+1], "bravo job"); i >= 0 { + x, y, found = lipgloss.Width(lines[ly+1][:i]), ly+1, true + break + } + } + if !found { + t.Fatalf("bravo job is not listed in the item panel:\n%s", strings.Join(lines, "\n")) + } + a.handleMouse(tea.MouseMsg{X: cr.x + x, Y: cr.y + y, Button: tea.MouseButtonRight, Action: tea.MouseActionPress}) + m, ok := a.overlay.(*menu) + if !ok { + t.Fatalf("overlay = %T, want the task menu", a.overlay) + } + if !strings.Contains(m.title, "bravo job") { + t.Fatalf("menu title = %q, want the bravo job task", m.title) + } +} diff --git a/internal/tui/sidepanel_calendar_guard_test.go b/internal/tui/sidepanel_calendar_guard_test.go new file mode 100644 index 0000000..b9f9700 --- /dev/null +++ b/internal/tui/sidepanel_calendar_guard_test.go @@ -0,0 +1,108 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/vault" +) + +func guardCalendarApp(t *testing.T) *App { + t.Helper() + a, _ := newFeatureTestApp(t) + a.idx.tasks = []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "pay rent", Due: "2026-11-18"}, + {ID: "n#1", SourcePath: "n.md", Content: "call mum", Due: "2026-11-18", Scheduled: "2026-11-16"}, + {ID: "n#2", SourcePath: "n.md", Content: "scheduled only", Scheduled: "2026-11-20"}, + {ID: "n#3", SourcePath: "n.md", Content: "no date"}, + {ID: "n#4", SourcePath: "n.md", Content: "later", Due: "2026-11-25"}, + } + a.calendarOpen = true + a.calendar.month = time.Date(2026, 11, 1, 0, 0, 0, 0, time.Local) + a.calendar.day = time.Date(2026, 11, 18, 0, 0, 0, 0, time.Local) + a.calendar.hasDaily = nil + a.prefs.CalendarWeekStart = "monday" + a.prefs.CalendarShowWeekNumbers = false + return a +} + +// The side calendar panel keeps its compact grid, its digit markers and its +// day list whatever the Tasks tab does with its own calendar. +func TestSideCalendarPanelRenderUnchanged(t *testing.T) { + a := guardCalendarApp(t) + got := stripAnsi(a.calendar.render(a, 40, 16)) + var trimmed []string + for _, l := range strings.Split(got, "\n") { + trimmed = append(trimmed, strings.TrimRight(l, " ")) + } + want := strings.Join([]string{ + " Calendar", + " November 2026", + " Mo Tu We Th Fr Sa Su", + " 26 27 28 29 30 31 1", + " 2 3 4 5 6 7 8", + " 9 10 11 12 13 14 15", + " 16 17 18 2 19 20 21 22", + " 23 24 25 1 26 27 28 29", + " 30 1 2 3 4 5 6", + "", + " No daily note. Enter creates it.", + " ☐ pay rent", + " ☐ call mum", + "", + "", + "", + }, "\n") + if strings.Join(trimmed, "\n") != want { + t.Errorf("side calendar render changed:\n%s", strings.Join(trimmed, "\n")) + } +} + +// dayAtCell maps clicks on the side panel's grid to days, with and without +// the week-number column. +func TestSideCalendarPanelClickUnchanged(t *testing.T) { + for _, weekNumbers := range []bool{false, true} { + a := guardCalendarApp(t) + a.prefs.CalendarShowWeekNumbers = weekNumbers + a.calendar.render(a, 40, 16) + first, weeks := a.calendar.calFirst, a.calendar.calWeeks + if first.Format("2006-01-02") != "2026-10-26" || weeks != 6 { + t.Fatalf("grid = %s x %d weeks, want 2026-10-26 x 6", first.Format("2006-01-02"), weeks) + } + offset := 0 + if weekNumbers { + offset = 4 + } + type cell struct { + name string + x, y int + want string + ok bool + } + cases := []cell{ + {"first cell", offset, 2, "2026-10-26", true}, + {"second column", offset + 5, 2, "2026-10-27", true}, + {"last column", offset + 30, 2, "2026-11-01", true}, + {"third row", offset + 10, 4, "2026-11-11", true}, + {"last row", offset + 15, 7, "2026-12-03", true}, + {"title row", offset, 0, "", false}, + {"header row", offset, 1, "", false}, + {"below grid", offset, 8, "", false}, + {"right of grid", offset + 35, 2, "", false}, + } + if weekNumbers { + cases = append(cases, cell{"week number column", 2, 2, "", false}) + } + for _, tc := range cases { + day, ok := dayAtCell(first, weeks, tc.x, tc.y, weekNumbers) + got := "" + if ok { + got = day.Format("2006-01-02") + } + if ok != tc.ok || got != tc.want { + t.Errorf("weekNumbers=%v %s: dayAtCell(%d,%d) = %q,%v want %q,%v", weekNumbers, tc.name, tc.x, tc.y, got, ok, tc.want, tc.ok) + } + } + } +} diff --git a/internal/tui/views_hints.go b/internal/tui/views_hints.go index d483e54..48b3423 100644 --- a/internal/tui/views_hints.go +++ b/internal/tui/views_hints.go @@ -9,7 +9,7 @@ func (v *tasksView) hintPairs(a *App) []hintPair { case "kanban": return append([]hintPair{{"key:h/l", "column"}, {"key:j/k", "card"}, {"key:H/L", "move card"}, {"nav.toggleTask", "toggle"}, {"key:Enter", "open"}, {"key:g", "group by"}, {"key:m", "menu"}}, common...) case "calendar": - return append([]hintPair{{"key:h/j/k/l", "day"}, {"key:[/]", "month"}, {"key:t", "today"}, {"key:Enter", "day tasks"}, {"key:o", "daily note"}}, common...) + return append([]hintPair{{"key:h/j/k/l", "day"}, {"key:[/]", "month"}, {"key:t", "today"}, {"key:Esc", "month/week/day"}, {"key:Tab", "focus"}, {"key:Enter", "day tasks"}, {"key:o", "daily note"}, {"key:z", "hour step"}, {"key:T", "due menu"}}, common...) } return append([]hintPair{{"nav.moveDown|nav.moveUp", "move"}, {"nav.toggleTask", "toggle"}, {"nav.openResult", "open"}, {"key:p", "priority"}, {"key:d", "due"}, {"key:w", "waiting"}, {"key:c", "cancel"}, {"key:/", "in progress"}, {"key:e", "edit"}, {"key:s", "field"}, {"key:", "shift due"}, {"tasks.moveTaskUp|tasks.moveTaskDown", "reorder"}, {"nav.contextMenu", "menu"}}, common...) } @@ -46,6 +46,10 @@ func (v *tasksView) scrollBy(a *App, delta int) { v.card = max(0, min(max(0, len(v.columns[v.col].cards)-1), v.card+delta)) } case "calendar": + if c := v.calc(); c.sub != calMonth && c.visibleHourRows() > 0 && !c.hourListWhole() { + c.scrollHours(sign(delta)) + return + } v.selectDay(a, v.day.AddDate(0, 0, 7*sign(delta))) default: v.list.move(delta, len(v.rows)) diff --git a/internal/tui/views_mouse.go b/internal/tui/views_mouse.go index c48a085..d313344 100644 --- a/internal/tui/views_mouse.go +++ b/internal/tui/views_mouse.go @@ -41,29 +41,7 @@ func (v *tasksView) click(a *App, p *pane, x, y int, m tea.MouseMsg, double bool a.openTaskSource(t) } case "calendar": - gridX := x - 1 - gridY := y - 1 - if day, ok := dayAtCell(v.calFirst, v.calWeeks, gridX, gridY, a.prefs.CalendarShowWeekNumbers); ok { - v.selectDay(a, day) - v.dayFocus = false - if double { - v.calendarKey(a, vim.KeyEnter) - } - return - } - listTop := 1 + 2 + v.calWeeks + 1 - idx := y - listTop - if idx >= 0 && idx < len(v.dayTasks) { - v.dayFocus = true - v.dayCursor = idx - t := v.dayTasks[idx] - switch { - case right: - a.taskMenu(t) - case double: - a.openTaskSource(t) - } - } + v.calc().click(a, v, x, y-1, right, double) default: idx := v.list.scroll + y - 1 if idx < 0 || idx >= len(v.rows) || v.rows[idx].task == nil { @@ -170,10 +148,8 @@ func (v *tasksView) selectionPos(a *App, p *pane) (int, int, bool) { } return a.contentRect(p).x + v.col*(colW+1) + 2, top + 1 + 2 + (v.card-start)*3 + 1, true case "calendar": - if v.dayFocus { - return a.contentRect(p).x + 4, top + 1 + 2 + v.calWeeks + 1 + v.dayCursor + 1, true - } - return a.contentRect(p).x + 8, top + 1 + 2 + 1, true + cx, cy := v.calc().selectionCell(v.day) + return a.contentRect(p).x + cx, top + 1 + cy, true } return a.contentRect(p).x + 4, top + 1 + (v.list.cursor - v.list.scroll) + 1, true } diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index b5fd016..4603c34 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -28,17 +28,12 @@ type tasksView struct { col int card int - month time.Time - day time.Time - dayTasks []vault.Task - dayCursor int - dayFocus bool - byDay map[string][]vault.Task - pageRows int + month time.Time + day time.Time + cal *taskCalendar + pageRows int boardStarts []int - calFirst time.Time - calWeeks int } type taskRow struct { @@ -69,6 +64,14 @@ func newTasksView(a *App) *tasksView { return v } +// calc is the calendar state, made on first use. +func (v *tasksView) calc() *taskCalendar { + if v.cal == nil { + v.cal = newTaskCalendar() + } + return v.cal +} + func (v *tasksView) title() string { return "Tasks" } func (v *tasksView) hint(a *App) string { return a.keysHint(v.hintPairs(a)) } @@ -79,6 +82,7 @@ func (v *tasksView) setMode(mode string) { v.mode = "kanban" case "calendar": v.mode = "calendar" + v.calc().half = false default: v.mode = "list" } @@ -120,11 +124,7 @@ func (v *tasksView) refresh(a *App) { v.list.cursor++ } v.buildColumns(a) - v.byDay = vault.BucketTasksByDueDate(v.tasks) - v.dayTasks = v.byDay[v.day.Format("2006-01-02")] - if v.dayCursor >= len(v.dayTasks) { - v.dayCursor = max(0, len(v.dayTasks)-1) - } + v.calc().rebuild(v.tasks, v.day) } // filterTasks applies the cross-view filter: free words match content and @@ -499,7 +499,7 @@ func (v *tasksView) render(a *App, w, h int, focused bool) string { case "kanban": body = v.renderBoard(a, w, h-1, focused) case "calendar": - body = v.renderCalendar(a, w, h-1, focused) + body = v.calc().render(a, v.month, v.day, w, h-1, focused) default: body = v.renderList(a, w, h-1, focused) } @@ -662,39 +662,6 @@ func (v *tasksView) renderBoard(a *App, w, h int, focused bool) string { return lipgloss.JoinHorizontal(lipgloss.Top, blocks...) } -func (v *tasksView) renderCalendar(a *App, w, h int, focused bool) string { - th := a.theme - weekStartMonday := a.prefs.CalendarWeekStart != "sunday" - grid, first, weeks := renderMonthGrid(a, v.month, v.day, weekStartMonday, a.prefs.CalendarShowWeekNumbers, func(day time.Time) (string, lipgloss.Style) { - tasks := v.byDay[day.Format("2006-01-02")] - open := 0 - for _, t := range tasks { - if !t.Checked && !t.Cancelled && !t.Forwarded { - open++ - } - } - if open == 0 { - return "", th.Base - } - return fmt.Sprint(open), th.Marker - }, focused && !v.dayFocus) - v.calFirst, v.calWeeks = first, weeks - gridLines := strings.Split(grid, "\n") - lines := []string{} - for _, gl := range gridLines { - lines = append(lines, padRight(" "+gl, w)) - } - lines = append(lines, padRight(th.Bold.Render(" "+v.day.Format("Monday, January 2")+fmt.Sprintf(" (%d tasks)", len(v.dayTasks))), w)) - listH := h - len(lines) - if len(v.dayTasks) == 0 { - lines = append(lines, padRight(" "+th.Muted.Render("No tasks due this day. n adds one to the daily note."), w)) - } - for i := 0; i < len(v.dayTasks) && i < listH; i++ { - lines = append(lines, v.taskLine(a, v.dayTasks[i], w, focused && v.dayFocus && i == v.dayCursor)) - } - return fitBlock(strings.Join(lines, "\n"), w, h) -} - // renderMonthGrid draws a month with a marker per day. func renderMonthGrid(a *App, month, selected time.Time, mondayFirst, weekNumbers bool, mark func(day time.Time) (string, lipgloss.Style), focused bool) (string, time.Time, int) { th := a.theme @@ -792,6 +759,9 @@ func (v *tasksView) handleKey(a *App, k vim.Key) bool { default: v.mode = "list" } + if v.mode == "calendar" { + v.calc().half = false + } a.prefs.TasksViewMode = v.mode v.refresh(a) return true @@ -847,11 +817,7 @@ func (v *tasksView) selectedTask() *vault.Task { } return nil case "calendar": - if v.dayFocus && v.dayCursor < len(v.dayTasks) { - t := v.dayTasks[v.dayCursor] - return &t - } - return nil + return v.calc().selected(v.day) } if v.list.cursor < len(v.rows) { return v.rows[v.list.cursor].task @@ -967,7 +933,7 @@ func (v *tasksView) taskEditKey(a *App, k vim.Key, t *vault.Task) bool { } func (v *tasksView) boardKey(a *App, k vim.Key) bool { - if !a.listKeyAllowed(k) && !k.Is("enter") && !k.Is("left") && !k.Is("right") && !k.Is("up") && !k.Is("down") { + if !a.listKeyAllowed(k) && !k.Is("enter") && !k.Is("tab") && !k.Is("left") && !k.Is("right") && !k.Is("up") && !k.Is("down") { return false } t := v.selectedTask() @@ -1080,17 +1046,31 @@ func (v *tasksView) moveCard(a *App, t vault.Task, target int) { func (v *tasksView) calendarKey(a *App, k vim.Key) bool { t := v.selectedTask() - if v.dayFocus { + if k.IsRune('z') && a.prefs.VimMode && v.calc().sub != calMonth { + v.calc().toggleHalf() + return true + } + if v.calc().focus != calFocusGrid { switch { case k.Is("esc") || k.IsRune('h') || k.Is("left"): - v.dayFocus = false + v.calc().focus = calFocusGrid + case k.Is("tab"): + v.calc().cycleFocus(v.day) case k.IsRune('j') || k.Is("down"): - if v.dayCursor < len(v.dayTasks)-1 { - v.dayCursor++ + if c := v.calc(); c.focus == calFocusStrip { + if c.strip < len(c.undated)-1 { + c.strip++ + } + } else if c.cursor < len(c.dayEntries(v.day))-1 { + c.cursor++ } case k.IsRune('k') || k.Is("up"): - if v.dayCursor > 0 { - v.dayCursor-- + if c := v.calc(); c.focus == calFocusStrip { + if c.strip > 0 { + c.strip-- + } + } else if c.cursor > 0 { + c.cursor-- } case k.Is("enter") || k.IsRune('o'): if t != nil { @@ -1104,6 +1084,10 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { if t != nil { a.taskMenu(*t) } + case k.IsRune('T'): + if t != nil { + v.calendarDueMenu(a, *t) + } default: return v.taskEditKey(a, k, t) } @@ -1112,7 +1096,18 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { if !a.listKeyAllowed(k) && !k.Is("enter") && !k.Is("left") && !k.Is("right") && !k.Is("up") && !k.Is("down") { return false } + hourList := v.calc().sub != calMonth switch { + case k.Is("esc"): + v.calc().sub = (v.calc().sub + 1) % 3 + case hourList && (k.IsRune('j') || k.Is("down")): + if !v.calc().scrollColumn(v.day, 1) { + v.calc().scrollHours(1) + } + case hourList && (k.IsRune('k') || k.Is("up")): + if !v.calc().scrollColumn(v.day, -1) { + v.calc().scrollHours(-1) + } case k.IsRune('h') || k.Is("left"): v.selectDay(a, v.day.AddDate(0, 0, -1)) case k.IsRune('l') || k.Is("right"): @@ -1121,6 +1116,10 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { v.selectDay(a, v.day.AddDate(0, 0, 7)) case k.IsRune('k') || k.Is("up"): v.selectDay(a, v.day.AddDate(0, 0, -7)) + case a.bound(k, "nav.halfPageUp"): + v.pageDay(a, -1) + case a.bound(k, "nav.halfPageDown"): + v.pageDay(a, 1) case k.IsRune('[') || k.IsRune('H'): v.month = v.month.AddDate(0, -1, 0) v.selectDay(a, v.day.AddDate(0, -1, 0)) @@ -1128,14 +1127,13 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { v.month = v.month.AddDate(0, 1, 0) v.selectDay(a, v.day.AddDate(0, 1, 0)) case k.IsRune('t') || k.IsRune('T'): - now := time.Now() + now := calendarNow() v.month = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local) v.selectDay(a, now) + case k.Is("tab"): + return v.calc().cycleFocus(v.day) case k.Is("enter"): - if len(v.dayTasks) > 0 { - v.dayFocus = true - v.dayCursor = 0 - } else { + if !v.calc().cycleFocus(v.day) { a.openPeriodicOn(periodic.Daily, v.day) } case k.IsRune('o'): @@ -1148,13 +1146,44 @@ func (v *tasksView) calendarKey(a *App, k vim.Key) bool { return true } +// calendarDueMenu offers the due dates the calendar can set at once. The +// choices have no shortcut keys, so letters never act on the task by accident. +func (v *tasksView) calendarDueMenu(a *App, t vault.Task) { + now := calendarNow() + set := func(d time.Time) func(a *App) { + return func(a *App) { a.setTaskDue(t, vault.TodayISO(d)) } + } + a.showMenu("Due date", []menuItem{ + {label: "Today (" + vault.TodayISO(now) + ")", run: set(now)}, + {label: "Tomorrow (" + vault.TodayISO(now.AddDate(0, 0, 1)) + ")", run: set(now.AddDate(0, 0, 1))}, + {label: "Next week (" + vault.TodayISO(now.AddDate(0, 0, 7)) + ")", run: set(now.AddDate(0, 0, 7))}, + {label: "Selected day (" + vault.TodayISO(v.day) + ")", run: set(v.day)}, + {label: "Clear due date", run: func(a *App) { a.setTaskDue(t, "") }}, + {label: "Other…", run: func(a *App) { a.taskDuePrompt(t) }}, + }) +} + +// pageDay moves the selected day by one page of the current layout: a month, +// a week or a day. dir is -1 or 1. +func (v *tasksView) pageDay(a *App, dir int) { + switch v.calc().sub { + case calWeek: + v.selectDay(a, v.day.AddDate(0, 0, 7*dir)) + case calDay: + v.selectDay(a, v.day.AddDate(0, 0, dir)) + default: + v.month = v.month.AddDate(0, dir, 0) + v.selectDay(a, v.day.AddDate(0, dir, 0)) + } +} + func (v *tasksView) selectDay(a *App, day time.Time) { v.day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.Local) if v.day.Month() != v.month.Month() || v.day.Year() != v.month.Year() { v.month = time.Date(v.day.Year(), v.day.Month(), 1, 0, 0, 0, 0, time.Local) } - v.dayTasks = v.byDay[v.day.Format("2006-01-02")] - v.dayCursor = 0 + v.calc().cursor = 0 + v.calc().colScroll = 0 } func (v *tasksView) hintTargets(a *App, p *pane) []hintTarget { diff --git a/internal/tui/views_tasks_calendar.go b/internal/tui/views_tasks_calendar.go new file mode 100644 index 0000000..6e5a048 --- /dev/null +++ b/internal/tui/views_tasks_calendar.go @@ -0,0 +1,792 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +// calendarNow is the clock the calendar reads for "today", so tests can fix it. +var calendarNow = time.Now + +// calSub is the calendar's layout: a month of day boxes, or a week or a day +// of columns. +type calSub int + +const ( + calMonth calSub = iota + calWeek + calDay +) + +// calFocus is the part of the calendar that takes the keys. +type calFocus int + +const ( + calFocusGrid calFocus = iota + calFocusItems + calFocusStrip +) + +// calMin holds the smallest body (width, height) each layout renders in. +// Below it the body only says the terminal is too small. +var calMin = map[calSub][2]int{ + calMonth: {72, 18}, + calWeek: {64, 12}, + calDay: {40, 10}, +} + +// calScheduledMark prefixes an entry placed by its scheduled date. It must +// stay one cell wide. +const calScheduledMark = "»" + +// calRect is a region of the calendar body, in body cells. +type calRect struct{ x, y, w, h int } + +func (r calRect) contains(x, y int) bool { + return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h +} + +// calDayBox is where one day sits in the grid. +type calDayBox struct { + day time.Time + rect calRect +} + +// calLayout is every region of the calendar body for one size. Rendering, +// clicks and menu placement all read the last one, so the geometry is +// written once. +type calLayout struct { + w, h int + tooSmall bool + items calRect + strip calRect + boxes []calDayBox + itemStart int + stripRows int + // stripStart is the index of the first strip task shown. + stripStart int + // gutter is the width of the hour labels left of the week and day columns. + gutter int + // hourStart is the index of the first hour row shown, and hourTotal the + // number of rows in the whole list. + hourStart, hourTotal int +} + +// taskCalendar is the Tasks tab's calendar: day entries, the items beside +// the grid, and the strip of tasks without a date. +type taskCalendar struct { + sub calSub + focus calFocus + cursor int + strip int + // half selects half-hourly hour rows in place of hourly ones. + half bool + // scroll is the time of day, in minutes, of the first hour row shown. + // It is a time rather than a row index so it survives a change of + // variant. + scroll int + // colScroll is how many entries the selected day's column has scrolled + // off its top. It only applies while the hour list is shown whole. + colScroll int + + byDay map[string][]calEntry + undated []vault.Task + layout calLayout +} + +func newTaskCalendar() *taskCalendar { return &taskCalendar{} } + +// rebuild places the (already filtered) tasks and keeps the cursors inside +// the new lists. +func (c *taskCalendar) rebuild(tasks []vault.Task, day time.Time) { + c.byDay, c.undated = bucketCalendarEntries(tasks) + entries := c.dayEntries(day) + if c.cursor >= len(entries) { + c.cursor = max(0, len(entries)-1) + } + if c.focus == calFocusItems && len(entries) == 0 { + c.focus = calFocusGrid + } + if c.strip >= len(c.undated) { + c.strip = max(0, len(c.undated)-1) + } + if c.focus == calFocusStrip && len(c.undated) == 0 { + c.focus = calFocusGrid + } +} + +func (c *taskCalendar) dayEntries(day time.Time) []calEntry { + return c.byDay[day.Format("2006-01-02")] +} + +// focusOrder is the parts Tab visits, in order: the grid, then the selected +// day's items when it has any, then the strip of undated tasks when it has any. +func (c *taskCalendar) focusOrder(day time.Time) []calFocus { + order := []calFocus{calFocusGrid} + if len(c.dayEntries(day)) > 0 { + order = append(order, calFocusItems) + } + if len(c.undated) > 0 { + order = append(order, calFocusStrip) + } + return order +} + +// cycleFocus moves focus to the next part in focusOrder, wrapping around. It +// reports whether focus moved. +func (c *taskCalendar) cycleFocus(day time.Time) bool { + order := c.focusOrder(day) + if len(order) < 2 { + return false + } + next := order[0] + for i, f := range order { + if f == c.focus { + next = order[(i+1)%len(order)] + break + } + } + c.focus = next + if next == calFocusItems { + c.cursor = 0 + } else if next == calFocusStrip { + c.strip = 0 + } + return true +} + +// selected is the task the item panel or the strip has selected, if any. +func (c *taskCalendar) selected(day time.Time) *vault.Task { + switch c.focus { + case calFocusItems: + entries := c.dayEntries(day) + if c.cursor < len(entries) { + t := entries[c.cursor].task + return &t + } + case calFocusStrip: + if c.strip < len(c.undated) { + t := c.undated[c.strip] + return &t + } + } + return nil +} + +// monthLayout computes the regions of the month view for a body of w x h. +func (c *taskCalendar) monthLayout(a *App, month time.Time, w, h int) calLayout { + lay := calLayout{w: w, h: h} + if m := calMin[calMonth]; w < m[0] || h < m[1] { + lay.tooSmall = true + return lay + } + panelW := clampInt(w*30/100, 26, 44) + gw := w - panelW + wnW := 0 + if a.prefs.CalendarShowWeekNumbers { + wnW = 3 + } + colW := (gw - wnW) / 7 + stripRows := clampInt(h/8, 2, 6) + stripH := 1 + stripRows + gridH := h - 2 - stripH + first, weeks := monthGridStart(month, a.prefs.CalendarWeekStart != "sunday") + rowH := gridH / weeks + lay.items = calRect{x: gw, y: 0, w: panelW, h: h - stripH} + lay.strip = calRect{x: 0, y: h - stripH, w: w, h: stripH} + lay.stripRows = stripRows + day := first + for r := 0; r < weeks; r++ { + for col := 0; col < 7; col++ { + lay.boxes = append(lay.boxes, calDayBox{day: day, rect: calRect{x: wnW + col*colW, y: 2 + r*rowH, w: colW, h: rowH}}) + day = day.AddDate(0, 0, 1) + } + } + return lay +} + +// hourLabelWidth is the width of the widest hour label for the time format +// and hour-list variant. +func hourLabelWidth(a *App, half bool) int { + w := 2 + if a.prefs.TimeFormat == "12h" { + w = 5 + } + if half { + w += 3 + } + return w +} + +// hourLabel is the label of the row starting at the given minute of the day. +func hourLabel(a *App, minute int) string { + h, half := minute/60, minute%60 == 30 + if a.prefs.TimeFormat == "12h" { + n := h % 12 + if n == 0 { + n = 12 + } + suffix := "AM" + if h >= 12 { + suffix = "PM" + } + if half { + return fmt.Sprintf("%2d:30 %s", n, suffix) + } + return fmt.Sprintf("%2d %s", n, suffix) + } + if half { + return fmt.Sprintf("%02d:30", h) + } + return fmt.Sprintf("%02d", h) +} + +// hourStep is the minutes one hour row covers. +func (c *taskCalendar) hourStep() int { + if c.half { + return 30 + } + return 60 +} + +// hourRowCount is the number of rows in the whole hour list. +func (c *taskCalendar) hourRowCount() int { return 24 * 60 / c.hourStep() } + +// firstHourRow is the index of the first row shown when room rows fit. +func (c *taskCalendar) firstHourRow(room int) int { + return clampInt(c.scroll/c.hourStep(), 0, max(0, c.hourRowCount()-room)) +} + +// visibleHourRows is how many hour rows the last layout showed, or 0 when +// none was laid out. +func (c *taskCalendar) visibleHourRows() int { + if c.layout.tooSmall || len(c.layout.boxes) == 0 || c.sub == calMonth { + return 0 + } + return c.layout.boxes[0].rect.h +} + +// hourListWhole reports whether the last layout shows every hour row. +func (c *taskCalendar) hourListWhole() bool { + n := c.visibleHourRows() + return n > 0 && n >= c.hourRowCount() +} + +// scrollHours moves the hour list by delta rows, never past either end. +func (c *taskCalendar) scrollHours(delta int) { + step := c.hourStep() + room := c.visibleHourRows() + if room == 0 { + c.scroll = max(0, c.scroll+delta*step) + return + } + c.scroll = clampInt(c.firstHourRow(room)+delta, 0, max(0, c.hourRowCount()-room)) * step +} + +// toggleHalf switches between hourly and half-hourly rows. The list starts at +// the same time, or at the hour containing it, kept inside the new list. +func (c *taskCalendar) toggleHalf() { + c.half = !c.half + c.scroll = c.scroll / c.hourStep() * c.hourStep() + if room := c.visibleHourRows(); room > 0 { + c.scroll = c.firstHourRow(room) * c.hourStep() + } +} + +// columnDays is the days a week or day layout shows as columns, in order. +func (c *taskCalendar) columnDays(a *App, day time.Time) []time.Time { + if c.sub == calDay { + return []time.Time{day} + } + offset := int(day.Weekday()) + if a.prefs.CalendarWeekStart != "sunday" { + offset = (offset + 6) % 7 + } + first := time.Date(day.Year(), day.Month(), day.Day()-offset, 0, 0, 0, 0, time.Local) + days := make([]time.Time, 7) + for i := range days { + days[i] = first.AddDate(0, 0, i) + } + return days +} + +// columnLayout computes the regions of the week or day view for a body of +// w x h. The hour list sits under the header rows with one column per day +// beside it, and the strip is pinned to the bottom of the body. +func (c *taskCalendar) columnLayout(a *App, day time.Time, w, h int) calLayout { + lay := calLayout{w: w, h: h} + if m := calMin[c.sub]; w < m[0] || h < m[1] { + lay.tooSmall = true + return lay + } + days := c.columnDays(a, day) + headRows := 1 + if c.sub == calWeek { + headRows = 2 + } + stripRows := clampInt(h/8, 2, 6) + stripH := 1 + stripRows + lay.stripRows = stripRows + lay.strip = calRect{x: 0, y: h - stripH, w: w, h: stripH} + lay.gutter = hourLabelWidth(a, c.half) + 2 + lay.hourTotal = c.hourRowCount() + hourRows := clampInt(h-stripH-headRows, 0, lay.hourTotal) + lay.hourStart = c.firstHourRow(hourRows) + colW := (w - lay.gutter) / len(days) + for i, d := range days { + lay.boxes = append(lay.boxes, calDayBox{day: d, rect: calRect{x: lay.gutter + i*colW, y: headRows, w: colW, h: hourRows}}) + } + return lay +} + +// renderColumns draws the week or day view: a header, the hour list with the +// day columns beside it, and the strip. Entries are listed from the top of +// their column, not placed by time of day. +func (c *taskCalendar) renderColumns(a *App, day time.Time, lay calLayout, w, h int, focused bool) string { + th := a.theme + rows := make([]string, 0, h) + blank := strings.Repeat(" ", lay.gutter) + first, last := lay.boxes[0].day, lay.boxes[len(lay.boxes)-1].day + if c.sub == calDay { + rows = append(rows, padRight(" "+th.Title.Render(day.Format("Monday, January 2, 2006")), w)) + } else { + title := first.Format("January 2") + " – " + last.Format("January 2, 2006") + rows = append(rows, padRight(" "+th.Title.Render(title), w)) + head := blank + for _, b := range lay.boxes { + cell := padRight(" "+b.day.Format("Mon")[:2]+b.day.Format(" 2"), b.rect.w) + if sameDay(b.day, day) { + cell = th.Selected.Render(cell) + } else { + cell = th.Dim.Render(cell) + } + head += cell + } + rows = append(rows, padRight(head, w)) + } + hourRows := lay.boxes[0].rect.h + for i := 0; i < hourRows; i++ { + var sb strings.Builder + sb.WriteString(th.Muted.Render(padRight(hourLabel(a, (lay.hourStart+i)*c.hourStep()), lay.gutter-2)) + th.Dim.Render(" │")) + for _, b := range lay.boxes { + scroll := 0 + if sameDay(b.day, day) { + scroll = c.columnScroll(b) + } + sb.WriteString(c.columnLine(a, b, i, focused && sameDay(b.day, day), scroll)) + } + rows = append(rows, padRight(sb.String(), w)) + } + for len(rows) < lay.strip.y { + rows = append(rows, strings.Repeat(" ", w)) + } + rows = append(rows, c.stripLines(a, lay, w, focused)...) + return fitBlock(strings.Join(rows, "\n"), w, h) +} + +// maxColumnScroll is the furthest a day column can scroll: the last entry +// then sits on the last line. +func (c *taskCalendar) maxColumnScroll(b calDayBox) int { + n := len(c.dayEntries(b.day)) + if n <= b.rect.h { + return 0 + } + return n - (b.rect.h - 1) +} + +// columnScroll is the scroll applied to box b, kept inside its range. A +// column only scrolls while the hour list is shown whole. +func (c *taskCalendar) columnScroll(b calDayBox) int { + items := c.focus == calFocusItems + if !c.hourListWhole() && !items { + return 0 + } + s := clampInt(c.colScroll, 0, c.maxColumnScroll(b)) + if items { + // Keep the selected entry on screen as the selection moves. + s = min(s, c.cursor) + for s < c.maxColumnScroll(b) && c.cursor >= s+c.columnShown(b, s) { + s++ + } + c.colScroll = s + } + return s +} + +// columnShown is how many entries box b lists when scrolled by s. +func (c *taskCalendar) columnShown(b calDayBox, s int) int { + room := b.rect.h - min(s, 1) + if rest := len(c.dayEntries(b.day)) - s; rest <= room { + return rest + } + return room - 1 +} + +// scrollColumn scrolls the selected day's column by delta entries, never past +// either end. It reports whether there is a column to scroll. +func (c *taskCalendar) scrollColumn(day time.Time, delta int) bool { + if c.sub == calMonth || !c.hourListWhole() { + return false + } + for _, b := range c.layout.boxes { + if sameDay(b.day, day) { + c.colScroll = clampInt(c.columnScroll(b)+delta, 0, c.maxColumnScroll(b)) + return true + } + } + return false +} + +// calMoreLabel is the overflow line for n hidden entries in a w-wide cell: +// " +N more" when it fits, else " +N", else " +N" cut to the width. +func calMoreLabel(n, w int) string { + long := fmt.Sprintf(" +%d more", n) + if cellWidth(long) <= w { + return long + } + return truncateCells(fmt.Sprintf(" +%d", n), w) +} + +// columnLine is line li of one day column: entries from the scroll offset, +// with "+N more" on the first line when some are scrolled off the top and on +// the last line when they do not all fit. +func (c *taskCalendar) columnLine(a *App, b calDayBox, li int, focusedDay bool, scroll int) string { + th := a.theme + w := b.rect.w + entries := c.dayEntries(b.day) + more := func(n int) string { + return th.Dim.Render(padRight(calMoreLabel(n, w), w)) + } + first := 0 + if scroll > 0 { + if li == 0 { + return more(scroll) + } + first = scroll + li-- + } + rest := entries[first:] + room := b.rect.h - min(scroll, 1) + shown := len(rest) + if shown > room { + shown = room - 1 + } + switch { + case li < shown: + idx := first + li + e := entries[idx] + lead := " " + if e.scheduled { + lead = calScheduledMark + } + text := padRight(lead+truncateCells(e.task.Content, w-2), w) + if focusedDay && c.focus == calFocusItems && idx == c.cursor { + return th.SelectedFocus.Render(text) + } + return text + case li == shown && shown < len(rest): + return more(len(rest) - shown) + } + return strings.Repeat(" ", w) +} + +// monthGridStart is the first day shown for a month and how many week rows +// cover it. +func monthGridStart(month time.Time, mondayFirst bool) (time.Time, int) { + first := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.Local) + offset := int(first.Weekday()) + if mondayFirst { + offset = (offset + 6) % 7 + } + days := first.AddDate(0, 1, -1).Day() + return first.AddDate(0, 0, -offset), (offset + days + 6) / 7 +} + +// render draws the calendar into a w x h body. +func (c *taskCalendar) render(a *App, month, day time.Time, w, h int, focused bool) string { + var lay calLayout + if c.sub == calMonth { + lay = c.monthLayout(a, month, w, h) + } else { + lay = c.columnLayout(a, day, w, h) + } + c.layout = lay + th := a.theme + if lay.tooSmall { + m := calMin[c.sub] + lines := make([]string, max(h, 0)) + if len(lines) > 0 { + msg := fmt.Sprintf("Terminal too small: needs %d×%d", m[0], m[1]) + lines[len(lines)/2] = centerLine(th.Muted.Render(msg), w) + } + return fitBlock(strings.Join(lines, "\n"), w, h) + } + + if c.sub != calMonth { + return c.renderColumns(a, day, lay, w, h, focused) + } + weeks := len(lay.boxes) / 7 + gw := lay.items.x + wnW := lay.boxes[0].rect.x + colW := lay.boxes[0].rect.w + rowH := lay.boxes[0].rect.h + left := make([]string, lay.items.h) + left[0] = padRight(" "+th.Title.Render(month.Format("January 2006")), gw) + names := []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} + if a.prefs.CalendarWeekStart == "sunday" { + names = []string{"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"} + } + head := strings.Repeat(" ", wnW) + for _, n := range names { + head += padRight(" "+th.Dim.Render(n), colW) + } + left[1] = padRight(head, gw) + today := calendarNow() + for r := 0; r < weeks; r++ { + for li := 0; li < rowH; li++ { + var sb strings.Builder + if wnW > 0 { + if li == 0 { + anchor := lay.boxes[r*7].day + if a.prefs.CalendarWeekStart == "sunday" { + anchor = anchor.AddDate(0, 0, 1) + } + _, wk := anchor.ISOWeek() + sb.WriteString(th.Muted.Render(fmt.Sprintf("%2d ", wk))) + } else { + sb.WriteString(strings.Repeat(" ", wnW)) + } + } + for col := 0; col < 7; col++ { + box := lay.boxes[r*7+col] + sb.WriteString(c.boxLine(a, box, li, sameDay(box.day, day), sameDay(box.day, today), box.day.Month() != month.Month(), focused)) + } + left[2+r*rowH+li] = padRight(sb.String(), gw) + } + } + for i := range left { + if left[i] == "" { + left[i] = strings.Repeat(" ", gw) + } + } + + panel := c.itemPanel(a, day, lay, focused) + rows := make([]string, 0, h) + for i := 0; i < lay.items.h; i++ { + rows = append(rows, left[i]+panel[i]) + } + rows = append(rows, c.stripLines(a, lay, w, focused)...) + return fitBlock(strings.Join(rows, "\n"), w, h) +} + +// boxLine is line li of one day box: the date number on top, then one line +// per entry, with "+N more" on the last line when they do not all fit. +func (c *taskCalendar) boxLine(a *App, box calDayBox, li int, selected, today, outside, focused bool) string { + th := a.theme + w := box.rect.w + text := "" + style := th.Base + if outside { + style = th.Muted + } + if li == 0 { + text = fmt.Sprintf(" %d", box.day.Day()) + if today && !selected { + style = th.Base.Foreground(th.Accent).Bold(true) + } + } else { + entries := c.dayEntries(box.day) + avail := box.rect.h - 1 + shown := len(entries) + if shown > avail { + shown = avail - 1 + } + switch { + case li-1 < shown: + e := entries[li-1] + lead := " " + if e.scheduled { + lead = calScheduledMark + } + text = lead + truncateCells(e.task.Content, w-1) + case li-1 == shown && shown < len(entries): + text = calMoreLabel(len(entries)-shown, w) + if !selected { + style = th.Dim + } + } + } + if selected { + if focused && c.focus == calFocusGrid { + style = th.SelectedFocus + } else { + style = th.Selected + } + } + return style.Render(padRight(text, w)) +} + +// itemPanel lists every entry of the selected day beside the grid. +func (c *taskCalendar) itemPanel(a *App, day time.Time, lay calLayout, focused bool) []string { + th := a.theme + pw := lay.items.w + sep := th.Dim.Render("│") + entries := c.dayEntries(day) + out := make([]string, lay.items.h) + out[0] = padRight(sep+" "+th.Bold.Render(day.Format("Monday, January 2"))+th.Muted.Render(fmt.Sprintf(" (%d)", len(entries))), pw) + rows := lay.items.h - 1 + start := 0 + if c.cursor >= rows { + start = c.cursor - rows + 1 + } + c.layout.itemStart = start + for i := 1; i < lay.items.h; i++ { + line := sep + idx := start + i - 1 + switch { + case idx < len(entries): + e := entries[idx] + lead := " " + if e.scheduled { + lead = calScheduledMark + } + body := " " + lead + " " + truncateCells(e.task.Content, pw-4) + if focused && c.focus == calFocusItems && idx == c.cursor { + body = th.SelectedFocus.Render(padRight(body, pw-1)) + } + line += body + case len(entries) == 0 && i == 1: + line += " " + th.Muted.Render(truncateCells("Nothing here. n adds a task.", pw-2)) + } + out[i] = padRight(line, pw) + } + return out +} + +// stripLines draws the strip of tasks without a date under the grid. +func (c *taskCalendar) stripLines(a *App, lay calLayout, w int, focused bool) []string { + th := a.theme + out := make([]string, lay.strip.h) + out[0] = padRight(" "+th.Bold.Render("No date")+th.Muted.Render(fmt.Sprintf(" (%d)", len(c.undated))), w) + rows := lay.stripRows + start := 0 + if c.focus == calFocusStrip && c.strip >= rows { + start = c.strip - rows + 1 + } + c.layout.stripStart = start + for i := 0; i < rows; i++ { + idx := start + i + line := "" + if idx < len(c.undated) { + line = " " + truncateCells(c.undated[idx].Content, w-2) + if focused && c.focus == calFocusStrip && idx == c.strip { + line = th.SelectedFocus.Render(padRight(line, w)) + } + } + out[1+i] = padRight(line, w) + } + return out +} + +// columnEntryAt is the index of the entry shown on line li of box b when its +// column is scrolled by s, or false for the "+N more" lines and empty ones. +func (c *taskCalendar) columnEntryAt(b calDayBox, s, li int) (int, bool) { + li -= min(s, 1) + if li < 0 || li >= c.columnShown(b, s) { + return 0, false + } + return s + li, true +} + +// pressTask acts on the task a press landed on: a right-click opens its menu +// and a double-click its source note. +func (c *taskCalendar) pressTask(a *App, t vault.Task, right, double bool) { + switch { + case right: + a.taskMenu(t) + case double: + a.openTaskSource(t) + } +} + +// click handles a mouse press at body cell (x, y) by hit-testing the last +// layout. +func (c *taskCalendar) click(a *App, v *tasksView, x, y int, right, double bool) { + lay := c.layout + if lay.tooSmall { + return + } + if lay.strip.contains(x, y) { + idx := lay.stripStart + y - lay.strip.y - 1 + if y > lay.strip.y && idx < len(c.undated) { + c.focus = calFocusStrip + c.strip = idx + c.pressTask(a, c.undated[idx], right, double) + } + return + } + for _, b := range lay.boxes { + if !b.rect.contains(x, y) { + continue + } + if c.sub != calMonth { + s := 0 + if sameDay(b.day, v.day) { + s = c.columnScroll(b) + } + if idx, ok := c.columnEntryAt(b, s, y-b.rect.y); ok { + if !sameDay(b.day, v.day) { + v.selectDay(a, b.day) + } + c.focus = calFocusItems + c.cursor = idx + c.pressTask(a, c.dayEntries(b.day)[idx].task, right, double) + return + } + } + v.selectDay(a, b.day) + c.focus = calFocusGrid + if double { + v.calendarKey(a, vim.KeyEnter) + } + return + } + if lay.items.contains(x, y) && y > lay.items.y { + idx := lay.itemStart + y - lay.items.y - 1 + entries := c.dayEntries(v.day) + if idx < len(entries) { + c.focus = calFocusItems + c.cursor = idx + c.pressTask(a, entries[idx].task, right, double) + } + } +} + +// selectionCell is the body cell menus open next to. +func (c *taskCalendar) selectionCell(day time.Time) (int, int) { + lay := c.layout + switch c.focus { + case calFocusStrip: + return lay.strip.x + 2, lay.strip.y + 1 + c.strip - lay.stripStart + case calFocusItems: + if c.sub == calMonth { + return lay.items.x + 3, lay.items.y + 1 + c.cursor - lay.itemStart + } + for _, b := range lay.boxes { + if sameDay(b.day, day) { + s := c.columnScroll(b) + return b.rect.x + 2, b.rect.y + c.cursor - s + min(s, 1) + } + } + } + for _, b := range lay.boxes { + if sameDay(b.day, day) { + return b.rect.x + 1, b.rect.y + } + } + return 0, 0 +} diff --git a/internal/tui/views_tasks_calendar_colscroll_test.go b/internal/tui/views_tasks_calendar_colscroll_test.go new file mode 100644 index 0000000..434ab43 --- /dev/null +++ b/internal/tui/views_tasks_calendar_colscroll_test.go @@ -0,0 +1,251 @@ +package tui + +import ( + "fmt" + "slices" + "strconv" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/vim" +) + +// colView is what the busy day's column shows: the entries listed, in order, +// and the two `+N more` counts around them (-1 when a count is absent). +type colView struct { + entries []int + above int + below int +} + +const colScrollTotal = 50 + +// calColumnView reads the zq entries and the `+N more` lines of one render. +func calColumnView(t *testing.T, lines []string) colView { + t.Helper() + v := colView{above: -1, below: -1} + first, last := -1, -1 + for i, l := range lines { + for _, m := range calZqRe.FindAllString(l, -1) { + n, err := strconv.Atoi(strings.TrimPrefix(m, "zq")) + if err != nil { + t.Fatal(err) + } + v.entries = append(v.entries, n) + if first < 0 { + first = i + } + last = i + } + } + if first < 0 { + t.Fatalf("no entries listed:\n%s", calScreenText(lines)) + } + for i, l := range lines { + m := calMoreRe.FindStringSubmatch(l) + if m == nil { + continue + } + n, _ := strconv.Atoi(m[1]) + switch { + case i < first: + if v.above >= 0 { + t.Fatalf("two `+N more` lines above the entries:\n%s", calScreenText(lines)) + } + v.above = n + case i > last: + if v.below >= 0 { + t.Fatalf("two `+N more` lines below the entries:\n%s", calScreenText(lines)) + } + v.below = n + default: + t.Fatalf("`+N more` line between entries:\n%s", calScreenText(lines)) + } + } + return v +} + +func (v colView) String() string { + return fmt.Sprintf("above=%d entries=%v below=%d", v.above, v.entries, v.below) +} + +// calColumnApp opens a layout with one very busy day and an hour list that is +// shown whole. +func calColumnApp(t *testing.T, sub calSub, vimOn bool) (*App, *tasksView) { + t.Helper() + a, tv := calTestApp(t, calManyTasks("2026-11-18", colScrollTotal, "zq")) + a.prefs.VimMode = vimOn + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, sub) + if got := calLabels(a, tv, 120, 70); len(got) != 24 || got[0] != "00" { + t.Fatalf("layout %v: hour list not shown whole: %v", sub, got) + } + return a, tv +} + +func calColumnAfter(t *testing.T, a *App, tv *tasksView) colView { + t.Helper() + return calColumnView(t, calScreen(a, tv, 120, 70)) +} + +func TestColumnScrollWhenHourListWhole(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, true) + ctx := fmt.Sprintf("layout %v", sub) + before := calColumnAfter(t, a, tv) + if before.above != -1 || before.below <= 0 || before.entries[0] != 0 { + t.Fatalf("%s: unscrolled column = %v", ctx, before) + } + + calPress(a, tv, vim.R('j')) + got := calColumnAfter(t, a, tv) + if got.above != 1 || got.entries[0] != 1 { + t.Errorf("%s: after j column = %v, want one entry scrolled off the top", ctx, got) + } + if tv.calc().focus != calFocusGrid { + t.Errorf("%s: j moved focus to %v, want the grid", ctx, tv.calc().focus) + } + if sel := tv.selectedTask(); sel != nil { + t.Errorf("%s: j selected entry %q, want none", ctx, sel.Content) + } + requireDay(t, tv, "2026-11-18", ctx+" after j") + if labels := calLabels(a, tv, 120, 70); len(labels) != 24 || labels[0] != "00" { + t.Errorf("%s: j scrolled the hour list: %v", ctx, labels) + } + + calPress(a, tv, calRunes("jj")...) + if got := calColumnAfter(t, a, tv); got.above != 3 || got.entries[0] != 3 { + t.Errorf("%s: after three j column = %v, want three entries scrolled off", ctx, got) + } + + calPress(a, tv, calRunes("kkk")...) + got = calColumnAfter(t, a, tv) + if got.above != -1 || got.entries[0] != 0 { + t.Errorf("%s: after k back to the top column = %v", ctx, got) + } + calPress(a, tv, vim.R('k')) + if got := calColumnAfter(t, a, tv); got.above != -1 || got.entries[0] != 0 { + t.Errorf("%s: k at the top scrolled the column to %v", ctx, got) + } + } +} + +func TestScrolledColumnShowsMoreAboveAndBelow(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, true) + ctx := fmt.Sprintf("layout %v", sub) + base := calColumnAfter(t, a, tv) + // The unscrolled column spends one line on the bottom count, so its + // room is the entries listed plus that line. + room := len(base.entries) + 1 + if room < 5 { + t.Fatalf("%s: column room %d is too small for the check", ctx, room) + } + calPress(a, tv, calRunes("jjj")...) + got := calColumnAfter(t, a, tv) + // Scrolled by three: a top line, a bottom line, entries between. + wantEntries := room - 2 + if got.above != 3 { + t.Errorf("%s: top count = %d, want +3 more", ctx, got.above) + } + if len(got.entries) != wantEntries || got.entries[0] != 3 || got.entries[len(got.entries)-1] != 3+wantEntries-1 { + t.Errorf("%s: entries = %v, want %d entries from the 4th", ctx, got.entries, wantEntries) + } + if want := colScrollTotal - 3 - wantEntries; got.below != want { + t.Errorf("%s: bottom count = %d, want +%d more", ctx, got.below, want) + } + lines := calScreen(a, tv, 120, 70) + if top, firstEntry := calRowOf(lines, "+3 more"), calRowOf(lines, "zq03"); top < 0 || top != firstEntry-1 { + t.Errorf("%s: `+3 more` on row %d, want the line right above the first entry (row %d)", ctx, top, firstEntry) + } + } +} + +func TestColumnScrolledToEndShowsOnlyTopMore(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, true) + ctx := fmt.Sprintf("layout %v", sub) + room := len(calColumnAfter(t, a, tv).entries) + 1 + for i := 0; i < colScrollTotal+20; i++ { + calPress(a, tv, vim.R('j')) + } + got := calColumnAfter(t, a, tv) + wantEntries := room - 1 + if got.below != -1 { + t.Errorf("%s: scrolled to the end still shows a bottom count: %v", ctx, got) + } + if want := colScrollTotal - wantEntries; got.above != want { + t.Errorf("%s: top count = %d, want +%d more", ctx, got.above, want) + } + if len(got.entries) != wantEntries || got.entries[len(got.entries)-1] != colScrollTotal-1 { + t.Errorf("%s: entries = %v, want the last %d ending at the 50th", ctx, got.entries, wantEntries) + } + requireDay(t, tv, "2026-11-18", ctx) + } +} + +func TestUnscrolledColumnHasNoTopMore(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, true) + ctx := fmt.Sprintf("layout %v", sub) + got := calColumnAfter(t, a, tv) + if got.above != -1 { + t.Errorf("%s: unscrolled column has a top count: %v", ctx, got) + } + if got.below != colScrollTotal-len(got.entries) { + t.Errorf("%s: bottom count = %d, want +%d more", ctx, got.below, colScrollTotal-len(got.entries)) + } + // Scroll down and all the way back: the top line goes away again. + calPress(a, tv, calRunes("jjjj")...) + calPress(a, tv, calRunes("kkkkk")...) + if got := calColumnAfter(t, a, tv); got.above != -1 || got.entries[0] != 0 { + t.Errorf("%s: column scrolled back to the top = %v", ctx, got) + } + } +} + +func TestColumnScrollArrowsWithoutVimMode(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, false) + ctx := fmt.Sprintf("layout %v", sub) + calPress(a, tv, vim.KeyDown, vim.KeyDown) + got := calColumnAfter(t, a, tv) + if got.above != 2 || got.entries[0] != 2 { + t.Errorf("%s: Down twice gave %v, want two entries scrolled off", ctx, got) + } + calPress(a, tv, vim.KeyUp) + if got := calColumnAfter(t, a, tv); got.above != 1 || got.entries[0] != 1 { + t.Errorf("%s: Down twice then Up gave %v, want one entry scrolled off", ctx, got) + } + if tv.calc().focus != calFocusGrid || tv.selectedTask() != nil { + t.Errorf("%s: arrows changed focus or selected an entry", ctx) + } + requireDay(t, tv, "2026-11-18", ctx) + } +} + +func TestColumnFollowsSelectionInItemFocus(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calColumnApp(t, sub, true) + ctx := fmt.Sprintf("layout %v", sub) + calPress(a, tv, vim.KeyTab) + if tv.calc().focus != calFocusItems { + t.Fatalf("%s: Tab did not focus the column", ctx) + } + for i := 0; i < 40; i++ { + calPress(a, tv, vim.R('j')) + sel := tv.selectedTask() + if sel == nil { + t.Fatalf("%s: no selection after %d j", ctx, i+1) + } + n, _ := strconv.Atoi(strings.TrimPrefix(sel.Content, "zq")) + if got := calColumnAfter(t, a, tv); !slices.Contains(got.entries, n) { + t.Fatalf("%s: selected zq%02d is off screen: %v", ctx, n, got) + } + } + calPress(a, tv, calRunes(strings.Repeat("k", 40))...) + if got := calColumnAfter(t, a, tv); got.above != -1 || got.entries[0] != 0 { + t.Errorf("%s: back on the first entry the column is still scrolled: %v", ctx, got) + } + } +} diff --git a/internal/tui/views_tasks_calendar_duemenu_test.go b/internal/tui/views_tasks_calendar_duemenu_test.go new file mode 100644 index 0000000..6cd1a4e --- /dev/null +++ b/internal/tui/views_tasks_calendar_duemenu_test.go @@ -0,0 +1,188 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/vim" +) + +const calLooseNote = "- [ ] loose end\n" + +// calDueMenu focuses the item panel on 2026-11-18 (today fixed to 2026-10-29) +// and presses T. +func calDueMenu(t *testing.T, files map[string]string, vimOn bool) (*App, *tasksView, string, *menu) { + t.Helper() + calFixToday(t, calDate(2026, time.October, 29)) + a, tv, root := calVaultApp(t, files) + a.prefs.VimMode = vimOn + calPress(a, tv, vim.KeyEnter) + calPress(a, tv, vim.R('T')) + m, ok := a.overlay.(*menu) + if !ok { + t.Fatalf("overlay after T on an item = %T, want the due-date menu", a.overlay) + } + return a, tv, root, m +} + +func calMenuIndex(t *testing.T, m *menu, word string) int { + t.Helper() + for i, it := range m.items { + if strings.Contains(strings.ToLower(it.label), word) { + return i + } + } + t.Fatalf("menu has no item containing %q: %+v", word, m.items) + return -1 +} + +// calPick moves the cursor to the item and presses Enter. +func calPick(t *testing.T, a *App, m *menu, word string) { + t.Helper() + want := calMenuIndex(t, m, word) + for i := 0; i < len(m.items) && m.cursor != want; i++ { + a.overlay.handleKey(a, vim.KeyDown) + } + if m.cursor != want { + t.Fatalf("cursor stuck at %d, want %d", m.cursor, want) + } + a.overlay.handleKey(a, vim.KeyEnter) +} + +func TestDueMenuOpensFromItemPanel(t *testing.T) { // AC88 + _, tv, _, _ := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + requireDay(t, tv, "2026-11-18", "after T") + if tv.calc().sub != calMonth { + t.Errorf("sub-mode = %v, want month", tv.calc().sub) + } + requireFocus(t, tv, calFocusItems, "after T") +} + +func TestDueMenuListsChoicesInOrder(t *testing.T) { // AC89 + _, _, _, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + if len(m.items) != 6 { + t.Fatalf("menu has %d items, want 6: %+v", len(m.items), m.items) + } + for i, w := range []string{"today", "tomorrow", "next week", "2026-11-18", "clear", "other"} { + if !strings.Contains(strings.ToLower(m.items[i].label), w) { + t.Errorf("item %d = %q, want it to mention %q", i, m.items[i].label, w) + } + } +} + +func TestDueMenuArrowsAndEnterRunChoice(t *testing.T) { // AC90 + a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + a.overlay.handleKey(a, vim.KeyDown) + if m.cursor != 1 { + t.Fatalf("cursor after Down = %d, want 1", m.cursor) + } + a.overlay.handleKey(a, vim.KeyEnter) + calReload(t, a) + if a.overlay != nil { + t.Errorf("overlay after Enter = %T, want none", a.overlay) + } + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "alpha job due:2026-10-30") { + t.Errorf("Down then Enter did not pick tomorrow:\n%s", got) + } +} + +func TestDueMenuLettersRunNothing(t *testing.T) { // AC91 + a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + for _, it := range m.items { + if it.key != "" { + t.Errorf("item %q has shortcut key %q, want none", it.label, it.key) + } + } + for _, r := range "tTnwscodxa" { + a.overlay.handleKey(a, vim.R(r)) + } + if got := calReadFile(t, root, "work.md"); got != calWorkNote { + t.Errorf("letters changed the note:\n%s", got) + } +} + +func TestDueMenuRelativeChoicesSetDue(t *testing.T) { // AC92 + for _, c := range []struct{ word, due string }{ + {"today", "2026-10-29"}, {"tomorrow", "2026-10-30"}, {"next week", "2026-11-05"}, + } { + a, _, root, m := calDueMenu(t, map[string]string{"plan.md": calPlanNote}, true) + calPick(t, a, m, c.word) + body := calReadFile(t, root, "plan.md") + if !strings.Contains(body, "due: "+c.due) && !strings.Contains(body, "due:"+c.due) { + t.Errorf("%s: due is not %s:\n%s", c.word, c.due, body) + } + if !strings.Contains(body, "scheduled: 2026-11-16") { + t.Errorf("%s: scheduled changed:\n%s", c.word, body) + } + } +} + +func TestDueMenuSelectedDaySetsDue(t *testing.T) { // AC93 + a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + calPick(t, a, m, "2026-11-18") + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "alpha job due:2026-11-18") { + t.Errorf("due is not the selected day:\n%s", got) + } +} + +func TestDueMenuClearRemovesDue(t *testing.T) { // AC94 + a, _, root, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + calPick(t, a, m, "clear") + got := calReadFile(t, root, "work.md") + if strings.Contains(got, "alpha job due:") || !strings.Contains(got, "alpha job") { + t.Errorf("alpha job should lose its due date and stay:\n%s", got) + } + if !strings.Contains(got, "bravo job due:2026-11-18") { + t.Errorf("another task changed:\n%s", got) + } +} + +func TestDueMenuOtherOpensDuePrompt(t *testing.T) { // AC95 + a, _, _, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + calPick(t, a, m, "other") + p, ok := a.overlay.(*prompt) + if !ok { + t.Fatalf("overlay = %T, want the due-date prompt", a.overlay) + } + if !strings.Contains(p.title, "Due") { + t.Errorf("prompt title = %q, want the due-date prompt", p.title) + } +} + +func TestDueMenuDismissLeavesTaskUnchanged(t *testing.T) { // AC96 + for _, k := range []vim.Key{vim.KeyEsc, vim.R('q'), vim.Ctrl('c')} { + a, _, root, _ := calDueMenu(t, map[string]string{"work.md": calWorkNote}, true) + a.overlay.handleKey(a, k) + if a.overlay != nil { + t.Errorf("%v: overlay = %T, want none", k, a.overlay) + } + if got := calReadFile(t, root, "work.md"); got != calWorkNote { + t.Errorf("%v: note changed:\n%s", k, got) + } + } +} + +func TestDueMenuOpensFromStrip(t *testing.T) { // AC97 + calFixToday(t, calDate(2026, time.October, 29)) + a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote, "loose.md": calLooseNote}) + calPress(a, tv, vim.KeyTab, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, "second Tab") + calPress(a, tv, vim.R('T')) + m, ok := a.overlay.(*menu) + if !ok { + t.Fatalf("overlay after T on the strip = %T, want the due-date menu", a.overlay) + } + calPick(t, a, m, "tomorrow") + if got := calReadFile(t, root, "loose.md"); !strings.Contains(got, "loose end due:2026-10-30") { + t.Errorf("strip task did not get the due date:\n%s", got) + } +} + +func TestDueMenuWithoutVimMode(t *testing.T) { // AC112 + _, tv, _, m := calDueMenu(t, map[string]string{"work.md": calWorkNote}, false) + if len(m.items) != 6 { + t.Errorf("menu has %d items, want 6", len(m.items)) + } + requireDay(t, tv, "2026-11-18", "after T") +} diff --git a/internal/tui/views_tasks_calendar_hours_test.go b/internal/tui/views_tasks_calendar_hours_test.go new file mode 100644 index 0000000..22fed9e --- /dev/null +++ b/internal/tui/views_tasks_calendar_hours_test.go @@ -0,0 +1,432 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/vim" +) + +// calHalfHours24 is the 48 half-hourly labels in the 24h format. +func calHalfHours24() []string { + var out []string + for h := 0; h < 24; h++ { + out = append(out, fmt.Sprintf("%02d", h), fmt.Sprintf("%02d:30", h)) + } + return out +} + +// calHalfHours12 is the 48 half-hourly labels in the 12h format. +func calHalfHours12() []string { + var out []string + for _, h := range calHours12() { + out = append(out, h, strings.Replace(h, " ", ":30 ", 1)) + } + return out +} + +func calLabels(a *App, tv *tasksView, w, bodyH int) []string { + return calHourLabels(calScreen(a, tv, w, bodyH), calLabel24) +} + +func requireVariant(t *testing.T, a *App, tv *tasksView, half bool, context string) { + t.Helper() + want := 24 + if half { + want = 48 + } + lines := calScreen(a, tv, 120, 110) + if got := calHourLabels(lines, calLabel24); len(got) != want { + t.Errorf("%s: %d hour rows shown, want %d (half-hourly=%v):\n%s", context, len(got), want, half, calScreenText(lines)) + } +} + +func TestMonthIgnoresZ(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 3, "zt")) + a.prefs.VimMode = true + before := calScreenText(calScreen(a, tv, 120, 40)) + calPress(a, tv, vim.R('z')) + if after := calScreenText(calScreen(a, tv, 120, 40)); after != before { + t.Errorf("z changed the month view:\nbefore:\n%s\nafter:\n%s", before, after) + } + if tv.calc().sub != calMonth { + t.Errorf("layout after z = %v, want month", tv.calc().sub) + } + calEsc(a, tv) + requireVariant(t, a, tv, false, "week after z in month view") +} + +func TestHourListStartsHourlyAtMidnight(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + got := calLabels(a, tv, 120, 70) + if strings.Join(got, ",") != strings.Join(calHours24(), ",") { + t.Errorf("layout %v: rows = %v, want 00..23", sub, got) + } + if small := calLabels(a, tv, 120, 20); len(small) == 0 || small[0] != "00" { + t.Errorf("layout %v: first visible row in a short body = %v, want 00", sub, small) + } + } +} + +func TestZTogglesVariantInWeek(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, calWeek) + if !tv.handleKey(a, vim.R('z')) { + t.Error("z was not handled in week view") + } + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), "week after z") + calPress(a, tv, vim.R('z')) + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHours24(), "week after z twice") +} + +func TestZTogglesVariantInDay(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, calDay) + if !tv.handleKey(a, vim.R('z')) { + t.Error("z was not handled in day view") + } + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), "day after z") + calPress(a, tv, vim.R('z')) + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHours24(), "day after z twice") +} + +func TestZWorksInItemFocusWithVimMode(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 2, "zi")) + a.prefs.VimMode = true + calToSub(t, a, tv, calWeek) + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusItems { + t.Fatalf("Enter left focus on %v, want the day's column", tv.calc().focus) + } + calPress(a, tv, vim.R('z')) + requireVariant(t, a, tv, true, "z in item focus") +} + +func TestVariantResetsOnCalendarEntry(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + calToSub(t, a, tv, calWeek) + calPress(a, tv, vim.R('z')) + requireVariant(t, a, tv, true, "before leaving") + for i := 0; i < 3; i++ { + calPress(a, tv, vim.R('v')) + } + if tv.mode != "calendar" { + t.Fatalf("mode after v round trip = %q, want calendar", tv.mode) + } + requireVariant(t, a, tv, false, "after the v round trip") + + calPress(a, tv, vim.R('z')) + requireVariant(t, a, tv, true, "before :tasks calendar") + tv.setMode("list") + if err := a.openTasksMode("calendar"); err != nil { + t.Fatal(err) + } + tv2, ok := a.activeTab().view.(*tasksView) + if !ok { + t.Fatalf("active tab is %T", a.activeTab().view) + } + requireVariant(t, a, tv2, false, "after :tasks calendar") +} + +func TestVariantSurvivesMovesAndWeekDaySwitch(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + calToSub(t, a, tv, calWeek) + calPress(a, tv, vim.R('z')) + // Over the end of the week into the next one, then back. + calPress(a, tv, calRunes("lllllll")...) + requireDay(t, tv, "2026-11-25", "seven l presses") + requireVariant(t, a, tv, true, "after moving to another week") + calPress(a, tv, calRunes("hhhhhhh")...) + requireVariant(t, a, tv, true, "after moving back") + calEsc(a, tv) + if tv.calc().sub != calDay { + t.Fatalf("Esc gave layout %v, want day", tv.calc().sub) + } + requireVariant(t, a, tv, true, "after switching to day view") +} + +func TestHourlyShownWholeWhenTallEnough(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + requireLabels(t, calScreen(a, tv, 120, 70), calLabel24, calHours24(), fmt.Sprintf("layout %v", sub)) + calPress(a, tv, vim.R('j')) + requireLabels(t, calScreen(a, tv, 120, 70), calLabel24, calHours24(), fmt.Sprintf("layout %v after j", sub)) + } +} + +func TestHalfHourlyShownWholeWhenTallEnough(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + if sub == calWeek { + calPress(a, tv, vim.R('z')) + } + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), fmt.Sprintf("layout %v", sub)) + } +} + +func TestHourListScrollsAtMinimumSize(t *testing.T) { + for _, c := range []struct { + sub calSub + w, h int + }{{calWeek, 64, 12}, {calDay, 40, 10}} { + for _, half := range []bool{false, true} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, c.sub) + if half { + calPress(a, tv, vim.R('z')) + } + lines := calScreen(a, tv, c.w, c.h) + ctx := fmt.Sprintf("layout %v half=%v %dx%d", c.sub, half, c.w, c.h) + requireTooSmall(t, lines, false, ctx) + got := calHourLabels(lines, calLabel24) + total := 24 + if half { + total = 48 + } + if len(got) == 0 || len(got) >= total { + t.Fatalf("%s: %d rows shown, want some but fewer than %d:\n%s", ctx, len(got), total, calScreenText(lines)) + } + if got[0] != "00" { + t.Errorf("%s: first row = %s, want 00", ctx, got[0]) + } + calPress(a, tv, vim.R('j')) + scrolled := calHourLabels(calScreen(a, tv, c.w, c.h), calLabel24) + if len(scrolled) == 0 || scrolled[0] == "00" { + t.Errorf("%s: j did not scroll the list: %v", ctx, scrolled) + } + } + } +} + +func TestHalfHourlyLabels24h(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + if sub == calWeek { + calPress(a, tv, vim.R('z')) + } + requireLabels(t, calScreen(a, tv, 120, 110), calLabel24, calHalfHours24(), fmt.Sprintf("layout %v", sub)) + } +} + +func TestHalfHourlyLabels12h(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "12h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + if sub == calWeek { + calPress(a, tv, vim.R('z')) + } + requireLabels(t, calScreen(a, tv, 120, 110), calLabel12, calHalfHours12(), fmt.Sprintf("layout %v", sub)) + } +} + +func TestHourListJKScrollClamped(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, sub) + first := calLabels(a, tv, 120, 20) + n := len(first) + if n == 0 || n >= 24 || first[0] != "00" { + t.Fatalf("layout %v: unexpected short-body rows %v", sub, first) + } + calPress(a, tv, vim.R('k')) + if got := calLabels(a, tv, 120, 20); got[0] != "00" { + t.Errorf("layout %v: k before the first row moved the list to %s", sub, got[0]) + } + calPress(a, tv, vim.R('j')) + if got := calLabels(a, tv, 120, 20); got[0] != "01" { + t.Errorf("layout %v: j moved the list to %s, want 01", sub, got[0]) + } + requireDay(t, tv, "2026-11-18", fmt.Sprintf("layout %v after j", sub)) + for i := 0; i < 60; i++ { + calPress(a, tv, vim.R('j')) + } + got := calLabels(a, tv, 120, 20) + if len(got) != n || got[len(got)-1] != "23" || got[0] != fmt.Sprintf("%02d", 24-n) { + t.Errorf("layout %v: after scrolling to the end rows = %v, want %d rows ending at 23", sub, got, n) + } + requireDay(t, tv, "2026-11-18", fmt.Sprintf("layout %v after many j", sub)) + for i := 0; i < 60; i++ { + calPress(a, tv, vim.R('k')) + } + if got := calLabels(a, tv, 120, 20); got[0] != "00" { + t.Errorf("layout %v: after scrolling back first row = %s, want 00", sub, got[0]) + } + } +} + +func TestHourListArrowsScrollWithoutVimMode(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = false + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, sub) + if got := calLabels(a, tv, 120, 20); len(got) >= 24 || got[0] != "00" { + t.Fatalf("layout %v: unexpected rows %v", sub, got) + } + calPress(a, tv, vim.KeyDown) + if got := calLabels(a, tv, 120, 20); got[0] != "01" { + t.Errorf("layout %v: Down moved the list to %s, want 01", sub, got[0]) + } + calPress(a, tv, vim.KeyDown) + calPress(a, tv, vim.KeyUp) + if got := calLabels(a, tv, 120, 20); got[0] != "01" { + t.Errorf("layout %v: Down, Down, Up left the list at %s, want 01", sub, got[0]) + } + requireDay(t, tv, "2026-11-18", fmt.Sprintf("layout %v", sub)) + } +} + +func TestHourListScrollPersists(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, calWeek) + calLabels(a, tv, 120, 20) + calPress(a, tv, calRunes("jjjjjj")...) + requireFirst := func(context string) { + t.Helper() + got := calLabels(a, tv, 120, 20) + if len(got) == 0 || got[0] != "06" { + t.Errorf("%s: first row = %v, want 06", context, got) + } + } + requireFirst("after scrolling") + calPress(a, tv, calRunes("lllllll")...) + requireFirst("in the next week") + calPress(a, tv, calRunes("hhhhhhh")...) + requireFirst("back in the first week") + calEsc(a, tv) + requireFirst("in day view") + calPress(a, tv, calRunes("l")...) + requireFirst("on another day") + calEsc(a, tv) + if tv.calc().sub != calMonth { + t.Fatalf("layout = %v, want month", tv.calc().sub) + } + calEsc(a, tv) + requireFirst("in week view after the month view") + calEsc(a, tv) + requireFirst("in day view after the month view") +} + +func TestHourListScrollAfterZKeepsTime(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, calWeek) + n := len(calLabels(a, tv, 120, 20)) + if n == 0 || n >= 24 { + t.Fatalf("unexpected short-body row count %d", n) + } + calPress(a, tv, calRunes("jjjjjj")...) + calPress(a, tv, vim.R('z')) + got := calLabels(a, tv, 120, 20) + if len(got) == 0 || got[0] != "06" { + t.Fatalf("hourly 06 became %v in the half-hourly variant, want first row 06", got) + } + calPress(a, tv, vim.R('j')) + if got := calLabels(a, tv, 120, 20); got[0] != "06:30" { + t.Fatalf("one j in the half-hourly variant gave %v, want first row 06:30", got) + } + calPress(a, tv, vim.R('z')) + if got := calLabels(a, tv, 120, 20); got[0] != "06" { + t.Errorf("06:30 became %v in the hourly variant, want first row 06", got) + } + + // At the end of the half-hourly list the same time is past the end of + // the hourly list, so the start is clamped to the last full page. + calPress(a, tv, vim.R('z')) + for i := 0; i < 120; i++ { + calPress(a, tv, vim.R('j')) + } + end := calLabels(a, tv, 120, 20) + if len(end) != n || end[len(end)-1] != "23:30" { + t.Fatalf("half-hourly end of list = %v, want %d rows ending at 23:30", end, n) + } + calPress(a, tv, vim.R('z')) + got = calLabels(a, tv, 120, 20) + if len(got) != n || got[len(got)-1] != "23" { + t.Errorf("hourly list after z at the end = %v, want %d rows ending at 23", got, n) + } +} + +func TestZWithoutVimModeInItemFocusDoesNothing(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 2, "zv")) + a.prefs.VimMode = false + calToSub(t, a, tv, calWeek) + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusItems { + t.Fatalf("Enter left focus on %v, want the day's column", tv.calc().focus) + } + calPress(a, tv, vim.R('z')) + requireVariant(t, a, tv, false, "z in item focus without Vim mode") +} + +func TestZWithoutVimModeOnGridDoesNothing(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = false + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + calPress(a, tv, vim.R('z')) + requireVariant(t, a, tv, false, fmt.Sprintf("z on the %v grid without Vim mode", sub)) + } +} + +func TestEditorFoldKeysUnaffected(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.prefs.VimMode = true + a.openNote("note.md", true) + buf := a.activeBuffer() + if buf == nil { + t.Fatal("no active note buffer") + } + buf.ed.ReplaceText("# A\na1\na2\n# B\nb1") + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) + press := func(s string) { + for _, r := range s { + a.handleKey(vim.R(r)) + } + } + press("zc") + if end, ok := buf.ed.IsFoldStart(0); !ok || end != 2 { + t.Errorf("zc: fold at line 0 = %d %v, want 0..2", end, ok) + } + press("zo") + if _, ok := buf.ed.IsFoldStart(0); ok { + t.Error("zo did not open the fold") + } + press("zM") + if len(buf.ed.Folds()) != 2 { + t.Errorf("zM: folds = %v, want both sections", buf.ed.Folds()) + } + press("zR") + if len(buf.ed.Folds()) != 0 { + t.Errorf("zR: folds = %v, want none", buf.ed.Folds()) + } +} diff --git a/internal/tui/views_tasks_calendar_keys_test.go b/internal/tui/views_tasks_calendar_keys_test.go new file mode 100644 index 0000000..28dae2d --- /dev/null +++ b/internal/tui/views_tasks_calendar_keys_test.go @@ -0,0 +1,363 @@ +package tui + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/keymaps" + "github.com/ZenNotes/tui/internal/periodic" + "github.com/ZenNotes/tui/internal/vim" +) + +const calWorkNote = "- [ ] alpha job due:2026-11-18\n- [ ] bravo job due:2026-11-18\n- [ ] charlie job due:2026-11-19\n" + +const calPlanNote = "---\ntags: [task]\ndue: 2026-11-18\nscheduled: 2026-11-16\n---\n# Plan the move\n" + +// calVaultApp opens the calendar on 2026-11-18 over real note files, so key +// presses that edit a task change a file that can be read back. +func calVaultApp(t *testing.T, files map[string]string) (*App, *tasksView, string) { + t.Helper() + a, root := newFeatureTestApp(t) + for name, body := range files { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + m := a.loadIndexCmd()().(indexLoadedMsg) + if m.err != nil { + t.Fatal(m.err) + } + a.idx = m.idx + tm := a.loadTasksCmd()().(tasksLoadedMsg) + if tm.err != nil { + t.Fatal(tm.err) + } + a.idx.tasks = tm.tasks + a.prefs.CalendarWeekStart = "monday" + a.prefs.CalendarShowWeekNumbers = true + a.prefs.VimMode = true + a, tv := calOpen(t, a) + return a, tv, root +} + +func calPress(a *App, tv *tasksView, keys ...vim.Key) { + for _, k := range keys { + tv.handleKey(a, k) + } +} + +func calRunes(s string) []vim.Key { + var out []vim.Key + for _, r := range s { + out = append(out, vim.R(r)) + } + return out +} + +func calReadFile(t *testing.T, root, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func calFixToday(t *testing.T, day time.Time) { + t.Helper() + prev := calendarNow + calendarNow = func() time.Time { return day } + t.Cleanup(func() { calendarNow = prev }) +} + +func calDayISO(tv *tasksView) string { + return tv.day.Format("2006-01-02") +} + +func calDailyPath(a *App, day time.Time) string { + _, _, _, rel := a.periodicLocation(periodic.Daily, day) + return rel +} + +func requireDay(t *testing.T, tv *tasksView, want string, context string) { + t.Helper() + if got := calDayISO(tv); got != want { + t.Errorf("%s: selected day = %s, want %s", context, got, want) + } +} + +func requireMonth(t *testing.T, tv *tasksView, year int, month time.Month, context string) { + t.Helper() + if tv.month.Year() != year || tv.month.Month() != month { + t.Errorf("%s: month shown = %s, want %d-%02d", context, tv.month.Format("2006-01"), year, int(month)) + } +} + +func TestMonthGridHJKLMoveByDayAndWeek(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + for _, c := range []struct { + key rune + want string + }{{'h', "2026-11-17"}, {'l', "2026-11-19"}, {'j', "2026-11-25"}, {'k', "2026-11-11"}} { + tv.selectDay(a, calDate(2026, time.November, 18)) + if !tv.handleKey(a, vim.R(c.key)) { + t.Errorf("%c was not handled on the grid", c.key) + } + requireDay(t, tv, c.want, string(c.key)) + } +} + +func TestMonthGridArrowsWithoutVimMode(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = false + for _, c := range []struct { + key string + want string + }{{"left", "2026-11-17"}, {"right", "2026-11-19"}, {"down", "2026-11-25"}, {"up", "2026-11-11"}} { + tv.selectDay(a, calDate(2026, time.November, 18)) + if !tv.handleKey(a, vim.Special(c.key)) { + t.Errorf("%s was not handled on the grid without Vim mode", c.key) + } + requireDay(t, tv, c.want, c.key) + } + // Printable letters keep their Vim-only gate. + for _, r := range "hjkl" { + tv.selectDay(a, calDate(2026, time.November, 18)) + if tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was handled on the grid without Vim mode", r) + } + requireDay(t, tv, "2026-11-18", string(r)+" without Vim mode") + } +} + +func TestMonthHalfPageKeysChangeMonth(t *testing.T) { + for _, vimOn := range []bool{true, false} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = vimOn + calPress(a, tv, vim.Ctrl('d')) + requireMonth(t, tv, 2026, time.December, "half-page down") + if tv.day.Month() != time.December { + t.Errorf("vim=%v: selected day %s is outside the month shown", vimOn, calDayISO(tv)) + } + calPress(a, tv, vim.Ctrl('u'), vim.Ctrl('u')) + requireMonth(t, tv, 2026, time.October, "half-page up twice") + if tv.day.Month() != time.October { + t.Errorf("vim=%v: selected day %s is outside the month shown", vimOn, calDayISO(tv)) + } + if text := calScreenText(calScreen(a, tv, 120, 40)); !strings.Contains(text, "October") { + t.Errorf("vim=%v: October is not shown after half-page up:\n%s", vimOn, text) + } + } +} + +func TestMonthHalfPageFollowsRebinding(t *testing.T) { + a, tv := calTestApp(t, nil) + a.keymap = keymaps.NewResolver(map[string]string{"nav.halfPageDown": "Ctrl+G"}) + calPress(a, tv, vim.Ctrl('d')) + requireMonth(t, tv, 2026, time.November, " after rebinding") + requireDay(t, tv, "2026-11-18", " after rebinding") + calPress(a, tv, vim.Ctrl('g')) + requireMonth(t, tv, 2026, time.December, "the rebound key") +} + +func TestMonthBracketAndShiftHLChangeMonth(t *testing.T) { + for _, c := range []struct { + key rune + year int + month time.Month + }{{']', 2026, time.December}, {'L', 2026, time.December}, {'[', 2026, time.October}, {'H', 2026, time.October}} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + if !tv.handleKey(a, vim.R(c.key)) { + t.Errorf("%c was not handled on the grid", c.key) + } + requireMonth(t, tv, c.year, c.month, string(c.key)) + } +} + +func TestMonthEnterFocusesItemPanel(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusItems { + t.Fatalf("focus after Enter on a day with entries = %v, want the item panel", tv.calc().focus) + } + got := tv.selectedTask() + if got == nil || got.Content != "alpha job" { + t.Errorf("selected entry after Enter = %+v, want the first entry (alpha job)", got) + } +} + +func TestMonthEnterOnEmptyDayOpensDailyNote(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + tv.selectDay(a, calDate(2026, time.November, 25)) + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusGrid { + t.Errorf("focus after Enter on an empty day = %v, want the grid", tv.calc().focus) + } + want := calDailyPath(a, calDate(2026, time.November, 25)) + if tab := a.activeTab(); tab == nil || tab.path != want { + t.Errorf("active tab = %+v, want the daily note %s", tab, want) + } +} + +func TestCalendarTodayKeysJumpToToday(t *testing.T) { + calFixToday(t, calDate(2026, time.October, 29)) + for _, r := range "tT" { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + if !tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was not handled on the grid", r) + } + requireDay(t, tv, "2026-10-29", string(r)) + requireMonth(t, tv, 2026, time.October, string(r)) + } + // Without Vim mode the printable key keeps its gate. + a, tv := calTestApp(t, nil) + a.prefs.VimMode = false + if tv.handleKey(a, vim.R('t')) { + t.Error("t was handled without Vim mode") + } + requireDay(t, tv, "2026-11-18", "t without Vim mode") +} + +func TestCalendarOpenKeyOpensDailyNote(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.R('o')) + want := calDailyPath(a, calDate(2026, time.November, 18)) + if tab := a.activeTab(); tab == nil || tab.path != want { + t.Errorf("active tab = %+v, want the daily note %s", tab, want) + } +} + +func TestItemPanelEditKeysChangeDue(t *testing.T) { + for _, c := range []struct { + key rune + want string + }{{'>', "alpha job due:2026-11-19"}, {'<', "alpha job due:2026-11-17"}} { + a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter, vim.R(c.key)) + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, c.want) { + t.Errorf("%c: note is\n%s\nwant it to contain %q", c.key, got, c.want) + } + } + + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter, vim.R('d')) + p, ok := a.overlay.(*prompt) + if !ok { + t.Fatalf("overlay after d = %T, want the due-date prompt", a.overlay) + } + if !strings.Contains(p.title, "Due") { + t.Errorf("prompt title = %q, want the due-date prompt", p.title) + } +} + +func TestItemPanelShiftOnScheduledEntryMovesDue(t *testing.T) { + a, tv, root := calVaultApp(t, map[string]string{"plan.md": calPlanNote}) + tv.selectDay(a, calDate(2026, time.November, 16)) + calPress(a, tv, vim.KeyEnter) + got := tv.selectedTask() + if got == nil || got.SourcePath != "plan.md" { + t.Fatalf("selected entry on the scheduled day = %+v, want the task file", got) + } + calPress(a, tv, vim.R('>')) + body := calReadFile(t, root, "plan.md") + if !regexp.MustCompile(`(?m)^due:\s*2026-11-19\s*$`).MatchString(body) { + t.Errorf("due did not move one day later:\n%s", body) + } + if !regexp.MustCompile(`(?m)^scheduled:\s*2026-11-16\s*$`).MatchString(body) { + t.Errorf("scheduled changed:\n%s", body) + } +} + +func TestItemPanelOpenToggleAndMenu(t *testing.T) { + for _, k := range []vim.Key{vim.KeyEnter, vim.R('o')} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter, k) + if tab := a.activeTab(); tab == nil || tab.path != "work.md" { + t.Errorf("%s: active tab = %+v, want work.md", k, tab) + } + } + + a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter, vim.R('x')) + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "- [x] alpha job") { + t.Errorf("x did not toggle the selected entry:\n%s", got) + } + + a, tv, _ = calVaultApp(t, map[string]string{"work.md": calWorkNote}) + keys := bindingKeys(a.keymap.Binding("nav.contextMenu")) + if len(keys) != 1 { + t.Fatalf("nav.contextMenu binding = %v, want one key", keys) + } + calPress(a, tv, vim.KeyEnter, keys[0]) + if _, ok := a.overlay.(*menu); !ok { + t.Errorf("overlay after the context-menu key = %T, want the task menu", a.overlay) + } +} + +func TestItemPanelSelectedTaskFeedsActions(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calPress(a, tv, vim.KeyEnter) + want := []string{"bravo job", "bravo job", "alpha job", "alpha job"} + moves := []vim.Key{vim.R('j'), vim.R('j'), vim.R('k'), vim.R('k')} + if got := tv.selectedTask(); got == nil || got.Content != "alpha job" { + t.Fatalf("selected after Enter = %+v, want alpha job", got) + } + // j stops at the last entry of the day; k stops at the first. + for i, k := range moves { + calPress(a, tv, k) + got := tv.selectedTask() + if got == nil || got.Content != want[i] { + t.Errorf("after move %d selected = %+v, want %s", i+1, got, want[i]) + } + } +} + +func TestItemPanelLeftAndEscReturnToGrid(t *testing.T) { + for _, vimOn := range []bool{true, false} { + for _, back := range []vim.Key{vim.KeyEsc, vim.Special("left"), vim.R('h')} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + a.prefs.VimMode = vimOn + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusItems { + t.Fatalf("vim=%v: Enter did not focus the item panel", vimOn) + } + calPress(a, tv, back) + if tv.calc().focus != calFocusGrid { + t.Errorf("vim=%v: %s left focus on %v, want the grid", vimOn, back, tv.calc().focus) + } + requireDay(t, tv, "2026-11-18", "returning to the grid") + } + } +} + +func TestItemPanelArrowsWithoutVimMode(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + a.prefs.VimMode = false + calPress(a, tv, vim.KeyEnter) + if tv.calc().focus != calFocusItems { + t.Fatal("Enter did not focus the item panel without Vim mode") + } + calPress(a, tv, vim.Special("down")) + if got := tv.selectedTask(); got == nil || got.Content != "bravo job" { + t.Errorf("after Down selected = %+v, want bravo job", got) + } + calPress(a, tv, vim.Special("up")) + if got := tv.selectedTask(); got == nil || got.Content != "alpha job" { + t.Errorf("after Up selected = %+v, want alpha job", got) + } + calPress(a, tv, vim.Special("left")) + if tv.calc().focus != calFocusGrid { + t.Errorf("Left left focus on %v, want the grid", tv.calc().focus) + } +} diff --git a/internal/tui/views_tasks_calendar_modes_test.go b/internal/tui/views_tasks_calendar_modes_test.go new file mode 100644 index 0000000..7df0f96 --- /dev/null +++ b/internal/tui/views_tasks_calendar_modes_test.go @@ -0,0 +1,537 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +var ( + calLabel24 = regexp.MustCompile(`^\s*(\d{2}(?::30)?)(?:\s|\x{2502}|\|$|$)`) + calLabel12 = regexp.MustCompile(`^\s*(\d{1,2}(?::30)? [AP]M)`) + calMoreRe = regexp.MustCompile(`\+(\d+) more`) + calZqRe = regexp.MustCompile(`zq\d\d`) +) + +func calEsc(a *App, tv *tasksView) { calPress(a, tv, vim.KeyEsc) } + +// calToSub presses Esc until the calendar shows the given layout. +func calToSub(t *testing.T, a *App, tv *tasksView, want calSub) { + t.Helper() + for i := 0; i < 3 && tv.calc().sub != want; i++ { + calEsc(a, tv) + } + if tv.calc().sub != want { + t.Fatalf("Esc did not reach layout %v (stuck on %v)", want, tv.calc().sub) + } +} + +func calHourLabels(lines []string, re *regexp.Regexp) []string { + var out []string + for _, l := range lines { + if m := re.FindStringSubmatch(l); m != nil { + out = append(out, m[1]) + } + } + return out +} + +func calHours24() []string { + var out []string + for h := 0; h < 24; h++ { + out = append(out, fmt.Sprintf("%02d", h)) + } + return out +} + +func calHours12() []string { + var out []string + for h := 0; h < 24; h++ { + n := h % 12 + if n == 0 { + n = 12 + } + suffix := "AM" + if h >= 12 { + suffix = "PM" + } + out = append(out, fmt.Sprintf("%d %s", n, suffix)) + } + return out +} + +func requireLabels(t *testing.T, lines []string, re *regexp.Regexp, want []string, context string) { + t.Helper() + got := calHourLabels(lines, re) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("%s: hour labels = %v, want %v\n%s", context, got, want, calScreenText(lines)) + } +} + +// calColumnOf is the cell column where text starts, or -1. +func calColumnOf(lines []string, text string) int { + for _, l := range lines { + if i := strings.Index(l, text); i >= 0 { + return len([]rune(l[:i])) + } + } + return -1 +} + +func calRowOf(lines []string, text string) int { + for i, l := range lines { + if strings.Contains(l, text) { + return i + } + } + return -1 +} + +func requireWeekHeader(t *testing.T, lines []string, order []string, context string) []int { + t.Helper() + cols := calWeekdayHeader(lines, order) + if cols == nil { + t.Fatalf("%s: no weekday header in %v order:\n%s", context, order, calScreenText(lines)) + } + return cols +} + +func requireTooSmall(t *testing.T, lines []string, want bool, context string) { + t.Helper() + got := strings.Contains(calScreenText(lines), "Terminal too small") + if got != want { + t.Errorf("%s: `Terminal too small` shown = %v, want %v:\n%s", context, got, want, calScreenText(lines)) + } +} + +func calManyTasks(day string, n int, prefix string) []vault.Task { + var tasks []vault.Task + for i := 0; i < n; i++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", i), SourcePath: "n.md", Content: fmt.Sprintf("%s%02d", prefix, i), Due: day}) + } + return tasks +} + +func TestEscCyclesSubModes(t *testing.T) { + for _, vimOn := range []bool{true, false} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = vimOn + if tv.calc().sub != calMonth { + t.Fatalf("vim=%v: calendar did not open on the month layout", vimOn) + } + for i, want := range []calSub{calWeek, calDay, calMonth} { + if !tv.handleKey(a, vim.KeyEsc) { + t.Errorf("vim=%v: Esc %d was not handled", vimOn, i+1) + } + if got := tv.calc().sub; got != want { + t.Errorf("vim=%v: after Esc %d layout = %v, want %v", vimOn, i+1, got, want) + } + if tv.mode != "calendar" { + t.Errorf("vim=%v: Esc %d left calendar mode: %q", vimOn, i+1, tv.mode) + } + } + } +} + +func TestEscToWeekKeepsSelectedDay(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "edge16 task", Due: "2026-11-16"}, + {ID: "n#1", SourcePath: "n.md", Content: "edge22 task", Due: "2026-11-22"}, + {ID: "n#2", SourcePath: "n.md", Content: "before15 task", Due: "2026-11-15"}, + {ID: "n#3", SourcePath: "n.md", Content: "after23 task", Due: "2026-11-23"}, + }) + calEsc(a, tv) + requireDay(t, tv, "2026-11-18", "week view") + if tv.calc().sub != calWeek { + t.Fatalf("Esc did not open week view") + } + lines := calScreen(a, tv, 120, 60) + requireWeekHeader(t, lines, calWeekdayNames, "week of 2026-11-18") + text := calScreenText(lines) + for _, want := range []string{"edge16 task", "edge22 task"} { + if !strings.Contains(text, want) { + t.Errorf("%q missing from the week containing 2026-11-18:\n%s", want, text) + } + } + for _, bad := range []string{"before15", "after23"} { + if strings.Contains(text, bad) { + t.Errorf("%q shown, but it is outside the week:\n%s", bad, text) + } + } + calEsc(a, tv) + requireDay(t, tv, "2026-11-18", "day view") +} + +func TestSubModeNeverWritesPreferences(t *testing.T) { + a, tv := calTestApp(t, nil) + dir := os.Getenv("ZENNOTES_CONFIG_DIR") + if dir == "" { + t.Fatal("test config dir is not set") + } + snapshot := func() map[string]string { + out := map[string]string{} + _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + b, _ := os.ReadFile(p) + out[p] = string(b) + return nil + }) + return out + } + // The user's saved view mode; opening the tab through a command does not set it. + a.prefs.TasksViewMode = "calendar" + before := snapshot() + for _, want := range []calSub{calWeek, calDay, calMonth, calWeek} { + calEsc(a, tv) + if tv.calc().sub != want { + t.Fatalf("layout = %v, want %v", tv.calc().sub, want) + } + } + after := snapshot() + if len(after) != len(before) { + t.Errorf("config dir has %d files after cycling, had %d", len(after), len(before)) + } + for p, body := range after { + if old, ok := before[p]; !ok || old != body { + t.Errorf("config file %s was written while cycling", p) + } + } + if a.prefs.TasksViewMode != "calendar" || tv.mode != "calendar" { + t.Errorf("tasks_view_mode = %q, view mode = %q, want calendar for both", a.prefs.TasksViewMode, tv.mode) + } +} + +func TestNewSessionOpensMonth(t *testing.T) { + a, tv := calTestApp(t, nil) + calEsc(a, tv) + if tv.calc().sub != calWeek { + t.Fatal("Esc did not leave the month layout in the first session") + } + a2, tv2 := calTestApp(t, nil) + if tv2.calc().sub != calMonth { + t.Errorf("new session opened on layout %v, want month", tv2.calc().sub) + } + requireFullMonthGrid(t, calScreen(a2, tv2, 120, 40)) +} + +func TestSubModeSurvivesViewCycle(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + calEsc(a, tv) + if tv.calc().sub != calWeek { + t.Fatal("Esc did not open week view") + } + for i, want := range []string{"list", "kanban", "calendar"} { + if !tv.handleKey(a, vim.R('v')) { + t.Fatalf("v %d was not handled", i+1) + } + if tv.mode != want { + t.Fatalf("v %d gave mode %q, want %q", i+1, tv.mode, want) + } + } + if tv.calc().sub != calWeek { + t.Errorf("layout after the view cycle = %v, want week", tv.calc().sub) + } + requireWeekHeader(t, calScreen(a, tv, 120, 60), calWeekdayNames, "week after v cycle") +} + +func TestSubModeSurvivesTasksCalendarCommand(t *testing.T) { + a, tv := calTestApp(t, nil) + calToSub(t, a, tv, calDay) + tv.setMode("list") + if err := a.openTasksMode("calendar"); err != nil { + t.Fatal(err) + } + tab := a.activeTab() + if tab == nil { + t.Fatal("no active tab") + } + tv2, ok := tab.view.(*tasksView) + if !ok { + t.Fatalf("active tab is %T", tab.view) + } + if tv2.mode != "calendar" { + t.Fatalf("mode = %q, want calendar", tv2.mode) + } + if tv2.calc().sub != calDay { + t.Errorf("layout after :tasks calendar = %v, want day", tv2.calc().sub) + } + if cols := calWeekdayHeader(calScreen(a, tv2, 120, 60), calWeekdayNames); cols != nil { + t.Errorf("day view shows a seven-day header") + } +} + +func TestWeekDayViewsHideWeekNumbers(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.CalendarShowWeekNumbers = true + if !weekNumberShown(calScreen(a, tv, 120, 60), 47) { + t.Fatal("month view should show ISO week 47 with week numbers on") + } + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + lines := calScreen(a, tv, 120, 60) + requireTooSmall(t, lines, false, fmt.Sprint(sub)) + if weekNumberShown(lines, 47) { + t.Errorf("layout %v shows a week number:\n%s", sub, calScreenText(lines)) + } + if sub == calWeek { + requireWeekHeader(t, lines, calWeekdayNames, "week view") + } + } +} + +func TestHourlyLabels24h(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + requireLabels(t, calScreen(a, tv, 120, 70), calLabel24, calHours24(), fmt.Sprintf("layout %v", sub)) + } +} + +func TestHourlyLabels12h(t *testing.T) { + a, tv := calTestApp(t, nil) + a.prefs.TimeFormat = "12h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + requireLabels(t, calScreen(a, tv, 120, 70), calLabel12, calHours12(), fmt.Sprintf("layout %v", sub)) + } +} + +func TestEntriesStayOutOfHourRows(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "call at 15:00 sharp", Due: "2026-11-18"}, + }) + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + lines := calScreen(a, tv, 120, 70) + text := calScreenText(lines) + if got := strings.Count(text, "call at 15:00"); got != 1 { + t.Fatalf("layout %v: entry shown %d times, want once in its column:\n%s", sub, got, text) + } + if len(calHourLabels(lines, calLabel24)) != 24 { + t.Fatalf("layout %v: hour list missing:\n%s", sub, text) + } + // Entries are listed from the top of the column, not by time of day: + // the entry cannot sit as far down as the 15 row. + entryRow := calRowOf(lines, "call at 15:00") + above := calHourLabels(lines[:entryRow+1], calLabel24) + if len(above) > 6 { + t.Errorf("layout %v: entry placed %d hour rows down the column:\n%s", sub, len(above), text) + } + } +} + +func TestWeekShowsSevenColumnsInWeekStartOrder(t *testing.T) { + var tasks []vault.Task + for d := 14; d <= 24; d++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", d), SourcePath: "n.md", Content: fmt.Sprintf("day%d", d), Due: fmt.Sprintf("2026-11-%02d", d)}) + } + for _, c := range []struct { + start string + order []string + in []int + outside []int + }{ + {"monday", calWeekdayNames, []int{16, 17, 18, 19, 20, 21, 22}, []int{14, 15, 23, 24}}, + {"sunday", calSundayFirst, []int{15, 16, 17, 18, 19, 20, 21}, []int{14, 22, 23, 24}}, + } { + a, tv := calTestApp(t, tasks) + a.prefs.CalendarWeekStart = c.start + calToSub(t, a, tv, calWeek) + lines := calScreen(a, tv, 120, 70) + cols := requireWeekHeader(t, lines, c.order, c.start) + if cols[0] < 2 { + t.Errorf("%s: first day column starts at %d, leaving no room for the hour list", c.start, cols[0]) + } + prev := -1 + for _, d := range c.in { + x := calColumnOf(lines, fmt.Sprintf("day%d", d)) + if x < 0 { + t.Errorf("%s: day%d missing from the week:\n%s", c.start, d, calScreenText(lines)) + continue + } + if x <= prev { + t.Errorf("%s: day%d at column %d is not right of the previous day (%d)", c.start, d, x, prev) + } + prev = x + } + for _, d := range c.outside { + if strings.Contains(calScreenText(lines), fmt.Sprintf("day%d", d)) { + t.Errorf("%s: day%d shown outside the week", c.start, d) + } + } + } +} + +func TestWeekColumnOverflowShowsMoreCount(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 50, "zq")) + calToSub(t, a, tv, calWeek) + lines := calScreen(a, tv, 120, 60) + requireWeekHeader(t, lines, calWeekdayNames, "week view") + requireOverflowCount(t, lines, 50) +} + +func TestDayColumnOverflowShowsMoreCount(t *testing.T) { + a, tv := calTestApp(t, calManyTasks("2026-11-18", 50, "zq")) + calToSub(t, a, tv, calDay) + requireOverflowCount(t, calScreen(a, tv, 120, 60), 50) +} + +func requireOverflowCount(t *testing.T, lines []string, total int) { + t.Helper() + text := calScreenText(lines) + listed := len(calZqRe.FindAllString(text, -1)) + if listed == 0 || listed >= total { + t.Fatalf("listed %d of %d entries, want some but not all:\n%s", listed, total, text) + } + more := calMoreRe.FindAllStringSubmatch(text, -1) + if len(more) != 1 { + t.Fatalf("found %d `+N more` lines, want 1:\n%s", len(more), text) + } + if want := fmt.Sprint(total - listed); more[0][1] != want { + t.Errorf("+%s more, want +%s more for %d listed", more[0][1], want, listed) + } + lastEntry := -1 + for i, l := range lines { + if calZqRe.MatchString(l) { + lastEntry = i + } + } + if row := calRowOf(lines, "more"); row <= lastEntry { + t.Errorf("`+N more` on row %d is not after the last listed entry (row %d)", row, lastEntry) + } +} + +func TestDayShowsOneFullWidthColumn(t *testing.T) { + long := "fullwidth " + strings.Repeat("abcdefghi ", 6) + "end" + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: long, Due: "2026-11-18"}, + {ID: "n#1", SourcePath: "n.md", Content: "nextday task", Due: "2026-11-19"}, + }) + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, calDay) + lines := calScreen(a, tv, 120, 60) + requireTooSmall(t, lines, false, "day view") + text := calScreenText(lines) + if !strings.Contains(text, long) { + t.Errorf("a %d-cell entry is cut in a 120-wide day column:\n%s", len(long), text) + } + if strings.Contains(text, "nextday task") { + t.Errorf("day view shows another day's entry:\n%s", text) + } + if cols := calWeekdayHeader(lines, calWeekdayNames); cols != nil { + t.Errorf("day view shows seven day columns:\n%s", text) + } + if len(calHourLabels(lines, calLabel24)) != 24 { + t.Errorf("day view hour list missing:\n%s", text) + } +} + +func TestWeekAndDayStripBelowGrid(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "column entry", Due: "2026-11-18"}, + {ID: "n#1", SourcePath: "n.md", Content: "loose strip task"}, + }) + a.prefs.TimeFormat = "24h" + for _, sub := range []calSub{calWeek, calDay} { + calToSub(t, a, tv, sub) + lines := calScreen(a, tv, 120, 70) + text := calScreenText(lines) + if got := strings.Count(text, "loose strip task"); got != 1 { + t.Fatalf("layout %v: undated task shown %d times, want once:\n%s", sub, got, text) + } + if calRowOf(lines, "column entry") < 0 { + t.Fatalf("layout %v: dated entry missing:\n%s", sub, text) + } + last := -1 + for i, l := range lines { + if m := calLabel24.FindStringSubmatch(l); m != nil && m[1] == "23" { + last = i + } + } + if last < 0 { + t.Fatalf("layout %v: no 23 hour row:\n%s", sub, text) + } + if row := calRowOf(lines, "loose strip task"); row <= last { + t.Errorf("layout %v: strip row %d is not below the last hour row %d", sub, row, last) + } + } +} + +func TestWeekTooSmallBelowMinimum(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{{ID: "n#0", SourcePath: "n.md", Content: "something", Due: "2026-11-18"}}) + calToSub(t, a, tv, calWeek) + for _, size := range [][2]int{{63, 12}, {64, 11}} { + lines := calScreen(a, tv, size[0], size[1]) + requireTooSmall(t, lines, true, fmt.Sprintf("%dx%d", size[0], size[1])) + if strings.Contains(calScreenText(lines), "something") { + t.Errorf("%dx%d: entry shown while too small", size[0], size[1]) + } + } + lines := calScreen(a, tv, 64, 12) + requireTooSmall(t, lines, false, "64x12") + requireWeekHeader(t, lines, calWeekdayNames, "64x12") + if len(lines) != 13 { + t.Errorf("render is %d lines, want toolbar plus 12", len(lines)) + } + for i, l := range lines { + if lipgloss.Width(l) > 64 { + t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) + } + } +} + +func TestDayTooSmallBelowMinimum(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{{ID: "n#0", SourcePath: "n.md", Content: "dt1", Due: "2026-11-18"}}) + calToSub(t, a, tv, calDay) + for _, size := range [][2]int{{39, 10}, {40, 9}} { + lines := calScreen(a, tv, size[0], size[1]) + requireTooSmall(t, lines, true, fmt.Sprintf("%dx%d", size[0], size[1])) + if strings.Contains(calScreenText(lines), "dt1") { + t.Errorf("%dx%d: entry shown while too small", size[0], size[1]) + } + } + lines := calScreen(a, tv, 40, 10) + requireTooSmall(t, lines, false, "40x10") + if !strings.Contains(calScreenText(lines), "dt1") { + t.Errorf("40x10: day column does not show the entry:\n%s", calScreenText(lines)) + } + if len(lines) != 11 { + t.Errorf("render is %d lines, want toolbar plus 10", len(lines)) + } + for i, l := range lines { + if lipgloss.Width(l) > 40 { + t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) + } + } +} + +func TestTooSmallMonthDoesNotSwitchModeButEscDoes(t *testing.T) { + a, tv := calTestApp(t, nil) + // A body that meets the week minimum but not the month one. + for i := 0; i < 2; i++ { + requireTooSmall(t, calScreen(a, tv, 64, 12), true, "month at 64x12") + if tv.calc().sub != calMonth { + t.Fatalf("render switched the layout to %v by itself", tv.calc().sub) + } + } + calEsc(a, tv) + if tv.calc().sub != calWeek { + t.Fatalf("Esc while too small left layout %v, want week", tv.calc().sub) + } + lines := calScreen(a, tv, 64, 12) + requireTooSmall(t, lines, false, "week at 64x12") + requireWeekHeader(t, lines, calWeekdayNames, "week at 64x12") +} diff --git a/internal/tui/views_tasks_calendar_moreline_test.go b/internal/tui/views_tasks_calendar_moreline_test.go new file mode 100644 index 0000000..dbf4e20 --- /dev/null +++ b/internal/tui/views_tasks_calendar_moreline_test.go @@ -0,0 +1,59 @@ +package tui + +import ( + "fmt" + "regexp" + "testing" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/charmbracelet/lipgloss" +) + +func TestCalMoreLabelFallsBackWithoutCuttingWords(t *testing.T) { + cases := []struct { + n, w int + want string + }{ + {4, 20, " +4 more"}, + {4, 8, " +4 more"}, + {4, 7, " +4"}, + {4, 3, " +4"}, + {12, 3, " +…"}, + {4, 0, ""}, + } + for _, c := range cases { + got := calMoreLabel(c.n, c.w) + if got != c.want { + t.Errorf("calMoreLabel(%d, %d) = %q, want %q", c.n, c.w, got, c.want) + } + if lipgloss.Width(got) > c.w { + t.Errorf("calMoreLabel(%d, %d) = %q is wider than %d", c.n, c.w, got, c.w) + } + } +} + +// A month box too narrow for "+N more" must read "+N", never a cut word. +func TestMonthOverflowLineNeverCutMidWord(t *testing.T) { + var tasks []vault.Task + for i := 0; i < 50; i++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", i), SourcePath: "n.md", Content: fmt.Sprintf("zq%02d", i), Due: "2026-11-10"}) + } + cut := regexp.MustCompile(`\+\d+ mo?r?(\s|$)`) + short := regexp.MustCompile(`\+\d+(\s|$)`) + for _, sz := range [][2]int{{120, 40}, {72, 19}} { + a, tv := calTestApp(t, tasks) + lines := calScreen(a, tv, sz[0], sz[1]) + text := calScreenText(lines) + for _, l := range lines { + if lipgloss.Width(l) > sz[0] { + t.Errorf("%dx%d: line wider than the body: %q", sz[0], sz[1], l) + } + if m := cut.FindString(l); m != "" { + t.Errorf("%dx%d: overflow line cut mid-word: %q\n%s", sz[0], sz[1], m, text) + } + } + if !short.MatchString(text) { + t.Errorf("%dx%d: no overflow count shown:\n%s", sz[0], sz[1], text) + } + } +} diff --git a/internal/tui/views_tasks_calendar_mouse_test.go b/internal/tui/views_tasks_calendar_mouse_test.go new file mode 100644 index 0000000..8c65b34 --- /dev/null +++ b/internal/tui/views_tasks_calendar_mouse_test.go @@ -0,0 +1,349 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/ZenNotes/tui/internal/vim" +) + +// calMouseFiles gives the calendar dated entries on 2026-11-18 and 19 and +// three undated tasks for the No-date strip. +func calMouseFiles() map[string]string { + return map[string]string{"work.md": calWorkNote, "strip.md": calStripNote} +} + +// calMouseSubs are the layouts a click has to work in, each with a day that +// holds no entries and is on screen next to 2026-11-21. +var calMouseSubs = []struct { + name string + sub calSub + target time.Time +}{ + {"month", calMonth, calDate(2026, time.November, 25)}, + {"week", calWeek, calDate(2026, time.November, 20)}, + {"day", calDay, calDate(2026, time.November, 21)}, +} + +// calPaneBody renders the Tasks view at the size its pane gives it, as the +// frame does, and returns the lines (line 0 is the view's own header). +func calPaneBody(a *App, tv *tasksView) []string { + cr := a.contentRect(a.activePane) + return strings.Split(stripAnsi(tv.render(a, cr.w, cr.h, true)), "\n") +} + +// calCellOf is the cell column where text starts on a line. +func calCellOf(line, text string) int { + i := strings.Index(line, text) + if i < 0 { + return -1 + } + return lipgloss.Width(line[:i]) +} + +// calFindIn looks for text on the rendered lines inside a layout rectangle +// (body cells, so line 0 of the view is the header above it) and returns the +// view-relative cell. +func calFindIn(lines []string, r calRect, text string) (int, int, bool) { + for y := r.y; y < r.y+r.h && y+1 < len(lines); y++ { + line := lines[y+1] + if x := calCellOf(line, text); x >= r.x && x < r.x+r.w { + return x, y + 1, true + } + } + return 0, 0, false +} + +func calBoxFor(t *testing.T, tv *tasksView, day time.Time) calRect { + t.Helper() + for _, b := range tv.calc().layout.boxes { + if b.day.Format("2006-01-02") == day.Format("2006-01-02") { + return b.rect + } + } + t.Fatalf("layout has no box for %s", day.Format("2006-01-02")) + return calRect{} +} + +// calMouse sends one mouse press at a view-relative cell through the app's +// real mouse entry point. +func calMouse(a *App, button tea.MouseButton, x, y int) { + cr := a.contentRect(a.activePane) + a.handleMouse(tea.MouseMsg{X: cr.x + x, Y: cr.y + y, Button: button, Action: tea.MouseActionPress}) +} + +// calDouble clicks the same cell twice, quickly. +func calDouble(a *App, x, y int) { + calMouse(a, tea.MouseButtonLeft, x, y) + calMouse(a, tea.MouseButtonLeft, x, y) +} + +// calEmptyDayCell is a view-relative cell inside a day's box or column, low +// enough to be clear of its entries. +func calEmptyDayCell(r calRect) (int, int) { + return r.x + r.w/2, 1 + r.y + r.h - 1 +} + +func requireMenuTitled(t *testing.T, a *App, want, context string) { + t.Helper() + m, ok := a.overlay.(*menu) + if !ok { + t.Errorf("%s: overlay = %T, want the task menu", context, a.overlay) + return + } + if !strings.Contains(m.title, want) { + t.Errorf("%s: menu title = %q, want the task %q", context, m.title, want) + } +} + +func TestClickDaySelectsWithGridFocus(t *testing.T) { + for _, s := range calMouseSubs { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + tv.selectDay(a, calDate(2026, time.November, 21)) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, s.name+" set-up") + calPaneBody(a, tv) + x, y := calEmptyDayCell(calBoxFor(t, tv, s.target)) + calMouse(a, tea.MouseButtonLeft, x, y) + requireDay(t, tv, s.target.Format("2006-01-02"), s.name+" click") + requireFocus(t, tv, calFocusGrid, s.name+" click") + if tv.calc().sub != s.sub { + t.Errorf("%s: a click changed the layout to %v", s.name, tv.calc().sub) + } + } +} + +func TestClickDayFromItemsFocus(t *testing.T) { + for _, s := range calMouseSubs[:2] { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyEnter) + requireFocus(t, tv, calFocusItems, s.name+" set-up") + calPaneBody(a, tv) + x, y := calEmptyDayCell(calBoxFor(t, tv, s.target)) + calMouse(a, tea.MouseButtonLeft, x, y) + requireDay(t, tv, s.target.Format("2006-01-02"), s.name+" click") + requireFocus(t, tv, calFocusGrid, s.name+" click") + } +} + +func TestDoubleClickDayActsAsEnter(t *testing.T) { + for _, s := range calMouseSubs { + // An empty day opens its daily note. There is no No-date strip here, + // because Enter on a grid with a strip moves focus to it first. + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, s.sub) + tv.selectDay(a, calDate(2026, time.November, 21)) + calPaneBody(a, tv) + x, y := calEmptyDayCell(calBoxFor(t, tv, s.target)) + calDouble(a, x, y) + want := calDailyPath(a, s.target) + if tab := a.activeTab(); tab == nil || tab.path != want { + t.Errorf("%s: double-click on an empty day: active tab = %+v, want the daily note %s", s.name, tab, want) + } + } + for _, s := range calMouseSubs[:2] { + // A day with entries focuses them. + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + tv.selectDay(a, calDate(2026, time.November, 21)) + calPaneBody(a, tv) + x, y := calEmptyDayCell(calBoxFor(t, tv, calDate(2026, time.November, 18))) + calDouble(a, x, y) + requireDay(t, tv, "2026-11-18", s.name+" double-click") + requireFocus(t, tv, calFocusItems, s.name+" double-click on a day with entries") + requireSelected(t, tv, "alpha job", s.name+" double-click") + } +} + +// calEntryRegion is the rectangle the 2026-11-18 entries are listed in: the +// item panel in month view, the day's column in week and day view. +func calEntryRegion(t *testing.T, tv *tasksView, sub calSub) calRect { + t.Helper() + if sub == calMonth { + return tv.calc().layout.items + } + return calBoxFor(t, tv, calDate(2026, time.November, 18)) +} + +// calFindEntry renders the calendar and finds the bravo entry on screen. +func calFindEntry(t *testing.T, a *App, tv *tasksView, name string, sub calSub) (int, int) { + t.Helper() + lines := calPaneBody(a, tv) + x, y, ok := calFindIn(lines, calEntryRegion(t, tv, sub), "bravo job") + if !ok { + t.Fatalf("%s: bravo job is not shown where the entries are listed:\n%s", name, calScreenText(lines)) + } + return x, y +} + +func TestClickEntryFocusesItems(t *testing.T) { + for _, s := range calMouseSubs { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + x, y := calFindEntry(t, a, tv, s.name, s.sub) + calMouse(a, tea.MouseButtonLeft, x, y) + requireFocus(t, tv, calFocusItems, s.name+" click on an entry") + requireSelected(t, tv, "bravo job", s.name+" click on an entry") + requireDay(t, tv, "2026-11-18", s.name+" click on an entry") + } +} + +func TestRightClickEntryOpensTaskMenu(t *testing.T) { + for _, s := range calMouseSubs { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + x, y := calFindEntry(t, a, tv, s.name, s.sub) + calMouse(a, tea.MouseButtonRight, x, y) + requireMenuTitled(t, a, "bravo job", s.name+" right-click on an entry") + } +} + +func TestDoubleClickEntryOpensSource(t *testing.T) { + for _, s := range calMouseSubs { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + x, y := calFindEntry(t, a, tv, s.name, s.sub) + calDouble(a, x, y) + if tab := a.activeTab(); tab == nil || tab.path != "work.md" { + t.Errorf("%s: double-click on an entry: active tab = %+v, want work.md", s.name, tab) + } + } +} + +func TestClickStripTaskFocusesStrip(t *testing.T) { + for _, s := range calMouseSubs { + find := func() (*App, *tasksView, int, int) { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + lines := calPaneBody(a, tv) + x, y, ok := calFindIn(lines, tv.calc().layout.strip, "bbb strip") + if !ok { + t.Fatalf("%s: bbb strip is not shown in the No-date strip:\n%s", s.name, calScreenText(lines)) + } + return a, tv, x, y + } + + a, tv, x, y := find() + calMouse(a, tea.MouseButtonLeft, x, y) + requireFocus(t, tv, calFocusStrip, s.name+" click on a strip task") + requireSelected(t, tv, "bbb strip", s.name+" click on a strip task") + + a, _, x, y = find() + calMouse(a, tea.MouseButtonRight, x, y) + requireMenuTitled(t, a, "bbb strip", s.name+" right-click on a strip task") + + a, _, x, y = find() + calDouble(a, x, y) + if tab := a.activeTab(); tab == nil || tab.path != "strip.md" { + t.Errorf("%s: double-click on a strip task: active tab = %+v, want strip.md", s.name, tab) + } + } +} + +func TestWheelScrollsHourListOneRow(t *testing.T) { + for _, c := range []struct { + sub calSub + w, h int + }{{calWeek, 64, 12}, {calDay, 40, 10}} { + for _, half := range []bool{false, true} { + a, tv := calTestApp(t, nil) + a.prefs.VimMode = true + a.prefs.TimeFormat = "24h" + calToSub(t, a, tv, c.sub) + if half { + calPress(a, tv, vim.R('z')) + } + ctx := fmt.Sprintf("layout %v half=%v", c.sub, half) + first := func() string { + got := calHourLabels(calScreen(a, tv, c.w, c.h), calLabel24) + if len(got) == 0 { + t.Fatalf("%s: no hour rows shown", ctx) + } + return got[0] + } + day := calDayISO(tv) + if got := first(); got != "00" { + t.Fatalf("%s: first row = %s before the wheel, want 00", ctx, got) + } + next := "01" + if half { + next = "00:30" + } + // The app sends the wheel as three steps; the list moves one row. + tv.scrollBy(a, 3) + if got := first(); got != next { + t.Errorf("%s: first row after one wheel step down = %s, want %s", ctx, got, next) + } + tv.scrollBy(a, -3) + if got := first(); got != "00" { + t.Errorf("%s: first row after one step down and one up = %s, want 00", ctx, got) + } + tv.scrollBy(a, -3) + if got := first(); got != "00" { + t.Errorf("%s: wheel up at the top scrolled to %s, want 00", ctx, got) + } + requireDay(t, tv, day, ctx+" wheel") + } + } +} + +func TestSelectionPosFollowsFocus(t *testing.T) { + for _, s := range calMouseSubs { + a, tv, _ := calVaultApp(t, calMouseFiles()) + calToSub(t, a, tv, s.sub) + p := a.activePane + cr := a.contentRect(p) + at := func(context string) (int, int, []string) { + t.Helper() + lines := calPaneBody(a, tv) + x, y, ok := tv.selectionPos(a, p) + if !ok { + t.Fatalf("%s %s: selectionPos reported no position", s.name, context) + } + return x - cr.x, y - cr.y, lines + } + + // Grid focus: inside the selected day's box or column. + x, y, _ := at("grid focus") + if box := calBoxFor(t, tv, calDate(2026, time.November, 18)); !box.contains(x, y-1) { + t.Errorf("%s: grid focus position (%d,%d) is outside the selected day's box %+v", s.name, x, y-1, box) + } + tv.selectDay(a, s.target) + x, y, _ = at("moved selection") + if box := calBoxFor(t, tv, s.target); !box.contains(x, y-1) { + t.Errorf("%s: position (%d,%d) did not follow the selection to %s's box %+v", s.name, x, y-1, s.target.Format("2006-01-02"), box) + } + tv.selectDay(a, calDate(2026, time.November, 18)) + + // Items focus: on the selected entry's line. + calPress(a, tv, vim.KeyEnter, vim.R('j')) + requireFocus(t, tv, calFocusItems, s.name+" set-up") + requireSelected(t, tv, "bravo job", s.name+" set-up") + _, y, lines := at("items focus") + if y < 0 || y >= len(lines) || !strings.Contains(lines[y], "bravo job") { + t.Errorf("%s: items focus position row %d is not the selected entry's line", s.name, y) + } + calPress(a, tv, vim.R('k')) + _, y2, lines := at("items focus moved") + if y2 == y || y2 < 0 || y2 >= len(lines) || !strings.Contains(lines[y2], "alpha job") { + t.Errorf("%s: position did not follow the selection to alpha job: row %d then %d", s.name, y, y2) + } + + // Strip focus: on the selected strip task's line. + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, s.name+" set-up") + calPress(a, tv, vim.R('j')) + requireSelected(t, tv, "bbb strip", s.name+" set-up") + _, y, lines = at("strip focus") + if y < 0 || y >= len(lines) || !strings.Contains(lines[y], "bbb strip") { + t.Errorf("%s: strip focus position row %d is not the selected strip task's line", s.name, y) + } + } +} diff --git a/internal/tui/views_tasks_calendar_nav_test.go b/internal/tui/views_tasks_calendar_nav_test.go new file mode 100644 index 0000000..30b24e8 --- /dev/null +++ b/internal/tui/views_tasks_calendar_nav_test.go @@ -0,0 +1,312 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +// calNavTasks puts one task in each of several weeks so a test can tell +// which week or day a render shows by the task texts on screen. +func calNavTasks() []vault.Task { + return []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "wk09 task", Due: "2026-11-09"}, + {ID: "n#1", SourcePath: "n.md", Content: "wk15 task", Due: "2026-11-15"}, + {ID: "n#2", SourcePath: "n.md", Content: "wk16 task", Due: "2026-11-16"}, + {ID: "n#3", SourcePath: "n.md", Content: "wk22 task", Due: "2026-11-22"}, + {ID: "n#4", SourcePath: "n.md", Content: "wk23 task", Due: "2026-11-23"}, + {ID: "n#5", SourcePath: "n.md", Content: "wk25 task", Due: "2026-11-25"}, + {ID: "n#6", SourcePath: "n.md", Content: "wk11 task", Due: "2026-11-11"}, + {ID: "n#7", SourcePath: "n.md", Content: "today27 task", Due: "2026-10-27"}, + {ID: "n#8", SourcePath: "n.md", Content: "today29 task", Due: "2026-10-29"}, + {ID: "n#9", SourcePath: "n.md", Content: "wk19 task", Due: "2026-11-19"}, + {ID: "n#10", SourcePath: "n.md", Content: "wk17 task", Due: "2026-11-17"}, + } +} + +func calNavApp(t *testing.T, sub calSub, vimOn bool) (*App, *tasksView) { + t.Helper() + a, tv := calTestApp(t, calNavTasks()) + a.prefs.VimMode = vimOn + calToSub(t, a, tv, sub) + return a, tv +} + +func requireShown(t *testing.T, a *App, tv *tasksView, shown, hidden []string, context string) { + t.Helper() + text := calScreenText(calScreen(a, tv, 140, 60)) + for _, s := range shown { + if !strings.Contains(text, s) { + t.Errorf("%s: %q is not shown:\n%s", context, s, text) + } + } + for _, s := range hidden { + if strings.Contains(text, s) { + t.Errorf("%s: %q is shown, but it is outside the view:\n%s", context, s, text) + } + } +} + +func TestWeekHLMoveWithinWeek(t *testing.T) { + a, tv := calNavApp(t, calWeek, true) + if !tv.handleKey(a, vim.R('l')) { + t.Error("l was not handled in week view") + } + requireDay(t, tv, "2026-11-19", "l") + if !tv.handleKey(a, vim.R('h')) || !tv.handleKey(a, vim.R('h')) { + t.Error("h was not handled in week view") + } + requireDay(t, tv, "2026-11-17", "h twice") + if tv.calc().sub != calWeek { + t.Errorf("layout after h/l = %v, want week", tv.calc().sub) + } +} + +func TestWeekLOnLastDayMovesToNextWeek(t *testing.T) { + a, tv := calNavApp(t, calWeek, true) + tv.selectDay(a, calDate(2026, time.November, 22)) + calPress(a, tv, vim.R('l')) + requireDay(t, tv, "2026-11-23", "l on the last day of the week") + requireShown(t, a, tv, []string{"wk23 task", "wk25 task"}, []string{"wk22 task", "wk16 task"}, "next week") +} + +func TestWeekHOnFirstDayMovesToPreviousWeek(t *testing.T) { + a, tv := calNavApp(t, calWeek, true) + tv.selectDay(a, calDate(2026, time.November, 16)) + calPress(a, tv, vim.R('h')) + requireDay(t, tv, "2026-11-15", "h on the first day of the week") + requireShown(t, a, tv, []string{"wk09 task", "wk15 task"}, []string{"wk16 task", "wk22 task"}, "previous week") +} + +func TestWeekHalfPageMovesByWeek(t *testing.T) { + for _, vimOn := range []bool{true, false} { + a, tv := calNavApp(t, calWeek, vimOn) + if !tv.handleKey(a, vim.Ctrl('d')) { + t.Errorf("vim=%v: half-page down was not handled in week view", vimOn) + } + requireDay(t, tv, "2026-11-25", "half-page down") + requireShown(t, a, tv, []string{"wk23 task", "wk25 task"}, []string{"wk16 task"}, "week after half-page down") + calPress(a, tv, vim.Ctrl('u'), vim.Ctrl('u')) + requireDay(t, tv, "2026-11-11", "half-page up twice") + requireShown(t, a, tv, []string{"wk09 task", "wk11 task"}, []string{"wk16 task", "wk25 task"}, "week after half-page up") + } +} + +func TestWeekTabFocusesColumnAndJKMove(t *testing.T) { + for _, k := range []vim.Key{vim.KeyTab, vim.KeyEnter} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, calWeek) + if !tv.handleKey(a, k) { + t.Errorf("%s was not handled on the week grid", k) + } + if tv.calc().focus != calFocusItems { + t.Fatalf("%s: focus = %v, want the day's column", k, tv.calc().focus) + } + if got := tv.selectedTask(); got == nil || got.Content != "alpha job" { + t.Errorf("%s: selected = %+v, want alpha job", k, got) + } + calPress(a, tv, vim.R('j')) + if got := tv.selectedTask(); got == nil || got.Content != "bravo job" { + t.Errorf("%s: after j selected = %+v, want bravo job", k, got) + } + calPress(a, tv, vim.R('k')) + if got := tv.selectedTask(); got == nil || got.Content != "alpha job" { + t.Errorf("%s: after k selected = %+v, want alpha job", k, got) + } + requireDay(t, tv, "2026-11-18", "moving through the column") + } +} + +func TestDayHalfPageMovesByDay(t *testing.T) { + for _, vimOn := range []bool{true, false} { + a, tv := calNavApp(t, calDay, vimOn) + if !tv.handleKey(a, vim.Ctrl('d')) { + t.Errorf("vim=%v: half-page down was not handled in day view", vimOn) + } + requireDay(t, tv, "2026-11-19", "half-page down") + requireShown(t, a, tv, []string{"wk19 task"}, []string{"wk17 task"}, "day after half-page down") + calPress(a, tv, vim.Ctrl('u'), vim.Ctrl('u')) + requireDay(t, tv, "2026-11-17", "half-page up twice") + requireShown(t, a, tv, []string{"wk17 task"}, []string{"wk19 task"}, "day after half-page up") + if tv.calc().sub != calDay { + t.Errorf("vim=%v: layout = %v, want day", vimOn, tv.calc().sub) + } + } +} + +func TestWeekArrowsWithoutVimMode(t *testing.T) { + a, tv := calNavApp(t, calWeek, false) + for _, c := range []struct{ key, want string }{{"right", "2026-11-19"}, {"left", "2026-11-17"}} { + tv.selectDay(a, calDate(2026, time.November, 18)) + if !tv.handleKey(a, vim.Special(c.key)) { + t.Errorf("%s was not handled in week view without Vim mode", c.key) + } + requireDay(t, tv, c.want, c.key) + } + tv.selectDay(a, calDate(2026, time.November, 22)) + calPress(a, tv, vim.Special("right")) + requireDay(t, tv, "2026-11-23", "Right on the last day of the week") + requireShown(t, a, tv, []string{"wk23 task"}, []string{"wk22 task"}, "next week by Right") + tv.selectDay(a, calDate(2026, time.November, 16)) + calPress(a, tv, vim.Special("left")) + requireDay(t, tv, "2026-11-15", "Left on the first day of the week") + requireShown(t, a, tv, []string{"wk15 task"}, []string{"wk16 task"}, "previous week by Left") +} + +func TestDayArrowsWithoutVimMode(t *testing.T) { + a, tv := calNavApp(t, calDay, false) + for _, c := range []struct{ key, want string }{{"right", "2026-11-19"}, {"left", "2026-11-17"}} { + tv.selectDay(a, calDate(2026, time.November, 18)) + if !tv.handleKey(a, vim.Special(c.key)) { + t.Errorf("%s was not handled in day view without Vim mode", c.key) + } + requireDay(t, tv, c.want, c.key) + } + requireShown(t, a, tv, []string{"wk17 task"}, []string{"wk19 task"}, "day by Left") +} + +func TestWeekPrintableGridKeysNeedVimMode(t *testing.T) { + a, tv := calNavApp(t, calWeek, false) + tabBefore := a.activeTab() + for _, r := range "z[]to" { + if tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was handled in week view without Vim mode", r) + } + requireDay(t, tv, "2026-11-18", string(r)+" without Vim mode") + if tv.calc().sub != calWeek || tv.calc().focus != calFocusGrid { + t.Errorf("%c without Vim mode changed layout/focus to %v/%v", r, tv.calc().sub, tv.calc().focus) + } + if a.activeTab() != tabBefore { + t.Errorf("%c without Vim mode changed the active tab", r) + } + if a.overlay != nil { + t.Errorf("%c without Vim mode opened an overlay", r) + } + } +} + +func TestNewTaskGoesToSelectedDayInEverySubMode(t *testing.T) { + for _, sub := range []calSub{calMonth, calWeek, calDay} { + a, tv, root := calVaultApp(t, nil) + a.idx.settings.DailyNotes.Enabled = true + calToSub(t, a, tv, sub) + calPress(a, tv, vim.R('n')) + p, ok := a.overlay.(*prompt) + if !ok { + t.Fatalf("layout %v: overlay after n = %T, want the new-task prompt", sub, a.overlay) + } + if !strings.Contains(p.title, "2026-11-18") { + t.Errorf("layout %v: prompt title = %q, want it to name 2026-11-18", sub, p.title) + } + p.onSubmit(a, "navtask text") + rel := calDailyPath(a, calDate(2026, time.November, 18)) + b, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatalf("layout %v: daily note %s not written: %v", sub, rel, err) + } + if !strings.Contains(string(b), "navtask text") { + t.Errorf("layout %v: daily note is\n%s\nwant it to hold the new task", sub, b) + } + } +} + +func TestWeekColumnFocusEditAndOpenKeys(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + for _, k := range []vim.Key{vim.KeyEnter, vim.R('o')} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, sub) + calPress(a, tv, vim.KeyTab, k) + if tab := a.activeTab(); tab == nil || tab.path != "work.md" { + t.Errorf("layout %v: %s opened %+v, want work.md", sub, k, tab) + } + } + + a, tv, root := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, sub) + calPress(a, tv, vim.KeyTab, vim.R('x')) + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "- [x] alpha job") { + t.Errorf("layout %v: x did not toggle the selected entry:\n%s", sub, got) + } + + a, tv, root = calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, sub) + calPress(a, tv, vim.KeyTab, vim.R('>')) + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "alpha job due:2026-11-19") { + t.Errorf("layout %v: > did not move due:\n%s", sub, got) + } + + a, tv, _ = calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, sub) + keys := bindingKeys(a.keymap.Binding("nav.contextMenu")) + if len(keys) != 1 { + t.Fatalf("nav.contextMenu binding = %v, want one key", keys) + } + calPress(a, tv, vim.KeyTab, keys[0]) + if _, ok := a.overlay.(*menu); !ok { + t.Errorf("layout %v: overlay after the context-menu key = %T, want the task menu", sub, a.overlay) + } + } +} + +func TestWeekDayEscAndLeftReturnToGrid(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + for _, vimOn := range []bool{true, false} { + for _, back := range []vim.Key{vim.KeyEsc, vim.Special("left"), vim.R('h')} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + a.prefs.VimMode = vimOn + calToSub(t, a, tv, sub) + calPress(a, tv, vim.KeyTab) + if tv.calc().focus != calFocusItems { + t.Fatalf("layout %v vim=%v: Tab did not focus the column", sub, vimOn) + } + calPress(a, tv, back) + if tv.calc().focus != calFocusGrid { + t.Errorf("layout %v vim=%v: %s left focus on %v, want the grid", sub, vimOn, back, tv.calc().focus) + } + if tv.calc().sub != sub { + t.Errorf("layout %v vim=%v: %s changed the layout to %v", sub, vimOn, back, tv.calc().sub) + } + requireDay(t, tv, "2026-11-18", "returning to the grid") + } + } + } +} + +func TestTodayKeysInWeekAndDay(t *testing.T) { + calFixToday(t, calDate(2026, time.October, 29)) + for _, r := range "tT" { + a, tv := calNavApp(t, calWeek, true) + if !tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was not handled in week view", r) + } + requireDay(t, tv, "2026-10-29", "week "+string(r)) + requireShown(t, a, tv, []string{"today27 task", "today29 task"}, []string{"wk16 task", "wk22 task"}, "week of today") + if tv.calc().sub != calWeek { + t.Errorf("%c changed the layout to %v", r, tv.calc().sub) + } + + a, tv = calNavApp(t, calDay, true) + if !tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was not handled in day view", r) + } + requireDay(t, tv, "2026-10-29", "day "+string(r)) + requireShown(t, a, tv, []string{"today29 task"}, []string{"today27 task"}, "day of today") + } +} + +func TestWeekDayOpenKeyOpensDailyNote(t *testing.T) { + for _, sub := range []calSub{calWeek, calDay} { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calWorkNote}) + calToSub(t, a, tv, sub) + calPress(a, tv, vim.R('o')) + want := calDailyPath(a, calDate(2026, time.November, 18)) + if tab := a.activeTab(); tab == nil || tab.path != want { + t.Errorf("layout %v: active tab = %+v, want the daily note %s", sub, tab, want) + } + } +} diff --git a/internal/tui/views_tasks_calendar_strip_test.go b/internal/tui/views_tasks_calendar_strip_test.go new file mode 100644 index 0000000..e747b00 --- /dev/null +++ b/internal/tui/views_tasks_calendar_strip_test.go @@ -0,0 +1,307 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +var calStripSubs = []struct { + name string + sub calSub +}{{"month", calMonth}, {"week", calWeek}, {"day", calDay}} + +const calStripNote = "- [ ] aaa strip\n- [ ] bbb strip\n- [ ] ccc strip\n" + +func calStripTask(id, content string) vault.Task { + return vault.Task{ID: id, SourcePath: "s.md", Content: content} +} + +func calDatedTask() vault.Task { + return vault.Task{ID: "d#0", SourcePath: "d.md", Content: "dated job", Due: "2026-11-18"} +} + +func requireFocus(t *testing.T, tv *tasksView, want calFocus, context string) { + t.Helper() + if got := tv.calc().focus; got != want { + t.Errorf("%s: focus = %v, want %v", context, got, want) + } +} + +func requireSelected(t *testing.T, tv *tasksView, want, context string) { + t.Helper() + got := tv.selectedTask() + if got == nil || got.Content != want { + t.Errorf("%s: selected = %+v, want %q", context, got, want) + } +} + +// calReload runs the reload every task change triggers: the tasks are scanned +// again and the open views rebuilt from them. +func calReload(t *testing.T, a *App) { + t.Helper() + msg, ok := a.loadTasksCmd()().(tasksLoadedMsg) + if !ok || msg.err != nil { + t.Fatalf("task reload failed: %+v", msg) + } + a.Update(msg) +} + +// calGiveDue sets a due date through the prompt that `d` opens. +func calGiveDue(t *testing.T, a *App, tv *tasksView, day string) { + t.Helper() + calPress(a, tv, vim.R('d')) + if _, ok := a.overlay.(*prompt); !ok { + t.Fatalf("overlay after d = %T, want the due-date prompt", a.overlay) + } + for _, r := range day { + a.overlay.handleKey(a, vim.R(r)) + } + a.overlay.handleKey(a, vim.KeyEnter) + calReload(t, a) +} + +func calManyUndated(n int) []vault.Task { + var out []vault.Task + for i := 0; i < n; i++ { + out = append(out, calStripTask(fmt.Sprintf("u#%d", i), fmt.Sprintf("loose item %02d", i))) + } + return out +} + +func TestTabCyclesGridItemsStrip(t *testing.T) { + for _, vimOn := range []bool{true, false} { + for _, s := range calStripSubs { + a, tv := calTestApp(t, []vault.Task{calDatedTask(), calStripTask("u#0", "loose end")}) + a.prefs.VimMode = vimOn + calToSub(t, a, tv, s.sub) + ctx := fmt.Sprintf("%s vim=%v", s.name, vimOn) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusItems, ctx+" first Tab") + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, ctx+" second Tab") + requireSelected(t, tv, "loose end", ctx+" second Tab") + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusGrid, ctx+" third Tab") + requireDay(t, tv, "2026-11-18", ctx) + if tv.calc().sub != s.sub { + t.Errorf("%s: Tab changed the sub-mode to %v", ctx, tv.calc().sub) + } + } + } +} + +func TestTabSkipsEmptyStrip(t *testing.T) { + for _, s := range calStripSubs { + a, tv := calTestApp(t, []vault.Task{calDatedTask()}) + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusItems, s.name+" first Tab") + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusGrid, s.name+" Tab from the items skips the empty strip") + } +} + +func TestTabSkipsEmptyItemPanel(t *testing.T) { + for _, s := range calStripSubs { + a, tv := calTestApp(t, []vault.Task{calStripTask("u#0", "loose end")}) + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, s.name+" Tab on an empty day skips the item panel") + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusGrid, s.name+" Tab from the strip") + } +} + +func TestTabStaysOnGridWhenBothEmpty(t *testing.T) { + for _, s := range calStripSubs { + a, tv := calTestApp(t, nil) + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusGrid, s.name+" Tab with nothing to focus") + requireDay(t, tv, "2026-11-18", s.name) + } +} + +func TestStripJKMovesAndScrolls(t *testing.T) { + a, tv := calTestApp(t, calManyUndated(3)) + a.prefs.VimMode = true + calPress(a, tv, vim.KeyTab) + requireSelected(t, tv, "loose item 00", "strip entered") + calPress(a, tv, vim.R('j'), vim.R('j'), vim.R('k')) + requireSelected(t, tv, "loose item 01", "j j k") + calPress(a, tv, vim.R('k'), vim.R('k')) + requireSelected(t, tv, "loose item 00", "k stops at the first") + calPress(a, tv, vim.R('j'), vim.R('j'), vim.R('j'), vim.R('j')) + requireSelected(t, tv, "loose item 02", "j stops at the last") + + for _, s := range calStripSubs { + a, tv := calTestApp(t, calManyUndated(30)) + a.prefs.VimMode = true + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyTab) + if text := calScreenText(calScreen(a, tv, 140, 40)); strings.Contains(text, "loose item 29") { + t.Fatalf("%s: the strip shows all 30 tasks, so it cannot scroll:\n%s", s.name, text) + } + for i := 0; i < 29; i++ { + calPress(a, tv, vim.R('j')) + } + requireSelected(t, tv, "loose item 29", s.name+" after 29 j") + if text := calScreenText(calScreen(a, tv, 140, 40)); !strings.Contains(text, "loose item 29") { + t.Errorf("%s: the strip did not scroll to the selected task:\n%s", s.name, text) + } + for i := 0; i < 29; i++ { + calPress(a, tv, vim.R('k')) + } + if text := calScreenText(calScreen(a, tv, 140, 40)); !strings.Contains(text, "loose item 00") { + t.Errorf("%s: the strip did not scroll back to the first task:\n%s", s.name, text) + } + } +} + +func TestStripArrowsWithoutVimMode(t *testing.T) { + a, tv := calTestApp(t, calManyUndated(3)) + a.prefs.VimMode = false + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, "Tab without Vim mode") + calPress(a, tv, vim.Special("down"), vim.Special("down"), vim.Special("up")) + requireSelected(t, tv, "loose item 01", "Down Down Up") +} + +func TestStripKeysActOnSelectedTask(t *testing.T) { + stripApp := func(t *testing.T) (*App, *tasksView, string) { + a, tv, root := calVaultApp(t, map[string]string{"work.md": calStripNote}) + calPress(a, tv, vim.KeyTab, vim.R('j')) + requireFocus(t, tv, calFocusStrip, "setup") + requireSelected(t, tv, "bbb strip", "setup") + return a, tv, root + } + + for _, k := range []vim.Key{vim.KeyEnter, vim.R('o')} { + a, tv, _ := stripApp(t) + calPress(a, tv, k) + if tab := a.activeTab(); tab == nil || tab.path != "work.md" { + t.Errorf("%s: active tab = %+v, want work.md", k, tab) + } + } + + a, tv, root := stripApp(t) + calPress(a, tv, vim.R('x')) + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "- [x] bbb strip") || strings.Contains(got, "- [x] aaa") { + t.Errorf("x did not toggle the selected strip task:\n%s", got) + } + + a, tv, _ = stripApp(t) + keys := bindingKeys(a.keymap.Binding("nav.contextMenu")) + if len(keys) != 1 { + t.Fatalf("nav.contextMenu binding = %v, want one key", keys) + } + calPress(a, tv, keys[0]) + if _, ok := a.overlay.(*menu); !ok { + t.Errorf("overlay after the context-menu key = %T, want the task menu", a.overlay) + } + + for _, r := range "pdesy" { + a, tv, _ := stripApp(t) + if !tv.handleKey(a, vim.R(r)) { + t.Errorf("%c was not handled with strip focus", r) + } + switch r { + case 'p': + if _, ok := a.overlay.(*menu); !ok { + t.Errorf("p: overlay = %T, want the priority menu", a.overlay) + } + case 'd', 'e', 's': + if _, ok := a.overlay.(*prompt); !ok { + t.Errorf("%c: overlay = %T, want a prompt", r, a.overlay) + } + } + requireFocus(t, tv, calFocusStrip, string(r)) + } + + for _, r := range "wc/" { + a, tv, root := stripApp(t) + calPress(a, tv, vim.R(r)) + got := calReadFile(t, root, "work.md") + if got == calStripNote { + t.Errorf("%c left the note unchanged", r) + } + want := strings.Split(calStripNote, "\n") + for i, l := range strings.Split(got, "\n") { + if i != 1 && (i >= len(want) || l != want[i]) { + t.Errorf("%c changed line %d (%q), which is not the selected task", r, i+1, l) + } + } + } + + for _, r := range "><" { + a, tv, root := stripApp(t) + calPress(a, tv, vim.R(r)) + got := calReadFile(t, root, "work.md") + if !strings.Contains(got, "bbb strip due:") || strings.Contains(got, "aaa strip due:") || strings.Contains(got, "ccc strip due:") { + t.Errorf("%c did not give the selected strip task a due date:\n%s", r, got) + } + } +} + +func TestStripSelectionMovesOnWhenTaskGetsDue(t *testing.T) { + a, tv, root := calVaultApp(t, map[string]string{"work.md": calStripNote}) + calPress(a, tv, vim.KeyTab, vim.R('j')) + requireSelected(t, tv, "bbb strip", "before") + calGiveDue(t, a, tv, "2026-11-18") + if got := calReadFile(t, root, "work.md"); !strings.Contains(got, "bbb strip due:2026-11-18") { + t.Fatalf("d did not set the due date:\n%s", got) + } + requireFocus(t, tv, calFocusStrip, "after the reload") + requireSelected(t, tv, "ccc strip", "after the second of three leaves") + if text := calScreenText(calScreen(a, tv, 140, 40)); !strings.Contains(text, "bbb strip") { + t.Errorf("the task is not on its day after the reload:\n%s", text) + } +} + +func TestStripLastTaskLeavingSelectsNewLast(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": calStripNote}) + calPress(a, tv, vim.KeyTab, vim.R('j'), vim.R('j')) + requireSelected(t, tv, "ccc strip", "before") + calGiveDue(t, a, tv, "2026-11-18") + requireFocus(t, tv, calFocusStrip, "after the reload") + requireSelected(t, tv, "bbb strip", "after the last of three leaves") +} + +func TestStripEmptyingReturnsFocusToGrid(t *testing.T) { + a, tv, _ := calVaultApp(t, map[string]string{"work.md": "- [ ] only strip\n"}) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, "before") + requireSelected(t, tv, "only strip", "before") + calGiveDue(t, a, tv, "2026-11-18") + requireFocus(t, tv, calFocusGrid, "after the only strip task leaves") + if got := tv.selectedTask(); got != nil { + t.Errorf("selected = %+v on the grid, want none", got) + } + requireDay(t, tv, "2026-11-18", "after the reload") + if tv.calc().sub != calMonth { + t.Errorf("sub-mode = %v, want it unchanged", tv.calc().sub) + } +} + +func TestStripEscReturnsToGrid(t *testing.T) { + for _, vimOn := range []bool{true, false} { + for _, s := range calStripSubs { + a, tv := calTestApp(t, calManyUndated(2)) + a.prefs.VimMode = vimOn + calToSub(t, a, tv, s.sub) + calPress(a, tv, vim.KeyTab) + requireFocus(t, tv, calFocusStrip, "setup") + calPress(a, tv, vim.KeyEsc) + ctx := fmt.Sprintf("%s vim=%v", s.name, vimOn) + requireFocus(t, tv, calFocusGrid, ctx+" after Esc") + if tv.calc().sub != s.sub { + t.Errorf("%s: Esc changed the sub-mode to %v", ctx, tv.calc().sub) + } + requireDay(t, tv, "2026-11-18", ctx) + } + } +} diff --git a/internal/tui/views_tasks_calendar_test.go b/internal/tui/views_tasks_calendar_test.go new file mode 100644 index 0000000..c0d372d --- /dev/null +++ b/internal/tui/views_tasks_calendar_test.go @@ -0,0 +1,455 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" +) + +func calDate(y int, m time.Month, d int) time.Time { + return time.Date(y, m, d, 0, 0, 0, 0, time.Local) +} + +// calTestApp opens the Tasks tab in calendar mode on 2026-11-18 with the given +// tasks as the vault's scan result. +func calTestApp(t *testing.T, tasks []vault.Task) (*App, *tasksView) { + t.Helper() + a, _ := newFeatureTestApp(t) + a.idx.tasks = tasks + a.prefs.CalendarWeekStart = "monday" + a.prefs.CalendarShowWeekNumbers = true + return calOpen(t, a) +} + +func calOpen(t *testing.T, a *App) (*App, *tasksView) { + t.Helper() + if err := a.openTasksMode("calendar"); err != nil { + t.Fatal(err) + } + tab := a.activeTab() + if tab == nil { + t.Fatal("no active tab after opening the Tasks tab") + } + tv, ok := tab.view.(*tasksView) + if !ok { + t.Fatalf("active tab is %T, want *tasksView", tab.view) + } + tv.refresh(a) + tv.selectDay(a, calDate(2026, time.November, 18)) + return a, tv +} + +// calScreen renders the calendar into a body of w x bodyH cells (the Tasks +// view adds its one-line toolbar on top) and returns the lines without styling. +func calScreen(a *App, tv *tasksView, w, bodyH int) []string { + return strings.Split(stripAnsi(tv.render(a, w, bodyH+1, true)), "\n") +} + +var calWeekdayNames = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} +var calSundayFirst = []string{"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"} + +// calWeekdayHeader finds the line carrying all seven weekday names in the +// given order and returns the column each name starts at. +func calWeekdayHeader(lines []string, order []string) []int { + for _, l := range lines { + cols := make([]int, len(order)) + ok := true + prev := -1 + for i, n := range order { + c := strings.Index(l, n) + if c <= prev { + ok = false + break + } + cols[i], prev = c, c + } + if ok { + return cols + } + } + return nil +} + +// requireFullMonthGrid fails unless the screen shows a month grid spread over +// a 120-wide page (day boxes), not the compact mini-grid with a day list. +func requireFullMonthGrid(t *testing.T, lines []string) { + t.Helper() + cols := calWeekdayHeader(lines, calWeekdayNames) + if cols == nil { + cols = calWeekdayHeader(lines, calSundayFirst) + } + if cols == nil { + t.Fatalf("no weekday header found:\n%s", calScreenText(lines)) + } + if last := cols[len(cols)-1]; last < 45 { + t.Fatalf("weekday header ends at column %d: the grid is not full page:\n%s", last, calScreenText(lines)) + } + joined := calScreenText(lines) + if strings.Contains(joined, "No tasks due this day") || strings.Contains(joined, "tasks)") { + t.Fatalf("the old mini-grid day list is still shown:\n%s", joined) + } +} + +// requireFullMonthGridAt is requireFullMonthGrid for other body widths. +func requireFullMonthGridAt(t *testing.T, lines []string, w int) { + t.Helper() + cols := calWeekdayHeader(lines, calWeekdayNames) + if cols == nil { + t.Fatalf("w=%d: no weekday header found:\n%s", w, calScreenText(lines)) + } + if cols[6] < w/3 { + t.Fatalf("w=%d: header ends at column %d: the grid is not full page:\n%s", w, cols[6], calScreenText(lines)) + } + joined := calScreenText(lines) + if strings.Contains(joined, "No tasks due this day") || strings.Contains(joined, "tasks)") { + t.Fatalf("w=%d: the old mini-grid day list is still shown:\n%s", w, joined) + } +} + +func calScreenText(lines []string) string { return strings.Join(lines, "\n") } + +func TestCalendarOpensOnMonthGrid(t *testing.T) { + a, tv := calTestApp(t, nil) + requireFullMonthGrid(t, calScreen(a, tv, 120, 40)) + + // Coming from the Tasks tab's kanban mode with v. + tv.setMode("kanban") + if !tv.handleKey(a, vim.Key{Rune: 'v'}) { + t.Fatal("v was not handled in kanban mode") + } + if tv.mode != "calendar" { + t.Fatalf("mode after v = %q, want calendar", tv.mode) + } + requireFullMonthGrid(t, calScreen(a, tv, 120, 40)) +} + +func TestMonthWeekdayHeaderFollowsWeekStart(t *testing.T) { + a, tv := calTestApp(t, nil) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + if calWeekdayHeader(lines, calWeekdayNames) == nil { + t.Errorf("Monday-first header not found:\n%s", calScreenText(lines)) + } + + a.prefs.CalendarWeekStart = "sunday" + lines = calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + if calWeekdayHeader(lines, calSundayFirst) == nil { + t.Errorf("Sunday-first header not found:\n%s", calScreenText(lines)) + } +} + +func TestMonthDayBoxesShowDateNumbers(t *testing.T) { + a, tv := calTestApp(t, nil) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + re := regexp.MustCompile(`(^|\s)(\d{1,2})(\s|$)`) + seen := map[string]bool{} + for _, l := range lines { + for _, m := range re.FindAllStringSubmatch(l, -1) { + seen[m[2]] = true + } + } + for d := 1; d <= 30; d++ { + if !seen[fmt.Sprint(d)] { + t.Errorf("date number %d not shown", d) + } + } +} + +func weekNumberShown(lines []string, n int) bool { + re := regexp.MustCompile(fmt.Sprintf(`(^|\s)%d(\s|$)`, n)) + for _, l := range lines { + if re.MatchString(l) { + return true + } + } + return false +} + +func TestMonthWeekNumbersFollowPreference(t *testing.T) { + // November 2026 spans ISO weeks 44 to 49. + a, tv := calTestApp(t, nil) + a.prefs.CalendarShowWeekNumbers = true + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + for n := 44; n <= 49; n++ { + if !weekNumberShown(lines, n) { + t.Errorf("week number %d missing with week numbers on:\n%s", n, calScreenText(lines)) + } + } + + a.prefs.CalendarShowWeekNumbers = false + lines = calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + for n := 44; n <= 49; n++ { + if weekNumberShown(lines, n) { + t.Errorf("week number %d shown with week numbers off", n) + } + } +} + +func TestMonthBoxListsEntriesCutToWidth(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "zebra alpha long text", Due: "2026-11-10"}, + {ID: "n#1", SourcePath: "n.md", Content: "zebra bravo long text", Due: "2026-11-10"}, + {ID: "n#2", SourcePath: "n.md", Content: "zebra charlie long text", Due: "2026-11-10"}, + }) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + text := calScreenText(lines) + if got := strings.Count(text, "zebra"); got != 3 { + t.Errorf("entries listed = %d, want one line per entry (3):\n%s", got, text) + } + if strings.Contains(text, "long text") { + t.Errorf("entry text is not cut to the box width:\n%s", text) + } +} + +func TestMonthBoxCutsWideCharactersToWidth(t *testing.T) { + wide := strings.Repeat("日本語", 12) + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: wide, Due: "2026-11-10"}, + }) + for _, w := range []int{72, 100, 120} { + lines := calScreen(a, tv, w, 40) + requireFullMonthGridAt(t, lines, w) + text := calScreenText(lines) + if !strings.Contains(text, "日") { + t.Errorf("w=%d: wide-character entry not shown:\n%s", w, text) + } + if strings.Contains(text, wide) { + t.Errorf("w=%d: wide-character entry not cut", w) + } + for i, l := range lines { + if lipgloss.Width(l) > w { + t.Errorf("w=%d line %d is %d cells wide: %q", w, i, lipgloss.Width(l), l) + } + } + } +} + +func TestMonthBoxOverflowShowsMoreCount(t *testing.T) { + var tasks []vault.Task + for i := 0; i < 50; i++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", i), SourcePath: "n.md", Content: fmt.Sprintf("zq%02d", i), Due: "2026-11-10"}) + } + a, tv := calTestApp(t, tasks) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + text := calScreenText(lines) + listed := len(regexp.MustCompile(`zq\d\d`).FindAllString(text, -1)) + if listed == 0 || listed >= 50 { + t.Fatalf("listed %d of 50 entries in a box, want some but not all:\n%s", listed, text) + } + more := regexp.MustCompile(`\+(\d+) more`).FindAllStringSubmatch(text, -1) + if len(more) != 1 { + t.Fatalf("found %d `+N more` lines, want 1:\n%s", len(more), text) + } + if want := fmt.Sprint(50 - listed); more[0][1] != want { + t.Errorf("+%s more, want +%s more for %d listed", more[0][1], want, listed) + } +} + +func TestMonthItemPanelListsSelectedDay(t *testing.T) { + var tasks []vault.Task + for i := 0; i < 6; i++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", i), SourcePath: "n.md", Content: fmt.Sprintf("panel entry number %d in full", i), Due: "2026-11-18"}) + } + tasks = append(tasks, + vault.Task{ID: "n#6", SourcePath: "n.md", Kind: "file", Content: "panel scheduled entry in full", Scheduled: "2026-11-18"}, + vault.Task{ID: "n#7", SourcePath: "n.md", Content: "other day entry in full text", Due: "2026-11-19"}, + ) + a, tv := calTestApp(t, tasks) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + text := calScreenText(lines) + for i := 0; i < 6; i++ { + if want := fmt.Sprintf("panel entry number %d in full", i); !strings.Contains(text, want) { + t.Errorf("item panel is missing %q:\n%s", want, text) + } + } + if !strings.Contains(text, "panel scheduled entry in full") { + t.Errorf("item panel is missing the scheduled entry:\n%s", text) + } + if strings.Contains(text, "other day entry in full text") { + t.Errorf("item panel lists another day's entry:\n%s", text) + } +} + +func TestScheduledEntryCarriesMarker(t *testing.T) { + const text = "twin task with a long tail" + tasks := []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Kind: "file", Content: text, Due: "2026-11-18", Scheduled: "2026-11-16"}, + } + around := func(a *App, tv *tasksView, day time.Time) string { + t.Helper() + tv.selectDay(a, day) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + for _, l := range lines { + if i := strings.Index(l, text); i >= 0 { + r := []rune(l[:i]) + if len(r) > 3 { + r = r[len(r)-3:] + } + return string(r) + "|" + l[i:i+len(text)] + } + } + t.Fatalf("%s: %q not found in the item panel:\n%s", day.Format("2006-01-02"), text, calScreenText(lines)) + return "" + } + a, tv := calTestApp(t, tasks) + due := around(a, tv, calDate(2026, time.November, 18)) + sched := around(a, tv, calDate(2026, time.November, 16)) + if due == sched { + t.Errorf("scheduled entry looks the same as the due entry: %q", due) + } +} + +func TestCalendarAppliesTaskFilter(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "alpha item in the panel", Due: "2026-11-18"}, + {ID: "n#1", SourcePath: "n.md", Content: "beta item in the panel", Due: "2026-11-18"}, + {ID: "n#2", SourcePath: "n.md", Content: "alpha loose end"}, + {ID: "n#3", SourcePath: "n.md", Content: "beta loose end"}, + }) + tv.filter = "alpha" + tv.refresh(a) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + text := calScreenText(lines) + for _, want := range []string{"alpha item in the panel", "alpha loose end"} { + if !strings.Contains(text, want) { + t.Errorf("%q missing under the filter:\n%s", want, text) + } + } + for _, bad := range []string{"beta item", "beta loose"} { + if strings.Contains(text, bad) { + t.Errorf("%q shown despite the filter:\n%s", bad, text) + } + } +} + +func TestMonthStripBelowGrid(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{ + {ID: "n#0", SourcePath: "n.md", Content: "bottom row task", Due: "2026-11-30"}, + {ID: "n#1", SourcePath: "n.md", Content: "loose strip task"}, + }) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + gridRow, stripRow := -1, -1 + for i, l := range lines { + if gridRow < 0 && strings.Contains(l, "bottom r") { + gridRow = i + } + if strings.Contains(l, "loose strip task") { + stripRow = i + } + } + if gridRow < 0 { + t.Fatalf("the task on the last grid row is not shown:\n%s", calScreenText(lines)) + } + if stripRow < 0 { + t.Fatalf("the undated task is not shown:\n%s", calScreenText(lines)) + } + if stripRow <= gridRow { + t.Errorf("strip row %d is not below the last grid row %d:\n%s", stripRow, gridRow, calScreenText(lines)) + } + if got := strings.Count(calScreenText(lines), "loose strip task"); got != 1 { + t.Errorf("undated task shown %d times, want once", got) + } +} + +func TestMonthTooSmallBelowMinimum(t *testing.T) { + a, tv := calTestApp(t, []vault.Task{{ID: "n#0", SourcePath: "n.md", Content: "something", Due: "2026-11-18"}}) + for _, size := range [][2]int{{71, 18}, {72, 17}} { + text := calScreenText(calScreen(a, tv, size[0], size[1])) + if !strings.Contains(text, "Terminal too small") { + t.Errorf("%dx%d: no `Terminal too small`:\n%s", size[0], size[1], text) + } + for _, name := range []string{"Tu", "We", "Th", "Fr", "something"} { + if strings.Contains(text, name) { + t.Errorf("%dx%d: grid content %q shown while too small:\n%s", size[0], size[1], name, text) + } + } + } +} + +func TestMonthRendersAtExactMinimum(t *testing.T) { + a, tv := calTestApp(t, nil) + lines := calScreen(a, tv, 72, 18) + text := calScreenText(lines) + if strings.Contains(text, "Terminal too small") { + t.Fatalf("72x18 is the minimum and must render:\n%s", text) + } + requireFullMonthGridAt(t, lines, 72) + for i, l := range lines { + if lipgloss.Width(l) > 72 { + t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) + } + } + if len(lines) != 19 { + t.Errorf("render is %d lines, want toolbar plus 18", len(lines)) + } +} + +func TestMonthRendersInVeryLargeBody(t *testing.T) { + var tasks []vault.Task + for i := 0; i < 20; i++ { + tasks = append(tasks, vault.Task{ID: fmt.Sprintf("n#%d", i), SourcePath: "n.md", Content: fmt.Sprintf("entry %d", i), Due: "2026-11-18"}) + } + tasks = append(tasks, vault.Task{ID: "n#u", SourcePath: "n.md", Content: "undated"}) + a, tv := calTestApp(t, tasks) + lines := calScreen(a, tv, 300, 100) + requireFullMonthGridAt(t, lines, 300) + if len(lines) != 101 { + t.Errorf("render is %d lines, want toolbar plus 100", len(lines)) + } + for i, l := range lines { + if lipgloss.Width(l) > 300 { + t.Errorf("line %d is %d cells wide", i, lipgloss.Width(l)) + } + } +} + +func TestCalendarRenderLeavesNotesUnchanged(t *testing.T) { + a, root := newFeatureTestApp(t) + body := []byte("---\ntags: [task]\nscheduled: 2026-11-16\n---\n# Plan the move\n") + path := filepath.Join(root, "plan.md") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + a.idx.tasks = []vault.Task{{ID: "plan.md#task", SourcePath: "plan.md", Kind: "file", Content: "Plan the move", Scheduled: "2026-11-16"}} + a.prefs.CalendarWeekStart = "monday" + a.prefs.CalendarShowWeekNumbers = true + a, tv := calOpen(t, a) + tv.selectDay(a, calDate(2026, time.November, 16)) + lines := calScreen(a, tv, 120, 40) + requireFullMonthGrid(t, lines) + if !strings.Contains(calScreenText(lines), "Plan the move") { + t.Fatalf("the scheduled task is not shown on its day:\n%s", calScreenText(lines)) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(body) { + t.Errorf("note changed by rendering:\n%s", got) + } +} + +func TestScheduledMarkIsOneCellWide(t *testing.T) { + if w := cellWidth(calScheduledMark); w != 1 { + t.Errorf("scheduled marker %q is %d cells wide, want 1", calScheduledMark, w) + } +} diff --git a/internal/vault/tasks_bucket_guard_test.go b/internal/vault/tasks_bucket_guard_test.go new file mode 100644 index 0000000..b16ae0f --- /dev/null +++ b/internal/vault/tasks_bucket_guard_test.go @@ -0,0 +1,31 @@ +package vault + +import ( + "reflect" + "testing" +) + +// BucketTasksByDueDate feeds Home and the side calendar panel; its result +// must not change when other surfaces learn about other dates. +func TestBucketTasksByDueDateUnchanged(t *testing.T) { + tasks := []Task{ + {ID: "due-a", Due: "2026-11-18"}, + {ID: "due-b", Due: "2026-11-18", Scheduled: "2026-11-16"}, + {ID: "sched-only", Scheduled: "2026-11-16"}, + {ID: "undated"}, + {ID: "checked", Checked: true, Due: "2026-11-18"}, + {ID: "cancelled", Cancelled: true}, + {ID: "forwarded-dated", Forwarded: true, Due: "2026-11-18"}, + {ID: "forwarded-undated", Forwarded: true}, + {ID: "due-c", Due: "2026-11-19"}, + } + got := BucketTasksByDueDate(tasks) + want := map[string][]Task{ + "2026-11-18": {tasks[0], tasks[1], tasks[6]}, + "2026-11-19": {tasks[8]}, + "unscheduled": {tasks[2], tasks[3]}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("BucketTasksByDueDate = %+v, want %+v", got, want) + } +}