diff --git a/README.md b/README.md index 0896741..68ccaee 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,27 @@ rm -rf "${GOPATH}/pkg/mod/github.com/usrme/cometary*" There is an additional `comet.json` file that includes the prefixes and descriptions that I most prefer myself, which can be added to either the root of a repository, to one's home directory as `.comet.json` or to `${XDG_CONFIG_HOME}/cometary/config.json`. Omitting this means that the same defaults are used as in the original. -- To adjust the character limit of the scope, add the key `scopeInputCharLimit` into the configuration file with the desired limit - - Omitting the key uses a default value of 16 characters -- To adjust the character limit of the message, add the key `commitInputCharLimit` into the configuration file with the desired limit - - Omitting the key uses a default value of 100 characters -- To adjust the total limit of characters in the *resulting* commit message, add the key `totalInputCharLimit` into the configuration file with the desired limit +- To adjust the character limit of the scope, add the key `scopeInputCharLimit` with the desired limit + - Default: 16 +- To adjust the character limit of the message, add the key `commitInputCharLimit` with the desired limit + - Default: 100 +- To adjust the total limit of characters in the *resulting* commit message, add the key `totalInputCharLimit` with the desired limit - Adding this key overrides scope- and message-specific limits -- To adjust the order of the scope completion values (i.e. longer or shorter strings first), then add the key `scopeOrderCompletion` into the configuration file with either `ascending` or `descending` as the values - - Omitting the key uses a default order of descending +- To adjust the order of the scope completion values (i.e. longer or shorter strings first), add the key `scopeOrderCompletion` with either `"ascending"` or `"descending"` + - Default: `"descending"` +- To enable the storing of runtime statistics, add the key `storeRuntime` with the value `true` + - Default: `false` + - This will create a `stats.json` file next to the configuration file with aggregated statistics across days, weeks, months, and years +- To show the session runtime statistics after each commit, add the key `showRuntime` with the value `true` + - Default: `false` + - This will show `> Session: N seconds` after the commit was successful +- To show the all-time runtime statistics after each commit, add the key `showStats` with the value `true` + - Default: `false` + - To just show the all-time runtime statistics and quit the program, run the program with the `-s` flag +- To adjust the format of the statistics from seconds to hours or minutes, add the key `showStatsFormat` with either `"minutes"` or `"hours"` + - Default: `"seconds"` +- To always show session runtime statistics as seconds but keep everything else as defined by `showStatsFormat`, add the key `sessionStatAsSeconds` with the value `true` + - Default: `false` There is also a `-m` flag that takes a string that will be used as the basis for a search among all commit messages. For example: if you're committing something of a chore and always just use the message "update dependencies", you can do `cometary -m update` (use quotation marks if argument to `-m` includes spaces) and Cometary will populate the list of possible messages with those that include "update", which can then be cycled through with the Tab key. This is similar to the search you could make with `git log --grep="update"`. diff --git a/comet.json b/comet.json index efb4144..5ab046f 100644 --- a/comet.json +++ b/comet.json @@ -1,10 +1,15 @@ { "signOffCommits": false, - "scopeInputCharLimit": 100, - "commitInputCharLimit": 120, - "totalInputCharLimit": 50, - "scopeCompletionOrder": "ascending", - "findAllCommitMessages": true, + "scopeInputCharLimit": 16, + "commitInputCharLimit": 100, + "totalInputCharLimit": 0, + "scopeCompletionOrder": "descending", + "findAllCommitMessages": false, + "storeRuntime": true, + "showRuntime": true, + "showStats": true, + "showStatsFormat": "minutes", + "sessionStatAsSeconds": false, "prefixes": [ { "title": "fix", diff --git a/config.go b/config.go index 9561764..ebe8656 100644 --- a/config.go +++ b/config.go @@ -2,11 +2,8 @@ package main import ( "encoding/json" - "fmt" "os" "path/filepath" - - "github.com/charmbracelet/bubbles/list" ) type prefix struct { @@ -22,54 +19,59 @@ type config struct { TotalInputCharLimit int `json:"totalInputCharLimit"` ScopeCompletionOrder string `json:"scopeCompletionOrder"` FindAllCommitMessages bool `json:"findAllCommitMessages"` + StoreRuntime bool `json:"storeRuntime"` + ShowRuntime bool `json:"showRuntime"` + ShowStats bool `json:"showStats"` + ShowStatsFormat string `json:"showStatsFormat"` + SessionStatAsSeconds bool `json:"sessionStatAsSeconds"` } func (i prefix) Title() string { return i.T } func (i prefix) Description() string { return i.D } func (i prefix) FilterValue() string { return i.T } -var defaultPrefixes = []list.Item{ - prefix{ +var defaultPrefixes = []prefix{ + { T: "feat", D: "Introduces a new feature", }, - prefix{ + { T: "fix", D: "Patches a bug", }, - prefix{ + { T: "docs", D: "Documentation changes only", }, - prefix{ + { T: "test", D: "Adding missing tests or correcting existing tests", }, - prefix{ + { T: "build", D: "Changes that affect the build system", }, - prefix{ + { T: "ci", D: "Changes to CI configuration files and scripts", }, - prefix{ + { T: "perf", D: "A code change that improves performance", }, - prefix{ + { T: "refactor", D: "A code change that neither fixes a bug nor adds a feature", }, - prefix{ + { T: "revert", D: "Reverts a previous change", }, - prefix{ + { T: "style", D: "Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)", }, - prefix{ + { T: "chore", D: "A minor change which does not fit into any other category", }, @@ -77,7 +79,7 @@ var defaultPrefixes = []list.Item{ const applicationName = "cometary" -func loadConfig() ([]list.Item, bool, *config, error) { +func loadConfig() *config { nonXdgConfigFile := ".comet.json" // Check for configuration file local to current directory @@ -94,20 +96,36 @@ func loadConfig() ([]list.Item, bool, *config, error) { } // Check for configuration file according to XDG Base Directory Specification - if cfgDir, err := getConfigDir(); err == nil { + if cfgDir, err := GetConfigDir(); err == nil { path := filepath.Join(cfgDir, "config.json") if _, err := os.Stat(path); err == nil { return loadConfigFile(path) } } - return defaultPrefixes, false, nil, nil + return newConfig() +} + +func newConfig() *config { + return &config{ + Prefixes: defaultPrefixes, + SignOffCommits: false, + ScopeInputCharLimit: 16, + CommitInputCharLimit: 100, + TotalInputCharLimit: 0, + ScopeCompletionOrder: "descending", + FindAllCommitMessages: false, + StoreRuntime: false, + ShowRuntime: false, + ShowStats: false, + ShowStatsFormat: "seconds", + SessionStatAsSeconds: true, + } } -func getConfigDir() (string, error) { +func GetConfigDir() (string, error) { configDir := os.Getenv("XDG_CONFIG_HOME") - // If the value of the environment variable is unset, empty, or not an absolute path, use the default if configDir == "" || configDir[0:1] != "/" { homeDir, err := os.UserHomeDir() if err != nil { @@ -116,30 +134,19 @@ func getConfigDir() (string, error) { return filepath.Join(homeDir, ".config", applicationName), nil } - // The value of the environment variable is valid; use it return filepath.Join(configDir, applicationName), nil } -func loadConfigFile(path string) ([]list.Item, bool, *config, error) { +func loadConfigFile(path string) *config { + var c config data, err := os.ReadFile(path) if err != nil { - return nil, false, nil, fmt.Errorf("failed to read config file: %w", err) + return &c } - var c config + if err := json.Unmarshal(data, &c); err != nil { - return nil, false, nil, fmt.Errorf("invalid json in config file '%s': %w", path, err) + return &c } - return convertPrefixes(c.Prefixes), c.SignOffCommits, &c, nil -} - -func convertPrefixes(prefixes []prefix) []list.Item { - var output []list.Item - for _, prefix := range prefixes { - output = append(output, prefix) - } - if len(output) == 0 { - return defaultPrefixes - } - return output + return &c } diff --git a/git.go b/git.go index d5d5898..4215c6f 100644 --- a/git.go +++ b/git.go @@ -10,7 +10,6 @@ import ( func filesInStaging() ([]string, error) { cmd := exec.Command("git", "diff", "--no-ext-diff", "--cached", "--name-only") output, err := cmd.CombinedOutput() - if err != nil { return []string{}, fmt.Errorf(string(output)) } @@ -21,22 +20,13 @@ func filesInStaging() ([]string, error) { return strings.Split(lines, "\n"), nil } -func checkGitInPath() error { - if _, err := exec.LookPath("git"); err != nil { - return fmt.Errorf("cannot find git in PATH: %w", err) - } - return nil -} - -func findGitDir() (string, error) { +func findGitDir() error { cmd := exec.Command("git", "rev-parse", "--show-toplevel") output, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf(string(output)) + return fmt.Errorf(string(output)) } - - return strings.TrimSpace(string(output)), nil + return nil } func commit(msg string, body bool, signOff bool) error { diff --git a/go.sum b/go.sum index af1183e..949cb76 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5 github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= -github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= -github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU= github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= @@ -42,15 +40,9 @@ golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= -golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= -golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= diff --git a/gui.go b/gui.go index 302313b..5486e59 100644 --- a/gui.go +++ b/gui.go @@ -23,12 +23,7 @@ const ( ) var ( - // #81a1c1: nord9 - // #88c0d0: nord8 - filterPromptStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#81a1c1", Dark: "#88c0d0"}) - // #5e81ac: nord10 - // #8fbcbb: nord7 - filterCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#5e81ac", Dark: "#8fbcbb"}) + titleTextStyle = lipgloss.NewStyle() titleStyle = lipgloss.NewStyle().MarginLeft(2) itemStyle = lipgloss.NewStyle().PaddingLeft(4) characterCountColors = lipgloss.AdaptiveColor{Light: "#8dacb6", Dark: "240"} @@ -36,7 +31,7 @@ var ( // #a3be8c: nord13 selectedItemColors = lipgloss.AdaptiveColor{Light: "#d08770", Dark: "#a3be8c"} selectedItemStyle = lipgloss.NewStyle().Foreground(selectedItemColors) - selectedItemPadded = selectedItemStyle.Copy().PaddingLeft(2) + selectedItemPadded = lipgloss.NewStyle().Foreground(selectedItemColors).PaddingLeft(2) itemDescriptionStyle = lipgloss.NewStyle().PaddingLeft(2).Faint(true) paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) @@ -64,7 +59,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list var output string if index == m.Index() { - output = selectedItemPadded.Render("ยป " + str) + output = selectedItemPadded.Render("> " + str) } else { output = itemStyle.Render(str) } @@ -107,39 +102,36 @@ type model struct { messageInputIndex int } -func newModel(prefixes []list.Item, config *config, stagedFiles []string, scopeCompletionOrder, commitSearchTerm string, findAllCommitMessages bool) *model { +func newModel(c *config, stagedFiles []string, commitSearchTerm string) *model { + prefixes := convertPrefixes(c.Prefixes) prefixList := list.New(prefixes, itemDelegate{}, defaultWidth, listHeight) prefixList.Title = "What are you committing?" prefixList.SetShowStatusBar(false) - prefixList.SetFilteringEnabled(true) - prefixList.Styles.Title = titleStyle + prefixList.SetFilteringEnabled(false) + prefixList.Styles.Title = titleTextStyle prefixList.Styles.PaginationStyle = paginationStyle prefixList.Styles.HelpStyle = helpStyle - prefixList.FilterInput.PromptStyle = filterPromptStyle - prefixList.FilterInput.CursorStyle = filterCursorStyle scopeInput := textinput.New() scopeInput.Placeholder = "Scope" - // when no limit was defined a default of 0 is used - if config == nil || config.ScopeInputCharLimit == 0 { + if c == nil || c.ScopeInputCharLimit == 0 { scopeInput.CharLimit = 16 scopeInput.Width = 20 } else { - scopeInput.CharLimit = config.ScopeInputCharLimit - scopeInput.Width = config.ScopeInputCharLimit + scopeInput.CharLimit = c.ScopeInputCharLimit + scopeInput.Width = c.ScopeInputCharLimit } commitInput := textinput.New() commitInput.Placeholder = "Commit message" - // when no limit was defined a default of 0 is used - if config == nil || config.CommitInputCharLimit == 0 { + if c == nil || c.CommitInputCharLimit == 0 { commitInput.CharLimit = 100 commitInput.Width = 50 } else { - commitInput.CharLimit = config.CommitInputCharLimit - commitInput.Width = config.CommitInputCharLimit + commitInput.CharLimit = c.CommitInputCharLimit + commitInput.Width = c.CommitInputCharLimit } bodyConfirmation := textinput.New() @@ -147,11 +139,11 @@ func newModel(prefixes []list.Item, config *config, stagedFiles []string, scopeC bodyConfirmation.CharLimit = 1 bodyConfirmation.Width = 20 - if config == nil || config.TotalInputCharLimit == 0 { + if c == nil || c.TotalInputCharLimit == 0 { constrainInput = false } else { constrainInput = true - totalInputCharLimit = config.TotalInputCharLimit + totalInputCharLimit = c.TotalInputCharLimit } bindings := []key.Binding{ @@ -168,12 +160,20 @@ func newModel(prefixes []list.Item, config *config, stagedFiles []string, scopeC constrainInput: constrainInput, totalInputCharLimit: totalInputCharLimit, stagedFiles: stagedFiles, - scopeCompletionOrder: scopeCompletionOrder, + scopeCompletionOrder: c.ScopeCompletionOrder, commitSearchTerm: commitSearchTerm, - findAllCommitMessages: findAllCommitMessages, + findAllCommitMessages: c.FindAllCommitMessages, } } +func convertPrefixes(prefixes []prefix) []list.Item { + var output []list.Item + for _, prefix := range prefixes { + output = append(output, prefix) + } + return output +} + func (m *model) Init() tea.Cmd { return tea.Batch( formUniquePaths(m.stagedFiles, m.scopeCompletionOrder), @@ -408,7 +408,7 @@ func (m *model) View() string { } return titleStyle.Render(fmt.Sprintf( - "%s%s (Enter to skip / Esc to cancel) %s:\n%s", + "%s%s (Enter to skip / Esc to cancel) %s\n%s", m.previousInputTexts, scopeInputText, limit, @@ -427,7 +427,7 @@ func (m *model) View() string { } return titleStyle.Render(fmt.Sprintf( - "%s%s (Esc to cancel) %s:\n%s", + "%s%s (Esc to cancel) %s\n%s", m.previousInputTexts, msgInputText, limit, @@ -435,7 +435,7 @@ func (m *model) View() string { )) case !m.chosenBody: return titleStyle.Render(fmt.Sprintf( - "%s%s (Esc to cancel):\n%s", + "%s%s (Esc to cancel)\n%s", m.previousInputTexts, bodyInputText, m.ynInput.View(), diff --git a/main.go b/main.go index 63dadde..3bb5b42 100644 --- a/main.go +++ b/main.go @@ -3,30 +3,38 @@ package main import ( "fmt" "os" + "strings" tea "github.com/charmbracelet/bubbletea" ) func main() { - if err := checkGitInPath(); err != nil { - fail(err.Error()) + config := loadConfig() + + format := config.ShowStatsFormat + if config.SessionStatAsSeconds { + format = "seconds" } - gitRoot, err := findGitDir() + tracker, err := NewRuntimeTracker("") if err != nil { - fail(err.Error()) + fail("error creating tracker: %s", err) } - if err := os.Chdir(gitRoot); err != nil { - fail("error changing directory: %s", err) + if len(os.Args) > 1 && os.Args[1] == "-s" { + stats := tracker.GetStats() + fmt.Println(formatStat("Daily", stats.Daily[stats.CurrentDay], config.ShowStatsFormat)) + fmt.Println(formatStat("Weekly", stats.Weekly[stats.CurrentWeek], config.ShowStatsFormat)) + fmt.Println(formatStat("Monthly", stats.Monthly[stats.CurrentMonth], config.ShowStatsFormat)) + fmt.Println(formatStat("Yearly", stats.Yearly[stats.CurrentYear], config.ShowStatsFormat)) + os.Exit(0) } - stagedFiles, err := filesInStaging() - if err != nil { + if err := findGitDir(); err != nil { fail(err.Error()) } - prefixes, signOff, config, err := loadConfig() + stagedFiles, err := filesInStaging() if err != nil { fail(err.Error()) } @@ -36,24 +44,66 @@ func main() { commitSearchTerm = os.Args[2] } - m := newModel(prefixes, config, stagedFiles, config.ScopeCompletionOrder, commitSearchTerm, config.FindAllCommitMessages) + if config.StoreRuntime || config.ShowRuntime { + tracker.Start() + } + + m := newModel(config, stagedFiles, commitSearchTerm) if _, err := tea.NewProgram(m).Run(); err != nil { fail(err.Error()) } fmt.Println("") - if !m.Finished() { fail("terminated") } msg, withBody := m.CommitMessage() - if err := commit(msg, withBody, signOff); err != nil { + if err := commit(msg, withBody, config.SignOffCommits); err != nil { fail("error committing: %s", err) } + + if config.StoreRuntime || config.ShowRuntime { + err := tracker.Stop() + if err != nil { + fail("error stopping tracker: %s", err) + } + + if config.ShowRuntime && !config.ShowStats { + stats := tracker.GetStats() + fmt.Println() + fmt.Println(formatStat("Session", stats.Session, format)) + } + } + + if config.ShowStats { + stats := tracker.GetStats() + fmt.Println() + fmt.Println(formatStat("Session", stats.Session, format)) + fmt.Println(formatStat("Daily", stats.Daily[stats.CurrentDay], config.ShowStatsFormat)) + fmt.Println(formatStat("Weekly", stats.Weekly[stats.CurrentWeek], config.ShowStatsFormat)) + fmt.Println(formatStat("Monthly", stats.Monthly[stats.CurrentMonth], config.ShowStatsFormat)) + fmt.Println(formatStat("Yearly", stats.Yearly[stats.CurrentYear], config.ShowStatsFormat)) + } +} + +func formatStat(stat string, seconds float32, format string) string { + var value float32 + switch format { + case "minutes": + value = seconds / 60.0 + case "hours": + value = seconds / 3600.0 + default: + value = seconds + } + return fmt.Sprintf(" > %s: %.2f %s", stat, value, format) } func fail(format string, args ...interface{}) { - _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) + if !strings.HasSuffix(format, "\n") { + format = format + "\n" + } + _, _ = fmt.Fprintf(os.Stderr, format, args...) os.Exit(1) } diff --git a/runtime.go b/runtime.go new file mode 100644 index 0000000..6289558 --- /dev/null +++ b/runtime.go @@ -0,0 +1,179 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// Stats holds the runtime statistics for different time periods. +type Stats struct { + Session float32 `json:"session"` + Daily map[string]float32 `json:"daily"` + CurrentDay string `json:"currentDay"` + Weekly map[string]float32 `json:"weekly"` + CurrentWeek string `json:"currentWeek"` + Monthly map[string]float32 `json:"monthly"` + CurrentMonth string `json:"currentMonth"` + Yearly map[string]float32 `json:"yearly"` + CurrentYear string `json:"currentYear"` + LastUpdate string `json:"lastUpdate"` +} + +// RuntimeTracker handles program runtime tracking. +type RuntimeTracker struct { + statsFilePath string + startTime time.Time + stats Stats +} + +// NewRuntimeTracker creates a new RuntimeTracker instance. +func NewRuntimeTracker(filename string) (*RuntimeTracker, error) { + cfgDir, err := GetConfigDir() + if err != nil { + return nil, err + } + + if filename == "" { + filename = "stats.json" + } + path := filepath.Join(cfgDir, filename) + + rt := &RuntimeTracker{ + statsFilePath: path, + stats: Stats{ + Session: 0.0, + Daily: make(map[string]float32), + Weekly: make(map[string]float32), + Monthly: make(map[string]float32), + Yearly: make(map[string]float32), + }, + } + + if _, err := os.Stat(rt.statsFilePath); errors.Is(err, os.ErrNotExist) { + rt.saveStats() + } + + if err := rt.loadStats(); err != nil { + return nil, fmt.Errorf("failed to load stats: %w", err) + } + + return rt, nil +} + +// loadStats loads existing statistics. +func (rt *RuntimeTracker) loadStats() error { + data, err := os.ReadFile(rt.statsFilePath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + return json.Unmarshal(data, &rt.stats) +} + +// saveStats saves current statistics. +func (rt *RuntimeTracker) saveStats() error { + if err := os.MkdirAll(filepath.Dir(rt.statsFilePath), 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + data, err := json.MarshalIndent(rt.stats, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal stats: %w", err) + } + + return os.WriteFile(rt.statsFilePath, data, 0644) +} + +// Start begins tracking runtime. +func (rt *RuntimeTracker) Start() { + rt.startTime = time.Now() +} + +// Stop ends tracking runtime and updates statistics. +func (rt *RuntimeTracker) Stop() error { + if rt.startTime.IsZero() { + return fmt.Errorf("tracking was not started") + } + + endTime := time.Now() + runtime := float32(endTime.Sub(rt.startTime).Seconds()) + + // Format time periods + date := endTime.Format("2006-01-02") + + // Get ISO week number + y, week := endTime.ISOWeek() + weekStr := fmt.Sprintf("%d-W%02d", y, week) + + month := endTime.Format("2006-01") + year := endTime.Format("2006") + + // Update statistics + rt.stats.Session = runtime + rt.stats.Daily[date] += runtime + rt.stats.CurrentDay = date + rt.stats.Weekly[weekStr] += runtime + rt.stats.CurrentWeek = weekStr + rt.stats.Monthly[month] += runtime + rt.stats.CurrentMonth = month + rt.stats.Yearly[year] += runtime + rt.stats.CurrentYear = year + rt.stats.LastUpdate = endTime.Format(time.RFC3339) + + // Save updated stats + if err := rt.saveStats(); err != nil { + return fmt.Errorf("failed to save stats: %w", err) + } + + // Reset start time + rt.startTime = time.Time{} + + return nil +} + +// CleanupOldData removes data older than the specified number of days +func (rt *RuntimeTracker) CleanupOldData(daysToKeep int) error { + cutoff := time.Now().AddDate(0, 0, -daysToKeep) + + // Helper function to check if a date string is before cutoff + isOld := func(dateStr string) bool { + t, err := time.Parse("2006-01-02", dateStr[:10]) + if err != nil { + return false + } + return t.Before(cutoff) + } + + // Clean up each period + for date := range rt.stats.Daily { + if isOld(date) { + delete(rt.stats.Daily, date) + } + } + for week := range rt.stats.Weekly { + if isOld(week) { + delete(rt.stats.Weekly, week) + } + } + for month := range rt.stats.Monthly { + if isOld(month) { + delete(rt.stats.Monthly, month) + } + } + for year := range rt.stats.Yearly { + if isOld(year) { + delete(rt.stats.Yearly, year) + } + } + + return rt.saveStats() +} + +// GetStats returns the current statistics +func (rt *RuntimeTracker) GetStats() Stats { + return rt.stats +}