Skip to content

Commit 8e35d11

Browse files
committed
fix: send better the heartbeat of buffer actions
1 parent 82f8490 commit 8e35d11

File tree

2 files changed

+229
-107
lines changed

2 files changed

+229
-107
lines changed

lua/codeme/profile.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ local function tab_today()
224224
end)
225225

226226
local tbl = { { "Language", "Time", "Lines", "%" } }
227-
for i = 1, math.min(5, #items) do
227+
for i = 1, math.min(10, #items) do
228228
local it = items[i]
229229
local pct = total > 0 and math.floor(it.time / total * 100) or 0
230230
tbl[#tbl + 1] = { it.name, fmt_time(it.time), fmt_num(it.lines), progress(pct, 15) .. " " .. pct .. "%" }

lua/codeme/tracker.lua

Lines changed: 228 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,38 @@
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

113
local 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+
-- Create augroup once (clear existing autocmds from previous loads)
6+
local augroup = vim.api.nvim_create_augroup("CodeMeTracker", { clear = true })
7+
8+
local last_heartbeat_time = {}
9+
local last_git_diff_lines = {}
10+
local last_non_git_save_time = {}
11+
local active_file = nil
12+
local presence_timer = nil
13+
local PRESENCE_INTERVAL = 120
14+
local COOLDOWN_SAME_FILE = 60
15+
local NON_GIT_COOLDOWN = 60
16+
17+
local function should_track(bufnr, filepath)
18+
if filepath == "" or not vim.api.nvim_buf_is_valid(bufnr) then
19+
return false
20+
end
21+
22+
if vim.bo[bufnr].buftype ~= "" then
23+
return false
24+
end
25+
26+
local filetype = vim.bo[bufnr].filetype
27+
local skip_fts = { "NvimTree", "neo-tree", "dashboard", "help", "qf", "TelescopePrompt", "oil", "noice", "notify" }
28+
29+
if vim.tbl_contains(skip_fts, filetype) then
30+
return false
31+
end
32+
33+
return true
34+
end
1835

19-
--- Calculate git diff for file (returns lines changed, or nil if not in git)
2036
local function get_git_lines_changed(filepath)
2137
local dir = vim.fn.fnamemodify(filepath, ":h")
2238
local filename = vim.fn.fnamemodify(filepath, ":t")
@@ -43,114 +59,48 @@ local function get_git_lines_changed(filepath)
4359
return nil
4460
end
4561

46-
--- Send heartbeat
47-
function M.send_heartbeat(opts)
62+
local function send_to_backend(filepath, lines_changed, opts)
4863
opts = opts or {}
4964

50-
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
65+
local bufnr = vim.fn.bufnr(filepath)
66+
if bufnr == -1 or not vim.api.nvim_buf_is_valid(bufnr) then
6567
return
6668
end
6769

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
127-
end
128-
12970
local line_count = vim.api.nvim_buf_line_count(bufnr)
130-
local language = filetype ~= "" and filetype or "unknown"
71+
local language = vim.bo[bufnr].filetype ~= "" and vim.bo[bufnr].filetype or "unknown"
13172

132-
-- Send to backend
133-
local cmd = string.format(
134-
"%s track --file %s --lang %s --lines %d --total %d",
73+
local cmd = {
13574
opts.codeme_bin or "codeme",
136-
vim.fn.shellescape(filepath),
75+
"track",
76+
"--file",
77+
filepath,
78+
"--lang",
13779
language,
138-
lines_changed,
139-
line_count
140-
)
80+
"--lines",
81+
tostring(lines_changed),
82+
"--total",
83+
tostring(line_count),
84+
}
14185

14286
vim.fn.jobstart(cmd, {
14387
detach = true,
14488
on_exit = function(_, code)
14589
if opts.verbose then
90+
local heartbeat_type = opts.heartbeat_type or "unknown"
14691
if code == 0 then
14792
vim.notify(
148-
string.format("CodeMe: ✓ %s (%d lines)", vim.fn.fnamemodify(filepath, ":t"), lines_changed),
93+
string.format(
94+
"codeme: ✓ %s (%d lines) [%s]",
95+
vim.fn.fnamemodify(filepath, ":t"),
96+
lines_changed,
97+
heartbeat_type
98+
),
14999
vim.log.levels.INFO
150100
)
151101
else
152102
vim.notify(
153-
string.format("CodeMe: ✗ Failed to track %s", vim.fn.fnamemodify(filepath, ":t")),
103+
string.format("codeme: ✗ failed to track %s", vim.fn.fnamemodify(filepath, ":t")),
154104
vim.log.levels.WARN
155105
)
156106
end
@@ -159,15 +109,187 @@ function M.send_heartbeat(opts)
159109
})
160110
end
161111

112+
local function calculate_lines_changed(filepath)
113+
local current_diff = get_git_lines_changed(filepath)
114+
115+
if current_diff ~= nil then
116+
-- GIT-TRACKED FILE
117+
local last_diff = last_git_diff_lines[filepath] or 0
118+
local delta = current_diff - last_diff
119+
120+
-- Update baseline
121+
last_git_diff_lines[filepath] = current_diff
122+
123+
if delta > 0 then
124+
return delta, "save" -- productivity signal
125+
end
126+
127+
-- File was reset (commit, checkout, stash, etc.)
128+
-- Still send heartbeat with 0 lines to preserve time tracking
129+
if current_diff == 0 and last_diff > 0 then
130+
return 0, "save_reset" -- still counts as time spent
131+
end
132+
133+
-- No new changes since last save (delta <= 0)
134+
-- Don't send redundant heartbeat
135+
return nil, nil
136+
else
137+
-- NON-GIT-TRACKED FILE (with NON_GIT_COOLDOWN throttling)
138+
local now = os.time()
139+
local last_save = last_non_git_save_time[filepath]
140+
141+
if not last_save then
142+
-- First save of this non-git file
143+
last_non_git_save_time[filepath] = now
144+
return 0, "save_new" -- new file, track time only
145+
elseif (now - last_save) < NON_GIT_COOLDOWN then
146+
-- Too soon since last save, skip to prevent spam
147+
-- This implements the NON_GIT_COOLDOWN throttling
148+
return nil, nil
149+
else
150+
-- Enough time passed, send heartbeat and reset timer
151+
last_non_git_save_time[filepath] = now
152+
return 0, "save_untracked" -- untracked file, time only
153+
end
154+
end
155+
end
156+
157+
function M.send_heartbeat(opts)
158+
opts = opts or {}
159+
local bufnr = opts.bufnr or vim.api.nvim_get_current_buf()
160+
local filepath = opts.filepath or 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
177+
local heartbeat_type
178+
179+
if is_save then
180+
local delta, hb_type = calculate_lines_changed(filepath)
181+
if delta == nil then
182+
return
183+
end
184+
lines_changed = delta
185+
heartbeat_type = hb_type or "save"
186+
else
187+
-- Presence / periodic heartbeat: time tracking only
188+
lines_changed = 0
189+
heartbeat_type = opts.is_periodic and "periodic" or (opts.heartbeat_type or "presence")
190+
end
191+
192+
send_to_backend(filepath, lines_changed, {
193+
codeme_bin = opts.codeme_bin,
194+
verbose = opts.verbose,
195+
heartbeat_type = heartbeat_type,
196+
})
197+
198+
last_heartbeat_time[filepath] = now
199+
end
200+
201+
local function start_periodic_heartbeat()
202+
-- Stop existing timer if any
203+
if presence_timer then
204+
vim.fn.timer_stop(presence_timer)
205+
end
206+
207+
-- Create new timer: fire every PRESENCE_INTERVAL seconds
208+
presence_timer = vim.fn.timer_start(PRESENCE_INTERVAL * 1000, function()
209+
if active_file then
210+
M.send_heartbeat({
211+
is_save = false,
212+
is_periodic = true,
213+
filepath = active_file,
214+
})
215+
end
216+
end, { repeats = -1 })
217+
end
218+
219+
--- Wire into BufEnter: file opened or switched
220+
vim.api.nvim_create_autocmd("BufEnter", {
221+
group = augroup,
222+
callback = function()
223+
local bufnr = vim.api.nvim_get_current_buf()
224+
local filepath = vim.fn.expand("%:p")
225+
226+
if should_track(bufnr, filepath) then
227+
-- Track active file for periodic heartbeats
228+
active_file = filepath
229+
230+
-- Send immediate presence heartbeat on file change (Layer 1)
231+
M.send_heartbeat({
232+
is_save = false,
233+
})
234+
235+
-- Start/restart periodic timer
236+
start_periodic_heartbeat()
237+
end
238+
end,
239+
})
240+
241+
--- Wire into BufWritePost: file saved
242+
vim.api.nvim_create_autocmd("BufWritePost", {
243+
group = augroup,
244+
callback = function()
245+
local bufnr = vim.api.nvim_get_current_buf()
246+
local filepath = vim.fn.expand("%:p")
247+
248+
if should_track(bufnr, filepath) then
249+
-- Send save heartbeat with productivity metadata (Layer 3)
250+
M.send_heartbeat({
251+
is_save = true,
252+
})
253+
end
254+
end,
255+
})
256+
257+
--- Wire into BufLeave: user switched away
258+
vim.api.nvim_create_autocmd("BufLeave", {
259+
group = augroup,
260+
callback = function()
261+
if presence_timer then
262+
vim.fn.timer_stop(presence_timer)
263+
presence_timer = nil
264+
end
265+
active_file = nil
266+
end,
267+
})
268+
162269
function M.clear_state()
163-
last_heartbeat = {}
164-
last_git_diff = {}
270+
last_heartbeat_time = {}
271+
last_git_diff_lines = {}
272+
last_non_git_save_time = {}
273+
active_file = nil
274+
275+
if presence_timer then
276+
vim.fn.timer_stop(presence_timer)
277+
presence_timer = nil
278+
end
165279
end
166280

281+
--- Get current tracking state (for debugging)
167282
function M.get_state()
168283
return {
169-
heartbeats = vim.deepcopy(last_heartbeat),
170-
git_diffs = vim.deepcopy(last_git_diff),
284+
last_heartbeat_time = vim.deepcopy(last_heartbeat_time),
285+
last_git_diff_lines = vim.deepcopy(last_git_diff_lines),
286+
last_non_git_save_time = vim.deepcopy(last_non_git_save_time),
287+
active_file = active_file,
288+
config = {
289+
PRESENCE_INTERVAL = PRESENCE_INTERVAL,
290+
COOLDOWN_SAME_FILE = COOLDOWN_SAME_FILE,
291+
NON_GIT_COOLDOWN = NON_GIT_COOLDOWN,
292+
},
171293
}
172294
end
173295

0 commit comments

Comments
 (0)