fewald-worklog 0.3.27 → 0.3.28

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5884616a7ecad2f07046d271cdd4d56a10d4d67afac7583f077b58d70c1adb2b
4
- data.tar.gz: 895be92dee9fe678bef8b108ba316f8d29a69ee4132ad4155d7e0bcb1a57b450
3
+ metadata.gz: 98413c2f56d95220ed96323e1bd719273a010fed346334650078e45adb7ca6e4
4
+ data.tar.gz: 42eadd693d07e91ac3e13a814c986671b62427d8c98488b809f5fb420b8a9aef
5
5
  SHA512:
6
- metadata.gz: 0f5e94a954a83e34ec9ccfcb026e253fa8122336eae4fccfe5e63ffbfe6005a8fb1c3740be5603d2d366eb309fc9909cc8163819e10e52be1645c4e5ec9b53a5
7
- data.tar.gz: 4f321c340d27c2cf3d6b9394d68cc21c3f5273749bbc9a51c0a6e2f80b4b7c9a4d7216b0d6d471c90ba79837e6edd8a9dc8670a6199028279459d0ac86191320
6
+ metadata.gz: f04df40355750a5c4efc7519faac0e5041f73e5ecd55b1ae9fbacdf694cb7f036b49b9d6d47c7edfc3316d29589b814969cbdfd5932fe3c77bcfe284667d84a3
7
+ data.tar.gz: 7cb02f4167a272f6f6da1ad58c8571cc3fdf9a9369afeb2f32887a8c679e9b269f1bd47e283e7978734a508d9997ee520559b732cbccbd63ff175bf01d4268ed
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.27
1
+ 0.3.28
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'rainbow'
5
+
6
+ # Renders a GitHub-style ASCII activity heatmap for worklog entries.
7
+ # Accepts an array of DailyLog objects and produces a 53-column × 7-row grid
8
+ # where each cell represents one day and darker (brighter) color means more activity.
9
+ #
10
+ # @example
11
+ # graph = ActivityGraph.new(storage.all_days)
12
+ # puts graph.render
13
+ class ActivityGraph
14
+ DAYS_OF_WEEK = %w[Mon Tue Wed Thu Fri Sat Sun].freeze
15
+
16
+ # Number of weeks to display (approx. one year).
17
+ NUM_WEEKS = 53
18
+
19
+ # Minimum column distance between consecutive month labels to prevent overlap.
20
+ MIN_MONTH_GAP = 3
21
+
22
+ def initialize(daily_logs)
23
+ @daily_logs = Array(daily_logs)
24
+ end
25
+
26
+ # Render the activity graph as a printable multi-line string.
27
+ # @return [String]
28
+ def render
29
+ counts = count_by_date
30
+ today = Date.today
31
+ start_date = week_start(today - ((NUM_WEEKS * 7) - 1))
32
+ weeks = build_weeks(start_date, today)
33
+ max_count = counts.values.max || 0
34
+
35
+ lines = []
36
+ lines << " #{Rainbow('Activity — past year').bold}"
37
+ lines << ''
38
+ lines << month_header(weeks)
39
+
40
+ DAYS_OF_WEEK.each_with_index do |day_name, day_idx|
41
+ cells = weeks.map do |week|
42
+ date = week[day_idx]
43
+ next ' ' if date > today
44
+
45
+ count = counts[date] || 0
46
+ "#{colored_block(intensity_level(count, max_count))} "
47
+ end
48
+ lines << "#{day_name} #{cells.join}"
49
+ end
50
+
51
+ lines << ''
52
+ lines << legend
53
+ lines.join("\n")
54
+ end
55
+
56
+ private
57
+
58
+ # Build a Hash mapping Date → entry count from the provided DailyLog objects.
59
+ # @return [Hash{Date => Integer}]
60
+ def count_by_date
61
+ @daily_logs.each_with_object({}) do |daily_log, hash|
62
+ date = daily_log.date.is_a?(Date) ? daily_log.date : Date.parse(daily_log.date.to_s)
63
+ hash[date] = daily_log.entries.length
64
+ end
65
+ end
66
+
67
+ # Return the Monday of the week that contains +date+.
68
+ # @param date [Date]
69
+ # @return [Date]
70
+ def week_start(date)
71
+ date - ((date.wday - 1) % 7)
72
+ end
73
+
74
+ # Build an array of weeks between +start_date+ and +end_date+.
75
+ # Each week is an Array of 7 Date objects (index 0 = Monday … index 6 = Sunday).
76
+ # +start_date+ must be a Monday.
77
+ # @param start_date [Date]
78
+ # @param end_date [Date]
79
+ # @return [Array<Array<Date>>]
80
+ def build_weeks(start_date, end_date)
81
+ weeks = []
82
+ current = start_date
83
+ while current <= end_date
84
+ weeks << (0..6).map { |i| current + i }
85
+ current += 7
86
+ end
87
+ weeks
88
+ end
89
+
90
+ # Map an entry count to an intensity level 0–4.
91
+ # Uses relative scaling so the full colour range adapts to the user's habits.
92
+ # Any day with at least one entry is guaranteed level ≥ 1.
93
+ # @param count [Integer]
94
+ # @param max_count [Integer]
95
+ # @return [Integer] 0–4
96
+ def intensity_level(count, max_count)
97
+ return 0 if count.zero? || max_count.zero?
98
+
99
+ ratio = count.to_f / max_count
100
+ return 1 if ratio <= 0.25
101
+ return 2 if ratio <= 0.50
102
+ return 3 if ratio <= 0.75
103
+
104
+ 4
105
+ end
106
+
107
+ # Return a Rainbow-coloured Unicode block for the given intensity level.
108
+ # Brighter green = more activity (optimised for dark-background terminals).
109
+ # @param level [Integer] 0–4
110
+ # @return [String]
111
+ def colored_block(level)
112
+ case level
113
+ when 0 then Rainbow('░').color(0x4a, 0x4a, 0x4a) # dark grey – no activity
114
+ when 1 then Rainbow('█').color(0x0e, 0x44, 0x29) # very dark green
115
+ when 2 then Rainbow('█').color(0x30, 0xa1, 0x4e) # medium green
116
+ when 3 then Rainbow('█').color(0x40, 0xc4, 0x63) # bright green
117
+ else Rainbow('█').color(0x9b, 0xe9, 0xa8) # very bright green – peak activity
118
+ end
119
+ end
120
+
121
+ # Build the month-label header row aligned with the week columns.
122
+ # Month names are placed at the column where the month first appears.
123
+ # A label is skipped when fewer than MIN_MONTH_GAP columns separate it from
124
+ # the previous one, preventing overlapping abbreviations at the graph edges.
125
+ # @param weeks [Array<Array<Date>>]
126
+ # @return [String]
127
+ def month_header(weeks)
128
+ # 4-char prefix to align with "Mon " row labels; 2 chars per week column.
129
+ buffer = ' ' * (4 + (weeks.length * 2))
130
+ prev_month = nil
131
+ last_label_col = -ActivityGraph::MIN_MONTH_GAP # allow first label at col 0
132
+
133
+ weeks.each_with_index do |week, col_idx|
134
+ month = week.first.month
135
+ next if month == prev_month
136
+ next if col_idx - last_label_col < MIN_MONTH_GAP
137
+
138
+ name = week.first.strftime('%b')
139
+ pos = 4 + (col_idx * 2)
140
+ buffer[pos, name.length] = name
141
+ prev_month = month
142
+ last_label_col = col_idx
143
+ end
144
+
145
+ buffer
146
+ end
147
+
148
+ # Return the legend row.
149
+ # @return [String]
150
+ def legend
151
+ blocks = (0..4).map { |level| colored_block(level) }
152
+ " Less #{blocks.join(' ')} More"
153
+ end
154
+ end
data/lib/worklog.rb CHANGED
@@ -18,6 +18,7 @@ require 'printer'
18
18
  require 'project_storage'
19
19
  require 'standup'
20
20
  require 'statistics'
21
+ require 'activity_graph'
21
22
  require 'storage'
22
23
  require 'summary'
23
24
  require 'takeout'
@@ -441,6 +442,8 @@ module Worklog
441
442
  puts "#{format_left('Entries per day')}: #{format('%.2f', stats.avg_entries)}"
442
443
  puts "#{format_left('First entry')}: #{stats.first_entry}"
443
444
  puts "#{format_left('Last entry')}: #{stats.last_entry}"
445
+ puts ''
446
+ puts ActivityGraph.new(@storage.all_days).render
444
447
  end
445
448
 
446
449
  def summary(options = {})
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fewald-worklog
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.27
4
+ version: 0.3.28
5
5
  platform: ruby
6
6
  authors:
7
7
  - Friedrich Ewald
@@ -166,6 +166,7 @@ files:
166
166
  - assets/prompts/standup.system.md.erb
167
167
  - assets/prompts/standup.user.md.erb
168
168
  - bin/wl
169
+ - lib/activity_graph.rb
169
170
  - lib/cli.rb
170
171
  - lib/configuration.rb
171
172
  - lib/daily_log.rb