summaryrefslogtreecommitdiff
path: root/internal/commands
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-05 11:37:02 -0600
committerT <t@tjp.lol>2025-08-05 11:37:08 -0600
commit665bd389a0a1c8adadcaa1122e846cc81f5ead31 (patch)
treef34f9ec77891308c600c680683f60951599429c3 /internal/commands
parentdc895cec9d8a84af89ce2501db234dff33c757e2 (diff)
WIP TUI
Diffstat (limited to 'internal/commands')
-rw-r--r--internal/commands/add_client.go60
-rw-r--r--internal/commands/add_project.go58
-rw-r--r--internal/commands/helpers.go32
-rw-r--r--internal/commands/in.go193
-rw-r--r--internal/commands/in_test.go2
-rw-r--r--internal/commands/out.go21
-rw-r--r--internal/commands/root.go1
-rw-r--r--internal/commands/tui.go29
8 files changed, 123 insertions, 273 deletions
diff --git a/internal/commands/add_client.go b/internal/commands/add_client.go
index e35eba9..98aec3d 100644
--- a/internal/commands/add_client.go
+++ b/internal/commands/add_client.go
@@ -1,13 +1,10 @@
package commands
import (
- "database/sql"
"fmt"
- "regexp"
- "strings"
+ "punchcard/internal/actions"
"punchcard/internal/context"
- "punchcard/internal/queries"
"github.com/spf13/cobra"
)
@@ -19,33 +16,27 @@ func NewAddClientCmd() *cobra.Command {
Long: "Add a new client to the database. Name can include email in format 'Name <email@domain.com>'",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
- name, email := parseNameAndEmail(args)
+ name := args[0]
+ var email string
+ if len(args) > 1 {
+ email = args[1]
+ }
billableRateFloat, _ := cmd.Flags().GetFloat64("hourly-rate")
- billableRate := int64(billableRateFloat * 100)
+ var billableRate *float64
+ if billableRateFloat > 0 {
+ billableRate = &billableRateFloat
+ }
q := context.GetDB(cmd.Context())
if q == nil {
return fmt.Errorf("database not available in context")
}
- var emailParam sql.NullString
- if email != "" {
- emailParam = sql.NullString{String: email, Valid: true}
- }
-
- var billableRateParam sql.NullInt64
- if billableRate > 0 {
- billableRateParam = sql.NullInt64{Int64: billableRate, Valid: true}
- }
-
- client, err := q.CreateClient(cmd.Context(), queries.CreateClientParams{
- Name: name,
- Email: emailParam,
- BillableRate: billableRateParam,
- })
+ a := actions.New(q)
+ client, err := a.CreateClient(cmd.Context(), name, email, billableRate)
if err != nil {
- return fmt.Errorf("failed to create client: %w", err)
+ return err
}
output := fmt.Sprintf("Created client: %s", client.Name)
@@ -63,28 +54,3 @@ func NewAddClientCmd() *cobra.Command {
return cmd
}
-
-func parseNameAndEmail(args []string) (string, string) {
- nameArg := args[0]
- var emailArg string
- if len(args) > 1 {
- emailArg = args[1]
- }
-
- if emailArg != "" {
- if matches := emailAndNameRegex.FindStringSubmatch(emailArg); matches != nil {
- emailArg = strings.TrimSpace(matches[2])
- }
- }
-
- if matches := emailAndNameRegex.FindStringSubmatch(nameArg); matches != nil {
- nameArg = strings.TrimSpace(matches[1])
- if emailArg == "" {
- emailArg = strings.TrimSpace(matches[2])
- }
- }
-
- return nameArg, emailArg
-}
-
-var emailAndNameRegex = regexp.MustCompile(`^(.+?)<([^>]+@[^>]+)>$`)
diff --git a/internal/commands/add_project.go b/internal/commands/add_project.go
index 6c37e2a..1ed42db 100644
--- a/internal/commands/add_project.go
+++ b/internal/commands/add_project.go
@@ -1,13 +1,10 @@
package commands
import (
- "context"
- "database/sql"
"fmt"
- "strconv"
+ "punchcard/internal/actions"
punchctx "punchcard/internal/context"
- "punchcard/internal/queries"
"github.com/spf13/cobra"
)
@@ -34,32 +31,26 @@ Examples:
}
billableRateFloat, _ := cmd.Flags().GetFloat64("hourly-rate")
- billableRate := int64(billableRateFloat * 100) // Convert dollars to cents
+ var billableRate *float64
+ if billableRateFloat > 0 {
+ billableRate = &billableRateFloat
+ }
q := punchctx.GetDB(cmd.Context())
if q == nil {
return fmt.Errorf("database not available in context")
}
- // Find client by ID or name
- client, err := findClient(cmd.Context(), q, clientRef)
+ a := actions.New(q)
+ project, err := a.CreateProject(cmd.Context(), projectName, clientRef, billableRate)
if err != nil {
- return fmt.Errorf("failed to find client: %w", err)
- }
-
- // Create project
- var billableRateParam sql.NullInt64
- if billableRate > 0 {
- billableRateParam = sql.NullInt64{Int64: billableRate, Valid: true}
+ return err
}
- project, err := q.CreateProject(cmd.Context(), queries.CreateProjectParams{
- Name: projectName,
- ClientID: client.ID,
- BillableRate: billableRateParam,
- })
+ // Get client name for output
+ client, err := a.FindClient(cmd.Context(), clientRef)
if err != nil {
- return fmt.Errorf("failed to create project: %w", err)
+ return fmt.Errorf("failed to get client name: %w", err)
}
output := fmt.Sprintf("Created project: %s for client %s (ID: %d)", project.Name, client.Name, project.ID)
@@ -77,30 +68,3 @@ Examples:
return cmd
}
-
-func findClient(ctx context.Context, q *queries.Queries, clientRef string) (queries.Client, error) {
- // Parse clientRef as ID if possible, otherwise use 0
- var idParam int64
- if id, err := strconv.ParseInt(clientRef, 10, 64); err == nil {
- idParam = id
- }
-
- // Search by both ID and name using UNION ALL
- clients, err := q.FindClient(ctx, queries.FindClientParams{
- ID: idParam,
- Name: clientRef,
- })
- if err != nil {
- return queries.Client{}, fmt.Errorf("database error looking up client: %w", err)
- }
-
- // Check results
- switch len(clients) {
- case 0:
- return queries.Client{}, fmt.Errorf("client not found: %s", clientRef)
- case 1:
- return clients[0], nil
- default:
- return queries.Client{}, fmt.Errorf("ambiguous client: %s", clientRef)
- }
-}
diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go
new file mode 100644
index 0000000..a0c572b
--- /dev/null
+++ b/internal/commands/helpers.go
@@ -0,0 +1,32 @@
+package commands
+
+import (
+ "context"
+ "errors"
+ "punchcard/internal/actions"
+ "punchcard/internal/queries"
+)
+
+// ErrNoActiveTimer is returned when trying to punch out but no timer is active
+var ErrNoActiveTimer = errors.New("no active timer found")
+
+// Helper functions for commands that need to find clients/projects
+// These wrap the actions package for backward compatibility
+
+func findClient(ctx context.Context, q *queries.Queries, clientRef string) (queries.Client, error) {
+ a := actions.New(q)
+ client, err := a.FindClient(ctx, clientRef)
+ if err != nil {
+ return queries.Client{}, err
+ }
+ return *client, nil
+}
+
+func findProject(ctx context.Context, q *queries.Queries, projectRef string) (queries.Project, error) {
+ a := actions.New(q)
+ project, err := a.FindProject(ctx, projectRef)
+ if err != nil {
+ return queries.Project{}, err
+ }
+ return *project, nil
+} \ No newline at end of file
diff --git a/internal/commands/in.go b/internal/commands/in.go
index abb57f1..e7847f6 100644
--- a/internal/commands/in.go
+++ b/internal/commands/in.go
@@ -1,15 +1,10 @@
package commands
import (
- "context"
- "database/sql"
- "errors"
"fmt"
- "strconv"
- "time"
+ "punchcard/internal/actions"
punchctx "punchcard/internal/context"
- "punchcard/internal/queries"
"github.com/spf13/cobra"
)
@@ -43,122 +38,54 @@ Examples:
}
billableRateFloat, _ := cmd.Flags().GetFloat64("hourly-rate")
- billableRate := int64(billableRateFloat * 100) // Convert dollars to cents
+ var billableRate *float64
+ if billableRateFloat > 0 {
+ billableRate = &billableRateFloat
+ }
q := punchctx.GetDB(cmd.Context())
if q == nil {
return fmt.Errorf("database not available in context")
}
- // Check if there's already an active timer
- activeEntry, err := q.GetActiveTimeEntry(cmd.Context())
- var hasActiveTimer bool
- if err != nil && !errors.Is(err, sql.ErrNoRows) {
- return fmt.Errorf("failed to check for active timer: %w", err)
- }
- hasActiveTimer = (err == nil)
-
- // Validate and get project first (if provided)
- var project queries.Project
- var projectID sql.NullInt64
- if projectFlag != "" {
- proj, err := findProject(cmd.Context(), q, projectFlag)
- if err != nil {
- return fmt.Errorf("invalid project: %w", err)
- }
- project = proj
- projectID = sql.NullInt64{Int64: project.ID, Valid: true}
- }
+ a := actions.New(q)
+
+ // Use the actions package based on what flags were provided
+ var session *actions.TimerSession
+ var err error
- // Validate and get client
- var clientID int64
- if clientFlag != "" {
- client, err := findClient(cmd.Context(), q, clientFlag)
- if err != nil {
- return fmt.Errorf("invalid client: %w", err)
- }
- clientID = client.ID
-
- // If project is specified, verify it belongs to this client
- if projectID.Valid && project.ClientID != clientID {
- return fmt.Errorf("project %q does not belong to client %q", projectFlag, clientFlag)
- }
- } else if projectID.Valid {
- clientID = project.ClientID
- } else if clientFlag == "" && projectFlag == "" {
- mostRecentEntry, err := q.GetMostRecentTimeEntry(cmd.Context())
- if err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return fmt.Errorf("no previous time entries found - client is required for first entry: use -c/--client flag")
- }
- return fmt.Errorf("failed to get most recent time entry: %w", err)
- }
-
- clientID = mostRecentEntry.ClientID
- projectID = mostRecentEntry.ProjectID
- if description == "" && mostRecentEntry.Description.Valid {
- description = mostRecentEntry.Description.String
- }
+ if clientFlag == "" && projectFlag == "" {
+ // Use most recent entry
+ session, err = a.PunchInMostRecent(cmd.Context(), description, billableRate)
} else {
- return fmt.Errorf("client is required: use -c/--client flag to specify client")
+ // Use specified client/project
+ session, err = a.PunchIn(cmd.Context(), clientFlag, projectFlag, description, billableRate)
}
- if hasActiveTimer {
- // Check if the new timer would be identical to the active one
- if timeEntriesMatch(clientID, projectID, description, activeEntry) {
- // No-op: identical timer already active
- cmd.Printf("Timer already active with same parameters (ID: %d)\n", activeEntry.ID)
- return nil
- }
-
- // Stop the active timer before starting new one
- stoppedEntry, err := q.StopTimeEntry(cmd.Context())
- if err != nil {
- return fmt.Errorf("failed to stop active timer: %w", err)
- }
-
- duration := stoppedEntry.EndTime.Time.Sub(stoppedEntry.StartTime)
- cmd.Printf("Stopped previous timer (ID: %d). Duration: %v\n",
- stoppedEntry.ID, duration.Round(time.Second))
+ if err != nil {
+ return err
}
- // Create time entry
- var descParam sql.NullString
- if description != "" {
- descParam = sql.NullString{String: description, Valid: true}
+ // Handle different response types
+ if session.WasNoOp {
+ cmd.Printf("Timer already active with same parameters (ID: %d)\n", session.ID)
+ return nil
}
- var billableRateParam sql.NullInt64
- if billableRate > 0 {
- billableRateParam = sql.NullInt64{Int64: billableRate, Valid: true}
- }
-
- timeEntry, err := q.CreateTimeEntry(cmd.Context(), queries.CreateTimeEntryParams{
- Description: descParam,
- ClientID: clientID,
- ProjectID: projectID,
- BillableRate: billableRateParam,
- })
- if err != nil {
- return fmt.Errorf("failed to create time entry: %w", err)
+ // Print stopped timer message if we stopped one
+ if session.StoppedEntryID != nil {
+ cmd.Printf("Stopped previous timer (ID: %d)\n", *session.StoppedEntryID)
}
// Build output message
- output := fmt.Sprintf("Started timer (ID: %d)", timeEntry.ID)
-
- // Add client info
- client, _ := findClient(cmd.Context(), q, strconv.FormatInt(clientID, 10))
- output += fmt.Sprintf(" for client: %s", client.Name)
+ output := fmt.Sprintf("Started timer (ID: %d) for client: %s", session.ID, session.ClientName)
- // Add project info if provided
- if projectID.Valid {
- project, _ := findProject(cmd.Context(), q, strconv.FormatInt(projectID.Int64, 10))
- output += fmt.Sprintf(", project: %s", project.Name)
+ if session.ProjectName != "" {
+ output += fmt.Sprintf(", project: %s", session.ProjectName)
}
- // Add description if provided
- if description != "" {
- output += fmt.Sprintf(", description: %s", description)
+ if session.Description != "" {
+ output += fmt.Sprintf(", description: %s", session.Description)
}
cmd.Print(output + "\n")
@@ -172,65 +99,3 @@ Examples:
return cmd
}
-
-func findProject(ctx context.Context, q *queries.Queries, projectRef string) (queries.Project, error) {
- // Parse projectRef as ID if possible, otherwise use 0
- var idParam int64
- if id, err := strconv.ParseInt(projectRef, 10, 64); err == nil {
- idParam = id
- }
-
- // Search by both ID and name using UNION ALL
- projects, err := q.FindProject(ctx, queries.FindProjectParams{
- ID: idParam,
- Name: projectRef,
- })
- if err != nil {
- return queries.Project{}, fmt.Errorf("database error looking up project: %w", err)
- }
-
- // Check results
- switch len(projects) {
- case 0:
- return queries.Project{}, fmt.Errorf("project not found: %s", projectRef)
- case 1:
- return projects[0], nil
- default:
- return queries.Project{}, fmt.Errorf("ambiguous project: %s", projectRef)
- }
-}
-
-// timeEntriesMatch checks if a new time entry would be identical to an active one
-// by comparing client ID, project ID, and description
-func timeEntriesMatch(clientID int64, projectID sql.NullInt64, description string, activeEntry queries.TimeEntry) bool {
- // Client must match
- if activeEntry.ClientID != clientID {
- return false
- }
-
- // Check project ID matching
- if projectID.Valid != activeEntry.ProjectID.Valid {
- // One has a project, the other doesn't
- return false
- }
- if projectID.Valid {
- // Both have projects - compare IDs
- if activeEntry.ProjectID.Int64 != projectID.Int64 {
- return false
- }
- }
-
- // Check description matching
- if (description != "") != activeEntry.Description.Valid {
- // One has description, the other doesn't
- return false
- }
- if activeEntry.Description.Valid {
- // Both have descriptions - compare strings
- if activeEntry.Description.String != description {
- return false
- }
- }
-
- return true
-}
diff --git a/internal/commands/in_test.go b/internal/commands/in_test.go
index 3832037..884a459 100644
--- a/internal/commands/in_test.go
+++ b/internal/commands/in_test.go
@@ -99,7 +99,7 @@ func TestInCommand(t *testing.T) {
setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 },
args: []string{"in"},
expectError: true,
- errorContains: "client is required",
+ errorContains: "no previous time entries found",
},
{
name: "punch in with nonexistent client",
diff --git a/internal/commands/out.go b/internal/commands/out.go
index f98a63c..1355f3d 100644
--- a/internal/commands/out.go
+++ b/internal/commands/out.go
@@ -1,18 +1,16 @@
package commands
import (
- "database/sql"
"errors"
"fmt"
"time"
+ "punchcard/internal/actions"
punchctx "punchcard/internal/context"
"github.com/spf13/cobra"
)
-var ErrNoActiveTimer = errors.New("no active timer found")
-
func NewOutCmd() *cobra.Command {
return &cobra.Command{
Use: "out",
@@ -25,23 +23,18 @@ func NewOutCmd() *cobra.Command {
return fmt.Errorf("database not available in context")
}
- // Stop the active timer by setting end_time to now
- stoppedEntry, err := q.StopTimeEntry(cmd.Context())
+ a := actions.New(q)
+ session, err := a.PunchOut(cmd.Context())
if err != nil {
- if errors.Is(err, sql.ErrNoRows) {
+ if errors.Is(err, actions.ErrNoActiveTimer) {
return ErrNoActiveTimer
}
- return fmt.Errorf("failed to stop timer: %w", err)
+ return err
}
- // Calculate duration
- duration := stoppedEntry.EndTime.Time.Sub(stoppedEntry.StartTime)
-
// Output success message
- cmd.Printf("Timer stopped. Session duration: %v\n", duration.Round(time.Second))
-
- // Show entry ID for reference
- cmd.Printf("Time entry ID: %d\n", stoppedEntry.ID)
+ cmd.Printf("Timer stopped. Session duration: %v\n", session.Duration.Round(time.Second))
+ cmd.Printf("Time entry ID: %d\n", session.ID)
return nil
},
diff --git a/internal/commands/root.go b/internal/commands/root.go
index 553d0b4..fda3d06 100644
--- a/internal/commands/root.go
+++ b/internal/commands/root.go
@@ -25,6 +25,7 @@ func NewRootCmd() *cobra.Command {
cmd.AddCommand(NewImportCmd())
cmd.AddCommand(NewReportCmd())
cmd.AddCommand(NewSetCmd())
+ cmd.AddCommand(NewTUICmd())
return cmd
}
diff --git a/internal/commands/tui.go b/internal/commands/tui.go
new file mode 100644
index 0000000..529e937
--- /dev/null
+++ b/internal/commands/tui.go
@@ -0,0 +1,29 @@
+package commands
+
+import (
+ "fmt"
+
+ punchctx "punchcard/internal/context"
+ "punchcard/internal/tui"
+
+ "github.com/spf13/cobra"
+)
+
+func NewTUICmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "tui",
+ Short: "Start the terminal user interface",
+ Long: `Start an interactive terminal user interface for time tracking with real-time updates.`,
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ q := punchctx.GetDB(cmd.Context())
+ if q == nil {
+ return fmt.Errorf("database not available in context")
+ }
+
+ return tui.Run(cmd.Context(), q)
+ },
+ }
+
+ return cmd
+}