package tui import ( "context" "database/sql" "errors" "fmt" "slices" "time" "punchcard/internal/queries" "github.com/charmbracelet/lipgloss/v2" ) var ( // Styles for the TUI topBarInactiveStyle = lipgloss.NewStyle(). Background(lipgloss.Color("21")). Foreground(lipgloss.Color("230")). Padding(0, 1) bottomBarStyle = lipgloss.NewStyle(). Background(lipgloss.Color("238")). Foreground(lipgloss.Color("252")) // Box styles selectedBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("62")). Padding(1, 2) unselectedBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("238")). Padding(1, 2) activeTimerStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("196")). Bold(true) inactiveTimerStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("246")) ) // FormatDuration formats a duration in a human-readable way func FormatDuration(d time.Duration) string { d = d.Round(time.Second) hours := int(d.Hours()) minutes := int(d.Minutes()) % 60 seconds := int(d.Seconds()) % 60 if hours > 0 { return fmt.Sprintf("%dh %02dm %02ds", hours, minutes, seconds) } if minutes > 0 { return fmt.Sprintf("%dm %02ds", minutes, seconds) } return fmt.Sprintf("%ds", seconds) } func getTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) { var info TimerInfo activeEntry, err := q.GetActiveTimeEntry(ctx) if err != nil && !errors.Is(err, sql.ErrNoRows) { return info, fmt.Errorf("failed to get active timer: %w", err) } if err != nil { return getMostRecentTimerInfo(ctx, q) } 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 } return info, nil } func getMostRecentTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) { var info TimerInfo entry, err := q.GetMostRecentTimeEntry(ctx) if err != nil && !errors.Is(err, sql.ErrNoRows) { return info, fmt.Errorf("failed to get most recent timer: %w", err) } if err != nil { return info, nil } 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 } if entry.Description.Valid { info.Description = &entry.Description.String } 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(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 := 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(m.width).Render(content) } // RenderBottomBar renders the bottom bar with key bindings 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 += " " } keyStyle := lipgloss.NewStyle().Bold(true) formattedKey := keyStyle.Render(fmt.Sprintf("[%s]", binding.Key)) content += fmt.Sprintf("%s %s", formattedKey, binding.Description(m)) } content = bottomBarStyle.Align(lipgloss.Left).Render(content) if err != nil { content = lipgloss.JoinHorizontal( lipgloss.Bottom, content, bottomBarStyle.Bold(true).Foreground(lipgloss.Color("196")).Align(lipgloss.Right).Render(err.Error()), ) } 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 } clients, err = q.ListAllClients(ctx) if err != nil { return } 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 } 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 } 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) }