Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a80c31f
docs(specs): design the v1.9 tranche C report dashboard (#117, #66, #80)
marcoarnulfo Jul 28, 2026
e9f5da6
docs(plans): plan the v1.9 tranche C report dashboard (#117, #66, #80)
marcoarnulfo Jul 28, 2026
03bff09
docs: fold the fable reviews of the tranche C spec and plan
marcoarnulfo Jul 28, 2026
6df7e26
test(tui): pin per-section selection in the billing editor
marcoarnulfo Jul 28, 2026
07d191a
refactor(tui): collapse the billing editor's four selection indices i…
marcoarnulfo Jul 28, 2026
a8b9831
feat(tui): add Subtle palette token and Cell/Border/Zebra styles
marcoarnulfo Jul 28, 2026
414a841
docs(plans): use American spelling for color throughout the plan
marcoarnulfo Jul 28, 2026
5401fae
feat(tui): render the report as a width-aware lipgloss table (#66)
marcoarnulfo Jul 28, 2026
93689f8
fix(tui): clamp report table width by display columns, not runes
marcoarnulfo Jul 28, 2026
62de960
feat(report): add DailyHours, a zero-filled per-day series (#80)
marcoarnulfo Jul 28, 2026
6ad480b
fix(report): use autumn DST transition in DailyHours test to properly…
marcoarnulfo Jul 28, 2026
f3919bb
feat(tui): draw a per-day sparkline on the report (#80)
marcoarnulfo Jul 28, 2026
00cff14
feat(tui): draw the budget burn-down as a block gauge (#80)
marcoarnulfo Jul 28, 2026
edafb72
docs(plans): fix the capitalised Colour the earlier normalisation missed
marcoarnulfo Jul 28, 2026
91db7d6
docs: record the report dashboard and refresh the demo GIF
marcoarnulfo Jul 28, 2026
ff57eb7
fix(tui): small corrections from the report-dashboard whole-branch re…
marcoarnulfo Jul 28, 2026
f17dadb
fix(tui): stop the report table's Amount column overflowing the terminal
marcoarnulfo Jul 28, 2026
b0a8518
fix(tui): keep the sparkline's hours/day label attached to the chart
marcoarnulfo Jul 28, 2026
e8472d2
docs: document the report table's adaptive width and the per-day spar…
marcoarnulfo Jul 28, 2026
e665bdd
docs: fix two comments that no longer matched behavior
marcoarnulfo Jul 28, 2026
315836a
fix(tui): stop two truncation edges from hanging or leaking stray output
marcoarnulfo Jul 28, 2026
d5fbaed
docs: refresh the demo GIF for the leading sparkline label
marcoarnulfo Jul 28, 2026
c410cb7
docs(specs): amend the Amount-column rule after the whole-branch review
marcoarnulfo Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- The report screen now draws a per-day sparkline of the selected range, with
idle days rendered as gaps rather than as low bars (#80).

### Changed

- The report is rendered as a real table that sizes itself to the terminal:
the numeric columns are right-aligned and the label column takes the slack,
instead of a fixed 32-column layout that wrapped on a narrow terminal (#66).
- The budget burn-down bars are drawn with block glyphs and colored — green
under budget, red over it — while still showing the true, unclamped
percentage (#80).

## [1.9.0] - 2026-07-28

### Added
Expand Down
6 changes: 6 additions & 0 deletions README.it.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ cambiare raggruppamento, riesportare o tornare alla home. Se il token risulta
invalido o revocato durante l'uso, la TUI ripropone automaticamente il wizard
di setup.

Il report stesso viene disegnato come una tabella dimensionata sul tuo terminale:
quando la finestra si restringe, è la colonna delle etichette a cedere spazio per
prima, così le colonne numeriche e l'importo restano leggibili. Sopra la tabella, una
sparkline giornaliera mostra a colpo d'occhio l'andamento del periodo — un giorno
senza ore registrate appare come un vuoto, non come una barra.

### Comandi nella TUI

Ogni schermata mostra un footer con i tasti che accetta in quel momento, generato
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ the **range picker**, which offers presets (this month, last month, last 7 days,
report you can change the grouping, re-export, or go back home. If the token becomes
invalid or is revoked while in use, the TUI automatically re-runs the setup wizard.

The report itself renders as a table sized to your terminal: as the window narrows,
the label column gives up its space first, so the numeric and amount columns keep
their footing. Above the table, a per-day sparkline shows the shape of the period at a
glance — a day with no logged hours renders as a gap, not a bar.

### TUI commands

Every screen shows a footer with the keys it currently accepts, generated directly
Expand Down
Binary file modified docs/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1,503 changes: 1,503 additions & 0 deletions docs/superpowers/plans/2026-07-28-report-dashboard.md

Large diffs are not rendered by default.

484 changes: 484 additions & 0 deletions docs/superpowers/specs/2026-07-28-report-dashboard-design.md

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions internal/report/daily.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package report

import "time"

// DailyHours returns one element per day in [start, end): the total hours of
// the entries that STARTED that day, read in loc. It returns nil when the
// range is empty or inverted.
//
// It counts ALL hours, billable and not — the same total Bucket.Hours carries.
// The series answers "when did I work", not "when did I bill"; the billable
// share has its own line under the report table.
//
// The hours of an entry land entirely on its start day, which is the same rule
// groupKeys uses for GroupByDay — the two views cannot disagree about which
// day an overnight entry belongs to.
//
// This exists instead of reusing GroupByDay buckets because Build creates no
// bucket for a day with no entries. A series built from buckets would close
// the gaps, drawing a full month of work for three days of it.
//
// Days are advanced with AddDate and identified by their formatted key, never
// by adding 24 hours: across a daylight-saving transition a day is 23 or 25
// hours long, and second-arithmetic would silently drop or duplicate one.
func DailyHours(entries []TimeEntry, start, end time.Time, loc *time.Location) []float64 {
loc = normLoc(loc)
day := midnightIn(start, loc)
last := midnightIn(end, loc)
if !day.Before(last) {
return nil
}

index := map[string]int{}
var out []float64
for d := day; d.Before(last); d = d.AddDate(0, 0, 1) {
index[d.Format(dayFormat)] = len(out)
out = append(out, 0)
}

for _, e := range entries {
if i, ok := index[e.Start.In(loc).Format(dayFormat)]; ok {
out[i] += e.Duration.Hours()
}
}
return out
}
120 changes: 120 additions & 0 deletions internal/report/daily_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package report

import (
"testing"
"time"
)

func TestDailyHours(t *testing.T) {
t.Parallel()
utc := time.UTC
day := func(y int, m time.Month, d int) time.Time {
return time.Date(y, m, d, 0, 0, 0, 0, utc)
}
entry := func(start time.Time, h float64) TimeEntry {
return TimeEntry{Start: start, Duration: time.Duration(h * float64(time.Hour))}
}

for _, tc := range []struct {
name string
entries []TimeEntry
start, end time.Time
want []float64
}{
{
name: "empty range yields nil",
start: day(2026, time.July, 5), end: day(2026, time.July, 5),
want: nil,
},
{
name: "end before start yields nil",
start: day(2026, time.July, 5), end: day(2026, time.July, 1),
want: nil,
},
{
name: "no entries still yields one zero per day",
start: day(2026, time.July, 1), end: day(2026, time.July, 4),
want: []float64{0, 0, 0},
},
{
name: "idle days stay zero rather than disappearing",
entries: []TimeEntry{
entry(day(2026, time.July, 1).Add(9*time.Hour), 2),
entry(day(2026, time.July, 3).Add(9*time.Hour), 1.5),
},
start: day(2026, time.July, 1), end: day(2026, time.July, 4),
want: []float64{2, 0, 1.5},
},
{
name: "several entries on one day are summed",
entries: []TimeEntry{
entry(day(2026, time.July, 2).Add(9*time.Hour), 2),
entry(day(2026, time.July, 2).Add(14*time.Hour), 3),
},
start: day(2026, time.July, 1), end: day(2026, time.July, 3),
want: []float64{0, 5},
},
{
name: "entries outside the range are ignored",
entries: []TimeEntry{
entry(day(2026, time.June, 30).Add(9*time.Hour), 8),
entry(day(2026, time.July, 2).Add(9*time.Hour), 1),
entry(day(2026, time.July, 9).Add(9*time.Hour), 8),
},
start: day(2026, time.July, 1), end: day(2026, time.July, 3),
want: []float64{0, 1},
},
{
name: "an entry is credited to the day it starts on",
entries: []TimeEntry{
// Starts at 23:00 on the 1st and runs three hours into the 2nd.
entry(day(2026, time.July, 1).Add(23*time.Hour), 3),
},
start: day(2026, time.July, 1), end: day(2026, time.July, 3),
want: []float64{3, 0},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := DailyHours(tc.entries, tc.start, tc.end, utc)
if len(got) != len(tc.want) {
t.Fatalf("got %d days %v, want %d days %v", len(got), got, len(tc.want), tc.want)
}
for i := range tc.want {
if got[i] != tc.want[i] {
t.Errorf("day %d = %v, want %v (full series %v)", i, got[i], tc.want[i], got)
}
}
})
}
}

// A day is not 24 hours across a DST transition. Rome falls back on
// 2026-10-25, making that day 25 hours long: a series built by adding 24h
// real-world hours would produce a duplicate calendar-day key and lose the
// third day entirely. Advancing by AddDate(0,0,1) correctly handles both
// 23-hour (spring) and 25-hour (autumn) transitions.
func TestDailyHoursSurvivesDST(t *testing.T) {
t.Parallel()
rome, err := time.LoadLocation("Europe/Rome")
if err != nil {
t.Skipf("tzdata unavailable: %v", err)
}
start := time.Date(2026, time.October, 24, 0, 0, 0, 0, rome)
end := time.Date(2026, time.October, 27, 0, 0, 0, 0, rome)
entries := []TimeEntry{
{Start: time.Date(2026, time.October, 24, 10, 0, 0, 0, rome), Duration: time.Hour},
{Start: time.Date(2026, time.October, 25, 10, 0, 0, 0, rome), Duration: 2 * time.Hour},
{Start: time.Date(2026, time.October, 26, 10, 0, 0, 0, rome), Duration: 3 * time.Hour},
}
got := DailyHours(entries, start, end, rome)
want := []float64{1, 2, 3}
if len(got) != len(want) {
t.Fatalf("got %d days %v, want 3", len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("day %d = %v, want %v (full series %v)", i, got[i], want[i], got)
}
}
}
4 changes: 2 additions & 2 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.report = report.Build(m.visibleEntries(), groupBy, p, start, end, m.loc)
m.report.Scope = m.scope
m.rep = newReport(m.report, m.memberFilterNote()+m.filteredNote())
m.rep = newReport(m.report, m.memberFilterNote()+m.filteredNote(), m.dailySeries())
// Report is reached three ways (Home enter, Report's own reload, and
// the logDone reload) and all three arrive here: re-rooting rather
// than replacing makes every arrival converge on nav == [Home], which
Expand Down Expand Up @@ -922,7 +922,7 @@ func (m Model) screenBody() string {
case screenLoading:
return m.theme.Title.Render("Loading hours…")
case screenReport:
return m.rep.view(m.theme)
return m.rep.view(m.theme, m.width)
case screenExport:
return m.export.view(m.theme)
case screenRates:
Expand Down
49 changes: 32 additions & 17 deletions internal/tui/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/marcoarnulfo/clickup-cli/internal/report"
)

Expand All @@ -27,26 +28,40 @@ func (m Model) updateBudget(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}

// budgetBarWidth is the text progress bar's width in characters, not counting
// the surrounding brackets.
// budgetBarWidth is the gauge's width in characters.
const budgetBarWidth = 20

// renderBudgetBar renders a percent-used value as a fixed-width text bar,
// e.g. "[############--------] 60%". The bar's fill is clamped to [0, 100]
// (a list can run over budget, but the bar itself can't render past full);
// the percentage in the label is shown unclamped so an over-100% burn is
// still visible in the number.
func renderBudgetBar(percentUsed float64) string {
fillPct := percentUsed
if fillPct < 0 {
fillPct = 0
}
if fillPct > 100 {
fillPct = 100
// The gauge's glyphs. Blocks rather than '#' and '-': at any font size they
// read as a filled bar instead of as text.
const (
gaugeFull = '█'
gaugeEmpty = '░'
)

// budgetFillStyle is the color of the filled part of a gauge. Over budget is
// the state this screen exists to surface, so it must not look like a healthy
// one. It is a named function because the package goldens run under
// termenv.Ascii, which strips the color: asserting on the style is the only
// way to test the choice.
func budgetFillStyle(th theme, percentUsed float64) lipgloss.Style {
if percentUsed > 100 {
return th.Err
}
return th.OK
}

// renderBudgetBar renders a percent-used value as a fixed-width gauge, e.g.
// "████████████░░░░░░░░ 60%". The fill is clamped to [0, 100] — a list can run
// over budget, but the bar cannot render past full — while the percentage in
// the label is shown UNCLAMPED, so an over-100% burn stays visible in the
// number. That asymmetry is the whole point: bubbles/progress clamps the
// number too, which is why this screen does not use it.
func renderBudgetBar(th theme, percentUsed float64) string {
fillPct := min(max(percentUsed, 0), 100)
filled := int(fillPct / 100 * budgetBarWidth)
bar := strings.Repeat("#", filled) + strings.Repeat("-", budgetBarWidth-filled)
return fmt.Sprintf("[%s] %.0f%%", bar, percentUsed)
bar := budgetFillStyle(th, percentUsed).Render(strings.Repeat(string(gaugeFull), filled)) +
th.Help.Render(strings.Repeat(string(gaugeEmpty), budgetBarWidth-filled))
return fmt.Sprintf("%s %.0f%%", bar, percentUsed)
}

func (bm budgetModel) view(th theme) string {
Expand All @@ -57,7 +72,7 @@ func (bm budgetModel) view(th theme) string {
var rows strings.Builder
for _, l := range bm.lines {
rows.WriteString(fmt.Sprintf("%-24s %s %.2f / %.2f %s (remaining %.2f)\n",
truncate(l.ListName, 24), renderBudgetBar(l.PercentUsed), l.Billed, l.Budget, l.Currency, l.Remaining))
truncate(l.ListName, 24), renderBudgetBar(th, l.PercentUsed), l.Billed, l.Budget, l.Currency, l.Remaining))
}
body := th.Box.Render(strings.TrimRight(rows.String(), "\n"))
return title + "\n\n" + body
Expand Down
32 changes: 25 additions & 7 deletions internal/tui/budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ func TestBudgetViewRendersProgressBar(t *testing.T) {
t.Errorf("budget view missing %q; got:\n%s", want, out)
}
}
if !strings.Contains(out, "#") || !strings.Contains(out, "-") {
t.Error("expected a text progress bar built from '#' and '-'")
if !strings.ContainsRune(out, gaugeFull) || !strings.ContainsRune(out, gaugeEmpty) {
t.Errorf("expected a bar built from %q and %q; got:\n%s", gaugeFull, gaugeEmpty, out)
}
}

Expand All @@ -33,19 +33,37 @@ func TestBudgetViewEmptyShowsMessage(t *testing.T) {
}
}

// A list that is over its budget must still be visible in the number even
// though the bar itself caps its fill at 100%.
// A list over its budget must still be visible in the number even though the
// bar itself caps its fill at 100%. This is exactly what bubbles/progress
// cannot do — it clamps the percentage to 100 before formatting it — and it is
// why this screen keeps its own bar.
func TestRenderBudgetBarClampsFillNotPercent(t *testing.T) {
out := renderBudgetBar(150)
t.Parallel()
out := renderBudgetBar(testTheme(true), 150)
if !strings.Contains(out, "150%") {
t.Errorf("renderBudgetBar(150) = %q, want the unclamped 150%% in the label", out)
}
full := strings.Repeat("#", budgetBarWidth)
if !strings.Contains(out, full) {
if full := strings.Repeat(string(gaugeFull), budgetBarWidth); !strings.Contains(out, full) {
t.Errorf("renderBudgetBar(150) = %q, want a fully filled bar", out)
}
}

// Over budget is the state the view exists to surface, so it must not look the
// same as a healthy one. The goldens run under termenv.Ascii, which strips the
// color entirely, so this asserts on the style rather than the output.
func TestBudgetBarColorsOverBudget(t *testing.T) {
t.Parallel()
th := paletteTheme(true) // real colors, so foregrounds are comparable
under := budgetFillStyle(th, 60)
over := budgetFillStyle(th, 130)
if under.GetForeground() != th.OK.GetForeground() {
t.Error("a bar under budget is not drawn in the OK color")
}
if over.GetForeground() != th.Err.GetForeground() {
t.Error("a bar over budget is not drawn in the Err color")
}
}

// TestBudgetKeyLabels pins the exact label set budget.go's updateBudget
// accepts today (every case label, verbatim), plus q — handled globally in
// app.go, in no case clause of budget.go itself. A dropped label is caught by
Expand Down
Loading
Loading