diff options
author | T <t@tjp.lol> | 2025-09-29 15:04:44 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-09-30 11:40:45 -0600 |
commit | 7ba68d333bc20b5795ccfd3870546a05eee60470 (patch) | |
tree | 12dc4b017803b7d01844fd42b9e3be281cbbd986 /internal | |
parent | bce8dbb58165e443902d9dae3909225ef42630c4 (diff) |
Diffstat (limited to 'internal')
-rw-r--r-- | internal/actions/actions.go | 8 | ||||
-rw-r--r-- | internal/actions/archive_test.go | 618 | ||||
-rw-r--r-- | internal/actions/clients.go | 16 | ||||
-rw-r--r-- | internal/actions/projects.go | 16 | ||||
-rw-r--r-- | internal/actions/timer.go | 74 | ||||
-rw-r--r-- | internal/actions/types.go | 2 | ||||
-rw-r--r-- | internal/commands/archive.go | 141 | ||||
-rw-r--r-- | internal/commands/archive_test.go | 517 | ||||
-rw-r--r-- | internal/commands/in.go | 38 | ||||
-rw-r--r-- | internal/commands/root.go | 2 | ||||
-rw-r--r-- | internal/database/queries.sql | 28 | ||||
-rw-r--r-- | internal/database/schema.sql | 2 | ||||
-rw-r--r-- | internal/queries/db.go | 2 | ||||
-rw-r--r-- | internal/queries/models.go | 4 | ||||
-rw-r--r-- | internal/queries/queries.sql.go | 81 | ||||
-rw-r--r-- | internal/reports/api.go | 31 | ||||
-rw-r--r-- | internal/reports/archive_reports_test.go | 612 | ||||
-rw-r--r-- | internal/tui/app.go | 78 | ||||
-rw-r--r-- | internal/tui/commands.go | 141 | ||||
-rw-r--r-- | internal/tui/history_box.go | 2 | ||||
-rw-r--r-- | internal/tui/keys.go | 74 | ||||
-rw-r--r-- | internal/tui/modal.go | 106 | ||||
-rw-r--r-- | internal/tui/projects_box.go | 210 | ||||
-rw-r--r-- | internal/tui/shared.go | 1 | ||||
-rw-r--r-- | internal/tui/shared_test.go | 2 |
25 files changed, 2667 insertions, 139 deletions
diff --git a/internal/actions/actions.go b/internal/actions/actions.go index 7f707d3..b421047 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -9,8 +9,8 @@ import ( // 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) + PunchIn(ctx context.Context, client, project, description string, billableRate *float64, autoUnarchive bool) (*TimerSession, error) + PunchInMostRecent(ctx context.Context, description string, billableRate *float64, autoUnarchive bool) (*TimerSession, error) PunchOut(ctx context.Context) (*TimerSession, error) EditEntry(ctx context.Context, entry queries.TimeEntry) error @@ -18,11 +18,15 @@ type Actions interface { CreateClient(ctx context.Context, name, email string, billableRate *float64) (*queries.Client, error) EditClient(ctx context.Context, id int64, name, email string, billableRate *float64) (*queries.Client, error) FindClient(ctx context.Context, nameOrID string) (*queries.Client, error) + ArchiveClient(ctx context.Context, id int64) error + UnarchiveClient(ctx context.Context, id int64) error // Project operations CreateProject(ctx context.Context, name, client string, billableRate *float64) (*queries.Project, error) EditProject(ctx context.Context, id int64, name string, billableRate *float64) (*queries.Project, error) FindProject(ctx context.Context, nameOrID string) (*queries.Project, error) + ArchiveProject(ctx context.Context, id int64) error + UnarchiveProject(ctx context.Context, id int64) error } // New creates a new Actions instance diff --git a/internal/actions/archive_test.go b/internal/actions/archive_test.go new file mode 100644 index 0000000..d205703 --- /dev/null +++ b/internal/actions/archive_test.go @@ -0,0 +1,618 @@ +package actions + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "git.tjp.lol/punchcard/internal/database" + "git.tjp.lol/punchcard/internal/queries" +) + +func setupTestDB(t *testing.T) (*queries.Queries, func()) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("Failed to open in-memory sqlite db: %v", err) + } + if err := database.InitializeDB(db); err != nil { + t.Fatalf("Failed to initialize in-memory sqlite db: %v", err) + } + q := queries.New(db) + + cleanup := func() { + if err := q.DBTX().(*sql.DB).Close(); err != nil { + t.Logf("error closing database: %v", err) + } + } + + return q, cleanup +} + +func TestArchiveClient(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) int64 + expectError bool + }{ + { + name: "archive existing client", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + return client.ID + }, + expectError: false, + }, + { + name: "archive already archived client", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "AlreadyArchived", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + a := New(q) + clientID := tt.setupData(q) + + err := a.ArchiveClient(context.Background(), clientID) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !tt.expectError { + client, err := a.FindClient(context.Background(), fmt.Sprintf("%d", clientID)) + if err != nil { + t.Fatalf("Failed to find client: %v", err) + } + if client.Archived == 0 { + t.Errorf("Expected client to be archived") + } + } + }) + } +} + +func TestUnarchiveClient(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) int64 + expectError bool + }{ + { + name: "unarchive archived client", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedClient", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID + }, + expectError: false, + }, + { + name: "unarchive already active client", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ActiveClient", + }) + return client.ID + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + a := New(q) + clientID := tt.setupData(q) + + err := a.UnarchiveClient(context.Background(), clientID) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !tt.expectError { + client, err := a.FindClient(context.Background(), fmt.Sprintf("%d", clientID)) + if err != nil { + t.Fatalf("Failed to find client: %v", err) + } + if client.Archived != 0 { + t.Errorf("Expected client to not be archived") + } + } + }) + } +} + +func TestArchiveProject(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) int64 + expectError bool + }{ + { + name: "archive existing project", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + return project.ID + }, + expectError: false, + }, + { + name: "archive already archived project", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "AlreadyArchived", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return project.ID + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + a := New(q) + projectID := tt.setupData(q) + + err := a.ArchiveProject(context.Background(), projectID) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !tt.expectError { + project, err := a.FindProject(context.Background(), fmt.Sprintf("%d", projectID)) + if err != nil { + t.Fatalf("Failed to find project: %v", err) + } + if project.Archived == 0 { + t.Errorf("Expected project to be archived") + } + } + }) + } +} + +func TestUnarchiveProject(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) int64 + expectError bool + }{ + { + name: "unarchive archived project", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedProject", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return project.ID + }, + expectError: false, + }, + { + name: "unarchive already active project", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ActiveProject", + ClientID: client.ID, + }) + return project.ID + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + a := New(q) + projectID := tt.setupData(q) + + err := a.UnarchiveProject(context.Background(), projectID) + + if tt.expectError && err == nil { + t.Errorf("Expected error but got none") + } + if !tt.expectError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !tt.expectError { + project, err := a.FindProject(context.Background(), fmt.Sprintf("%d", projectID)) + if err != nil { + t.Fatalf("Failed to find project: %v", err) + } + if project.Archived != 0 { + t.Errorf("Expected project to not be archived") + } + } + }) + } +} + +func TestPunchInWithArchivedClient(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (clientName string) + autoUnarchive bool + expectError bool + errorType error + }{ + { + name: "punch in on archived client without auto-unarchive returns error", + setupData: func(q *queries.Queries) string { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedClient", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.Name + }, + autoUnarchive: false, + expectError: true, + errorType: ErrArchivedClient, + }, + { + name: "punch in on archived client with auto-unarchive succeeds", + setupData: func(q *queries.Queries) string { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedClient", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.Name + }, + autoUnarchive: true, + expectError: false, + }, + { + name: "punch in on active client succeeds", + setupData: func(q *queries.Queries) string { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ActiveClient", + }) + return client.Name + }, + autoUnarchive: false, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + a := New(q) + clientName := tt.setupData(q) + + session, err := a.PunchIn(context.Background(), clientName, "", "", nil, tt.autoUnarchive) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if tt.errorType != nil && err != tt.errorType { + t.Errorf("Expected error %v, got %v", tt.errorType, err) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if session == nil { + t.Fatalf("Expected session but got nil") + } + + if tt.autoUnarchive { + client, err := a.FindClient(context.Background(), clientName) + if err != nil { + t.Fatalf("Failed to find client: %v", err) + } + if client.Archived != 0 { + t.Errorf("Expected client to be unarchived after auto-unarchive") + } + } + }) + } +} + +func TestPunchInWithArchivedProject(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (projectName string) + autoUnarchive bool + expectError bool + errorType error + }{ + { + name: "punch in on archived project without auto-unarchive returns error", + setupData: func(q *queries.Queries) string { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedProject", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return project.Name + }, + autoUnarchive: false, + expectError: true, + errorType: ErrArchivedProject, + }, + { + name: "punch in on archived project with auto-unarchive succeeds", + setupData: func(q *queries.Queries) string { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedProject", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return project.Name + }, + autoUnarchive: true, + expectError: false, + }, + { + name: "punch in on active project succeeds", + setupData: func(q *queries.Queries) string { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ActiveProject", + ClientID: client.ID, + }) + return project.Name + }, + autoUnarchive: false, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + a := New(q) + projectName := tt.setupData(q) + + session, err := a.PunchIn(context.Background(), "", projectName, "", nil, tt.autoUnarchive) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if tt.errorType != nil && err != tt.errorType { + t.Errorf("Expected error %v, got %v", tt.errorType, err) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if session == nil { + t.Fatalf("Expected session but got nil") + } + + if tt.autoUnarchive { + project, err := a.FindProject(context.Background(), projectName) + if err != nil { + t.Fatalf("Failed to find project: %v", err) + } + if project.Archived != 0 { + t.Errorf("Expected project to be unarchived after auto-unarchive") + } + } + }) + } +} + +func TestPunchInMostRecentWithArchivedClient(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) + autoUnarchive bool + expectError bool + errorType error + }{ + { + name: "punch in most recent with archived client without auto-unarchive returns error", + setupData: func(q *queries.Queries) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedClient", + }) + _, _ = q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + ClientID: client.ID, + }) + _, _ = q.StopTimeEntry(context.Background()) + _ = q.ArchiveClient(context.Background(), client.ID) + }, + autoUnarchive: false, + expectError: true, + errorType: ErrArchivedClient, + }, + { + name: "punch in most recent with archived client with auto-unarchive succeeds", + setupData: func(q *queries.Queries) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedClient", + }) + _, _ = q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + ClientID: client.ID, + }) + _, _ = q.StopTimeEntry(context.Background()) + _ = q.ArchiveClient(context.Background(), client.ID) + }, + autoUnarchive: true, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + a := New(q) + tt.setupData(q) + + session, err := a.PunchInMostRecent(context.Background(), "", nil, tt.autoUnarchive) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if tt.errorType != nil && err != tt.errorType { + t.Errorf("Expected error %v, got %v", tt.errorType, err) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if session == nil { + t.Fatalf("Expected session but got nil") + } + }) + } +} + +func TestPunchInMostRecentWithArchivedProject(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) + autoUnarchive bool + expectError bool + errorType error + }{ + { + name: "punch in most recent with archived project without auto-unarchive returns error", + setupData: func(q *queries.Queries) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedProject", + ClientID: client.ID, + }) + _, _ = q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + ClientID: client.ID, + ProjectID: sql.NullInt64{Int64: project.ID, Valid: true}, + }) + _, _ = q.StopTimeEntry(context.Background()) + _ = q.ArchiveProject(context.Background(), project.ID) + }, + autoUnarchive: false, + expectError: true, + errorType: ErrArchivedProject, + }, + { + name: "punch in most recent with archived project with auto-unarchive succeeds", + setupData: func(q *queries.Queries) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedProject", + ClientID: client.ID, + }) + _, _ = q.CreateTimeEntry(context.Background(), queries.CreateTimeEntryParams{ + ClientID: client.ID, + ProjectID: sql.NullInt64{Int64: project.ID, Valid: true}, + }) + _, _ = q.StopTimeEntry(context.Background()) + _ = q.ArchiveProject(context.Background(), project.ID) + }, + autoUnarchive: true, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + a := New(q) + tt.setupData(q) + + session, err := a.PunchInMostRecent(context.Background(), "", nil, tt.autoUnarchive) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if tt.errorType != nil && err != tt.errorType { + t.Errorf("Expected error %v, got %v", tt.errorType, err) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if session == nil { + t.Fatalf("Expected session but got nil") + } + }) + } +} diff --git a/internal/actions/clients.go b/internal/actions/clients.go index 10e8e7d..78b7934 100644 --- a/internal/actions/clients.go +++ b/internal/actions/clients.go @@ -115,3 +115,19 @@ func parseNameAndEmail(nameArg, emailArg string) (string, string) { } var emailAndNameRegex = regexp.MustCompile(`^(.+?)<([^>]+@[^>]+)>$`) + +func (a *actions) ArchiveClient(ctx context.Context, id int64) error { + err := a.queries.ArchiveClient(ctx, id) + if err != nil { + return fmt.Errorf("failed to archive client: %w", err) + } + return nil +} + +func (a *actions) UnarchiveClient(ctx context.Context, id int64) error { + err := a.queries.UnarchiveClient(ctx, id) + if err != nil { + return fmt.Errorf("failed to unarchive client: %w", err) + } + return nil +} diff --git a/internal/actions/projects.go b/internal/actions/projects.go index d36780b..d085faa 100644 --- a/internal/actions/projects.go +++ b/internal/actions/projects.go @@ -79,3 +79,19 @@ func (a *actions) FindProject(ctx context.Context, nameOrID string) (*queries.Pr return nil, fmt.Errorf("%w: %s matches multiple projects", ErrAmbiguousProject, nameOrID) } } + +func (a *actions) ArchiveProject(ctx context.Context, id int64) error { + err := a.queries.ArchiveProject(ctx, id) + if err != nil { + return fmt.Errorf("failed to archive project: %w", err) + } + return nil +} + +func (a *actions) UnarchiveProject(ctx context.Context, id int64) error { + err := a.queries.UnarchiveProject(ctx, id) + if err != nil { + return fmt.Errorf("failed to unarchive project: %w", err) + } + return nil +} diff --git a/internal/actions/timer.go b/internal/actions/timer.go index a7e7bbb..d5a85e1 100644 --- a/internal/actions/timer.go +++ b/internal/actions/timer.go @@ -11,10 +11,10 @@ import ( // 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) (*TimerSession, error) { +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) + 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) { @@ -73,6 +73,28 @@ func (a *actions) PunchIn(ctx context.Context, client, project, description stri 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 @@ -119,7 +141,7 @@ func (a *actions) PunchIn(ctx context.Context, client, project, description stri } // PunchInMostRecent starts a timer copying the most recent time entry -func (a *actions) PunchInMostRecent(ctx context.Context, description string, billableRate *float64) (*TimerSession, error) { +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 { @@ -135,6 +157,37 @@ func (a *actions) PunchInMostRecent(ctx context.Context, description string, bil 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 @@ -148,13 +201,6 @@ func (a *actions) PunchInMostRecent(ctx context.Context, description string, bil // 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)) @@ -166,7 +212,7 @@ func (a *actions) PunchInMostRecent(ctx context.Context, description string, bil // No-op: identical timer already active return &TimerSession{ ID: activeEntry.ID, - ClientName: clientName, + ClientName: client.Name, ProjectName: projectName, Description: finalDescription, StartTime: activeEntry.StartTime, @@ -190,12 +236,6 @@ func (a *actions) PunchInMostRecent(ctx context.Context, description string, bil 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 { diff --git a/internal/actions/types.go b/internal/actions/types.go index 899583b..caf3dc5 100644 --- a/internal/actions/types.go +++ b/internal/actions/types.go @@ -15,6 +15,8 @@ var ( ErrAmbiguousProject = errors.New("ambiguous project reference") ErrProjectClientMismatch = errors.New("project does not belong to specified client") ErrNoRecentEntries = errors.New("no previous time entries found") + ErrArchivedClient = errors.New("client is archived") + ErrArchivedProject = errors.New("project is archived") ) // TimerSession represents an active or completed time tracking session diff --git a/internal/commands/archive.go b/internal/commands/archive.go new file mode 100644 index 0000000..d6c9977 --- /dev/null +++ b/internal/commands/archive.go @@ -0,0 +1,141 @@ +package commands + +import ( + "errors" + "fmt" + + "git.tjp.lol/punchcard/internal/actions" + punchctx "git.tjp.lol/punchcard/internal/context" + + "github.com/spf13/cobra" +) + +func NewArchiveCmd() *cobra.Command { + var clientFlag, projectFlag string + + cmd := &cobra.Command{ + Use: "archive", + Short: "Archive a client or project", + Long: `Archive a client or project to hide it from the default view. + +Archived clients and projects will not be shown in the "Clients & Projects" pane +by default, but can be toggled visible. All historical time entries remain accessible. + +Examples: + punch archive --client "Acme Corp" + punch archive --client 1 + punch archive --project "Website Redesign" + punch archive --project 5`, + RunE: func(cmd *cobra.Command, args []string) error { + if clientFlag == "" && projectFlag == "" { + return errors.New("either --client or --project must be specified") + } + + if clientFlag != "" && projectFlag != "" { + return errors.New("cannot specify both --client and --project") + } + + q := punchctx.GetDB(cmd.Context()) + if q == nil { + return fmt.Errorf("database not available in context") + } + + a := actions.New(q) + + if clientFlag != "" { + client, err := a.FindClient(cmd.Context(), clientFlag) + if err != nil { + return fmt.Errorf("failed to find client: %w", err) + } + + if err := a.ArchiveClient(cmd.Context(), client.ID); err != nil { + return fmt.Errorf("failed to archive client: %w", err) + } + + cmd.Printf("Archived client: %s\n", client.Name) + } else { + project, err := a.FindProject(cmd.Context(), projectFlag) + if err != nil { + return fmt.Errorf("failed to find project: %w", err) + } + + if err := a.ArchiveProject(cmd.Context(), project.ID); err != nil { + return fmt.Errorf("failed to archive project: %w", err) + } + + cmd.Printf("Archived project: %s\n", project.Name) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&clientFlag, "client", "c", "", "Client name or ID") + cmd.Flags().StringVarP(&projectFlag, "project", "p", "", "Project name or ID") + + return cmd +} + +func NewUnarchiveCmd() *cobra.Command { + var clientFlag, projectFlag string + + cmd := &cobra.Command{ + Use: "unarchive", + Short: "Unarchive a client or project", + Long: `Unarchive a client or project to show it in the default view again. + +Examples: + punch unarchive --client "Acme Corp" + punch unarchive --client 1 + punch unarchive --project "Website Redesign" + punch unarchive --project 5`, + RunE: func(cmd *cobra.Command, args []string) error { + if clientFlag == "" && projectFlag == "" { + return errors.New("either --client or --project must be specified") + } + + if clientFlag != "" && projectFlag != "" { + return errors.New("cannot specify both --client and --project") + } + + q := punchctx.GetDB(cmd.Context()) + if q == nil { + return fmt.Errorf("database not available in context") + } + + a := actions.New(q) + + if clientFlag != "" { + client, err := a.FindClient(cmd.Context(), clientFlag) + if err != nil { + return fmt.Errorf("failed to find client: %w", err) + } + + if err := a.UnarchiveClient(cmd.Context(), client.ID); err != nil { + return fmt.Errorf("failed to unarchive client: %w", err) + } + + cmd.Printf("Unarchived client: %s\n", client.Name) + } else { + project, err := a.FindProject(cmd.Context(), projectFlag) + if err != nil { + return fmt.Errorf("failed to find project: %w", err) + } + + if err := a.UnarchiveProject(cmd.Context(), project.ID); err != nil { + return fmt.Errorf("failed to unarchive project: %w", err) + } + + cmd.Printf("Unarchived project: %s\n", project.Name) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&clientFlag, "client", "c", "", "Client name or ID") + cmd.Flags().StringVarP(&projectFlag, "project", "p", "", "Project name or ID") + + return cmd +} + diff --git a/internal/commands/archive_test.go b/internal/commands/archive_test.go new file mode 100644 index 0000000..ae9ce1f --- /dev/null +++ b/internal/commands/archive_test.go @@ -0,0 +1,517 @@ +package commands + +import ( + "context" + "strings" + "testing" + + "git.tjp.lol/punchcard/internal/queries" +) + +func TestArchiveCommand(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (clientID, projectID int64) + args []string + expectedOutputs []string + expectError bool + errorContains string + }{ + { + name: "archive client by name", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + return client.ID, 0 + }, + args: []string{"archive", "--client", "TestClient"}, + expectedOutputs: []string{"Archived client: TestClient"}, + expectError: false, + }, + { + name: "archive client by ID", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ClientByID", + }) + return client.ID, 0 + }, + args: []string{"archive", "--client", "1"}, + expectedOutputs: []string{"Archived client: ClientByID"}, + expectError: false, + }, + { + name: "archive project by name", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + return client.ID, project.ID + }, + args: []string{"archive", "--project", "TestProject"}, + expectedOutputs: []string{"Archived project: TestProject"}, + expectError: false, + }, + { + name: "archive project by ID", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ProjectByID", + ClientID: client.ID, + }) + return client.ID, project.ID + }, + args: []string{"archive", "--project", "1"}, + expectedOutputs: []string{"Archived project: ProjectByID"}, + expectError: false, + }, + { + name: "archive without flags returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"archive"}, + expectError: true, + errorContains: "either --client or --project must be specified", + }, + { + name: "archive with both client and project flags returns error", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + return client.ID, project.ID + }, + args: []string{"archive", "--client", "TestClient", "--project", "TestProject"}, + expectError: true, + errorContains: "cannot specify both --client and --project", + }, + { + name: "archive nonexistent client returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"archive", "--client", "NonexistentClient"}, + expectError: true, + errorContains: "failed to find client", + }, + { + name: "archive nonexistent project returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"archive", "--project", "NonexistentProject"}, + expectError: true, + errorContains: "failed to find project", + }, + { + name: "archive already archived client succeeds", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "AlreadyArchived", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID, 0 + }, + args: []string{"archive", "--client", "AlreadyArchived"}, + expectedOutputs: []string{"Archived client: AlreadyArchived"}, + expectError: false, + }, + { + name: "archive client using short flag", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ShortFlagClient", + }) + return client.ID, 0 + }, + args: []string{"archive", "-c", "ShortFlagClient"}, + expectedOutputs: []string{"Archived client: ShortFlagClient"}, + expectError: false, + }, + { + name: "archive project using short flag", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ShortFlagProject", + ClientID: client.ID, + }) + return client.ID, project.ID + }, + args: []string{"archive", "-p", "ShortFlagProject"}, + expectedOutputs: []string{"Archived project: ShortFlagProject"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + tt.setupData(q) + + output, err := executeCommandWithDB(t, q, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error to contain %q, got %q", tt.errorContains, err.Error()) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + found := false + for _, expectedOutput := range tt.expectedOutputs { + if strings.Contains(output, expectedOutput) { + found = true + break + } + } + + if !found { + t.Errorf("Expected output to contain one of %v, got %q", tt.expectedOutputs, output) + } + }) + } +} + +func TestUnarchiveCommand(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (clientID, projectID int64) + args []string + expectedOutputs []string + expectError bool + errorContains string + }{ + { + name: "unarchive client by name", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedClient", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID, 0 + }, + args: []string{"unarchive", "--client", "ArchivedClient"}, + expectedOutputs: []string{"Unarchived client: ArchivedClient"}, + expectError: false, + }, + { + name: "unarchive client by ID", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ClientByID", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID, 0 + }, + args: []string{"unarchive", "--client", "1"}, + expectedOutputs: []string{"Unarchived client: ClientByID"}, + expectError: false, + }, + { + name: "unarchive project by name", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedProject", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return client.ID, project.ID + }, + args: []string{"unarchive", "--project", "ArchivedProject"}, + expectedOutputs: []string{"Unarchived project: ArchivedProject"}, + expectError: false, + }, + { + name: "unarchive project by ID", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ProjectByID", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return client.ID, project.ID + }, + args: []string{"unarchive", "--project", "1"}, + expectedOutputs: []string{"Unarchived project: ProjectByID"}, + expectError: false, + }, + { + name: "unarchive without flags returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"unarchive"}, + expectError: true, + errorContains: "either --client or --project must be specified", + }, + { + name: "unarchive with both client and project flags returns error", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + _ = q.ArchiveClient(context.Background(), client.ID) + _ = q.ArchiveProject(context.Background(), project.ID) + return client.ID, project.ID + }, + args: []string{"unarchive", "--client", "TestClient", "--project", "TestProject"}, + expectError: true, + errorContains: "cannot specify both --client and --project", + }, + { + name: "unarchive nonexistent client returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"unarchive", "--client", "NonexistentClient"}, + expectError: true, + errorContains: "failed to find client", + }, + { + name: "unarchive nonexistent project returns error", + setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, + args: []string{"unarchive", "--project", "NonexistentProject"}, + expectError: true, + errorContains: "failed to find project", + }, + { + name: "unarchive already active client succeeds", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ActiveClient", + }) + return client.ID, 0 + }, + args: []string{"unarchive", "--client", "ActiveClient"}, + expectedOutputs: []string{"Unarchived client: ActiveClient"}, + expectError: false, + }, + { + name: "unarchive client using short flag", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ShortFlagClient", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID, 0 + }, + args: []string{"unarchive", "-c", "ShortFlagClient"}, + expectedOutputs: []string{"Unarchived client: ShortFlagClient"}, + expectError: false, + }, + { + name: "unarchive project using short flag", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ShortFlagProject", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return client.ID, project.ID + }, + args: []string{"unarchive", "-p", "ShortFlagProject"}, + expectedOutputs: []string{"Unarchived project: ShortFlagProject"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + tt.setupData(q) + + output, err := executeCommandWithDB(t, q, tt.args...) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error to contain %q, got %q", tt.errorContains, err.Error()) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + found := false + for _, expectedOutput := range tt.expectedOutputs { + if strings.Contains(output, expectedOutput) { + found = true + break + } + } + + if !found { + t.Errorf("Expected output to contain one of %v, got %q", tt.expectedOutputs, output) + } + }) + } +} + +func TestArchiveStateVerification(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (entityID int64, isClient bool) + archiveAction func(*queries.Queries, int64) + verifyFunc func(*testing.T, *queries.Queries, int64, bool) + }{ + { + name: "client is archived after archive command", + setupData: func(q *queries.Queries) (int64, bool) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + return client.ID, true + }, + archiveAction: func(q *queries.Queries, id int64) { + _ = q.ArchiveClient(context.Background(), id) + }, + verifyFunc: func(t *testing.T, q *queries.Queries, id int64, isClient bool) { + clients, err := q.FindClient(context.Background(), queries.FindClientParams{ + ID: id, + }) + if err != nil { + t.Fatalf("Failed to find client: %v", err) + } + if len(clients) != 1 { + t.Fatalf("Expected 1 client, got %d", len(clients)) + } + if clients[0].Archived == 0 { + t.Errorf("Expected client to be archived") + } + }, + }, + { + name: "project is archived after archive command", + setupData: func(q *queries.Queries) (int64, bool) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "TestProject", + ClientID: client.ID, + }) + return project.ID, false + }, + archiveAction: func(q *queries.Queries, id int64) { + _ = q.ArchiveProject(context.Background(), id) + }, + verifyFunc: func(t *testing.T, q *queries.Queries, id int64, isClient bool) { + projects, err := q.FindProject(context.Background(), queries.FindProjectParams{ + ID: id, + }) + if err != nil { + t.Fatalf("Failed to find project: %v", err) + } + if len(projects) != 1 { + t.Fatalf("Expected 1 project, got %d", len(projects)) + } + if projects[0].Archived == 0 { + t.Errorf("Expected project to be archived") + } + }, + }, + { + name: "client is unarchived after unarchive command", + setupData: func(q *queries.Queries) (int64, bool) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedClient", + }) + _ = q.ArchiveClient(context.Background(), client.ID) + return client.ID, true + }, + archiveAction: func(q *queries.Queries, id int64) { + _ = q.UnarchiveClient(context.Background(), id) + }, + verifyFunc: func(t *testing.T, q *queries.Queries, id int64, isClient bool) { + clients, err := q.FindClient(context.Background(), queries.FindClientParams{ + ID: id, + }) + if err != nil { + t.Fatalf("Failed to find client: %v", err) + } + if len(clients) != 1 { + t.Fatalf("Expected 1 client, got %d", len(clients)) + } + if clients[0].Archived != 0 { + t.Errorf("Expected client to not be archived") + } + }, + }, + { + name: "project is unarchived after unarchive command", + setupData: func(q *queries.Queries) (int64, bool) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedProject", + ClientID: client.ID, + }) + _ = q.ArchiveProject(context.Background(), project.ID) + return project.ID, false + }, + archiveAction: func(q *queries.Queries, id int64) { + _ = q.UnarchiveProject(context.Background(), id) + }, + verifyFunc: func(t *testing.T, q *queries.Queries, id int64, isClient bool) { + projects, err := q.FindProject(context.Background(), queries.FindProjectParams{ + ID: id, + }) + if err != nil { + t.Fatalf("Failed to find project: %v", err) + } + if len(projects) != 1 { + t.Fatalf("Expected 1 project, got %d", len(projects)) + } + if projects[0].Archived != 0 { + t.Errorf("Expected project to not be archived") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + entityID, isClient := tt.setupData(q) + tt.archiveAction(q, entityID) + tt.verifyFunc(t, q, entityID, isClient) + }) + } +} diff --git a/internal/commands/in.go b/internal/commands/in.go index 8c5025a..8a08edf 100644 --- a/internal/commands/in.go +++ b/internal/commands/in.go @@ -1,6 +1,7 @@ package commands import ( + "errors" "fmt" "git.tjp.lol/punchcard/internal/actions" @@ -54,16 +55,43 @@ Examples: var session *actions.TimerSession var err error + // Try punching in without auto-unarchive first if clientFlag == "" && projectFlag == "" { - // Use most recent entry - session, err = a.PunchInMostRecent(cmd.Context(), description, billableRate) + session, err = a.PunchInMostRecent(cmd.Context(), description, billableRate, false) } else { - // Use specified client/project - session, err = a.PunchIn(cmd.Context(), clientFlag, projectFlag, description, billableRate) + session, err = a.PunchIn(cmd.Context(), clientFlag, projectFlag, description, billableRate, false) } + // Handle archived errors by prompting user if err != nil { - return err + if errors.Is(err, actions.ErrArchivedClient) || errors.Is(err, actions.ErrArchivedProject) { + entityType := "client" + if errors.Is(err, actions.ErrArchivedProject) { + entityType = "project" + } + + cmd.Printf("Warning: This %s is archived.\n", entityType) + cmd.Print("Continue and unarchive? (y/N): ") + + var response string + _, err := fmt.Scanln(&response) + if err != nil || (response != "y" && response != "Y") { + return fmt.Errorf("operation cancelled") + } + + // Retry with auto-unarchive enabled + if clientFlag == "" && projectFlag == "" { + session, err = a.PunchInMostRecent(cmd.Context(), description, billableRate, true) + } else { + session, err = a.PunchIn(cmd.Context(), clientFlag, projectFlag, description, billableRate, true) + } + + if err != nil { + return err + } + } else { + return err + } } // Handle different response types diff --git a/internal/commands/root.go b/internal/commands/root.go index 9ac0790..6e61980 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -26,6 +26,8 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(NewReportCmd()) cmd.AddCommand(NewSetCmd()) cmd.AddCommand(NewTUICmd()) + cmd.AddCommand(NewArchiveCmd()) + cmd.AddCommand(NewUnarchiveCmd()) return cmd } diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 4ed5578..c3a356c 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -4,9 +4,9 @@ values (@name, @email, @billable_rate) returning *; -- name: FindClient :many -select c1.id, c1.name, c1.email, c1.billable_rate, c1.created_at from client c1 where c1.id = cast(@id as integer) +select c1.id, c1.name, c1.email, c1.billable_rate, c1.archived, c1.created_at from client c1 where c1.id = cast(@id as integer) union all -select c2.id, c2.name, c2.email, c2.billable_rate, c2.created_at from client c2 where c2.name = @name; +select c2.id, c2.name, c2.email, c2.billable_rate, c2.archived, c2.created_at from client c2 where c2.name = @name; -- name: CreateProject :one insert into project (name, client_id, billable_rate) @@ -14,9 +14,9 @@ values (@name, @client_id, @billable_rate) returning *; -- name: FindProject :many -select p1.id, p1.name, p1.client_id, p1.billable_rate, p1.created_at from project p1 where p1.id = cast(@id as integer) +select p1.id, p1.name, p1.client_id, p1.billable_rate, p1.archived, p1.created_at from project p1 where p1.id = cast(@id as integer) union all -select p2.id, p2.name, p2.client_id, p2.billable_rate, p2.created_at from project p2 where p2.name = @name; +select p2.id, p2.name, p2.client_id, p2.billable_rate, p2.archived, p2.created_at from project p2 where p2.name = @name; -- name: CreateTimeEntry :one insert into time_entry (start_time, description, client_id, project_id, billable_rate) @@ -327,3 +327,23 @@ where id = @entry_id; -- name: RemoveTimeEntry :exec delete from time_entry where id = @entry_id; + +-- name: ArchiveClient :exec +update client +set archived = 1 +where id = @id; + +-- name: UnarchiveClient :exec +update client +set archived = 0 +where id = @id; + +-- name: ArchiveProject :exec +update project +set archived = 1 +where id = @id; + +-- name: UnarchiveProject :exec +update project +set archived = 0 +where id = @id; diff --git a/internal/database/schema.sql b/internal/database/schema.sql index 84f4f02..31a5d0a 100644 --- a/internal/database/schema.sql +++ b/internal/database/schema.sql @@ -3,6 +3,7 @@ create table if not exists client ( name text not null unique, email text, billable_rate integer, + archived integer not null default 0, created_at datetime default current_timestamp ); @@ -11,6 +12,7 @@ create table if not exists project ( name text not null unique, client_id integer not null, billable_rate integer, + archived integer not null default 0, created_at datetime default current_timestamp, foreign key (client_id) references client(id) ); diff --git a/internal/queries/db.go b/internal/queries/db.go index 85679b3..51576a5 100644 --- a/internal/queries/db.go +++ b/internal/queries/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 package queries diff --git a/internal/queries/models.go b/internal/queries/models.go index b42de02..5db918e 100644 --- a/internal/queries/models.go +++ b/internal/queries/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 package queries @@ -14,6 +14,7 @@ type Client struct { Name string Email sql.NullString BillableRate sql.NullInt64 + Archived int64 CreatedAt sql.NullTime } @@ -40,6 +41,7 @@ type Project struct { Name string ClientID int64 BillableRate sql.NullInt64 + Archived int64 CreatedAt sql.NullTime } diff --git a/internal/queries/queries.sql.go b/internal/queries/queries.sql.go index 4ae940c..035be20 100644 --- a/internal/queries/queries.sql.go +++ b/internal/queries/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.29.0 +// sqlc v1.30.0 // source: queries.sql package queries @@ -11,10 +11,32 @@ import ( "time" ) +const archiveClient = `-- name: ArchiveClient :exec +update client +set archived = 1 +where id = ?1 +` + +func (q *Queries) ArchiveClient(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, archiveClient, id) + return err +} + +const archiveProject = `-- name: ArchiveProject :exec +update project +set archived = 1 +where id = ?1 +` + +func (q *Queries) ArchiveProject(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, archiveProject, id) + return err +} + const createClient = `-- name: CreateClient :one insert into client (name, email, billable_rate) values (?1, ?2, ?3) -returning id, name, email, billable_rate, created_at +returning id, name, email, billable_rate, archived, created_at ` type CreateClientParams struct { @@ -31,6 +53,7 @@ func (q *Queries) CreateClient(ctx context.Context, arg CreateClientParams) (Cli &i.Name, &i.Email, &i.BillableRate, + &i.Archived, &i.CreatedAt, ) return i, err @@ -99,7 +122,7 @@ func (q *Queries) CreateInvoice(ctx context.Context, arg CreateInvoiceParams) (I const createProject = `-- name: CreateProject :one insert into project (name, client_id, billable_rate) values (?1, ?2, ?3) -returning id, name, client_id, billable_rate, created_at +returning id, name, client_id, billable_rate, archived, created_at ` type CreateProjectParams struct { @@ -116,6 +139,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P &i.Name, &i.ClientID, &i.BillableRate, + &i.Archived, &i.CreatedAt, ) return i, err @@ -240,9 +264,9 @@ func (q *Queries) EditTimeEntry(ctx context.Context, arg EditTimeEntryParams) er } const findClient = `-- name: FindClient :many -select c1.id, c1.name, c1.email, c1.billable_rate, c1.created_at from client c1 where c1.id = cast(?1 as integer) +select c1.id, c1.name, c1.email, c1.billable_rate, c1.archived, c1.created_at from client c1 where c1.id = cast(?1 as integer) union all -select c2.id, c2.name, c2.email, c2.billable_rate, c2.created_at from client c2 where c2.name = ?2 +select c2.id, c2.name, c2.email, c2.billable_rate, c2.archived, c2.created_at from client c2 where c2.name = ?2 ` type FindClientParams struct { @@ -264,6 +288,7 @@ func (q *Queries) FindClient(ctx context.Context, arg FindClientParams) ([]Clien &i.Name, &i.Email, &i.BillableRate, + &i.Archived, &i.CreatedAt, ); err != nil { return nil, err @@ -280,9 +305,9 @@ func (q *Queries) FindClient(ctx context.Context, arg FindClientParams) ([]Clien } const findProject = `-- name: FindProject :many -select p1.id, p1.name, p1.client_id, p1.billable_rate, p1.created_at from project p1 where p1.id = cast(?1 as integer) +select p1.id, p1.name, p1.client_id, p1.billable_rate, p1.archived, p1.created_at from project p1 where p1.id = cast(?1 as integer) union all -select p2.id, p2.name, p2.client_id, p2.billable_rate, p2.created_at from project p2 where p2.name = ?2 +select p2.id, p2.name, p2.client_id, p2.billable_rate, p2.archived, p2.created_at from project p2 where p2.name = ?2 ` type FindProjectParams struct { @@ -304,6 +329,7 @@ func (q *Queries) FindProject(ctx context.Context, arg FindProjectParams) ([]Pro &i.Name, &i.ClientID, &i.BillableRate, + &i.Archived, &i.CreatedAt, ); err != nil { return nil, err @@ -342,7 +368,7 @@ func (q *Queries) GetActiveTimeEntry(ctx context.Context) (TimeEntry, error) { } const getClientByName = `-- name: GetClientByName :one -select id, name, email, billable_rate, created_at from client where name = ?1 limit 1 +select id, name, email, billable_rate, archived, created_at from client where name = ?1 limit 1 ` func (q *Queries) GetClientByName(ctx context.Context, name string) (Client, error) { @@ -353,6 +379,7 @@ func (q *Queries) GetClientByName(ctx context.Context, name string) (Client, err &i.Name, &i.Email, &i.BillableRate, + &i.Archived, &i.CreatedAt, ) return i, err @@ -712,7 +739,7 @@ func (q *Queries) GetMostRecentTimeEntry(ctx context.Context) (TimeEntry, error) } const getProjectByNameAndClient = `-- name: GetProjectByNameAndClient :one -select id, name, client_id, billable_rate, created_at from project where name = ?1 and client_id = ?2 limit 1 +select id, name, client_id, billable_rate, archived, created_at from project where name = ?1 and client_id = ?2 limit 1 ` type GetProjectByNameAndClientParams struct { @@ -728,6 +755,7 @@ func (q *Queries) GetProjectByNameAndClient(ctx context.Context, arg GetProjectB &i.Name, &i.ClientID, &i.BillableRate, + &i.Archived, &i.CreatedAt, ) return i, err @@ -1004,7 +1032,7 @@ func (q *Queries) GetWeekSummaryByProject(ctx context.Context) ([]GetWeekSummary } const listAllClients = `-- name: ListAllClients :many -select id, name, email, billable_rate, created_at from client +select id, name, email, billable_rate, archived, created_at from client order by name ` @@ -1022,6 +1050,7 @@ func (q *Queries) ListAllClients(ctx context.Context) ([]Client, error) { &i.Name, &i.Email, &i.BillableRate, + &i.Archived, &i.CreatedAt, ); err != nil { return nil, err @@ -1038,7 +1067,7 @@ func (q *Queries) ListAllClients(ctx context.Context) ([]Client, error) { } const listAllProjects = `-- name: ListAllProjects :many -select p.id, p.name, p.client_id, p.billable_rate, p.created_at, c.name as client_name from project p +select p.id, p.name, p.client_id, p.billable_rate, p.archived, p.created_at, c.name as client_name from project p join client c on p.client_id = c.id order by c.name, p.name ` @@ -1048,6 +1077,7 @@ type ListAllProjectsRow struct { Name string ClientID int64 BillableRate sql.NullInt64 + Archived int64 CreatedAt sql.NullTime ClientName string } @@ -1066,6 +1096,7 @@ func (q *Queries) ListAllProjects(ctx context.Context) ([]ListAllProjectsRow, er &i.Name, &i.ClientID, &i.BillableRate, + &i.Archived, &i.CreatedAt, &i.ClientName, ); err != nil { @@ -1120,6 +1151,28 @@ func (q *Queries) StopTimeEntry(ctx context.Context) (TimeEntry, error) { return i, err } +const unarchiveClient = `-- name: UnarchiveClient :exec +update client +set archived = 0 +where id = ?1 +` + +func (q *Queries) UnarchiveClient(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, unarchiveClient, id) + return err +} + +const unarchiveProject = `-- name: UnarchiveProject :exec +update project +set archived = 0 +where id = ?1 +` + +func (q *Queries) UnarchiveProject(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, unarchiveProject, id) + return err +} + const updateActiveTimerDescription = `-- name: UpdateActiveTimerDescription :exec update time_entry set description = ?1 @@ -1141,7 +1194,7 @@ const updateClient = `-- name: UpdateClient :one update client set name = ?1, email = ?2, billable_rate = ?3 where id = ?4 -returning id, name, email, billable_rate, created_at +returning id, name, email, billable_rate, archived, created_at ` type UpdateClientParams struct { @@ -1164,6 +1217,7 @@ func (q *Queries) UpdateClient(ctx context.Context, arg UpdateClientParams) (Cli &i.Name, &i.Email, &i.BillableRate, + &i.Archived, &i.CreatedAt, ) return i, err @@ -1199,7 +1253,7 @@ const updateProject = `-- name: UpdateProject :one update project set name = ?1, billable_rate = ?2 where id = ?3 -returning id, name, client_id, billable_rate, created_at +returning id, name, client_id, billable_rate, archived, created_at ` type UpdateProjectParams struct { @@ -1216,6 +1270,7 @@ func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (P &i.Name, &i.ClientID, &i.BillableRate, + &i.Archived, &i.CreatedAt, ) return i, err diff --git a/internal/reports/api.go b/internal/reports/api.go index 90b066b..b7bd3c2 100644 --- a/internal/reports/api.go +++ b/internal/reports/api.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "fmt" + "os" "path/filepath" + "strings" "time" "git.tjp.lol/punchcard/internal/queries" @@ -25,6 +27,23 @@ type ReportResult struct { TotalEntries int } +func expandPath(path string) (string, error) { + if strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + path = filepath.Join(home, path[2:]) + } else if path == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + path = home + } + return filepath.Abs(path) +} + func GenerateInvoice(ctx context.Context, q *queries.Queries, params ReportParams) (*ReportResult, error) { if params.ProjectName == "" { return GenerateClientInvoice(ctx, q, params) @@ -91,7 +110,7 @@ func GenerateClientInvoice(ctx context.Context, q *queries.Queries, params Repor outputPath = GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, params.DateRange) } - outputPath, err = filepath.Abs(outputPath) + outputPath, err = expandPath(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } @@ -172,7 +191,7 @@ func GenerateProjectInvoice(ctx context.Context, q *queries.Queries, params Repo outputPath = GenerateDefaultInvoiceFilename(invoiceData.ClientName, invoiceData.ProjectName, params.DateRange) } - outputPath, err = filepath.Abs(outputPath) + outputPath, err = expandPath(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } @@ -236,7 +255,7 @@ func GenerateClientTimesheet(ctx context.Context, q *queries.Queries, params Rep outputPath = GenerateDefaultTimesheetFilename(timesheetData.ClientName, timesheetData.ProjectName, params.DateRange) } - outputPath, err = filepath.Abs(outputPath) + outputPath, err = expandPath(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } @@ -299,7 +318,7 @@ func GenerateProjectTimesheet(ctx context.Context, q *queries.Queries, params Re outputPath = GenerateDefaultTimesheetFilename(timesheetData.ClientName, timesheetData.ProjectName, params.DateRange) } - outputPath, err = filepath.Abs(outputPath) + outputPath, err = expandPath(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } @@ -361,7 +380,7 @@ func GenerateClientUnifiedReport(ctx context.Context, q *queries.Queries, params outputPath = GenerateDefaultUnifiedFilename(unifiedData.InvoiceData.ClientName, unifiedData.InvoiceData.ProjectName, params.DateRange) } - outputPath, err = filepath.Abs(outputPath) + outputPath, err = expandPath(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } @@ -442,7 +461,7 @@ func GenerateProjectUnifiedReport(ctx context.Context, q *queries.Queries, param outputPath = GenerateDefaultUnifiedFilename(unifiedData.InvoiceData.ClientName, unifiedData.InvoiceData.ProjectName, params.DateRange) } - outputPath, err = filepath.Abs(outputPath) + outputPath, err = expandPath(outputPath) if err != nil { return nil, fmt.Errorf("failed to resolve output path: %w", err) } diff --git a/internal/reports/archive_reports_test.go b/internal/reports/archive_reports_test.go new file mode 100644 index 0000000..d300031 --- /dev/null +++ b/internal/reports/archive_reports_test.go @@ -0,0 +1,612 @@ +package reports + +import ( + "context" + "database/sql" + "testing" + "time" + + "git.tjp.lol/punchcard/internal/database" + "git.tjp.lol/punchcard/internal/queries" +) + +func setupTestDB(t *testing.T) (*queries.Queries, func()) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("Failed to open in-memory sqlite db: %v", err) + } + if err := database.InitializeDB(db); err != nil { + t.Fatalf("Failed to initialize in-memory sqlite db: %v", err) + } + q := queries.New(db) + + cleanup := func() { + if err := q.DBTX().(*sql.DB).Close(); err != nil { + t.Logf("error closing database: %v", err) + } + } + + return q, cleanup +} + +func TestGetInvoiceDataByClientWithArchivedClient(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (clientID int64) + expectEntries int + expectTotalAmount float64 + }{ + { + name: "archived client time entries appear in invoice", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedClient", + BillableRate: sql.NullInt64{Int64: 10000, Valid: true}, + }) + + startTime := time.Now().UTC().Add(-2 * time.Hour) + endTime := time.Now().UTC() + + _, err := q.DBTX().(*sql.DB).Exec( + "INSERT INTO time_entry (client_id, start_time, end_time) VALUES (?, ?, ?)", + client.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + + _ = q.ArchiveClient(context.Background(), client.ID) + + return client.ID + }, + expectEntries: 1, + expectTotalAmount: 200.0, + }, + { + name: "multiple time entries for archived client all appear", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "MultiEntryClient", + BillableRate: sql.NullInt64{Int64: 15000, Valid: true}, + }) + + for i := 0; i < 3; i++ { + startTime := time.Now().UTC().Add(-time.Duration(i+2) * time.Hour) + endTime := time.Now().UTC().Add(-time.Duration(i+1) * time.Hour) + + _, err := q.DBTX().(*sql.DB).Exec( + "INSERT INTO time_entry (client_id, start_time, end_time) VALUES (?, ?, ?)", + client.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + } + + _ = q.ArchiveClient(context.Background(), client.ID) + + return client.ID + }, + expectEntries: 3, + expectTotalAmount: 450.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + clientID := tt.setupData(q) + + startTime := time.Now().UTC().Add(-7 * 24 * time.Hour) + endTime := time.Now().UTC() + + entries, err := q.GetInvoiceDataByClient(context.Background(), queries.GetInvoiceDataByClientParams{ + ClientID: clientID, + StartTime: startTime, + EndTime: endTime, + }) + + if err != nil { + t.Fatalf("Failed to get invoice data: %v", err) + } + + if len(entries) != tt.expectEntries { + t.Errorf("Expected %d entries, got %d", tt.expectEntries, len(entries)) + } + + if len(entries) > 0 { + contractor := queries.Contractor{ + Name: "Test Contractor", + Label: "Contractor", + Email: "test@example.com", + } + + invoiceData, err := GenerateInvoiceData( + entries, + clientID, + "TestClient", + "", + contractor, + 1, + DateRange{Start: startTime, End: endTime}, + ) + + if err != nil { + t.Fatalf("Failed to generate invoice data: %v", err) + } + + if invoiceData.TotalAmount != tt.expectTotalAmount { + t.Errorf("Expected total amount %.2f, got %.2f", tt.expectTotalAmount, invoiceData.TotalAmount) + } + } + }) + } +} + +func TestGetInvoiceDataByProjectWithArchivedProject(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (projectID int64) + expectEntries int + expectTotalAmount float64 + }{ + { + name: "archived project time entries appear in invoice", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedProject", + ClientID: client.ID, + BillableRate: sql.NullInt64{Int64: 12500, Valid: true}, + }) + + baseTime := time.Now().UTC() + startTime := baseTime.Add(-2 * time.Hour) + endTime := baseTime + + _, err := q.DBTX().(*sql.DB).Exec( + "INSERT INTO time_entry (client_id, project_id, start_time, end_time) VALUES (?, ?, ?, ?)", + client.ID, project.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + + _ = q.ArchiveProject(context.Background(), project.ID) + + return project.ID + }, + expectEntries: 1, + expectTotalAmount: 250.0, + }, + { + name: "multiple time entries for archived project all appear", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "MultiEntryProject", + ClientID: client.ID, + BillableRate: sql.NullInt64{Int64: 20000, Valid: true}, + }) + + baseTime := time.Now().UTC() + for i := 0; i < 2; i++ { + startTime := baseTime.Add(-time.Duration(i+2) * time.Hour) + endTime := baseTime.Add(-time.Duration(i+1) * time.Hour) + + _, err := q.DBTX().(*sql.DB).Exec( + "INSERT INTO time_entry (client_id, project_id, start_time, end_time) VALUES (?, ?, ?, ?)", + client.ID, project.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + } + + _ = q.ArchiveProject(context.Background(), project.ID) + + return project.ID + }, + expectEntries: 2, + expectTotalAmount: 396.66, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + projectID := tt.setupData(q) + + startTime := time.Now().UTC().Add(-7 * 24 * time.Hour) + endTime := time.Now().UTC() + + entries, err := q.GetInvoiceDataByProject(context.Background(), queries.GetInvoiceDataByProjectParams{ + ProjectID: projectID, + StartTime: startTime, + EndTime: endTime, + }) + + if err != nil { + t.Fatalf("Failed to get invoice data: %v", err) + } + + if len(entries) != tt.expectEntries { + t.Errorf("Expected %d entries, got %d", tt.expectEntries, len(entries)) + } + + if len(entries) > 0 { + contractor := queries.Contractor{ + Name: "Test Contractor", + Label: "Contractor", + Email: "test@example.com", + } + + invoiceData, err := GenerateInvoiceData( + entries, + entries[0].ClientID, + "TestClient", + "ArchivedProject", + contractor, + 1, + DateRange{Start: startTime, End: endTime}, + ) + + if err != nil { + t.Fatalf("Failed to generate invoice data: %v", err) + } + + if invoiceData.TotalAmount != tt.expectTotalAmount { + t.Errorf("Expected total amount %.2f, got %.2f", tt.expectTotalAmount, invoiceData.TotalAmount) + } + } + }) + } +} + +func TestGetTimesheetDataByClientWithArchivedClient(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (clientID int64) + expectEntries int + expectTotalHrs float64 + }{ + { + name: "archived client time entries appear in timesheet", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "ArchivedTimesheetClient", + }) + + startTime := time.Now().UTC().Add(-2 * time.Hour) + endTime := time.Now().UTC() + + _, err := q.DBTX().(*sql.DB).Exec( + "INSERT INTO time_entry (client_id, start_time, end_time) VALUES (?, ?, ?)", + client.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + + _ = q.ArchiveClient(context.Background(), client.ID) + + return client.ID + }, + expectEntries: 1, + expectTotalHrs: 2.0, + }, + { + name: "multiple time entries for archived client all appear in timesheet", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "MultiEntryTimesheetClient", + }) + + for i := 0; i < 3; i++ { + startTime := time.Now().UTC().Add(-time.Duration(i+2) * time.Hour) + endTime := time.Now().UTC().Add(-time.Duration(i+1) * time.Hour) + + _, err := q.DBTX().(*sql.DB).Exec( + "INSERT INTO time_entry (client_id, start_time, end_time) VALUES (?, ?, ?)", + client.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + } + + _ = q.ArchiveClient(context.Background(), client.ID) + + return client.ID + }, + expectEntries: 3, + expectTotalHrs: 3.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + clientID := tt.setupData(q) + + startTime := time.Now().UTC().Add(-7 * 24 * time.Hour) + endTime := time.Now().UTC() + + entries, err := q.GetTimesheetDataByClient(context.Background(), queries.GetTimesheetDataByClientParams{ + ClientID: clientID, + StartTime: startTime, + EndTime: endTime, + }) + + if err != nil { + t.Fatalf("Failed to get timesheet data: %v", err) + } + + if len(entries) != tt.expectEntries { + t.Errorf("Expected %d entries, got %d", tt.expectEntries, len(entries)) + } + + if len(entries) > 0 { + contractor := queries.Contractor{ + Name: "Test Contractor", + Label: "Contractor", + Email: "test@example.com", + } + + timesheetData, err := GenerateTimesheetData( + entries, + clientID, + "TestClient", + "", + contractor, + DateRange{Start: startTime, End: endTime}, + time.UTC, + ) + + if err != nil { + t.Fatalf("Failed to generate timesheet data: %v", err) + } + + if timesheetData.TotalHours != tt.expectTotalHrs { + t.Errorf("Expected total hours %.2f, got %.2f", tt.expectTotalHrs, timesheetData.TotalHours) + } + } + }) + } +} + +func TestGetTimesheetDataByProjectWithArchivedProject(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (projectID int64) + expectEntries int + expectTotalHrs float64 + }{ + { + name: "archived project time entries appear in timesheet", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "ArchivedTimesheetProject", + ClientID: client.ID, + }) + + startTime := time.Now().UTC().Add(-2 * time.Hour) + endTime := time.Now().UTC() + + _, err := q.DBTX().(*sql.DB).Exec( + "INSERT INTO time_entry (client_id, project_id, start_time, end_time) VALUES (?, ?, ?, ?)", + client.ID, project.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + + _ = q.ArchiveProject(context.Background(), project.ID) + + return project.ID + }, + expectEntries: 1, + expectTotalHrs: 2.0, + }, + { + name: "multiple time entries for archived project all appear in timesheet", + setupData: func(q *queries.Queries) int64 { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "TestClient", + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "MultiEntryTimesheetProject", + ClientID: client.ID, + }) + + for i := 0; i < 2; i++ { + startTime := time.Now().UTC().Add(-time.Duration(i+2) * time.Hour) + endTime := time.Now().UTC().Add(-time.Duration(i+1) * time.Hour) + + _, err := q.DBTX().(*sql.DB).Exec( + "INSERT INTO time_entry (client_id, project_id, start_time, end_time) VALUES (?, ?, ?, ?)", + client.ID, project.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + } + + _ = q.ArchiveProject(context.Background(), project.ID) + + return project.ID + }, + expectEntries: 2, + expectTotalHrs: 2.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + projectID := tt.setupData(q) + + startTime := time.Now().UTC().Add(-7 * 24 * time.Hour) + endTime := time.Now().UTC() + + entries, err := q.GetTimesheetDataByProject(context.Background(), queries.GetTimesheetDataByProjectParams{ + ProjectID: projectID, + StartTime: startTime, + EndTime: endTime, + }) + + if err != nil { + t.Fatalf("Failed to get timesheet data: %v", err) + } + + t.Logf("Got %d entries, expected %d", len(entries), tt.expectEntries) + if len(entries) != tt.expectEntries { + t.Errorf("Expected %d entries, got %d", tt.expectEntries, len(entries)) + return + } + + if len(entries) > 0 { + contractor := queries.Contractor{ + Name: "Test Contractor", + Label: "Contractor", + Email: "test@example.com", + } + + timesheetData, err := GenerateTimesheetData( + entries, + entries[0].ClientID, + "TestClient", + "ArchivedTimesheetProject", + contractor, + DateRange{Start: startTime, End: endTime}, + time.UTC, + ) + + if err != nil { + t.Fatalf("Failed to generate timesheet data: %v", err) + } + + // Allow for small floating point rounding errors + diff := timesheetData.TotalHours - tt.expectTotalHrs + if diff < 0 { + diff = -diff + } + if diff > 0.01 { + t.Errorf("Expected total hours %.2f, got %.2f", tt.expectTotalHrs, timesheetData.TotalHours) + return + } + } + }) + } +} + +func TestArchivedClientProjectRevenueIntegrity(t *testing.T) { + tests := []struct { + name string + setupData func(*queries.Queries) (clientID, projectID int64) + verifyInvoice func(*testing.T, []queries.GetInvoiceDataByClientRow) + }{ + { + name: "archived client and project both appear in combined invoice", + setupData: func(q *queries.Queries) (int64, int64) { + client, _ := q.CreateClient(context.Background(), queries.CreateClientParams{ + Name: "FullyArchivedClient", + BillableRate: sql.NullInt64{Int64: 10000, Valid: true}, + }) + project, _ := q.CreateProject(context.Background(), queries.CreateProjectParams{ + Name: "FullyArchivedProject", + ClientID: client.ID, + }) + + for i := 0; i < 2; i++ { + startTime := time.Now().UTC().Add(-time.Duration(i+2) * time.Hour) + endTime := time.Now().UTC().Add(-time.Duration(i+1) * time.Hour) + + if i == 1 { + _, err := q.DBTX().(*sql.DB).Exec( + "INSERT INTO time_entry (client_id, project_id, start_time, end_time) VALUES (?, ?, ?, ?)", + client.ID, project.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + } else { + _, err := q.DBTX().(*sql.DB).Exec( + "INSERT INTO time_entry (client_id, start_time, end_time) VALUES (?, ?, ?)", + client.ID, startTime.Format("2006-01-02 15:04:05"), endTime.Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("Failed to create time entry: %v", err) + } + } + } + + _ = q.ArchiveClient(context.Background(), client.ID) + _ = q.ArchiveProject(context.Background(), project.ID) + + return client.ID, project.ID + }, + verifyInvoice: func(t *testing.T, entries []queries.GetInvoiceDataByClientRow) { + if len(entries) != 2 { + t.Errorf("Expected 2 entries, got %d", len(entries)) + } + + hasProjectEntry := false + hasClientEntry := false + for _, entry := range entries { + if entry.ProjectID.Valid { + hasProjectEntry = true + } else { + hasClientEntry = true + } + } + + if !hasProjectEntry { + t.Errorf("Expected at least one project entry") + } + if !hasClientEntry { + t.Errorf("Expected at least one client-only entry") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q, cleanup := setupTestDB(t) + defer cleanup() + + clientID, _ := tt.setupData(q) + + startTime := time.Now().UTC().Add(-7 * 24 * time.Hour) + endTime := time.Now().UTC() + + entries, err := q.GetInvoiceDataByClient(context.Background(), queries.GetInvoiceDataByClientParams{ + ClientID: clientID, + StartTime: startTime, + EndTime: endTime, + }) + + if err != nil { + t.Fatalf("Failed to get invoice data: %v", err) + } + + tt.verifyInvoice(t, entries) + }) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index fe5f364..38457ff 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "git.tjp.lol/punchcard/internal/actions" "git.tjp.lol/punchcard/internal/queries" tea "github.com/charmbracelet/bubbletea" @@ -180,6 +181,13 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case modalClosed: m.modalBox.deactivate() + case reportGenerationSucceeded: + m.modalBox.deactivate() + + case reportGenerationFailed: + m.modalBox.form.err = msg.err + m.modalBox.Active = true + case openTimeEntryEditor: if m.selectedBox == HistoryBox && m.historyBox.viewLevel == HistoryLevelDetails { m.openEntryEditor() @@ -232,6 +240,32 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.filterHistoryByProjectBox() } cmds = append(cmds, m.refreshCmd) + + case toggleShowArchivedMsg: + if m.selectedBox == ProjectsBox { + m.projectsBox.showArchived = !m.projectsBox.showArchived + m.projectsBox.restoreSelection(msg.restoreClientID, msg.restoreProjectID) + } + + case archiveSelectedMsg: + if m.selectedBox == ProjectsBox { + cmds = append(cmds, m.archiveSelectedClientOrProject(msg.clientID, msg.projectID, msg.restoreClientID, msg.restoreProjectID)) + } + + case unarchiveSelectedMsg: + if m.selectedBox == ProjectsBox { + cmds = append(cmds, m.unarchiveSelectedClientOrProject(msg.clientID, msg.projectID, msg.restoreClientID, msg.restoreProjectID)) + } + + case restoreSelectionMsg: + if m.selectedBox == ProjectsBox { + m.projectsBox.restoreSelection(msg.clientID, msg.projectID) + } + + case showArchivedWarningMsg: + m.modalBox.Active = true + m.modalBox.Type = ModalTypeArchivedWarning + m.modalBox.archivedPunchInParams = msg.params } return m, tea.Batch(cmds...) @@ -249,6 +283,50 @@ func (m *AppModel) filterHistoryByProjectBox() { m.historyBox.resetSelection() } +func (m *AppModel) archiveSelectedClientOrProject(clientID int64, projectID *int64, restoreClientID int64, restoreProjectID *int64) tea.Cmd { + return tea.Sequence( + func() tea.Msg { + a := actions.New(m.queries) + + if projectID == nil { + // Archive client + _ = a.ArchiveClient(context.Background(), clientID) + } else { + // Archive project + _ = a.ArchiveProject(context.Background(), *projectID) + } + + return nil + }, + m.refreshCmd, + func() tea.Msg { + return restoreSelectionMsg{clientID: restoreClientID, projectID: restoreProjectID} + }, + ) +} + +func (m *AppModel) unarchiveSelectedClientOrProject(clientID int64, projectID *int64, restoreClientID int64, restoreProjectID *int64) tea.Cmd { + return tea.Sequence( + func() tea.Msg { + a := actions.New(m.queries) + + if projectID == nil { + // Unarchive client + _ = a.UnarchiveClient(context.Background(), clientID) + } else { + // Unarchive project + _ = a.UnarchiveProject(context.Background(), *projectID) + } + + return nil + }, + m.refreshCmd, + func() tea.Msg { + return restoreSelectionMsg{clientID: restoreClientID, projectID: restoreProjectID} + }, + ) +} + func (m *AppModel) openEntryEditor() { m.modalBox.activate(ModalTypeEntry, m.historyBox.selectedEntry().ID, *m) m.modalBox.form.fields[0].Focus() diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 4fdd9e0..90bc05f 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -2,9 +2,7 @@ package tui import ( "context" - "fmt" - "strings" - "time" + "errors" "git.tjp.lol/punchcard/internal/actions" "git.tjp.lol/punchcard/internal/queries" @@ -32,6 +30,26 @@ type ( openHistoryFilterModal struct{} openReportModal struct{} updateHistoryFilter HistoryFilter + archiveSelectedMsg struct { + clientID int64 + projectID *int64 + restoreClientID int64 + restoreProjectID *int64 + } + unarchiveSelectedMsg struct { + clientID int64 + projectID *int64 + restoreClientID int64 + restoreProjectID *int64 + } + toggleShowArchivedMsg struct { + restoreClientID int64 + restoreProjectID *int64 + } + restoreSelectionMsg struct{ clientID int64; projectID *int64 } + showArchivedWarningMsg struct{ params *ArchivedPunchInParams } + reportGenerationSucceeded struct{} + reportGenerationFailed struct{ err error } ) func navigate(forward bool) tea.Cmd { @@ -40,8 +58,25 @@ func navigate(forward bool) tea.Cmd { func punchIn(m AppModel) tea.Cmd { return func() tea.Msg { - _, _ = actions.New(m.queries).PunchInMostRecent(context.Background(), "", nil) - // TODO: use the returned TimerSession instead of re-querying everything + a := actions.New(m.queries) + _, err := a.PunchInMostRecent(context.Background(), "", nil, false) + // Handle archived errors by showing modal + if err != nil { + if errors.Is(err, actions.ErrArchivedClient) { + return showArchivedWarningMsg{ + params: &ArchivedPunchInParams{ + EntityType: "client", + }, + } + } else if errors.Is(err, actions.ErrArchivedProject) { + return showArchivedWarningMsg{ + params: &ArchivedPunchInParams{ + EntityType: "project", + }, + } + } + } + return m.refreshCmd() } } @@ -69,8 +104,33 @@ func punchInOnSelection(m AppModel) tea.Cmd { return nil } - _, _ = actions.New(m.queries).PunchIn(context.Background(), clientID, projectID, description, entryRate) - // TODO: use the returned TimerSession instead of re-querying everything + a := actions.New(m.queries) + _, err := a.PunchIn(context.Background(), clientID, projectID, description, entryRate, false) + // Handle archived errors by showing modal + if err != nil { + if errors.Is(err, actions.ErrArchivedClient) { + return showArchivedWarningMsg{ + params: &ArchivedPunchInParams{ + ClientID: clientID, + ProjectID: projectID, + Description: description, + Rate: entryRate, + EntityType: "client", + }, + } + } else if errors.Is(err, actions.ErrArchivedProject) { + return showArchivedWarningMsg{ + params: &ArchivedPunchInParams{ + ClientID: clientID, + ProjectID: projectID, + Description: description, + Rate: entryRate, + EntityType: "project", + }, + } + } + } + return m.refreshCmd() } } @@ -139,51 +199,42 @@ func createReportModal() tea.Cmd { return func() tea.Msg { return openReportModal{} } } -func generateReport(m *ModalBoxModel, am AppModel) tea.Cmd { +func generateReport(am AppModel, genFunc func(context.Context, *queries.Queries, reports.ReportParams) (*reports.ReportResult, error), params reports.ReportParams) tea.Cmd { return func() tea.Msg { - form := &m.form - - dateRange, err := reports.ParseDateRange(form.fields[1].Value()) - if err != nil { - form.fields[1].Err = fmt.Errorf("invalid date range: %v", err) - return reOpenModal() + if _, err := genFunc(context.Background(), am.queries, params); err != nil { + return reportGenerationFailed{err: err} } + return reportGenerationSucceeded{} + } +} - var tz *time.Location - tzstr := form.fields[5].Value() - if tzstr == "" { - tz = time.Local - } else { - zone, err := time.LoadLocation(tzstr) - if err != nil { - form.fields[5].Err = err - return reOpenModal() - } - tz = zone +func archiveClientOrProject(clientID int64, projectID *int64) tea.Cmd { + return func() tea.Msg { + return archiveSelectedMsg{ + clientID: clientID, + projectID: projectID, + restoreClientID: clientID, + restoreProjectID: projectID, } + } +} - var genFunc func(context.Context, *queries.Queries, reports.ReportParams) (*reports.ReportResult, error) - switch strings.ToLower(form.fields[0].Value()) { - case "invoice": - genFunc = reports.GenerateInvoice - case "timesheet": - genFunc = reports.GenerateTimesheet - case "unified": - genFunc = reports.GenerateUnifiedReport +func unarchiveClientOrProject(clientID int64, projectID *int64) tea.Cmd { + return func() tea.Msg { + return unarchiveSelectedMsg{ + clientID: clientID, + projectID: projectID, + restoreClientID: clientID, + restoreProjectID: projectID, } + } +} - params := reports.ReportParams{ - ClientName: form.fields[2].Value(), - ProjectName: form.fields[3].Value(), - DateRange: dateRange, - OutputPath: form.fields[4].Value(), - Timezone: tz, - } - if _, err := genFunc(context.Background(), am.queries, params); err != nil { - form.err = err - return reOpenModal() +func toggleShowArchived(clientID int64, projectID *int64) tea.Cmd { + return func() tea.Msg { + return toggleShowArchivedMsg{ + restoreClientID: clientID, + restoreProjectID: projectID, } - - return nil } } diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go index 760926e..17958c3 100644 --- a/internal/tui/history_box.go +++ b/internal/tui/history_box.go @@ -268,7 +268,7 @@ var ( selectedActiveEntryStyle = lipgloss.NewStyle().Background(lipgloss.Color("196")).Foreground(lipgloss.Color("230")) descriptionStyle = lipgloss.NewStyle() activeDescriptionStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) - filterInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("248")) + filterInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246")) ) // renderSummaryView renders the summary view (level 1) with date headers and client/project summaries diff --git a/internal/tui/keys.go b/internal/tui/keys.go index c4ccad3..c0c5605 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -128,6 +128,75 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map }, Result: func(*AppModel) tea.Cmd { return filterHistoryFromProjectBox() }, }, + "a": KeyBinding{ + Key: "a", + Description: func(m AppModel) string { + visibleClients := m.projectsBox.visibleClients() + if len(visibleClients) == 0 { + return "" + } + if m.projectsBox.selectedClient >= len(visibleClients) { + return "" + } + client := visibleClients[m.projectsBox.selectedClient] + + if m.projectsBox.selectedProject != nil { + // Project selected + visibleProjects := m.projectsBox.visibleProjects(client.ID) + if *m.projectsBox.selectedProject >= len(visibleProjects) { + return "" + } + project := visibleProjects[*m.projectsBox.selectedProject] + if project.Archived != 0 { + return "Unarchive Project" + } + return "Archive Project" + } + + // Client selected + if client.Archived != 0 { + return "Unarchive Client" + } + return "Archive Client" + }, + Result: func(m *AppModel) tea.Cmd { + clientID, projectID := m.projectsBox.getSelectedIDs() + visibleClients := m.projectsBox.visibleClients() + if len(visibleClients) == 0 { + return nil + } + client := visibleClients[m.projectsBox.selectedClient] + + if m.projectsBox.selectedProject != nil { + // Project selected + visibleProjects := m.projectsBox.visibleProjects(client.ID) + project := visibleProjects[*m.projectsBox.selectedProject] + if project.Archived != 0 { + return unarchiveClientOrProject(clientID, projectID) + } + return archiveClientOrProject(clientID, projectID) + } + + // Client selected + if client.Archived != 0 { + return unarchiveClientOrProject(clientID, projectID) + } + return archiveClientOrProject(clientID, projectID) + }, + }, + ".": KeyBinding{ + Key: ".", + Description: func(m AppModel) string { + if m.projectsBox.showArchived { + return "Hide Archived" + } + return "Show Archived" + }, + Result: func(m *AppModel) tea.Cmd { + clientID, projectID := m.projectsBox.getSelectedIDs() + return toggleShowArchived(clientID, projectID) + }, + }, "down": KeyBinding{ Key: "down", Description: func(AppModel) string { return "Down" }, @@ -270,10 +339,7 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map Key: "Enter", Description: func(AppModel) string { return "Submit" }, Result: func(am *AppModel) tea.Cmd { - return tea.Sequence( - closeModal(), - am.modalBox.SubmitForm(*am), - ) + return am.modalBox.SubmitForm(*am) }, }, "esc": KeyBinding{ diff --git a/internal/tui/modal.go b/internal/tui/modal.go index 248654b..b614d04 100644 --- a/internal/tui/modal.go +++ b/internal/tui/modal.go @@ -3,8 +3,10 @@ package tui import ( "context" "database/sql" + "errors" "fmt" "strconv" + "strings" "time" "git.tjp.lol/punchcard/internal/actions" @@ -26,6 +28,7 @@ const ( ModalTypeHistoryFilter ModalTypeGenerateReport ModalTypeContractor + ModalTypeArchivedWarning ) func (mt ModalType) newForm() Form { @@ -56,6 +59,17 @@ type ModalBoxModel struct { form Form editedID int64 + + // For archived warning modal - store punch-in parameters + archivedPunchInParams *ArchivedPunchInParams +} + +type ArchivedPunchInParams struct { + ClientID string + ProjectID string + Description string + Rate *float64 + EntityType string // "client" or "project" } func (m *ModalBoxModel) HandleKeyPress(msg tea.KeyMsg) tea.Cmd { @@ -94,6 +108,8 @@ func (m ModalBoxModel) Render() string { return m.RenderFormModal("⏰ Time Entry") case ModalTypeDeleteConfirmation: return m.RenderDeleteConfirmation() + case ModalTypeArchivedWarning: + return m.RenderArchivedWarning() case ModalTypeClient: return m.RenderFormModal("👤 Client") case ModalTypeProjectCreate, ModalTypeProjectEdit: @@ -128,6 +144,21 @@ func (m ModalBoxModel) RenderDeleteConfirmation() string { ) } +func (m ModalBoxModel) RenderArchivedWarning() string { + entityType := "client" + if m.archivedPunchInParams != nil { + entityType = m.archivedPunchInParams.EntityType + } + + return fmt.Sprintf( + "%s\n\nThis %s is archived.\n\nContinuing will unarchive it and start tracking time.\n\n%s Continue %s Cancel", + modalTitleStyle.Render("⚠️ Archived "+entityType), + entityType, + boldStyle.Render("[Enter]"), + boldStyle.Render("[Esc]"), + ) +} + func (m *ModalBoxModel) activateCreateProjectModal(am AppModel) { m.activate(ModalTypeProjectCreate, 0, am) if am.selectedBox == ProjectsBox && len(am.projectsBox.clients) > 0 { @@ -184,7 +215,7 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { if err != nil { return reOpenModal() } - return tea.Sequence(am.refreshCmd, func() tea.Msg { return recheckBounds{} }) + return tea.Sequence(closeModal(), am.refreshCmd, func() tea.Msg { return recheckBounds{} }) case ModalTypeEntry: if err := m.form.Error(); err != nil { @@ -202,7 +233,7 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { return reOpenModal() } - return am.refreshCmd + return tea.Sequence(closeModal(), am.refreshCmd) case ModalTypeClient: if err := m.form.Error(); err != nil { @@ -238,7 +269,7 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { } } - return am.refreshCmd + return tea.Sequence(closeModal(), am.refreshCmd) case ModalTypeProjectCreate: if err := m.form.Error(); err != nil { @@ -261,7 +292,7 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { return reOpenModal() } - return am.refreshCmd + return tea.Sequence(closeModal(), am.refreshCmd) case ModalTypeProjectEdit: if err := m.form.Error(); err != nil { @@ -284,7 +315,7 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { return reOpenModal() } - return am.refreshCmd + return tea.Sequence(closeModal(), am.refreshCmd) case ModalTypeHistoryFilter: if err := m.form.Error(); err != nil { @@ -335,14 +366,55 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { } // Return filter update message - return func() tea.Msg { return updateHistoryFilter(newFilter) } + return tea.Sequence(closeModal(), func() tea.Msg { return updateHistoryFilter(newFilter) }) case ModalTypeGenerateReport: if err := m.form.Error(); err != nil { return reOpenModal() } - return generateReport(m, am) + // Validate report type + var genFunc func(context.Context, *queries.Queries, reports.ReportParams) (*reports.ReportResult, error) + switch strings.ToLower(m.form.fields[0].Value()) { + case "invoice": + genFunc = reports.GenerateInvoice + case "timesheet": + genFunc = reports.GenerateTimesheet + case "unified": + genFunc = reports.GenerateUnifiedReport + default: + m.form.fields[0].Err = errors.New("pick one of invoice, timesheet, or unified") + return reOpenModal() + } + + // Parse date range + dateRange, err := reports.ParseDateRange(m.form.fields[1].Value()) + if err != nil { + m.form.fields[1].Err = fmt.Errorf("invalid date range: %v", err) + return reOpenModal() + } + + // Parse timezone + var tz *time.Location + tzstr := m.form.fields[5].Value() + if tzstr == "" { + tz = time.Local + } else { + zone, err := time.LoadLocation(tzstr) + if err != nil { + m.form.fields[5].Err = err + return reOpenModal() + } + tz = zone + } + + return generateReport(am, genFunc, reports.ReportParams{ + ClientName: m.form.fields[2].Value(), + ProjectName: m.form.fields[3].Value(), + DateRange: dateRange, + OutputPath: m.form.fields[4].Value(), + Timezone: tz, + }) case ModalTypeContractor: if err := m.form.Error(); err != nil { @@ -358,7 +430,25 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { return reOpenModal() } - return am.refreshCmd + return tea.Sequence(closeModal(), am.refreshCmd) + + case ModalTypeArchivedWarning: + // User confirmed unarchiving - punch in with autoUnarchive=true + if m.archivedPunchInParams == nil { + return nil + } + + a := actions.New(am.queries) + _, _ = a.PunchIn( + context.Background(), + m.archivedPunchInParams.ClientID, + m.archivedPunchInParams.ProjectID, + m.archivedPunchInParams.Description, + m.archivedPunchInParams.Rate, + true, // autoUnarchive + ) + + return tea.Sequence(closeModal(), am.refreshCmd) } return nil diff --git a/internal/tui/projects_box.go b/internal/tui/projects_box.go index 3bf44b5..cd50d5e 100644 --- a/internal/tui/projects_box.go +++ b/internal/tui/projects_box.go @@ -14,6 +14,7 @@ type ClientsProjectsModel struct { projects map[int64][]queries.Project selectedClient int selectedProject *int + showArchived bool } // NewClientsProjectsModel creates a new clients/projects model @@ -21,12 +22,48 @@ func NewClientsProjectsModel() ClientsProjectsModel { return ClientsProjectsModel{} } +// visibleClients returns the list of clients that should be displayed +func (m ClientsProjectsModel) visibleClients() []queries.Client { + if m.showArchived { + return m.clients + } + + visible := make([]queries.Client, 0, len(m.clients)) + for _, client := range m.clients { + if client.Archived == 0 { + visible = append(visible, client) + } + } + return visible +} + +// visibleProjects returns the list of projects for a client that should be displayed +func (m ClientsProjectsModel) visibleProjects(clientID int64) []queries.Project { + allProjects := m.projects[clientID] + if m.showArchived { + return allProjects + } + + visible := make([]queries.Project, 0, len(allProjects)) + for _, project := range allProjects { + if project.Archived == 0 { + visible = append(visible, project) + } + } + return visible +} + // View renders the clients/projects box func (m ClientsProjectsModel) View(width, height int, isSelected bool) string { var content string - if len(m.clients) == 0 { - content = inactiveTimerStyle.Render("No clients found\n\nUse 'punch add client' to\nadd your first client.") + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { + if len(m.clients) == 0 { + content = inactiveTimerStyle.Render("No clients found\n\nUse 'punch add client' to\nadd your first client.") + } else { + content = inactiveTimerStyle.Render("All clients archived\n\nPress '.' to show archived") + } } else { content = m.renderClientsAndProjects() } @@ -47,48 +84,58 @@ func (m ClientsProjectsModel) View(width, height int, isSelected bool) string { // renderClientsAndProjects renders the clients and their projects func (m ClientsProjectsModel) renderClientsAndProjects() string { var content string - absoluteRowIndex := 0 + visibleClients := m.visibleClients() - for i, client := range m.clients { + for i, client := range visibleClients { if i > 0 { content += "\n" } - clientLine := fmt.Sprintf("• %s", client.Name) + // Build client name and rate + clientText := client.Name if client.BillableRate.Valid { rateInDollars := float64(client.BillableRate.Int64) / 100.0 - clientLine += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) + clientText += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) } - // Highlight if this client is selected + // Style for client text clientStyle := lipgloss.NewStyle().Bold(true) if m.selectedClient == i && m.selectedProject == nil { clientStyle = clientStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + } else if client.Archived != 0 { + // Gray out archived clients + clientStyle = clientStyle.Foreground(lipgloss.Color("246")) } - content += clientStyle.Render(clientLine) + "\n" - absoluteRowIndex++ - clientProjects := m.projects[client.ID] - if len(clientProjects) == 0 { + content += "• " + clientStyle.Render(clientText) + "\n" + + visibleProjects := m.visibleProjects(client.ID) + if len(visibleProjects) == 0 { content += " └── (no projects)\n" } else { - for j, project := range clientProjects { + for j, project := range visibleProjects { prefix := "├──" - if j == len(clientProjects)-1 { + if j == len(visibleProjects)-1 { prefix = "└──" } - projectLine := fmt.Sprintf(" %s %s", prefix, project.Name) + // Build project name and rate + projectText := project.Name if project.BillableRate.Valid { rateInDollars := float64(project.BillableRate.Int64) / 100.0 - projectLine += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) + projectText += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) } + // Style for project text projectStyle := lipgloss.NewStyle() if m.selectedClient == i && m.selectedProject != nil && *m.selectedProject == j { projectStyle = projectStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + } else if project.Archived != 0 { + // Gray out archived projects + projectStyle = projectStyle.Foreground(lipgloss.Color("246")) } - content += projectStyle.Render(projectLine) + "\n" + + content += fmt.Sprintf(" %s ", prefix) + projectStyle.Render(projectText) + "\n" } } } @@ -105,16 +152,17 @@ func (m *ClientsProjectsModel) changeSelection(forward bool) { } func (m *ClientsProjectsModel) changeSelectionForward() { - if len(m.clients) == 0 { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { return } - selectedClient := m.clients[m.selectedClient] - projects := m.projects[selectedClient.ID] + selectedClient := visibleClients[m.selectedClient] + visibleProjects := m.visibleProjects(selectedClient.ID) if m.selectedProject == nil { // starting with a client selected - if len(projects) > 0 { + if len(visibleProjects) > 0 { // can jump into the first project zero := 0 m.selectedProject = &zero @@ -122,7 +170,7 @@ func (m *ClientsProjectsModel) changeSelectionForward() { } // there is no next client - at the bottom, no-op - if m.selectedClient == len(m.clients)-1 { + if m.selectedClient == len(visibleClients)-1 { return } @@ -131,10 +179,10 @@ func (m *ClientsProjectsModel) changeSelectionForward() { return } - if *m.selectedProject == len(projects)-1 { + if *m.selectedProject == len(visibleProjects)-1 { // at last project - if m.selectedClient == len(m.clients)-1 { + if m.selectedClient == len(visibleClients)-1 { // also at last client - at the bottom, no-op return } @@ -150,11 +198,12 @@ func (m *ClientsProjectsModel) changeSelectionForward() { } func (m *ClientsProjectsModel) changeSelectionBackward() { - if len(m.clients) == 0 { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { return } - selectedClient := m.clients[m.selectedClient] + selectedClient := visibleClients[m.selectedClient] if m.selectedProject == nil { // starting with a client selected @@ -164,12 +213,12 @@ func (m *ClientsProjectsModel) changeSelectionBackward() { } m.selectedClient-- - selectedClient = m.clients[m.selectedClient] - projects := m.projects[selectedClient.ID] + selectedClient = visibleClients[m.selectedClient] + visibleProjects := m.visibleProjects(selectedClient.ID) - if len(projects) > 0 { + if len(visibleProjects) > 0 { // previous client has projects, jump to last one - i := len(projects) - 1 + i := len(visibleProjects) - 1 m.selectedProject = &i } @@ -188,18 +237,115 @@ func (m *ClientsProjectsModel) changeSelectionBackward() { } func (m ClientsProjectsModel) selection() (string, string, string, *float64) { - if len(m.clients) == 0 { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { return "", "", "", nil } - client := m.clients[m.selectedClient] + client := visibleClients[m.selectedClient] clientID := strconv.FormatInt(client.ID, 10) projectID := "" if m.selectedProject != nil { - project := m.projects[client.ID][*m.selectedProject] + visibleProjects := m.visibleProjects(client.ID) + project := visibleProjects[*m.selectedProject] projectID = strconv.FormatInt(project.ID, 10) } return clientID, projectID, "", nil } + +// resetSelection clamps the selection to valid bounds after visibility changes +func (m *ClientsProjectsModel) resetSelection() { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { + m.selectedClient = 0 + m.selectedProject = nil + return + } + + // Clamp client selection + if m.selectedClient >= len(visibleClients) { + m.selectedClient = len(visibleClients) - 1 + } + + // Clamp project selection + if m.selectedProject != nil { + client := visibleClients[m.selectedClient] + visibleProjects := m.visibleProjects(client.ID) + if len(visibleProjects) == 0 { + m.selectedProject = nil + } else if *m.selectedProject >= len(visibleProjects) { + i := len(visibleProjects) - 1 + m.selectedProject = &i + } + } +} + +// getSelectedIDs returns the currently selected client ID and optional project ID +func (m ClientsProjectsModel) getSelectedIDs() (clientID int64, projectID *int64) { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { + return 0, nil + } + if m.selectedClient >= len(visibleClients) { + return 0, nil + } + + client := visibleClients[m.selectedClient] + clientID = client.ID + + if m.selectedProject != nil { + visibleProjects := m.visibleProjects(client.ID) + if *m.selectedProject >= len(visibleProjects) { + return clientID, nil + } + project := visibleProjects[*m.selectedProject] + projectID = &project.ID + } + + return clientID, projectID +} + +// restoreSelection tries to restore selection to the given IDs, or resets to top if not found +func (m *ClientsProjectsModel) restoreSelection(clientID int64, projectID *int64) { + visibleClients := m.visibleClients() + if len(visibleClients) == 0 { + m.selectedClient = 0 + m.selectedProject = nil + return + } + + // Try to find the client + clientFound := false + for i, client := range visibleClients { + if client.ID == clientID { + m.selectedClient = i + clientFound = true + + // Try to find the project if one was selected + if projectID != nil { + visibleProjects := m.visibleProjects(client.ID) + for j, project := range visibleProjects { + if project.ID == *projectID { + m.selectedProject = &j + return + } + } + // Project not found, select client only + m.selectedProject = nil + return + } + + // No project was selected, just client + m.selectedProject = nil + return + } + } + + // Client not found, reset to top + if !clientFound { + m.selectedClient = 0 + m.selectedProject = nil + } +} diff --git a/internal/tui/shared.go b/internal/tui/shared.go index 8a36108..7271293 100644 --- a/internal/tui/shared.go +++ b/internal/tui/shared.go @@ -254,6 +254,7 @@ func getAppData( Name: projects[i].Name, ClientID: projects[i].ClientID, BillableRate: projects[i].BillableRate, + Archived: projects[i].Archived, CreatedAt: projects[i].CreatedAt, }, ) diff --git a/internal/tui/shared_test.go b/internal/tui/shared_test.go index 1df3eb9..135e02e 100644 --- a/internal/tui/shared_test.go +++ b/internal/tui/shared_test.go @@ -34,6 +34,7 @@ func setupTestDB(t *testing.T) (*queries.Queries, *sql.DB, func()) { name TEXT NOT NULL UNIQUE, email TEXT, billable_rate INTEGER, + archived INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE project ( @@ -41,6 +42,7 @@ func setupTestDB(t *testing.T) (*queries.Queries, *sql.DB, func()) { name TEXT NOT NULL, client_id INTEGER NOT NULL, billable_rate INTEGER, + archived INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (client_id) REFERENCES client (id) ); |