11-- CodeMe Tracker
2- --
3- -- Key principles:
4- -- 1. On SAVE: Calculate git diff DELTA (changes since last save), send only new changes
5- -- - Git-tracked files: Track line count delta using git diff
6- -- - Non-git files: Track activity (time) but not line counts (can't calculate without git)
7- -- 2. On OPEN: Send heartbeat with file info (backend logs activity time)
8- -- 3. Use 2-min cooldown to prevent spam on file opens
9- -- 4. Track last git diff per file to avoid counting same changes multiple times
102
113local M = {}
124
13- -- Last heartbeat time per file
14- local last_heartbeat = {}
15- -- Last git diff value per file (for delta tracking)
16- local last_git_diff = {}
17- local COOLDOWN_SECONDS = 120
5+ local last_heartbeat_time = {}
6+ local last_git_diff_lines = {}
7+ local last_non_git_save_time = {}
8+ local active_file = nil
9+ local presence_timer = nil
10+ local PRESENCE_INTERVAL = 120
11+ local COOLDOWN_SAME_FILE = 60
12+ local NON_GIT_COOLDOWN = 60
13+
14+ local function should_track (bufnr , filepath )
15+ if filepath == " " or not vim .api .nvim_buf_is_valid (bufnr ) then
16+ return false
17+ end
18+
19+ if vim .bo [bufnr ].buftype ~= " " then
20+ return false
21+ end
22+
23+ local filetype = vim .bo [bufnr ].filetype
24+ local skip_fts = { " NvimTree" , " neo-tree" , " dashboard" , " help" , " qf" , " TelescopePrompt" , " oil" , " noice" , " notify" }
25+
26+ if vim .tbl_contains (skip_fts , filetype ) then
27+ return false
28+ end
29+
30+ return true
31+ end
1832
19- --- Calculate git diff for file (returns lines changed, or nil if not in git)
2033local function get_git_lines_changed (filepath )
2134 local dir = vim .fn .fnamemodify (filepath , " :h" )
2235 local filename = vim .fn .fnamemodify (filepath , " :t" )
@@ -43,100 +56,31 @@ local function get_git_lines_changed(filepath)
4356 return nil
4457end
4558
46- --- Send heartbeat
47- function M . send_heartbeat ( opts )
59+ --- Send heartbeat to backend
60+ local function send_to_backend ( filepath , lines_changed , opts )
4861 opts = opts or {}
4962
5063 local bufnr = vim .api .nvim_get_current_buf ()
51- local filepath = vim .fn .expand (" %:p" )
52-
53- -- Validation
54- if filepath == " " or not vim .api .nvim_buf_is_valid (bufnr ) then
55- return
56- end
57-
58- if vim .bo [bufnr ].buftype ~= " " then
59- return
60- end
61-
62- local filetype = vim .bo [bufnr ].filetype
63- local skip_fts = { " NvimTree" , " neo-tree" , " dashboard" , " help" , " qf" , " TelescopePrompt" , " oil" , " noice" , " notify" }
64- if vim .tbl_contains (skip_fts , filetype ) then
64+ if not vim .api .nvim_buf_is_valid (bufnr ) then
6565 return
6666 end
6767
68- -- Cooldown check (skip for save events)
69- local now = os.time ()
70- local is_save = opts .is_save or false
71-
72- if not is_save then
73- local last_time = last_heartbeat [filepath ]
74- if last_time and (now - last_time ) < COOLDOWN_SECONDS then
75- return -- Too soon
76- end
77- end
78-
79- -- Update heartbeat time
80- last_heartbeat [filepath ] = now
81-
82- -- Calculate lines changed (DELTA, not total)
83- local lines_changed = 0
84- if is_save then
85- local current_diff = get_git_lines_changed (filepath )
86-
87- if current_diff ~= nil then
88- -- File is git-tracked, use delta tracking
89- local last_diff = last_git_diff [filepath ] or 0
90- local delta = current_diff - last_diff
91-
92- -- Only track positive deltas (new changes)
93- if delta > 0 then
94- lines_changed = delta
95- last_git_diff [filepath ] = current_diff
96- elseif current_diff == 0 and last_diff > 0 then
97- -- File was committed (git diff is now 0)
98- -- Reset tracking but don't send a heartbeat
99- last_git_diff [filepath ] = 0
100- return
101- else
102- -- No new changes since last save (delta <= 0)
103- return
104- end
105- else
106- -- File is NOT git-tracked (non-git file or new untracked file)
107- -- For non-git files, we can't use git diff, so we track file modifications
108- -- using Neovim's modified flag and a simple change detection
109-
110- -- Check if this is the first save or if file was modified
111- local last_modified = last_git_diff [filepath ]
112-
113- if not last_modified then
114- -- First save of this session, count it as activity but don't track lines
115- -- (we don't know what changed without git)
116- lines_changed = 0
117- last_git_diff [filepath ] = now -- Use timestamp instead of line count
118- elseif (now - last_modified ) > 60 then
119- -- More than 1 minute since last save, count as new activity
120- lines_changed = 0
121- last_git_diff [filepath ] = now
122- else
123- -- Saved recently, skip to avoid spam
124- return
125- end
126- end
68+ local line_count = vim .api .nvim_buf_line_count (bufnr )
69+ local language = vim .bo [bufnr ].filetype
70+ if language == " " then
71+ language = " unknown"
12772 end
12873
129- local line_count = vim .api .nvim_buf_line_count (bufnr )
130- local language = filetype ~= " " and filetype or " unknown"
74+ local heartbeat_type = opts .heartbeat_type or " presence"
13175
132- -- Send to backend
13376 local cmd = string.format (
134- " %s track --file %s --lang %s --lines %d --total %d" ,
77+ " %s track --file %s --lang %s --lines %d --total %d --type %s " ,
13578 opts .codeme_bin or " codeme" ,
13679 vim .fn .shellescape (filepath ),
13780 language ,
13881 lines_changed ,
139- line_count
82+ line_count ,
83+ heartbeat_type
14084 )
14185
14286 vim .fn .jobstart (cmd , {
@@ -145,29 +89,211 @@ function M.send_heartbeat(opts)
14589 if opts .verbose then
14690 if code == 0 then
14791 vim .notify (
148- string.format (" CodeMe: ✓ %s (%d lines)" , vim .fn .fnamemodify (filepath , " :t" ), lines_changed ),
149- vim .log .levels .INFO
92+ string.format (
93+ " codeme: ✓ %s (%d lines) [%s]" ,
94+ vim .fn .fnamemodify (filepath , " :t" ),
95+ lines_changed ,
96+ heartbeat_type
97+ ),
98+ vim .log .levels .info
15099 )
151100 else
152101 vim .notify (
153- string.format (" CodeMe : ✗ Failed to track %s" , vim .fn .fnamemodify (filepath , " :t" )),
154- vim .log .levels .WARN
102+ string.format (" codeme : ✗ failed to track %s" , vim .fn .fnamemodify (filepath , " :t" )),
103+ vim .log .levels .warn
155104 )
156105 end
157106 end
158107 end ,
159108 })
160109end
161110
111+ local function calculate_lines_changed (filepath )
112+ local current_diff = get_git_lines_changed (filepath )
113+
114+ if current_diff ~= nil then
115+ -- GIT-TRACKED FILE
116+ local last_diff = last_git_diff_lines [filepath ] or 0
117+ local delta = current_diff - last_diff
118+
119+ -- Update baseline
120+ last_git_diff_lines [filepath ] = current_diff
121+
122+ if delta > 0 then
123+ return delta , " save" -- productivity signal
124+ end
125+
126+ -- File was reset (commit, checkout, stash, etc.)
127+ -- Still send heartbeat with 0 lines to preserve time tracking
128+ if current_diff == 0 and last_diff > 0 then
129+ return 0 , " save_reset" -- still counts as time spent
130+ end
131+
132+ -- No new changes since last save (delta <= 0)
133+ -- Don't send redundant heartbeat
134+ return nil , nil
135+ else
136+ -- NON-GIT-TRACKED FILE (with NON_GIT_COOLDOWN throttling)
137+ local now = os.time ()
138+ local last_save = last_non_git_save_time [filepath ]
139+
140+ if not last_save then
141+ -- First save of this non-git file
142+ last_non_git_save_time [filepath ] = now
143+ return 0 , " save_new" -- new file, track time only
144+ elseif (now - last_save ) < NON_GIT_COOLDOWN then
145+ -- Too soon since last save, skip to prevent spam
146+ -- This implements the NON_GIT_COOLDOWN throttling
147+ return nil , nil
148+ else
149+ -- Enough time passed, send heartbeat and reset timer
150+ last_non_git_save_time [filepath ] = now
151+ return 0 , " save_untracked" -- untracked file, time only
152+ end
153+ end
154+ end
155+
156+ function M .send_heartbeat (opts )
157+ opts = opts or {}
158+
159+ local bufnr = vim .api .nvim_get_current_buf ()
160+ local filepath = vim .fn .expand (" %:p" )
161+
162+ if not should_track (bufnr , filepath ) then
163+ return
164+ end
165+
166+ local now = os.time ()
167+ local is_save = opts .is_save or false
168+
169+ if not is_save and not opts .is_periodic then
170+ local last_time = last_heartbeat_time [filepath ]
171+ if last_time and (now - last_time ) < COOLDOWN_SAME_FILE then
172+ return
173+ end
174+ end
175+
176+ local lines_changed = 0
177+ local heartbeat_type = " presence" -- default
178+
179+ if is_save then
180+ local delta , hb_type = calculate_lines_changed (filepath )
181+ if delta == nil then
182+ -- No new changes, skip this heartbeat
183+ return
184+ end
185+ lines_changed = delta
186+ heartbeat_type = hb_type or " save"
187+ else
188+ lines_changed = 0
189+ heartbeat_type = opts .heartbeat_type or " presence"
190+ if opts .is_periodic then
191+ heartbeat_type = " periodic"
192+ end
193+ end
194+
195+ send_to_backend (filepath , lines_changed , {
196+ codeme_bin = opts .codeme_bin ,
197+ verbose = opts .verbose ,
198+ heartbeat_type = heartbeat_type ,
199+ })
200+
201+ last_heartbeat_time [filepath ] = now
202+ end
203+
204+ local function start_periodic_heartbeat ()
205+ -- Stop existing timer if any
206+ if presence_timer then
207+ vim .fn .timer_stop (presence_timer )
208+ end
209+
210+ -- Create new timer: fire every PRESENCE_INTERVAL seconds
211+ presence_timer = vim .fn .timer_start (PRESENCE_INTERVAL * 1000 , function ()
212+ if active_file and active_file ~= " " then
213+ vim .api .nvim_set_current_buf (vim .fn .bufnr (active_file ))
214+ -- Send periodic heartbeat while user is active
215+ M .send_heartbeat ({
216+ is_save = false ,
217+ is_periodic = true ,
218+ })
219+ end
220+ end , { repeats = - 1 }) -- Repeat forever
221+ end
222+
223+ --- Wire into BufEnter: file opened or switched
224+ vim .api .nvim_create_autocmd (" BufEnter" , {
225+ group = vim .api .nvim_create_augroup (" CodeMeTracker" , { clear = true }),
226+ callback = function ()
227+ local bufnr = vim .api .nvim_get_current_buf ()
228+ local filepath = vim .fn .expand (" %:p" )
229+
230+ if should_track (bufnr , filepath ) then
231+ -- Track active file for periodic heartbeats
232+ active_file = filepath
233+
234+ -- Send immediate presence heartbeat on file change (Layer 1)
235+ M .send_heartbeat ({
236+ is_save = false ,
237+ })
238+
239+ -- Start/restart periodic timer
240+ start_periodic_heartbeat ()
241+ end
242+ end ,
243+ })
244+
245+ --- Wire into BufWritePost: file saved
246+ vim .api .nvim_create_autocmd (" BufWritePost" , {
247+ group = vim .api .nvim_create_augroup (" CodeMeTracker" , { clear = true }),
248+ callback = function ()
249+ local bufnr = vim .api .nvim_get_current_buf ()
250+ local filepath = vim .fn .expand (" %:p" )
251+
252+ if should_track (bufnr , filepath ) then
253+ -- Send save heartbeat with productivity metadata (Layer 3)
254+ M .send_heartbeat ({
255+ is_save = true ,
256+ })
257+ end
258+ end ,
259+ })
260+
261+ --- Wire into BufLeave: user switched away
262+ vim .api .nvim_create_autocmd (" BufLeave" , {
263+ group = vim .api .nvim_create_augroup (" CodeMeTracker" , { clear = true }),
264+ callback = function ()
265+ if presence_timer then
266+ vim .fn .timer_stop (presence_timer )
267+ presence_timer = nil
268+ end
269+ active_file = nil
270+ end ,
271+ })
272+
162273function M .clear_state ()
163- last_heartbeat = {}
164- last_git_diff = {}
274+ last_heartbeat_time = {}
275+ last_git_diff_lines = {}
276+ last_non_git_save_time = {}
277+ active_file = nil
278+
279+ if presence_timer then
280+ vim .fn .timer_stop (presence_timer )
281+ presence_timer = nil
282+ end
165283end
166284
285+ --- Get current tracking state (for debugging)
167286function M .get_state ()
168287 return {
169- heartbeats = vim .deepcopy (last_heartbeat ),
170- git_diffs = vim .deepcopy (last_git_diff ),
288+ last_heartbeat_time = vim .deepcopy (last_heartbeat_time ),
289+ last_git_diff_lines = vim .deepcopy (last_git_diff_lines ),
290+ last_non_git_save_time = vim .deepcopy (last_non_git_save_time ),
291+ active_file = active_file ,
292+ config = {
293+ PRESENCE_INTERVAL = PRESENCE_INTERVAL ,
294+ COOLDOWN_SAME_FILE = COOLDOWN_SAME_FILE ,
295+ NON_GIT_COOLDOWN = NON_GIT_COOLDOWN ,
296+ },
171297 }
172298end
173299
0 commit comments