diff --git a/.goreleaser.yml b/.goreleaser.yml index 166983f..5f63208 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -12,9 +12,9 @@ builds: binary: tmpo ldflags: - -s -w - - -X github.com/DylanDevelops/tmpo/cmd.Version={{.Version}} - - -X github.com/DylanDevelops/tmpo/cmd.Commit={{.Commit}} - - -X github.com/DylanDevelops/tmpo/cmd.Date={{.Date}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Version={{.Version}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Commit={{.Commit}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Date={{.Date}} # Linux - id: linux @@ -25,9 +25,9 @@ builds: binary: tmpo ldflags: - -s -w - - -X github.com/DylanDevelops/tmpo/cmd.Version={{.Version}} - - -X github.com/DylanDevelops/tmpo/cmd.Commit={{.Commit}} - - -X github.com/DylanDevelops/tmpo/cmd.Date={{.Date}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Version={{.Version}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Commit={{.Commit}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Date={{.Date}} # Windows - id: windows @@ -38,9 +38,9 @@ builds: binary: tmpo ldflags: - -s -w - - -X github.com/DylanDevelops/tmpo/cmd.Version={{.Version}} - - -X github.com/DylanDevelops/tmpo/cmd.Commit={{.Commit}} - - -X github.com/DylanDevelops/tmpo/cmd.Date={{.Date}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Version={{.Version}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Commit={{.Commit}} + - -X github.com/DylanDevelops/tmpo/cmd/utilities.Date={{.Date}} archives: - id: windows-zip diff --git a/CLAUDE.md b/CLAUDE.md index a2d9bf2..37c067c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,8 +40,9 @@ goreleaser build --snapshot --clean **CLI Layer** (`cmd/`): - Uses Cobra for command structure -- Each command is a separate file (start.go, stop.go, status.go, etc.) -- All commands registered in `cmd/root.go` via `init()` functions +- Commands organized in subdirectories: tracking/, entries/, history/, setup/, utilities/ +- Each command is a constructor function that returns `*cobra.Command` +- All commands explicitly registered in `cmd/root.go` RootCmd() function - Version information is injected via ldflags during build **Storage Layer** (`internal/storage/`): @@ -83,7 +84,7 @@ defer db.Close() ``` **Project Name Detection:** -The `cmd/start.go:DetectProjectName()` function implements the priority: .tmporc config → Git repository → directory name +The `internal/project.DetectConfiguredProject()` function implements the priority: .tmporc config → Git repository → directory name **Time Entry States:** - Running: EndTime is nil @@ -107,13 +108,13 @@ Uses `modernc.org/sqlite` (pure Go, no CGO) instead of mattn/go-sqlite3. This is **Version Injection:** Version, Commit, and Date are injected at build time via ldflags: ``` --X github.com/DylanDevelops/tmpo/cmd.Version={{.Version}} --X github.com/DylanDevelops/tmpo/cmd.Commit={{.Commit}} --X github.com/DylanDevelops/tmpo/cmd.Date={{.Date}} +-X github.com/DylanDevelops/tmpo/cmd/utilities.Version={{.Version}} +-X github.com/DylanDevelops/tmpo/cmd/utilities.Commit={{.Commit}} +-X github.com/DylanDevelops/tmpo/cmd/utilities.Date={{.Date}} ``` **Command Registration:** -New commands must be added via `rootCmd.AddCommand()` in their `init()` function. +New commands should be created as constructor functions (e.g., `StartCmd()`, `StopCmd()`) that return `*cobra.Command`. Each command registers its own flags before returning. Commands are then registered in `cmd/root.go` RootCmd() by calling `cmd.AddCommand(tracking.StartCmd())`. **Interactive Prompts:** The `manual` command uses `github.com/manifoldco/promptui` for interactive prompts (date/time input). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c397c28..1634401 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,15 +59,18 @@ goreleaser build --snapshot --clean ``` tmpo/ ├── cmd/ # CLI commands (Using Cobra) -│ ├── command1.go -│ ├── command2.go -│ ├── command3.go -│ ├── ... +│ ├── root.go # Root command with RootCmd() constructor +│ ├── tracking/ # Time tracking commands (start, stop, pause, resume, status) +│ ├── entries/ # Entry management (edit, delete, manual) +│ ├── history/ # History commands (log, stats, export) +│ ├── setup/ # Setup commands (init) +│ └── utilities/ # Utility commands (version) ├── internal/ │ ├── config/ # Configuration management (.tmporc files) │ ├── storage/ # SQLite database layer │ ├── project/ # Project detection logic -│ └── export/ # Export functionality +│ ├── export/ # Export functionality +│ └── ui/ # UI helpers (formatting, colors, printing) ├── docs/ # User documentation │ ├── usage.md │ └── configuration.md @@ -78,10 +81,16 @@ tmpo/ ### Key Directories - **`cmd/`**: Contains all CLI command implementations using Cobra + - **`cmd/tracking/`**: Time tracking commands (start, stop, pause, resume, status) + - **`cmd/entries/`**: Entry management commands (edit, delete, manual) + - **`cmd/history/`**: History and reporting commands (log, stats, export) + - **`cmd/setup/`**: Setup and initialization commands (init) + - **`cmd/utilities/`**: Utility commands and version information (version) - **`internal/config/`**: Handles `.tmporc` file parsing and configuration - **`internal/storage/`**: SQLite database operations and models - **`internal/project/`**: Project name detection logic (git/directory/config) -- **`internal/export/`**: Export functionality used by commands +- **`internal/export/`**: Export functionality (CSV, JSON) +- **`internal/ui/`**: UI helpers for formatting, colors, and terminal output - **`docs/`**: User-facing documentation and guides ### Data Storage @@ -107,7 +116,7 @@ When a user runs `tmpo start`, the project name is detected in this priority ord 2. **Git repository** - Uses `git rev-parse --show-toplevel` to find repo root 3. **Directory name** - Falls back to current directory name -This logic lives in `internal/project/detector.go`. +This logic lives in `internal/project/detect.go`. ## Making Changes diff --git a/cmd/delete.go b/cmd/delete.go deleted file mode 100644 index 411e17e..0000000 --- a/cmd/delete.go +++ /dev/null @@ -1,198 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/DylanDevelops/tmpo/internal/storage" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/manifoldco/promptui" - "github.com/spf13/cobra" -) - -var showAllProjectsDelete bool - -var deleteCmd = &cobra.Command{ - Use: "delete", - Short: "Delete a time entry", - Long: `Delete a time entry using an interactive menu.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - ui.PrintSuccess("🗑️", "Delete Time Entry") - fmt.Println() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - defer db.Close() - - var entries []*storage.TimeEntry - var projectName string - - if showAllProjectsDelete { - // Show project selection first - projects, err := db.GetAllProjects() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if len(projects) == 0 { - ui.PrintError(ui.EmojiError, "No time entries found") - ui.NewlineBelow() - os.Exit(1) - } - - projectPrompt := promptui.Select{ - Label: "Select project", - Items: projects, - } - - _, selectedProject, err := projectPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - projectName = selectedProject - } else { - // Use current project - detectedProject, err := DetectProjectName() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) - } - projectName = detectedProject - } - - // Get all entries for the selected/detected project - entries, err = db.GetEntriesByProject(projectName) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if len(entries) == 0 { - ui.PrintError(ui.EmojiError, fmt.Sprintf("No time entries found for project '%s'", projectName)) - if !showAllProjectsDelete { - ui.PrintMuted(0, "Use 'tmpo delete --show-all-projects' to see entries from all projects") - } - ui.NewlineBelow() - os.Exit(1) - } - - // Format entries for selection - templates := &promptui.SelectTemplates{ - Label: "{{ . }}", - Active: "▸ {{ .Label }}", - Inactive: " {{ .Label }}", - Selected: "{{ .Label }}", - } - - type entryItem struct { - Label string - Entry *storage.TimeEntry - } - - var items []entryItem - for _, entry := range entries { - label := formatEntryLabelForDelete(entry) - items = append(items, entryItem{Label: label, Entry: entry}) - } - - entryPrompt := promptui.Select{ - Label: "Select entry to delete", - Items: items, - Templates: templates, - } - - idx, _, err := entryPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - selectedEntry := items[idx].Entry - - // Show entry details and confirmation - fmt.Println() - ui.PrintWarning(ui.EmojiWarning, "You are about to delete this entry:") - fmt.Println() - ui.PrintInfo(4, ui.Bold("ID"), fmt.Sprintf("%d", selectedEntry.ID)) - ui.PrintInfo(4, ui.Bold("Project"), selectedEntry.ProjectName) - ui.PrintInfo(4, ui.Bold("Start"), selectedEntry.StartTime.Format("Jan 2, 2006 at 3:04 PM")) - if selectedEntry.EndTime != nil { - ui.PrintInfo(4, ui.Bold("End"), selectedEntry.EndTime.Format("Jan 2, 2006 at 3:04 PM")) - ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(selectedEntry.Duration())) - } else { - ui.PrintInfo(4, ui.Bold("Status"), ui.Warning("Running")) - } - if selectedEntry.Description != "" { - ui.PrintInfo(4, ui.Bold("Description"), selectedEntry.Description) - } - fmt.Println() - - // Confirm deletion - confirmPrompt := promptui.Select{ - Label: "Are you sure you want to delete this entry?", - Items: []string{"No", "Yes"}, - } - - _, result, err := confirmPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if result == "No" { - ui.PrintWarning(ui.EmojiWarning, "Deletion cancelled") - ui.NewlineBelow() - os.Exit(0) - } - - // Delete from database - if err := db.DeleteTimeEntry(selectedEntry.ID); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - fmt.Println() - ui.PrintSuccess(ui.EmojiSuccess, "Entry deleted successfully") - ui.NewlineBelow() - }, -} - -// formatEntryLabelForDelete formats a time entry for display in the delete selection list -// Shows running entries differently than completed ones -func formatEntryLabelForDelete(entry *storage.TimeEntry) string { - startStr := entry.StartTime.Format("2006-01-02 3:04 PM") - - if entry.EndTime == nil { - // Running entry - description := entry.Description - if description == "" { - description = "(no description)" - } - return fmt.Sprintf("%s → Running - %s", startStr, description) - } - - // Completed entry - endStr := entry.EndTime.Format("3:04 PM") - duration := entry.Duration() - durationStr := ui.FormatDuration(duration) - - description := entry.Description - if description == "" { - description = "(no description)" - } - - return fmt.Sprintf("%s → %s (%s) - %s", startStr, endStr, durationStr, description) -} - -func init() { - rootCmd.AddCommand(deleteCmd) - - deleteCmd.Flags().BoolVar(&showAllProjectsDelete, "show-all-projects", false, "Show project selection before entry selection") -} diff --git a/cmd/edit.go b/cmd/edit.go deleted file mode 100644 index 75fef93..0000000 --- a/cmd/edit.go +++ /dev/null @@ -1,356 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "strings" - - "github.com/DylanDevelops/tmpo/internal/storage" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/manifoldco/promptui" - "github.com/spf13/cobra" -) - -var showAllProjects bool - -var editCmd = &cobra.Command{ - Use: "edit", - Short: "Edit an existing time entry", - Long: `Edit an existing time entry using an interactive menu.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - ui.PrintSuccess("✏️", "Edit Time Entry") - fmt.Println() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - defer db.Close() - - var entries []*storage.TimeEntry - var projectName string - - if showAllProjects { - // Show project selection first - projects, err := db.GetProjectsWithCompletedEntries() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if len(projects) == 0 { - ui.PrintError(ui.EmojiError, "No completed time entries found") - ui.NewlineBelow() - os.Exit(1) - } - - projectPrompt := promptui.Select{ - Label: "Select project", - Items: projects, - } - - _, selectedProject, err := projectPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - projectName = selectedProject - } else { - // Use current project - detectedProject, err := DetectProjectName() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) - } - projectName = detectedProject - } - - // Get completed entries for the selected/detected project - entries, err = db.GetCompletedEntriesByProject(projectName) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if len(entries) == 0 { - ui.PrintError(ui.EmojiError, fmt.Sprintf("No completed time entries found for project '%s'", projectName)) - if !showAllProjects { - ui.PrintMuted(0, "Use 'tmpo edit --show-all-projects' to see entries from all projects") - } - ui.NewlineBelow() - os.Exit(1) - } - - // Format entries for selection - templates := &promptui.SelectTemplates{ - Label: "{{ . }}", - Active: "▸ {{ .Label }}", - Inactive: " {{ .Label }}", - Selected: "{{ .Label }}", - } - - type entryItem struct { - Label string - Entry *storage.TimeEntry - } - - var items []entryItem - for _, entry := range entries { - label := formatEntryLabel(entry) - items = append(items, entryItem{Label: label, Entry: entry}) - } - - entryPrompt := promptui.Select{ - Label: "Select entry to edit", - Items: items, - Templates: templates, - } - - idx, _, err := entryPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - selectedEntry := items[idx].Entry - - // Create a copy of the entry for editing - editedEntry := &storage.TimeEntry{ - ID: selectedEntry.ID, - ProjectName: selectedEntry.ProjectName, - StartTime: selectedEntry.StartTime, - EndTime: selectedEntry.EndTime, - Description: selectedEntry.Description, - HourlyRate: selectedEntry.HourlyRate, - } - - // Edit start date - currentStartDate := selectedEntry.StartTime.Format("01-02-2006") - startDatePrompt := promptui.Prompt{ - Label: fmt.Sprintf("Start date (MM-DD-YYYY): (%s)", currentStartDate), - Validate: validateDateOptional, - AllowEdit: true, - } - - startDateInput, err := startDatePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - startDateInput = strings.TrimSpace(startDateInput) - if startDateInput == "" { - startDateInput = currentStartDate - } - - // Edit start time - currentStartTime := selectedEntry.StartTime.Format("3:04 PM") - startTimePrompt := promptui.Prompt{ - Label: fmt.Sprintf("Start time (e.g., 9:30 AM or 14:30): (%s)", currentStartTime), - Validate: validateTimeOptional, - AllowEdit: true, - } - - startTimeInput, err := startTimePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - startTimeInput = strings.TrimSpace(startTimeInput) - if startTimeInput == "" { - startTimeInput = currentStartTime - } - - // Edit end date - currentEndDate := selectedEntry.EndTime.Format("01-02-2006") - endDatePrompt := promptui.Prompt{ - Label: fmt.Sprintf("End date (MM-DD-YYYY): (%s)", currentEndDate), - Validate: validateDateOptional, - AllowEdit: true, - } - - endDateInput, err := endDatePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - endDateInput = strings.TrimSpace(endDateInput) - if endDateInput == "" { - endDateInput = currentEndDate - } - - // Edit end time - currentEndTime := selectedEntry.EndTime.Format("3:04 PM") - endTimePrompt := promptui.Prompt{ - Label: fmt.Sprintf("End time (e.g., 5:00 PM or 17:00): (%s)", currentEndTime), - Validate: validateTimeOptional, - AllowEdit: true, - } - - endTimeInput, err := endTimePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - endTimeInput = strings.TrimSpace(endTimeInput) - if endTimeInput == "" { - endTimeInput = currentEndTime - } - - // Validate that end is after start - if err := validateEndDateTime(startDateInput, startTimeInput, endDateInput, endTimeInput); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - // Edit description - currentDescription := selectedEntry.Description - descriptionLabel := "Description" - if currentDescription != "" { - descriptionLabel = fmt.Sprintf("Description: (%s)", currentDescription) - } - descriptionPrompt := promptui.Prompt{ - Label: descriptionLabel, - AllowEdit: true, - } - - descriptionInput, err := descriptionPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - descriptionInput = strings.TrimSpace(descriptionInput) - if descriptionInput == "" { - descriptionInput = currentDescription - } - - // Parse the new times - newStartTime, err := parseDateTime(startDateInput, startTimeInput) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) - os.Exit(1) - } - - newEndTime, err := parseDateTime(endDateInput, endTimeInput) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) - os.Exit(1) - } - - // Update the edited entry - editedEntry.StartTime = newStartTime - editedEntry.EndTime = &newEndTime - editedEntry.Description = descriptionInput - - // Show confirmation with diff - fmt.Println() - ui.PrintInfo(0, ui.Bold("Changes to entry"), fmt.Sprintf("#%d", selectedEntry.ID)) - fmt.Println() - - hasChanges := false - - if !selectedEntry.StartTime.Equal(editedEntry.StartTime) { - hasChanges = true - oldStr := selectedEntry.StartTime.Format("01-02-2006 3:04 PM") - newStr := editedEntry.StartTime.Format("01-02-2006 3:04 PM") - fmt.Printf(" %s %s → %s\n", ui.Bold("Start time:"), ui.Muted(oldStr), newStr) - } - - if !selectedEntry.EndTime.Equal(*editedEntry.EndTime) { - hasChanges = true - oldStr := selectedEntry.EndTime.Format("01-02-2006 3:04 PM") - newStr := editedEntry.EndTime.Format("01-02-2006 3:04 PM") - fmt.Printf(" %s %s → %s\n", ui.Bold("End time:"), ui.Muted(oldStr), newStr) - } - - if selectedEntry.Description != editedEntry.Description { - hasChanges = true - fmt.Printf(" %s %s → %s\n", ui.Bold("Description:"), ui.Muted(fmt.Sprintf("%q", selectedEntry.Description)), fmt.Sprintf("%q", editedEntry.Description)) - } - - if !hasChanges { - ui.PrintWarning(ui.EmojiWarning, "No changes detected") - ui.NewlineBelow() - os.Exit(0) - } - - fmt.Println() - - // Confirm save - confirmPrompt := promptui.Select{ - Label: "Save changes?", - Items: []string{"Yes", "No"}, - } - - _, result, err := confirmPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if result == "No" { - ui.PrintWarning(ui.EmojiWarning, "Changes discarded") - ui.NewlineBelow() - os.Exit(0) - } - - // Save to database - if err := db.UpdateTimeEntry(editedEntry.ID, editedEntry); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - fmt.Println() - ui.PrintSuccess(ui.EmojiSuccess, "Entry updated successfully") - ui.NewlineBelow() - }, -} - -// formatEntryLabel formats a time entry for display in the selection list -// Format: "2024-05-21 9:30 AM → 10:30 AM (1h) - Fixed bug in UI" -func formatEntryLabel(entry *storage.TimeEntry) string { - startStr := entry.StartTime.Format("2006-01-02 3:04 PM") - endStr := entry.EndTime.Format("3:04 PM") - duration := entry.Duration() - durationStr := ui.FormatDuration(duration) - - description := entry.Description - if description == "" { - description = "(no description)" - } - - return fmt.Sprintf("%s → %s (%s) - %s", startStr, endStr, durationStr, description) -} - -// validateDateOptional validates date input for edit mode, allowing empty input -// Empty input is valid (indicates keeping current value) -// Non-empty input is validated using the same rules as validateDate -func validateDateOptional(input string) error { - if input == "" { - return nil - } - return validateDate(input) -} - -// validateTimeOptional validates time input for edit mode, allowing empty input -// Empty input is valid (indicates keeping current value) -// Non-empty input is validated using the same rules as validateTime -func validateTimeOptional(input string) error { - if input == "" { - return nil - } - return validateTime(input) -} - -func init() { - rootCmd.AddCommand(editCmd) - - editCmd.Flags().BoolVar(&showAllProjects, "show-all-projects", false, "Show project selection before entry selection") -} diff --git a/cmd/entries/delete.go b/cmd/entries/delete.go new file mode 100644 index 0000000..7f23fe0 --- /dev/null +++ b/cmd/entries/delete.go @@ -0,0 +1,199 @@ +package entries + +import ( + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/project" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +var showAllProjectsDelete bool + +func DeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a time entry", + Long: `Delete a time entry using an interactive menu.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + ui.PrintSuccess("🗑️", "Delete Time Entry") + fmt.Println() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + defer db.Close() + + var entries []*storage.TimeEntry + var projectName string + + if showAllProjectsDelete { + // Show project selection first + projects, err := db.GetAllProjects() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if len(projects) == 0 { + ui.PrintError(ui.EmojiError, "No time entries found") + ui.NewlineBelow() + os.Exit(1) + } + + projectPrompt := promptui.Select{ + Label: "Select project", + Items: projects, + } + + _, selectedProject, err := projectPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + projectName = selectedProject + } else { + // Use current project + detectedProject, err := project.DetectConfiguredProject() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) + os.Exit(1) + } + projectName = detectedProject + } + + // Get all entries for the selected/detected project + entries, err = db.GetEntriesByProject(projectName) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if len(entries) == 0 { + ui.PrintError(ui.EmojiError, fmt.Sprintf("No time entries found for project '%s'", projectName)) + if !showAllProjectsDelete { + ui.PrintMuted(0, "Use 'tmpo delete --show-all-projects' to see entries from all projects") + } + ui.NewlineBelow() + os.Exit(1) + } + + // Format entries for selection + templates := &promptui.SelectTemplates{ + Label: "{{ . }}", + Active: "▸ {{ .Label }}", + Inactive: " {{ .Label }}", + Selected: "{{ .Label }}", + } + + type entryItem struct { + Label string + Entry *storage.TimeEntry + } + + var items []entryItem + for _, entry := range entries { + label := formatEntryLabelForDelete(entry) + items = append(items, entryItem{Label: label, Entry: entry}) + } + + entryPrompt := promptui.Select{ + Label: "Select entry to delete", + Items: items, + Templates: templates, + } + + idx, _, err := entryPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + selectedEntry := items[idx].Entry + + // Show entry details and confirmation + fmt.Println() + ui.PrintWarning(ui.EmojiWarning, "You are about to delete this entry:") + fmt.Println() + ui.PrintInfo(4, ui.Bold("ID"), fmt.Sprintf("%d", selectedEntry.ID)) + ui.PrintInfo(4, ui.Bold("Project"), selectedEntry.ProjectName) + ui.PrintInfo(4, ui.Bold("Start"), selectedEntry.StartTime.Format("Jan 2, 2006 at 3:04 PM")) + if selectedEntry.EndTime != nil { + ui.PrintInfo(4, ui.Bold("End"), selectedEntry.EndTime.Format("Jan 2, 2006 at 3:04 PM")) + ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(selectedEntry.Duration())) + } else { + ui.PrintInfo(4, ui.Bold("Status"), ui.Warning("Running")) + } + if selectedEntry.Description != "" { + ui.PrintInfo(4, ui.Bold("Description"), selectedEntry.Description) + } + fmt.Println() + + // Confirm deletion + confirmPrompt := promptui.Select{ + Label: "Are you sure you want to delete this entry?", + Items: []string{"No", "Yes"}, + } + + _, result, err := confirmPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if result == "No" { + ui.PrintWarning(ui.EmojiWarning, "Deletion cancelled") + ui.NewlineBelow() + os.Exit(0) + } + + // Delete from database + if err := db.DeleteTimeEntry(selectedEntry.ID); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, "Entry deleted successfully") + ui.NewlineBelow() + }, + } + + cmd.Flags().BoolVar(&showAllProjectsDelete, "show-all-projects", false, "Show project selection before entry selection") + + return cmd +} + +// formatEntryLabelForDelete formats a time entry for display in the delete selection list +// Shows running entries differently than completed ones +func formatEntryLabelForDelete(entry *storage.TimeEntry) string { + startStr := entry.StartTime.Format("2006-01-02 3:04 PM") + + if entry.EndTime == nil { + // Running entry + description := entry.Description + if description == "" { + description = "(no description)" + } + return fmt.Sprintf("%s → Running - %s", startStr, description) + } + + // Completed entry + endStr := entry.EndTime.Format("3:04 PM") + duration := entry.Duration() + durationStr := ui.FormatDuration(duration) + + description := entry.Description + if description == "" { + description = "(no description)" + } + + return fmt.Sprintf("%s → %s (%s) - %s", startStr, endStr, durationStr, description) +} diff --git a/cmd/entries/edit.go b/cmd/entries/edit.go new file mode 100644 index 0000000..19509c9 --- /dev/null +++ b/cmd/entries/edit.go @@ -0,0 +1,357 @@ +package entries + +import ( + "fmt" + "os" + "strings" + + "github.com/DylanDevelops/tmpo/internal/project" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +var showAllProjects bool + +func EditCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "edit", + Short: "Edit an existing time entry", + Long: `Edit an existing time entry using an interactive menu.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + ui.PrintSuccess("✏️", "Edit Time Entry") + fmt.Println() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + defer db.Close() + + var entries []*storage.TimeEntry + var projectName string + + if showAllProjects { + // Show project selection first + projects, err := db.GetProjectsWithCompletedEntries() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if len(projects) == 0 { + ui.PrintError(ui.EmojiError, "No completed time entries found") + ui.NewlineBelow() + os.Exit(1) + } + + projectPrompt := promptui.Select{ + Label: "Select project", + Items: projects, + } + + _, selectedProject, err := projectPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + projectName = selectedProject + } else { + // Use current project + detectedProject, err := project.DetectConfiguredProject() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) + os.Exit(1) + } + projectName = detectedProject + } + + // Get completed entries for the selected/detected project + entries, err = db.GetCompletedEntriesByProject(projectName) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if len(entries) == 0 { + ui.PrintError(ui.EmojiError, fmt.Sprintf("No completed time entries found for project '%s'", projectName)) + if !showAllProjects { + ui.PrintMuted(0, "Use 'tmpo edit --show-all-projects' to see entries from all projects") + } + ui.NewlineBelow() + os.Exit(1) + } + + // Format entries for selection + templates := &promptui.SelectTemplates{ + Label: "{{ . }}", + Active: "▸ {{ .Label }}", + Inactive: " {{ .Label }}", + Selected: "{{ .Label }}", + } + + type entryItem struct { + Label string + Entry *storage.TimeEntry + } + + var items []entryItem + for _, entry := range entries { + label := formatEntryLabel(entry) + items = append(items, entryItem{Label: label, Entry: entry}) + } + + entryPrompt := promptui.Select{ + Label: "Select entry to edit", + Items: items, + Templates: templates, + } + + idx, _, err := entryPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + selectedEntry := items[idx].Entry + + // Create a copy of the entry for editing + editedEntry := &storage.TimeEntry{ + ID: selectedEntry.ID, + ProjectName: selectedEntry.ProjectName, + StartTime: selectedEntry.StartTime, + EndTime: selectedEntry.EndTime, + Description: selectedEntry.Description, + HourlyRate: selectedEntry.HourlyRate, + } + + // Edit start date + currentStartDate := selectedEntry.StartTime.Format("01-02-2006") + startDatePrompt := promptui.Prompt{ + Label: fmt.Sprintf("Start date (MM-DD-YYYY): (%s)", currentStartDate), + Validate: validateDateOptional, + AllowEdit: true, + } + + startDateInput, err := startDatePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + startDateInput = strings.TrimSpace(startDateInput) + if startDateInput == "" { + startDateInput = currentStartDate + } + + // Edit start time + currentStartTime := selectedEntry.StartTime.Format("3:04 PM") + startTimePrompt := promptui.Prompt{ + Label: fmt.Sprintf("Start time (e.g., 9:30 AM or 14:30): (%s)", currentStartTime), + Validate: validateTimeOptional, + AllowEdit: true, + } + + startTimeInput, err := startTimePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + startTimeInput = strings.TrimSpace(startTimeInput) + if startTimeInput == "" { + startTimeInput = currentStartTime + } + + // Edit end date + currentEndDate := selectedEntry.EndTime.Format("01-02-2006") + endDatePrompt := promptui.Prompt{ + Label: fmt.Sprintf("End date (MM-DD-YYYY): (%s)", currentEndDate), + Validate: validateDateOptional, + AllowEdit: true, + } + + endDateInput, err := endDatePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + endDateInput = strings.TrimSpace(endDateInput) + if endDateInput == "" { + endDateInput = currentEndDate + } + + // Edit end time + currentEndTime := selectedEntry.EndTime.Format("3:04 PM") + endTimePrompt := promptui.Prompt{ + Label: fmt.Sprintf("End time (e.g., 5:00 PM or 17:00): (%s)", currentEndTime), + Validate: validateTimeOptional, + AllowEdit: true, + } + + endTimeInput, err := endTimePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + endTimeInput = strings.TrimSpace(endTimeInput) + if endTimeInput == "" { + endTimeInput = currentEndTime + } + + // Validate that end is after start + if err := validateEndDateTime(startDateInput, startTimeInput, endDateInput, endTimeInput); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + // Edit description + currentDescription := selectedEntry.Description + descriptionLabel := "Description" + if currentDescription != "" { + descriptionLabel = fmt.Sprintf("Description: (%s)", currentDescription) + } + descriptionPrompt := promptui.Prompt{ + Label: descriptionLabel, + AllowEdit: true, + } + + descriptionInput, err := descriptionPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + descriptionInput = strings.TrimSpace(descriptionInput) + if descriptionInput == "" { + descriptionInput = currentDescription + } + + // Parse the new times + newStartTime, err := parseDateTime(startDateInput, startTimeInput) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) + os.Exit(1) + } + + newEndTime, err := parseDateTime(endDateInput, endTimeInput) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) + os.Exit(1) + } + + // Update the edited entry + editedEntry.StartTime = newStartTime + editedEntry.EndTime = &newEndTime + editedEntry.Description = descriptionInput + + // Show confirmation with diff + fmt.Println() + ui.PrintInfo(0, ui.Bold("Changes to entry"), fmt.Sprintf("#%d", selectedEntry.ID)) + fmt.Println() + + hasChanges := false + + if !selectedEntry.StartTime.Equal(editedEntry.StartTime) { + hasChanges = true + oldStr := selectedEntry.StartTime.Format("01-02-2006 3:04 PM") + newStr := editedEntry.StartTime.Format("01-02-2006 3:04 PM") + fmt.Printf(" %s %s → %s\n", ui.Bold("Start time:"), ui.Muted(oldStr), newStr) + } + + if !selectedEntry.EndTime.Equal(*editedEntry.EndTime) { + hasChanges = true + oldStr := selectedEntry.EndTime.Format("01-02-2006 3:04 PM") + newStr := editedEntry.EndTime.Format("01-02-2006 3:04 PM") + fmt.Printf(" %s %s → %s\n", ui.Bold("End time:"), ui.Muted(oldStr), newStr) + } + + if selectedEntry.Description != editedEntry.Description { + hasChanges = true + fmt.Printf(" %s %s → %s\n", ui.Bold("Description:"), ui.Muted(fmt.Sprintf("%q", selectedEntry.Description)), fmt.Sprintf("%q", editedEntry.Description)) + } + + if !hasChanges { + ui.PrintWarning(ui.EmojiWarning, "No changes detected") + ui.NewlineBelow() + os.Exit(0) + } + + fmt.Println() + + // Confirm save + confirmPrompt := promptui.Select{ + Label: "Save changes?", + Items: []string{"Yes", "No"}, + } + + _, result, err := confirmPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if result == "No" { + ui.PrintWarning(ui.EmojiWarning, "Changes discarded") + ui.NewlineBelow() + os.Exit(0) + } + + // Save to database + if err := db.UpdateTimeEntry(editedEntry.ID, editedEntry); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, "Entry updated successfully") + ui.NewlineBelow() + }, + } + + cmd.Flags().BoolVar(&showAllProjects, "show-all-projects", false, "Show project selection before entry selection") + + return cmd +} + +// formatEntryLabel formats a time entry for display in the selection list +// Format: "2024-05-21 9:30 AM → 10:30 AM (1h) - Fixed bug in UI" +func formatEntryLabel(entry *storage.TimeEntry) string { + startStr := entry.StartTime.Format("2006-01-02 3:04 PM") + endStr := entry.EndTime.Format("3:04 PM") + duration := entry.Duration() + durationStr := ui.FormatDuration(duration) + + description := entry.Description + if description == "" { + description = "(no description)" + } + + return fmt.Sprintf("%s → %s (%s) - %s", startStr, endStr, durationStr, description) +} + +// validateDateOptional validates date input for edit mode, allowing empty input +// Empty input is valid (indicates keeping current value) +// Non-empty input is validated using the same rules as validateDate +func validateDateOptional(input string) error { + if input == "" { + return nil + } + return validateDate(input) +} + +// validateTimeOptional validates time input for edit mode, allowing empty input +// Empty input is valid (indicates keeping current value) +// Non-empty input is validated using the same rules as validateTime +func validateTimeOptional(input string) error { + if input == "" { + return nil + } + return validateTime(input) +} diff --git a/cmd/entries/manual.go b/cmd/entries/manual.go new file mode 100644 index 0000000..406ce0c --- /dev/null +++ b/cmd/entries/manual.go @@ -0,0 +1,291 @@ +package entries + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/DylanDevelops/tmpo/internal/config" + "github.com/DylanDevelops/tmpo/internal/project" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +func ManualCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "manual", + Short: "Create a manual time entry", + Long: `Create a completed time entry by specifying start and end times using an interactive menu.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + ui.PrintSuccess(ui.EmojiManual, "Create Manual Time Entry") + fmt.Println() + + defaultProject := detectProjectNameWithSource() + + var projectLabel string + if defaultProject != "" { + projectLabel = fmt.Sprintf("Project name: (%s)", defaultProject) + } else { + projectLabel = "Project name" + } + + projectPrompt := promptui.Prompt{ + Label: projectLabel, + AllowEdit: true, + } + + projectInput, err := projectPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + projectName := strings.TrimSpace(projectInput) + if projectName == "" { + projectName = defaultProject + } + + if projectName == "" { + ui.PrintError(ui.EmojiError, "project name cannot be empty") + os.Exit(1) + } + + startDatePrompt := promptui.Prompt{ + Label: "Start date (MM-DD-YYYY)", + Validate: validateDate, + } + + startDateInput, err := startDatePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + startTimePrompt := promptui.Prompt{ + Label: "Start time (e.g., 9:30 AM or 14:30)", + Validate: validateTime, + } + + startTimeStr, err := startTimePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + endDateLabel := fmt.Sprintf("End date (MM-DD-YYYY): (%s)", startDateInput) + + endDatePrompt := promptui.Prompt{ + Label: endDateLabel, + AllowEdit: true, + } + + endDateInput, err := endDatePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + endDateInput = strings.TrimSpace(endDateInput) + if endDateInput == "" { + endDateInput = startDateInput + } + + if err := validateDate(endDateInput); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + endTimePrompt := promptui.Prompt{ + Label: "End time (e.g., 5:00 PM or 17:00)", + Validate: validateTime, + } + + endTimeStr, err := endTimePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if err := validateEndDateTime(startDateInput, startTimeStr, endDateInput, endTimeStr); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + descriptionPrompt := promptui.Prompt{ + Label: "Description (optional, press Enter to skip)", + } + + description, err := descriptionPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + startTime, err := parseDateTime(startDateInput, startTimeStr) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) + os.Exit(1) + } + + endTime, err := parseDateTime(endDateInput, endTimeStr) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) + os.Exit(1) + } + + var hourlyRate *float64 + if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { + hourlyRate = &cfg.HourlyRate + } + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + defer db.Close() + + entry, err := db.CreateManualEntry(projectName, description, startTime, endTime, hourlyRate) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + duration := entry.Duration() + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created manual entry for %s", ui.Bold(entry.ProjectName))) + ui.PrintInfo(4, ui.Bold("Start"), startTime.Format("Jan 2, 2006 at 3:04 PM")) + ui.PrintInfo(4, ui.Bold("End"), endTime.Format("Jan 2, 2006 at 3:04 PM")) + ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(duration)) + + if entry.Description != "" { + ui.PrintInfo(4, ui.Bold("Description"), entry.Description) + } + + if entry.HourlyRate != nil { + earnings := duration.Hours() * *entry.HourlyRate + fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), fmt.Sprintf("$%.2f", *entry.HourlyRate)) + fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), fmt.Sprintf("$%.2f", earnings)) + } + + ui.NewlineBelow() + }, + } + + return cmd +} + +// validateDate validates that the provided input is a non-empty date string in MM-DD-YYYY format. +// It attempts to parse the input using the layout "01-02-2006" and returns an error if parsing fails. +// It also rejects dates that are more than 24 hours in the future (i.e., strictly after time.Now().Add(24*time.Hour)). +// Returns nil if the input is valid. +func validateDate(input string) error { + if input == "" { + return fmt.Errorf("date cannot be empty") + } + + date, err := time.Parse("01-02-2006", input) + if err != nil { + return fmt.Errorf("invalid date format, use MM-DD-YYYY") + } + + if date.After(time.Now().Add(24 * time.Hour)) { + return fmt.Errorf("date cannot be in the future") + } + + return nil +} + +// validateTime validates the provided time string. +// It accepts 12-hour formats with an AM/PM designator (e.g., "9:30 AM", "09:30 PM") +// and 24-hour format (e.g., "14:30"). Empty input yields an error. The function +// normalizes AM/PM markers before parsing and returns nil on success or an error +// describing the expected formats on failure. +func validateTime(input string) error { + if input == "" { + return fmt.Errorf("time cannot be empty") + } + + normalizedInput := normalizeAMPM(input) + + if _, err := time.Parse("3:04 PM", normalizedInput); err == nil { + return nil + } + + if _, err := time.Parse("03:04 PM", normalizedInput); err == nil { + return nil + } + + if _, err := time.Parse("15:04", normalizedInput); err == nil { + return nil + } + + return fmt.Errorf("invalid time format, use 12-hour (e.g., 9:30 AM) or 24-hour (e.g., 14:30)") +} + + +// validateEndDateTime verifies that the end date/time represented by +// endDate and endTime is a valid datetime and occurs strictly after the +// start date/time represented by startDate and startTime. +// It returns nil when the end datetime is strictly after the start datetime. +// If parsing of the start or end datetime fails, it returns an error +// wrapping the parse error (prefixed with "invalid start datetime" or +// "invalid end datetime"). If the end is not after the start, it +// returns an error stating that the end time must be after the start time. +func validateEndDateTime(startDate, startTime, endDate, endTime string) error { + start, err := parseDateTime(startDate, startTime) + if err != nil { + return fmt.Errorf("invalid start datetime: %w", err) + } + + end, err := parseDateTime(endDate, endTime) + if err != nil { + return fmt.Errorf("invalid end datetime: %w", err) + } + + if !end.After(start) { + return fmt.Errorf("end time must be after start time") + } + + return nil +} + +// parseDateTime combines date and time strings into time.Time +// Expects date in MM-DD-YYYY format and time in either 12-hour (with AM/PM) or 24-hour format +func parseDateTime(date, timeStr string) (time.Time, error) { + normalizedTime := normalizeAMPM(timeStr) + dateTime := fmt.Sprintf("%s %s", date, normalizedTime) + + if dt, err := time.ParseInLocation("01-02-2006 3:04 PM", dateTime, time.Local); err == nil { + return dt, nil + } + + if dt, err := time.ParseInLocation("01-02-2006 03:04 PM", dateTime, time.Local); err == nil { + return dt, nil + } + + return time.ParseInLocation("01-02-2006 15:04", dateTime, time.Local) +} + +// normalizeAMPM converts lowercase am/pm to uppercase AM/PM +func normalizeAMPM(input string) string { + return strings.ToUpper(input) +} + +// detectProjectNameWithSource returns the project name +func detectProjectNameWithSource() (string) { + if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.ProjectName != "" { + return cfg.ProjectName + } + + projectName, err := project.DetectProject() + if err != nil { + return "" + } + + return projectName +} diff --git a/cmd/manual_test.go b/cmd/entries/manual_test.go similarity index 99% rename from cmd/manual_test.go rename to cmd/entries/manual_test.go index 390588a..d2a3dab 100644 --- a/cmd/manual_test.go +++ b/cmd/entries/manual_test.go @@ -1,4 +1,4 @@ -package cmd +package entries import ( "testing" diff --git a/cmd/export.go b/cmd/export.go deleted file mode 100644 index 6915492..0000000 --- a/cmd/export.go +++ /dev/null @@ -1,118 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "time" - - "github.com/DylanDevelops/tmpo/internal/export" - "github.com/DylanDevelops/tmpo/internal/storage" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/spf13/cobra" -) - -var ( - exportFormat string - exportOutput string - exportProject string - exportToday bool - exportWeek bool -) - -var exportCmd = &cobra.Command{ - Use: "export", - Short: "Export time entries", - Long: `Export time tracking data to different formats.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - var entries []*storage.TimeEntry - - if exportToday { - start := time.Now().Truncate(24 * time.Hour) - end := start.Add(24 * time.Hour) - entries, err = db.GetEntriesByDateRange(start, end) - } else if exportWeek { - now := time.Now() - weekday := int(now.Weekday()) - if weekday == 0 { - weekday = 7 // sunday - } - - start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) - end := start.AddDate(0, 0, 7) - entries, err = db.GetEntriesByDateRange(start, end) - } else if exportProject != "" { - entries, err = db.GetEntriesByProject(exportProject) - } else { - entries, err = db.GetEntries(0) // all - } - - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if len(entries) == 0 { - ui.PrintWarning(ui.EmojiWarning, "No entries to export.") - ui.NewlineBelow() - os.Exit(0) - } - - filename := exportOutput - if filename == "" { - timestamp := time.Now().Format("2006-01-02") - ext := "csv" - - if exportFormat == "json" { - ext = "json" - } - - filename = fmt.Sprintf("tmpo-export-%s.%s", timestamp, ext) - } - - if exportFormat == "csv" && filepath.Ext(filename) != ".csv" { - filename += ".csv" - } else if exportFormat == "json" && filepath.Ext(filename) != ".json" { - filename += ".json" - } - - switch exportFormat { - case "csv": - err = export.ToCSV(entries, filename) - case "json": - err = export.ToJson(entries, filename) - default: - ui.PrintError(ui.EmojiError, fmt.Sprintf("Unknown format '%s'. Use 'csv' or 'json'", exportFormat)) - os.Exit(1) - } - - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - ui.PrintSuccess(ui.EmojiExport, fmt.Sprintf("Exported %s to %s", ui.Bold(fmt.Sprintf("%d entries", len(entries))), ui.Bold(filename))) - - ui.NewlineBelow() - }, -} - -func init() { - rootCmd.AddCommand(exportCmd) - - exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "csv", "Export format (csv or json)") - exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "Output filename") - exportCmd.Flags().StringVarP(&exportProject, "project", "p", "", "Filter by project") - exportCmd.Flags().BoolVarP(&exportToday, "today", "t", false, "Export today's entries") - exportCmd.Flags().BoolVarP(&exportWeek, "week", "w", false, "Export this week's entries") -} diff --git a/cmd/history/export.go b/cmd/history/export.go new file mode 100644 index 0000000..9861807 --- /dev/null +++ b/cmd/history/export.go @@ -0,0 +1,118 @@ +package history + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/DylanDevelops/tmpo/internal/export" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +var ( + exportFormat string + exportOutput string + exportProject string + exportToday bool + exportWeek bool +) + +func ExportCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "export", + Short: "Export time entries", + Long: `Export time tracking data to different formats.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + var entries []*storage.TimeEntry + + if exportToday { + start := time.Now().Truncate(24 * time.Hour) + end := start.Add(24 * time.Hour) + entries, err = db.GetEntriesByDateRange(start, end) + } else if exportWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // sunday + } + + start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end := start.AddDate(0, 0, 7) + entries, err = db.GetEntriesByDateRange(start, end) + } else if exportProject != "" { + entries, err = db.GetEntriesByProject(exportProject) + } else { + entries, err = db.GetEntries(0) // all + } + + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if len(entries) == 0 { + ui.PrintWarning(ui.EmojiWarning, "No entries to export.") + ui.NewlineBelow() + os.Exit(0) + } + + filename := exportOutput + if filename == "" { + timestamp := time.Now().Format("2006-01-02") + ext := "csv" + + if exportFormat == "json" { + ext = "json" + } + + filename = fmt.Sprintf("tmpo-export-%s.%s", timestamp, ext) + } + + if exportFormat == "csv" && filepath.Ext(filename) != ".csv" { + filename += ".csv" + } else if exportFormat == "json" && filepath.Ext(filename) != ".json" { + filename += ".json" + } + + switch exportFormat { + case "csv": + err = export.ToCSV(entries, filename) + case "json": + err = export.ToJson(entries, filename) + default: + ui.PrintError(ui.EmojiError, fmt.Sprintf("Unknown format '%s'. Use 'csv' or 'json'", exportFormat)) + os.Exit(1) + } + + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + ui.PrintSuccess(ui.EmojiExport, fmt.Sprintf("Exported %s to %s", ui.Bold(fmt.Sprintf("%d entries", len(entries))), ui.Bold(filename))) + + ui.NewlineBelow() + }, + } + + cmd.Flags().StringVarP(&exportFormat, "format", "f", "csv", "Export format (csv or json)") + cmd.Flags().StringVarP(&exportOutput, "output", "o", "", "Output filename") + cmd.Flags().StringVarP(&exportProject, "project", "p", "", "Filter by project") + cmd.Flags().BoolVarP(&exportToday, "today", "t", false, "Export today's entries") + cmd.Flags().BoolVarP(&exportWeek, "week", "w", false, "Export this week's entries") + + return cmd +} diff --git a/cmd/history/log.go b/cmd/history/log.go new file mode 100644 index 0000000..2e30a64 --- /dev/null +++ b/cmd/history/log.go @@ -0,0 +1,117 @@ +package history + +import ( + "fmt" + "os" + "time" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +var ( + logLimit int + logProject string + logToday bool + logWeek bool +) + +func LogCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "log", + Short: "View time tracking history", + Long: `Display past time tracking entries with optional filtering.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + var entries []*storage.TimeEntry + + if logToday { + start := time.Now().Truncate(24 * time.Hour) + end := start.Add(24 * time.Hour) + entries, err = db.GetEntriesByDateRange(start, end) + } else if logWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // sunday + } + + start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end := start.AddDate(0, 0, 7) + entries, err = db.GetEntriesByDateRange(start, end) + } else if logProject != "" { + entries, err = db.GetEntriesByProject(logProject) + } else { + entries, err = db.GetEntries(logLimit) + } + + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if len(entries) == 0 { + ui.PrintWarning(ui.EmojiWarning, "No time entries found.") + ui.NewlineBelow() + return + } + + ui.PrintSuccess(ui.EmojiLog, fmt.Sprintf("Time Entries (%d total)", len(entries))) + fmt.Println() + + var totalDuration time.Duration + currentDate := "" + + for _, entry := range entries { + entryDate := entry.StartTime.Format("Mon, Jan 2, 2006") + if entryDate != currentDate { + if currentDate != "" { + fmt.Println() + } + + fmt.Println(ui.Bold(ui.Muted(fmt.Sprintf("─── %s ───", entryDate)))) + currentDate = entryDate + } + + duration := entry.Duration() + totalDuration += duration + + timeRange := entry.StartTime.Format("03:04 PM") + " - " + if entry.EndTime != nil { + timeRange += entry.EndTime.Format("03:04 PM") + " " + } else { + timeRange += ui.Warning("(running)") + " " + } + + fmt.Printf(" %s %s %s\n", timeRange, ui.Bold(fmt.Sprintf("%-20s", entry.ProjectName)), ui.FormatDuration(duration)) + if entry.Description != "" { + fmt.Printf(" %s %s\n", ui.Muted("└─"), entry.Description) + } + } + + fmt.Println() + ui.PrintSeparator() + fmt.Printf("%s %s\n", ui.BoldInfo("Total Time:"), ui.Bold(ui.FormatDuration(totalDuration))) + + ui.NewlineBelow() + }, + } + + cmd.Flags().IntVarP(&logLimit, "limit", "l", 10, "Number of entries to show") + cmd.Flags().StringVarP(&logProject, "project", "p", "", "Filter by project name") + cmd.Flags().BoolVarP(&logToday, "today", "t", false, "Show today's entries") + cmd.Flags().BoolVarP(&logWeek, "week", "w", false, "Show this week's entries") + + return cmd +} diff --git a/cmd/stats.go b/cmd/history/stats.go similarity index 81% rename from cmd/stats.go rename to cmd/history/stats.go index b848045..e96d579 100644 --- a/cmd/stats.go +++ b/cmd/history/stats.go @@ -1,4 +1,4 @@ -package cmd +package history import ( "fmt" @@ -16,57 +16,64 @@ var ( statsWeek bool ) -var statsCmd = &cobra.Command{ - Use: "stats", - Short: "Show time tracking statistics", - Long: `Display statistics and summaries of your time tracking data.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() +func StatsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "stats", + Short: "Show time tracking statistics", + Long: `Display statistics and summaries of your time tracking data.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() - var start, end time.Time - var periodName string + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - if statsToday { - start = time.Now().Truncate(24 * time.Hour) - end = start.Add(24 * time.Hour) - periodName = "Today" - } else if statsWeek { - now := time.Now() - weekday := int(now.Weekday()) - if weekday == 0 { - weekday = 7 + defer db.Close() + + var start, end time.Time + var periodName string + + if statsToday { + start = time.Now().Truncate(24 * time.Hour) + end = start.Add(24 * time.Hour) + periodName = "Today" + } else if statsWeek { + now := time.Now() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + + start = now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) + end = start.AddDate(0, 0, 7) + periodName = "This Week" + } else { + entries, err := db.GetEntries(0) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + ShowAllTimeStats(entries, db) + return } - start = now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) - end = start.AddDate(0, 0, 7) - periodName = "This Week" - } else { - entries, err := db.GetEntries(0) + entries, err := db.GetEntriesByDateRange(start, end) if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } - ShowAllTimeStats(entries, db) - return - } + ShowPeriodStats(entries, periodName) + }, + } - entries, err := db.GetEntriesByDateRange(start, end) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + cmd.Flags().BoolVarP(&statsToday, "today", "t", false, "Show today's stats") + cmd.Flags().BoolVarP(&statsWeek, "week", "w", false, "Show this week's stats") - ShowPeriodStats(entries, periodName) - }, + return cmd } // ShowPeriodStats prints aggregated statistics for a named period to standard @@ -217,10 +224,3 @@ func ShowAllTimeStats(entries []*storage.TimeEntry, db *storage.Database) { ui.NewlineBelow() } - -func init() { - rootCmd.AddCommand(statsCmd) - - statsCmd.Flags().BoolVarP(&statsToday, "today", "t", false, "Show today's stats") - statsCmd.Flags().BoolVarP(&statsWeek, "week", "w", false, "Show this week's stats") -} diff --git a/cmd/init.go b/cmd/init.go deleted file mode 100644 index 0864290..0000000 --- a/cmd/init.go +++ /dev/null @@ -1,170 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/DylanDevelops/tmpo/internal/config" - "github.com/DylanDevelops/tmpo/internal/project" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/manifoldco/promptui" - "github.com/spf13/cobra" -) - -var ( - acceptDefaults bool -) - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize a .tmporc config file", - Long: `Create a .tmporc configuration file in the current directory using an interactive form.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - if _, err := os.Stat(".tmporc"); err == nil { - ui.PrintError(ui.EmojiError, ".tmporc already exists in this directory") - os.Exit(1) - } - - // Detect default project name - defaultName := detectDefaultProjectName() - - var name string - var hourlyRate float64 - var description string - - if acceptDefaults { - // Use all defaults without prompting - name = defaultName - hourlyRate = 0 - description = "" - } else { - // Interactive form - ui.PrintSuccess(ui.EmojiInit, "Initialize Project Configuration") - fmt.Println() - - // Project Name prompt - namePrompt := promptui.Prompt{ - Label: fmt.Sprintf("Project name (%s)", defaultName), - AllowEdit: true, - } - - nameInput, err := namePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - name = strings.TrimSpace(nameInput) - if name == "" { - name = defaultName - } - - // Hourly Rate prompt - ratePrompt := promptui.Prompt{ - Label: "Hourly rate (press Enter to skip)", - Validate: validateHourlyRate, - } - - rateInput, err := ratePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - rateInput = strings.TrimSpace(rateInput) - if rateInput != "" { - hourlyRate, err = strconv.ParseFloat(rateInput, 64) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing hourly rate: %v", err)) - os.Exit(1) - } - } - - // Description prompt - descPrompt := promptui.Prompt{ - Label: "Description (press Enter to skip)", - } - - descInput, err := descPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - description = strings.TrimSpace(descInput) - } - - // Create the .tmporc file - err := config.CreateWithTemplate(name, hourlyRate, description) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - fmt.Println() - ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created .tmporc for project %s", ui.Bold(name))) - if hourlyRate > 0 { - ui.PrintInfo(4, ui.Bold("Hourly Rate"), fmt.Sprintf("$%.2f", hourlyRate)) - } - if description != "" { - ui.PrintInfo(4, ui.Bold("Description"), description) - } - - fmt.Println() - ui.PrintMuted(0, "You can edit .tmporc to customize your project settings.") - - ui.NewlineBelow() - }, -} - -// detectDefaultProjectName returns the auto-detected project name -func detectDefaultProjectName() string { - cwd, err := os.Getwd() - if err != nil { - return "my-project" - } - - name := "" - if project.IsInGitRepo() { - gitName, _ := project.GetGitRoot() - if gitName != "" { - name = filepath.Base(gitName) - } - } - - if name == "" { - name = filepath.Base(cwd) - } - - return name -} - -// validateHourlyRate validates that the input is empty or a valid positive number -func validateHourlyRate(input string) error { - input = strings.TrimSpace(input) - if input == "" { - return nil // Allow empty for optional field - } - - rate, err := strconv.ParseFloat(input, 64) - if err != nil { - return fmt.Errorf("must be a valid number") - } - - if rate < 0 { - return fmt.Errorf("hourly rate cannot be negative") - } - - return nil -} - -func init() { - rootCmd.AddCommand(initCmd) - - initCmd.Flags().BoolVarP(&acceptDefaults, "accept-defaults", "a", false, "Accept all defaults and skip interactive prompts") -} diff --git a/cmd/log.go b/cmd/log.go deleted file mode 100644 index 7ee735a..0000000 --- a/cmd/log.go +++ /dev/null @@ -1,117 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "time" - - "github.com/DylanDevelops/tmpo/internal/storage" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/spf13/cobra" -) - -var ( - logLimit int - logProject string - logToday bool - logWeek bool -) - -var logCmd = &cobra.Command{ - Use: "log", - Short: "View time tracking history", - Long: `Display past time tracking entries with optional filtering.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - var entries []*storage.TimeEntry - - if logToday { - start := time.Now().Truncate(24 * time.Hour) - end := start.Add(24 * time.Hour) - entries, err = db.GetEntriesByDateRange(start, end) - } else if logWeek { - now := time.Now() - weekday := int(now.Weekday()) - if weekday == 0 { - weekday = 7 // sunday - } - - start := now.AddDate(0, 0, -weekday+1).Truncate(24 * time.Hour) - end := start.AddDate(0, 0, 7) - entries, err = db.GetEntriesByDateRange(start, end) - } else if logProject != "" { - entries, err = db.GetEntriesByProject(logProject) - } else { - entries, err = db.GetEntries(logLimit) - } - - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if len(entries) == 0 { - ui.PrintWarning(ui.EmojiWarning, "No time entries found.") - ui.NewlineBelow() - return - } - - ui.PrintSuccess(ui.EmojiLog, fmt.Sprintf("Time Entries (%d total)", len(entries))) - fmt.Println() - - var totalDuration time.Duration - currentDate := "" - - for _, entry := range entries { - entryDate := entry.StartTime.Format("Mon, Jan 2, 2006") - if entryDate != currentDate { - if currentDate != "" { - fmt.Println() - } - - fmt.Println(ui.Bold(ui.Muted(fmt.Sprintf("─── %s ───", entryDate)))) - currentDate = entryDate - } - - duration := entry.Duration() - totalDuration += duration - - timeRange := entry.StartTime.Format("03:04 PM") + " - " - if entry.EndTime != nil { - timeRange += entry.EndTime.Format("03:04 PM") + " " - } else { - timeRange += ui.Warning("(running)") + " " - } - - fmt.Printf(" %s %s %s\n", timeRange, ui.Bold(fmt.Sprintf("%-20s", entry.ProjectName)), ui.FormatDuration(duration)) - if entry.Description != "" { - fmt.Printf(" %s %s\n", ui.Muted("└─"), entry.Description) - } - } - - fmt.Println() - ui.PrintSeparator() - fmt.Printf("%s %s\n", ui.BoldInfo("Total Time:"), ui.Bold(ui.FormatDuration(totalDuration))) - - ui.NewlineBelow() - }, -} - -func init() { - rootCmd.AddCommand(logCmd) - - logCmd.Flags().IntVarP(&logLimit, "limit", "l", 10, "Number of entries to show") - logCmd.Flags().StringVarP(&logProject, "project", "p", "", "Filter by project name") - logCmd.Flags().BoolVarP(&logToday, "today", "t", false, "Show today's entries") - logCmd.Flags().BoolVarP(&logWeek, "week", "w", false, "Show this week's entries") -} diff --git a/cmd/manual.go b/cmd/manual.go deleted file mode 100644 index 40587f1..0000000 --- a/cmd/manual.go +++ /dev/null @@ -1,291 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "strings" - "time" - - "github.com/DylanDevelops/tmpo/internal/config" - "github.com/DylanDevelops/tmpo/internal/project" - "github.com/DylanDevelops/tmpo/internal/storage" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/manifoldco/promptui" - "github.com/spf13/cobra" -) - -var manualCmd = &cobra.Command{ - Use: "manual", - Short: "Create a manual time entry", - Long: `Create a completed time entry by specifying start and end times using an interactive menu.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - ui.PrintSuccess(ui.EmojiManual, "Create Manual Time Entry") - fmt.Println() - - defaultProject := detectProjectNameWithSource() - - var projectLabel string - if defaultProject != "" { - projectLabel = fmt.Sprintf("Project name: (%s)", defaultProject) - } else { - projectLabel = "Project name" - } - - projectPrompt := promptui.Prompt{ - Label: projectLabel, - AllowEdit: true, - } - - projectInput, err := projectPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - projectName := strings.TrimSpace(projectInput) - if projectName == "" { - projectName = defaultProject - } - - if projectName == "" { - ui.PrintError(ui.EmojiError, "project name cannot be empty") - os.Exit(1) - } - - startDatePrompt := promptui.Prompt{ - Label: "Start date (MM-DD-YYYY)", - Validate: validateDate, - } - - startDateInput, err := startDatePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - startTimePrompt := promptui.Prompt{ - Label: "Start time (e.g., 9:30 AM or 14:30)", - Validate: validateTime, - } - - startTimeStr, err := startTimePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - endDateLabel := fmt.Sprintf("End date (MM-DD-YYYY): (%s)", startDateInput) - - endDatePrompt := promptui.Prompt{ - Label: endDateLabel, - AllowEdit: true, - } - - endDateInput, err := endDatePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - endDateInput = strings.TrimSpace(endDateInput) - if endDateInput == "" { - endDateInput = startDateInput - } - - if err := validateDate(endDateInput); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - endTimePrompt := promptui.Prompt{ - Label: "End time (e.g., 5:00 PM or 17:00)", - Validate: validateTime, - } - - endTimeStr, err := endTimePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if err := validateEndDateTime(startDateInput, startTimeStr, endDateInput, endTimeStr); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - descriptionPrompt := promptui.Prompt{ - Label: "Description (optional, press Enter to skip)", - } - - description, err := descriptionPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - startTime, err := parseDateTime(startDateInput, startTimeStr) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) - os.Exit(1) - } - - endTime, err := parseDateTime(endDateInput, endTimeStr) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) - os.Exit(1) - } - - var hourlyRate *float64 - if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { - hourlyRate = &cfg.HourlyRate - } - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - defer db.Close() - - entry, err := db.CreateManualEntry(projectName, description, startTime, endTime, hourlyRate) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - duration := entry.Duration() - fmt.Println() - ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created manual entry for %s", ui.Bold(entry.ProjectName))) - ui.PrintInfo(4, ui.Bold("Start"), startTime.Format("Jan 2, 2006 at 3:04 PM")) - ui.PrintInfo(4, ui.Bold("End"), endTime.Format("Jan 2, 2006 at 3:04 PM")) - ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(duration)) - - if entry.Description != "" { - ui.PrintInfo(4, ui.Bold("Description"), entry.Description) - } - - if entry.HourlyRate != nil { - earnings := duration.Hours() * *entry.HourlyRate - fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), fmt.Sprintf("$%.2f", *entry.HourlyRate)) - fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), fmt.Sprintf("$%.2f", earnings)) - } - - ui.NewlineBelow() - }, -} - -// validateDate validates that the provided input is a non-empty date string in MM-DD-YYYY format. -// It attempts to parse the input using the layout "01-02-2006" and returns an error if parsing fails. -// It also rejects dates that are more than 24 hours in the future (i.e., strictly after time.Now().Add(24*time.Hour)). -// Returns nil if the input is valid. -func validateDate(input string) error { - if input == "" { - return fmt.Errorf("date cannot be empty") - } - - date, err := time.Parse("01-02-2006", input) - if err != nil { - return fmt.Errorf("invalid date format, use MM-DD-YYYY") - } - - if date.After(time.Now().Add(24 * time.Hour)) { - return fmt.Errorf("date cannot be in the future") - } - - return nil -} - -// validateTime validates the provided time string. -// It accepts 12-hour formats with an AM/PM designator (e.g., "9:30 AM", "09:30 PM") -// and 24-hour format (e.g., "14:30"). Empty input yields an error. The function -// normalizes AM/PM markers before parsing and returns nil on success or an error -// describing the expected formats on failure. -func validateTime(input string) error { - if input == "" { - return fmt.Errorf("time cannot be empty") - } - - normalizedInput := normalizeAMPM(input) - - if _, err := time.Parse("3:04 PM", normalizedInput); err == nil { - return nil - } - - if _, err := time.Parse("03:04 PM", normalizedInput); err == nil { - return nil - } - - if _, err := time.Parse("15:04", normalizedInput); err == nil { - return nil - } - - return fmt.Errorf("invalid time format, use 12-hour (e.g., 9:30 AM) or 24-hour (e.g., 14:30)") -} - - -// validateEndDateTime verifies that the end date/time represented by -// endDate and endTime is a valid datetime and occurs strictly after the -// start date/time represented by startDate and startTime. -// It returns nil when the end datetime is strictly after the start datetime. -// If parsing of the start or end datetime fails, it returns an error -// wrapping the parse error (prefixed with "invalid start datetime" or -// "invalid end datetime"). If the end is not after the start, it -// returns an error stating that the end time must be after the start time. -func validateEndDateTime(startDate, startTime, endDate, endTime string) error { - start, err := parseDateTime(startDate, startTime) - if err != nil { - return fmt.Errorf("invalid start datetime: %w", err) - } - - end, err := parseDateTime(endDate, endTime) - if err != nil { - return fmt.Errorf("invalid end datetime: %w", err) - } - - if !end.After(start) { - return fmt.Errorf("end time must be after start time") - } - - return nil -} - -// parseDateTime combines date and time strings into time.Time -// Expects date in MM-DD-YYYY format and time in either 12-hour (with AM/PM) or 24-hour format -func parseDateTime(date, timeStr string) (time.Time, error) { - normalizedTime := normalizeAMPM(timeStr) - dateTime := fmt.Sprintf("%s %s", date, normalizedTime) - - if dt, err := time.ParseInLocation("01-02-2006 3:04 PM", dateTime, time.Local); err == nil { - return dt, nil - } - - if dt, err := time.ParseInLocation("01-02-2006 03:04 PM", dateTime, time.Local); err == nil { - return dt, nil - } - - return time.ParseInLocation("01-02-2006 15:04", dateTime, time.Local) -} - -// normalizeAMPM converts lowercase am/pm to uppercase AM/PM -func normalizeAMPM(input string) string { - return strings.ToUpper(input) -} - -// detectProjectNameWithSource returns the project name -func detectProjectNameWithSource() (string) { - if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.ProjectName != "" { - return cfg.ProjectName - } - - projectName, err := project.DetectProject() - if err != nil { - return "" - } - - return projectName -} - -func init() { - rootCmd.AddCommand(manualCmd) -} diff --git a/cmd/pause.go b/cmd/pause.go deleted file mode 100644 index d5caabf..0000000 --- a/cmd/pause.go +++ /dev/null @@ -1,58 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "time" - - "github.com/DylanDevelops/tmpo/internal/storage" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/spf13/cobra" -) - -var pauseCmd = &cobra.Command{ - Use: "pause", - Short: "Pause time tracking", - Long: `Pause the currently running time tracking session. Use 'tmpo resume' to continue tracking.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - running, err := db.GetRunningEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if running == nil { - ui.PrintWarning(ui.EmojiWarning, "No active time tracking session to pause.") - ui.NewlineBelow() - os.Exit(0) - } - - err = db.StopEntry(running.ID) - if(err != nil) { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - duration := time.Since(running.StartTime) - - ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Paused tracking %s", ui.Bold(running.ProjectName))) - ui.PrintInfo(4, ui.Bold("Session Duration"), ui.FormatDuration(duration)) - ui.PrintMuted(4, "Use 'tmpo resume' to continue tracking") - - ui.NewlineBelow() - }, -} - -func init() { - rootCmd.AddCommand(pauseCmd) -} diff --git a/cmd/resume.go b/cmd/resume.go deleted file mode 100644 index 903b365..0000000 --- a/cmd/resume.go +++ /dev/null @@ -1,71 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/DylanDevelops/tmpo/internal/storage" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/spf13/cobra" -) - -var resumeCmd = &cobra.Command{ - Use: "resume", - Short: "Resume time tracking", - Long: `Resume time tracking by starting a new session with the same project and description as the last paused session.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - running, err := db.GetRunningEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if running != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) - ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") - ui.NewlineBelow() - os.Exit(1) - } - - lastStopped, err := db.GetLastStoppedEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if lastStopped == nil { - ui.PrintError(ui.EmojiError, "No previous session found to resume.") - ui.PrintMuted(0, "Use 'tmpo start' to begin a new session.") - ui.NewlineBelow() - os.Exit(1) - } - - entry, err := db.CreateEntry(lastStopped.ProjectName, lastStopped.Description, lastStopped.HourlyRate) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Resumed tracking time for %s", ui.Bold(entry.ProjectName))) - - if entry.Description != "" { - ui.PrintInfo(4, "Description", entry.Description) - } - - ui.NewlineBelow() - }, -} - -func init() { - rootCmd.AddCommand(resumeCmd) -} diff --git a/cmd/root.go b/cmd/root.go index 70dce17..19ebb7b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,43 +3,67 @@ package cmd import ( "os" + "github.com/DylanDevelops/tmpo/cmd/entries" + "github.com/DylanDevelops/tmpo/cmd/history" + "github.com/DylanDevelops/tmpo/cmd/setup" + "github.com/DylanDevelops/tmpo/cmd/tracking" + "github.com/DylanDevelops/tmpo/cmd/utilities" "github.com/spf13/cobra" ) -var ( - Version = "dev" - Commit = "none" - Date = "unknown" -) - -var rootCmd = &cobra.Command{ - Use: "tmpo", - Short: "Minimal CLI time tracker for developers", - Long: `tmpo - Set the tmpo +func RootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tmpo", + Short: "Minimal CLI time tracker for developers", + Long: `tmpo - Set the tmpo A minimal, developer-friendly time tracking tool that lives in your terminal. Track time effortlessly with automatic project detection and simple commands.`, - Run: func(cmd *cobra.Command, args []string) { - // Check if version flag was set - versionFlag, _ := cmd.Flags().GetBool("version") - - if versionFlag { - DisplayVersionWithUpdateCheck() - return - } - - // Otherwise show help - cmd.Help() - }, + Run: func(cmd *cobra.Command, args []string) { + // Check if version flag was set + versionFlag, _ := cmd.Flags().GetBool("version") + + if versionFlag { + utilities.DisplayVersionWithUpdateCheck() + return + } + + // Otherwise show help + cmd.Help() + }, + } + + cmd.Flags().BoolP("version", "v", false, "version for tmpo") + + // Utilities + cmd.AddCommand(utilities.VersionCmd()) + + // Tracking + cmd.AddCommand(tracking.StartCmd()) + cmd.AddCommand(tracking.StopCmd()) + cmd.AddCommand(tracking.PauseCmd()) + cmd.AddCommand(tracking.ResumeCmd()) + cmd.AddCommand(tracking.StatusCmd()) + + // History + cmd.AddCommand(history.LogCmd()) + cmd.AddCommand(history.StatsCmd()) + cmd.AddCommand(history.ExportCmd()) + + // Entries + cmd.AddCommand(entries.EditCmd()) + cmd.AddCommand(entries.DeleteCmd()) + cmd.AddCommand(entries.ManualCmd()) + + // Setup + cmd.AddCommand(setup.InitCmd()) + + return cmd } func Execute() { - err := rootCmd.Execute() + err := RootCmd().Execute() if err != nil { os.Exit(1) } } - -func init() { - rootCmd.Flags().BoolP("version", "v", false, "version for tmpo") -} diff --git a/cmd/setup/init.go b/cmd/setup/init.go new file mode 100644 index 0000000..048a7a3 --- /dev/null +++ b/cmd/setup/init.go @@ -0,0 +1,170 @@ +package setup + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/DylanDevelops/tmpo/internal/config" + "github.com/DylanDevelops/tmpo/internal/project" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +var ( + acceptDefaults bool +) + +func InitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize a .tmporc config file", + Long: `Create a .tmporc configuration file in the current directory using an interactive form.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + if _, err := os.Stat(".tmporc"); err == nil { + ui.PrintError(ui.EmojiError, ".tmporc already exists in this directory") + os.Exit(1) + } + + // Detect default project name + defaultName := detectDefaultProjectName() + + var name string + var hourlyRate float64 + var description string + + if acceptDefaults { + // Use all defaults without prompting + name = defaultName + hourlyRate = 0 + description = "" + } else { + // Interactive form + ui.PrintSuccess(ui.EmojiInit, "Initialize Project Configuration") + fmt.Println() + + // Project Name prompt + namePrompt := promptui.Prompt{ + Label: fmt.Sprintf("Project name (%s)", defaultName), + AllowEdit: true, + } + + nameInput, err := namePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + name = strings.TrimSpace(nameInput) + if name == "" { + name = defaultName + } + + // Hourly Rate prompt + ratePrompt := promptui.Prompt{ + Label: "Hourly rate (press Enter to skip)", + Validate: validateHourlyRate, + } + + rateInput, err := ratePrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + rateInput = strings.TrimSpace(rateInput) + if rateInput != "" { + hourlyRate, err = strconv.ParseFloat(rateInput, 64) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing hourly rate: %v", err)) + os.Exit(1) + } + } + + // Description prompt + descPrompt := promptui.Prompt{ + Label: "Description (press Enter to skip)", + } + + descInput, err := descPrompt.Run() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + description = strings.TrimSpace(descInput) + } + + // Create the .tmporc file + err := config.CreateWithTemplate(name, hourlyRate, description) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + fmt.Println() + ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Created .tmporc for project %s", ui.Bold(name))) + if hourlyRate > 0 { + ui.PrintInfo(4, ui.Bold("Hourly Rate"), fmt.Sprintf("$%.2f", hourlyRate)) + } + if description != "" { + ui.PrintInfo(4, ui.Bold("Description"), description) + } + + fmt.Println() + ui.PrintMuted(0, "You can edit .tmporc to customize your project settings.") + + ui.NewlineBelow() + }, + } + + cmd.Flags().BoolVarP(&acceptDefaults, "accept-defaults", "a", false, "Accept all defaults and skip interactive prompts") + + return cmd +} + +// detectDefaultProjectName returns the auto-detected project name +func detectDefaultProjectName() string { + cwd, err := os.Getwd() + if err != nil { + return "my-project" + } + + name := "" + if project.IsInGitRepo() { + gitName, _ := project.GetGitRoot() + if gitName != "" { + name = filepath.Base(gitName) + } + } + + if name == "" { + name = filepath.Base(cwd) + } + + return name +} + +// validateHourlyRate validates that the input is empty or a valid positive number +func validateHourlyRate(input string) error { + input = strings.TrimSpace(input) + if input == "" { + return nil // Allow empty for optional field + } + + rate, err := strconv.ParseFloat(input, 64) + if err != nil { + return fmt.Errorf("must be a valid number") + } + + if rate < 0 { + return fmt.Errorf("hourly rate cannot be negative") + } + + return nil +} diff --git a/cmd/init_test.go b/cmd/setup/init_test.go similarity index 99% rename from cmd/init_test.go rename to cmd/setup/init_test.go index 4b0948e..d9edf62 100644 --- a/cmd/init_test.go +++ b/cmd/setup/init_test.go @@ -1,4 +1,4 @@ -package cmd +package setup import ( "os" diff --git a/cmd/start.go b/cmd/start.go deleted file mode 100644 index b641c7c..0000000 --- a/cmd/start.go +++ /dev/null @@ -1,102 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/DylanDevelops/tmpo/internal/config" - "github.com/DylanDevelops/tmpo/internal/project" - "github.com/DylanDevelops/tmpo/internal/storage" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/spf13/cobra" -) - -var startCmd = &cobra.Command{ - Use: "start [description]", - Short: "Start tracking time", - Long: `Start a new time tracking session for the current project.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - running, err := db.GetRunningEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if running != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) - ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") - ui.NewlineBelow() - os.Exit(1) - } - - projectName, err := DetectProjectName() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) - os.Exit(1) - } - - description := "" - if len(args) > 0 { - description = args[0] - } - - // Load config to get hourly rate if available - var hourlyRate *float64 - if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { - hourlyRate = &cfg.HourlyRate - } - - entry, err := db.CreateEntry(projectName, description, hourlyRate) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Started tracking time for %s", ui.Bold(entry.ProjectName))) - - if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil { - ui.PrintMuted(4, "└─ Config Source: .tmporc") - } else if project.IsInGitRepo() { - ui.PrintMuted(4, "└─ Config Source: git repository") - } else { - ui.PrintMuted(4, "└─ Config Source: directory name") - } - - if description != "" { - ui.PrintInfo(4, "Description", description) - } - - ui.NewlineBelow() - }, -} - -// DetectProjectName returns the name of the current project. -// It first attempts to load a configuration via config.FindAndLoad; if a configuration -// is found and its ProjectName field is non-empty, that value is returned. -// If no configuration or project name is available, DetectProjectName falls back to -// project.DetectProject() to determine the project name from the repository or environment. -// The function returns the determined project name and any error encountered during -// configuration loading or fallback detection. -func DetectProjectName() (string, error) { - if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil { - if cfg.ProjectName != "" { - return cfg.ProjectName, nil - } - } - - return project.DetectProject() -} - -func init() { - rootCmd.AddCommand(startCmd) -} diff --git a/cmd/status.go b/cmd/status.go deleted file mode 100644 index 8363438..0000000 --- a/cmd/status.go +++ /dev/null @@ -1,59 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "time" - - "github.com/DylanDevelops/tmpo/internal/storage" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/spf13/cobra" -) - -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show current tracking status", - Long: `Display information about the currently running time tracking session.`, - - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - running, err := db.GetRunningEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if running == nil { - ui.PrintWarning(ui.EmojiWarning, "Not currently tracking time") - ui.NewlineBelow() - ui.PrintMuted(0, "Use 'tmpo start' to begin tracking") - ui.NewlineBelow() - return - } - - duration := time.Since(running.StartTime) - - ui.PrintSuccess(ui.EmojiStatus, fmt.Sprintf("Currently tracking: %s", ui.Bold(running.ProjectName))) - ui.PrintInfo(4, ui.Bold("Started"), running.StartTime.Format("3:04 PM")) - ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(duration)) - - if running.Description != "" { - ui.PrintInfo(4, ui.Bold("Description"), running.Description) - } - - ui.NewlineBelow() - }, -} - -func init() { - rootCmd.AddCommand(statusCmd) -} diff --git a/cmd/stop.go b/cmd/stop.go deleted file mode 100644 index dc6cb40..0000000 --- a/cmd/stop.go +++ /dev/null @@ -1,57 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "time" - - "github.com/DylanDevelops/tmpo/internal/storage" - "github.com/DylanDevelops/tmpo/internal/ui" - "github.com/spf13/cobra" -) - -// stopCmd represents the stop command -var stopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop tracking time", - Long: `Stop the currently running time tracking session.`, - Run: func(cmd *cobra.Command, args []string) { - ui.NewlineAbove() - - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - defer db.Close() - - running, err := db.GetRunningEntry() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - if running == nil { - ui.PrintWarning(ui.EmojiWarning, "No active time tracking session.") - os.Exit(0) - } - - err = db.StopEntry(running.ID) - if(err != nil) { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - - duration := time.Since(running.StartTime) - - ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Stopped tracking %s", ui.Bold(running.ProjectName))) - ui.PrintInfo(4, ui.Bold("Total Duration"), ui.FormatDuration(duration)) - - ui.NewlineBelow() - }, -} - -func init() { - rootCmd.AddCommand(stopCmd) -} diff --git a/cmd/tracking/pause.go b/cmd/tracking/pause.go new file mode 100644 index 0000000..fd57c4a --- /dev/null +++ b/cmd/tracking/pause.go @@ -0,0 +1,58 @@ +package tracking + +import ( + "fmt" + "os" + "time" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +func PauseCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "pause", + Short: "Pause time tracking", + Long: `Pause the currently running time tracking session. Use 'tmpo resume' to continue tracking.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running == nil { + ui.PrintWarning(ui.EmojiWarning, "No active time tracking session to pause.") + ui.NewlineBelow() + os.Exit(0) + } + + err = db.StopEntry(running.ID) + if(err != nil) { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + duration := time.Since(running.StartTime) + + ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Paused tracking %s", ui.Bold(running.ProjectName))) + ui.PrintInfo(4, ui.Bold("Session Duration"), ui.FormatDuration(duration)) + ui.PrintMuted(4, "Use 'tmpo resume' to continue tracking") + + ui.NewlineBelow() + }, + } + + return cmd +} diff --git a/cmd/tracking/resume.go b/cmd/tracking/resume.go new file mode 100644 index 0000000..5d9e954 --- /dev/null +++ b/cmd/tracking/resume.go @@ -0,0 +1,71 @@ +package tracking + +import ( + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +func ResumeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "resume", + Short: "Resume time tracking", + Long: `Resume time tracking by starting a new session with the same project and description as the last paused session.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) + ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") + ui.NewlineBelow() + os.Exit(1) + } + + lastStopped, err := db.GetLastStoppedEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if lastStopped == nil { + ui.PrintError(ui.EmojiError, "No previous session found to resume.") + ui.PrintMuted(0, "Use 'tmpo start' to begin a new session.") + ui.NewlineBelow() + os.Exit(1) + } + + entry, err := db.CreateEntry(lastStopped.ProjectName, lastStopped.Description, lastStopped.HourlyRate) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Resumed tracking time for %s", ui.Bold(entry.ProjectName))) + + if entry.Description != "" { + ui.PrintInfo(4, "Description", entry.Description) + } + + ui.NewlineBelow() + }, + } + + return cmd +} diff --git a/cmd/tracking/start.go b/cmd/tracking/start.go new file mode 100644 index 0000000..76c576e --- /dev/null +++ b/cmd/tracking/start.go @@ -0,0 +1,85 @@ +package tracking + +import ( + "fmt" + "os" + + "github.com/DylanDevelops/tmpo/internal/config" + "github.com/DylanDevelops/tmpo/internal/project" + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +func StartCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "start [description]", + Short: "Start tracking time", + Long: `Start a new time tracking session for the current project.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("Already tracking time for `%s`", running.ProjectName)) + ui.PrintMuted(0, "Use 'tmpo stop' to stop the current session first.") + ui.NewlineBelow() + os.Exit(1) + } + + projectName, err := project.DetectConfiguredProject() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("detecting project: %v", err)) + os.Exit(1) + } + + description := "" + if len(args) > 0 { + description = args[0] + } + + // Load config to get hourly rate if available + var hourlyRate *float64 + if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { + hourlyRate = &cfg.HourlyRate + } + + entry, err := db.CreateEntry(projectName, description, hourlyRate) + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + ui.PrintSuccess(ui.EmojiStart, fmt.Sprintf("Started tracking time for %s", ui.Bold(entry.ProjectName))) + + if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil { + ui.PrintMuted(4, "└─ Config Source: .tmporc") + } else if project.IsInGitRepo() { + ui.PrintMuted(4, "└─ Config Source: git repository") + } else { + ui.PrintMuted(4, "└─ Config Source: directory name") + } + + if description != "" { + ui.PrintInfo(4, "Description", description) + } + + ui.NewlineBelow() + }, + } + + return cmd +} diff --git a/cmd/start_test.go b/cmd/tracking/start_test.go similarity index 89% rename from cmd/start_test.go rename to cmd/tracking/start_test.go index 5f233e5..6561570 100644 --- a/cmd/start_test.go +++ b/cmd/tracking/start_test.go @@ -1,4 +1,4 @@ -package cmd +package tracking import ( "os" @@ -6,6 +6,7 @@ import ( "testing" "github.com/DylanDevelops/tmpo/internal/config" + "github.com/DylanDevelops/tmpo/internal/project" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -30,7 +31,7 @@ func TestDetectProjectName(t *testing.T) { require.NoError(t, err) // Test - projectName, err := DetectProjectName() + projectName, err := project.DetectConfiguredProject() assert.NoError(t, err) assert.Equal(t, "test-project-from-config", projectName) }) @@ -50,7 +51,7 @@ func TestDetectProjectName(t *testing.T) { require.NoError(t, err) // Test - should use directory name as fallback since it's not a real git repo - projectName, err := DetectProjectName() + projectName, err := project.DetectConfiguredProject() assert.NoError(t, err) assert.NotEmpty(t, projectName) }) @@ -66,7 +67,7 @@ func TestDetectProjectName(t *testing.T) { require.NoError(t, err) // Test - projectName, err := DetectProjectName() + projectName, err := project.DetectConfiguredProject() assert.NoError(t, err) // Should use the directory name assert.NotEmpty(t, projectName) @@ -92,7 +93,7 @@ func TestDetectProjectName(t *testing.T) { require.NoError(t, err) // Test - projectName, err := DetectProjectName() + projectName, err := project.DetectConfiguredProject() assert.NoError(t, err) assert.NotEmpty(t, projectName) }) diff --git a/cmd/tracking/status.go b/cmd/tracking/status.go new file mode 100644 index 0000000..da3a784 --- /dev/null +++ b/cmd/tracking/status.go @@ -0,0 +1,59 @@ +package tracking + +import ( + "fmt" + "os" + "time" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +func StatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show current tracking status", + Long: `Display information about the currently running time tracking session.`, + + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running == nil { + ui.PrintWarning(ui.EmojiWarning, "Not currently tracking time") + ui.NewlineBelow() + ui.PrintMuted(0, "Use 'tmpo start' to begin tracking") + ui.NewlineBelow() + return + } + + duration := time.Since(running.StartTime) + + ui.PrintSuccess(ui.EmojiStatus, fmt.Sprintf("Currently tracking: %s", ui.Bold(running.ProjectName))) + ui.PrintInfo(4, ui.Bold("Started"), running.StartTime.Format("3:04 PM")) + ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(duration)) + + if running.Description != "" { + ui.PrintInfo(4, ui.Bold("Description"), running.Description) + } + + ui.NewlineBelow() + }, + } + + return cmd +} diff --git a/cmd/tracking/stop.go b/cmd/tracking/stop.go new file mode 100644 index 0000000..b6afaf6 --- /dev/null +++ b/cmd/tracking/stop.go @@ -0,0 +1,56 @@ +package tracking + +import ( + "fmt" + "os" + "time" + + "github.com/DylanDevelops/tmpo/internal/storage" + "github.com/DylanDevelops/tmpo/internal/ui" + "github.com/spf13/cobra" +) + +func StopCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop tracking time", + Long: `Stop the currently running time tracking session.`, + Run: func(cmd *cobra.Command, args []string) { + ui.NewlineAbove() + + db, err := storage.Initialize() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + defer db.Close() + + running, err := db.GetRunningEntry() + if err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + if running == nil { + ui.PrintWarning(ui.EmojiWarning, "No active time tracking session.") + os.Exit(0) + } + + err = db.StopEntry(running.ID) + if(err != nil) { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } + + duration := time.Since(running.StartTime) + + ui.PrintSuccess(ui.EmojiStop, fmt.Sprintf("Stopped tracking %s", ui.Bold(running.ProjectName))) + ui.PrintInfo(4, ui.Bold("Total Duration"), ui.FormatDuration(duration)) + + ui.NewlineBelow() + }, + } + + return cmd +} diff --git a/cmd/version.go b/cmd/utilities/version.go similarity index 84% rename from cmd/version.go rename to cmd/utilities/version.go index 0fd710d..459f990 100644 --- a/cmd/version.go +++ b/cmd/utilities/version.go @@ -1,4 +1,4 @@ -package cmd +package utilities import ( "fmt" @@ -11,14 +11,24 @@ import ( "github.com/spf13/cobra" ) -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Show version information", - Long: "Display the current version information including date and release URL.", - Hidden: true, - Run: func(cmd *cobra.Command, args []string) { - DisplayVersionWithUpdateCheck() - }, +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) + +func VersionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Show version information", + Long: "Display the current version information including date and release URL.", + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + DisplayVersionWithUpdateCheck() + }, + } + + return cmd } // DisplayVersionWithUpdateCheck displays the version information and checks for updates. @@ -78,7 +88,3 @@ func checkForUpdates() { fmt.Printf("%s\n\n", ui.Muted(updateInfo.UpdateURL)) } } - -func init() { - rootCmd.AddCommand(versionCmd) -} diff --git a/cmd/version_test.go b/cmd/utilities/version_test.go similarity index 99% rename from cmd/version_test.go rename to cmd/utilities/version_test.go index 29942bf..b340e32 100644 --- a/cmd/version_test.go +++ b/cmd/utilities/version_test.go @@ -1,4 +1,4 @@ -package cmd +package utilities import ( "testing" diff --git a/internal/project/detect.go b/internal/project/detect.go index d6edd10..540ee56 100644 --- a/internal/project/detect.go +++ b/internal/project/detect.go @@ -6,6 +6,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/DylanDevelops/tmpo/internal/config" ) // DetectProject attempts to determine the project name using a prioritized strategy: @@ -41,6 +43,23 @@ func DetectProject() (string, error) { return filepath.Base(cwd), nil } + +// DetectConfiguredProject returns the project name specified in the repository +// configuration, if present, otherwise it falls back to auto-detection. +// It loads configuration via config.FindAndLoad(); if a non-empty cfg.ProjectName +// is found that value is returned with a nil error. If no configured project +// name exists or loading fails, DetectProject() is invoked and its result is +// returned (project name, error). +func DetectConfiguredProject() (string, error) { + if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil { + if cfg.ProjectName != "" { + return cfg.ProjectName, nil + } + } + + return DetectProject() +} + // FindTmporc searches the current working directory and each parent directory // up to the filesystem root for a file named ".tmporc". If found, it returns // the path to that file and a nil error. If no ".tmporc" file is found, it