diff --git a/docs/demos/journal-before.png b/docs/demos/journal-before.png new file mode 100644 index 00000000..bd1d50e7 Binary files /dev/null and b/docs/demos/journal-before.png differ diff --git a/docs/demos/journal-feed.gif b/docs/demos/journal-feed.gif new file mode 100644 index 00000000..d0e7dd98 Binary files /dev/null and b/docs/demos/journal-feed.gif differ diff --git a/docs/demos/journal-feed.tape b/docs/demos/journal-feed.tape new file mode 100644 index 00000000..8ee5ef34 --- /dev/null +++ b/docs/demos/journal-feed.tape @@ -0,0 +1,57 @@ +# Journal feed usability concept, recorded against the development Haystack. +Output docs/demos/journal-feed.gif + +Require ./bin/hey + +Set Shell "bash" +Set FontSize 18 +Set Width 1100 +Set Height 650 +Set TypingSpeed 35ms +Set Framerate 30 +Set Theme "Catppuccin Mocha" + +Type "./bin/hey tui" Enter +Sleep 4s + +# Open Journal and browse far enough to load older entries. +Type "J" +Sleep 4s +PageDown@120ms 2 +Sleep 3s +Down@100ms 5 +Sleep 2s + +# Search journal content and open the result. +Type "/" +Sleep 1s +Type "Northstar" +Enter +Sleep 4s +Enter +Sleep 3s +Escape +Sleep 2s +Type "c" +Sleep 4s + +# Jump directly to a known date. +Type "g" +Sleep 1s +Type "2026-07-14" +Enter +Sleep 4s +Escape +Sleep 2s + +# Add today's entry from the feed. +Type "a" +Sleep 3s +Type "Captured feedback on the new journal feed and planned the next usability pass." +Sleep 2s +Ctrl+S +Sleep 5s + +Ctrl+C +Sleep 500ms +Ctrl+C diff --git a/go.mod b/go.mod index 28da28c0..0e70e965 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.13.0 + github.com/basecamp/hey-sdk/go v0.15.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 f3cf0068..b4a0bb3c 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.13.0 h1:aQbU/TJp1AeYTLZhAjXQv1VVHrzmrYAE7lVS9TD5D8o= -github.com/basecamp/hey-sdk/go v0.13.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= +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/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/tui/journal.go b/internal/tui/journal.go index a348b5e0..1d75b735 100644 --- a/internal/tui/journal.go +++ b/internal/tui/journal.go @@ -3,99 +3,197 @@ package tui import ( "context" "errors" + "fmt" stdhtml "html" "io" "strings" "time" + "charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" nethtml "golang.org/x/net/html" + "github.com/basecamp/hey-sdk/go/pkg/generated" + "github.com/basecamp/hey-cli/internal/htmlutil" "github.com/basecamp/hey-cli/internal/markdown" + "github.com/basecamp/hey-cli/internal/terminal" ) -// --- Journal messages --- - type journalRequestKind int const ( journalRequestNone journalRequestKind = iota - journalRequestEntry + journalRequestFeed + journalRequestDetail journalRequestMutation ) +type journalPageLoadedMsg struct { + requestResult + entries []journalSummary + nextPage string +} + +type journalPageAppendedMsg struct { + requestID uint64 + entries []journalSummary + nextPage string + err error +} + type journalDetailMsg struct { requestResult + date string content string body htmlutil.Markdown images [][]byte + edit bool } type journalSavedMsg struct { requestResult + date string removed bool } -// --- Journal section view --- +type journalPromptKind int + +const ( + journalPromptNone journalPromptKind = iota + journalPromptSearch + journalPromptDate +) + +type journalPrompt struct { + kind journalPromptKind + input textinput.Model + status string + styles styles +} + +func newJournalPrompt(kind journalPromptKind, value string, styles styles) *journalPrompt { + input := textinput.New() + input.Prompt = "" + input.SetValue(value) + if kind == journalPromptSearch { + input.Placeholder = "Search your journal…" + } else { + input.Placeholder = "YYYY-MM-DD" + } + return &journalPrompt{kind: kind, input: input, styles: styles} +} + +func (p *journalPrompt) init() tea.Cmd { return p.input.Focus() } + +func (p *journalPrompt) resize(width int) { p.input.SetWidth(max(width-14, 10)) } + +func (p *journalPrompt) update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + return cmd +} + +func (p *journalPrompt) view() string { + title, label := "Search journal", "Search: " + if p.kind == journalPromptDate { + title, label = "Go to date", "Date: " + } + var b strings.Builder + b.WriteString(p.styles.title.Render(title)) + b.WriteString("\n\n") + b.WriteString(styleMuted.Render(label)) + b.WriteString(p.input.View()) + if p.status != "" { + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(colorError).Render(terminal.SanitizeLine(p.status))) + } + return b.String() +} type journalView struct { vc *viewContext - dates []string - dateIndex int - - topicViewport viewport.Model - topicContent string - editContent string - inThread bool + list journalList + loaded bool + query string + nextPage string + loadingMore bool + moreID uint64 + + detailDate string + detailContent string + detailBody htmlutil.Markdown + detailView viewport.Model + inDetail bool form *journalForm + prompt *journalPrompt + confirmRemove bool + selectDate string notice string - requests requestLane[journalRequestKind] + + requests requestLane[journalRequestKind] } func newJournalView(vc *viewContext) *journalView { return &journalView{ - vc: vc, - dates: generateJournalDates(30), - topicViewport: viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)), + vc: vc, + detailView: viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)), } } func (v *journalView) Init() tea.Cmd { - v.dates = generateJournalDates(30) - v.dateIndex = len(v.dates) - 1 - return v.requestJournalEntry() + if v.loaded { + return nil + } + return v.requestFeed("") } func (v *journalView) Update(msg tea.Msg) (tea.Cmd, bool) { switch msg := msg.(type) { - case journalDetailMsg: + case journalPageLoadedMsg: if cmd, ok := v.requests.settle(msg.requestResult); !ok { return cmd, true } - v.inThread = true - v.editContent = msg.content - v.topicContent = markdown.Render(msg.body, max(v.vc.width-4, 40)) - if msg.body.IsEmpty() { - v.topicContent = "(empty)" + v.loaded = true + v.loadingMore = false + v.nextPage = msg.nextPage + v.list.setEntries(msg.entries) + if v.selectDate != "" { + v.list.selectDate(v.selectDate) + v.selectDate = "" } - v.topicViewport.SetContent(v.topicContent) - v.topicViewport.GotoTop() - var uploadCmds []tea.Cmd - for _, imgData := range msg.images { - imageID := nextImageID() - cols, rows := imageDimensions(imgData, v.vc.width-4) - v.topicContent += "\n\n" + renderImagePlaceholder(imageID, cols, rows) - v.topicViewport.SetContent(v.topicContent) - seq := kittyUploadAndPlace(imgData, imageID, cols, rows) - uploadCmds = append(uploadCmds, tea.Raw(seq)) + return v.loadMoreEntries(), true + + case journalPageAppendedMsg: + if msg.requestID != v.moreID { + return nil, true } - if len(uploadCmds) > 0 { - return tea.Batch(uploadCmds...), true + v.loadingMore = false + if msg.err != nil { + v.notice = errorNotice("Could not load older journal entries", msg.err) + return nil, true } - return nil, true + v.list.growEntries(msg.entries) + v.nextPage = msg.nextPage + return v.loadMoreEntries(), true + + case journalDetailMsg: + if cmd, ok := v.requests.settle(msg.requestResult); !ok { + return cmd, true + } + v.detailDate = msg.date + v.detailContent = msg.content + v.detailBody = msg.body + v.inDetail = true + v.confirmRemove = false + uploads := v.renderDetail(msg.images) + if msg.edit { + return tea.Batch(uploads, v.startEditor()), true + } + return uploads, true case journalSavedMsg: if !v.requests.accepts(msg.requestResult) { @@ -107,155 +205,404 @@ func (v *journalView) Update(msg tea.Msg) (tea.Cmd, bool) { v.form.saving = false v.form.status = errorNotice("Save failed", msg.err) v.form.isError = true + } else { + v.notice = errorNotice("Could not remove journal entry", msg.err) } return nil, true } v.form = nil - v.setNotice("Journal entry saved") + v.inDetail = false + v.query = "" + v.selectDate = msg.date + v.notice = "Journal entry saved" if msg.removed { - v.setNotice("Journal entry removed") + v.notice = "Journal entry removed" } - return v.requestJournalEntry(), true + return v.requestFeed(""), true } + if v.prompt != nil { + return v.prompt.update(msg), true + } if v.form != nil { return v.form.update(msg), true } - if v.inThread { + if v.inDetail { var cmd tea.Cmd - v.topicViewport, cmd = v.topicViewport.Update(msg) + v.detailView, cmd = v.detailView.Update(msg) return cmd, cmd != nil } - return nil, false } func (v *journalView) View() string { + if v.prompt != nil { + return v.prompt.view() + } if v.form != nil { return v.form.view() } - if v.notice != "" { - return v.vc.styles.title.Render(v.notice) + "\n" + v.topicViewport.View() + if v.inDetail { + if v.notice != "" { + return v.vc.styles.title.Render(v.notice) + "\n" + v.detailView.View() + } + return v.detailView.View() + } + + var heading string + switch { + case v.notice != "": + heading = v.vc.styles.title.Render(v.notice) + case v.query != "": + heading = fmt.Sprintf("Search: %s · %d results", terminal.SanitizeLine(v.query), len(v.list.entries)) + case len(v.list.entries) == 0 && v.loaded: + heading = "No journal entries yet · press a to write about today" + case !v.hasToday(): + heading = "Today is empty · press a to write" + default: + heading = "Journal · newest first" + } + if v.loadingMore { + heading += " · loading older entries…" } - return v.topicViewport.View() + return v.vc.styles.title.Render(heading) + "\n" + v.list.view() } func (v *journalView) HelpBindings() []helpBinding { + if v.prompt != nil { + label := "search" + if v.prompt.kind == journalPromptDate { + label = "go" + } + return []helpBinding{{"enter", label}, {"esc", "cancel"}} + } if v.form != nil { return v.form.helpBindings() } - return []helpBinding{{"e", "edit"}} + if v.inDetail { + bindings := []helpBinding{{"e", "edit"}, {"t", "today"}} + if strings.TrimSpace(v.detailContent) != "" { + label := "remove" + if v.confirmRemove { + label = "confirm remove" + } + bindings = append(bindings, helpBinding{"x", label}) + } + return bindings + } + bindings := []helpBinding{{"enter", "open"}, {"a", "add today"}, {"/", "search"}, {"g", "go to date"}, {"t", "today"}, {"r", "refresh"}} + if v.query != "" { + bindings = append(bindings, helpBinding{"c", "clear search"}) + } + return bindings } func (v *journalView) SubnavItems() ([]navItem, int, string, bool) { label := "Journal" - if v.dateIndex >= 0 && v.dateIndex < len(v.dates) { - label = v.dates[v.dateIndex] + if v.inDetail && v.detailDate != "" { + label = "Journal · " + v.detailDate + } else if v.query != "" { + label = "Journal search" } - return journalNavItems(v.dates), v.dateIndex, label, false + return nil, 0, label, true } -func (v *journalView) SubnavLeft() tea.Cmd { - if v.dateIndex > 0 { - v.dateIndex-- - v.setNotice("") - return v.requestJournalEntry() - } - return nil -} - -func (v *journalView) SubnavRight() tea.Cmd { - if v.dateIndex < len(v.dates)-1 { - v.dateIndex++ - v.setNotice("") - return v.requestJournalEntry() - } - return nil -} +func (v *journalView) SubnavLeft() tea.Cmd { return nil } +func (v *journalView) SubnavRight() tea.Cmd { return nil } func (v *journalView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { + if v.prompt != nil { + return v.handlePromptKey(msg) + } if v.form != nil { if msg.Key().Code == tea.KeyEscape && !v.form.saving { - v.form = nil + if v.form.canClose() { + v.form = nil + } return nil } - cmd, submit := v.form.handleKey(msg) - if submit { - return v.saveJournalEntry() + cmd, action := v.form.handleKey(msg) + switch action { + case journalFormSave: + return v.saveJournalEntry(false) + case journalFormRemove: + return v.saveJournalEntry(true) + default: + return cmd } + } + if v.requests.kind == journalRequestMutation { + return nil + } + + if msg.String() != "x" { + v.confirmRemove = false + } + v.notice = "" + if v.inDetail { + switch msg.String() { + case "e": + return v.startEditor() + case "t": + return v.requestDate(todayJournalDate(), false) + case "x": + if strings.TrimSpace(v.detailContent) == "" { + return nil + } + if !v.confirmRemove { + v.confirmRemove = true + v.notice = "Press x again to permanently remove this journal entry" + return nil + } + return v.removeJournalEntry(v.detailDate) + } + var cmd tea.Cmd + v.detailView, cmd = v.detailView.Update(msg) return cmd } - if msg.String() == "e" && v.inThread { - v.setNotice("") - v.form = newJournalForm(v.dates[v.dateIndex], v.editContent, v.vc.styles) - v.form.resize(v.vc.width, v.vc.height) - return v.form.init() + + switch msg.Key().Code { + case tea.KeyUp: + v.list.moveUp() + case tea.KeyDown: + v.list.moveDown() + return v.loadMoreEntries() + case tea.KeyPgDown: + for range v.list.visibleCount() { + v.list.moveDown() + } + return v.loadMoreEntries() + case tea.KeyPgUp: + for range v.list.visibleCount() { + v.list.moveUp() + } + case tea.KeyEnter: + if entry := v.list.selected(); entry != nil { + return v.requestDate(entry.Date, false) + } + default: + switch msg.String() { + case "j": + v.list.moveDown() + return v.loadMoreEntries() + case "k": + v.list.moveUp() + case "a", "t": + return v.requestDate(todayJournalDate(), msg.String() == "a") + case "e": + if entry := v.list.selected(); entry != nil { + return v.requestDate(entry.Date, true) + } + case "/": + return v.startPrompt(journalPromptSearch, v.query) + case "g": + return v.startPrompt(journalPromptDate, "") + case "c": + if v.query != "" { + v.query = "" + return v.requestFeed("") + } + case "r": + return v.requestFeed(v.query) + } + } + return nil +} + +func (v *journalView) handlePromptKey(msg tea.KeyPressMsg) tea.Cmd { + if msg.Key().Code == tea.KeyEscape { + v.prompt = nil + return nil + } + if msg.Key().Code != tea.KeyEnter { + return v.prompt.update(msg) } - // Journal always shows content in viewport. - var cmd tea.Cmd - v.topicViewport, cmd = v.topicViewport.Update(msg) - return cmd + value := strings.TrimSpace(v.prompt.input.Value()) + if v.prompt.kind == journalPromptSearch { + v.prompt = nil + v.query = value + return v.requestFeed(value) + } + if _, err := time.Parse("2006-01-02", value); err != nil { + v.prompt.status = "Use a date in YYYY-MM-DD format" + return nil + } + v.prompt = nil + return v.requestDate(value, false) +} + +func (v *journalView) startPrompt(kind journalPromptKind, value string) tea.Cmd { + v.prompt = newJournalPrompt(kind, value, v.vc.styles) + v.prompt.resize(v.vc.width) + return v.prompt.init() +} + +func (v *journalView) startEditor() tea.Cmd { + v.notice = "" + v.form = newJournalForm(v.detailDate, v.detailContent, v.vc.styles) + v.form.resize(v.vc.width, v.vc.height) + return v.form.init() } -func (v *journalView) InThread() bool { return v.inThread } -func (v *journalView) ExitThread() {} // no-op: journal always shows content -func (v *journalView) Loading() bool { return v.requests.loading } -func (v *journalView) CapturingInput() bool { - return v.form != nil +func (v *journalView) InThread() bool { return v.inDetail } + +func (v *journalView) ExitThread() { + v.inDetail = false + v.form = nil + v.confirmRemove = false + v.detailDate = "" + v.detailContent = "" + v.detailBody = htmlutil.Markdown{} + v.requests.cancel() +} + +func (v *journalView) CancelPendingDetail() bool { + if v.requests.kind != journalRequestDetail { + return false + } + v.requests.cancel() + return true } +func (v *journalView) CapturingInput() bool { return v.form != nil || v.prompt != nil } + func (v *journalView) AccountSwitchBlocked() bool { return v.requests.kind == journalRequestMutation } -// Restyle hands the active palette to the form. Journal content itself is plain text -// and Kitty placeholders, so the viewport needs no repaint. +func (v *journalView) Loading() bool { return v.requests.loading } + func (v *journalView) Restyle() { if v.form != nil { v.form.styles = v.vc.styles } + if v.prompt != nil { + v.prompt.styles = v.vc.styles + } } func (v *journalView) Resize(width, height int) { - v.topicViewport.SetWidth(width) - v.resizeViewport(height) + v.list.setSize(width, max(height-1, 1)) + v.detailView.SetWidth(width) + v.detailView.SetHeight(height) if v.form != nil { v.form.resize(width, height) } + if v.prompt != nil { + v.prompt.resize(width) + } } -func (v *journalView) setNotice(notice string) { - v.notice = notice - v.resizeViewport(v.vc.height) +func (v *journalView) hasToday() bool { + today := todayJournalDate() + for _, entry := range v.list.entries { + if entry.Date == today { + return true + } + } + return false } -func (v *journalView) resizeViewport(height int) { - if v.notice != "" { - height-- +func todayJournalDate() string { return time.Now().Format("2006-01-02") } + +func (v *journalView) requestFeed(query string) tea.Cmd { + v.nextPage = "" + v.loadingMore = false + v.moreID++ + v.query = query + requestID, ctx := v.requests.begin(v.vc.ctx, journalRequestFeed) + return v.fetchJournalPage(ctx, requestID, "", query) +} + +func (v *journalView) loadMoreEntries() tea.Cmd { + if v.loadingMore || v.nextPage == "" || v.query != "" { + return nil + } + if v.list.hasRowsBelow() && len(v.list.entries)-v.list.cursor > loadMoreThreshold { + return nil } - v.topicViewport.SetHeight(max(height, 1)) + v.loadingMore = true + v.moreID++ + return v.fetchMoreJournalPage(v.vc.ctx, v.moreID, v.nextPage) } -// --- Fetch command --- +func (v *journalView) fetchJournalPage(ctx context.Context, requestID uint64, page, query string) tea.Cmd { + return func() tea.Msg { + result, err := v.vc.sdk.Journal().ListPage(ctx, page, query) + if err != nil { + return journalPageLoadedMsg{requestResult: newRequestResult(requestID, err)} + } + if result == nil { + return journalPageLoadedMsg{requestResult: newRequestResult(requestID, nil)} + } + return journalPageLoadedMsg{ + requestResult: newRequestResult(requestID, nil), + entries: journalSummaries(result.Entries), + nextPage: result.NextPage, + } + } +} + +func (v *journalView) fetchMoreJournalPage(ctx context.Context, requestID uint64, page string) tea.Cmd { + return func() tea.Msg { + result, err := v.vc.sdk.Journal().ListPage(ctx, page, "") + if err != nil { + return journalPageAppendedMsg{requestID: requestID, err: err} + } + if result == nil { + return journalPageAppendedMsg{requestID: requestID} + } + return journalPageAppendedMsg{ + requestID: requestID, + entries: journalSummaries(result.Entries), + nextPage: result.NextPage, + } + } +} -func (v *journalView) requestJournalEntry() tea.Cmd { - v.inThread = false - v.editContent = "" - requestID, ctx := v.requests.begin(v.vc.ctx, journalRequestEntry) - return v.fetchJournalEntry(ctx, requestID, v.dates[v.dateIndex]) +func journalSummaries(recordings []generated.Recording) []journalSummary { + entries := make([]journalSummary, 0, len(recordings)) + for _, recording := range recordings { + if recording.Type != "" && recording.Type != "Calendar::JournalEntry" { + continue + } + date, starts := journalRecordingDate(recording.StartsAt) + entries = append(entries, journalSummary{ + ID: recording.Id, + Date: date, + Starts: starts, + Preview: recording.Content, + }) + } + return entries } -// A day HEY has nothing for answers 204, which opens as an empty editable page. A failed -// read remains an error so the editor cannot replace an entry whose content it never saw. -func (v *journalView) fetchJournalEntry(ctx context.Context, requestID uint64, date string) tea.Cmd { +// HEY sends a journal recording's day as midnight UTC so its calendar date survives +// deserialization unchanged. Build the display time from that date rather than converting +// midnight to local time, which would move the entry onto yesterday west of UTC. +func journalRecordingDate(starts time.Time) (string, time.Time) { + if starts.IsZero() { + return "", time.Time{} + } + utc := starts.UTC() + year, month, day := utc.Date() + return utc.Format("2006-01-02"), time.Date(year, month, day, 12, 0, 0, 0, time.Local) +} + +func (v *journalView) requestDate(date string, edit bool) tea.Cmd { + requestID, ctx := v.requests.begin(v.vc.ctx, journalRequestDetail) + return v.fetchJournalEntry(ctx, requestID, date, edit) +} + +func (v *journalView) fetchJournalEntry(ctx context.Context, requestID uint64, date string, edit bool) tea.Cmd { return func() tea.Msg { recording, err := v.vc.sdk.Journal().Get(ctx, date) if err != nil { - return journalDetailMsg{requestResult: newRequestResult(requestID, err)} + return journalDetailMsg{requestResult: newRequestResult(requestID, err), date: date, edit: edit} } if recording == nil { - return journalDetailMsg{requestResult: newRequestResult(requestID, nil)} + return journalDetailMsg{requestResult: newRequestResult(requestID, nil), date: date, edit: edit} } editableContent := recording.ContentHtml @@ -273,16 +620,48 @@ func (v *journalView) fetchJournalEntry(ctx context.Context, requestID uint64, d return journalDetailMsg{ requestResult: newRequestResult(requestID, nil), + date: date, content: editableContent, body: htmlToMarkdown(renderedContent), images: images, + edit: edit, } } } -// journalEditorContent returns a stable rich-text document for HEY updates. It removes -// div.trix-content presentation containers and writes every other token byte-for-byte, -// preserving Trix attachment attributes across repeated edit-save cycles. +func (v *journalView) renderDetail(images [][]byte) tea.Cmd { + content := markdown.Render(v.detailBody, max(v.vc.width-4, 40)) + if v.detailBody.IsEmpty() { + content = "No entry for this day · press e to write" + } + heading := friendlyDateFromString(v.detailDate) + content = v.vc.styles.title.Render(heading) + "\n\n" + content + uploads := make([]tea.Cmd, 0, len(images)) + for _, imageData := range images { + imageID := nextImageID() + cols, rows := imageDimensions(imageData, v.vc.width-4) + content += "\n\n" + renderImagePlaceholder(imageID, cols, rows) + uploads = append(uploads, tea.Raw(kittyUploadAndPlace(imageData, imageID, cols, rows))) + } + v.detailView.SetContent(content) + v.detailView.GotoTop() + if len(uploads) == 0 { + return nil + } + return tea.Batch(uploads...) +} + +func friendlyDateFromString(date string) string { + parsed, err := time.Parse("2006-01-02", date) + if err != nil { + return terminal.SanitizeLine(date) + } + if date == todayJournalDate() { + return "Today · " + parsed.Format("Monday, January 2, 2006") + } + return parsed.Format("Monday, January 2, 2006") +} + func journalEditorContent(content string) string { content = strings.TrimSpace(content) tokens := nethtml.NewTokenizer(strings.NewReader(content)) @@ -340,27 +719,23 @@ func hasHTMLClass(token nethtml.Token, name string) bool { return false } -func (v *journalView) saveJournalEntry() tea.Cmd { +func (v *journalView) saveJournalEntry(remove bool) tea.Cmd { content := v.form.content() + if remove { + content = "" + } date := v.form.date requestID, ctx := v.requests.begin(v.vc.ctx, journalRequestMutation) return func() tea.Msg { _, err := v.vc.sdk.Journal().Update(ctx, date, content) - return journalSavedMsg{ - requestResult: newRequestResult(requestID, err), - removed: content == "", - } + return journalSavedMsg{requestResult: newRequestResult(requestID, err), date: date, removed: remove} } } -// --- Journal date generation --- - -func generateJournalDates(n int) []string { - dates := make([]string, n) - today := time.Now() - for i := range n { - d := today.AddDate(0, 0, -(n - 1 - i)) - dates[i] = d.Format("2006-01-02") +func (v *journalView) removeJournalEntry(date string) tea.Cmd { + requestID, ctx := v.requests.begin(v.vc.ctx, journalRequestMutation) + return func() tea.Msg { + _, err := v.vc.sdk.Journal().Update(ctx, date, "") + return journalSavedMsg{requestResult: newRequestResult(requestID, err), date: date, removed: true} } - return dates } diff --git a/internal/tui/journal_form.go b/internal/tui/journal_form.go index 15c92e80..1336a8df 100644 --- a/internal/tui/journal_form.go +++ b/internal/tui/journal_form.go @@ -8,13 +8,24 @@ import ( "charm.land/lipgloss/v2" ) +type journalFormAction int + +const ( + journalFormNone journalFormAction = iota + journalFormSave + journalFormRemove +) + type journalForm struct { - date string - input textarea.Model - status string - isError bool - saving bool - styles styles + date string + initial string + input textarea.Model + status string + isError bool + saving bool + confirmRemove bool + confirmDiscard bool + styles styles } func newJournalForm(date, content string, styles styles) *journalForm { @@ -23,31 +34,79 @@ func newJournalForm(date, content string, styles styles) *journalForm { input.ShowLineNumbers = false input.Placeholder = "Write about your day…" input.SetValue(content) - return &journalForm{date: date, input: input, styles: styles} + return &journalForm{date: date, initial: content, input: input, styles: styles} } func (f *journalForm) init() tea.Cmd { return f.input.Focus() } func (f *journalForm) resize(width, height int) { f.input.SetWidth(max(width-4, 10)) - f.input.SetHeight(max(height-7, 3)) + f.input.SetHeight(max(height-8, 3)) } func (f *journalForm) content() string { return strings.TrimSpace(f.input.Value()) } -func (f *journalForm) handleKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { +func (f *journalForm) dirty() bool { + return f.input.Value() != f.initial +} + +func (f *journalForm) canClose() bool { + if !f.dirty() || f.confirmDiscard { + return true + } + f.confirmDiscard = true + f.confirmRemove = false + f.status = "Press esc again to discard your changes" + f.isError = false + return false +} + +func (f *journalForm) handleKey(msg tea.KeyPressMsg) (tea.Cmd, journalFormAction) { if f.saving { - return nil, false + return nil, journalFormNone } - if msg.String() == "ctrl+s" { + + switch msg.String() { + case "ctrl+s": + f.confirmDiscard = false + f.confirmRemove = false + if f.content() == "" { + if strings.TrimSpace(f.initial) == "" { + f.status = "Write something before saving" + } else { + f.status = "The entry is empty. Press ctrl+d twice to remove it" + } + f.isError = true + return nil, journalFormNone + } f.saving = true f.status = "Saving…" f.isError = false - return nil, true + return nil, journalFormSave + case "ctrl+d": + f.confirmDiscard = false + if strings.TrimSpace(f.initial) == "" { + f.status = "There is no saved entry to remove" + f.isError = true + return nil, journalFormNone + } + if !f.confirmRemove { + f.confirmRemove = true + f.status = "Press ctrl+d again to permanently remove this entry" + f.isError = false + return nil, journalFormNone + } + f.saving = true + f.status = "Removing…" + f.isError = false + return nil, journalFormRemove } - return f.update(msg), false + + f.confirmDiscard = false + f.confirmRemove = false + return f.update(msg), journalFormNone } func (f *journalForm) update(msg tea.Msg) tea.Cmd { @@ -57,7 +116,15 @@ func (f *journalForm) update(msg tea.Msg) tea.Cmd { } func (f *journalForm) helpBindings() []helpBinding { - return []helpBinding{{"ctrl+s", "save"}, {"esc", "cancel"}} + bindings := []helpBinding{{"ctrl+s", "save"}, {"esc", "cancel"}} + if strings.TrimSpace(f.initial) != "" { + label := "remove" + if f.confirmRemove { + label = "confirm remove" + } + bindings = append(bindings, helpBinding{"ctrl+d", label}) + } + return bindings } func (f *journalForm) view() string { @@ -65,7 +132,7 @@ func (f *journalForm) view() string { b.WriteString(f.styles.title.Render("Journal · " + f.date)) b.WriteString("\n\n") b.WriteString(f.input.View()) - b.WriteString("\n" + styleMuted.Render("Rich formatting appears as HTML. Saving an empty entry removes it.")) + b.WriteString("\n" + styleMuted.Render("Rich formatting appears as HTML. Removal uses ctrl+d and requires confirmation.")) if f.status != "" { statusStyle := styleMuted if f.isError { diff --git a/internal/tui/journal_list.go b/internal/tui/journal_list.go new file mode 100644 index 00000000..db9cb962 --- /dev/null +++ b/internal/tui/journal_list.go @@ -0,0 +1,151 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "charm.land/lipgloss/v2" + + "github.com/basecamp/hey-cli/internal/terminal" +) + +type journalSummary struct { + ID int64 + Date string + Starts time.Time + Preview string +} + +type journalList struct { + entries []journalSummary + cursor int + scrollOff int + width int + height int +} + +func (l *journalList) setEntries(entries []journalSummary) { + l.entries = entries + l.cursor = 0 + l.scrollOff = 0 +} + +func (l *journalList) growEntries(entries []journalSummary) { + seen := make(map[int64]bool, len(l.entries)) + for _, entry := range l.entries { + seen[entry.ID] = true + } + for _, entry := range entries { + if !seen[entry.ID] { + l.entries = append(l.entries, entry) + seen[entry.ID] = true + } + } +} + +func (l *journalList) setSize(width, height int) { + l.width = width + l.height = height + l.ensureVisible() +} + +func (l *journalList) moveUp() { + if l.cursor > 0 { + l.cursor-- + l.ensureVisible() + } +} + +func (l *journalList) moveDown() { + if l.cursor < len(l.entries)-1 { + l.cursor++ + l.ensureVisible() + } +} + +func (l *journalList) visibleCount() int { + return max(l.height/2, 1) +} + +func (l *journalList) hasRowsBelow() bool { + return l.scrollOff+l.visibleCount() < len(l.entries) +} + +func (l *journalList) ensureVisible() { + visible := l.visibleCount() + if l.cursor < l.scrollOff { + l.scrollOff = l.cursor + } + if l.cursor >= l.scrollOff+visible { + l.scrollOff = l.cursor - visible + 1 + } +} + +func (l *journalList) selected() *journalSummary { + if l.cursor < 0 || l.cursor >= len(l.entries) { + return nil + } + return &l.entries[l.cursor] +} + +func (l *journalList) selectDate(date string) { + for index := range l.entries { + if l.entries[index].Date == date { + l.cursor = index + l.ensureVisible() + return + } + } +} + +func (l *journalList) view() string { + if len(l.entries) == 0 { + return "" + } + end := min(l.scrollOff+l.visibleCount(), len(l.entries)) + cursorMarker, selected := cursorStyles() + selectedGap := selectionStyle(lipgloss.NewStyle()) + normal := lipgloss.NewStyle().Foreground(colorBright) + + var b strings.Builder + for index := l.scrollOff; index < end; index++ { + entry := l.entries[index] + active := index == l.cursor + prefix := " " + if active { + prefix = cursorMarker.Render("│") + selectedGap.Render(" ") + } + date := friendlyJournalDate(entry.Starts) + preview := truncateStr(terminal.SanitizeLine(entry.Preview), max(l.width-6, 10)) + if preview == "" { + preview = "(empty)" + } + if active { + fmt.Fprintf(&b, "%s%s\n", prefix, selected.Render(date)) + fmt.Fprintf(&b, "%s%s%s\n", cursorMarker.Render("│"), selectedGap.Render(" "), selected.Render(preview)) + } else { + fmt.Fprintf(&b, "%s%s\n", prefix, normal.Render(date)) + fmt.Fprintf(&b, " %s\n", styleMuted.Render(preview)) + } + } + return b.String() +} + +func friendlyJournalDate(starts time.Time) string { + local := starts.Local() + if local.IsZero() { + return "Journal entry" + } + today := time.Now().Local() + if sameJournalDay(local, today) { + return "Today · " + local.Format("Monday, January 2") + } + return local.Format("Monday, January 2, 2006") +} + +func sameJournalDay(left, right time.Time) bool { + ly, lm, ld := left.Date() + ry, rm, rd := right.Date() + return ly == ry && lm == rm && ld == rd +} diff --git a/internal/tui/journal_test.go b/internal/tui/journal_test.go index 9b3c34b7..5a286e1b 100644 --- a/internal/tui/journal_test.go +++ b/internal/tui/journal_test.go @@ -6,549 +6,324 @@ import ( "net/http" "net/http/httptest" "strings" - "sync/atomic" "testing" "time" + "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" "github.com/basecamp/hey-cli/internal/htmlutil" ) -func journalWithEntry() *journalView { - v := newJournalView(testVC()) - v.Init() - v.Update(journalDetailMsg{ - requestResult: currentJournalRequest(v), - content: "

