diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..65393cd --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,198 @@ +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, "ID", fmt.Sprintf("%d", selectedEntry.ID)) + ui.PrintInfo(4, "Project", selectedEntry.ProjectName) + ui.PrintInfo(4, "Start", selectedEntry.StartTime.Format("Jan 2, 2006 at 3:04 PM")) + if selectedEntry.EndTime != nil { + ui.PrintInfo(4, "End", selectedEntry.EndTime.Format("Jan 2, 2006 at 3:04 PM")) + ui.PrintInfo(4, "Duration", ui.FormatDuration(selectedEntry.Duration())) + } else { + ui.PrintInfo(4, "Status", ui.Warning("Running")) + } + if selectedEntry.Description != "" { + ui.PrintInfo(4, "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 new file mode 100644 index 0000000..d29e3be --- /dev/null +++ b/cmd/edit.go @@ -0,0 +1,352 @@ +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 + descriptionPrompt := promptui.Prompt{ + Label: fmt.Sprintf("Description: (%s)", currentDescription), + 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, "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(" Start time: %s → %s\n", 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(" End time: %s → %s\n", ui.Muted(oldStr), newStr) + } + + if selectedEntry.Description != editedEntry.Description { + hasChanges = true + fmt.Printf(" Description: %s → %s\n", 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/internal/storage/db.go b/internal/storage/db.go index 3288f2e..046d100 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -426,6 +426,136 @@ func (d *Database) GetAllProjects() ([]string, error) { return projects, nil } +// GetProjectsWithCompletedEntries retrieves all distinct project names that have at least +// one completed time entry (end_time IS NOT NULL) from the time_entries table. +// The results are returned in ascending order by project_name. +// On success it returns a slice of project names (which will be empty if no completed entries exist) +// and a nil error. If the underlying database query or a row scan fails, it returns a +// non-nil error describing the failure. +func (d *Database) GetProjectsWithCompletedEntries() ([]string, error) { + rows, err := d.db.Query(` + SELECT DISTINCT project_name + FROM time_entries + WHERE end_time IS NOT NULL + ORDER BY project_name + `) + + if err != nil { + return nil, fmt.Errorf("failed to query projects: %w", err) + } + + defer rows.Close() + + var projects []string + + for rows.Next() { + var project string + if err := rows.Scan(&project); err != nil { + return nil, fmt.Errorf("failed to scan project: %w", err) + } + + projects = append(projects, project) + } + + return projects, nil +} + +// GetCompletedEntriesByProject retrieves completed time entries (where end_time IS NOT NULL) +// for the specified projectName from the time_entries table. Results are ordered by start_time +// in descending order (newest first). +// +// For each row a TimeEntry is populated. Since this query only returns completed entries, +// the EndTime field will always be non-nil. If the hourly_rate column is NULL the returned +// TimeEntry.HourlyRate will be nil; otherwise HourlyRate will point to the scanned float64. +// +// On success the function returns a slice of pointers to TimeEntry. If there are no +// matching rows the returned slice will have length 0 (it may be nil). On failure the +// function returns a non-nil error and a nil slice. Errors may originate from the +// query execution, row scanning, or row iteration. +func (d *Database) GetCompletedEntriesByProject(projectName string) ([]*TimeEntry, error) { + rows, err := d.db.Query(` + SELECT id, project_name, start_time, end_time, description, hourly_rate + FROM time_entries + WHERE project_name = ? AND end_time IS NOT NULL + ORDER BY start_time DESC + `, projectName) + + if err != nil { + return nil, fmt.Errorf("failed to query entries: %w", err) + } + + defer rows.Close() + + var entries []*TimeEntry + + for rows.Next() { + var entry TimeEntry + var endTime sql.NullTime + var hourlyRate sql.NullFloat64 + + err := rows.Scan(&entry.ID, &entry.ProjectName, &entry.StartTime, &endTime, &entry.Description, &hourlyRate) + if err != nil { + return nil, fmt.Errorf("failed to scan entry: %w", err) + } + + if endTime.Valid { + entry.EndTime = &endTime.Time + } + + if hourlyRate.Valid { + entry.HourlyRate = &hourlyRate.Float64 + } + + entries = append(entries, &entry) + } + + return entries, nil +} + +// UpdateTimeEntry updates an existing time entry in the database with the values from the provided +// TimeEntry. It updates the project_name, start_time, end_time, description and hourly_rate fields +// for the entry with the matching ID. +// +// If the provided entry's EndTime is nil, the end_time column will be set to NULL. +// If the provided entry's HourlyRate is nil, the hourly_rate column will be set to NULL. +// +// Returns an error if the update fails. Does not verify that a row with the given ID exists; +// if no rows are affected the function will still return nil (no error). +func (d *Database) UpdateTimeEntry(id int64, entry *TimeEntry) error { + var endTime sql.NullTime + if entry.EndTime != nil { + endTime = sql.NullTime{Time: *entry.EndTime, Valid: true} + } + + var hourlyRate sql.NullFloat64 + if entry.HourlyRate != nil { + hourlyRate = sql.NullFloat64{Float64: *entry.HourlyRate, Valid: true} + } + + _, err := d.db.Exec(` + UPDATE time_entries + SET project_name = ?, start_time = ?, end_time = ?, description = ?, hourly_rate = ? + WHERE id = ? + `, entry.ProjectName, entry.StartTime, endTime, entry.Description, hourlyRate, id) + + if err != nil { + return fmt.Errorf("failed to update entry: %w", err) + } + + return nil +} + +// DeleteTimeEntry deletes a time entry from the database by its ID. +// Returns an error if the deletion fails. Does not verify that a row with the given ID exists; +// if no rows are affected the function will still return nil (no error). +func (d *Database) DeleteTimeEntry(id int64) error { + _, err := d.db.Exec("DELETE FROM time_entries WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to delete entry: %w", err) + } + return nil +} + // Close closes the Database, releasing any underlying resources. // It delegates to the wrapped database's Close method and returns any error encountered. // After Close is called, the Database must not be used for further operations. diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 707d99d..00ca277 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -17,6 +17,14 @@ const ( ColorGray = "\033[90m" // Muted text ) +// ANSI Text Formatting Constants +const ( + FormatBold = "\033[1m" + FormatDim = "\033[2m" + FormatItalic = "\033[3m" + FormatUnderline = "\033[4m" +) + // Emoji Constants const ( EmojiStart = "✨" @@ -33,27 +41,71 @@ const ( EmojiInfo = "â„šī¸" ) -// Colored output functions that return colored strings +// Success colored output functions that returns colored string func Success(message string) string { return ColorGreen + message + ColorReset } +// Error colored output functions that returns colored string func Error(message string) string { return ColorRed + message + ColorReset } +// Info colored output functions that returns colored string func Info(message string) string { return ColorBlue + message + ColorReset } +// Warning colored output functions that returns colored string func Warning(message string) string { return ColorYellow + message + ColorReset } +// Muted colored output functions that returns colored string func Muted(message string) string { return ColorGray + message + ColorReset } +// Bold text formatting functions that return formatted string +func Bold(message string) string { + return FormatBold + message + ColorReset +} + +// Dim text formatting functions that return formatted string +func Dim(message string) string { + return FormatDim + message + ColorReset +} + +// Italic text formatting functions that return formatted string +func Italic(message string) string { + return FormatItalic + message + ColorReset +} + +// Underline text formatting functions that return formatted string +func Underline(message string) string { + return FormatUnderline + message + ColorReset +} + +// Bold success combined formatting functions for common use cases +func BoldSuccess(message string) string { + return FormatBold + ColorGreen + message + ColorReset +} + +// Bold error combined formatting functions for common use cases +func BoldError(message string) string { + return FormatBold + ColorRed + message + ColorReset +} + +// Bold info combined formatting functions for common use cases +func BoldInfo(message string) string { + return FormatBold + ColorBlue + message + ColorReset +} + +// Bold warning combined formatting functions for common use cases +func BoldWarning(message string) string { + return FormatBold + ColorYellow + message + ColorReset +} + // PrintSuccess prints a success message with emoji and color to stdout func PrintSuccess(emoji, message string) { fmt.Println(Success(fmt.Sprintf("%s %s", emoji, message)))