Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<key>`.
`: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, `<C-D>`/`<C-U>` 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
Expand Down
67 changes: 67 additions & 0 deletions internal/tui/calendar_entries.go
Original file line number Diff line number Diff line change
@@ -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
}
199 changes: 199 additions & 0 deletions internal/tui/calendar_entries_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
7 changes: 6 additions & 1 deletion internal/tui/mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
38 changes: 38 additions & 0 deletions internal/tui/mouse_right_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading