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 | |
parent | dc895cec9d8a84af89ce2501db234dff33c757e2 (diff) |
WIP TUI
Diffstat (limited to 'internal/actions')
-rw-r--r-- | internal/actions/actions.go | 34 | ||||
-rw-r--r-- | internal/actions/clients.go | 92 | ||||
-rw-r--r-- | internal/actions/projects.go | 64 | ||||
-rw-r--r-- | internal/actions/timer.go | 342 | ||||
-rw-r--r-- | internal/actions/types.go | 31 |
5 files changed, 563 insertions, 0 deletions
diff --git a/internal/actions/actions.go b/internal/actions/actions.go new file mode 100644 index 0000000..5e2610a --- /dev/null +++ b/internal/actions/actions.go @@ -0,0 +1,34 @@ +package actions + +import ( + "context" + "punchcard/internal/queries" +) + +// Actions provides high-level business operations for time tracking +type Actions interface { + // Timer operations + PunchIn(ctx context.Context, client, project, description string, billableRate *float64) (*TimerSession, error) + PunchInMostRecent(ctx context.Context, description string, billableRate *float64) (*TimerSession, error) + PunchOut(ctx context.Context) (*TimerSession, error) + + // Client operations + CreateClient(ctx context.Context, name, email string, billableRate *float64) (*queries.Client, error) + FindClient(ctx context.Context, nameOrID string) (*queries.Client, error) + + // Project operations + CreateProject(ctx context.Context, name, client string, billableRate *float64) (*queries.Project, error) + FindProject(ctx context.Context, nameOrID string) (*queries.Project, error) +} + +// New creates a new Actions instance +func New(q *queries.Queries) Actions { + return &actionsImpl{ + queries: q, + } +} + +// actionsImpl implements the Actions interface +type actionsImpl struct { + queries *queries.Queries +}
\ No newline at end of file diff --git a/internal/actions/clients.go b/internal/actions/clients.go new file mode 100644 index 0000000..bc77139 --- /dev/null +++ b/internal/actions/clients.go @@ -0,0 +1,92 @@ +package actions + +import ( + "context" + "database/sql" + "fmt" + "regexp" + "strconv" + "strings" + + "punchcard/internal/queries" +) + +// CreateClient creates a new client with the given name and optional email/rate +func (a *actionsImpl) CreateClient(ctx context.Context, name, email string, billableRate *float64) (*queries.Client, error) { + // Parse name and email if name contains email format "Name <email>" + finalName, finalEmail := parseNameAndEmail(name, email) + + var emailParam sql.NullString + if finalEmail != "" { + emailParam = sql.NullString{String: finalEmail, 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} + } + + client, err := a.queries.CreateClient(ctx, queries.CreateClientParams{ + Name: finalName, + Email: emailParam, + BillableRate: billableRateParam, + }) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + return &client, nil +} + +// FindClient finds a client by name or ID +func (a *actionsImpl) FindClient(ctx context.Context, nameOrID string) (*queries.Client, error) { + // Parse as ID if possible, otherwise use 0 + var idParam int64 + if id, err := strconv.ParseInt(nameOrID, 10, 64); err == nil { + idParam = id + } + + // Search by both ID and name + clients, err := a.queries.FindClient(ctx, queries.FindClientParams{ + ID: idParam, + Name: nameOrID, + }) + if err != nil { + return nil, fmt.Errorf("database error looking up client: %w", err) + } + + // Check results + switch len(clients) { + case 0: + return nil, fmt.Errorf("%w: %s", ErrClientNotFound, nameOrID) + case 1: + return &clients[0], nil + default: + return nil, fmt.Errorf("%w: %s matches multiple clients", ErrAmbiguousClient, nameOrID) + } +} + +// parseNameAndEmail handles parsing name and email from various input formats +func parseNameAndEmail(nameArg, emailArg string) (string, string) { + // If separate email provided, use it (but still check for embedded format) + finalEmail := emailArg + if finalEmail != "" { + if matches := emailAndNameRegex.FindStringSubmatch(finalEmail); matches != nil { + finalEmail = strings.TrimSpace(matches[2]) + } + } + + // Check if name contains embedded email format "Name <email@domain.com>" + finalName := nameArg + if matches := emailAndNameRegex.FindStringSubmatch(nameArg); matches != nil { + finalName = strings.TrimSpace(matches[1]) + if finalEmail == "" { + finalEmail = strings.TrimSpace(matches[2]) + } + } + + return finalName, finalEmail +} + +var emailAndNameRegex = regexp.MustCompile(`^(.+?)<([^>]+@[^>]+)>$`)
\ No newline at end of file diff --git a/internal/actions/projects.go b/internal/actions/projects.go new file mode 100644 index 0000000..f991728 --- /dev/null +++ b/internal/actions/projects.go @@ -0,0 +1,64 @@ +package actions + +import ( + "context" + "database/sql" + "fmt" + "strconv" + + "punchcard/internal/queries" +) + +// CreateProject creates a new project for the specified client +func (a *actionsImpl) CreateProject(ctx context.Context, name, client string, billableRate *float64) (*queries.Project, error) { + // Find the client first + clientRecord, err := a.FindClient(ctx, client) + if err != nil { + return nil, fmt.Errorf("invalid client: %w", err) + } + + var billableRateParam sql.NullInt64 + if billableRate != nil && *billableRate > 0 { + rate := int64(*billableRate * 100) // Convert dollars to cents + billableRateParam = sql.NullInt64{Int64: rate, Valid: true} + } + + project, err := a.queries.CreateProject(ctx, queries.CreateProjectParams{ + Name: name, + ClientID: clientRecord.ID, + BillableRate: billableRateParam, + }) + if err != nil { + return nil, fmt.Errorf("failed to create project: %w", err) + } + + return &project, nil +} + +// FindProject finds a project by name or ID +func (a *actionsImpl) FindProject(ctx context.Context, nameOrID string) (*queries.Project, error) { + // Parse as ID if possible, otherwise use 0 + var idParam int64 + if id, err := strconv.ParseInt(nameOrID, 10, 64); err == nil { + idParam = id + } + + // Search by both ID and name + projects, err := a.queries.FindProject(ctx, queries.FindProjectParams{ + ID: idParam, + Name: nameOrID, + }) + if err != nil { + return nil, fmt.Errorf("database error looking up project: %w", err) + } + + // Check results + switch len(projects) { + case 0: + return nil, fmt.Errorf("%w: %s", ErrProjectNotFound, nameOrID) + case 1: + return &projects[0], nil + default: + return nil, fmt.Errorf("%w: %s matches multiple projects", ErrAmbiguousProject, nameOrID) + } +}
\ No newline at end of file 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 diff --git a/internal/actions/types.go b/internal/actions/types.go new file mode 100644 index 0000000..899583b --- /dev/null +++ b/internal/actions/types.go @@ -0,0 +1,31 @@ +package actions + +import ( + "errors" + "time" +) + +// Common errors +var ( + ErrNoActiveTimer = errors.New("no active timer found") + ErrClientRequired = errors.New("client is required") + ErrClientNotFound = errors.New("client not found") + ErrProjectNotFound = errors.New("project not found") + ErrAmbiguousClient = errors.New("ambiguous client reference") + ErrAmbiguousProject = errors.New("ambiguous project reference") + ErrProjectClientMismatch = errors.New("project does not belong to specified client") + ErrNoRecentEntries = errors.New("no previous time entries found") +) + +// TimerSession represents an active or completed time tracking session +type TimerSession struct { + ID int64 + ClientName string + ProjectName string // empty if no project + Description string // empty if no description + StartTime time.Time + EndTime *time.Time // nil if still active + Duration time.Duration + WasNoOp bool // true if timer was already active with same parameters + StoppedEntryID *int64 // ID of previously stopped entry (if any) +}
\ No newline at end of file |