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 []", 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 }