From 918c4f28ce19695efb4cff9a990270b3a97f4143 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:22:10 -0700 Subject: [PATCH] refresh from tui ledger --- README.md | 1 + internal/tui/commands.go | 10 +++ internal/tui/keymap.go | 6 ++ internal/tui/tui.go | 21 ++++++ internal/tui/tui_test.go | 156 +++++++++++++++++++++++++++++++++++++++ internal/tui/update.go | 12 +++ internal/tui/view.go | 2 +- 7 files changed, 207 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ac5f449..cfeab31 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ For other commands and options see, `daylog --help`. - `↑`/`↓` move, `enter` to open a day - `a` append a one-line entry, `e` open the day in `$EDITOR`, `y` copy the log - `/` filter by date or text (searches log contents), `n` start a new day from a date +- `r` refresh the ledger (the ledger also picks up a new day on its own when the date changes) - `t` toggle todos, `p` switch projects, `?` for full help, `q` to quit Add `-p ` to open a specific project. diff --git a/internal/tui/commands.go b/internal/tui/commands.go index e8353cf..7cc1bf1 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -62,6 +62,7 @@ type searchResultsMsg struct { matches []daylog.SearchMatch } type previewsLoadedMsg struct{ previews map[string][]string } +type clockTickMsg struct{} type errMsg struct{ err error } // loadDays lists all logs for the project, ensuring today is present @@ -258,6 +259,15 @@ func copyDay(projectPath, day string) tea.Cmd { } } +// tickClock fires clockTickMsg after d so the update loop can notice a day +// cutover while the tui stays open. a short fixed interval (rather than one +// timer aimed at midnight) survives a laptop sleeping across the cutover +func tickClock(d time.Duration) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { + return clockTickMsg{} + }) +} + func clearStatusAfter(d time.Duration) tea.Cmd { return tea.Tick(d, func(time.Time) tea.Msg { return clearStatusMsg{} diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index e2ac89e..4dee2dd 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -15,6 +15,7 @@ type keyMap struct { Copy key.Binding Projects key.Binding Todos key.Binding + Refresh key.Binding Help key.Binding Quit key.Binding } @@ -71,6 +72,11 @@ func defaultKeyMap() keyMap { key.WithKeys("t"), key.WithHelp("t", "todos"), ), + // ledger only: re-read the clock and the day list + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh"), + ), Help: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "help"), diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4962bd0..c223079 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -31,6 +31,10 @@ type Model struct { project string projectPath string today time.Time + // now is the wall clock; refresh re-reads it so today tracks the calendar + // across a day cutover. clockTick is how often the cutover check runs + now func() time.Time + clockTick time.Duration mode mode inputReturn mode // mode to return to after the append input closes days []string // YYYY/MM/DD, newest first @@ -94,6 +98,8 @@ func New(projectPath, project string, today time.Time) Model { project: project, projectPath: projectPath, today: today, + now: time.Now, + clockTick: time.Minute, // land on the ledger: a list of days, not an empty today mode: modeLedger, previews: map[string][]string{}, @@ -109,9 +115,24 @@ func New(projectPath, project string, today time.Time) Model { } func (m Model) Init() tea.Cmd { + return tea.Batch(loadDays(m.projectPath, m.today), tickClock(m.clockTick)) +} + +// refresh re-reads the clock so today tracks the calendar, drops every cached +// preview so content changed outside the tui is re-read, and reloads the day +// list. shared by r on the ledger and the day-cutover tick +func (m *Model) refresh() tea.Cmd { + m.today = m.now() + m.previews = map[string][]string{} return loadDays(m.projectPath, m.today) } +// crossedDay reports whether the wall clock has moved past the calendar day +// the model currently treats as today +func (m Model) crossedDay() bool { + return m.now().Format(dayFormat) != m.today.Format(dayFormat) +} + func (m *Model) layout() { m.help.Width = m.width diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 930db38..3115869 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1741,3 +1741,159 @@ func TestLayoutRobustAtExtremes(t *testing.T) { } } } + +// r on the ledger re-reads the clock and the day list: a day that arrived +// outside the tui, and a new calendar day, both show up without a restart +func TestLedgerRefreshKey(t *testing.T) { + today := time.Date(2026, 7, 10, 12, 0, 0, 0, time.Local) + projectPath := t.TempDir() + seedLog(t, projectPath, "2026/07/09", "- before refresh\n") + + m := newLedgerModel(t, projectPath, today) + if m.days[0] != "2026/07/10" { + t.Fatalf("expected today pinned first, got %v", m.days) + } + + // the clock moves to the next day and a log is written behind the tui's + // back, as if `daylog -a` ran from another shell + tomorrow := today.Add(24 * time.Hour) + m.now = func() time.Time { return tomorrow } + seedLog(t, projectPath, "2026/07/10", "- written elsewhere\n") + seedLog(t, projectPath, "2026/07/09", "- before refresh\n- edited elsewhere\n") + + mm, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + m = mm.(Model) + drainAll(t, &m, cmd) + + if !m.today.Equal(tomorrow) { + t.Errorf("expected today to track the clock, got %s", m.today) + } + if m.mode != modeLedger { + t.Errorf("expected to stay on the ledger, got mode %d", m.mode) + } + if m.days[0] != "2026/07/11" || !m.noLogToday { + t.Errorf("expected the new logless day pinned first, got days=%v noLogToday=%v", m.days, m.noLogToday) + } + if got := m.previews["2026/07/10"]; len(got) != 1 || got[0] != "written elsewhere" { + t.Errorf("expected the externally written log's preview, got %v", got) + } + if got := m.previews["2026/07/09"]; len(got) != 2 { + t.Errorf("expected the edited log's preview re-read, got %v", got) + } + + // the new day block renders with the empty-today call to action + found := false + for _, it := range m.picker.Items() { + if p, ok := it.(pickerItem); ok && p.kind == itemDay && p.value == "2026/07/11" { + found = p.row != nil && p.row.marker == "+" + } + } + if !found { + t.Error("expected the new day's row to carry the + marker") + } +} + +// r while the filter is focused types into the filter rather than refreshing +func TestLedgerRefreshKeyFeedsFilter(t *testing.T) { + today := time.Date(2026, 7, 10, 12, 0, 0, 0, time.Local) + m := newLedgerModel(t, t.TempDir(), today) + m.now = func() time.Time { return today.Add(24 * time.Hour) } + + mm, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = mm.(Model) + mm, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + m = mm.(Model) + + if got := m.dayFilter.Value(); got != "r" { + t.Errorf("expected r to feed the filter, got %q", got) + } + if !m.today.Equal(today) { + t.Errorf("expected today unchanged while filtering, got %s", m.today) + } +} + +// the clock tick is quiet until the calendar day changes, then reloads the +// day list with the new today pinned first and keeps ticking either way +func TestClockTickCrossesDay(t *testing.T) { + today := time.Date(2026, 7, 10, 23, 59, 0, 0, time.Local) + projectPath := t.TempDir() + seedLog(t, projectPath, "2026/07/10", "- late entry\n") + seedLog(t, projectPath, "2026/07/09", "- yesterday\n") + + m := newLedgerModel(t, projectPath, today) + m.clockTick = 0 // fire the rescheduled tick immediately so execCmd doesn't block + m.now = func() time.Time { return today } + + // same day: nothing reloads, the tick is rescheduled + mm, cmd := m.Update(clockTickMsg{}) + m = mm.(Model) + msgs := execCmd(t, cmd) + if len(msgs) != 1 { + t.Fatalf("expected only a rescheduled tick on the same day, got %v", msgs) + } + if _, ok := msgs[0].(clockTickMsg); !ok { + t.Fatalf("expected a clockTickMsg, got %T", msgs[0]) + } + if m.days[0] != "2026/07/10" { + t.Errorf("expected days untouched on the same day, got %v", m.days) + } + + // cross midnight: the tick reloads days against the new today + m.now = func() time.Time { return today.Add(2 * time.Minute) } + mm, cmd = m.Update(clockTickMsg{}) + m = mm.(Model) + + ticked := false + for _, msg := range execCmd(t, cmd) { + switch msg.(type) { + case clockTickMsg: + ticked = true + case daysLoadedMsg: + mm, next := m.Update(msg) + m = mm.(Model) + drainAll(t, &m, next) + } + } + if !ticked { + t.Error("expected the tick to be rescheduled after a cutover") + } + if m.today.Day() != 11 { + t.Errorf("expected today to advance to the 11th, got %s", m.today) + } + if m.days[0] != "2026/07/11" || !m.noLogToday { + t.Errorf("expected the new day pinned first, got days=%v noLogToday=%v", m.days, m.noLogToday) + } + if day, _ := m.selectedDay(); day != "2026/07/10" { + t.Errorf("expected the cursor to stay on the day it was on, got %s", day) + } +} + +// in the reading view a cutover re-labels the day without moving the reader +func TestClockTickInBrowseKeepsSelection(t *testing.T) { + today := time.Date(2026, 7, 10, 23, 59, 0, 0, time.Local) + projectPath := t.TempDir() + seedLog(t, projectPath, "2026/07/10", "- late entry\n") + + m := newTestModel(t, projectPath, today) + m.clockTick = 0 + m.now = func() time.Time { return today.Add(2 * time.Minute) } + + mm, cmd := m.Update(clockTickMsg{}) + m = mm.(Model) + for _, msg := range execCmd(t, cmd) { + if loaded, ok := msg.(daysLoadedMsg); ok { + mm, _ = m.Update(loaded) + m = mm.(Model) + } + } + + if m.mode != modeBrowse { + t.Errorf("expected to stay in browse, got mode %d", m.mode) + } + if day, _ := m.selectedDay(); day != "2026/07/10" { + t.Errorf("expected the reader to stay on 2026/07/10, got %s", day) + } + if !strings.Contains(m.headerView(), "yesterday") { + t.Errorf("expected the header to re-label the day as yesterday, got %q", m.headerView()) + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index fc0f690..ad2e434 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -168,6 +168,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.layout() return m, nil + case clockTickMsg: + // keep ticking for as long as the program runs; only a real calendar + // change triggers a reload, so an idle session stays quiet + if m.crossedDay() { + return m, tea.Batch(m.refresh(), tickClock(m.clockTick)) + } + return m, tickClock(m.clockTick) + case copiedMsg: m.status = "copied to clipboard" return m, clearStatusAfter(2 * time.Second) @@ -353,6 +361,10 @@ func (m Model) onLedgerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = "" return m, loadProjects() + case key.Matches(msg, m.keys.Refresh): + m.status = "" + return m, m.refresh() + case key.Matches(msg, m.keys.Help): m.help.ShowAll = !m.help.ShowAll m.layout() diff --git a/internal/tui/view.go b/internal/tui/view.go index 3bc3471..a1ae2de 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -496,7 +496,7 @@ func (m Model) footerView() string { if m.dayFilter.Focused() { return footer.Render("filter by date or text • ↑/↓ move • enter open • esc clear") } - return footer.Render("↑/↓ move • enter open • a append • n new day • / filter • p project • ? help") + return footer.Render("↑/↓ move • enter open • a append • n new day • / filter • r refresh • p project • ? help") } if m.status != "" {