fix(charts): keep negative weekly hours inside the chart viewBox - #44
Conversation
The weekly line chart floored its y-domain at zero, so a week with negative hours_saved -- which docs/metrics.md defines as normal (a rejected run earns -m minutes) -- mapped far below the 236px viewBox and was clipped away by the browser. The dashboard silently hid exactly the week an executive most needs to see. Derive the domain from both extremes so it always contains zero, wash the area back to the zero line rather than the frame floor, and draw the axis on zero. Non-negative data keeps bottom=0 and renders coordinate-for-coordinate as before. The terminal sparkline already handled this input; the HTML surface was the unfixed sibling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx
…dentical Gate review: the first commit widened the y-domain correctly but reformatted the axis from "206" to "206.0", which also reached column_chart -- the AI-spend card, which has nothing to do with negative hours -- and made the docstring's "byte-for-byte as before" claim false on every non-negative input. Emit whole coordinates through _coord() so they keep their integer form. The non-negative path is now genuinely byte-identical: 0 diffs over 506 generated cases for both line_chart and column_chart, and the demo dashboard matches main exactly. The regression tests now pin the axis and area verbatim -- the two marks the fix actually rewrote -- instead of only the line coordinates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx
…tive Gate review, second round. Keeping the negative week inside the viewBox was only half the job: the ticks stayed anchored to thirds of the band while the axis moved to a value-derived position, so a mixed-sign chart showed a heavier unlabeled rule at an arbitrary height and three ticks none of which was zero. The reader could see the dip but not read how deep it went -- which is the number the fix exists to surface. Two smaller faults shared that root cause: adjacent ticks could both render "-0.1" (or "-0.0", a signed zero the project forbids twice), and zero's gridline could land on the axis, stacking two opaque strokes and costing the chart one of its three promised hairlines. Snap a two-sided domain to multiples of a nice step, so zero and the domain floor are always labeled gridlines; take tick precision from the step rather than the value, so neighbouring ticks stay distinguishable; skip the hairline under the axis; and normalize a signed zero away in _fmt, matching format.money's contract. A domain floored at zero still takes the established path and is byte-identical: 0 diffs over 2,006 non-negative cases for line_chart and column_chart, and the demo dashboard matches main exactly. Over 5,583 mixed-sign cases: no value escapes the plot band, no signed-zero tick, no duplicate or coincident rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx
Round-2 review: test_line_chart_draws_no_hairline_under_the_zero_axis passed unchanged on 678655f -- the exact code that did stack a hairline on the axis. The axis is emitted through _coord ("18") while gridlines use ":.1f" ("18.0"), so the string comparison missed at precisely the two integral positions where a regression lands. Compare floats; verified it now fails on 678655f. Also narrow two claims the review showed were overstated: the byte-identity of a zero-floored domain holds except for a label that rounds to zero losing its minus sign (the deliberate _fmt normalization, which also reaches column_chart's peak label for -0.0), and a two-sided scale yields 2-4 hairlines rather than the fixed three, since readable tick values matter more than a tidy count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx
Gate verdict — PASS after two amendment roundsFour adversarial reviews, each told to refute the fix from a different angle. Recording what they broke, since it shaped the final diff. Round 1 — three findings, all real, all fixed:
Round 2 — one blocking finding, fixed:
The same round narrowed two claims I had overstated, now corrected in the docstring: byte-identity holds except for a label that rounds to zero losing its minus sign, and a two-sided scale yields 2–4 hairlines rather than a fixed three. Verified invariants
Known follow-ups (not defects, deliberately out of scope)
CI green on all five jobs. Left open for human review. |
Found by an autonomous
bughuntiteration.Bug
line_chart— the "Hours saved per week" card on the executive dashboard — clipped any week with negativehours_savedout of the SVG entirely.y=582 in a 236px-tall viewBox: the browser clips it. The line dives off the bottom of the card and the vertex is invisible, so the week reads as missing data rather than as a bad week.
Negative weekly hours are normal, documented domain, not a degenerate input:
docs/metrics.md:42— "completed | rejected, minutesm(or default) |−m— negative: review time spent, nothing produced"report/terminal.py:17— the sparkline carries the comment "hours_saved can be negative (a rejection-heavy week earns negative minutes)" and three pinning tests intests/test_terminal.pycharts.hbar_chart— "Handles negative values (a workflow can destroy value; the chart must be able to say so)"Every sibling surface was made to say so.
line_chartwas the one that wasn't. This also cuts the wrong way against ADR-004: hiding a value-destroying week overstates the program's health.Root cause
charts.py:83-89fixed the y-domain to[0, _nice_top(max(values))]:For
v < 0,1 - v/top > 1, soy > _PAD_T + plot_h— below the plot floor, outside the viewBox.Fix
Derive the domain from both extremes so it always contains zero, and place the axis on the zero line rather than the frame floor:
top/bottomare each_nice_top-rounded only on the side that actually needs it;bottomstays0.0whenevermin(values) >= 0.zero_y, so a negative excursion reads as a dip below the axis instead of a full-height fill._grid_and_axistakes an optionalbottom(default0.0) and spreads its ticks over[bottom, top].Non-negative data is coordinate-for-coordinate unchanged — with
bottom=0the zero line is the plot floor and every formula collapses to the original. That is pinned by a test, because the demo dashboard is CI's end-to-end golden.Test
tests/test_charts.py, three cases:test_line_chart_keeps_a_negative_week_inside_the_viewbox[18, 206]; the negative week sits lowesttest_line_chart_all_negative_stays_in_band_and_hangs_below_the_axistest_line_chart_non_negative_framing_is_unchangedVerified to fail without the fix (
git stashon the source alone):AssertionError: [1146.0, 2086.0]and[18.0, 582.0, 112.0]. The non-negative guard passes on both sides, as a regression guard must.Validation
All three CI jobs run green locally:
python -m pytest --cov=flightdeck --cov-fail-under=85→ 261 passed, coverage 94.49%ruff check src tests→ All checks passed!flightdeck demo+flightdeck audit verify→ ledger verified, 2,434 entries, chain intact🤖 Generated with Claude Code
https://claude.ai/code/session_01XjLWb6igee2tVTG7wg93Fx