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/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/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/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/calendar.go b/internal/tui/calendar.go index 411363d3..778e9908 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" @@ -15,29 +16,87 @@ 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. +// 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 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 +} + +// 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 --- @@ -48,13 +107,24 @@ const ( calendarRequestNone calendarRequestKind = iota calendarRequestCalendars calendarRequestRecordings - calendarRequestHabitMutation + calendarRequestToggle + calendarRequestMutation calendarRequestCategories ) 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 { @@ -62,6 +132,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. @@ -79,9 +156,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 --- @@ -90,7 +170,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 @@ -100,19 +185,37 @@ type calendarView struct { // fetching around the day it started on while the grid highlights today. now func() time.Time - // Recordings split by type + // 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, for the day and the week events []Recording 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 + // Scrollable content viewport for the calendar views contentVP viewport.Model - timeTrackCategories *timeTrackCategoryManager - habitForm *habitForm - habitIndex int - confirmedHabitDeleteID int64 - notice string + // 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 + todoPicker *todoPicker + calendarPicker *calendarPicker requests requestLane[calendarRequestKind] } @@ -127,12 +230,11 @@ 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()) - } 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...) } @@ -149,44 +251,72 @@ 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 } - v.confirmedHabitDeleteID = 0 - v.events, v.todos, v.habits = splitRecordings(msg.recordings) - v.normalizeHabitSelection() + v.events, v.todos, v.habits, v.habitCompletions = splitRecordings(msg.recordings) + if v.habitPicker != nil { + v.habitPicker.setHabits(v.manageableHabits()) + } + if v.todoPicker != nil { + v.todoPicker.setTodos(v.todos) + } + 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 habitMutationMsg: + case calendarMutationMsg: if !v.requests.accepts(msg.requestResult) { return nil, true } v.requests.finish(msg.requestID) if msg.err != 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 - } else { - v.notice = errorNotice("Delete failed", msg.err) + return nil, true } - return nil, true + return notifyError(msg.failure, msg.err), 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 + if len(v.calendars) > 0 { + return tea.Batch(notify(msg.action), v.requestRecordings()), true } - return nil, true + return notify(msg.action), true case timeTrackCategoriesLoadedMsg: if !v.requests.accepts(msg.requestResult) { @@ -230,17 +360,49 @@ 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.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 { - 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 { + return "" + } + 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") } - 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 header + "\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,44 +412,59 @@ 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}) + if v.todoPicker != nil { + return v.todoPicker.helpBindings() + } + if v.calendarPicker != nil { + return v.calendarPicker.helpBindings() + } + // 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 + // 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.listedCalendars()) > 0 { + bindings = append(bindings, helpBinding{"g", "calendars"}) + } + bindings = append(bindings, helpBinding{"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() +} + +// 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 v.calIndex >= 0 && v.calIndex < len(v.calendars) { - label = v.calendars[v.calIndex].Name - } - label += " ยท " + v.viewMode.String() - return calendarNavItems(v.calendars), v.calIndex, label, true + return calendarNavItems(), int(v.viewMode), v.viewMode.String(), true } 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 { @@ -305,56 +482,188 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { } return cmd } - if v.requests.kind == calendarRequestHabitMutation { + if v.requests.kind == calendarRequestMutation { return nil } - if msg.String() != "x" { - v.confirmedHabitDeleteID = 0 + if v.habitPicker != nil { + return v.handleHabitPickerKey(msg) + } + + if v.todoPicker != nil { + return v.handleTodoPickerKey(msg) } - v.notice = "" + + if v.calendarPicker != nil { + return v.handleCalendarPickerKey(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 + // 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 listed := v.listedCalendars(); len(listed) > 0 { + v.calendarPicker = newCalendarPicker(listed, v.selected) + } + return nil case "c": v.timeTrackCategories = newTimeTrackCategoryManager() 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) + // 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 "p": + return v.step(-1) + case "n": + return v.step(1) + case "t": + return v.today() + } + + // 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. +// 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 + + switch msg.String() { + case "esc", "q": + v.calendarPicker = nil + return nil + case "enter", " ", "space": + calendar, ok := picker.highlighted() + if !ok || v.togglePending() { + return nil } - v.rebuildView() + return v.toggleCalendar(calendar) + } + + 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 { + 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 + + 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,13 +732,22 @@ 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, 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() && !v.drawn +} func (v *calendarView) CapturingInput() bool { - return v.timeTrackCategories != nil || v.habitForm != nil + return v.timeTrackCategories != nil || v.habitForm != nil || + v.habitPicker != nil || v.todoPicker != nil || v.calendarPicker != 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 @@ -441,11 +759,12 @@ 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) } + if v.todoPicker != nil { + v.todoPicker.resize(width) + } v.rebuildView() } @@ -457,20 +776,31 @@ 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 + // 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, v.stepHint(), 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, v.habitCompletions, anchor, v.firstWeekDay, w, v.contentVP.Height(), v.stepHint(), 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.stepHint()) } v.contentVP.SetContent(content) + v.drawn = true // For year view, scroll to the current week if v.viewMode == viewYear { @@ -484,58 +814,114 @@ func (v *calendarView) rebuildView() { } } -func (v *calendarView) viewingPersonalCalendar() bool { - return v.calIndex >= 0 && v.calIndex < len(v.calendars) && v.calendars[v.calIndex].Personal +// 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) manageableHabits() []Recording { - seen := make(map[int64]bool) - habits := make([]Recording, 0, len(v.habits)) - for _, habit := range v.habits { - if habit.ID <= 0 || seen[habit.ID] { - continue - } - seen[habit.ID] = true - habits = append(habits, habit) +// 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 take the reader somewhere. +func (v *calendarView) stepHint() string { + hint := "p/n " + v.viewMode.unit() + if !v.showingToday() { + hint += " ยท t today" } - return habits + return hint +} + +// 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: + 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() } -func (v *calendarView) selectedHabit() *Recording { - habits := v.manageableHabits() - if len(habits) == 0 { +// 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.habitIndex = max(0, min(v.habitIndex, len(habits)-1)) - habit := habits[v.habitIndex] - return &habit + v.anchor = time.Time{} + return v.reread() } -func (v *calendarView) normalizeHabitSelection() { - habits := v.manageableHabits() - if len(habits) == 0 { - v.habitIndex = 0 - return +// 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 len(v.calendars) > 0 { + return v.requestRecordings() } - v.habitIndex = max(0, min(v.habitIndex, len(habits)-1)) + v.rebuildView() + return nil } -func (v *calendarView) moveHabitSelection(delta int) { - habits := v.manageableHabits() - if len(habits) == 0 { - v.habitIndex = 0 - return +// 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) + } } - v.habitIndex = (v.habitIndex + delta + len(habits)) % len(habits) + return listed +} + +func (v *calendarView) togglePending() bool { + return v.requests.loading && v.requests.kind == calendarRequestToggle } -func (v *calendarView) habitDeleteConfirmed() bool { - habit := v.selectedHabit() - return habit != nil && v.confirmedHabitDeleteID == habit.ID +// 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 { + for _, calendar := range v.calendars { + if calendar.Personal { + return true + } + } + return false +} + +func (v *calendarView) manageableHabits() []Recording { + seen := make(map[int64]bool) + habits := make([]Recording, 0, len(v.habits)) + for _, habit := range v.habits { + if habit.ID <= 0 || seen[habit.ID] { + continue + } + seen[habit.ID] = true + habits = append(habits, habit) + } + return habits } 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,9 +929,9 @@ 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) + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) return func() tea.Msg { var err error action := "Habit created" @@ -555,15 +941,82 @@ 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"} + } +} + +// 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.day().Local().Format(time.DateOnly) + done := habit.Done() + requestID, ctx := v.requests.begin(v.vc.ctx, calendarRequestMutation) + 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 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.Done() + 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"} } } @@ -573,15 +1026,16 @@ func (v *calendarView) deleteHabit(recording 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 { return Recording{ - ID: r.Id, 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...), + ID: r.Id, ParentID: r.ParentId, Title: r.Title, AllDay: r.AllDay, Type: r.Type, + 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...), } } @@ -618,35 +1072,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, + } } } -// 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.now(), v.firstWeekDay) +func selectionSet(ids []int64) map[int64]bool { + selected := make(map[int64]bool, len(ids)) + for _, id := range ids { + selected[id] = true + } + return selected +} + +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 7f847a14..59f343f0 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -2,28 +2,59 @@ package tui import ( "errors" + "image/color" "net/http" "net/http/httptest" + "strings" "sync" "testing" "time" + "charm.land/lipgloss/v2" + 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 { 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"}, - {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"}, } } @@ -57,7 +88,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") @@ -81,7 +111,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 { @@ -124,60 +154,75 @@ 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, 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() - 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) + // 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 ยท Day" { - t.Errorf("label = %q, want \"Work ยท Day\"", label) + // 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") } + + v.viewMode = viewYear + if _, selected, label, _ = v.SubnavItems(); label != "Year" || selected != int(viewYear) { + t.Errorf("year row = selected:%d label:%q", selected, 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") @@ -185,8 +230,137 @@ func TestCalendarViewSubnavLeftRight(t *testing.T) { v.requests.finish(v.requests.id) v.SubnavRight() - if v.calIndex != 1 { - t.Errorf("SubnavRight at end: calIndex = %d, want 1", v.calIndex) + 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) + } +} + +// --- 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) + + 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") + } + view := stripANSI(v.View()) + if !strings.Contains(view, "Calendars") || !strings.Contains(view, "Design Team") { + t.Errorf("the modal does not list the calendars: %q", view) + } + + cmd := v.HandleContentKey(keyPress(" ")) + if cmd == nil { + t.Fatal("space did not switch the calendar") + } + 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) + } + + // 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 TestCalendarPickerStaysShutWithNothingToSwitch(t *testing.T) { + v := calendarWithRecordings() + 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 switch") } } @@ -197,13 +371,13 @@ 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"}, + {ID: 300, Title: "Design review", StartsAt: at("2025-03-04T15:00:00Z"), EndsAt: at("2025-03-04T16:00:00Z"), Type: "CalendarEvent"}, }} v.Update(fresh) @@ -252,7 +426,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 @@ -266,12 +439,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(`{}`)) @@ -285,21 +460,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) - } - if queries[0] != "ends_on=2025-03-10&starts_on=2025-03-09" { - t.Errorf("first query = %q", queries[0]) + 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[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) } } @@ -321,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", @@ -340,16 +538,817 @@ 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{"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{"p", "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 != "p/n 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") + } +} + +// 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("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("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)) + } +} + +// --- 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, 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: at("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) + } + if len(habits) != 2 { + t.Fatalf("habits = %+v, want the two habits without the completion", habits) + } + 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].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 +// 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) +} + +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: 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) + out := renderWeekView(events, habits, completions, anchor, time.Monday, 100, weekViewRows, "p/n week", nil) + return strings.Split(stripANSI(out), "\n") +} + +func habitDone(habitID int64, day string) Recording { + 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 +// 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"), + }) + + // 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]) + } + 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])) + } + 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 +} + +// 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) + + 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 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) + } + } +} + +// 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: 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: 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: 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) + 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) { + 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]) + } + } +} + +// 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: 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: 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), + 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) { + events, todos, habits, _ := splitRecordings([]Recording{ + {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: 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: at("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 + v.calendars = []Calendar{{ID: 10, Name: "Personal", Personal: true}} + v.habits = []Recording{ + {ID: 7, Title: "Read before bed"}, + {ID: 8, Title: "Evening walk", CompletedAt: at("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: at("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: 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"}} + + day := time.Date(2026, 8, 24, 9, 0, 0, 0, time.Local) + 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) + } + } +} + +// 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: 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) + + // 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 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. + 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) + } + + // 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) + } + if cell := []rune(line)[44]; cell == hourRule { + t.Errorf("grid row %d ruled through the event's own block: %q", i, line) + } + if rules := strings.Count(line, string(hourRule)); rules != 24 { + t.Errorf("grid row %d has %d rules, want 24: %q", i, rules, 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) + } + } +} + +// 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: 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. + 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) { + 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(""); got != colorPrimary { + t.Errorf("an event with no calendar color filled with %v, want the accent", got) + } + + // 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 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()) + } + if style.GetForeground() != colorPaper { + t.Errorf("%q drew %v on a light hue, want the theme's paper %v", + calendarColor, style.GetForeground(), colorPaper) + } + } +} + +// 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())) + } +} + +// 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}) }) + + 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.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 onDark == onLight { + t.Errorf("the pill did not follow the theme: %q", onDark) + } +} + +// 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. +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: atLocal("2026-08-24T11:00:00"), EndsAt: atLocal("2026-08-24T12:00:00")}, + } + 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 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) != 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{"g", "c", "b"} { found := false for _, binding := range bindings { found = found || binding.key == want @@ -358,4 +1357,28 @@ func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { t.Errorf("missing binding %q: %+v", want, bindings) } } + + // 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() { + 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 + + 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..c4273ce7 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" @@ -9,6 +10,7 @@ import ( "charm.land/lipgloss/v2" + habitvalues "github.com/basecamp/hey-cli/internal/habit" "github.com/basecamp/hey-cli/internal/terminal" ) @@ -33,28 +35,17 @@ func (m calendarViewMode) String() string { return "Day" } -func (m calendarViewMode) next() calendarViewMode { - return (m + 1) % 3 -} - -// 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 { +// 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: - start = time.Date(anchor.Year(), anchor.Month(), anchor.Day(), 0, 0, 0, 0, loc) - end = start.AddDate(0, 0, 1) + return "day" case viewWeek: - start = weekStartDate(anchor, firstWeekDay) - end = start.AddDate(0, 0, 7) + return "week" 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 "year" } - return + return "day" } // weekStartDate returns the start of the week containing t. @@ -64,54 +55,73 @@ 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". -func splitRecordings(recs []Recording) (events, todos, habits []Recording) { +// 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, 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]time.Time) + 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): + completions = append(completions, r) 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: + case strings.Contains(t, "event"): events = append(events, r) } } sort.Slice(events, func(i, j int) bool { - return events[i].StartsAt < events[j].StartsAt + return events[i].StartsAt.Before(events[j].StartsAt) }) return } -// 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{} +func isHabitCompletion(recordingType string) bool { + t := strings.ToLower(recordingType) + return strings.Contains(t, "habit") && strings.Contains(t, "completion") } -// 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) { @@ -151,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 } @@ -187,31 +197,133 @@ 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 +) + +// 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. 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 eventTextStyle(cell.color) + 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 { + // 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 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 = 'โ”Š' + +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 + // 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 โ€” 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 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 @@ -227,8 +339,8 @@ func renderDayView(events, todos, habits []Recording, _ time.Time, width, _ int) // 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 } @@ -236,18 +348,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,157 +390,189 @@ 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 + // 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(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)) + b.WriteString(eventPill(e, gridWidth)) 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") - } - - return b.String() + // 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") } -// 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 "" +// 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. +// +// 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 { + laneRows := shareDayRows(max(rows, 1), len(lanes)) + height := max(rows, 1) + if total := sumOf(laneRows); total > height { + height = total } - // 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))) - } - } - 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 { + // 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 โ€” + // 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([][]dayCell, height) + for row := range height { grid[row] = make([]rune, gridWidth) - isBox[row] = make([]bool, gridWidth) + cells[row] = make([]dayCell, gridWidth) for col := range gridWidth { grid[row][col] = ' ' } } - // Draw each event box - for _, pe := range lane { - sc, ec := pe.startCol, pe.endCol - boxW := ec - sc - titleRunes := []rune(terminal.SanitizeLine(pe.rec.Title)) + offset := 0 + for i, lane := range lanes { + drawDayLane(grid, cells, lane, offset, laneRows[i]) + offset += laneRows[i] + } - // Top border: โ”Œโ”€โ”€โ” - grid[0][sc] = 'โ”Œ' - isBox[0][sc] = true - for c := sc + 1; c < ec-1; c++ { - grid[0][c] = 'โ”€' - isBox[0][c] = true - } - if boxW > 1 { - grid[0][ec-1] = 'โ”' - isBox[0][ec-1] = true - } - - // Middle rows: โ”‚c โ”‚ (vertical title text) - for row := 1; row < bandHeight-1; row++ { - grid[row][sc] = 'โ”‚' - isBox[row][sc] = true - if boxW > 1 { - grid[row][ec-1] = 'โ”‚' - isBox[row][ec-1] = true - } - // Title character - titleIdx := row - 1 - if titleIdx < len(titleRunes) && sc+1 < ec-1 { - grid[row][sc+1] = titleRunes[titleIdx] - isBox[row][sc+1] = true + // 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].kind == cellEmpty { + grid[row][col] = hourRule + cells[row][col] = dayCell{kind: cellRule} } - // Fill inner space - for c := sc + 2; c < ec-1; c++ { - isBox[row][c] = true - } - } - - // Bottom border: โ””โ”€โ”€โ”˜ - grid[bandHeight-1][sc] = 'โ””' - isBox[bandHeight-1][sc] = true - for c := sc + 1; c < ec-1; c++ { - grid[bandHeight-1][c] = 'โ”€' - isBox[bandHeight-1][c] = true - } - if boxW > 1 { - grid[bandHeight-1][ec-1] = 'โ”˜' - isBox[bandHeight-1][ec-1] = true } } - // Render the grid row by row, batching consecutive styled/unstyled segments + // Render row by row, batching consecutive cells that draw the same way var b strings.Builder - for row := range bandHeight { + for row := range height { var seg strings.Builder - inStyled := false + cell := dayCell{} flush := func() { - s := seg.String() - if s == "" { - return - } - if inStyled { - b.WriteString(primary.Render(s)) - } else { - b.WriteString(muted.Render(s)) + if s := seg.String(); s != "" { + b.WriteString(cell.style(chrome, title, muted).Render(s)) + seg.Reset() } - seg.Reset() } for col := range gridWidth { - styled := isBox[row][col] - if styled != inStyled { + if cells[row][col] != cell { flush() - inStyled = styled + cell = cells[row][col] } seg.WriteRune(grid[row][col]) } flush() - // Trim trailing spaces b.WriteString("\n") } return b.String() } +// 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 total +} + +// drawDayLane draws one lane of non-overlapping events into the grid at rowOffset, as +// 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 + rows - 1 + + for _, pe := range lane { + sc, ec := pe.startCol, pe.endCol + 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 + } + } + + // 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 + } + } +} + // ============================================= // Week View โ€” 7 day columns with bordered grid // ============================================= @@ -440,15 +584,24 @@ type weekDayInfo struct { allDay []Recording } -func renderWeekView(events, todos, habits []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) - primary := lipgloss.NewStyle().Foreground(colorPrimary) + 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 } @@ -470,43 +623,79 @@ func renderWeekView(events, todos, habits []Recording, anchor time.Time, firstWe } } - // 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 := completion.Starts() + 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) + } + } + } + + // 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") } - sep := muted.Render("โ”‚") + // 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++ + } + } - // Top border - 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++ - // Column headers - b.WriteString(sep) + // 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) - padded := centerPad(label, colWidth) - b.WriteString(bright.Render(padded)) - b.WriteString(sep) + headers[i] = chrome.Render(centerPad(label, colWidth)) } - b.WriteString("\n") - - // Header separator - b.WriteString(weekGridBorder("โ”œ", "โ”ผ", "โ”ค", colWidth, muted)) - b.WriteString("\n") + writeRow(headers) + rowsUsed++ // 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 @@ -515,85 +704,203 @@ func renderWeekView(events, todos, habits []Recording, anchor time.Time, firstWe maxH = len(col) } } - if maxH == 0 { - maxH = 1 + + // 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) } - // 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") - - // Todos ribbon - if len(todos) > 0 { - b.WriteString(renderTodosRibbon(todos, width)) + if len(allDay) > 0 { + b.WriteString(sectionHeader("All day", width)) b.WriteString("\n") + for _, row := range allDay { + writeRow(row) + } } - return b.String() + // No bottom border: the week ends where the screen does, as the day does. + return strings.TrimRight(b.String(), "\n") } -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)) +// 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 +// 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) } - s.WriteString(muted.Render(right)) - return s.String() + // 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 { + 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 } // 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 { + // 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 _, 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 { + // 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)) } - 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))) + 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 +// 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 eventTextStyle(event.CalendarColor).Render(title) } // weekDayColumnLabel returns the header label for a week column. @@ -613,12 +920,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 // =============================================== -func renderYearView(events []Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int, dayLabels map[string]string) string { +// 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. 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 @@ -632,88 +949,69 @@ 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("โ”‚") + 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, maxEventsPerCell, - sameDay(d, today), d.Year() == anchor.Year(), primary, bright, muted, faint, dayLabels) + 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. -// 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 @@ -732,15 +1030,9 @@ func buildYearDayCell(d time.Time, dayEvents []Recording, colWidth, maxEvents in return lines } - // Event titles - shown := min(len(dayEvents), maxEvents) - for i := range shown { - title := truncateStr(terminal.SanitizeLine(dayEvents[i].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))) + // Event titles, each a bar in its calendar's color + for _, event := range dayEvents { + lines = append(lines, eventPill(event, colWidth)) } return lines @@ -769,44 +1061,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.Done()), 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.Done() { + return "โ– ", styleMuted, label + } + 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.Done() { + labelStyle = styleMuted + } + + gap := "" + if i > 0 { + gap = " " } - ribbon = string(runes) + "โ€ฆ" + 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 --- @@ -832,6 +1134,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 diff --git a/internal/tui/calendars.go b/internal/tui/calendars.go new file mode 100644 index 00000000..41641b5f --- /dev/null +++ b/internal/tui/calendars.go @@ -0,0 +1,99 @@ +package tui + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/basecamp/hey-cli/internal/terminal" +) + +// 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 { + 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.calendars), msg) +} + +// 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.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 { + 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"}, {"space", "show or hide"}, {"esc", "close"}} +} 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/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/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..8c518c2e 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) } } @@ -134,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{} @@ -148,8 +215,14 @@ 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.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.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 == 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) } @@ -160,9 +233,14 @@ 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) + // Every habit mutation starts from the habits modal, so these tests open it too. + view.HandleContentKey(keyPress("b")) return view, recorded } @@ -179,46 +257,51 @@ 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) + view.HandleContentKey(keyPress("b")) return view } -func finishHabitMutation(t *testing.T, view *calendarView, cmd tea.Cmd) { +// 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 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) } - 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 { + if cmd == nil || view.requests.kind != calendarRequestMutation { 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 := 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() - 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 @@ -233,16 +316,13 @@ 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 := finishCalendarMutation(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" { + if len(requests) < 2 || requests[0] != "PATCH /calendar/habits/7.json" || requests[1] != "GET "+habitRefreshPath { t.Errorf("requests = %v", requests) } } @@ -260,10 +340,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,71 +356,108 @@ 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 != 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 + // 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 := finishCalendarMutation(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: 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") + } + if toast := finishCalendarMutation(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 { + if cmd == nil || view.requests.kind != calendarRequestMutation { 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 := 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() - 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 new file mode 100644 index 00000000..08e05694 --- /dev/null +++ b/internal/tui/habits.go @@ -0,0 +1,164 @@ +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" +) + +// 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, + "green": lipgloss.Green, + "teal": lipgloss.Cyan, + "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 := heyColors[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.Done() + 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.Done() { + 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/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/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/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/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 +} diff --git a/internal/tui/nav.go b/internal/tui/nav.go index 31a072ec..bcbbffa2 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{ + {shortcut: "1", label: viewDay.String()}, + {shortcut: "2", label: viewWeek.String()}, + {shortcut: "3", label: viewYear.String()}, } - return items } // --- Rendering --- 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() 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()) != "" { 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/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/todos.go b/internal/tui/todos.go new file mode 100644 index 00000000..74c2b6b6 --- /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.Done() + + 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.Done() { + 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..0a5ed061 --- /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: at("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: at("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]) + } +} 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 0357cc2b..e94e1252 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 != "" { @@ -933,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 { 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) } 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" ];