diff --git a/internal/models/calendar.go b/internal/models/calendar.go index 8cfc70e..fcab0ec 100644 --- a/internal/models/calendar.go +++ b/internal/models/calendar.go @@ -28,6 +28,7 @@ type Recording struct { UpdatedAt string `json:"updated_at"` Type string `json:"type"` CompletedAt string `json:"completed_at,omitempty"` + Label string `json:"label,omitempty"` Calendar *Calendar `json:"calendar,omitempty"` RemindersLabel string `json:"reminders_label,omitempty"` OccurrencesURL string `json:"occurrences_url,omitempty"` diff --git a/internal/tui/calendar.go b/internal/tui/calendar.go index d9e4f0d..c95de7b 100644 --- a/internal/tui/calendar.go +++ b/internal/tui/calendar.go @@ -1,8 +1,6 @@ package tui import ( - "fmt" - "strings" "time" "charm.land/bubbles/v2/viewport" @@ -26,6 +24,10 @@ type recordingDetailMsg struct { body string } +type identityLoadedMsg struct { + firstWeekDay time.Weekday +} + // --- Calendar section view --- type calendarView struct { @@ -34,7 +36,19 @@ type calendarView struct { calendars []models.Calendar calIndex int - recordingL recordingList + viewMode calendarViewMode + firstWeekDay time.Weekday + anchorDate time.Time + + // Recordings split by type + events []models.Recording + todos []models.Recording + habits []models.Recording + + // Scrollable content viewport for the calendar views + contentVP viewport.Model + + // Detail view topicViewport viewport.Model topicContent string inThread bool @@ -44,24 +58,32 @@ type calendarView struct { func newCalendarView(vc *viewContext) *calendarView { return &calendarView{ vc: vc, + anchorDate: time.Now(), + firstWeekDay: time.Monday, topicViewport: viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)), + contentVP: viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)), } } func (v *calendarView) Init() tea.Cmd { + cmds := []tea.Cmd{v.fetchIdentity()} if len(v.calendars) == 0 { v.loading = true - return v.fetchCalendars() - } - if v.calIndex < len(v.calendars) { + cmds = append(cmds, v.fetchCalendars()) + } else if v.calIndex < len(v.calendars) { v.loading = true - return v.fetchRecordings(v.calendars[v.calIndex].ID) + cmds = append(cmds, v.fetchRecordings(v.calendars[v.calIndex].ID)) } - return nil + return tea.Batch(cmds...) } func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { switch msg := msg.(type) { + case identityLoadedMsg: + v.firstWeekDay = msg.firstWeekDay + v.rebuildView() + return nil, true + case calendarsLoadedMsg: v.loading = false v.calendars = []models.Calendar(msg) @@ -74,7 +96,8 @@ func (v *calendarView) Update(msg tea.Msg) (tea.Cmd, bool) { case recordingsLoadedMsg: v.loading = false - v.recordingL.setRecordings(msg.recordings) + v.events, v.todos, v.habits = splitRecordings(msg.recordings) + v.rebuildView() return nil, true case recordingDetailMsg: @@ -99,16 +122,21 @@ func (v *calendarView) View() string { if v.inThread { return v.topicViewport.View() } - return v.recordingL.view() + return v.contentVP.View() } -func (v *calendarView) HelpBindings() []helpBinding { return nil } +func (v *calendarView) HelpBindings() []helpBinding { + return []helpBinding{ + {"v", v.viewMode.next().String() + " view"}, + } +} 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 } @@ -137,18 +165,21 @@ func (v *calendarView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return cmd } - switch msg.Key().Code { - case tea.KeyUp: - v.recordingL.moveUp() - case tea.KeyDown: - v.recordingL.moveDown() - case tea.KeyEnter: - r := v.recordingL.selectedRecording() - if r != nil { - return v.showRecordingDetail(*r) + switch msg.String() { + case "v": + v.viewMode = v.viewMode.next() + if v.calIndex >= 0 && v.calIndex < len(v.calendars) { + v.loading = true + return v.fetchRecordings(v.calendars[v.calIndex].ID) } + v.rebuildView() + return nil } - return nil + + // Delegate scrolling to the content viewport + var cmd tea.Cmd + v.contentVP, cmd = v.contentVP.Update(msg) + return cmd } func (v *calendarView) InThread() bool { return v.inThread } @@ -156,9 +187,46 @@ func (v *calendarView) ExitThread() { v.inThread = false } func (v *calendarView) Loading() bool { return v.loading } func (v *calendarView) Resize(width, height int) { - v.recordingL.setSize(width, height) + v.contentVP.SetWidth(width) + v.contentVP.SetHeight(height) v.topicViewport.SetWidth(width) v.topicViewport.SetHeight(height) + v.rebuildView() +} + +// rebuildView re-renders the current view mode content into the viewport. +func (v *calendarView) rebuildView() { + w := v.vc.width + h := v.vc.height + if w == 0 || h == 0 { + return + } + + dayLabels := dayLabelsFromEvents(v.events) + + var content string + switch v.viewMode { + case viewDay: + content = renderDayView(v.events, v.todos, v.habits, v.anchorDate, w, h) + case viewWeek: + content = renderWeekView(v.events, v.todos, v.habits, v.anchorDate, v.firstWeekDay, w, h, dayLabels) + case viewYear: + content = renderYearView(v.events, v.anchorDate, v.firstWeekDay, w, h, dayLabels) + } + + v.contentVP.SetContent(content) + + // For year view, scroll to the current week + if v.viewMode == viewYear { + today := time.Now() + gridStart := weekStartDate(time.Date(v.anchorDate.Year(), 1, 1, 0, 0, 0, 0, v.anchorDate.Location()), v.firstWeekDay) + weeksToToday := int(today.Sub(gridStart).Hours()/24) / 7 + // Center today's week in the viewport (+2 for header rows) + offset := max(weeksToToday-h/2+2, 0) + v.contentVP.SetYOffset(offset) + } else { + v.contentVP.GotoTop() + } } // --- SDK type converters --- @@ -177,11 +245,29 @@ func sdkRecordingToModel(r generated.Recording) models.Recording { StartsAtTimeZone: r.StartsAtTimeZone, EndsAtTimeZone: r.EndsAtTimeZone, CreatedAt: formatTimestamp(r.CreatedAt), UpdatedAt: formatTimestamp(r.UpdatedAt), Type: r.Type, Content: r.Content, RemindersLabel: r.RemindersLabel, + CompletedAt: formatTimestamp(r.CompletedAt), Label: r.Label, } } // --- Fetch commands --- +func (v *calendarView) fetchIdentity() tea.Cmd { + return func() tea.Msg { + if v.vc.sdk == nil || v.vc.ctx == nil { + return identityLoadedMsg{firstWeekDay: time.Monday} + } + identity, err := v.vc.sdk.Identity().GetIdentity(v.vc.ctx) + if err != nil || identity == nil { + return identityLoadedMsg{firstWeekDay: time.Monday} + } + wd := identity.FirstWeekDay + if wd < 0 || wd > 6 { + wd = 1 // default to Monday + } + return identityLoadedMsg{firstWeekDay: time.Weekday(wd)} + } +} + func (v *calendarView) fetchCalendars() tea.Cmd { return func() tea.Msg { payload, err := v.vc.sdk.Calendars().List(v.vc.ctx) @@ -200,11 +286,11 @@ func (v *calendarView) fetchCalendars() tea.Cmd { } func (v *calendarView) fetchRecordings(calID int64) tea.Cmd { + start, end := dateRangeForMode(v.viewMode, v.anchorDate, v.firstWeekDay) return func() tea.Msg { - now := time.Now() resp, err := v.vc.sdk.Calendars().GetRecordings(v.vc.ctx, calID, &generated.GetCalendarRecordingsParams{ - StartsOn: now.Format("2006-01-02"), - EndsOn: now.AddDate(0, 0, 30).Format("2006-01-02"), + StartsOn: start.Format("2006-01-02"), + EndsOn: end.Format("2006-01-02"), }) if err != nil { return errMsg{err} @@ -220,41 +306,3 @@ func (v *calendarView) fetchRecordings(calID int64) tea.Cmd { return recordingsLoadedMsg{recordings: all} } } - -func (v *calendarView) showRecordingDetail(rec models.Recording) tea.Cmd { - return func() tea.Msg { - var b strings.Builder - - if rec.Title != "" { - fmt.Fprintf(&b, "%s\n\n", rec.Title) - } - if rec.AllDay { - fmt.Fprintf(&b, "All day\n") - } else { - if len(rec.StartsAt) >= 16 { - fmt.Fprintf(&b, "Starts: %s\n", rec.StartsAt[:16]) - } - if len(rec.EndsAt) >= 16 { - fmt.Fprintf(&b, "Ends: %s\n", rec.EndsAt[:16]) - } - } - if rec.StartsAtTimeZone != "" { - fmt.Fprintf(&b, "Timezone: %s\n", rec.StartsAtTimeZone) - } - if rec.Recurring { - fmt.Fprintf(&b, "Recurring: yes\n") - } - if rec.RemindersLabel != "" { - fmt.Fprintf(&b, "Reminders: %s\n", rec.RemindersLabel) - } - if rec.Content != "" { - fmt.Fprintf(&b, "\n%s\n", rec.Content) - } - - title := rec.Title - if title == "" && len(rec.StartsAt) >= 10 { - title = rec.StartsAt[:10] - } - return recordingDetailMsg{title: title, body: b.String()} - } -} diff --git a/internal/tui/calendar_test.go b/internal/tui/calendar_test.go index 1e67904..1077cb7 100644 --- a/internal/tui/calendar_test.go +++ b/internal/tui/calendar_test.go @@ -2,6 +2,7 @@ package tui import ( "testing" + "time" "github.com/basecamp/hey-cli/internal/models" ) @@ -15,13 +16,16 @@ func testCalendars() []models.Calendar { func testRecordings() []models.Recording { return []models.Recording{ - {ID: 200, Title: "Standup", StartsAt: "2025-03-01T09:00:00Z", Type: "event"}, - {ID: 201, Title: "Lunch", StartsAt: "2025-03-01T12:00:00Z", AllDay: false, Type: "event"}, + {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"}, } } func calendarWithRecordings() *calendarView { v := newCalendarView(testVC()) + v.Resize(80, 30) v.Update(calendarsLoadedMsg(testCalendars())) v.Update(recordingsLoadedMsg{recordings: testRecordings()}) return v @@ -65,6 +69,7 @@ func TestCalendarViewHandlesCalendarsLoaded(t *testing.T) { func TestCalendarViewHandlesRecordingsLoaded(t *testing.T) { v := newCalendarView(testVC()) + v.Resize(80, 30) v.calendars = testCalendars() v.loading = true @@ -75,8 +80,14 @@ func TestCalendarViewHandlesRecordingsLoaded(t *testing.T) { if v.loading { t.Error("loading should be false after recordings loaded") } - if len(v.recordingL.recordings) != 2 { - t.Errorf("expected 2 recordings, got %d", len(v.recordingL.recordings)) + if len(v.events) != 2 { + t.Errorf("expected 2 events, got %d", len(v.events)) + } + if len(v.habits) != 1 { + t.Errorf("expected 1 habit, got %d", len(v.habits)) + } + if len(v.todos) != 1 { + t.Errorf("expected 1 todo, got %d", len(v.todos)) } } @@ -92,6 +103,19 @@ func TestCalendarViewHandlesRecordingDetail(t *testing.T) { } } +func TestCalendarViewHandlesIdentityLoaded(t *testing.T) { + v := newCalendarView(testVC()) + v.Resize(80, 30) + + _, consumed := v.Update(identityLoadedMsg{firstWeekDay: time.Sunday}) + if !consumed { + t.Error("identityLoadedMsg should be consumed") + } + if v.firstWeekDay != time.Sunday { + t.Errorf("firstWeekDay = %v, want Sunday", v.firstWeekDay) + } +} + func TestCalendarViewIgnoresUnrelatedMessages(t *testing.T) { v := newCalendarView(testVC()) _, consumed := v.Update(boxesLoadedMsg{}) @@ -100,33 +124,28 @@ func TestCalendarViewIgnoresUnrelatedMessages(t *testing.T) { } } -// --- Content key handling --- +// --- View mode cycling --- -func TestCalendarViewContentKeyUpDown(t *testing.T) { +func TestCalendarViewModeCycle(t *testing.T) { v := calendarWithRecordings() - if v.recordingL.cursor != 0 { - t.Fatalf("initial cursor = %d, want 0", v.recordingL.cursor) + if v.viewMode != viewDay { + t.Fatalf("initial mode = %v, want Day", v.viewMode) } - v.HandleContentKey(keyPress("down")) - if v.recordingL.cursor != 1 { - t.Errorf("after down: cursor = %d, want 1", v.recordingL.cursor) + v.HandleContentKey(keyPress("v")) + if v.viewMode != viewWeek { + t.Errorf("after first v: mode = %v, want Week", v.viewMode) } - v.HandleContentKey(keyPress("up")) - if v.recordingL.cursor != 0 { - t.Errorf("after up: cursor = %d, want 0", v.recordingL.cursor) + v.HandleContentKey(keyPress("v")) + if v.viewMode != viewYear { + t.Errorf("after second v: mode = %v, want Year", v.viewMode) } -} -func TestCalendarViewContentKeyEnter(t *testing.T) { - v := calendarWithRecordings() - v.Resize(80, 30) - - cmd := v.HandleContentKey(keyPress("enter")) - if cmd == nil { - t.Fatal("enter on a recording should return a command") + v.HandleContentKey(keyPress("v")) + if v.viewMode != viewDay { + t.Errorf("after third v: mode = %v, want Day (wrap around)", v.viewMode) } } @@ -142,8 +161,8 @@ func TestCalendarViewSubnavItems(t *testing.T) { if selected != 0 { t.Errorf("selected = %d, want 0", selected) } - if label != "Work" { - t.Errorf("label = %q, want Work", label) + if label != "Work · Day" { + t.Errorf("label = %q, want \"Work · Day\"", label) } if !centered { t.Error("calendar subnav should be centered") @@ -192,10 +211,13 @@ func TestCalendarViewInThread(t *testing.T) { // --- Help bindings --- -func TestCalendarViewHelpBindingsEmpty(t *testing.T) { +func TestCalendarViewHelpBindingsShowsViewToggle(t *testing.T) { v := calendarWithRecordings() bindings := v.HelpBindings() - if len(bindings) != 0 { - t.Errorf("calendar should have no extra bindings, got %d", len(bindings)) + if len(bindings) != 1 { + t.Fatalf("expected 1 binding, got %d", len(bindings)) + } + if bindings[0].key != "v" { + t.Errorf("binding key = %q, want \"v\"", bindings[0].key) } } diff --git a/internal/tui/calendar_views.go b/internal/tui/calendar_views.go new file mode 100644 index 0000000..e142390 --- /dev/null +++ b/internal/tui/calendar_views.go @@ -0,0 +1,833 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + "charm.land/lipgloss/v2" + + "github.com/basecamp/hey-cli/internal/models" +) + +// calendarViewMode represents the calendar display mode. +type calendarViewMode int + +const ( + viewDay calendarViewMode = iota + viewWeek + viewYear +) + +func (m calendarViewMode) String() string { + switch m { + case viewDay: + return "Day" + case viewWeek: + return "Week" + case viewYear: + return "Year" + } + 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 { + case viewDay: + start = time.Date(anchor.Year(), anchor.Month(), anchor.Day(), 0, 0, 0, 0, loc) + end = start.AddDate(0, 0, 1) + case viewWeek: + start = weekStartDate(anchor, firstWeekDay) + end = start.AddDate(0, 0, 7) + case viewYear: + yearStart := time.Date(anchor.Year(), 1, 1, 0, 0, 0, 0, loc) + yearEnd := time.Date(anchor.Year()+1, 1, 1, 0, 0, 0, 0, loc) + start = weekStartDate(yearStart, firstWeekDay) + endWeekStart := weekStartDate(yearEnd.AddDate(0, 0, -1), firstWeekDay) + end = endWeekStart.AddDate(0, 0, 7) + } + return +} + +// weekStartDate returns the start of the week containing t. +func weekStartDate(t time.Time, firstDay time.Weekday) time.Time { + d := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + diff := (int(d.Weekday()) - int(firstDay) + 7) % 7 + 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 []models.Recording) (events, todos, habits []models.Recording) { + for _, r := range recs { + t := strings.ToLower(r.Type) + switch { + case strings.Contains(t, "todo"): + todos = append(todos, r) + case strings.Contains(t, "habit"): + habits = append(habits, r) + default: + events = append(events, r) + } + } + sort.Slice(events, func(i, j int) bool { + return events[i].StartsAt < 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{} +} + +// eventsByDate groups events by date (YYYY-MM-DD), expanding multi-day events +// so they appear on every day they span. +func eventsByDate(events []models.Recording) map[string][]models.Recording { + m := make(map[string][]models.Recording) + for _, e := range events { + st := parseEventTime(e.StartsAt) + if st.IsZero() { + continue + } + et := parseEventTime(e.EndsAt) + + // Single-day or no end time: just the start date + if et.IsZero() || !et.After(st) || dateKey(st) == dateKey(et) { + m[dateKey(st)] = append(m[dateKey(st)], e) + continue + } + + // Multi-day: add to every day from start through end (inclusive of + // end date only if it doesn't start at midnight, i.e. the event + // actually occupies part of that day). + d := time.Date(st.Year(), st.Month(), st.Day(), 0, 0, 0, 0, st.Location()) + endDay := time.Date(et.Year(), et.Month(), et.Day(), 0, 0, 0, 0, et.Location()) + // If the event ends exactly at midnight, the last occupied day is the day before + if et.Equal(endDay) { + endDay = endDay.AddDate(0, 0, -1) + } + for !d.After(endDay) { + m[dateKey(d)] = append(m[dateKey(d)], e) + d = d.AddDate(0, 0, 1) + } + } + return m +} + +func dateKey(t time.Time) string { + return t.Format("2006-01-02") +} + +// dayLabelsFromEvents builds a map of date → custom label from recordings +// that have a Label set (named days in HEY). +func dayLabelsFromEvents(events []models.Recording) map[string]string { + m := make(map[string]string) + for _, e := range events { + if e.Label == "" { + continue + } + t := parseEventTime(e.StartsAt) + if t.IsZero() { + continue + } + // First label wins + key := dateKey(t) + if _, exists := m[key]; !exists { + m[key] = e.Label + } + } + return m +} + +// ============================================================ +// Day View — hours as columns, event names rendered vertically +// ============================================================ + +// placedEvent stores an event's position in the day grid. +type placedEvent struct { + rec models.Recording + startCol int + endCol int + lane int +} + +func renderDayView(events, todos, habits []models.Recording, _ time.Time, width, _ int) string { + var b strings.Builder + muted := lipgloss.NewStyle().Foreground(colorMuted) + primary := lipgloss.NewStyle().Foreground(colorPrimary) + + // Habits ribbon above columns + if len(habits) > 0 { + b.WriteString(renderHabitsRibbon(habits, width)) + b.WriteString("\n") + } + + colWidth := max(width/24, 3) + gridWidth := colWidth * 24 + + // Hour header + var header strings.Builder + for h := range 24 { + label := fmt.Sprintf("%02d", h) + pad := colWidth - 2 + header.WriteString(label) + if pad > 0 { + header.WriteString(strings.Repeat(" ", pad)) + } + } + b.WriteString(muted.Render(header.String())) + b.WriteString("\n") + + // Separate timed and all-day events + var timed, allDay []models.Recording + for _, e := range events { + if e.AllDay { + allDay = append(allDay, e) + } else { + timed = append(timed, e) + } + } + + // 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) + if st.IsZero() { + continue + } + if et.IsZero() || !et.After(st) { + et = st.Add(time.Hour) + } + + startPos := (st.Hour()*60 + st.Minute()) * gridWidth / (24 * 60) + endPos := (et.Hour()*60 + et.Minute()) * gridWidth / (24 * 60) + if et.Day() != st.Day() || (et.Hour() == 0 && et.Minute() == 0 && et.After(st)) { + endPos = gridWidth + } + if endPos <= startPos { + endPos = startPos + colWidth + } + startPos = min(startPos, gridWidth-1) + endPos = min(endPos, gridWidth) + if endPos-startPos < 3 { + endPos = min(startPos+3, gridWidth) + } + + placed = append(placed, placedEvent{rec: e, startCol: startPos, endCol: endPos}) + } + + // Assign lanes: find the lowest lane where the event doesn't overlap + laneEnds := []int{} // tracks the rightmost endCol in each lane + for i := range placed { + assigned := false + for l, laneEnd := range laneEnds { + if placed[i].startCol >= laneEnd { + placed[i].lane = l + laneEnds[l] = placed[i].endCol + assigned = true + break + } + } + if !assigned { + placed[i].lane = len(laneEnds) + laneEnds = append(laneEnds, placed[i].endCol) + } + } + + // Group events by lane + numLanes := len(laneEnds) + lanes := make([][]placedEvent, numLanes) + for _, pe := range placed { + 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)) + } + + if len(timed) == 0 && len(allDay) == 0 { + b.WriteString(muted.Render(" (no events)")) + b.WriteString("\n") + } + + // All-day events as full-width horizontal bars at the bottom + if len(allDay) > 0 { + b.WriteString(muted.Render(strings.Repeat("─", width))) + b.WriteString("\n") + for _, e := range allDay { + title := 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("\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() +} + +// 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 "" + } + + // Find the tallest title to determine band height + maxTitle := 0 + for _, pe := range lane { + if len([]rune(pe.rec.Title)) > maxTitle { + maxTitle = len([]rune(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 { + grid[row] = make([]rune, gridWidth) + isBox[row] = make([]bool, 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(pe.rec.Title) + + // 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 + } + // 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 + var b strings.Builder + for row := range bandHeight { + var seg strings.Builder + inStyled := false + + flush := func() { + s := seg.String() + if s == "" { + return + } + if inStyled { + b.WriteString(primary.Render(s)) + } else { + b.WriteString(muted.Render(s)) + } + seg.Reset() + } + + for col := range gridWidth { + styled := isBox[row][col] + if styled != inStyled { + flush() + inStyled = styled + } + seg.WriteRune(grid[row][col]) + } + flush() + // Trim trailing spaces + b.WriteString("\n") + } + + return b.String() +} + +// ============================================= +// Week View — 7 day columns with bordered grid +// ============================================= + +type weekDayInfo struct { + date time.Time + habits []models.Recording + events []models.Recording + allDay []models.Recording +} + +func renderWeekView(events, todos, habits []models.Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int, dayLabels map[string]string) string { + var b strings.Builder + muted := lipgloss.NewStyle().Foreground(colorMuted) + bright := lipgloss.NewStyle().Foreground(colorBright) + primary := lipgloss.NewStyle().Foreground(colorPrimary) + + ws := weekStartDate(anchor, firstWeekDay) + + colWidth := (width - 8) / 7 + if colWidth < 8 { + colWidth = 8 + } + + byDate := eventsByDate(events) + + days := make([]weekDayInfo, 7) + for i := range 7 { + d := ws.AddDate(0, 0, i) + days[i] = weekDayInfo{date: d} + + dateKey := d.Format("2006-01-02") + for _, e := range byDate[dateKey] { + if e.AllDay { + days[i].allDay = append(days[i].allDay, e) + } else { + days[i].events = append(days[i].events, e) + } + } + } + + // Assign habits to their dates + for _, h := range habits { + ht := parseEventTime(h.StartsAt) + if ht.IsZero() { + continue + } + for i := range days { + if sameDay(days[i].date, ht) { + days[i].habits = append(days[i].habits, h) + } + } + } + + sep := muted.Render("│") + + // Top border + b.WriteString(weekGridBorder("┌", "┬", "┐", colWidth, muted)) + b.WriteString("\n") + + // Column headers + b.WriteString(sep) + for i := range 7 { + label := dayLabelOrDefault(days[i].date, i == 0, dayLabels, weekDayColumnLabel) + padded := centerPad(label, colWidth) + b.WriteString(bright.Render(padded)) + b.WriteString(sep) + } + b.WriteString("\n") + + // Header separator + b.WriteString(weekGridBorder("├", "┼", "┤", colWidth, muted)) + b.WriteString("\n") + + // Build column content + cols := make([][]string, 7) + for i := range 7 { + cols[i] = buildWeekDayColumn(days[i], colWidth, primary, bright, muted) + } + + maxH := 0 + for _, col := range cols { + if len(col) > maxH { + maxH = len(col) + } + } + if maxH == 0 { + maxH = 1 + } + + // Render rows + for row := range maxH { + b.WriteString(sep) + for i := range 7 { + 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)) + } + b.WriteString(sep) + } + b.WriteString("\n") + } + + // Bottom border + b.WriteString(weekGridBorder("└", "┴", "┘", colWidth, muted)) + b.WriteString("\n") + + // Todos ribbon + if len(todos) > 0 { + b.WriteString(renderTodosRibbon(todos, width)) + b.WriteString("\n") + } + + return b.String() +} + +func weekGridBorder(left, mid, right string, colWidth int, muted lipgloss.Style) string { + var s strings.Builder + s.WriteString(muted.Render(left)) + for i := range 7 { + s.WriteString(muted.Render(strings.Repeat("─", colWidth))) + if i < 6 { + s.WriteString(muted.Render(mid)) + } + } + s.WriteString(muted.Render(right)) + return s.String() +} + +// buildWeekDayColumn returns styled lines for one day column. +// Order: habits at top, timed events in the middle, all-day at bottom. +func buildWeekDayColumn(d weekDayInfo, width int, primary, bright, muted lipgloss.Style) []string { + var lines []string + + for _, h := range d.habits { + marker := "○" + if h.CompletedAt != "" { + marker = "●" + } + line := marker + " " + truncateStr(h.Title, width-2) + lines = append(lines, muted.Render(line)) + } + + for _, e := range d.events { + timeStr := "" + if len(e.StartsAt) >= 16 { + timeStr = e.StartsAt[11:16] + } + if timeStr != "" { + lines = append(lines, muted.Render(timeStr)) + } + lines = append(lines, bright.Render(truncateStr(e.Title, width))) + } + + for _, e := range d.allDay { + lines = append(lines, primary.Render(truncateStr(e.Title, width))) + } + + return lines +} + +// weekDayColumnLabel returns the header label for a week column. +func weekDayColumnLabel(d time.Time, isFirstCol bool) string { + dayName := strings.ToUpper(d.Weekday().String()[:3]) + dayNum := d.Day() + + if dayNum == 1 { + monthName := strings.ToUpper(d.Month().String()[:3]) + return fmt.Sprintf("%s %s %d", monthName, dayName, dayNum) + } + if isFirstCol { + monthName := strings.ToUpper(d.Month().String()[:3]) + return fmt.Sprintf("%s %s %d", monthName, dayName, dayNum) + } + return fmt.Sprintf("%s %d", dayName, dayNum) +} + +// =============================================== +// Year View — bordered grid, one box per day +// =============================================== + +func renderYearView(events []models.Recording, anchor time.Time, firstWeekDay time.Weekday, width, _ int, dayLabels map[string]string) string { + var b strings.Builder + muted := lipgloss.NewStyle().Foreground(colorMuted) + bright := lipgloss.NewStyle().Foreground(colorBright) + primary := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) + faint := lipgloss.NewStyle().Foreground(colorMuted).Faint(true) + + loc := anchor.Location() + 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) + gridStart := weekStartDate(yearStart, firstWeekDay) + gridEndWeek := weekStartDate(yearEnd.AddDate(0, 0, -1), firstWeekDay) + gridEnd := gridEndWeek.AddDate(0, 0, 7) + + byDate := eventsByDate(events) + + colWidth := max((width-8)/7, 9) + maxEventsPerCell := 2 // show at most 2 event titles per cell + + sep := muted.Render("│") + + // Top border + b.WriteString(weekGridBorder("┌", "┬", "┐", colWidth, muted)) + 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") + + // 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") + + // Build cell content for each day in the week + weekDates := make([]time.Time, 7) + cells := make([][]string, 7) + for i := range 7 { + weekDates[i] = d + cells[i] = buildYearDayCell(d, byDate[dateKey(d)], colWidth, maxEventsPerCell, + sameDay(d, today), d.Year() == anchor.Year(), primary, bright, muted, faint, dayLabels) + 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 + } + + // 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)) + } + b.WriteString(sep) + } + b.WriteString("\n") + } + } + + // Bottom border + b.WriteString(weekGridBorder("└", "┴", "┘", colWidth, muted)) + b.WriteString("\n") + + return b.String() +} + +// 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 []models.Recording, colWidth, maxEvents int, + isToday, isCurrentYear bool, primary, bright, muted, faint lipgloss.Style, + dayLabels map[string]string, +) []string { + label := dayLabelOrDefault(d, false, dayLabels, yearDayColumnLabel) + + // Pick the style for the header line + headerStyle := muted + switch { + case isToday: + headerStyle = primary + case len(dayEvents) > 0 && isCurrentYear: + headerStyle = bright + case !isCurrentYear: + headerStyle = faint + } + + lines := []string{headerStyle.Render(truncateStr(label, colWidth))} + + if !isCurrentYear { + return lines + } + + // Event titles + shown := min(len(dayEvents), maxEvents) + for i := range shown { + title := truncateStr(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))) + } + + return lines +} + +// yearDayColumnLabel returns the default label for a day in the year view. +func yearDayColumnLabel(d time.Time, _ bool) string { + dayName := strings.ToUpper(d.Weekday().String()[:3]) + dayNum := d.Day() + + if dayNum == 1 { + monthName := strings.ToUpper(d.Month().String()[:3]) + return fmt.Sprintf("%s %s %d", monthName, dayName, dayNum) + } + return fmt.Sprintf("%s %d", dayName, dayNum) +} + +// dayLabelOrDefault returns the custom day label if one exists, otherwise +// falls back to the provided default label function. +func dayLabelOrDefault(d time.Time, isFirstCol bool, dayLabels map[string]string, fallback func(time.Time, bool) string) string { + if label, ok := dayLabels[dateKey(d)]; ok { + return label + } + return fallback(d, isFirstCol) +} + +// --- Ribbons --- + +func renderHabitsRibbon(habits []models.Recording, width int) string { + parts := make([]string, 0, len(habits)) + for _, h := range habits { + marker := "○" + if h.CompletedAt != "" { + marker = "●" + } + parts = append(parts, marker+" "+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 +} + +func renderTodosRibbon(todos []models.Recording, width int) string { + parts := make([]string, 0, len(todos)) + for _, t := range todos { + marker := "□" + if t.CompletedAt != "" { + marker = "■" + } + parts = append(parts, marker+" "+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] + } + ribbon = string(runes) + "…" + } + return ribbon +} + +// --- Helpers --- + +func sameDay(a, b time.Time) bool { + return a.Year() == b.Year() && a.Month() == b.Month() && a.Day() == b.Day() +} + +func truncateStr(s string, maxLen int) string { + if maxLen <= 0 { + return "" + } + if lipgloss.Width(s) <= maxLen { + return s + } + if maxLen <= 1 { + return "…" + } + runes := []rune(s) + for len(runes) > 0 && lipgloss.Width(string(runes))+lipgloss.Width("…") > maxLen { + runes = runes[:len(runes)-1] + } + return string(runes) + "…" +} + +func centerPad(s string, width int) string { + sw := lipgloss.Width(s) + pad := width - sw + if pad <= 0 { + runes := []rune(s) + for len(runes) > 0 && lipgloss.Width(string(runes)) > width { + runes = runes[:len(runes)-1] + } + return string(runes) + } + left := pad / 2 + right := pad - left + return strings.Repeat(" ", left) + s + strings.Repeat(" ", right) +} diff --git a/internal/tui/content.go b/internal/tui/content.go index e211737..374a501 100644 --- a/internal/tui/content.go +++ b/internal/tui/content.go @@ -202,132 +202,3 @@ func (c *contentList) view() string { return b.String() } - -// --- Calendar recordings content --- - -type recordingList struct { - recordings []models.Recording - cursor int - scrollOff int - width int - height int -} - -func (c *recordingList) setRecordings(recordings []models.Recording) { - c.recordings = recordings - c.cursor = 0 - c.scrollOff = 0 -} - -func (c *recordingList) setSize(w, h int) { - c.width = w - c.height = h -} - -func (c *recordingList) moveUp() { - if c.cursor > 0 { - c.cursor-- - c.ensureVisible() - } -} - -func (c *recordingList) moveDown() { - if c.cursor < len(c.recordings)-1 { - c.cursor++ - c.ensureVisible() - } -} - -func (c *recordingList) ensureVisible() { - visibleItems := c.height / 2 - if visibleItems < 1 { - visibleItems = 1 - } - if c.cursor < c.scrollOff { - c.scrollOff = c.cursor - } - if c.cursor >= c.scrollOff+visibleItems { - c.scrollOff = c.cursor - visibleItems + 1 - } -} - -func (c *recordingList) selectedRecording() *models.Recording { - if c.cursor < 0 || c.cursor >= len(c.recordings) { - return nil - } - return &c.recordings[c.cursor] -} - -func (c *recordingList) view() string { - if len(c.recordings) == 0 { - return lipgloss.NewStyle().Foreground(colorMuted).Render(" (empty)") - } - - visibleItems := c.height / 2 - if visibleItems < 1 { - visibleItems = 1 - } - - selected := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) - normal := lipgloss.NewStyle().Foreground(colorBright) - muted := lipgloss.NewStyle().Foreground(colorMuted) - - var b strings.Builder - end := min(c.scrollOff+visibleItems, len(c.recordings)) - - for i := c.scrollOff; i < end; i++ { - r := c.recordings[i] - isCursor := i == c.cursor - - // Line 1: [time] Title Date - var line1 strings.Builder - line1.WriteString(" ") - - prefix := "[All day]" - if !r.AllDay && len(r.StartsAt) >= 16 { - prefix = fmt.Sprintf("[%s]", r.StartsAt[11:16]) - } - - title := prefix + " " + r.Title - date := "" - if len(r.StartsAt) >= 10 { - date = r.StartsAt[:10] - } - - subjectWidth := max(c.width-4-len(date)-2, 10) - if len(title) > subjectWidth { - title = title[:subjectWidth-3] + "..." - } - gap := max(c.width-2-len(title)-len(date), 1) - - if isCursor { - line1.WriteString(selected.Render(title)) - line1.WriteString(strings.Repeat(" ", gap)) - line1.WriteString(selected.Render(date)) - } else { - line1.WriteString(normal.Render(title)) - line1.WriteString(strings.Repeat(" ", gap)) - line1.WriteString(muted.Render(date)) - } - - // Line 2: type info - var line2 strings.Builder - line2.WriteString(" ") - var parts []string - parts = append(parts, r.Type) - if r.Recurring { - parts = append(parts, "recurring") - } - desc := strings.Join(parts, " · ") - if isCursor { - line2.WriteString(selected.Render(desc)) - } else { - line2.WriteString(muted.Render(desc)) - } - - fmt.Fprintln(&b, line1.String()) - fmt.Fprintln(&b, line2.String()) - } - - return b.String() -} diff --git a/internal/tui/loading.go b/internal/tui/loading.go index 40ea8ae..c1aab18 100644 --- a/internal/tui/loading.go +++ b/internal/tui/loading.go @@ -14,10 +14,10 @@ import ( const ( hourglassVisualWidth = 11 // visual cell width of each frame line hourglassDrainFrames = 7 // frames where sand falls - hourglassFlipFrames = 4 // frames for the rotation - hourglassTotalFrames = 11 // drain + flip + hourglassFlipFrames = 6 // frames for the rotation + hourglassTotalFrames = 13 // drain + flip hourglassDrainPhase = 9.0 // ~3 sec of drain (60 ticks × 0.15) - hourglassFlipPhase = 2.4 // ~0.8 sec of flip (16 ticks × 0.15) + hourglassFlipPhase = 3.6 // ~1.2 sec of flip (24 ticks × 0.15) hourglassCyclePhase = hourglassDrainPhase + hourglassFlipPhase ) @@ -91,7 +91,15 @@ var hourglassFrames = [hourglassTotalFrames][]string{ " │⣿⣿⣿│ ", " ╰───╯", }, - { // 8: 90° horizontal — sand on right + { // 8: ~67° tilt + "╭─ ", + "│ ──╮ ", + "╰─ ⣠╭── ", + " ──╯ ⣾⣿⣿─╮", + " ╰──⣿│", + " ─╯", + }, + { // 9: 90° horizontal — sand on right " ", "╭───╮ ╭───╮", "│ ⣠⣾⣿⣿⣿│", @@ -99,7 +107,15 @@ var hourglassFrames = [hourglassTotalFrames][]string{ " ", " ", }, - { // 9: ~135° tilt — sand now top-right, empty bottom-left + { // 10: ~113° tilt + " ─╮", + " ╭──⣿│", + " ──╮ ⣾⣿⣿─╯", + "╭─ ╰── ", + "│ ──╯ ", + "╰─ ", + }, + { // 11: ~135° tilt — sand now top-right, empty bottom-left " ╭───╮", " │⣿⣿⣿│ ", " ╰╮ ╭╯ ", @@ -107,7 +123,7 @@ var hourglassFrames = [hourglassTotalFrames][]string{ " │ │ ", "╰───╯ ", }, - { // 10: 180° inverted — sand at top, glass upside-down + { // 12: 180° inverted — sand at top, glass upside-down " ╭───╮ ", " │⣿⣿⣿│ ", " ╰╮⣿╭╯ ",