summaryrefslogtreecommitdiff
path: root/internal/actions/timer.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/actions/timer.go')
-rw-r--r--internal/actions/timer.go342
1 files changed, 342 insertions, 0 deletions
diff --git a/internal/actions/timer.go b/internal/actions/timer.go
new file mode 100644
index 0000000..58dbba2
--- /dev/null
+++ b/internal/actions/timer.go
@@ -0,0 +1,342 @@
+package actions
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+
+ "punchcard/internal/queries"
+)
+
+// PunchIn starts a timer for the specified client/project
+// Use empty strings for client/project to use most recent entry
+func (a *actionsImpl) PunchIn(ctx context.Context, client, project, description string, billableRate *float64) (*TimerSession, error) {
+ // If no client specified, delegate to PunchInMostRecent
+ if client == "" && project == "" {
+ session, err := a.PunchInMostRecent(ctx, description, billableRate)
+ if err != nil {
+ // Convert "no recent entries" error to "client required" for better UX
+ if errors.Is(err, ErrNoRecentEntries) {
+ return nil, ErrClientRequired
+ }
+ return nil, err
+ }
+ return session, nil
+ }
+
+ // Check if there's already an active timer
+ activeEntry, err := a.queries.GetActiveTimeEntry(ctx)
+ var hasActiveTimer bool
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return nil, fmt.Errorf("failed to check for active timer: %w", err)
+ }
+ hasActiveTimer = (err == nil)
+
+ // Resolve project first (if provided) to get its client
+ var projectID sql.NullInt64
+ var resolvedProject *queries.Project
+ if project != "" {
+ proj, err := a.FindProject(ctx, project)
+ if err != nil {
+ return nil, fmt.Errorf("invalid project: %w", err)
+ }
+ resolvedProject = proj
+ projectID = sql.NullInt64{Int64: proj.ID, Valid: true}
+ }
+
+ // Resolve client
+ var clientID int64
+ var resolvedClient *queries.Client
+ if client != "" {
+ c, err := a.FindClient(ctx, client)
+ if err != nil {
+ return nil, fmt.Errorf("invalid client: %w", err)
+ }
+ resolvedClient = c
+ clientID = c.ID
+
+ // Verify project belongs to client if both specified
+ if resolvedProject != nil && resolvedProject.ClientID != clientID {
+ return nil, fmt.Errorf("%w: project %q does not belong to client %q",
+ ErrProjectClientMismatch, project, client)
+ }
+ } else if resolvedProject != nil {
+ // Use project's client
+ clientID = resolvedProject.ClientID
+ c, err := a.FindClient(ctx, fmt.Sprintf("%d", clientID))
+ if err != nil {
+ return nil, fmt.Errorf("failed to get client for project: %w", err)
+ }
+ resolvedClient = c
+ } else {
+ return nil, ErrClientRequired
+ }
+
+ var stoppedEntryID *int64
+
+ // Check for identical timer if one is active
+ if hasActiveTimer {
+ if timeEntriesMatch(clientID, projectID, description, billableRate, activeEntry) {
+ // No-op: identical timer already active
+ return &TimerSession{
+ ID: activeEntry.ID,
+ ClientName: resolvedClient.Name,
+ ProjectName: getProjectName(resolvedProject),
+ Description: description,
+ StartTime: activeEntry.StartTime,
+ EndTime: nil,
+ Duration: 0,
+ WasNoOp: true,
+ }, nil
+ }
+
+ // Stop the active timer before starting new one
+ stoppedEntry, err := a.queries.StopTimeEntry(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to stop active timer: %w", err)
+ }
+ stoppedEntryID = &stoppedEntry.ID
+ }
+
+ // Create the time entry
+ timeEntry, err := a.createTimeEntry(ctx, clientID, projectID, description, billableRate)
+ if err != nil {
+ return nil, err
+ }
+
+ return &TimerSession{
+ ID: timeEntry.ID,
+ ClientName: resolvedClient.Name,
+ ProjectName: getProjectName(resolvedProject),
+ Description: description,
+ StartTime: timeEntry.StartTime,
+ EndTime: nil,
+ Duration: 0,
+ WasNoOp: false,
+ StoppedEntryID: stoppedEntryID,
+ }, nil
+}
+
+// PunchInMostRecent starts a timer copying the most recent time entry
+func (a *actionsImpl) PunchInMostRecent(ctx context.Context, description string, billableRate *float64) (*TimerSession, error) {
+ // Get most recent entry
+ mostRecent, err := a.queries.GetMostRecentTimeEntry(ctx)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNoRecentEntries
+ }
+ return nil, fmt.Errorf("failed to get most recent entry: %w", err)
+ }
+
+ // Use description from recent entry if none provided
+ finalDescription := description
+ if finalDescription == "" && mostRecent.Description.Valid {
+ finalDescription = mostRecent.Description.String
+ }
+
+ // Check if there's already an active timer
+ activeEntry, err := a.queries.GetActiveTimeEntry(ctx)
+ var hasActiveTimer bool
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return nil, fmt.Errorf("failed to check for active timer: %w", err)
+ }
+ hasActiveTimer = (err == nil)
+
+ var stoppedEntryID *int64
+
+ // Check for identical timer if one is active
+ if hasActiveTimer {
+ if timeEntriesMatch(mostRecent.ClientID, mostRecent.ProjectID, finalDescription, billableRate, activeEntry) {
+ // Get client/project names for the result
+ client, _ := a.FindClient(ctx, fmt.Sprintf("%d", mostRecent.ClientID))
+ clientName := ""
+ if client != nil {
+ clientName = client.Name
+ }
+
+ var projectName string
+ if mostRecent.ProjectID.Valid {
+ project, _ := a.FindProject(ctx, fmt.Sprintf("%d", mostRecent.ProjectID.Int64))
+ if project != nil {
+ projectName = project.Name
+ }
+ }
+
+ // No-op: identical timer already active
+ return &TimerSession{
+ ID: activeEntry.ID,
+ ClientName: clientName,
+ ProjectName: projectName,
+ Description: finalDescription,
+ StartTime: activeEntry.StartTime,
+ EndTime: nil,
+ Duration: 0,
+ WasNoOp: true,
+ }, nil
+ }
+
+ // Stop the active timer before starting new one
+ stoppedEntry, err := a.queries.StopTimeEntry(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to stop active timer: %w", err)
+ }
+ stoppedEntryID = &stoppedEntry.ID
+ }
+
+ // Create new entry copying from most recent
+ timeEntry, err := a.createTimeEntry(ctx, mostRecent.ClientID, mostRecent.ProjectID, finalDescription, billableRate)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get client name
+ client, err := a.FindClient(ctx, fmt.Sprintf("%d", mostRecent.ClientID))
+ if err != nil {
+ return nil, fmt.Errorf("failed to get client name: %w", err)
+ }
+
+ // Get project name if exists
+ var projectName string
+ if mostRecent.ProjectID.Valid {
+ project, err := a.FindProject(ctx, fmt.Sprintf("%d", mostRecent.ProjectID.Int64))
+ if err == nil {
+ projectName = project.Name
+ }
+ }
+
+ return &TimerSession{
+ ID: timeEntry.ID,
+ ClientName: client.Name,
+ ProjectName: projectName,
+ Description: finalDescription,
+ StartTime: timeEntry.StartTime,
+ EndTime: nil,
+ Duration: 0,
+ WasNoOp: false,
+ StoppedEntryID: stoppedEntryID,
+ }, nil
+}
+
+// PunchOut stops the active timer
+func (a *actionsImpl) PunchOut(ctx context.Context) (*TimerSession, error) {
+ stoppedEntry, err := a.queries.StopTimeEntry(ctx)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNoActiveTimer
+ }
+ return nil, fmt.Errorf("failed to stop timer: %w", err)
+ }
+
+ duration := stoppedEntry.EndTime.Time.Sub(stoppedEntry.StartTime)
+ endTime := stoppedEntry.EndTime.Time
+
+ // Get client name
+ client, err := a.FindClient(ctx, fmt.Sprintf("%d", stoppedEntry.ClientID))
+ if err != nil {
+ return nil, fmt.Errorf("failed to get client name: %w", err)
+ }
+
+ // Get project name if exists
+ var projectName string
+ if stoppedEntry.ProjectID.Valid {
+ project, err := a.FindProject(ctx, fmt.Sprintf("%d", stoppedEntry.ProjectID.Int64))
+ if err == nil {
+ projectName = project.Name
+ }
+ }
+
+ description := ""
+ if stoppedEntry.Description.Valid {
+ description = stoppedEntry.Description.String
+ }
+
+ return &TimerSession{
+ ID: stoppedEntry.ID,
+ ClientName: client.Name,
+ ProjectName: projectName,
+ Description: description,
+ StartTime: stoppedEntry.StartTime,
+ EndTime: &endTime,
+ Duration: duration,
+ }, nil
+}
+
+// Helper functions
+
+func (a *actionsImpl) createTimeEntry(ctx context.Context, clientID int64, projectID sql.NullInt64, description string, billableRate *float64) (*queries.TimeEntry, error) {
+ var descParam sql.NullString
+ if description != "" {
+ descParam = sql.NullString{String: description, Valid: true}
+ }
+
+ var billableRateParam sql.NullInt64
+ if billableRate != nil && *billableRate > 0 {
+ rate := int64(*billableRate * 100) // Convert dollars to cents
+ billableRateParam = sql.NullInt64{Int64: rate, Valid: true}
+ }
+
+ timeEntry, err := a.queries.CreateTimeEntry(ctx, queries.CreateTimeEntryParams{
+ Description: descParam,
+ ClientID: clientID,
+ ProjectID: projectID,
+ BillableRate: billableRateParam,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &timeEntry, nil
+}
+
+func getProjectName(project *queries.Project) string {
+ if project == nil {
+ return ""
+ }
+ return project.Name
+}
+
+// timeEntriesMatch checks if a new time entry would be identical to an active one
+// by comparing client ID, project ID, description, and billable rate
+func timeEntriesMatch(clientID int64, projectID sql.NullInt64, description string, billableRate *float64, 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
+ }
+ }
+
+ // Check billable rate matching
+ if billableRate != nil {
+ // New entry has explicit rate
+ expectedRate := int64(*billableRate * 100) // Convert to cents
+ if !activeEntry.BillableRate.Valid || activeEntry.BillableRate.Int64 != expectedRate {
+ return false
+ }
+ }
+ // New entry has no explicit rate - for simplicity, we consider them matching
+ // regardless of what coalesced rate the active entry might have
+
+ return true
+} \ No newline at end of file