diff options
Diffstat (limited to 'internal/tui/shared.go')
-rw-r--r-- | internal/tui/shared.go | 294 |
1 files changed, 175 insertions, 119 deletions
diff --git a/internal/tui/shared.go b/internal/tui/shared.go index 77b282d..b6bca20 100644 --- a/internal/tui/shared.go +++ b/internal/tui/shared.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "time" "punchcard/internal/queries" @@ -15,32 +16,32 @@ import ( var ( // Styles for the TUI topBarInactiveStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("21")). - Foreground(lipgloss.Color("230")). - Padding(0, 1) + Background(lipgloss.Color("21")). + Foreground(lipgloss.Color("230")). + Padding(0, 1) bottomBarStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("238")). - Foreground(lipgloss.Color("252")). - Padding(0, 1) + Background(lipgloss.Color("238")). + Foreground(lipgloss.Color("252")). + Padding(0, 1) // Box styles selectedBoxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). - Padding(1, 2) + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2) unselectedBoxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("238")). - Padding(1, 2) + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("238")). + Padding(1, 2) activeTimerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")). - Bold(true) + Foreground(lipgloss.Color("196")). + Bold(true) inactiveTimerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("246")) + Foreground(lipgloss.Color("246")) ) // FormatDuration formats a duration in a human-readable way @@ -59,167 +60,222 @@ func FormatDuration(d time.Duration) string { return fmt.Sprintf("%ds", seconds) } -// GetTimeStats retrieves today's and week's time statistics -func GetTimeStats(ctx context.Context, q *queries.Queries) (TimeStats, error) { - var stats TimeStats +func getTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) { + var info TimerInfo - // Get today's total - todaySeconds, err := q.GetTodaySummary(ctx) + activeEntry, err := q.GetActiveTimeEntry(ctx) if err != nil && !errors.Is(err, sql.ErrNoRows) { - return stats, fmt.Errorf("failed to get today's summary: %w", err) - } - if err == nil { - stats.TodayTotal = time.Duration(todaySeconds) * time.Second + return info, fmt.Errorf("failed to get active timer: %w", err) } - - // Get week's total - weekSummary, err := q.GetWeekSummaryByProject(ctx) if err != nil { - return stats, fmt.Errorf("failed to get week summary: %w", err) + return getMostRecentTimerInfo(ctx, q) } - var weekTotal time.Duration - for _, row := range weekSummary { - weekTotal += time.Duration(row.TotalSeconds) * time.Second + info.IsActive = true + info.EntryID = activeEntry.ID + info.Duration = time.Since(activeEntry.StartTime) + info.StartTime = activeEntry.StartTime + info.ClientID = activeEntry.ClientID + if activeEntry.ProjectID.Valid { + info.ProjectID = &activeEntry.ProjectID.Int64 + } + if activeEntry.Description.Valid { + info.Description = &activeEntry.Description.String + } + if activeEntry.BillableRate.Valid { + rate := float64(activeEntry.BillableRate.Int64) / 100 + info.BillableRate = &rate } - stats.WeekTotal = weekTotal - return stats, nil + return info, nil } -// GetTimerInfo retrieves current timer information -func GetTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) { +func getMostRecentTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) { var info TimerInfo - activeEntry, err := q.GetActiveTimeEntry(ctx) + entry, err := q.GetMostRecentTimeEntry(ctx) if err != nil && !errors.Is(err, sql.ErrNoRows) { - return info, fmt.Errorf("failed to get active timer: %w", err) + return info, fmt.Errorf("failed to get most recent timer: %w", err) } - - if errors.Is(err, sql.ErrNoRows) { - // No active timer + if err != nil { return info, nil } - // Active timer found - info.IsActive = true - info.StartTime = activeEntry.StartTime - info.Duration = time.Since(activeEntry.StartTime) - - // Get client information - client, err := q.FindClient(ctx, queries.FindClientParams{ - ID: activeEntry.ClientID, - Name: "", - }) - if err == nil && len(client) > 0 { - info.ClientName = client[0].Name - if client[0].BillableRate.Valid { - rate := float64(client[0].BillableRate.Int64) / 100.0 - info.BillableRate = &rate - } + info.IsActive = false + info.EntryID = entry.ID + info.Duration = entry.EndTime.Time.Sub(entry.StartTime) + info.StartTime = entry.StartTime + info.ClientID = entry.ClientID + if entry.ProjectID.Valid { + info.ProjectID = &entry.ProjectID.Int64 } - - // Get project information if exists - if activeEntry.ProjectID.Valid { - project, err := q.FindProject(ctx, queries.FindProjectParams{ - ID: activeEntry.ProjectID.Int64, - Name: "", - }) - if err == nil && len(project) > 0 { - info.ProjectName = project[0].Name - if project[0].BillableRate.Valid { - projectRate := float64(project[0].BillableRate.Int64) / 100.0 - info.BillableRate = &projectRate - } - } - } - - // Get description - if activeEntry.Description.Valid { - info.Description = activeEntry.Description.String + if entry.Description.Valid { + info.Description = &entry.Description.String } - - // Use entry-specific billable rate if set - if activeEntry.BillableRate.Valid { - entryRate := float64(activeEntry.BillableRate.Int64) / 100.0 - info.BillableRate = &entryRate + if entry.BillableRate.Valid { + rate := float64(entry.BillableRate.Int64) / 100 + info.BillableRate = &rate } return info, nil } // RenderTopBar renders the top bar with view name and time stats -func RenderTopBar(viewName string, stats TimeStats, width int) string { - left := viewName - right := fmt.Sprintf("Today: %s | Week: %s", - FormatDuration(stats.TodayTotal), - FormatDuration(stats.WeekTotal)) - +func RenderTopBar(m AppModel) string { + left := fmt.Sprintf("👊 Punchcard ♦️ - %s", m.selectedBox.String()) + + today := m.timeStats.TodayTotal + week := m.timeStats.WeekTotal + + if m.timerBox.timerInfo.IsActive { + activeTime := m.timerBox.currentTime.Sub(m.timerBox.timerInfo.StartTime) + today += activeTime + week += activeTime + } + + right := fmt.Sprintf("Today: %s | Week: %s", + FormatDuration(today), + FormatDuration(week)) + // Use lipgloss to create left and right aligned content leftStyle := lipgloss.NewStyle().Align(lipgloss.Left) rightStyle := lipgloss.NewStyle().Align(lipgloss.Right) - + // Calculate available width for content (minus padding) - contentWidth := width - 2 // Account for horizontal padding - + contentWidth := m.width - 2 // Account for horizontal padding + // Create a layout with left and right content content := lipgloss.JoinHorizontal( lipgloss.Top, leftStyle.Width(contentWidth/2).Render(left), rightStyle.Width(contentWidth/2).Render(right), ) - - return topBarInactiveStyle.Width(width).Render(content) + + return topBarInactiveStyle.Width(m.width).Render(content) } // RenderBottomBar renders the bottom bar with key bindings -func RenderBottomBar(bindings []KeyBinding, width int) string { +func RenderBottomBar(m AppModel, bindings []KeyBinding, err error) string { var content string for i, binding := range bindings { + if binding.Hide { + continue + } if i > 0 { content += " " } - // Format key with bold and square brackets keyStyle := lipgloss.NewStyle().Bold(true) formattedKey := keyStyle.Render(fmt.Sprintf("[%s]", binding.Key)) - content += fmt.Sprintf("%s %s", formattedKey, binding.Description) + content += fmt.Sprintf("%s %s", formattedKey, binding.Description(m)) } - - return bottomBarStyle.Width(width).Render(content) -} -// GetAppData fetches all data needed for the TUI -func GetAppData(ctx context.Context, q *queries.Queries) (TimerInfo, TimeStats, []queries.Client, []queries.ListAllProjectsRow, []queries.TimeEntry, error) { - // Get timer info - timerInfo, err := GetTimerInfo(ctx, q) + content = bottomBarStyle.Align(lipgloss.Left).Render(content) if err != nil { - return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get timer info: %w", err) + content = lipgloss.JoinHorizontal( + lipgloss.Bottom, + content, + bottomBarStyle.Bold(true).Foreground(lipgloss.Color("196")).Align(lipgloss.Right).Render(err.Error()), + ) } - - // Get time stats - stats, err := GetTimeStats(ctx, q) + + return bottomBarStyle.Width(m.width).Render(content) +} + +// GetAppData fetches all data needed for the TUI +func getAppData( + ctx context.Context, + q *queries.Queries, +) ( + info TimerInfo, + stats TimeStats, + clients []queries.Client, + projectsIdx map[int64][]queries.Project, + entries []queries.TimeEntry, + err error, +) { + info, err = getTimerInfo(ctx, q) if err != nil { - return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get time stats: %w", err) + return } - - // Get clients - clients, err := q.ListAllClients(ctx) + + clients, err = q.ListAllClients(ctx) if err != nil { - return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get clients: %w", err) + return } - - // Get projects + slices.SortFunc(clients, func(a, b queries.Client) int { + if a.Name <= b.Name { + return -1 + } + return 1 + }) + projects, err := q.ListAllProjects(ctx) if err != nil { - return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get projects: %w", err) + return } - - // Get recent entries - entries, err := q.GetRecentTimeEntries(ctx, 20) + slices.SortFunc(projects, func(a, b queries.ListAllProjectsRow) int { + if a.Name <= b.Name { + return -1 + } + return 1 + }) + projectsIdx = make(map[int64][]queries.Project) + for i := range projects { + projectsIdx[projects[i].ClientID] = append( + projectsIdx[projects[i].ClientID], + queries.Project{ + ID: projects[i].ID, + Name: projects[i].Name, + ClientID: projects[i].ClientID, + BillableRate: projects[i].BillableRate, + CreatedAt: projects[i].CreatedAt, + }, + ) + } + + entries, err = q.GetRecentTimeEntries(ctx, time.Now().Add(-time.Hour*24*14)) if err != nil { - return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get recent entries: %w", err) + return } - - return timerInfo, stats, clients, projects, entries, nil + + now := time.Now() + todayY, todayM, todayD := now.Date() + lastMon := mostRecentMonday(now) + inDay := true + for i := range entries { + e := entries[i] + + if info.IsActive && e.ID == info.EntryID { + // skip the active timer + continue + } + + if inDay { + y, m, d := e.StartTime.Date() + if y != todayY || m != todayM || d != todayD { + inDay = false + } + } + + dur := e.EndTime.Time.Sub(e.StartTime) + if inDay { + stats.TodayTotal += dur + stats.WeekTotal += dur + continue + } + + mon := mostRecentMonday(e.StartTime) + if mon != lastMon { + break + } + stats.WeekTotal += dur + } + + return } +func mostRecentMonday(from time.Time) time.Time { + d := dateOnly(from) + dayOffset := time.Duration(d.Weekday()-1) % 7 + return d.Add(-time.Hour * 24 * dayOffset) +} |