Files
accounted/components/kpi/month-values.ts
T
Mattsson c72b8bcee1 feat(kpi): show every month's result in the Resultat per månad pane (#2198)
* feat(kpi): show every month's result in the Resultat per månad pane

A user asked to see the sum for each month on Nyckeltal, not only the
latest bar. The pane now lists the exact net result per month in two
columns under the axis (negatives in terracotta, months after the last
active one muted), and labels every non-zero bar with its compact value
when the twelve labels fit side by side. When they would collide (a
decimal negative like "-3,4 tn" or six-figure months) the bars keep the
single latest label as before; the list always carries the numbers.

The fit rule lives in components/kpi/month-values.ts with a glyph-aware
width estimate so it is deterministic and unit-tested without a DOM.
No preference toggle: the numbers are the default the user asked for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJUkdbACeRS93Eenr59D3H

* fix(kpi): address skeptic findings on the monthly result pane

- Never label a bar "-0": compact labels use signDisplay negative and
  stay blank under 5 ore, so an oresavrundning-only month shows nothing.
- Mute every month with no result movement, not only the trailing ones,
  so a mid-year start does not print leading no-data months full-strength.
- Measure the latest bar's label at the size it renders (10.5 vs 8) and
  test neighbours pairwise, so the fit rule guarantees what it claims.
- Let the bars pane span two grid rows so the first metric pane no longer
  stretches to fill the taller pane.
- Key the new list rows by position as well as label (18-month years).
- DECISIONS.md: no Anpassa toggle; storno asymmetry between the monthly
  and year-total paths left for a founder call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJUkdbACeRS93Eenr59D3H

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:43:11 +02:00

88 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pure helpers for the "Resultat per månad" pane: the compact bar label and
* the decision whether every bar can carry one without labels colliding.
* Kept out of the component so the fit rule is testable without a DOM.
*/
/**
* Swedish compact form, e.g. "12 tn" or "1,2 mn", used on the bars.
* `signDisplay: 'negative'` keeps an öre-sized loss that rounds to zero from
* printing as "0"; a minus sign only appears on a value that shows as one.
*/
export function compactKr(n: number): string {
return new Intl.NumberFormat('sv-SE', {
notation: 'compact',
maximumFractionDigits: 1,
signDisplay: 'negative',
}).format(n)
}
/** Below this the compact form is "0", so the bar carries no label at all. */
export const LABEL_THRESHOLD_KR = 0.05
/** Label for one bar: blank when the month is empty or rounds to nothing. */
export function barLabel(net: number): string {
return Math.abs(net) < LABEL_THRESHOLD_KR ? '' : compactKr(net)
}
/**
* Font size of the per-bar labels, in viewBox units. Smaller than the
* latest bar's label: twelve labels have to share 320 units, and the exact
* amounts are listed under the axis anyway.
*/
export const BAR_LABEL_FONT_PX = 8
/** Font size of the latest bar's label, the pane's one emphasised number. */
export const LATEST_LABEL_FONT_PX = 10.5
/**
* Advance widths per glyph class as a fraction of the font size, tuned for
* Geist. Digits and the minus sign are tabular-wide; the thin separators and
* the "tn"/"mn" suffix letters are what make a compact label fit or not, so
* a single average would misjudge "12 tn" against "3,4 tn".
*/
const GLYPH_EM: Record<string, number> = {
' ': 0.28,
// No-break space: what Intl puts between the number and its "tn" suffix.
[String.fromCharCode(160)]: 0.28,
',': 0.28,
'.': 0.28,
t: 0.35,
n: 0.55,
m: 0.85,
}
const DEFAULT_GLYPH_EM = 0.6
/** Breathing room between two adjacent labels, in viewBox units. */
const LABEL_GAP_PX = 3
/** Estimated rendered width of one label at the given font size. */
export function estimateLabelWidth(label: string, fontPx = BAR_LABEL_FONT_PX): number {
let em = 0
for (const ch of label) em += GLYPH_EM[ch] ?? DEFAULT_GLYPH_EM
return em * fontPx
}
/**
* True when every bar can carry its label without any two overlapping.
*
* Labels are centred on their bar, so two neighbours collide when half of
* each plus the gap exceeds one slot; that pairwise test is the real
* geometry, and it measures the latest bar at the larger size it renders
* at. Each label must also fit inside its own slot so the first and last
* never run past the viewBox edge. A company with six-figure months
* ("123 tn") on a twelve-slot pane fails this and falls back to labelling
* the latest bar only, exactly as before.
*/
export function allLabelsFit(labels: string[], slotWidth: number, latestIndex = -1): boolean {
const widths = labels.map((label, i) =>
estimateLabelWidth(label, i === latestIndex ? LATEST_LABEL_FONT_PX : BAR_LABEL_FONT_PX),
)
if (widths.some((w) => w > slotWidth)) return false
for (let i = 1; i < widths.length; i++) {
if (widths[i - 1] === 0 || widths[i] === 0) continue
if ((widths[i - 1] + widths[i]) / 2 + LABEL_GAP_PX > slotWidth) return false
}
return true
}