diff --git a/internal/api/entries.go b/internal/api/entries.go index facdf18..aa4da99 100644 --- a/internal/api/entries.go +++ b/internal/api/entries.go @@ -84,6 +84,32 @@ func FetchEntriesForWeek(apiKey, workspaceId, userId string, weekStart time.Time } } +// FetchEntriesForWeek returns a command that fetches time entries for a specific week +func FetchEntriesForMonth(apiKey, workspaceId, userId string, requestedDate time.Time) tea.Cmd { + return func() tea.Msg { + client := NewClient(apiKey) + + monthStart := time.Date(requestedDate.Year(), requestedDate.Month(), 1, 0, 0, 0, 0, time.UTC) + monthEnd := time.Date(requestedDate.Year(), requestedDate.Month()+1, 1, 0, 0, 0, 0, time.UTC).Add(-time.Second) + + startStr := monthStart.Format("2006-01-02") + endStr := monthEnd.Format("2006-01-02") + + endpoint := "/workspaces/%s/user/%s/time-entries?start=%sT00:00:00Z&end=%sT23:59:59Z" + body, err := client.Get(fmt.Sprintf(endpoint, workspaceId, userId, startStr, endStr)) + if err != nil { + return messages.ErrorMsg{Err: err} + } + + var entries []models.Entry + if err := json.Unmarshal(body, &entries); err != nil { + return messages.ErrorMsg{Err: fmt.Errorf("failed to parse entries: %w", err)} + } + + return messages.EntriesLoadedMsg{Entries: entries} + } +} + // CreateTimeEntry creates a new time entry in Clockify // Takes all the necessary parameters and returns an error if creation fails func (c *Client) CreateTimeEntry(workspaceID, projectID, taskID, description, startTimeStr, endTimeStr string, date time.Time) (models.Entry, error) { diff --git a/internal/ui/app.go b/internal/ui/app.go index e186fdd..17994f2 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -14,6 +14,7 @@ import ( "clockify-app/internal/ui/components/help" "clockify-app/internal/ui/components/modal" "clockify-app/internal/ui/views/entries" + "clockify-app/internal/ui/views/month" "clockify-app/internal/ui/views/project" "clockify-app/internal/ui/views/projects" "clockify-app/internal/ui/views/settings" @@ -30,6 +31,7 @@ const ( SettingsView View = iota EntriesView WeekView + MonthView ProjectsView ProjectView ) @@ -42,6 +44,7 @@ type Page struct { var pages = []Page{ {"Entries", EntriesView}, {"WeekView", WeekView}, + {"MonthView", MonthView}, {"Projects", ProjectsView}, {"Settings", SettingsView}, } @@ -62,6 +65,7 @@ type Model struct { projectsView projects.Model // List of Projects projectView project.Model // Single Project view weekView week.Model // Week view + monthView month.Model // Month view // Modal state modal *modal.Model @@ -93,6 +97,7 @@ func NewModel() Model { entriesView: entries.New(cfg), projectsView: projects.New(cfg), weekView: week.New(cfg), + monthView: month.New(cfg), ready: false, } } @@ -127,6 +132,9 @@ func (m Model) initializeFirstViewCmd() tea.Cmd { ), m.weekView.Init(), ) + + case MonthView: + return m.monthView.Init() } return nil } @@ -165,6 +173,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.projectView, cmd = m.projectView.Update(msg) case WeekView: m.weekView.SetSize(m.width, m.height) + case MonthView: + m.monthView.SetSize(m.width, m.height) case SettingsView: m.settingsView.SetSize(m.width, m.height) } @@ -181,7 +191,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "q": return m, tea.Quit - case "1", "2", "3", "4": + case "1", "2", "3", "4", "5": if num, err := strconv.Atoi(msg.String()); err == nil { m.currentView = pages[num-1].Key m.viewport.SetContent(m.renderContent()) @@ -207,6 +217,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ), m.weekView.Init(), ) + case MonthView: + m.monthView.SetSize(m.width, m.height) + return m, m.monthView.Init() case SettingsView: return m, settings.Init() } @@ -340,6 +353,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.entriesView, cmd = m.entriesView.Update(msg) case WeekView: m.weekView, cmd = m.weekView.Update(msg) + case MonthView: + m.monthView, cmd = m.monthView.Update(msg) } m.viewport.SetContent(m.renderContent()) return m, cmd @@ -397,6 +412,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.projectView, cmd = m.projectView.Update(msg) case WeekView: m.weekView, cmd = m.weekView.Update(msg) + case MonthView: + m.monthView, cmd = m.monthView.Update(msg) } cmds = append(cmds, cmd) @@ -426,7 +443,7 @@ func (m Model) View() string { scrollbar := utils.RenderScrollbarSimple(m.viewport) switch m.currentView { - case EntriesView, ProjectsView, WeekView: + case EntriesView, ProjectsView, WeekView, MonthView: scrollbar = "" } // The viewport already contains the view content in Update @@ -503,6 +520,8 @@ func (m Model) renderContent() string { return m.projectView.View() case WeekView: return m.weekView.View() + case MonthView: + return m.monthView.View() } return "" diff --git a/internal/ui/views/month/month.go b/internal/ui/views/month/month.go new file mode 100644 index 0000000..b935463 --- /dev/null +++ b/internal/ui/views/month/month.go @@ -0,0 +1,274 @@ +package month + +import ( + "clockify-app/internal/api" + "clockify-app/internal/config" + "clockify-app/internal/messages" + "clockify-app/internal/models" + "clockify-app/internal/styles" + "fmt" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var TableStyle = lipgloss.NewStyle().Padding(0, 2) + +var ColumnWidth = 10 + +type Model struct { + config *config.Config + entries []models.Entry + currentMonth time.Time + + table table.Model + + width int + height int + ready bool +} + +func New(cfg *config.Config) Model { + m := Model{ + config: cfg, + entries: []models.Entry{}, + currentMonth: time.Now(), + ready: false, + } + + t := table.New( + table.WithColumns(m.setTableColumns()), + table.WithFocused(false), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.Secondary). + BorderBottom(true). + Bold(false) + s.Selected = lipgloss.NewStyle() + t.SetStyles(s) + + m.table = t + + return m +} + +func (m Model) Init() tea.Cmd { + return api.FetchEntriesForMonth(m.config.APIKey, m.config.WorkspaceId, m.config.UserId, m.currentMonth) +} + +func (m Model) View() string { + + footer := m.renderFooter() + + return lipgloss.JoinVertical( + lipgloss.Left, + styles.TitleStyle. + PaddingTop(1). + PaddingLeft(2). + Render(m.currentMonth.Format("January")), + TableStyle.Render( + lipgloss.JoinVertical( + lipgloss.Left, + m.table.View(), + footer, + ), + ), + ) +} + +func (m Model) renderFooter() string { + monthTotal := m.calculateMonthTotal() + + totalStyle := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.Secondary). + BorderTop(true). + Width(m.table.Width()) + + label := lipgloss.NewStyle(). + Foreground(styles.Secondary). + Bold(true). + Render("Month Total") + + value := lipgloss.NewStyle(). + Foreground(styles.Text). + Render(formatDuration(monthTotal)) + + content := lipgloss.JoinHorizontal( + lipgloss.Left, + label, + " ", + value, + ) + + return totalStyle.Render(content) +} + +func (m Model) calculateMonthTotal() time.Duration { + var total time.Duration + for _, entry := range m.entries { + if entry.TimeInterval.End.IsZero() { + continue + } + total += entry.TimeInterval.End.Sub(entry.TimeInterval.Start) + } + return total +} + +func (m Model) NextMonth() (Model, tea.Cmd) { + m.currentMonth = m.currentMonth.AddDate(0, 1, 0) + m.ready = false + m.entries = []models.Entry{} + m.table.SetRows([]table.Row{}) + return m, api.FetchEntriesForMonth(m.config.APIKey, m.config.WorkspaceId, m.config.UserId, m.currentMonth) +} + +func (m Model) PreviousMonth() (Model, tea.Cmd) { + m.currentMonth = m.currentMonth.AddDate(0, -1, 0) + m.ready = false + m.entries = []models.Entry{} + m.table.SetRows([]table.Row{}) + return m, api.FetchEntriesForMonth(m.config.APIKey, m.config.WorkspaceId, m.config.UserId, m.currentMonth) + +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "h", "left": + m, cmd = m.PreviousMonth() + cmds = append(cmds, cmd) + case "l", "right": + m, cmd = m.NextMonth() + cmds = append(cmds, cmd) + } + + case messages.EntriesLoadedMsg: + m.entries = msg.Entries + m.table.SetColumns(m.setTableColumns()) + m.table.SetRows(m.setTableData()) + m.SetSize(m.width, m.height) + m.ready = true + } + + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m *Model) SetSize(width, height int) { + h, _ := TableStyle.GetFrameSize() + widthPadding := 1 + m.width = width + m.height = height + if m.ready { + m.table.SetWidth(m.width - h - widthPadding) + m.table.SetHeight(7) + } else { + m.table.SetWidth(m.width - h - widthPadding) + m.table.SetHeight(7) + } + + //cols := m.table.Columns() + //cols[0].Width = m.width - h - 5 - ColumnWidth*len(cols) + //m.table.SetRows(m.setTableData()) +} + +var WeekColumnWidth = 8 +var WeekLabelWidth = 8 + +func (m Model) setTableColumns() []table.Column { + cols := []table.Column{ + {Title: "", Width: WeekLabelWidth}, + {Title: "Mon", Width: WeekColumnWidth}, + {Title: "Tue", Width: WeekColumnWidth}, + {Title: "Wed", Width: WeekColumnWidth}, + {Title: "Thu", Width: WeekColumnWidth}, + {Title: "Fri", Width: WeekColumnWidth}, + {Title: "Total", Width: WeekColumnWidth}, + } + return cols +} + +func (m Model) setTableData() []table.Row { + // Aggregate daily totals from entries + dailyTotals := make(map[string]time.Duration) + for _, entry := range m.entries { + if entry.TimeInterval.End.IsZero() { + continue + } + duration := entry.TimeInterval.End.Sub(entry.TimeInterval.Start) + day := entry.TimeInterval.Start.Format("2006-01-02") + dailyTotals[day] += duration + } + + startOfMonth := time.Date(m.currentMonth.Year(), m.currentMonth.Month(), 1, 0, 0, 0, 0, time.Local) + daysInMonth := time.Date(m.currentMonth.Year(), m.currentMonth.Month()+1, 0, 0, 0, 0, 0, time.Local).Day() + + // Group days into Mon–Fri weeks + type week struct { + label string + days [5]time.Duration // Mon=0 ... Fri=4 + } + + var weeks []week + var current *week + weekNum := 0 + + for i := range daysInMonth { + day := startOfMonth.AddDate(0, 0, i) + weekday := day.Weekday() + + if weekday == time.Saturday || weekday == time.Sunday { + continue + } + + if weekday == time.Monday || current == nil { + weekNum++ + weeks = append(weeks, week{label: fmt.Sprintf("Week %d", weekNum)}) + current = &weeks[len(weeks)-1] + } + + idx := int(weekday) - 1 // Mon=0, Tue=1, ..., Fri=4 + key := day.Format("2006-01-02") + current.days[idx] = dailyTotals[key] + } + + // Build rows + rows := []table.Row{} + for _, w := range weeks { + var weekTotal time.Duration + row := table.Row{w.label} + for _, d := range w.days { + weekTotal += d + row = append(row, formatDuration(d)) + } + row = append(row, formatDuration(weekTotal)) + rows = append(rows, row) + } + + return rows +} + +func formatDuration(d time.Duration) string { + if d == 0 { + return "-" + } + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + if h == 0 { + return fmt.Sprintf("%dm", m) + } + if m == 0 { + return fmt.Sprintf("%dh", h) + } + return fmt.Sprintf("%dh %dm", h, m) +}