package tui import ( "context" "database/sql" "errors" "fmt" "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")). Padding(0, 1) // 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", hours, minutes) } if minutes > 0 { return fmt.Sprintf("%dm %02ds", minutes, seconds) } 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 // Get today's total todaySeconds, err := q.GetTodaySummary(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 } // Get week's total weekSummary, err := q.GetWeekSummaryByProject(ctx) if err != nil { return stats, fmt.Errorf("failed to get week summary: %w", err) } var weekTotal time.Duration for _, row := range weekSummary { weekTotal += time.Duration(row.TotalSeconds) * time.Second } stats.WeekTotal = weekTotal return stats, nil } // GetTimerInfo retrieves current timer information 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 errors.Is(err, sql.ErrNoRows) { // No active timer 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 } } // 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 } // Use entry-specific billable rate if set if activeEntry.BillableRate.Valid { entryRate := float64(activeEntry.BillableRate.Int64) / 100.0 info.BillableRate = &entryRate } 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)) // 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 // 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) } // RenderBottomBar renders the bottom bar with key bindings func RenderBottomBar(bindings []KeyBinding, width int) string { var content string for i, binding := range bindings { 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) } 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) if err != nil { return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get timer info: %w", err) } // Get time stats stats, err := GetTimeStats(ctx, q) if err != nil { return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get time stats: %w", err) } // Get clients clients, err := q.ListAllClients(ctx) if err != nil { return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get clients: %w", err) } // Get projects projects, err := q.ListAllProjects(ctx) if err != nil { return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get projects: %w", err) } // Get recent entries entries, err := q.GetRecentTimeEntries(ctx, 20) if err != nil { return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get recent entries: %w", err) } return timerInfo, stats, clients, projects, entries, nil }