From 665bd389a0a1c8adadcaa1122e846cc81f5ead31 Mon Sep 17 00:00:00 2001 From: T Date: Tue, 5 Aug 2025 11:37:02 -0600 Subject: WIP TUI --- TODO.md | 10 +- go.mod | 19 ++ go.sum | 40 +++ internal/actions/actions.go | 34 +++ internal/actions/clients.go | 92 ++++++ internal/actions/projects.go | 64 +++++ internal/actions/timer.go | 342 ++++++++++++++++++++++ internal/actions/types.go | 31 ++ internal/commands/add_client.go | 60 +--- internal/commands/add_project.go | 58 +--- internal/commands/helpers.go | 32 +++ internal/commands/in.go | 193 ++----------- internal/commands/in_test.go | 2 +- internal/commands/out.go | 21 +- internal/commands/root.go | 1 + internal/commands/tui.go | 29 ++ internal/database/queries.sql | 29 ++ internal/queries/queries.sql.go | 75 +++++ internal/tui/app.go | 538 +++++++++++++++++++++++++++++++++++ internal/tui/clients_projects_box.go | 247 ++++++++++++++++ internal/tui/history_box.go | 516 +++++++++++++++++++++++++++++++++ internal/tui/keys.go | 229 +++++++++++++++ internal/tui/shared.go | 225 +++++++++++++++ internal/tui/timer.go | 150 ++++++++++ internal/tui/timer_box.go | 101 +++++++ internal/tui/types.go | 165 +++++++++++ keys.md | 56 ++++ wireframes.md | 86 ++++++ 28 files changed, 3169 insertions(+), 276 deletions(-) create mode 100644 internal/actions/actions.go create mode 100644 internal/actions/clients.go create mode 100644 internal/actions/projects.go create mode 100644 internal/actions/timer.go create mode 100644 internal/actions/types.go create mode 100644 internal/commands/helpers.go create mode 100644 internal/commands/tui.go create mode 100644 internal/tui/app.go create mode 100644 internal/tui/clients_projects_box.go create mode 100644 internal/tui/history_box.go create mode 100644 internal/tui/keys.go create mode 100644 internal/tui/shared.go create mode 100644 internal/tui/timer.go create mode 100644 internal/tui/timer_box.go create mode 100644 internal/tui/types.go create mode 100644 keys.md create mode 100644 wireframes.md diff --git a/TODO.md b/TODO.md index fa81a6b..a81d1b4 100644 --- a/TODO.md +++ b/TODO.md @@ -3,6 +3,10 @@ - [x] add a `billable_rate` to both client and project DB tables - [x] get rid of `test_entry.created_at` - [x] build any best-practice `#PRAGMA`s for working with sqlite in a local user's environment into `GetDB()` -- [ ] fill in `punch report` with typst report generation for invoices and timesheets -- [ ] brainstorm a TUI mode made with bubbletea -- [ ] implement `punch set [-c|--client ] [-p|--project ] key=value ...` - one of -c or -p is required (but never both), and keys will only be valid if they are members of a specific list, which is dependent on whether we're setting something on a client or project. on client we can set "name", "email", and "billable_rate", and on project we can set "name" and "billable_rate". +- [x] implement `punch set [-c|--client ] [-p|--project ] key=value ...` - one of -c or -p is required (but never both), and keys will only be valid if they are members of a specific list, which is dependent on whether we're setting something on a client or project. on client we can set "name", "email", and "billable_rate", and on project we can set "name" and "billable_rate". +- [x] fill in `punch report` with typst report generation for invoices and timesheets +- [x] brainstorm a TUI mode made with bubbletea +- [ ] implement TUI mode from the wireframes +- [ ] expand `punch report X -d ` parsing: + - [ ] any month name and it's the full month in the current year (unless it hasn't begun yet, then it's last year's) + - [ ] "april 2022" - month and year diff --git a/go.mod b/go.mod index 056f5cc..b9b28f6 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,39 @@ module punchcard go 1.24.4 require ( + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2 github.com/spf13/cobra v1.9.1 modernc.org/sqlite v1.38.2 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.3.8 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 6c60822..d7c93ed 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +1,72 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v0.27.0 h1:Mznj+vvYuYagD9Pn2mY7fuelGvP0HAXtZYGgRBCbHvU= +github.com/charmbracelet/bubbletea v0.27.0/go.mod h1:5MdP9XH6MbQkgGhnlxUqCNmBXf9I74KRQ8HIidRxV1Y= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2 h1:vq2enzx1Hr3UenVefpPEf+E2xMmqtZoSHhx8IE+V8ug= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/actions/actions.go b/internal/actions/actions.go new file mode 100644 index 0000000..5e2610a --- /dev/null +++ b/internal/actions/actions.go @@ -0,0 +1,34 @@ +package actions + +import ( + "context" + "punchcard/internal/queries" +) + +// Actions provides high-level business operations for time tracking +type Actions interface { + // Timer operations + PunchIn(ctx context.Context, client, project, description string, billableRate *float64) (*TimerSession, error) + PunchInMostRecent(ctx context.Context, description string, billableRate *float64) (*TimerSession, error) + PunchOut(ctx context.Context) (*TimerSession, error) + + // Client operations + CreateClient(ctx context.Context, name, email string, billableRate *float64) (*queries.Client, error) + FindClient(ctx context.Context, nameOrID string) (*queries.Client, error) + + // Project operations + CreateProject(ctx context.Context, name, client string, billableRate *float64) (*queries.Project, error) + FindProject(ctx context.Context, nameOrID string) (*queries.Project, error) +} + +// New creates a new Actions instance +func New(q *queries.Queries) Actions { + return &actionsImpl{ + queries: q, + } +} + +// actionsImpl implements the Actions interface +type actionsImpl struct { + queries *queries.Queries +} \ No newline at end of file diff --git a/internal/actions/clients.go b/internal/actions/clients.go new file mode 100644 index 0000000..bc77139 --- /dev/null +++ b/internal/actions/clients.go @@ -0,0 +1,92 @@ +package actions + +import ( + "context" + "database/sql" + "fmt" + "regexp" + "strconv" + "strings" + + "punchcard/internal/queries" +) + +// CreateClient creates a new client with the given name and optional email/rate +func (a *actionsImpl) CreateClient(ctx context.Context, name, email string, billableRate *float64) (*queries.Client, error) { + // Parse name and email if name contains email format "Name " + finalName, finalEmail := parseNameAndEmail(name, email) + + var emailParam sql.NullString + if finalEmail != "" { + emailParam = sql.NullString{String: finalEmail, Valid: true} + } + + var billableRateParam sql.NullInt64 + if billableRate != nil && *billableRate > 0 { + rate := int64(*billableRate * 100) // Convert dollars to cents + billableRateParam = sql.NullInt64{Int64: rate, Valid: true} + } + + client, err := a.queries.CreateClient(ctx, queries.CreateClientParams{ + Name: finalName, + Email: emailParam, + BillableRate: billableRateParam, + }) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + return &client, nil +} + +// FindClient finds a client by name or ID +func (a *actionsImpl) FindClient(ctx context.Context, nameOrID string) (*queries.Client, error) { + // Parse as ID if possible, otherwise use 0 + var idParam int64 + if id, err := strconv.ParseInt(nameOrID, 10, 64); err == nil { + idParam = id + } + + // Search by both ID and name + clients, err := a.queries.FindClient(ctx, queries.FindClientParams{ + ID: idParam, + Name: nameOrID, + }) + if err != nil { + return nil, fmt.Errorf("database error looking up client: %w", err) + } + + // Check results + switch len(clients) { + case 0: + return nil, fmt.Errorf("%w: %s", ErrClientNotFound, nameOrID) + case 1: + return &clients[0], nil + default: + return nil, fmt.Errorf("%w: %s matches multiple clients", ErrAmbiguousClient, nameOrID) + } +} + +// parseNameAndEmail handles parsing name and email from various input formats +func parseNameAndEmail(nameArg, emailArg string) (string, string) { + // If separate email provided, use it (but still check for embedded format) + finalEmail := emailArg + if finalEmail != "" { + if matches := emailAndNameRegex.FindStringSubmatch(finalEmail); matches != nil { + finalEmail = strings.TrimSpace(matches[2]) + } + } + + // Check if name contains embedded email format "Name " + finalName := nameArg + if matches := emailAndNameRegex.FindStringSubmatch(nameArg); matches != nil { + finalName = strings.TrimSpace(matches[1]) + if finalEmail == "" { + finalEmail = strings.TrimSpace(matches[2]) + } + } + + return finalName, finalEmail +} + +var emailAndNameRegex = regexp.MustCompile(`^(.+?)<([^>]+@[^>]+)>$`) \ No newline at end of file diff --git a/internal/actions/projects.go b/internal/actions/projects.go new file mode 100644 index 0000000..f991728 --- /dev/null +++ b/internal/actions/projects.go @@ -0,0 +1,64 @@ +package actions + +import ( + "context" + "database/sql" + "fmt" + "strconv" + + "punchcard/internal/queries" +) + +// CreateProject creates a new project for the specified client +func (a *actionsImpl) CreateProject(ctx context.Context, name, client string, billableRate *float64) (*queries.Project, error) { + // Find the client first + clientRecord, err := a.FindClient(ctx, client) + if err != nil { + return nil, fmt.Errorf("invalid client: %w", err) + } + + var billableRateParam sql.NullInt64 + if billableRate != nil && *billableRate > 0 { + rate := int64(*billableRate * 100) // Convert dollars to cents + billableRateParam = sql.NullInt64{Int64: rate, Valid: true} + } + + project, err := a.queries.CreateProject(ctx, queries.CreateProjectParams{ + Name: name, + ClientID: clientRecord.ID, + BillableRate: billableRateParam, + }) + if err != nil { + return nil, fmt.Errorf("failed to create project: %w", err) + } + + return &project, nil +} + +// FindProject finds a project by name or ID +func (a *actionsImpl) FindProject(ctx context.Context, nameOrID string) (*queries.Project, error) { + // Parse as ID if possible, otherwise use 0 + var idParam int64 + if id, err := strconv.ParseInt(nameOrID, 10, 64); err == nil { + idParam = id + } + + // Search by both ID and name + projects, err := a.queries.FindProject(ctx, queries.FindProjectParams{ + ID: idParam, + Name: nameOrID, + }) + if err != nil { + return nil, fmt.Errorf("database error looking up project: %w", err) + } + + // Check results + switch len(projects) { + case 0: + return nil, fmt.Errorf("%w: %s", ErrProjectNotFound, nameOrID) + case 1: + return &projects[0], nil + default: + return nil, fmt.Errorf("%w: %s matches multiple projects", ErrAmbiguousProject, nameOrID) + } +} \ No newline at end of file diff --git a/internal/actions/timer.go b/internal/actions/timer.go new file mode 100644 index 0000000..58dbba2 --- /dev/null +++ b/internal/actions/timer.go @@ -0,0 +1,342 @@ +package actions + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "punchcard/internal/queries" +) + +// PunchIn starts a timer for the specified client/project +// Use empty strings for client/project to use most recent entry +func (a *actionsImpl) PunchIn(ctx context.Context, client, project, description string, billableRate *float64) (*TimerSession, error) { + // If no client specified, delegate to PunchInMostRecent + if client == "" && project == "" { + session, err := a.PunchInMostRecent(ctx, description, billableRate) + if err != nil { + // Convert "no recent entries" error to "client required" for better UX + if errors.Is(err, ErrNoRecentEntries) { + return nil, ErrClientRequired + } + return nil, err + } + return session, nil + } + + // Check if there's already an active timer + activeEntry, err := a.queries.GetActiveTimeEntry(ctx) + var hasActiveTimer bool + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("failed to check for active timer: %w", err) + } + hasActiveTimer = (err == nil) + + // Resolve project first (if provided) to get its client + var projectID sql.NullInt64 + var resolvedProject *queries.Project + if project != "" { + proj, err := a.FindProject(ctx, project) + if err != nil { + return nil, fmt.Errorf("invalid project: %w", err) + } + resolvedProject = proj + projectID = sql.NullInt64{Int64: proj.ID, Valid: true} + } + + // Resolve client + var clientID int64 + var resolvedClient *queries.Client + if client != "" { + c, err := a.FindClient(ctx, client) + if err != nil { + return nil, fmt.Errorf("invalid client: %w", err) + } + resolvedClient = c + clientID = c.ID + + // Verify project belongs to client if both specified + if resolvedProject != nil && resolvedProject.ClientID != clientID { + return nil, fmt.Errorf("%w: project %q does not belong to client %q", + ErrProjectClientMismatch, project, client) + } + } else if resolvedProject != nil { + // Use project's client + clientID = resolvedProject.ClientID + c, err := a.FindClient(ctx, fmt.Sprintf("%d", clientID)) + if err != nil { + return nil, fmt.Errorf("failed to get client for project: %w", err) + } + resolvedClient = c + } else { + return nil, ErrClientRequired + } + + var stoppedEntryID *int64 + + // Check for identical timer if one is active + if hasActiveTimer { + if timeEntriesMatch(clientID, projectID, description, billableRate, activeEntry) { + // No-op: identical timer already active + return &TimerSession{ + ID: activeEntry.ID, + ClientName: resolvedClient.Name, + ProjectName: getProjectName(resolvedProject), + Description: description, + StartTime: activeEntry.StartTime, + EndTime: nil, + Duration: 0, + WasNoOp: true, + }, nil + } + + // Stop the active timer before starting new one + stoppedEntry, err := a.queries.StopTimeEntry(ctx) + if err != nil { + return nil, fmt.Errorf("failed to stop active timer: %w", err) + } + stoppedEntryID = &stoppedEntry.ID + } + + // Create the time entry + timeEntry, err := a.createTimeEntry(ctx, clientID, projectID, description, billableRate) + if err != nil { + return nil, err + } + + return &TimerSession{ + ID: timeEntry.ID, + ClientName: resolvedClient.Name, + ProjectName: getProjectName(resolvedProject), + Description: description, + StartTime: timeEntry.StartTime, + EndTime: nil, + Duration: 0, + WasNoOp: false, + StoppedEntryID: stoppedEntryID, + }, nil +} + +// PunchInMostRecent starts a timer copying the most recent time entry +func (a *actionsImpl) PunchInMostRecent(ctx context.Context, description string, billableRate *float64) (*TimerSession, error) { + // Get most recent entry + mostRecent, err := a.queries.GetMostRecentTimeEntry(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNoRecentEntries + } + return nil, fmt.Errorf("failed to get most recent entry: %w", err) + } + + // Use description from recent entry if none provided + finalDescription := description + if finalDescription == "" && mostRecent.Description.Valid { + finalDescription = mostRecent.Description.String + } + + // Check if there's already an active timer + activeEntry, err := a.queries.GetActiveTimeEntry(ctx) + var hasActiveTimer bool + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("failed to check for active timer: %w", err) + } + hasActiveTimer = (err == nil) + + var stoppedEntryID *int64 + + // Check for identical timer if one is active + if hasActiveTimer { + if timeEntriesMatch(mostRecent.ClientID, mostRecent.ProjectID, finalDescription, billableRate, activeEntry) { + // Get client/project names for the result + client, _ := a.FindClient(ctx, fmt.Sprintf("%d", mostRecent.ClientID)) + clientName := "" + if client != nil { + clientName = client.Name + } + + var projectName string + if mostRecent.ProjectID.Valid { + project, _ := a.FindProject(ctx, fmt.Sprintf("%d", mostRecent.ProjectID.Int64)) + if project != nil { + projectName = project.Name + } + } + + // No-op: identical timer already active + return &TimerSession{ + ID: activeEntry.ID, + ClientName: clientName, + ProjectName: projectName, + Description: finalDescription, + StartTime: activeEntry.StartTime, + EndTime: nil, + Duration: 0, + WasNoOp: true, + }, nil + } + + // Stop the active timer before starting new one + stoppedEntry, err := a.queries.StopTimeEntry(ctx) + if err != nil { + return nil, fmt.Errorf("failed to stop active timer: %w", err) + } + stoppedEntryID = &stoppedEntry.ID + } + + // Create new entry copying from most recent + timeEntry, err := a.createTimeEntry(ctx, mostRecent.ClientID, mostRecent.ProjectID, finalDescription, billableRate) + if err != nil { + return nil, err + } + + // Get client name + client, err := a.FindClient(ctx, fmt.Sprintf("%d", mostRecent.ClientID)) + if err != nil { + return nil, fmt.Errorf("failed to get client name: %w", err) + } + + // Get project name if exists + var projectName string + if mostRecent.ProjectID.Valid { + project, err := a.FindProject(ctx, fmt.Sprintf("%d", mostRecent.ProjectID.Int64)) + if err == nil { + projectName = project.Name + } + } + + return &TimerSession{ + ID: timeEntry.ID, + ClientName: client.Name, + ProjectName: projectName, + Description: finalDescription, + StartTime: timeEntry.StartTime, + EndTime: nil, + Duration: 0, + WasNoOp: false, + StoppedEntryID: stoppedEntryID, + }, nil +} + +// PunchOut stops the active timer +func (a *actionsImpl) PunchOut(ctx context.Context) (*TimerSession, error) { + stoppedEntry, err := a.queries.StopTimeEntry(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNoActiveTimer + } + return nil, fmt.Errorf("failed to stop timer: %w", err) + } + + duration := stoppedEntry.EndTime.Time.Sub(stoppedEntry.StartTime) + endTime := stoppedEntry.EndTime.Time + + // Get client name + client, err := a.FindClient(ctx, fmt.Sprintf("%d", stoppedEntry.ClientID)) + if err != nil { + return nil, fmt.Errorf("failed to get client name: %w", err) + } + + // Get project name if exists + var projectName string + if stoppedEntry.ProjectID.Valid { + project, err := a.FindProject(ctx, fmt.Sprintf("%d", stoppedEntry.ProjectID.Int64)) + if err == nil { + projectName = project.Name + } + } + + description := "" + if stoppedEntry.Description.Valid { + description = stoppedEntry.Description.String + } + + return &TimerSession{ + ID: stoppedEntry.ID, + ClientName: client.Name, + ProjectName: projectName, + Description: description, + StartTime: stoppedEntry.StartTime, + EndTime: &endTime, + Duration: duration, + }, nil +} + +// Helper functions + +func (a *actionsImpl) createTimeEntry(ctx context.Context, clientID int64, projectID sql.NullInt64, description string, billableRate *float64) (*queries.TimeEntry, error) { + var descParam sql.NullString + if description != "" { + descParam = sql.NullString{String: description, Valid: true} + } + + var billableRateParam sql.NullInt64 + if billableRate != nil && *billableRate > 0 { + rate := int64(*billableRate * 100) // Convert dollars to cents + billableRateParam = sql.NullInt64{Int64: rate, Valid: true} + } + + timeEntry, err := a.queries.CreateTimeEntry(ctx, queries.CreateTimeEntryParams{ + Description: descParam, + ClientID: clientID, + ProjectID: projectID, + BillableRate: billableRateParam, + }) + if err != nil { + return nil, err + } + return &timeEntry, nil +} + +func getProjectName(project *queries.Project) string { + if project == nil { + return "" + } + return project.Name +} + +// timeEntriesMatch checks if a new time entry would be identical to an active one +// by comparing client ID, project ID, description, and billable rate +func timeEntriesMatch(clientID int64, projectID sql.NullInt64, description string, billableRate *float64, activeEntry queries.TimeEntry) bool { + // Client must match + if activeEntry.ClientID != clientID { + return false + } + + // Check project ID matching + if projectID.Valid != activeEntry.ProjectID.Valid { + // One has a project, the other doesn't + return false + } + if projectID.Valid { + // Both have projects - compare IDs + if activeEntry.ProjectID.Int64 != projectID.Int64 { + return false + } + } + + // Check description matching + if (description != "") != activeEntry.Description.Valid { + // One has description, the other doesn't + return false + } + if activeEntry.Description.Valid { + // Both have descriptions - compare strings + if activeEntry.Description.String != description { + return false + } + } + + // Check billable rate matching + if billableRate != nil { + // New entry has explicit rate + expectedRate := int64(*billableRate * 100) // Convert to cents + if !activeEntry.BillableRate.Valid || activeEntry.BillableRate.Int64 != expectedRate { + return false + } + } + // New entry has no explicit rate - for simplicity, we consider them matching + // regardless of what coalesced rate the active entry might have + + return true +} \ No newline at end of file diff --git a/internal/actions/types.go b/internal/actions/types.go new file mode 100644 index 0000000..899583b --- /dev/null +++ b/internal/actions/types.go @@ -0,0 +1,31 @@ +package actions + +import ( + "errors" + "time" +) + +// Common errors +var ( + ErrNoActiveTimer = errors.New("no active timer found") + ErrClientRequired = errors.New("client is required") + ErrClientNotFound = errors.New("client not found") + ErrProjectNotFound = errors.New("project not found") + ErrAmbiguousClient = errors.New("ambiguous client reference") + ErrAmbiguousProject = errors.New("ambiguous project reference") + ErrProjectClientMismatch = errors.New("project does not belong to specified client") + ErrNoRecentEntries = errors.New("no previous time entries found") +) + +// TimerSession represents an active or completed time tracking session +type TimerSession struct { + ID int64 + ClientName string + ProjectName string // empty if no project + Description string // empty if no description + StartTime time.Time + EndTime *time.Time // nil if still active + Duration time.Duration + WasNoOp bool // true if timer was already active with same parameters + StoppedEntryID *int64 // ID of previously stopped entry (if any) +} \ No newline at end of file diff --git a/internal/commands/add_client.go b/internal/commands/add_client.go index e35eba9..98aec3d 100644 --- a/internal/commands/add_client.go +++ b/internal/commands/add_client.go @@ -1,13 +1,10 @@ package commands import ( - "database/sql" "fmt" - "regexp" - "strings" + "punchcard/internal/actions" "punchcard/internal/context" - "punchcard/internal/queries" "github.com/spf13/cobra" ) @@ -19,33 +16,27 @@ func NewAddClientCmd() *cobra.Command { Long: "Add a new client to the database. Name can include email in format 'Name '", Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { - name, email := parseNameAndEmail(args) + name := args[0] + var email string + if len(args) > 1 { + email = args[1] + } billableRateFloat, _ := cmd.Flags().GetFloat64("hourly-rate") - billableRate := int64(billableRateFloat * 100) + var billableRate *float64 + if billableRateFloat > 0 { + billableRate = &billableRateFloat + } q := context.GetDB(cmd.Context()) if q == nil { return fmt.Errorf("database not available in context") } - var emailParam sql.NullString - if email != "" { - emailParam = sql.NullString{String: email, Valid: true} - } - - var billableRateParam sql.NullInt64 - if billableRate > 0 { - billableRateParam = sql.NullInt64{Int64: billableRate, Valid: true} - } - - client, err := q.CreateClient(cmd.Context(), queries.CreateClientParams{ - Name: name, - Email: emailParam, - BillableRate: billableRateParam, - }) + a := actions.New(q) + client, err := a.CreateClient(cmd.Context(), name, email, billableRate) if err != nil { - return fmt.Errorf("failed to create client: %w", err) + return err } output := fmt.Sprintf("Created client: %s", client.Name) @@ -63,28 +54,3 @@ func NewAddClientCmd() *cobra.Command { return cmd } - -func parseNameAndEmail(args []string) (string, string) { - nameArg := args[0] - var emailArg string - if len(args) > 1 { - emailArg = args[1] - } - - if emailArg != "" { - if matches := emailAndNameRegex.FindStringSubmatch(emailArg); matches != nil { - emailArg = strings.TrimSpace(matches[2]) - } - } - - if matches := emailAndNameRegex.FindStringSubmatch(nameArg); matches != nil { - nameArg = strings.TrimSpace(matches[1]) - if emailArg == "" { - emailArg = strings.TrimSpace(matches[2]) - } - } - - return nameArg, emailArg -} - -var emailAndNameRegex = regexp.MustCompile(`^(.+?)<([^>]+@[^>]+)>$`) diff --git a/internal/commands/add_project.go b/internal/commands/add_project.go index 6c37e2a..1ed42db 100644 --- a/internal/commands/add_project.go +++ b/internal/commands/add_project.go @@ -1,13 +1,10 @@ package commands import ( - "context" - "database/sql" "fmt" - "strconv" + "punchcard/internal/actions" punchctx "punchcard/internal/context" - "punchcard/internal/queries" "github.com/spf13/cobra" ) @@ -34,32 +31,26 @@ Examples: } billableRateFloat, _ := cmd.Flags().GetFloat64("hourly-rate") - billableRate := int64(billableRateFloat * 100) // Convert dollars to cents + var billableRate *float64 + if billableRateFloat > 0 { + billableRate = &billableRateFloat + } q := punchctx.GetDB(cmd.Context()) if q == nil { return fmt.Errorf("database not available in context") } - // Find client by ID or name - client, err := findClient(cmd.Context(), q, clientRef) + a := actions.New(q) + project, err := a.CreateProject(cmd.Context(), projectName, clientRef, billableRate) if err != nil { - return fmt.Errorf("failed to find client: %w", err) - } - - // Create project - var billableRateParam sql.NullInt64 - if billableRate > 0 { - billableRateParam = sql.NullInt64{Int64: billableRate, Valid: true} + return err } - project, err := q.CreateProject(cmd.Context(), queries.CreateProjectParams{ - Name: projectName, - ClientID: client.ID, - BillableRate: billableRateParam, - }) + // Get client name for output + client, err := a.FindClient(cmd.Context(), clientRef) if err != nil { - return fmt.Errorf("failed to create project: %w", err) + return fmt.Errorf("failed to get client name: %w", err) } output := fmt.Sprintf("Created project: %s for client %s (ID: %d)", project.Name, client.Name, project.ID) @@ -77,30 +68,3 @@ Examples: return cmd } - -func findClient(ctx context.Context, q *queries.Queries, clientRef string) (queries.Client, error) { - // Parse clientRef as ID if possible, otherwise use 0 - var idParam int64 - if id, err := strconv.ParseInt(clientRef, 10, 64); err == nil { - idParam = id - } - - // Search by both ID and name using UNION ALL - clients, err := q.FindClient(ctx, queries.FindClientParams{ - ID: idParam, - Name: clientRef, - }) - if err != nil { - return queries.Client{}, fmt.Errorf("database error looking up client: %w", err) - } - - // Check results - switch len(clients) { - case 0: - return queries.Client{}, fmt.Errorf("client not found: %s", clientRef) - case 1: - return clients[0], nil - default: - return queries.Client{}, fmt.Errorf("ambiguous client: %s", clientRef) - } -} diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go new file mode 100644 index 0000000..a0c572b --- /dev/null +++ b/internal/commands/helpers.go @@ -0,0 +1,32 @@ +package commands + +import ( + "context" + "errors" + "punchcard/internal/actions" + "punchcard/internal/queries" +) + +// ErrNoActiveTimer is returned when trying to punch out but no timer is active +var ErrNoActiveTimer = errors.New("no active timer found") + +// Helper functions for commands that need to find clients/projects +// These wrap the actions package for backward compatibility + +func findClient(ctx context.Context, q *queries.Queries, clientRef string) (queries.Client, error) { + a := actions.New(q) + client, err := a.FindClient(ctx, clientRef) + if err != nil { + return queries.Client{}, err + } + return *client, nil +} + +func findProject(ctx context.Context, q *queries.Queries, projectRef string) (queries.Project, error) { + a := actions.New(q) + project, err := a.FindProject(ctx, projectRef) + if err != nil { + return queries.Project{}, err + } + return *project, nil +} \ No newline at end of file diff --git a/internal/commands/in.go b/internal/commands/in.go index abb57f1..e7847f6 100644 --- a/internal/commands/in.go +++ b/internal/commands/in.go @@ -1,15 +1,10 @@ package commands import ( - "context" - "database/sql" - "errors" "fmt" - "strconv" - "time" + "punchcard/internal/actions" punchctx "punchcard/internal/context" - "punchcard/internal/queries" "github.com/spf13/cobra" ) @@ -43,122 +38,54 @@ Examples: } billableRateFloat, _ := cmd.Flags().GetFloat64("hourly-rate") - billableRate := int64(billableRateFloat * 100) // Convert dollars to cents + var billableRate *float64 + if billableRateFloat > 0 { + billableRate = &billableRateFloat + } q := punchctx.GetDB(cmd.Context()) if q == nil { return fmt.Errorf("database not available in context") } - // Check if there's already an active timer - activeEntry, err := q.GetActiveTimeEntry(cmd.Context()) - var hasActiveTimer bool - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("failed to check for active timer: %w", err) - } - hasActiveTimer = (err == nil) - - // Validate and get project first (if provided) - var project queries.Project - var projectID sql.NullInt64 - if projectFlag != "" { - proj, err := findProject(cmd.Context(), q, projectFlag) - if err != nil { - return fmt.Errorf("invalid project: %w", err) - } - project = proj - projectID = sql.NullInt64{Int64: project.ID, Valid: true} - } + a := actions.New(q) + + // Use the actions package based on what flags were provided + var session *actions.TimerSession + var err error - // Validate and get client - var clientID int64 - if clientFlag != "" { - client, err := findClient(cmd.Context(), q, clientFlag) - if err != nil { - return fmt.Errorf("invalid client: %w", err) - } - clientID = client.ID - - // If project is specified, verify it belongs to this client - if projectID.Valid && project.ClientID != clientID { - return fmt.Errorf("project %q does not belong to client %q", projectFlag, clientFlag) - } - } else if projectID.Valid { - clientID = project.ClientID - } else if clientFlag == "" && projectFlag == "" { - mostRecentEntry, err := q.GetMostRecentTimeEntry(cmd.Context()) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("no previous time entries found - client is required for first entry: use -c/--client flag") - } - return fmt.Errorf("failed to get most recent time entry: %w", err) - } - - clientID = mostRecentEntry.ClientID - projectID = mostRecentEntry.ProjectID - if description == "" && mostRecentEntry.Description.Valid { - description = mostRecentEntry.Description.String - } + if clientFlag == "" && projectFlag == "" { + // Use most recent entry + session, err = a.PunchInMostRecent(cmd.Context(), description, billableRate) } else { - return fmt.Errorf("client is required: use -c/--client flag to specify client") + // Use specified client/project + session, err = a.PunchIn(cmd.Context(), clientFlag, projectFlag, description, billableRate) } - if hasActiveTimer { - // Check if the new timer would be identical to the active one - if timeEntriesMatch(clientID, projectID, description, activeEntry) { - // No-op: identical timer already active - cmd.Printf("Timer already active with same parameters (ID: %d)\n", activeEntry.ID) - return nil - } - - // Stop the active timer before starting new one - stoppedEntry, err := q.StopTimeEntry(cmd.Context()) - if err != nil { - return fmt.Errorf("failed to stop active timer: %w", err) - } - - duration := stoppedEntry.EndTime.Time.Sub(stoppedEntry.StartTime) - cmd.Printf("Stopped previous timer (ID: %d). Duration: %v\n", - stoppedEntry.ID, duration.Round(time.Second)) + if err != nil { + return err } - // Create time entry - var descParam sql.NullString - if description != "" { - descParam = sql.NullString{String: description, Valid: true} + // Handle different response types + if session.WasNoOp { + cmd.Printf("Timer already active with same parameters (ID: %d)\n", session.ID) + return nil } - var billableRateParam sql.NullInt64 - if billableRate > 0 { - billableRateParam = sql.NullInt64{Int64: billableRate, Valid: true} - } - - timeEntry, err := q.CreateTimeEntry(cmd.Context(), queries.CreateTimeEntryParams{ - Description: descParam, - ClientID: clientID, - ProjectID: projectID, - BillableRate: billableRateParam, - }) - if err != nil { - return fmt.Errorf("failed to create time entry: %w", err) + // Print stopped timer message if we stopped one + if session.StoppedEntryID != nil { + cmd.Printf("Stopped previous timer (ID: %d)\n", *session.StoppedEntryID) } // Build output message - output := fmt.Sprintf("Started timer (ID: %d)", timeEntry.ID) - - // Add client info - client, _ := findClient(cmd.Context(), q, strconv.FormatInt(clientID, 10)) - output += fmt.Sprintf(" for client: %s", client.Name) + output := fmt.Sprintf("Started timer (ID: %d) for client: %s", session.ID, session.ClientName) - // Add project info if provided - if projectID.Valid { - project, _ := findProject(cmd.Context(), q, strconv.FormatInt(projectID.Int64, 10)) - output += fmt.Sprintf(", project: %s", project.Name) + if session.ProjectName != "" { + output += fmt.Sprintf(", project: %s", session.ProjectName) } - // Add description if provided - if description != "" { - output += fmt.Sprintf(", description: %s", description) + if session.Description != "" { + output += fmt.Sprintf(", description: %s", session.Description) } cmd.Print(output + "\n") @@ -172,65 +99,3 @@ Examples: return cmd } - -func findProject(ctx context.Context, q *queries.Queries, projectRef string) (queries.Project, error) { - // Parse projectRef as ID if possible, otherwise use 0 - var idParam int64 - if id, err := strconv.ParseInt(projectRef, 10, 64); err == nil { - idParam = id - } - - // Search by both ID and name using UNION ALL - projects, err := q.FindProject(ctx, queries.FindProjectParams{ - ID: idParam, - Name: projectRef, - }) - if err != nil { - return queries.Project{}, fmt.Errorf("database error looking up project: %w", err) - } - - // Check results - switch len(projects) { - case 0: - return queries.Project{}, fmt.Errorf("project not found: %s", projectRef) - case 1: - return projects[0], nil - default: - return queries.Project{}, fmt.Errorf("ambiguous project: %s", projectRef) - } -} - -// timeEntriesMatch checks if a new time entry would be identical to an active one -// by comparing client ID, project ID, and description -func timeEntriesMatch(clientID int64, projectID sql.NullInt64, description string, activeEntry queries.TimeEntry) bool { - // Client must match - if activeEntry.ClientID != clientID { - return false - } - - // Check project ID matching - if projectID.Valid != activeEntry.ProjectID.Valid { - // One has a project, the other doesn't - return false - } - if projectID.Valid { - // Both have projects - compare IDs - if activeEntry.ProjectID.Int64 != projectID.Int64 { - return false - } - } - - // Check description matching - if (description != "") != activeEntry.Description.Valid { - // One has description, the other doesn't - return false - } - if activeEntry.Description.Valid { - // Both have descriptions - compare strings - if activeEntry.Description.String != description { - return false - } - } - - return true -} diff --git a/internal/commands/in_test.go b/internal/commands/in_test.go index 3832037..884a459 100644 --- a/internal/commands/in_test.go +++ b/internal/commands/in_test.go @@ -99,7 +99,7 @@ func TestInCommand(t *testing.T) { setupData: func(q *queries.Queries) (int64, int64) { return 0, 0 }, args: []string{"in"}, expectError: true, - errorContains: "client is required", + errorContains: "no previous time entries found", }, { name: "punch in with nonexistent client", diff --git a/internal/commands/out.go b/internal/commands/out.go index f98a63c..1355f3d 100644 --- a/internal/commands/out.go +++ b/internal/commands/out.go @@ -1,18 +1,16 @@ package commands import ( - "database/sql" "errors" "fmt" "time" + "punchcard/internal/actions" punchctx "punchcard/internal/context" "github.com/spf13/cobra" ) -var ErrNoActiveTimer = errors.New("no active timer found") - func NewOutCmd() *cobra.Command { return &cobra.Command{ Use: "out", @@ -25,23 +23,18 @@ func NewOutCmd() *cobra.Command { return fmt.Errorf("database not available in context") } - // Stop the active timer by setting end_time to now - stoppedEntry, err := q.StopTimeEntry(cmd.Context()) + a := actions.New(q) + session, err := a.PunchOut(cmd.Context()) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, actions.ErrNoActiveTimer) { return ErrNoActiveTimer } - return fmt.Errorf("failed to stop timer: %w", err) + return err } - // Calculate duration - duration := stoppedEntry.EndTime.Time.Sub(stoppedEntry.StartTime) - // Output success message - cmd.Printf("Timer stopped. Session duration: %v\n", duration.Round(time.Second)) - - // Show entry ID for reference - cmd.Printf("Time entry ID: %d\n", stoppedEntry.ID) + cmd.Printf("Timer stopped. Session duration: %v\n", session.Duration.Round(time.Second)) + cmd.Printf("Time entry ID: %d\n", session.ID) return nil }, diff --git a/internal/commands/root.go b/internal/commands/root.go index 553d0b4..fda3d06 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -25,6 +25,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(NewImportCmd()) cmd.AddCommand(NewReportCmd()) cmd.AddCommand(NewSetCmd()) + cmd.AddCommand(NewTUICmd()) return cmd } diff --git a/internal/commands/tui.go b/internal/commands/tui.go new file mode 100644 index 0000000..529e937 --- /dev/null +++ b/internal/commands/tui.go @@ -0,0 +1,29 @@ +package commands + +import ( + "fmt" + + punchctx "punchcard/internal/context" + "punchcard/internal/tui" + + "github.com/spf13/cobra" +) + +func NewTUICmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tui", + Short: "Start the terminal user interface", + Long: `Start an interactive terminal user interface for time tracking with real-time updates.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + q := punchctx.GetDB(cmd.Context()) + if q == nil { + return fmt.Errorf("database not available in context") + } + + return tui.Run(cmd.Context(), q) + }, + } + + return cmd +} diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 3a644b8..c68cdad 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -284,3 +284,32 @@ where p.id = @project_id and te.start_time <= @end_time and te.end_time is not null order by te.start_time; + +-- name: GetTodaySummary :one +select + cast(sum( + case + when te.end_time is null then + (julianday('now', 'utc') - julianday(te.start_time)) * 24 * 60 * 60 + else + (julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60 + end + ) as integer) as total_seconds +from time_entry te +where date(te.start_time) = date('now'); + +-- name: GetRecentTimeEntries :many +select * from time_entry +order by start_time desc +limit @limit_count; + +-- name: UpdateActiveTimerDescription :exec +update time_entry +set description = @description +where id = ( + select id + from time_entry + where end_time is null + order by start_time desc + limit 1 +); diff --git a/internal/queries/queries.sql.go b/internal/queries/queries.sql.go index dff9659..f5cf70f 100644 --- a/internal/queries/queries.sql.go +++ b/internal/queries/queries.sql.go @@ -654,6 +654,43 @@ func (q *Queries) GetProjectByNameAndClient(ctx context.Context, arg GetProjectB return i, err } +const getRecentTimeEntries = `-- name: GetRecentTimeEntries :many +select id, start_time, end_time, description, client_id, project_id, billable_rate from time_entry +order by start_time desc +limit ?1 +` + +func (q *Queries) GetRecentTimeEntries(ctx context.Context, limitCount int64) ([]TimeEntry, error) { + rows, err := q.db.QueryContext(ctx, getRecentTimeEntries, limitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TimeEntry + for rows.Next() { + var i TimeEntry + if err := rows.Scan( + &i.ID, + &i.StartTime, + &i.EndTime, + &i.Description, + &i.ClientID, + &i.ProjectID, + &i.BillableRate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTimesheetDataByClient = `-- name: GetTimesheetDataByClient :many select te.id as time_entry_id, @@ -823,6 +860,27 @@ func (q *Queries) GetTimesheetDataByProject(ctx context.Context, arg GetTimeshee return items, nil } +const getTodaySummary = `-- name: GetTodaySummary :one +select + cast(sum( + case + when te.end_time is null then + (julianday('now', 'utc') - julianday(te.start_time)) * 24 * 60 * 60 + else + (julianday(te.end_time) - julianday(te.start_time)) * 24 * 60 * 60 + end + ) as integer) as total_seconds +from time_entry te +where date(te.start_time) = date('now') +` + +func (q *Queries) GetTodaySummary(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, getTodaySummary) + var total_seconds int64 + err := row.Scan(&total_seconds) + return total_seconds, err +} + const getWeekSummaryByProject = `-- name: GetWeekSummaryByProject :many select p.id as project_id, @@ -990,6 +1048,23 @@ func (q *Queries) StopTimeEntry(ctx context.Context) (TimeEntry, error) { return i, err } +const updateActiveTimerDescription = `-- name: UpdateActiveTimerDescription :exec +update time_entry +set description = ?1 +where id = ( + select id + from time_entry + where end_time is null + order by start_time desc + limit 1 +) +` + +func (q *Queries) UpdateActiveTimerDescription(ctx context.Context, description sql.NullString) error { + _, err := q.db.ExecContext(ctx, updateActiveTimerDescription, description) + return err +} + const updateClient = `-- name: UpdateClient :one update client set name = ?1, email = ?2, billable_rate = ?3 diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..98bad4f --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,538 @@ +package tui + +import ( + "context" + "database/sql" + "fmt" + "time" + + "punchcard/internal/queries" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss/v2" +) + +// NewApp creates a new TUI application +func NewApp(ctx context.Context, q *queries.Queries) *AppModel { + return &AppModel{ + ctx: ctx, + queries: q, + selectedBox: TimerBox, + timerBoxModel: NewTimerBoxModel(), + clientsProjectsModel: NewClientsProjectsModel(), + historyBoxModel: NewHistoryBoxModel(), + } +} + +// Init initializes the app +func (m AppModel) Init() tea.Cmd { + return tea.Batch( + m.updateDataCmd(), + m.tickCmd(), + ) +} + +// Update handles messages for the app +func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + if m.showModal { + // Handle modal input + cmd := m.handleModalInput(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + } else { + // Handle normal input + action := HandleKeyPress(msg, m.selectedBox, m.historyBoxModel.viewLevel, m.timerBoxModel.timerInfo.IsActive) + cmd := m.handleAction(action) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + case TickMsg: + // Update timer duration if active using cached start time + if m.runningTimerStart != nil { + m.timerBoxModel.timerInfo.IsActive = true + m.timerBoxModel.timerInfo.Duration = time.Since(*m.runningTimerStart) + m.timerBoxModel.timerInfo.StartTime = *m.runningTimerStart + // Keep history model in sync + m.historyBoxModel.runningTimerStart = m.runningTimerStart + } else { + m.timerBoxModel.timerInfo.IsActive = false + m.timerBoxModel.timerInfo.Duration = 0 + // Keep history model in sync + m.historyBoxModel.runningTimerStart = nil + } + cmds = append(cmds, m.tickCmd()) + + case dataUpdatedMsg: + // Update all models with fresh data + m.timerBoxModel = m.timerBoxModel.UpdateTimerInfo(msg.timerInfo) + m.clientsProjectsModel = m.clientsProjectsModel.UpdateData(msg.clients, msg.projects) + m.historyBoxModel = m.historyBoxModel.UpdateData(msg.entries, msg.clients, msg.projects) + // Update running timer data in history model too + if msg.timerInfo.IsActive { + m.historyBoxModel.runningTimerStart = &msg.timerInfo.StartTime + } else { + m.historyBoxModel.runningTimerStart = nil + } + // Cache stats and running timer start time + m.stats = msg.stats + if msg.timerInfo.IsActive { + m.runningTimerStart = &msg.timerInfo.StartTime + } else { + m.runningTimerStart = nil + } + + // Schedule next data update in 30 seconds + cmds = append(cmds, tea.Tick(30*time.Second, func(t time.Time) tea.Msg { + return updateDataCmd{} + })) + + case updateDataCmd: + cmds = append(cmds, m.updateDataCmd()) + } + + return m, tea.Batch(cmds...) +} + +// handleAction processes the action returned by key handling +func (m *AppModel) handleAction(action KeyAction) tea.Cmd { + switch action { + // Global actions + case ActionNextPane: + m.selectedBox = (m.selectedBox + 1) % 3 + case ActionPrevPane: + m.selectedBox = (m.selectedBox + 2) % 3 // +2 is like -1 in mod 3 + case ActionPunchToggle: + return m.handlePunchToggle() + case ActionSearch: + return m.handleSearch() + case ActionRefresh: + return m.handleRefresh() + case ActionQuit: + return tea.Quit + + // Timer pane actions + case ActionTimerEnter: + return m.handleTimerEnter() + case ActionTimerDescribe: + m.handleTimerDescribe() + + // Projects pane actions + case ActionProjectsNext: + m.handleProjectsNext() + case ActionProjectsPrev: + m.handleProjectsPrev() + case ActionProjectsEnter: + return m.handleProjectsEnter() + case ActionProjectsNewProject: + return m.handleProjectsNewProject() + case ActionProjectsNewClient: + return m.handleProjectsNewClient() + + // History pane actions + case ActionHistoryNext: + m.handleHistoryNext() + case ActionHistoryPrev: + m.handleHistoryPrev() + case ActionHistoryEnter: + return m.handleHistoryEnter() + case ActionHistoryEdit: + return m.handleHistoryEdit() + case ActionHistoryDelete: + return m.handleHistoryDelete() + case ActionHistoryResume: + return m.handleHistoryResume() + case ActionHistoryBack: + m.handleHistoryBack() + } + + return nil +} + +// Global action handlers +func (m *AppModel) handlePunchToggle() tea.Cmd { + // TODO: Implement punch in/out toggle + return nil +} + +func (m *AppModel) handleSearch() tea.Cmd { + // TODO: Implement search modal + return nil +} + +func (m *AppModel) handleRefresh() tea.Cmd { + // Immediately refresh data from database + return m.updateDataCmd() +} + +// Timer pane action handlers +func (m *AppModel) handleTimerEnter() tea.Cmd { + if m.timerBoxModel.timerInfo.IsActive { + // TODO: Implement punch out + return nil + } else { + // TODO: Implement resume recent (punch back in to most recent project) + return nil + } +} + +// Projects pane action handlers +func (m *AppModel) handleProjectsNext() { + m.clientsProjectsModel = m.clientsProjectsModel.NextSelection() +} + +func (m *AppModel) handleProjectsPrev() { + m.clientsProjectsModel = m.clientsProjectsModel.PrevSelection() +} + +func (m *AppModel) handleProjectsEnter() tea.Cmd { + // TODO: Punch in to selected client/project + return nil +} + +func (m *AppModel) handleProjectsNewProject() tea.Cmd { + // TODO: Open new project modal + return nil +} + +func (m *AppModel) handleProjectsNewClient() tea.Cmd { + // TODO: Open new client modal + return nil +} + +// History pane action handlers +func (m *AppModel) handleHistoryNext() { + m.historyBoxModel = m.historyBoxModel.NextSelection() +} + +func (m *AppModel) handleHistoryPrev() { + m.historyBoxModel = m.historyBoxModel.PrevSelection() +} + +func (m *AppModel) handleHistoryEnter() tea.Cmd { + if m.historyBoxModel.viewLevel == HistoryLevelSummary { + // Drill down to details view + m.historyBoxModel = m.historyBoxModel.DrillDown() + return nil + } else { + // TODO: Resume selected entry (punch in with same details) + return nil + } +} + +func (m *AppModel) handleHistoryEdit() tea.Cmd { + // TODO: Open edit modal for selected entry + return nil +} + +func (m *AppModel) handleHistoryDelete() tea.Cmd { + // TODO: Delete selected entry + return nil +} + +func (m *AppModel) handleHistoryResume() tea.Cmd { + // TODO: Resume selected entry (punch in with same details) + return nil +} + +func (m *AppModel) handleHistoryBack() { + // Switch back to summary view + m.historyBoxModel = m.historyBoxModel.GoBack() +} + +// Timer action handlers +func (m *AppModel) handleTimerDescribe() { + // Show modal for timer description + m.showModal = true + m.modalType = ModalDescribeTimer + + // Get current description if any + currentDesc := "" + if m.timerBoxModel.timerInfo.Description != "" { + currentDesc = m.timerBoxModel.timerInfo.Description + } + + m.textInputModel = TextInputModel{ + prompt: "Enter timer description:", + value: currentDesc, + placeholder: "Working on...", + cursorPos: len(currentDesc), + } +} + +// Modal handling methods +func (m *AppModel) handleModalInput(msg tea.KeyMsg) tea.Cmd { + key := msg.String() + + switch key { + case "enter": + return m.submitModal() + case "escape", "ctrl+c": + m.closeModal() + return nil + case "backspace": + if m.textInputModel.cursorPos > 0 { + // Remove character before cursor + value := m.textInputModel.value + m.textInputModel.value = value[:m.textInputModel.cursorPos-1] + value[m.textInputModel.cursorPos:] + m.textInputModel.cursorPos-- + } + case "left": + if m.textInputModel.cursorPos > 0 { + m.textInputModel.cursorPos-- + } + case "right": + if m.textInputModel.cursorPos < len(m.textInputModel.value) { + m.textInputModel.cursorPos++ + } + case "home", "ctrl+a": + m.textInputModel.cursorPos = 0 + case "end", "ctrl+e": + m.textInputModel.cursorPos = len(m.textInputModel.value) + default: + // Handle character input + if len(key) == 1 && key[0] >= 32 && key[0] <= 126 { + // Insert character at cursor position + value := m.textInputModel.value + m.textInputModel.value = value[:m.textInputModel.cursorPos] + key + value[m.textInputModel.cursorPos:] + m.textInputModel.cursorPos++ + } + } + + return nil +} + +func (m *AppModel) submitModal() tea.Cmd { + var cmd tea.Cmd + + switch m.modalType { + case ModalDescribeTimer: + // Update the active timer description + cmd = m.updateTimerDescription(m.textInputModel.value) + } + + m.closeModal() + return cmd +} + +func (m *AppModel) closeModal() { + m.showModal = false + m.textInputModel = TextInputModel{} +} + +func (m *AppModel) updateTimerDescription(description string) tea.Cmd { + return func() tea.Msg { + // Update the active timer's description in the database + var desc sql.NullString + if description != "" { + desc = sql.NullString{String: description, Valid: true} + } + + err := m.queries.UpdateActiveTimerDescription(m.ctx, desc) + if err != nil { + // Handle error silently for now + return nil + } + + // Trigger a data refresh to update the UI + return updateDataCmd{} + } +} + +// View renders the app +func (m AppModel) View() string { + if m.width == 0 || m.height == 0 { + return "Loading..." + } + + // Calculate dimensions + topBarHeight := 1 + bottomBarHeight := 1 + contentHeight := m.height - topBarHeight - bottomBarHeight + + vertBoxOverhead := 6 // 2 border, 4 padding + horizBoxOverhead := 4 // 2 border, 2 padding + + // Timer box is in top-left + timerBoxWidth := (m.width / 3) - horizBoxOverhead + timerBoxHeight := (contentHeight / 2) - vertBoxOverhead + + // Clients/Projects box is in bottom-left + clientsProjectsBoxWidth := (m.width / 3) - horizBoxOverhead + clientsProjectsBoxHeight := (contentHeight - timerBoxHeight) - vertBoxOverhead + + // History box takes the right side + historyBoxWidth := (m.width - (m.width / 3)) - horizBoxOverhead + historyBoxHeight := contentHeight - vertBoxOverhead + + // Render top bar with current box info and time stats + viewName := fmt.Sprintf("Selected: %s", m.selectedBox.String()) + // Use cached stats, but add running timer duration to both today's and week's totals if active + currentStats := m.stats + if m.runningTimerStart != nil { + runningDuration := time.Since(*m.runningTimerStart) + currentStats.TodayTotal += runningDuration + currentStats.WeekTotal += runningDuration + } + topBar := RenderTopBar(viewName, currentStats, m.width) + + // Render boxes + timerBox := m.timerBoxModel.View(timerBoxWidth, timerBoxHeight, m.selectedBox == TimerBox) + clientsProjectsBox := m.clientsProjectsModel.View(clientsProjectsBoxWidth, clientsProjectsBoxHeight, m.selectedBox == ClientsProjectsBox) + historyBox := m.historyBoxModel.View(historyBoxWidth, historyBoxHeight, m.selectedBox == HistoryBox) + + // Layout: Timer box above Clients/Projects box on the left, History box on the right + leftColumn := lipgloss.JoinVertical(lipgloss.Left, timerBox, clientsProjectsBox) + mainContent := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, historyBox) + + // Render bottom bar + keyBindings := GetContextualKeyBindings(m.selectedBox, m.historyBoxModel.viewLevel, m.timerBoxModel.timerInfo.IsActive) + bottomBar := RenderBottomBar(keyBindings, m.width) + + // Combine everything + finalView := topBar + "\n" + mainContent + "\n" + bottomBar + + // Overlay modal if one is active using lipgloss v2 Layers + if m.showModal { + modal := m.renderModal() + + // Create layers for base content and modal + baseLayer := lipgloss.NewLayer(finalView) + modalLayer := lipgloss.NewLayer(modal). + X((m.width - 60) / 2). // Center horizontally + Y((m.height - 8) / 2). // Center vertically + Z(1) // Put modal on top + + // Use lipgloss v2 Canvas to overlay the modal + canvas := lipgloss.NewCanvas(baseLayer, modalLayer) + finalView = canvas.Render() + } + + return finalView +} + +// renderModal renders the modal content with proper styling +func (m AppModel) renderModal() string { + // Modal dimensions + modalWidth := 60 + + // Create modal content based on type + var modalContent string + switch m.modalType { + case ModalDescribeTimer: + modalContent = m.renderDescribeTimerModal(modalWidth-8) // Account for border and padding + } + + // Create modal box with border using lipgloss v2 + modalStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Background(lipgloss.Color("235")). + Padding(1, 2). + Width(modalWidth-4) + + return modalStyle.Render(modalContent) +} + + +// renderDescribeTimerModal renders the timer description modal +func (m AppModel) renderDescribeTimerModal(width int) string { + prompt := m.textInputModel.prompt + value := m.textInputModel.value + placeholder := m.textInputModel.placeholder + cursorPos := m.textInputModel.cursorPos + + // Show placeholder if value is empty + displayValue := value + if displayValue == "" { + displayValue = placeholder + // Style placeholder differently + displayValue = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(displayValue) + } else { + // Insert cursor + if cursorPos >= 0 && cursorPos <= len(value) { + if cursorPos == len(value) { + displayValue = value + "│" + } else { + displayValue = value[:cursorPos] + "│" + value[cursorPos:] + } + } + } + + // Input field styling + inputStyle := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("6")). + Padding(0, 1). + Width(width - 2) + + inputField := inputStyle.Render(displayValue) + + instructions := lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")). + Render("Press Enter to save, Escape to cancel") + + return prompt + "\n\n" + inputField + "\n\n" + instructions +} + +// tickCmd returns a command that sends a tick message every second +func (m AppModel) tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return TickMsg(t) + }) +} + +// updateDataCmd triggers a data update +type updateDataCmd struct{} + +// dataUpdatedMsg is sent when data is updated from the database +type dataUpdatedMsg struct { + timerInfo TimerInfo + stats TimeStats + clients []queries.Client + projects []queries.ListAllProjectsRow + entries []queries.TimeEntry +} + +// updateDataCmd returns a command to update data +func (m AppModel) updateDataCmd() tea.Cmd { + return func() tea.Msg { + timerInfo, stats, clients, projects, entries, err := GetAppData(m.ctx, m.queries) + if err != nil { + // Handle error silently for now - return empty data + return dataUpdatedMsg{ + timerInfo: TimerInfo{}, + stats: TimeStats{}, + clients: []queries.Client{}, + projects: []queries.ListAllProjectsRow{}, + entries: []queries.TimeEntry{}, + } + } + + return dataUpdatedMsg{ + timerInfo: timerInfo, + stats: stats, + clients: clients, + projects: projects, + entries: entries, + } + } +} + +// Run starts the TUI application +func Run(ctx context.Context, q *queries.Queries) error { + app := NewApp(ctx, q) + p := tea.NewProgram(app, tea.WithAltScreen()) + _, err := p.Run() + return err +} diff --git a/internal/tui/clients_projects_box.go b/internal/tui/clients_projects_box.go new file mode 100644 index 0000000..c52e964 --- /dev/null +++ b/internal/tui/clients_projects_box.go @@ -0,0 +1,247 @@ +package tui + +import ( + "fmt" + + "punchcard/internal/queries" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss/v2" +) + +// NewClientsProjectsModel creates a new clients/projects model +func NewClientsProjectsModel() ClientsProjectsModel { + return ClientsProjectsModel{ + selectedIndex: 0, + selectedIsClient: true, + } +} + +// Update handles messages for the clients/projects box +func (m ClientsProjectsModel) Update(msg tea.Msg) (ClientsProjectsModel, tea.Cmd) { + return m, nil +} + +// View renders the clients/projects box +func (m ClientsProjectsModel) View(width, height int, isSelected bool) string { + var content string + + if len(m.clients) == 0 { + content = "No clients found\n\nUse 'punch add client' to\nadd your first client." + } else { + content = m.renderClientsAndProjects() + } + + // Apply box styling + style := unselectedBoxStyle + if isSelected { + style = selectedBoxStyle + } + + title := "👥 Clients & Projects" + + return style.Width(width).Height(height).Render( + fmt.Sprintf("%s\n\n%s", title, content), + ) +} + +// renderClientsAndProjects renders the clients and their projects +func (m ClientsProjectsModel) renderClientsAndProjects() string { + var content string + + // Group projects by client + projectsByClient := make(map[int64][]queries.ListAllProjectsRow) + for _, project := range m.projects { + projectsByClient[project.ClientID] = append(projectsByClient[project.ClientID], project) + } + + // Track the absolute row index for selection highlighting + absoluteRowIndex := 0 + + for i, client := range m.clients { + if i > 0 { + content += "\n" + } + + // Client name with rate if available + clientLine := fmt.Sprintf("• %s", client.Name) + if client.BillableRate.Valid { + rateInDollars := float64(client.BillableRate.Int64) / 100.0 + clientLine += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) + } + + // Highlight if this client is selected + clientStyle := lipgloss.NewStyle().Bold(true) + if m.selectedIsClient && m.selectedIndex == i { + clientStyle = clientStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + } + content += clientStyle.Render(clientLine) + "\n" + absoluteRowIndex++ + + // Projects for this client + clientProjects := projectsByClient[client.ID] + if len(clientProjects) == 0 { + content += " └── (no projects)\n" + } else { + for j, project := range clientProjects { + prefix := "├──" + if j == len(clientProjects)-1 { + prefix = "└──" + } + + projectLine := fmt.Sprintf(" %s %s", prefix, project.Name) + if project.BillableRate.Valid { + rateInDollars := float64(project.BillableRate.Int64) / 100.0 + projectLine += fmt.Sprintf(" ($%.2f/hr)", rateInDollars) + } + + // Highlight if this project is selected + // We need to check against the absolute project index in m.projects + projectStyle := lipgloss.NewStyle() + if !m.selectedIsClient { + // Find this project's index in the m.projects slice + for k, p := range m.projects { + if p.ID == project.ID && m.selectedIndex == k { + projectStyle = projectStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + break + } + } + } + content += projectStyle.Render(projectLine) + "\n" + } + } + } + + return content +} + +// UpdateData updates the clients and projects data +func (m ClientsProjectsModel) UpdateData(clients []queries.Client, projects []queries.ListAllProjectsRow) ClientsProjectsModel { + m.clients = clients + m.projects = projects + // Reset selection if we have new data + if len(clients) > 0 { + m.selectedIndex = 0 + m.selectedIsClient = true + } + return m +} + +// NextSelection moves to the next selectable row +func (m ClientsProjectsModel) NextSelection() ClientsProjectsModel { + totalRows := m.getTotalSelectableRows() + if totalRows == 0 { + return m + } + + currentIndex := m.getCurrentRowIndex() + if currentIndex < totalRows-1 { + m.setRowIndex(currentIndex + 1) + } + return m +} + +// PrevSelection moves to the previous selectable row +func (m ClientsProjectsModel) PrevSelection() ClientsProjectsModel { + totalRows := m.getTotalSelectableRows() + if totalRows == 0 { + return m + } + + currentIndex := m.getCurrentRowIndex() + if currentIndex > 0 { + m.setRowIndex(currentIndex - 1) + } + return m +} + +// getDisplayOrder returns items in the order they are displayed (tree structure) +func (m ClientsProjectsModel) getDisplayOrder() []ProjectsDisplayItem { + var items []ProjectsDisplayItem + + // Group projects by client + projectsByClient := make(map[int64][]queries.ListAllProjectsRow) + projectIndexByID := make(map[int64]int) + for i, project := range m.projects { + projectsByClient[project.ClientID] = append(projectsByClient[project.ClientID], project) + projectIndexByID[project.ID] = i + } + + // Build display order: client followed by its projects + for i, client := range m.clients { + // Add client + items = append(items, ProjectsDisplayItem{ + IsClient: true, + ClientIndex: i, + Client: &client, + }) + + // Add projects for this client + clientProjects := projectsByClient[client.ID] + for _, project := range clientProjects { + projectCopy := project // Copy to avoid reference issues + items = append(items, ProjectsDisplayItem{ + IsClient: false, + ClientIndex: i, + ProjectIndex: projectIndexByID[project.ID], + Project: &projectCopy, + }) + } + } + + return items +} + +// getTotalSelectableRows counts total items in display order +func (m ClientsProjectsModel) getTotalSelectableRows() int { + return len(m.getDisplayOrder()) +} + +// getCurrentRowIndex gets the current absolute row index in display order +func (m ClientsProjectsModel) getCurrentRowIndex() int { + displayOrder := m.getDisplayOrder() + + for i, item := range displayOrder { + if item.IsClient && m.selectedIsClient && item.ClientIndex == m.selectedIndex { + return i + } + if !item.IsClient && !m.selectedIsClient && item.ProjectIndex == m.selectedIndex { + return i + } + } + + return 0 // Default to first item if not found +} + +// setRowIndex sets the selection to the given absolute row index in display order +func (m *ClientsProjectsModel) setRowIndex(index int) { + displayOrder := m.getDisplayOrder() + if index < 0 || index >= len(displayOrder) { + return + } + + item := displayOrder[index] + if item.IsClient { + m.selectedIndex = item.ClientIndex + m.selectedIsClient = true + } else { + m.selectedIndex = item.ProjectIndex + m.selectedIsClient = false + } +} + +// GetSelectedClient returns the selected client if one is selected +func (m ClientsProjectsModel) GetSelectedClient() *queries.Client { + if m.selectedIsClient && m.selectedIndex < len(m.clients) { + return &m.clients[m.selectedIndex] + } + return nil +} + +// GetSelectedProject returns the selected project if one is selected +func (m ClientsProjectsModel) GetSelectedProject() *queries.ListAllProjectsRow { + if !m.selectedIsClient && m.selectedIndex < len(m.projects) { + return &m.projects[m.selectedIndex] + } + return nil +} \ No newline at end of file diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go new file mode 100644 index 0000000..813eb17 --- /dev/null +++ b/internal/tui/history_box.go @@ -0,0 +1,516 @@ +package tui + +import ( + "fmt" + "sort" + "time" + + "punchcard/internal/queries" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss/v2" +) + +// NewHistoryBoxModel creates a new history box model +func NewHistoryBoxModel() HistoryBoxModel { + return HistoryBoxModel{ + viewLevel: HistoryLevelSummary, + selectedIndex: 0, + } +} + +// Update handles messages for the history box +func (m HistoryBoxModel) Update(msg tea.Msg) (HistoryBoxModel, tea.Cmd) { + return m, nil +} + +// View renders the history box +func (m HistoryBoxModel) View(width, height int, isSelected bool) string { + var content string + var title string + + if len(m.entries) == 0 { + content = "No recent entries\n\nStart tracking time to\nsee your history here." + title = "📝 Recent History" + } else { + if m.viewLevel == HistoryLevelDetails && m.selectedSummaryItem != nil { + // Details view + title = fmt.Sprintf("📝 Details: %s", m.formatSummaryTitle(*m.selectedSummaryItem)) + content = m.renderDetailsView() + } else { + // Summary view + title = "📝 Recent History" + content = m.renderSummaryView() + } + } + + // Apply box styling + style := unselectedBoxStyle + if isSelected { + style = selectedBoxStyle + } + + return style.Width(width).Height(height).Render( + fmt.Sprintf("%s\n\n%s", title, content), + ) +} + +// renderSummaryView renders the summary view (level 1) with date headers and client/project summaries +func (m HistoryBoxModel) renderSummaryView() string { + var content string + displayItems := m.getDisplayItems() + + if len(displayItems) == 0 { + return "No recent entries found." + } + + // Find a valid selected index for rendering (don't modify the model) + selectedIndex := m.selectedIndex + if selectedIndex < 0 || selectedIndex >= len(displayItems) || !displayItems[selectedIndex].IsSelectable { + // Find the first selectable item for display purposes + for i, item := range displayItems { + if item.IsSelectable { + selectedIndex = i + break + } + } + } + + for i, item := range displayItems { + var itemStyle lipgloss.Style + var line string + + switch item.Type { + case HistoryItemDateHeader: + // Date header + line = *item.DateHeader + itemStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")) + + case HistoryItemSummary: + // Summary item + summary := item.Summary + clientProject := m.formatSummaryTitle(*summary) + line = fmt.Sprintf(" %s (%s)", clientProject, FormatDuration(summary.TotalDuration)) + + // Highlight if selected + if item.IsSelectable && selectedIndex == i { + itemStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + } else { + itemStyle = lipgloss.NewStyle() + } + } + + content += itemStyle.Render(line) + "\n" + } + + return content +} + +// renderDetailsView renders the details view (level 2) showing individual entries +func (m HistoryBoxModel) renderDetailsView() string { + var content string + + if len(m.detailsEntries) == 0 { + return "No entries found for this selection." + } + + for i, entry := range m.detailsEntries { + // Calculate duration + var duration time.Duration + if entry.EndTime.Valid { + duration = entry.EndTime.Time.Sub(entry.StartTime) + } else { + // Active entry - use cached running timer data if available + if m.runningTimerStart != nil { + duration = time.Since(*m.runningTimerStart) + } else { + // Fallback to entry start time if cache not available + duration = time.Since(entry.StartTime) + } + } + + // Format time range + startTime := entry.StartTime.Local().Format("3:04 PM") + var timeRange string + if entry.EndTime.Valid { + endTime := entry.EndTime.Time.Local().Format("3:04 PM") + timeRange = fmt.Sprintf("%s - %s", startTime, endTime) + } else { + timeRange = fmt.Sprintf("%s - now", startTime) + } + + // Entry line + entryLine := fmt.Sprintf("%s (%s)", timeRange, FormatDuration(duration)) + + // Apply selection highlighting + entryStyle := lipgloss.NewStyle() + if m.selectedIndex == i { + entryStyle = entryStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + } + + // Also highlight active entries differently + if !entry.EndTime.Valid { + if m.selectedIndex == i { + // Selected active entry + entryStyle = entryStyle.Background(lipgloss.Color("196")).Foreground(lipgloss.Color("230")) + } else { + // Non-selected active entry + entryStyle = activeTimerStyle + } + } + + content += entryStyle.Render(entryLine) + "\n" + + // Description if available + if entry.Description.Valid && entry.Description.String != "" { + descStyle := lipgloss.NewStyle() + if m.selectedIndex == i { + descStyle = descStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")) + } + content += descStyle.Render(fmt.Sprintf(" \"%s\"", entry.Description.String)) + "\n" + } + + // Add spacing between entries + if i < len(m.detailsEntries)-1 { + content += "\n" + } + } + + return content +} + +// formatSummaryTitle creates a display title for a summary item +func (m HistoryBoxModel) formatSummaryTitle(summary HistorySummaryItem) string { + var title string + if summary.ClientName != "" { + title = summary.ClientName + } else { + title = fmt.Sprintf("Client %d", summary.ClientID) + } + + if summary.ProjectID != nil { + if summary.ProjectName != nil && *summary.ProjectName != "" { + title += fmt.Sprintf(" / %s", *summary.ProjectName) + } else { + title += fmt.Sprintf(" / Project %d", *summary.ProjectID) + } + } + + return title +} + +// UpdateEntries updates the history entries and regenerates summary data +func (m HistoryBoxModel) UpdateEntries(entries []queries.TimeEntry) HistoryBoxModel { + m.entries = entries + // Reset view to summary level + m.viewLevel = HistoryLevelSummary + m.selectedSummaryItem = nil + // Regenerate summary data + m.summaryItems = m.generateSummaryItems(entries) + // Ensure we have a valid selection pointing to a selectable item + m.selectedIndex = 0 + m = m.ensureValidSelection() + return m +} + +// UpdateData updates the history entries along with client and project data for name lookups +func (m HistoryBoxModel) UpdateData(entries []queries.TimeEntry, clients []queries.Client, projects []queries.ListAllProjectsRow) HistoryBoxModel { + m.entries = entries + m.clients = clients + m.projects = projects + // Reset view to summary level + m.viewLevel = HistoryLevelSummary + m.selectedSummaryItem = nil + // Regenerate summary data with the new client/project data + m.summaryItems = m.generateSummaryItems(entries) + // Ensure we have a valid selection pointing to a selectable item + m.selectedIndex = 0 + m = m.ensureValidSelection() + return m +} + +// NextSelection moves to the next selectable row +func (m HistoryBoxModel) NextSelection() HistoryBoxModel { + displayItems := m.getDisplayItems() + if len(displayItems) == 0 { + return m + } + + // Ensure current selection is valid + m = m.ensureValidSelection() + + // Find next selectable item + for i := m.selectedIndex + 1; i < len(displayItems); i++ { + if displayItems[i].IsSelectable { + m.selectedIndex = i + break + } + } + + return m +} + +// PrevSelection moves to the previous selectable row +func (m HistoryBoxModel) PrevSelection() HistoryBoxModel { + displayItems := m.getDisplayItems() + if len(displayItems) == 0 { + return m + } + + // Ensure current selection is valid + m = m.ensureValidSelection() + + // Find previous selectable item + for i := m.selectedIndex - 1; i >= 0; i-- { + if displayItems[i].IsSelectable { + m.selectedIndex = i + break + } + } + + return m +} + +// ensureValidSelection ensures the selected index points to a valid selectable item +func (m HistoryBoxModel) ensureValidSelection() HistoryBoxModel { + displayItems := m.getDisplayItems() + if len(displayItems) == 0 { + m.selectedIndex = 0 + return m + } + + // If current selection is valid and selectable, keep it + if m.selectedIndex >= 0 && m.selectedIndex < len(displayItems) && displayItems[m.selectedIndex].IsSelectable { + return m + } + + // Find the first selectable item + for i, item := range displayItems { + if item.IsSelectable { + m.selectedIndex = i + break + } + } + + return m +} + +// GetSelectedEntry returns the currently selected entry +func (m HistoryBoxModel) GetSelectedEntry() *queries.TimeEntry { + if m.viewLevel == HistoryLevelDetails { + if m.selectedIndex >= 0 && m.selectedIndex < len(m.detailsEntries) { + return &m.detailsEntries[m.selectedIndex] + } + } else { + if m.selectedIndex >= 0 && m.selectedIndex < len(m.entries) { + return &m.entries[m.selectedIndex] + } + } + return nil +} + +// generateSummaryItems creates summary items grouped by date and client/project +func (m HistoryBoxModel) generateSummaryItems(entries []queries.TimeEntry) []HistorySummaryItem { + // Group entries by date and client/project combination + groupMap := make(map[string]*HistorySummaryItem) + + for _, entry := range entries { + // Get the date (year-month-day only) + date := entry.StartTime.Truncate(24 * time.Hour) + + // Create a key for grouping + key := fmt.Sprintf("%s-%d", date.Format("2006-01-02"), entry.ClientID) + if entry.ProjectID.Valid { + key += fmt.Sprintf("-%d", entry.ProjectID.Int64) + } + + // Calculate duration for this entry + var duration time.Duration + if entry.EndTime.Valid { + duration = entry.EndTime.Time.Sub(entry.StartTime) + } else { + // Active entry - use cached running timer data if available + if m.runningTimerStart != nil { + duration = time.Since(*m.runningTimerStart) + } else { + // Fallback to entry start time if cache not available + duration = time.Since(entry.StartTime) + } + } + + // Add to or update existing group + if existing, exists := groupMap[key]; exists { + existing.TotalDuration += duration + existing.EntryCount++ + } else { + // Create new summary item + item := &HistorySummaryItem{ + Date: date, + ClientID: entry.ClientID, + ClientName: m.lookupClientName(entry.ClientID), + TotalDuration: duration, + EntryCount: 1, + } + + if entry.ProjectID.Valid { + projectID := entry.ProjectID.Int64 + item.ProjectID = &projectID + projectName := m.lookupProjectName(projectID) + item.ProjectName = &projectName + } + + groupMap[key] = item + } + } + + // Convert map to slice and sort by date (descending) then by client name + var items []HistorySummaryItem + for _, item := range groupMap { + items = append(items, *item) + } + + sort.Slice(items, func(i, j int) bool { + // Sort by date descending, then by client name ascending + if !items[i].Date.Equal(items[j].Date) { + return items[i].Date.After(items[j].Date) + } + return items[i].ClientName < items[j].ClientName + }) + + return items +} + +// lookupClientName finds the client name by ID +func (m HistoryBoxModel) lookupClientName(clientID int64) string { + for _, client := range m.clients { + if client.ID == clientID { + return client.Name + } + } + return fmt.Sprintf("Client %d", clientID) // Fallback if not found +} + +// lookupProjectName finds the project name by ID +func (m HistoryBoxModel) lookupProjectName(projectID int64) string { + for _, project := range m.projects { + if project.ID == projectID { + return project.Name + } + } + return fmt.Sprintf("Project %d", projectID) // Fallback if not found +} + +// DrillDown drills down into the selected summary item +func (m HistoryBoxModel) DrillDown() HistoryBoxModel { + if m.viewLevel != HistoryLevelSummary { + return m + } + + // Get the selected summary item + displayItems := m.getDisplayItems() + if m.selectedIndex >= 0 && m.selectedIndex < len(displayItems) { + item := displayItems[m.selectedIndex] + if item.Type == HistoryItemSummary && item.Summary != nil { + // Switch to details view + m.viewLevel = HistoryLevelDetails + m.selectedSummaryItem = item.Summary + m.selectedIndex = 0 + + // Filter entries for this date/client/project combination + m.detailsEntries = m.getEntriesForSummaryItem(*item.Summary) + } + } + + return m +} + +// GoBack goes back to summary view from details view +func (m HistoryBoxModel) GoBack() HistoryBoxModel { + if m.viewLevel == HistoryLevelDetails { + m.viewLevel = HistoryLevelSummary + m.selectedSummaryItem = nil + m.selectedIndex = 0 + m.detailsEntries = nil + // Ensure we have a valid selection pointing to a selectable item + m = m.ensureValidSelection() + } + return m +} + +// getEntriesForSummaryItem returns all entries that match the given summary item +func (m HistoryBoxModel) getEntriesForSummaryItem(summary HistorySummaryItem) []queries.TimeEntry { + var matchingEntries []queries.TimeEntry + + for _, entry := range m.entries { + // Check if entry matches the summary item criteria + entryDate := entry.StartTime.Truncate(24 * time.Hour) + if !entryDate.Equal(summary.Date) { + continue + } + + if entry.ClientID != summary.ClientID { + continue + } + + // Check project ID match + if summary.ProjectID == nil && entry.ProjectID.Valid { + continue + } + if summary.ProjectID != nil && (!entry.ProjectID.Valid || entry.ProjectID.Int64 != *summary.ProjectID) { + continue + } + + matchingEntries = append(matchingEntries, entry) + } + + // Sort by start time descending (most recent first) + sort.Slice(matchingEntries, func(i, j int) bool { + return matchingEntries[i].StartTime.After(matchingEntries[j].StartTime) + }) + + return matchingEntries +} + +// getDisplayItems returns the items to display based on current view level +func (m HistoryBoxModel) getDisplayItems() []HistoryDisplayItem { + if m.viewLevel == HistoryLevelDetails { + // Details view - show individual entries + var items []HistoryDisplayItem + for _, entry := range m.detailsEntries { + entryCopy := entry + items = append(items, HistoryDisplayItem{ + Type: HistoryItemEntry, + Entry: &entryCopy, + IsSelectable: true, + }) + } + return items + } else { + // Summary view - show date headers and summary items + var items []HistoryDisplayItem + var currentDate *time.Time + + for _, summary := range m.summaryItems { + // Add date header if this is a new date + if currentDate == nil || !currentDate.Equal(summary.Date) { + dateStr := summary.Date.Format("Monday, January 2, 2006") + items = append(items, HistoryDisplayItem{ + Type: HistoryItemDateHeader, + DateHeader: &dateStr, + IsSelectable: false, + }) + currentDate = &summary.Date + } + + // Add summary item + summaryCopy := summary + items = append(items, HistoryDisplayItem{ + Type: HistoryItemSummary, + Summary: &summaryCopy, + IsSelectable: true, + }) + } + + return items + } +} \ No newline at end of file diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..7f23407 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,229 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +// KeyAction represents the action to take for a key press +type KeyAction int + +const ( + // Global actions + ActionNone KeyAction = iota + ActionNextPane + ActionPrevPane + ActionPunchToggle + ActionSearch + ActionRefresh + ActionQuit + + // Timer pane actions + ActionTimerEnter + ActionTimerDescribe + + // Projects pane actions + ActionProjectsNext + ActionProjectsPrev + ActionProjectsEnter + ActionProjectsNewProject + ActionProjectsNewClient + + // History pane actions (level 1) + ActionHistoryNext + ActionHistoryPrev + ActionHistoryEnter + + // History pane actions (level 2) + ActionHistoryEdit + ActionHistoryDelete + ActionHistoryResume + ActionHistoryBack +) + +// KeyHandler processes key messages and returns the appropriate action +func HandleKeyPress(msg tea.KeyMsg, selectedBox BoxType, historyLevel HistoryViewLevel, hasActiveTimer bool) KeyAction { + key := msg.String() + + // Global keybindings (always available) + switch key { + case "ctrl+n": + return ActionNextPane + case "ctrl+p": + return ActionPrevPane + case "p": + return ActionPunchToggle + case "/": + return ActionSearch + case "r": + return ActionRefresh + case "q", "ctrl+c", "ctrl+d": + return ActionQuit + } + + // Context-specific keybindings based on selected box + switch selectedBox { + case TimerBox: + return handleTimerKeys(key, hasActiveTimer) + case ClientsProjectsBox: + return handleProjectsKeys(key) + case HistoryBox: + return handleHistoryKeys(key, historyLevel) + } + + return ActionNone +} + +// handleTimerKeys handles keys specific to the timer box +func handleTimerKeys(key string, hasActiveTimer bool) KeyAction { + switch key { + case "enter": + return ActionTimerEnter + case "d": + if hasActiveTimer { + return ActionTimerDescribe + } + } + return ActionNone +} + +// handleProjectsKeys handles keys specific to the projects box +func handleProjectsKeys(key string) KeyAction { + switch key { + case "j", "down": + return ActionProjectsNext + case "k", "up": + return ActionProjectsPrev + case "enter": + return ActionProjectsEnter + case "n": + return ActionProjectsNewProject + case "N": + return ActionProjectsNewClient + } + return ActionNone +} + +// handleHistoryKeys handles keys specific to the history box +func handleHistoryKeys(key string, level HistoryViewLevel) KeyAction { + switch level { + case HistoryLevelSummary: + return handleHistoryLevel1Keys(key) + case HistoryLevelDetails: + return handleHistoryLevel2Keys(key) + } + return ActionNone +} + +// handleHistoryLevel1Keys handles keys for history summary view +func handleHistoryLevel1Keys(key string) KeyAction { + switch key { + case "j", "down": + return ActionHistoryNext + case "k", "up": + return ActionHistoryPrev + case "enter": + return ActionHistoryEnter + } + return ActionNone +} + +// handleHistoryLevel2Keys handles keys for history details view +func handleHistoryLevel2Keys(key string) KeyAction { + switch key { + case "j", "down": + return ActionHistoryNext + case "k", "up": + return ActionHistoryPrev + case "e": + return ActionHistoryEdit + case "d": + return ActionHistoryDelete + case "enter": + return ActionHistoryResume + case "b", "escape": + return ActionHistoryBack + } + return ActionNone +} + +// GetContextualKeyBindings returns the key bindings that should be shown in the bottom bar +func GetContextualKeyBindings(selectedBox BoxType, historyLevel HistoryViewLevel, hasActiveTimer bool) []KeyBinding { + var bindings []KeyBinding + + // Global bindings (always shown) + bindings = append(bindings, []KeyBinding{ + {"Ctrl+n", "Next"}, + {"Ctrl+p", "Prev"}, + }...) + + // Add punch toggle binding + if hasActiveTimer { + bindings = append(bindings, KeyBinding{"p", "Punch Out"}) + } else { + bindings = append(bindings, KeyBinding{"p", "Punch In"}) + } + + // Add search and refresh bindings + bindings = append(bindings, []KeyBinding{ + {"/", "Search"}, + {"r", "Refresh"}, + }...) + + // Context-specific bindings + switch selectedBox { + case TimerBox: + bindings = append(bindings, getTimerKeyBindings(hasActiveTimer)...) + case ClientsProjectsBox: + bindings = append(bindings, getProjectsKeyBindings()...) + case HistoryBox: + bindings = append(bindings, getHistoryKeyBindings(historyLevel)...) + } + + // Always end with quit + bindings = append(bindings, KeyBinding{"q", "Quit"}) + + return bindings +} + +// getTimerKeyBindings returns key bindings for the timer box +func getTimerKeyBindings(hasActiveTimer bool) []KeyBinding { + if hasActiveTimer { + return []KeyBinding{ + {"Enter", "Punch Out"}, + {"d", "Describe"}, + } + } + return []KeyBinding{ + {"Enter", "Resume Recent"}, + } +} + +// getProjectsKeyBindings returns key bindings for the projects box +func getProjectsKeyBindings() []KeyBinding { + return []KeyBinding{ + {"j/k", "Navigate"}, + {"Enter", "Select"}, + {"n", "New Project"}, + {"N", "New Client"}, + } +} + +// getHistoryKeyBindings returns key bindings for the history box +func getHistoryKeyBindings(level HistoryViewLevel) []KeyBinding { + switch level { + case HistoryLevelSummary: + return []KeyBinding{ + {"j/k", "Navigate"}, + {"Enter", "Details"}, + } + case HistoryLevelDetails: + return []KeyBinding{ + {"j/k", "Navigate"}, + {"Enter", "Resume"}, + {"e", "Edit"}, + {"d", "Delete"}, + {"b", "Back"}, + } + } + return []KeyBinding{} +} diff --git a/internal/tui/shared.go b/internal/tui/shared.go new file mode 100644 index 0000000..77b282d --- /dev/null +++ b/internal/tui/shared.go @@ -0,0 +1,225 @@ +package tui + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "punchcard/internal/queries" + + "github.com/charmbracelet/lipgloss/v2" +) + +var ( + // Styles for the TUI + topBarInactiveStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("21")). + Foreground(lipgloss.Color("230")). + Padding(0, 1) + + bottomBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("238")). + Foreground(lipgloss.Color("252")). + Padding(0, 1) + + // Box styles + selectedBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2) + + unselectedBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("238")). + Padding(1, 2) + + activeTimerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true) + + inactiveTimerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("246")) +) + +// FormatDuration formats a duration in a human-readable way +func FormatDuration(d time.Duration) string { + d = d.Round(time.Second) + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + if hours > 0 { + return fmt.Sprintf("%dh %02dm", hours, minutes) + } + if minutes > 0 { + return fmt.Sprintf("%dm %02ds", minutes, seconds) + } + return fmt.Sprintf("%ds", seconds) +} + +// GetTimeStats retrieves today's and week's time statistics +func GetTimeStats(ctx context.Context, q *queries.Queries) (TimeStats, error) { + var stats TimeStats + + // Get today's total + todaySeconds, err := q.GetTodaySummary(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return stats, fmt.Errorf("failed to get today's summary: %w", err) + } + if err == nil { + stats.TodayTotal = time.Duration(todaySeconds) * time.Second + } + + // Get week's total + weekSummary, err := q.GetWeekSummaryByProject(ctx) + if err != nil { + return stats, fmt.Errorf("failed to get week summary: %w", err) + } + + var weekTotal time.Duration + for _, row := range weekSummary { + weekTotal += time.Duration(row.TotalSeconds) * time.Second + } + stats.WeekTotal = weekTotal + + return stats, nil +} + +// GetTimerInfo retrieves current timer information +func GetTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo, error) { + var info TimerInfo + + activeEntry, err := q.GetActiveTimeEntry(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return info, fmt.Errorf("failed to get active timer: %w", err) + } + + if errors.Is(err, sql.ErrNoRows) { + // No active timer + return info, nil + } + + // Active timer found + info.IsActive = true + info.StartTime = activeEntry.StartTime + info.Duration = time.Since(activeEntry.StartTime) + + // Get client information + client, err := q.FindClient(ctx, queries.FindClientParams{ + ID: activeEntry.ClientID, + Name: "", + }) + if err == nil && len(client) > 0 { + info.ClientName = client[0].Name + if client[0].BillableRate.Valid { + rate := float64(client[0].BillableRate.Int64) / 100.0 + info.BillableRate = &rate + } + } + + // Get project information if exists + if activeEntry.ProjectID.Valid { + project, err := q.FindProject(ctx, queries.FindProjectParams{ + ID: activeEntry.ProjectID.Int64, + Name: "", + }) + if err == nil && len(project) > 0 { + info.ProjectName = project[0].Name + if project[0].BillableRate.Valid { + projectRate := float64(project[0].BillableRate.Int64) / 100.0 + info.BillableRate = &projectRate + } + } + } + + // Get description + if activeEntry.Description.Valid { + info.Description = activeEntry.Description.String + } + + // Use entry-specific billable rate if set + if activeEntry.BillableRate.Valid { + entryRate := float64(activeEntry.BillableRate.Int64) / 100.0 + info.BillableRate = &entryRate + } + + return info, nil +} + +// RenderTopBar renders the top bar with view name and time stats +func RenderTopBar(viewName string, stats TimeStats, width int) string { + left := viewName + right := fmt.Sprintf("Today: %s | Week: %s", + FormatDuration(stats.TodayTotal), + FormatDuration(stats.WeekTotal)) + + // Use lipgloss to create left and right aligned content + leftStyle := lipgloss.NewStyle().Align(lipgloss.Left) + rightStyle := lipgloss.NewStyle().Align(lipgloss.Right) + + // Calculate available width for content (minus padding) + contentWidth := width - 2 // Account for horizontal padding + + // Create a layout with left and right content + content := lipgloss.JoinHorizontal( + lipgloss.Top, + leftStyle.Width(contentWidth/2).Render(left), + rightStyle.Width(contentWidth/2).Render(right), + ) + + return topBarInactiveStyle.Width(width).Render(content) +} + +// RenderBottomBar renders the bottom bar with key bindings +func RenderBottomBar(bindings []KeyBinding, width int) string { + var content string + for i, binding := range bindings { + if i > 0 { + content += " " + } + // Format key with bold and square brackets + keyStyle := lipgloss.NewStyle().Bold(true) + formattedKey := keyStyle.Render(fmt.Sprintf("[%s]", binding.Key)) + content += fmt.Sprintf("%s %s", formattedKey, binding.Description) + } + + return bottomBarStyle.Width(width).Render(content) +} + +// GetAppData fetches all data needed for the TUI +func GetAppData(ctx context.Context, q *queries.Queries) (TimerInfo, TimeStats, []queries.Client, []queries.ListAllProjectsRow, []queries.TimeEntry, error) { + // Get timer info + timerInfo, err := GetTimerInfo(ctx, q) + if err != nil { + return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get timer info: %w", err) + } + + // Get time stats + stats, err := GetTimeStats(ctx, q) + if err != nil { + return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get time stats: %w", err) + } + + // Get clients + clients, err := q.ListAllClients(ctx) + if err != nil { + return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get clients: %w", err) + } + + // Get projects + projects, err := q.ListAllProjects(ctx) + if err != nil { + return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get projects: %w", err) + } + + // Get recent entries + entries, err := q.GetRecentTimeEntries(ctx, 20) + if err != nil { + return TimerInfo{}, TimeStats{}, nil, nil, nil, fmt.Errorf("failed to get recent entries: %w", err) + } + + return timerInfo, stats, clients, projects, entries, nil +} + diff --git a/internal/tui/timer.go b/internal/tui/timer.go new file mode 100644 index 0000000..827951d --- /dev/null +++ b/internal/tui/timer.go @@ -0,0 +1,150 @@ +package tui + +import ( + "context" + "fmt" + "time" + + "punchcard/internal/queries" + + tea "github.com/charmbracelet/bubbletea" +) + +// TimerModel represents the timer view model +type TimerModel struct { + ctx context.Context + queries *queries.Queries + timerInfo TimerInfo + stats TimeStats + lastTick time.Time +} + +// NewTimerModel creates a new timer model +func NewTimerModel(ctx context.Context, q *queries.Queries) TimerModel { + return TimerModel{ + ctx: ctx, + queries: q, + } +} + +// Init initializes the timer model +func (m TimerModel) Init() tea.Cmd { + return tea.Batch( + m.updateData(), + m.tickCmd(), + ) +} + +// Update handles messages for the timer model +func (m TimerModel) Update(msg tea.Msg) (TimerModel, tea.Cmd) { + switch msg := msg.(type) { + case TickMsg: + // Update timer duration if active + if m.timerInfo.IsActive { + m.timerInfo.Duration = time.Since(m.timerInfo.StartTime) + } + m.lastTick = time.Time(msg) + return m, m.tickCmd() + } + + return m, nil +} + +// View renders the timer view +func (m TimerModel) View(width, height int) string { + var content string + + if m.timerInfo.IsActive { + content += m.renderActiveTimer() + } else { + content += m.renderInactiveTimer() + } + + return content +} + +// renderActiveTimer renders the active timer display +func (m TimerModel) renderActiveTimer() string { + var content string + + // Timer status + timerLine := fmt.Sprintf("⏱ Tracking: %s", FormatDuration(m.timerInfo.Duration)) + content += activeTimerStyle.Render(timerLine) + "\n" + + // Project/Client info + if m.timerInfo.ProjectName != "" { + projectLine := fmt.Sprintf("Project: %s / %s", m.timerInfo.ClientName, m.timerInfo.ProjectName) + content += projectLine + "\n" + } else { + clientLine := fmt.Sprintf("Client: %s", m.timerInfo.ClientName) + content += clientLine + "\n" + } + + // Description if available + if m.timerInfo.Description != "" { + descLine := fmt.Sprintf("Description: %s", m.timerInfo.Description) + content += descLine + "\n" + } + + // Billable rate if available + if m.timerInfo.BillableRate != nil { + rateLine := fmt.Sprintf("Rate: $%.2f/hr", *m.timerInfo.BillableRate) + content += rateLine + "\n" + } + + // Start time (convert from UTC to local) + localStartTime := m.timerInfo.StartTime.Local() + startLine := fmt.Sprintf("Started: %s", localStartTime.Format("3:04 PM")) + content += startLine + "\n" + + return content +} + +// renderInactiveTimer renders the inactive timer display +func (m TimerModel) renderInactiveTimer() string { + var content string + + content += inactiveTimerStyle.Render("⚪ No active timer") + "\n" + content += "\n" + content += "Ready to start tracking time.\n" + + return content +} + +// updateData fetches fresh data from the database +func (m TimerModel) updateData() tea.Cmd { + return func() tea.Msg { + // Get timer info + timerInfo, err := GetTimerInfo(m.ctx, m.queries) + if err != nil { + // Handle error silently for now + return nil + } + + // Get time stats + stats, err := GetTimeStats(m.ctx, m.queries) + if err != nil { + // Handle error silently for now + return nil + } + + return dataUpdatedMsg{ + timerInfo: timerInfo, + stats: stats, + } + } +} + +// tickCmd returns a command that sends a tick message every second +func (m TimerModel) tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return TickMsg(t) + }) +} + +// UpdateData updates the model with fresh data +func (m TimerModel) UpdateData(timerInfo TimerInfo, stats TimeStats) TimerModel { + m.timerInfo = timerInfo + m.stats = stats + return m +} diff --git a/internal/tui/timer_box.go b/internal/tui/timer_box.go new file mode 100644 index 0000000..17781ee --- /dev/null +++ b/internal/tui/timer_box.go @@ -0,0 +1,101 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +// NewTimerBoxModel creates a new timer box model +func NewTimerBoxModel() TimerBoxModel { + return TimerBoxModel{} +} + +// Update handles messages for the timer box +func (m TimerBoxModel) Update(msg tea.Msg) (TimerBoxModel, tea.Cmd) { + return m, nil +} + +// View renders the timer box +func (m TimerBoxModel) View(width, height int, isSelected bool) string { + var content string + + if m.timerInfo.IsActive { + content = m.renderActiveTimer() + } else { + content = m.renderInactiveTimer() + } + + // Apply box styling + style := unselectedBoxStyle + if isSelected { + style = selectedBoxStyle + } + + var title string + if m.timerInfo.IsActive { + title = "⏱ Active Timer" + } else { + title = "⚪ Timer (Inactive)" + } + + return style.Width(width).Height(height).Render( + fmt.Sprintf("%s\n\n%s", title, content), + ) +} + +// renderActiveTimer renders the active timer display +func (m TimerBoxModel) renderActiveTimer() string { + var content string + + // Timer duration + timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.timerInfo.Duration)) + content += activeTimerStyle.Render(timerLine) + "\n\n" + + // Project/Client info + if m.timerInfo.ProjectName != "" { + projectLine := fmt.Sprintf("Project: %s / %s", m.timerInfo.ClientName, m.timerInfo.ProjectName) + content += projectLine + "\n" + } else { + clientLine := fmt.Sprintf("Client: %s", m.timerInfo.ClientName) + content += clientLine + "\n" + } + + // Start time (convert from UTC to local) + localStartTime := m.timerInfo.StartTime.Local() + startLine := fmt.Sprintf("Started: %s", localStartTime.Format("3:04 PM")) + content += startLine + "\n" + + // Description if available + if m.timerInfo.Description != "" { + content += "\n" + descLine := fmt.Sprintf("Description: %s", m.timerInfo.Description) + content += descLine + "\n" + } + + // Billable rate if available + if m.timerInfo.BillableRate != nil { + rateLine := fmt.Sprintf("Rate: $%.2f/hr", *m.timerInfo.BillableRate) + content += rateLine + "\n" + } + + return content +} + +// renderInactiveTimer renders the inactive timer display +func (m TimerBoxModel) renderInactiveTimer() string { + var content string + + content += "No active timer\n\n" + content += "Ready to start tracking time.\n" + content += "Use 'i' to punch in or select\n" + content += "a client/project from the left." + + return content +} + +// UpdateTimerInfo updates the timer info +func (m TimerBoxModel) UpdateTimerInfo(timerInfo TimerInfo) TimerBoxModel { + m.timerInfo = timerInfo + return m +} \ No newline at end of file diff --git a/internal/tui/types.go b/internal/tui/types.go new file mode 100644 index 0000000..2fcf55c --- /dev/null +++ b/internal/tui/types.go @@ -0,0 +1,165 @@ +package tui + +import ( + "context" + "time" + + "punchcard/internal/queries" +) + +// BoxType represents the different boxes that can be selected +type BoxType int + +const ( + TimerBox BoxType = iota + ClientsProjectsBox + HistoryBox +) + +func (b BoxType) String() string { + switch b { + case TimerBox: + return "Timer" + case ClientsProjectsBox: + return "Clients & Projects" + case HistoryBox: + return "History" + default: + return "Unknown" + } +} + +// AppModel is the main model for the TUI application +type AppModel struct { + ctx context.Context + queries *queries.Queries + selectedBox BoxType + timerBoxModel TimerBoxModel + clientsProjectsModel ClientsProjectsModel + historyBoxModel HistoryBoxModel + width int + height int + // Cached data to avoid DB queries in View() + stats TimeStats + runningTimerStart *time.Time // UTC timestamp when timer started, nil if not active + + // Modal state + showModal bool + modalType ModalType + textInputModel TextInputModel +} + +// ModalType represents different types of modals +type ModalType int + +const ( + ModalDescribeTimer ModalType = iota +) + +// TextInputModel represents a text input modal +type TextInputModel struct { + prompt string + value string + placeholder string + cursorPos int +} + +// TimerInfo holds information about the current timer state +type TimerInfo struct { + IsActive bool + Duration time.Duration + StartTime time.Time + ClientName string + ProjectName string + Description string + BillableRate *float64 +} + +// TimeStats holds time statistics for display +type TimeStats struct { + TodayTotal time.Duration + WeekTotal time.Duration +} + +// TickMsg is sent every second to update the timer +type TickMsg time.Time + +// KeyBinding represents the available key bindings for a view +type KeyBinding struct { + Key string + Description string +} + +// HistoryViewLevel represents the level of detail in history view +type HistoryViewLevel int + +const ( + HistoryLevelSummary HistoryViewLevel = iota // Level 1: Date/project summaries + HistoryLevelDetails // Level 2: Individual entries +) + +// Box models for the three main components +type TimerBoxModel struct { + timerInfo TimerInfo +} + +type ClientsProjectsModel struct { + clients []queries.Client + projects []queries.ListAllProjectsRow + selectedIndex int // Index of selected row (client or project) + selectedIsClient bool // True if selected row is a client, false if project +} + +type HistoryBoxModel struct { + entries []queries.TimeEntry + clients []queries.Client // For looking up client names + projects []queries.ListAllProjectsRow // For looking up project names + viewLevel HistoryViewLevel + selectedIndex int // Index of selected row + // Cached running timer data to avoid recalculating in View() + runningTimerStart *time.Time // UTC timestamp when timer started, nil if not active + + // Summary view data (level 1) + summaryItems []HistorySummaryItem + + // Details view data (level 2) + detailsEntries []queries.TimeEntry + selectedSummaryItem *HistorySummaryItem // Which summary item we drilled down from +} + +// HistorySummaryItem represents a date + client/project combination with total duration +type HistorySummaryItem struct { + Date time.Time + ClientID int64 + ClientName string + ProjectID *int64 // nil if no project + ProjectName *string // nil if no project + TotalDuration time.Duration + EntryCount int +} + +// HistoryDisplayItem represents an item in the history view (either date header or summary/detail item) +type HistoryDisplayItem struct { + Type HistoryDisplayItemType + DateHeader *string // Set if Type is DateHeader + Summary *HistorySummaryItem // Set if Type is Summary + Entry *queries.TimeEntry // Set if Type is Entry + IsSelectable bool +} + +type HistoryDisplayItemType int + +const ( + HistoryItemDateHeader HistoryDisplayItemType = iota + HistoryItemSummary + HistoryItemEntry +) + +// ProjectsDisplayItem represents an item in the projects display order (either client or project) +type ProjectsDisplayItem struct { + IsClient bool + ClientIndex int // Index in m.clients + ProjectIndex int // Index in m.projects, only used when IsClient=false + Client *queries.Client + Project *queries.ListAllProjectsRow +} diff --git a/keys.md b/keys.md new file mode 100644 index 0000000..fe461c6 --- /dev/null +++ b/keys.md @@ -0,0 +1,56 @@ +## Global always-available bindings + +* **Ctrl-n**: cycle selection to next pane +* **Ctrl-p**: cycle selection to previous pane +* **p**: punch in/punch out +* **/**: search clients and projects (pulls up a modal with fuzzy search-as-you-type where Ctrl-n and Ctrl-p cycle the active suggestions, and **Enter** jumps you to the projects pane with that client or project selected) +* **r**: refresh view - will go back to the database to reload all displayed data +* **q**: quit + +* **Ctrl-c**: quit (not shown on bottom bar) +* **Ctrl-d**: quit (not shown on bottom bar) + + +## Timer pane bindings + +* **Enter** (no active timer): punch back in to most recent project (and copy description) +* **Enter** (timer active): punch out + +## Projects pane bindings + +The projects pane shows a 2-level tree view of all clients, and all their projects beneath them. + +One row of this view (a client or a project) is always selected. When a user navigates away from the pane (ctrl-n, ctrl-p) they should their previous row selection should come back. + +* **j**: select next row +* **k**: select previous row +* **down arrow**: select next row (not shown on bottom bar) +* **up arrow**: select previous row (not shown on bottom bar) +* **Enter**: equivalent to `punch in -c [-p ]` (will punch in to the selected client or project, punching out if a different client/project is currently active) +* **n**: new project +* **N**: new client + +## History pane - level 1: summaries + +The history pane will show recent time_entry history (sorted descending). + +At first it displays a 2-level tree view of dates, and `Client[ / Project]` rows beneath that with the total duration worked on that project in that day. If there were time entries for a client not associated with a project, those will be in a `Client` row. + +Like the projects pane, this always has a row selected. Date rows display the total for the day, but can't be selected - j and k and up and down arrows just skip over them. + +* **j** (and secretly down arrow): select next row +* **k** (and secretly up arrow): select previous row +* **Enter**: drill down on the selected date/project + +## History pane - level 2: details + +When drilling down on a date and project (or just client), all the time_entry rows which made up that summary row total are displayed in reverse-chronological order. + +They also have a selection. + +* **j** (and down arrow): select next entry +* **k** (and up arrow): select previous entry +* **e**: edit selected entry (pulls up an edit form modal) +* **d**: delete selected entry +* **Enter**: "Resume" - punch into a new copy of this entry (potentially punching out of an existing one, mirroring `punch in` behavior) +* **b**: back to level 1 view. escape key also does this but that doesn't show up on the bottom bar. diff --git a/wireframes.md b/wireframes.md new file mode 100644 index 0000000..c31cd5f --- /dev/null +++ b/wireframes.md @@ -0,0 +1,86 @@ +## Shared Layout + +``` +┌────────────────────────────────────────────────────────────┐ +│ Timer View Today: 3h 42m | Week: 18h 03m │ ← Top bar (always present) +├────────────────────────────────────────────────────────────┤ +│ │ +│ [Active View Content] │ ← View-specific content +│ │ +├────────────────────────────────────────────────────────────┤ +│ 1 Timer 2 Projects 3 Entries q Quit │ ← Footer (always visible keybindings) +└────────────────────────────────────────────────────────────┘ +``` + + +## Timer View (default page) + +``` +┌────────────────────────────────────────────────────────────┐ +│ Timer View Today: 3h 42m | Week: 18h 03m │ +├────────────────────────────────────────────────────────────┤ +│ ⏱ Tracking: 00:25:43 │ +│ Project: ACME Corp / Onboarding App │ +│ Started: 10:03 AM │ +│ │ +│ [o] Punch Out [s] Switch Project │ +├────────────────────────────────────────────────────────────┤ +│ 1 Timer 2 Projects 3 Entries p Punch s Switch q Quit │ +└────────────────────────────────────────────────────────────┘ +``` + + +## Projects View + +``` +┌───────────────────────────────────────────────────────────────┐ +│ Projects View Today: 3h 42m | Week: 18h 03m │ +├───────────────────────────────────────────────────────────────┤ +│ ACME Corp ▼ │ +│ - Onboarding App │ +│ - Website Redesign │ +│ Freelance Inc ▼ │ +│ - Newsletter Design │ +│ - Logo Refresh │ +│ │ +│ [i] Punch In [/] Search [n] New Project [N] New Client │ +├───────────────────────────────────────────────────────────────┤ +│ 1 Timer 2 Projects 3 Entries / Search n/N New s Switch │ +└───────────────────────────────────────────────────────────────┘ +``` + + +## History View + +### Level 1: Project/Day Summary + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Entries View Today: 3h 42m | Week: 18h 03m │ +├──────────────────────────────────────────────────────────────┤ +│ 08/04 ▼ │ +│ - ACME / Onboarding App 1h 45m │ +│ - Freelance / Logo Refresh 0h 35m │ +│ 08/03 ▼ │ +│ - Freelance / Logo Refresh 2h 15m │ +│ │ +│ [Enter] View Details [/] Search [←] Back │ +├──────────────────────────────────────────────────────────────┤ +│ 1 Timer 2 Projects 3 Entries / Search Enter View q Quit │ +└──────────────────────────────────────────────────────────────┘ +``` + +### Level 2: Entry Details for a Selected Group + +``` +┌────────────────────────────────────────────────────────────┐ +│ Entries: 08/04 — ACME / Onboarding App │ +├────────────────────────────────────────────────────────────┤ +│ ⏺ 10:03 AM – 11:48 AM (1h 45m) │ +│ "Wrote user authentication logic" │ +│ │ +│ [e] Edit [d] Delete [Enter] Resume [←] Back │ +├────────────────────────────────────────────────────────────┤ +│ 1 Timer 2 Projects 3 Entries e Edit d Delete q Quit │ +└────────────────────────────────────────────────────────────┘ +``` -- cgit v1.2.3