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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>` to open a specific project.
Expand Down
10 changes: 10 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}
Expand Down
6 changes: 6 additions & 0 deletions internal/tui/keymap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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"),
Expand Down
21 changes: 21 additions & 0 deletions internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{},
Expand All @@ -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

Expand Down
156 changes: 156 additions & 0 deletions internal/tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
12 changes: 12 additions & 0 deletions internal/tui/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
Loading