├── .github ├── FUNDING.yml └── workflows │ └── docs.yml ├── assets ├── screenshot0.png └── screenshot1.png ├── .stylua.toml ├── remote ├── go.mod ├── pkg │ ├── tab.go │ ├── window.go │ ├── buffer.go │ └── args │ │ └── args.go ├── go.sum ├── internal │ ├── data │ │ ├── csv.go │ │ ├── aggregate_hours.go │ │ ├── utils.go │ │ └── aggregate_metrics.go │ └── prettify │ │ ├── utils.go │ │ ├── metrics.go │ │ └── hours.go └── main.go ├── Makefile ├── lua └── pendulum │ ├── init.lua │ ├── csv.lua │ ├── handlers.lua │ └── remote.lua ├── LICENSE ├── .gitignore ├── README.md └── doc └── pendulum-nvim.txt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ptdewey 2 | -------------------------------------------------------------------------------- /assets/screenshot0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ptdewey/pendulum-nvim/HEAD/assets/screenshot0.png -------------------------------------------------------------------------------- /assets/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ptdewey/pendulum-nvim/HEAD/assets/screenshot1.png -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 80 2 | line_endings = "Unix" 3 | indent_type = "Spaces" 4 | indent_width = 4 5 | quote_style = "AutoPreferDouble" 6 | -------------------------------------------------------------------------------- /remote/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ptdewey/pendulum-nvim 2 | 3 | go 1.22.3 4 | 5 | require github.com/neovim/go-client v1.2.1 6 | 7 | require golang.org/x/text v0.16.0 8 | -------------------------------------------------------------------------------- /remote/pkg/tab.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "github.com/neovim/go-client/nvim" 5 | ) 6 | 7 | func CreateNewTab(v *nvim.Nvim, buf nvim.Buffer) error { 8 | if err := v.SetCurrentBuffer(buf); err != nil { 9 | return err 10 | } 11 | 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | fmt: 2 | echo "Formatting lua/yankbank..." 3 | stylua lua/ --config-path=.stylua.toml 4 | echo "Formatting Go files in ./remote..." 5 | find ./remote -name '*.go' -exec gofmt -w {} + 6 | 7 | lint: 8 | echo "Linting lua/yankbank..." 9 | luacheck lua/ --globals vim 10 | 11 | pr-ready: fmt lint 12 | -------------------------------------------------------------------------------- /remote/go.sum: -------------------------------------------------------------------------------- 1 | github.com/neovim/go-client v1.2.1 h1:kl3PgYgbnBfvaIoGYi3ojyXH0ouY6dJY/rYUCssZKqI= 2 | github.com/neovim/go-client v1.2.1/go.mod h1:EeqCP3z1vJd70JTaH/KXz9RMZ/nIgEFveX83hYnh/7c= 3 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 4 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 5 | -------------------------------------------------------------------------------- /remote/internal/data/csv.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "encoding/csv" 5 | "os" 6 | ) 7 | 8 | func ReadPendulumLogFile(filepath string) ([][]string, error) { 9 | f, err := os.Open(filepath) 10 | if err != nil { 11 | return nil, err 12 | } 13 | defer f.Close() 14 | 15 | csvReader := csv.NewReader(f) 16 | data, err := csvReader.ReadAll() 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | return data, nil 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Generate Vimdoc 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | docs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: panvimdoc 14 | uses: kdheepak/panvimdoc@main 15 | with: 16 | vimdoc: pendulum-nvim 17 | version: "Neovim >= 0.10.0" 18 | demojify: true 19 | treesitter: true 20 | - name: Push changes 21 | uses: stefanzweifel/git-auto-commit-action@v4 22 | with: 23 | commit_message: "doc: auto-generate vimdoc" 24 | commit_user_name: "github-actions[bot]" 25 | commit_user_email: "github-actions[bot]@users.noreply.github.com" 26 | commit_author: "github-actions[bot] " 27 | -------------------------------------------------------------------------------- /lua/pendulum/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local handlers = require("pendulum.handlers") 4 | local remote = require("pendulum.remote") 5 | 6 | -- default plugin options 7 | local default_opts = { 8 | log_file = vim.env.HOME .. "/pendulum-log.csv", 9 | timeout_len = 180, 10 | timer_len = 120, 11 | gen_reports = true, 12 | top_n = 5, 13 | hours_n = 10, 14 | time_format = "12h", 15 | time_zone = "UTC", -- Format "America/New_York" 16 | report_excludes = { 17 | branch = {}, 18 | directory = {}, 19 | file = {}, 20 | filetype = {}, 21 | project = {}, 22 | }, 23 | report_section_excludes = {}, 24 | } 25 | 26 | ---set up plugin autocommands with user options 27 | ---@param opts table? 28 | function M.setup(opts) 29 | opts = vim.tbl_deep_extend("force", default_opts, opts or {}) 30 | handlers.setup(opts) 31 | 32 | if opts.gen_reports == true then 33 | remote.setup(opts) 34 | end 35 | end 36 | 37 | return M 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Patrick Dewey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /remote/pkg/window.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import "github.com/neovim/go-client/nvim" 4 | 5 | // CreatePopupWindow creates a popup window in Neovim with a specified buffer. 6 | // 7 | // Parameters: 8 | // - v: A pointer to the Neovim instance. 9 | // - buf: The buffer to display in the popup window. 10 | // 11 | // Returns: 12 | // - An error if the window cannot be created, or nil if successful. 13 | func CreatePopupWindow(v *nvim.Nvim, buf nvim.Buffer) error { 14 | // get window size 15 | var screen_size [2]int 16 | err := v.Eval("[&columns, &lines]", &screen_size) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | // define window size and create popup window 22 | popupWidth := int(0.85 * float64(screen_size[0])) 23 | popupHeight := int(0.85 * float64(screen_size[1])) 24 | _, err = v.OpenWindow(buf, true, &nvim.WindowConfig{ 25 | Relative: "editor", 26 | Row: float64((screen_size[1])-popupHeight)/2 - 1, 27 | Col: float64((screen_size[0])-popupWidth) / 2, 28 | Width: popupWidth, 29 | Height: popupHeight, 30 | Style: "minimal", 31 | Border: "rounded", 32 | ZIndex: 50, 33 | }) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Go gitignore 2 | # If you prefer the allow list template instead of the deny list, see community template: 3 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 4 | # 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | go.work.sum 24 | 25 | # env file 26 | .env 27 | 28 | ## lua gitignore 29 | # Compiled Lua sources 30 | luac.out 31 | 32 | # luarocks build files 33 | *.src.rock 34 | *.zip 35 | *.tar.gz 36 | 37 | # Object files 38 | *.o 39 | *.os 40 | *.ko 41 | *.obj 42 | *.elf 43 | 44 | # Precompiled Headers 45 | *.gch 46 | *.pch 47 | 48 | # Libraries 49 | *.lib 50 | *.a 51 | *.la 52 | *.lo 53 | *.def 54 | *.exp 55 | 56 | # Shared objects (inc. Windows DLLs) 57 | *.dll 58 | *.so 59 | *.so.* 60 | *.dylib 61 | 62 | # Executables 63 | *.exe 64 | *.out 65 | *.app 66 | *.i*86 67 | *.x86_64 68 | *.hex 69 | 70 | ## Extras 71 | remote/pendulum-nvim 72 | .luarc.json 73 | -------------------------------------------------------------------------------- /remote/internal/prettify/utils.go: -------------------------------------------------------------------------------- 1 | package prettify 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // reformat time.Duration values into a more readable format 12 | func formatDuration(d time.Duration) string { 13 | if d >= 24*time.Hour { 14 | days := float32(d) / (24 * float32(time.Hour)) 15 | return fmt.Sprintf("%.2fd", days) 16 | } else if d >= time.Hour { 17 | hours := float32(d) / float32(time.Hour) 18 | return fmt.Sprintf("%.2fh", hours) 19 | } else if d >= time.Minute { 20 | minutes := float32(d) / float32(time.Minute) 21 | return fmt.Sprintf("%.2fm", minutes) 22 | } else { 23 | seconds := float32(d) / float32(time.Second) 24 | return fmt.Sprintf("%.2fs", seconds) 25 | } 26 | } 27 | 28 | // Truncate long path strings and replace /home/user with ~/ 29 | func truncatePath(path string) string { 30 | path = strings.TrimSpace(path) 31 | if path == "" { 32 | return "" 33 | } 34 | 35 | home, err := os.UserHomeDir() 36 | if err != nil { 37 | home = "" 38 | } 39 | 40 | if strings.HasPrefix(path, home) { 41 | rpath, err := filepath.Rel(home, path) 42 | if err == nil { 43 | path = "~" + string(filepath.Separator) + rpath 44 | } 45 | } 46 | 47 | parts := strings.Split(path, string(filepath.Separator)) 48 | if len(parts) > 7 { 49 | path = strings.Join(parts[len(parts)-7:], string(filepath.Separator)) 50 | } 51 | 52 | return path 53 | } 54 | -------------------------------------------------------------------------------- /remote/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "os" 7 | 8 | "github.com/ptdewey/pendulum-nvim/pkg" 9 | "github.com/ptdewey/pendulum-nvim/pkg/args" 10 | 11 | "github.com/neovim/go-client/nvim" 12 | ) 13 | 14 | // RpcEventHandler handles the RPC call from Lua and creates a buffer with pendulum data. 15 | func RpcEventHandler(v *nvim.Nvim, luaArgs map[string]any) error { 16 | // Extract and validate arguments from input table 17 | err := args.ParsePendulumArgs(luaArgs) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | // Call CreateBuffer with the struct 23 | buf, err := pkg.CreateBuffer(v) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | // Open popup window 29 | // if err := pkg.CreateNewTab(v, buf); err != nil { 30 | if err := pkg.CreatePopupWindow(v, buf); err != nil { 31 | return err 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func main() { 38 | log.SetFlags(0) 39 | 40 | // Redirect stdout to stderr 41 | stdout := os.Stdout 42 | os.Stdout = os.Stderr 43 | 44 | // Connect to Neovim 45 | v, err := nvim.New(os.Stdin, stdout, stdout, log.Printf) 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | 50 | // Register the "pendulum" RPC handler, which receives Lua tables 51 | v.RegisterHandler("pendulum", func(v *nvim.Nvim, args ...any) error { 52 | // Expecting the first argument to be a map (Lua table) 53 | if len(args) < 1 { 54 | return errors.New("not enough arguments") 55 | } 56 | 57 | // Parse the first argument as a map 58 | argMap, ok := args[0].(map[string]any) 59 | if !ok { 60 | return errors.New("expected a map as the first argument") 61 | } 62 | 63 | // Call the actual handler with the parsed map 64 | return RpcEventHandler(v, argMap) 65 | }) 66 | 67 | // Run the RPC message loop 68 | if err := v.Serve(); err != nil { 69 | log.Fatal(err) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /remote/pkg/buffer.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/ptdewey/pendulum-nvim/internal/data" 7 | "github.com/ptdewey/pendulum-nvim/internal/prettify" 8 | "github.com/ptdewey/pendulum-nvim/pkg/args" 9 | 10 | "github.com/neovim/go-client/nvim" 11 | ) 12 | 13 | func CreateBuffer(v *nvim.Nvim) (nvim.Buffer, error) { 14 | // create a new buffer 15 | buf, err := v.CreateBuffer(false, true) 16 | if err != nil { 17 | return buf, err 18 | } 19 | 20 | // set buffer filetype to add some highlighting 21 | if err := v.SetBufferOption(buf, "filetype", "markdown"); err != nil { 22 | return buf, err 23 | } 24 | 25 | // read pendulum data file 26 | data, err := data.ReadPendulumLogFile(args.PendulumArgs().LogFile) 27 | if err != nil { 28 | return buf, err 29 | } 30 | 31 | // get prettified buffer text 32 | bufText := getBufText(data) 33 | 34 | // set contents of new buffer 35 | if err := v.SetBufferLines(buf, 0, -1, false, bufText); err != nil { 36 | return buf, err 37 | } 38 | 39 | // set buffer close keymap 40 | kopts := map[string]bool{ 41 | "silent": true, 42 | } 43 | if err := v.SetBufferKeyMap(buf, "n", "q", "close!", kopts); err != nil { 44 | return buf, err 45 | } 46 | 47 | return buf, nil 48 | } 49 | 50 | func getBufText(pendulumData [][]string) [][]byte { 51 | pendulumArgs := args.PendulumArgs() 52 | 53 | // TODO: add header to popup window showing the current view 54 | var lines []string 55 | switch pendulumArgs.View { 56 | case "metrics": 57 | out := data.AggregatePendulumMetrics(pendulumData[:]) 58 | lines = prettify.PrettifyMetrics(out) 59 | case "hours": 60 | out := data.AggregatePendlulumHours(pendulumData) 61 | lines = prettify.PrettifyActiveHours(out) 62 | } 63 | 64 | var bufText [][]byte 65 | for _, l := range lines { 66 | splitLines := strings.Split(l, "\n") 67 | for _, line := range splitLines { 68 | bufText = append(bufText, []byte(line)) 69 | } 70 | } 71 | 72 | return bufText 73 | } 74 | -------------------------------------------------------------------------------- /remote/pkg/args/args.go: -------------------------------------------------------------------------------- 1 | package args 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // Add new fields here and to following function to extend functionalities 9 | // - The fields in this struct should mirror the "command_args" table in `lua/pendulum/remote.lua` 10 | type PendulumNvimArgs struct { 11 | LogFile string 12 | Timeout float64 13 | NMetrics int 14 | NHours int 15 | TimeRange string 16 | ReportExcludes map[string]any 17 | ReportSectionExcludes []any 18 | View string 19 | TimeFormat string 20 | TimeZone string 21 | } 22 | 23 | var pendulumArgs PendulumNvimArgs 24 | 25 | func PendulumArgs() *PendulumNvimArgs { 26 | return &pendulumArgs 27 | } 28 | 29 | // Parse input arguments from Lua table args 30 | func ParsePendulumArgs(args map[string]any) error { 31 | logFile, ok := args["log_file"].(string) 32 | if !ok { 33 | return errors.New("log_file missing or not a string. " + 34 | fmt.Sprintf("Type: %T\n", args["log_file"])) 35 | } 36 | 37 | topN, ok := args["top_n"].(int64) 38 | if !ok { 39 | return errors.New("top_n missing or not a number. " + 40 | fmt.Sprintf("Type: %T\n", args["top_n"])) 41 | } 42 | 43 | hoursN, ok := args["hours_n"].(int64) 44 | if !ok { 45 | return errors.New("hours_n missing or not a number. " + 46 | fmt.Sprintf("Type: %T\n", args["hours_n"])) 47 | } 48 | 49 | timerLen, ok := args["timer_len"].(int64) 50 | if !ok { 51 | return errors.New("timer_len missing or not a number. " + 52 | fmt.Sprintf("Type: %T\n", args["timer_len"])) 53 | } 54 | 55 | timeRange, ok := args["time_range"].(string) 56 | if !ok { 57 | timeRange = "all" 58 | } 59 | 60 | reportExcludes, ok := args["report_excludes"].(map[string]any) 61 | if !ok { 62 | return errors.New("report_excludes missing or not an map. " + 63 | fmt.Sprintf("Type: %T\n", args["report_excludes"])) 64 | } 65 | 66 | reportSectionExcludes, ok := args["report_section_excludes"].([]any) 67 | if !ok { 68 | return errors.New("report_excludes missing or not a list. " + 69 | fmt.Sprintf("Type: %T\n", args["report_section_excludes"])) 70 | } 71 | 72 | view, ok := args["view"].(string) 73 | if !ok { 74 | return errors.New("view missing or not a string. " + fmt.Sprintf("Type: %T\n", args["view"])) 75 | } 76 | 77 | timeFormat, ok := args["time_format"].(string) 78 | if !ok { 79 | return errors.New("time_format missing or not a string. " + fmt.Sprintf("Type: %T\n", args["time_format"])) 80 | } 81 | 82 | timeZone, ok := args["time_zone"].(string) 83 | if !ok { 84 | return errors.New("time_zone missing or not a string. " + fmt.Sprintf("Type: %T\n", args["time_zone"])) 85 | } 86 | 87 | pendulumArgs = PendulumNvimArgs{ 88 | LogFile: logFile, 89 | Timeout: float64(timerLen), 90 | NMetrics: int(topN), 91 | NHours: int(hoursN), 92 | TimeRange: timeRange, 93 | ReportExcludes: reportExcludes, 94 | ReportSectionExcludes: reportSectionExcludes, 95 | View: view, 96 | TimeFormat: timeFormat, 97 | TimeZone: timeZone, 98 | } 99 | 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /lua/pendulum/csv.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | ---@param field any 4 | ---@return string 5 | local function escape_csv_field(field) 6 | if type(field) == "string" and (field:find('[,"]') or field:find("\n")) then 7 | field = '"' .. field:gsub('"', '""') .. '"' 8 | end 9 | return tostring(field) 10 | end 11 | 12 | ---convert lua table to csv style table 13 | ---@param t table 14 | ---@return string, table 15 | local function table_to_csv(t) 16 | if #t == 0 then 17 | return "", {} 18 | end 19 | 20 | local csv_data = {} 21 | local headers = {} 22 | 23 | for key, _ in pairs(t[1]) do 24 | table.insert(headers, key) 25 | end 26 | table.sort(headers) 27 | 28 | for _, row in ipairs(t) do 29 | local temp = {} 30 | for _, field_key in ipairs(headers) do 31 | table.insert(temp, escape_csv_field(row[field_key])) 32 | end 33 | 34 | -- Incomplete rows can sometimes occur when multiple Neovim sessions are open, 35 | -- so validating the number of entries matches the number of headers prevents 36 | -- incomplete insertions. 37 | if #temp == #headers then 38 | table.insert(csv_data, table.concat(temp, ",") .. "\n") 39 | end 40 | end 41 | 42 | return table.concat(csv_data), headers 43 | end 44 | 45 | ---write lua table to csv file 46 | ---@param filepath string 47 | ---@param data_table table 48 | ---@param include_header boolean 49 | function M.write_table_to_csv(filepath, data_table, include_header) 50 | filepath = filepath:gsub("\\", "\\\\") 51 | local d = filepath:match("^(.*[\\/])") 52 | if vim.fn.isdirectory(d) == 0 then 53 | vim.fn.mkdir(d, "p") 54 | end 55 | local f = io.open(filepath, "a+") 56 | if not f then 57 | error("Error opening file: " .. filepath) 58 | end 59 | 60 | local csv_content, headers = table_to_csv(data_table) 61 | if f:seek("end") == 0 and include_header then 62 | f:write(table.concat(headers, ",") .. "\n") 63 | end 64 | 65 | if csv_content ~= "" then 66 | f:write(csv_content) 67 | else 68 | print("No data to write.") 69 | end 70 | 71 | f:close() 72 | end 73 | 74 | ---function to split a string by a given delimiter 75 | ---@param input string 76 | ---@param delimiter string 77 | ---@return table 78 | local function split(input, delimiter) 79 | local result = {} 80 | for match in (input .. delimiter):gmatch("(.-)" .. delimiter) do 81 | table.insert(result, match) 82 | end 83 | return result 84 | end 85 | 86 | ---read a csv file into a lua table 87 | ---@param filepath string 88 | ---@return table? 89 | function M.read_csv(filepath) 90 | local csv_file = io.open(filepath, "r") 91 | if not csv_file then 92 | print("Could not open file: " .. filepath) 93 | return nil 94 | end 95 | 96 | local headers = split(csv_file:read("*l"), ",") 97 | local data = {} 98 | 99 | for line in csv_file:lines() do 100 | local row = {} 101 | local values = split(line, ",") 102 | for i, header in ipairs(headers) do 103 | row[header] = values[i] 104 | end 105 | table.insert(data, row) 106 | end 107 | 108 | csv_file:close() 109 | return data 110 | end 111 | 112 | return M 113 | -------------------------------------------------------------------------------- /remote/internal/data/aggregate_hours.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/ptdewey/pendulum-nvim/pkg/args" 9 | ) 10 | 11 | type PendulumHours struct { 12 | ActiveTimestamps []string 13 | Timestamps []string 14 | ActiveTimeHours map[int]time.Duration 15 | ActiveTimeHoursRecent map[int]time.Duration 16 | TotalTimeHours map[int]time.Duration 17 | TotalTimeHoursRecent map[int]time.Duration 18 | } 19 | 20 | func AggregatePendlulumHours(data [][]string) *PendulumHours { 21 | out := &PendulumHours{ 22 | ActiveTimestamps: []string{}, 23 | Timestamps: []string{}, 24 | ActiveTimeHours: map[int]time.Duration{}, 25 | ActiveTimeHoursRecent: map[int]time.Duration{}, 26 | TotalTimeHours: map[int]time.Duration{}, 27 | TotalTimeHoursRecent: map[int]time.Duration{}, 28 | } 29 | 30 | timeoutLen := args.PendulumArgs().Timeout 31 | 32 | // TODO: Exclude handling for hours tab should likely be different from metrics 33 | // due to this hours running only once (as opposed to once per column). 34 | // Presence of any excluded term in a row would exclude the time from the entire row here. 35 | // 36 | // var exclusionPatterns []*regexp.Regexp 37 | // for _, colName := range data[0] { 38 | // if colName == "branches" || colName == "projects" { 39 | // continue 40 | // } 41 | // 42 | // if patterns, exists := args.PendulumArgs().ReportExcludes[colName]; exists { 43 | // compiledPatterns, err := compileRegexPatterns(patterns) 44 | // if err != nil { 45 | // panic(err) 46 | // } 47 | // exclusionPatterns = append(exclusionPatterns, compiledPatterns...) 48 | // } 49 | // } 50 | 51 | for i := 1; i < len(data[:]); i++ { 52 | // excluded := false 53 | // for _, val := range data[i] { 54 | // if isExcluded(val, exclusionPatterns) { 55 | // excluded = true 56 | // break 57 | // } 58 | // } 59 | // if excluded { 60 | // continue 61 | // } 62 | 63 | active, err := strconv.ParseBool(data[i][0]) 64 | if err != nil { 65 | log.Printf("Error parsing boolean at row %d, value: %s, error: %v", i, data[i][0], err) 66 | } 67 | 68 | timestampStr := data[i][csvColumns["time"]] 69 | out.updateTotalHours(timestampStr, timeoutLen) 70 | 71 | if active == true { 72 | out.updateActiveHours(timestampStr, timeoutLen) 73 | } 74 | } 75 | 76 | return out 77 | } 78 | 79 | // Recent (last week) total hours 80 | func (p *PendulumHours) updateTotalHours(timestampStr string, timeoutLen float64) { 81 | p.Timestamps = append(p.Timestamps, timestampStr) 82 | t, _ := time.Parse("2006-01-02 15:04:05", timestampStr) 83 | 84 | tth, _ := timeDiff(p.Timestamps, timeoutLen, true) 85 | p.TotalTimeHours[t.Hour()] += tth 86 | 87 | inRange, _ := isTimestampInRange(timestampStr, "week") 88 | if inRange { 89 | p.TotalTimeHoursRecent[t.Hour()] += tth 90 | } 91 | } 92 | 93 | // Extract active time per hour 94 | func (entry *PendulumHours) updateActiveHours(timestampStr string, timeoutLen float64) { 95 | entry.ActiveTimestamps = append(entry.ActiveTimestamps, timestampStr) 96 | t, _ := time.Parse("2006-01-02 15:04:05", timestampStr) 97 | 98 | ath, _ := timeDiff(entry.ActiveTimestamps, timeoutLen, true) 99 | entry.ActiveTimeHours[t.Hour()] += ath 100 | 101 | if inRange, _ := isTimestampInRange(timestampStr, "week"); inRange { 102 | entry.ActiveTimeHoursRecent[t.Hour()] += ath 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /remote/internal/prettify/metrics.go: -------------------------------------------------------------------------------- 1 | package prettify 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "sort" 7 | 8 | "github.com/ptdewey/pendulum-nvim/internal/data" 9 | "github.com/ptdewey/pendulum-nvim/pkg/args" 10 | 11 | "golang.org/x/text/cases" 12 | "golang.org/x/text/language" 13 | ) 14 | 15 | // PrettifyMetrics converts a slice of PendulumMetric structs into a slice of formatted strings. 16 | // 17 | // Parameters: 18 | // - metrics: A slice of PendulumMetric structs containing the metrics data. 19 | // 20 | // Returns: 21 | // - A slice of strings where each string is a formatted representation of a metric. 22 | func PrettifyMetrics(metrics []data.PendulumMetric) []string { 23 | var lines []string 24 | 25 | // TODO: add printing of plugin name, log file path, and report generation time 26 | // also time frame of the report 27 | // - Do this in a utility function to use with the active time display as well 28 | 29 | // iterate over each metric 30 | for _, metric := range metrics { 31 | // TODO: redefine order? (might require hardcoding) 32 | if metric.Name != "" && len(metric.Value) != 0 { 33 | lines = append(lines, prettifyMetric(metric, args.PendulumArgs().NMetrics)) 34 | } 35 | } 36 | 37 | return lines 38 | } 39 | 40 | // Function prettifyMetric converts a single PendulumMetric struct into a formatted string. 41 | // 42 | // Parameters: 43 | // - metric: A PendulumMetric struct containing the metric data. 44 | // - n: An integer specifying the number of top entries to include in the output. 45 | // 46 | // Returns: 47 | // - A string formatted to display the top n entries of the metric. 48 | func prettifyMetric(metric data.PendulumMetric, n int) string { 49 | keys := make([]string, 0, len(metric.Value)) 50 | for k := range metric.Value { 51 | keys = append(keys, k) 52 | } 53 | 54 | // Sort map by time spent active per key 55 | sort.SliceStable(keys, func(a int, b int) bool { 56 | return metric.Value[keys[a]].ActiveTime > metric.Value[keys[b]].ActiveTime 57 | }) 58 | 59 | if n > len(keys) { 60 | n = len(keys) 61 | } 62 | 63 | // Find longest length ID value in top 5 to align text width 64 | l := 15 65 | for i := range n { 66 | il := len(truncatePath(metric.Value[keys[i]].ID)) 67 | if l < il { 68 | l = il 69 | } 70 | } 71 | 72 | // write out top n list 73 | name := cases.Title(language.English, cases.Compact).String(metric.Name) 74 | out := fmt.Sprintf("# Top %d %s:\n", n, prettifyMetricName(name)) 75 | for i := range n { 76 | if math.IsNaN(float64(metric.Value[keys[i]].ActivePct)) { 77 | continue 78 | } 79 | 80 | out = fmt.Sprintln(out, prettifyEntry(metric.Value[keys[i]], i, l, n)) 81 | } 82 | 83 | return out 84 | } 85 | 86 | // prettifyEntry converts a single PendulumEntry into a formatted string. 87 | // 88 | // Parameters: 89 | // - e: A pointer to a PendulumEntry struct containing the entry data. 90 | // - i: An integer representing the index of the entry in the list. 91 | // - l: An integer specifying the width for aligning the ID column. 92 | // - n: An integer specifying the number of top entries to include in the output. 93 | // 94 | // Returns: 95 | // - A formatted string representing the entry. 96 | func prettifyEntry(e *data.PendulumEntry, i int, l int, n int) string { 97 | format := fmt.Sprintf("%%%dd. %%-%ds: Total Time %%+6s, Active Time %%+6s (%%-5.2f%%%%)", 98 | len(fmt.Sprintf("%d", n)), l+1) 99 | return fmt.Sprintf(format, 100 | i+1, truncatePath(e.ID), formatDuration(e.TotalTime), 101 | formatDuration(e.ActiveTime), e.ActivePct*100) 102 | } 103 | 104 | // prettifyMetricName converts metric names into a more readable form. 105 | // 106 | // Parameters: 107 | // - name: A string containing the metric name. 108 | // 109 | // Returns: 110 | // - A string representing the prettified metric name. 111 | func prettifyMetricName(name string) string { 112 | switch name { 113 | case "Cwd": 114 | return "Directories" 115 | case "Branch": 116 | return "Branches" 117 | default: 118 | return fmt.Sprintf("%ss", name) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /lua/pendulum/handlers.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local csv = require("pendulum.csv") 4 | 5 | ---initialize last active time 6 | local last_active_time = os.time() 7 | 8 | local flag = true 9 | 10 | ---update last active time 11 | local function update_activity() 12 | last_active_time = os.time() 13 | end 14 | 15 | ---get the name of the git project 16 | ---@return string 17 | local function git_project() 18 | -- TODO: possibly change cwd to file path (to capture its git project while in a different working directory) 19 | local project_name = vim.system( 20 | { "git", "config", "--local", "remote.origin.url" }, 21 | { text = true, cwd = vim.loop.cwd() } 22 | ) 23 | :wait().stdout 24 | 25 | if project_name then 26 | project_name = project_name:gsub("%s+$", ""):match(".*/([^.]+)%.git$") 27 | end 28 | 29 | return project_name or "unknown_project" 30 | end 31 | 32 | ---get name of current git branch 33 | ---@return string 34 | local function git_branch() 35 | -- TODO: possibly change cwd to file path (to capture its git project while in a different working directory) 36 | local branch_name = vim.system( 37 | { "git", "branch", "--show-current" }, 38 | { text = true, cwd = vim.loop.cwd() } 39 | ) 40 | :wait().stdout 41 | 42 | if not branch_name or branch_name == "" or branch_name:match("^fatal:") then 43 | return "unknown_branch" 44 | end 45 | 46 | return branch_name:gsub("%s+$", "") or "unknown_branch" 47 | end 48 | 49 | ---get table of tracked metrics 50 | ---@param is_active boolean 51 | ---@param active_time integer? 52 | ---@return table 53 | local function log_activity(is_active, opts, active_time) 54 | local _ = active_time 55 | local ft = vim.bo.filetype 56 | if ft == "" then 57 | ft = "unknown_filetype" 58 | end 59 | local data = { 60 | -- time = vim.fn.strftime("%Y-%m-%d %H:%M:%S"), -- Use local time zone instead 61 | time = os.date("!%Y-%m-%d %H:%M:%S"), 62 | active = tostring(is_active), 63 | -- file = vim.fn.expand("%:t+"), -- only file name 64 | file = vim.fn.expand("%:p"), -- file name with path 65 | -- TODO: file path - filename -> handoff to git to get file names 66 | -- - change cwd to file path without filename 67 | filetype = ft, 68 | cwd = vim.loop.cwd(), 69 | project = git_project(), 70 | branch = git_branch(), 71 | } 72 | if data.file ~= "" then 73 | csv.write_table_to_csv(opts.log_file, { data }, true) 74 | end 75 | 76 | return data 77 | end 78 | 79 | ---Check if the user is currently active 80 | ---@param opts table 81 | local function check_active_status(opts) 82 | local is_active = os.time() - last_active_time < opts.timeout_len 83 | 84 | -- for first non-active entry, log last active time 85 | if not is_active and flag then 86 | flag = false 87 | log_activity(true, opts, last_active_time) 88 | elseif is_active and not flag then 89 | flag = true 90 | end 91 | 92 | log_activity(is_active, opts) 93 | end 94 | 95 | ---Setup periodic activity checks 96 | ---@param opts table 97 | function M.setup(opts) 98 | update_activity() 99 | 100 | -- create autocommand group 101 | vim.api.nvim_create_augroup("Pendulum", { clear = true }) 102 | 103 | -- define autocmd to update last active time 104 | vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { 105 | group = "Pendulum", 106 | callback = function() 107 | update_activity() 108 | end, 109 | }) 110 | 111 | -- define autocmd for logging events 112 | vim.api.nvim_create_autocmd({ "BufEnter", "VimLeave" }, { 113 | group = "Pendulum", 114 | callback = function() 115 | log_activity(true, opts) 116 | end, 117 | }) 118 | 119 | -- logging timer 120 | vim.fn.timer_start(opts.timer_len * 1000, function() 121 | vim.schedule(function() 122 | check_active_status(opts) 123 | end) 124 | end, { ["repeat"] = -1 }) 125 | end 126 | 127 | return M 128 | -------------------------------------------------------------------------------- /lua/pendulum/remote.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local chan 4 | local bin_path 5 | local plugin_path 6 | 7 | local options = {} 8 | 9 | --- job runner for pendulum remote binary 10 | ---@return integer? 11 | local function ensure_job() 12 | if chan then 13 | return chan 14 | end 15 | 16 | if not bin_path then 17 | print("Error: Pendulum binary not found.") 18 | return 19 | end 20 | 21 | chan = vim.fn.jobstart({ bin_path }, { 22 | rpc = true, 23 | on_exit = function(_, code, _) 24 | if code ~= 0 then 25 | print("Error: Pendulum job exited with code " .. code) 26 | chan = nil 27 | end 28 | end, 29 | on_stderr = function(_, data, _) 30 | for _, line in ipairs(data) do 31 | if line ~= "" then 32 | print("stderr: " .. line) 33 | end 34 | end 35 | end, 36 | on_stdout = function(_, data, _) 37 | for _, line in ipairs(data) do 38 | if line ~= "" then 39 | print("stdout: " .. line) 40 | end 41 | end 42 | end, 43 | }) 44 | 45 | if not chan or chan == 0 then 46 | error("Failed to start pendulum-nvim job") 47 | end 48 | 49 | return chan 50 | end 51 | 52 | --- create plugin user commands to build binary and show report 53 | local function setup_pendulum_commands() 54 | vim.api.nvim_create_user_command("Pendulum", function(args) 55 | chan = ensure_job() 56 | if not chan or chan == 0 then 57 | print("Error: Invalid channel") 58 | return 59 | end 60 | 61 | options.time_range = args.args or "all" 62 | options.view = "metrics" 63 | 64 | local success, result = 65 | pcall(vim.fn.rpcrequest, chan, "pendulum", options) 66 | if not success then 67 | print("RPC request failed: " .. result) 68 | end 69 | end, { nargs = "?" }) 70 | 71 | vim.api.nvim_create_user_command("PendulumHours", function(args) 72 | chan = ensure_job() 73 | if not chan or chan == 0 then 74 | print("Error: Invalid channel") 75 | return 76 | end 77 | 78 | options.view = "hours" 79 | 80 | local success, result = 81 | pcall(vim.fn.rpcrequest, chan, "pendulum", options) 82 | if not success then 83 | print("RPC request failed: " .. result) 84 | end 85 | end, { nargs = 0 }) 86 | 87 | vim.api.nvim_create_user_command("PendulumRebuild", function() 88 | print("Rebuilding Pendulum binary with Go...") 89 | local result = 90 | os.execute("cd " .. plugin_path .. "remote" .. " && go build") 91 | if result == 0 then 92 | print("Go binary compiled successfully.") 93 | if chan then 94 | vim.fn.jobstop(chan) 95 | chan = nil 96 | end 97 | else 98 | print("Failed to compile Go binary.") 99 | end 100 | end, { nargs = 0 }) 101 | end 102 | 103 | --- report generation setup (requires go) 104 | ---@param opts table 105 | function M.setup(opts) 106 | options = opts 107 | 108 | -- get plugin install path 109 | plugin_path = debug.getinfo(1).source:sub(2):match("(.*/).*/.*/") 110 | 111 | -- check os to switch separators and binary extension if necessary 112 | local uname = vim.loop.os_uname().sysname 113 | local path_separator = (uname == "Windows_NT") and "\\" or "/" 114 | bin_path = plugin_path 115 | .. "remote" 116 | .. path_separator 117 | .. "pendulum-nvim" 118 | .. (uname == "Windows_NT" and ".exe" or "") 119 | 120 | setup_pendulum_commands() 121 | 122 | -- check if binary exists 123 | local uv = vim.loop 124 | local handle = uv.fs_open(bin_path, "r", 438) 125 | if handle then 126 | uv.fs_close(handle) 127 | return 128 | end 129 | 130 | -- compile binary if it doesn't exist 131 | print( 132 | "Pendulum binary not found at " 133 | .. bin_path 134 | .. ", attempting to compile with Go..." 135 | ) 136 | 137 | local result = 138 | os.execute("cd " .. plugin_path .. "remote" .. " && go build") 139 | if result == 0 then 140 | print("Go binary compiled successfully.") 141 | else 142 | print("Failed to compile Go binary." .. uv.cwd()) 143 | end 144 | end 145 | 146 | return M 147 | -------------------------------------------------------------------------------- /remote/internal/prettify/hours.go: -------------------------------------------------------------------------------- 1 | package prettify 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "time" 7 | "unicode/utf8" 8 | 9 | "github.com/ptdewey/pendulum-nvim/internal/data" 10 | "github.com/ptdewey/pendulum-nvim/pkg/args" 11 | ) 12 | 13 | type hourDuration struct { 14 | hour int 15 | duration time.Duration 16 | } 17 | 18 | func PrettifyActiveHours(hours *data.PendulumHours) []string { 19 | pendulumArgs := args.PendulumArgs() 20 | return []string{ 21 | prettifyActiveHours(hours, pendulumArgs.NHours, 22 | pendulumArgs.TimeFormat, pendulumArgs.TimeZone), 23 | } 24 | } 25 | 26 | func prettifyActiveHours(hours *data.PendulumHours, n int, timeFormat string, timeZone string) string { 27 | hourCountsActive := make(map[int]int) 28 | 29 | hourDurationsActive := make(map[int]time.Duration) 30 | hourDurationsTotal := make(map[int]time.Duration) 31 | weekHourDurationsActive := make(map[int]time.Duration) 32 | weekHourDurationsTotal := make(map[int]time.Duration) 33 | 34 | loc, err := time.LoadLocation(timeZone) 35 | if err != nil { 36 | loc = time.UTC 37 | } 38 | 39 | layout := "2006-01-02 15:04:05" 40 | for _, ts := range hours.ActiveTimestamps { 41 | t, err := time.Parse(layout, ts) 42 | if err != nil { 43 | fmt.Println("Failed to parse timestamp: ", ts) 44 | continue 45 | } 46 | 47 | hourCountsActive[t.In(loc).Hour()]++ 48 | } 49 | 50 | for k, v := range hours.ActiveTimeHours { 51 | t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) 52 | hourDurationsActive[t.In(loc).Hour()] += v 53 | 54 | } 55 | 56 | for k, v := range hours.TotalTimeHours { 57 | t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) 58 | hourDurationsTotal[t.In(loc).Hour()] += v 59 | } 60 | 61 | for k, v := range hours.ActiveTimeHoursRecent { 62 | t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) 63 | weekHourDurationsActive[t.In(loc).Hour()] += v 64 | } 65 | 66 | for k, v := range hours.TotalTimeHoursRecent { 67 | t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) 68 | weekHourDurationsTotal[t.In(loc).Hour()] += v 69 | } 70 | 71 | // Create a slice of hourDuration structs to sort by duration 72 | var hourDurationSlice []hourDuration 73 | for hour, duration := range hourDurationsActive { 74 | hourDurationSlice = append(hourDurationSlice, hourDuration{hour: hour, duration: duration}) 75 | } 76 | 77 | // Sort by duration (largest first) 78 | sort.SliceStable(hourDurationSlice, func(a, b int) bool { 79 | return hourDurationSlice[a].duration > hourDurationSlice[b].duration 80 | }) 81 | 82 | if n > len(hourDurationSlice) { 83 | n = len(hourDurationSlice) 84 | } 85 | 86 | var overallHoursWidth int 87 | var recentHoursWidth int 88 | 89 | for _, d := range hourDurationsActive { 90 | w := utf8.RuneCountInString(fmt.Sprintf("%v", d)) 91 | if overallHoursWidth < w { 92 | overallHoursWidth = w 93 | } 94 | } 95 | 96 | for _, d := range weekHourDurationsActive { 97 | w := utf8.RuneCountInString(fmt.Sprintf("%v", d)) 98 | if recentHoursWidth < w { 99 | recentHoursWidth = w 100 | } 101 | } 102 | 103 | // Column width stuff 104 | bulletWidth := len(fmt.Sprintf("%d", n)) 105 | // TODO: store largest duration string lengths 106 | 107 | // Header formatting 108 | out := fmt.Sprintf("# Times Most Active\n") 109 | out += fmt.Sprintf("%*s %-*s %-*s %-*s %-*s\n", 110 | bulletWidth, "", 7, "Time", 21, "Overall (Active %)", 111 | 23, "This Week (Active %)", 3, "Entry Count", 112 | ) 113 | 114 | // Loop through results and format accordingly 115 | for i := range n { 116 | h24 := hourDurationSlice[i].hour 117 | c := hourCountsActive[h24] 118 | dur := hourDurationsActive[h24] 119 | weeklyDur := weekHourDurationsActive[h24] 120 | 121 | h := h24 122 | var period string 123 | if timeFormat == "12h" { 124 | h = h24 % 12 125 | if h == 0 { 126 | h = 12 127 | } 128 | period = "AM" 129 | if h24 >= 12 { 130 | period = "PM" 131 | } 132 | } 133 | 134 | var overallPct, recentPct float64 135 | if _, exists := hourDurationsTotal[h24]; exists { 136 | overallPct = float64(dur) / float64(hourDurationsTotal[h24]) * 100 137 | } 138 | if _, exists := weekHourDurationsTotal[h24]; exists { 139 | recentPct = float64(weeklyDur) / float64(weekHourDurationsTotal[h24]) * 100 140 | } 141 | 142 | // FIX: whitespace formatting for non-numeric weekly percentages 143 | out += fmt.Sprintf("%*d. %2d%s %-*s%-*v (%.2f%%)%-*s %-*v (%.2f%%)%-*s %-*d\n", 144 | bulletWidth, i+1, 145 | h, period, 146 | 3, "", 147 | overallHoursWidth, dur, 148 | overallPct, 149 | 3, "", 150 | recentHoursWidth, weeklyDur, 151 | recentPct, 152 | 6, "", 153 | 3, c, 154 | ) 155 | } 156 | 157 | return out 158 | } 159 | -------------------------------------------------------------------------------- /remote/internal/data/utils.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "time" 7 | 8 | "github.com/ptdewey/pendulum-nvim/pkg/args" 9 | ) 10 | 11 | // timeDiff calculates the time difference between the last two timestamps. 12 | // REFACTOR: take in 2 timestamps instead of a slice 13 | func timeDiff(timestamps []string, timeoutLen float64, clamp bool) (time.Duration, error) { 14 | n := len(timestamps) 15 | if n < 2 { 16 | return time.Duration(0), nil 17 | } 18 | 19 | curr, prev := timestamps[n-1], timestamps[n-2] 20 | var d time.Duration 21 | var err error 22 | if !clamp { 23 | d, err = calcDuration(curr, prev) 24 | } else { 25 | d, err = calcDurationWithinHour(curr, prev) 26 | } 27 | 28 | if err != nil { 29 | return time.Duration(0), err 30 | } 31 | 32 | // if difference between timestamps exceeds timeout length then editor was closed between sessions. 33 | if d.Seconds() > timeoutLen { 34 | return time.Duration(0), nil 35 | } 36 | 37 | return d, nil 38 | } 39 | 40 | // calculate the duration between two string timestamps (curr - prev) 41 | func calcDuration(curr string, prev string) (time.Duration, error) { 42 | layout := "2006-01-02 15:04:05" 43 | 44 | curr_t, err := time.Parse(layout, curr) 45 | if err != nil { 46 | return time.Duration(0), err 47 | } 48 | 49 | prev_t, err := time.Parse(layout, prev) 50 | if err != nil { 51 | return time.Duration(0), err 52 | } 53 | 54 | return curr_t.Sub(prev_t), nil 55 | } 56 | 57 | func calcDurationWithinHour(curr string, prev string) (time.Duration, error) { 58 | layout := "2006-01-02 15:04:05" 59 | 60 | curr_t, err := time.Parse(layout, curr) 61 | if err != nil { 62 | return 0, err 63 | } 64 | 65 | prev_t, err := time.Parse(layout, prev) 66 | if err != nil { 67 | return 0, err 68 | } 69 | 70 | // If prev_t is within the same hour as curr_t, return the direct difference 71 | if prev_t.Hour() == curr_t.Hour() && prev_t.Day() == curr_t.Day() { 72 | return curr_t.Sub(prev_t), nil 73 | } 74 | 75 | // Otherwise, clamp prev_t to the start of curr_t's hour 76 | clamped_prev_t := time.Date(curr_t.Year(), curr_t.Month(), curr_t.Day(), curr_t.Hour(), 77 | 0, 0, 0, curr_t.Location()) 78 | 79 | return curr_t.Sub(clamped_prev_t), nil 80 | } 81 | 82 | func compileRegexPatterns(filters any) ([]*regexp.Regexp, error) { 83 | var patterns []*regexp.Regexp 84 | if _, ok := filters.([]any); !ok { 85 | return nil, nil 86 | } 87 | for _, expr := range filters.([]any) { 88 | r, err := regexp.Compile(expr.(string)) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | patterns = append(patterns, r) 94 | } 95 | 96 | return patterns, nil 97 | } 98 | 99 | func isExcluded(val string, patterns []*regexp.Regexp) bool { 100 | for _, r := range patterns { 101 | if r.MatchString(val) { 102 | return true 103 | } 104 | } 105 | return false 106 | } 107 | 108 | func isTimestampInRange(timestampStr, rangeType string) (bool, error) { 109 | layout := "2006-01-02 15:04:05" 110 | 111 | timestamp, err := time.Parse(layout, timestampStr) 112 | if err != nil { 113 | return false, fmt.Errorf("error parsing timestamp: %v", err) 114 | } 115 | 116 | // TEST: Add time-range option back, test tz change 117 | // TEST: ensure removing `now.loc()` calls from switch date calls doesn't break things 118 | loc, err := time.LoadLocation(args.PendulumArgs().TimeZone) 119 | if err == nil { 120 | timestamp = timestamp.In(loc) 121 | } 122 | 123 | now := time.Now().UTC() 124 | 125 | var startOfRange, endOfRange time.Time 126 | 127 | switch rangeType { 128 | case "today", "day": 129 | startOfRange = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) 130 | endOfRange = startOfRange.Add(24 * time.Hour).Add(-time.Nanosecond) 131 | case "year": 132 | startOfRange = time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc) 133 | endOfRange = startOfRange.AddDate(1, 0, 0).Add(-time.Nanosecond) 134 | case "month": 135 | startOfRange = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) 136 | endOfRange = startOfRange.AddDate(0, 1, 0).Add(-time.Nanosecond) 137 | case "week": 138 | startOfRange = now.AddDate(0, 0, -6) 139 | endOfRange = now.Add(24*time.Hour - time.Nanosecond) 140 | case "hour": 141 | startOfRange = now.Truncate(time.Hour) 142 | endOfRange = startOfRange.Add(time.Hour).Add(-time.Nanosecond) 143 | default: 144 | startOfRange = time.Time{} 145 | endOfRange = time.Time{} 146 | } 147 | 148 | // Handle the default "all" range case (from the earliest possible time to the latest possible time) 149 | if rangeType == "all" || startOfRange.IsZero() || endOfRange.IsZero() { 150 | startOfRange = time.Time{} 151 | endOfRange = time.Now().Add(1 * time.Second) 152 | } 153 | 154 | // Check if the timestamp is within the range 155 | return timestamp.After(startOfRange) && timestamp.Before(endOfRange), nil 156 | } 157 | -------------------------------------------------------------------------------- /remote/internal/data/aggregate_metrics.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | "sync" 7 | "time" 8 | 9 | "github.com/ptdewey/pendulum-nvim/pkg/args" 10 | ) 11 | 12 | type PendulumMetric struct { 13 | Name string 14 | Index int 15 | Value map[string]*PendulumEntry 16 | } 17 | 18 | type PendulumEntry struct { 19 | ID string 20 | ActiveCount uint 21 | TotalCount uint 22 | ActiveTime time.Duration 23 | TotalTime time.Duration 24 | ActiveTimestamps []string 25 | Timestamps []string 26 | ActivePct float64 27 | } 28 | 29 | var csvColumns = map[string]int{ 30 | "active": 0, 31 | "branch": 1, 32 | "directory": 2, 33 | "file": 3, 34 | "filetype": 4, 35 | "project": 5, 36 | "time": 6, 37 | } 38 | 39 | func AggregatePendulumMetrics(data [][]string) []PendulumMetric { 40 | // create waitgroup 41 | var wg sync.WaitGroup 42 | 43 | // create buffered channel to store results and avoid deadlock in main 44 | res := make(chan PendulumMetric, len(data[0])) 45 | 46 | excludeMap := make(map[int]struct{}) 47 | for _, section := range args.PendulumArgs().ReportSectionExcludes { 48 | if idx, exists := csvColumns[section.(string)]; exists { 49 | excludeMap[idx] = struct{}{} 50 | } 51 | } 52 | 53 | timeoutLen := args.PendulumArgs().Timeout 54 | reportExcludes := args.PendulumArgs().ReportExcludes 55 | 56 | // iterate through each metric column as specified in Sections config 57 | // and create goroutine for each 58 | for colName, colIdx := range csvColumns { 59 | if colName == "active" || colName == "time" { 60 | continue 61 | } 62 | 63 | if _, excluded := excludeMap[colIdx]; excluded { 64 | continue 65 | } 66 | 67 | wg.Add(1) 68 | go func(m int) { 69 | defer wg.Done() 70 | aggregatePendulumMetric( 71 | data, 72 | m, 73 | timeoutLen, 74 | reportExcludes, 75 | res, 76 | ) 77 | }(colIdx) 78 | } 79 | 80 | go func() { 81 | wg.Wait() 82 | close(res) 83 | }() 84 | 85 | // deal with results 86 | out := make([]PendulumMetric, len(data[0])) 87 | for r := range res { 88 | out[r.Index] = r 89 | } 90 | 91 | return out 92 | } 93 | 94 | func aggregatePendulumMetric( 95 | data [][]string, 96 | colIdx int, 97 | timeoutLen float64, 98 | reportExcludes map[string]any, 99 | ch chan<- PendulumMetric, 100 | ) { 101 | out := PendulumMetric{ 102 | Name: data[0][colIdx], 103 | Index: colIdx, 104 | Value: make(map[string]*PendulumEntry), 105 | } 106 | 107 | timecol := csvColumns["time"] 108 | colName := out.Name 109 | if colName == "cwd" { 110 | // This is a bit hacky. csv uses cwd, code uses directory. 111 | // TODO: consolidate these two terms? 112 | colName = "directory" 113 | } 114 | 115 | exclusionPatterns, err := compileRegexPatterns(reportExcludes[colName]) 116 | if err != nil { 117 | panic(err) 118 | } 119 | 120 | // iterate through each row of data 121 | for i := 1; i < len(data[:]); i++ { 122 | active, err := strconv.ParseBool(data[i][0]) 123 | if err != nil { 124 | log.Printf("Error parsing boolean at row %d, value: %s, error: %v", i, data[i][0], err) 125 | } 126 | 127 | timeRange := args.PendulumArgs().TimeRange 128 | if timeRange != "all" { 129 | inRange, _ := isTimestampInRange(data[i][timecol], timeRange) 130 | if !inRange { 131 | continue 132 | } 133 | } 134 | 135 | val := data[i][colIdx] 136 | if isExcluded(val, exclusionPatterns) { 137 | continue 138 | } 139 | 140 | // check if key doesn't exist in value map 141 | if out.Value[val] == nil { 142 | out.Value[val] = &PendulumEntry{ 143 | ID: val, 144 | ActiveCount: 0, 145 | TotalCount: 0, 146 | ActiveTime: 0, 147 | TotalTime: 0, 148 | Timestamps: make([]string, 0), 149 | ActiveTimestamps: make([]string, 0), 150 | ActivePct: 0, 151 | } 152 | } 153 | entry := out.Value[val] 154 | 155 | entry.updateTotalMetrics(data[i][timecol], timeoutLen) 156 | 157 | if active == true { 158 | entry.updateActiveMetrics(data[i][timecol], timeoutLen) 159 | } 160 | } 161 | 162 | calculateActivePercentages(out.Value) 163 | 164 | // pass output into channel 165 | ch <- out 166 | } 167 | 168 | func (entry *PendulumEntry) updateTotalMetrics(timestampStr string, timeoutLen float64) { 169 | entry.Timestamps = append(entry.Timestamps, timestampStr) 170 | tt, _ := timeDiff(entry.Timestamps, timeoutLen, false) 171 | entry.TotalCount++ 172 | entry.TotalTime += tt 173 | } 174 | 175 | func (entry *PendulumEntry) updateActiveMetrics(timestampStr string, timeoutLen float64) { 176 | entry.ActiveTimestamps = append(entry.ActiveTimestamps, timestampStr) 177 | at, _ := timeDiff(entry.ActiveTimestamps, timeoutLen, false) 178 | entry.ActiveCount++ 179 | entry.ActiveTime += at 180 | } 181 | 182 | func calculateActivePercentages(values map[string]*PendulumEntry) { 183 | for _, v := range values { 184 | if v.TotalTime > 0 { 185 | v.ActivePct = float64(v.ActiveTime) / float64(v.TotalTime) 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pendulum-nvim 2 | 3 | Pendulum is a Neovim plugin designed for tracking time spent on projects within Neovim. It logs various events like entering and leaving buffers and idle times into a CSV file, making it easy to analyze your coding activity over time. 4 | 5 | Pendulum also includes a user command that aggregates log information into a popup report viewable within your editor 6 | 7 | ![Pendulum Metrics View](./assets/screenshot0.png) 8 | 9 | ![Pendulum Active Hours View](./assets/screenshot1.png) 10 | 11 | ## Motivation 12 | 13 | Pendulum was created to offer a privacy-focused alternative to cloud-based time tracking tools, addressing concerns about data security and ownership. This "local-first" tool ensures all data stays on the user's machine, providing full control and customization without requiring internet access. It's designed for developers who prioritize privacy and autonomy but are curious about how they spend their time. 14 | 15 | ## What it Does 16 | 17 | - Automatic Time Tracking: Logs time spent in each file along with the workding directory, file type, project name, and git branch if available. 18 | - Activity Detection: Detects user activity based on cursor movements (on a timer) and buffer switches. 19 | - Customizable Timeout: Configurable timeout to define user inactivity. 20 | - Event Logging: Tracks buffer events and idle periods, writing these to a CSV log for later analysis. 21 | - Report Generation: Generate reports from the log file to quickly view how time was spent on various projects (requires Go installed). 22 | 23 | ## Installation 24 | 25 | Install Pendulum using your favorite package manager: 26 | 27 | #### With Report Generation (Requires Go) 28 | 29 | With lazy.nvim 30 | 31 | ```lua 32 | { 33 | "ptdewey/pendulum-nvim", 34 | config = function() 35 | require("pendulum").setup() 36 | end, 37 | } 38 | ``` 39 | 40 | #### Without Report Generation 41 | 42 | With lazy.nvim 43 | 44 | ```lua 45 | { 46 | "ptdewey/pendulum-nvim", 47 | config = function() 48 | require("pendulum").setup({ 49 | gen_reports = false, 50 | }) 51 | end, 52 | } 53 | ``` 54 | 55 | ## Configuration 56 | 57 | Pendulum can be customized with several options. Here is a table with configurable options: 58 | 59 | | Option | Description | Default | 60 | |-----------------------------|---------------------------------------------------------|--------------------------| 61 | | `log_file` | Path to the CSV file where logs should be written | `$HOME/pendulum-log.csv` | 62 | | `timeout_len` | Length of time in seconds to determine inactivity | `180` | 63 | | `timer_len` | Interval in seconds at which to check activity | `120` | 64 | | `gen_reports` | Generate reports from the log file | `true` | 65 | | `top_n` | Number of top entries to include in the report | `5` | 66 | | `hours_n` | Number of entries to show in active hours report | `10` | 67 | | `time_format` | Use 12/24 hour time format for active hours report (possible values: `12h`, `24h`) | `12h` | 68 | | `time_zone` | Time zone for use in active hour calculations (format: `America/New_York`) | `UTC` | 69 | | `report_section_excludes` | Additional filters to be applied to each report section | `{}` | 70 | | `report_excludes` | Show/Hide report sections. e.g `branch`, `directory`, `file`, `filetype`, `project` | `{}` | 71 | 72 | Default configuration 73 | 74 | ```lua 75 | require("pendulum").setup({ 76 | log_file = vim.env.HOME .. "/pendulum-log.csv", 77 | timeout_len = 180, 78 | timer_len = 120, 79 | gen_reports = true, 80 | top_n = 5, 81 | hours_n = 10, 82 | time_format = "12h", 83 | time_zone = "UTC", -- Format "America/New_York" 84 | report_excludes = { 85 | branch = {}, 86 | directory = {}, 87 | file = {}, 88 | filetype = {}, 89 | project = {}, 90 | }, 91 | report_section_excludes = {}, 92 | }) 93 | ``` 94 | 95 | Example configuration with custom options: (Note: this is not the recommended configuration, but just shows potential options) 96 | 97 | ```lua 98 | require('pendulum').setup({ 99 | log_file = vim.fn.expand("$HOME/Documents/my_custom_log.csv"), 100 | timeout_len = 300, -- 5 minutes 101 | timer_len = 60, -- 1 minute 102 | gen_reports = true, -- Enable report generation (requires Go) 103 | top_n = 10, -- Include top 10 entries in the report 104 | hours_n = 10, 105 | time_format = "12h", 106 | time_zone = "America/New_York", 107 | report_section_excludes = { 108 | "branch", -- Hide `branch` section of the report 109 | -- Other options includes: 110 | -- "directory", 111 | -- "filetype", 112 | -- "file", 113 | -- "project", 114 | }, 115 | report_excludes = { 116 | filetype = { 117 | -- This table controls what to be excluded from `filetype` section 118 | "neo-tree", -- Exclude neo-tree filetype 119 | }, 120 | file = { 121 | -- This table controls what to be excluded from `file` section 122 | "test.py", -- Exclude any test.py 123 | ".*.go", -- Exclude all Go files 124 | } 125 | project = { 126 | -- This table controls what to be excluded from `project` section 127 | "unknown_project" -- Exclude unknown (non-git) projects 128 | }, 129 | directory = { 130 | -- This table controls what to be excluded from `directory` section 131 | }, 132 | branch = { 133 | -- This table controls what to be excluded from `branch` section 134 | }, 135 | }, 136 | }) 137 | ``` 138 | 139 | **Note**: You can use regex to express the matching patterns within `report_excludes`. 140 | 141 | ## Usage 142 | 143 | Once configured, Pendulum runs automatically in the background. It logs each specified event into the CSV file, which includes timestamps, file names, project names (from Git), and activity states. 144 | 145 | The CSV log file will have the columns: `time`, `active`, `file`, `filetype`, `cwd`, `project`, and `branch`. 146 | 147 | ## Report Generation 148 | 149 | Pendulum can generate detailed reports from the log file. To use this feature, you need to have Go installed on your system. The report includes the top entries based on the time spent on various projects. 150 | 151 | To rebuild the Pendulum binary and generate reports, use the following commands: 152 | 153 | ```vim 154 | :PendulumRebuild 155 | :Pendulum 156 | :PendulumHours 157 | ``` 158 | 159 | The `:PendulumRebuild` command recompiles the Go binary, and the :Pendulum command generates the report based on the current log file. 160 | I recommend rebuilding the binary after the plugin is updated. 161 | 162 | The `:Pendulum` command generates and shows the metrics view (i.e. time spent per branch, project, filetype, etc.). Report generation will take longer as the size of your log file increases. 163 | 164 | The `:PendulumHours` command generates and shows the active hours view, which shows which times of day you are most active (and time spent). 165 | 166 | If you do not want to install Go, report generation can be disabled by changing the `gen_reports` option to `false`. Disabling reports will cause the `Pendulum`, `PendulumHours`, and `PendulumRebuild` commands to not be created since they are exclusively used for the reports feature. 167 | 168 | ```lua 169 | config = function() 170 | require("pendulum").setup({ 171 | -- disable report generations (avoids Go dependency) 172 | gen_reports = false, 173 | }) 174 | end, 175 | ``` 176 | 177 | The metrics report contents are customizable and section items or entire sections can be excluded from the report if desired. (See `report_excludes` and `report_section_excludes` options in setup) 178 | 179 | ## Future Ideas 180 | 181 | These are some potential future ideas that would make for welcome contributions for anyone interested. 182 | 183 | - Logging to SQLite database (optionally) 184 | - Telescope integration 185 | - Get stats for specified project, filetype, etc. (Could work well with Telescope) 186 | - Nicer looking popup with custom highlight groups 187 | - Alternative version of popup that uses a terminal buffer and [bubbletea](https://github.com/charmbracelet/bubbletea) (using the table component) 188 | -------------------------------------------------------------------------------- /doc/pendulum-nvim.txt: -------------------------------------------------------------------------------- 1 | *pendulum-nvim.txt* For Neovim >= 0.10.0 Last change: 2025 June 25 2 | 3 | ============================================================================== 4 | Table of Contents *pendulum-nvim-table-of-contents* 5 | 6 | 1. Pendulum-nvim |pendulum-nvim-pendulum-nvim| 7 | - Motivation |pendulum-nvim-pendulum-nvim-motivation| 8 | - What it Does |pendulum-nvim-pendulum-nvim-what-it-does| 9 | - Installation |pendulum-nvim-pendulum-nvim-installation| 10 | - Configuration |pendulum-nvim-pendulum-nvim-configuration| 11 | - Usage |pendulum-nvim-pendulum-nvim-usage| 12 | - Report Generation |pendulum-nvim-pendulum-nvim-report-generation| 13 | - Future Ideas |pendulum-nvim-pendulum-nvim-future-ideas| 14 | 2. Links |pendulum-nvim-links| 15 | 16 | ============================================================================== 17 | 1. Pendulum-nvim *pendulum-nvim-pendulum-nvim* 18 | 19 | Pendulum is a Neovim plugin designed for tracking time spent on projects within 20 | Neovim. It logs various events like entering and leaving buffers and idle times 21 | into a CSV file, making it easy to analyze your coding activity over time. 22 | 23 | Pendulum also includes a user command that aggregates log information into a 24 | popup report viewable within your editor 25 | 26 | 27 | MOTIVATION *pendulum-nvim-pendulum-nvim-motivation* 28 | 29 | Pendulum was created to offer a privacy-focused alternative to cloud-based time 30 | tracking tools, addressing concerns about data security and ownership. This 31 | "local-first" tool ensures all data stays on the user’s machine, providing 32 | full control and customization without requiring internet access. It’s 33 | designed for developers who prioritize privacy and autonomy but are curious 34 | about how they spend their time. 35 | 36 | 37 | WHAT IT DOES *pendulum-nvim-pendulum-nvim-what-it-does* 38 | 39 | - Automatic Time Tracking: Logs time spent in each file along with the workding directory, file type, project name, and git branch if available. 40 | - Activity Detection: Detects user activity based on cursor movements (on a timer) and buffer switches. 41 | - Customizable Timeout: Configurable timeout to define user inactivity. 42 | - Event Logging: Tracks buffer events and idle periods, writing these to a CSV log for later analysis. 43 | - Report Generation: Generate reports from the log file to quickly view how time was spent on various projects (requires Go installed). 44 | 45 | 46 | INSTALLATION *pendulum-nvim-pendulum-nvim-installation* 47 | 48 | Install Pendulum using your favorite package manager: 49 | 50 | 51 | WITH REPORT GENERATION (REQUIRES GO) 52 | 53 | With lazy.nvim 54 | 55 | >lua 56 | { 57 | "ptdewey/pendulum-nvim", 58 | config = function() 59 | require("pendulum").setup() 60 | end, 61 | } 62 | < 63 | 64 | 65 | WITHOUT REPORT GENERATION 66 | 67 | With lazy.nvim 68 | 69 | >lua 70 | { 71 | "ptdewey/pendulum-nvim", 72 | config = function() 73 | require("pendulum").setup({ 74 | gen_reports = false, 75 | }) 76 | end, 77 | } 78 | < 79 | 80 | 81 | CONFIGURATION *pendulum-nvim-pendulum-nvim-configuration* 82 | 83 | Pendulum can be customized with several options. Here is a table with 84 | configurable options: 85 | 86 | --------------------------------------------------------------------------------------- 87 | Option Description Default 88 | ------------------------- ------------------------------------ ------------------------ 89 | log_file Path to the CSV file where logs $HOME/pendulum-log.csv 90 | should be written 91 | 92 | timeout_len Length of time in seconds to 180 93 | determine inactivity 94 | 95 | timer_len Interval in seconds at which to 120 96 | check activity 97 | 98 | gen_reports Generate reports from the log file true 99 | 100 | top_n Number of top entries to include in 5 101 | the report 102 | 103 | hours_n Number of entries to show in active 10 104 | hours report 105 | 106 | time_format Use 12/24 hour time format for 12h 107 | active hours report (possible 108 | values: 12h, 24h) 109 | 110 | time_zone Time zone for use in active hour UTC 111 | calculations (format: 112 | America/New_York) 113 | 114 | report_section_excludes Additional filters to be applied to {} 115 | each report section 116 | 117 | report_excludes Show/Hide report sections. e.g {} 118 | branch, directory, file, filetype, 119 | project 120 | --------------------------------------------------------------------------------------- 121 | Default configuration 122 | 123 | >lua 124 | require("pendulum").setup({ 125 | log_file = vim.env.HOME .. "/pendulum-log.csv", 126 | timeout_len = 180, 127 | timer_len = 120, 128 | gen_reports = true, 129 | top_n = 5, 130 | hours_n = 10, 131 | time_format = "12h", 132 | time_zone = "UTC", -- Format "America/New_York" 133 | report_excludes = { 134 | branch = {}, 135 | directory = {}, 136 | file = {}, 137 | filetype = {}, 138 | project = {}, 139 | }, 140 | report_section_excludes = {}, 141 | }) 142 | < 143 | 144 | Example configuration with custom options: (Note: this is not the recommended 145 | configuration, but just shows potential options) 146 | 147 | >lua 148 | require('pendulum').setup({ 149 | log_file = vim.fn.expand("$HOME/Documents/my_custom_log.csv"), 150 | timeout_len = 300, -- 5 minutes 151 | timer_len = 60, -- 1 minute 152 | gen_reports = true, -- Enable report generation (requires Go) 153 | top_n = 10, -- Include top 10 entries in the report 154 | hours_n = 10, 155 | time_format = "12h", 156 | time_zone = "America/New_York", 157 | report_section_excludes = { 158 | "branch", -- Hide `branch` section of the report 159 | -- Other options includes: 160 | -- "directory", 161 | -- "filetype", 162 | -- "file", 163 | -- "project", 164 | }, 165 | report_excludes = { 166 | filetype = { 167 | -- This table controls what to be excluded from `filetype` section 168 | "neo-tree", -- Exclude neo-tree filetype 169 | }, 170 | file = { 171 | -- This table controls what to be excluded from `file` section 172 | "test.py", -- Exclude any test.py 173 | ".*.go", -- Exclude all Go files 174 | } 175 | project = { 176 | -- This table controls what to be excluded from `project` section 177 | "unknown_project" -- Exclude unknown (non-git) projects 178 | }, 179 | directory = { 180 | -- This table controls what to be excluded from `directory` section 181 | }, 182 | branch = { 183 | -- This table controls what to be excluded from `branch` section 184 | }, 185 | }, 186 | }) 187 | < 188 | 189 | **Note**You can use regex to express the matching patterns within 190 | `report_excludes`. 191 | 192 | 193 | USAGE *pendulum-nvim-pendulum-nvim-usage* 194 | 195 | Once configured, Pendulum runs automatically in the background. It logs each 196 | specified event into the CSV file, which includes timestamps, file names, 197 | project names (from Git), and activity states. 198 | 199 | The CSV log file will have the columns: `time`, `active`, `file`, `filetype`, 200 | `cwd`, `project`, and `branch`. 201 | 202 | 203 | REPORT GENERATION *pendulum-nvim-pendulum-nvim-report-generation* 204 | 205 | Pendulum can generate detailed reports from the log file. To use this feature, 206 | you need to have Go installed on your system. The report includes the top 207 | entries based on the time spent on various projects. 208 | 209 | To rebuild the Pendulum binary and generate reports, use the following 210 | commands: 211 | 212 | >vim 213 | :PendulumRebuild 214 | :Pendulum 215 | :PendulumHours 216 | < 217 | 218 | The `:PendulumRebuild` command recompiles the Go binary, and the :Pendulum 219 | command generates the report based on the current log file. I recommend 220 | rebuilding the binary after the plugin is updated. 221 | 222 | The `:Pendulum` command generates and shows the metrics view (i.e. time spent 223 | per branch, project, filetype, etc.). Report generation will take longer as the 224 | size of your log file increases. 225 | 226 | The `:PendulumHours` command generates and shows the active hours view, which 227 | shows which times of day you are most active (and time spent). 228 | 229 | If you do not want to install Go, report generation can be disabled by changing 230 | the `gen_reports` option to `false`. Disabling reports will cause the 231 | `Pendulum`, `PendulumHours`, and `PendulumRebuild` commands to not be created 232 | since they are exclusively used for the reports feature. 233 | 234 | >lua 235 | config = function() 236 | require("pendulum").setup({ 237 | -- disable report generations (avoids Go dependency) 238 | gen_reports = false, 239 | }) 240 | end, 241 | < 242 | 243 | The metrics report contents are customizable and section items or entire 244 | sections can be excluded from the report if desired. (See `report_excludes` and 245 | `report_section_excludes` options in setup) 246 | 247 | 248 | FUTURE IDEAS *pendulum-nvim-pendulum-nvim-future-ideas* 249 | 250 | These are some potential future ideas that would make for welcome contributions 251 | for anyone interested. 252 | 253 | - Logging to SQLite database (optionally) 254 | - Telescope integration 255 | - Get stats for specified project, filetype, etc. (Could work well with Telescope) 256 | - Nicer looking popup with custom highlight groups 257 | - Alternative version of popup that uses a terminal buffer and bubbletea (using the table component) 258 | 259 | ============================================================================== 260 | 2. Links *pendulum-nvim-links* 261 | 262 | 1. *Pendulum Metrics View*: ./assets/screenshot0.png 263 | 2. *Pendulum Active Hours View*: ./assets/screenshot1.png 264 | 265 | Generated by panvimdoc 266 | 267 | vim:tw=78:ts=8:noet:ft=help:norl: 268 | --------------------------------------------------------------------------------