Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/architecture/calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ habits without keeping them — nothing on that screen knows what was kept on th
the minimum needed. A week row is as tall as its busiest day, so a week's *number* says nothing
about which line it is on — scrolling by the number is what let the cursor walk off the bottom.

**All-day start and end dates are inclusive.** HEY serves them as UTC midnights, but they
are calendar dates, not instants to convert or exclusive boundaries. The card shows both
dates, and `eventsByDate` keeps the final day in the grid. Only a timed event ending at
midnight excludes the following day.

**A selection is a `Recording.key()`, not an id.** HEY serves a repeating event's days as virtual
occurrences with `id: 0` and an `occurrence_id` of `"<series>_<date>"`, so an id keyed selection
could not pick one out at all. The same fact decides how it is written: `occurrenceOf` sends an
Expand Down
8 changes: 6 additions & 2 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,11 @@ The private note is shown formatted and edited as Markdown, the way `hey contact

## Calendar

Press Shift+C to open Calendar, then `c` to manage time track categories. Create a category with `n`, rename the selected category with Enter or `r`, and press `x` twice to delete it. Time tracks in a deleted category become uncategorized.
Press Shift+C to open Calendar. `1`, `2` and `3` switch between the day, week and year spans, `p` and `n` step back and forward, and `t` returns to today. The arrows walk what the span is made of — events on the day, days then events on the week, cells then a cell's events on the year (Enter steps into a cell, Escape steps back out).

With an event picked out, Enter opens a read-only card showing what it carries — when and where it is, the link to join it, the guest list and the notes. From the card, `o` opens an http or https link in your browser, `e` switches to the edit form on the same event, and Escape or `q` closes it. The arrows and Page Up/Down scroll long notes. `a` creates an event, `e` edits the selected one, and `x` twice deletes it.

Press `l` to open time tracking, then choose Categories to manage time track categories. Create a category with `n`, rename the selected category with Enter or `r`, and press `x` twice to delete it. Time tracks in a deleted category become uncategorized.

A new event's times are written in your HEY account's time zone, the one HEY's web app and
`hey event add` use: the Starts and Ends rows name it, and the event is saved in it. The
Expand All @@ -233,4 +237,4 @@ somewhere else — at the second of two moments as the clocks go back, or with s
than moving the event: choose another time, or press Ctrl+S again to save it where HEY reads it.
The tracked-time form keeps an unchanged time the same way.

In Calendar, press `a` to create a habit. Habits visible in the current calendar range can be selected with `[` and `]`, edited with `e`, and deleted by pressing `x` twice. Habit forms use Tab to move between fields and Ctrl+S to save.
In Calendar, press `b` to manage habits. In the habit picker, the arrows select a habit, `a` creates one, `e` edits it, and `x` twice deletes it. Habit forms use Tab to move between fields and Ctrl+S to save.
116 changes: 113 additions & 3 deletions internal/tui/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,11 @@ type calendarView struct {
// settings is the open calendar settings form, standing over the calendar.
settings *calendarSettingsForm

// detail is the read-only card Enter opens over a selected event — everything the event
// carries, laid out to be read. It never stands with the event form: e closes it and
// opens the form on the same event.
detail *eventDetail

timeTrack *timeTrackMenu
trackedTime *trackedTimeScreen
// trackedTimeForm is the open edit form, standing over the tracked time screen.
Expand Down Expand Up @@ -578,6 +583,10 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) {
}
v.firstWeekDay = msg.firstWeekDay
v.use24Hour = msg.use24Hour
if v.detail != nil {
v.detail.use24 = v.use24Hour
v.detail.resize(v.vc.width, v.vc.height)
}
v.accountZone = msg.timeZone
if v.eventForm != nil {
v.eventForm.adoptAccountZone(msg.timeZone)
Expand Down Expand Up @@ -885,6 +894,11 @@ func (v *calendarView) View() string {
frame := modalFrame(v.settings.title(), v.settings.view(), v.vc.width)
view = overlayModal(view, frame, v.vc.width, v.vc.height)
}
// The detail card stands over the grid like the event form does, and never with it: e
// closes the card and opens the form on the same event.
if v.detail != nil {
view = overlayModal(view, v.detail.view(), v.vc.width, v.vc.height)
}
return view
}

Expand Down Expand Up @@ -912,6 +926,9 @@ func (v *calendarView) todosFooterHeight() int {
}

func (v *calendarView) HelpBindings() []helpBinding {
if v.detail != nil {
return v.detail.helpBindings()
}
if v.settings != nil {
return v.settings.helpBindings()
}
Expand Down Expand Up @@ -1079,6 +1096,34 @@ func (v *calendarView) handleContentKey(msg tea.KeyPressMsg) tea.Cmd {
}
return cmd
}
// The event detail card takes every key while it is up — it is an inputCapturer, so the
// model routes esc here rather than through CancelPendingDetail. esc and q close it, o opens
// the link, e trades the card for the form on the same event, and anything else scrolls the
// notes or does nothing.
if v.detail != nil {
switch msg.String() {
case "esc", "q":
v.detail = nil
return nil
case "o":
return v.openEventLink()
case "e":
event, ok := v.selectedRecording()
if !ok || event.key() != v.detail.event.key() {
return notify("This event is no longer available")
}
cmd := v.startEventForm(eventFormEdit, event)
// Only close the card if the form was successfully created; if startEventForm
// found no fileable calendars it returns an error notice without setting
// v.eventForm, so we keep the card open rather than leaving the user with nothing.
if v.eventForm != nil {
v.detail = nil
}
return cmd
}
return v.detail.update(msg)
}

if v.requests.kind == calendarRequestMutation {
return nil
}
Expand Down Expand Up @@ -1175,6 +1220,9 @@ func (v *calendarView) handleContentKey(msg tea.KeyPressMsg) tea.Cmd {
// the arrows move between cells, enter steps into one, and only then do ↑ and ↓ belong to that
// day's events. esc steps back out. Without the two stages ↑ and ↓ would have to be both a
// week's worth of movement and an event's, and a year of cells has no way to show which.
//
// enter opens the selected event's detail card wherever one is picked out — on the day, on the
// week, and inside a year cell. On the year with no cell open it is the step into the cell.
func (v *calendarView) handleArrowKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
key := msg.String()

Expand All @@ -1189,6 +1237,9 @@ func (v *calendarView) handleArrowKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
return v.crossTheDay(-1), true
case "down":
return v.crossTheDay(1), true
case "enter":
v.openEventDetail()
return nil, true
}
case viewWeek:
switch key {
Expand All @@ -1200,6 +1251,9 @@ func (v *calendarView) handleArrowKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
return v.moveSelection(-1), true
case "down":
return v.moveSelection(1), true
case "enter":
v.openEventDetail()
return nil, true
}
case viewYear:
switch key {
Expand All @@ -1218,6 +1272,10 @@ func (v *calendarView) handleArrowKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
}
return v.moveCursorDay(7), true
case "enter":
if v.inYearCell {
v.openEventDetail()
return nil, true
}
v.enterYearCell()
return nil, true
}
Expand Down Expand Up @@ -1314,7 +1372,8 @@ func (v *calendarView) leaveYearCell() {

// CancelPendingDetail is how esc reaches a year cell. The model reads esc before a view sees a
// key, and only offers it on through here — so stepping out of a cell is the same seam a mail
// thread's read is cancelled through, rather than a key the calendar handles itself.
// thread's read is cancelled through, rather than a key the calendar handles itself. The event
// card does not come through here: it is an inputCapturer, so the model hands it esc directly.
func (v *calendarView) CancelPendingDetail() bool {
if !v.inYearCell {
return false
Expand All @@ -1323,6 +1382,51 @@ func (v *calendarView) CancelPendingDetail() bool {
return true
}

// openEventDetail is Enter on the grid: the read-only card over whatever event the arrows have
// walked to. There is nothing to fetch — the grid read already carries the notes, the link and
// the guest list — so the card is built straight from the selected recording, and Enter with
// nothing picked out does nothing.
func (v *calendarView) openEventDetail() {
event, ok := v.selectedRecording()
if !ok {
return
}
v.detail = newEventDetail(event, v.calendarName(event.CalendarID), v.use24Hour, v.vc.styles, v.vc.width, v.vc.height)
}

// openEventLink hands the card's event link to the browser launcher.
// Only an http/https link is handed over (openableLink),
// so a shared event's file:// path or application scheme cannot invoke a local handler; a
// launcher missing from PATH says so in a toast rather than the key seeming dead.
func (v *calendarView) openEventLink() tea.Cmd {
if v.detail == nil {
return nil
}
link, ok := v.detail.openableLink()
if !ok {
return nil
}
if v.vc.openURL == nil {
return nil
}
if err := v.vc.openURL(link); err != nil {
return notifyError("Could not open the link", err)
}
return notify("Opening the link…")
}

// calendarName is the event's calendar by name, for the detail card. The personal calendar and
// any calendar the reader is not a member of are not in the list, and get no name rather than
// a wrong one.
func (v *calendarView) calendarName(id int64) string {
for _, calendar := range v.calendars {
if calendar.ID == id {
return calendar.Name
}
}
return ""
}

// 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
Expand Down Expand Up @@ -1640,7 +1744,7 @@ func (v *calendarView) Loading() bool {
}
func (v *calendarView) CapturingInput() bool {
return v.timeTrack != nil || v.trackedTime != nil || v.timeTrackCategories != nil ||
v.habitForm != nil || v.eventForm != nil || v.settings != nil ||
v.habitForm != nil || v.eventForm != nil || v.settings != nil || v.detail != nil ||
v.habitPicker != nil || v.todoPicker != nil || v.calendarPicker != nil
}

Expand All @@ -1665,11 +1769,14 @@ func (v *calendarView) refreshLive() (tea.Cmd, bool) {
}

// Restyle re-renders the day/week/year grid, which caches styled output in its
// viewport. The recording detail is plain text and needs nothing.
// viewport, and the event card, which caches its own.
func (v *calendarView) Restyle() {
if v.trackedTime != nil {
v.trackedTime.rebuild()
}
if v.detail != nil {
v.detail.restyle(v.vc.styles)
}
v.rebuildKeepingScroll()
}

Expand Down Expand Up @@ -1701,6 +1808,9 @@ func (v *calendarView) Resize(width, height int) {
if v.settings != nil {
v.settings.resize(width, height)
}
if v.detail != nil {
v.detail.resize(width, height)
}
v.rebuildView()
}

Expand Down
68 changes: 68 additions & 0 deletions internal/tui/calendar_enter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package tui

import (
"strings"
"testing"
"time"
)

func TestCalendarEnterShowsEventNotes(t *testing.T) {
for _, test := range []struct {
name string
mode calendarViewMode
selectKey string
}{
{name: "day", mode: viewDay, selectKey: "right"},
{name: "week", mode: viewWeek, selectKey: "down"},
} {
t.Run(test.name, func(t *testing.T) {
v := newCalendarView(testVC())
v.Resize(100, 30)
v.viewMode = test.mode
v.now = func() time.Time { return time.Date(2026, 8, 20, 9, 0, 0, 0, time.Local) }
v.Update(calendarsLoadedMsg{calendars: testCalendars()})
v.Update(recordingsLoadedMsg{requestResult: currentRequest(v), recordings: []Recording{
{ID: 5, Title: "Roadmap review", Type: "Calendar::Event", CalendarID: 10,
StartsAt: atLocal("2026-08-20T14:00:00"), EndsAt: atLocal("2026-08-20T15:00:00"),
Notes: "Bring the September report"},
}})
v.HandleContentKey(keyPress(test.selectKey))
v.HandleContentKey(keyPress("enter"))
if !strings.Contains(stripANSI(v.View()), "Bring the September report") {
t.Fatal("Enter did not show the selected event's notes")
}
})
}
}

func TestCalendarOpensAllDayEventOnInclusiveEndDate(t *testing.T) {
inZone(t, "America/New_York")
v := newCalendarView(testVC())
v.Resize(100, 30)
v.now = func() time.Time { return time.Date(2026, 8, 22, 9, 0, 0, 0, time.Local) }
v.Update(calendarsLoadedMsg{calendars: testCalendars()})
v.Update(recordingsLoadedMsg{requestResult: currentRequest(v), recordings: []Recording{
{ID: 5, Title: "Design offsite", Type: "Calendar::Event", CalendarID: 10, AllDay: true,
StartsAt: at("2026-08-20T00:00:00Z"), EndsAt: at("2026-08-22T00:00:00Z")},
}})
v.HandleContentKey(keyPress("down"))
v.HandleContentKey(keyPress("enter"))
if !strings.Contains(stripANSI(v.View()), "Thursday, August 20 – Saturday, August 22 · all day") {
t.Fatal("the event could not be opened on its inclusive final date")
}
}

func TestTimedMidnightEndDoesNotOccupyTheNextDay(t *testing.T) {
inZone(t, "Etc/UTC")
event := Recording{ID: 5, Title: "Overnight maintenance",
StartsAt: at("2026-08-20T22:00:00Z"), EndsAt: at("2026-08-22T00:00:00Z")}
days := eventsByDate([]Recording{event})
for _, day := range []string{"2026-08-20", "2026-08-21"} {
if len(days[day]) != 1 {
t.Errorf("timed event missing from %s", day)
}
}
if len(days["2026-08-22"]) != 0 {
t.Fatal("a timed midnight end occupied the next day")
}
}
Loading
Loading