diff --git a/cmd/entries/manual.go b/cmd/entries/manual.go index 0cb2120..99428af 100644 --- a/cmd/entries/manual.go +++ b/cmd/entries/manual.go @@ -42,185 +42,316 @@ func ManualCmd() *cobra.Command { ui.PrintSuccess(ui.EmojiManual, "Create Manual Time Entry") fmt.Println() - // Load global config to get date format preference globalCfg, err := settings.LoadGlobalConfig() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("loading config: %v", err)) os.Exit(1) } - // Get date format for prompts and validation dateFormatDisplay, dateFormatLayout := getDateFormatInfo(globalCfg.DateFormat) - defaultProject := detectProjectNameWithSource(manualProjectFlag) - 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() + db, err := storage.Initialize() if err != nil { ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) os.Exit(1) } + defer db.Close() - projectName := strings.TrimSpace(projectInput) - if projectName == "" { - projectName = defaultProject - } - - if projectName == "" { - ui.PrintError(ui.EmojiError, "project name cannot be empty") - os.Exit(1) + var hourlyRate *float64 + if cfg, _, cfgErr := settings.FindAndLoad(); cfgErr == nil && cfg != nil && cfg.HourlyRate > 0 { + hourlyRate = &cfg.HourlyRate } todayDate := time.Now().Format(dateFormatLayout) - startDatePrompt := promptui.Prompt{ - Label: fmt.Sprintf("Start date (%s): (%s)", dateFormatDisplay, todayDate), - AllowEdit: true, - Validate: func(input string) error { - if strings.TrimSpace(input) == "" { - return nil + var ( + projectName string + startDateInput string + startTimeStr string + endDateInput string + endTimeStr string + description string + milestoneName *string + startTime time.Time + endTime time.Time + ) + + for { + // project name + projectHint := defaultProject + if projectName != "" { + projectHint = projectName + } + + var projectLabel string + if projectHint != "" { + projectLabel = fmt.Sprintf("Project name: (%s)", projectHint) + } else { + projectLabel = "Project name" + } + + projectPrompt := promptui.Prompt{ + Label: projectLabel, + AllowEdit: true, + } + + projectInput, promptErr := projectPrompt.Run() + if promptErr != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) + os.Exit(1) + } + + projectInput = strings.TrimSpace(projectInput) + if projectInput == "" { + projectName = projectHint + } else { + projectName = projectInput + } + + if projectName == "" { + ui.PrintError(ui.EmojiError, "project name cannot be empty") + os.Exit(1) + } + + // start date + startDateHint := startDateInput + if startDateHint == "" { + startDateHint = todayDate + } + + startDatePrompt := promptui.Prompt{ + Label: fmt.Sprintf("Start date (%s): (%s)", dateFormatDisplay, startDateHint), + AllowEdit: true, + Validate: func(input string) error { + if strings.TrimSpace(input) == "" { + return nil + } + return validateDate(input, dateFormatLayout, dateFormatDisplay) + }, + } + + startDateVal, promptErr := startDatePrompt.Run() + if promptErr != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) + os.Exit(1) + } + + startDateVal = strings.TrimSpace(startDateVal) + if startDateVal == "" { + startDateInput = startDateHint + } else { + startDateInput = startDateVal + } + + // start time + var startTimePrompt promptui.Prompt + if startTimeStr != "" { + startTimePrompt = promptui.Prompt{ + Label: fmt.Sprintf("Start time (e.g., 9:30 AM or 14:30): (%s)", startTimeStr), + Validate: validateTimeOptional, + AllowEdit: true, } - - return validateDate(input, dateFormatLayout, dateFormatDisplay) - }, - } + } else { + startTimePrompt = promptui.Prompt{ + Label: "Start time (e.g., 9:30 AM or 14:30)", + Validate: validateTime, + } + } - startDateInput, err := startDatePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + startTimeVal, promptErr := startTimePrompt.Run() + if promptErr != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) + os.Exit(1) + } - startDateInput = strings.TrimSpace(startDateInput) - if startDateInput == "" { - startDateInput = todayDate - } + startTimeVal = strings.TrimSpace(startTimeVal) + if startTimeVal != "" { + startTimeStr = startTimeVal + } - startTimePrompt := promptui.Prompt{ - Label: "Start time (e.g., 9:30 AM or 14:30)", - Validate: validateTime, - } + // end date + endDateHint := endDateInput + if endDateHint == "" { + endDateHint = startDateInput + } - startTimeStr, err := startTimePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + endDatePrompt := promptui.Prompt{ + Label: fmt.Sprintf("End date (%s): (%s)", dateFormatDisplay, endDateHint), + AllowEdit: true, + } - endDateLabel := fmt.Sprintf("End date (%s): (%s)", dateFormatDisplay, startDateInput) + endDateVal, promptErr := endDatePrompt.Run() + if promptErr != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) + os.Exit(1) + } - endDatePrompt := promptui.Prompt{ - Label: endDateLabel, - AllowEdit: true, - } + endDateVal = strings.TrimSpace(endDateVal) + if endDateVal == "" { + endDateInput = endDateHint + } else { + endDateInput = endDateVal + } - endDateInput, err := endDatePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + if err := validateDate(endDateInput, dateFormatLayout, dateFormatDisplay); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - endDateInput = strings.TrimSpace(endDateInput) - if endDateInput == "" { - endDateInput = startDateInput - } + // end time + var endTimePrompt promptui.Prompt + if endTimeStr != "" { + endTimePrompt = promptui.Prompt{ + Label: fmt.Sprintf("End time (e.g., 5:00 PM or 17:00): (%s)", endTimeStr), + Validate: validateTimeOptional, + AllowEdit: true, + } + } else { + endTimePrompt = promptui.Prompt{ + Label: "End time (e.g., 5:00 PM or 17:00)", + Validate: validateTime, + } + } - if err := validateDate(endDateInput, dateFormatLayout, dateFormatDisplay); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + endTimeVal, promptErr := endTimePrompt.Run() + if promptErr != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) + os.Exit(1) + } - endTimePrompt := promptui.Prompt{ - Label: "End time (e.g., 5:00 PM or 17:00)", - Validate: validateTime, - } + endTimeVal = strings.TrimSpace(endTimeVal) + if endTimeVal != "" { + endTimeStr = endTimeVal + } - 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, dateFormatLayout); err != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + os.Exit(1) + } - if err := validateEndDateTime(startDateInput, startTimeStr, endDateInput, endTimeStr, dateFormatLayout); err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + // description + var descLabel string + if description != "" { + descLabel = fmt.Sprintf("Description: (%s)", description) + } else { + descLabel = "Description (optional, press Enter to skip)" + } - descriptionPrompt := promptui.Prompt{ - Label: "Description (optional, press Enter to skip)", - } + descriptionPrompt := promptui.Prompt{ + Label: descLabel, + AllowEdit: description != "", + } - description, err := descriptionPrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } + descVal, promptErr := descriptionPrompt.Run() + if promptErr != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) + os.Exit(1) + } - startTime, err := parseDateTime(startDateInput, startTimeStr, dateFormatLayout) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", err)) - os.Exit(1) - } + descVal = strings.TrimSpace(descVal) + if descVal != "" { + description = descVal + } - endTime, err := parseDateTime(endDateInput, endTimeStr, dateFormatLayout) - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", err)) - os.Exit(1) - } + parsedStart, parseErr := parseDateTime(startDateInput, startTimeStr, dateFormatLayout) + if parseErr != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing start time: %v", parseErr)) + os.Exit(1) + } + startTime = parsedStart - var hourlyRate *float64 - if cfg, _, err := settings.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 { - hourlyRate = &cfg.HourlyRate - } + parsedEnd, parseErr := parseDateTime(endDateInput, endTimeStr, dateFormatLayout) + if parseErr != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("parsing end time: %v", parseErr)) + os.Exit(1) + } + endTime = parsedEnd + + // milestone + milestoneName = nil + milestones, milestoneErr := db.GetMilestonesByProject(projectName) + if milestoneErr == nil && len(milestones) > 0 { + milestoneOptions := []string{"(None)"} + for _, m := range milestones { + status := "Active" + if !m.IsActive() { + status = "Finished" + } + milestoneOptions = append(milestoneOptions, fmt.Sprintf("%s (%s)", m.Name, status)) + } - db, err := storage.Initialize() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) - os.Exit(1) - } - defer db.Close() + milestonePrompt := promptui.Select{ + Label: "Assign to milestone (optional)", + Items: milestoneOptions, + } + + milestoneIdx, _, promptErr := milestonePrompt.Run() + if promptErr != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) + os.Exit(1) + } - // Check for available milestones - var milestoneName *string - milestones, err := db.GetMilestonesByProject(projectName) - if err == nil && len(milestones) > 0 { - // Build milestone options - milestoneOptions := []string{"(None)"} - for _, m := range milestones { - status := "Active" - if !m.IsActive() { - status = "Finished" + if milestoneIdx > 0 { + selectedMilestone := milestones[milestoneIdx-1] + milestoneName = &selectedMilestone.Name } - milestoneOptions = append(milestoneOptions, fmt.Sprintf("%s (%s)", m.Name, status)) } - milestonePrompt := promptui.Select{ - Label: "Assign to milestone (optional)", - Items: milestoneOptions, + // summary + tempEntry := &storage.TimeEntry{StartTime: startTime, EndTime: &endTime, HourlyRate: hourlyRate} + duration := tempEntry.Duration() + + fmt.Println() + ui.PrintInfo(0, ui.Bold("Entry Summary"), "") + fmt.Println() + ui.PrintInfo(4, ui.Bold("Project"), projectName) + ui.PrintInfo(4, ui.Bold("Start"), settings.FormatDateTimeLong(startTime)) + ui.PrintInfo(4, ui.Bold("End"), settings.FormatDateTimeLong(endTime)) + ui.PrintInfo(4, ui.Bold("Duration"), ui.FormatDuration(duration)) + + if description != "" { + ui.PrintInfo(4, ui.Bold("Description"), description) + } else { + ui.PrintInfo(4, ui.Bold("Description"), ui.Muted("(none)")) } - milestoneIdx, _, err := milestonePrompt.Run() - if err != nil { - ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err)) + if milestoneName != nil && *milestoneName != "" { + ui.PrintInfo(4, ui.Bold("Milestone"), *milestoneName) + } else { + ui.PrintInfo(4, ui.Bold("Milestone"), ui.Muted("(none)")) + } + + if hourlyRate != nil { + earnings := tempEntry.RoundedHours() * *hourlyRate + fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), currency.FormatCurrency(*hourlyRate, globalCfg.Currency)) + fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), currency.FormatCurrency(earnings, globalCfg.Currency)) + } + + fmt.Println() + + // confirmation + confirmPrompt := promptui.Select{ + Label: "What would you like to do?", + Items: []string{"Confirm", "Edit", "Cancel"}, + } + + _, result, promptErr := confirmPrompt.Run() + if promptErr != nil { + ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", promptErr)) os.Exit(1) } - // If not "(None)", assign the milestone - if milestoneIdx > 0 { - selectedMilestone := milestones[milestoneIdx-1] - milestoneName = &selectedMilestone.Name + if result == "Confirm" { + break + } else if result == "Cancel" { + fmt.Println() + ui.PrintWarning(ui.EmojiWarning, "Entry creation cancelled") + ui.NewlineBelow() + os.Exit(0) } + + fmt.Println() } entry, err := db.CreateManualEntry(projectName, description, startTime, endTime, hourlyRate, milestoneName) @@ -245,15 +376,9 @@ func ManualCmd() *cobra.Command { } if entry.HourlyRate != nil { - // Get currency from global config - currencyCode := currency.DefaultCurrency - if globalCfg, err := settings.LoadGlobalConfig(); err == nil { - currencyCode = globalCfg.Currency - } - earnings := entry.RoundedHours() * *entry.HourlyRate - fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), currency.FormatCurrency(*entry.HourlyRate, currencyCode)) - fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), currency.FormatCurrency(earnings, currencyCode)) + fmt.Printf(" %s %s\n", ui.BoldInfo("Hourly Rate:"), currency.FormatCurrency(*entry.HourlyRate, globalCfg.Currency)) + fmt.Printf(" %s %s\n", ui.BoldInfo("Earnings:"), currency.FormatCurrency(earnings, globalCfg.Currency)) } ui.NewlineBelow()