summaryrefslogtreecommitdiff
path: root/internal/tui/shared.go
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-05 12:36:30 -0600
committerT <t@tjp.lol>2025-08-06 12:13:11 -0600
commit65e2ed65775d64afbc6065a3b4ac1069020093ca (patch)
treef94fabfed5be2d2622429ebc7c8af1bf51085824 /internal/tui/shared.go
parent665bd389a0a1c8adadcaa1122e846cc81f5ead31 (diff)
most features in TUI working, remaining unimplemented keybinds need a modal view
Diffstat (limited to 'internal/tui/shared.go')
-rw-r--r--internal/tui/shared.go294
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)
+}