summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/actions/actions.go8
-rw-r--r--internal/actions/archive_test.go618
-rw-r--r--internal/actions/clients.go16
-rw-r--r--internal/actions/projects.go16
-rw-r--r--internal/actions/timer.go74
-rw-r--r--internal/actions/types.go2
-rw-r--r--internal/commands/archive.go141
-rw-r--r--internal/commands/archive_test.go517
-rw-r--r--internal/commands/in.go38
-rw-r--r--internal/commands/root.go2
-rw-r--r--internal/database/queries.sql28
-rw-r--r--internal/database/schema.sql2
-rw-r--r--internal/queries/db.go2
-rw-r--r--internal/queries/models.go4
-rw-r--r--internal/queries/queries.sql.go81
-rw-r--r--internal/reports/api.go31
-rw-r--r--internal/reports/archive_reports_test.go612
-rw-r--r--internal/tui/app.go78
-rw-r--r--internal/tui/commands.go141
-rw-r--r--internal/tui/history_box.go2
-rw-r--r--internal/tui/keys.go74
-rw-r--r--internal/tui/modal.go106
-rw-r--r--internal/tui/projects_box.go210
-rw-r--r--internal/tui/shared.go1
-rw-r--r--internal/tui/shared_test.go2
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)
);