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/session.rb
ADDED
|
@@ -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
|
data/lib/fuso/styles.rb
ADDED
|
@@ -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
|
data/lib/fuso/version.rb
ADDED
|
@@ -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
|