diff options
Diffstat (limited to 'internal/commands/in.go')
-rw-r--r-- | internal/commands/in.go | 236 |
1 files changed, 236 insertions, 0 deletions
diff --git a/internal/commands/in.go b/internal/commands/in.go new file mode 100644 index 0000000..abb57f1 --- /dev/null +++ b/internal/commands/in.go @@ -0,0 +1,236 @@ +package commands + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "time" + + punchctx "punchcard/internal/context" + "punchcard/internal/queries" + + "github.com/spf13/cobra" +) + +func NewInCmd() *cobra.Command { + var clientFlag, projectFlag string + + cmd := &cobra.Command{ + Use: "in [<description>]", + Aliases: []string{"i"}, + Short: "Start a timer", + Long: `Start tracking time for the current work session. + +If no flags are provided, copies the most recent time entry. +If -p/--project is provided without -c/--client, uses the project's client. +If a timer is already active: + - Same parameters: no-op + - Different parameters: stops current timer and starts new one + +Examples: + punch in # Copy most recent entry + punch in "Working on website redesign" # Copy most recent but change description + punch in -c "Acme Corp" "Client meeting" # Specific client + punch in -p "Website Redesign" "Frontend development" # Project (client auto-selected) + punch in --client 1 --project "Website Redesign" # Explicit client and project`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var description string + if len(args) > 0 { + description = args[0] + } + + billableRateFloat, _ := cmd.Flags().GetFloat64("hourly-rate") + billableRate := int64(billableRateFloat * 100) // Convert dollars to cents + + 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} + } + + // 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 + } + } else { + return fmt.Errorf("client is required: use -c/--client flag to specify client") + } + + 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)) + } + + // Create time entry + var descParam sql.NullString + if description != "" { + descParam = sql.NullString{String: description, Valid: true} + } + + 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) + } + + // 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) + + // 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) + } + + // Add description if provided + if description != "" { + output += fmt.Sprintf(", description: %s", description) + } + + cmd.Print(output + "\n") + return nil + }, + } + + cmd.Flags().StringVarP(&clientFlag, "client", "c", "", "Client name or ID") + cmd.Flags().StringVarP(&projectFlag, "project", "p", "", "Project name or ID") + cmd.Flags().Float64("hourly-rate", 0, "Override hourly billable rate for this time entry") + + 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 +} |