Skip to content

Commit 92650f1

Browse files
authored
Optionally include bar chart renderings for klog report
Related #336. This PR adds a `--chart` (`-c`) flag to the `klog report` command, which includes bar chart diagrams in the output like so: ``` $ klog report --chart worktimes.klg Total 2025 Jan Tue 14. 6h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ Thu 16. 7h30m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ Fri 17. 6h45m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ Sat 18. 8h ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ Mon 20. 3h20m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇ Wed 22. 7h45m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ Sat 25. 7h15m ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ ======== 46h35m ``` The default resolution of the bars depends on the aggregation mode. (Remember: aggregation defaults to daily, unless specified via `--aggregate`/`-a`). The default resolution aims to make for a good balance between granularity and row width for typical usage scenarios (e.g., tracking work times). The resolution can be overridden with the `--chart-res` flag; it needs to be a positive integer, denoting the number of minutes per rendered block. One implementation note for reference: I originally tried to use Unicode’s [“eighths” type block characters](https://www.compart.com/en/unicode/block/U+2580) to make the bars more precise. This didn’t work out so well, however, because on some systems, the full block and the “eighths” blocks render at a slightly different height, which looks weird.
1 parent dcf38a4 commit 92650f1

File tree

3 files changed

+305
-14
lines changed

3 files changed

+305
-14
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# Changelog
22
**Summary of changes of the command line tool**
33