Today was great

", - body: htmlutil.ToMarkdown("

Today was great

"), - }) - return v -} - -// currentJournalRequest tags a response as the answer to the read the journal is -// waiting on, the way the fetch command that started it would. -func currentJournalRequest(v *journalView) requestResult { +func currentJournalResult(v *journalView) requestResult { return requestResult{requestID: v.requests.id} } -// --- Init --- - -func TestJournalViewInitFetchesEntry(t *testing.T) { - v := newJournalView(testVC()) - cmd := v.Init() - if cmd == nil { - t.Fatal("Init should return a fetch command") - } - if !v.requests.loading { - t.Error("Init should set loading = true") +func journalEntries(count int) []journalSummary { + entries := make([]journalSummary, count) + for index := range count { + starts := time.Now().AddDate(0, 0, -index) + entries[index] = journalSummary{ + ID: int64(index + 1), + Date: starts.Format("2006-01-02"), + Starts: starts, + Preview: "Reflection from the day", + } } + return entries } -func TestJournalViewInitSelectsToday(t *testing.T) { +func loadedJournalView(entries []journalSummary) *journalView { v := newJournalView(testVC()) + v.Resize(80, 20) v.Init() - today := time.Now().Format("2006-01-02") - if v.dateIndex < 0 || v.dateIndex >= len(v.dates) { - t.Fatalf("dateIndex = %d out of range", v.dateIndex) - } - if v.dates[v.dateIndex] != today { - t.Errorf("selected date = %q, want today %q", v.dates[v.dateIndex], today) - } + v.Update(journalPageLoadedMsg{requestResult: currentJournalResult(v), entries: entries}) + return v } -// --- Update: message routing --- - -func TestJournalFetchKeepsRichContentForEditing(t *testing.T) { - heyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 1, - "content": "Today was great", - "content_html": `

