@@ -3,35 +3,35 @@ local renderer = require("codeme.ui.renderer")
33
44local M = {}
55
6- function M .render (stats , width )
7- stats = require (" codeme.util" ).apply_privacy_mask (stats )
6+ function M .render (stats , width , height )
87 local lines = {}
98 local today = stats .today or {}
109 local today_sessions = today .sessions or {}
1110 local total_time = today .total_time or 0
1211 local focus_score = today .focus_score or 0
1312
14- -- Header
15- local today_date = os.date (" %A, %B %d" )
16-
13+ -- ── Header ─────────────────────────────────────────────────────────
1714 table.insert (lines , {})
1815 table.insert (lines , {
19- { " ☀️ Activity Timeline" , " exgreen" },
20- { string.rep (" " , 30 ), " commentfg" },
16+ { " ☀️ Today's Activity" , " exgreen" },
17+ { " — " , " commentfg" },
18+ { os.date (" %A, %B %d" ), " exyellow" },
19+ { " — " , " commentfg" },
20+ { " Total: " , " commentfg" },
2121 { util .format_duration (total_time ), " exgreen" },
22- { " • " , " commentfg" },
23- { today_date , " exyellow" },
2422 })
2523 table.insert (lines , {})
2624
2725 if total_time == 0 then
28- table.insert (lines , { { " No coding activity yet today" , " commentfg" } })
29- table.insert (lines , { { " Start working to track your productivity!" , " commentfg" } })
26+ table.insert (
27+ lines ,
28+ { { " No coding activity yet today. Start working to track your productivity!" , " commentfg" } }
29+ )
3030 table.insert (lines , {})
3131 return lines
3232 end
3333
34- -- Hourly Activity Sparkline (Condensed)
34+ -- ── Hourly sparkline ───────────────────────────────────────────────
3535 local hourly_raw = {}
3636 for i = 1 , 24 do
3737 hourly_raw [i ] = 0
@@ -41,151 +41,227 @@ function M.render(stats, width)
4141 hourly_raw [item .hour + 1 ] = item .duration or 0
4242 end
4343 end
44- if # today_sessions > 0 then
45- local hist_line = { { " Activity Map: " , " commentfg" } }
46- local hist_segs = renderer .histogram (hourly_raw , 0 , 1 , " exblue" )
47- for _ , s in ipairs (hist_segs ) do
48- table.insert (hist_line , s )
49- end
50- table.insert (hist_line , { " Focus: " .. focus_score .. " %" , focus_score >= 70 and " exgreen" or " exyellow" })
51- table.insert (lines , hist_line )
52- table.insert (lines , { { " 00 04 08 12 16 20 23" , " commentfg" } })
5344
54- -- Peak Block Insight (Restored)
45+ local hist_line = { { " " , " commentfg" } }
46+ for _ , s in ipairs (renderer .histogram (hourly_raw , 0 , 1 , " exblue" )) do
47+ table.insert (hist_line , s )
48+ end
49+ table.insert (lines , hist_line )
50+ table.insert (lines , { { " 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23" , " commentfg" } })
51+
52+ -- Focus score inline
53+ if focus_score > 0 then
54+ local focus_hl = focus_score >= 70 and " exgreen" or focus_score >= 40 and " exyellow" or " exred"
55+ table.insert (lines , {
56+ { " Focus score: " , " commentfg" },
57+ { tostring (focus_score ) .. " %" , focus_hl },
58+ { " Peak block: " , " commentfg" },
59+ })
60+ -- find peak 4-hour block
61+ local block_labels = {
62+ { label = " early morning" , start_h = 4 , end_h = 7 },
63+ { label = " morning" , start_h = 8 , end_h = 11 },
64+ { label = " afternoon" , start_h = 12 , end_h = 15 },
65+ { label = " evening" , start_h = 16 , end_h = 19 },
66+ { label = " night" , start_h = 20 , end_h = 23 },
67+ { label = " late night" , start_h = 0 , end_h = 3 },
68+ }
5569 local total_hourly = 0
56- for _ , dur in pairs (hourly_raw ) do
57- total_hourly = total_hourly + dur
70+ for _ , v in ipairs (hourly_raw ) do
71+ total_hourly = total_hourly + v
5872 end
5973 if total_hourly > 0 then
60- local blocks = {
61- { label = " early morning 🌅" , start_h = 4 , end_h = 7 },
62- { label = " morning ☕" , start_h = 8 , end_h = 11 },
63- { label = " afternoon ☀️" , start_h = 12 , end_h = 15 },
64- { label = " evening 🌆" , start_h = 16 , end_h = 19 },
65- { label = " night 🌃" , start_h = 20 , end_h = 23 },
66- { label = " late night 🌙" , start_h = 0 , end_h = 3 },
67- }
68- local peak_block , max_pct = nil , 0
69- for _ , block in ipairs (blocks ) do
70- local block_time = 0
71- for h = block .start_h , block .end_h do
72- block_time = block_time + (hourly_raw [h + 1 ] or 0 )
74+ local peak_block , max_pct = " —" , 0
75+ for _ , b in ipairs (block_labels ) do
76+ local bt = 0
77+ for h = b .start_h , b .end_h do
78+ bt = bt + (hourly_raw [h + 1 ] or 0 )
7379 end
74- local pct = math.floor ((block_time / total_hourly ) * 100 )
80+ local pct = math.floor ((bt / total_hourly ) * 100 )
7581 if pct > max_pct then
7682 max_pct = pct
77- peak_block = block .label
83+ peak_block = b .label
7884 end
7985 end
80- if peak_block then
81- table.insert (lines , {
82- { " 💡 You're most productive in the " , " commentfg" },
83- { peak_block , " exgreen" },
84- { " today." , " commentfg" },
85- })
86- end
86+ -- patch the last inserted line to complete it
87+ local last = lines [# lines ]
88+ table.insert (last , { peak_block , " normal" })
8789 end
90+ end
91+ table.insert (lines , {})
92+
93+ -- ── Sessions ───────────────────────────────────────────────────────
94+ if # today_sessions == 0 then
95+ table.insert (lines , { { " No sessions recorded yet." , " commentfg" } })
8896 table.insert (lines , {})
97+ return lines
98+ end
8999
90- -- Narrative Story (Restored)
91- local first_session = today_sessions [1 ]
92- local last_session = today_sessions [# today_sessions ]
93- if first_session .start_time and last_session .end_time then
94- local start_hour = tonumber (first_session .start_time :sub (12 , 13 ))
95- local end_hour = tonumber (last_session .end_time :sub (12 , 13 ))
96- local function get_period (hour )
97- if hour >= 5 and hour < 12 then
98- return " morning" , " 🌅"
99- elseif hour >= 12 and hour < 17 then
100- return " afternoon" , " ☀️"
101- elseif hour >= 17 and hour < 21 then
102- return " evening" , " 🌆"
103- else
104- return " late night" , " 🦉"
100+ -- Compute per-session duration defensively.
101+ -- Backend may send duration directly (seconds) OR start+end timestamps.
102+ -- We never accumulate across sessions — each session is independent.
103+ local function session_duration (s )
104+ -- Prefer explicit duration field if it looks sane (< 24h = 86400s)
105+ if s .duration and s .duration > 0 and s .duration < 86400 then
106+ return s .duration
107+ end
108+ -- Fall back: parse ISO start/end strings
109+ if s .start_time and s .end_time then
110+ local function parse_t (iso )
111+ local h , m , sec = iso :match (" T?(%d%d):(%d%d):(%d%d)" )
112+ if h then
113+ return tonumber (h ) * 3600 + tonumber (m ) * 60 + tonumber (sec )
114+ end
115+ -- short "HH:MM" form
116+ h , m = iso :match (" (%d%d):(%d%d)$" )
117+ if h then
118+ return tonumber (h ) * 3600 + tonumber (m ) * 60
105119 end
120+ return nil
106121 end
107- local start_period , start_icon = get_period (start_hour )
108- local end_period , end_icon = get_period (end_hour )
109- local story = {
110- { " 📖 " , " exgreen" },
111- { " You started in the " , " commentfg" },
112- { start_period .. " " .. start_icon , " normal" },
113- }
114- if start_period ~= end_period then
115- table.insert (story , { " and continued into the " , " commentfg" })
116- table.insert (story , { end_period .. " " .. end_icon , " normal" })
122+ local st = parse_t (s .start_time )
123+ local et = parse_t (s .end_time )
124+ if st and et then
125+ local diff = et - st
126+ -- Handle midnight crossover
127+ if diff < 0 then
128+ diff = diff + 86400
129+ end
130+ if diff > 0 and diff < 86400 then
131+ return diff
132+ end
117133 end
118- table.insert (story , { " ." , " commentfg" })
119- table.insert (lines , story )
120- table.insert (lines , {})
121134 end
135+ return 0
122136 end
123137
124- -- Visual Timeline
125- table.insert (lines , { { " ⏰ Sessions" , " exgreen" } })
138+ -- Clamp sum: sessions should never exceed total_time reported by backend.
139+ -- If they do, it means durations overlap or are cumulative — we scale them.
140+ local raw_sum = 0
141+ local durations = {}
142+ for i , s in ipairs (today_sessions ) do
143+ durations [i ] = session_duration (s )
144+ raw_sum = raw_sum + durations [i ]
145+ end
146+ local scale = (raw_sum > 0 and raw_sum > total_time * 1.05 ) and (total_time / raw_sum ) or 1.0
147+
148+ -- Find longest session index for star marker
149+ local max_dur , max_idx = 0 , 1
150+ for i , d in ipairs (durations ) do
151+ local scaled = math.floor (d * scale )
152+ if scaled > max_dur then
153+ max_dur = scaled
154+ max_idx = i
155+ end
156+ end
157+
158+ -- Section header
159+ table.insert (lines , {
160+ { " ⏰ Sessions" , " exgreen" },
161+ { " (" .. # today_sessions .. " total)" , " commentfg" },
162+ })
126163 table.insert (lines , {})
127164
128- if # today_sessions == 0 then
129- table.insert (lines , { { " No sessions recorded yet" , " commentfg" } })
130- table.insert (lines , {})
131- else
132- local max_dur = 0
133- for _ , s in ipairs (today_sessions ) do
134- if (s .duration or 0 ) > max_dur then
135- max_dur = s .duration
136- end
165+ -- Bar width for mini per-session bar (adapts to window)
166+ local bar_w = math.max (8 , math.min (20 , math.floor ((width - 60 ) / 2 )))
167+
168+ for i , session in ipairs (today_sessions ) do
169+ local dur = math.floor (durations [i ] * scale )
170+ local is_last = (i == # today_sessions )
171+ local is_peak = (i == max_idx ) and (# today_sessions > 1 )
172+
173+ -- Time range string
174+ local t_start = " "
175+ local t_end = " "
176+ if session .start_time then
177+ t_start = session .start_time :match (" T?(%d%d:%d%d)" ) or session .start_time :sub (1 , 5 )
178+ end
179+ if session .end_time then
180+ t_end = session .end_time :match (" T?(%d%d:%d%d)" ) or session .end_time :sub (1 , 5 )
181+ end
182+ local time_range = (t_start ~= " " and t_end ~= " " ) and (t_start .. " →" .. t_end )
183+ or (t_start ~= " " and t_start or " ??:??" )
184+
185+ -- Mini progress bar proportional to total_time
186+ local pct = total_time > 0 and math.floor ((dur / total_time ) * 100 ) or 0
187+ local bar_hl = is_peak and " exyellow" or " exblue"
188+
189+ -- Tree connector
190+ local connector = is_last and " ╰─" or " ├─"
191+
192+ -- Projects / languages (compact)
193+ local proj_str = util .top_items (session .projects or {}, 2 )
194+ local lang_str = util .top_items (session .languages or {}, 2 )
195+ local meta = " "
196+ if proj_str ~= " " and lang_str ~= " " then
197+ meta = proj_str .. " [" .. lang_str .. " ]"
198+ elseif proj_str ~= " " then
199+ meta = proj_str
200+ elseif lang_str ~= " " then
201+ meta = " [" .. lang_str .. " ]"
137202 end
138203
139- for i , session in ipairs (today_sessions ) do
140- local time_str = session .start_time and session .start_time :sub (12 , 16 ) or " ??:??"
141- local dur_str = util .format_duration (session .duration or 0 )
142- local is_peak = (session .duration or 0 ) == max_dur and # today_sessions > 1
143-
144- -- Session entry
145- local line = {
146- { " " .. time_str .. " " , " commentfg" },
147- { i == # today_sessions and " ╰─ " or " ├─ " , " exblue" },
148- { dur_str , is_peak and " exyellow" or " normal" },
149- { is_peak and " ⭐ " or " " , " exyellow" },
150- { util .top_items (session .projects or {}, 2 ), " exgreen" },
151- { " (" , " commentfg" },
152- { util .top_items (session .languages or {}, 3 ), " excyan" },
153- { " )" , " commentfg" },
154- }
155- table.insert (lines , line )
156-
157- -- Break indicator
158- if session .break_after and session .break_after > 300 then
159- local icon = session .break_after < 1800 and " ☕" or " 🍽️"
204+ -- Session line
205+ local sess_line = {
206+ { " " .. connector .. " " , " commentfg" },
207+ { time_range , " commentfg" },
208+ { " " , " normal" },
209+ { util .format_duration (dur ), is_peak and " exyellow" or " normal" },
210+ { is_peak and " ⭐" or " " , " exyellow" },
211+ { " " , " normal" },
212+ }
213+ -- Mini bar
214+ local filled = math.floor (pct / 100 * bar_w )
215+ table.insert (sess_line , { string.rep (" ▪" , filled ), bar_hl })
216+ table.insert (sess_line , { string.rep (" ·" , bar_w - filled ), " commentfg" })
217+ table.insert (sess_line , { string.format (" %3d%% " , pct ), " commentfg" })
218+ if meta ~= " " then
219+ table.insert (sess_line , { meta , " exgreen" })
220+ end
221+ table.insert (lines , sess_line )
222+
223+ -- Break gap between sessions (only show if meaningful > 5 min)
224+ if not is_last then
225+ local break_sec = session .break_after or 0
226+ if break_sec >= 300 then
227+ local break_icon = break_sec >= 3600 and " 🍽️ " or " ☕ "
160228 table.insert (lines , {
161- { " " , " commentfg" },
162- { " │ " , " exblue " },
163- { icon .. " " .. util .format_duration (session . break_after ) .. " break" , " commentfg" },
229+ { " │ " , " commentfg" },
230+ { break_icon , " normal " },
231+ { util .format_duration (break_sec ) .. " break" , " commentfg" },
164232 })
165- elseif i < # today_sessions then
166- table.insert (lines , { { " " , " commentfg " }, { " │" , " exblue " } })
233+ else
234+ table.insert (lines , { { " │" , " commentfg " } })
167235 end
168236 end
169- table.insert (lines , {})
170237 end
238+ table.insert (lines , {})
171239
172- -- FileType Activity Table (Adaptive)
240+ -- ── Summary bar (total confirmed) ─────────────────────────────────
241+ table.insert (lines , {
242+ { " Total coded today: " , " commentfg" },
243+ { util .format_duration (total_time ), " exgreen" },
244+ { " across " , " commentfg" },
245+ { tostring (# today_sessions ), " normal" },
246+ { " session" .. (# today_sessions == 1 and " " or " s" ), " commentfg" },
247+ })
248+ table.insert (lines , {})
249+
250+ -- ── Language breakdown table ───────────────────────────────────────
173251 local languages = today .languages or {}
174252 if # languages > 0 then
175- table.insert (lines , { { " FileType breakdown " , " exgreen" } })
253+ table.insert (lines , { { " 📝 FileType Breakdown " , " exgreen" } })
176254 table.insert (lines , {})
177-
178- local tblLang = { { " FileType" , " Time" , " Lines" , " Share" } }
255+ local tbl = { { " FileType" , " Time" , " Lines" , " Share" } }
179256 for _ , lang in ipairs (languages ) do
180- table.insert (tblLang , {
257+ table.insert (tbl , {
181258 lang .name ,
182259 util .format_duration (lang .time ),
183260 util .format_number (lang .lines ),
184261 string.format (" %.1f%%" , lang .percent_total or 0 ),
185262 })
186263 end
187-
188- for _ , l in ipairs (renderer .table (tblLang , width - 10 )) do
264+ for _ , l in ipairs (renderer .table (tbl , width - 10 )) do
189265 table.insert (lines , l )
190266 end
191267 table.insert (lines , {})
0 commit comments