summaryrefslogtreecommitdiff
path: root/internal/actions
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-05 11:37:02 -0600
committerT <t@tjp.lol>2025-08-05 11:37:08 -0600
commit665bd389a0a1c8adadcaa1122e846cc81f5ead31 (patch)
treef34f9ec77891308c600c680683f60951599429c3 /internal/actions
parentdc895cec9d8a84af89ce2501db234dff33c757e2 (diff)
WIP TUI
Diffstat (limited to 'internal/actions')
-rw-r--r--internal/actions/actions.go34
-rw-r--r--internal/actions/clients.go92
-rw-r--r--internal/actions/projects.go64
-rw-r--r--internal/actions/timer.go342
-rw-r--r--internal/actions/types.go31
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