summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--TODO.md10
-rw-r--r--go.mod19
-rw-r--r--go.sum40
-rw-r--r--internal/actions/actions.go34
-rw-r--r--internal/actions/clients.go92
-rw-r--r--internal/actions/projects.go64
-rw-r--r--internal/actions/timer.go342
-rw-r--r--internal/actions/types.go31
-rw-r--r--internal/commands/add_client.go60
-rw-r--r--internal/commands/add_project.go58
-rw-r--r--internal/commands/helpers.go32
-rw-r--r--internal/commands/in.go193
-rw-r--r--internal/commands/in_test.go2
-rw-r--r--internal/commands/out.go21
-rw-r--r--internal/commands/root.go1
-rw-r--r--internal/commands/tui.go29
-rw-r--r--internal/database/queries.sql29
-rw-r--r--internal/queries/queries.sql.go75
-rw-r--r--internal/tui/app.go538
-rw-r--r--internal/tui/clients_projects_box.go247
-rw-r--r--internal/tui/history_box.go516
-rw-r--r--internal/tui/keys.go229
-rw-r--r--internal/tui/shared.go225
-rw-r--r--internal/tui/timer.go150
-rw-r--r--internal/tui/timer_box.go101
-rw-r--r--internal/tui/types.go165
-rw-r--r--keys.md56
-rw-r--r--wireframes.md86
28 files changed, 3169 insertions, 276 deletions
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 <client>] [-p|--project <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 <client>] [-p|--project <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 <spec>` 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 <email>"
+ 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 <email@domain.com>"
+ 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 <email@domain.com>'",
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 <selected-client> [-p <selected-project-if-any>]` (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 │
+└────────────────────────────────────────────────────────────┘
+```