Today was great

`, - "type": "Calendar::JournalEntry", - }) - })) - t.Cleanup(heyServer.Close) - - client := hey.NewClient( - &hey.Config{BaseURL: heyServer.URL}, - &hey.StaticTokenProvider{Token: "test-token"}, - hey.WithMaxRetries(0), - ) - vc := testVC() - vc.ctx = context.Background() - vc.sdk = client - v := newJournalView(vc) - - loaded := v.fetchJournalEntry(vc.ctx, 1, "2026-08-19")().(journalDetailMsg) - - if loaded.content != "

Today was great

" { - t.Errorf("editable content = %q", loaded.content) - } - if loaded.body.IsEmpty() { - t.Error("rich-text body should be rendered") +func TestJournalInitRequestsFeed(t *testing.T) { + v := newJournalView(testVC()) + cmd := v.Init() + if cmd == nil || !v.requests.loading || v.requests.kind != journalRequestFeed { + t.Fatalf("feed request = cmd:%v loading:%v kind:%v", cmd != nil, v.requests.loading, v.requests.kind) } } -func TestJournalEditorContentRemovesTrixContainers(t *testing.T) { - tests := []struct { - name string - content string - want string - }{ - { - name: "read wrapper", - content: `

Today was great

`, - want: `

Today was great

