package actions import ( "context" "database/sql" "errors" "fmt" "git.tjp.lol/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 *actions) PunchIn(ctx context.Context, client, project, description string, billableRate *float64, autoUnarchive bool) (*TimerSession, error) { // If no client specified, delegate to PunchInMostRecent if client == "" && project == "" { session, err := a.PunchInMostRecent(ctx, description, billableRate, autoUnarchive) 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 } // Check if client is archived if resolvedClient.Archived != 0 { if !autoUnarchive { return nil, ErrArchivedClient } // Auto-unarchive the client if err := a.UnarchiveClient(ctx, resolvedClient.ID); err != nil { return nil, fmt.Errorf("failed to unarchive client: %w", err) } } // Check if project is archived if resolvedProject != nil && resolvedProject.Archived != 0 { if !autoUnarchive { return nil, ErrArchivedProject } // Auto-unarchive the project if err := a.UnarchiveProject(ctx, resolvedProject.ID); err != nil { return nil, fmt.Errorf("failed to unarchive project: %w", err) } } 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 *actions) PunchInMostRecent(ctx context.Context, description string, billableRate *float64, autoUnarchive bool) (*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 } // Get client to check if archived client, err := a.FindClient(ctx, fmt.Sprintf("%d", mostRecent.ClientID)) if err != nil { return nil, fmt.Errorf("failed to get client: %w", err) } // Check if client is archived if client.Archived != 0 { if !autoUnarchive { return nil, ErrArchivedClient } // Auto-unarchive the client if err := a.UnarchiveClient(ctx, client.ID); err != nil { return nil, fmt.Errorf("failed to unarchive client: %w", err) } } // Check if project is archived (if exists) if mostRecent.ProjectID.Valid { project, err := a.FindProject(ctx, fmt.Sprintf("%d", mostRecent.ProjectID.Int64)) if err == nil && project.Archived != 0 { if !autoUnarchive { return nil, ErrArchivedProject } // Auto-unarchive the project if err := a.UnarchiveProject(ctx, project.ID); err != nil { return nil, fmt.Errorf("failed to unarchive project: %w", err) } } } // 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) { 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: client.Name, 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 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 *actions) 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 } func (a *actions) EditEntry(ctx context.Context, entry queries.TimeEntry) error { return a.queries.EditTimeEntry(ctx, queries.EditTimeEntryParams{ StartTime: entry.StartTime, EndTime: entry.EndTime, Description: entry.Description, ClientID: entry.ClientID, ProjectID: entry.ProjectID, HourlyRate: entry.BillableRate, EntryID: entry.ID, }) } // Helper functions func (a *actions) 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 }