Skip to content

Commit 246e388

Browse files
Merge pull request #7 from DylanDevelops/ravel/manual-time-entry-creation
feat: `tmpo manual` for manual time entry creation
2 parents 0e675c0 + dfd2bb5 commit 246e388

4 files changed

Lines changed: 329 additions & 0 deletions

File tree

cmd/manual.go

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
"time"
8+
9+
"github.com/DylanDevelops/tmpo/internal/config"
10+
"github.com/DylanDevelops/tmpo/internal/project"
11+
"github.com/DylanDevelops/tmpo/internal/storage"
12+
"github.com/manifoldco/promptui"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var manualCmd = &cobra.Command{
17+
Use: "manual",
18+
Short: "Create a manual time entry",
19+
Long: `Create a completed time entry by specifying start and end times using an interactive menu.`,
20+
Run: func(cmd *cobra.Command, args []string) {
21+
fmt.Println("\n[tmpo] Create Manual Time Entry")
22+
23+
defaultProject := detectProjectNameWithSource()
24+
25+
var projectLabel string
26+
if defaultProject != "" {
27+
projectLabel = fmt.Sprintf("Project name: (%s)", defaultProject)
28+
} else {
29+
projectLabel = "Project name"
30+
}
31+
32+
projectPrompt := promptui.Prompt{
33+
Label: projectLabel,
34+
AllowEdit: true,
35+
}
36+
37+
projectInput, err := projectPrompt.Run()
38+
if err != nil {
39+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
40+
os.Exit(1)
41+
}
42+
43+
projectName := strings.TrimSpace(projectInput)
44+
if projectName == "" {
45+
projectName = defaultProject
46+
}
47+
48+
if projectName == "" {
49+
fmt.Fprintf(os.Stderr, "Error: project name cannot be empty\n")
50+
os.Exit(1)
51+
}
52+
53+
startDatePrompt := promptui.Prompt{
54+
Label: "Start date (MM-DD-YYYY)",
55+
Validate: validateDate,
56+
}
57+
58+
startDateInput, err := startDatePrompt.Run()
59+
if err != nil {
60+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
61+
os.Exit(1)
62+
}
63+
64+
startTimePrompt := promptui.Prompt{
65+
Label: "Start time (e.g., 9:30 AM or 14:30)",
66+
Validate: validateTime,
67+
}
68+
69+
startTimeStr, err := startTimePrompt.Run()
70+
if err != nil {
71+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
72+
os.Exit(1)
73+
}
74+
75+
endDateLabel := fmt.Sprintf("End date (MM-DD-YYYY): (%s)", startDateInput)
76+
77+
endDatePrompt := promptui.Prompt{
78+
Label: endDateLabel,
79+
AllowEdit: true,
80+
}
81+
82+
endDateInput, err := endDatePrompt.Run()
83+
if err != nil {
84+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
85+
os.Exit(1)
86+
}
87+
88+
endDateInput = strings.TrimSpace(endDateInput)
89+
if endDateInput == "" {
90+
endDateInput = startDateInput
91+
}
92+
93+
if err := validateDate(endDateInput); err != nil {
94+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
95+
os.Exit(1)
96+
}
97+
98+
endTimePrompt := promptui.Prompt{
99+
Label: "End time (e.g., 5:00 PM or 17:00)",
100+
Validate: validateTime,
101+
}
102+
103+
endTimeStr, err := endTimePrompt.Run()
104+
if err != nil {
105+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
106+
os.Exit(1)
107+
}
108+
109+
if err := validateEndDateTime(startDateInput, startTimeStr, endDateInput, endTimeStr); err != nil {
110+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
111+
os.Exit(1)
112+
}
113+
114+
descriptionPrompt := promptui.Prompt{
115+
Label: "Description (optional, press Enter to skip)",
116+
}
117+
118+
description, err := descriptionPrompt.Run()
119+
if err != nil {
120+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
121+
os.Exit(1)
122+
}
123+
124+
startTime, err := parseDateTime(startDateInput, startTimeStr)
125+
if err != nil {
126+
fmt.Fprintf(os.Stderr, "Error parsing start time: %v\n", err)
127+
os.Exit(1)
128+
}
129+
130+
endTime, err := parseDateTime(endDateInput, endTimeStr)
131+
if err != nil {
132+
fmt.Fprintf(os.Stderr, "Error parsing end time: %v\n", err)
133+
os.Exit(1)
134+
}
135+
136+
var hourlyRate *float64
137+
if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.HourlyRate > 0 {
138+
hourlyRate = &cfg.HourlyRate
139+
}
140+
141+
db, err := storage.Initialize()
142+
if err != nil {
143+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
144+
os.Exit(1)
145+
}
146+
defer db.Close()
147+
148+
entry, err := db.CreateManualEntry(projectName, description, startTime, endTime, hourlyRate)
149+
if err != nil {
150+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
151+
os.Exit(1)
152+
}
153+
154+
duration := entry.Duration()
155+
fmt.Printf("\n[tmpo] Created manual entry for '%s'\n", entry.ProjectName)
156+
fmt.Printf(" Start: %s\n", startTime.Format("Jan 2, 2006 at 3:04 PM"))
157+
fmt.Printf(" End: %s\n", endTime.Format("Jan 2, 2006 at 3:04 PM"))
158+
fmt.Printf(" Duration: %s\n", formatDuration(duration))
159+
160+
if entry.HourlyRate != nil {
161+
earnings := duration.Hours() * *entry.HourlyRate
162+
fmt.Printf(" Hourly Rate: $%.2f\n", *entry.HourlyRate)
163+
fmt.Printf(" Estimated Earnings: $%.2f\n", earnings)
164+
}
165+
166+
fmt.Println()
167+
},
168+
}
169+
170+
// validateDate validates that the provided input is a non-empty date string in MM-DD-YYYY format.
171+
// It attempts to parse the input using the layout "01-02-2006" and returns an error if parsing fails.
172+
// It also rejects dates that are more than 24 hours in the future (i.e., strictly after time.Now().Add(24*time.Hour)).
173+
// Returns nil if the input is valid.
174+
func validateDate(input string) error {
175+
if input == "" {
176+
return fmt.Errorf("date cannot be empty")
177+
}
178+
179+
date, err := time.Parse("01-02-2006", input)
180+
if err != nil {
181+
return fmt.Errorf("invalid date format, use MM-DD-YYYY")
182+
}
183+
184+
if date.After(time.Now().Add(24 * time.Hour)) {
185+
return fmt.Errorf("date cannot be in the future")
186+
}
187+
188+
return nil
189+
}
190+
191+
// validateTime validates the provided time string.
192+
// It accepts 12-hour formats with an AM/PM designator (e.g., "9:30 AM", "09:30 PM")
193+
// and 24-hour format (e.g., "14:30"). Empty input yields an error. The function
194+
// normalizes AM/PM markers before parsing and returns nil on success or an error
195+
// describing the expected formats on failure.
196+
func validateTime(input string) error {
197+
if input == "" {
198+
return fmt.Errorf("time cannot be empty")
199+
}
200+
201+
normalizedInput := normalizeAMPM(input)
202+
203+
if _, err := time.Parse("3:04 PM", normalizedInput); err == nil {
204+
return nil
205+
}
206+
207+
if _, err := time.Parse("03:04 PM", normalizedInput); err == nil {
208+
return nil
209+
}
210+
211+
if _, err := time.Parse("15:04", normalizedInput); err == nil {
212+
return nil
213+
}
214+
215+
return fmt.Errorf("invalid time format, use 12-hour (e.g., 9:30 AM) or 24-hour (e.g., 14:30)")
216+
}
217+
218+
219+
// validateEndDateTime verifies that the end date/time represented by
220+
// endDate and endTime is a valid datetime and occurs strictly after the
221+
// start date/time represented by startDate and startTime.
222+
// It returns nil when the end datetime is strictly after the start datetime.
223+
// If parsing of the start or end datetime fails, it returns an error
224+
// wrapping the parse error (prefixed with "invalid start datetime" or
225+
// "invalid end datetime"). If the end is not after the start, it
226+
// returns an error stating that the end time must be after the start time.
227+
func validateEndDateTime(startDate, startTime, endDate, endTime string) error {
228+
start, err := parseDateTime(startDate, startTime)
229+
if err != nil {
230+
return fmt.Errorf("invalid start datetime: %w", err)
231+
}
232+
233+
end, err := parseDateTime(endDate, endTime)
234+
if err != nil {
235+
return fmt.Errorf("invalid end datetime: %w", err)
236+
}
237+
238+
if !end.After(start) {
239+
return fmt.Errorf("end time must be after start time")
240+
}
241+
242+
return nil
243+
}
244+
245+
// parseDateTime combines date and time strings into time.Time
246+
// Expects date in MM-DD-YYYY format and time in either 12-hour (with AM/PM) or 24-hour format
247+
func parseDateTime(date, timeStr string) (time.Time, error) {
248+
normalizedTime := normalizeAMPM(timeStr)
249+
dateTime := fmt.Sprintf("%s %s", date, normalizedTime)
250+
251+
if dt, err := time.ParseInLocation("01-02-2006 3:04 PM", dateTime, time.Local); err == nil {
252+
return dt, nil
253+
}
254+
255+
if dt, err := time.ParseInLocation("01-02-2006 03:04 PM", dateTime, time.Local); err == nil {
256+
return dt, nil
257+
}
258+
259+
return time.ParseInLocation("01-02-2006 15:04", dateTime, time.Local)
260+
}
261+
262+
// normalizeAMPM converts lowercase am/pm to uppercase AM/PM
263+
func normalizeAMPM(input string) string {
264+
return strings.ToUpper(input)
265+
}
266+
267+
// detectProjectNameWithSource returns the project name
268+
func detectProjectNameWithSource() (string) {
269+
if cfg, _, err := config.FindAndLoad(); err == nil && cfg != nil && cfg.ProjectName != "" {
270+
return cfg.ProjectName
271+
}
272+
273+
projectName, err := project.DetectProject()
274+
if err != nil {
275+
return ""
276+
}
277+
278+
return projectName
279+
}
280+
281+
func init() {
282+
rootCmd.AddCommand(manualCmd)
283+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ module github.com/DylanDevelops/tmpo
33
go 1.25.5
44

55
require (
6+
github.com/manifoldco/promptui v0.9.0
67
github.com/spf13/cobra v1.10.2
78
go.yaml.in/yaml/v3 v3.0.4
89
modernc.org/sqlite v1.40.1
910
)
1011

1112
require (
13+
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
1214
github.com/dustin/go-humanize v1.0.1 // indirect
1315
github.com/google/uuid v1.6.0 // indirect
1416
github.com/inconshreveable/mousetrap v1.1.0 // indirect

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
2+
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
3+
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
4+
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
5+
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
6+
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
17
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
28
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
39
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=
713
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
814
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
915
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
16+
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
17+
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
1018
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
1119
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
1220
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=
2735
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
2836
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
2937
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
38+
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
3039
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
3140
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
3241
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

internal/storage/db.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,41 @@ func (d *Database) CreateEntry(projectName, description string, hourlyRate *floa
110110
return d.GetEntry(id)
111111
}
112112

113+
// CreateManualEntry inserts a completed time entry with specific start and end times.
114+
// Unlike CreateEntry which uses the current time and leaves end_time NULL, this method
115+
// creates a fully specified historical entry for manual record-keeping.
116+
// If hourlyRate is nil, the hourly_rate column will be set to NULL. On success it returns
117+
// the created *TimeEntry (retrieved by querying the database for the last insert id).
118+
// If the insert or the subsequent retrieval fails, an error wrapping the underlying
119+
// database error is returned.
120+
func (d *Database) CreateManualEntry(projectName, description string, startTime, endTime time.Time, hourlyRate *float64) (*TimeEntry, error) {
121+
var rate sql.NullFloat64
122+
if hourlyRate != nil {
123+
rate = sql.NullFloat64{Float64: *hourlyRate, Valid: true}
124+
}
125+
126+
result, err := d.db.Exec(
127+
"INSERT INTO time_entries (project_name, start_time, end_time, description, hourly_rate) VALUES (?, ?, ?, ?, ?)",
128+
projectName,
129+
startTime,
130+
endTime,
131+
description,
132+
rate,
133+
)
134+
135+
if err != nil {
136+
return nil, fmt.Errorf("failed to create manual entry: %w", err)
137+
}
138+
139+
id, err := result.LastInsertId()
140+
141+
if err != nil {
142+
return nil, fmt.Errorf("failed to get last insert id: %w", err)
143+
}
144+
145+
return d.GetEntry(id)
146+
}
147+
113148
// GetRunningEntry retrieves the most recently started time entry that is still running
114149
// (i.e. has a NULL end_time) from the time_entries table. The query orders by
115150
// start_time descending and returns at most one row.

0 commit comments

Comments
 (0)