`, - }, - { - name: "wrappers stored by earlier saves", - content: `
-
-
Today was great
-
- and then some -
`, - want: "
Today was great
\n \n and then some", - }, - { - name: "attachment bytes", - content: `
`, - want: `
`, - }, - { - name: "ordinary div", - content: `
Keep me
`, - want: `
Keep me
`, - }, +func TestJournalSummaryKeepsHEYsUTCCalendarDate(t *testing.T) { + recording := generated.Recording{ + Id: 1, + Type: "Calendar::JournalEntry", + StartsAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + Content: "A day worth remembering", } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := journalEditorContent(tt.content); got != tt.want { - t.Errorf("journalEditorContent() = %q, want %q", got, tt.want) - } - }) + entries := journalSummaries([]generated.Recording{recording}) + if len(entries) != 1 || entries[0].Date != "2026-07-01" { + t.Fatalf("journal date = %#v, want 2026-07-01", entries) + } + if got := entries[0].Starts.Format("2006-01-02"); got != "2026-07-01" { + t.Fatalf("display date = %q, want 2026-07-01", got) } } -func TestJournalFailedReadDoesNotOpenEditor(t *testing.T) { - heyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, "nope", http.StatusBadRequest) - })) - t.Cleanup(heyServer.Close) - - vc := testVC() - vc.ctx = context.Background() - vc.sdk = hey.NewClient( - &hey.Config{BaseURL: heyServer.URL}, - &hey.StaticTokenProvider{Token: "test-token"}, - hey.WithMaxRetries(0), - ) - v := newJournalView(vc) - cmd := v.Init() - - errCmd, consumed := v.Update(cmd()) - if !consumed || errCmd == nil { - t.Fatalf("failed read = consumed:%v error command:%v", consumed, errCmd != nil) - } - if v.inThread || v.editContent != "" { - t.Fatalf("failed read state = open:%v content:%q", v.inThread, v.editContent) +func TestJournalFeedShowsEntriesInsteadOfDateTabs(t *testing.T) { + v := loadedJournalView(journalEntries(2)) + content := stripANSI(v.View()) + if !strings.Contains(content, "Journal · newest first") || !strings.Contains(content, "Reflection from the day") { + t.Fatalf("feed = %q", content) } - if cmd := v.HandleContentKey(keyPress("e")); cmd != nil || v.form != nil { - t.Fatalf("edit after failed read = cmd:%v form:%v", cmd != nil, v.form != nil) + items, _, label, _ := v.SubnavItems() + if len(items) != 0 || label != "Journal" { + t.Fatalf("subnav = items:%d label:%q", len(items), label) } } -func TestJournalPlainTextFallbackStaysLiteral(t *testing.T) { - var imageRequests atomic.Int64 - imageData := testPNG(t) - literal := `` - heyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/rails/active_storage/blobs/") { - imageRequests.Add(1) - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(imageData) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 1, - "content": literal, - "type": "Calendar::JournalEntry", - }) - })) - t.Cleanup(heyServer.Close) - - client := hey.NewClient( - &hey.Config{BaseURL: heyServer.URL}, - &hey.StaticTokenProvider{Token: "test-token"}, - hey.WithMaxRetries(0), - ) - vc := testVC() - vc.ctx = context.Background() - vc.sdk = client - vc.imageRenderer = kittyImageRenderer{} - vc.imageFetcher = newTrustedImageFetcher(client) - v := newJournalView(vc) - - loaded := v.fetchJournalEntry(vc.ctx, 1, "2026-08-19")().(journalDetailMsg) - - if loaded.content != literal { - t.Errorf("editable content = %q, want literal %q", loaded.content, literal) - } - if got := imageRequests.Load(); got != 0 { - t.Fatalf("text journal fetched an image %d time(s)", got) - } - if len(loaded.images) != 0 { - t.Fatalf("text journal returned %d images", len(loaded.images)) +func TestJournalFeedEmptyStateInvitesAddingToday(t *testing.T) { + v := loadedJournalView(nil) + if got := stripANSI(v.View()); !strings.Contains(got, "press a to write about today") { + t.Fatalf("empty feed = %q", got) } } -func TestJournalViewHandlesDetailLoaded(t *testing.T) { +func TestJournalFeedAppendsOlderPage(t *testing.T) { v := newJournalView(testVC()) - v.Init() // sets dateIndex to today - - _, consumed := v.Update(journalDetailMsg{ - requestResult: currentJournalRequest(v), - content: "Entry body", - body: htmlutil.ToMarkdown("

Entry body

"), + v.Resize(80, 20) + v.Init() + loadMore, _ := v.Update(journalPageLoadedMsg{ + requestResult: currentJournalResult(v), + entries: journalEntries(2), + nextPage: "older-cursor", }) - if !consumed { - t.Error("journalDetailMsg should be consumed") - } - if v.requests.loading { - t.Error("loading should be false after detail loaded") - } - if !v.inThread { - t.Error("should be in thread after detail loaded") - } - if v.editContent != "Entry body" { - t.Errorf("editable content = %q", v.editContent) + if loadMore == nil || !v.loadingMore { + t.Fatal("a short first page should load the page below it") + } + _, consumed := v.Update(journalPageAppendedMsg{ + requestID: v.moreID, + entries: []journalSummary{{ + ID: 9, Date: "2025-01-01", Starts: time.Date(2025, 1, 1, 12, 0, 0, 0, time.Local), Preview: "Older entry", + }}, + }) + if !consumed || len(v.list.entries) != 3 || v.list.entries[2].Preview != "Older entry" { + t.Fatalf("grown feed = consumed:%v entries:%#v", consumed, v.list.entries) } } -func TestJournalViewIgnoresStaleResponse(t *testing.T) { +func TestJournalFeedIgnoresStalePage(t *testing.T) { v := newJournalView(testVC()) v.Init() - - // A response to the read the reader has since moved off - _, consumed := v.Update(journalDetailMsg{requestResult: requestResult{requestID: v.requests.id - 1}, body: htmlutil.ToMarkdown("

old content

")}) - if !consumed { - t.Error("stale journalDetailMsg should still be consumed") - } - if !v.requests.loading { - t.Error("loading should remain true after stale response") - } - if v.topicContent == "old content" { - t.Error("stale response should not overwrite content") + v.requestFeed("planning") + v.Update(journalPageLoadedMsg{ + requestResult: requestResult{requestID: v.requests.id - 1}, + entries: journalEntries(1), + }) + if len(v.list.entries) != 0 || !v.requests.loading { + t.Fatalf("stale page changed feed: entries:%d loading:%v", len(v.list.entries), v.requests.loading) } } -func TestJournalViewIgnoresUnrelatedMessages(t *testing.T) { - v := newJournalView(testVC()) - _, consumed := v.Update(boxesLoadedMsg{}) - if consumed { - t.Error("boxesLoadedMsg should not be consumed by journalView") +func TestJournalScrollNearBottomLoadsMore(t *testing.T) { + v := loadedJournalView(journalEntries(10)) + v.nextPage = "next" + v.Resize(80, 6) + for range 6 { + v.HandleContentKey(keyPress("down")) + } + if !v.loadingMore { + t.Fatal("scrolling near the bottom should load another page") } } -// --- Content key handling --- - -func TestJournalViewContentKeyScrolls(t *testing.T) { - v := journalWithEntry() - v.Resize(80, 30) - - // Keys should go to viewport without crashing - v.HandleContentKey(keyPress("down")) - v.HandleContentKey(keyPress("up")) -} - -func TestJournalViewEditsSelectedDay(t *testing.T) { - v := journalWithEntry() - v.Resize(80, 30) - - if cmd := v.HandleContentKey(keyPress("e")); cmd == nil { - t.Fatal("edit should focus the journal form") +func TestJournalSearchAndClear(t *testing.T) { + v := loadedJournalView(journalEntries(1)) + if cmd := v.HandleContentKey(keyPress("/")); cmd == nil || v.prompt == nil { + t.Fatal("/ should open and focus search") } - if !v.CapturingInput() { - t.Fatal("journal form should capture input") + v.prompt.input.SetValue("quarterly planning") + cmd := v.HandleContentKey(keyPress("enter")) + if cmd == nil || v.prompt != nil || v.query != "quarterly planning" || v.requests.kind != journalRequestFeed { + t.Fatalf("search state = cmd:%v prompt:%v query:%q kind:%v", cmd != nil, v.prompt != nil, v.query, v.requests.kind) } - if got := v.form.input.Value(); got != "

Today was great

" { - t.Errorf("form content = %q", got) + v.Update(journalPageLoadedMsg{requestResult: currentJournalResult(v)}) + if got := stripANSI(v.View()); !strings.Contains(got, "Search: quarterly planning · 0 results") { + t.Fatalf("search empty state = %q", got) } - if got := v.form.date; got != v.dates[v.dateIndex] { - t.Errorf("form date = %q, want %q", got, v.dates[v.dateIndex]) + if cmd := v.HandleContentKey(keyPress("c")); cmd == nil || v.query != "" { + t.Fatalf("clear search = cmd:%v query:%q", cmd != nil, v.query) } +} +func TestJournalSearchEscapeKeepsFeed(t *testing.T) { + v := loadedJournalView(journalEntries(1)) + v.HandleContentKey(keyPress("/")) + v.prompt.input.SetValue("discard me") v.HandleContentKey(keyPress("esc")) - if v.form != nil || v.CapturingInput() { - t.Fatal("escape should cancel the journal form") + if v.prompt != nil || v.query != "" || len(v.list.entries) != 1 { + t.Fatalf("cancelled search = prompt:%v query:%q entries:%d", v.prompt != nil, v.query, len(v.list.entries)) } } -func TestJournalViewSavesAndRemovesEntries(t *testing.T) { - tests := []struct { - name string - input string - wantContent string - wantNotice string - }{ - {name: "save", input: " A better day\n", wantContent: "A better day", wantNotice: "Journal entry saved"}, - { - name: "preserve rich content", - input: `
Great
`, - wantContent: `
Great
`, - wantNotice: "Journal entry saved", - }, - {name: "remove", input: " \n ", wantContent: "", wantNotice: "Journal entry removed"}, +func TestJournalDateJumpValidatesAndLoads(t *testing.T) { + v := loadedJournalView(nil) + v.HandleContentKey(keyPress("g")) + v.prompt.input.SetValue("August 19") + if cmd := v.HandleContentKey(keyPress("enter")); cmd != nil || v.prompt.status == "" { + t.Fatalf("invalid date = cmd:%v status:%q", cmd != nil, v.prompt.status) } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var gotContent string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPatch { - t.Fatalf("method = %s, want PATCH", r.Method) - } - var payload struct { - CalendarJournalEntry struct { - Content string `json:"content"` - } `json:"calendar_journal_entry"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatal(err) - } - gotContent = payload.CalendarJournalEntry.Content - if gotContent == "" { - w.WriteHeader(http.StatusNoContent) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 1, - "content": gotContent, - "content_html": "

" + gotContent + "

", - "type": "Calendar::JournalEntry", - }) - })) - t.Cleanup(server.Close) - - vc := testVC() - vc.ctx = context.Background() - vc.sdk = hey.NewClient( - &hey.Config{BaseURL: server.URL}, - &hey.StaticTokenProvider{Token: "test-token"}, - hey.WithMaxRetries(0), - ) - v := newJournalView(vc) - v.Init() - v.Update(journalDetailMsg{requestResult: currentJournalRequest(v)}) - v.HandleContentKey(keyPress("e")) - v.form.input.SetValue(tt.input) - - cmd := v.HandleContentKey(keyPress("ctrl+s")) - if cmd == nil || !v.form.saving || !v.AccountSwitchBlocked() { - t.Fatalf("save state = cmd:%v saving:%v blocked:%v", cmd != nil, v.form.saving, v.AccountSwitchBlocked()) - } - refresh, consumed := v.Update(cmd()) - if !consumed || refresh == nil { - t.Fatalf("saved message = consumed:%v refresh:%v", consumed, refresh != nil) - } - if gotContent != tt.wantContent { - t.Errorf("saved content = %q, want %q", gotContent, tt.wantContent) - } - if v.form != nil || v.notice != tt.wantNotice { - t.Errorf("saved state = form:%v notice:%q", v.form != nil, v.notice) - } - if got, want := v.topicViewport.Height(), v.vc.height-1; got != want { - t.Errorf("viewport height with notice = %d, want %d", got, want) - } - if v.requests.kind != journalRequestEntry { - t.Errorf("request kind = %v, want refresh", v.requests.kind) - } - }) + v.prompt.input.SetValue("2026-08-19") + cmd := v.HandleContentKey(keyPress("enter")) + if cmd == nil || v.prompt != nil || v.requests.kind != journalRequestDetail { + t.Fatalf("date jump = cmd:%v prompt:%v kind:%v", cmd != nil, v.prompt != nil, v.requests.kind) } } -func TestJournalRepeatedSavesDoNotAddTrixWrapper(t *testing.T) { - stored := `
Today was great
` - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.Method { - case http.MethodGet: - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 1, - "content": "Today was great", - "content_html": `
` + stored + `
`, - "type": "Calendar::JournalEntry", - }) - case http.MethodPatch: - var payload struct { - CalendarJournalEntry struct { - Content string `json:"content"` - } `json:"calendar_journal_entry"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatal(err) - } - stored = payload.CalendarJournalEntry.Content - _ = json.NewEncoder(w).Encode(map[string]any{ - "id": 1, - "content": "Today was great", - "content_html": `
` + stored + `
`, - "type": "Calendar::JournalEntry", - }) - default: - t.Fatalf("method = %s", r.Method) - } - })) - t.Cleanup(server.Close) - - vc := testVC() - vc.ctx = context.Background() - vc.sdk = hey.NewClient( - &hey.Config{BaseURL: server.URL}, - &hey.StaticTokenProvider{Token: "test-token"}, - hey.WithMaxRetries(0), - ) - v := newJournalView(vc) - load := v.Init() - v.Update(load()) - - want := stored - for range 3 { - v.HandleContentKey(keyPress("e")) - save := v.HandleContentKey(keyPress("ctrl+s")) - refresh, _ := v.Update(save()) - v.Update(refresh()) +func TestJournalOpensSelectedEntryAndBackReturnsToFeed(t *testing.T) { + v := loadedJournalView(journalEntries(1)) + if cmd := v.HandleContentKey(keyPress("enter")); cmd == nil || v.requests.kind != journalRequestDetail { + t.Fatal("enter should request the selected day") } - - if stored != want { - t.Errorf("stored content after repeated saves = %q, want %q", stored, want) + v.Update(journalDetailMsg{ + requestResult: currentJournalResult(v), + date: v.list.entries[0].Date, + content: "Today was productive", + body: htmlutil.ToMarkdown("

Today was productive

"), + }) + if !v.InThread() || !strings.Contains(stripANSI(v.View()), "Today was productive") { + t.Fatalf("detail = inDetail:%v view:%q", v.InThread(), stripANSI(v.View())) } - if strings.Contains(stored, "trix-content") { - t.Errorf("stored content contains a Trix wrapper: %q", stored) + v.ExitThread() + if v.InThread() || len(v.list.entries) != 1 { + t.Fatalf("back = inDetail:%v entries:%d", v.InThread(), len(v.list.entries)) } } -func TestJournalViewKeepsEditorOnSaveFailure(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, "nope", http.StatusInternalServerError) - })) - t.Cleanup(server.Close) - - vc := testVC() - vc.ctx = context.Background() - vc.sdk = hey.NewClient( - &hey.Config{BaseURL: server.URL}, - &hey.StaticTokenProvider{Token: "test-token"}, - hey.WithMaxRetries(0), - ) - v := newJournalView(vc) - v.Init() - v.Update(journalDetailMsg{requestResult: currentJournalRequest(v)}) - v.HandleContentKey(keyPress("e")) - v.form.input.SetValue("Keep this draft") - - cmd := v.HandleContentKey(keyPress("ctrl+s")) - v.Update(cmd()) - - if v.form == nil || v.form.saving { - t.Fatalf("failed save state = form:%v saving:%v", v.form != nil, v.form != nil && v.form.saving) - } - if v.form.input.Value() != "Keep this draft" { - t.Errorf("draft = %q", v.form.input.Value()) +func TestJournalAddTodayOpensEditorAfterSafeRead(t *testing.T) { + v := loadedJournalView(nil) + if cmd := v.HandleContentKey(keyPress("a")); cmd == nil { + t.Fatal("add should read today before editing") } - if !v.form.isError || v.form.status == "" { - t.Errorf("failure status = error:%v status:%q", v.form.isError, v.form.status) + edit, consumed := v.Update(journalDetailMsg{ + requestResult: currentJournalResult(v), + date: todayJournalDate(), + edit: true, + }) + if !consumed || edit == nil || v.form == nil || v.form.date != todayJournalDate() { + t.Fatalf("add state = consumed:%v focus:%v form:%v", consumed, edit != nil, v.form != nil) } } -// --- Subnav --- - -func TestJournalViewSubnavItems(t *testing.T) { - v := newJournalView(testVC()) - v.Init() - items, selected, label, centered := v.SubnavItems() - - if len(items) != 30 { - t.Errorf("expected 30 subnav items, got %d", len(items)) - } - if selected != len(items)-1 { - t.Errorf("selected = %d, want last item %d", selected, len(items)-1) - } - today := time.Now().Format("2006-01-02") - if label != today { - t.Errorf("label = %q, want %q", label, today) +func TestJournalDirtyEditorRequiresSecondEscape(t *testing.T) { + v := loadedJournalView(nil) + v.detailDate = "2026-08-19" + v.detailContent = "Original" + v.inDetail = true + v.startEditor() + v.form.input.SetValue("Changed") + v.HandleContentKey(keyPress("esc")) + if v.form == nil || !v.form.confirmDiscard { + t.Fatal("first escape should warn and keep the editor") } - if centered { - t.Error("journal subnav should not be centered") + v.HandleContentKey(keyPress("esc")) + if v.form != nil { + t.Fatal("second escape should discard the draft") } } -func TestJournalViewSubnavLeftRight(t *testing.T) { - v := newJournalView(testVC()) - v.Init() - lastIdx := v.dateIndex - - v.SubnavLeft() - if v.dateIndex != lastIdx-1 { - t.Errorf("after SubnavLeft: dateIndex = %d, want %d", v.dateIndex, lastIdx-1) +func TestJournalEmptySaveRequiresExplicitConfirmedRemoval(t *testing.T) { + v := loadedJournalView(nil) + v.detailDate = "2026-08-19" + v.detailContent = "Original" + v.inDetail = true + v.startEditor() + v.form.input.SetValue(" ") + if cmd := v.HandleContentKey(keyPress("ctrl+s")); cmd != nil || v.form.status == "" { + t.Fatalf("empty save = cmd:%v status:%q", cmd != nil, v.form.status) } - if !v.requests.loading { - t.Error("SubnavLeft should set loading") + if cmd := v.HandleContentKey(keyPress("ctrl+d")); cmd != nil || !v.form.confirmRemove { + t.Fatalf("first removal = cmd:%v confirmed:%v", cmd != nil, v.form.confirmRemove) } - - v.SubnavRight() - if v.dateIndex != lastIdx { - t.Errorf("after SubnavRight: dateIndex = %d, want %d", v.dateIndex, lastIdx) + if cmd := v.HandleContentKey(keyPress("ctrl+d")); cmd == nil || v.requests.kind != journalRequestMutation { + t.Fatalf("confirmed removal = cmd:%v kind:%v", cmd != nil, v.requests.kind) } +} - // Can't go right past the end - v.SubnavRight() - if v.dateIndex != lastIdx { - t.Errorf("SubnavRight at end: dateIndex = %d, want %d", v.dateIndex, lastIdx) +func TestJournalNewEmptyEntryDoesNotSave(t *testing.T) { + v := loadedJournalView(nil) + v.detailDate = "2026-08-19" + v.inDetail = true + v.startEditor() + if cmd := v.HandleContentKey(keyPress("ctrl+s")); cmd != nil || !v.form.isError { + t.Fatalf("new empty save = cmd:%v error:%v", cmd != nil, v.form.isError) } } -// --- Thread state --- - -func TestJournalViewInThread(t *testing.T) { - v := newJournalView(testVC()) - if v.InThread() { - t.Error("should not be in thread initially") +func TestJournalDetailRemovalRequiresConfirmation(t *testing.T) { + v := loadedJournalView(nil) + v.detailDate = "2026-08-19" + v.detailContent = "Original" + v.inDetail = true + if cmd := v.HandleContentKey(keyPress("x")); cmd != nil || !v.confirmRemove { + t.Fatalf("first x = cmd:%v confirmed:%v", cmd != nil, v.confirmRemove) } - v.inThread = true - if !v.InThread() { - t.Error("InThread should return true") + if cmd := v.HandleContentKey(keyPress("x")); cmd == nil || v.requests.kind != journalRequestMutation { + t.Fatalf("second x = cmd:%v kind:%v", cmd != nil, v.requests.kind) } - // ExitThread is a no-op for journal — content always stays visible - v.ExitThread() - if !v.InThread() { - t.Error("ExitThread should be a no-op for journal") +} + +func TestJournalFailedReadDoesNotOpenEditor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusBadRequest) + })) + t.Cleanup(server.Close) + vc := testVC() + vc.ctx = context.Background() + vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + v := newJournalView(vc) + v.requestDate("2026-08-19", true) + msg := v.fetchJournalEntry(vc.ctx, v.requests.id, "2026-08-19", true)() + cmd, consumed := v.Update(msg) + if !consumed || cmd == nil || v.form != nil || v.inDetail { + t.Fatalf("failed read = consumed:%v error:%v form:%v detail:%v", consumed, cmd != nil, v.form != nil, v.inDetail) } } -// --- Help bindings --- +func TestJournalFetchKeepsRichContentForEditing(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, "content": "Today was great", + "content_html": `

Today was great

`, + "type": "Calendar::JournalEntry", + }) + })) + t.Cleanup(server.Close) + vc := testVC() + vc.ctx = context.Background() + vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + v := newJournalView(vc) + loaded := v.fetchJournalEntry(vc.ctx, 1, "2026-08-19", false)().(journalDetailMsg) + if loaded.content != "

Today was great

" || loaded.body.IsEmpty() { + t.Fatalf("loaded rich entry = content:%q empty:%v", loaded.content, loaded.body.IsEmpty()) + } +} -func TestJournalViewHelpBindings(t *testing.T) { - v := journalWithEntry() - bindings := v.HelpBindings() - if len(bindings) != 1 || bindings[0] != (helpBinding{"e", "edit"}) { - t.Errorf("journal bindings = %#v", bindings) +func TestJournalEditorContentPreservesAttachmentsWithoutTrixWrapper(t *testing.T) { + content := `
` + want := `
` + if got := journalEditorContent(content); got != want { + t.Fatalf("journalEditorContent() = %q, want %q", got, want) } +} - v.HandleContentKey(keyPress("e")) - bindings = v.HelpBindings() - want := []helpBinding{{"ctrl+s", "save"}, {"esc", "cancel"}} - if len(bindings) != len(want) { - t.Fatalf("form bindings = %#v", bindings) - } - for i := range want { - if bindings[i] != want[i] { - t.Errorf("form binding %d = %#v, want %#v", i, bindings[i], want[i]) +func TestJournalHelpBindingsDescribeFeedSearchDateAndAdd(t *testing.T) { + v := loadedJournalView(journalEntries(1)) + got := v.HelpBindings() + for _, want := range []helpBinding{{"enter", "open"}, {"a", "add today"}, {"/", "search"}, {"g", "go to date"}} { + found := false + for _, binding := range got { + found = found || binding == want + } + if !found { + t.Errorf("help bindings %#v do not contain %#v", got, want) } } } diff --git a/internal/tui/nav.go b/internal/tui/nav.go index f2fbbf2f..31a072ec 100644 --- a/internal/tui/nav.go +++ b/internal/tui/nav.go @@ -148,15 +148,6 @@ func calendarNavItems(calendars []Calendar) []navItem { return items } -// journalNavItems builds nav items for the journal date row. -func journalNavItems(dates []string) []navItem { - items := make([]navItem, len(dates)) - for i, d := range dates { - items[i] = navItem{label: d} - } - return items -} - // --- Rendering --- // renderRule draws a horizontal rule with a centered label: diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index e74d0951..07c85ac2 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1074,16 +1074,3 @@ func TestViewShowsBoxNames(t *testing.T) { t.Error("View should contain Imbox") } } - -// --- Journal dates --- - -func TestGenerateJournalDates(t *testing.T) { - dates := generateJournalDates(7) - if len(dates) != 7 { - t.Fatalf("expected 7 dates, got %d", len(dates)) - } - today := time.Now().Format("2006-01-02") - if dates[6] != today { - t.Errorf("last date = %q, want today %q", dates[6], today) - } -} diff --git a/nix/package.nix b/nix/package.nix index 7f54e781..441231fd 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-rH/n1+ZcsiwkoKPgHNWepzZR0aFuuHwk3MQm3bXjipc="; + vendorHash = "sha256-so8/cKnHEq0KY+Q1sdT7Dpy+ZUN7tpqBel7VYrrCxcw="; subPackages = [ "cmd/hey" ];