diff options
author | T <t@tjp.lol> | 2025-08-05 11:37:02 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-05 11:37:08 -0600 |
commit | 665bd389a0a1c8adadcaa1122e846cc81f5ead31 (patch) | |
tree | f34f9ec77891308c600c680683f60951599429c3 /internal/commands | |
parent | dc895cec9d8a84af89ce2501db234dff33c757e2 (diff) |
WIP TUI
Diffstat (limited to 'internal/commands')
-rw-r--r-- | internal/commands/add_client.go | 60 | ||||
-rw-r--r-- | internal/commands/add_project.go | 58 | ||||
-rw-r--r-- | internal/commands/helpers.go | 32 | ||||
-rw-r--r-- | internal/commands/in.go | 193 | ||||
-rw-r--r-- | internal/commands/in_test.go | 2 | ||||
-rw-r--r-- | internal/commands/out.go | 21 | ||||
-rw-r--r-- | internal/commands/root.go | 1 | ||||
-rw-r--r-- | internal/commands/tui.go | 29 |
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 +} |