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: "