diff options
author | T <t@tjp.lol> | 2025-08-05 11:37:02 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-05 11:37:08 -0600 |
commit | 665bd389a0a1c8adadcaa1122e846cc81f5ead31 (patch) | |
tree | f34f9ec77891308c600c680683f60951599429c3 /internal/actions/timer.go | |
parent | dc895cec9d8a84af89ce2501db234dff33c757e2 (diff) |
WIP TUI
Diffstat (limited to 'internal/actions/timer.go')
-rw-r--r-- | internal/actions/timer.go | 342 |
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 |