fuso 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +242 -0
- data/assets/screenshot_idle.png +0 -0
- data/assets/screenshot_report.png +0 -0
- data/assets/screenshot_tracking.png +0 -0
- data/exe/fuso +90 -0
- data/lib/fuso/app.rb +470 -0
- data/lib/fuso/config.rb +117 -0
- data/lib/fuso/heartbeat.rb +59 -0
- data/lib/fuso/session.rb +188 -0
- data/lib/fuso/styles.rb +181 -0
- data/lib/fuso/version.rb +5 -0
- data/lib/fuso/views/main_view.rb +115 -0
- data/lib/fuso/views/report_view.rb +239 -0
- data/lib/fuso/views/settings_view.rb +107 -0
- data/lib/fuso.rb +4 -0
- metadata +117 -0
data/lib/fuso/app.rb
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bubbletea"
|
|
4
|
+
require "lipgloss"
|
|
5
|
+
require "date"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
require_relative "config"
|
|
9
|
+
require_relative "session"
|
|
10
|
+
require_relative "styles"
|
|
11
|
+
require_relative "views/main_view"
|
|
12
|
+
require_relative "views/settings_view"
|
|
13
|
+
require_relative "views/report_view"
|
|
14
|
+
require_relative "heartbeat"
|
|
15
|
+
|
|
16
|
+
module Fuso
|
|
17
|
+
class TickMessage; end
|
|
18
|
+
|
|
19
|
+
class App
|
|
20
|
+
include Bubbletea::Model
|
|
21
|
+
|
|
22
|
+
attr_reader :config, :session
|
|
23
|
+
attr_reader :current_project, :current_category
|
|
24
|
+
attr_reader :active_project, :active_category
|
|
25
|
+
attr_accessor :settings_focus, :settings_index, :settings_input_mode
|
|
26
|
+
attr_accessor :settings_input_buffer, :settings_message
|
|
27
|
+
attr_accessor :report_mode, :report_date
|
|
28
|
+
attr_reader :toast
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@config = Config.load
|
|
32
|
+
@session = Session.load
|
|
33
|
+
|
|
34
|
+
@current_project = @config.default_project
|
|
35
|
+
@current_category = @config.default_category
|
|
36
|
+
|
|
37
|
+
# Timer state
|
|
38
|
+
@tracking = false
|
|
39
|
+
@active_project = nil
|
|
40
|
+
@active_category = nil
|
|
41
|
+
@timer_start = nil
|
|
42
|
+
@timer_accumulated = 0 # seconds accumulated before pause
|
|
43
|
+
|
|
44
|
+
# Screen state
|
|
45
|
+
@screen = :main
|
|
46
|
+
|
|
47
|
+
# Settings state
|
|
48
|
+
@settings_focus = :projects
|
|
49
|
+
@settings_index = 0
|
|
50
|
+
@settings_input_mode = false
|
|
51
|
+
@settings_input_buffer = ""
|
|
52
|
+
@settings_message = nil
|
|
53
|
+
|
|
54
|
+
# Report state
|
|
55
|
+
@report_mode = :weekly
|
|
56
|
+
@report_date = Date.today
|
|
57
|
+
|
|
58
|
+
# Toast notification
|
|
59
|
+
@toast = nil
|
|
60
|
+
@toast_expires_at = nil
|
|
61
|
+
|
|
62
|
+
# Views
|
|
63
|
+
@main_view = Views::MainView.new
|
|
64
|
+
@settings_view = Views::SettingsView.new
|
|
65
|
+
@report_view = Views::ReportView.new
|
|
66
|
+
|
|
67
|
+
# Heartbeat for suspend detection
|
|
68
|
+
@heartbeat = Heartbeat.new
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def init
|
|
72
|
+
[self, schedule_tick]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def update(message)
|
|
76
|
+
case message
|
|
77
|
+
when TickMessage
|
|
78
|
+
# Check for suspend via heartbeat staleness
|
|
79
|
+
if @tracking
|
|
80
|
+
if (stale_time = @heartbeat.pulse)
|
|
81
|
+
save_and_stop(at: stale_time)
|
|
82
|
+
show_toast("Timer stopped — system was suspended")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
# Clear expired toast
|
|
86
|
+
if @toast && @toast_expires_at && Time.now > @toast_expires_at
|
|
87
|
+
@toast = nil
|
|
88
|
+
@toast_expires_at = nil
|
|
89
|
+
end
|
|
90
|
+
# Timer ticks — just re-render
|
|
91
|
+
[self, schedule_tick]
|
|
92
|
+
when Bubbletea::WindowSizeMessage
|
|
93
|
+
@width = message.width
|
|
94
|
+
@height = message.height
|
|
95
|
+
[self, nil]
|
|
96
|
+
when Bubbletea::KeyMessage
|
|
97
|
+
handle_key(message)
|
|
98
|
+
else
|
|
99
|
+
[self, nil]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def view
|
|
104
|
+
content = case @screen
|
|
105
|
+
when :main
|
|
106
|
+
@main_view.render(self)
|
|
107
|
+
when :settings
|
|
108
|
+
@settings_view.render(self)
|
|
109
|
+
when :report
|
|
110
|
+
@report_view.render(self)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Append toast notification if present
|
|
114
|
+
if @toast
|
|
115
|
+
content += "\n\n" + Styles.toast.render(@toast)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if @width && @height
|
|
119
|
+
Lipgloss.place(@width, @height, :center, :center, content)
|
|
120
|
+
else
|
|
121
|
+
content
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Public state accessors
|
|
126
|
+
|
|
127
|
+
def tracking?
|
|
128
|
+
@tracking
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def active_elapsed_seconds
|
|
132
|
+
return 0 unless @tracking && @timer_start
|
|
133
|
+
|
|
134
|
+
@timer_accumulated + (Time.now - @timer_start).to_i
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def current_timer_seconds
|
|
138
|
+
return 0 unless @tracking && @timer_start
|
|
139
|
+
|
|
140
|
+
(Time.now - @timer_start).to_i
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def schedule_tick
|
|
146
|
+
Bubbletea.tick(0.5) { TickMessage.new }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def handle_key(message)
|
|
150
|
+
key = message.to_s
|
|
151
|
+
|
|
152
|
+
# Global quit
|
|
153
|
+
if key == "ctrl+c"
|
|
154
|
+
save_and_stop if @tracking
|
|
155
|
+
return [self, Bubbletea.quit]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
case @screen
|
|
159
|
+
when :main
|
|
160
|
+
handle_main_key(key)
|
|
161
|
+
when :settings
|
|
162
|
+
handle_settings_key(key)
|
|
163
|
+
when :report
|
|
164
|
+
handle_report_key(key)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# ── Main Screen ──
|
|
169
|
+
|
|
170
|
+
def handle_main_key(key)
|
|
171
|
+
case key
|
|
172
|
+
when "q"
|
|
173
|
+
save_and_stop if @tracking
|
|
174
|
+
[self, Bubbletea.quit]
|
|
175
|
+
when "s", " "
|
|
176
|
+
toggle_tracking
|
|
177
|
+
[self, nil]
|
|
178
|
+
when "1", "2", "3", "4", "5", "6", "7", "8", "9"
|
|
179
|
+
idx = key.to_i - 1
|
|
180
|
+
switch_category(idx) if idx < @config.categories.size
|
|
181
|
+
[self, nil]
|
|
182
|
+
when "p"
|
|
183
|
+
cycle_project
|
|
184
|
+
[self, nil]
|
|
185
|
+
when "m"
|
|
186
|
+
@screen = :settings
|
|
187
|
+
@settings_index = 0
|
|
188
|
+
@settings_focus = :projects
|
|
189
|
+
@settings_message = nil
|
|
190
|
+
[self, nil]
|
|
191
|
+
when "r"
|
|
192
|
+
@screen = :report
|
|
193
|
+
@report_date = Date.today
|
|
194
|
+
[self, nil]
|
|
195
|
+
else
|
|
196
|
+
[self, nil]
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def toggle_tracking
|
|
201
|
+
if @tracking
|
|
202
|
+
save_and_stop
|
|
203
|
+
else
|
|
204
|
+
start_tracking(@current_project, @current_category)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def start_tracking(project, category)
|
|
209
|
+
@heartbeat.clear
|
|
210
|
+
@tracking = true
|
|
211
|
+
@active_project = project
|
|
212
|
+
@active_category = category
|
|
213
|
+
@current_project = project
|
|
214
|
+
@current_category = category
|
|
215
|
+
@timer_start = Time.now
|
|
216
|
+
@timer_accumulated = 0
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def save_and_stop(at: nil)
|
|
220
|
+
return unless @tracking && @timer_start
|
|
221
|
+
|
|
222
|
+
end_time = at || Time.now
|
|
223
|
+
elapsed = (end_time - @timer_start).to_i
|
|
224
|
+
total = @timer_accumulated + elapsed
|
|
225
|
+
|
|
226
|
+
if total > 0
|
|
227
|
+
started_at = end_time - total
|
|
228
|
+
@session.record(
|
|
229
|
+
project: @active_project,
|
|
230
|
+
category: @active_category,
|
|
231
|
+
started_at: started_at,
|
|
232
|
+
ended_at: end_time,
|
|
233
|
+
elapsed_seconds: total
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
@tracking = false
|
|
238
|
+
@timer_start = nil
|
|
239
|
+
@timer_accumulated = 0
|
|
240
|
+
@active_project = nil
|
|
241
|
+
@active_category = nil
|
|
242
|
+
@heartbeat.clear
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def switch_category(idx)
|
|
246
|
+
new_category = @config.categories[idx]
|
|
247
|
+
return if new_category.nil?
|
|
248
|
+
return if @tracking && new_category == @active_category && @current_project == @active_project
|
|
249
|
+
|
|
250
|
+
if @tracking
|
|
251
|
+
# Save current session
|
|
252
|
+
elapsed = (Time.now - @timer_start).to_i
|
|
253
|
+
total = @timer_accumulated + elapsed
|
|
254
|
+
|
|
255
|
+
if total > 0
|
|
256
|
+
started_at = Time.now - total
|
|
257
|
+
@session.record(
|
|
258
|
+
project: @active_project,
|
|
259
|
+
category: @active_category,
|
|
260
|
+
started_at: started_at,
|
|
261
|
+
ended_at: Time.now,
|
|
262
|
+
elapsed_seconds: total
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Start new session
|
|
267
|
+
start_tracking(@current_project, new_category)
|
|
268
|
+
else
|
|
269
|
+
@current_category = new_category
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def cycle_project
|
|
274
|
+
return if @config.projects.size <= 1
|
|
275
|
+
|
|
276
|
+
current_idx = @config.projects.index(@current_project) || 0
|
|
277
|
+
next_idx = (current_idx + 1) % @config.projects.size
|
|
278
|
+
new_project = @config.projects[next_idx]
|
|
279
|
+
|
|
280
|
+
if @tracking
|
|
281
|
+
# Save current session
|
|
282
|
+
elapsed = (Time.now - @timer_start).to_i
|
|
283
|
+
total = @timer_accumulated + elapsed
|
|
284
|
+
|
|
285
|
+
if total > 0
|
|
286
|
+
started_at = Time.now - total
|
|
287
|
+
@session.record(
|
|
288
|
+
project: @active_project,
|
|
289
|
+
category: @active_category,
|
|
290
|
+
started_at: started_at,
|
|
291
|
+
ended_at: Time.now,
|
|
292
|
+
elapsed_seconds: total
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Start on new project with current category
|
|
297
|
+
start_tracking(new_project, @current_category)
|
|
298
|
+
else
|
|
299
|
+
@current_project = new_project
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# ── Settings Screen ──
|
|
304
|
+
|
|
305
|
+
def handle_settings_key(key)
|
|
306
|
+
if @settings_input_mode
|
|
307
|
+
handle_settings_input_key(key)
|
|
308
|
+
else
|
|
309
|
+
handle_settings_nav_key(key)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def handle_settings_nav_key(key)
|
|
314
|
+
case key
|
|
315
|
+
when "esc"
|
|
316
|
+
@screen = :main
|
|
317
|
+
@current_project = @config.default_project
|
|
318
|
+
@current_category = @config.default_category
|
|
319
|
+
[self, nil]
|
|
320
|
+
when "tab"
|
|
321
|
+
@settings_focus = @settings_focus == :projects ? :categories : :projects
|
|
322
|
+
@settings_index = 0
|
|
323
|
+
@settings_message = nil
|
|
324
|
+
[self, nil]
|
|
325
|
+
when "up", "k"
|
|
326
|
+
@settings_index = [0, @settings_index - 1].max
|
|
327
|
+
@settings_message = nil
|
|
328
|
+
[self, nil]
|
|
329
|
+
when "down", "j"
|
|
330
|
+
max = current_settings_list.size - 1
|
|
331
|
+
@settings_index = [@settings_index + 1, max].min
|
|
332
|
+
@settings_message = nil
|
|
333
|
+
[self, nil]
|
|
334
|
+
when "a"
|
|
335
|
+
@settings_input_mode = true
|
|
336
|
+
@settings_input_buffer = ""
|
|
337
|
+
@settings_message = nil
|
|
338
|
+
[self, nil]
|
|
339
|
+
when "d"
|
|
340
|
+
set_default_selected
|
|
341
|
+
[self, nil]
|
|
342
|
+
when "x"
|
|
343
|
+
delete_selected
|
|
344
|
+
[self, nil]
|
|
345
|
+
else
|
|
346
|
+
[self, nil]
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def handle_settings_input_key(key)
|
|
351
|
+
case key
|
|
352
|
+
when "esc"
|
|
353
|
+
@settings_input_mode = false
|
|
354
|
+
@settings_input_buffer = ""
|
|
355
|
+
[self, nil]
|
|
356
|
+
when "enter"
|
|
357
|
+
add_item(@settings_input_buffer)
|
|
358
|
+
@settings_input_mode = false
|
|
359
|
+
@settings_input_buffer = ""
|
|
360
|
+
[self, nil]
|
|
361
|
+
when "backspace"
|
|
362
|
+
@settings_input_buffer = @settings_input_buffer[0..-2]
|
|
363
|
+
[self, nil]
|
|
364
|
+
else
|
|
365
|
+
if key.length == 1
|
|
366
|
+
@settings_input_buffer += key
|
|
367
|
+
end
|
|
368
|
+
[self, nil]
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def current_settings_list
|
|
373
|
+
@settings_focus == :projects ? @config.projects : @config.categories
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def add_item(name)
|
|
377
|
+
if @settings_focus == :projects
|
|
378
|
+
if @config.add_project(name)
|
|
379
|
+
@settings_message = "Added project: #{name}"
|
|
380
|
+
else
|
|
381
|
+
@settings_message = "Could not add project (empty or duplicate)"
|
|
382
|
+
end
|
|
383
|
+
else
|
|
384
|
+
if @config.add_category(name)
|
|
385
|
+
@settings_message = "Added category: #{name}"
|
|
386
|
+
else
|
|
387
|
+
@settings_message = "Could not add category (empty or duplicate)"
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def set_default_selected
|
|
393
|
+
list = current_settings_list
|
|
394
|
+
return if @settings_index >= list.size
|
|
395
|
+
|
|
396
|
+
item = list[@settings_index]
|
|
397
|
+
if @settings_focus == :projects
|
|
398
|
+
@config.set_default_project(item)
|
|
399
|
+
@settings_message = "Default project: #{item}"
|
|
400
|
+
else
|
|
401
|
+
@config.set_default_category(item)
|
|
402
|
+
@settings_message = "Default category: #{item}"
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def delete_selected
|
|
407
|
+
list = current_settings_list
|
|
408
|
+
return if @settings_index >= list.size
|
|
409
|
+
|
|
410
|
+
item = list[@settings_index]
|
|
411
|
+
if @settings_focus == :projects
|
|
412
|
+
if @config.remove_project(item)
|
|
413
|
+
@settings_message = "Removed project: #{item}"
|
|
414
|
+
@settings_index = [@settings_index, @config.projects.size - 1].min
|
|
415
|
+
else
|
|
416
|
+
@settings_message = "Cannot remove (last project)"
|
|
417
|
+
end
|
|
418
|
+
else
|
|
419
|
+
if @config.remove_category(item)
|
|
420
|
+
@settings_message = "Removed category: #{item}"
|
|
421
|
+
@settings_index = [@settings_index, @config.categories.size - 1].min
|
|
422
|
+
else
|
|
423
|
+
@settings_message = "Cannot remove (last category)"
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# ── Report Screen ──
|
|
429
|
+
|
|
430
|
+
def handle_report_key(key)
|
|
431
|
+
case key
|
|
432
|
+
when "esc"
|
|
433
|
+
@screen = :main
|
|
434
|
+
[self, nil]
|
|
435
|
+
when "tab"
|
|
436
|
+
@report_mode = @report_mode == :weekly ? :monthly : :weekly
|
|
437
|
+
[self, nil]
|
|
438
|
+
when "left", "h"
|
|
439
|
+
navigate_report(-1)
|
|
440
|
+
[self, nil]
|
|
441
|
+
when "right", "l"
|
|
442
|
+
navigate_report(1)
|
|
443
|
+
[self, nil]
|
|
444
|
+
when "e"
|
|
445
|
+
filename = @report_view.export_csv(self)
|
|
446
|
+
show_toast("File saved to #{filename}")
|
|
447
|
+
[self, nil]
|
|
448
|
+
else
|
|
449
|
+
[self, nil]
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def navigate_report(direction)
|
|
454
|
+
if @report_mode == :weekly
|
|
455
|
+
@report_date += (direction * 7)
|
|
456
|
+
else
|
|
457
|
+
if direction > 0
|
|
458
|
+
@report_date = Date.new(@report_date.year, @report_date.month, 1).next_month
|
|
459
|
+
else
|
|
460
|
+
@report_date = Date.new(@report_date.year, @report_date.month, 1).prev_month
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def show_toast(message)
|
|
466
|
+
@toast = message
|
|
467
|
+
@toast_expires_at = Time.now + 4
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|
data/lib/fuso/config.rb
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Fuso
|
|
7
|
+
class Config
|
|
8
|
+
CONFIG_DIR = File.join(Dir.home, ".fuso")
|
|
9
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.json")
|
|
10
|
+
|
|
11
|
+
DEFAULT = {
|
|
12
|
+
"projects" => ["Project 1"],
|
|
13
|
+
"categories" => ["Development", "Planning", "Meeting", "Support"],
|
|
14
|
+
"default_project" => "Project 1",
|
|
15
|
+
"default_category" => "Development"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
attr_accessor :projects, :categories, :default_project, :default_category
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@projects = []
|
|
22
|
+
@categories = []
|
|
23
|
+
@default_project = nil
|
|
24
|
+
@default_category = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.load
|
|
28
|
+
config = new
|
|
29
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
30
|
+
|
|
31
|
+
if File.exist?(CONFIG_FILE)
|
|
32
|
+
data = JSON.parse(File.read(CONFIG_FILE))
|
|
33
|
+
else
|
|
34
|
+
data = {
|
|
35
|
+
"projects" => DEFAULT["projects"].dup,
|
|
36
|
+
"categories" => DEFAULT["categories"].dup,
|
|
37
|
+
"default_project" => DEFAULT["default_project"],
|
|
38
|
+
"default_category" => DEFAULT["default_category"]
|
|
39
|
+
}
|
|
40
|
+
File.write(CONFIG_FILE, JSON.pretty_generate(data))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
config.projects = (data["projects"] || []).dup
|
|
44
|
+
config.categories = (data["categories"] || []).dup
|
|
45
|
+
config.default_project = data["default_project"]
|
|
46
|
+
config.default_category = data["default_category"]
|
|
47
|
+
config
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def save
|
|
51
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
52
|
+
data = {
|
|
53
|
+
"projects" => @projects,
|
|
54
|
+
"categories" => @categories,
|
|
55
|
+
"default_project" => @default_project,
|
|
56
|
+
"default_category" => @default_category
|
|
57
|
+
}
|
|
58
|
+
File.write(CONFIG_FILE, JSON.pretty_generate(data))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_project(name)
|
|
62
|
+
name = name.strip
|
|
63
|
+
return false if name.empty? || @projects.include?(name)
|
|
64
|
+
|
|
65
|
+
@projects << name
|
|
66
|
+
@default_project ||= name
|
|
67
|
+
save
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def add_category(name)
|
|
72
|
+
name = name.strip
|
|
73
|
+
return false if name.empty? || @categories.include?(name)
|
|
74
|
+
|
|
75
|
+
@categories << name
|
|
76
|
+
@default_category ||= name
|
|
77
|
+
save
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def remove_project(name)
|
|
82
|
+
return false unless @projects.include?(name)
|
|
83
|
+
return false if @projects.size <= 1
|
|
84
|
+
|
|
85
|
+
@projects.delete(name)
|
|
86
|
+
@default_project = @projects.first if @default_project == name
|
|
87
|
+
save
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def remove_category(name)
|
|
92
|
+
return false unless @categories.include?(name)
|
|
93
|
+
return false if @categories.size <= 1
|
|
94
|
+
|
|
95
|
+
@categories.delete(name)
|
|
96
|
+
@default_category = @categories.first if @default_category == name
|
|
97
|
+
save
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def set_default_project(name)
|
|
102
|
+
return false unless @projects.include?(name)
|
|
103
|
+
|
|
104
|
+
@default_project = name
|
|
105
|
+
save
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def set_default_category(name)
|
|
110
|
+
return false unless @categories.include?(name)
|
|
111
|
+
|
|
112
|
+
@default_category = name
|
|
113
|
+
save
|
|
114
|
+
true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Fuso
|
|
6
|
+
# Writes a timestamp to ~/.fuso/heartbeat every INTERVAL seconds while
|
|
7
|
+
# the timer is running. On each tick, checks whether the last heartbeat
|
|
8
|
+
# is stale (gap > STALE_THRESHOLD), which indicates the system was
|
|
9
|
+
# suspended. Returns the stale timestamp so the app can use it as the
|
|
10
|
+
# real session end time.
|
|
11
|
+
class Heartbeat
|
|
12
|
+
HEARTBEAT_FILE = File.join(Config::CONFIG_DIR, "heartbeat")
|
|
13
|
+
INTERVAL = 10 # seconds between file writes
|
|
14
|
+
STALE_THRESHOLD = 60 # seconds — gap larger than this means suspend
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@last_write_at = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Call on each tick while tracking. Returns the last-known timestamp
|
|
21
|
+
# if a suspend gap was detected, nil otherwise.
|
|
22
|
+
def pulse
|
|
23
|
+
now = Time.now
|
|
24
|
+
stale_time = detect_staleness(now)
|
|
25
|
+
return stale_time if stale_time
|
|
26
|
+
|
|
27
|
+
if @last_write_at.nil? || (now - @last_write_at) >= INTERVAL
|
|
28
|
+
write(now)
|
|
29
|
+
@last_write_at = now
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Call when tracking stops or app quits.
|
|
36
|
+
def clear
|
|
37
|
+
File.delete(HEARTBEAT_FILE) if File.exist?(HEARTBEAT_FILE)
|
|
38
|
+
@last_write_at = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def detect_staleness(now)
|
|
44
|
+
return nil unless File.exist?(HEARTBEAT_FILE)
|
|
45
|
+
|
|
46
|
+
last = Time.at(File.read(HEARTBEAT_FILE).strip.to_f)
|
|
47
|
+
gap = now - last
|
|
48
|
+
|
|
49
|
+
gap > STALE_THRESHOLD ? last : nil
|
|
50
|
+
rescue
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def write(time)
|
|
55
|
+
FileUtils.mkdir_p(Config::CONFIG_DIR)
|
|
56
|
+
File.write(HEARTBEAT_FILE, time.to_f.to_s)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|