summaryrefslogtreecommitdiff
path: root/internal/commands/in.go
blob: abb57f1b61a570057485e174463508fe247e3e00 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
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
}