diff --git a/cmd/manual.go b/cmd/manual.go new file mode 100644 index 0000000..020f99e --- /dev/null +++ b/cmd/manual.go @@ -0,0 +1,283 @@ +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/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) { + fmt.Println("\n[tmpo] Create Manual Time Entry") + + 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 { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + projectName := strings.TrimSpace(projectInput) + if projectName == "" { + projectName = defaultProject + } + + if projectName == "" { + fmt.Fprintf(os.Stderr, "Error: project name cannot be empty\n") + os.Exit(1) + } + + startDatePrompt := promptui.Prompt{ + Label: "Start date (MM-DD-YYYY)", + Validate: validateDate, + } + + startDateInput, err := startDatePrompt.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", 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 { + fmt.Fprintf(os.Stderr, "Error: %v\n", 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 { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + endDateInput = strings.TrimSpace(endDateInput) + if endDateInput == "" { + endDateInput = startDateInput + } + + if err := validateDate(endDateInput); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", 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 { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if err := validateEndDateTime(startDateInput, startTimeStr, endDateInput, endTimeStr); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + descriptionPrompt := promptui.Prompt{ + Label: "Description (optional, press Enter to skip)", + } + + description, err := descriptionPrompt.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + startTime, err := parseDateTime(startDateInput, startTimeStr) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing start time: %v\n", err) + os.Exit(1) + } + + endTime, err := parseDateTime(endDateInput, endTimeStr) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing end time: %v\n", 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 { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer db.Close() + + entry, err := db.CreateManualEntry(projectName, description, startTime, endTime, hourlyRate) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + duration := entry.Duration() + fmt.Printf("\n[tmpo] Created manual entry for '%s'\n", entry.ProjectName) + fmt.Printf(" Start: %s\n", startTime.Format("Jan 2, 2006 at 3:04 PM")) + fmt.Printf(" End: %s\n", endTime.Format("Jan 2, 2006 at 3:04 PM")) + fmt.Printf(" Duration: %s\n", formatDuration(duration)) + + if entry.HourlyRate != nil { + earnings := duration.Hours() * *entry.HourlyRate + fmt.Printf(" Hourly Rate: $%.2f\n", *entry.HourlyRate) + fmt.Printf(" Estimated Earnings: $%.2f\n", earnings) + } + + fmt.Println() + }, +} + +// 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/go.mod b/go.mod index 225ea47..2e5420a 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/DylanDevelops/tmpo go 1.25.5 require ( + github.com/manifoldco/promptui v0.9.0 github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v3 v3.0.4 modernc.org/sqlite v1.40.1 ) require ( + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 96201ff..a522fee 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -7,6 +13,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -27,6 +35,7 @@ golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/storage/db.go b/internal/storage/db.go index e6a1a06..3288f2e 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -110,6 +110,41 @@ func (d *Database) CreateEntry(projectName, description string, hourlyRate *floa return d.GetEntry(id) } +// CreateManualEntry inserts a completed time entry with specific start and end times. +// Unlike CreateEntry which uses the current time and leaves end_time NULL, this method +// creates a fully specified historical entry for manual record-keeping. +// If hourlyRate is nil, the hourly_rate column will be set to NULL. On success it returns +// the created *TimeEntry (retrieved by querying the database for the last insert id). +// If the insert or the subsequent retrieval fails, an error wrapping the underlying +// database error is returned. +func (d *Database) CreateManualEntry(projectName, description string, startTime, endTime time.Time, hourlyRate *float64) (*TimeEntry, error) { + var rate sql.NullFloat64 + if hourlyRate != nil { + rate = sql.NullFloat64{Float64: *hourlyRate, Valid: true} + } + + result, err := d.db.Exec( + "INSERT INTO time_entries (project_name, start_time, end_time, description, hourly_rate) VALUES (?, ?, ?, ?, ?)", + projectName, + startTime, + endTime, + description, + rate, + ) + + if err != nil { + return nil, fmt.Errorf("failed to create manual entry: %w", err) + } + + id, err := result.LastInsertId() + + if err != nil { + return nil, fmt.Errorf("failed to get last insert id: %w", err) + } + + return d.GetEntry(id) +} + // GetRunningEntry retrieves the most recently started time entry that is still running // (i.e. has a NULL end_time) from the time_entries table. The query orders by // start_time descending and returns at most one row.