Skip to content

Commit 4e48502

Browse files
committed
feat: add status command
1 parent 5527905 commit 4e48502

5 files changed

Lines changed: 496 additions & 5 deletions

File tree

README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ hourgit version
8989
## Table of Contents
9090

9191
- [Commands](#commands)
92-
- [Time Tracking](#time-tracking) — init, log, edit, remove, sync, report, history
92+
- [Time Tracking](#time-tracking) — init, log, edit, remove, sync, report, history, status
9393
- [Project Management](#project-management) — project add/assign/list/remove
9494
- [Schedule Configuration](#schedule-configuration) — config get/set/reset/report
9595
- [Default Schedule](#default-schedule) — defaults get/set/reset/report
@@ -106,7 +106,7 @@ hourgit version
106106

107107
Core commands for recording, viewing, and managing your time entries.
108108

109-
Commands: `init` · `log` · `edit` · `remove` · `sync` · `report` · `history`
109+
Commands: `init` · `log` · `edit` · `remove` · `sync` · `report` · `history` · `status`
110110

111111
#### `hourgit init`
112112

@@ -274,6 +274,26 @@ hourgit history [--project <name>] [--limit <N>]
274274

275275
> Each line shows the entry hash, timestamp, type (log or checkout), project name, and details. Log entries display duration + task label (if set) + message. Checkout entries display previous branch → next branch.
276276
277+
#### `hourgit status`
278+
279+
Show current tracking status — project, branch, time logged today, and schedule state.
280+
281+
```bash
282+
hourgit status [--project <name>]
283+
```
284+
285+
| Flag | Default | Description |
286+
|------|---------|-------------|
287+
| `--project` | auto-detect | Project name or ID |
288+
289+
**Output includes:**
290+
291+
- Current project and branch
292+
- Time since last checkout
293+
- Time logged today and remaining scheduled hours
294+
- Today's schedule windows
295+
- Tracking state (active/inactive based on current time vs schedule)
296+
277297
### Project Management
278298

279299
Group repositories into projects for organized time tracking.
@@ -528,9 +548,7 @@ Every project starts with a copy of the defaults. You can then customize a proje
528548

529549
## Roadmap
530550

531-
The following features are planned but not yet implemented:
532-
533-
- **Status** — show currently active branch/project and time logged today
551+
No major features are currently planned. Have an idea? [Open an issue](https://github.com/Flyrell/hourgit/issues).
534552

535553
## License
536554

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func newRootCmd() *cobra.Command {
2020
syncCmd,
2121
reportCmd,
2222
historyCmd,
23+
statusCmd,
2324
versionCmd,
2425
projectCmd,
2526
configCmd,

internal/cli/status.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"sort"
7+
"strings"
8+
"time"
9+
10+
"github.com/Flyrell/hourgit/internal/entry"
11+
"github.com/Flyrell/hourgit/internal/project"
12+
"github.com/Flyrell/hourgit/internal/schedule"
13+
"github.com/Flyrell/hourgit/internal/timetrack"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
var statusCmd = LeafCommand{
18+
Use: "status",
19+
Short: "Show current tracking status",
20+
StrFlags: []StringFlag{
21+
{Name: "project", Usage: "project name or ID"},
22+
},
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
homeDir, repoDir, err := getContextPaths()
25+
if err != nil {
26+
return err
27+
}
28+
projectFlag, _ := cmd.Flags().GetString("project")
29+
return runStatus(cmd, homeDir, repoDir, projectFlag, defaultGitBranch, time.Now)
30+
},
31+
}.Build()
32+
33+
// defaultGitBranch returns the current git branch name.
34+
func defaultGitBranch() (string, error) {
35+
out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
36+
if err != nil {
37+
return "", err
38+
}
39+
return strings.TrimSpace(string(out)), nil
40+
}
41+
42+
func runStatus(
43+
cmd *cobra.Command,
44+
homeDir, repoDir, projectFlag string,
45+
gitBranchFunc func() (string, error),
46+
nowFunc func() time.Time,
47+
) error {
48+
proj, err := ResolveProjectContext(homeDir, repoDir, projectFlag)
49+
if err != nil {
50+
return err
51+
}
52+
53+
cfg, err := project.ReadConfig(homeDir)
54+
if err != nil {
55+
return err
56+
}
57+
58+
now := nowFunc()
59+
w := cmd.OutOrStdout()
60+
61+
// Project
62+
_, _ = fmt.Fprintf(w, "%s %s\n", Silent("Project:"), Primary(proj.Name))
63+
64+
// Branch
65+
branch, branchErr := gitBranchFunc()
66+
if branchErr == nil && branch != "" {
67+
_, _ = fmt.Fprintf(w, "%s %s\n", Silent("Branch:"), Primary(branch))
68+
}
69+
70+
// Last checkout
71+
checkouts, err := entry.ReadAllCheckoutEntries(homeDir, proj.Slug)
72+
if err != nil {
73+
return err
74+
}
75+
if last := findLastCheckout(checkouts); last != nil {
76+
ago := formatDurationAgo(now.Sub(last.Timestamp))
77+
_, _ = fmt.Fprintf(w, "%s %s\n", Silent("Checked out:"), Text(ago+" ago"))
78+
}
79+
80+
// Schedule for today
81+
schedules := project.GetSchedules(cfg, proj.ID)
82+
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
83+
todayEnd := todayStart.Add(24*time.Hour - time.Second)
84+
daySchedules, err := schedule.ExpandSchedules(schedules, todayStart, todayEnd)
85+
if err != nil {
86+
return err
87+
}
88+
89+
// Find today's schedule
90+
var todaySchedule *schedule.DaySchedule
91+
for i := range daySchedules {
92+
if daySchedules[i].Date.Day() == now.Day() &&
93+
daySchedules[i].Date.Month() == now.Month() &&
94+
daySchedules[i].Date.Year() == now.Year() {
95+
todaySchedule = &daySchedules[i]
96+
break
97+
}
98+
}
99+
100+
if todaySchedule == nil || len(todaySchedule.Windows) == 0 {
101+
_, _ = fmt.Fprintln(w)
102+
_, _ = fmt.Fprintf(w, "%s %s\n", Silent("Today:"), Text("not a working day"))
103+
return nil
104+
}
105+
106+
// Compute today's logged time
107+
logs, err := entry.ReadAllEntries(homeDir, proj.Slug)
108+
if err != nil {
109+
return err
110+
}
111+
112+
// Expand schedules for the whole month (needed by BuildReport)
113+
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
114+
monthEnd := time.Date(now.Year(), now.Month()+1, 0, 23, 59, 59, 0, time.UTC)
115+
monthSchedules, err := schedule.ExpandSchedules(schedules, monthStart, monthEnd)
116+
if err != nil {
117+
return err
118+
}
119+
120+
report := timetrack.BuildReport(checkouts, logs, monthSchedules, now.Year(), now.Month(), now, nil)
121+
122+
todayMinutes := 0
123+
for _, row := range report.Rows {
124+
if mins, ok := row.Days[now.Day()]; ok {
125+
todayMinutes += mins
126+
}
127+
}
128+
129+
// Total scheduled minutes for today
130+
totalScheduled := 0
131+
for _, win := range todaySchedule.Windows {
132+
fromMins := win.From.Hour*60 + win.From.Minute
133+
toMins := win.To.Hour*60 + win.To.Minute
134+
totalScheduled += toMins - fromMins
135+
}
136+
137+
remaining := totalScheduled - todayMinutes
138+
if remaining < 0 {
139+
remaining = 0
140+
}
141+
142+
_, _ = fmt.Fprintln(w)
143+
_, _ = fmt.Fprintf(w, "%s %s %s %s\n",
144+
Silent("Today:"),
145+
Primary(entry.FormatMinutes(todayMinutes)+" logged"),
146+
Silent("·"),
147+
Text(entry.FormatMinutes(remaining)+" remaining"),
148+
)
149+
150+
// Schedule line
151+
windowStrs := make([]string, len(todaySchedule.Windows))
152+
for i, win := range todaySchedule.Windows {
153+
windowStrs[i] = schedule.FormatTimeRange(win.From.String(), win.To.String())
154+
}
155+
_, _ = fmt.Fprintf(w, "%s %s\n", Silent("Schedule:"), Text(strings.Join(windowStrs, ", ")))
156+
157+
// Tracking state
158+
active, activeUntil := isWithinSchedule(now, todaySchedule.Windows)
159+
if active {
160+
// Format the end time using FormatTimeRange and extracting the "to" part
161+
untilStr := schedule.FormatTimeRange(activeUntil.String(), activeUntil.String())
162+
// FormatTimeRange returns "H:MM PM - H:MM PM", take the first part
163+
untilStr = strings.SplitN(untilStr, " - ", 2)[0]
164+
_, _ = fmt.Fprintf(w, "%s %s\n", Silent("Tracking:"), Info("active (until "+untilStr+")"))
165+
} else {
166+
_, _ = fmt.Fprintf(w, "%s %s\n", Silent("Tracking:"), Warning("inactive (no scheduled hours remaining)"))
167+
}
168+
169+
return nil
170+
}
171+
172+
// findLastCheckout returns the most recent checkout entry, or nil if none.
173+
func findLastCheckout(checkouts []entry.CheckoutEntry) *entry.CheckoutEntry {
174+
if len(checkouts) == 0 {
175+
return nil
176+
}
177+
sort.Slice(checkouts, func(i, j int) bool {
178+
return checkouts[i].Timestamp.Before(checkouts[j].Timestamp)
179+
})
180+
return &checkouts[len(checkouts)-1]
181+
}
182+
183+
// formatDurationAgo formats a duration into a human-friendly "Xh Ym" string.
184+
func formatDurationAgo(d time.Duration) string {
185+
if d < 0 {
186+
d = 0
187+
}
188+
totalMins := int(d.Minutes())
189+
if totalMins < 1 {
190+
return "just now"
191+
}
192+
hours := totalMins / 60
193+
mins := totalMins % 60
194+
if hours > 0 && mins > 0 {
195+
return fmt.Sprintf("%dh %dm", hours, mins)
196+
}
197+
if hours > 0 {
198+
return fmt.Sprintf("%dh", hours)
199+
}
200+
return fmt.Sprintf("%dm", mins)
201+
}
202+
203+
// isWithinSchedule checks if the current time falls within any schedule window.
204+
// Returns true and the end time of the current window if active.
205+
func isWithinSchedule(now time.Time, windows []schedule.TimeWindow) (bool, schedule.TimeOfDay) {
206+
nowMinutes := now.Hour()*60 + now.Minute()
207+
for _, w := range windows {
208+
fromMins := w.From.Hour*60 + w.From.Minute
209+
toMins := w.To.Hour*60 + w.To.Minute
210+
if nowMinutes >= fromMins && nowMinutes < toMins {
211+
return true, w.To
212+
}
213+
}
214+
return false, schedule.TimeOfDay{}
215+
}

0 commit comments

Comments
 (0)