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,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+ require "date"
7
+
8
+ module Fuso
9
+ class Session
10
+ SESSIONS_FILE = File.join(Config::CONFIG_DIR, "sessions.json")
11
+
12
+ attr_reader :entries
13
+
14
+ def initialize
15
+ @entries = {} # date_string => [entry, ...]
16
+ end
17
+
18
+ def self.load
19
+ session = new
20
+ if File.exist?(SESSIONS_FILE)
21
+ data = JSON.parse(File.read(SESSIONS_FILE))
22
+ session.instance_variable_set(:@entries, data)
23
+ end
24
+ session
25
+ end
26
+
27
+ def save
28
+ FileUtils.mkdir_p(Config::CONFIG_DIR)
29
+ File.write(SESSIONS_FILE, JSON.pretty_generate(@entries))
30
+ end
31
+
32
+ def record(project:, category:, started_at:, ended_at:, elapsed_seconds:)
33
+ date_key = started_at.strftime("%Y-%m-%d")
34
+ @entries[date_key] ||= []
35
+ @entries[date_key] << {
36
+ "project" => project,
37
+ "category" => category,
38
+ "started_at" => started_at.iso8601,
39
+ "ended_at" => ended_at.iso8601,
40
+ "elapsed_seconds" => elapsed_seconds.to_i
41
+ }
42
+ save
43
+ end
44
+
45
+ def today_entries
46
+ @entries[Date.today.to_s] || []
47
+ end
48
+
49
+ def today_total_seconds
50
+ today_entries.sum { |e| e["elapsed_seconds"] }
51
+ end
52
+
53
+ def today_seconds_for(project, category)
54
+ today_entries
55
+ .select { |e| e["project"] == project && e["category"] == category }
56
+ .sum { |e| e["elapsed_seconds"] }
57
+ end
58
+
59
+ def entries_for_week(date)
60
+ monday = date - (date.wday == 0 ? 6 : date.wday - 1)
61
+ sunday = monday + 6
62
+ result = []
63
+ (monday..sunday).each do |d|
64
+ result.concat(@entries[d.to_s] || [])
65
+ end
66
+ result
67
+ end
68
+
69
+ def entries_for_month(date)
70
+ year = date.year
71
+ month = date.month
72
+ result = []
73
+ @entries.each do |date_str, day_entries|
74
+ d = Date.parse(date_str)
75
+ result.concat(day_entries) if d.year == year && d.month == month
76
+ end
77
+ result
78
+ end
79
+
80
+ def week_number_in_month(date)
81
+ first_day = Date.new(date.year, date.month, 1)
82
+ first_monday = first_day + ((1 - first_day.wday) % 7)
83
+ first_monday = first_day if first_day.wday == 1
84
+ return 1 if date < first_monday
85
+
86
+ ((date - first_monday).to_i / 7) + 1
87
+ end
88
+
89
+ def weeks_in_month(date)
90
+ last_day = Date.new(date.year, date.month, -1)
91
+ week_number_in_month(last_day)
92
+ end
93
+
94
+ def monthly_breakdown(date)
95
+ month_entries = entries_for_month(date)
96
+ num_weeks = weeks_in_month(date)
97
+
98
+ # Build: { [project, category] => { week_num => seconds, ... } }
99
+ breakdown = Hash.new { |h, k| h[k] = Hash.new(0) }
100
+
101
+ month_entries.each do |entry|
102
+ d = Date.parse(entry["started_at"][0..9])
103
+ wn = week_number_in_month(d)
104
+ key = [entry["project"], entry["category"]]
105
+ breakdown[key][wn] += entry["elapsed_seconds"]
106
+ end
107
+
108
+ { breakdown: breakdown, num_weeks: num_weeks }
109
+ end
110
+
111
+ def weekly_breakdown(date)
112
+ week_entries = entries_for_week(date)
113
+ totals = Hash.new(0)
114
+
115
+ week_entries.each do |entry|
116
+ key = [entry["project"], entry["category"]]
117
+ totals[key] += entry["elapsed_seconds"]
118
+ end
119
+
120
+ totals
121
+ end
122
+
123
+ def self.format_duration(seconds)
124
+ seconds = seconds.to_i
125
+ hours = seconds / 3600
126
+ minutes = (seconds % 3600) / 60
127
+ secs = seconds % 60
128
+ format("%02d:%02d:%02d", hours, minutes, secs)
129
+ end
130
+
131
+ def self.format_duration_hm(seconds)
132
+ seconds = seconds.to_i
133
+ hours = seconds / 3600
134
+ minutes = (seconds % 3600) / 60
135
+ format("%02d:%02d", hours, minutes)
136
+ end
137
+
138
+ def self.format_decimal_hours(seconds)
139
+ return "" if seconds.to_i == 0
140
+
141
+ hours = (seconds.to_f / 3600).round(2)
142
+ return "" if hours == 0.0
143
+
144
+ hours == hours.to_i ? hours.to_i.to_s : format("%.2f", hours).sub(/0$/, "")
145
+ end
146
+
147
+ # Returns an array of week-ending dates (Sundays) for the given month.
148
+ # If the month ends mid-week, the last day of month is included as the final ending date.
149
+ def self.week_ending_dates_for_month(date)
150
+ first_day = Date.new(date.year, date.month, 1)
151
+ last_day = Date.new(date.year, date.month, -1)
152
+
153
+ dates = []
154
+ d = first_day
155
+ d += 1 until d.sunday?
156
+
157
+ while d <= last_day
158
+ dates << d
159
+ d += 7
160
+ end
161
+
162
+ # If last day is not a Sunday, add it as the final week-ending date
163
+ if dates.empty? || dates.last < last_day
164
+ dates << last_day
165
+ end
166
+
167
+ dates
168
+ end
169
+
170
+ # Returns { week_ending_date => { [project, category] => seconds } }
171
+ def monthly_breakdown_by_week_ending(date)
172
+ month_entries = entries_for_month(date)
173
+ ending_dates = self.class.week_ending_dates_for_month(date)
174
+
175
+ breakdown = Hash.new { |h, k| h[k] = Hash.new(0) }
176
+
177
+ month_entries.each do |entry|
178
+ d = Date.parse(entry["started_at"][0..9])
179
+ # Find the first week-ending date >= this day
180
+ we = ending_dates.find { |ed| ed >= d } || ending_dates.last
181
+ key = [entry["project"], entry["category"]]
182
+ breakdown[we][key] += entry["elapsed_seconds"]
183
+ end
184
+
185
+ { breakdown: breakdown, ending_dates: ending_dates }
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lipgloss"
4
+
5
+ module Fuso
6
+ module Styles
7
+ # Colors
8
+ ACCENT = "#00FF88"
9
+ ACCENT_DIM = "#00AA55"
10
+ DIM = "#555555"
11
+ DIMMER = "#333333"
12
+ TEXT = "#DDDDDD"
13
+ TEXT_BRIGHT = "#FFFFFF"
14
+ BG = "#0A0A0A"
15
+ SURFACE = "#1A1A1A"
16
+ DANGER = "#FF4444"
17
+
18
+ # Title / header
19
+ def self.title
20
+ @title ||= Lipgloss::Style.new
21
+ .bold(true)
22
+ .foreground(ACCENT)
23
+ .margin_bottom(1)
24
+ end
25
+
26
+ # Big timer display
27
+ def self.timer
28
+ @timer ||= Lipgloss::Style.new
29
+ .bold(true)
30
+ .foreground(ACCENT)
31
+ end
32
+
33
+ # Active project/category label
34
+ def self.active_label
35
+ @active_label ||= Lipgloss::Style.new
36
+ .foreground(TEXT)
37
+ end
38
+
39
+ # Dimmed text
40
+ def self.dim
41
+ @dim ||= Lipgloss::Style.new
42
+ .foreground(DIM)
43
+ end
44
+
45
+ # Category list item (active)
46
+ def self.category_active
47
+ @category_active ||= Lipgloss::Style.new
48
+ .bold(true)
49
+ .foreground(ACCENT)
50
+ end
51
+
52
+ # Category list item (inactive)
53
+ def self.category_inactive
54
+ @category_inactive ||= Lipgloss::Style.new
55
+ .foreground(TEXT)
56
+ end
57
+
58
+ # Category time
59
+ def self.category_time
60
+ @category_time ||= Lipgloss::Style.new
61
+ .foreground(DIM)
62
+ end
63
+
64
+ # Play/stop button prompt
65
+ def self.prompt
66
+ @prompt ||= Lipgloss::Style.new
67
+ .foreground(ACCENT)
68
+ .bold(true)
69
+ .margin_top(1)
70
+ .margin_bottom(1)
71
+ end
72
+
73
+ # Footer / totals
74
+ def self.footer
75
+ @footer ||= Lipgloss::Style.new
76
+ .foreground(DIM)
77
+ .margin_top(1)
78
+ end
79
+
80
+ # Footer value
81
+ def self.footer_value
82
+ @footer_value ||= Lipgloss::Style.new
83
+ .foreground(ACCENT)
84
+ .bold(true)
85
+ end
86
+
87
+ # Settings section header
88
+ def self.section_header
89
+ @section_header ||= Lipgloss::Style.new
90
+ .bold(true)
91
+ .foreground(TEXT_BRIGHT)
92
+ end
93
+
94
+ # Settings list item (selected)
95
+ def self.list_selected
96
+ @list_selected ||= Lipgloss::Style.new
97
+ .foreground(ACCENT)
98
+ .bold(true)
99
+ end
100
+
101
+ # Settings list item (normal)
102
+ def self.list_normal
103
+ @list_normal ||= Lipgloss::Style.new
104
+ .foreground(TEXT)
105
+ end
106
+
107
+ # Default indicator
108
+ def self.default_indicator
109
+ @default_indicator ||= Lipgloss::Style.new
110
+ .foreground(ACCENT)
111
+ end
112
+
113
+ # Panel with border
114
+ def self.panel
115
+ @panel ||= Lipgloss::Style.new
116
+ .border(:rounded)
117
+ .border_foreground(DIMMER)
118
+ .padding(1, 2)
119
+ end
120
+
121
+ # Report table header
122
+ def self.table_header
123
+ @table_header ||= Lipgloss::Style.new
124
+ .bold(true)
125
+ .foreground(TEXT_BRIGHT)
126
+ end
127
+
128
+ # Report table cell
129
+ def self.table_cell
130
+ @table_cell ||= Lipgloss::Style.new
131
+ .foreground(TEXT)
132
+ end
133
+
134
+ # Report table total
135
+ def self.table_total
136
+ @table_total ||= Lipgloss::Style.new
137
+ .bold(true)
138
+ .foreground(ACCENT)
139
+ end
140
+
141
+ # Help text
142
+ def self.help
143
+ @help ||= Lipgloss::Style.new
144
+ .foreground(DIM)
145
+ end
146
+
147
+ # Error / warning
148
+ def self.error
149
+ @error ||= Lipgloss::Style.new
150
+ .foreground(DANGER)
151
+ end
152
+
153
+ # Input prompt
154
+ def self.input_label
155
+ @input_label ||= Lipgloss::Style.new
156
+ .foreground(ACCENT)
157
+ .bold(true)
158
+ end
159
+
160
+ # Tab active
161
+ def self.tab_active
162
+ @tab_active ||= Lipgloss::Style.new
163
+ .foreground(ACCENT)
164
+ .bold(true)
165
+ end
166
+
167
+ # Tab inactive
168
+ def self.tab_inactive
169
+ @tab_inactive ||= Lipgloss::Style.new
170
+ .foreground(DIM)
171
+ end
172
+
173
+ # Toast notification
174
+ def self.toast
175
+ @toast ||= Lipgloss::Style.new
176
+ .foreground(ACCENT)
177
+ .bold(true)
178
+ .padding(0, 2)
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuso
4
+ VERSION = "0.1.2"
5
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuso
4
+ module Views
5
+ class MainView
6
+ def render(app)
7
+ lines = []
8
+
9
+ # Title
10
+ lines << ""
11
+ lines << Styles.title.render("Fuso")
12
+
13
+ if app.tracking?
14
+ lines << render_active(app)
15
+ else
16
+ lines << render_idle(app)
17
+ end
18
+
19
+ lines << ""
20
+ lines << render_help(app)
21
+ lines.join("\n")
22
+ end
23
+
24
+ private
25
+
26
+ def render_idle(app)
27
+ lines = []
28
+ lines << ""
29
+ lines << Styles.prompt.render("▶ Press [s] to start")
30
+ lines << ""
31
+ lines << Styles.dim.render("#{app.current_category} · #{app.current_project}")
32
+ lines << ""
33
+
34
+ # Show today's totals if any
35
+ total = app.session.today_total_seconds
36
+ if total > 0
37
+ lines << Styles.footer.render("Today: ") + Styles.footer_value.render(Session.format_duration(total))
38
+ end
39
+
40
+ lines.join("\n")
41
+ end
42
+
43
+ def render_active(app)
44
+ lines = []
45
+
46
+ # Current tracking info
47
+ lines << ""
48
+ lines << Styles.active_label.render("#{app.current_project} / #{app.current_category}")
49
+
50
+ # Timer
51
+ elapsed = app.active_elapsed_seconds
52
+ lines << Styles.timer.render(Session.format_duration(elapsed))
53
+
54
+ # Stop button
55
+ lines << Styles.prompt.render("■ Press [s] to stop")
56
+
57
+ # Category list
58
+ lines << ""
59
+ app.config.categories.each_with_index do |cat, i|
60
+ num = Styles.dim.render("#{i + 1} ")
61
+ is_active = (cat == app.active_category && app.current_project == app.active_project)
62
+
63
+ # Accumulated time today for this category
64
+ accumulated = app.session.today_seconds_for(app.current_project, cat)
65
+ if is_active
66
+ accumulated += app.current_timer_seconds
67
+ end
68
+ time_str = Session.format_duration(accumulated)
69
+
70
+ if is_active
71
+ indicator = Styles.category_active.render(" ●")
72
+ time = Styles.category_active.render(time_str)
73
+ else
74
+ indicator = " "
75
+ time = Styles.category_time.render(time_str)
76
+ end
77
+
78
+ # Pad the name to align times
79
+ padded_name = cat.ljust(16)
80
+ if is_active
81
+ padded_name = Styles.category_active.render(padded_name)
82
+ else
83
+ padded_name = Styles.category_inactive.render(padded_name)
84
+ end
85
+
86
+ lines << " #{num}#{padded_name} #{time}#{indicator}"
87
+ end
88
+
89
+ # Show other projects if more than one
90
+ if app.config.projects.size > 1
91
+ lines << ""
92
+ lines << Styles.dim.render(" [p] switch project: #{app.current_project}")
93
+ end
94
+
95
+ # Today total
96
+ lines << ""
97
+ total = app.session.today_total_seconds
98
+ if app.tracking?
99
+ total += app.current_timer_seconds
100
+ end
101
+ lines << Styles.footer.render(" Today: ") + Styles.footer_value.render(Session.format_duration(total))
102
+
103
+ lines.join("\n")
104
+ end
105
+
106
+ def render_help(app)
107
+ if app.tracking?
108
+ Styles.help.render(" [s]top [1-9] switch [p]roject [m]anage [r]eport [q]uit")
109
+ else
110
+ Styles.help.render(" [s]tart [m]anage [r]eport [q]uit")
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end