mdlogbook 0.1.0

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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +80 -0
  3. data/CHANGELOG.md +16 -0
  4. data/CLAUDE.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +99 -0
  8. data/Rakefile +21 -0
  9. data/docs/sample-logs/sample-2025-01.md +122 -0
  10. data/docs/sample-logs/sample-2025-02.md +123 -0
  11. data/docs/sample-logs/sample-2025-03.md +116 -0
  12. data/docs/sample-logs/sample-2025-04.md +128 -0
  13. data/docs/sample-logs/sample-2025-05.md +122 -0
  14. data/docs/sample-logs/sample-2025-06.md +113 -0
  15. data/docs/sample-logs/sample-2025-07.md +122 -0
  16. data/docs/sample-logs/sample-2025-08.md +94 -0
  17. data/docs/sample-logs/sample-2025-09.md +119 -0
  18. data/docs/sample-logs/sample-2025-10.md +123 -0
  19. data/docs/sample-logs/sample-2025-11.md +113 -0
  20. data/docs/sample-logs/sample-2025-12.md +121 -0
  21. data/docs/usage.md +778 -0
  22. data/exe/mlb +6 -0
  23. data/lib/mdlogbook/arg_parser.rb +372 -0
  24. data/lib/mdlogbook/brace_expander.rb +59 -0
  25. data/lib/mdlogbook/cli.rb +224 -0
  26. data/lib/mdlogbook/config.rb +247 -0
  27. data/lib/mdlogbook/date_filter.rb +22 -0
  28. data/lib/mdlogbook/editor_launcher.rb +90 -0
  29. data/lib/mdlogbook/env.rb +95 -0
  30. data/lib/mdlogbook/file_resolver.rb +43 -0
  31. data/lib/mdlogbook/formatter/anonymize.rb +75 -0
  32. data/lib/mdlogbook/formatter/anonymize_dict.rb +36 -0
  33. data/lib/mdlogbook/formatter/base.rb +138 -0
  34. data/lib/mdlogbook/formatter/calendar.rb +154 -0
  35. data/lib/mdlogbook/formatter/chart_series_builder.rb +57 -0
  36. data/lib/mdlogbook/formatter/extract.rb +41 -0
  37. data/lib/mdlogbook/formatter/grid.rb +256 -0
  38. data/lib/mdlogbook/formatter/heatmap.rb +142 -0
  39. data/lib/mdlogbook/formatter/line_chart.rb +156 -0
  40. data/lib/mdlogbook/formatter/simple.rb +42 -0
  41. data/lib/mdlogbook/option_def.rb +68 -0
  42. data/lib/mdlogbook/option_registry.rb +205 -0
  43. data/lib/mdlogbook/overlap_checker.rb +42 -0
  44. data/lib/mdlogbook/parser.rb +213 -0
  45. data/lib/mdlogbook/period_spec.rb +56 -0
  46. data/lib/mdlogbook/raw_extractor.rb +100 -0
  47. data/lib/mdlogbook/runner/base.rb +66 -0
  48. data/lib/mdlogbook/runner/editor_runner.rb +49 -0
  49. data/lib/mdlogbook/runner/extract_format_runner.rb +54 -0
  50. data/lib/mdlogbook/runner/standard_format_runner.rb +105 -0
  51. data/lib/mdlogbook/runner/visual_format_runner.rb +77 -0
  52. data/lib/mdlogbook/runner.rb +27 -0
  53. data/lib/mdlogbook/section_extractor.rb +140 -0
  54. data/lib/mdlogbook/version.rb +6 -0
  55. data/lib/mdlogbook/work_thresholds.rb +37 -0
  56. data/lib/mdlogbook.rb +41 -0
  57. data/sig/mdlogbook/arg_parser.rbs +54 -0
  58. data/sig/mdlogbook/brace_expander.rbs +4 -0
  59. data/sig/mdlogbook/cli.rbs +14 -0
  60. data/sig/mdlogbook/config.rbs +60 -0
  61. data/sig/mdlogbook/date_filter.rbs +4 -0
  62. data/sig/mdlogbook/editor_launcher.rbs +9 -0
  63. data/sig/mdlogbook/env.rbs +32 -0
  64. data/sig/mdlogbook/file_resolver.rbs +9 -0
  65. data/sig/mdlogbook/formatter/anonymize.rbs +4 -0
  66. data/sig/mdlogbook/formatter/anonymize_dict.rbs +4 -0
  67. data/sig/mdlogbook/formatter/base.rbs +15 -0
  68. data/sig/mdlogbook/formatter/calendar.rbs +4 -0
  69. data/sig/mdlogbook/formatter/chart_series_builder.rbs +9 -0
  70. data/sig/mdlogbook/formatter/extract.rbs +4 -0
  71. data/sig/mdlogbook/formatter/grid.rbs +8 -0
  72. data/sig/mdlogbook/formatter/heatmap.rbs +10 -0
  73. data/sig/mdlogbook/formatter/line_chart.rbs +7 -0
  74. data/sig/mdlogbook/formatter/simple.rbs +6 -0
  75. data/sig/mdlogbook/option_def.rbs +30 -0
  76. data/sig/mdlogbook/option_registry.rbs +18 -0
  77. data/sig/mdlogbook/overlap_checker.rbs +13 -0
  78. data/sig/mdlogbook/parser.rbs +32 -0
  79. data/sig/mdlogbook/period_spec.rbs +11 -0
  80. data/sig/mdlogbook/raw_extractor.rbs +15 -0
  81. data/sig/mdlogbook/runner/base.rbs +4 -0
  82. data/sig/mdlogbook/runner/editor_runner.rbs +5 -0
  83. data/sig/mdlogbook/runner/extract_format_runner.rbs +4 -0
  84. data/sig/mdlogbook/runner/standard_format_runner.rbs +4 -0
  85. data/sig/mdlogbook/runner/visual_format_runner.rbs +4 -0
  86. data/sig/mdlogbook/runner.rbs +4 -0
  87. data/sig/mdlogbook/section_extractor.rbs +24 -0
  88. data/sig/mdlogbook/work_thresholds.rbs +11 -0
  89. data/sig/mdlogbook.rbs +8 -0
  90. metadata +149 -0
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlogbook
4
+ module Formatter
5
+ # Shared template expansion for preamble/postamble text.
6
+ module TemplateHelper
7
+ private
8
+
9
+ # Substitutes +{range_begin}+ and +{range_end}+ in +text+.
10
+ # @param text [String]
11
+ # @param range_begin [Date, nil]
12
+ # @param range_end [Date, nil]
13
+ # @return [String]
14
+ def expand_range(text, range_begin, range_end)
15
+ text
16
+ .gsub("{range_begin}", range_begin&.to_s || "")
17
+ .gsub("{range_end}", range_end&.to_s || "")
18
+ end
19
+ end
20
+
21
+ # Shared helpers for remaining-hours display and month-length calculation.
22
+ module RemainingHelper
23
+ private
24
+
25
+ # Returns the number of days in the month containing +date+.
26
+ # @param date [Date]
27
+ # @return [Integer]
28
+ def days_of_month(date)
29
+ day1 = Date.new(date.year, date.month, 1)
30
+ (day1.next_month - day1).to_i
31
+ end
32
+
33
+ # Prints remaining-hours stats when +today+ is in the same month as +first_day+.
34
+ # @param io [IO]
35
+ # @param total [Float] Total hours logged so far.
36
+ # @param first_day [Date] First day of the month.
37
+ # @param settings [WorkThresholds]
38
+ # @param today [Date]
39
+ # @return [void]
40
+ def print_remaining(io, total, first_day, settings, today)
41
+ return unless today.year == first_day.year && today.month == first_day.month
42
+
43
+ n_days = days_of_month(today)
44
+ working_day_ratio = settings.working_days_per_month.to_f / n_days
45
+ rest_days = n_days - today.day + 1
46
+ remaining = settings.target_hours - total
47
+
48
+ io.puts
49
+ if remaining > 0
50
+ if rest_days > 0
51
+ io.printf(
52
+ "Remaining to meet target: %.2f h/weekday (%.2f h/day, total %.2f h)\n",
53
+ remaining / (rest_days * working_day_ratio),
54
+ remaining / rest_days,
55
+ remaining
56
+ )
57
+ else
58
+ io.printf "Remaining to meet target: total %.2f h\n", remaining
59
+ end
60
+ else
61
+ io.puts "Monthly target already met!"
62
+ end
63
+ end
64
+ end
65
+
66
+ # Shared helpers for anonymize formatters to check passthrough rules.
67
+ module PassthroughHelper
68
+ private
69
+
70
+ # Returns true when +job+ matches any passthrough rule (exact string or /pattern/).
71
+ # @param job [String]
72
+ # @param passthrough [Array<String>]
73
+ # @return [Boolean]
74
+ def passthrough?(job, passthrough)
75
+ passthrough.any? do |rule|
76
+ if rule.start_with?("/") && rule.end_with?("/")
77
+ Regexp.new(rule[1..-2]).match?(job)
78
+ else
79
+ job == rule
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ # Abstract base class for output formatters.
86
+ # Subclasses must implement {#format_output}.
87
+ class Base
88
+ include TemplateHelper
89
+
90
+ # Symbols mapped to their plain-text display signs.
91
+ PLAIN_SIGNS = {
92
+ excellent: "◎",
93
+ on_track: "○",
94
+ below: "△",
95
+ poor: "×"
96
+ }.freeze
97
+
98
+ # @!attribute [r] color_reset
99
+ # @return [String] ANSI reset escape sequence, or empty string when color is disabled.
100
+ # @!attribute [r] row_colors
101
+ # @return [Array<String>] ANSI color codes cycled per date row, or a single empty string.
102
+ # @!attribute [r] calendar_signs
103
+ # @return [Hash{Symbol => String}] Sign characters (optionally colored) keyed by rating.
104
+ # @!attribute [r] calendar_colors
105
+ # @return [Hash{Symbol => String}] ANSI color codes keyed by rating symbol.
106
+ attr_reader :color_reset, :row_colors, :calendar_signs, :calendar_colors
107
+
108
+ # @param color [Boolean] Whether to emit ANSI color codes (default: terminal detection).
109
+ def initialize(color: $stdout.tty?)
110
+ if color
111
+ @color_reset = "\e[0m"
112
+ @row_colors = [218, 107, 75].map { |n| "\e[38;5;#{n}m" }.freeze
113
+ @calendar_colors = {
114
+ excellent: 40, on_track: 45, below: 220, poor: 196, zero: 240
115
+ }.transform_values { |n| "\e[38;5;#{n}m" }.freeze
116
+ @calendar_signs = PLAIN_SIGNS.each_with_object({}) { |(key, sign), hash|
117
+ hash[key] = "#{@calendar_colors[key]}#{sign}#{@color_reset}"
118
+ }.freeze
119
+ else
120
+ @color_reset = ""
121
+ @row_colors = [""].freeze
122
+ @calendar_colors = Hash.new("")
123
+ @calendar_signs = PLAIN_SIGNS
124
+ end
125
+ end
126
+
127
+ # Formats and writes output for the given result.
128
+ # Subclasses must override this method.
129
+ # @param _result [Parser::Result]
130
+ # @param _settings [WorkThresholds]
131
+ # @param _io [IO]
132
+ # @raise [NotImplementedError] Always, unless overridden by a subclass.
133
+ def format_output(_result, _settings, _io, **_options)
134
+ raise NotImplementedError
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Mdlogbook
6
+ module Formatter
7
+ # Formats logbook entries as a weekly calendar grid with daily totals and ratings.
8
+ class Calendar < Base
9
+ include RemainingHelper
10
+
11
+ # Writes a calendar grid for the given result to +io+.
12
+ # @param result [Parser::Result]
13
+ # @param settings [WorkThresholds]
14
+ # @param io [IO] Output stream (default: $stdout).
15
+ # @param today [Date] Reference date used to calculate remaining hours (default: Date.today).
16
+ # @param debug [Boolean] When true, prints cumulative diagnostics beside each day sign.
17
+ # @return [void]
18
+ def format_output(result, settings, io = $stdout, today: Date.today, debug: false, **_options)
19
+ if result.dates.empty?
20
+ print_empty(io)
21
+ return
22
+ end
23
+
24
+ d_beg = result.dates.first
25
+ d_end = result.dates.last
26
+ d_range = d_beg..d_end
27
+ c_beg = d_beg - (d_beg.wday - 1) % 7
28
+ c_end = d_end - (d_end.wday - 1) % 7
29
+
30
+ print_header(io)
31
+
32
+ w_ts = Array.new(7) { [] }
33
+ sum = 0.0
34
+ date = c_beg
35
+
36
+ until date > c_end
37
+ ts = []
38
+ io.print date.strftime("%m/%d ")
39
+
40
+ 7.times do |idx|
41
+ d = date + idx
42
+ day_total = result.entries_on(d).sum(&:time)
43
+
44
+ unless d_range.cover?(d)
45
+ io.print "| - "
46
+ next
47
+ end
48
+
49
+ ts << day_total
50
+ w_ts[idx] << day_total
51
+ sum += day_total
52
+
53
+ sign = if settings.target_hours?
54
+ sign_key = compute_sign(day_total, sum, d, d_beg, settings)
55
+ s = calendar_signs[sign_key]
56
+ if debug
57
+ days = (d - d_beg).to_i + 1
58
+ working_day_ratio = settings.working_days_per_month.to_f / days_of_month(d)
59
+ working_days = days * working_day_ratio
60
+ s += Kernel.format("<%.1f %d %.1f %.1f>", sum, days, working_days, sum / working_days)
61
+ end
62
+ s
63
+ else
64
+ " "
65
+ end
66
+
67
+ color = day_total.zero? ? calendar_colors[:zero] : ""
68
+ io.printf "| %s%5.2f%s%s ", color, day_total, color_reset, sign
69
+ end
70
+
71
+ week_sum = ts.sum
72
+ avg = ts.empty? ? 0.0 : week_sum / ts.size
73
+ io.printf "| %6.2f (%5.2f)\n", week_sum, avg
74
+ date += 7
75
+ end
76
+
77
+ print_separator(io)
78
+ print_sum_row(io, w_ts)
79
+ print_avg_row(io, w_ts)
80
+ print_remaining(io, sum, d_beg, settings, today) if settings.target_hours?
81
+ end
82
+
83
+ private
84
+
85
+ def compute_sign(day_total, sum, date, d_beg, settings)
86
+ days = (date - d_beg).to_i + 1
87
+ working_day_ratio = settings.working_days_per_month.to_f / days_of_month(date)
88
+ working_days = days * working_day_ratio
89
+ working_days_avg = sum / working_days
90
+
91
+ if day_total >= settings.high_threshold || working_days_avg >= settings.high_threshold
92
+ :excellent
93
+ elsif day_total >= settings.daily_target || working_days_avg >= settings.daily_target
94
+ :on_track
95
+ elsif day_total < settings.low_threshold
96
+ :poor
97
+ else
98
+ :below
99
+ end
100
+ end
101
+
102
+ def print_header(io)
103
+ header = " " + %w[Mon Tue Wed Thu Fri Sat Sun].map { |s| "| #{s} " }.join + "| sum ( avg )"
104
+ separator = "----- " + "| ------ " * 7 + "| -------------- "
105
+ io.puts header
106
+ io.puts separator
107
+ end
108
+
109
+ def print_separator(io)
110
+ io.puts "----- " + "| ------ " * 7 + "| -------------- "
111
+ end
112
+
113
+ def print_sum_row(io, w_ts)
114
+ all = []
115
+ io.print " sum "
116
+ w_ts.each do |ts|
117
+ all.concat(ts)
118
+ io.printf "| %5.2f ", ts.sum
119
+ end
120
+ total = all.sum
121
+ if all.empty?
122
+ io.printf "| %6.2f ( - )\n", total
123
+ else
124
+ io.printf "| %6.2f (%5.2f)\n", total, total / all.size
125
+ end
126
+ end
127
+
128
+ def print_avg_row(io, w_ts)
129
+ io.print " avg "
130
+ w_ts.each do |ts|
131
+ if ts.empty?
132
+ io.print "| - "
133
+ else
134
+ io.printf "| %5.2f ", ts.sum / ts.size
135
+ end
136
+ end
137
+ io.puts
138
+ end
139
+
140
+ def print_empty(io)
141
+ print_header(io)
142
+ print_separator(io)
143
+
144
+ io.print " sum "
145
+ 7.times { io.print "| - " }
146
+ io.printf "| %6.2f ( - )\n", 0.0
147
+
148
+ io.print " avg "
149
+ 7.times { io.print "| - " }
150
+ io.puts
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlogbook
4
+ module Formatter
5
+ # Shared methods for building cumulative chart series used by {LineChart} and {Grid}.
6
+ module ChartSeriesBuilder
7
+ private
8
+
9
+ # Builds cumulative actual hours for each calendar day.
10
+ # @param result [Parser::Result]
11
+ # @param first_day [Date]
12
+ # @param n_days [Integer]
13
+ # @return [Array<Float>]
14
+ def build_actual(result, first_day, n_days)
15
+ cumulative = 0.0
16
+ (0...n_days).map do |i|
17
+ date = first_day + i
18
+ cumulative += result.entries_on(date).sum(&:time)
19
+ cumulative
20
+ end
21
+ end
22
+
23
+ # Builds a straight-line target: day_index * (target / n_days).
24
+ # @param target_hours [Numeric]
25
+ # @param n_days [Integer]
26
+ # @return [Array<Float>]
27
+ def build_linear_target(target_hours, n_days)
28
+ daily = target_hours.to_f / n_days
29
+ (1..n_days).map { |i| i * daily }
30
+ end
31
+
32
+ # Builds a step-wise target that accumulates only on Mon-Fri.
33
+ # @param settings [WorkThresholds]
34
+ # @param first_day [Date]
35
+ # @param n_days [Integer]
36
+ # @return [Array<Float>]
37
+ def build_weekday_target(settings, first_day, n_days)
38
+ working_days_in_month = (0...n_days).count { |i| working_day?(first_day + i) }
39
+ return Array.new(n_days, 0.0) if working_days_in_month.zero?
40
+
41
+ daily = settings.target_hours.to_f / working_days_in_month
42
+ cumulative = 0.0
43
+ (0...n_days).map do |i|
44
+ cumulative += daily if working_day?(first_day + i)
45
+ cumulative
46
+ end
47
+ end
48
+
49
+ # Returns true when the date is a weekday (Mon-Fri).
50
+ # @param date [Date]
51
+ # @return [Boolean]
52
+ def working_day?(date)
53
+ date.wday.between?(1, 5)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlogbook
4
+ module Formatter
5
+ # Outputs raw logbook content for a date range, suitable for AI summarization.
6
+ #
7
+ # Unlike other formatters, this class takes a {RawExtractor::Result} instead of
8
+ # a {Parser::Result}, preserving all lines (including free-form notes).
9
+ class Extract
10
+ include TemplateHelper
11
+
12
+ # Writes raw logbook content to +io+.
13
+ #
14
+ # @param raw_result [RawExtractor::Result]
15
+ # @param io [IO] Output stream (default: $stdout).
16
+ # @param extract_config [Config::ExtractSettings, nil]
17
+ # @param range_begin [Date, nil] Start of the output period for template substitution.
18
+ # @param range_end [Date, nil] End of the output period for template substitution.
19
+ # @return [void]
20
+ def format_output(raw_result, io = $stdout, extract_config: nil, range_begin: nil, range_end: nil)
21
+ extract_config ||= Config::ExtractSettings.new(preamble: nil, postamble: nil)
22
+
23
+ if extract_config.preamble
24
+ io.print expand_range(extract_config.preamble, range_begin, range_end)
25
+ end
26
+
27
+ first = true
28
+ raw_result.each_date do |_date, lines|
29
+ io.puts unless first
30
+ first = false
31
+ lines.each { |line| io.puts line }
32
+ end
33
+
34
+ if extract_config.postamble
35
+ io.puts
36
+ io.print expand_range(extract_config.postamble, range_begin, range_end)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "io/console"
5
+ require "unicode_plot"
6
+
7
+ module Mdlogbook
8
+ module Formatter
9
+ # Renders multiple months as a grid of line charts.
10
+ #
11
+ # Columns are auto-detected from terminal width:
12
+ # * 3 columns when the terminal is wide enough.
13
+ # * 2 columns for narrower terminals.
14
+ # * 1 column (vertical stack) as a final fallback.
15
+ #
16
+ # The legend is shown only on the last chart of the first row (top-right).
17
+ # Axis labels and remaining-hours stats are not displayed in grid mode.
18
+ # Each chart shows total hours and daily average below it.
19
+ class Grid
20
+ include ChartSeriesBuilder
21
+
22
+ # Minimum chart plot-area width before we drop to fewer columns.
23
+ MIN_CHART_WIDTH = 30
24
+
25
+ # Extra terminal columns unicode_plot needs (axis labels + legend text).
26
+ OVERHEAD = 30
27
+
28
+ # Gap between columns.
29
+ GAP = 2
30
+
31
+ # Series type keys used for color lookup when legend names are suppressed.
32
+ SERIES_TYPES = {
33
+ today: "today",
34
+ weekday: "weekday",
35
+ linear: "linear",
36
+ actual: "actual"
37
+ }.freeze
38
+
39
+ # Default colors for each series type (matching unicode_plot auto-cycle order).
40
+ DEFAULT_SERIES_COLORS = {
41
+ today: :magenta,
42
+ weekday: :yellow,
43
+ linear: :cyan,
44
+ actual: :green
45
+ }.freeze
46
+
47
+ # Renders charts for all given month specs in a grid layout.
48
+ #
49
+ # @param results [Array<Array(PeriodSpec, Parser::Result)>] Spec/result pairs, sorted.
50
+ # @param settings [WorkThresholds]
51
+ # @param io [IO] Output stream.
52
+ # @param today [Date] Reference date for the "Today" vertical line.
53
+ # @param line_chart_config [Config::VisualSettings, nil] Series visibility and color overrides.
54
+ # @return [void]
55
+ def format_output(results, settings, io = $stdout, today: Date.today, line_chart_config: nil)
56
+ vc = line_chart_config || Config::VisualSettings.new
57
+ term_cols = terminal_width(io)
58
+ color = tty?(io)
59
+ cols = detect_columns(term_cols)
60
+ chart_width = compute_chart_width(term_cols, cols)
61
+ y_max = unified_y_max(results, settings)
62
+
63
+ results.each_slice(cols).with_index do |row_group, row_idx|
64
+ io.puts if row_idx.positive?
65
+ last_in_first_row = row_idx.zero? ? row_group.size - 1 : nil
66
+ rendered = row_group.map.with_index do |(spec, result), col_idx|
67
+ legend = col_idx == last_in_first_row
68
+ render_one(result, settings, spec, today, vc, chart_width, y_max,
69
+ legend: legend, color: color)
70
+ end
71
+ paste_side_by_side(rendered, io)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # @return [Integer, nil]
78
+ def terminal_width(io)
79
+ io.winsize[1]
80
+ rescue
81
+ nil
82
+ end
83
+
84
+ # @return [Boolean]
85
+ def tty?(io)
86
+ io.respond_to?(:tty?) && io.tty?
87
+ end
88
+
89
+ # Determines the number of columns (3, 2, or 1) that fit in the terminal.
90
+ # @param term_cols [Integer, nil]
91
+ # @return [Integer]
92
+ def detect_columns(term_cols)
93
+ return 1 unless term_cols
94
+
95
+ [3, 2, 1].find do |n|
96
+ per_chart = (term_cols - GAP * (n - 1)) / n
97
+ per_chart - OVERHEAD >= MIN_CHART_WIDTH
98
+ end || 1
99
+ end
100
+
101
+ # Computes the unicode_plot width parameter for each chart.
102
+ # @param term_cols [Integer, nil]
103
+ # @param cols [Integer]
104
+ # @return [Integer]
105
+ def compute_chart_width(term_cols, cols)
106
+ return 60 unless term_cols
107
+
108
+ per_chart = (term_cols - GAP * (cols - 1)) / cols
109
+ [per_chart - OVERHEAD, MIN_CHART_WIDTH].max
110
+ end
111
+
112
+ # Computes a unified Y-axis maximum across all charts.
113
+ # @return [Float]
114
+ def unified_y_max(results, settings)
115
+ target = settings.target_hours? ? settings.target_hours : 0
116
+ max_actual = results.map do |spec, result|
117
+ first_day = spec.start_date
118
+ n_days = (spec.end_date - first_day).to_i + 1
119
+ build_actual(result, first_day, n_days).last || 0.0
120
+ end.max || 0.0
121
+ [target, max_actual].max
122
+ end
123
+
124
+ # Renders a single month chart to an array of lines (including stats footer).
125
+ # @return [Array<String>]
126
+ def render_one(result, settings, spec, today, vc, chart_width, y_max, legend:, color:)
127
+ first_day = spec.start_date
128
+ last_day = spec.end_date
129
+ n_days = (last_day - first_day).to_i + 1
130
+ xs = (1..n_days).map(&:to_f)
131
+
132
+ y_actual = build_actual(result, first_day, n_days)
133
+ has_target = settings.target_hours?
134
+ y_linear = has_target ? build_linear_target(settings.target_hours, n_days) : nil
135
+ y_weekday = has_target ? build_weekday_target(settings, first_day, n_days) : nil
136
+
137
+ height = [chart_width / 4, 15].min
138
+ base_opts = {
139
+ title: first_day.strftime("%Y-%m"),
140
+ xlim: [1, n_days], ylim: [0, y_max],
141
+ width: chart_width, height: height
142
+ }
143
+
144
+ effective_vc = if has_target
145
+ vc
146
+ else
147
+ Config::VisualSettings.new(show_today: vc.show_today, show_weekday: false,
148
+ show_linear: false, colors: vc.colors)
149
+ end
150
+
151
+ series = build_series(xs, y_actual, y_linear, y_weekday, y_max,
152
+ today, first_day, effective_vc, legend: legend)
153
+
154
+ first_series, *rest_series = series
155
+ plot = UnicodePlot.lineplot(first_series[:x], first_series[:y],
156
+ name: first_series[:name], **base_opts, **series_color(first_series[:type], vc))
157
+ rest_series.each do |s|
158
+ UnicodePlot.lineplot!(plot, s[:x], s[:y], name: s[:name], **series_color(s[:type], vc))
159
+ end
160
+
161
+ buf = StringIO.new
162
+ plot.render(buf, color: color)
163
+ lines = buf.string.lines.map(&:chomp)
164
+
165
+ # Inject stats into the title line (first line)
166
+ total = y_actual.last || 0.0
167
+ days_with_entries = count_days_with_entries(result, first_day, n_days)
168
+ stats = format_stats(total, days_with_entries)
169
+ lines[0] = "#{lines[0]}#{stats}" if lines[0]
170
+
171
+ lines
172
+ end
173
+
174
+ def build_series(xs, y_actual, y_linear, y_weekday, y_max, today, first_day, vc, legend:)
175
+ in_current_month = today.year == first_day.year && today.month == first_day.month
176
+ show_today = vc.show_today && in_current_month
177
+
178
+ series = []
179
+ if show_today
180
+ x = today.day.to_f
181
+ series << {name: legend ? "Today" : "", type: :today,
182
+ x: [x, x], y: [0.0, y_max.to_f]}
183
+ end
184
+ if vc.show_weekday
185
+ series << {name: legend ? "Weekday target" : "", type: :weekday,
186
+ x: xs, y: y_weekday}
187
+ end
188
+ if vc.show_linear
189
+ series << {name: legend ? "Linear target" : "", type: :linear,
190
+ x: xs, y: y_linear}
191
+ end
192
+ series << {name: legend ? "Actual" : "", type: :actual,
193
+ x: xs, y: y_actual}
194
+ series
195
+ end
196
+
197
+ # Returns a color keyword argument hash for the given series type.
198
+ # Always returns a color — either from config or the built-in default.
199
+ # @param type [Symbol]
200
+ # @param vc [Config::VisualSettings]
201
+ # @return [Hash]
202
+ def series_color(type, vc)
203
+ key = SERIES_TYPES[type]
204
+ color = vc.colors[key] || DEFAULT_SERIES_COLORS[type]
205
+ {color: color}
206
+ end
207
+
208
+ def count_days_with_entries(result, first_day, n_days)
209
+ (0...n_days).count do |i|
210
+ date = first_day + i
211
+ result.entries_on(date).sum(&:time) > 0
212
+ end
213
+ end
214
+
215
+ def format_stats(total, days_with_entries)
216
+ avg = days_with_entries.positive? ? total / days_with_entries : 0.0
217
+ Kernel.format(" (%.1fh %.1fh/d)", total, avg)
218
+ end
219
+
220
+ # Strips ANSI escape sequences to compute visible character width.
221
+ # @param str [String]
222
+ # @return [Integer]
223
+ def visible_length(str)
224
+ str.gsub(/\e\[[0-9;]*m/, "").length
225
+ end
226
+
227
+ # Pads a string (which may contain ANSI codes) to a visible width.
228
+ # @param str [String]
229
+ # @param width [Integer] Desired visible width.
230
+ # @return [String]
231
+ def ansi_ljust(str, width)
232
+ padding = width - visible_length(str)
233
+ (padding > 0) ? "#{str}#{" " * padding}" : str
234
+ end
235
+
236
+ # Pastes multiple rendered chart line arrays side by side.
237
+ # @param charts [Array<Array<String>>] Each element is an array of lines.
238
+ # @param io [IO]
239
+ def paste_side_by_side(charts, io)
240
+ max_lines = charts.map(&:size).max
241
+ empty = [""].freeze
242
+ charts.each { |c| c.concat(empty * (max_lines - c.size)) }
243
+ widths = charts.map { |c| c.map { |line| visible_length(line) }.max || 0 }
244
+ last_col = charts.size - 1
245
+
246
+ max_lines.times do |i|
247
+ row = charts.each_with_index.map do |c, ci|
248
+ line = c[i]
249
+ (ci < last_col) ? ansi_ljust(line, widths[ci]) : line
250
+ end
251
+ io.puts row.join(" " * GAP)
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end