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.
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "csv"
5
+ require "fileutils"
6
+
7
+ module Fuso
8
+ module Views
9
+ class ReportView
10
+ def render(app)
11
+ lines = []
12
+
13
+ lines << ""
14
+ lines << Styles.title.render("Fuso — Reports")
15
+ lines << ""
16
+
17
+ # Tab bar
18
+ tabs = render_tabs(app)
19
+ lines << " #{tabs}"
20
+ lines << ""
21
+
22
+ # Navigation
23
+ date_label = if app.report_mode == :weekly
24
+ monday = app.report_date - ((app.report_date.wday + 6) % 7)
25
+ sunday = monday + 6
26
+ "Week #{app.report_date.cweek}: #{monday.strftime('%b %d')} — #{sunday.strftime('%b %d, %Y')}"
27
+ else
28
+ app.report_date.strftime("%B %Y")
29
+ end
30
+ lines << " " + Styles.dim.render("◀ ") + Styles.active_label.render(date_label) + Styles.dim.render(" ▶")
31
+ lines << ""
32
+
33
+ if app.report_mode == :weekly
34
+ lines << render_weekly(app)
35
+ else
36
+ lines << render_monthly(app)
37
+ end
38
+
39
+ lines << ""
40
+ lines << render_help
41
+
42
+ lines.join("\n")
43
+ end
44
+
45
+ def export_csv(app)
46
+ if app.report_mode == :weekly
47
+ export_weekly_csv(app)
48
+ else
49
+ export_monthly_csv(app)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def exports_dir
56
+ dir = File.join(Config::CONFIG_DIR, "exports")
57
+ FileUtils.mkdir_p(dir)
58
+ dir
59
+ end
60
+
61
+ def render_tabs(app)
62
+ weekly = app.report_mode == :weekly ? Styles.tab_active.render("[Weekly]") : Styles.tab_inactive.render(" Weekly ")
63
+ monthly = app.report_mode == :monthly ? Styles.tab_active.render("[Monthly]") : Styles.tab_inactive.render(" Monthly ")
64
+ "#{weekly} #{monthly}"
65
+ end
66
+
67
+ def render_weekly(app)
68
+ totals = app.session.weekly_breakdown(app.report_date)
69
+ return Styles.dim.render(" No tracked time this week.") if totals.empty?
70
+
71
+ lines = []
72
+
73
+ # Header
74
+ header = " " + Styles.table_header.render("PROJECT / CATEGORY".ljust(30)) +
75
+ Styles.table_header.render("TOTAL".rjust(10))
76
+ lines << header
77
+ lines << " " + Styles.dim.render("─" * 40)
78
+
79
+ grand_total = 0
80
+ totals.sort_by { |k, _| k }.each do |(project, category), seconds|
81
+ label = "#{project} / #{category}".ljust(30)
82
+ time = Session.format_duration_hm(seconds).rjust(10)
83
+ lines << " " + Styles.table_cell.render(label) + Styles.table_total.render(time)
84
+ grand_total += seconds
85
+ end
86
+
87
+ lines << " " + Styles.dim.render("─" * 40)
88
+ lines << " " + Styles.table_header.render("TOTAL".ljust(30)) +
89
+ Styles.table_total.render(Session.format_duration_hm(grand_total).rjust(10))
90
+
91
+ lines.join("\n")
92
+ end
93
+
94
+ def render_monthly(app)
95
+ data = app.session.monthly_breakdown(app.report_date)
96
+ breakdown = data[:breakdown]
97
+ num_weeks = data[:num_weeks]
98
+
99
+ return Styles.dim.render(" No tracked time this month.") if breakdown.empty?
100
+
101
+ lines = []
102
+
103
+ # Header
104
+ header = " " + Styles.table_header.render("PROJECT / CATEGORY".ljust(26))
105
+ (1..num_weeks).each do |w|
106
+ header += Styles.table_header.render("W#{w}".rjust(8))
107
+ end
108
+ header += Styles.table_header.render("TOTAL".rjust(10))
109
+ lines << header
110
+ lines << " " + Styles.dim.render("─" * (26 + num_weeks * 8 + 10))
111
+
112
+ week_totals = Hash.new(0)
113
+ grand_total = 0
114
+
115
+ breakdown.sort_by { |k, _| k }.each do |(project, category), weeks|
116
+ label = "#{project} / #{category}"
117
+ label = label.length > 25 ? label[0..22] + "..." : label.ljust(26)
118
+ row = " " + Styles.table_cell.render(label)
119
+
120
+ row_total = 0
121
+ (1..num_weeks).each do |w|
122
+ seconds = weeks[w]
123
+ row_total += seconds
124
+ week_totals[w] += seconds
125
+ time_str = seconds > 0 ? Session.format_duration_hm(seconds) : "—"
126
+ row += Styles.table_cell.render(time_str.rjust(8))
127
+ end
128
+
129
+ grand_total += row_total
130
+ row += Styles.table_total.render(Session.format_duration_hm(row_total).rjust(10))
131
+ lines << row
132
+ end
133
+
134
+ # Totals row
135
+ lines << " " + Styles.dim.render("─" * (26 + num_weeks * 8 + 10))
136
+ total_row = " " + Styles.table_header.render("TOTAL".ljust(26))
137
+ (1..num_weeks).each do |w|
138
+ seconds = week_totals[w]
139
+ time_str = seconds > 0 ? Session.format_duration_hm(seconds) : "—"
140
+ total_row += Styles.table_total.render(time_str.rjust(8))
141
+ end
142
+ total_row += Styles.table_total.render(Session.format_duration_hm(grand_total).rjust(10))
143
+ lines << total_row
144
+
145
+ lines.join("\n")
146
+ end
147
+
148
+ def render_help
149
+ Styles.help.render(" [tab] weekly/monthly [←→] navigate [e]xport CSV [esc] back")
150
+ end
151
+
152
+ def export_weekly_csv(app)
153
+ totals = app.session.weekly_breakdown(app.report_date)
154
+ week_num = app.report_date.cweek
155
+ year = app.report_date.year
156
+ monday = app.report_date - ((app.report_date.wday + 6) % 7)
157
+ sunday = monday + 6
158
+ filename = File.join(exports_dir, "fuso_weekly_#{year}-W#{format('%02d', week_num)}.csv")
159
+
160
+ CSV.open(filename, "w") do |csv|
161
+ # Header rows
162
+ csv << ["", "Week ending on day", "Total"]
163
+ csv << ["Projects", sunday.day, "Hours"]
164
+
165
+ # All project × category combos
166
+ grand_total = 0
167
+ app.config.projects.each do |project|
168
+ app.config.categories.each do |category|
169
+ key = [project, category]
170
+ seconds = totals[key] || 0
171
+ grand_total += seconds
172
+ csv << [
173
+ "#{project}-#{category}",
174
+ Session.format_decimal_hours(seconds),
175
+ Session.format_decimal_hours(seconds)
176
+ ]
177
+ end
178
+ end
179
+
180
+ csv << ["Total", Session.format_decimal_hours(grand_total), Session.format_decimal_hours(grand_total)]
181
+ end
182
+
183
+ filename
184
+ end
185
+
186
+ def export_monthly_csv(app)
187
+ data = app.session.monthly_breakdown_by_week_ending(app.report_date)
188
+ breakdown = data[:breakdown]
189
+ ending_dates = data[:ending_dates]
190
+ filename = File.join(exports_dir, "fuso_monthly_#{app.report_date.strftime('%Y-%m')}.csv")
191
+
192
+ CSV.open(filename, "w") do |csv|
193
+ # Row 1: empty | "Week ending on day" × N | "Total"
194
+ header1 = [""]
195
+ ending_dates.each { header1 << "Week ending on day" }
196
+ header1 << "Total"
197
+ csv << header1
198
+
199
+ # Row 2: "Projects" | day_number × N | "Hours"
200
+ header2 = ["Projects"]
201
+ ending_dates.each { |d| header2 << d.day }
202
+ header2 << "Hours"
203
+ csv << header2
204
+
205
+ # Data rows: all project × category combos
206
+ week_totals = Hash.new(0)
207
+ grand_total = 0
208
+
209
+ app.config.projects.each do |project|
210
+ app.config.categories.each do |category|
211
+ key = [project, category]
212
+ row = ["#{project}-#{category}"]
213
+ row_total = 0
214
+
215
+ ending_dates.each do |we|
216
+ seconds = breakdown.key?(we) ? (breakdown[we][key] || 0) : 0
217
+ row_total += seconds
218
+ week_totals[we] += seconds
219
+ row << Session.format_decimal_hours(seconds)
220
+ end
221
+
222
+ grand_total += row_total
223
+ row << Session.format_decimal_hours(row_total)
224
+ csv << row
225
+ end
226
+ end
227
+
228
+ # Total row
229
+ total_row = ["Total"]
230
+ ending_dates.each { |we| total_row << Session.format_decimal_hours(week_totals[we]) }
231
+ total_row << Session.format_decimal_hours(grand_total)
232
+ csv << total_row
233
+ end
234
+
235
+ filename
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuso
4
+ module Views
5
+ class SettingsView
6
+ def render(app)
7
+ lines = []
8
+
9
+ lines << ""
10
+ lines << Styles.title.render("Fuso — Settings")
11
+ lines << ""
12
+
13
+ # Two columns: Projects | Categories
14
+ left_lines = render_project_list(app)
15
+ right_lines = render_category_list(app)
16
+
17
+ # Merge columns side by side
18
+ max_left = left_lines.map { |l| visible_length(l) }.max || 0
19
+ max_rows = [left_lines.size, right_lines.size].max
20
+
21
+ max_rows.times do |i|
22
+ l = left_lines[i] || ""
23
+ r = right_lines[i] || ""
24
+ pad = max_left - visible_length(l) + 4
25
+ lines << " #{l}#{' ' * pad}#{r}"
26
+ end
27
+
28
+ # Input area
29
+ if app.settings_input_mode
30
+ lines << ""
31
+ label = app.settings_focus == :projects ? "New project: " : "New category: "
32
+ lines << " " + Styles.input_label.render(label) + app.settings_input_buffer + "█"
33
+ end
34
+
35
+ # Status message
36
+ if app.settings_message
37
+ lines << ""
38
+ lines << " " + Styles.dim.render(app.settings_message)
39
+ end
40
+
41
+ lines << ""
42
+ lines << render_help(app)
43
+
44
+ lines.join("\n")
45
+ end
46
+
47
+ private
48
+
49
+ # Strip ANSI escape codes to get the visible character count
50
+ def visible_length(str)
51
+ str.gsub(/\e\[[0-9;]*m/, "").length
52
+ end
53
+
54
+ def render_project_list(app)
55
+ lines = []
56
+ lines << Styles.section_header.render("PROJECTS")
57
+ lines << ""
58
+ app.config.projects.each_with_index do |proj, i|
59
+ is_default = (proj == app.config.default_project)
60
+ is_selected = (app.settings_focus == :projects && app.settings_index == i)
61
+
62
+ indicator = is_default ? Styles.default_indicator.render(" ●") : " "
63
+ cursor = is_selected ? Styles.category_active.render("› ") : " "
64
+
65
+ if is_selected
66
+ name = Styles.list_selected.render(proj)
67
+ else
68
+ name = Styles.list_normal.render(proj)
69
+ end
70
+
71
+ lines << "#{cursor}#{name}#{indicator}"
72
+ end
73
+ lines
74
+ end
75
+
76
+ def render_category_list(app)
77
+ lines = []
78
+ lines << Styles.section_header.render("CATEGORIES")
79
+ lines << ""
80
+ app.config.categories.each_with_index do |cat, i|
81
+ is_default = (cat == app.config.default_category)
82
+ is_selected = (app.settings_focus == :categories && app.settings_index == i)
83
+
84
+ indicator = is_default ? Styles.default_indicator.render(" ●") : " "
85
+ cursor = is_selected ? Styles.category_active.render("› ") : " "
86
+
87
+ if is_selected
88
+ name = Styles.list_selected.render(cat)
89
+ else
90
+ name = Styles.list_normal.render(cat)
91
+ end
92
+
93
+ lines << "#{cursor}#{name}#{indicator}"
94
+ end
95
+ lines
96
+ end
97
+
98
+ def render_help(app)
99
+ if app.settings_input_mode
100
+ Styles.help.render(" [enter] confirm [esc] cancel")
101
+ else
102
+ Styles.help.render(" [↑↓] navigate [tab] switch column [a]dd [d]efault [x] delete [esc] back")
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
data/lib/fuso.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fuso/version"
4
+ require_relative "fuso/app"
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fuso
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Afonso Neto
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bubbletea
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: lipgloss
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: bubbles
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: csv
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: Fuso is a minimalist TUI time tracker. One key to start, one key to switch,
69
+ one key to stop. Offline, private, and suspend-safe.
70
+ email:
71
+ - afonso.pontesneto@gmail.com
72
+ executables:
73
+ - fuso
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE
78
+ - README.md
79
+ - assets/screenshot_idle.png
80
+ - assets/screenshot_report.png
81
+ - assets/screenshot_tracking.png
82
+ - exe/fuso
83
+ - lib/fuso.rb
84
+ - lib/fuso/app.rb
85
+ - lib/fuso/config.rb
86
+ - lib/fuso/heartbeat.rb
87
+ - lib/fuso/session.rb
88
+ - lib/fuso/styles.rb
89
+ - lib/fuso/version.rb
90
+ - lib/fuso/views/main_view.rb
91
+ - lib/fuso/views/report_view.rb
92
+ - lib/fuso/views/settings_view.rb
93
+ homepage: https://github.com/AfonsoNeto/fuso-tui
94
+ licenses:
95
+ - MIT
96
+ metadata:
97
+ homepage_uri: https://github.com/AfonsoNeto/fuso-tui
98
+ source_code_uri: https://github.com/AfonsoNeto/fuso-tui
99
+ changelog_uri: https://github.com/AfonsoNeto/fuso-tui/blob/main/CHANGELOG.md
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: 3.2.0
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 4.0.12
115
+ specification_version: 4
116
+ summary: A dead-simple terminal time tracker.
117
+ test_files: []