From 08dc1b796a8d5e6be2fb06faeeb79857c5548c52 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 11:30:15 +0200 Subject: [PATCH 01/19] Share one frame between the label and collection pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two were byte-for-byte copies of each other's drawing: the same compositor, the same rounded border, the same scrolling column of names, differing only in the title and which slice they walked. modal.go now holds that once — overlayModal for the centered composite, modalFrame for the border and title, framedList for a picker's body, and stepListCursor for the arrow-key ladder each was carrying its own copy of. The frame takes any body, so a form fits it as well as a list. Nothing about what is drawn changed, which is why both pickers' tests pass untouched. --- internal/tui/collections.go | 50 +---------------- internal/tui/labels.go | 57 +------------------- internal/tui/modal.go | 104 ++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 103 deletions(-) diff --git a/internal/tui/collections.go b/internal/tui/collections.go index 473a5d14..bb1e66ae 100644 --- a/internal/tui/collections.go +++ b/internal/tui/collections.go @@ -61,57 +61,11 @@ func (p *collectionNavPicker) draw(view *mailView) string { } func (p *collectionNavPicker) update(msg tea.KeyPressMsg) { - switch msg.Key().Code { - case tea.KeyUp: - if p.cursor > 0 { - p.cursor-- - } - case tea.KeyDown: - if p.cursor < len(p.sourceIndexes)-1 { - p.cursor++ - } - } + p.cursor = stepListCursor(p.cursor, len(p.sourceIndexes), msg) } func (p *collectionNavPicker) overlay(base string, width, height int) string { - modal := p.view(width, height) - x := max((width-lipgloss.Width(modal))/2, 0) - y := max((height-lipgloss.Height(modal))/2, 0) - compositor := lipgloss.NewCompositor( - lipgloss.NewLayer(base).Z(0), - lipgloss.NewLayer(modal).X(x).Y(y).Z(1), - ) - canvas := lipgloss.NewCanvas(width, height) - compositor.Draw(canvas, canvas.Bounds()) - return canvas.Render() -} - -func (p *collectionNavPicker) view(width, height int) string { - contentWidth := max(width-6, 1) - title := lipgloss.NewStyle().Foreground(colorChrome).Bold(true).Render(truncateToWidth("Collections", contentWidth)) - selected := lipgloss.NewStyle().Foreground(colorActive).Bold(true) - - maxRows := max(height-6, 1) - start := 0 - if p.cursor >= maxRows { - start = p.cursor - maxRows + 1 - } - rows := make([]string, 0, maxRows) - for i := start; i < min(start+maxRows, len(p.names)); i++ { - name := truncateToWidth(p.names[i], max(contentWidth-2, 1)) - if i == p.cursor { - rows = append(rows, selected.Render("› "+name)) - } else { - rows = append(rows, " "+name) - } - } - - body := title + "\n\n" + strings.Join(rows, "\n") - return lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(colorChrome). - Padding(0, 2). - Render(body) + return overlayModal(base, framedList("Collections", p.names, p.cursor, width, height), width, height) } func (p *collectionNavPicker) helpBindings() []helpBinding { diff --git a/internal/tui/labels.go b/internal/tui/labels.go index ea205d67..a1fc8627 100644 --- a/internal/tui/labels.go +++ b/internal/tui/labels.go @@ -1,10 +1,7 @@ package tui import ( - "strings" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/basecamp/hey-cli/internal/mail" "github.com/basecamp/hey-cli/internal/terminal" @@ -61,61 +58,11 @@ func (p *labelPicker) draw(view *mailView) string { } func (p *labelPicker) update(msg tea.KeyPressMsg) { - switch msg.Key().Code { - case tea.KeyUp: - if p.cursor > 0 { - p.cursor-- - } - case tea.KeyDown: - if p.cursor < len(p.boxIndexes)-1 { - p.cursor++ - } - } + p.cursor = stepListCursor(p.cursor, len(p.boxIndexes), msg) } -// overlay composites the picker as a centered modal over the base content -// using a lipgloss layer compositor. func (p *labelPicker) overlay(base string, width, height int) string { - modal := p.view(width, height) - x := max((width-lipgloss.Width(modal))/2, 0) - y := max((height-lipgloss.Height(modal))/2, 0) - compositor := lipgloss.NewCompositor( - lipgloss.NewLayer(base).Z(0), - lipgloss.NewLayer(modal).X(x).Y(y).Z(1), - ) - canvas := lipgloss.NewCanvas(width, height) - compositor.Draw(canvas, canvas.Bounds()) - return canvas.Render() -} - -func (p *labelPicker) view(width, height int) string { - // Rounded borders and two cells of padding on each side use six columns. - contentWidth := max(width-6, 1) - title := lipgloss.NewStyle().Foreground(colorChrome).Bold(true).Render(truncateToWidth("Labels", contentWidth)) - selected := lipgloss.NewStyle().Foreground(colorActive).Bold(true) - - // Scroll the list when it cannot fit: border, padding and title take 6 lines. - maxRows := max(height-6, 1) - start := 0 - if p.cursor >= maxRows { - start = p.cursor - maxRows + 1 - } - rows := make([]string, 0, maxRows) - for i := start; i < min(start+maxRows, len(p.names)); i++ { - name := truncateToWidth(p.names[i], max(contentWidth-2, 1)) - if i == p.cursor { - rows = append(rows, selected.Render("› "+name)) - } else { - rows = append(rows, " "+name) - } - } - - body := title + "\n\n" + strings.Join(rows, "\n") - return lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(colorChrome). - Padding(0, 2). - Render(body) + return overlayModal(base, framedList("Labels", p.names, p.cursor, width, height), width, height) } func (p *labelPicker) helpBindings() []helpBinding { diff --git a/internal/tui/modal.go b/internal/tui/modal.go index 367726f6..f3e04584 100644 --- a/internal/tui/modal.go +++ b/internal/tui/modal.go @@ -1,7 +1,10 @@ package tui import ( + "strings" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) // modal is what is open over the mail list: a form or a picker that holds every key @@ -45,3 +48,104 @@ func modalOf[T modal](view *mailView) T { open, _ := view.modal.(T) return open } + +// A modal's border takes a column on each side and its padding two more, so a frame +// spends six columns on chrome; the border and the title's blank line spend six rows. +// A frame is never given a width to fill, so it hugs its widest line and these are the +// ceiling rather than the size. +const ( + modalChromeWidth = 6 + modalChromeRows = 6 +) + +func modalContentWidth(width int) int { return max(width-modalChromeWidth, 1) } + +func modalContentRows(height int) int { return max(height-modalChromeRows, 1) } + +// overlayModal draws a modal over the view that opened it, centered, so the list or +// the calendar behind it stays on screen around the border. +func overlayModal(base, modal string, width, height int) string { + x := max((width-lipgloss.Width(modal))/2, 0) + y := max((height-lipgloss.Height(modal))/2, 0) + return overlayAt(base, modal, x, y, width, height) +} + +// overlayAt composites one layer over another at a given cell. It is where every +// overlay in the TUI ends up, so there is one answer to how layers are drawn. +func overlayAt(base, layer string, x, y, width, height int) string { + compositor := lipgloss.NewCompositor( + lipgloss.NewLayer(base).Z(0), + lipgloss.NewLayer(layer).X(x).Y(y).Z(1), + ) + canvas := lipgloss.NewCanvas(width, height) + compositor.Draw(canvas, canvas.Bounds()) + return canvas.Render() +} + +// modalFrame is the box every modal wears: a rounded border in the chrome color with a +// title above the body. What the body is — a column of names, a form — is the modal's +// own business. +func modalFrame(title, body string, width int) string { + heading := lipgloss.NewStyle(). + Foreground(colorChrome). + Bold(true). + Render(truncateToWidth(title, modalContentWidth(width))) + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorChrome). + Padding(0, 2). + Render(heading + "\n\n" + body) +} + +// framedList is a modal that chooses one of a list: the names in a frame, the cursor +// marked, scrolled to whatever the screen has room for. +func framedList(title string, names []string, cursor, width, height int) string { + rows := modalListRows(names, cursor, modalContentWidth(width), modalContentRows(height)) + return modalFrame(title, strings.Join(rows, "\n"), width) +} + +// modalListRows draws the names a modal is choosing between, marking the cursor and +// scrolling to keep it on screen. It is separate from framedList because a picker with +// something to say underneath its list — a switch in flight, an error — builds its own +// body out of these rows and gets the same list either way. +func modalListRows(names []string, cursor, contentWidth, visible int) []string { + selected := lipgloss.NewStyle().Foreground(colorActive).Bold(true) + + start, end := modalListWindow(len(names), cursor, visible) + rows := make([]string, 0, visible) + for i := start; i < end; i++ { + name := truncateToWidth(names[i], max(contentWidth-2, 1)) + if i == cursor { + rows = append(rows, selected.Render("› "+name)) + } else { + rows = append(rows, " "+name) + } + } + return rows +} + +// modalListWindow is the slice of a list a modal has room for, scrolled to keep the +// cursor on screen. A modal whose rows carry their own colors builds them itself — a +// row cannot be truncated once it holds escape sequences — and shares the scrolling. +func modalListWindow(count, cursor, visible int) (start, end int) { + if cursor >= visible { + start = cursor - visible + 1 + } + return start, min(start+visible, count) +} + +// stepListCursor moves a modal's cursor within a list of count items, and is why a +// picker does not carry its own arrow-key ladder. +func stepListCursor(cursor, count int, msg tea.KeyPressMsg) int { + switch msg.Key().Code { + case tea.KeyUp: + if cursor > 0 { + return cursor - 1 + } + case tea.KeyDown: + if cursor < count-1 { + return cursor + 1 + } + } + return cursor +} From 93b56b6a5225dd68e71928ce30c3d159915a5a63 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 11:31:23 +0200 Subject: [PATCH 02/19] Draw the model's own layers over the content instead of replacing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account picker blanked the content area and took the screen, with five branches through the model to make that happen — its own case in View, in updateHelpBindings, in handleKey, in canToggleHelp. It is a modal now, in the same frame as the label and collection pickers, drawn over whatever section it was opened from. That needed the err/loading/activeView ladder pulled out of View as contentView, which is the seam a layer composites over. The first two users are the picker and a toast: notify(...) raises one from anywhere, the model holds it in the top right corner for two seconds and takes it away, and its timer names the toast it belongs to so a stale clock cannot clear a newer one. Nothing raises a toast yet — the sections still write their own notices. --- internal/tui/accounts.go | 37 +++++++----- internal/tui/accounts_test.go | 17 +++++- internal/tui/toast.go | 78 +++++++++++++++++++++++++ internal/tui/toast_test.go | 107 ++++++++++++++++++++++++++++++++++ internal/tui/tui.go | 42 ++++++++++--- 5 files changed, 258 insertions(+), 23 deletions(-) create mode 100644 internal/tui/toast.go create mode 100644 internal/tui/toast_test.go diff --git a/internal/tui/accounts.go b/internal/tui/accounts.go index 03935903..94a989c4 100644 --- a/internal/tui/accounts.go +++ b/internal/tui/accounts.go @@ -7,6 +7,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" @@ -114,24 +115,32 @@ func tuiAccountEmail(users []generated.User, accountID int64) string { return "" } -func renderMailAccountPicker(m *model) string { - var content strings.Builder - content.WriteString(m.styles.title.Render("Select mail account")) - content.WriteString("\n\n") +// renderMailAccountPicker draws the picker over the section the reader opened it from, +// so choosing an account never blanks the screen they came from. +func renderMailAccountPicker(m *model, base string) string { + labels := make([]string, len(m.mailAccounts)) for index, account := range m.mailAccounts { - prefix := " " - style := m.styles.entryBody - if index == m.mailAccountCursor { - prefix = "› " - style = m.styles.entryFrom - } - content.WriteString(prefix + style.Render(account.label) + "\n") + labels[index] = account.label } + + var status []string if m.mailAccountSwitching { - content.WriteString("\n" + m.styles.entryDate.Render("Switching account…")) + status = append(status, styleMuted.Render("Switching account…")) } if m.mailAccountErr != "" { - content.WriteString("\n" + m.styles.entryDate.Render("Error: "+terminal.SanitizeLine(m.mailAccountErr))) + status = append(status, lipgloss.NewStyle(). + Foreground(colorError). + Render("Error: "+terminal.SanitizeLine(m.mailAccountErr))) + } + + height := m.contentHeight() + visible := modalContentRows(height) + if len(status) > 0 { + visible = max(visible-len(status)-1, 1) + } + body := strings.Join(modalListRows(labels, m.mailAccountCursor, modalContentWidth(m.width), visible), "\n") + if len(status) > 0 { + body += "\n\n" + strings.Join(status, "\n") } - return content.String() + return overlayModal(base, modalFrame("Select mail account", body, m.width), m.width, height) } diff --git a/internal/tui/accounts_test.go b/internal/tui/accounts_test.go index 879c256d..a6afa358 100644 --- a/internal/tui/accounts_test.go +++ b/internal/tui/accounts_test.go @@ -10,6 +10,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" hey "github.com/basecamp/hey-sdk/go/pkg/hey" ) @@ -118,8 +119,10 @@ func TestAccountPickerRequiresMultipleLinkedAccounts(t *testing.T) { } } -func TestCtrlAOpensAccountPicker(t *testing.T) { +func TestCtrlAOpensAccountPickerOverTheSectionBehindIt(t *testing.T) { m := newModel() + sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) + m = sized.(model) m.loading = false m.mailAccounts = []mailAccountChoice{ {label: "All Accounts"}, @@ -131,9 +134,19 @@ func TestCtrlAOpensAccountPicker(t *testing.T) { if cmd != nil || !m.mailAccountPicker { t.Fatal("ctrl+a did not open the account picker") } - if view := m.View().Content; !strings.Contains(view, "Select mail account") { + + view := stripANSI(m.View().Content) + if !strings.Contains(view, "Select mail account") || !strings.Contains(view, "› All Accounts") { t.Fatalf("picker view = %q", view) } + if !strings.Contains(view, "╭") || !strings.Contains(view, "╯") { + t.Errorf("picker did not draw the modal frame: %q", view) + } + for _, line := range strings.Split(view, "\n") { + if lipgloss.Width(line) > 80 { + t.Errorf("picker line width = %d, want at most 80: %q", lipgloss.Width(line), line) + } + } } func TestAccountPickerWaitsForPendingMutation(t *testing.T) { diff --git a/internal/tui/toast.go b/internal/tui/toast.go new file mode 100644 index 00000000..24f50518 --- /dev/null +++ b/internal/tui/toast.go @@ -0,0 +1,78 @@ +package tui + +import ( + "image/color" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/basecamp/hey-cli/internal/terminal" +) + +// A toast says what just happened — a habit ticked off, a contact saved, an attachment +// written — in the top right corner, over whatever is on screen, and takes itself away. +// It is the model's rather than each section's because it outlives the thing that raised +// it: a save that switches views should still say it saved. +// +// What stays an inline notice instead is anything describing the state of the screen — +// a list that has stopped following the server, a load that failed and wants a key, a +// confirmation waiting to be answered. Those have to be readable after two seconds. +const toastDuration = 2 * time.Second + +type toastKind int + +const ( + toastInfo toastKind = iota + toastError +) + +// notifyMsg raises a toast. A section asks for one by returning notify(...) as a +// command, so the request travels the same path as everything else it answers with. +type notifyMsg struct { + text string + kind toastKind +} + +// toastExpiredMsg carries the id of the toast whose time is up, so a second toast +// raised while the first is on screen is not cleared by the first one's timer. +type toastExpiredMsg struct { + id uint64 +} + +func notify(text string) tea.Cmd { + return func() tea.Msg { return notifyMsg{text: text} } +} + +func notifyError(what string, err error) tea.Cmd { + return func() tea.Msg { return notifyMsg{text: errorNotice(what, err), kind: toastError} } +} + +// showToast puts one on screen and starts its clock. +func (m *model) showToast(msg notifyMsg) tea.Cmd { + m.toastID++ + m.toast = msg + id := m.toastID + return tea.Tick(toastDuration, func(time.Time) tea.Msg { return toastExpiredMsg{id: id} }) +} + +// toastView is the toast itself, or nothing when none is up. +func (m model) toastView() string { + if m.toast.text == "" { + return "" + } + var border color.Color = colorChrome + text := lipgloss.NewStyle().Foreground(colorBright) + if m.toast.kind == toastError { + border, text = colorError, lipgloss.NewStyle().Foreground(colorError) + } + + // A toast is over the content, so it can never be wider than half the screen: the + // reader is looking at what they were doing, not at this. + body := truncateToWidth(terminal.SanitizeLine(m.toast.text), max(m.width/2-4, 10)) + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(border). + Padding(0, 1). + Render(text.Render(body)) +} diff --git a/internal/tui/toast_test.go b/internal/tui/toast_test.go new file mode 100644 index 00000000..a7e1146e --- /dev/null +++ b/internal/tui/toast_test.go @@ -0,0 +1,107 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// toastAndRest unpacks the batch a section answers a mutation with, without running +// anything but the toast: a toast first, then whatever it wants run next, which is the +// order notify(...) is batched in everywhere. Use it where the rest of the answer needs +// handling a test's own way — a list that pages, say — and deliverToView otherwise. +func toastAndRest(t *testing.T, cmd tea.Cmd) (string, tea.Cmd) { + t.Helper() + msg := cmd() + batch, ok := msg.(tea.BatchMsg) + if !ok || len(batch) != 2 { + t.Fatalf("expected a toast batched with one command, got %T", msg) + } + notice, ok := batch[0]().(notifyMsg) + if !ok { + t.Fatal("the first command in the batch is not a toast") + } + return notice.text, batch[1] +} + +// deliverToView runs a command, following a batch into the commands it holds, giving +// the view every message but a toast and answering the toast's text. +func deliverToView(view sectionView, cmd tea.Cmd) string { + if cmd == nil { + return "" + } + switch msg := cmd().(type) { + case notifyMsg: + return msg.text + case tea.BatchMsg: + toast := "" + for _, sub := range msg { + if text := deliverToView(view, sub); text != "" { + toast = text + } + } + return toast + default: + view.Update(msg) + return "" + } +} + +func TestToastStandsInTheTopRightAndTakesItselfAway(t *testing.T) { + m := testModel() + sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = sized.(model) + + updated, cmd := m.Update(notifyMsg{text: "Habit cleared for today"}) + m = updated.(model) + if cmd == nil { + t.Fatal("a toast should start its own clock") + } + + rows := strings.Split(stripANSI(m.View().Content), "\n") + row := -1 + for i, line := range rows { + if strings.Contains(line, "Habit cleared for today") { + row = i + } + } + if row < 0 { + t.Fatalf("the toast is not on screen: %q", rows) + } + + // It stands over the content rather than pushing it down, in the corner furthest + // from the list a reader is working through. + if row != headerHeight-1 { + t.Errorf("toast is on row %d, want the first content row (%d)", row, headerHeight-1) + } + line := rows[row] + if trailing := lipgloss.Width(line) - lipgloss.Width(strings.TrimRight(line, " ")); trailing > 1 { + t.Errorf("toast is not against the right edge: %q", line) + } + + // The clock names the toast it belongs to, so a stale timer cannot clear a newer one. + stale, _ := m.Update(toastExpiredMsg{id: m.toastID - 1}) + if stale.(model).toast.text == "" { + t.Error("an older toast's timer cleared the one on screen") + } + cleared, _ := m.Update(toastExpiredMsg{id: m.toastID}) + if cleared.(model).toast.text != "" { + t.Error("the toast outlived its own timer") + } +} + +func TestToastSurvivesTheViewThatRaisedItGoingAway(t *testing.T) { + m := testModel() + sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = sized.(model) + + updated, _ := m.Update(notifyMsg{text: "Contact hidden"}) + m = updated.(model) + + switched, _ := m.switchSection(sectionCalendar) + if view := stripANSI(switched.(model).View().Content); !strings.Contains(view, "Contact hidden") { + t.Errorf("the toast did not outlive the section that raised it: %q", view) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0357cc2b..33ba6215 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -11,6 +11,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" hey "github.com/basecamp/hey-sdk/go/pkg/hey" @@ -84,6 +85,10 @@ type model struct { viewGeneration uint64 viewGenerationToken *atomic.Uint64 + // What just happened, in the top right corner until its clock runs out + toast notifyMsg + toastID uint64 + // Loading & error loading bool spinnerPhase float64 @@ -194,6 +199,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m.Update(msg.msg) + case notifyMsg: + return m, m.showToast(msg) + + case toastExpiredMsg: + if msg.id == m.toastID { + m.toast = notifyMsg{} + } + return m, nil + case mailAccountsLoadedMsg: if msg.err != nil { m.mailAccountDiscoveryErr = errorNotice("Could not load the mail accounts", msg.err) @@ -475,21 +489,35 @@ func (m model) applyMailAccount(account mailAccountChoice, client *hey.Client) ( const headerHeight = 6 // five drawn rows and the terminal's final safety row +// contentView is the content area on its own, which is what a modal draws itself over. +func (m model) contentView() string { + switch { + case m.err != nil: + return errorView(m.err.Error(), m.width) + case m.loading: + return loadingView(m.width, m.contentHeight(), m.spinnerPhase) + default: + return m.activeView.View() + } +} + func (m model) View() tea.View { var b strings.Builder b.WriteString(renderHeader(&m)) b.WriteString("\n") + content := m.contentView() if m.mailAccountPicker { - b.WriteString(renderMailAccountPicker(&m)) - } else if m.err != nil { - b.WriteString(errorView(m.err.Error(), m.width)) - } else if m.loading { - b.WriteString(loadingView(m.width, m.contentHeight(), m.spinnerPhase)) - } else { - b.WriteString(m.activeView.View()) + content = renderMailAccountPicker(&m, content) + } + // The toast goes on last, over the modals too: it is the answer to what the reader + // just did, and a form open over the list does not make it less so. + if toast := m.toastView(); toast != "" { + x := max(m.width-lipgloss.Width(toast)-1, 0) + content = overlayAt(content, toast, x, 0, m.width, m.contentHeight()) } + b.WriteString(content) helpView := m.help.view() if helpView != "" { From 3a00290654ba55e47e98bfb2391dc6d598bdab83 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 11:32:19 +0200 Subject: [PATCH 03/19] Say what just happened in a toast rather than in the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A confirmation was written into the section that raised it, which meant it was shown wherever that section happens to put a notice — and a thread covers the posting list's header, so "Message forwarded" was said to nobody. There was a test working around exactly that. Every mutation that says what just happened now returns notify(...) and the model draws it over whatever is on screen: threads moved, seen and unseen, labels and collections, contacts and their notes, journal entries, bulk replies, attachments, Screener decisions. What stays an inline notice is anything describing the state of the screen — a list that has stopped following the server, a load that failed and names the key that retries it, a confirmation waiting to be answered, a thread's partial-read notice. Two seconds is exactly wrong for those. The journal's notice row went with it, so an entry gets that line back. --- internal/tui/bulk_reply_test.go | 19 +++++---- internal/tui/collections_test.go | 12 +++--- internal/tui/compose_test.go | 30 +++++++------ internal/tui/contacts.go | 20 ++++----- internal/tui/contacts_test.go | 35 ++++++++------- internal/tui/journal.go | 28 +++++------- internal/tui/mail.go | 53 +++++++++++------------ internal/tui/mail_test.go | 73 +++++++++++++++++--------------- internal/tui/screener.go | 3 +- internal/tui/screener_test.go | 13 +++--- 10 files changed, 143 insertions(+), 143 deletions(-) diff --git a/internal/tui/bulk_reply_test.go b/internal/tui/bulk_reply_test.go index 9d7d8376..a978c8c9 100644 --- a/internal/tui/bulk_reply_test.go +++ b/internal/tui/bulk_reply_test.go @@ -240,12 +240,13 @@ func TestTUIBulkReplyReviewsThenSendsAndOffersUndo(t *testing.T) { if !ok || sent.err != nil { t.Fatalf("send returned %#v", sent) } - view.Update(sent) + answer, _ := view.Update(sent) if bulkReplyModal(view) != nil || len(view.postingList.selectedIDs()) != 0 { t.Error("successful send should close the form and clear selection") } - if view.lastBulkReplyID != 900 || !strings.Contains(view.notice, "2 bulk replies queued with undo available") || !strings.Contains(view.notice, "press ctrl+u to undo") { - t.Errorf("delivery state = id:%d notice:%q", view.lastBulkReplyID, view.notice) + toast := deliverToView(view, answer) + if view.lastBulkReplyID != 900 || !strings.Contains(toast, "2 bulk replies queued with undo available") || !strings.Contains(toast, "press ctrl+u to undo") { + t.Errorf("delivery state = id:%d toast:%q", view.lastBulkReplyID, toast) } if !slices.ContainsFunc(view.HelpBindings(), func(b helpBinding) bool { return b.key == "ctrl+u" }) { t.Errorf("help does not offer undo: %v", view.HelpBindings()) @@ -284,9 +285,9 @@ func TestTUIBulkReplyEmptyDraftNeverSends(t *testing.T) { state.draftStatus = test.status selectTwoThreads(view) loaded := runCmd(view.HandleContentKey(keyPress("ctrl+b"))).(bulkReplyDraftLoadedMsg) - view.Update(loaded) - if bulkReplyModal(view) != nil || !strings.Contains(view.notice, "nothing was sent") { - t.Errorf("empty draft state = form:%v notice:%q", bulkReplyModal(view), view.notice) + answer, _ := view.Update(loaded) + if toast := deliverToView(view, answer); bulkReplyModal(view) != nil || !strings.Contains(toast, "nothing was sent") { + t.Errorf("empty draft state = form:%v toast:%q", bulkReplyModal(view), toast) } if requests := state.snapshot(); len(requests) != 1 || requests[0].method != http.MethodGet { t.Errorf("empty draft made a mutation request: %+v", requests) @@ -332,9 +333,9 @@ func TestTUIBulkReplyUndoSuccessAndExpiry(t *testing.T) { if !ok || undone.err != nil { t.Fatalf("undo returned %#v", undone) } - view.Update(undone) - if view.lastBulkReplyID != 0 || view.notice != "Bulk reply recalled" { - t.Errorf("undo state = id:%d notice:%q", view.lastBulkReplyID, view.notice) + answer, _ := view.Update(undone) + if toast := deliverToView(view, answer); view.lastBulkReplyID != 0 || toast != "Bulk reply recalled" { + t.Errorf("undo state = id:%d toast:%q", view.lastBulkReplyID, toast) } view, state := tuiBulkReplyServer(t) diff --git a/internal/tui/collections_test.go b/internal/tui/collections_test.go index 618b0ea7..89b2eac9 100644 --- a/internal/tui/collections_test.go +++ b/internal/tui/collections_test.go @@ -134,9 +134,9 @@ func TestMailViewCollectionMembershipAddsAndRemoves(t *testing.T) { if recorded.method != http.MethodPost || recorded.path != "/topics/501/collecting" || recorded.rawQueries[len(recorded.rawQueries)-1] != "collection_id=12" { t.Errorf("request = %s %s?%v", recorded.method, recorded.path, recorded.rawQueries) } - v.Update(done) - if v.pendingMutations != 0 || v.notice != "Added to collection Kitchen remodel" { - t.Errorf("mutation state = pending:%d notice:%q", v.pendingMutations, v.notice) + answer, _ := v.Update(done) + if toast := deliverToView(v, answer); v.pendingMutations != 0 || toast != "Added to collection Kitchen remodel" { + t.Errorf("mutation state = pending:%d toast:%q", v.pendingMutations, toast) } if memberships := v.postingList.postings[0].Collections; len(memberships) != 1 || memberships[0].ID != 12 { t.Errorf("memberships = %+v", memberships) @@ -155,9 +155,9 @@ func TestMailViewCollectionMembershipAddsAndRemoves(t *testing.T) { if recorded.method != http.MethodDelete || recorded.path != "/topics/501/collecting" || recorded.rawQueries[len(recorded.rawQueries)-1] != "collection_id=12" { t.Errorf("request = %s %s?%v", recorded.method, recorded.path, recorded.rawQueries) } - v.Update(done) - if len(v.postingList.postings[0].Collections) != 0 || v.notice != "Removed from collection Kitchen remodel" { - t.Errorf("posting = %+v notice = %q", v.postingList.postings[0], v.notice) + answer, _ := v.Update(done) + if toast := deliverToView(v, answer); len(v.postingList.postings[0].Collections) != 0 || toast != "Removed from collection Kitchen remodel" { + t.Errorf("posting = %+v toast = %q", v.postingList.postings[0], toast) } }) } diff --git a/internal/tui/compose_test.go b/internal/tui/compose_test.go index 8558549b..300bc4cf 100644 --- a/internal/tui/compose_test.go +++ b/internal/tui/compose_test.go @@ -191,16 +191,12 @@ func TestComposeSendsMessage(t *testing.T) { t.Errorf("copied = %v", got) } - v.Update(sent) + answer, _ := v.Update(sent) if composeModal(v) != nil { t.Error("form should close after a successful send") } - if v.notice != "Message sent" || !strings.Contains(v.View(), "Message sent") { - t.Errorf("expected sent notice, got %q", v.notice) - } - v.HandleContentKey(keyPress("down")) - if v.notice != "" { - t.Error("notice should clear on the next key") + if toast := deliverToView(v, answer); toast != "Message sent" { + t.Errorf("expected a sent toast, got %q", toast) } } @@ -401,13 +397,16 @@ func TestForwardFormLoadsLatestEntryAndSends(t *testing.T) { t.Errorf("directly = %v", got) } - v.Update(sent) - if composeModal(v) != nil || v.notice != "Message forwarded" { - t.Errorf("forward completion = compose %v notice %q", composeModal(v), v.notice) + answer, _ := v.Update(sent) + if toast := deliverToView(v, answer); composeModal(v) != nil || toast != "Message forwarded" { + t.Errorf("forward completion = compose %v toast %q", composeModal(v), toast) } } -func TestForwardCompletionNoticeIsVisibleInThread(t *testing.T) { +// Sending from inside a thread used to leave its confirmation in the posting list's +// header, which a thread covers — so it was said to nobody. A toast belongs to the +// model and is drawn over whatever the section is showing. +func TestForwardCompletionIsSaidFromInsideAThread(t *testing.T) { v := mailWithPostings() v.Resize(80, 30) v.inThread = true @@ -418,13 +417,16 @@ func TestForwardCompletionNoticeIsVisibleInThread(t *testing.T) { content: "
Quoted message
", }, v.vc.styles) - v.Update(composeSentMsg{label: "Message forwarded"}) + answer, _ := v.Update(composeSentMsg{label: "Message forwarded"}) if composeModal(v) != nil { t.Error("forward form should close after sending") } - if view := v.View(); !strings.Contains(view, "Message forwarded") || !strings.Contains(view, "Original thread") { - t.Errorf("thread view should show the forwarding notice, got %q", view) + if toast := deliverToView(v, answer); toast != "Message forwarded" { + t.Errorf("toast = %q", toast) + } + if view := v.View(); !strings.Contains(view, "Original thread") { + t.Errorf("the thread should still be on screen, got %q", view) } } diff --git a/internal/tui/contacts.go b/internal/tui/contacts.go index 1e4aad7e..48053165 100644 --- a/internal/tui/contacts.go +++ b/internal/tui/contacts.go @@ -188,16 +188,15 @@ func (v *contactsView) Update(msg tea.Msg) (tea.Cmd, bool) { return nil, true } v.contactForm = nil + saved := "Contact updated" if msg.created { - v.notice = "Contact added" - } else { - v.notice = "Contact updated" + saved = "Contact added" } if msg.originalID != 0 && msg.originalID != msg.contact.ID { v.list.remove(msg.originalID) } v.updateContactInList(msg.contact) - return v.requestContactDetail(msg.contact.ID), true + return tea.Batch(notify(saved), v.requestContactDetail(msg.contact.ID)), true case contactHiddenMsg: if cmd, ok := v.requests.settle(msg.requestResult); !ok { @@ -208,16 +207,14 @@ func (v *contactsView) Update(msg tea.Msg) (tea.Cmd, bool) { v.inDetail = false v.detail = Contact{} v.note = "" - v.notice = "Contact hidden" - return v.loadMoreContacts(), true + return tea.Batch(notify("Contact hidden"), v.loadMoreContacts()), true case contactRevealedMsg: if cmd, ok := v.requests.settle(msg.requestResult); !ok { return cmd, true } v.lastHiddenID = 0 - v.notice = "Contact shown again" - return v.requestContacts(), true + return tea.Batch(notify("Contact shown again"), v.requestContacts()), true case contactNoteSavedMsg: if !v.requests.accepts(msg.requestResult) { @@ -235,13 +232,12 @@ func (v *contactsView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.noteForm = nil v.note = msg.note + saved := "Private note saved" if msg.deleted { - v.notice = "Private note deleted" - } else { - v.notice = "Private note saved" + saved = "Private note deleted" } v.refreshDetailView() - return nil, true + return notify(saved), true } if v.contactForm != nil { diff --git a/internal/tui/contacts_test.go b/internal/tui/contacts_test.go index 48ecd43f..1f45e011 100644 --- a/internal/tui/contacts_test.go +++ b/internal/tui/contacts_test.go @@ -296,11 +296,13 @@ func TestContactsViewAddsContact(t *testing.T) { if save == nil { t.Fatal("ctrl+s should save the contact") } - detail, _ := view.Update(save()) - if detail == nil { + answer, _ := view.Update(save()) + if answer == nil { t.Fatal("successful create should load contact detail") } - view.Update(detail()) + if toast := deliverToView(view, answer); toast != "Contact added" { + t.Errorf("toast = %q", toast) + } if view.contactForm != nil || !view.inDetail || view.detail.ID != 8 { t.Errorf("create state = form:%v detail:%+v", view.contactForm, view.detail) } @@ -429,18 +431,19 @@ func TestContactsViewHidesAndShowsAgain(t *testing.T) { loadTUIContacts(t, view) openTUIContact(t, view) hide := view.HandleContentKey(keyPress("h")) - view.Update(hide()) - if view.inDetail || len(view.list.contacts) != 2 || view.lastHiddenID != 7 || view.notice != "Contact hidden" { - t.Errorf("hide state = detail:%v contacts:%+v hidden:%d notice:%q", view.inDetail, view.list.contacts, view.lastHiddenID, view.notice) + hidden, _ := view.Update(hide()) + if toast := deliverToView(view, hidden); view.inDetail || len(view.list.contacts) != 2 || view.lastHiddenID != 7 || toast != "Contact hidden" { + t.Errorf("hide state = detail:%v contacts:%+v hidden:%d toast:%q", view.inDetail, view.list.contacts, view.lastHiddenID, toast) } reveal := view.HandleContentKey(keyPress("u")) - refresh, _ := view.Update(reveal()) - if refresh == nil { + revealed, _ := view.Update(reveal()) + if revealed == nil { t.Fatal("successful reveal should refresh contacts") } + toast, refresh := toastAndRest(t, revealed) drainContactPages(t, view, refresh()) - if view.lastHiddenID != 0 || len(view.list.contacts) != 3 || view.notice != "Contact shown again" { - t.Errorf("reveal state = contacts:%+v hidden:%d notice:%q", view.list.contacts, view.lastHiddenID, view.notice) + if view.lastHiddenID != 0 || len(view.list.contacts) != 3 || toast != "Contact shown again" { + t.Errorf("reveal state = contacts:%+v hidden:%d toast:%q", view.list.contacts, view.lastHiddenID, toast) } requests, _ := recorded.snapshot() joined := strings.Join(requests, "\n") @@ -459,9 +462,9 @@ func TestContactsViewEditsAndDeletesNote(t *testing.T) { } view.noteForm.input.SetValue("Prefers a call") save := view.HandleContentKey(keyPress("ctrl+s")) - view.Update(save()) - if view.noteForm != nil || view.note != "Prefers a call" || view.notice != "Private note saved" { - t.Errorf("note save state = form:%v note:%q notice:%q", view.noteForm, view.note, view.notice) + saved, _ := view.Update(save()) + if toast := deliverToView(view, saved); view.noteForm != nil || view.note != "Prefers a call" || toast != "Private note saved" { + t.Errorf("note save state = form:%v note:%q toast:%q", view.noteForm, view.note, toast) } if deleteCmd := view.HandleContentKey(keyPress("x")); deleteCmd != nil || !view.confirmNoteDelete || !strings.Contains(view.notice, "permanently delete") { t.Fatal("first x should request note deletion confirmation") @@ -470,9 +473,9 @@ func TestContactsViewEditsAndDeletesNote(t *testing.T) { if deleteCmd == nil { t.Fatal("second x should delete the note") } - view.Update(deleteCmd()) - if view.note != "" || view.notice != "Private note deleted" { - t.Errorf("note delete state = note:%q notice:%q", view.note, view.notice) + deleted, _ := view.Update(deleteCmd()) + if toast := deliverToView(view, deleted); view.note != "" || toast != "Private note deleted" { + t.Errorf("note delete state = note:%q toast:%q", view.note, toast) } requests, _ := recorded.snapshot() joined := strings.Join(requests, "\n") diff --git a/internal/tui/journal.go b/internal/tui/journal.go index 1d75b735..832b5f97 100644 --- a/internal/tui/journal.go +++ b/internal/tui/journal.go @@ -132,7 +132,6 @@ type journalView struct { prompt *journalPrompt confirmRemove bool selectDate string - notice string requests requestLane[journalRequestKind] } @@ -173,8 +172,7 @@ func (v *journalView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.loadingMore = false if msg.err != nil { - v.notice = errorNotice("Could not load older journal entries", msg.err) - return nil, true + return notifyError("Could not load older journal entries", msg.err), true } v.list.growEntries(msg.entries) v.nextPage = msg.nextPage @@ -201,24 +199,24 @@ func (v *journalView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.requests.finish(msg.requestID) if msg.err != nil { + // A form that is open says so itself; everything else says it in a toast. if v.form != nil { v.form.saving = false v.form.status = errorNotice("Save failed", msg.err) v.form.isError = true - } else { - v.notice = errorNotice("Could not remove journal entry", msg.err) + return nil, true } - return nil, true + return notifyError("Could not remove journal entry", msg.err), true } v.form = nil v.inDetail = false v.query = "" v.selectDate = msg.date - v.notice = "Journal entry saved" + saved := "Journal entry saved" if msg.removed { - v.notice = "Journal entry removed" + saved = "Journal entry removed" } - return v.requestFeed(""), true + return tea.Batch(notify(saved), v.requestFeed("")), true } if v.prompt != nil { @@ -243,16 +241,13 @@ func (v *journalView) View() string { return v.form.view() } if v.inDetail { - if v.notice != "" { - return v.vc.styles.title.Render(v.notice) + "\n" + v.detailView.View() - } return v.detailView.View() } + // What just happened is a toast now, so the heading is only ever about what the reader + // is looking at. var heading string switch { - case v.notice != "": - heading = v.vc.styles.title.Render(v.notice) case v.query != "": heading = fmt.Sprintf("Search: %s · %d results", terminal.SanitizeLine(v.query), len(v.list.entries)) case len(v.list.entries) == 0 && v.loaded: @@ -338,7 +333,6 @@ func (v *journalView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { if msg.String() != "x" { v.confirmRemove = false } - v.notice = "" if v.inDetail { switch msg.String() { case "e": @@ -349,9 +343,10 @@ func (v *journalView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { if strings.TrimSpace(v.detailContent) == "" { return nil } + // The help bar asks for the second x — "confirm remove" — as the habits + // picker does. A toast would be gone before the reader answered. if !v.confirmRemove { v.confirmRemove = true - v.notice = "Press x again to permanently remove this journal entry" return nil } return v.removeJournalEntry(v.detailDate) @@ -439,7 +434,6 @@ func (v *journalView) startPrompt(kind journalPromptKind, value string) tea.Cmd } func (v *journalView) startEditor() tea.Cmd { - v.notice = "" v.form = newJournalForm(v.detailDate, v.detailContent, v.vc.styles) v.form.resize(v.vc.width, v.vc.height) return v.form.init() diff --git a/internal/tui/mail.go b/internal/tui/mail.go index d0a46c77..7a36fd0f 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -446,8 +446,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { return nil, true } if msg.draft == nil || len(msg.draft.Entries) == 0 { - v.notice = "No replyable threads found; nothing was sent" - return nil, true + return notify("No replyable threads found; nothing was sent"), true } form := newBulkReplyForm(msg.postingIDs, msg.draft, v.vc.styles) v.openModal(form) @@ -473,19 +472,21 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { v.modal = nil v.postingList.clearSelected() count := int(msg.delivery.EntriesCount) - v.notice = fmt.Sprintf("%d bulk %s sent", count, replyNoun(count)) + sent := fmt.Sprintf("%d bulk %s sent", count, replyNoun(count)) v.lastBulkReplyID = 0 if msg.delivery.Delayed { - v.notice = fmt.Sprintf("%d bulk %s queued with undo available", count, replyNoun(count)) + sent = fmt.Sprintf("%d bulk %s queued with undo available", count, replyNoun(count)) + // The undo stands in the help bar for as long as it is available, so the + // toast can say so and go. if msg.delivery.Id > 0 { v.lastBulkReplyID = msg.delivery.Id - v.notice += " — press ctrl+u to undo" + sent += " — press ctrl+u to undo" } } if msg.skipped > 0 { - v.notice += fmt.Sprintf("; %d skipped", msg.skipped) + sent += fmt.Sprintf("; %d skipped", msg.skipped) } - return nil, true + return notify(sent), true case bulkReplyUndoneMsg: v.finishMutation() @@ -497,8 +498,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { return nil, true } v.lastBulkReplyID = 0 - v.notice = "Bulk reply recalled" - return nil, true + return notify("Bulk reply recalled"), true case snippetsLoadedMsg: form := modalOf[*composeForm](v) @@ -525,8 +525,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { return nil, true } v.modal = nil - v.notice = msg.label - return nil, true + return notify(msg.label), true case attachmentSavedMsg: if !v.currentAttachmentAction(msg.topicID, msg.attachmentID) { @@ -535,14 +534,12 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { if msg.err != nil { saveErr := apierr.AsError(msg.err) if saveErr.Code == "usage" && strings.HasPrefix(saveErr.Message, "destination already exists:") { - v.notice = "Attachment already exists: " + terminal.SanitizeLine(msg.path) - } else { - v.noteFailure("Could not save attachment", msg.err) + return notify("Attachment already exists: " + msg.path), true } + v.noteFailure("Could not save attachment", msg.err) return nil, true } - v.notice = "Saved attachment to " + terminal.SanitizeLine(msg.path) - return nil, true + return notify("Saved attachment to " + msg.path), true case attachmentOpenedMsg: if !v.currentAttachmentAction(msg.topicID, msg.attachmentID) { @@ -552,8 +549,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { v.noteFailure("Could not open attachment", msg.err) return nil, true } - v.notice = "Opened attachment " + terminal.SanitizeLine(msg.filename) - return nil, true + return notify("Opened attachment " + msg.filename), true case postingActionDoneMsg: v.finishMutation() @@ -563,7 +559,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { if msg.err != nil { return func() tea.Msg { return errMsg{msg.err} }, true } - v.notice = msg.action + done := notify(msg.action) idx := v.postingIndex(msg.postingID) if idx >= 0 { switch msg.effect { @@ -582,12 +578,12 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } if v.requests.kind == mailRequestPostings { if source := v.currentSource(); source != nil { - return v.requestPostings(*source), true + return tea.Batch(done, v.requestPostings(*source)), true } } // A thread leaving the list can uncover the bottom of it, so what is below comes up // to fill the gap rather than leaving a short list with more waiting behind it. - return v.loadMorePostings(), true + return tea.Batch(done, v.loadMorePostings()), true case postingSeenMsg: v.finishMutation() @@ -610,16 +606,17 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } return nil, true } + var done tea.Cmd if msg.sourceID == v.currentBoxID() && msg.sourceKind == v.currentSourceKind() { - v.notice = msg.action + done = notify(msg.action) } if msg.created { - return v.requestSources(), true + return tea.Batch(done, v.requestSources()), true } if source := v.currentSource(); source != nil && msg.sourceID == source.ID && msg.sourceKind == source.Kind { - return v.requestPostings(*source), true + return tea.Batch(done, v.requestPostings(*source)), true } - return nil, true + return done, true case collectionActionDoneMsg: v.finishMutation() @@ -630,7 +627,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { v.notice = terminal.SanitizeLine(errorNotice("Could not update collections", msg.err)) return nil, true } - v.notice = msg.action + done := notify(msg.action) if index := v.postingIndex(msg.postingID); index >= 0 { v.updatePostingCollection(index, msg.collection, msg.added) if !msg.added && msg.sourceKind == mail.KindCollection && msg.collection.ID == msg.sourceID { @@ -639,10 +636,10 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } if msg.sourceKind == mail.KindCollection && msg.collection.ID == msg.sourceID { if source := v.currentSource(); source != nil { - return v.requestPostings(*source), true + return tea.Batch(done, v.requestPostings(*source)), true } } - return nil, true + return done, true } // Cursor blinks and other component messages go to the open modal. A form owns diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 3d4ef335..1be6f9db 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -505,7 +505,8 @@ func TestMailViewPostingKeysCallExpectedEndpoints(t *testing.T) { if done.effect != tt.effect { t.Errorf("action effect = %v, want %v", done.effect, tt.effect) } - v.Update(done) + answer, _ := v.Update(done) + toast := deliverToView(v, answer) if v.AccountSwitchBlocked() { t.Fatal("completed posting mutation still blocks account switching") } @@ -539,8 +540,8 @@ func TestMailViewPostingKeysCallExpectedEndpoints(t *testing.T) { if tt.effect == postingActionIgnore && !v.postingList.postings[0].Muted { t.Error("selected posting should be ignored") } - if v.notice != tt.notice || !strings.Contains(v.View(), tt.notice) { - t.Errorf("notice = %q, want visible %q", v.notice, tt.notice) + if toast != tt.notice { + t.Errorf("toast = %q, want %q", toast, tt.notice) } }) } @@ -574,7 +575,8 @@ func TestMailViewUnseenKeysRestoreSeenAndBubbledUpThreads(t *testing.T) { t.Errorf("posting_ids = %v, want [100]", recorded.body.PostingIDs) } - v.Update(done) + answer, _ := v.Update(done) + toast := deliverToView(v, answer) posting := v.postingList.selectedPosting() if posting == nil || posting.ID != 100 || posting.Seen || posting.BubbledUp { t.Errorf("selected posting after unseen = %+v", posting) @@ -582,8 +584,8 @@ func TestMailViewUnseenKeysRestoreSeenAndBubbledUpThreads(t *testing.T) { if v.postingList.postings[0].ID != 100 { t.Errorf("unseen posting did not move to New for You: %+v", v.postingList.postings) } - if v.notice != "Thread marked as unseen" { - t.Errorf("notice = %q", v.notice) + if toast != "Thread marked as unseen" { + t.Errorf("toast = %q", toast) } }) } @@ -649,15 +651,16 @@ func TestMailViewStopIgnoringCallsDeleteAndKeepsThreadVisible(t *testing.T) { t.Errorf("posting_ids = %v, want [100]", recorded.body.PostingIDs) } - v.Update(done) + answer, _ := v.Update(done) + toast := deliverToView(v, answer) if len(v.postingList.postings) != 2 { t.Errorf("posting count = %d, want ignored thread to remain visible", len(v.postingList.postings)) } if v.postingList.postings[0].Muted { t.Error("selected posting should no longer be ignored") } - if v.notice != "Stopped ignoring thread" { - t.Errorf("notice = %q, want %q", v.notice, "Stopped ignoring thread") + if toast != "Stopped ignoring thread" { + t.Errorf("toast = %q, want %q", toast, "Stopped ignoring thread") } } @@ -702,9 +705,10 @@ func TestMailViewImboxKeysMoveToImbox(t *testing.T) { if recorded.path != "/postings/moves.json" || recorded.body.BoxID == nil || *recorded.body.BoxID != 1 { t.Errorf("request = %s body=%+v", recorded.path, recorded.body) } - v.Update(done) - if v.postingIndex(100) >= 0 || v.notice != "Thread moved to Imbox" { - t.Errorf("posting present=%v notice=%q", v.postingIndex(100) >= 0, v.notice) + answer, _ := v.Update(done) + toast := deliverToView(v, answer) + if v.postingIndex(100) >= 0 || toast != "Thread moved to Imbox" { + t.Errorf("posting present=%v toast=%q", v.postingIndex(100) >= 0, toast) } }) } @@ -761,12 +765,13 @@ func TestMailViewMovePickerMovesToSelectedBox(t *testing.T) { t.Errorf("posting_ids = %v, want [100]", recorded.body.PostingIDs) } - v.Update(done) + answer, _ := v.Update(done) + toast := deliverToView(v, answer) if v.postingIndex(100) >= 0 { t.Error("moved posting should leave the current box") } - if v.notice != "Thread moved to The Feed" { - t.Errorf("notice = %q", v.notice) + if toast != "Thread moved to The Feed" { + t.Errorf("toast = %q", toast) } } @@ -1176,9 +1181,9 @@ func TestMailViewFolderPickerFilesAndUnfilesThread(t *testing.T) { if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 100 { t.Errorf("posting_ids = %v", recorded.body.PostingIDs) } - refresh, consumed := v.Update(done) - if !consumed || refresh == nil || v.notice != "Label Receipts added" { - t.Errorf("completion = consumed:%v refresh:%v notice:%q", consumed, refresh != nil, v.notice) + answer, consumed := v.Update(done) + if toast, _ := toastAndRest(t, answer); !consumed || toast != "Label Receipts added" { + t.Errorf("completion = consumed:%v toast:%q", consumed, toast) } }) @@ -1218,9 +1223,9 @@ func TestMailViewFolderPickerFilesAndUnfilesThread(t *testing.T) { if len(recorded.rawQueries) == 0 || strings.Contains(recorded.rawQueries[len(recorded.rawQueries)-1], "folder_id=") { t.Errorf("queries = %v, want no folder_id", recorded.rawQueries) } - v.Update(done) - if v.notice != "All labels removed" { - t.Errorf("notice = %q", v.notice) + answer, _ := v.Update(done) + if toast := deliverToView(v, answer); toast != "All labels removed" { + t.Errorf("toast = %q", toast) } }) } @@ -1243,9 +1248,9 @@ func TestMailViewFolderPickerCreatesFolder(t *testing.T) { if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 100 { t.Errorf("posting_ids = %v", recorded.body.PostingIDs) } - refresh, consumed := v.Update(done) - if !consumed || refresh == nil || v.notice != "Label Travel receipts created" { - t.Errorf("completion = consumed:%v refresh:%v notice:%q", consumed, refresh != nil, v.notice) + answer, consumed := v.Update(done) + if toast, _ := toastAndRest(t, answer); !consumed || toast != "Label Travel receipts created" { + t.Errorf("completion = consumed:%v toast:%q", consumed, toast) } } @@ -2191,14 +2196,15 @@ func TestMailViewSavesSelectedAttachmentOnlyAfterExplicitAction(t *testing.T) { if saveCalls != 0 { t.Fatal("handling the key must defer file access to the command") } - if _, consumed := v.Update(save()); !consumed { + answer, consumed := v.Update(save()) + if !consumed { t.Fatal("attachment save result was not consumed") } if saveCalls != 1 || savedDestination != "chart.png" || savedURL != "/rails/blobs/chart.png" || savedForce { t.Errorf("save call = count:%d destination:%q URL:%q force:%v", saveCalls, savedDestination, savedURL, savedForce) } - if v.notice != "Saved attachment to chart.png" { - t.Errorf("save notice = %q", v.notice) + if toast := deliverToView(v, answer); toast != "Saved attachment to chart.png" { + t.Errorf("save toast = %q", toast) } } @@ -2216,9 +2222,9 @@ func TestMailViewExplainsThatSaveWillNotReplaceExistingAttachment(t *testing.T) }) save := v.HandleContentKey(keyPress("s")) - v.Update(save()) - if v.notice != "Attachment already exists: agenda.pdf" { - t.Errorf("existing attachment notice = %q", v.notice) + answer, _ := v.Update(save()) + if toast := deliverToView(v, answer); toast != "Attachment already exists: agenda.pdf" { + t.Errorf("existing attachment toast = %q", toast) } } @@ -2258,7 +2264,8 @@ func TestMailViewDownloadsBeforeExplicitExternalOpen(t *testing.T) { if len(events) != 0 { t.Fatalf("handling the key opened an attachment before the command ran: %v", events) } - if _, consumed := v.Update(open()); !consumed { + answer, consumed := v.Update(open()) + if !consumed { t.Fatal("attachment open result was not consumed") } if fmt.Sprint(events) != "[save open]" { @@ -2267,8 +2274,8 @@ func TestMailViewDownloadsBeforeExplicitExternalOpen(t *testing.T) { if openedPath != filepath.Join(temporaryDirectory, "chart.png") { t.Errorf("opened path = %q", openedPath) } - if v.notice != "Opened attachment chart.png" { - t.Errorf("open notice = %q", v.notice) + if toast := deliverToView(v, answer); toast != "Opened attachment chart.png" { + t.Errorf("open toast = %q", toast) } } diff --git a/internal/tui/screener.go b/internal/tui/screener.go index 5641977c..fd7fac6b 100644 --- a/internal/tui/screener.go +++ b/internal/tui/screener.go @@ -317,10 +317,9 @@ func (v *screenerView) Update(msg tea.Msg) (tea.Cmd, bool) { v.pending.remove(msg.clearanceID) v.pendingCount = max(v.pendingCount-1, 0) v.history.loaded = false - v.notice = msg.name + " " + screenedVerb(msg.status) // A sender being dealt with can uncover the bottom of the queue, so the senders // behind them come up rather than leaving an empty pane with a count over it. - return v.loadMoreRows(), true + return tea.Batch(notify(msg.name+" "+screenedVerb(msg.status)), v.loadMoreRows()), true case screenerClearedMsg: if v.mutations > 0 { diff --git a/internal/tui/screener_test.go b/internal/tui/screener_test.go index 8ecb2378..3bc2729c 100644 --- a/internal/tui/screener_test.go +++ b/internal/tui/screener_test.go @@ -186,7 +186,8 @@ func TestScreenerScreensSenderIn(t *testing.T) { if done.err != nil { t.Fatalf("screening in failed: %v", done.err) } - view.Update(done) + answer, _ := view.Update(done) + toast := deliverToView(view, answer) if len(view.pending.rows) != 1 || view.pending.rows[0].id != 92 { t.Errorf("screened sender should leave the queue: %+v", view.pending.rows) @@ -194,8 +195,8 @@ func TestScreenerScreensSenderIn(t *testing.T) { if view.pendingCount != 1 { t.Errorf("pendingCount = %d, want 1", view.pendingCount) } - if view.notice != "Jane Doe screened in" { - t.Errorf("notice = %q", view.notice) + if toast != "Jane Doe screened in" { + t.Errorf("toast = %q", toast) } requests := state.snapshot() @@ -213,9 +214,9 @@ func TestScreenerScreensSenderOut(t *testing.T) { if done.status != hey.ClearanceDenied || done.name != "Bob Smith" { t.Fatalf("decision = %+v", done) } - view.Update(done) - if view.notice != "Bob Smith screened out" { - t.Errorf("notice = %q", view.notice) + answer, _ := view.Update(done) + if toast := deliverToView(view, answer); toast != "Bob Smith screened out" { + t.Errorf("toast = %q", toast) } requests := state.snapshot() From 3c0f896a104c9585b59a474645f37efbeaffa7b7 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 11:33:49 +0200 Subject: [PATCH 04/19] Rework the calendar's day view and put habits behind a modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The day view spoke its own visual language and got a few things wrong. It borrows the mail list's vocabulary now: chrome for the structure a reader looks past — the hour axis, an event's border, a section's rule — and bright bold for the thing they came to read. Its sections are labelled the way the posting list's are, the day names itself above its hours, and the week's to-dos are pinned under the grid as "Sometime this week" rather than scrolling away inside it, because a to-do is not due at an hour. An hour rule falls from every label to the bottom of the grid, dotted so it reads as a guide rather than another box's border, and the axis closes on another 00: twenty-four hours are twenty-five lines. That replaces "(no events)" — an empty day is its hours. Habits were wrong, and it was data rather than drawing. HEY answers a habit and *doing* the habit as separate recordings, and a completion carries no title and no icon — only the parent it belongs to. Both were being listed as habits, so a day with three habits done showed three nameless circles and no habit marked done. A completion now marks the habit it names and is never listed itself. Managing them moved off the calendar's keys into a modal on b, as in the web app, with the hint on the section header where the cover puts "x to peek". Enter ticks a habit off for the day on screen or clears it; the ring wears the habit's own color; the icon is the emoji standing in for HEY's SVG. The edit form stopped asking anyone to spell "meditate": icon, color and days are pickers, and the wall of accepted values underneath them is gone along with two classes of error. A read behind a modal no longer claims the spinner. Ticking a habit off reads the day again, and a spinner for that is a flash of nothing where the day used to be. --- internal/cmd/testdata/sink_manifest.txt | 1 + internal/habit/values.go | 55 ++- internal/habit/values_test.go | 26 ++ internal/tui/calendar.go | 231 ++++++++----- internal/tui/calendar_test.go | 241 ++++++++++++- internal/tui/calendar_views.go | 381 +++++++++++++-------- internal/tui/content.go | 11 +- internal/tui/habit_form.go | 283 ++++++++++----- internal/tui/habit_form_test.go | 279 ++++++++++----- internal/tui/habits.go | 158 +++++++++ internal/tui/time_track_categories_test.go | 11 +- internal/tui/tui_test.go | 2 + 12 files changed, 1252 insertions(+), 427 deletions(-) create mode 100644 internal/tui/habits.go diff --git a/internal/cmd/testdata/sink_manifest.txt b/internal/cmd/testdata/sink_manifest.txt index aa116261..844891f3 100644 --- a/internal/cmd/testdata/sink_manifest.txt +++ b/internal/cmd/testdata/sink_manifest.txt @@ -59,6 +59,7 @@ exempt internal/cmd/setup_agent.go:newSetupAgentCommands agent names are this pr exempt internal/cmd/setup_agent.go:runSetupAgent agent names are this program's own constants exempt internal/cmd/setup.go:setupAgents agent names are this program's own constants exempt internal/cmd/setup.go:showWizardSuccess check names and statuses are this program's own constants +exempt internal/tui/habit_form.go:iconField the icon names and emoji are this program's own constants, from internal/habit's list of what HEY accepts, never a name HEY served exempt internal/cmd/upgrade_selfupdate.go:extractTarGzMember archive member names come from a release whose signature was verified first exempt internal/cmd/upgrade_selfupdate.go:extractZipMember archive member names come from a release whose signature was verified first exempt internal/cmd/topic.go:writeThreadMarkdown the body it writes is the Markdown ToMarkdown produced, which carries no control characters, and the document is Markdown for a reader rather than a terminal diff --git a/internal/habit/values.go b/internal/habit/values.go index 1ecbd458..6e0aaa2a 100644 --- a/internal/habit/values.go +++ b/internal/habit/values.go @@ -5,18 +5,63 @@ import ( "strings" ) -const ( +// Icon is one of the icons HEY draws a habit with. HEY serves an SVG per icon, which a +// terminal cannot draw, so each carries the emoji that stands in for it. The emoji are +// all two cells wide — see TestEveryHabitEmojiIsTwoCellsWide — because a habit's icon +// sits in lists whose width is measured. +type Icon struct { + Name string + Emoji string +} + +// Icons are the habit icons HEY accepts, in the order its own enum declares them. +var Icons = []Icon{ + {"weights", "💪"}, {"art", "🎨"}, {"baseball", "⚾"}, {"basketball", "🏀"}, + {"bed", "😴"}, {"bicycle", "🚲"}, {"brain", "🧠"}, {"camera", "📷"}, + {"cat", "🐱"}, {"church", "⛪"}, {"clean", "🧹"}, {"cook", "🍳"}, + {"dog", "🐶"}, {"football", "🏈"}, {"fruit", "🍎"}, {"game", "🎮"}, + {"garden", "🌻"}, {"guitar", "🎸"}, {"heart", "💗"}, {"hydrate", "💧"}, + {"meditate", "🧘"}, {"money", "💰"}, {"music", "🎵"}, {"piano", "🎹"}, + {"pill", "💊"}, {"plant", "🌱"}, {"read", "📖"}, {"run", "🏃"}, + {"smoke", "🚬"}, {"soccer", "⚽"}, {"study", "📚"}, {"swim", "🌊"}, + {"tea", "🍵"}, {"toothbrush", "🪥"}, {"tree", "🌳"}, {"tv", "📺"}, + {"vegetable", "🥕"}, {"walk", "🚶"}, {"water", "🚰"}, {"write", "📝"}, + {"yoga", "🤸"}, {"heat", "🔥"}, {"ice", "🧊"}, {"lotus", "🌸"}, + {"breathe", "💨"}, {"drink", "🥤"}, {"star", "⭐"}, +} + +// Colors are the habit colors HEY accepts, in the order its own enum declares them. +var Colors = []string{"blue", "red", "gold", "green", "teal", "purple", "pink", "brown"} + +var ( // IconValues lists the icon names HEY accepts for habits. - IconValues = "weights, art, baseball, basketball, bed, bicycle, brain, camera, cat, church, clean, cook, dog, football, fruit, game, garden, guitar, heart, hydrate, meditate, money, music, piano, pill, plant, read, run, smoke, soccer, study, swim, tea, toothbrush, tree, tv, vegetable, walk, water, write, yoga, heat, ice, lotus, breathe, drink, star" + IconValues = iconNames() // ColorValues lists the color names HEY accepts for habits. - ColorValues = "blue, red, gold, green, teal, purple, pink, brown" -) + ColorValues = strings.Join(Colors, ", ") -var ( acceptedIcons = acceptedValues(IconValues) acceptedColors = acceptedValues(ColorValues) ) +// EmojiFor answers the emoji standing in for an icon, and nothing for a name HEY does +// not know, so a habit carrying an icon this build has not heard of still lists. +func EmojiFor(icon string) string { + for _, known := range Icons { + if known.Name == icon { + return known.Emoji + } + } + return "" +} + +func iconNames() string { + names := make([]string, len(Icons)) + for i, icon := range Icons { + names[i] = icon.Name + } + return strings.Join(names, ", ") +} + // ValidateIcon accepts an icon name supported by HEY habits. func ValidateIcon(value string) error { if !acceptedIcons[value] { diff --git a/internal/habit/values_test.go b/internal/habit/values_test.go index 554aa34d..0e9bf89d 100644 --- a/internal/habit/values_test.go +++ b/internal/habit/values_test.go @@ -3,8 +3,34 @@ package habit import ( "strings" "testing" + + "charm.land/lipgloss/v2" ) +// Every icon's emoji must measure two cells. An emoji whose default presentation is +// text — the ones a variation selector would have to widen — measures one cell in some +// terminals and two in others, which slides everything to its right by an amount +// nothing here can know. +func TestEveryHabitEmojiIsTwoCellsWide(t *testing.T) { + for _, icon := range Icons { + if width := lipgloss.Width(icon.Emoji); width != 2 { + t.Errorf("%s emoji %q is %d cells wide, want 2", icon.Name, icon.Emoji, width) + } + if strings.ContainsRune(icon.Emoji, '️') { + t.Errorf("%s emoji %q carries a variation selector", icon.Name, icon.Emoji) + } + } +} + +func TestEmojiForAnswersNothingForAnUnknownIcon(t *testing.T) { + if got := EmojiFor("read"); got != "📖" { + t.Errorf("EmojiFor(read) = %q", got) + } + if got := EmojiFor("hovercraft"); got != "" { + t.Errorf("EmojiFor(hovercraft) = %q, want nothing", got) + } +} + func TestValidateIconAcceptsEveryIconValue(t *testing.T) { for _, icon := range strings.Split(IconValues, ", ") { if err := ValidateIcon(icon); err != nil { diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 411363d3..9651944a 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -6,6 +6,7 @@ import ( "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" @@ -28,6 +29,7 @@ type Calendar struct { // calendar views read them in; giving them time.Time is the next thing to do here. type Recording struct { ID int64 + ParentID int64 Title string AllDay bool StartsAt string @@ -108,11 +110,9 @@ type calendarView struct { // Scrollable content viewport for the calendar views contentVP viewport.Model - timeTrackCategories *timeTrackCategoryManager - habitForm *habitForm - habitIndex int - confirmedHabitDeleteID int64 - notice string + timeTrackCategories *timeTrackCategoryManager + habitPicker *habitPicker + habitForm *habitForm requests requestLane[calendarRequestKind] } @@ -127,7 +127,6 @@ func newCalendarView(vc *viewContext) *calendarView { } func (v *calendarView) Init() tea.Cmd { - v.confirmedHabitDeleteID = 0 cmds := []tea.Cmd{v.fetchIdentity()} if len(v.calendars) == 0 { cmds = append(cmds, v.requestCalendars()) @@ -159,9 +158,10 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { if cmd, ok := v.requests.settle(msg.requestResult); !ok { return cmd, true } - v.confirmedHabitDeleteID = 0 v.events, v.todos, v.habits = splitRecordings(msg.recordings) - v.normalizeHabitSelection() + if v.habitPicker != nil { + v.habitPicker.setHabits(v.manageableHabits()) + } v.rebuildView() return nil, true @@ -171,22 +171,21 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.requests.finish(msg.requestID) if msg.err != nil { - if v.habitForm != nil { + switch { + case v.habitForm != nil: v.habitForm.saving = false v.habitForm.status = errorNotice("Save failed", msg.err) v.habitForm.isError = true - } else { - v.notice = errorNotice("Delete failed", msg.err) + default: + return notifyError("Delete failed", msg.err), true } return nil, true } v.habitForm = nil - v.confirmedHabitDeleteID = 0 - v.notice = msg.action if v.calIndex >= 0 && v.calIndex < len(v.calendars) { - return v.requestRecordings(v.calendars[v.calIndex].ID), true + return tea.Batch(notify(msg.action), v.requestRecordings(v.calendars[v.calIndex].ID)), true } - return nil, true + return notify(msg.action), true case timeTrackCategoriesLoadedMsg: if !v.requests.accepts(msg.requestResult) { @@ -230,17 +229,38 @@ func (v *calendarView) View() string { if v.timeTrackCategories != nil { return v.timeTrackCategories.view(v.vc.styles, v.vc.width, v.vc.height) } + view := v.contentVP.View() + if footer := v.todosFooter(); footer != "" { + view += "\n" + footer + } + // The form stands over the picker it was opened from, and the picker over the + // calendar, so a habit is edited without the day it belongs to leaving the screen. + if v.habitPicker != nil { + view = v.habitPicker.draw(view, v.vc.width, v.vc.height) + } if v.habitForm != nil { - return v.habitForm.view() + frame := modalFrame(v.habitForm.title(), v.habitForm.view(), v.vc.width) + view = overlayModal(view, frame, v.vc.width, v.vc.height) } - var heading string - if v.notice != "" { - heading = v.vc.styles.title.Render(v.notice) + "\n" + return view +} + +// todosFooter is the week's to-dos, standing at the bottom of the screen under the grid +// rather than scrolling away inside it. A to-do is not due at an hour, so it does not +// belong in a grid that is precise about time — and a day with a long event pushes +// everything below it out of the viewport. +func (v *calendarView) todosFooter() string { + if v.viewMode == viewYear || len(v.todos) == 0 { + return "" } - if habit := v.selectedHabit(); habit != nil { - heading += styleMuted.Render(fmt.Sprintf("Selected habit %d/%d: %s (ID %d)", v.habitIndex+1, len(v.manageableHabits()), terminal.SanitizeLine(habit.Title), habit.ID)) + "\n" + return sectionHeader(todosSectionLabel, v.vc.width) + "\n" + renderTodosRibbon(v.todos, v.vc.width) +} + +func (v *calendarView) todosFooterHeight() int { + if footer := v.todosFooter(); footer != "" { + return lipgloss.Height(footer) } - return heading + v.contentVP.View() + return 0 } func (v *calendarView) HelpBindings() []helpBinding { @@ -250,21 +270,22 @@ func (v *calendarView) HelpBindings() []helpBinding { if v.habitForm != nil { return v.habitForm.helpBindings() } - bindings := []helpBinding{{"v", v.viewMode.next().String() + " view"}, {"c", "time categories"}} - if v.viewingPersonalCalendar() { - bindings = append(bindings, helpBinding{"a", "create habit"}) + if v.habitPicker != nil { + return v.habitPicker.helpBindings() } - if len(v.manageableHabits()) > 0 { - bindings = append(bindings, helpBinding{"[/]", "select habit"}, helpBinding{"e", "edit habit"}) - deleteLabel := "delete habit" - if v.habitDeleteConfirmed() { - deleteLabel = "confirm delete" - } - bindings = append(bindings, helpBinding{"x", deleteLabel}) + bindings := []helpBinding{{"v", v.viewMode.next().String() + " view"}, {"c", "time categories"}} + if v.showsHabits() { + bindings = append(bindings, helpBinding{"b", "habits"}) } return bindings } +// showsHabits reports whether the day has habits to manage: the hint on the section +// header and the h that opens the picker are the same offer. +func (v *calendarView) showsHabits() bool { + return len(v.manageableHabits()) > 0 || v.viewingPersonalCalendar() +} + func (v *calendarView) SubnavItems() ([]navItem, int, string, bool) { label := "Calendar" if v.calIndex >= 0 && v.calIndex < len(v.calendars) { @@ -309,11 +330,15 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return nil } - if msg.String() != "x" { - v.confirmedHabitDeleteID = 0 + if v.habitPicker != nil { + return v.handleHabitPickerKey(msg) } - v.notice = "" + switch msg.String() { + // b for habits, as in HEY's own calendar. + case "b": + v.habitPicker = newHabitPicker(v.manageableHabits()) + return nil case "c": v.timeTrackCategories = newTimeTrackCategoryManager() return v.requestTimeTrackCategories() @@ -324,37 +349,55 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { } v.rebuildView() return nil + } + + // Delegate scrolling to the content viewport + var cmd tea.Cmd + v.contentVP, cmd = v.contentVP.Update(msg) + return cmd +} + +// handleHabitPickerKey gives the open picker every key: managing a habit is what the +// modal is for, so a is a new habit here rather than whatever a means outside it. +func (v *calendarView) handleHabitPickerKey(msg tea.KeyPressMsg) tea.Cmd { + picker := v.habitPicker + + switch msg.String() { + case "esc", "q": + v.habitPicker = nil + return nil case "a": if !v.viewingPersonalCalendar() { - v.notice = "Habits can only be created from the personal calendar" + picker.status = "Habits can only be created from the personal calendar" return nil } return v.startHabitForm(habitFormCreate, Recording{}) - case "[": - v.moveHabitSelection(-1) - return nil - case "]": - v.moveHabitSelection(1) + case "enter": + if habit := picker.selected(); habit != nil { + return v.toggleHabitCompletion(*habit) + } return nil case "e": - if habit := v.selectedHabit(); habit != nil { + if habit := picker.selected(); habit != nil { return v.startHabitForm(habitFormEdit, *habit) } + return nil case "x": - if habit := v.selectedHabit(); habit != nil { - if v.confirmedHabitDeleteID != habit.ID { - v.confirmedHabitDeleteID = habit.ID - v.notice = fmt.Sprintf("Press x again to permanently delete %s and its history", terminal.SanitizeLine(habit.Title)) - return nil - } - return v.deleteHabit(*habit) + habit := picker.selected() + if habit == nil { + return nil } + if picker.confirmed != habit.ID { + picker.confirmed = habit.ID + picker.status = fmt.Sprintf("Press x again to permanently delete %s and its history", terminal.SanitizeLine(habit.Title)) + return nil + } + return v.deleteHabit(*habit) } - // Delegate scrolling to the content viewport - var cmd tea.Cmd - v.contentVP, cmd = v.contentVP.Update(msg) - return cmd + picker.moveCursor(msg) + picker.status = "" + return nil } func (v *calendarView) handleTimeTrackCategoryKey(msg tea.KeyPressMsg) tea.Cmd { @@ -423,9 +466,16 @@ func (v *calendarView) handleTimeTrackCategoryKey(msg tea.KeyPressMsg) tea.Cmd { // The calendar has no thread to be in: a recording is read where it sits in the grid. func (v *calendarView) InThread() bool { return false } func (v *calendarView) ExitThread() {} -func (v *calendarView) Loading() bool { return v.requests.loading } + +// Loading is what puts the spinner over the content, so a read with a modal open does +// not claim it: the reader is looking at the modal, and the calendar behind it can keep +// the day it was showing until the new one arrives. Ticking a habit off reads the day +// again, and a spinner for that is a flash of nothing where the day used to be. +func (v *calendarView) Loading() bool { + return v.requests.loading && !v.CapturingInput() +} func (v *calendarView) CapturingInput() bool { - return v.timeTrackCategories != nil || v.habitForm != nil + return v.timeTrackCategories != nil || v.habitForm != nil || v.habitPicker != nil } func (v *calendarView) AccountSwitchBlocked() bool { @@ -441,8 +491,6 @@ func (v *calendarView) Restyle() { } func (v *calendarView) Resize(width, height int) { - v.contentVP.SetWidth(width) - v.contentVP.SetHeight(max(height-2, 1)) if v.habitForm != nil { v.habitForm.resize(width, height) } @@ -460,12 +508,19 @@ func (v *calendarView) rebuildView() { anchor := v.now() dayLabels := dayLabelsFromRecordings(v.events, v.todos, v.habits) + // The grid scrolls; the week's to-dos do not, so the viewport gives up the rows + // they stand on. It is sized here rather than in Resize because the to-dos arrive + // with the recordings, long after the screen has its size — and the day fills + // whatever is left, so it has to be sized before it is drawn. + v.contentVP.SetWidth(w) + v.contentVP.SetHeight(max(h-2-v.todosFooterHeight(), 1)) + var content string switch v.viewMode { case viewDay: - content = renderDayView(v.events, v.todos, v.habits, anchor, w, h) + content = renderDayView(v.events, v.habits, anchor, w, v.contentVP.Height()) case viewWeek: - content = renderWeekView(v.events, v.todos, v.habits, anchor, v.firstWeekDay, w, h, dayLabels) + content = renderWeekView(v.events, v.habits, anchor, v.firstWeekDay, w, h, dayLabels) case viewYear: content = renderYearView(v.events, anchor, v.firstWeekDay, w, h, dayLabels) } @@ -501,41 +556,7 @@ func (v *calendarView) manageableHabits() []Recording { return habits } -func (v *calendarView) selectedHabit() *Recording { - habits := v.manageableHabits() - if len(habits) == 0 { - return nil - } - v.habitIndex = max(0, min(v.habitIndex, len(habits)-1)) - habit := habits[v.habitIndex] - return &habit -} - -func (v *calendarView) normalizeHabitSelection() { - habits := v.manageableHabits() - if len(habits) == 0 { - v.habitIndex = 0 - return - } - v.habitIndex = max(0, min(v.habitIndex, len(habits)-1)) -} - -func (v *calendarView) moveHabitSelection(delta int) { - habits := v.manageableHabits() - if len(habits) == 0 { - v.habitIndex = 0 - return - } - v.habitIndex = (v.habitIndex + delta + len(habits)) % len(habits) -} - -func (v *calendarView) habitDeleteConfirmed() bool { - habit := v.selectedHabit() - return habit != nil && v.confirmedHabitDeleteID == habit.ID -} - func (v *calendarView) startHabitForm(mode habitFormMode, recording Recording) tea.Cmd { - v.confirmedHabitDeleteID = 0 v.habitForm = newHabitForm(mode, recording, v.vc.styles) v.habitForm.resize(v.vc.width, v.vc.height) return v.habitForm.init() @@ -543,7 +564,7 @@ func (v *calendarView) startHabitForm(mode habitFormMode, recording Recording) t func (v *calendarView) saveHabit() tea.Cmd { form := v.habitForm - name, icon, color, days, _ := form.values() + name, icon, color, days := form.values() params := hey.HabitParams{Name: name, Icon: icon, Color: color, Days: days} requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestHabitMutation) return func() tea.Msg { @@ -559,6 +580,26 @@ func (v *calendarView) saveHabit() tea.Cmd { } } +// toggleHabitCompletion does a habit for the day on screen, or undoes it. HEY records +// the doing as a recording of its own, on a day-scoped route, so which day is being +// looked at is part of the request rather than an argument to the habit. +func (v *calendarView) toggleHabitCompletion(habit Recording) tea.Cmd { + day := v.now().Local().Format(time.DateOnly) + done := habit.CompletedAt != "" + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestHabitMutation) + return func() tea.Msg { + var err error + action := "Habit done for today" + if done { + action = "Habit cleared for today" + _, err = v.vc.sdk.Habits().Uncomplete(ctx, day, habit.ID) + } else { + _, err = v.vc.sdk.Habits().Complete(ctx, day, habit.ID) + } + return habitMutationMsg{requestResult: newRequestResult(requestID, err), action: action} + } +} + func (v *calendarView) deleteHabit(recording Recording) tea.Cmd { requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestHabitMutation) return func() tea.Msg { @@ -578,7 +619,7 @@ func sdkCalendarToModel(c generated.Calendar) Calendar { func sdkRecordingToModel(r generated.Recording) Recording { return Recording{ - ID: r.Id, Title: r.Title, AllDay: r.AllDay, Type: r.Type, + ID: r.Id, ParentID: r.ParentId, Title: r.Title, AllDay: r.AllDay, Type: r.Type, StartsAt: formatTimestamp(r.StartsAt), EndsAt: formatTimestamp(r.EndsAt), CompletedAt: formatTimestamp(r.CompletedAt), Label: r.Label, Icon: r.Icon, Color: r.Color, Days: append([]int32(nil), r.Days...), diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 7f847a14..f0924541 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -4,10 +4,13 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "sync" "testing" "time" + "charm.land/lipgloss/v2" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" ) @@ -340,16 +343,237 @@ func TestDayLabelsCoverTodosAndHabits(t *testing.T) { } } +// --- Habits --- + +func TestHabitCompletionsMarkTheirHabitRatherThanListingThemselves(t *testing.T) { + // HEY answers a habit and its completion as separate recordings, the completion + // carrying no title and naming its habit in parent_id. + events, todos, habits := splitRecordings([]Recording{ + {ID: 14796085, Title: "Read", Type: "Calendar::Habit", Icon: "read"}, + {ID: 14113260, Title: "Meditate", Type: "Calendar::Habit", Icon: "meditate"}, + {ID: 171477412, Type: "Calendar::Habit::Completion", ParentID: 14796085, StartsAt: "2026-08-22T00:00:00Z"}, + }) + + if len(events) != 0 || len(todos) != 0 { + t.Errorf("a completion is neither an event nor a to-do: events=%v todos=%v", events, todos) + } + if len(habits) != 2 { + t.Fatalf("habits = %+v, want the two habits without the completion", habits) + } + if habits[0].Title != "Read" || habits[0].CompletedAt != "2026-08-22T00:00:00Z" { + t.Errorf("the completed habit was not marked done: %+v", habits[0]) + } + if habits[1].Title != "Meditate" || habits[1].CompletedAt != "" { + t.Errorf("a habit with no completion was marked done: %+v", habits[1]) + } +} + +func TestHabitsModalOpensOverTheCalendarAndManagesHabits(t *testing.T) { + v := newCalendarView(testVC()) + v.vc.width, v.vc.height = 80, 20 + v.calendars = []Calendar{{ID: 10, Name: "Personal", Personal: true}} + v.habits = []Recording{ + {ID: 7, Title: "Read before bed"}, + {ID: 8, Title: "Evening walk", CompletedAt: "2026-08-22T00:00:00Z"}, + } + v.rebuildView() + + v.HandleContentKey(keyPress("b")) + if v.habitPicker == nil || !v.CapturingInput() { + t.Fatal("b did not open the habits modal") + } + + view := stripANSI(v.View()) + if !strings.Contains(view, "Habits") || !strings.Contains(view, "○ Read before bed") { + t.Errorf("modal did not list the habits: %q", view) + } + if !strings.Contains(view, "● Evening walk") { + t.Errorf("a habit done today is not marked done: %q", view) + } + if !strings.Contains(view, "╭") { + t.Errorf("habits modal drew no frame: %q", view) + } + + v.HandleContentKey(keyPress("esc")) + if v.habitPicker != nil { + t.Error("esc did not close the habits modal") + } +} + +// --- Day view --- + +func TestRibbonMarksWhatIsDoneAndStopsAtTheWidth(t *testing.T) { + todos := []Recording{ + {ID: 1, Title: "Renew passport"}, + {ID: 2, Title: "Send the invoice", CompletedAt: "2026-08-24T08:00:00Z"}, + } + + ribbon := renderTodosRibbon(todos, 80) + if stripANSI(ribbon) != "□ Renew passport ■ Send the invoice" { + t.Errorf("ribbon = %q", stripANSI(ribbon)) + } + if !strings.Contains(ribbon, "\x1b[2m■") { + t.Errorf("a finished to-do should be muted like a seen thread: %q", ribbon) + } + + // A ribbon too long for its line ends in an ellipsis rather than a cut title, + // and never draws past the width it was given. + narrow := renderTodosRibbon(todos, 20) + if stripANSI(narrow) != "□ Renew passport…" { + t.Errorf("narrow ribbon = %q", stripANSI(narrow)) + } + if width := lipgloss.Width(narrow); width > 20 { + t.Errorf("narrow ribbon width = %d, want at most 20", width) + } +} + +func TestDayViewLabelsItsSections(t *testing.T) { + events := []Recording{ + {ID: 1, Title: "Design review with Ryan", StartsAt: "2026-08-24T11:00:00Z", EndsAt: "2026-08-24T12:00:00Z"}, + {ID: 2, Title: "Dentist", AllDay: true}, + } + habits := []Recording{{ID: 4, Title: "Read 20 pages"}} + + day := time.Date(2026, 8, 24, 9, 0, 0, 0, time.Local) + view := stripANSI(renderDayView(events, habits, day, 100, 24)) + for _, label := range []string{"Habits", "Monday, August 24", "All day"} { + if !strings.Contains(view, label) { + t.Errorf("day view did not label its %q section: %q", label, view) + } + } +} + +func TestDayViewRulesFallFromEveryHourWithoutCuttingIntoAnEvent(t *testing.T) { + events := []Recording{ + {ID: 1, Title: "Design review with Ryan", StartsAt: "2026-08-24T11:00:00Z", EndsAt: "2026-08-24T12:00:00Z"}, + } + day := time.Date(2026, 8, 24, 9, 0, 0, 0, time.Local) + + // 98 columns leaves 96 for the hours once the closing label has its two, which puts + // an hour every four columns and the 11:00 event's box on the four from 44. The + // day's header and the hour axis take the first two rows of the 40 it is given, + // leaving 38 for the grid — more than the 25 rows the event's title needs read + // downwards. + lines := strings.Split(stripANSI(renderDayView(events, nil, day, 98, 40)), "\n") + grid := lines[2:] + if len(grid) != 38 { + t.Fatalf("grid is %d rows of the 38 left to it: %q", len(grid), grid) + } + + const eventRows = 25 // "Design review with Ryan" between its two borders + for i, line := range grid { + if cell := []rune(line)[0]; cell != hourRule { + t.Errorf("grid row %d lost midnight's rule: %q", i, line) + } + cell := []rune(line)[44] + if i < eventRows && cell == hourRule { + t.Errorf("grid row %d ruled through the event's own box: %q", i, line) + } + if i >= eventRows && cell != hourRule { + t.Errorf("grid row %d below the event kept no rule at 11: %q", i, line) + } + + // Twenty-four hours are twenty-five rules: the day closes where the next one + // starts. The event's box holds one of them for its own height. + want := 25 + if i < eventRows { + want = 24 + } + if rules := strings.Count(line, string(hourRule)); rules != want { + t.Errorf("grid row %d has %d rules, want %d: %q", i, rules, want, line) + } + } + + // The axis closes on the hour the day ends at. + if axis := lines[1]; !strings.HasPrefix(axis, "00") || !strings.HasSuffix(axis, "23 00") { + t.Errorf("hour axis does not run 00 through 00: %q", axis) + } +} + +func TestEmptyDayIsItsHoursRatherThanANotice(t *testing.T) { + day := time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) + view := stripANSI(renderDayView(nil, nil, day, 96, 20)) + + if strings.Contains(view, "no events") { + t.Errorf("an empty day still announces itself: %q", view) + } + rows := strings.Split(strings.TrimRight(view, "\n"), "\n") + if len(rows) != 20 { + t.Fatalf("empty day is %d rows of the 20 it was given: %q", len(rows), rows) + } + for i, row := range rows[2:] { + if !strings.HasPrefix(row, string(hourRule)) { + t.Errorf("grid row %d of an empty day is not ruled: %q", i, row) + } + } +} + +// A day sized to the room it has must not scroll. It used to by exactly one row: every +// section ended its own last line, so the day carried a blank line after it that the +// viewport counted. +func TestDayThatFitsDoesNotScroll(t *testing.T) { + v := newCalendarView(testVC()) + v.vc.width, v.vc.height = 90, 20 + v.habits = []Recording{{ID: 1, Title: "Read", Icon: "read", Color: "green"}} + v.todos = []Recording{{ID: 2, Title: "Clean the attic"}} + v.rebuildView() + + lines := strings.Count(v.contentVP.View(), "\n") + 1 + if lines != v.contentVP.Height() { + t.Errorf("the day is %d lines in a %d row viewport", lines, v.contentVP.Height()) + } + if !v.contentVP.AtBottom() { + t.Error("a day that fits its viewport still has somewhere to scroll") + } +} + +func TestCalendarPinsTodosBelowTheGrid(t *testing.T) { + v := newCalendarView(testVC()) + v.vc.width, v.vc.height = 80, 20 + v.todos = []Recording{{ID: 1, Title: "Renew passport"}} + v.events = []Recording{ + {ID: 2, Title: "A design review long enough to fill the day view twice over", + StartsAt: "2026-08-24T11:00:00Z", EndsAt: "2026-08-24T12:00:00Z"}, + } + v.rebuildView() + + // The grid is taller than the screen, so the to-dos would scroll out of sight if + // they were part of it. They are the last two rows of the view either way, and + // the grid above them is what gave up the room. + lines := strings.Split(stripANSI(v.View()), "\n") + if len(lines) > 20 { + t.Fatalf("view height = %d lines, want at most 20: %q", len(lines), lines) + } + if header := lines[len(lines)-2]; !strings.HasPrefix(header, todosSectionLabel) { + t.Errorf("to-dos header is not the second-to-last row: %q", header) + } + if ribbon := lines[len(lines)-1]; !strings.Contains(ribbon, "Renew passport") { + t.Errorf("to-dos ribbon is not the last row: %q", ribbon) + } + if v.contentVP.Height() != 16 { + t.Errorf("grid height = %d, want 16 with two rows given to the to-dos", v.contentVP.Height()) + } + + // The year has no to-dos under it, so the grid gets those rows back. + v.viewMode = viewYear + v.rebuildView() + if footer := v.todosFooter(); footer != "" { + t.Errorf("year view drew a to-dos footer: %q", footer) + } +} + // --- Help bindings --- func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { v := calendarWithRecordings() v.calIndex = 1 + // The calendar offers the view, the categories and the habits modal; creating, + // editing and deleting a habit are the modal's own keys. bindings := v.HelpBindings() - if len(bindings) != 6 { - t.Fatalf("expected 6 bindings, got %d", len(bindings)) + if len(bindings) != 3 { + t.Fatalf("expected 3 bindings, got %d: %+v", len(bindings), bindings) } - for _, want := range []string{"v", "c", "a", "[/]", "e", "x"} { + for _, want := range []string{"v", "c", "b"} { found := false for _, binding := range bindings { found = found || binding.key == want @@ -358,4 +582,15 @@ func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { t.Errorf("missing binding %q: %+v", want, bindings) } } + + v.HandleContentKey(keyPress("b")) + for _, want := range []string{"↑↓", "a", "e", "x", "esc"} { + found := false + for _, binding := range v.HelpBindings() { + found = found || binding.key == want + } + if !found { + t.Errorf("habits modal is missing binding %q: %+v", want, v.HelpBindings()) + } + } } diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 5615bd1b..64299cd8 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -67,12 +67,29 @@ func weekStartDate(t time.Time, firstDay time.Weekday) time.Time { // splitRecordings separates recordings into events, todos, and habits. // The API returns Type values like "CalendarEvent", "CalendarTodo", "Habit". func splitRecordings(recs []Recording) (events, todos, habits []Recording) { + // Doing a habit is a recording of its own — a `Calendar::Habit::Completion` + // carrying nothing but the habit it belongs to, since HEY records the doing rather + // than flagging the habit. So a completion marks the habit it names and is never + // listed itself: left in, it read as a habit with no name and left every habit + // looking undone. + completed := make(map[int64]string) + for _, r := range recs { + if isHabitCompletion(r.Type) { + completed[r.ParentID] = r.StartsAt + } + } + for _, r := range recs { t := strings.ToLower(r.Type) switch { + case isHabitCompletion(r.Type): + // Already folded into the habit it completes. case strings.Contains(t, "todo"): todos = append(todos, r) case strings.Contains(t, "habit"): + if done, ok := completed[r.ID]; ok { + r.CompletedAt = done + } habits = append(habits, r) default: events = append(events, r) @@ -84,6 +101,11 @@ func splitRecordings(recs []Recording) (events, todos, habits []Recording) { return } +func isHabitCompletion(recordingType string) bool { + t := strings.ToLower(recordingType) + return strings.Contains(t, "habit") && strings.Contains(t, "completion") +} + // parseEventTime parses a recording timestamp to time.Time. func parseEventTime(ts string) time.Time { if ts == "" { @@ -187,31 +209,65 @@ type placedEvent struct { lane int } -func renderDayView(events, todos, habits []Recording, _ time.Time, width, _ int) string { +// A to-do in HEY is not due at an hour, it is due around now, which is what the web app +// means by "Sometime this week". The day and the week both say it, since both are showing +// the same week's to-dos under a grid that is precise about time. +const todosSectionLabel = "Sometime this week" + +// cellKind is what one cell of the day grid holds, and so which style draws it. +type cellKind int + +const ( + cellEmpty cellKind = iota + cellRule + cellChrome + cellTitle +) + +// hourRule is dotted rather than solid so an hour's line reads as a guide behind the +// events and not as another box's border. +const hourRule = '┊' + +func renderDayView(events, habits []Recording, anchor time.Time, width, height int) string { var b strings.Builder + + // The day borrows the mail list's vocabulary: chrome for the structure a reader + // looks past — the hour axis, a box's border, a section's rule — and the bright + // bold that a subject wears for the one thing on the row they came to read. muted := styleMuted - primary := lipgloss.NewStyle().Foreground(colorPrimary) + chrome := lipgloss.NewStyle().Foreground(colorChrome) + eventTitle := lipgloss.NewStyle().Foreground(colorBright).Bold(true) - // Habits ribbon above columns if len(habits) > 0 { + b.WriteString(hintedSectionHeader("Habits", "b to manage", width)) + b.WriteString("\n") b.WriteString(renderHabitsRibbon(habits, width)) b.WriteString("\n") } - colWidth := max(width/24, 3) - gridWidth := colWidth * 24 + // A day ends where the next one begins, so the axis closes on another 00 with a + // rule under it: twenty-four hours are twenty-five lines, and the day reads as a + // span rather than as columns that stop. The two columns that last label needs are + // what the hours are sized against. + colWidth := max((width-2)/24, 3) + daySpan := colWidth * 24 + gridWidth := daySpan + 1 + + // The day names itself above its hours: the subnav carries the calendar and the + // view mode, so which day this is has nowhere else to be said. + b.WriteString(sectionHeader(anchor.Local().Format("Monday, January 2"), width)) + b.WriteString("\n") // Hour header var header strings.Builder for h := range 24 { - label := fmt.Sprintf("%02d", h) - pad := colWidth - 2 - header.WriteString(label) - if pad > 0 { + fmt.Fprintf(&header, "%02d", h) + if pad := colWidth - 2; pad > 0 { header.WriteString(strings.Repeat(" ", pad)) } } - b.WriteString(muted.Render(header.String())) + header.WriteString("00") + b.WriteString(chrome.Render(header.String())) b.WriteString("\n") // Separate timed and all-day events @@ -236,18 +292,18 @@ func renderDayView(events, todos, habits []Recording, _ time.Time, width, _ int) et = st.Add(time.Hour) } - startPos := (st.Hour()*60 + st.Minute()) * gridWidth / (24 * 60) - endPos := (et.Hour()*60 + et.Minute()) * gridWidth / (24 * 60) + startPos := (st.Hour()*60 + st.Minute()) * daySpan / (24 * 60) + endPos := (et.Hour()*60 + et.Minute()) * daySpan / (24 * 60) if et.Day() != st.Day() || (et.Hour() == 0 && et.Minute() == 0 && et.After(st)) { - endPos = gridWidth + endPos = daySpan } if endPos <= startPos { endPos = startPos + colWidth } - startPos = min(startPos, gridWidth-1) - endPos = min(endPos, gridWidth) + startPos = min(startPos, daySpan-1) + endPos = min(endPos, daySpan) if endPos-startPos < 3 { - endPos = min(startPos+3, gridWidth) + endPos = min(startPos+3, daySpan) } placed = append(placed, placedEvent{rec: e, startCol: startPos, endCol: endPos}) @@ -278,155 +334,178 @@ func renderDayView(events, todos, habits []Recording, _ time.Time, width, _ int) lanes[pe.lane] = append(lanes[pe.lane], pe) } - // Render each lane as a vertical band with boxes and rotated titles - for _, lane := range lanes { - b.WriteString(renderDayLane(lane, gridWidth, primary, muted)) + // The grid fills the room the rest of the day view leaves it, so the hours reach + // the bottom of the screen on a day with nothing on them. + spent := 2 // the day's own header and the hour axis + if len(habits) > 0 { + spent += 2 } - - if len(timed) == 0 && len(allDay) == 0 { - b.WriteString(muted.Render(" (no events)")) - b.WriteString("\n") + if len(allDay) > 0 { + spent += 1 + len(allDay) } + b.WriteString(renderDayGrid(lanes, gridWidth, colWidth, height-spent, chrome, eventTitle, muted)) // All-day events as full-width horizontal bars at the bottom if len(allDay) > 0 { - b.WriteString(muted.Render(strings.Repeat("─", width))) + b.WriteString(sectionHeader("All day", width)) b.WriteString("\n") for _, e := range allDay { - title := terminal.SanitizeLine(e.Title) innerLen := gridWidth - 2 - if len(title) > innerLen { - title = truncateStr(title, innerLen) - } - fill := max(innerLen-len(title), 0) - box := "[" + title + strings.Repeat("─", fill) + "]" - b.WriteString(primary.Render(box)) + title := truncateStr(terminal.SanitizeLine(e.Title), innerLen) + fill := max(innerLen-lipgloss.Width(title), 0) + b.WriteString(chrome.Render("[") + eventTitle.Render(title) + + chrome.Render(strings.Repeat("─", fill)+"]")) b.WriteString("\n") } } - // Todos ribbon - if len(todos) > 0 { - b.WriteString(muted.Render(strings.Repeat("─", width))) - b.WriteString("\n") - b.WriteString(renderTodosRibbon(todos, width)) - b.WriteString("\n") + // Every section here ends its own last line, so the day would otherwise carry a + // blank line the viewport counts — one row of scroll on a day that fits exactly. + return strings.TrimRight(b.String(), "\n") +} + +// renderDayGrid draws the day's hours as one canvas: the lanes of events stacked down +// it, and an hour rule falling down every hour column no event stands on. The rules are +// the grid, not a decoration on the events, so a day with nothing on it still reads as +// a day. It is never shorter than the rows it is given, and grows past them for a day +// too full to fit, which is what the viewport scrolls. +func renderDayGrid(lanes [][]placedEvent, gridWidth, colWidth, rows int, chrome, title, muted lipgloss.Style) string { + height := 0 + for _, lane := range lanes { + height += laneHeight(lane) } + height = max(height, rows, 1) - return b.String() -} + // A 2D grid of runes and a parallel note of what each cell is: the empty grid + // between events, an hour's rule, a box's own chrome, or a rune of its title. + // The four are styled separately so an event's name stands out of its border the + // way a subject stands out of the mail list's rules. + grid := make([][]rune, height) + cells := make([][]cellKind, height) + for row := range height { + grid[row] = make([]rune, gridWidth) + cells[row] = make([]cellKind, gridWidth) + for col := range gridWidth { + grid[row][col] = ' ' + } + } -// renderDayLane renders one lane of non-overlapping events as boxes with -// vertical (90-degree rotated) title text. -func renderDayLane(lane []placedEvent, gridWidth int, primary, muted lipgloss.Style) string { - if len(lane) == 0 { - return "" + offset := 0 + for _, lane := range lanes { + drawDayLane(grid, cells, lane, offset) + offset += laneHeight(lane) } - // Find the tallest title to determine band height - maxTitle := 0 - for _, pe := range lane { - if len([]rune(terminal.SanitizeLine(pe.rec.Title))) > maxTitle { - maxTitle = len([]rune(terminal.SanitizeLine(pe.rec.Title))) + // The rules go in last and only where nothing else stands: a box is drawn over an + // hour, never cut by it. + for row := range height { + for col := 0; col < gridWidth; col += colWidth { + if cells[row][col] == cellEmpty { + grid[row][col] = hourRule + cells[row][col] = cellRule + } } } - bandHeight := maxTitle + 2 // top border + title rows + bottom border - // Build a 2D grid of runes and a parallel "styled" flag - grid := make([][]rune, bandHeight) - isBox := make([][]bool, bandHeight) - for row := range bandHeight { - grid[row] = make([]rune, gridWidth) - isBox[row] = make([]bool, gridWidth) + styleFor := map[cellKind]lipgloss.Style{ + cellEmpty: muted, + cellRule: muted, + cellChrome: chrome, + cellTitle: title, + } + + // Render row by row, batching consecutive cells of the same kind + var b strings.Builder + for row := range height { + var seg strings.Builder + kind := cellEmpty + + flush := func() { + if s := seg.String(); s != "" { + b.WriteString(styleFor[kind].Render(s)) + seg.Reset() + } + } + for col := range gridWidth { - grid[row][col] = ' ' + if cells[row][col] != kind { + flush() + kind = cells[row][col] + } + seg.WriteRune(grid[row][col]) } + flush() + b.WriteString("\n") + } + + return b.String() +} + +// laneHeight is the rows a lane needs: its longest title read downwards, between the +// top and bottom borders of its boxes. +func laneHeight(lane []placedEvent) int { + longest := 0 + for _, pe := range lane { + longest = max(longest, len([]rune(terminal.SanitizeLine(pe.rec.Title)))) } + return longest + 2 +} + +// drawDayLane draws one lane of non-overlapping events into the grid at rowOffset, as +// boxes with vertical (90-degree rotated) title text. +func drawDayLane(grid [][]rune, cells [][]cellKind, lane []placedEvent, rowOffset int) { + top := rowOffset + bottom := rowOffset + laneHeight(lane) - 1 - // Draw each event box for _, pe := range lane { sc, ec := pe.startCol, pe.endCol boxW := ec - sc titleRunes := []rune(terminal.SanitizeLine(pe.rec.Title)) // Top border: ┌──┐ - grid[0][sc] = '┌' - isBox[0][sc] = true + grid[top][sc] = '┌' + cells[top][sc] = cellChrome for c := sc + 1; c < ec-1; c++ { - grid[0][c] = '─' - isBox[0][c] = true + grid[top][c] = '─' + cells[top][c] = cellChrome } if boxW > 1 { - grid[0][ec-1] = '┐' - isBox[0][ec-1] = true + grid[top][ec-1] = '┐' + cells[top][ec-1] = cellChrome } // Middle rows: │c │ (vertical title text) - for row := 1; row < bandHeight-1; row++ { + for row := top + 1; row < bottom; row++ { grid[row][sc] = '│' - isBox[row][sc] = true + cells[row][sc] = cellChrome if boxW > 1 { grid[row][ec-1] = '│' - isBox[row][ec-1] = true + cells[row][ec-1] = cellChrome } // Title character - titleIdx := row - 1 + titleIdx := row - top - 1 if titleIdx < len(titleRunes) && sc+1 < ec-1 { grid[row][sc+1] = titleRunes[titleIdx] - isBox[row][sc+1] = true + cells[row][sc+1] = cellTitle } // Fill inner space for c := sc + 2; c < ec-1; c++ { - isBox[row][c] = true + cells[row][c] = cellChrome } } // Bottom border: └──┘ - grid[bandHeight-1][sc] = '└' - isBox[bandHeight-1][sc] = true + grid[bottom][sc] = '└' + cells[bottom][sc] = cellChrome for c := sc + 1; c < ec-1; c++ { - grid[bandHeight-1][c] = '─' - isBox[bandHeight-1][c] = true + grid[bottom][c] = '─' + cells[bottom][c] = cellChrome } if boxW > 1 { - grid[bandHeight-1][ec-1] = '┘' - isBox[bandHeight-1][ec-1] = true + grid[bottom][ec-1] = '┘' + cells[bottom][ec-1] = cellChrome } } - - // Render the grid row by row, batching consecutive styled/unstyled segments - var b strings.Builder - for row := range bandHeight { - var seg strings.Builder - inStyled := false - - flush := func() { - s := seg.String() - if s == "" { - return - } - if inStyled { - b.WriteString(primary.Render(s)) - } else { - b.WriteString(muted.Render(s)) - } - seg.Reset() - } - - for col := range gridWidth { - styled := isBox[row][col] - if styled != inStyled { - flush() - inStyled = styled - } - seg.WriteRune(grid[row][col]) - } - flush() - // Trim trailing spaces - b.WriteString("\n") - } - - return b.String() } // ============================================= @@ -440,7 +519,7 @@ type weekDayInfo struct { allDay []Recording } -func renderWeekView(events, todos, habits []Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int, dayLabels map[string]string) string { +func renderWeekView(events, habits []Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int, dayLabels map[string]string) string { var b strings.Builder muted := styleMuted bright := lipgloss.NewStyle().Foreground(colorBright) @@ -542,12 +621,6 @@ func renderWeekView(events, todos, habits []Recording, anchor time.Time, firstWe b.WriteString(weekGridBorder("└", "┴", "┘", colWidth, muted)) b.WriteString("\n") - // Todos ribbon - if len(todos) > 0 { - b.WriteString(renderTodosRibbon(todos, width)) - b.WriteString("\n") - } - return b.String() } @@ -769,44 +842,54 @@ func dayLabelOrDefault(d time.Time, isFirstCol bool, dayLabels map[string]string // --- Ribbons --- +// renderHabitsRibbon is the day's habits, each wearing the ring HEY fills in when it is +// done, the color HEY gave it, and the emoji standing in for its icon. func renderHabitsRibbon(habits []Recording, width int) string { - parts := make([]string, 0, len(habits)) - for _, h := range habits { - marker := "○" - if h.CompletedAt != "" { - marker = "●" - } - parts = append(parts, marker+" "+terminal.SanitizeLine(h.Title)) - } - ribbon := strings.Join(parts, " ") - if lipgloss.Width(ribbon) > width { - runes := []rune(ribbon) - for lipgloss.Width(string(runes)) > width-1 && len(runes) > 0 { - runes = runes[:len(runes)-1] - } - ribbon = string(runes) + "…" - } - return ribbon + return renderRibbon(habits, width, func(habit Recording) (string, lipgloss.Style, string) { + return habitMarker(habit.CompletedAt != ""), habitMarkerStyle(habit.Color), habitLabel(habit) + }) } func renderTodosRibbon(todos []Recording, width int) string { - parts := make([]string, 0, len(todos)) - for _, t := range todos { - marker := "□" - if t.CompletedAt != "" { - marker = "■" - } - parts = append(parts, marker+" "+terminal.SanitizeLine(t.Title)) - } - ribbon := strings.Join(parts, " ") - if lipgloss.Width(ribbon) > width { - runes := []rune(ribbon) - for lipgloss.Width(string(runes)) > width-1 && len(runes) > 0 { - runes = runes[:len(runes)-1] + return renderRibbon(todos, width, func(todo Recording) (string, lipgloss.Style, string) { + label := terminal.SanitizeLine(todo.Title) + if todo.CompletedAt != "" { + return "■", styleMuted, label } - ribbon = string(runes) + "…" + return "□", lipgloss.NewStyle().Foreground(colorAlert).Bold(true), label + }) +} + +// renderRibbon lays out one line of markers and labels in the mail list's vocabulary: +// something still waiting wears a bright label the way an unseen thread does, and +// something done is muted the way a seen one is. The marker and the label are the +// caller's, since a habit's ring is colored by the habit and carries its icon while a +// to-do's box is colored by whether it is waiting. What is left over at the end of the +// line is an ellipsis rather than a label cut mid-word. +func renderRibbon(items []Recording, width int, describe func(Recording) (string, lipgloss.Style, string)) string { + var b strings.Builder + used := 0 + for i, item := range items { + marker, markerStyle, label := describe(item) + labelStyle := lipgloss.NewStyle().Foreground(colorBright) + if item.CompletedAt != "" { + labelStyle = styleMuted + } + + gap := "" + if i > 0 { + gap = " " + } + if used+lipgloss.Width(gap+marker+" "+label) > width { + if used < width { + b.WriteString(styleMuted.Render("…")) + } + break + } + used += lipgloss.Width(gap + marker + " " + label) + b.WriteString(gap + markerStyle.Render(marker) + " " + labelStyle.Render(label)) } - return ribbon + return b.String() } // --- Helpers --- diff --git a/internal/tui/content.go b/internal/tui/content.go index fbeca284..aaace1ea 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -556,7 +556,7 @@ func (c *contentList) view() string { if label := c.sectionLabelAt(i); label != "" { if c.cover != coverNone && sectionOf(p) == sectionPreviouslySeen { - fmt.Fprintln(&b, coverHeader(label, "x to cover", c.width)) + fmt.Fprintln(&b, hintedSectionHeader(label, "x to cover", c.width)) } else { fmt.Fprintln(&b, sectionHeader(label, c.width)) } @@ -683,7 +683,7 @@ func (c *contentList) view() string { // cover, and it is why the art can have every row the postings did not use. func (c *contentList) coverView(hidden, rowsUsed int) string { hint := fmt.Sprintf("%d hidden · x to peek", hidden) - header := coverHeader(sectionPreviouslySeen.label(), hint, c.width) + header := hintedSectionHeader(sectionPreviouslySeen.label(), hint, c.width) rows := c.height - rowsUsed - 1 if rows < coverMinRows { @@ -702,9 +702,10 @@ func sectionHeader(label string, width int) string { return s } -// coverHeader is a section label with a hint on its right, where the HEY web app -// puts the cover's buttons: "Previously Seen ──── 34 hidden · x to peek". -func coverHeader(label, hint string, width int) string { +// hintedSectionHeader is a section label with a hint on its right, where the HEY web +// app puts a section's buttons: "Previously Seen ──── 34 hidden · x to peek", or +// "Habits ──── b to manage". +func hintedSectionHeader(label, hint string, width int) string { rule := lipgloss.NewStyle().Foreground(colorChrome) fill := width - lipgloss.Width(label) - lipgloss.Width(hint) - 4 if fill < 1 { diff --git a/internal/tui/habit_form.go b/internal/tui/habit_form.go index c10d7f15..3d14acaf 100644 --- a/internal/tui/habit_form.go +++ b/internal/tui/habit_form.go @@ -23,85 +23,120 @@ const ( habitFieldIcon habitFieldColor habitFieldDays + habitFieldCount ) +var habitDayNames = []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} + +// habitForm creates and edits a habit. Only the name is typed: an icon is one of +// HEY's forty-seven, a color one of its eight and a day one of seven, so those are +// chosen with the arrow keys rather than spelled correctly from a list of accepted +// values printed underneath. type habitForm struct { - mode habitFormMode - habitID int64 - inputs []textinput.Model - focus int - status string - isError bool - saving bool - width int - styles styles + mode habitFormMode + habitID int64 + name textinput.Model + icon int + color int + days [7]bool + dayCursor int + focus int + status string + isError bool + saving bool + width int + styles styles } func newHabitForm(mode habitFormMode, recording Recording, styles styles) *habitForm { - form := &habitForm{mode: mode, habitID: recording.ID, styles: styles} - placeholders := []string{"Morning strength training", habitvalues.DefaultIcon, habitvalues.DefaultColor, "monday,wednesday,friday"} - for _, placeholder := range placeholders { - input := textinput.New() - input.Prompt = "" - input.Placeholder = placeholder - form.inputs = append(form.inputs, input) - } + name := textinput.New() + name.Prompt = "" + name.Placeholder = "Morning strength training" + + form := &habitForm{mode: mode, habitID: recording.ID, name: name, styles: styles} if mode == habitFormCreate { - form.inputs[habitFieldIcon].SetValue(habitvalues.DefaultIcon) - form.inputs[habitFieldColor].SetValue(habitvalues.DefaultColor) - form.inputs[habitFieldDays].SetValue(habitvalues.FormatDays(habitvalues.EveryDay)) + form.icon = indexOfIcon(habitvalues.DefaultIcon) + form.color = indexOfColor(habitvalues.DefaultColor) + for _, day := range habitvalues.EveryDay { + form.days[day] = true + } } else { - form.inputs[habitFieldName].SetValue(recording.Title) - form.inputs[habitFieldIcon].SetValue(recording.Icon) - form.inputs[habitFieldColor].SetValue(recording.Color) - form.inputs[habitFieldDays].SetValue(habitvalues.FormatDays(recording.Days)) + form.name.SetValue(recording.Title) + form.icon = indexOfIcon(recording.Icon) + form.color = indexOfColor(recording.Color) + for _, day := range recording.Days { + if day >= 0 && day < int32(len(form.days)) { + form.days[day] = true + } + } } return form } +// indexOfIcon and indexOfColor fall back to the first value rather than refusing a +// habit whose icon this build has not heard of: HEY can add one at any time, and +// editing such a habit's name should not require knowing its icon. +func indexOfIcon(name string) int { + for i, icon := range habitvalues.Icons { + if icon.Name == name { + return i + } + } + return 0 +} + +func indexOfColor(name string) int { + for i, color := range habitvalues.Colors { + if color == name { + return i + } + } + return 0 +} + func (f *habitForm) init() tea.Cmd { return f.focusCurrent() } +// focusCurrent puts the cursor in the name only while the name is the focused field: +// the pickers take the arrow keys, so a blinking cursor elsewhere would be a lie. func (f *habitForm) focusCurrent() tea.Cmd { - for i := range f.inputs { - f.inputs[i].Blur() + if f.focus == habitFieldName { + return f.name.Focus() } - return f.inputs[f.focus].Focus() + f.name.Blur() + return nil } +// habitFormWidth is what the form asks for. A frame hugs its widest line, and the +// widest line here is the name input, so left to fill the screen the form draws a box +// as wide as the terminal around four short fields. +const habitFormWidth = 46 + +// resize sizes the name input for the frame the form is drawn in: the frame's own +// chrome and the label column stand to the left of every field. func (f *habitForm) resize(width, _ int) { - f.width = width - for i := range f.inputs { - f.inputs[i].SetWidth(max(width-12, 10)) - } + f.width = min(modalContentWidth(width), habitFormWidth) + f.name.SetWidth(max(f.width-10, 10)) } -func (f *habitForm) values() (name, icon, color string, days []int32, err error) { - name = strings.TrimSpace(f.inputs[habitFieldName].Value()) - icon = strings.TrimSpace(f.inputs[habitFieldIcon].Value()) - color = strings.TrimSpace(f.inputs[habitFieldColor].Value()) - days, err = habitvalues.ParseDays(f.inputs[habitFieldDays].Value()) - return +func (f *habitForm) values() (name, icon, color string, days []int32) { + name = strings.TrimSpace(f.name.Value()) + icon = habitvalues.Icons[f.icon].Name + color = habitvalues.Colors[f.color] + for day, on := range f.days { + if on { + days = append(days, int32(day)) + } + } + return name, icon, color, days } func (f *habitForm) validate() string { - name, icon, color, _, err := f.values() + name, _, _, days := f.values() if name == "" { return "Name is required" } - if icon == "" { - return "Icon is required" - } - if problem := habitvalues.ValidateIcon(icon); problem != nil { - return problem.Error() - } - if color == "" { - return "Color is required" - } - if problem := habitvalues.ValidateColor(color); problem != nil { - return problem.Error() - } - if err != nil { - return err.Error() + if len(days) == 0 { + return "Pick at least one day" } return "" } @@ -112,10 +147,10 @@ func (f *habitForm) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { } switch { case msg.Key().Code == tea.KeyTab && msg.Key().Mod == tea.ModShift: - f.focus = (f.focus + len(f.inputs) - 1) % len(f.inputs) + f.focus = (f.focus + habitFieldCount - 1) % habitFieldCount return f.focusCurrent(), false case msg.Key().Code == tea.KeyTab || msg.Key().Code == tea.KeyEnter: - f.focus = (f.focus + 1) % len(f.inputs) + f.focus = (f.focus + 1) % habitFieldCount return f.focusCurrent(), false case msg.String() == "ctrl+s": if problem := f.validate(); problem != "" { @@ -128,47 +163,139 @@ func (f *habitForm) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { f.isError = false return nil, true } - return f.update(msg), false + if f.focus == habitFieldName { + return f.update(msg), false + } + f.choose(msg) + return nil, false +} + +// choose moves within the focused picker. Days are toggled rather than stepped through, +// since a habit is on any set of the seven. +func (f *habitForm) choose(msg tea.KeyPressMsg) { + switch f.focus { + case habitFieldIcon: + f.icon = wrapIndex(f.icon, len(habitvalues.Icons), msg) + case habitFieldColor: + f.color = wrapIndex(f.color, len(habitvalues.Colors), msg) + case habitFieldDays: + switch msg.String() { + case " ", "space": + f.days[f.dayCursor] = !f.days[f.dayCursor] + default: + f.dayCursor = wrapIndex(f.dayCursor, len(f.days), msg) + } + } +} + +// wrapIndex steps a picker's choice by one and comes round the other side, so the +// forty-seventh icon is one key left of the first. +func wrapIndex(index, count int, msg tea.KeyPressMsg) int { + switch msg.Key().Code { + case tea.KeyLeft, tea.KeyUp: + return (index + count - 1) % count + case tea.KeyRight, tea.KeyDown: + return (index + 1) % count + } + return index } func (f *habitForm) update(msg tea.Msg) tea.Cmd { var cmd tea.Cmd - f.inputs[f.focus], cmd = f.inputs[f.focus].Update(msg) + f.name, cmd = f.name.Update(msg) return cmd } func (f *habitForm) helpBindings() []helpBinding { - return []helpBinding{{"tab", "next field"}, {"ctrl+s", "save"}, {"esc", "cancel"}} + bindings := []helpBinding{{"tab", "next field"}} + switch f.focus { + case habitFieldIcon, habitFieldColor: + bindings = append(bindings, helpBinding{"←→", "choose"}) + case habitFieldDays: + bindings = append(bindings, helpBinding{"←→", "day"}, helpBinding{"space", "toggle"}) + } + return append(bindings, helpBinding{"ctrl+s", "save"}, helpBinding{"esc", "cancel"}) } -func (f *habitForm) view() string { - title := "Create habit" +func (f *habitForm) title() string { if f.mode == habitFormEdit { - title = "Edit habit" + return "Edit habit" } - labels := []string{"Name", "Icon", "Color", "Days"} + return "Create habit" +} + +// view is the form's body: the frame it stands in supplies the title and the border. +func (f *habitForm) view() string { var b strings.Builder - b.WriteString(f.styles.title.Render(title)) - b.WriteString("\n\n") - for i := range f.inputs { - fmt.Fprintf(&b, "%s %s\n", styleMuted.Render(fmt.Sprintf("%8s:", labels[i])), f.inputs[i].View()) - } - guidance := []string{ - "Icons: " + habitvalues.IconValues, - "Colors: " + habitvalues.ColorValues, - "Days accept weekday names or 0 (Sunday) through 6 (Saturday).", - } - for _, text := range guidance { - for _, line := range wrapText(text, max(f.width, 20)) { - b.WriteString(styleMuted.Render(line) + "\n") - } - } + f.writeField(&b, "Name", habitFieldName, f.name.View()) + f.writeField(&b, "Icon", habitFieldIcon, f.iconField()) + f.writeField(&b, "Color", habitFieldColor, f.colorField()) + f.writeField(&b, "Days", habitFieldDays, f.daysField()) + if f.status != "" { statusStyle := styleMuted if f.isError { statusStyle = lipgloss.NewStyle().Foreground(colorError) } - b.WriteString("\n\n" + statusStyle.Render(f.status)) + b.WriteString("\n" + statusStyle.Render(f.status)) + } + return strings.TrimRight(b.String(), "\n") +} + +// writeField marks the focused field's label, which is how a picker with no cursor of +// its own says that the arrow keys belong to it. +func (f *habitForm) writeField(b *strings.Builder, label string, field int, value string) { + labelStyle := styleMuted + if f.focus == field { + labelStyle = lipgloss.NewStyle().Foreground(colorActive).Bold(true) + } + fmt.Fprintf(b, "%s %s\n", labelStyle.Render(fmt.Sprintf("%6s:", label)), value) +} + +// iconField shows the chosen icon with its neighbors on either side, so stepping +// through forty-seven of them is a walk rather than a guess. The chosen one is bracketed +// rather than colored: a color emoji font paints in its own colors and ignores the +// foreground it is handed, so highlighting one by tinting it may do nothing at all. +func (f *habitForm) iconField() string { + icon := habitvalues.Icons[f.icon] + before := habitvalues.Icons[(f.icon+len(habitvalues.Icons)-1)%len(habitvalues.Icons)] + after := habitvalues.Icons[(f.icon+1)%len(habitvalues.Icons)] + bracket := lipgloss.NewStyle().Foreground(colorActive).Bold(true) + + return fmt.Sprintf("%s %s%s%s %s %s", + styleMuted.Render(before.Emoji), + bracket.Render("‹"), icon.Emoji, bracket.Render("›"), + styleMuted.Render(after.Emoji), + lipgloss.NewStyle().Foreground(colorBright).Render(icon.Name)) +} + +// colorField draws all eight, since a color is chosen by seeing it. +func (f *habitForm) colorField() string { + swatches := make([]string, 0, len(habitvalues.Colors)) + for i, name := range habitvalues.Colors { + marker := "●" + if i == f.color { + marker = "◉" + } + swatches = append(swatches, habitMarkerStyle(name).Render(marker)) + } + return strings.Join(swatches, " ") + " " + + lipgloss.NewStyle().Foreground(colorBright).Render(habitvalues.Colors[f.color]) +} + +// daysField draws the week with the chosen days filled in, the way HEY's own day +// toggles read. +func (f *habitForm) daysField() string { + names := make([]string, 0, len(habitDayNames)) + for day, label := range habitDayNames { + style := styleMuted + if f.days[day] { + style = lipgloss.NewStyle().Foreground(colorBright).Bold(true) + } + if f.focus == habitFieldDays && day == f.dayCursor { + style = style.Underline(true) + } + names = append(names, style.Render(label)) } - return b.String() + return strings.Join(names, " ") } diff --git a/internal/tui/habit_form_test.go b/internal/tui/habit_form_test.go index acc19d38..7cd1c8ab 100644 --- a/internal/tui/habit_form_test.go +++ b/internal/tui/habit_form_test.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "testing" + "time" tea "charm.land/bubbletea/v2" @@ -16,10 +17,33 @@ import ( habitvalues "github.com/basecamp/hey-cli/internal/habit" ) +// openHabits opens the habits modal, which is where a habit is created, edited and +// deleted: the calendar's own keys stayed with the calendar. +func openHabits(t *testing.T, view *calendarView) { + t.Helper() + view.HandleContentKey(keyPress("b")) + if view.habitPicker == nil { + t.Fatal("b did not open the habits modal") + } +} + +// fillHabitForm sets what the form's pickers would be left on, which is the state the +// mutation reads — the keys that get there are covered by the picker test. +func fillHabitForm(form *habitForm, name, icon, color string, days ...int32) { + form.name.SetValue(name) + form.icon = indexOfIcon(icon) + form.color = indexOfColor(color) + form.days = [7]bool{} + for _, day := range days { + form.days[day] = true + } +} + func TestHabitFormValidationAndKeyRouting(t *testing.T) { view := newCalendarView(testVC()) view.calendars = []Calendar{{ID: 10, Name: "Rob Zolkos", Personal: true}} view.Resize(80, 30) + openHabits(t, view) if cmd := view.HandleContentKey(keyPress("a")); cmd == nil || view.habitForm == nil || !view.CapturingInput() { t.Fatal("a should open and focus the habit form") } @@ -28,45 +52,83 @@ func TestHabitFormValidationAndKeyRouting(t *testing.T) { t.Errorf("empty save status = %q, saving=%v", view.habitForm.status, view.habitForm.saving) } view.HandleContentKey(keyPress("R")) - if got := view.habitForm.inputs[habitFieldName].Value(); got != "R" { + if got := view.habitForm.name.Value(); got != "R" { t.Errorf("form key was not routed to name input: %q", got) } - view.habitForm.inputs[habitFieldName].SetValue("Read before bed") - view.habitForm.inputs[habitFieldIcon].SetValue("walking") - view.HandleContentKey(keyPress("ctrl+s")) - if !strings.Contains(view.habitForm.status, "icon must be one of") { - t.Errorf("invalid icon status = %q", view.habitForm.status) - } - view.habitForm.inputs[habitFieldIcon].SetValue("read") - view.habitForm.inputs[habitFieldColor].SetValue("orange") + + // A new habit is on every day, so clearing all seven is the only way to reach the + // days error — an icon or a color cannot be wrong now that neither is typed. + view.habitForm.name.SetValue("Read before bed") + view.habitForm.days = [7]bool{} view.HandleContentKey(keyPress("ctrl+s")) - if !strings.Contains(view.habitForm.status, "color must be one of") { - t.Errorf("invalid color status = %q", view.habitForm.status) + if view.habitForm.status != "Pick at least one day" { + t.Errorf("no-days status = %q", view.habitForm.status) } - view.habitForm.inputs[habitFieldColor].SetValue("blue") - view.habitForm.inputs[habitFieldDays].SetValue("Monday, someday") - view.HandleContentKey(keyPress("ctrl+s")) - if !strings.Contains(view.habitForm.status, "invalid weekday") { - t.Errorf("invalid days status = %q", view.habitForm.status) + + // Escape steps back to the habits modal the form was opened from, and again out of + // the modal to the calendar. + view.HandleContentKey(keyPress("esc")) + if view.habitForm != nil || view.habitPicker == nil { + t.Error("escape should close a form that is not saving and leave the habits modal") } view.HandleContentKey(keyPress("esc")) - if view.habitForm != nil || view.CapturingInput() { - t.Error("escape should close a form that is not saving") + if view.habitPicker != nil || view.CapturingInput() { + t.Error("escape should close the habits modal") } } -func TestHabitFormGuidanceListsAcceptedIconsAndColors(t *testing.T) { +func TestHabitFormPickersStepThroughHEYsOwnValues(t *testing.T) { form := newHabitForm(habitFormCreate, Recording{}, testVC().styles) - form.resize(50, 30) - rendered := form.view() - for _, value := range strings.Split(habitvalues.IconValues, ", ") { - if !strings.Contains(rendered, value) { - t.Errorf("form guidance is missing icon %q", value) - } + form.resize(60, 30) + + // A new habit starts on HEY's defaults, on every day. + name, icon, color, days := form.values() + if name != "" || icon != habitvalues.DefaultIcon || color != habitvalues.DefaultColor { + t.Errorf("new habit = name:%q icon:%q color:%q", name, icon, color) + } + if len(days) != 7 { + t.Errorf("new habit days = %v, want every day", days) + } + + // The icon picker walks HEY's list and comes round the other side. + form.focus = habitFieldIcon + form.choose(keyPress("left")) + if _, icon, _, _ := form.values(); icon != habitvalues.Icons[len(habitvalues.Icons)-1].Name { + t.Errorf("stepping back from the first icon = %q", icon) + } + form.choose(keyPress("right")) + if _, icon, _, _ := form.values(); icon != habitvalues.DefaultIcon { + t.Errorf("stepping forward again = %q", icon) + } + + form.focus = habitFieldColor + form.choose(keyPress("right")) + if _, _, color, _ := form.values(); color != habitvalues.Colors[1] { + t.Errorf("next color = %q, want %q", color, habitvalues.Colors[1]) + } + + // Days are toggled where they sit rather than stepped through, since a habit is on + // any set of the seven. + form.focus = habitFieldDays + form.choose(keyPress("right")) + form.choose(keyPress(" ")) + if _, _, _, days := form.values(); len(days) != 6 || days[0] != 0 || days[1] != 2 { + t.Errorf("days after clearing Monday = %v", days) } - for _, value := range strings.Split(habitvalues.ColorValues, ", ") { - if !strings.Contains(rendered, value) { - t.Errorf("form guidance is missing color %q", value) + + // The chosen icon shows as its emoji, named, since HEY's own icon is an SVG a + // terminal cannot draw. + rendered := stripANSI(form.view()) + if !strings.Contains(rendered, habitvalues.EmojiFor(habitvalues.DefaultIcon)) || + !strings.Contains(rendered, habitvalues.DefaultIcon) { + t.Errorf("icon field does not show the emoji and the name: %q", rendered) + } + if strings.Contains(rendered, habitvalues.IconValues) { + t.Errorf("form still prints the whole list of accepted icons: %q", rendered) + } + for _, label := range habitDayNames { + if !strings.Contains(rendered, label) { + t.Errorf("days field is missing %q: %q", label, rendered) } } } @@ -74,17 +136,14 @@ func TestHabitFormGuidanceListsAcceptedIconsAndColors(t *testing.T) { func TestCalendarHabitCreateRequiresPersonalCalendarMetadata(t *testing.T) { view := newCalendarView(testVC()) view.calendars = []Calendar{{ID: 10, Name: "Personal", Personal: false}} + view.habits = []Recording{{ID: 7, Title: "Read before bed"}} + openHabits(t, view) - for _, binding := range view.HelpBindings() { - if binding.key == "a" { - t.Errorf("non-personal calendar offers create: %v", view.HelpBindings()) - } - } if cmd := view.HandleContentKey(keyPress("a")); cmd != nil || view.habitForm != nil { t.Fatalf("non-personal create = cmd:%v form:%v", cmd, view.habitForm) } - if view.notice != "Habits can only be created from the personal calendar" { - t.Errorf("notice = %q", view.notice) + if view.habitPicker.status != "Habits can only be created from the personal calendar" { + t.Errorf("status = %q", view.habitPicker.status) } } @@ -95,22 +154,24 @@ func TestCalendarHabitSelectionAndEditPrefill(t *testing.T) { {ID: 8, Title: "Evening walk", Icon: "walk", Color: "green", Days: []int32{0, 6}}, {ID: 7, Title: "Read before bed"}, } - if selected := view.selectedHabit(); selected == nil || selected.ID != 7 { + openHabits(t, view) + if selected := view.habitPicker.selected(); selected == nil || selected.ID != 7 { t.Fatalf("initial selection = %+v", selected) } - view.HandleContentKey(keyPress("]")) - if selected := view.selectedHabit(); selected == nil || selected.ID != 8 { + view.HandleContentKey(keyPress("down")) + if selected := view.habitPicker.selected(); selected == nil || selected.ID != 8 { t.Fatalf("next selection = %+v", selected) } view.HandleContentKey(keyPress("e")) if view.habitForm == nil || view.habitForm.mode != habitFormEdit || view.habitForm.habitID != 8 { t.Fatal("e should edit the selected visible habit") } - if got := view.habitForm.inputs[habitFieldName].Value(); got != "Evening walk" { - t.Errorf("prefilled name = %q", got) + name, icon, color, days := view.habitForm.values() + if name != "Evening walk" || icon != "walk" || color != "green" { + t.Errorf("prefilled = name:%q icon:%q color:%q", name, icon, color) } - if got := view.habitForm.inputs[habitFieldDays].Value(); got != "0,6" { - t.Errorf("prefilled days = %q", got) + if len(days) != 2 || days[0] != 0 || days[1] != 6 { + t.Errorf("prefilled days = %v, want Sunday and Saturday", days) } } @@ -148,6 +209,11 @@ func calendarHabitsWithServer(t *testing.T) (*calendarView, *recordedHabitReques _, _ = io.WriteString(w, `{"id":7,"title":"Read every evening","type":"CalendarHabit","icon":"read","color":"purple","days":[0,6]}`) case req.Method == http.MethodDelete && req.URL.Path == "/calendar/habits/7.json": w.WriteHeader(http.StatusNoContent) + case req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/habits/7/completions.json"): + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"id":99,"type":"Calendar::Habit::Completion","parent_id":7}`) + case req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/habits/7/completions.json"): + w.WriteHeader(http.StatusNoContent) case req.Method == http.MethodGet && req.URL.Path == "/calendars/10/recordings.json": _, _ = io.WriteString(w, `{"Calendar::Habit":[{"id":7,"title":"Read before bed","type":"CalendarHabit","icon":"read","color":"blue","days":[1,3,5]}]}`) default: @@ -163,6 +229,8 @@ func calendarHabitsWithServer(t *testing.T) (*calendarView, *recordedHabitReques view.calendars = []Calendar{{ID: 10, Name: "Rob Zolkos", Personal: true}} view.habits = []Recording{{ID: 7, Title: "Read before bed", Icon: "read", Color: "blue", Days: []int32{1, 3, 5}}} view.Resize(vc.width, vc.height) + // Every habit mutation starts from the habits modal, so these tests open it too. + view.HandleContentKey(keyPress("b")) return view, recorded } @@ -182,40 +250,42 @@ func calendarHabitsWithFailingServer(t *testing.T, status int) *calendarView { view.calendars = []Calendar{{ID: 10, Name: "Rob Zolkos", Personal: true}} view.habits = []Recording{{ID: 7, Title: "Read before bed", Icon: "read", Color: "blue", Days: []int32{1, 3, 5}}} view.Resize(vc.width, vc.height) + view.HandleContentKey(keyPress("b")) return view } -func finishHabitMutation(t *testing.T, view *calendarView, cmd tea.Cmd) { +// finishHabitMutation settles a habit mutation and answers the toast it raised. What a +// mutation answers with is a batch — the toast, and the day read again behind it — so +// this walks the batch the way the runtime does, handing the view everything but the +// toast, which belongs to the model. +func finishHabitMutation(t *testing.T, view *calendarView, cmd tea.Cmd) string { t.Helper() msg := cmd() mutation, ok := msg.(habitMutationMsg) if !ok { t.Fatalf("mutation command returned %T", msg) } - refresh, consumed := view.Update(mutation) - if !consumed || refresh == nil { - t.Fatalf("mutation update = consumed:%v refresh:%v", consumed, refresh) + answer, consumed := view.Update(mutation) + if !consumed || answer == nil { + t.Fatalf("mutation update = consumed:%v answer:%v", consumed, answer) } - view.Update(refresh()) + toast := deliverToView(view, answer) if view.requests.loading || view.requests.kind != calendarRequestNone { t.Errorf("mutation did not finish: loading=%v kind=%v", view.requests.loading, view.requests.kind) } + return toast } func TestCalendarHabitCreateMutationAndRefresh(t *testing.T) { view, recorded := calendarHabitsWithServer(t) view.HandleContentKey(keyPress("a")) - view.habitForm.inputs[habitFieldName].SetValue("Practice piano") - view.habitForm.inputs[habitFieldIcon].SetValue("music") - view.habitForm.inputs[habitFieldColor].SetValue("green") - view.habitForm.inputs[habitFieldDays].SetValue("Mon,Wed,Fri") + fillHabitForm(view.habitForm, "Practice piano", "music", "green", 1, 3, 5) cmd := view.HandleContentKey(keyPress("ctrl+s")) if cmd == nil || view.requests.kind != calendarRequestHabitMutation { t.Fatal("ctrl+s should start habit creation") } - finishHabitMutation(t, view, cmd) - if view.notice != "Habit created" || view.habitForm != nil { - t.Errorf("create state = notice:%q form:%v", view.notice, view.habitForm) + if toast := finishHabitMutation(t, view, cmd); toast != "Habit created" || view.habitForm != nil { + t.Errorf("create state = toast:%q form:%v", toast, view.habitForm) } requests, bodies := recorded.snapshot() if len(requests) < 2 || requests[0] != "POST /calendar/habits.json" || requests[1] != "GET /calendars/10/recordings.json" { @@ -233,13 +303,10 @@ func TestCalendarHabitCreateMutationAndRefresh(t *testing.T) { func TestCalendarHabitEditMutationAndRefresh(t *testing.T) { view, recorded := calendarHabitsWithServer(t) view.HandleContentKey(keyPress("e")) - view.habitForm.inputs[habitFieldName].SetValue("Read every evening") - view.habitForm.inputs[habitFieldColor].SetValue("purple") - view.habitForm.inputs[habitFieldDays].SetValue("0,6") + fillHabitForm(view.habitForm, "Read every evening", "read", "purple", 0, 6) cmd := view.HandleContentKey(keyPress("ctrl+s")) - finishHabitMutation(t, view, cmd) - if view.notice != "Habit updated" { - t.Errorf("notice = %q", view.notice) + if toast := finishHabitMutation(t, view, cmd); toast != "Habit updated" { + t.Errorf("toast = %q", toast) } requests, _ := recorded.snapshot() if len(requests) < 2 || requests[0] != "PATCH /calendar/habits/7.json" || requests[1] != "GET /calendars/10/recordings.json" { @@ -260,10 +327,7 @@ func TestCalendarHabitSaveFailuresUnlockAndPreserveFormValues(t *testing.T) { t.Run(tt.name, func(t *testing.T) { view := calendarHabitsWithFailingServer(t, tt.status) tt.open(view) - values := []string{"Practice piano", "piano", "gold", "Mon,Wed,Fri"} - for i, value := range values { - view.habitForm.inputs[i].SetValue(value) - } + fillHabitForm(view.habitForm, "Practice piano", "piano", "gold", 1, 3, 5) cmd := view.HandleContentKey(keyPress("ctrl+s")) if cmd == nil { @@ -279,68 +343,105 @@ func TestCalendarHabitSaveFailuresUnlockAndPreserveFormValues(t *testing.T) { if !strings.Contains(view.habitForm.status, "Save failed") { t.Errorf("status = %q", view.habitForm.status) } - for i, want := range values { - if got := view.habitForm.inputs[i].Value(); got != want { - t.Errorf("field %d after failure = %q, want %q", i, got, want) - } + name, icon, color, days := view.habitForm.values() + if name != "Practice piano" || icon != "piano" || color != "gold" || len(days) != 3 { + t.Errorf("form after failure = name:%q icon:%q color:%q days:%v", name, icon, color, days) } }) } } +func TestCalendarHabitEnterCompletesAndClearsForTheDayOnScreen(t *testing.T) { + view, recorded := calendarHabitsWithServer(t) + view.now = func() time.Time { return time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) } + + cmd := view.HandleContentKey(keyPress("enter")) + if cmd == nil || view.requests.kind != calendarRequestHabitMutation { + t.Fatal("enter should complete the selected habit") + } + // Ticking a habit off reads the day again behind the modal, and a spinner for that + // is a flash of nothing where the day used to be. + if !view.requests.loading || view.Loading() { + t.Errorf("completing a habit claimed the spinner: loading=%v spinner=%v", view.requests.loading, view.Loading()) + } + if toast := finishHabitMutation(t, view, cmd); toast != "Habit done for today" { + t.Errorf("toast = %q", toast) + } + requests, _ := recorded.snapshot() + if requests[0] != "POST /calendar/days/2026-08-22/habits/7/completions.json" { + t.Errorf("completion request = %q", requests[0]) + } + + // A habit already done for the day is cleared by the same key. + view.habitPicker.setHabits([]Recording{{ID: 7, Title: "Read before bed", CompletedAt: "2026-08-22T00:00:00Z"}}) + cmd = view.HandleContentKey(keyPress("enter")) + if cmd == nil { + t.Fatal("enter should clear a habit that is already done") + } + if toast := finishHabitMutation(t, view, cmd); toast != "Habit cleared for today" { + t.Errorf("toast = %q", toast) + } + requests, _ = recorded.snapshot() + if got := requests[len(requests)-2]; got != "DELETE /calendar/days/2026-08-22/habits/7/completions.json" { + t.Errorf("clearing request = %q", got) + } +} + func TestCalendarHabitDeleteFailurePreservesConfirmationAndSelection(t *testing.T) { view := calendarHabitsWithFailingServer(t, http.StatusUnprocessableEntity) - selected := view.selectedHabit() + picker := view.habitPicker + selected := picker.selected() view.HandleContentKey(keyPress("x")) cmd := view.HandleContentKey(keyPress("x")) if cmd == nil { t.Fatal("confirmed delete did not return a mutation command") } - refresh, consumed := view.Update(cmd()) - if !consumed || refresh != nil { - t.Fatalf("failed delete update = consumed:%v refresh:%v", consumed, refresh) + answer, consumed := view.Update(cmd()) + if !consumed || answer == nil { + t.Fatalf("failed delete update = consumed:%v answer:%v", consumed, answer) } - if !view.habitDeleteConfirmed() || view.requests.loading || view.requests.kind != calendarRequestNone { - t.Errorf("failed delete state = confirmed ID:%d kind:%v loading:%v", view.confirmedHabitDeleteID, view.requests.kind, view.requests.loading) + if toast := deliverToView(view, answer); !strings.Contains(toast, "Delete failed") { + t.Errorf("toast = %q", toast) } - if current := view.selectedHabit(); current == nil || selected == nil || current.ID != selected.ID || view.habitIndex != 0 { - t.Errorf("selection changed after delete failure: before=%+v after=%+v index=%d", selected, current, view.habitIndex) + if picker.confirmed != selected.ID || view.requests.loading || view.requests.kind != calendarRequestNone { + t.Errorf("failed delete state = confirmed ID:%d kind:%v loading:%v", picker.confirmed, view.requests.kind, view.requests.loading) } - if !strings.Contains(view.notice, "Delete failed") { - t.Errorf("notice = %q", view.notice) + if current := picker.selected(); current == nil || current.ID != selected.ID || picker.cursor != 0 { + t.Errorf("selection changed after delete failure: before=%+v after=%+v cursor=%d", selected, current, picker.cursor) } } func TestCalendarHabitDeleteConfirmationIsBoundToSelectedHabit(t *testing.T) { view, _ := calendarHabitsWithServer(t) + picker := view.habitPicker view.HandleContentKey(keyPress("x")) - if view.confirmedHabitDeleteID != 7 { - t.Fatalf("confirmed habit ID = %d, want 7", view.confirmedHabitDeleteID) + if picker.confirmed != 7 { + t.Fatalf("confirmed habit ID = %d, want 7", picker.confirmed) } view.Update(recordingsLoadedMsg{recordings: []Recording{{ ID: 8, Title: "Evening walk", Type: "CalendarHabit", Icon: "walk", Color: "gold", Days: []int32{1, 3, 5}, }}}) - if view.confirmedHabitDeleteID != 0 { - t.Fatalf("recordings reload preserved confirmed habit ID %d", view.confirmedHabitDeleteID) + if picker.confirmed != 0 { + t.Fatalf("recordings reload preserved confirmed habit ID %d", picker.confirmed) } - if cmd := view.HandleContentKey(keyPress("x")); cmd != nil || view.confirmedHabitDeleteID != 8 { - t.Fatalf("first x for reloaded habit = cmd:%v confirmed ID:%d", cmd, view.confirmedHabitDeleteID) + if cmd := view.HandleContentKey(keyPress("x")); cmd != nil || picker.confirmed != 8 { + t.Fatalf("first x for reloaded habit = cmd:%v confirmed ID:%d", cmd, picker.confirmed) } } func TestCalendarHabitDeleteRequiresConfirmationAndRefresh(t *testing.T) { view, recorded := calendarHabitsWithServer(t) - if cmd := view.HandleContentKey(keyPress("x")); cmd != nil || !view.habitDeleteConfirmed() || !strings.Contains(view.notice, "Press x again") { - t.Fatalf("first x = cmd:%v confirmed ID:%d notice:%q", cmd, view.confirmedHabitDeleteID, view.notice) + picker := view.habitPicker + if cmd := view.HandleContentKey(keyPress("x")); cmd != nil || picker.confirmed != 7 || !strings.Contains(picker.status, "Press x again") { + t.Fatalf("first x = cmd:%v confirmed ID:%d status:%q", cmd, picker.confirmed, picker.status) } cmd := view.HandleContentKey(keyPress("x")) if cmd == nil || view.requests.kind != calendarRequestHabitMutation { t.Fatal("second x should start deletion") } - finishHabitMutation(t, view, cmd) - if view.notice != "Habit deleted" || view.confirmedHabitDeleteID != 0 { - t.Errorf("delete state = notice:%q confirmed ID:%d", view.notice, view.confirmedHabitDeleteID) + if toast := finishHabitMutation(t, view, cmd); toast != "Habit deleted" || picker.confirmed != 0 { + t.Errorf("delete state = toast:%q confirmed ID:%d", toast, picker.confirmed) } requests, _ := recorded.snapshot() if len(requests) < 2 || requests[0] != "DELETE /calendar/habits/7.json" || requests[1] != "GET /calendars/10/recordings.json" { diff --git a/internal/tui/habits.go b/internal/tui/habits.go new file mode 100644 index 00000000..8e862cd1 --- /dev/null +++ b/internal/tui/habits.go @@ -0,0 +1,158 @@ +package tui + +import ( + "image/color" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + habitvalues "github.com/basecamp/hey-cli/internal/habit" + "github.com/basecamp/hey-cli/internal/terminal" +) + +// habitColors stands HEY's eight habit colors up as ANSI slots, for the reason +// styles.go and covers.go give: the reader's terminal theme defines those sixteen, so a +// habit wears its own color in the reader's palette rather than HEY's hex. Gold takes +// the bright yellow and brown the plain one, which is what a dark yellow looks like in +// every theme. +var habitColors = map[string]color.Color{ + "blue": lipgloss.Blue, + "red": lipgloss.Red, + "gold": lipgloss.BrightYellow, + "green": lipgloss.Green, + "teal": lipgloss.Cyan, + "purple": lipgloss.Magenta, + "pink": lipgloss.BrightMagenta, + "brown": lipgloss.Yellow, +} + +// habitMarkerStyle is the style for a habit's ring: its own color where HEY gave it +// one, and the alert red every other waiting thing wears where it did not. +func habitMarkerStyle(habitColor string) lipgloss.Style { + if slot, ok := habitColors[habitColor]; ok { + return lipgloss.NewStyle().Foreground(slot).Bold(true) + } + return lipgloss.NewStyle().Foreground(colorAlert).Bold(true) +} + +// habitMarker is the ring HEY fills in when a habit is done for the day. +func habitMarker(done bool) string { + if done { + return "●" + } + return "○" +} + +// habitLabel is a habit as it reads in a list: its icon's emoji, then its name. +func habitLabel(habit Recording) string { + name := terminal.SanitizeLine(habit.Title) + if emoji := habitvalues.EmojiFor(habit.Icon); emoji != "" { + return emoji + " " + name + } + return name +} + +// habitPicker is the habits of the day, opened over the calendar with h. Habits are +// managed from here rather than from the calendar's own keys: a habit is picked by +// looking at the list, and the calendar keeps its keys for the calendar. +type habitPicker struct { + habits []Recording + cursor int + confirmed int64 // the habit whose deletion has been asked for once + status string +} + +func newHabitPicker(habits []Recording) *habitPicker { + return &habitPicker{habits: habits} +} + +// setHabits keeps the cursor on the habit it was on, which is what a save or a delete +// leaves behind: the list is read again and comes back a habit shorter or renamed. +func (p *habitPicker) setHabits(habits []Recording) { + var onID int64 + if selected := p.selected(); selected != nil { + onID = selected.ID + } + p.habits = habits + p.cursor = min(p.cursor, max(len(habits)-1, 0)) + for i, habit := range habits { + if habit.ID == onID { + p.cursor = i + break + } + } + p.confirmed = 0 +} + +func (p *habitPicker) selected() *Recording { + if p.cursor < 0 || p.cursor >= len(p.habits) { + return nil + } + return &p.habits[p.cursor] +} + +func (p *habitPicker) moveCursor(msg tea.KeyPressMsg) { + p.cursor = stepListCursor(p.cursor, len(p.habits), msg) + p.confirmed = 0 +} + +// draw puts the picker over the calendar it was opened from. Its rows carry each +// habit's own color, so it lays them out itself rather than handing plain names to +// framedList. +func (p *habitPicker) draw(base string, width, height int) string { + contentWidth := modalContentWidth(width) + visible := modalContentRows(height) + if p.status != "" { + visible = max(visible-2, 1) + } + + var rows []string + start, end := modalListWindow(len(p.habits), p.cursor, visible) + for i := start; i < end; i++ { + habit := p.habits[i] + done := habit.CompletedAt != "" + marker := habitMarkerStyle(habit.Color).Render(habitMarker(done)) + + label := truncateToWidth(habitLabel(habit), max(contentWidth-4, 1)) + labelStyle := lipgloss.NewStyle().Foreground(colorBright) + prefix := " " + if done { + labelStyle = styleMuted + } + if i == p.cursor { + labelStyle = lipgloss.NewStyle().Foreground(colorActive).Bold(true) + prefix = "› " + } + rows = append(rows, prefix+marker+" "+labelStyle.Render(label)) + } + + body := strings.Join(rows, "\n") + if len(p.habits) == 0 { + body = styleMuted.Render("No habits yet") + } + if p.status != "" { + body += "\n\n" + styleMuted.Render(truncateToWidth(terminal.SanitizeLine(p.status), contentWidth)) + } + return overlayModal(base, modalFrame("Habits", body, width), width, height) +} + +func (p *habitPicker) helpBindings() []helpBinding { + bindings := []helpBinding{{"↑↓", "choose"}} + if selected := p.selected(); selected != nil { + doneLabel := "mark done" + if selected.CompletedAt != "" { + doneLabel = "clear" + } + deleteLabel := "delete" + if p.confirmed == selected.ID { + deleteLabel = "press x again to delete" + } + bindings = append(bindings, + helpBinding{"enter", doneLabel}, + helpBinding{"e", "edit"}, + helpBinding{"x", deleteLabel}) + } + bindings = append(bindings, helpBinding{"a", "new habit"}) + return append(bindings, helpBinding{"esc", "close"}) +} diff --git a/internal/tui/time_track_categories_test.go b/internal/tui/time_track_categories_test.go index 5f5da10d..e8e17856 100644 --- a/internal/tui/time_track_categories_test.go +++ b/internal/tui/time_track_categories_test.go @@ -71,11 +71,16 @@ func finishTimeTrackCategoryMutation(t *testing.T, view *calendarView, cmd tea.C func TestCalendarTimeTrackCategoriesLoadAndClose(t *testing.T) { view, requests := timeTrackCategoryView(t) cmd := view.HandleContentKey(keyPress("c")) - if cmd == nil || !view.CapturingInput() || !view.Loading() { + if cmd == nil || !view.CapturingInput() || !view.requests.loading { t.Fatal("c should open and load the time track category manager") } + // A modal is what the reader is looking at, so a read behind it does not put the + // spinner over the calendar. + if view.Loading() { + t.Error("a read with the manager open claimed the spinner") + } _, consumed := view.Update(cmd()) - if !consumed || view.Loading() { + if !consumed || view.requests.loading { t.Fatal("category response should finish loading") } if got := plainText(view.View()); !strings.Contains(got, "Client work") || !strings.Contains(got, "Planning") { @@ -104,7 +109,7 @@ func TestCalendarTimeTrackCategoriesBlockKeysWhileLoading(t *testing.T) { view.HandleContentKey(keyPress("n")) view.timeTrackCategories.input.SetValue("Research") create := view.HandleContentKey(keyPress("enter")) - if create == nil || !view.Loading() { + if create == nil || !view.requests.loading { t.Fatal("saving a category should enter a loading state") } view.HandleContentKey(keyPress("n")) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 07c85ac2..9aa045f5 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -86,6 +86,8 @@ func keyPress(key string) tea.KeyPressMsg { k = tea.Key{Code: tea.KeyUp} case "down": k = tea.Key{Code: tea.KeyDown} + case " ", "space": + k = tea.Key{Code: tea.KeySpace, Text: " "} } return tea.KeyPressMsg(k) } From 9ad3de74944fbe938bc3ca8435f7dbaac618b0f8 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 11:48:18 +0200 Subject: [PATCH 05/19] Move the calendar off today with the arrows, and back with t MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar only ever showed today. ← and → — or p and n — now move it by its own unit: a day in the day view, a week in the week view, a year in the year view, since "the one before this" means whatever the view is showing. t comes back. Today is the zero value rather than today's date, so t is a reset and a view sitting on today keeps following the clock past midnight, which is what reading it on every fetch was for. The keys that move the day are said on the line that names it, where the cover puts "x to peek", and t joins them once it would do something; the week and the year have no such line, so the help bar still carries theirs. Only the first read puts the spinner over the calendar now. Once a day has been drawn, a step to the day either side keeps it on screen until the answer lands — the way the mail list keeps its list while it reads the page below — instead of blanking the screen on every arrow press. Ticking a habit off also lands on the day being looked at rather than on today, which is what its own comment already claimed. --- internal/tui/calendar.go | 111 ++++++++++++++++++++++++++++----- internal/tui/calendar_test.go | 96 ++++++++++++++++++++++++++-- internal/tui/calendar_views.go | 22 +++++-- 3 files changed, 204 insertions(+), 25 deletions(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 9651944a..288a25ba 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -102,6 +102,11 @@ type calendarView struct { // fetching around the day it started on while the grid highlights today. now func() time.Time + // anchor is the day the reader has moved to, and zero for today. Following the + // clock is the zero value on purpose: t puts the view back by clearing this, and + // a view left on today then keeps up with the clock overnight. + anchor time.Time + // Recordings split by type events []Recording todos []Recording @@ -110,6 +115,10 @@ type calendarView struct { // Scrollable content viewport for the calendar views contentVP viewport.Model + // drawn is whether a day has ever reached the screen, which is what the spinner + // waits for. See Loading. + drawn bool + timeTrackCategories *timeTrackCategoryManager habitPicker *habitPicker habitForm *habitForm @@ -273,7 +282,18 @@ func (v *calendarView) HelpBindings() []helpBinding { if v.habitPicker != nil { return v.habitPicker.helpBindings() } - bindings := []helpBinding{{"v", v.viewMode.next().String() + " view"}, {"c", "time categories"}} + // The day says which keys move it on the line that names it. The week and the year + // have no such line, so the help bar carries it for them. + var bindings []helpBinding + if v.viewMode != viewDay { + bindings = append(bindings, helpBinding{"←→", v.viewMode.unit()}) + if !v.onToday() { + bindings = append(bindings, helpBinding{"t", "today"}) + } + } + bindings = append(bindings, + helpBinding{"v", v.viewMode.next().String() + " view"}, + helpBinding{"c", "time categories"}) if v.showsHabits() { bindings = append(bindings, helpBinding{"b", "habits"}) } @@ -344,11 +364,13 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return v.requestTimeTrackCategories() case "v": v.viewMode = v.viewMode.next() - if v.calIndex >= 0 && v.calIndex < len(v.calendars) { - return v.requestRecordings(v.calendars[v.calIndex].ID) - } - v.rebuildView() - return nil + return v.reread() + case "left", "p": + return v.step(-1) + case "right", "n": + return v.step(1) + case "t": + return v.today() } // Delegate scrolling to the content viewport @@ -467,12 +489,13 @@ func (v *calendarView) handleTimeTrackCategoryKey(msg tea.KeyPressMsg) tea.Cmd { func (v *calendarView) InThread() bool { return false } func (v *calendarView) ExitThread() {} -// Loading is what puts the spinner over the content, so a read with a modal open does -// not claim it: the reader is looking at the modal, and the calendar behind it can keep -// the day it was showing until the new one arrives. Ticking a habit off reads the day -// again, and a spinner for that is a flash of nothing where the day used to be. +// Loading is what puts the spinner over the content, and only the first read claims it. +// Once a day has been drawn, every later read — a step to the day either side, a habit +// ticked off, another calendar — keeps what is on screen until its answer lands, the way +// the mail list keeps its list while it reads the page below. A modal is the reader's +// focus, so a read behind one never claims the spinner either. func (v *calendarView) Loading() bool { - return v.requests.loading && !v.CapturingInput() + return v.requests.loading && !v.CapturingInput() && !v.drawn } func (v *calendarView) CapturingInput() bool { return v.timeTrackCategories != nil || v.habitForm != nil || v.habitPicker != nil @@ -505,7 +528,7 @@ func (v *calendarView) rebuildView() { return } - anchor := v.now() + anchor := v.day() dayLabels := dayLabelsFromRecordings(v.events, v.todos, v.habits) // The grid scrolls; the week's to-dos do not, so the viewport gives up the rows @@ -518,7 +541,7 @@ func (v *calendarView) rebuildView() { var content string switch v.viewMode { case viewDay: - content = renderDayView(v.events, v.habits, anchor, w, v.contentVP.Height()) + content = renderDayView(v.events, v.habits, anchor, v.stepHint(), w, v.contentVP.Height()) case viewWeek: content = renderWeekView(v.events, v.habits, anchor, v.firstWeekDay, w, h, dayLabels) case viewYear: @@ -526,6 +549,7 @@ func (v *calendarView) rebuildView() { } v.contentVP.SetContent(content) + v.drawn = true // For year view, scroll to the current week if v.viewMode == viewYear { @@ -539,6 +563,63 @@ func (v *calendarView) rebuildView() { } } +// day is the day the view is on: today, until the reader steps off it. +func (v *calendarView) day() time.Time { + if v.anchor.IsZero() { + return v.now() + } + return v.anchor +} + +func (v *calendarView) onToday() bool { + return v.anchor.IsZero() +} + +// stepHint is the keys that move the view, said on the line that names the day rather +// than in the help bar: they belong to the date they act on. t is only mentioned once it +// would do something. +func (v *calendarView) stepHint() string { + hint := "←→ " + v.viewMode.unit() + if !v.onToday() { + hint += " · t today" + } + return hint +} + +// step moves the view by its own unit — a day, a week or a year — since ← and → mean +// "the one before this" whatever the view is showing. +func (v *calendarView) step(delta int) tea.Cmd { + switch v.viewMode { + case viewWeek: + v.anchor = v.day().AddDate(0, 0, 7*delta) + case viewYear: + v.anchor = v.day().AddDate(delta, 0, 0) + default: + v.anchor = v.day().AddDate(0, 0, delta) + } + return v.reread() +} + +// today puts the view back on the clock rather than on the date it happens to be today, +// so it keeps up with a TUI left open overnight. +func (v *calendarView) today() tea.Cmd { + if v.onToday() { + return nil + } + v.anchor = time.Time{} + return v.reread() +} + +// reread reads the range the view now covers, or redraws what is already here when +// there is no calendar to read from. +func (v *calendarView) reread() tea.Cmd { + if v.calIndex >= 0 && v.calIndex < len(v.calendars) { + return v.requestRecordings(v.calendars[v.calIndex].ID) + } + v.rebuildView() + return nil +} + func (v *calendarView) viewingPersonalCalendar() bool { return v.calIndex >= 0 && v.calIndex < len(v.calendars) && v.calendars[v.calIndex].Personal } @@ -584,7 +665,7 @@ func (v *calendarView) saveHabit() tea.Cmd { // the doing as a recording of its own, on a day-scoped route, so which day is being // looked at is part of the request rather than an argument to the habit. func (v *calendarView) toggleHabitCompletion(habit Recording) tea.Cmd { - day := v.now().Local().Format(time.DateOnly) + day := v.day().Local().Format(time.DateOnly) done := habit.CompletedAt != "" requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestHabitMutation) return func() tea.Msg { @@ -667,7 +748,7 @@ func (v *calendarView) requestCalendars() tea.Cmd { // range is fixed when the read starts, which is why the answer has to be discarded // when the mode or the calendar has moved on since. func (v *calendarView) requestRecordings(calID int64) tea.Cmd { - start, end := dateRangeForMode(v.viewMode, v.now(), v.firstWeekDay) + start, end := dateRangeForMode(v.viewMode, v.day(), v.firstWeekDay) requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestRecordings) return func() tea.Msg { startsOn, endsOn := start.Format("2006-01-02"), end.Format("2006-01-02") diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index f0924541..b22ac05e 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -343,6 +343,76 @@ func TestDayLabelsCoverTodosAndHabits(t *testing.T) { } } +// --- Moving between days --- + +func TestCalendarStepsThroughDaysAndBackToToday(t *testing.T) { + v := calendarWithRecordings() + today := time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) + v.now = func() time.Time { return today } + + for _, key := range []string{"right", "n"} { + v.HandleContentKey(keyPress(key)) + } + if got := v.day(); !sameDay(got, today.AddDate(0, 0, 2)) { + t.Errorf("two steps forward = %s, want %s", got.Format(time.DateOnly), today.AddDate(0, 0, 2).Format(time.DateOnly)) + } + for _, key := range []string{"left", "p", "p"} { + v.HandleContentKey(keyPress(key)) + } + if got := v.day(); !sameDay(got, today.AddDate(0, 0, -1)) { + t.Errorf("three steps back = %s, want yesterday", got.Format(time.DateOnly)) + } + + // The day on screen stays the one that was read until the new one's answer lands — + // a step is not a blank screen and a spinner. + if v.Loading() { + t.Error("stepping to another day claimed the spinner") + } + v.Update(recordingsLoadedMsg{requestResult: currentRequest(v)}) + if view := stripANSI(v.View()); !strings.Contains(view, today.AddDate(0, 0, -1).Format("Monday, January 2")) { + t.Errorf("the view does not name the day it moved to: %q", view) + } + // The keys that move the day are said on the day's own line, and t joins them once + // it would do something. + if hint := v.stepHint(); hint != "←→ day · t today" { + t.Errorf("hint on the date line = %q", hint) + } + + // t goes back to following the clock rather than to the date that is today now, + // so a view left open overnight keeps up. + if cmd := v.HandleContentKey(keyPress("t")); cmd == nil { + t.Error("t should read the day it returned to") + } + if !v.onToday() { + t.Error("t did not return the view to today") + } + v.now = func() time.Time { return today.AddDate(0, 0, 1) } + if got := v.day(); !sameDay(got, today.AddDate(0, 0, 1)) { + t.Errorf("a view on today did not follow the clock: %s", got.Format(time.DateOnly)) + } + if cmd := v.HandleContentKey(keyPress("t")); cmd != nil { + t.Error("t read the day again while already on today") + } +} + +func TestCalendarStepsByTheUnitTheViewShows(t *testing.T) { + v := calendarWithRecordings() + today := time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) + v.now = func() time.Time { return today } + + v.viewMode = viewWeek + v.HandleContentKey(keyPress("right")) + if got := v.day(); !sameDay(got, today.AddDate(0, 0, 7)) { + t.Errorf("a step in the week view = %s, want a week on", got.Format(time.DateOnly)) + } + + v.viewMode = viewYear + v.HandleContentKey(keyPress("left")) + if got := v.day(); !sameDay(got, today.AddDate(0, 0, 7).AddDate(-1, 0, 0)) { + t.Errorf("a step in the year view = %s, want a year back", got.Format(time.DateOnly)) + } +} + // --- Habits --- func TestHabitCompletionsMarkTheirHabitRatherThanListingThemselves(t *testing.T) { @@ -435,8 +505,8 @@ func TestDayViewLabelsItsSections(t *testing.T) { habits := []Recording{{ID: 4, Title: "Read 20 pages"}} day := time.Date(2026, 8, 24, 9, 0, 0, 0, time.Local) - view := stripANSI(renderDayView(events, habits, day, 100, 24)) - for _, label := range []string{"Habits", "Monday, August 24", "All day"} { + view := stripANSI(renderDayView(events, habits, day, "←→ day", 100, 24)) + for _, label := range []string{"Habits", "Monday, August 24", "←→ day", "All day"} { if !strings.Contains(view, label) { t.Errorf("day view did not label its %q section: %q", label, view) } @@ -454,7 +524,7 @@ func TestDayViewRulesFallFromEveryHourWithoutCuttingIntoAnEvent(t *testing.T) { // day's header and the hour axis take the first two rows of the 40 it is given, // leaving 38 for the grid — more than the 25 rows the event's title needs read // downwards. - lines := strings.Split(stripANSI(renderDayView(events, nil, day, 98, 40)), "\n") + lines := strings.Split(stripANSI(renderDayView(events, nil, day, "", 98, 40)), "\n") grid := lines[2:] if len(grid) != 38 { t.Fatalf("grid is %d rows of the 38 left to it: %q", len(grid), grid) @@ -492,7 +562,7 @@ func TestDayViewRulesFallFromEveryHourWithoutCuttingIntoAnEvent(t *testing.T) { func TestEmptyDayIsItsHoursRatherThanANotice(t *testing.T) { day := time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) - view := stripANSI(renderDayView(nil, nil, day, 96, 20)) + view := stripANSI(renderDayView(nil, nil, day, "", 96, 20)) if strings.Contains(view, "no events") { t.Errorf("an empty day still announces itself: %q", view) @@ -567,8 +637,9 @@ func TestCalendarPinsTodosBelowTheGrid(t *testing.T) { func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { v := calendarWithRecordings() v.calIndex = 1 - // The calendar offers the view, the categories and the habits modal; creating, - // editing and deleting a habit are the modal's own keys. + // The day view offers the view, the categories and the habits modal; creating, + // editing and deleting a habit are the modal's own keys, and the keys that move the + // day are on the day's own line rather than in here. bindings := v.HelpBindings() if len(bindings) != 3 { t.Fatalf("expected 3 bindings, got %d: %+v", len(bindings), bindings) @@ -583,6 +654,19 @@ func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { } } + // The week and the year have no date line, so the help bar carries their steps. + v.viewMode = viewWeek + for _, want := range []string{"←→", "v", "c"} { + found := false + for _, binding := range v.HelpBindings() { + found = found || binding.key == want + } + if !found { + t.Errorf("the week view is missing binding %q: %+v", want, v.HelpBindings()) + } + } + v.viewMode = viewDay + v.HandleContentKey(keyPress("b")) for _, want := range []string{"↑↓", "a", "e", "x", "esc"} { found := false diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 64299cd8..81c3e4be 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -37,6 +37,19 @@ func (m calendarViewMode) next() calendarViewMode { return (m + 1) % 3 } +// unit is what one step of ← or → moves in this view, as the help bar says it. +func (m calendarViewMode) unit() string { + switch m { + case viewDay: + return "day" + case viewWeek: + return "week" + case viewYear: + return "year" + } + return "day" +} + // dateRangeForMode returns the start and end dates for fetching recordings. func dateRangeForMode(mode calendarViewMode, anchor time.Time, firstWeekDay time.Weekday) (start, end time.Time) { loc := anchor.Location() @@ -228,7 +241,7 @@ const ( // events and not as another box's border. const hourRule = '┊' -func renderDayView(events, habits []Recording, anchor time.Time, width, height int) string { +func renderDayView(events, habits []Recording, anchor time.Time, hint string, width, height int) string { var b strings.Builder // The day borrows the mail list's vocabulary: chrome for the structure a reader @@ -253,9 +266,10 @@ func renderDayView(events, habits []Recording, anchor time.Time, width, height i daySpan := colWidth * 24 gridWidth := daySpan + 1 - // The day names itself above its hours: the subnav carries the calendar and the - // view mode, so which day this is has nowhere else to be said. - b.WriteString(sectionHeader(anchor.Local().Format("Monday, January 2"), width)) + // The day names itself above its hours — the subnav carries the calendar and the + // view mode, so which day this is has nowhere else to be said — and the keys that + // move it sit on the same line, where the cover puts "x to peek". + b.WriteString(hintedSectionHeader(anchor.Local().Format("Monday, January 2"), hint, width)) b.WriteString("\n") // Hour header From dca9a23ef2a47eea980c33b212e335eed39946a3 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 12:34:04 +0200 Subject: [PATCH 06/19] Manage the week's to-dos from the section they sit under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Sometime this week" was a line of to-dos you could read and nothing else. s opens it as a modal, in the same frame as the habits one, and that is where they are managed: enter ticks one off or clears it, a names a new one, e renames, x twice deletes. A new to-do is filed on the day on screen, sent as a bare date so the day is the reader's rather than UTC's — step to Friday and it lands on Friday. A rename carries the title alone, so the day it is filed on stays where it was, and an unedited input is no rename: it was filled with the title as the screen shows it, so saving it back would rewrite whatever the sanitizer took out. While the input is open every key belongs to it, so a types an "a" rather than starting a second to-do, and an unnamed one is refused rather than sent. The section line stays on an empty week — it is where the first to-do is added from. calendarRequestHabitMutation and habitMutationMsg lose the habit in their names, since a to-do write is the same thing: it blocks the same keys, blocks an account switch, and refreshes the day on success. The message carries its own failure wording now, so a to-do that could not be added says so instead of inheriting "Delete failed". --- internal/tui/calendar.go | 180 ++++++++++++++++++++++--- internal/tui/habit_form_test.go | 22 +-- internal/tui/todos.go | 224 ++++++++++++++++++++++++++++++ internal/tui/todos_test.go | 232 ++++++++++++++++++++++++++++++++ 4 files changed, 626 insertions(+), 32 deletions(-) create mode 100644 internal/tui/todos.go create mode 100644 internal/tui/todos_test.go diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 288a25ba..e5a69757 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -50,7 +50,7 @@ const ( calendarRequestNone calendarRequestKind = iota calendarRequestCalendars calendarRequestRecordings - calendarRequestHabitMutation + calendarRequestMutation calendarRequestCategories ) @@ -81,9 +81,12 @@ type timeTrackCategorySavedMsg struct { summary string } -type habitMutationMsg struct { +// calendarMutationMsg is the answer to a write on the calendar — a habit, a to-do — +// carrying what to say about it either way. +type calendarMutationMsg struct { requestResult - action string + action string // what happened, once it has + failure string // and what did not, when it did not } // --- Calendar section view --- @@ -122,6 +125,7 @@ type calendarView struct { timeTrackCategories *timeTrackCategoryManager habitPicker *habitPicker habitForm *habitForm + todoPicker *todoPicker requests requestLane[calendarRequestKind] } @@ -171,24 +175,27 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { if v.habitPicker != nil { v.habitPicker.setHabits(v.manageableHabits()) } + if v.todoPicker != nil { + v.todoPicker.setTodos(v.todos) + } v.rebuildView() return nil, true - case habitMutationMsg: + case calendarMutationMsg: if !v.requests.accepts(msg.requestResult) { return nil, true } v.requests.finish(msg.requestID) if msg.err != nil { - switch { - case v.habitForm != nil: + // A form that is open says so itself; everything else says it in a toast, + // which is over the picker the write came from. + if v.habitForm != nil { v.habitForm.saving = false - v.habitForm.status = errorNotice("Save failed", msg.err) + v.habitForm.status = errorNotice(msg.failure, msg.err) v.habitForm.isError = true - default: - return notifyError("Delete failed", msg.err), true + return nil, true } - return nil, true + return notifyError(msg.failure, msg.err), true } v.habitForm = nil if v.calIndex >= 0 && v.calIndex < len(v.calendars) { @@ -247,6 +254,9 @@ func (v *calendarView) View() string { if v.habitPicker != nil { view = v.habitPicker.draw(view, v.vc.width, v.vc.height) } + if v.todoPicker != nil { + view = v.todoPicker.draw(view, v.vc.width, v.vc.height) + } if v.habitForm != nil { frame := modalFrame(v.habitForm.title(), v.habitForm.view(), v.vc.width) view = overlayModal(view, frame, v.vc.width, v.vc.height) @@ -259,10 +269,15 @@ func (v *calendarView) View() string { // belong in a grid that is precise about time — and a day with a long event pushes // everything below it out of the viewport. func (v *calendarView) todosFooter() string { - if v.viewMode == viewYear || len(v.todos) == 0 { + if v.viewMode == viewYear { return "" } - return sectionHeader(todosSectionLabel, v.vc.width) + "\n" + renderTodosRibbon(v.todos, v.vc.width) + header := hintedSectionHeader(todosSectionLabel, "s to manage", v.vc.width) + if len(v.todos) == 0 { + // The line stays on an empty week, because it is where a to-do is added from. + return header + "\n" + styleMuted.Render("Nothing to do") + } + return header + "\n" + renderTodosRibbon(v.todos, v.vc.width) } func (v *calendarView) todosFooterHeight() int { @@ -282,6 +297,9 @@ func (v *calendarView) HelpBindings() []helpBinding { if v.habitPicker != nil { return v.habitPicker.helpBindings() } + if v.todoPicker != nil { + return v.todoPicker.helpBindings() + } // The day says which keys move it on the line that names it. The week and the year // have no such line, so the help bar carries it for them. var bindings []helpBinding @@ -346,7 +364,7 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { } return cmd } - if v.requests.kind == calendarRequestHabitMutation { + if v.requests.kind == calendarRequestMutation { return nil } @@ -354,11 +372,20 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return v.handleHabitPickerKey(msg) } + if v.todoPicker != nil { + return v.handleTodoPickerKey(msg) + } + switch msg.String() { // b for habits, as in HEY's own calendar. case "b": v.habitPicker = newHabitPicker(v.manageableHabits()) return nil + // s for the to-dos, which HEY files under "Sometime this week". + case "s": + v.todoPicker = newTodoPicker(v.todos) + v.todoPicker.resize(v.vc.width) + return nil case "c": v.timeTrackCategories = newTimeTrackCategoryManager() return v.requestTimeTrackCategories() @@ -381,6 +408,67 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { // handleHabitPickerKey gives the open picker every key: managing a habit is what the // modal is for, so a is a new habit here rather than whatever a means outside it. +// handleTodoPickerKey gives the open picker every key. While it is naming a new to-do +// every key is the input's, so a is a letter there rather than another to-do. +func (v *calendarView) handleTodoPickerKey(msg tea.KeyPressMsg) tea.Cmd { + picker := v.todoPicker + + if picker.editing() { + switch msg.Key().Code { + case tea.KeyEscape: + picker.stopEditing() + return nil + case tea.KeyEnter: + if picker.mode == todoRenaming { + todo, title, ok := picker.renamed() + picker.stopEditing() + if !ok { + return nil + } + return v.renameTodo(todo, title) + } + title, ok := picker.title() + if !ok { + return nil + } + picker.stopEditing() + return v.addTodo(title) + default: + return picker.update(msg) + } + } + + switch msg.String() { + case "esc", "q": + v.todoPicker = nil + return nil + case "a": + return picker.startAdding() + case "e": + return picker.startRenaming() + case "enter": + if todo := picker.selected(); todo != nil { + return v.toggleTodo(*todo) + } + return nil + case "x": + todo := picker.selected() + if todo == nil { + return nil + } + if picker.confirmed != todo.ID { + picker.confirmed = todo.ID + picker.status = "Press x again to delete " + terminal.SanitizeLine(todo.Title) + return nil + } + return v.deleteTodo(*todo) + } + + picker.moveCursor(msg) + picker.status = "" + return nil +} + func (v *calendarView) handleHabitPickerKey(msg tea.KeyPressMsg) tea.Cmd { picker := v.habitPicker @@ -498,11 +586,11 @@ func (v *calendarView) Loading() bool { return v.requests.loading && !v.CapturingInput() && !v.drawn } func (v *calendarView) CapturingInput() bool { - return v.timeTrackCategories != nil || v.habitForm != nil || v.habitPicker != nil + return v.timeTrackCategories != nil || v.habitForm != nil || v.habitPicker != nil || v.todoPicker != nil } func (v *calendarView) AccountSwitchBlocked() bool { - return v.requests.kind == calendarRequestHabitMutation + return v.requests.kind == calendarRequestMutation } // Restyle re-renders the day/week/year grid, which caches styled output in its @@ -517,6 +605,9 @@ func (v *calendarView) Resize(width, height int) { if v.habitForm != nil { v.habitForm.resize(width, height) } + if v.todoPicker != nil { + v.todoPicker.resize(width) + } v.rebuildView() } @@ -647,7 +738,7 @@ func (v *calendarView) saveHabit() tea.Cmd { form := v.habitForm name, icon, color, days := form.values() params := hey.HabitParams{Name: name, Icon: icon, Color: color, Days: days} - requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestHabitMutation) + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) return func() tea.Msg { var err error action := "Habit created" @@ -657,7 +748,7 @@ func (v *calendarView) saveHabit() tea.Cmd { action = "Habit updated" _, err = v.vc.sdk.Habits().Update(ctx, form.habitID, params) } - return habitMutationMsg{requestResult: newRequestResult(requestID, err), action: action} + return calendarMutationMsg{requestResult: newRequestResult(requestID, err), action: action, failure: "Save failed"} } } @@ -667,7 +758,7 @@ func (v *calendarView) saveHabit() tea.Cmd { func (v *calendarView) toggleHabitCompletion(habit Recording) tea.Cmd { day := v.day().Local().Format(time.DateOnly) done := habit.CompletedAt != "" - requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestHabitMutation) + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) return func() tea.Msg { var err error action := "Habit done for today" @@ -677,15 +768,62 @@ func (v *calendarView) toggleHabitCompletion(habit Recording) tea.Cmd { } else { _, err = v.vc.sdk.Habits().Complete(ctx, day, habit.ID) } - return habitMutationMsg{requestResult: newRequestResult(requestID, err), action: action} + return calendarMutationMsg{requestResult: newRequestResult(requestID, err), action: action, failure: "Could not update the habit"} } } func (v *calendarView) deleteHabit(recording Recording) tea.Cmd { - requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestHabitMutation) + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) return func() tea.Msg { err := v.vc.sdk.Habits().Delete(ctx, recording.ID) - return habitMutationMsg{requestResult: newRequestResult(requestID, err), action: "Habit deleted"} + return calendarMutationMsg{requestResult: newRequestResult(requestID, err), action: "Habit deleted", failure: "Delete failed"} + } +} + +// addTodo files a to-do on the day on screen, which is the week the picker is showing. +// HEY takes a bare date so the day is the reader's rather than UTC's. +func (v *calendarView) addTodo(title string) tea.Cmd { + day := v.day().Local().Format(time.DateOnly) + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) + return func() tea.Msg { + _, err := v.vc.sdk.CalendarTodos().Create(ctx, title, day) + return calendarMutationMsg{requestResult: newRequestResult(requestID, err), action: "To-do added", failure: "Could not add the to-do"} + } +} + +// renameTodo changes a to-do's title and nothing else: TodoChanges leaves a zero field +// alone, so the day it is filed on stays where it was. +func (v *calendarView) renameTodo(todo Recording, title string) tea.Cmd { + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) + return func() tea.Msg { + _, err := v.vc.sdk.CalendarTodos().Update(ctx, todo.ID, hey.TodoChanges{Title: title}) + return calendarMutationMsg{requestResult: newRequestResult(requestID, err), action: "To-do renamed", failure: "Could not rename the to-do"} + } +} + +// toggleTodo ticks a to-do off or puts it back. Unlike a habit, which is done on a +// given day, a to-do is done or it is not. +func (v *calendarView) toggleTodo(todo Recording) tea.Cmd { + done := todo.CompletedAt != "" + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) + return func() tea.Msg { + var err error + action := "To-do done" + if done { + action = "To-do cleared" + _, err = v.vc.sdk.CalendarTodos().Uncomplete(ctx, todo.ID) + } else { + _, err = v.vc.sdk.CalendarTodos().Complete(ctx, todo.ID) + } + return calendarMutationMsg{requestResult: newRequestResult(requestID, err), action: action, failure: "Could not update the to-do"} + } +} + +func (v *calendarView) deleteTodo(todo Recording) tea.Cmd { + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) + return func() tea.Msg { + err := v.vc.sdk.CalendarTodos().Delete(ctx, todo.ID) + return calendarMutationMsg{requestResult: newRequestResult(requestID, err), action: "To-do deleted", failure: "Could not delete the to-do"} } } diff --git a/internal/tui/habit_form_test.go b/internal/tui/habit_form_test.go index 7cd1c8ab..8b629a71 100644 --- a/internal/tui/habit_form_test.go +++ b/internal/tui/habit_form_test.go @@ -254,14 +254,14 @@ func calendarHabitsWithFailingServer(t *testing.T, status int) *calendarView { return view } -// finishHabitMutation settles a habit mutation and answers the toast it raised. What a +// finishCalendarMutation settles a habit mutation and answers the toast it raised. What a // mutation answers with is a batch — the toast, and the day read again behind it — so // this walks the batch the way the runtime does, handing the view everything but the // toast, which belongs to the model. -func finishHabitMutation(t *testing.T, view *calendarView, cmd tea.Cmd) string { +func finishCalendarMutation(t *testing.T, view *calendarView, cmd tea.Cmd) string { t.Helper() msg := cmd() - mutation, ok := msg.(habitMutationMsg) + mutation, ok := msg.(calendarMutationMsg) if !ok { t.Fatalf("mutation command returned %T", msg) } @@ -281,10 +281,10 @@ func TestCalendarHabitCreateMutationAndRefresh(t *testing.T) { view.HandleContentKey(keyPress("a")) fillHabitForm(view.habitForm, "Practice piano", "music", "green", 1, 3, 5) cmd := view.HandleContentKey(keyPress("ctrl+s")) - if cmd == nil || view.requests.kind != calendarRequestHabitMutation { + if cmd == nil || view.requests.kind != calendarRequestMutation { t.Fatal("ctrl+s should start habit creation") } - if toast := finishHabitMutation(t, view, cmd); toast != "Habit created" || view.habitForm != nil { + if toast := finishCalendarMutation(t, view, cmd); toast != "Habit created" || view.habitForm != nil { t.Errorf("create state = toast:%q form:%v", toast, view.habitForm) } requests, bodies := recorded.snapshot() @@ -305,7 +305,7 @@ func TestCalendarHabitEditMutationAndRefresh(t *testing.T) { view.HandleContentKey(keyPress("e")) fillHabitForm(view.habitForm, "Read every evening", "read", "purple", 0, 6) cmd := view.HandleContentKey(keyPress("ctrl+s")) - if toast := finishHabitMutation(t, view, cmd); toast != "Habit updated" { + if toast := finishCalendarMutation(t, view, cmd); toast != "Habit updated" { t.Errorf("toast = %q", toast) } requests, _ := recorded.snapshot() @@ -356,7 +356,7 @@ func TestCalendarHabitEnterCompletesAndClearsForTheDayOnScreen(t *testing.T) { view.now = func() time.Time { return time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) } cmd := view.HandleContentKey(keyPress("enter")) - if cmd == nil || view.requests.kind != calendarRequestHabitMutation { + if cmd == nil || view.requests.kind != calendarRequestMutation { t.Fatal("enter should complete the selected habit") } // Ticking a habit off reads the day again behind the modal, and a spinner for that @@ -364,7 +364,7 @@ func TestCalendarHabitEnterCompletesAndClearsForTheDayOnScreen(t *testing.T) { if !view.requests.loading || view.Loading() { t.Errorf("completing a habit claimed the spinner: loading=%v spinner=%v", view.requests.loading, view.Loading()) } - if toast := finishHabitMutation(t, view, cmd); toast != "Habit done for today" { + if toast := finishCalendarMutation(t, view, cmd); toast != "Habit done for today" { t.Errorf("toast = %q", toast) } requests, _ := recorded.snapshot() @@ -378,7 +378,7 @@ func TestCalendarHabitEnterCompletesAndClearsForTheDayOnScreen(t *testing.T) { if cmd == nil { t.Fatal("enter should clear a habit that is already done") } - if toast := finishHabitMutation(t, view, cmd); toast != "Habit cleared for today" { + if toast := finishCalendarMutation(t, view, cmd); toast != "Habit cleared for today" { t.Errorf("toast = %q", toast) } requests, _ = recorded.snapshot() @@ -437,10 +437,10 @@ func TestCalendarHabitDeleteRequiresConfirmationAndRefresh(t *testing.T) { t.Fatalf("first x = cmd:%v confirmed ID:%d status:%q", cmd, picker.confirmed, picker.status) } cmd := view.HandleContentKey(keyPress("x")) - if cmd == nil || view.requests.kind != calendarRequestHabitMutation { + if cmd == nil || view.requests.kind != calendarRequestMutation { t.Fatal("second x should start deletion") } - if toast := finishHabitMutation(t, view, cmd); toast != "Habit deleted" || picker.confirmed != 0 { + if toast := finishCalendarMutation(t, view, cmd); toast != "Habit deleted" || picker.confirmed != 0 { t.Errorf("delete state = toast:%q confirmed ID:%d", toast, picker.confirmed) } requests, _ := recorded.snapshot() diff --git a/internal/tui/todos.go b/internal/tui/todos.go new file mode 100644 index 00000000..c9cc3589 --- /dev/null +++ b/internal/tui/todos.go @@ -0,0 +1,224 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/basecamp/hey-cli/internal/terminal" +) + +// A to-do is named, so this modal has an input as well as a list, and one of three +// things is going on: reading the list, naming a new to-do, or renaming the one under +// the cursor. +type todoMode int + +const ( + todoBrowsing todoMode = iota + todoAdding + todoRenaming +) + +// todoPicker is the week's to-dos, opened over the calendar with s. Like the habits +// modal it is where they are managed, so the calendar keeps its keys for the calendar. +type todoPicker struct { + todos []Recording + cursor int + mode todoMode + input textinput.Model + confirmed int64 // the to-do whose deletion has been asked for once + status string +} + +func newTodoPicker(todos []Recording) *todoPicker { + input := textinput.New() + input.Prompt = "" + input.Placeholder = "Renew passport" + return &todoPicker{todos: todos, input: input} +} + +// setTodos keeps the cursor on the to-do it was on, which is what a write leaves +// behind: the week is read again and comes back a to-do longer or shorter. +func (p *todoPicker) setTodos(todos []Recording) { + var onID int64 + if selected := p.selected(); selected != nil { + onID = selected.ID + } + p.todos = todos + p.cursor = min(p.cursor, max(len(todos)-1, 0)) + for i, todo := range todos { + if todo.ID == onID { + p.cursor = i + break + } + } + p.confirmed = 0 +} + +func (p *todoPicker) selected() *Recording { + if p.cursor < 0 || p.cursor >= len(p.todos) { + return nil + } + return &p.todos[p.cursor] +} + +// todoInputWidth is what the input asks for. A frame hugs its widest line, so an input +// sized off the whole screen draws a box as wide as the terminal around a short list. +const todoInputWidth = 34 + +// resize sizes the input for the frame it is drawn in: the frame's chrome and the +// "New to-do: " label stand to its left. +func (p *todoPicker) resize(width int) { + p.input.SetWidth(min(max(modalContentWidth(width)-12, 10), todoInputWidth)) +} + +func (p *todoPicker) startAdding() tea.Cmd { + p.mode = todoAdding + p.confirmed = 0 + p.status = "" + p.input.SetValue("") + return p.input.Focus() +} + +// startRenaming fills the input with the title as it is shown rather than as HEY served +// it, so what the reader edits is what they were reading. +func (p *todoPicker) startRenaming() tea.Cmd { + selected := p.selected() + if selected == nil { + return nil + } + p.mode = todoRenaming + p.confirmed = 0 + p.status = "" + p.input.SetValue(terminal.SanitizeLine(selected.Title)) + return p.input.Focus() +} + +func (p *todoPicker) stopEditing() { + p.mode = todoBrowsing + p.status = "" + p.input.Blur() +} + +// title is what the input holds, and whether there is anything to make a to-do out of. +func (p *todoPicker) title() (string, bool) { + title := strings.TrimSpace(p.input.Value()) + if title == "" { + p.status = "Give the to-do a name" + return "", false + } + return title, true +} + +// renamed answers the new title for the selected to-do, and false when there is nothing +// to save. An unedited input is no rename: the input was filled with the title as the +// screen shows it, so saving it back would rewrite whatever the sanitizer took out. +func (p *todoPicker) renamed() (Recording, string, bool) { + selected := p.selected() + title, ok := p.title() + if selected == nil || !ok { + return Recording{}, "", false + } + if title == selected.Title || title == terminal.SanitizeLine(selected.Title) { + return Recording{}, "", false + } + return *selected, title, true +} + +func (p *todoPicker) editing() bool { + return p.mode != todoBrowsing +} + +func (p *todoPicker) update(msg tea.Msg) tea.Cmd { + if !p.editing() { + return nil + } + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + return cmd +} + +func (p *todoPicker) moveCursor(msg tea.KeyPressMsg) { + p.cursor = stepListCursor(p.cursor, len(p.todos), msg) + p.confirmed = 0 +} + +// draw puts the picker over the calendar it was opened from. +func (p *todoPicker) draw(base string, width, height int) string { + contentWidth := modalContentWidth(width) + visible := modalContentRows(height) + for _, line := range []bool{p.editing(), p.status != ""} { + if line { + visible = max(visible-2, 1) + } + } + + var rows []string + start, end := modalListWindow(len(p.todos), p.cursor, visible) + for i := start; i < end; i++ { + todo := p.todos[i] + done := todo.CompletedAt != "" + + marker, markerStyle := "□", lipgloss.NewStyle().Foreground(colorAlert).Bold(true) + labelStyle := lipgloss.NewStyle().Foreground(colorBright) + if done { + marker, markerStyle, labelStyle = "■", styleMuted, styleMuted + } + prefix := " " + if i == p.cursor { + prefix = "› " + // While the input is open the cursor keeps its place but gives up the + // highlight: the reader is typing, not choosing. + if !p.editing() { + labelStyle = lipgloss.NewStyle().Foreground(colorActive).Bold(true) + } + } + title := truncateToWidth(terminal.SanitizeLine(todo.Title), max(contentWidth-4, 1)) + rows = append(rows, prefix+markerStyle.Render(marker)+" "+labelStyle.Render(title)) + } + + body := strings.Join(rows, "\n") + if len(p.todos) == 0 { + body = styleMuted.Render("Nothing to do this week") + } + if p.editing() { + label := "New to-do: " + if p.mode == todoRenaming { + label = "Rename: " + } + body += "\n\n" + styleMuted.Render(label) + p.input.View() + } + if p.status != "" { + body += "\n\n" + styleMuted.Render(truncateToWidth(terminal.SanitizeLine(p.status), contentWidth)) + } + return overlayModal(base, modalFrame(todosSectionLabel, body, width), width, height) +} + +func (p *todoPicker) helpBindings() []helpBinding { + if p.editing() { + save := "add" + if p.mode == todoRenaming { + save = "rename" + } + return []helpBinding{{"enter", save}, {"esc", "cancel"}} + } + + bindings := []helpBinding{{"↑↓", "choose"}} + if selected := p.selected(); selected != nil { + doneLabel := "mark done" + if selected.CompletedAt != "" { + doneLabel = "clear" + } + deleteLabel := "delete" + if p.confirmed == selected.ID { + deleteLabel = "press x again to delete" + } + bindings = append(bindings, + helpBinding{"enter", doneLabel}, + helpBinding{"e", "rename"}, + helpBinding{"x", deleteLabel}) + } + return append(bindings, helpBinding{"a", "new to-do"}, helpBinding{"esc", "close"}) +} diff --git a/internal/tui/todos_test.go b/internal/tui/todos_test.go new file mode 100644 index 00000000..ff453b68 --- /dev/null +++ b/internal/tui/todos_test.go @@ -0,0 +1,232 @@ +package tui + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + hey "github.com/basecamp/hey-sdk/go/pkg/hey" +) + +// calendarTodosWithServer is a calendar showing one open and one finished to-do, with +// the modal already open — every to-do write starts from there. +func calendarTodosWithServer(t *testing.T) (*calendarView, *recordedHabitRequests) { + t.Helper() + recorded := &recordedHabitRequests{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + recorded.add(req) + w.Header().Set("Content-Type", "application/json") + switch { + case req.Method == http.MethodPost && req.URL.Path == "/calendar/todos.json": + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"id":9,"title":"Renew passport","type":"Calendar::Todo"}`) + case req.Method == http.MethodPatch && req.URL.Path == "/calendar/todos/7.json": + _, _ = io.WriteString(w, `{"id":7,"title":"Clean the attic properly","type":"Calendar::Todo"}`) + case req.Method == http.MethodPost && req.URL.Path == "/calendar/todos/7/completions.json": + _, _ = io.WriteString(w, `{"id":7,"title":"Clean the attic","type":"Calendar::Todo"}`) + case req.Method == http.MethodDelete && req.URL.Path == "/calendar/todos/8/completions.json": + w.WriteHeader(http.StatusNoContent) + case req.Method == http.MethodDelete && req.URL.Path == "/calendar/todos/7.json": + w.WriteHeader(http.StatusNoContent) + case req.Method == http.MethodGet && req.URL.Path == "/calendars/10/recordings.json": + _, _ = io.WriteString(w, `{"Calendar::Todo":[{"id":7,"title":"Clean the attic","type":"Calendar::Todo"}]}`) + default: + http.NotFound(w, req) + } + })) + t.Cleanup(server.Close) + + vc := testVC() + vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + view := newCalendarView(vc) + view.calendars = []Calendar{{ID: 10, Name: "Rob Zolkos", Personal: true}} + view.now = func() time.Time { return time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) } + view.todos = []Recording{ + {ID: 7, Title: "Clean the attic", Type: "Calendar::Todo"}, + {ID: 8, Title: "Send the invoice", Type: "Calendar::Todo", CompletedAt: "2026-08-21T08:00:00Z"}, + } + view.Resize(vc.width, vc.height) + view.HandleContentKey(keyPress("s")) + if view.todoPicker == nil { + t.Fatal("s did not open the to-dos modal") + } + return view, recorded +} + +func TestTodosModalListsTheWeekAndWhatIsDone(t *testing.T) { + view, _ := calendarTodosWithServer(t) + + rendered := stripANSI(view.View()) + if !strings.Contains(rendered, todosSectionLabel) { + t.Errorf("the modal is not titled after the section it opens from: %q", rendered) + } + if !strings.Contains(rendered, "□ Clean the attic") || !strings.Contains(rendered, "■ Send the invoice") { + t.Errorf("the modal does not mark what is done: %q", rendered) + } + if !strings.Contains(rendered, "╭") { + t.Errorf("the to-dos modal drew no frame: %q", rendered) + } + + view.HandleContentKey(keyPress("esc")) + if view.todoPicker != nil || view.CapturingInput() { + t.Error("esc did not close the to-dos modal") + } +} + +func TestTodosModalTicksOffAndClears(t *testing.T) { + view, recorded := calendarTodosWithServer(t) + + cmd := view.HandleContentKey(keyPress("enter")) + if cmd == nil || view.requests.kind != calendarRequestMutation { + t.Fatal("enter should tick the selected to-do off") + } + if toast := finishCalendarMutation(t, view, cmd); toast != "To-do done" { + t.Errorf("toast = %q", toast) + } + if requests, _ := recorded.snapshot(); requests[0] != "POST /calendar/todos/7/completions.json" { + t.Errorf("completion request = %q", requests[0]) + } + + // A to-do already done is cleared by the same key. + view.todoPicker.setTodos([]Recording{ + {ID: 8, Title: "Send the invoice", CompletedAt: "2026-08-21T08:00:00Z"}, + }) + cmd = view.HandleContentKey(keyPress("enter")) + if toast := finishCalendarMutation(t, view, cmd); toast != "To-do cleared" { + t.Errorf("toast = %q", toast) + } + requests, _ := recorded.snapshot() + if got := requests[len(requests)-2]; got != "DELETE /calendar/todos/8/completions.json" { + t.Errorf("clearing request = %q", got) + } +} + +func TestTodosModalAddsOnTheDayOnScreen(t *testing.T) { + view, recorded := calendarTodosWithServer(t) + + if cmd := view.HandleContentKey(keyPress("a")); cmd == nil || view.todoPicker.mode != todoAdding { + t.Fatal("a should open the input for a new to-do") + } + // While the input is open every key is the input's, so a is a letter. + view.HandleContentKey(keyPress("a")) + if got := view.todoPicker.input.Value(); got != "a" { + t.Errorf("the key was not routed to the input: %q", got) + } + + // An empty to-do is refused rather than sent. + view.todoPicker.input.SetValue(" ") + if cmd := view.HandleContentKey(keyPress("enter")); cmd != nil { + t.Error("an unnamed to-do was sent") + } + if view.todoPicker.status == "" { + t.Error("an unnamed to-do said nothing") + } + + view.todoPicker.input.SetValue("Renew passport") + cmd := view.HandleContentKey(keyPress("enter")) + if cmd == nil || view.todoPicker.editing() { + t.Fatal("enter should add the to-do and close the input") + } + if toast := finishCalendarMutation(t, view, cmd); toast != "To-do added" { + t.Errorf("toast = %q", toast) + } + + requests, bodies := recorded.snapshot() + if requests[0] != "POST /calendar/todos.json" { + t.Fatalf("requests = %v", requests) + } + var payload map[string]map[string]any + if err := json.Unmarshal([]byte(bodies[0]), &payload); err != nil { + t.Fatal(err) + } + // A to-do is filed on the day the reader is looking at, as a bare date so the day + // is theirs rather than UTC's. + if payload["calendar_todo"]["title"] != "Renew passport" || payload["calendar_todo"]["starts_at"] != "2026-08-22" { + t.Errorf("create payload = %v", payload) + } +} + +func TestTodosModalRenamesWithoutMovingTheDay(t *testing.T) { + view, recorded := calendarTodosWithServer(t) + + if cmd := view.HandleContentKey(keyPress("e")); cmd == nil || view.todoPicker.mode != todoRenaming { + t.Fatal("e should open the input on the selected to-do") + } + if got := view.todoPicker.input.Value(); got != "Clean the attic" { + t.Errorf("the input was not filled with the to-do's title: %q", got) + } + + view.todoPicker.input.SetValue("Clean the attic properly") + cmd := view.HandleContentKey(keyPress("enter")) + if cmd == nil || view.todoPicker.editing() { + t.Fatal("enter should rename the to-do and close the input") + } + if toast := finishCalendarMutation(t, view, cmd); toast != "To-do renamed" { + t.Errorf("toast = %q", toast) + } + + requests, bodies := recorded.snapshot() + if requests[0] != "PATCH /calendar/todos/7.json" { + t.Fatalf("requests = %v", requests) + } + var payload map[string]map[string]any + if err := json.Unmarshal([]byte(bodies[0]), &payload); err != nil { + t.Fatal(err) + } + // A rename carries the title and nothing else, so the day it is filed on stays. + if payload["calendar_todo"]["title"] != "Clean the attic properly" { + t.Errorf("rename payload = %v", payload) + } + if _, ok := payload["calendar_todo"]["starts_at"]; ok { + t.Errorf("a rename moved the day: %v", payload) + } +} + +func TestTodosModalTreatsAnUneditedRenameAsNoRename(t *testing.T) { + view, recorded := calendarTodosWithServer(t) + + view.HandleContentKey(keyPress("e")) + if cmd := view.HandleContentKey(keyPress("enter")); cmd != nil { + t.Error("an unedited rename was sent") + } + if view.todoPicker.editing() { + t.Error("enter left the input open") + } + if requests, _ := recorded.snapshot(); len(requests) != 0 { + t.Errorf("an unedited rename made requests: %v", requests) + } +} + +func TestTodosModalDeletesOnlyAfterConfirmation(t *testing.T) { + view, recorded := calendarTodosWithServer(t) + picker := view.todoPicker + + if cmd := view.HandleContentKey(keyPress("x")); cmd != nil || picker.confirmed != 7 { + t.Fatalf("first x = cmd:%v confirmed:%d", cmd, picker.confirmed) + } + if !strings.Contains(picker.status, "Press x again") { + t.Errorf("status = %q", picker.status) + } + // Moving off the to-do takes the question with it. + view.HandleContentKey(keyPress("down")) + if picker.confirmed != 0 { + t.Error("the delete question survived the cursor moving") + } + view.HandleContentKey(keyPress("up")) + + view.HandleContentKey(keyPress("x")) + cmd := view.HandleContentKey(keyPress("x")) + if cmd == nil { + t.Fatal("second x should delete the to-do") + } + if toast := finishCalendarMutation(t, view, cmd); toast != "To-do deleted" { + t.Errorf("toast = %q", toast) + } + if requests, _ := recorded.snapshot(); requests[0] != "DELETE /calendar/todos/7.json" { + t.Errorf("delete request = %q", requests[0]) + } +} From d8fb85e82e083bc1b5327909e6653f216dc3f0a0 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 14:58:34 +0200 Subject: [PATCH 07/19] Give the calendar row to the span, and the calendars a menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row above the grid held the calendars and the span was a key that cycled — which is backwards. There are three spans, always the same three, which is exactly what a row of tabs is for; a reader can be on any number of calendars, shared ones come and go, and a row cannot show more than fits. So the row is Day, Week and Year now, picked by 1, 2 and 3 the way a box is picked in the mail list, or with ←→ from the row itself. The rule above it names the calendar being read and the key that changes it, where the day's line carries its own keys. C opens the calendars as a menu, in the same frame as habits and the to-dos; with one calendar there is nothing to switch between, so it does not open and the rule does not offer it. v is gone with the cycle it drove. Three named keys and a row that shows which one is on beat one key that moves you somewhere you have to read to discover. --- internal/tui/calendar.go | 93 ++++++++++++++++++++++----- internal/tui/calendar_test.go | 114 ++++++++++++++++++++++++--------- internal/tui/calendar_views.go | 4 -- internal/tui/calendars.go | 45 +++++++++++++ internal/tui/nav.go | 14 ++-- 5 files changed, 213 insertions(+), 57 deletions(-) create mode 100644 internal/tui/calendars.go diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index e5a69757..f621f9dd 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -126,6 +126,7 @@ type calendarView struct { habitPicker *habitPicker habitForm *habitForm todoPicker *todoPicker + calendarPicker *calendarPicker requests requestLane[calendarRequestKind] } @@ -257,6 +258,9 @@ func (v *calendarView) View() string { if v.todoPicker != nil { view = v.todoPicker.draw(view, v.vc.width, v.vc.height) } + if v.calendarPicker != nil { + view = v.calendarPicker.draw(view, v.vc.width, v.vc.height) + } if v.habitForm != nil { frame := modalFrame(v.habitForm.title(), v.habitForm.view(), v.vc.width) view = overlayModal(view, frame, v.vc.width, v.vc.height) @@ -300,6 +304,9 @@ func (v *calendarView) HelpBindings() []helpBinding { if v.todoPicker != nil { return v.todoPicker.helpBindings() } + if v.calendarPicker != nil { + return v.calendarPicker.helpBindings() + } // The day says which keys move it on the line that names it. The week and the year // have no such line, so the help bar carries it for them. var bindings []helpBinding @@ -310,7 +317,7 @@ func (v *calendarView) HelpBindings() []helpBinding { } } bindings = append(bindings, - helpBinding{"v", v.viewMode.next().String() + " view"}, + helpBinding{"1-3", "day, week, year"}, helpBinding{"c", "time categories"}) if v.showsHabits() { bindings = append(bindings, helpBinding{"b", "habits"}) @@ -324,29 +331,43 @@ func (v *calendarView) showsHabits() bool { return len(v.manageableHabits()) > 0 || v.viewingPersonalCalendar() } +// SubnavItems is the span the calendar is read over — Day, Week, Year. The rule above +// it names the calendar being read and the key that changes it, since that is a menu +// now rather than a row. func (v *calendarView) SubnavItems() ([]navItem, int, string, bool) { label := "Calendar" + if name := v.calendarName(); name != "" { + label = name + } + if len(v.calendars) > 1 { + label += " · C to switch" + } + return calendarNavItems(), int(v.viewMode), label, true +} + +func (v *calendarView) calendarName() string { if v.calIndex >= 0 && v.calIndex < len(v.calendars) { - label = v.calendars[v.calIndex].Name + return v.calendars[v.calIndex].Name } - label += " · " + v.viewMode.String() - return calendarNavItems(v.calendars), v.calIndex, label, true + return "" } func (v *calendarView) SubnavLeft() tea.Cmd { - if v.calIndex > 0 { - v.calIndex-- - return v.requestRecordings(v.calendars[v.calIndex].ID) - } - return nil + return v.setViewMode(v.viewMode - 1) } func (v *calendarView) SubnavRight() tea.Cmd { - if v.calIndex < len(v.calendars)-1 { - v.calIndex++ - return v.requestRecordings(v.calendars[v.calIndex].ID) + return v.setViewMode(v.viewMode + 1) +} + +// setViewMode reads the range the new span covers, and does nothing at either end: the +// row stops rather than wrapping, as the box tabs do. +func (v *calendarView) setViewMode(mode calendarViewMode) tea.Cmd { + if mode < viewDay || mode > viewYear || mode == v.viewMode { + return nil } - return nil + v.viewMode = mode + return v.reread() } func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { @@ -376,6 +397,10 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return v.handleTodoPickerKey(msg) } + if v.calendarPicker != nil { + return v.handleCalendarPickerKey(msg) + } + switch msg.String() { // b for habits, as in HEY's own calendar. case "b": @@ -386,12 +411,22 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { v.todoPicker = newTodoPicker(v.todos) v.todoPicker.resize(v.vc.width) return nil + case "C": + if len(v.calendars) > 1 { + v.calendarPicker = newCalendarPicker(v.calendars, v.calIndex) + } + return nil case "c": v.timeTrackCategories = newTimeTrackCategoryManager() return v.requestTimeTrackCategories() - case "v": - v.viewMode = v.viewMode.next() - return v.reread() + // The span is picked by number, as a box is in the mail list, and the row above the + // grid shows which one is on. + case "1": + return v.setViewMode(viewDay) + case "2": + return v.setViewMode(viewWeek) + case "3": + return v.setViewMode(viewYear) case "left", "p": return v.step(-1) case "right", "n": @@ -408,6 +443,29 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { // handleHabitPickerKey gives the open picker every key: managing a habit is what the // modal is for, so a is a new habit here rather than whatever a means outside it. +// handleCalendarPickerKey gives the open picker every key. Choosing the calendar that is +// already open just closes it, rather than reading the same range again. +func (v *calendarView) handleCalendarPickerKey(msg tea.KeyPressMsg) tea.Cmd { + picker := v.calendarPicker + + switch msg.String() { + case "esc", "q": + v.calendarPicker = nil + return nil + case "enter": + index := picker.selected() + v.calendarPicker = nil + if index < 0 || index == v.calIndex { + return nil + } + v.calIndex = index + return v.reread() + } + + picker.moveCursor(msg) + return nil +} + // handleTodoPickerKey gives the open picker every key. While it is naming a new to-do // every key is the input's, so a is a letter there rather than another to-do. func (v *calendarView) handleTodoPickerKey(msg tea.KeyPressMsg) tea.Cmd { @@ -586,7 +644,8 @@ func (v *calendarView) Loading() bool { return v.requests.loading && !v.CapturingInput() && !v.drawn } func (v *calendarView) CapturingInput() bool { - return v.timeTrackCategories != nil || v.habitForm != nil || v.habitPicker != nil || v.todoPicker != nil + return v.timeTrackCategories != nil || v.habitForm != nil || + v.habitPicker != nil || v.todoPicker != nil || v.calendarPicker != nil } func (v *calendarView) AccountSwitchBlocked() bool { diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index b22ac05e..7ed05d41 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -127,60 +127,68 @@ func TestCalendarViewIgnoresUnrelatedMessages(t *testing.T) { // --- View mode cycling --- -func TestCalendarViewModeCycle(t *testing.T) { +// The span is picked by number, as a box is in the mail list. +func TestCalendarViewModeNumberKeys(t *testing.T) { v := calendarWithRecordings() if v.viewMode != viewDay { t.Fatalf("initial mode = %v, want Day", v.viewMode) } - - v.HandleContentKey(keyPress("v")) - if v.viewMode != viewWeek { - t.Errorf("after first v: mode = %v, want Week", v.viewMode) - } - - v.HandleContentKey(keyPress("v")) - if v.viewMode != viewYear { - t.Errorf("after second v: mode = %v, want Year", v.viewMode) + for _, tt := range []struct { + key string + want calendarViewMode + }{{"2", viewWeek}, {"3", viewYear}, {"1", viewDay}} { + if cmd := v.HandleContentKey(keyPress(tt.key)); cmd == nil { + t.Errorf("%s did not read the span it switched to", tt.key) + } + if v.viewMode != tt.want { + t.Errorf("after %s: mode = %v, want %v", tt.key, v.viewMode, tt.want) + } } - v.HandleContentKey(keyPress("v")) - if v.viewMode != viewDay { - t.Errorf("after third v: mode = %v, want Day (wrap around)", v.viewMode) + // The span already on screen is not read again. + if cmd := v.HandleContentKey(keyPress("1")); cmd != nil { + t.Error("1 read the day again while the day was already showing") } } // --- Subnav --- +// The row above the grid is the span, and the rule above it names the calendar being +// read and the key that changes it. func TestCalendarViewSubnavItems(t *testing.T) { v := calendarWithRecordings() items, selected, label, centered := v.SubnavItems() - if len(items) != 2 { - t.Errorf("expected 2 subnav items, got %d", len(items)) + if len(items) != 3 || items[0].label != "Day" || items[2].label != "Year" { + t.Errorf("subnav items = %+v, want Day, Week and Year", items) } - if selected != 0 { - t.Errorf("selected = %d, want 0", selected) + if selected != int(viewDay) { + t.Errorf("selected = %d, want the day", selected) } - if label != "Work · Day" { - t.Errorf("label = %q, want \"Work · Day\"", label) + if label != "Work · C to switch" { + t.Errorf("label = %q", label) } if !centered { t.Error("calendar subnav should be centered") } + + // One calendar is nothing to switch between. + v.calendars = v.calendars[:1] + if _, _, label, _ = v.SubnavItems(); label != "Work" { + t.Errorf("label with one calendar = %q", label) + } } -func TestCalendarViewSubnavLeftRight(t *testing.T) { +func TestCalendarViewSubnavLeftRightMovesTheSpan(t *testing.T) { v := calendarWithRecordings() - v.SubnavLeft() - if v.calIndex != 0 { - t.Errorf("SubnavLeft at 0: calIndex = %d, want 0", v.calIndex) + if cmd := v.SubnavLeft(); cmd != nil || v.viewMode != viewDay { + t.Errorf("SubnavLeft on the day = cmd:%v mode:%v, want the row to stop", cmd != nil, v.viewMode) } - v.SubnavRight() - if v.calIndex != 1 { - t.Errorf("after SubnavRight: calIndex = %d, want 1", v.calIndex) + if cmd := v.SubnavRight(); cmd == nil || v.viewMode != viewWeek { + t.Errorf("SubnavRight = cmd:%v mode:%v, want the week read", cmd != nil, v.viewMode) } if !v.requests.loading { t.Error("SubnavRight should start a read") @@ -188,8 +196,54 @@ func TestCalendarViewSubnavLeftRight(t *testing.T) { v.requests.finish(v.requests.id) v.SubnavRight() + if v.viewMode != viewYear { + t.Errorf("mode = %v, want the year", v.viewMode) + } + if cmd := v.SubnavRight(); cmd != nil || v.viewMode != viewYear { + t.Errorf("SubnavRight on the year = cmd:%v mode:%v, want the row to stop", cmd != nil, v.viewMode) + } +} + +func TestCalendarPickerSwitchesTheCalendarItReads(t *testing.T) { + v := calendarWithRecordings() + v.vc.width, v.vc.height = 80, 20 + v.requests.finish(v.requests.id) + + if cmd := v.HandleContentKey(keyPress("C")); cmd != nil || v.calendarPicker == nil { + t.Fatal("C did not open the calendars modal") + } + if !v.CapturingInput() { + t.Error("the calendars modal does not hold the keys") + } + view := stripANSI(v.View()) + if !strings.Contains(view, "Calendars") || !strings.Contains(view, "Personal") { + t.Errorf("the modal does not list the calendars: %q", view) + } + + v.HandleContentKey(keyPress("down")) + cmd := v.HandleContentKey(keyPress("enter")) + if cmd == nil || v.calendarPicker != nil { + t.Fatal("enter should read the chosen calendar and close the modal") + } if v.calIndex != 1 { - t.Errorf("SubnavRight at end: calIndex = %d, want 1", v.calIndex) + t.Errorf("calIndex = %d, want the second calendar", v.calIndex) + } + + // Choosing the calendar already open is not a read. + v.requests.finish(v.requests.id) + v.HandleContentKey(keyPress("C")) + if cmd := v.HandleContentKey(keyPress("enter")); cmd != nil { + t.Error("choosing the open calendar read it again") + } +} + +func TestCalendarPickerStaysShutWithOneCalendar(t *testing.T) { + v := calendarWithRecordings() + v.calendars = v.calendars[:1] + + v.HandleContentKey(keyPress("C")) + if v.calendarPicker != nil { + t.Error("C opened a modal with nothing to choose between") } } @@ -637,14 +691,14 @@ func TestCalendarPinsTodosBelowTheGrid(t *testing.T) { func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { v := calendarWithRecordings() v.calIndex = 1 - // The day view offers the view, the categories and the habits modal; creating, + // The day view offers the span, the categories and the habits modal; creating, // editing and deleting a habit are the modal's own keys, and the keys that move the // day are on the day's own line rather than in here. bindings := v.HelpBindings() if len(bindings) != 3 { t.Fatalf("expected 3 bindings, got %d: %+v", len(bindings), bindings) } - for _, want := range []string{"v", "c", "b"} { + for _, want := range []string{"1-3", "c", "b"} { found := false for _, binding := range bindings { found = found || binding.key == want @@ -656,7 +710,7 @@ func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { // The week and the year have no date line, so the help bar carries their steps. v.viewMode = viewWeek - for _, want := range []string{"←→", "v", "c"} { + for _, want := range []string{"←→", "1-3", "c"} { found := false for _, binding := range v.HelpBindings() { found = found || binding.key == want diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 81c3e4be..4c90f39b 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -33,10 +33,6 @@ func (m calendarViewMode) String() string { return "Day" } -func (m calendarViewMode) next() calendarViewMode { - return (m + 1) % 3 -} - // unit is what one step of ← or → moves in this view, as the help bar says it. func (m calendarViewMode) unit() string { switch m { diff --git a/internal/tui/calendars.go b/internal/tui/calendars.go new file mode 100644 index 00000000..2bf329cc --- /dev/null +++ b/internal/tui/calendars.go @@ -0,0 +1,45 @@ +package tui + +import ( + tea "charm.land/bubbletea/v2" + + "github.com/basecamp/hey-cli/internal/terminal" +) + +// calendarPicker chooses which calendar to read, opened over the day with C. It is a +// menu rather than a row of tabs: a reader can be on any number of calendars, shared +// ones come and go, and the row above the grid is the span — Day, Week, Year — which is +// always those three. +type calendarPicker struct { + names []string + cursor int +} + +func newCalendarPicker(calendars []Calendar, current int) *calendarPicker { + picker := &calendarPicker{cursor: max(current, 0)} + for _, calendar := range calendars { + picker.names = append(picker.names, terminal.SanitizeLine(calendar.Name)) + } + return picker +} + +func (p *calendarPicker) moveCursor(msg tea.KeyPressMsg) { + p.cursor = stepListCursor(p.cursor, len(p.names), msg) +} + +// selected answers the index into the view's calendars, and -1 when there is nothing +// under the cursor. +func (p *calendarPicker) selected() int { + if p.cursor < 0 || p.cursor >= len(p.names) { + return -1 + } + return p.cursor +} + +func (p *calendarPicker) draw(base string, width, height int) string { + return overlayModal(base, framedList("Calendars", p.names, p.cursor, width, height), width, height) +} + +func (p *calendarPicker) helpBindings() []helpBinding { + return []helpBinding{{"↑↓", "choose"}, {"enter", "open"}, {"esc", "close"}} +} diff --git a/internal/tui/nav.go b/internal/tui/nav.go index 31a072ec..f697c986 100644 --- a/internal/tui/nav.go +++ b/internal/tui/nav.go @@ -139,13 +139,15 @@ func boxForShortcut(key string, boxes []mail.Source) int { return -1 } -// calendarNavItems builds nav items for the calendar row. -func calendarNavItems(calendars []Calendar) []navItem { - items := make([]navItem, len(calendars)) - for i, c := range calendars { - items[i] = navItem{label: c.Name} +// calendarNavItems builds nav items for the calendar row: the spans a calendar can be +// read over. Which calendar is being read is a menu rather than a row — there can be +// more of those than fit, and they change, while these three do not. +func calendarNavItems() []navItem { + return []navItem{ + {label: viewDay.String()}, + {label: viewWeek.String()}, + {label: viewYear.String()}, } - return items } // --- Rendering --- From 524bee38d0fea3b38caba886c1c6066408a1bdef Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 17:06:55 +0200 Subject: [PATCH 08/19] Number the calendar's spans and move the calendars to a menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second row is the span now — Day, Week, Year, each wearing its own number the way the boxes do, so 1, 2 and 3 leave the help bar: a shortcut printed in the tab it belongs to does not need saying twice. That frees the rule above the row to name the span that is on, as the box row's rule names the open box. Which calendar is being read moves into a menu, where the row it opens on is marked, since the menu is now the only place that is said. g opens it, not shift+C: the model reads the section shortcuts before a view sees a key, so C jumps to the calendar and never reaches here. --- internal/tui/calendar.go | 35 +++++++++++------------------- internal/tui/calendar_test.go | 41 ++++++++++++++++++++--------------- internal/tui/calendars.go | 11 ++++++++-- internal/tui/nav.go | 6 ++--- 4 files changed, 49 insertions(+), 44 deletions(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index f621f9dd..5826fd39 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -316,9 +316,13 @@ func (v *calendarView) HelpBindings() []helpBinding { bindings = append(bindings, helpBinding{"t", "today"}) } } - bindings = append(bindings, - helpBinding{"1-3", "day, week, year"}, - helpBinding{"c", "time categories"}) + // The spans are not in here: the row above the grid shows each one's number in the + // tab itself, the way the box row does. Which calendar is being read is only in the + // menu, so the key that opens it has to be said. + if len(v.calendars) > 1 { + bindings = append(bindings, helpBinding{"g", "calendars"}) + } + bindings = append(bindings, helpBinding{"c", "time categories"}) if v.showsHabits() { bindings = append(bindings, helpBinding{"b", "habits"}) } @@ -331,25 +335,10 @@ func (v *calendarView) showsHabits() bool { return len(v.manageableHabits()) > 0 || v.viewingPersonalCalendar() } -// SubnavItems is the span the calendar is read over — Day, Week, Year. The rule above -// it names the calendar being read and the key that changes it, since that is a menu -// now rather than a row. +// SubnavItems is the span the calendar is read over — Day, Week, Year — and the rule +// above the row names the one that is on, as the box row's rule names the open box. func (v *calendarView) SubnavItems() ([]navItem, int, string, bool) { - label := "Calendar" - if name := v.calendarName(); name != "" { - label = name - } - if len(v.calendars) > 1 { - label += " · C to switch" - } - return calendarNavItems(), int(v.viewMode), label, true -} - -func (v *calendarView) calendarName() string { - if v.calIndex >= 0 && v.calIndex < len(v.calendars) { - return v.calendars[v.calIndex].Name - } - return "" + return calendarNavItems(), int(v.viewMode), v.viewMode.String(), true } func (v *calendarView) SubnavLeft() tea.Cmd { @@ -411,7 +400,9 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { v.todoPicker = newTodoPicker(v.todos) v.todoPicker.resize(v.vc.width) return nil - case "C": + // g for the calendars, since shift+C is the jump to this section and never reaches + // here — the model reads the section shortcuts before a view sees a key. + case "g": if len(v.calendars) > 1 { v.calendarPicker = newCalendarPicker(v.calendars, v.calIndex) } diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 7ed05d41..0f2989d1 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -154,8 +154,8 @@ func TestCalendarViewModeNumberKeys(t *testing.T) { // --- Subnav --- -// The row above the grid is the span, and the rule above it names the calendar being -// read and the key that changes it. +// The row above the grid is the span, numbered as the boxes are, and the rule above it +// names the one that is on. func TestCalendarViewSubnavItems(t *testing.T) { v := calendarWithRecordings() items, selected, label, centered := v.SubnavItems() @@ -163,20 +163,27 @@ func TestCalendarViewSubnavItems(t *testing.T) { if len(items) != 3 || items[0].label != "Day" || items[2].label != "Year" { t.Errorf("subnav items = %+v, want Day, Week and Year", items) } + // Each span wears its own number, as the boxes do, which is why the help bar does + // not carry them. + for i, want := range []string{"1", "2", "3"} { + if items[i].shortcut != want { + t.Errorf("%s tab shortcut = %q, want %q", items[i].label, items[i].shortcut, want) + } + } if selected != int(viewDay) { t.Errorf("selected = %d, want the day", selected) } - if label != "Work · C to switch" { + // The rule names the span that is on, as the box row's rule names the open box. + if label != "Day" { t.Errorf("label = %q", label) } if !centered { t.Error("calendar subnav should be centered") } - // One calendar is nothing to switch between. - v.calendars = v.calendars[:1] - if _, _, label, _ = v.SubnavItems(); label != "Work" { - t.Errorf("label with one calendar = %q", label) + v.viewMode = viewYear + if _, selected, label, _ = v.SubnavItems(); label != "Year" || selected != int(viewYear) { + t.Errorf("year row = selected:%d label:%q", selected, label) } } @@ -209,8 +216,8 @@ func TestCalendarPickerSwitchesTheCalendarItReads(t *testing.T) { v.vc.width, v.vc.height = 80, 20 v.requests.finish(v.requests.id) - if cmd := v.HandleContentKey(keyPress("C")); cmd != nil || v.calendarPicker == nil { - t.Fatal("C did not open the calendars modal") + if cmd := v.HandleContentKey(keyPress("g")); cmd != nil || v.calendarPicker == nil { + t.Fatal("g did not open the calendars modal") } if !v.CapturingInput() { t.Error("the calendars modal does not hold the keys") @@ -231,7 +238,7 @@ func TestCalendarPickerSwitchesTheCalendarItReads(t *testing.T) { // Choosing the calendar already open is not a read. v.requests.finish(v.requests.id) - v.HandleContentKey(keyPress("C")) + v.HandleContentKey(keyPress("g")) if cmd := v.HandleContentKey(keyPress("enter")); cmd != nil { t.Error("choosing the open calendar read it again") } @@ -241,9 +248,9 @@ func TestCalendarPickerStaysShutWithOneCalendar(t *testing.T) { v := calendarWithRecordings() v.calendars = v.calendars[:1] - v.HandleContentKey(keyPress("C")) + v.HandleContentKey(keyPress("g")) if v.calendarPicker != nil { - t.Error("C opened a modal with nothing to choose between") + t.Error("g opened a modal with nothing to choose between") } } @@ -691,14 +698,14 @@ func TestCalendarPinsTodosBelowTheGrid(t *testing.T) { func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { v := calendarWithRecordings() v.calIndex = 1 - // The day view offers the span, the categories and the habits modal; creating, - // editing and deleting a habit are the modal's own keys, and the keys that move the - // day are on the day's own line rather than in here. + // The day view offers the categories and the habits modal. Creating, editing and + // deleting a habit are the modal's own keys; the keys that move the day are on the + // day's own line; and each span's number is in its own tab above the grid. bindings := v.HelpBindings() if len(bindings) != 3 { t.Fatalf("expected 3 bindings, got %d: %+v", len(bindings), bindings) } - for _, want := range []string{"1-3", "c", "b"} { + for _, want := range []string{"g", "c", "b"} { found := false for _, binding := range bindings { found = found || binding.key == want @@ -710,7 +717,7 @@ func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { // The week and the year have no date line, so the help bar carries their steps. v.viewMode = viewWeek - for _, want := range []string{"←→", "1-3", "c"} { + for _, want := range []string{"←→", "c"} { found := false for _, binding := range v.HelpBindings() { found = found || binding.key == want diff --git a/internal/tui/calendars.go b/internal/tui/calendars.go index 2bf329cc..9a07e763 100644 --- a/internal/tui/calendars.go +++ b/internal/tui/calendars.go @@ -15,10 +15,17 @@ type calendarPicker struct { cursor int } +// newCalendarPicker opens on the calendar being read, and marks it: now that the rule +// above the row names the span rather than the calendar, this menu is the only place +// which calendar is on is said. func newCalendarPicker(calendars []Calendar, current int) *calendarPicker { picker := &calendarPicker{cursor: max(current, 0)} - for _, calendar := range calendars { - picker.names = append(picker.names, terminal.SanitizeLine(calendar.Name)) + for i, calendar := range calendars { + marker := "○ " + if i == current { + marker = "● " + } + picker.names = append(picker.names, marker+terminal.SanitizeLine(calendar.Name)) } return picker } diff --git a/internal/tui/nav.go b/internal/tui/nav.go index f697c986..bcbbffa2 100644 --- a/internal/tui/nav.go +++ b/internal/tui/nav.go @@ -144,9 +144,9 @@ func boxForShortcut(key string, boxes []mail.Source) int { // more of those than fit, and they change, while these three do not. func calendarNavItems() []navItem { return []navItem{ - {label: viewDay.String()}, - {label: viewWeek.String()}, - {label: viewYear.String()}, + {shortcut: "1", label: viewDay.String()}, + {shortcut: "2", label: viewWeek.String()}, + {shortcut: "3", label: viewYear.String()}, } } From f18c3d0d3f3337ae26b30d6872f29b1e79a41007 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 18:03:22 +0200 Subject: [PATCH 09/19] Move to SDK 0.17.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It carries the calendar's period reads — a day, a week and a year, each scoped to the calendars the reader has switched on — plus the toggle that switches them and the selection the calendar list answers alongside it. Nothing reads them yet. Bumping first so the commit that does has something to call. vendorHash recomputed and verified against a real Nix build. Note the order: `go get` leaves stale go.sum lines that `make tidy-check` rejects, and pruning them moves the hash, so tidy has to come before the hash rather than after. --- go.mod | 2 +- go.sum | 4 ++-- nix/package.nix | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 0e70e965..67457850 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 github.com/basecamp/actioncable-go v0.0.0-20260821132720-3f7811951537 - github.com/basecamp/hey-sdk/go v0.15.0 + github.com/basecamp/hey-sdk/go v0.17.0 github.com/charmbracelet/x/ansi v0.11.8 github.com/fsnotify/fsnotify v1.10.1 github.com/gofrs/flock v0.13.0 diff --git a/go.sum b/go.sum index b4a0bb3c..1df36595 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v0.0.0-20260821132720-3f7811951537 h1:OE1VMvKkpI+Vo7aP5IDRG6PNXW2IVMlLUWLgBcybGNc= github.com/basecamp/actioncable-go v0.0.0-20260821132720-3f7811951537/go.mod h1:9+DEydJMniIKraEsd4fDJpFEnqlLUJ6XhAswxRBaITk= -github.com/basecamp/hey-sdk/go v0.15.0 h1:z7C46J9zaMZv1umx9O9v3lcPMuwtuoyvhM+m4VDKt/k= -github.com/basecamp/hey-sdk/go v0.15.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= +github.com/basecamp/hey-sdk/go v0.17.0 h1:0tjB1P7Pe8nRtjdxke6oFqyynM7kaqNq58i9QMoNZ08= +github.com/basecamp/hey-sdk/go v0.17.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= diff --git a/nix/package.nix b/nix/package.nix index 441231fd..e47587f4 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-so8/cKnHEq0KY+Q1sdT7Dpy+ZUN7tpqBel7VYrrCxcw="; + vendorHash = "sha256-HgeBHA0qSqZ87/i8V+qPndkYdEZ1ggaUp8APF0UFPXo="; subPackages = [ "cmd/hey" ]; From 8f37b5dd5ddb830701e739d429f845f3a5537e7c Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 18:06:04 +0200 Subject: [PATCH 10/19] Read the calendar by period, from the calendars that are on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A calendar's recordings and a period's are not the same answer. A calendar lists what it holds, and a recurring event is one row in it; a day, a week or a year expands that row into the occurrences inside its window. So the day drawn from a calendar's recordings showed a weekly meeting once, on the day it was created, and every week after that looked empty. The day and the week now read the period HEY serves for them, and the recurrence arrives already expanded. That also means there is no longer one calendar being read. HEY draws a period from every calendar the identity has switched on at once, so calIndex is gone and the picker is a multi-select over that selection: space writes the toggle and takes the answer back, and the period behind it is read again so the reader sees what they changed. The selection is never guessed at — the toggle answers it, and the calendar list carries it for the first read. Each row wears its own color as an ANSI slot, for the reason styles.go and covers.go give. HEY has one color enum for calendars and habits, so habitColors became heyColors and gained black, which takes the foreground slot rather than lipgloss.Black — the reader's ink, legible on either theme, where a literal black would vanish on half of them. The personal calendar is not in the list. It has no name of its own, HEY keeps it selected, and there is no way to switch it off, so a blank unswitchable row is all it could have been. It still always counts as drawn, which is what decides whether habits are the reader's to manage. The year is the odd one. HEY serves a year as the grid it is drawn as — the days, and the events that span more than one — because a year of expanded occurrences is not what opening a year asks for. So the year keeps its grid and gives up two things it used to show: a day's timed events, which the year read does not carry, and a named day's title, which is a field on a recording there are none of. The web app's year shows neither. A day's cell shows every event on it now. The cap was two and a "+N more" line, which named a number instead of the thing the reader was looking for; the grid already sizes a week to its tallest cell, so a busy day just makes that week taller. --- internal/tui/calendar.go | 274 ++++++++++++++++++++++++++------ internal/tui/calendar_test.go | 181 +++++++++++++++++---- internal/tui/calendar_views.go | 46 ++---- internal/tui/calendars.go | 107 +++++++++---- internal/tui/habit_form_test.go | 23 ++- internal/tui/habits.go | 20 ++- 6 files changed, 492 insertions(+), 159 deletions(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 5826fd39..bbe08476 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -16,14 +16,42 @@ import ( // --- Calendar view types --- -// Calendar is one of the reader's calendars, as the subnav and the habit form need it. -// Personal is the one HEY files a habit or a todo on when no calendar is named. +// Calendar is one of the reader's calendars, as the picker and the habit form need it. +// Personal is the one HEY files a habit or a todo on when no calendar is named, and it +// has no name of its own — HEY leaves the field empty and the web app labels the row +// from the identity instead. type Calendar struct { ID int64 Name string + Color string Personal bool } +// listed is whether the picker offers this calendar at all. The personal calendar is +// never offered: it holds the reader's own habits and todos, it is on in every client, +// and with no name of its own it would be a blank row that cannot be switched off. +func (c Calendar) listed() bool { + return !c.Personal +} + +// CalendarYear is a year as HEY draws one: a grid of days, and the events that span more +// than one of them. It is deliberately not a year's worth of recordings — see +// renderYearView. +type CalendarYear struct { + // PaddingDays is how many cells sit before January 1st, so the grid lines up under + // the reader's first weekday. + PaddingDays int + Days []YearDay + SpannedEvents []Recording +} + +// YearDay is one cell of that grid. Backgrounded is whether the day carries a background +// image, which the web app paints behind the cell. +type YearDay struct { + Date time.Time + Backgrounded bool +} + // Recording is anything HEY keeps on a calendar — an event, a todo, a habit, a time // track — told apart by Type. Its times are strings because that is the shape the // calendar views read them in; giving them time.Time is the next thing to do here. @@ -50,6 +78,7 @@ const ( calendarRequestNone calendarRequestKind = iota calendarRequestCalendars calendarRequestRecordings + calendarRequestToggle calendarRequestMutation calendarRequestCategories ) @@ -57,6 +86,16 @@ const ( type calendarsLoadedMsg struct { requestResult calendars []Calendar + selected map[int64]bool +} + +// calendarToggledMsg carries the selection HEY was left holding. The toggle answers it, +// so the picker never has to guess what the next period read will cover. +type calendarToggledMsg struct { + requestResult + selected map[int64]bool + name string + on bool } type recordingsLoadedMsg struct { @@ -64,6 +103,13 @@ type recordingsLoadedMsg struct { recordings []Recording } +// yearLoadedMsg is the year's own answer. It rides the same lane as the recordings, since +// it is the same read from the reader's side — the span they are looking at. +type yearLoadedMsg struct { + requestResult + year CalendarYear +} + // identityLoadedMsg stays off the request lane: the first day of the week is read // once, alongside the calendars rather than instead of them, so putting it on the // lane would cancel the read it was batched with. @@ -95,7 +141,12 @@ type calendarView struct { vc *viewContext calendars []Calendar - calIndex int + + // selected is the calendars HEY is drawing the period from, by id. It is the + // server's answer rather than this view's choice: a period read is scoped to the + // identity's selection whatever this holds, so the picker writes the toggle and + // takes the selection back from it instead of keeping its own. + selected map[int64]bool viewMode calendarViewMode firstWeekDay time.Weekday @@ -110,11 +161,15 @@ type calendarView struct { // a view left on today then keeps up with the clock overnight. anchor time.Time - // Recordings split by type + // Recordings split by type, for the day and the week events []Recording todos []Recording habits []Recording + // year is what the year span draws, and it is a different answer than the + // recordings above rather than a summary of them. + year CalendarYear + // Scrollable content viewport for the calendar views contentVP viewport.Model @@ -144,8 +199,8 @@ func (v *calendarView) Init() tea.Cmd { cmds := []tea.Cmd{v.fetchIdentity()} if len(v.calendars) == 0 { cmds = append(cmds, v.requestCalendars()) - } else if v.calIndex < len(v.calendars) { - cmds = append(cmds, v.requestRecordings(v.calendars[v.calIndex].ID)) + } else { + cmds = append(cmds, v.requestRecordings()) } return tea.Batch(cmds...) } @@ -162,12 +217,29 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { return cmd, true } v.calendars = msg.calendars + v.selected = msg.selected + if v.calendarPicker != nil { + v.calendarPicker.setCalendars(v.listedCalendars(), v.selected) + } if len(v.calendars) > 0 { - v.calIndex = 0 - return v.requestRecordings(v.calendars[0].ID), true + return v.requestRecordings(), true } return nil, true + case calendarToggledMsg: + if !v.requests.accepts(msg.requestResult) { + return nil, true + } + v.requests.finish(msg.requestID) + if msg.err != nil { + return notifyError("Could not switch "+msg.name, msg.err), true + } + v.selected = msg.selected + if v.calendarPicker != nil { + v.calendarPicker.setCalendars(v.listedCalendars(), v.selected) + } + return tea.Batch(notify(toggleNotice(msg.name, msg.on)), v.requestRecordings()), true + case recordingsLoadedMsg: if cmd, ok := v.requests.settle(msg.requestResult); !ok { return cmd, true @@ -182,6 +254,14 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { v.rebuildView() return nil, true + case yearLoadedMsg: + if cmd, ok := v.requests.settle(msg.requestResult); !ok { + return cmd, true + } + v.year = msg.year + v.rebuildView() + return nil, true + case calendarMutationMsg: if !v.requests.accepts(msg.requestResult) { return nil, true @@ -199,8 +279,8 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { return notifyError(msg.failure, msg.err), true } v.habitForm = nil - if v.calIndex >= 0 && v.calIndex < len(v.calendars) { - return tea.Batch(notify(msg.action), v.requestRecordings(v.calendars[v.calIndex].ID)), true + if len(v.calendars) > 0 { + return tea.Batch(notify(msg.action), v.requestRecordings()), true } return notify(msg.action), true @@ -319,7 +399,7 @@ func (v *calendarView) HelpBindings() []helpBinding { // The spans are not in here: the row above the grid shows each one's number in the // tab itself, the way the box row does. Which calendar is being read is only in the // menu, so the key that opens it has to be said. - if len(v.calendars) > 1 { + if len(v.listedCalendars()) > 0 { bindings = append(bindings, helpBinding{"g", "calendars"}) } bindings = append(bindings, helpBinding{"c", "time categories"}) @@ -403,8 +483,8 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { // g for the calendars, since shift+C is the jump to this section and never reaches // here — the model reads the section shortcuts before a view sees a key. case "g": - if len(v.calendars) > 1 { - v.calendarPicker = newCalendarPicker(v.calendars, v.calIndex) + if listed := v.listedCalendars(); len(listed) > 0 { + v.calendarPicker = newCalendarPicker(listed, v.selected) } return nil case "c": @@ -434,8 +514,9 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { // handleHabitPickerKey gives the open picker every key: managing a habit is what the // modal is for, so a is a new habit here rather than whatever a means outside it. -// handleCalendarPickerKey gives the open picker every key. Choosing the calendar that is -// already open just closes it, rather than reading the same range again. +// handleCalendarPickerKey gives the open picker every key. The picker stays open across a +// toggle: switching calendars on and off is a few decisions at once, not one, and the +// period behind it is read again after each so the reader sees what they just changed. func (v *calendarView) handleCalendarPickerKey(msg tea.KeyPressMsg) tea.Cmd { picker := v.calendarPicker @@ -443,14 +524,12 @@ func (v *calendarView) handleCalendarPickerKey(msg tea.KeyPressMsg) tea.Cmd { case "esc", "q": v.calendarPicker = nil return nil - case "enter": - index := picker.selected() - v.calendarPicker = nil - if index < 0 || index == v.calIndex { + case "enter", " ", "space": + calendar, ok := picker.highlighted() + if !ok || v.togglePending() { return nil } - v.calIndex = index - return v.reread() + return v.toggleCalendar(calendar) } picker.moveCursor(msg) @@ -686,7 +765,10 @@ func (v *calendarView) rebuildView() { case viewWeek: content = renderWeekView(v.events, v.habits, anchor, v.firstWeekDay, w, h, dayLabels) case viewYear: - content = renderYearView(v.events, anchor, v.firstWeekDay, w, h, dayLabels) + // The year's events are HEY's spanned_events — the all-day and multi-day ones — + // because that is all a year read carries. eventsByDate spreads a multi-day event + // over the days it covers, so the grid fills the same way it always did. + content = renderYearView(v.year.SpannedEvents, anchor, v.firstWeekDay, w, h) } v.contentVP.SetContent(content) @@ -751,18 +833,42 @@ func (v *calendarView) today() tea.Cmd { return v.reread() } -// reread reads the range the view now covers, or redraws what is already here when -// there is no calendar to read from. +// reread reads the period the view now covers, or redraws what is already here when the +// calendars have not been read yet. func (v *calendarView) reread() tea.Cmd { - if v.calIndex >= 0 && v.calIndex < len(v.calendars) { - return v.requestRecordings(v.calendars[v.calIndex].ID) + if len(v.calendars) > 0 { + return v.requestRecordings() } v.rebuildView() return nil } +// listedCalendars is what the picker offers — everything but the personal one, which is +// always on and has no name to show. +func (v *calendarView) listedCalendars() []Calendar { + listed := make([]Calendar, 0, len(v.calendars)) + for _, calendar := range v.calendars { + if calendar.listed() { + listed = append(listed, calendar) + } + } + return listed +} + +func (v *calendarView) togglePending() bool { + return v.requests.loading && v.requests.kind == calendarRequestToggle +} + +// viewingPersonalCalendar is whether the reader's own calendar is among the ones being +// drawn, which is what decides whether habits are theirs to manage. It is always among +// them: HEY keeps it in the selection and offers no way to switch it off. func (v *calendarView) viewingPersonalCalendar() bool { - return v.calIndex >= 0 && v.calIndex < len(v.calendars) && v.calendars[v.calIndex].Personal + for _, calendar := range v.calendars { + if calendar.Personal { + return true + } + } + return false } func (v *calendarView) manageableHabits() []Recording { @@ -883,7 +989,7 @@ func (v *calendarView) deleteTodo(todo Recording) tea.Cmd { // title goes back through the edit form, so sanitizing it here would rewrite it on an // unrelated save. Every view sanitizes what it shows instead. func sdkCalendarToModel(c generated.Calendar) Calendar { - return Calendar{ID: c.Id, Name: c.Name, Personal: c.Personal} + return Calendar{ID: c.Id, Name: c.Name, Color: c.Color, Personal: c.Personal} } func sdkRecordingToModel(r generated.Recording) Recording { @@ -928,35 +1034,109 @@ func (v *calendarView) requestCalendars() tea.Cmd { for _, cw := range payload.Calendars { calendars = append(calendars, sdkCalendarToModel(cw.Calendar)) } - return calendarsLoadedMsg{requestResult: newRequestResult(requestID, nil), calendars: calendars} + return calendarsLoadedMsg{ + requestResult: newRequestResult(requestID, nil), + calendars: calendars, + selected: selectionSet(payload.SelectedCalendarIds), + } + } +} + +// toggleCalendar switches one calendar and takes the selection back from the answer. +func (v *calendarView) toggleCalendar(calendar Calendar) tea.Cmd { + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestToggle) + on := !v.selected[calendar.ID] + name := terminal.SanitizeLine(calendar.Name) + return func() tea.Msg { + ids, err := v.vc.sdk.Calendars().Toggle(ctx, calendar.ID) + return calendarToggledMsg{ + requestResult: newRequestResult(requestID, err), + selected: selectionSet(ids), + name: name, + on: on, + } + } +} + +func selectionSet(ids []int64) map[int64]bool { + selected := make(map[int64]bool, len(ids)) + for _, id := range ids { + selected[id] = true } + return selected } -// requestRecordings reads the range the current view mode covers around today. The -// range is fixed when the read starts, which is why the answer has to be discarded -// when the mode or the calendar has moved on since. -func (v *calendarView) requestRecordings(calID int64) tea.Cmd { - start, end := dateRangeForMode(v.viewMode, v.day(), v.firstWeekDay) +func toggleNotice(name string, on bool) string { + if on { + return name + " shown" + } + return name + " hidden" +} + +// requestRecordings reads the period the view is on, scoped by HEY to the calendars the +// identity has switched on. The period is fixed when the read starts, which is why the +// answer has to be discarded when the span or the day has moved on since. +// +// A period is the read to use rather than a calendar's recordings over the same dates: a +// recurring event is one row on a calendar and HEY expands it into occurrences per +// period, so a weekly meeting drawn from a calendar's recordings appears once, on the day +// it was created. +func (v *calendarView) requestRecordings() tea.Cmd { + date := v.day().Format("2006-01-02") + mode := v.viewMode requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestRecordings) return func() tea.Msg { - startsOn, endsOn := start.Format("2006-01-02"), end.Format("2006-01-02") - resp, err := v.vc.sdk.Calendars().GetRecordings(ctx, calID, &generated.GetCalendarRecordingsParams{ - StartsOn: &startsOn, - EndsOn: &endsOn, - }) + periods := v.vc.sdk.CalendarPeriods() + + // The year is its own answer, and one read: HEY serves a year as the grid it is + // drawn as rather than as the recordings inside it. + if mode == viewYear { + year, err := periods.Year(ctx, date) + if err != nil { + return yearLoadedMsg{requestResult: newRequestResult(requestID, err)} + } + return yearLoadedMsg{requestResult: newRequestResult(requestID, nil), year: sdkYearToModel(year)} + } + + var period *generated.CalendarPeriod + var err error + if mode == viewWeek { + period, err = periods.Week(ctx, date) + } else { + period, err = periods.Day(ctx, date) + } if err != nil { return recordingsLoadedMsg{requestResult: newRequestResult(requestID, err)} } - var all []Recording - if resp != nil { - for _, recs := range *resp { - for _, r := range recs { - all = append(all, sdkRecordingToModel(r)) - } - } + return recordingsLoadedMsg{requestResult: newRequestResult(requestID, nil), recordings: recordingsIn(period)} + } +} + +func sdkYearToModel(year *generated.CalendarYear) CalendarYear { + if year == nil { + return CalendarYear{} + } + model := CalendarYear{PaddingDays: int(year.PaddingDaysCount)} + for _, day := range year.Days { + model.Days = append(model.Days, YearDay{Date: day.StartsAt, Backgrounded: day.Backgrounded}) + } + for _, event := range year.SpannedEvents { + model.SpannedEvents = append(model.SpannedEvents, sdkRecordingToModel(event)) + } + return model +} + +func recordingsIn(period *generated.CalendarPeriod) []Recording { + if period == nil { + return nil + } + var all []Recording + for _, recs := range period.Recordings { + for _, r := range recs { + all = append(all, sdkRecordingToModel(r)) } - return recordingsLoadedMsg{requestResult: newRequestResult(requestID, nil), recordings: all} } + return all } func (v *calendarView) requestTimeTrackCategories() tea.Cmd { diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 0f2989d1..3951d623 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -14,13 +14,19 @@ import ( hey "github.com/basecamp/hey-sdk/go/pkg/hey" ) +// The personal calendar carries no name and no color, which is how HEY serves it — the +// web app labels that row from the identity instead. func testCalendars() []Calendar { return []Calendar{ - {ID: 10, Name: "Work"}, - {ID: 11, Name: "Personal", Personal: true}, + {ID: 10, Name: "Design Team", Color: "teal"}, + {ID: 11, Personal: true}, } } +func testSelection(ids ...int64) map[int64]bool { + return selectionSet(ids) +} + func testRecordings() []Recording { return []Recording{ {ID: 200, Title: "Standup", StartsAt: "2025-03-01T09:00:00Z", EndsAt: "2025-03-01T09:30:00Z", Type: "CalendarEvent"}, @@ -60,7 +66,6 @@ func TestCalendarViewInitFetchesCalendars(t *testing.T) { func TestCalendarViewInitRefetchesWhenLoaded(t *testing.T) { v := newCalendarView(testVC()) v.calendars = testCalendars() - v.calIndex = 0 cmd := v.Init() if cmd == nil { t.Fatal("Init with calendars should return a fetch command") @@ -84,7 +89,7 @@ func TestCalendarViewHandlesRecordingsLoaded(t *testing.T) { v := newCalendarView(testVC()) v.Resize(80, 30) v.calendars = testCalendars() - v.requestRecordings(10) + v.requestRecordings() _, consumed := v.Update(recordingsLoadedMsg{requestResult: currentRequest(v), recordings: testRecordings()}) if !consumed { @@ -211,7 +216,59 @@ func TestCalendarViewSubnavLeftRightMovesTheSpan(t *testing.T) { } } -func TestCalendarPickerSwitchesTheCalendarItReads(t *testing.T) { +// --- Year view --- + +// The year is read as a year, not as the recordings inside it: HEY answers a grid, and one +// request draws it. +func TestCalendarYearReadsTheYearItself(t *testing.T) { + var mu sync.Mutex + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + mu.Lock() + paths = append(paths, req.URL.Path) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"starts_at":"2026-01-01T00:00:00Z","ends_at":"2026-12-31T23:59:59Z","kind":"year", + "padding_days_count":3, + "days":[{"starts_at":"2026-01-01T00:00:00Z","backgrounded":false}, + {"starts_at":"2026-01-02T00:00:00Z","backgrounded":true}], + "spanned_events":[{"id":1,"type":"CalendarEvent","title":"Summer break","all_day":true, + "starts_at":"2026-07-06T00:00:00Z","ends_at":"2026-07-17T23:59:59Z"}]}`)) + })) + t.Cleanup(server.Close) + + vc := testVC() + vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + v := newCalendarView(vc) + v.Resize(80, 30) + v.now = func() time.Time { return time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) } + v.viewMode = viewYear + + msg := v.requestRecordings()() + mu.Lock() + defer mu.Unlock() + if len(paths) != 1 || paths[0] != "/calendar/years/2026-08-22.json" { + t.Fatalf("paths = %v, want one read of the year", paths) + } + + loaded, ok := msg.(yearLoadedMsg) + if !ok { + t.Fatalf("msg = %T, want yearLoadedMsg", msg) + } + if loaded.year.PaddingDays != 3 { + t.Errorf("padding = %d, want 3", loaded.year.PaddingDays) + } + if len(loaded.year.Days) != 2 || !loaded.year.Days[1].Backgrounded { + t.Errorf("days = %+v", loaded.year.Days) + } + if len(loaded.year.SpannedEvents) != 1 || loaded.year.SpannedEvents[0].Title != "Summer break" { + t.Errorf("spanned events = %+v", loaded.year.SpannedEvents) + } +} + +// The picker lists what can be switched, marks what is on, and stays open across a +// toggle: switching calendars is a few decisions at once rather than one. +func TestCalendarPickerTogglesTheCalendarsItLists(t *testing.T) { v := calendarWithRecordings() v.vc.width, v.vc.height = 80, 20 v.requests.finish(v.requests.id) @@ -223,34 +280,65 @@ func TestCalendarPickerSwitchesTheCalendarItReads(t *testing.T) { t.Error("the calendars modal does not hold the keys") } view := stripANSI(v.View()) - if !strings.Contains(view, "Calendars") || !strings.Contains(view, "Personal") { + if !strings.Contains(view, "Calendars") || !strings.Contains(view, "Design Team") { t.Errorf("the modal does not list the calendars: %q", view) } - v.HandleContentKey(keyPress("down")) - cmd := v.HandleContentKey(keyPress("enter")) - if cmd == nil || v.calendarPicker != nil { - t.Fatal("enter should read the chosen calendar and close the modal") + cmd := v.HandleContentKey(keyPress(" ")) + if cmd == nil { + t.Fatal("space did not switch the calendar") } - if v.calIndex != 1 { - t.Errorf("calIndex = %d, want the second calendar", v.calIndex) + if v.calendarPicker == nil { + t.Error("the modal closed on a toggle") + } + if !v.togglePending() { + t.Errorf("lane = loading:%v kind:%v, want the toggle", v.requests.loading, v.requests.kind) } - // Choosing the calendar already open is not a read. - v.requests.finish(v.requests.id) - v.HandleContentKey(keyPress("g")) - if cmd := v.HandleContentKey(keyPress("enter")); cmd != nil { - t.Error("choosing the open calendar read it again") + // A second toggle while the first is in flight would race the selection HEY answers. + if cmd := v.HandleContentKey(keyPress(" ")); cmd != nil { + t.Error("space switched a second calendar while the first was still in flight") + } + + // The answer replaces the selection wholesale and reads the period again. + toggled := calendarToggledMsg{ + requestResult: currentRequest(v), + selected: testSelection(10, 11), + name: "Design Team", + on: true, + } + if _, consumed := v.Update(toggled); !consumed { + t.Error("calendarToggledMsg should be consumed") + } + if !v.selected[10] { + t.Errorf("selection = %v, want the shared calendar on", v.selected) + } +} + +// The personal calendar is not in the picker: it has no name to show, it is on in every +// client, and HEY offers no way to switch it off. +func TestCalendarPickerLeavesOutThePersonalCalendar(t *testing.T) { + v := calendarWithRecordings() + v.vc.width, v.vc.height = 80, 20 + + listed := v.listedCalendars() + if len(listed) != 1 || listed[0].ID != 10 { + t.Errorf("listed calendars = %+v, want the shared one alone", listed) + } + + // And it stays selected regardless, which is what lets habits be managed. + if !v.viewingPersonalCalendar() { + t.Error("the personal calendar should always count as being drawn") } } -func TestCalendarPickerStaysShutWithOneCalendar(t *testing.T) { +func TestCalendarPickerStaysShutWithNothingToSwitch(t *testing.T) { v := calendarWithRecordings() - v.calendars = v.calendars[:1] + v.calendars = v.calendars[1:] // the personal one alone v.HandleContentKey(keyPress("g")) if v.calendarPicker != nil { - t.Error("g opened a modal with nothing to choose between") + t.Error("g opened a modal with nothing to switch") } } @@ -261,11 +349,11 @@ func TestCalendarViewIgnoresStaleRecordings(t *testing.T) { v.Resize(80, 30) v.calendars = testCalendars() - v.requestRecordings(10) + v.requestRecordings() stale := recordingsLoadedMsg{requestResult: currentRequest(v), recordings: testRecordings()} v.viewMode = viewWeek - v.requestRecordings(10) + v.requestRecordings() fresh := recordingsLoadedMsg{requestResult: currentRequest(v), recordings: []Recording{ {ID: 300, Title: "Design review", StartsAt: "2025-03-04T15:00:00Z", EndsAt: "2025-03-04T16:00:00Z", Type: "CalendarEvent"}, }} @@ -316,7 +404,6 @@ func TestCalendarViewFailedReadFinishesTheLane(t *testing.T) { func TestCalendarViewKeysDoNotSupersedeAHabitWrite(t *testing.T) { v := calendarWithRecordings() - v.calIndex = 1 v.deleteHabit(Recording{ID: 202, Title: "Read a book"}) requestID := v.requests.id @@ -330,12 +417,14 @@ func TestCalendarViewKeysDoNotSupersedeAHabitWrite(t *testing.T) { // --- Today --- +// The clock is read on every fetch, so a TUI left open overnight reads the new day rather +// than the one it started on. func TestCalendarViewFetchesAroundTheCurrentDay(t *testing.T) { var mu sync.Mutex - var queries []string + var paths []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { mu.Lock() - queries = append(queries, req.URL.RawQuery) + paths = append(paths, req.URL.Path) mu.Unlock() w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{}`)) @@ -349,21 +438,44 @@ func TestCalendarViewFetchesAroundTheCurrentDay(t *testing.T) { firstDay := time.Date(2025, 3, 9, 23, 45, 0, 0, time.UTC) v.now = func() time.Time { return firstDay } - v.requestRecordings(10)() + v.requestRecordings()() v.now = func() time.Time { return firstDay.AddDate(0, 0, 1) } - v.requestRecordings(10)() + v.requestRecordings()() mu.Lock() defer mu.Unlock() - if len(queries) != 2 { - t.Fatalf("queries = %v", queries) + want := []string{"/calendar/days/2025-03-09.json", "/calendar/days/2025-03-10.json"} + if len(paths) != 2 || paths[0] != want[0] || paths[1] != want[1] { + t.Errorf("paths = %v, want %v", paths, want) } - if queries[0] != "ends_on=2025-03-10&starts_on=2025-03-09" { - t.Errorf("first query = %q", queries[0]) - } - if queries[1] != "ends_on=2025-03-11&starts_on=2025-03-10" { - t.Errorf("second query still asks for the day the TUI opened on: %q", queries[1]) +} + +// The week reads the week the day falls in — one request, not seven days' worth. +func TestCalendarViewReadsTheWeekForTheWeekSpan(t *testing.T) { + var mu sync.Mutex + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + mu.Lock() + paths = append(paths, req.URL.Path) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"starts_at":"2025-03-03T00:00:00Z","ends_at":"2025-03-09T23:59:59Z","kind":"week","recordings":{}}`)) + })) + t.Cleanup(server.Close) + + vc := testVC() + vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + v := newCalendarView(vc) + v.Resize(80, 30) + v.now = func() time.Time { return time.Date(2025, 3, 9, 12, 0, 0, 0, time.UTC) } + v.viewMode = viewWeek + + v.requestRecordings()() + mu.Lock() + defer mu.Unlock() + if len(paths) != 1 || paths[0] != "/calendar/weeks/2025-03-09.json" { + t.Errorf("paths = %v, want one read of the week", paths) } } @@ -697,7 +809,6 @@ func TestCalendarPinsTodosBelowTheGrid(t *testing.T) { func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { v := calendarWithRecordings() - v.calIndex = 1 // The day view offers the categories and the habits modal. Creating, editing and // deleting a habit are the modal's own keys; the keys that move the day are on the // day's own line; and each span's number is in its own tab above the grid. diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 4c90f39b..d07ab480 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -46,26 +46,6 @@ func (m calendarViewMode) unit() string { return "day" } -// dateRangeForMode returns the start and end dates for fetching recordings. -func dateRangeForMode(mode calendarViewMode, anchor time.Time, firstWeekDay time.Weekday) (start, end time.Time) { - loc := anchor.Location() - switch mode { - case viewDay: - start = time.Date(anchor.Year(), anchor.Month(), anchor.Day(), 0, 0, 0, 0, loc) - end = start.AddDate(0, 0, 1) - case viewWeek: - start = weekStartDate(anchor, firstWeekDay) - end = start.AddDate(0, 0, 7) - case viewYear: - yearStart := time.Date(anchor.Year(), 1, 1, 0, 0, 0, 0, loc) - yearEnd := time.Date(anchor.Year()+1, 1, 1, 0, 0, 0, 0, loc) - start = weekStartDate(yearStart, firstWeekDay) - endWeekStart := weekStartDate(yearEnd.AddDate(0, 0, -1), firstWeekDay) - end = endWeekStart.AddDate(0, 0, 7) - } - return -} - // weekStartDate returns the start of the week containing t. func weekStartDate(t time.Time, firstDay time.Weekday) time.Time { d := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) @@ -699,7 +679,9 @@ func weekDayColumnLabel(d time.Time, isFirstCol bool) string { // Year View — bordered grid, one box per day // =============================================== -func renderYearView(events []Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int, dayLabels map[string]string) string { +// The year takes no day labels: a named day is a title on a recording, and a year read +// carries no recordings to hang one on. The web app's year does not show them either. +func renderYearView(events []Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int) string { var b strings.Builder muted := styleMuted bright := lipgloss.NewStyle().Foreground(colorBright) @@ -716,7 +698,6 @@ func renderYearView(events []Recording, anchor time.Time, firstWeekDay time.Week byDate := eventsByDate(events) colWidth := max((width-8)/7, 9) - maxEventsPerCell := 2 // show at most 2 event titles per cell sep := muted.Render("│") @@ -747,8 +728,8 @@ func renderYearView(events []Recording, anchor time.Time, firstWeekDay time.Week cells := make([][]string, 7) for i := range 7 { weekDates[i] = d - cells[i] = buildYearDayCell(d, byDate[dateKey(d)], colWidth, maxEventsPerCell, - sameDay(d, today), d.Year() == anchor.Year(), primary, bright, muted, faint, dayLabels) + cells[i] = buildYearDayCell(d, byDate[dateKey(d)], colWidth, + sameDay(d, today), d.Year() == anchor.Year(), primary, bright, muted, faint) d = d.AddDate(0, 0, 1) } @@ -791,12 +772,12 @@ func renderYearView(events []Recording, anchor time.Time, firstWeekDay time.Week } // buildYearDayCell returns styled lines for one day cell in the year grid. -// Line 0: day label. Lines 1+: truncated event titles. -func buildYearDayCell(d time.Time, dayEvents []Recording, colWidth, maxEvents int, +// Line 0: day label. Lines 1+: one truncated title per event, all of them — the week's row +// is as tall as its busiest day, which is how the web app's grid behaves too. +func buildYearDayCell(d time.Time, dayEvents []Recording, colWidth int, isToday, isCurrentYear bool, primary, bright, muted, faint lipgloss.Style, - dayLabels map[string]string, ) []string { - label := dayLabelOrDefault(d, false, dayLabels, yearDayColumnLabel) + label := yearDayColumnLabel(d, false) // Pick the style for the header line headerStyle := muted @@ -816,15 +797,10 @@ func buildYearDayCell(d time.Time, dayEvents []Recording, colWidth, maxEvents in } // Event titles - shown := min(len(dayEvents), maxEvents) - for i := range shown { - title := truncateStr(terminal.SanitizeLine(dayEvents[i].Title), colWidth) + for _, event := range dayEvents { + title := truncateStr(terminal.SanitizeLine(event.Title), colWidth) lines = append(lines, bright.Render(title)) } - if len(dayEvents) > maxEvents { - more := fmt.Sprintf("+%d more", len(dayEvents)-maxEvents) - lines = append(lines, muted.Render(truncateStr(more, colWidth))) - } return lines } diff --git a/internal/tui/calendars.go b/internal/tui/calendars.go index 9a07e763..41641b5f 100644 --- a/internal/tui/calendars.go +++ b/internal/tui/calendars.go @@ -1,52 +1,99 @@ package tui import ( + "strings" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/basecamp/hey-cli/internal/terminal" ) -// calendarPicker chooses which calendar to read, opened over the day with C. It is a -// menu rather than a row of tabs: a reader can be on any number of calendars, shared -// ones come and go, and the row above the grid is the span — Day, Week, Year — which is -// always those three. +// calendarPicker switches calendars on and off, opened over the day with g. It is a menu +// rather than a row of tabs: a reader can be on any number of calendars, shared ones come +// and go, and the row above the grid is the span — Day, Week, Year — which is always those +// three. +// +// It is a multi-select because HEY's calendar is: a period is drawn from every calendar +// the identity has switched on at once, not from one at a time. Which are on is the +// server's answer, so the picker holds what it was handed rather than a choice of its own, +// and takes a fresh selection back from every toggle. type calendarPicker struct { - names []string - cursor int -} - -// newCalendarPicker opens on the calendar being read, and marks it: now that the rule -// above the row names the span rather than the calendar, this menu is the only place -// which calendar is on is said. -func newCalendarPicker(calendars []Calendar, current int) *calendarPicker { - picker := &calendarPicker{cursor: max(current, 0)} - for i, calendar := range calendars { - marker := "○ " - if i == current { - marker = "● " - } - picker.names = append(picker.names, marker+terminal.SanitizeLine(calendar.Name)) - } - return picker + calendars []Calendar + selected map[int64]bool + cursor int +} + +func newCalendarPicker(calendars []Calendar, selected map[int64]bool) *calendarPicker { + return &calendarPicker{calendars: calendars, selected: selected} +} + +// setCalendars takes the selection HEY answered. The cursor stays where the reader left +// it — they are working down the list — and is pulled back only when the list shrank +// under it. +func (p *calendarPicker) setCalendars(calendars []Calendar, selected map[int64]bool) { + p.calendars = calendars + p.selected = selected + p.cursor = min(p.cursor, max(len(calendars)-1, 0)) } func (p *calendarPicker) moveCursor(msg tea.KeyPressMsg) { - p.cursor = stepListCursor(p.cursor, len(p.names), msg) + p.cursor = stepListCursor(p.cursor, len(p.calendars), msg) } -// selected answers the index into the view's calendars, and -1 when there is nothing -// under the cursor. -func (p *calendarPicker) selected() int { - if p.cursor < 0 || p.cursor >= len(p.names) { - return -1 +// highlighted is the calendar under the cursor, and false when the list is empty. +func (p *calendarPicker) highlighted() (Calendar, bool) { + if p.cursor < 0 || p.cursor >= len(p.calendars) { + return Calendar{}, false } - return p.cursor + return p.calendars[p.cursor], true } +// draw puts the picker over the calendar it was opened from. Its rows carry each +// calendar's own color, so it lays them out itself rather than handing plain names to +// framedList. func (p *calendarPicker) draw(base string, width, height int) string { - return overlayModal(base, framedList("Calendars", p.names, p.cursor, width, height), width, height) + contentWidth := modalContentWidth(width) + visible := modalContentRows(height) + + var rows []string + start, end := modalListWindow(len(p.calendars), p.cursor, visible) + for i := start; i < end; i++ { + calendar := p.calendars[i] + on := p.selected[calendar.ID] + marker := calendarMarkerStyle(calendar.Color).Render(habitMarker(on)) + + label := truncateToWidth(terminal.SanitizeLine(calendar.Name), max(contentWidth-4, 1)) + labelStyle := lipgloss.NewStyle().Foreground(colorBright) + prefix := " " + // A calendar switched off is still a calendar, so it dims rather than + // disappearing — the row is how it gets switched back on. + if !on { + labelStyle = styleMuted + } + if i == p.cursor { + labelStyle = lipgloss.NewStyle().Foreground(colorActive).Bold(true) + prefix = "› " + } + rows = append(rows, prefix+marker+" "+labelStyle.Render(label)) + } + + body := strings.Join(rows, "\n") + if len(p.calendars) == 0 { + body = styleMuted.Render("No other calendars") + } + return overlayModal(base, modalFrame("Calendars", body, width), width, height) +} + +// calendarMarkerStyle is the dot beside a calendar: its own color where HEY gave it one, +// and the reader's own ink where it did not. +func calendarMarkerStyle(calendarColor string) lipgloss.Style { + if slot, ok := heyColors[calendarColor]; ok { + return lipgloss.NewStyle().Foreground(slot).Bold(true) + } + return lipgloss.NewStyle().Foreground(colorBright).Bold(true) } func (p *calendarPicker) helpBindings() []helpBinding { - return []helpBinding{{"↑↓", "choose"}, {"enter", "open"}, {"esc", "close"}} + return []helpBinding{{"↑↓", "choose"}, {"space", "show or hide"}, {"esc", "close"}} } diff --git a/internal/tui/habit_form_test.go b/internal/tui/habit_form_test.go index 8b629a71..c6a0243b 100644 --- a/internal/tui/habit_form_test.go +++ b/internal/tui/habit_form_test.go @@ -195,6 +195,12 @@ func (r *recordedHabitRequests) snapshot() ([]string, []string) { return append([]string(nil), r.requests...), append([]string(nil), r.bodies...) } +// habitRefreshDay is the day these tests sit on, and habitRefreshPath is the read a habit +// write refreshes it with. +var habitRefreshDay = time.Date(2025, 3, 9, 12, 0, 0, 0, time.UTC) + +const habitRefreshPath = "/calendar/days/2025-03-09.json" + func calendarHabitsWithServer(t *testing.T) (*calendarView, *recordedHabitRequests) { t.Helper() recorded := &recordedHabitRequests{} @@ -214,8 +220,9 @@ func calendarHabitsWithServer(t *testing.T) (*calendarView, *recordedHabitReques _, _ = io.WriteString(w, `{"id":99,"type":"Calendar::Habit::Completion","parent_id":7}`) case req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/habits/7/completions.json"): w.WriteHeader(http.StatusNoContent) - case req.Method == http.MethodGet && req.URL.Path == "/calendars/10/recordings.json": - _, _ = io.WriteString(w, `{"Calendar::Habit":[{"id":7,"title":"Read before bed","type":"CalendarHabit","icon":"read","color":"blue","days":[1,3,5]}]}`) + case req.Method == http.MethodGet && req.URL.Path == habitRefreshPath: + _, _ = io.WriteString(w, `{"starts_at":"2025-03-09T00:00:00Z","ends_at":"2025-03-09T23:59:59Z","kind":"day", + "recordings":{"Calendar::Habit":[{"id":7,"title":"Read before bed","type":"CalendarHabit","icon":"read","color":"blue","days":[1,3,5]}]}}`) default: http.NotFound(w, req) } @@ -226,6 +233,9 @@ func calendarHabitsWithServer(t *testing.T) (*calendarView, *recordedHabitReques vc := testVC() vc.sdk = client view := newCalendarView(vc) + // The clock is pinned so the day a habit write refreshes is the one the fake server + // answers: the refresh reads the day the view is on rather than a fixed URL. + view.now = func() time.Time { return habitRefreshDay } view.calendars = []Calendar{{ID: 10, Name: "Rob Zolkos", Personal: true}} view.habits = []Recording{{ID: 7, Title: "Read before bed", Icon: "read", Color: "blue", Days: []int32{1, 3, 5}}} view.Resize(vc.width, vc.height) @@ -247,6 +257,9 @@ func calendarHabitsWithFailingServer(t *testing.T, status int) *calendarView { vc := testVC() vc.sdk = client view := newCalendarView(vc) + // The clock is pinned so the day a habit write refreshes is the one the fake server + // answers: the refresh reads the day the view is on rather than a fixed URL. + view.now = func() time.Time { return habitRefreshDay } view.calendars = []Calendar{{ID: 10, Name: "Rob Zolkos", Personal: true}} view.habits = []Recording{{ID: 7, Title: "Read before bed", Icon: "read", Color: "blue", Days: []int32{1, 3, 5}}} view.Resize(vc.width, vc.height) @@ -288,7 +301,7 @@ func TestCalendarHabitCreateMutationAndRefresh(t *testing.T) { t.Errorf("create state = toast:%q form:%v", toast, view.habitForm) } requests, bodies := recorded.snapshot() - if len(requests) < 2 || requests[0] != "POST /calendar/habits.json" || requests[1] != "GET /calendars/10/recordings.json" { + if len(requests) < 2 || requests[0] != "POST /calendar/habits.json" || requests[1] != "GET "+habitRefreshPath { t.Errorf("requests = %v", requests) } var payload map[string]map[string]any @@ -309,7 +322,7 @@ func TestCalendarHabitEditMutationAndRefresh(t *testing.T) { t.Errorf("toast = %q", toast) } requests, _ := recorded.snapshot() - if len(requests) < 2 || requests[0] != "PATCH /calendar/habits/7.json" || requests[1] != "GET /calendars/10/recordings.json" { + if len(requests) < 2 || requests[0] != "PATCH /calendar/habits/7.json" || requests[1] != "GET "+habitRefreshPath { t.Errorf("requests = %v", requests) } } @@ -444,7 +457,7 @@ func TestCalendarHabitDeleteRequiresConfirmationAndRefresh(t *testing.T) { t.Errorf("delete state = toast:%q confirmed ID:%d", toast, picker.confirmed) } requests, _ := recorded.snapshot() - if len(requests) < 2 || requests[0] != "DELETE /calendar/habits/7.json" || requests[1] != "GET /calendars/10/recordings.json" { + if len(requests) < 2 || requests[0] != "DELETE /calendar/habits/7.json" || requests[1] != "GET "+habitRefreshPath { t.Errorf("requests = %v", requests) } } diff --git a/internal/tui/habits.go b/internal/tui/habits.go index 8e862cd1..1408494d 100644 --- a/internal/tui/habits.go +++ b/internal/tui/habits.go @@ -11,12 +11,17 @@ import ( "github.com/basecamp/hey-cli/internal/terminal" ) -// habitColors stands HEY's eight habit colors up as ANSI slots, for the reason -// styles.go and covers.go give: the reader's terminal theme defines those sixteen, so a -// habit wears its own color in the reader's palette rather than HEY's hex. Gold takes -// the bright yellow and brown the plain one, which is what a dark yellow looks like in -// every theme. -var habitColors = map[string]color.Color{ +// heyColors stands HEY's colors up as ANSI slots, for the reason styles.go and covers.go +// give: the reader's terminal theme defines those sixteen, so a habit or a calendar wears +// its own color in the reader's palette rather than HEY's hex. Gold takes the bright +// yellow and brown the plain one, which is what a dark yellow looks like in every theme. +// +// One vocabulary covers both, because HEY has one: `Calendar::Preference::Colored` is +// where a calendar's color comes from and a habit's is the same enum. Black is a calendar +// color only, and it takes the foreground slot rather than lipgloss.Black — the reader's +// ink, which is dark on a light theme and light on a dark one, where a literal black +// would vanish into half of them. +var heyColors = map[string]color.Color{ "blue": lipgloss.Blue, "red": lipgloss.Red, "gold": lipgloss.BrightYellow, @@ -25,12 +30,13 @@ var habitColors = map[string]color.Color{ "purple": lipgloss.Magenta, "pink": lipgloss.BrightMagenta, "brown": lipgloss.Yellow, + "black": lipgloss.White, } // habitMarkerStyle is the style for a habit's ring: its own color where HEY gave it // one, and the alert red every other waiting thing wears where it did not. func habitMarkerStyle(habitColor string) lipgloss.Style { - if slot, ok := habitColors[habitColor]; ok { + if slot, ok := heyColors[habitColor]; ok { return lipgloss.NewStyle().Foreground(slot).Bold(true) } return lipgloss.NewStyle().Foreground(colorAlert).Bold(true) From 7fa3deb691743ae84cb5ab447b9b38e3878bc82e Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 18:25:09 +0200 Subject: [PATCH 11/19] Draw an event as a block in its calendar's color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which calendar an event is on was not answerable by looking at it. Every event was the same bright bold, so a day with three of them told you when they were and nothing about whose they were. So an event is a filled block now, in all three views: its calendar's color as the background and its name inverted over it, which is what the web app draws. The foreground is colorOnAccent, the one the mail list's pills already use on a filled background, so it stays legible whichever way the theme goes. The color is a new field rather than the one already there — Recording.Color is a habit's own color, and HEY keeps the two apart too. The day's blocks also stop being as tall as their titles. An event's box is the span it covers, so its height has to come from the day: one event is as tall as the grid, two that overlap take half each, three a third, and the title centres in whatever that leaves. A lane never drops below the three rows a block needs, so a day with more overlaps than rows grows and scrolls rather than drawing blocks with no inside. The borders are gone with it — the fill says where an event starts and stops, and an outline drawn in the color read as a box around empty grid. One gap worth knowing about: `_calendar.jbuilder` serves a calendar's color `unless calendar.personal?`, so an event on the reader's own calendar arrives without one and falls back to the theme's accent. Every other client reads that color server-side. Serving it would make the fallback unnecessary. --- internal/tui/calendar.go | 13 +- internal/tui/calendar_test.go | 117 ++++++++++++++--- internal/tui/calendar_views.go | 234 +++++++++++++++++++++------------ 3 files changed, 256 insertions(+), 108 deletions(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index bbe08476..b8378229 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -66,8 +66,14 @@ type Recording struct { CompletedAt string Label string Icon string - Color string - Days []int32 + // Color is a habit's own color. An event has none — what it wears is its + // calendar's, which is CalendarColor, and the two are different fields in HEY too. + Color string + // CalendarColor is the color of the calendar this is filed on, which is how a reader + // tells whose event they are looking at. HEY leaves it empty for the personal + // calendar: `_calendar.jbuilder` serves the color `unless calendar.personal?`. + CalendarColor string + Days []int32 } // --- Calendar messages --- @@ -997,7 +1003,8 @@ func sdkRecordingToModel(r generated.Recording) Recording { ID: r.Id, ParentID: r.ParentId, Title: r.Title, AllDay: r.AllDay, Type: r.Type, StartsAt: formatTimestamp(r.StartsAt), EndsAt: formatTimestamp(r.EndsAt), CompletedAt: formatTimestamp(r.CompletedAt), Label: r.Label, - Icon: r.Icon, Color: r.Color, Days: append([]int32(nil), r.Days...), + Icon: r.Icon, Color: r.Color, CalendarColor: r.Calendar.Color, + Days: append([]int32(nil), r.Days...), } } diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 3951d623..24201b1f 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -693,37 +693,27 @@ func TestDayViewRulesFallFromEveryHourWithoutCuttingIntoAnEvent(t *testing.T) { day := time.Date(2026, 8, 24, 9, 0, 0, 0, time.Local) // 98 columns leaves 96 for the hours once the closing label has its two, which puts - // an hour every four columns and the 11:00 event's box on the four from 44. The + // an hour every four columns and the 11:00 event's block on the four from 44. The // day's header and the hour axis take the first two rows of the 40 it is given, - // leaving 38 for the grid — more than the 25 rows the event's title needs read - // downwards. + // leaving 38 for the grid. lines := strings.Split(stripANSI(renderDayView(events, nil, day, "", 98, 40)), "\n") grid := lines[2:] if len(grid) != 38 { t.Fatalf("grid is %d rows of the 38 left to it: %q", len(grid), grid) } - const eventRows = 25 // "Design review with Ryan" between its two borders + // The event is the only thing on the day, so its block is as tall as the grid and + // holds the 11:00 rule for every row of it. Twenty-four hours are twenty-five rules; + // the block keeps one of them covered all the way down. for i, line := range grid { if cell := []rune(line)[0]; cell != hourRule { t.Errorf("grid row %d lost midnight's rule: %q", i, line) } - cell := []rune(line)[44] - if i < eventRows && cell == hourRule { - t.Errorf("grid row %d ruled through the event's own box: %q", i, line) + if cell := []rune(line)[44]; cell == hourRule { + t.Errorf("grid row %d ruled through the event's own block: %q", i, line) } - if i >= eventRows && cell != hourRule { - t.Errorf("grid row %d below the event kept no rule at 11: %q", i, line) - } - - // Twenty-four hours are twenty-five rules: the day closes where the next one - // starts. The event's box holds one of them for its own height. - want := 25 - if i < eventRows { - want = 24 - } - if rules := strings.Count(line, string(hourRule)); rules != want { - t.Errorf("grid row %d has %d rules, want %d: %q", i, rules, want, line) + if rules := strings.Count(line, string(hourRule)); rules != 24 { + t.Errorf("grid row %d has %d rules, want 24: %q", i, rules, line) } } @@ -751,6 +741,95 @@ func TestEmptyDayIsItsHoursRatherThanANotice(t *testing.T) { } } +// An event's box is the span it covers, not the length of its name: alone it is as tall as +// the day, and events that overlap share the height between them. +func TestOverlappingEventsShareTheDaysHeight(t *testing.T) { + for _, tt := range []struct { + rows, lanes int + want []int + }{ + {38, 1, []int{38}}, + {38, 2, []int{19, 19}}, + {38, 3, []int{13, 13, 12}}, // the odd rows go to the earlier lanes + {38, 0, nil}, + // More overlapping events than rows: a lane keeps the three a block needs and + // the grid grows past the screen instead, which is what the viewport scrolls. + {10, 5, []int{3, 3, 3, 3, 3}}, + } { + got := shareDayRows(tt.rows, tt.lanes) + if len(got) != len(tt.want) { + t.Errorf("%d rows over %d lanes = %v, want %v", tt.rows, tt.lanes, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("%d rows over %d lanes = %v, want %v", tt.rows, tt.lanes, got, tt.want) + break + } + } + } +} + +// And on screen: one event holds its hour's rule all the way down, because its block is the +// whole grid rather than as tall as its title. +func TestDayViewGivesASingleEventTheWholeGrid(t *testing.T) { + day := time.Date(2026, 8, 24, 9, 0, 0, 0, time.Local) + events := []Recording{{ID: 1, Title: "Charge the car", Type: "CalendarEvent", + StartsAt: "2026-08-24T07:00:00Z", EndsAt: "2026-08-24T10:00:00Z"}} + + // An hour every four columns, so 07:00 starts on column 28 and the block covers the + // rules at 28 and 32 for every one of the grid's 38 rows. + grid := strings.Split(stripANSI(renderDayView(events, nil, day, "", 98, 40)), "\n")[2:] + if len(grid) != 38 { + t.Fatalf("grid is %d rows, want 38: %q", len(grid), grid) + } + for i, row := range grid { + if cell := []rune(row)[28]; cell == hourRule { + t.Errorf("grid row %d ruled through the event's block at 07:00: %q", i, row) + } + } +} + +// An event is drawn in the color of the calendar it is filed on, so which calendar it +// belongs to is answered by looking at it. HEY leaves the personal calendar's color out of +// its JSON, and those fall back to the theme's own accent rather than to no fill. +func TestCalendarColorFillsAnEventsBlock(t *testing.T) { + if got := eventFillColor("teal"); got != lipgloss.Cyan { + t.Errorf("teal filled with %v, want cyan", got) + } + if got := eventFillColor("black"); got != lipgloss.White { + t.Errorf("black filled with %v — it takes the foreground slot so it survives a light theme", got) + } + if got := eventFillColor(""); got != colorPrimary { + t.Errorf("an event with no calendar color filled with %v, want the accent", got) + } + + // The title inverts over the fill, using the same foreground the mail list's pills do. + titled := dayCell{kind: cellTitle, color: "teal"}.style(styleMuted, styleMuted, styleMuted) + if titled.GetBackground() != lipgloss.Cyan || titled.GetForeground() != colorOnAccent { + t.Errorf("title style = fg:%v bg:%v", titled.GetForeground(), titled.GetBackground()) + } +} + +// The week and the year draw an event as a filled bar too, off the same field and padded to +// the cell so the fill reads as a block rather than as a highlight behind some words. +func TestEventPillFillsTheCellInItsCalendarsColor(t *testing.T) { + pill := eventPill(Recording{Title: "Standup", CalendarColor: "gold"}, 12) + + if got := stripANSI(pill); got != "Standup " { + t.Errorf("pill text = %q, want the title padded to 12", got) + } + if !strings.Contains(pill, "\x1b[") { + t.Errorf("pill carries no styling: %q", pill) + } + + // A title longer than the cell is cut to it rather than spilling into the next day. + long := eventPill(Recording{Title: "Design review with the whole team", CalendarColor: "teal"}, 12) + if got := lipgloss.Width(stripANSI(long)); got != 12 { + t.Errorf("pill is %d columns wide, want 12", got) + } +} + // A day sized to the room it has must not scroll. It used to by exactly one row: every // section ended its own last line, so the day carried a blank line after it that the // viewport counted. diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index d07ab480..1a72ca9d 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "image/color" "math" "sort" "strings" @@ -213,6 +214,43 @@ const ( cellTitle ) +// dayCell is a cell's kind and, for the cells an event owns, the color of the calendar it +// is filed on. The color rides along with the kind so consecutive cells are batched by +// both: two events touching in the same row are two runs, not one. +type dayCell struct { + kind cellKind + color string +} + +// style is how a cell is drawn. An event is a block filled with its calendar's color and +// its title inverted over it, the way the web app draws its bars — colorOnAccent is the +// same foreground the mail list's pills use on a filled background, so it stays legible on +// either theme. Anything outside an event keeps the grid's own styles. +func (cell dayCell) style(_, _, muted lipgloss.Style) lipgloss.Style { + switch cell.kind { + case cellChrome: + return lipgloss.NewStyle().Background(eventFillColor(cell.color)) + case cellTitle: + return lipgloss.NewStyle(). + Background(eventFillColor(cell.color)). + Foreground(colorOnAccent). + Bold(true) + default: + return muted + } +} + +// eventFillColor is the block an event is drawn as. HEY leaves the personal calendar's +// color out of its JSON, so the reader's own events fall back to the theme's accent rather +// than to no fill at all — an unfilled event among filled ones reads as a different kind of +// thing rather than as one without a color. +func eventFillColor(calendarColor string) color.Color { + if slot, ok := heyColors[calendarColor]; ok { + return slot + } + return colorPrimary +} + // hourRule is dotted rather than solid so an hour's line reads as a guide behind the // events and not as another box's border. const hourRule = '┊' @@ -359,68 +397,68 @@ func renderDayView(events, habits []Recording, anchor time.Time, hint string, wi // the grid, not a decoration on the events, so a day with nothing on it still reads as // a day. It is never shorter than the rows it is given, and grows past them for a day // too full to fit, which is what the viewport scrolls. +// +// The lanes share the height between them: one event on its own is as tall as the day, two +// that overlap take half each, three a third. An event's box was as tall as its title used +// to be, which left a short name looking like a short event and a long one looking like a +// long one — the box is the span, so its size has to come from the day rather than from +// the words in it. func renderDayGrid(lanes [][]placedEvent, gridWidth, colWidth, rows int, chrome, title, muted lipgloss.Style) string { - height := 0 - for _, lane := range lanes { - height += laneHeight(lane) + laneRows := shareDayRows(max(rows, 1), len(lanes)) + height := max(rows, 1) + if total := sumOf(laneRows); total > height { + height = total } - height = max(height, rows, 1) // A 2D grid of runes and a parallel note of what each cell is: the empty grid - // between events, an hour's rule, a box's own chrome, or a rune of its title. - // The four are styled separately so an event's name stands out of its border the - // way a subject stands out of the mail list's rules. + // between events, an hour's rule, a box's own chrome, or a rune of its title — + // carrying, for the last two, the color of the calendar the event is filed on. + // They are styled separately so an event's name stands out of its border the way a + // subject stands out of the mail list's rules. grid := make([][]rune, height) - cells := make([][]cellKind, height) + cells := make([][]dayCell, height) for row := range height { grid[row] = make([]rune, gridWidth) - cells[row] = make([]cellKind, gridWidth) + cells[row] = make([]dayCell, gridWidth) for col := range gridWidth { grid[row][col] = ' ' } } offset := 0 - for _, lane := range lanes { - drawDayLane(grid, cells, lane, offset) - offset += laneHeight(lane) + for i, lane := range lanes { + drawDayLane(grid, cells, lane, offset, laneRows[i]) + offset += laneRows[i] } // The rules go in last and only where nothing else stands: a box is drawn over an // hour, never cut by it. for row := range height { for col := 0; col < gridWidth; col += colWidth { - if cells[row][col] == cellEmpty { + if cells[row][col].kind == cellEmpty { grid[row][col] = hourRule - cells[row][col] = cellRule + cells[row][col] = dayCell{kind: cellRule} } } } - styleFor := map[cellKind]lipgloss.Style{ - cellEmpty: muted, - cellRule: muted, - cellChrome: chrome, - cellTitle: title, - } - - // Render row by row, batching consecutive cells of the same kind + // Render row by row, batching consecutive cells that draw the same way var b strings.Builder for row := range height { var seg strings.Builder - kind := cellEmpty + cell := dayCell{} flush := func() { if s := seg.String(); s != "" { - b.WriteString(styleFor[kind].Render(s)) + b.WriteString(cell.style(chrome, title, muted).Render(s)) seg.Reset() } } for col := range gridWidth { - if cells[row][col] != kind { + if cells[row][col] != cell { flush() - kind = cells[row][col] + cell = cells[row][col] } seg.WriteRune(grid[row][col]) } @@ -431,69 +469,78 @@ func renderDayGrid(lanes [][]placedEvent, gridWidth, colWidth, rows int, chrome, return b.String() } -// laneHeight is the rows a lane needs: its longest title read downwards, between the -// top and bottom borders of its boxes. -func laneHeight(lane []placedEvent) int { - longest := 0 - for _, pe := range lane { - longest = max(longest, len([]rune(terminal.SanitizeLine(pe.rec.Title)))) +// shareDayRows splits the grid's height between the lanes, giving the earlier ones the odd +// row left over. A lane never gets less than a box needs — two borders and a row of title — +// so a day with more overlapping events than rows grows the grid and scrolls instead of +// drawing boxes with no inside. +func shareDayRows(rows, lanes int) []int { + if lanes == 0 { + return nil + } + + const minLaneRows = 3 + share := max(rows/lanes, minLaneRows) + extra := 0 + if share > minLaneRows { + extra = rows - share*lanes + } + + shares := make([]int, lanes) + for i := range shares { + shares[i] = share + if i < extra { + shares[i]++ + } + } + return shares +} + +func sumOf(values []int) int { + total := 0 + for _, value := range values { + total += value } - return longest + 2 + return total } // drawDayLane draws one lane of non-overlapping events into the grid at rowOffset, as -// boxes with vertical (90-degree rotated) title text. -func drawDayLane(grid [][]rune, cells [][]cellKind, lane []placedEvent, rowOffset int) { +// boxes rows tall with vertical (90-degree rotated) title text. Every cell a box owns +// carries the color of the calendar the event is filed on, so which calendar an event +// belongs to is answered by looking at it. +func drawDayLane(grid [][]rune, cells [][]dayCell, lane []placedEvent, rowOffset, rows int) { top := rowOffset - bottom := rowOffset + laneHeight(lane) - 1 + bottom := rowOffset + rows - 1 for _, pe := range lane { sc, ec := pe.startCol, pe.endCol - boxW := ec - sc - titleRunes := []rune(terminal.SanitizeLine(pe.rec.Title)) - - // Top border: ┌──┐ - grid[top][sc] = '┌' - cells[top][sc] = cellChrome - for c := sc + 1; c < ec-1; c++ { - grid[top][c] = '─' - cells[top][c] = cellChrome - } - if boxW > 1 { - grid[top][ec-1] = '┐' - cells[top][ec-1] = cellChrome - } - - // Middle rows: │c │ (vertical title text) - for row := top + 1; row < bottom; row++ { - grid[row][sc] = '│' - cells[row][sc] = cellChrome - if boxW > 1 { - grid[row][ec-1] = '│' - cells[row][ec-1] = cellChrome - } - // Title character - titleIdx := row - top - 1 - if titleIdx < len(titleRunes) && sc+1 < ec-1 { - grid[row][sc+1] = titleRunes[titleIdx] - cells[row][sc+1] = cellTitle - } - // Fill inner space - for c := sc + 2; c < ec-1; c++ { - cells[row][c] = cellChrome + fill := dayCell{kind: cellChrome, color: pe.rec.CalendarColor} + titled := dayCell{kind: cellTitle, color: pe.rec.CalendarColor} + + // The whole block is the event: filled with its calendar's color and carrying no + // border, because the fill already says where it starts and stops. Borders drawn + // in the color left the box reading as an outline around empty grid. + for row := top; row <= bottom; row++ { + for col := sc; col < ec; col++ { + grid[row][col] = ' ' + cells[row][col] = fill } } - // Bottom border: └──┘ - grid[bottom][sc] = '└' - cells[bottom][sc] = cellChrome - for c := sc + 1; c < ec-1; c++ { - grid[bottom][c] = '─' - cells[bottom][c] = cellChrome - } - if boxW > 1 { - grid[bottom][ec-1] = '┘' - cells[bottom][ec-1] = cellChrome + // The title reads downwards, centred in the block: a name at the top of a + // full-height column reads as an event that starts there and stops. It is + // clipped rather than shrinking the block, since the block is the span. + titleRunes := []rune(terminal.SanitizeLine(pe.rec.Title)) + rows := bottom - top + 1 + titleRow := top + max((rows-len(titleRunes))/2, 0) + titleCol := sc + max((ec-sc-1)/2, 0) + + for i, r := range titleRunes { + row := titleRow + i + if row > bottom { + break + } + grid[row][titleCol] = r + cells[row][titleCol] = titled } } } @@ -513,7 +560,6 @@ func renderWeekView(events, habits []Recording, anchor time.Time, firstWeekDay t var b strings.Builder muted := styleMuted bright := lipgloss.NewStyle().Foreground(colorBright) - primary := lipgloss.NewStyle().Foreground(colorPrimary) ws := weekStartDate(anchor, firstWeekDay) @@ -575,7 +621,7 @@ func renderWeekView(events, habits []Recording, anchor time.Time, firstWeekDay t // Build column content cols := make([][]string, 7) for i := range 7 { - cols[i] = buildWeekDayColumn(days[i], colWidth, primary, bright, muted) + cols[i] = buildWeekDayColumn(days[i], colWidth, muted) } maxH := 0 @@ -629,7 +675,7 @@ func weekGridBorder(left, mid, right string, colWidth int, muted lipgloss.Style) // buildWeekDayColumn returns styled lines for one day column. // Order: habits at top, timed events in the middle, all-day at bottom. -func buildWeekDayColumn(d weekDayInfo, width int, primary, bright, muted lipgloss.Style) []string { +func buildWeekDayColumn(d weekDayInfo, width int, muted lipgloss.Style) []string { var lines []string for _, h := range d.habits { @@ -649,16 +695,33 @@ func buildWeekDayColumn(d weekDayInfo, width int, primary, bright, muted lipglos if timeStr != "" { lines = append(lines, muted.Render(timeStr)) } - lines = append(lines, bright.Render(truncateStr(terminal.SanitizeLine(e.Title), width))) + lines = append(lines, eventPill(e, width)) } for _, e := range d.allDay { - lines = append(lines, primary.Render(truncateStr(terminal.SanitizeLine(e.Title), width))) + lines = append(lines, eventPill(e, width)) } return lines } +// eventPill is an event as the week and the year draw it: a bar filled with its calendar's +// color, its name inverted over it, padded to the cell so the fill reads as a block rather +// than as a highlight behind some words. It is the same thing the day view fills its column +// with, and the same thing the web app draws in all three. +func eventPill(event Recording, width int) string { + title := truncateStr(terminal.SanitizeLine(event.Title), width) + if pad := width - lipgloss.Width(title); pad > 0 { + title += strings.Repeat(" ", pad) + } + + return lipgloss.NewStyle(). + Background(eventFillColor(event.CalendarColor)). + Foreground(colorOnAccent). + Bold(true). + Render(title) +} + // weekDayColumnLabel returns the header label for a week column. func weekDayColumnLabel(d time.Time, isFirstCol bool) string { dayName := strings.ToUpper(d.Weekday().String()[:3]) @@ -796,10 +859,9 @@ func buildYearDayCell(d time.Time, dayEvents []Recording, colWidth int, return lines } - // Event titles + // Event titles, each a bar in its calendar's color for _, event := range dayEvents { - title := truncateStr(terminal.SanitizeLine(event.Title), colWidth) - lines = append(lines, bright.Render(title)) + lines = append(lines, eventPill(event, colWidth)) } return lines From 2598a61cf9dc64ea2bd5f92b90f3a5190c236180 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 19:55:27 +0200 Subject: [PATCH 12/19] Pick an event's ink from the fill it sits on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit colorOnAccent answers this for the theme's accent and no further, so every event got black text whatever it was filled with. Black on ANSI red or blue is about 2:1, which is why a light terminal full of filled events was hard to look at while the same fills read fine on a dark one. So the ink is asked per fill now, the way applyTheme already asks it for the accent: whichever of black or bright white reads better on this particular background. A gold event keeps its black text; a red or a blue one gets white. It is decided while rendering rather than cached in newStyles, which is what lets it follow a theme switch. Omarchy retints a running terminal on a keyboard shortcut, so an event filled with the accent — every event on the personal calendar, since HEY does not serve that calendar's color — has to re-ask on the next frame rather than keep the answer it got at startup. --- internal/tui/calendar_test.go | 46 +++++++++++++++++++++++++++++++--- internal/tui/calendar_views.go | 33 +++++++++++++++--------- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 24201b1f..fbd77e13 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -2,6 +2,7 @@ package tui import ( "errors" + "image/color" "net/http" "net/http/httptest" "strings" @@ -804,10 +805,47 @@ func TestCalendarColorFillsAnEventsBlock(t *testing.T) { t.Errorf("an event with no calendar color filled with %v, want the accent", got) } - // The title inverts over the fill, using the same foreground the mail list's pills do. - titled := dayCell{kind: cellTitle, color: "teal"}.style(styleMuted, styleMuted, styleMuted) - if titled.GetBackground() != lipgloss.Cyan || titled.GetForeground() != colorOnAccent { - t.Errorf("title style = fg:%v bg:%v", titled.GetForeground(), titled.GetBackground()) + // The title sits on its own fill in whichever of ink or paper reads better there, + // asked per color: black on ANSI red or blue is around 2:1, which is what made a + // light terminal full of filled events hard to look at. + for _, calendarColor := range []string{"teal", "red", "blue", "gold", "green", ""} { + fill := eventFillColor(calendarColor) + style := dayCell{kind: cellTitle, color: calendarColor}.style(styleMuted, styleMuted, styleMuted) + + if style.GetBackground() != fill { + t.Errorf("%q sits on %v, want its own fill %v", calendarColor, style.GetBackground(), fill) + } + text := style.GetForeground() + if contrastRatio(text, fill) < contrastRatio(oppositeInk(text), fill) { + t.Errorf("%q drew %v on %v at %.1f:1 when the other ink reads better", + calendarColor, text, fill, contrastRatio(text, fill)) + } + } +} + +func oppositeInk(text color.Color) color.Color { + if text == color.Color(lipgloss.Black) { + return lipgloss.BrightWhite + } + return lipgloss.Black +} + +// Omarchy retints a running terminal on a keyboard shortcut, so an event's ink cannot be +// decided once at startup. The styles are built while rendering rather than cached in +// newStyles, so applyTheme is all a theme switch has to do. +func TestEventInkFollowsALiveThemeChange(t *testing.T) { + t.Cleanup(func() { applyTheme(Theme{Accent: lipgloss.BrightBlue, Bright: lipgloss.BrightWhite, Dark: true}) }) + + // An event with no calendar color is filled with the accent, so the accent decides + // its ink — and the accent is what a theme switch replaces. + applyTheme(Theme{Accent: lipgloss.BrightYellow, Bright: lipgloss.BrightWhite, Dark: true}) + onLight := eventPill(Recording{Title: "Release HEY CLI 0.9"}, 20) + + applyTheme(Theme{Accent: lipgloss.Blue, Bright: lipgloss.BrightWhite, Dark: true}) + onDark := eventPill(Recording{Title: "Release HEY CLI 0.9"}, 20) + + if onLight == onDark { + t.Errorf("the pill did not follow the theme: %q", onLight) } } diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 1a72ca9d..cde929f0 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -223,18 +223,14 @@ type dayCell struct { } // style is how a cell is drawn. An event is a block filled with its calendar's color and -// its title inverted over it, the way the web app draws its bars — colorOnAccent is the -// same foreground the mail list's pills use on a filled background, so it stays legible on -// either theme. Anything outside an event keeps the grid's own styles. +// its title inverted over it, the way the web app draws its bars. Anything outside an event +// keeps the grid's own styles. func (cell dayCell) style(_, _, muted lipgloss.Style) lipgloss.Style { switch cell.kind { case cellChrome: return lipgloss.NewStyle().Background(eventFillColor(cell.color)) case cellTitle: - return lipgloss.NewStyle(). - Background(eventFillColor(cell.color)). - Foreground(colorOnAccent). - Bold(true) + return eventTextStyle(eventFillColor(cell.color)) default: return muted } @@ -251,6 +247,23 @@ func eventFillColor(calendarColor string) color.Color { return colorPrimary } +// eventTextStyle is a title over its own fill, in whichever of the terminal's ink or paper +// reads better on it. colorOnAccent picks that for the theme's accent and no further: black +// on ANSI red or blue is about 2:1, which is what made a light terminal full of filled +// events hard to look at even though the same fills read fine on a dark one. Every calendar +// color is a different background, so each one is asked separately. +func eventTextStyle(fill color.Color) lipgloss.Style { + text := colorOnAccent + if _, colorless := fill.(lipgloss.NoColor); !colorless { + text = color.Color(lipgloss.Black) + if contrastRatio(lipgloss.BrightWhite, fill) > contrastRatio(lipgloss.Black, fill) { + text = lipgloss.BrightWhite + } + } + + return lipgloss.NewStyle().Background(fill).Foreground(text).Bold(true) +} + // hourRule is dotted rather than solid so an hour's line reads as a guide behind the // events and not as another box's border. const hourRule = '┊' @@ -715,11 +728,7 @@ func eventPill(event Recording, width int) string { title += strings.Repeat(" ", pad) } - return lipgloss.NewStyle(). - Background(eventFillColor(event.CalendarColor)). - Foreground(colorOnAccent). - Bold(true). - Render(title) + return eventTextStyle(eventFillColor(event.CalendarColor)).Render(title) } // weekDayColumnLabel returns the header label for a week column. From 73578dc69149e7c8e6e7dc0c55651edb91e324c9 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 20:59:31 +0200 Subject: [PATCH 13/19] Take a theme at its word about what its colors are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ANSI slot's nominal value says nothing about what a reader sees. A theme retints the running terminal over OSC 4, so blue is #000080 in the table and a light periwinkle on screen — and contrast measured against #000080 put white text on it, which is why green and blue were the hard ones to make out. Omarchy's colors.toml has been answering this all along: background = "#060B1E" blue = "#7d82d9" green = "#92a593" red = "#ED5B5A" So Theme carries Background and Hues now, an event is filled with the theme's own value for its calendar's color, and the ink is whichever of the theme's paper or its text color contrasts better with that fill. Two colors the theme states, measured against each other. On a dark theme every hue arrives light and they all take the dark paper; on a light one the same hues arrive deep and the rule flips by itself, with nothing here asking which mode it is in. That is also why the light-mode blue was dark on dark. colorPaper came from theme.Dark, and a theme file's mode wins over the terminal's own report by design — so a light theme whose file says `mode = "dark"` got black ink on a light background. Reading `background` sidesteps the question. A terminal with no theme file still gets the ANSI slots and their nominal values, which is correct there: nothing retinted them. --- internal/tui/calendar_test.go | 91 ++++++++++++++++++++++++---------- internal/tui/calendar_views.go | 45 +++++++++++------ internal/tui/styles.go | 16 ++++++ internal/tui/theme.go | 41 +++++++++++++++ internal/tui/theme_test.go | 6 ++- 5 files changed, 157 insertions(+), 42 deletions(-) diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index fbd77e13..186f6acd 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -795,39 +795,70 @@ func TestDayViewGivesASingleEventTheWholeGrid(t *testing.T) { // belongs to is answered by looking at it. HEY leaves the personal calendar's color out of // its JSON, and those fall back to the theme's own accent rather than to no fill. func TestCalendarColorFillsAnEventsBlock(t *testing.T) { + ansiTheme := Theme{Accent: lipgloss.BrightBlue, Bright: lipgloss.BrightWhite, Dark: true} + t.Cleanup(func() { applyTheme(ansiTheme) }) + + // With no theme file the ANSI slots are all there is, and their nominal values are + // what the terminal really draws. + applyTheme(ansiTheme) if got := eventFillColor("teal"); got != lipgloss.Cyan { t.Errorf("teal filled with %v, want cyan", got) } - if got := eventFillColor("black"); got != lipgloss.White { - t.Errorf("black filled with %v — it takes the foreground slot so it survives a light theme", got) - } if got := eventFillColor(""); got != colorPrimary { t.Errorf("an event with no calendar color filled with %v, want the accent", got) } - // The title sits on its own fill in whichever of ink or paper reads better there, - // asked per color: black on ANSI red or blue is around 2:1, which is what made a - // light terminal full of filled events hard to look at. - for _, calendarColor := range []string{"teal", "red", "blue", "gold", "green", ""} { - fill := eventFillColor(calendarColor) - style := dayCell{kind: cellTitle, color: calendarColor}.style(styleMuted, styleMuted, styleMuted) + // A theme that states its hues is taken at its word, because that is what the reader + // sees. This is the dark Omarchy palette from the screenshots: its blue is a light + // periwinkle, nothing like ANSI blue's nominal #000080. + applyTheme(Theme{ + Accent: lipgloss.Color("#7d82d9"), Bright: lipgloss.Color("#ffcead"), Dark: true, + Background: lipgloss.Color("#060B1E"), + Hues: map[string]color.Color{ + "blue": lipgloss.Color("#7d82d9"), + "green": lipgloss.Color("#92a593"), + "red": lipgloss.Color("#ED5B5A"), + "gold": lipgloss.Color("#f7dc9c"), + }, + }) - if style.GetBackground() != fill { - t.Errorf("%q sits on %v, want its own fill %v", calendarColor, style.GetBackground(), fill) + if got := eventFillColor("blue"); got != lipgloss.Color("#7d82d9") { + t.Errorf("blue filled with %v, want the theme's own periwinkle", got) + } + + // Every one of those is light, so every one takes the theme's dark paper as its ink — + // which is what nominal contrast got backwards on green and blue. + for _, calendarColor := range []string{"blue", "green", "red", "gold"} { + style := dayCell{kind: cellTitle, color: calendarColor}.style(styleMuted, styleMuted, styleMuted) + if style.GetBackground() != eventFillColor(calendarColor) { + t.Errorf("%q sits on %v, want its own fill", calendarColor, style.GetBackground()) } - text := style.GetForeground() - if contrastRatio(text, fill) < contrastRatio(oppositeInk(text), fill) { - t.Errorf("%q drew %v on %v at %.1f:1 when the other ink reads better", - calendarColor, text, fill, contrastRatio(text, fill)) + if style.GetForeground() != colorPaper { + t.Errorf("%q drew %v on a light hue, want the theme's paper %v", + calendarColor, style.GetForeground(), colorPaper) } } } -func oppositeInk(text color.Color) color.Color { - if text == color.Color(lipgloss.Black) { - return lipgloss.BrightWhite +// The ink is whichever of the theme's paper and its own text color reads better on the +// fill, so a light theme — where the same hues arrive deep rather than pale — gets the +// other one without any of this knowing which mode it is in. +func TestEventInkFollowsTheThemesOwnPalette(t *testing.T) { + t.Cleanup(func() { applyTheme(Theme{Accent: lipgloss.BrightBlue, Bright: lipgloss.BrightWhite, Dark: true}) }) + + applyTheme(Theme{ + Accent: lipgloss.Color("#2b4c8c"), Bright: lipgloss.Color("#1c1c1c"), Dark: false, + Background: lipgloss.Color("#fafafa"), + Hues: map[string]color.Color{"blue": lipgloss.Color("#2b4c8c")}, + }) + + style := dayCell{kind: cellTitle, color: "blue"}.style(styleMuted, styleMuted, styleMuted) + if style.GetForeground() != colorPaper { + t.Errorf("a deep blue on a light theme drew %v, want the pale paper", style.GetForeground()) + } + if contrastRatio(style.GetForeground(), style.GetBackground()) < 4.5 { + t.Errorf("ink on fill is only %.1f:1", contrastRatio(style.GetForeground(), style.GetBackground())) } - return lipgloss.Black } // Omarchy retints a running terminal on a keyboard shortcut, so an event's ink cannot be @@ -836,16 +867,22 @@ func oppositeInk(text color.Color) color.Color { func TestEventInkFollowsALiveThemeChange(t *testing.T) { t.Cleanup(func() { applyTheme(Theme{Accent: lipgloss.BrightBlue, Bright: lipgloss.BrightWhite, Dark: true}) }) - // An event with no calendar color is filled with the accent, so the accent decides - // its ink — and the accent is what a theme switch replaces. - applyTheme(Theme{Accent: lipgloss.BrightYellow, Bright: lipgloss.BrightWhite, Dark: true}) - onLight := eventPill(Recording{Title: "Release HEY CLI 0.9"}, 20) + applyTheme(Theme{ + Accent: lipgloss.BrightBlue, Bright: lipgloss.Color("#ffcead"), Dark: true, + Background: lipgloss.Color("#060B1E"), + Hues: map[string]color.Color{"green": lipgloss.Color("#92a593")}, + }) + onDark := eventPill(Recording{Title: "Summer friday", CalendarColor: "green"}, 20) - applyTheme(Theme{Accent: lipgloss.Blue, Bright: lipgloss.BrightWhite, Dark: true}) - onDark := eventPill(Recording{Title: "Release HEY CLI 0.9"}, 20) + applyTheme(Theme{ + Accent: lipgloss.BrightBlue, Bright: lipgloss.Color("#1c1c1c"), Dark: false, + Background: lipgloss.Color("#fafafa"), + Hues: map[string]color.Color{"green": lipgloss.Color("#1f5c2f")}, + }) + onLight := eventPill(Recording{Title: "Summer friday", CalendarColor: "green"}, 20) - if onLight == onDark { - t.Errorf("the pill did not follow the theme: %q", onLight) + if onDark == onLight { + t.Errorf("the pill did not follow the theme: %q", onDark) } } diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index cde929f0..77d171ec 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -230,7 +230,7 @@ func (cell dayCell) style(_, _, muted lipgloss.Style) lipgloss.Style { case cellChrome: return lipgloss.NewStyle().Background(eventFillColor(cell.color)) case cellTitle: - return eventTextStyle(eventFillColor(cell.color)) + return eventTextStyle(cell.color) default: return muted } @@ -241,29 +241,46 @@ func (cell dayCell) style(_, _, muted lipgloss.Style) lipgloss.Style { // than to no fill at all — an unfilled event among filled ones reads as a different kind of // thing rather than as one without a color. func eventFillColor(calendarColor string) color.Color { + // The theme's own value for the hue where it gave one, since that is what the reader + // sees; the ANSI slot otherwise, so a terminal with no theme file still gets color. + if hue, ok := colorHues[calendarColor]; ok { + return hue + } if slot, ok := heyColors[calendarColor]; ok { return slot } return colorPrimary } -// eventTextStyle is a title over its own fill, in whichever of the terminal's ink or paper -// reads better on it. colorOnAccent picks that for the theme's accent and no further: black -// on ANSI red or blue is about 2:1, which is what made a light terminal full of filled -// events hard to look at even though the same fills read fine on a dark one. Every calendar -// color is a different background, so each one is asked separately. -func eventTextStyle(fill color.Color) lipgloss.Style { - text := colorOnAccent - if _, colorless := fill.(lipgloss.NoColor); !colorless { - text = color.Color(lipgloss.Black) - if contrastRatio(lipgloss.BrightWhite, fill) > contrastRatio(lipgloss.Black, fill) { - text = lipgloss.BrightWhite - } +// eventTextStyle is a title over the fill of the calendar it is on, in whichever of the +// theme's paper and its own ink reads better there. Both candidates are the reader's own, +// so the answer is the theme's rather than a guess about it. +// +// It is a real measurement now because the theme says what its hues actually are. Against +// an ANSI slot's nominal value it could only ever be wrong: #000080 for blue reads as +// nearly black, so contrast picked white text for what a dark theme draws as a light +// periwinkle. With the theme's own #7d82d9 the same arithmetic picks dark text, which is +// what the eye wanted all along. +func eventTextStyle(calendarColor string) lipgloss.Style { + fill := eventFillColor(calendarColor) + + text := colorPaper + if ink := themeInk(); contrastRatio(ink, fill) > contrastRatio(colorPaper, fill) { + text = ink } return lipgloss.NewStyle().Background(fill).Foreground(text).Bold(true) } +// themeInk is the color the theme writes its own text in, which is the other candidate for +// a title on a fill. +func themeInk() color.Color { + if colorBright != nil { + return colorBright + } + return lipgloss.BrightWhite +} + // hourRule is dotted rather than solid so an hour's line reads as a guide behind the // events and not as another box's border. const hourRule = '┊' @@ -728,7 +745,7 @@ func eventPill(event Recording, width int) string { title += strings.Repeat(" ", pad) } - return eventTextStyle(eventFillColor(event.CalendarColor)).Render(title) + return eventTextStyle(event.CalendarColor).Render(title) } // weekDayColumnLabel returns the header label for a week column. diff --git a/internal/tui/styles.go b/internal/tui/styles.go index f481e101..09523556 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -39,6 +39,14 @@ var ( colorSelection color.Color // cursor row background; nil means none colorOnAccent color.Color = lipgloss.Black // pill text on an accent-filled background + + // colorPaper is the terminal's own background and colorHues are the colors it renders + // the ANSI slots as, both taken from the theme when it says. They are what anything + // drawn *on* a hue needs: an ANSI slot's nominal value says nothing about what a + // reader sees, since a theme retints the running terminal over OSC 4. ANSI blue is + // #000080 nominally and a light periwinkle in a dark Omarchy theme. + colorPaper color.Color = lipgloss.Black + colorHues map[string]color.Color ) // styleMuted dims the theme's default foreground with the SGR faint @@ -73,6 +81,14 @@ func applyTheme(theme Theme) { contrastRatio(lipgloss.BrightWhite, theme.Accent) > contrastRatio(lipgloss.Black, theme.Accent) { colorOnAccent = lipgloss.BrightWhite } + colorHues = theme.Hues + colorPaper = theme.Background + if colorPaper == nil { + colorPaper = lipgloss.Black + if !theme.Dark { + colorPaper = lipgloss.BrightWhite + } + } if !theme.Dark && theme.Bright == lipgloss.BrightWhite { // Bright white is the background on a light terminal; ANSI black is its text. colorBright = lipgloss.Black diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 81315c61..093bf2c8 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -24,6 +24,15 @@ type Theme struct { Bright color.Color Error color.Color + // Background is the theme's own paper, and Hues are the colors it renders the ANSI + // slots as — both nil when no theme file said. They matter for anything drawn *on* + // a hue: an ANSI slot's nominal value says nothing about what a reader sees, since + // a theme retints the running terminal over OSC 4. ANSI blue is #000080 nominally + // and a light periwinkle in a dark Omarchy theme, so ink picked against the nominal + // value comes out backwards. Keyed by the color names HEY uses. + Background color.Color + Hues map[string]color.Color + // Dark reports whether the theme is for a dark background. HasMode is true when // a theme file said so; otherwise Dark is a guess the terminal can correct. Dark bool @@ -217,6 +226,10 @@ func overlayTheme(base Theme, values map[string]string, trusted bool) Theme { if c, ok := themeColor(values, "error", "red"); ok { theme.Error = c } + if c, ok := themeColor(values, "background"); ok { + theme.Background = c + } + theme.Hues = themeHues(values) switch strings.ToLower(values["mode"]) { case "dark": theme.Dark, theme.HasMode = true, true @@ -267,6 +280,34 @@ func colorDistance(a, b color.Color) float64 { return math.Sqrt(d(ar, br)*d(ar, br) + d(ag, bg)*d(ag, bg) + d(ab, bb)*d(ab, bb)) } +// themeHues reads the colors a theme renders the ANSI slots as, under the names HEY gives +// its own. Gold and brown take the bright and the plain yellow, as heyColors does, and a +// key a theme leaves out is simply absent — the caller falls back to the ANSI slot. +func themeHues(values map[string]string) map[string]color.Color { + keys := map[string][]string{ + "blue": {"blue"}, + "red": {"red"}, + "gold": {"bright_yellow", "yellow"}, + "green": {"green"}, + "teal": {"cyan"}, + "purple": {"magenta"}, + "pink": {"bright_magenta", "magenta"}, + "brown": {"brown", "yellow"}, + "black": {"foreground", "bright_foreground"}, + } + + hues := make(map[string]color.Color, len(keys)) + for name, candidates := range keys { + if c, ok := themeColor(values, candidates...); ok { + hues[name] = c + } + } + if len(hues) == 0 { + return nil + } + return hues +} + // themeColor returns the first key that holds a valid hex color. func themeColor(values map[string]string, keys ...string) (color.Color, bool) { for _, key := range keys { diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go index fb796326..f5f2023b 100644 --- a/internal/tui/theme_test.go +++ b/internal/tui/theme_test.go @@ -172,8 +172,12 @@ func TestResolveThemeOrder(t *testing.T) { } func TestResolveThemeWithoutOmarchy(t *testing.T) { + // Theme carries a map of the theme's own hues now, so it is compared field by field + // rather than as a value. theme := resolveTheme(envOf(nil), t.TempDir()) - if theme != defaultTheme() { + if want := defaultTheme(); theme.Accent != want.Accent || theme.Muted != want.Muted || + theme.Bright != want.Bright || theme.Error != want.Error || theme.Dark != want.Dark || + theme.Source != want.Source || theme.Background != nil || theme.Hues != nil { t.Errorf("non-omarchy machine should get the ANSI defaults, got %+v", theme) } if omarchyWatchDir(t.TempDir()) != "" { From f4346f0cf19ec34db20fac78de7d127f76c29d0e Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 21:08:08 +0200 Subject: [PATCH 14/19] Draw events on the calendar, and only events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A calendar carries a day's own records alongside its events: a `Calendar::JournalEntry` where the day has been written on, a `Calendar::DayBackground` where it has a picture, a `Calendar::TimeTrack` where time was logged. splitRecordings named the todos and the habits and let everything else fall through to events, so all of those were drawn on the grid. A journal entry has no title, and a grid draws an event as its name — so it came out as a bar of bare color across the day. The fill made it obvious; the empty box was there before and just looked like grid. So the classifier names what it wants instead of skipping what it does not. A time track has a title and is still not an event, which is why "no title" was the wrong test for this. --- internal/tui/calendar_test.go | 25 +++++++++++++++++++++++++ internal/tui/calendar_views.go | 14 +++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 186f6acd..f21054eb 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -612,6 +612,31 @@ func TestHabitCompletionsMarkTheirHabitRatherThanListingThemselves(t *testing.T) } } +// A calendar carries a day's own records alongside its events. Only the events are drawn: +// a journal entry taken for one came out as a bar of bare color across the day. +func TestOnlyEventsAreDrawnOnTheGrid(t *testing.T) { + events, todos, habits := splitRecordings([]Recording{ + {ID: 169118695, Title: "Stanko & Kevin", Type: "Calendar::Event", StartsAt: "2026-08-20T14:00:00Z"}, + // The journal entry behind the stray stripe, as HEY answered it: no title. + {ID: 171477000, Type: "Calendar::JournalEntry", AllDay: true, StartsAt: "2026-08-20T00:00:00Z"}, + {ID: 171477001, Type: "Calendar::DayBackground", AllDay: true, StartsAt: "2026-08-20T00:00:00Z"}, + // A time track has a name, and still is not an event. + {ID: 171477002, Title: "Design work", Type: "Calendar::TimeTrack", StartsAt: "2026-08-20T09:00:00Z"}, + {ID: 171477003, Title: "Clean the attic", Type: "Calendar::Todo"}, + {ID: 14796085, Title: "Read", Type: "Calendar::Habit"}, + }) + + if len(events) != 1 || events[0].Title != "Stanko & Kevin" { + t.Errorf("events = %+v, want the one event alone", events) + } + if len(todos) != 1 || todos[0].Title != "Clean the attic" { + t.Errorf("todos = %+v", todos) + } + if len(habits) != 1 || habits[0].Title != "Read" { + t.Errorf("habits = %+v", habits) + } +} + func TestHabitsModalOpensOverTheCalendarAndManagesHabits(t *testing.T) { v := newCalendarView(testVC()) v.vc.width, v.vc.height = 80, 20 diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 77d171ec..b16fec20 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -54,8 +54,16 @@ func weekStartDate(t time.Time, firstDay time.Weekday) time.Time { return d.AddDate(0, 0, -diff) } -// splitRecordings separates recordings into events, todos, and habits. -// The API returns Type values like "CalendarEvent", "CalendarTodo", "Habit". +// splitRecordings picks the events, the todos and the habits out of what a calendar holds, +// and takes nothing else. A calendar carries a day's own records alongside its events — a +// `Calendar::JournalEntry` where the day has been written on, a `Calendar::DayBackground` +// where it has a picture, a `Calendar::TimeTrack` where time was logged — and none of those +// belong on a grid of events. Naming what is wanted rather than skipping what is not is the +// point: the grid drew a journal entry as a bar of bare color, because it was an event by +// default and had no name to put in it. +// +// HEY's type names are namespaced — `Calendar::Event`, `Calendar::Habit::Completion` — which +// is why these match on a substring rather than on the whole string. func splitRecordings(recs []Recording) (events, todos, habits []Recording) { // Doing a habit is a recording of its own — a `Calendar::Habit::Completion` // carrying nothing but the habit it belongs to, since HEY records the doing rather @@ -81,7 +89,7 @@ func splitRecordings(recs []Recording) (events, todos, habits []Recording) { r.CompletedAt = done } habits = append(habits, r) - default: + case strings.Contains(t, "event"): events = append(events, r) } } From 4261bbc33c281541cfb5be419a4ba495f84c51d2 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 21:19:24 +0200 Subject: [PATCH 15/19] Show the week what was kept each day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The week gets a band across the top holding the habits kept on each day, as their icons alone: there is room for seven days of icons and none for seven days of names. Every day's band is as tall as the busiest one, so the rule closing it off is straight and each day's events start level with its neighbours'. A week nobody kept a habit in has no band and no rule rather than an empty stripe. Getting there turned up that the week has never shown a habit at all. It matched a habit's StartsAt against each day, but that is the day the habit was taken up — Read starts in 2024 — so it never landed in the week on screen. Which day a habit was kept on is what its completions say, and those were folded into a single CompletedAt and then dropped. Folding is lossy past one day: a habit kept on three days of a week has three completions and only the last survived. So splitRecordings answers them alongside the fold now, and the week joins a completion to its habit by parent_id for the icon and the color. The day view keeps the fold, which is the right shape for one day. The habits leave the day columns with this, since the band is where they are now. padTo comes back — it went out with the year view rewrite that got reverted. --- internal/tui/calendar.go | 9 ++- internal/tui/calendar_test.go | 93 +++++++++++++++++++++++++++- internal/tui/calendar_views.go | 108 +++++++++++++++++++++++++++------ 3 files changed, 188 insertions(+), 22 deletions(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index b8378229..4c723bc1 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -172,6 +172,11 @@ type calendarView struct { todos []Recording habits []Recording + // habitCompletions is which habit was done on which day, which the week needs and a + // habit's own CompletedAt cannot carry: over a week a habit has one per day it was + // done on, and folding them keeps only the last. + habitCompletions []Recording + // year is what the year span draws, and it is a different answer than the // recordings above rather than a summary of them. year CalendarYear @@ -250,7 +255,7 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { if cmd, ok := v.requests.settle(msg.requestResult); !ok { return cmd, true } - v.events, v.todos, v.habits = splitRecordings(msg.recordings) + v.events, v.todos, v.habits, v.habitCompletions = splitRecordings(msg.recordings) if v.habitPicker != nil { v.habitPicker.setHabits(v.manageableHabits()) } @@ -769,7 +774,7 @@ func (v *calendarView) rebuildView() { case viewDay: content = renderDayView(v.events, v.habits, anchor, v.stepHint(), w, v.contentVP.Height()) case viewWeek: - content = renderWeekView(v.events, v.habits, anchor, v.firstWeekDay, w, h, dayLabels) + content = renderWeekView(v.events, v.habits, v.habitCompletions, anchor, v.firstWeekDay, w, h, dayLabels) case viewYear: // The year's events are HEY's spanned_events — the all-day and multi-day ones — // because that is all a year read carries. eventsByDate spreads a multi-day event diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index f21054eb..d942171a 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -592,12 +592,18 @@ func TestCalendarStepsByTheUnitTheViewShows(t *testing.T) { func TestHabitCompletionsMarkTheirHabitRatherThanListingThemselves(t *testing.T) { // HEY answers a habit and its completion as separate recordings, the completion // carrying no title and naming its habit in parent_id. - events, todos, habits := splitRecordings([]Recording{ + events, todos, habits, completions := splitRecordings([]Recording{ {ID: 14796085, Title: "Read", Type: "Calendar::Habit", Icon: "read"}, {ID: 14113260, Title: "Meditate", Type: "Calendar::Habit", Icon: "meditate"}, {ID: 171477412, Type: "Calendar::Habit::Completion", ParentID: 14796085, StartsAt: "2026-08-22T00:00:00Z"}, }) + // The completion is answered as well as folded: a week needs to know which day each + // one landed on, which a single CompletedAt cannot say. + if len(completions) != 1 || completions[0].ParentID != 14796085 { + t.Errorf("completions = %+v, want the one that marked Read", completions) + } + if len(events) != 0 || len(todos) != 0 { t.Errorf("a completion is neither an event nor a to-do: events=%v todos=%v", events, todos) } @@ -612,10 +618,93 @@ func TestHabitCompletionsMarkTheirHabitRatherThanListingThemselves(t *testing.T) } } +// --- Week: the habits band --- + +// weekWithHabits is a week where five habits were kept on the Monday, one on the Tuesday +// and two on the Thursday, plus one event, rendered at 100 columns. +func weekWithHabits(t *testing.T, completions []Recording) []string { + t.Helper() + + habits := []Recording{ + {ID: 1, Title: "Meditate", Icon: "meditate", Color: "purple", Type: "Calendar::Habit"}, + {ID: 2, Title: "Work out", Icon: "weights", Color: "red", Type: "Calendar::Habit"}, + {ID: 3, Title: "Write", Icon: "write", Color: "gold", Type: "Calendar::Habit"}, + {ID: 4, Title: "Read", Icon: "read", Color: "green", Type: "Calendar::Habit"}, + {ID: 5, Title: "Learn a language", Icon: "study", Color: "teal", Type: "Calendar::Habit"}, + } + events := []Recording{ + {ID: 9, Title: "Stanko & Kevin", CalendarColor: "blue", Type: "Calendar::Event", + StartsAt: "2026-08-20T14:00:00Z", EndsAt: "2026-08-20T15:00:00Z"}, + } + + anchor := time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC) + out := renderWeekView(events, habits, completions, anchor, time.Monday, 100, 30, nil) + return strings.Split(stripANSI(out), "\n") +} + +func habitDone(habitID int64, day string) Recording { + return Recording{Type: "Calendar::Habit::Completion", ParentID: habitID, StartsAt: day + "T00:00:00Z"} +} + +// The week says what was kept each day, as icons — it has room for seven days of those and +// none for seven days of names. Every day's band is the same height, so the rule under it +// is straight and each day's events start level with its neighbours'. +func TestWeekDrawsTheHabitsKeptEachDay(t *testing.T) { + lines := weekWithHabits(t, []Recording{ + habitDone(1, "2026-08-17"), habitDone(2, "2026-08-17"), habitDone(3, "2026-08-17"), + habitDone(4, "2026-08-17"), habitDone(5, "2026-08-17"), + habitDone(1, "2026-08-18"), + habitDone(1, "2026-08-20"), habitDone(4, "2026-08-20"), + }) + + // Rows 0-2 are the top border, the day names and the header rule; the band follows. + band := lines[3:5] + if !strings.Contains(band[0], "🧘") || !strings.Contains(band[0], "📖") { + t.Errorf("Monday's habits are not in the band: %q", band[0]) + } + // Five icons do not fit one cell, so the band is two rows — for every day, not just + // the one that needed it. + if !strings.Contains(band[1], "📚") { + t.Errorf("the fifth habit did not wrap onto a second row: %q", band[1]) + } + if lipgloss.Width(band[0]) != lipgloss.Width(band[1]) { + t.Errorf("band rows are %d and %d wide", lipgloss.Width(band[0]), lipgloss.Width(band[1])) + } + + // A rule closes the band off from the day's own content. + if rule := lines[5]; !strings.HasPrefix(rule, "├") || !strings.Contains(rule, "┼") { + t.Errorf("no rule under the band: %q", rule) + } + // And the day's events are below it, not in it. + if !strings.Contains(lines[7], "Stanko & Kev") { + t.Errorf("the event is not under the band: %q", lines[6:8]) + } + for _, row := range band { + if strings.Contains(row, "Meditate") || strings.Contains(row, "Stanko") { + t.Errorf("the band carries a name, not just icons: %q", row) + } + } +} + +// A week nobody kept a habit in gets no band and no rule, rather than an empty stripe. +func TestWeekWithoutHabitsHasNoBand(t *testing.T) { + lines := weekWithHabits(t, nil) + + // The header rule is row 2, and the day's own content starts straight after it. + if !strings.Contains(lines[3], "14:00") { + t.Errorf("row 3 should be the day's content, got %q", lines[3]) + } + for i, line := range lines { + if strings.Contains(line, "🧘") || strings.Contains(line, "📖") { + t.Errorf("row %d drew a habit nobody kept: %q", i, line) + } + } +} + // A calendar carries a day's own records alongside its events. Only the events are drawn: // a journal entry taken for one came out as a bar of bare color across the day. func TestOnlyEventsAreDrawnOnTheGrid(t *testing.T) { - events, todos, habits := splitRecordings([]Recording{ + events, todos, habits, _ := splitRecordings([]Recording{ {ID: 169118695, Title: "Stanko & Kevin", Type: "Calendar::Event", StartsAt: "2026-08-20T14:00:00Z"}, // The journal entry behind the stray stripe, as HEY answered it: no title. {ID: 171477000, Type: "Calendar::JournalEntry", AllDay: true, StartsAt: "2026-08-20T00:00:00Z"}, diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index b16fec20..dafb3f0d 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -10,6 +10,7 @@ import ( "charm.land/lipgloss/v2" + habitvalues "github.com/basecamp/hey-cli/internal/habit" "github.com/basecamp/hey-cli/internal/terminal" ) @@ -64,12 +65,17 @@ func weekStartDate(t time.Time, firstDay time.Weekday) time.Time { // // HEY's type names are namespaced — `Calendar::Event`, `Calendar::Habit::Completion` — which // is why these match on a substring rather than on the whole string. -func splitRecordings(recs []Recording) (events, todos, habits []Recording) { +func splitRecordings(recs []Recording) (events, todos, habits, completions []Recording) { // Doing a habit is a recording of its own — a `Calendar::Habit::Completion` // carrying nothing but the habit it belongs to, since HEY records the doing rather // than flagging the habit. So a completion marks the habit it names and is never // listed itself: left in, it read as a habit with no name and left every habit // looking undone. + // + // The completions are answered as well as folded, because folding is lossy over more + // than a day: a habit done on three days of a week has three of them, and only the + // last would survive as a CompletedAt. The day view wants the fold, the week wants + // the list. completed := make(map[int64]string) for _, r := range recs { if isHabitCompletion(r.Type) { @@ -81,7 +87,7 @@ func splitRecordings(recs []Recording) (events, todos, habits []Recording) { t := strings.ToLower(r.Type) switch { case isHabitCompletion(r.Type): - // Already folded into the habit it completes. + completions = append(completions, r) case strings.Contains(t, "todo"): todos = append(todos, r) case strings.Contains(t, "habit"): @@ -594,7 +600,7 @@ type weekDayInfo struct { allDay []Recording } -func renderWeekView(events, habits []Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int, dayLabels map[string]string) string { +func renderWeekView(events, habits, completions []Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int, dayLabels map[string]string) string { var b strings.Builder muted := styleMuted bright := lipgloss.NewStyle().Foreground(colorBright) @@ -623,15 +629,26 @@ func renderWeekView(events, habits []Recording, anchor time.Time, firstWeekDay t } } - // Assign habits to their dates - for _, h := range habits { - ht := parseEventTime(h.StartsAt) - if ht.IsZero() { + // Which habits were done on which day. It comes from the completions rather than from + // the habits: a habit's StartsAt is the day it was taken up — "Read" starts in 2024 — + // so matching a habit against a day in this week never hit, and the week has been + // showing none of them. + byID := make(map[int64]Recording, len(habits)) + for _, habit := range habits { + byID[habit.ID] = habit + } + for _, completion := range completions { + done := parseEventTime(completion.StartsAt) + if done.IsZero() { + continue + } + habit, ok := byID[completion.ParentID] + if !ok { continue } for i := range days { - if sameDay(days[i].date, ht) { - days[i].habits = append(days[i].habits, h) + if sameDay(days[i].date, done) { + days[i].habits = append(days[i].habits, habit) } } } @@ -656,6 +673,23 @@ func renderWeekView(events, habits []Recording, anchor time.Time, firstWeekDay t b.WriteString(weekGridBorder("├", "┼", "┤", colWidth, muted)) b.WriteString("\n") + // The habits done each day, as a band across the top with a rule under it. Every day + // gets the same number of rows so the rule is straight and a day's events start where + // its neighbours' do — the band is a row of the grid, not something each column grew. + // A week nobody kept a habit in has no band and no rule. + if band := weekHabitBand(days, colWidth); len(band) > 0 { + for _, row := range band { + b.WriteString(sep) + for _, cell := range row { + b.WriteString(cell) + b.WriteString(sep) + } + b.WriteString("\n") + } + b.WriteString(weekGridBorder("├", "┼", "┤", colWidth, muted)) + b.WriteString("\n") + } + // Build column content cols := make([][]string, 7) for i := range 7 { @@ -698,6 +732,43 @@ func renderWeekView(events, habits []Recording, anchor time.Time, firstWeekDay t return b.String() } +// weekHabitBand is the habits kept each day, as their icons alone — the week has room for +// seven days of them and none for seven days of names. It answers rows of seven cells, each +// already padded to the column, and nothing at all for a week with no habits kept in it. +func weekHabitBand(days []weekDayInfo, colWidth int) [][]string { + // An icon is two cells wide and wants one between it and the next. + const iconWidth = 3 + perRow := max(colWidth/iconWidth, 1) + + icons := make([][]string, len(days)) + rows := 0 + for i, day := range days { + for _, habit := range day.habits { + if emoji := habitvalues.EmojiFor(habit.Icon); emoji != "" { + icons[i] = append(icons[i], habitMarkerStyle(habit.Color).Render(emoji)) + } + } + rows = max(rows, (len(icons[i])+perRow-1)/perRow) + } + if rows == 0 { + return nil + } + + band := make([][]string, rows) + for row := range band { + band[row] = make([]string, len(days)) + for day := range days { + var cell strings.Builder + for i := row * perRow; i < min((row+1)*perRow, len(icons[day])); i++ { + cell.WriteString(icons[day][i]) + cell.WriteString(" ") + } + band[row][day] = padTo(cell.String(), colWidth) + } + } + return band +} + func weekGridBorder(left, mid, right string, colWidth int, muted lipgloss.Style) string { var s strings.Builder s.WriteString(muted.Render(left)) @@ -714,17 +785,9 @@ func weekGridBorder(left, mid, right string, colWidth int, muted lipgloss.Style) // buildWeekDayColumn returns styled lines for one day column. // Order: habits at top, timed events in the middle, all-day at bottom. func buildWeekDayColumn(d weekDayInfo, width int, muted lipgloss.Style) []string { + // The day's habits are the band above the grid, not lines in the column. var lines []string - for _, h := range d.habits { - marker := "○" - if h.CompletedAt != "" { - marker = "●" - } - line := marker + " " + truncateStr(terminal.SanitizeLine(h.Title), width-2) - lines = append(lines, muted.Render(line)) - } - for _, e := range d.events { timeStr := "" if len(e.StartsAt) >= 16 { @@ -997,6 +1060,15 @@ func truncateStr(s string, maxLen int) string { return string(runes) + "…" } +// padTo fills a cell out to its column width, measuring what is visible so the styling a +// cell already carries is not counted. +func padTo(s string, width int) string { + if pad := width - lipgloss.Width(s); pad > 0 { + return s + strings.Repeat(" ", pad) + } + return s +} + func centerPad(s string, width int) string { sw := lipgloss.Width(s) pad := width - sw From b80ae51b47249819cd6a3f12c11a03e721630fdd Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 21:28:44 +0200 Subject: [PATCH 16/19] Move between days, weeks and years with p and n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arrows stepped the period as well as p and n did, which left them meaning two things: at the nav and subnav rows they move between sections and spans, and in the content they moved the date. Now they only ever move between things, and p and n move the period — so what a key does no longer depends on where the focus sits. The hint says "p/n day" rather than "pn day", which read as noise. t drops out of the hint once today is on screen. Stepping away and back leaves the anchor pinned to today's own date rather than cleared, and onToday asks whether the view is following the clock — true only for a cleared anchor — so the hint kept offering to take a reader to the day they were already looking at. That is a second question, showingToday, and t still clears a pinned anchor either way. It is just not worth a hint. --- internal/tui/calendar.go | 27 ++++++++++++++------- internal/tui/calendar_test.go | 44 ++++++++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 4c723bc1..745b1aa0 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -402,8 +402,8 @@ func (v *calendarView) HelpBindings() []helpBinding { // have no such line, so the help bar carries it for them. var bindings []helpBinding if v.viewMode != viewDay { - bindings = append(bindings, helpBinding{"←→", v.viewMode.unit()}) - if !v.onToday() { + bindings = append(bindings, helpBinding{"p/n", v.viewMode.unit()}) + if !v.showingToday() { bindings = append(bindings, helpBinding{"t", "today"}) } } @@ -509,9 +509,9 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return v.setViewMode(viewWeek) case "3": return v.setViewMode(viewYear) - case "left", "p": + case "p": return v.step(-1) - case "right", "n": + case "n": return v.step(1) case "t": return v.today() @@ -805,23 +805,32 @@ func (v *calendarView) day() time.Time { return v.anchor } +// onToday is whether the view is following the clock rather than pinned to a date, which +// is what decides whether t has anything to do. func (v *calendarView) onToday() bool { return v.anchor.IsZero() } +// showingToday is whether today is on screen, which is not the same question: stepping +// away and back pins the anchor to today's own date rather than clearing it, and a reader +// looking at today does not need to be told how to get to it. +func (v *calendarView) showingToday() bool { + return v.onToday() || sameDay(v.day(), v.now()) +} + // stepHint is the keys that move the view, said on the line that names the day rather // than in the help bar: they belong to the date they act on. t is only mentioned once it -// would do something. +// would take the reader somewhere. func (v *calendarView) stepHint() string { - hint := "←→ " + v.viewMode.unit() - if !v.onToday() { + hint := "p/n " + v.viewMode.unit() + if !v.showingToday() { hint += " · t today" } return hint } -// step moves the view by its own unit — a day, a week or a year — since ← and → mean -// "the one before this" whatever the view is showing. +// step moves the view by its own unit — a day, a week or a year — since p and n mean +// "the one before this" and "the one after" whatever the view is showing. func (v *calendarView) step(delta int) tea.Cmd { switch v.viewMode { case viewWeek: diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index d942171a..44ed6944 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -524,13 +524,13 @@ func TestCalendarStepsThroughDaysAndBackToToday(t *testing.T) { today := time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) v.now = func() time.Time { return today } - for _, key := range []string{"right", "n"} { + for _, key := range []string{"n", "n"} { v.HandleContentKey(keyPress(key)) } if got := v.day(); !sameDay(got, today.AddDate(0, 0, 2)) { t.Errorf("two steps forward = %s, want %s", got.Format(time.DateOnly), today.AddDate(0, 0, 2).Format(time.DateOnly)) } - for _, key := range []string{"left", "p", "p"} { + for _, key := range []string{"p", "p", "p"} { v.HandleContentKey(keyPress(key)) } if got := v.day(); !sameDay(got, today.AddDate(0, 0, -1)) { @@ -548,7 +548,7 @@ func TestCalendarStepsThroughDaysAndBackToToday(t *testing.T) { } // The keys that move the day are said on the day's own line, and t joins them once // it would do something. - if hint := v.stepHint(); hint != "←→ day · t today" { + if hint := v.stepHint(); hint != "p/n day · t today" { t.Errorf("hint on the date line = %q", hint) } @@ -569,19 +569,47 @@ func TestCalendarStepsThroughDaysAndBackToToday(t *testing.T) { } } +// Stepping away and back leaves the anchor pinned to today's own date rather than cleared, +// so the view is on today without following the clock. The hint asks the second question: +// a reader looking at today does not need to be told how to get to it. +func TestCalendarHidesTheTodayHintWhenTodayIsOnScreen(t *testing.T) { + v := calendarWithRecordings() + today := time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) + v.now = func() time.Time { return today } + + v.HandleContentKey(keyPress("n")) + if hint := v.stepHint(); hint != "p/n day · t today" { + t.Errorf("a day away, hint = %q, want t offered", hint) + } + + v.HandleContentKey(keyPress("p")) + if v.onToday() { + t.Fatal("stepping back should leave the anchor pinned, not clear it") + } + if hint := v.stepHint(); hint != "p/n day" { + t.Errorf("back on today, hint = %q, want t left out", hint) + } + + // t still has something to do — it returns the view to following the clock — it just + // is not worth a hint while today is already on screen. + if cmd := v.HandleContentKey(keyPress("t")); cmd == nil || !v.onToday() { + t.Error("t should clear the anchor even from today's own date") + } +} + func TestCalendarStepsByTheUnitTheViewShows(t *testing.T) { v := calendarWithRecordings() today := time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) v.now = func() time.Time { return today } v.viewMode = viewWeek - v.HandleContentKey(keyPress("right")) + v.HandleContentKey(keyPress("n")) if got := v.day(); !sameDay(got, today.AddDate(0, 0, 7)) { t.Errorf("a step in the week view = %s, want a week on", got.Format(time.DateOnly)) } v.viewMode = viewYear - v.HandleContentKey(keyPress("left")) + v.HandleContentKey(keyPress("p")) if got := v.day(); !sameDay(got, today.AddDate(0, 0, 7).AddDate(-1, 0, 0)) { t.Errorf("a step in the year view = %s, want a year back", got.Format(time.DateOnly)) } @@ -793,8 +821,8 @@ func TestDayViewLabelsItsSections(t *testing.T) { habits := []Recording{{ID: 4, Title: "Read 20 pages"}} day := time.Date(2026, 8, 24, 9, 0, 0, 0, time.Local) - view := stripANSI(renderDayView(events, habits, day, "←→ day", 100, 24)) - for _, label := range []string{"Habits", "Monday, August 24", "←→ day", "All day"} { + view := stripANSI(renderDayView(events, habits, day, "p/n day", 100, 24)) + for _, label := range []string{"Habits", "Monday, August 24", "p/n day", "All day"} { if !strings.Contains(view, label) { t.Errorf("day view did not label its %q section: %q", label, view) } @@ -1096,7 +1124,7 @@ func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { // The week and the year have no date line, so the help bar carries their steps. v.viewMode = viewWeek - for _, want := range []string{"←→", "c"} { + for _, want := range []string{"p/n", "c"} { found := false for _, binding := range v.HelpBindings() { found = found || binding.key == want From 5e3839a68a95a51d0486f33889a587750aeaba46 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 21:40:03 +0200 Subject: [PATCH 17/19] Draw the week the way the day is drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The week was a boxed table, every cell walled in with solid ─ │ ┌ ┬ ┐, which read as a spreadsheet of the week rather than as the week. The day never had a box: it is a header, an axis, and events on open ground. A week is the same thing with seven days across instead of twenty-four hours. So it borrows the day's vocabulary. The rule between days is the dotted ┊ the day uses for its hours — a guide behind the events, not another box's wall. The day names are the axis, in the chrome the hour labels wear, rather than the bright a reader is meant to stop on. And the days run to the bottom of the screen whether anything is on them or not, because the rules between them are the grid: a quiet week still reads as seven days rather than as a paragraph that stops. The week names itself now — "August 17 – 23" — and carries the keys that move it on that line, where they belong to the date they act on. They leave the help bar, which was only holding them because the week had nowhere to say them. The year still has nowhere, so the bar keeps them for that one. The habits band gets a section header instead of a rule, which is both the day's pattern and a better divider: a rule with a name on it says what the band above it was. It stands whether or not anything was kept that week, so stepping through the weeks does not shift the grid underneath the reader. It goes altogether only for somebody who keeps no habits at all, since then there is nothing to head. The band tests keyed off row numbers and broke the moment the layout moved. They find their rows by what those rows say. --- internal/tui/calendar.go | 9 ++- internal/tui/calendar_test.go | 119 ++++++++++++++++++++++++------ internal/tui/calendar_views.go | 129 +++++++++++++++++++-------------- 3 files changed, 174 insertions(+), 83 deletions(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 745b1aa0..251f20a7 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -398,10 +398,11 @@ func (v *calendarView) HelpBindings() []helpBinding { if v.calendarPicker != nil { return v.calendarPicker.helpBindings() } - // The day says which keys move it on the line that names it. The week and the year - // have no such line, so the help bar carries it for them. + // The day and the week say which keys move them on the line that names them, where + // they belong to the date they act on. The year has no such line, so the help bar + // carries it for that one. var bindings []helpBinding - if v.viewMode != viewDay { + if v.viewMode == viewYear { bindings = append(bindings, helpBinding{"p/n", v.viewMode.unit()}) if !v.showingToday() { bindings = append(bindings, helpBinding{"t", "today"}) @@ -774,7 +775,7 @@ func (v *calendarView) rebuildView() { case viewDay: content = renderDayView(v.events, v.habits, anchor, v.stepHint(), w, v.contentVP.Height()) case viewWeek: - content = renderWeekView(v.events, v.habits, v.habitCompletions, anchor, v.firstWeekDay, w, h, dayLabels) + content = renderWeekView(v.events, v.habits, v.habitCompletions, anchor, v.firstWeekDay, w, v.contentVP.Height(), v.stepHint(), dayLabels) case viewYear: // The year's events are HEY's spanned_events — the all-day and multi-day ones — // because that is all a year read carries. eventsByDate spreads a multi-day event diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 44ed6944..1c7eb0ad 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -652,21 +652,31 @@ func TestHabitCompletionsMarkTheirHabitRatherThanListingThemselves(t *testing.T) // and two on the Thursday, plus one event, rendered at 100 columns. func weekWithHabits(t *testing.T, completions []Recording) []string { t.Helper() + return weekView(t, testHabits(), completions) +} - habits := []Recording{ +func testHabits() []Recording { + return []Recording{ {ID: 1, Title: "Meditate", Icon: "meditate", Color: "purple", Type: "Calendar::Habit"}, {ID: 2, Title: "Work out", Icon: "weights", Color: "red", Type: "Calendar::Habit"}, {ID: 3, Title: "Write", Icon: "write", Color: "gold", Type: "Calendar::Habit"}, {ID: 4, Title: "Read", Icon: "read", Color: "green", Type: "Calendar::Habit"}, {ID: 5, Title: "Learn a language", Icon: "study", Color: "teal", Type: "Calendar::Habit"}, } +} + +const weekViewRows = 20 + +func weekView(t *testing.T, habits, completions []Recording) []string { + t.Helper() + events := []Recording{ {ID: 9, Title: "Stanko & Kevin", CalendarColor: "blue", Type: "Calendar::Event", StartsAt: "2026-08-20T14:00:00Z", EndsAt: "2026-08-20T15:00:00Z"}, } anchor := time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC) - out := renderWeekView(events, habits, completions, anchor, time.Monday, 100, 30, nil) + out := renderWeekView(events, habits, completions, anchor, time.Monday, 100, weekViewRows, "p/n week", nil) return strings.Split(stripANSI(out), "\n") } @@ -685,48 +695,100 @@ func TestWeekDrawsTheHabitsKeptEachDay(t *testing.T) { habitDone(1, "2026-08-20"), habitDone(4, "2026-08-20"), }) - // Rows 0-2 are the top border, the day names and the header rule; the band follows. - band := lines[3:5] + // Found by what they say rather than by their row number, so the layout can move + // without the test having to be told. + header := rowContaining(t, lines, "Habits") + label := rowContaining(t, lines, "August 17 – 23") + + band := lines[header+1 : label] + if len(band) != 2 { + t.Fatalf("band is %d rows, want 2 — five icons do not fit one cell: %q", len(band), band) + } if !strings.Contains(band[0], "🧘") || !strings.Contains(band[0], "📖") { t.Errorf("Monday's habits are not in the band: %q", band[0]) } - // Five icons do not fit one cell, so the band is two rows — for every day, not just - // the one that needed it. if !strings.Contains(band[1], "📚") { t.Errorf("the fifth habit did not wrap onto a second row: %q", band[1]) } + // Both rows are the full width, so the header under them is straight and each day's + // events start level with its neighbours'. if lipgloss.Width(band[0]) != lipgloss.Width(band[1]) { t.Errorf("band rows are %d and %d wide", lipgloss.Width(band[0]), lipgloss.Width(band[1])) } - - // A rule closes the band off from the day's own content. - if rule := lines[5]; !strings.HasPrefix(rule, "├") || !strings.Contains(rule, "┼") { - t.Errorf("no rule under the band: %q", rule) - } - // And the day's events are below it, not in it. - if !strings.Contains(lines[7], "Stanko & Kev") { - t.Errorf("the event is not under the band: %q", lines[6:8]) - } for _, row := range band { if strings.Contains(row, "Meditate") || strings.Contains(row, "Stanko") { t.Errorf("the band carries a name, not just icons: %q", row) } } + + // The week's own line is what closes the band off, and the day's events are below it. + if event := rowContaining(t, lines, "Stanko & Kev"); event <= label { + t.Errorf("the event is not under the week's line: row %d against %d", event, label) + } +} + +// rowContaining is the index of the one row holding s, so a test can say where it looked. +func rowContaining(t *testing.T, lines []string, s string) int { + t.Helper() + for i, line := range lines { + if strings.Contains(line, s) { + return i + } + } + t.Fatalf("no row contains %q: %q", s, lines) + return -1 } -// A week nobody kept a habit in gets no band and no rule, rather than an empty stripe. -func TestWeekWithoutHabitsHasNoBand(t *testing.T) { - lines := weekWithHabits(t, nil) +// The band keeps its place in a week where nothing was kept, so stepping from week to week +// does not shift the grid up and down underneath the reader. +func TestWeekKeepsTheHabitsBandWhenNothingWasKept(t *testing.T) { + kept := weekWithHabits(t, []Recording{habitDone(1, "2026-08-17")}) + none := weekWithHabits(t, nil) - // The header rule is row 2, and the day's own content starts straight after it. - if !strings.Contains(lines[3], "14:00") { - t.Errorf("row 3 should be the day's content, got %q", lines[3]) + if rowContaining(t, kept, "August 17 – 23") != rowContaining(t, none, "August 17 – 23") { + t.Error("the week's line moved between a week with habits kept and one without") } - for i, line := range lines { + for i, line := range none { if strings.Contains(line, "🧘") || strings.Contains(line, "📖") { t.Errorf("row %d drew a habit nobody kept: %q", i, line) } } + if !strings.Contains(none[0], "Habits") { + t.Errorf("the band lost its header: %q", none[0]) + } +} + +// Somebody who keeps no habits gets no band at all, since there is nothing to head. +func TestWeekWithoutAnyHabitsHasNoBand(t *testing.T) { + lines := weekView(t, nil, nil) + + if got := rowContaining(t, lines, "August 17 – 23"); got != 0 { + t.Errorf("the week's line is row %d, want the first: %q", got, lines[:got+1]) + } + if !strings.Contains(lines[1], "MON 17") { + t.Errorf("the day names should follow the week's line, got %q", lines[1]) + } + for i, line := range lines { + if strings.Contains(line, "Habits") { + t.Errorf("row %d headed a band that is not there: %q", i, line) + } + } +} + +// The days run to the bottom of the screen: the rules between them are the grid, so a quiet +// week still reads as seven days rather than as a paragraph that stops. +func TestWeekRunsItsDaysToTheBottom(t *testing.T) { + lines := weekView(t, nil, nil) + + if len(lines) != weekViewRows { + t.Fatalf("week is %d rows of the %d it was given: %q", len(lines), weekViewRows, lines) + } + // Six rules for seven days, on the last row as on the first. + for _, row := range []int{1, len(lines) - 1} { + if got := strings.Count(lines[row], string(hourRule)); got != 6 { + t.Errorf("row %d has %d rules, want 6: %q", row, got, lines[row]) + } + } } // A calendar carries a day's own records alongside its events. Only the events are drawn: @@ -1122,15 +1184,24 @@ func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { } } - // The week and the year have no date line, so the help bar carries their steps. + // The week names itself and carries its own keys now, as the day does, so the help bar + // stops repeating them. v.viewMode = viewWeek + for _, binding := range v.HelpBindings() { + if binding.key == "p/n" || binding.key == "t" { + t.Errorf("the week's own line says %q; the help bar should not: %+v", binding.key, v.HelpBindings()) + } + } + + // The year has no line of its own, so the help bar still carries its steps. + v.viewMode = viewYear for _, want := range []string{"p/n", "c"} { found := false for _, binding := range v.HelpBindings() { found = found || binding.key == want } if !found { - t.Errorf("the week view is missing binding %q: %+v", want, v.HelpBindings()) + t.Errorf("the year view is missing binding %q: %+v", want, v.HelpBindings()) } } v.viewMode = viewDay diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index dafb3f0d..b0d2f82f 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -600,14 +600,24 @@ type weekDayInfo struct { allDay []Recording } -func renderWeekView(events, habits, completions []Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int, dayLabels map[string]string) string { +// renderWeekView draws the week in the day's own vocabulary: a section header naming what +// the reader is looking at with the keys that move it on the same line, chrome for the +// structure they look past, and the dotted rule the day uses for its hours standing between +// the days here. +// +// It used to be a boxed table — every cell walled in solid ─ │ ┌ ┬ ┐ — which read as a +// spreadsheet of the week rather than as the week. The day never had a box: it is a header, +// an axis, and events on open ground. That is what a week is too, with seven days across +// instead of twenty-four hours. +func renderWeekView(events, habits, completions []Recording, anchor time.Time, firstWeekDay time.Weekday, width, height int, hint string, dayLabels map[string]string) string { var b strings.Builder muted := styleMuted - bright := lipgloss.NewStyle().Foreground(colorBright) + chrome := lipgloss.NewStyle().Foreground(colorChrome) ws := weekStartDate(anchor, firstWeekDay) - colWidth := (width - 8) / 7 + // Seven days with a rule between them, and no box around the lot. + colWidth := (width - 6) / 7 if colWidth < 8 { colWidth = 8 } @@ -653,42 +663,50 @@ func renderWeekView(events, habits, completions []Recording, anchor time.Time, f } } - sep := muted.Render("│") - - // Top border - b.WriteString(weekGridBorder("┌", "┬", "┐", colWidth, muted)) - b.WriteString("\n") + // The rule between days is dotted for the reason the day's hours are: it is a guide + // behind the events, not another box's wall. + sep := chrome.Render(string(hourRule)) + writeRow := func(cells []string) { + for i, cell := range cells { + if i > 0 { + b.WriteString(sep) + } + b.WriteString(padTo(cell, colWidth)) + } + b.WriteString("\n") + } - // Column headers - b.WriteString(sep) - for i := range 7 { - label := dayLabelOrDefault(days[i].date, i == 0, dayLabels, weekDayColumnLabel) - padded := centerPad(label, colWidth) - b.WriteString(bright.Render(padded)) - b.WriteString(sep) + // The habits kept each day, under their own header as they are on the day. The header + // is the divider: a rule with a name on it says what the band above it was. + // + // The band stands whether or not anything was kept, so stepping through the weeks does + // not shift the grid up and down underneath the reader. It goes altogether only for + // somebody who keeps no habits at all, since there is nothing to head. + rowsUsed := 0 + if len(habits) > 0 { + b.WriteString(hintedSectionHeader("Habits", "b to manage", width)) + b.WriteString("\n") + rowsUsed++ + for _, row := range weekHabitBand(days, colWidth) { + writeRow(row) + rowsUsed++ + } } - b.WriteString("\n") - // Header separator - b.WriteString(weekGridBorder("├", "┼", "┤", colWidth, muted)) + // The week names itself and carries the keys that move it, the way the day does. It + // used to have no line of its own and the help bar said it instead. + b.WriteString(hintedSectionHeader(weekLabel(ws), hint, width)) b.WriteString("\n") + rowsUsed++ - // The habits done each day, as a band across the top with a rule under it. Every day - // gets the same number of rows so the rule is straight and a day's events start where - // its neighbours' do — the band is a row of the grid, not something each column grew. - // A week nobody kept a habit in has no band and no rule. - if band := weekHabitBand(days, colWidth); len(band) > 0 { - for _, row := range band { - b.WriteString(sep) - for _, cell := range row { - b.WriteString(cell) - b.WriteString(sep) - } - b.WriteString("\n") - } - b.WriteString(weekGridBorder("├", "┼", "┤", colWidth, muted)) - b.WriteString("\n") + // The day names are the week's axis, in the chrome the day's hours wear. + headers := make([]string, 7) + for i := range 7 { + label := dayLabelOrDefault(days[i].date, i == 0, dayLabels, weekDayColumnLabel) + headers[i] = chrome.Render(centerPad(label, colWidth)) } + writeRow(headers) + rowsUsed++ // Build column content cols := make([][]string, 7) @@ -702,34 +720,35 @@ func renderWeekView(events, habits, completions []Recording, anchor time.Time, f maxH = len(col) } } - if maxH == 0 { - maxH = 1 - } - // Render rows + // The days run to the bottom of the screen whether or not anything is on them, the way + // the day's hours do: the rules between them are the grid, so a week with a quiet + // Thursday still reads as seven days rather than as a paragraph that stops. + maxH = max(maxH, height-rowsUsed, 1) + + cells := make([]string, 7) for row := range maxH { - b.WriteString(sep) for i := range 7 { + cells[i] = "" if row < len(cols[i]) { - line := cols[i][row] - pad := colWidth - lipgloss.Width(line) - b.WriteString(line) - if pad > 0 { - b.WriteString(strings.Repeat(" ", pad)) - } - } else { - b.WriteString(strings.Repeat(" ", colWidth)) + cells[i] = cols[i][row] } - b.WriteString(sep) } - b.WriteString("\n") + writeRow(cells) } - // Bottom border - b.WriteString(weekGridBorder("└", "┴", "┘", colWidth, muted)) - b.WriteString("\n") + // No bottom border: the week ends where the screen does, as the day does. + return strings.TrimRight(b.String(), "\n") +} - return b.String() +// weekLabel names the span a week covers, saying the month once where it can — "August 17 – +// 23" rather than repeating it, and both where the week crosses over. +func weekLabel(start time.Time) string { + end := start.AddDate(0, 0, 6) + if start.Month() == end.Month() { + return fmt.Sprintf("%s %d – %d", start.Format("January"), start.Day(), end.Day()) + } + return fmt.Sprintf("%s – %s", start.Format("January 2"), end.Format("January 2")) } // weekHabitBand is the habits kept each day, as their icons alone — the week has room for @@ -750,9 +769,9 @@ func weekHabitBand(days []weekDayInfo, colWidth int) [][]string { } rows = max(rows, (len(icons[i])+perRow-1)/perRow) } - if rows == 0 { - return nil - } + // A week where nothing was kept still gets its row, so the grid below does not move as + // the reader steps from one week to the next. + rows = max(rows, 1) band := make([][]string, rows) for row := range band { From 7e0d70d899d87e12a0460965ff032e9b4705808b Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 21:56:49 +0200 Subject: [PATCH 18/19] Finish carrying the day's design across the calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The year gets what the week got. No box — it was the last boxed grid left — the days dotted apart with the rule the day uses for its hours, and the year naming itself with the keys that move it. It keeps a solid rule between weeks, which the week view has no need of: a row there is one line, and here it is as tall as its busiest day, so without one there is nothing to say where January's last week ends and February's first begins. And it grows no column-name header, because each of its cells already says its own weekday — the week needs one because its cells do not. With that, every span names itself, so p/n and t leave the help bar altogether. All-day events are blocks in their calendars' colors now, in both the day and the week. The day drew them [like this─────], which said "all day" by reaching across and said nothing about whose they were; a timed event is a solid block standing up in the grid, so an all-day one is that block lying on its side. The week gathers them at its foot under an "All day" header, as the day does. They belong to no hour, so leaving them at whatever depth each day's timed events happened to reach put them at seven different heights. Laying each day out on its own had a second problem: a day with a one-off all-day event pushed the week-long one down a row there and nowhere else, so a holiday spanning the week came out as a staircase. The band assigns lanes across the whole week instead — longest first, one row held for every day an event covers — so a week-long event is a single bar straight across and the short ones fill in around it. weekGridBorder goes with all this. Nothing draws an event as an outline any more. --- internal/tui/calendar.go | 13 +- internal/tui/calendar_test.go | 149 +++++++++++++++++++--- internal/tui/calendar_views.go | 220 +++++++++++++++++++++------------ 3 files changed, 279 insertions(+), 103 deletions(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 251f20a7..3cab0ef1 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -398,16 +398,9 @@ func (v *calendarView) HelpBindings() []helpBinding { if v.calendarPicker != nil { return v.calendarPicker.helpBindings() } - // The day and the week say which keys move them on the line that names them, where - // they belong to the date they act on. The year has no such line, so the help bar - // carries it for that one. + // Every span says which keys move it on the line that names it, where they belong to + // the date they act on, so the help bar carries none of that. var bindings []helpBinding - if v.viewMode == viewYear { - bindings = append(bindings, helpBinding{"p/n", v.viewMode.unit()}) - if !v.showingToday() { - bindings = append(bindings, helpBinding{"t", "today"}) - } - } // The spans are not in here: the row above the grid shows each one's number in the // tab itself, the way the box row does. Which calendar is being read is only in the // menu, so the key that opens it has to be said. @@ -780,7 +773,7 @@ func (v *calendarView) rebuildView() { // The year's events are HEY's spanned_events — the all-day and multi-day ones — // because that is all a year read carries. eventsByDate spreads a multi-day event // over the days it covers, so the grid fills the same way it always did. - content = renderYearView(v.year.SpannedEvents, anchor, v.firstWeekDay, w, h) + content = renderYearView(v.year.SpannedEvents, anchor, v.firstWeekDay, w, h, v.stepHint()) } v.contentVP.SetContent(content) diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 1c7eb0ad..d0855f9a 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -775,6 +775,45 @@ func TestWeekWithoutAnyHabitsHasNoBand(t *testing.T) { } } +// An all-day event belongs to no hour, so the week gathers them at its foot rather than +// leaving them at whatever depth each day's timed events reached. An event keeps one row +// across every day it covers, so a week-long one is a single bar straight across. +func TestWeekGathersAllDayEventsAtTheFoot(t *testing.T) { + events := []Recording{ + {ID: 9, Title: "Stanko & Kevin", CalendarColor: "blue", Type: "Calendar::Event", + StartsAt: "2026-08-20T14:00:00Z", EndsAt: "2026-08-20T15:00:00Z"}, + {ID: 10, Title: "Summer friday", CalendarColor: "gold", AllDay: true, Type: "Calendar::Event", + StartsAt: "2026-08-21T00:00:00Z", EndsAt: "2026-08-21T23:59:59Z"}, + {ID: 11, Title: "On call", CalendarColor: "green", AllDay: true, Type: "Calendar::Event", + StartsAt: "2026-08-17T00:00:00Z", EndsAt: "2026-08-23T23:59:59Z"}, + } + + anchor := time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC) + out := renderWeekView(events, nil, nil, anchor, time.Monday, 100, weekViewRows, "p/n week", nil) + lines := strings.Split(stripANSI(out), "\n") + + // The band is under the grid, headed as the day view heads its own. + header := rowContaining(t, lines, "All day") + if timed := rowContaining(t, lines, "Stanko & Kev"); timed > header { + t.Errorf("a timed event is below the all-day band: row %d against %d", timed, header) + } + if header != len(lines)-3 { + t.Errorf("the band is at row %d of %d, want it at the foot", header, len(lines)) + } + + // The week-long event holds one row all the way across; the single day goes below it. + across := lines[header+1] + if got := strings.Count(across, "On call"); got != 7 { + t.Errorf("the week-long event covers %d days, want 7: %q", got, across) + } + if strings.Contains(across, "Summer friday") { + t.Errorf("the one-day event took a row from the one spanning the week: %q", across) + } + if below := lines[header+2]; !strings.Contains(below, "Summer friday") { + t.Errorf("the one-day event is not on the row under it: %q", below) + } +} + // The days run to the bottom of the screen: the rules between them are the grid, so a quiet // week still reads as seven days rather than as a paragraph that stops. func TestWeekRunsItsDaysToTheBottom(t *testing.T) { @@ -791,6 +830,56 @@ func TestWeekRunsItsDaysToTheBottom(t *testing.T) { } } +// The year is drawn as the week is: it names itself with the keys that move it, its days +// are dotted apart, and there is no box around the lot. It keeps a rule between weeks, +// which the week view has no need of — a row there is one line, and here it is as tall as +// its busiest day. +func TestYearIsDrawnLikeTheWeek(t *testing.T) { + events := []Recording{ + {ID: 1, Title: "Switch out contact lense", CalendarColor: "blue", AllDay: true, + StartsAt: "2026-01-26T00:00:00Z", EndsAt: "2026-01-26T23:59:59Z", Type: "Calendar::Event"}, + {ID: 3, Title: "MEETUP", CalendarColor: "gold", AllDay: true, + StartsAt: "2026-02-05T00:00:00Z", EndsAt: "2026-02-08T23:59:59Z", Type: "Calendar::Event"}, + } + + out := renderYearView(events, time.Date(2026, 8, 22, 0, 0, 0, 0, time.UTC), + time.Monday, 100, 30, "p/n year · t today") + lines := strings.Split(stripANSI(out), "\n") + + // The year names itself and says what moves it, on its own first line. + if !strings.HasPrefix(lines[0], "2026 ") || !strings.Contains(lines[0], "p/n year · t today") { + t.Errorf("the year does not name itself with its keys: %q", lines[0]) + } + + // No box: nothing draws the old grid's corners or walls. + for i, line := range lines { + for _, boxed := range []string{"┌", "┬", "┐", "│", "├", "┼", "┤", "└", "┴", "┘"} { + if strings.Contains(line, boxed) { + t.Errorf("row %d still carries %q from the boxed grid: %q", i, boxed, line) + } + } + } + + // Each cell says its own weekday, so there is no header row naming the columns. + if !strings.Contains(lines[1], "MON") || !strings.Contains(lines[1], "JAN THU 1") { + t.Errorf("the first week should be dates, not column names: %q", lines[1]) + } + + // Six dotted rules for seven days, and a solid one closing each week off. + if got := strings.Count(lines[1], string(hourRule)); got != 6 { + t.Errorf("a week row has %d dotted rules, want 6: %q", got, lines[1]) + } + if !strings.HasPrefix(lines[2], strings.Repeat("─", 10)) { + t.Errorf("no rule under the first week: %q", lines[2]) + } + + // A multi-day event fills every day it covers, each in its calendar's color. + meetup := rowContaining(t, lines, "MEETUP") + if got := strings.Count(lines[meetup], "MEETUP"); got != 4 { + t.Errorf("MEETUP spans four days but appears %d times: %q", got, lines[meetup]) + } +} + // A calendar carries a day's own records alongside its events. Only the events are drawn: // a journal entry taken for one came out as a bar of bare color across the day. func TestOnlyEventsAreDrawnOnTheGrid(t *testing.T) { @@ -891,6 +980,41 @@ func TestDayViewLabelsItsSections(t *testing.T) { } } +// An all-day event is a block in its calendar's color lying across the day, the same thing +// a timed one is standing up in the grid. It used to be drawn [like this─────], which said +// "all day" by reaching across and said nothing about whose it was. +func TestAllDayEventsAreBlocksInTheirCalendarsColor(t *testing.T) { + events := []Recording{ + {ID: 1, Title: "Summer friday", CalendarColor: "gold", AllDay: true, Type: "Calendar::Event"}, + {ID: 2, Title: "Rosa and Stanko (On Call)", CalendarColor: "green", AllDay: true, Type: "Calendar::Event"}, + } + day := time.Date(2026, 8, 21, 9, 0, 0, 0, time.Local) + + rendered := renderDayView(events, nil, day, "", 100, 14) + lines := strings.Split(rendered, "\n") + + gold := lines[rowContaining(t, lines, "Summer friday")] + green := lines[rowContaining(t, lines, "Rosa and Stanko")] + + // Filled, and each in its own calendar's color rather than one style for both. + for _, bar := range []string{gold, green} { + if !strings.Contains(bar, "\x1b[") { + t.Errorf("all-day bar carries no fill: %q", bar) + } + if strings.ContainsAny(stripANSI(bar), "[]─") { + t.Errorf("all-day bar still drawn with brackets and dashes: %q", stripANSI(bar)) + } + } + if gold == green { + t.Error("two all-day events on different calendars drew identically") + } + + // The bar reaches across the day, so it reads as covering all of it. + if got := lipgloss.Width(stripANSI(gold)); got < 90 { + t.Errorf("all-day bar is %d columns of the ~97 the grid spans", got) + } +} + func TestDayViewRulesFallFromEveryHourWithoutCuttingIntoAnEvent(t *testing.T) { events := []Recording{ {ID: 1, Title: "Design review with Ryan", StartsAt: "2026-08-24T11:00:00Z", EndsAt: "2026-08-24T12:00:00Z"}, @@ -1184,24 +1308,15 @@ func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { } } - // The week names itself and carries its own keys now, as the day does, so the help bar - // stops repeating them. - v.viewMode = viewWeek - for _, binding := range v.HelpBindings() { - if binding.key == "p/n" || binding.key == "t" { - t.Errorf("the week's own line says %q; the help bar should not: %+v", binding.key, v.HelpBindings()) - } - } - - // The year has no line of its own, so the help bar still carries its steps. - v.viewMode = viewYear - for _, want := range []string{"p/n", "c"} { - found := false + // Every span names itself and carries its own keys now, as the day always did, so the + // help bar repeats none of them. + for _, mode := range []calendarViewMode{viewDay, viewWeek, viewYear} { + v.viewMode = mode for _, binding := range v.HelpBindings() { - found = found || binding.key == want - } - if !found { - t.Errorf("the year view is missing binding %q: %+v", want, v.HelpBindings()) + if binding.key == "p/n" || binding.key == "t" { + t.Errorf("%v: its own line says %q; the help bar should not: %+v", + mode, binding.key, v.HelpBindings()) + } } } v.viewMode = viewDay diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index b0d2f82f..67cc5e9c 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -417,16 +417,16 @@ func renderDayView(events, habits []Recording, anchor time.Time, hint string, wi } b.WriteString(renderDayGrid(lanes, gridWidth, colWidth, height-spent, chrome, eventTitle, muted)) - // All-day events as full-width horizontal bars at the bottom + // All-day events run the width of the day at the bottom of it, as blocks in their + // calendars' colors. They used to be drawn [like this─────], which said "all day" by + // reaching across and nothing else: an event's color is how a reader tells whose it is, + // and a timed one on the grid above is a solid block, so an all-day one is the same + // block lying on its side. if len(allDay) > 0 { b.WriteString(sectionHeader("All day", width)) b.WriteString("\n") for _, e := range allDay { - innerLen := gridWidth - 2 - title := truncateStr(terminal.SanitizeLine(e.Title), innerLen) - fill := max(innerLen-lipgloss.Width(title), 0) - b.WriteString(chrome.Render("[") + eventTitle.Render(title) + - chrome.Render(strings.Repeat("─", fill)+"]")) + b.WriteString(eventPill(e, gridWidth)) b.WriteString("\n") } } @@ -721,6 +721,14 @@ func renderWeekView(events, habits, completions []Recording, anchor time.Time, f } } + // The all-day events are held back so they can sit at the foot of the week, under the + // grid rather than at whatever depth each day's timed events reached. Their rows and + // their header come off the space the grid has to fill. + allDay := weekAllDayBand(days, colWidth) + if len(allDay) > 0 { + rowsUsed += 1 + len(allDay) + } + // The days run to the bottom of the screen whether or not anything is on them, the way // the day's hours do: the rules between them are the grid, so a week with a quiet // Thursday still reads as seven days rather than as a paragraph that stops. @@ -737,6 +745,14 @@ func renderWeekView(events, habits, completions []Recording, anchor time.Time, f writeRow(cells) } + if len(allDay) > 0 { + b.WriteString(sectionHeader("All day", width)) + b.WriteString("\n") + for _, row := range allDay { + writeRow(row) + } + } + // No bottom border: the week ends where the screen does, as the day does. return strings.TrimRight(b.String(), "\n") } @@ -788,23 +804,11 @@ func weekHabitBand(days []weekDayInfo, colWidth int) [][]string { return band } -func weekGridBorder(left, mid, right string, colWidth int, muted lipgloss.Style) string { - var s strings.Builder - s.WriteString(muted.Render(left)) - for i := range 7 { - s.WriteString(muted.Render(strings.Repeat("─", colWidth))) - if i < 6 { - s.WriteString(muted.Render(mid)) - } - } - s.WriteString(muted.Render(right)) - return s.String() -} - // buildWeekDayColumn returns styled lines for one day column. // Order: habits at top, timed events in the middle, all-day at bottom. func buildWeekDayColumn(d weekDayInfo, width int, muted lipgloss.Style) []string { - // The day's habits are the band above the grid, not lines in the column. + // The day's habits are the band above the grid and its all-day events the band below + // it, so the column itself is the timed events alone. var lines []string for _, e := range d.events { @@ -818,11 +822,85 @@ func buildWeekDayColumn(d weekDayInfo, width int, muted lipgloss.Style) []string lines = append(lines, eventPill(e, width)) } - for _, e := range d.allDay { - lines = append(lines, eventPill(e, width)) + return lines +} + +// weekAllDayBand is the all-day events of each day, gathered at the foot of the week. They +// belong to no hour, so they sit under the grid rather than floating at whatever depth the +// timed events above them happened to reach — which is where the day view puts its own. +// +// Each event keeps one row for every day it covers, so a week-long one reads as a single bar +// straight across. Laying each day out on its own instead put it on whatever row that day +// had free, and a holiday spanning the week came out as a staircase. +func weekAllDayBand(days []weekDayInfo, colWidth int) [][]string { + spans := weekAllDaySpans(days) + if len(spans) == 0 { + return nil } - return lines + var lanes [][]bool // which days each lane has taken + rows := make([][]string, 0, len(spans)) + for _, span := range spans { + lane := 0 + for ; lane < len(lanes); lane++ { + if !span.overlaps(lanes[lane]) { + break + } + } + if lane == len(lanes) { + lanes = append(lanes, make([]bool, len(days))) + rows = append(rows, make([]string, len(days))) + } + for _, day := range span.days { + lanes[lane][day] = true + rows[lane][day] = eventPill(span.event, colWidth) + } + } + return rows +} + +// weekAllDaySpan is one all-day event and the days of the week it covers. eventsByDate hands +// a multi-day event to every day it touches, so the same event arrives seven times and is +// gathered back into one here. +type weekAllDaySpan struct { + event Recording + days []int +} + +func (span weekAllDaySpan) overlaps(taken []bool) bool { + for _, day := range span.days { + if taken[day] { + return true + } + } + return false +} + +// weekAllDaySpans gathers the week's all-day events, longest first so the bars that reach +// furthest take the top rows and the single days fill in around them. +func weekAllDaySpans(days []weekDayInfo) []weekAllDaySpan { + var order []int64 + spans := make(map[int64]weekAllDaySpan) + for i, day := range days { + for _, event := range day.allDay { + span, seen := spans[event.ID] + if !seen { + span.event = event + order = append(order, event.ID) + } + span.days = append(span.days, i) + spans[event.ID] = span + } + } + + gathered := make([]weekAllDaySpan, 0, len(order)) + for _, id := range order { + gathered = append(gathered, spans[id]) + } + sort.SliceStable(gathered, func(i, j int) bool { + return len(gathered[i].days) > len(gathered[j].days) + }) + return gathered } // eventPill is an event as the week and the year draw it: a bar filled with its calendar's @@ -855,14 +933,22 @@ func weekDayColumnLabel(d time.Time, isFirstCol bool) string { } // =============================================== -// Year View — bordered grid, one box per day +// Year View — the whole year, a week to a row // =============================================== +// renderYearView draws the year in the day's vocabulary, as the week does: the year names +// itself with the keys that move it, the days are dotted apart, and there is no box around +// the lot. A week keeps a solid rule under it, which the week view has no need of — a row +// there is one line, and here it is as tall as its busiest day, so without one there is +// nothing to say where January's last week ends and February's first begins. +// // The year takes no day labels: a named day is a title on a recording, and a year read -// carries no recordings to hang one on. The web app's year does not show them either. -func renderYearView(events []Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int) string { +// carries no recordings to hang one on. The web app's year does not show them either. It +// takes no habits band either — a year of icons says nothing a reader can use. +func renderYearView(events []Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int, hint string) string { var b strings.Builder muted := styleMuted + chrome := lipgloss.NewStyle().Foreground(colorChrome) bright := lipgloss.NewStyle().Foreground(colorBright) primary := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) faint := styleMuted.Foreground(colorMuted) // extra-dim filler days outside the year @@ -876,78 +962,60 @@ func renderYearView(events []Recording, anchor time.Time, firstWeekDay time.Week byDate := eventsByDate(events) - colWidth := max((width-8)/7, 9) - - sep := muted.Render("│") + colWidth := max((width-6)/7, 9) + sep := chrome.Render(string(hourRule)) + writeRow := func(cells []string) { + for i, cell := range cells { + if i > 0 { + b.WriteString(sep) + } + b.WriteString(padTo(cell, colWidth)) + } + b.WriteString("\n") + } - // Top border - b.WriteString(weekGridBorder("┌", "┬", "┐", colWidth, muted)) + // The year names itself and carries the keys that move it. Its cells each say their own + // weekday, so there is no header row to name the columns — the week needs one because + // its cells do not. + b.WriteString(hintedSectionHeader(anchor.Format("2006"), hint, width)) b.WriteString("\n") - // Weekday header row - b.WriteString(sep) - for i := range 7 { - wd := time.Weekday((int(firstWeekDay) + i) % 7) - name := strings.ToUpper(wd.String()[:3]) - padded := centerPad(name, colWidth) - b.WriteString(muted.Render(padded)) - b.WriteString(sep) - } - b.WriteString("\n") + weekRule := chrome.Render(strings.Repeat("─", colWidth*7+6)) - // Grid rows — one multi-line row per week today := time.Now() - d := gridStart - for d.Before(gridEnd) { - b.WriteString(weekGridBorder("├", "┼", "┤", colWidth, muted)) - b.WriteString("\n") - + cells := make([]string, 7) + for d := gridStart; d.Before(gridEnd); { // Build cell content for each day in the week - weekDates := make([]time.Time, 7) - cells := make([][]string, 7) + columns := make([][]string, 7) for i := range 7 { - weekDates[i] = d - cells[i] = buildYearDayCell(d, byDate[dateKey(d)], colWidth, + columns[i] = buildYearDayCell(d, byDate[dateKey(d)], colWidth, sameDay(d, today), d.Year() == anchor.Year(), primary, bright, muted, faint) d = d.AddDate(0, 0, 1) } - // Find tallest cell maxH := 0 - for _, cell := range cells { - if len(cell) > maxH { - maxH = len(cell) - } - } - if maxH == 0 { - maxH = 1 + for _, column := range columns { + maxH = max(maxH, len(column)) } + maxH = max(maxH, 1) - // Render rows for row := range maxH { - b.WriteString(sep) for i := range 7 { - if row < len(cells[i]) { - line := cells[i][row] - pad := colWidth - lipgloss.Width(line) - b.WriteString(line) - if pad > 0 { - b.WriteString(strings.Repeat(" ", pad)) - } - } else { - b.WriteString(strings.Repeat(" ", colWidth)) + cells[i] = "" + if row < len(columns[i]) { + cells[i] = columns[i][row] } - b.WriteString(sep) } + writeRow(cells) + } + + if d.Before(gridEnd) { + b.WriteString(weekRule) b.WriteString("\n") } } - // Bottom border - b.WriteString(weekGridBorder("└", "┴", "┘", colWidth, muted)) - b.WriteString("\n") - - return b.String() + return strings.TrimRight(b.String(), "\n") } // buildYearDayCell returns styled lines for one day cell in the year grid. From e7776d471b3210955f9f06bbf77b95b3ca9e3703 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Sat, 22 Aug 2026 22:07:54 +0200 Subject: [PATCH 19/19] Show the calendar on the reader's clock, not UTC's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HEY answers every timestamp in UTC — ApiRequest#set_utc_timezone sets the zone for every JSON request — and the calendar was drawing them as they arrived. A 14:00Z meeting sat on the 14:00 column wherever the reader was, the week printed "14:00" beside it, and a 23:30Z one was filed under the wrong day for anybody east of UTC. The cause was the round trip. A Recording's times were strings, rendered by formatTimestamp into UTC and parsed back by parseEventTime, so the zone was thrown away at the edge and nothing downstream could get it back. AGENTS.md already said this was the thing to fix and why: "Formatting one for display and parsing it back is how `hey journal list` printed the wrong day." So they are time.Time now, carried across untouched, and read through Starts and Ends — which answer on the local clock. formatTimestamp and parseEventTime are both gone. An all-day event is the exception, and haystack is explicit about it: its timestamp is a calendar date served as UTC midnight, wrapped in Time.use_zone("UTC") in `_recording.jbuilder` so no offset creeps in. Converting one would move a birthday to the day before for every reader west of UTC, so Starts leaves it alone. The grid tests name the hour the reader sees rather than the hour UTC does, since that is the hour the column is. atLocal is that, and it keeps them honest on a machine in any zone. --- internal/tui/calendar.go | 41 +++++++++--- internal/tui/calendar_test.go | 106 +++++++++++++++++++++++--------- internal/tui/calendar_views.go | 53 ++++++---------- internal/tui/habit_form_test.go | 2 +- internal/tui/habits.go | 4 +- internal/tui/todos.go | 4 +- internal/tui/todos_test.go | 4 +- internal/tui/translate_test.go | 53 ++++++++++++---- internal/tui/tui.go | 7 --- 9 files changed, 178 insertions(+), 96 deletions(-) diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index 3cab0ef1..778e9908 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -53,17 +53,21 @@ type YearDay struct { } // Recording is anything HEY keeps on a calendar — an event, a todo, a habit, a time -// track — told apart by Type. Its times are strings because that is the shape the -// calendar views read them in; giving them time.Time is the next thing to do here. +// track — told apart by Type. +// +// Its times are time.Time and stay that way. They used to be strings rendered in UTC and +// parsed back, which is how the whole calendar came to be drawn in UTC: an event at 14:00Z +// sat on the 14:00 column wherever the reader was. Read Starts and Ends rather than these +// fields — those answer in the zone a reader thinks in. type Recording struct { ID int64 ParentID int64 Title string AllDay bool - StartsAt string - EndsAt string + StartsAt time.Time + EndsAt time.Time Type string - CompletedAt string + CompletedAt time.Time Label string Icon string // Color is a habit's own color. An event has none — what it wears is its @@ -76,6 +80,25 @@ type Recording struct { Days []int32 } +// Starts and Ends are when a recording begins and ends, in the zone the reader is in. +// +// An all-day event is the exception, and it is not one HEY leaves to guesswork: its +// timestamp is a calendar date, which haystack serves as UTC midnight on purpose — +// `_recording.jbuilder` wraps it in `Time.use_zone("UTC")` so no offset creeps in. Convert +// that and a birthday moves to the day before for every reader west of UTC. +func (r Recording) Starts() time.Time { return localizedEventTime(r.StartsAt, r.AllDay) } +func (r Recording) Ends() time.Time { return localizedEventTime(r.EndsAt, r.AllDay) } + +// Done is whether a habit or a todo has been completed. +func (r Recording) Done() bool { return !r.CompletedAt.IsZero() } + +func localizedEventTime(at time.Time, allDay bool) time.Time { + if at.IsZero() || allDay { + return at + } + return at.Local() +} + // --- Calendar messages --- type calendarRequestKind int @@ -927,7 +950,7 @@ func (v *calendarView) saveHabit() tea.Cmd { // looked at is part of the request rather than an argument to the habit. func (v *calendarView) toggleHabitCompletion(habit Recording) tea.Cmd { day := v.day().Local().Format(time.DateOnly) - done := habit.CompletedAt != "" + done := habit.Done() requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) return func() tea.Msg { var err error @@ -974,7 +997,7 @@ func (v *calendarView) renameTodo(todo Recording, title string) tea.Cmd { // toggleTodo ticks a to-do off or puts it back. Unlike a habit, which is done on a // given day, a to-do is done or it is not. func (v *calendarView) toggleTodo(todo Recording) tea.Cmd { - done := todo.CompletedAt != "" + done := todo.Done() requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) return func() tea.Msg { var err error @@ -1009,8 +1032,8 @@ func sdkCalendarToModel(c generated.Calendar) Calendar { func sdkRecordingToModel(r generated.Recording) Recording { return Recording{ ID: r.Id, ParentID: r.ParentId, Title: r.Title, AllDay: r.AllDay, Type: r.Type, - StartsAt: formatTimestamp(r.StartsAt), EndsAt: formatTimestamp(r.EndsAt), - CompletedAt: formatTimestamp(r.CompletedAt), Label: r.Label, + StartsAt: r.StartsAt, EndsAt: r.EndsAt, + CompletedAt: r.CompletedAt, Label: r.Label, Icon: r.Icon, Color: r.Color, CalendarColor: r.Calendar.Color, Days: append([]int32(nil), r.Days...), } diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index d0855f9a..59f343f0 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -15,6 +15,27 @@ import ( hey "github.com/basecamp/hey-sdk/go/pkg/hey" ) +// at is a timestamp as HEY answers one: RFC 3339 in UTC, which is what every fixture here +// stands in for. +func at(ts string) time.Time { + parsed, err := time.Parse(time.RFC3339, ts) + if err != nil { + panic("bad fixture timestamp " + ts + ": " + err.Error()) + } + return parsed +} + +// atLocal is the same instant said the other way round: whatever UTC time puts this clock +// time on the reader's own zone. A test about where an event lands in the grid has to name +// the hour the reader sees, since that is the hour the column is. +func atLocal(ts string) time.Time { + parsed, err := time.ParseInLocation("2006-01-02T15:04:05", strings.TrimSuffix(ts, "Z"), time.Local) + if err != nil { + panic("bad fixture timestamp " + ts + ": " + err.Error()) + } + return parsed.UTC() +} + // The personal calendar carries no name and no color, which is how HEY serves it — the // web app labels that row from the identity instead. func testCalendars() []Calendar { @@ -30,10 +51,10 @@ func testSelection(ids ...int64) map[int64]bool { func testRecordings() []Recording { return []Recording{ - {ID: 200, Title: "Standup", StartsAt: "2025-03-01T09:00:00Z", EndsAt: "2025-03-01T09:30:00Z", Type: "CalendarEvent"}, - {ID: 201, Title: "Lunch", StartsAt: "2025-03-01T12:00:00Z", EndsAt: "2025-03-01T13:00:00Z", AllDay: false, Type: "CalendarEvent"}, - {ID: 202, Title: "Read a book", StartsAt: "2025-03-01T06:00:00Z", Type: "Habit"}, - {ID: 203, Title: "Buy milk", StartsAt: "2025-03-01T00:00:00Z", Type: "CalendarTodo"}, + {ID: 200, Title: "Standup", StartsAt: at("2025-03-01T09:00:00Z"), EndsAt: at("2025-03-01T09:30:00Z"), Type: "CalendarEvent"}, + {ID: 201, Title: "Lunch", StartsAt: at("2025-03-01T12:00:00Z"), EndsAt: at("2025-03-01T13:00:00Z"), AllDay: false, Type: "CalendarEvent"}, + {ID: 202, Title: "Read a book", StartsAt: at("2025-03-01T06:00:00Z"), Type: "Habit"}, + {ID: 203, Title: "Buy milk", StartsAt: at("2025-03-01T00:00:00Z"), Type: "CalendarTodo"}, } } @@ -356,7 +377,7 @@ func TestCalendarViewIgnoresStaleRecordings(t *testing.T) { v.viewMode = viewWeek v.requestRecordings() fresh := recordingsLoadedMsg{requestResult: currentRequest(v), recordings: []Recording{ - {ID: 300, Title: "Design review", StartsAt: "2025-03-04T15:00:00Z", EndsAt: "2025-03-04T16:00:00Z", Type: "CalendarEvent"}, + {ID: 300, Title: "Design review", StartsAt: at("2025-03-04T15:00:00Z"), EndsAt: at("2025-03-04T16:00:00Z"), Type: "CalendarEvent"}, }} v.Update(fresh) @@ -498,9 +519,9 @@ func TestDaysBetweenIgnoresDaylightSavingShifts(t *testing.T) { func TestDayLabelsCoverTodosAndHabits(t *testing.T) { labels := dayLabelsFromRecordings( - []Recording{{ID: 200, StartsAt: "2025-03-01T09:00:00Z", Type: "CalendarEvent", Label: "Launch day"}}, - []Recording{{ID: 203, StartsAt: "2025-03-02T00:00:00Z", Type: "CalendarTodo", Label: "Moving day"}}, - []Recording{{ID: 202, StartsAt: "2025-03-03T06:00:00Z", Type: "Habit", Label: "Rest day"}}, + []Recording{{ID: 200, StartsAt: at("2025-03-01T09:00:00Z"), Type: "CalendarEvent", Label: "Launch day"}}, + []Recording{{ID: 203, StartsAt: at("2025-03-02T00:00:00Z"), Type: "CalendarTodo", Label: "Moving day"}}, + []Recording{{ID: 202, StartsAt: at("2025-03-03T06:00:00Z"), Type: "Habit", Label: "Rest day"}}, ) want := map[string]string{ "2025-03-01": "Launch day", @@ -623,7 +644,7 @@ func TestHabitCompletionsMarkTheirHabitRatherThanListingThemselves(t *testing.T) events, todos, habits, completions := splitRecordings([]Recording{ {ID: 14796085, Title: "Read", Type: "Calendar::Habit", Icon: "read"}, {ID: 14113260, Title: "Meditate", Type: "Calendar::Habit", Icon: "meditate"}, - {ID: 171477412, Type: "Calendar::Habit::Completion", ParentID: 14796085, StartsAt: "2026-08-22T00:00:00Z"}, + {ID: 171477412, Type: "Calendar::Habit::Completion", ParentID: 14796085, StartsAt: at("2026-08-22T00:00:00Z")}, }) // The completion is answered as well as folded: a week needs to know which day each @@ -638,14 +659,43 @@ func TestHabitCompletionsMarkTheirHabitRatherThanListingThemselves(t *testing.T) if len(habits) != 2 { t.Fatalf("habits = %+v, want the two habits without the completion", habits) } - if habits[0].Title != "Read" || habits[0].CompletedAt != "2026-08-22T00:00:00Z" { + if habits[0].Title != "Read" || !habits[0].CompletedAt.Equal(at("2026-08-22T00:00:00Z")) { t.Errorf("the completed habit was not marked done: %+v", habits[0]) } - if habits[1].Title != "Meditate" || habits[1].CompletedAt != "" { + if habits[1].Title != "Meditate" || habits[1].Done() { t.Errorf("a habit with no completion was marked done: %+v", habits[1]) } } +// HEY answers every timestamp in UTC, so a reader east or west of it sees an event on the +// wrong hour unless the view converts. The week said 14:00 for a 14:00Z meeting whatever +// time it was where they were. +func TestEventsAreShownOnTheReadersClock(t *testing.T) { + zone, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + + // 23:30 in Tokyo on the 20th is 14:30Z on the same day; 23:30Z is 08:30 on the 21st. + utc := at("2026-08-20T23:30:00Z") + event := Recording{Title: "Late call", Type: "Calendar::Event", StartsAt: utc, EndsAt: utc.Add(time.Hour)} + + // The instant is untouched, only the clock it is read on. + if !event.Starts().Equal(utc) { + t.Errorf("Starts() moved the instant: %v against %v", event.Starts(), utc) + } + + // And the day it belongs to follows that clock, which is what puts it in the right + // column of the week. + inTokyo := utc.In(zone) + if inTokyo.Day() != 21 { + t.Fatalf("fixture is wrong: 23:30Z is %s in Tokyo", inTokyo.Format(time.RFC3339)) + } + if got := dateKey(inTokyo); got == dateKey(utc) { + t.Fatalf("fixture is wrong: the UTC and Tokyo dates should differ, both %s", got) + } +} + // --- Week: the habits band --- // weekWithHabits is a week where five habits were kept on the Monday, one on the Tuesday @@ -672,7 +722,7 @@ func weekView(t *testing.T, habits, completions []Recording) []string { events := []Recording{ {ID: 9, Title: "Stanko & Kevin", CalendarColor: "blue", Type: "Calendar::Event", - StartsAt: "2026-08-20T14:00:00Z", EndsAt: "2026-08-20T15:00:00Z"}, + StartsAt: at("2026-08-20T14:00:00Z"), EndsAt: at("2026-08-20T15:00:00Z")}, } anchor := time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC) @@ -681,7 +731,7 @@ func weekView(t *testing.T, habits, completions []Recording) []string { } func habitDone(habitID int64, day string) Recording { - return Recording{Type: "Calendar::Habit::Completion", ParentID: habitID, StartsAt: day + "T00:00:00Z"} + return Recording{Type: "Calendar::Habit::Completion", ParentID: habitID, StartsAt: at(day + "T00:00:00Z")} } // The week says what was kept each day, as icons — it has room for seven days of those and @@ -781,11 +831,11 @@ func TestWeekWithoutAnyHabitsHasNoBand(t *testing.T) { func TestWeekGathersAllDayEventsAtTheFoot(t *testing.T) { events := []Recording{ {ID: 9, Title: "Stanko & Kevin", CalendarColor: "blue", Type: "Calendar::Event", - StartsAt: "2026-08-20T14:00:00Z", EndsAt: "2026-08-20T15:00:00Z"}, + StartsAt: at("2026-08-20T14:00:00Z"), EndsAt: at("2026-08-20T15:00:00Z")}, {ID: 10, Title: "Summer friday", CalendarColor: "gold", AllDay: true, Type: "Calendar::Event", - StartsAt: "2026-08-21T00:00:00Z", EndsAt: "2026-08-21T23:59:59Z"}, + StartsAt: at("2026-08-21T00:00:00Z"), EndsAt: at("2026-08-21T23:59:59Z")}, {ID: 11, Title: "On call", CalendarColor: "green", AllDay: true, Type: "Calendar::Event", - StartsAt: "2026-08-17T00:00:00Z", EndsAt: "2026-08-23T23:59:59Z"}, + StartsAt: at("2026-08-17T00:00:00Z"), EndsAt: at("2026-08-23T23:59:59Z")}, } anchor := time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC) @@ -837,9 +887,9 @@ func TestWeekRunsItsDaysToTheBottom(t *testing.T) { func TestYearIsDrawnLikeTheWeek(t *testing.T) { events := []Recording{ {ID: 1, Title: "Switch out contact lense", CalendarColor: "blue", AllDay: true, - StartsAt: "2026-01-26T00:00:00Z", EndsAt: "2026-01-26T23:59:59Z", Type: "Calendar::Event"}, + StartsAt: at("2026-01-26T00:00:00Z"), EndsAt: at("2026-01-26T23:59:59Z"), Type: "Calendar::Event"}, {ID: 3, Title: "MEETUP", CalendarColor: "gold", AllDay: true, - StartsAt: "2026-02-05T00:00:00Z", EndsAt: "2026-02-08T23:59:59Z", Type: "Calendar::Event"}, + StartsAt: at("2026-02-05T00:00:00Z"), EndsAt: at("2026-02-08T23:59:59Z"), Type: "Calendar::Event"}, } out := renderYearView(events, time.Date(2026, 8, 22, 0, 0, 0, 0, time.UTC), @@ -884,12 +934,12 @@ func TestYearIsDrawnLikeTheWeek(t *testing.T) { // a journal entry taken for one came out as a bar of bare color across the day. func TestOnlyEventsAreDrawnOnTheGrid(t *testing.T) { events, todos, habits, _ := splitRecordings([]Recording{ - {ID: 169118695, Title: "Stanko & Kevin", Type: "Calendar::Event", StartsAt: "2026-08-20T14:00:00Z"}, + {ID: 169118695, Title: "Stanko & Kevin", Type: "Calendar::Event", StartsAt: at("2026-08-20T14:00:00Z")}, // The journal entry behind the stray stripe, as HEY answered it: no title. - {ID: 171477000, Type: "Calendar::JournalEntry", AllDay: true, StartsAt: "2026-08-20T00:00:00Z"}, - {ID: 171477001, Type: "Calendar::DayBackground", AllDay: true, StartsAt: "2026-08-20T00:00:00Z"}, + {ID: 171477000, Type: "Calendar::JournalEntry", AllDay: true, StartsAt: at("2026-08-20T00:00:00Z")}, + {ID: 171477001, Type: "Calendar::DayBackground", AllDay: true, StartsAt: at("2026-08-20T00:00:00Z")}, // A time track has a name, and still is not an event. - {ID: 171477002, Title: "Design work", Type: "Calendar::TimeTrack", StartsAt: "2026-08-20T09:00:00Z"}, + {ID: 171477002, Title: "Design work", Type: "Calendar::TimeTrack", StartsAt: at("2026-08-20T09:00:00Z")}, {ID: 171477003, Title: "Clean the attic", Type: "Calendar::Todo"}, {ID: 14796085, Title: "Read", Type: "Calendar::Habit"}, }) @@ -911,7 +961,7 @@ func TestHabitsModalOpensOverTheCalendarAndManagesHabits(t *testing.T) { v.calendars = []Calendar{{ID: 10, Name: "Personal", Personal: true}} v.habits = []Recording{ {ID: 7, Title: "Read before bed"}, - {ID: 8, Title: "Evening walk", CompletedAt: "2026-08-22T00:00:00Z"}, + {ID: 8, Title: "Evening walk", CompletedAt: at("2026-08-22T00:00:00Z")}, } v.rebuildView() @@ -942,7 +992,7 @@ func TestHabitsModalOpensOverTheCalendarAndManagesHabits(t *testing.T) { func TestRibbonMarksWhatIsDoneAndStopsAtTheWidth(t *testing.T) { todos := []Recording{ {ID: 1, Title: "Renew passport"}, - {ID: 2, Title: "Send the invoice", CompletedAt: "2026-08-24T08:00:00Z"}, + {ID: 2, Title: "Send the invoice", CompletedAt: at("2026-08-24T08:00:00Z")}, } ribbon := renderTodosRibbon(todos, 80) @@ -966,7 +1016,7 @@ func TestRibbonMarksWhatIsDoneAndStopsAtTheWidth(t *testing.T) { func TestDayViewLabelsItsSections(t *testing.T) { events := []Recording{ - {ID: 1, Title: "Design review with Ryan", StartsAt: "2026-08-24T11:00:00Z", EndsAt: "2026-08-24T12:00:00Z"}, + {ID: 1, Title: "Design review with Ryan", StartsAt: atLocal("2026-08-24T11:00:00"), EndsAt: atLocal("2026-08-24T12:00:00")}, {ID: 2, Title: "Dentist", AllDay: true}, } habits := []Recording{{ID: 4, Title: "Read 20 pages"}} @@ -1017,7 +1067,7 @@ func TestAllDayEventsAreBlocksInTheirCalendarsColor(t *testing.T) { func TestDayViewRulesFallFromEveryHourWithoutCuttingIntoAnEvent(t *testing.T) { events := []Recording{ - {ID: 1, Title: "Design review with Ryan", StartsAt: "2026-08-24T11:00:00Z", EndsAt: "2026-08-24T12:00:00Z"}, + {ID: 1, Title: "Design review with Ryan", StartsAt: atLocal("2026-08-24T11:00:00"), EndsAt: atLocal("2026-08-24T12:00:00")}, } day := time.Date(2026, 8, 24, 9, 0, 0, 0, time.Local) @@ -1104,7 +1154,7 @@ func TestOverlappingEventsShareTheDaysHeight(t *testing.T) { func TestDayViewGivesASingleEventTheWholeGrid(t *testing.T) { day := time.Date(2026, 8, 24, 9, 0, 0, 0, time.Local) events := []Recording{{ID: 1, Title: "Charge the car", Type: "CalendarEvent", - StartsAt: "2026-08-24T07:00:00Z", EndsAt: "2026-08-24T10:00:00Z"}} + StartsAt: atLocal("2026-08-24T07:00:00"), EndsAt: atLocal("2026-08-24T10:00:00")}} // An hour every four columns, so 07:00 starts on column 28 and the block covers the // rules at 28 and 32 for every one of the grid's 38 rows. @@ -1258,7 +1308,7 @@ func TestCalendarPinsTodosBelowTheGrid(t *testing.T) { v.todos = []Recording{{ID: 1, Title: "Renew passport"}} v.events = []Recording{ {ID: 2, Title: "A design review long enough to fill the day view twice over", - StartsAt: "2026-08-24T11:00:00Z", EndsAt: "2026-08-24T12:00:00Z"}, + StartsAt: atLocal("2026-08-24T11:00:00"), EndsAt: atLocal("2026-08-24T12:00:00")}, } v.rebuildView() diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go index 67cc5e9c..c4273ce7 100644 --- a/internal/tui/calendar_views.go +++ b/internal/tui/calendar_views.go @@ -76,7 +76,7 @@ func splitRecordings(recs []Recording) (events, todos, habits, completions []Rec // than a day: a habit done on three days of a week has three of them, and only the // last would survive as a CompletedAt. The day view wants the fold, the week wants // the list. - completed := make(map[int64]string) + completed := make(map[int64]time.Time) for _, r := range recs { if isHabitCompletion(r.Type) { completed[r.ParentID] = r.StartsAt @@ -100,7 +100,7 @@ func splitRecordings(recs []Recording) (events, todos, habits, completions []Rec } } sort.Slice(events, func(i, j int) bool { - return events[i].StartsAt < events[j].StartsAt + return events[i].StartsAt.Before(events[j].StartsAt) }) return } @@ -110,34 +110,18 @@ func isHabitCompletion(recordingType string) bool { return strings.Contains(t, "habit") && strings.Contains(t, "completion") } -// parseEventTime parses a recording timestamp to time.Time. -func parseEventTime(ts string) time.Time { - if ts == "" { - return time.Time{} - } - for _, layout := range []string{ - "2006-01-02T15:04:05Z", - "2006-01-02T15:04:05-07:00", - "2006-01-02T15:04:05", - "2006-01-02", - } { - if t, err := time.Parse(layout, ts); err == nil { - return t - } - } - return time.Time{} -} - -// eventsByDate groups events by date (YYYY-MM-DD), expanding multi-day events -// so they appear on every day they span. +// eventsByDate groups events by the day a reader would file them under, expanding multi-day +// events so they appear on every day they span. The day is the local one: a 23:30Z meeting +// belongs to tomorrow for anybody east of UTC, and grouping on the UTC date put it on the +// wrong column of the week. func eventsByDate(events []Recording) map[string][]Recording { m := make(map[string][]Recording) for _, e := range events { - st := parseEventTime(e.StartsAt) + st := e.Starts() if st.IsZero() { continue } - et := parseEventTime(e.EndsAt) + et := e.Ends() // Single-day or no end time: just the start date if et.IsZero() || !et.After(st) || dateKey(st) == dateKey(et) { @@ -177,7 +161,7 @@ func dayLabelsFromRecordings(groups ...[]Recording) map[string]string { if recording.Label == "" { continue } - day := parseEventTime(recording.StartsAt) + day := recording.Starts() if day.IsZero() { continue } @@ -355,8 +339,8 @@ func renderDayView(events, habits []Recording, anchor time.Time, hint string, wi // Place events into lanes (non-overlapping groups) placed := make([]placedEvent, 0, len(timed)) for _, e := range timed { - st := parseEventTime(e.StartsAt) - et := parseEventTime(e.EndsAt) + st := e.Starts() + et := e.Ends() if st.IsZero() { continue } @@ -648,7 +632,7 @@ func renderWeekView(events, habits, completions []Recording, anchor time.Time, f byID[habit.ID] = habit } for _, completion := range completions { - done := parseEventTime(completion.StartsAt) + done := completion.Starts() if done.IsZero() { continue } @@ -812,9 +796,12 @@ func buildWeekDayColumn(d weekDayInfo, width int, muted lipgloss.Style) []string var lines []string for _, e := range d.events { + // The clock a reader keeps, not the one HEY answers in. This used to be the 11th + // through 16th characters of the timestamp, which is 14:00 for a 14:00Z event + // whatever time it is where they are. timeStr := "" - if len(e.StartsAt) >= 16 { - timeStr = e.StartsAt[11:16] + if starts := e.Starts(); !starts.IsZero() { + timeStr = starts.Format("15:04") } if timeStr != "" { lines = append(lines, muted.Render(timeStr)) @@ -1078,14 +1065,14 @@ func dayLabelOrDefault(d time.Time, isFirstCol bool, dayLabels map[string]string // done, the color HEY gave it, and the emoji standing in for its icon. func renderHabitsRibbon(habits []Recording, width int) string { return renderRibbon(habits, width, func(habit Recording) (string, lipgloss.Style, string) { - return habitMarker(habit.CompletedAt != ""), habitMarkerStyle(habit.Color), habitLabel(habit) + return habitMarker(habit.Done()), habitMarkerStyle(habit.Color), habitLabel(habit) }) } func renderTodosRibbon(todos []Recording, width int) string { return renderRibbon(todos, width, func(todo Recording) (string, lipgloss.Style, string) { label := terminal.SanitizeLine(todo.Title) - if todo.CompletedAt != "" { + if todo.Done() { return "■", styleMuted, label } return "□", lipgloss.NewStyle().Foreground(colorAlert).Bold(true), label @@ -1104,7 +1091,7 @@ func renderRibbon(items []Recording, width int, describe func(Recording) (string for i, item := range items { marker, markerStyle, label := describe(item) labelStyle := lipgloss.NewStyle().Foreground(colorBright) - if item.CompletedAt != "" { + if item.Done() { labelStyle = styleMuted } diff --git a/internal/tui/habit_form_test.go b/internal/tui/habit_form_test.go index c6a0243b..8c518c2e 100644 --- a/internal/tui/habit_form_test.go +++ b/internal/tui/habit_form_test.go @@ -386,7 +386,7 @@ func TestCalendarHabitEnterCompletesAndClearsForTheDayOnScreen(t *testing.T) { } // A habit already done for the day is cleared by the same key. - view.habitPicker.setHabits([]Recording{{ID: 7, Title: "Read before bed", CompletedAt: "2026-08-22T00:00:00Z"}}) + view.habitPicker.setHabits([]Recording{{ID: 7, Title: "Read before bed", CompletedAt: at("2026-08-22T00:00:00Z")}}) cmd = view.HandleContentKey(keyPress("enter")) if cmd == nil { t.Fatal("enter should clear a habit that is already done") diff --git a/internal/tui/habits.go b/internal/tui/habits.go index 1408494d..08e05694 100644 --- a/internal/tui/habits.go +++ b/internal/tui/habits.go @@ -117,7 +117,7 @@ func (p *habitPicker) draw(base string, width, height int) string { start, end := modalListWindow(len(p.habits), p.cursor, visible) for i := start; i < end; i++ { habit := p.habits[i] - done := habit.CompletedAt != "" + done := habit.Done() marker := habitMarkerStyle(habit.Color).Render(habitMarker(done)) label := truncateToWidth(habitLabel(habit), max(contentWidth-4, 1)) @@ -147,7 +147,7 @@ func (p *habitPicker) helpBindings() []helpBinding { bindings := []helpBinding{{"↑↓", "choose"}} if selected := p.selected(); selected != nil { doneLabel := "mark done" - if selected.CompletedAt != "" { + if selected.Done() { doneLabel = "clear" } deleteLabel := "delete" diff --git a/internal/tui/todos.go b/internal/tui/todos.go index c9cc3589..74c2b6b6 100644 --- a/internal/tui/todos.go +++ b/internal/tui/todos.go @@ -159,7 +159,7 @@ func (p *todoPicker) draw(base string, width, height int) string { start, end := modalListWindow(len(p.todos), p.cursor, visible) for i := start; i < end; i++ { todo := p.todos[i] - done := todo.CompletedAt != "" + done := todo.Done() marker, markerStyle := "□", lipgloss.NewStyle().Foreground(colorAlert).Bold(true) labelStyle := lipgloss.NewStyle().Foreground(colorBright) @@ -208,7 +208,7 @@ func (p *todoPicker) helpBindings() []helpBinding { bindings := []helpBinding{{"↑↓", "choose"}} if selected := p.selected(); selected != nil { doneLabel := "mark done" - if selected.CompletedAt != "" { + if selected.Done() { doneLabel = "clear" } deleteLabel := "delete" diff --git a/internal/tui/todos_test.go b/internal/tui/todos_test.go index ff453b68..0a5ed061 100644 --- a/internal/tui/todos_test.go +++ b/internal/tui/todos_test.go @@ -47,7 +47,7 @@ func calendarTodosWithServer(t *testing.T) (*calendarView, *recordedHabitRequest view.now = func() time.Time { return time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) } view.todos = []Recording{ {ID: 7, Title: "Clean the attic", Type: "Calendar::Todo"}, - {ID: 8, Title: "Send the invoice", Type: "Calendar::Todo", CompletedAt: "2026-08-21T08:00:00Z"}, + {ID: 8, Title: "Send the invoice", Type: "Calendar::Todo", CompletedAt: at("2026-08-21T08:00:00Z")}, } view.Resize(vc.width, vc.height) view.HandleContentKey(keyPress("s")) @@ -93,7 +93,7 @@ func TestTodosModalTicksOffAndClears(t *testing.T) { // A to-do already done is cleared by the same key. view.todoPicker.setTodos([]Recording{ - {ID: 8, Title: "Send the invoice", CompletedAt: "2026-08-21T08:00:00Z"}, + {ID: 8, Title: "Send the invoice", CompletedAt: at("2026-08-21T08:00:00Z")}, }) cmd = view.HandleContentKey(keyPress("enter")) if toast := finishCalendarMutation(t, view, cmd); toast != "To-do cleared" { diff --git a/internal/tui/translate_test.go b/internal/tui/translate_test.go index de681d79..993a8c81 100644 --- a/internal/tui/translate_test.go +++ b/internal/tui/translate_test.go @@ -11,28 +11,57 @@ import ( // worth pinning in one is the decision it makes rather than the fields it copies. A // posting and an entry are described by internal/mail now, and tested there. -func TestSDKRecordingToModelFormatsItsTimes(t *testing.T) { +// A timestamp is carried across as it came, and turned into the reader's own clock only +// where it is read. It used to be rendered into a UTC string here and parsed back, which is +// how the whole calendar came to be drawn in UTC. +func TestSDKRecordingToModelKeepsItsTimes(t *testing.T) { + starts := time.Date(2026, 8, 18, 14, 0, 0, 0, time.UTC) got := sdkRecordingToModel(generated.Recording{ Id: 99, Title: "Standup", Type: "Calendar::Habit", - StartsAt: time.Date(2026, 8, 18, 14, 0, 0, 0, time.UTC), - EndsAt: time.Date(2026, 8, 18, 15, 0, 0, 0, time.UTC), - CompletedAt: time.Date(2026, 8, 18, 16, 0, 0, 0, time.UTC), + StartsAt: starts, + EndsAt: starts.Add(time.Hour), + CompletedAt: starts.Add(2 * time.Hour), }) - if got.StartsAt != "2026-08-18T14:00:00Z" || got.EndsAt != "2026-08-18T15:00:00Z" { - t.Errorf("times = %q / %q", got.StartsAt, got.EndsAt) + if !got.StartsAt.Equal(starts) || !got.EndsAt.Equal(starts.Add(time.Hour)) { + t.Errorf("times = %v / %v", got.StartsAt, got.EndsAt) } - if got.CompletedAt != "2026-08-18T16:00:00Z" { - t.Errorf("completed = %q", got.CompletedAt) + if !got.CompletedAt.Equal(starts.Add(2 * time.Hour)) { + t.Errorf("completed = %v", got.CompletedAt) + } + + // Read back, it is the same instant on the reader's clock rather than on UTC's. + if !got.Starts().Equal(starts) { + t.Errorf("Starts() moved the instant: %v against %v", got.Starts(), starts) + } + if got.Starts().Location() != time.Local { + t.Errorf("Starts() answered in %v, want the local zone", got.Starts().Location()) } } // An incomplete recording is the common case — a todo has no end, an open one no -// completion — and those have to read as empty rather than as a zero date. -func TestSDKRecordingToModelLeavesMissingTimesEmpty(t *testing.T) { +// completion — and those have to read as unset rather than as some date. +func TestSDKRecordingToModelLeavesMissingTimesUnset(t *testing.T) { got := sdkRecordingToModel(generated.Recording{Id: 5, Title: "Buy milk", Type: "Calendar::Todo"}) - if got.StartsAt != "" || got.EndsAt != "" || got.CompletedAt != "" { - t.Errorf("zero times should read as empty: %+v", got) + if !got.StartsAt.IsZero() || !got.EndsAt.IsZero() || got.Done() { + t.Errorf("missing times should read as unset: %+v", got) + } + if !got.Starts().IsZero() || !got.Ends().IsZero() { + t.Errorf("a missing time should stay missing when read: %+v", got) + } +} + +// An all-day event's timestamp is a calendar date, which haystack serves as UTC midnight on +// purpose. Converting it would move a birthday to the day before for anybody west of UTC. +func TestAllDayRecordingsKeepTheirCalendarDate(t *testing.T) { + midnight := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + allDay := Recording{Title: "🎂 Karla", AllDay: true, StartsAt: midnight, EndsAt: midnight} + + if got := allDay.Starts(); !got.Equal(midnight) || got.Day() != 21 { + t.Errorf("an all-day event moved off its date: %v", got) + } + if got := allDay.Starts().Location(); got != time.UTC { + t.Errorf("an all-day date was converted to %v", got) } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 33ba6215..e94e1252 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -961,13 +961,6 @@ func (m model) handleSubnavKey(msg tea.KeyPressMsg) tea.Cmd { // --- Shared utilities --- -func formatTimestamp(ts time.Time) string { - if ts.IsZero() { - return "" - } - return ts.UTC().Format("2006-01-02T15:04:05Z") -} - // Run starts the TUI with the resolved mail account, the identity root client used for // interactive account switching, and the watchers that tell it when things changed. func Run(rootSDK, sdk *hey.Client, selected string, watchers Watchers) error {