4+
## Scheduled for next release
5+
- **[ FEATURE ]** Add `--chart` flag to `klog report` command, which
6+
includes bar chart renderings in the output, to allow for convenient visual
7+
comparison at a glance. (See also `--chart-resolution`.)
8+
49
## v6.5 (2024-11-28)
510
- **[ FEATURE ]** Introduce `basic` colour scheme based on the basic 8-bit ANSI
611
colours – see `colour_scheme` entry in `config.ini` file. (Run `klog config` to

klog/app/cli/report.go

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import (
88
"github.com/jotaen/klog/klog/app/cli/util"
99
"github.com/jotaen/klog/klog/service"
1010
"github.com/jotaen/klog/klog/service/period"
11+
"math"
1112
"strings"
1213
)
1314

1415
type Report struct {
15-
AggregateBy string `name:"aggregate" placeholder:"KIND" short:"a" help:"How to aggregate the data. KIND can be 'day' (default), 'week', 'month', 'quarter' or 'year'." enum:"DAY,day,d,WEEK,week,w,MONTH,month,m,QUARTER,quarter,q,YEAR,year,y," default:"day"`
16-
Fill bool `name:"fill" short:"f" help:"Fill the gaps and show a consecutive stream."`
16+
AggregateBy string `name:"aggregate" placeholder:"KIND" short:"a" help:"How to aggregate the data. KIND can be 'day' (default), 'week', 'month', 'quarter' or 'year'." enum:"DAY,day,d,WEEK,week,w,MONTH,month,m,QUARTER,quarter,q,YEAR,year,y," default:"day"`
17+
Fill bool `name:"fill" short:"f" help:"Fill any calendar gaps and show a consecutive sequence of dates."`
18+
Chart bool `name:"chart" short:"c" help:"Includes a bar chart rendering, to aid visual comparison."`
19+
ChartResolution int `name:"chart-res" help:"Configure the chart resolution. INT must be a positive integer, denoting the minutes per rendered block."`
1720
util.DiffArgs
1821
util.FilterArgs
1922
util.NowArgs
@@ -36,6 +39,10 @@ If you want a consecutive, chronological stream, you can use the '--fill' flag.
3639
func (opt *Report) Run(ctx app.Context) app.Error {
3740
opt.DecimalArgs.Apply(&ctx)
3841
opt.NoStyleArgs.Apply(&ctx)
42+
cErr := opt.canonicaliseOpts()
43+
if cErr != nil {
44+
return cErr
45+
}
3946
_, serialiser := ctx.Serialise()
4047
records, err := ctx.ReadInputs(opt.File...)
4148
if err != nil {
@@ -51,18 +58,22 @@ func (opt *Report) Run(ctx app.Context) app.Error {
5158
return nErr
5259
}
5360
records = service.Sort(records, true)
54-
aggregator := opt.findAggregator()
61+
aggregator := opt.aggregator()
5562
recordGroups, dates := groupByDate(aggregator.DateHash, records)
5663
if opt.Fill {
5764
dates = allDatesRange(records[0].Date(), records[len(records)-1].Date())
5865
}
5966

6067
// Table setup
6168
numberOfValueColumns := func() int {
69+
n := 1
6270
if opt.Diff {
63-
return 3
71+
n += 2
72+
}
73+
if opt.Chart {
74+
n += 1
6475
}
65-
return 1
76+
return n
6677
}()
6778
table := tf.NewTable(
6879
aggregator.NumberOfPrefixColumns()+numberOfValueColumns,
@@ -75,6 +86,9 @@ func (opt *Report) Run(ctx app.Context) app.Error {
7586
if opt.Diff {
7687
table.CellR(" Should").CellR(" Diff")
7788
}
89+
if opt.Chart {
90+
table.Skip(1)
91+
}
7892

7993
// Rows
8094
hashesAlreadyProcessed := make(map[period.Hash]bool)
@@ -99,38 +113,73 @@ func (opt *Report) Run(ctx app.Context) app.Error {
99113
diff := service.Diff(should, total)
100114
table.CellR(serialiser.ShouldTotal(should)).CellR(serialiser.SignedDuration(diff))
101115
}
116+
if opt.Chart {
117+
table.CellL(" " + renderBar(opt.ChartResolution, total))
118+
}
102119
}
103120

104121
// Line
105122
table.Skip(aggregator.NumberOfPrefixColumns()).Fill("=")
106123
if opt.Diff {
107124
table.Fill("=").Fill("=")
108125
}
109-
grandTotal := service.Total(records...)
126+
if opt.Chart {
127+
table.Skip(1)
128+
}
110129

111130
// Footer
131+
grandTotal := service.Total(records...)
112132
table.Skip(aggregator.NumberOfPrefixColumns())
113133
table.CellR(serialiser.Duration(grandTotal))
114134
if opt.Diff {
115135
grandShould := service.ShouldTotalSum(records...)
116136
grandDiff := service.Diff(grandShould, grandTotal)
117137
table.CellR(serialiser.ShouldTotal(grandShould)).CellR(serialiser.SignedDuration(grandDiff))
118138
}
139+
if opt.Chart {
140+
table.Skip(1)
141+
}
119142

120143
table.Collect(ctx.Print)
121144
opt.WarnArgs.PrintWarnings(ctx, records, opt.GetNowWarnings())
122145
return nil
123146
}
124147

125-
func (opt *Report) findAggregator() report.Aggregator {
126-
category := (func() string {
127-
if opt.AggregateBy == "" {
128-
return "d"
129-
} else {
130-
return strings.ToLower(opt.AggregateBy[:1])
148+
func (opt *Report) canonicaliseOpts() app.Error {
149+
if opt.AggregateBy == "" {
150+
opt.AggregateBy = "d"
151+
} else {
152+
opt.AggregateBy = strings.ToLower(opt.AggregateBy[:1])
153+
}
154+
155+
if opt.ChartResolution == 0 {
156+
// If the resolution wasn’t explicitly specified, use a default value
157+
// that aims for a good balance between granularity and overall row width
158+
// in the context of the desired aggregation mode.
159+
switch opt.AggregateBy {
160+
case "y":
161+
opt.ChartResolution = 60 * 8 * 7 // Full working week
162+
case "q":
163+
opt.ChartResolution = 60 * 8 // Full working day
164+
case "m":
165+
opt.ChartResolution = 60 * 4 // Half working day
166+
case "w":
167+
opt.ChartResolution = 60
168+
default: // "d"
169+
opt.ChartResolution = 15
131170
}
132-
})()
133-
switch category {
171+
} else if opt.ChartResolution > 0 {
172+
// When chart resolution is specified, automatically assume --chart
173+
// to be given as well.
174+
opt.Chart = true
175+
} else if opt.ChartResolution < 0 {
176+
return app.NewErrorWithCode(app.LOGICAL_ERROR, "Invalid resolution", "The resolution must be a positive integer", nil)
177+
}
178+
return nil
179+
}
180+
181+
func (opt *Report) aggregator() report.Aggregator {
182+
switch opt.AggregateBy {
134183
case "y":
135184
return report.NewYearAggregator()
136185
case "q":
@@ -169,3 +218,15 @@ func groupByDate(hashProvider func(klog.Date) period.Hash, rs []klog.Record) (ma
169218
}
170219
return days, order
171220
}
221+
222+
func renderBar(minutesPerUnit int, d klog.Duration) string {
223+
block := "▇"
224+
blocksCount := func() int {
225+
mins := d.InMinutes()
226+
if mins <= 0 {
227+
return 0
228+
}
229+
return int(math.Ceil(float64(mins) / float64(minutesPerUnit)))
230+
}()
231+
return strings.Repeat(block, blocksCount)
232+
}

0 commit comments

Comments
 (0)