package tui import ( "context" "database/sql" "errors" "fmt" "slices" "time" "git.tjp.lol/punchcard/internal/queries" "github.com/charmbracelet/lipgloss/v2" ) // Maximum content width for large displays - prevents over-stretching const maxContentWidth = 180 var ( // Color palette colorAccent = lipgloss.Color("4") // Blue accent colorAccentBright = lipgloss.Color("12") // Bright blue colorTimerActive = lipgloss.Color("2") // Green for active timer colorTimerText = lipgloss.Color("10") // Bright green colorDimmed = lipgloss.Color("242") // Dimmed text colorSubtle = lipgloss.Color("238") // Very subtle borders/separators colorFg = lipgloss.Color("253") // Main foreground colorBg = lipgloss.Color("235") // Slightly lighter than terminal default colorBarBg = lipgloss.Color("236") // Bar background colorSelected = lipgloss.Color("4") // Selection background colorSelectedFg = lipgloss.Color("15") // White text on selection colorDate = lipgloss.Color("6") // Cyan for dates colorWarning = lipgloss.Color("1") // Red for warnings/errors // Styles for the TUI topBarStyle = lipgloss.NewStyle(). Background(colorBarBg). Foreground(colorFg). Padding(0, 1) bottomBarStyle = lipgloss.NewStyle(). Background(colorBarBg). Foreground(colorDimmed) // Box styles selectedBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorAccent). Padding(1, 2) unselectedBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorSubtle). Padding(1, 2) activeTimerStyle = lipgloss.NewStyle(). Foreground(colorTimerText). Bold(true) activeBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorTimerActive). Padding(1, 2) inactiveTimerStyle = lipgloss.NewStyle(). Foreground(colorDimmed) ) // 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 getContractorInfo(ctx context.Context, q *queries.Queries) (ContractorInfo, error) { c, err := q.GetContractor(ctx) if err != nil { return ContractorInfo{}, err } return ContractorInfo{ name: c.Name, label: c.Label, email: c.Email, }, nil } 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, contentWidth int) string { leftText := 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 } rightText := fmt.Sprintf("today %s week %s", FormatDuration(today), FormatDuration(week)) leftStyle := lipgloss.NewStyle().Align(lipgloss.Left) rightStyle := lipgloss.NewStyle().Align(lipgloss.Right) // Account for the 2 chars of padding in topBarStyle innerWidth := contentWidth - 2 content := lipgloss.JoinHorizontal( lipgloss.Top, leftStyle.Width(innerWidth/2).Render(leftText), rightStyle.Width(innerWidth-innerWidth/2).Render(rightText), ) return topBarStyle.Width(contentWidth).Render(content) } // RenderBottomBar renders the bottom bar with key bindings func RenderBottomBar(m AppModel, bindings []KeyBinding, err error, contentWidth int) string { var content string keyStyle := lipgloss.NewStyle(). Background(lipgloss.Color("240")). Foreground(lipgloss.Color("15")). Padding(0, 1) descStyle := lipgloss.NewStyle(). Background(colorBarBg). Foreground(colorDimmed) sepStyle := lipgloss.NewStyle().Background(colorBarBg) for i, binding := range bindings { if binding.Hide { continue } desc := binding.Description(m) if desc == "" { continue } if i > 0 { content += sepStyle.Render(" ") } content += keyStyle.Render(binding.Key) content += descStyle.Render(" " + desc) } if err != nil { errStyle := lipgloss.NewStyle().Background(colorBarBg).Bold(true).Foreground(colorWarning) content += sepStyle.Render(" ") content += errStyle.Render(err.Error()) } return bottomBarStyle.Width(contentWidth).Align(lipgloss.Left).Render(content) } // GetAppData fetches all data needed for the TUI func getAppData( ctx context.Context, q *queries.Queries, filter HistoryFilter, ) ( contractor ContractorInfo, info TimerInfo, stats TimeStats, clients []queries.Client, projectsIdx map[int64][]queries.Project, entries []queries.TimeEntry, err error, ) { contractor, err = getContractorInfo(ctx, q) if err != nil { return } 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, Archived: projects[i].Archived, CreatedAt: projects[i].CreatedAt, }, ) } // Use filtered query with the provided filter var endTimeParam interface{} if filter.EndDate != nil { endTimeParam = *filter.EndDate } var clientIDParam interface{} if filter.ClientID != nil { clientIDParam = *filter.ClientID } var projectIDParam interface{} if filter.ProjectID != nil { projectIDParam = *filter.ProjectID } entries, err = q.GetFilteredTimeEntries(ctx, queries.GetFilteredTimeEntriesParams{ StartTime: filter.StartDate, EndTime: endTimeParam, ClientID: clientIDParam, ProjectID: projectIDParam, }) if err != nil { return } now := time.Now().Local() 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.Local().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 } // RenderContractorPanel renders the full-width contractor info panel. func RenderContractorPanel(c ContractorInfo, contentWidth int, isSelected bool) string { style := unselectedBoxStyle if isSelected { style = selectedBoxStyle } // Build content: name, label, email on one line separated by dimmed dividers dimStyle := lipgloss.NewStyle().Foreground(colorDimmed) nameStyle := lipgloss.NewStyle().Bold(true).Foreground(colorFg) var parts []string if c.name != "" { parts = append(parts, nameStyle.Render(c.name)) } if c.label != "" { parts = append(parts, dimStyle.Render(c.label)) } if c.email != "" { parts = append(parts, dimStyle.Render(c.email)) } var content string if len(parts) == 0 { content = dimStyle.Render("No contractor info set. Press 'e' to edit.") } else { sep := dimStyle.Render(" | ") content = "" for i, part := range parts { if i > 0 { content += sep } content += part } } // Use padding 0 vertically to keep it compact, override the box style padding panelStyle := style. Width(contentWidth). Padding(0, 2) return panelStyle.Render(content) } func mostRecentMonday(from time.Time) time.Time { d := dateOnly(from.Local()) dayOffset := time.Duration(d.Weekday()-1) % 7 return d.Add(-time.Hour * 24 * dayOffset) }