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.
- checksums.yaml +7 -0
- data/AGENTS.md +80 -0
- data/CHANGELOG.md +16 -0
- data/CLAUDE.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +99 -0
- data/Rakefile +21 -0
- data/docs/sample-logs/sample-2025-01.md +122 -0
- data/docs/sample-logs/sample-2025-02.md +123 -0
- data/docs/sample-logs/sample-2025-03.md +116 -0
- data/docs/sample-logs/sample-2025-04.md +128 -0
- data/docs/sample-logs/sample-2025-05.md +122 -0
- data/docs/sample-logs/sample-2025-06.md +113 -0
- data/docs/sample-logs/sample-2025-07.md +122 -0
- data/docs/sample-logs/sample-2025-08.md +94 -0
- data/docs/sample-logs/sample-2025-09.md +119 -0
- data/docs/sample-logs/sample-2025-10.md +123 -0
- data/docs/sample-logs/sample-2025-11.md +113 -0
- data/docs/sample-logs/sample-2025-12.md +121 -0
- data/docs/usage.md +778 -0
- data/exe/mlb +6 -0
- data/lib/mdlogbook/arg_parser.rb +372 -0
- data/lib/mdlogbook/brace_expander.rb +59 -0
- data/lib/mdlogbook/cli.rb +224 -0
- data/lib/mdlogbook/config.rb +247 -0
- data/lib/mdlogbook/date_filter.rb +22 -0
- data/lib/mdlogbook/editor_launcher.rb +90 -0
- data/lib/mdlogbook/env.rb +95 -0
- data/lib/mdlogbook/file_resolver.rb +43 -0
- data/lib/mdlogbook/formatter/anonymize.rb +75 -0
- data/lib/mdlogbook/formatter/anonymize_dict.rb +36 -0
- data/lib/mdlogbook/formatter/base.rb +138 -0
- data/lib/mdlogbook/formatter/calendar.rb +154 -0
- data/lib/mdlogbook/formatter/chart_series_builder.rb +57 -0
- data/lib/mdlogbook/formatter/extract.rb +41 -0
- data/lib/mdlogbook/formatter/grid.rb +256 -0
- data/lib/mdlogbook/formatter/heatmap.rb +142 -0
- data/lib/mdlogbook/formatter/line_chart.rb +156 -0
- data/lib/mdlogbook/formatter/simple.rb +42 -0
- data/lib/mdlogbook/option_def.rb +68 -0
- data/lib/mdlogbook/option_registry.rb +205 -0
- data/lib/mdlogbook/overlap_checker.rb +42 -0
- data/lib/mdlogbook/parser.rb +213 -0
- data/lib/mdlogbook/period_spec.rb +56 -0
- data/lib/mdlogbook/raw_extractor.rb +100 -0
- data/lib/mdlogbook/runner/base.rb +66 -0
- data/lib/mdlogbook/runner/editor_runner.rb +49 -0
- data/lib/mdlogbook/runner/extract_format_runner.rb +54 -0
- data/lib/mdlogbook/runner/standard_format_runner.rb +105 -0
- data/lib/mdlogbook/runner/visual_format_runner.rb +77 -0
- data/lib/mdlogbook/runner.rb +27 -0
- data/lib/mdlogbook/section_extractor.rb +140 -0
- data/lib/mdlogbook/version.rb +6 -0
- data/lib/mdlogbook/work_thresholds.rb +37 -0
- data/lib/mdlogbook.rb +41 -0
- data/sig/mdlogbook/arg_parser.rbs +54 -0
- data/sig/mdlogbook/brace_expander.rbs +4 -0
- data/sig/mdlogbook/cli.rbs +14 -0
- data/sig/mdlogbook/config.rbs +60 -0
- data/sig/mdlogbook/date_filter.rbs +4 -0
- data/sig/mdlogbook/editor_launcher.rbs +9 -0
- data/sig/mdlogbook/env.rbs +32 -0
- data/sig/mdlogbook/file_resolver.rbs +9 -0
- data/sig/mdlogbook/formatter/anonymize.rbs +4 -0
- data/sig/mdlogbook/formatter/anonymize_dict.rbs +4 -0
- data/sig/mdlogbook/formatter/base.rbs +15 -0
- data/sig/mdlogbook/formatter/calendar.rbs +4 -0
- data/sig/mdlogbook/formatter/chart_series_builder.rbs +9 -0
- data/sig/mdlogbook/formatter/extract.rbs +4 -0
- data/sig/mdlogbook/formatter/grid.rbs +8 -0
- data/sig/mdlogbook/formatter/heatmap.rbs +10 -0
- data/sig/mdlogbook/formatter/line_chart.rbs +7 -0
- data/sig/mdlogbook/formatter/simple.rbs +6 -0
- data/sig/mdlogbook/option_def.rbs +30 -0
- data/sig/mdlogbook/option_registry.rbs +18 -0
- data/sig/mdlogbook/overlap_checker.rbs +13 -0
- data/sig/mdlogbook/parser.rbs +32 -0
- data/sig/mdlogbook/period_spec.rbs +11 -0
- data/sig/mdlogbook/raw_extractor.rbs +15 -0
- data/sig/mdlogbook/runner/base.rbs +4 -0
- data/sig/mdlogbook/runner/editor_runner.rbs +5 -0
- data/sig/mdlogbook/runner/extract_format_runner.rbs +4 -0
- data/sig/mdlogbook/runner/standard_format_runner.rbs +4 -0
- data/sig/mdlogbook/runner/visual_format_runner.rbs +4 -0
- data/sig/mdlogbook/runner.rbs +4 -0
- data/sig/mdlogbook/section_extractor.rbs +24 -0
- data/sig/mdlogbook/work_thresholds.rbs +11 -0
- data/sig/mdlogbook.rbs +8 -0
- 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
|