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.
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
@@ -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