summaryrefslogtreecommitdiff
path: root/internal/commands/in.go
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-02 17:25:59 -0600
committerT <t@tjp.lol>2025-08-04 09:34:14 -0600
commit8be5f93f5b2d4b6f438ca84094937a0f7101c59b (patch)
tree3cedb6379818a28179e269477c12ae06dd57ca36 /internal/commands/in.go
Initial commit of punchcard.
Contains working time tracking commands, and the stub of a command to generate reports.
Diffstat (limited to 'internal/commands/in.go')
-rw-r--r--internal/commands/in.go236
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
+}