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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
1c82baf553
commit
c72b8bcee1
@@ -1517,3 +1517,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-02] Accounted Connect direction (founder decision after a fork was resold): the ledger stays AGPL with no licence change and no ee/ split; provider integration logic moves behind the connector (hosted app/api/connect/* today, a separate Connect service later) one upstream at a time on the existing connector keys, ledger and entitlement sync. Rejected: FSL/BSL relicensing (58 public forks keep the AGPL version; DCO-only contributions cannot be relicensed without consent) and closed first-party extensions alone (the code still ships to every self-hoster, and the provider extensions do not honour the Extension API boundary).
|
||||
[2026-09-02] Peppol connector proxy (#2177) is operation-shaped (lookup/submit/status/evidence/recipient/inbound), NOT a path passthrough like the bank proxy: Qvalia URLs embed Arcim's partner and account numbers, the account is shared by every hosted company and every instance so reads must be scoped to what the caller owns, and Qvalia's inbound "read" endpoint marks documents read for the whole account. Ownership is bound to (key, company_ref); participants a key may publish are recorded on the key at issuance (connector_keys.peppol_participants) because the hosted side cannot otherwise know which organisations an instance legitimately hosts. Inbound is served from the hosted archive, never by calling Qvalia on the instance's behalf.
|
||||
[2026-09-02] The connector wire contract is an MIT package (packages/connect-contract, @accounted/connect-contract) consumed in-repo from source through a tsconfig/vitest alias (#2179), and check:guards ratchets the set of files naming a provider API host (#2178): the open repo keeps the contract and the manual file paths, either side of the connection can be implemented outside it, and the grandfathered provider-host set may only shrink. Declined a NOT VALID + later VALIDATE pair for the ledger service CHECK: connector_connections has zero prod rows until keys are issued.
|
||||
[2026-09-02] Nyckeltal "Resultat per månad" shows the exact per-month figures as an always-on list under the bars (#2198), not behind an "Anpassa" toggle: a preference would touch the type, the PUT schema, the strict preferences-body validator, the dialog and its tests for a switch nobody turns off. Per-bar compact labels are conditional on a glyph-width fit rule and fall back to the single latest label, so they never overlap. Left alone: the monthly path counts only posted entries while the year-total path also counts reversed originals (pinned as intended in tests/pg/kpi-report-aggregates-rpc.pg.test.ts), so a same-year storno makes the sum of months differ from Nettoresultat; visible as numbers now, founder call whether to align the two.
|
||||
|
||||
@@ -4,6 +4,13 @@ import { useTranslations } from 'next-intl'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import type { KPIReport, KPIPreferences } from '@/types'
|
||||
import {
|
||||
allLabelsFit,
|
||||
barLabel,
|
||||
compactKr,
|
||||
BAR_LABEL_FONT_PX,
|
||||
LATEST_LABEL_FONT_PX,
|
||||
} from './month-values'
|
||||
|
||||
/**
|
||||
* Nyckeltal as the founder-picked "Instrumentbrädan" layout: a grid of
|
||||
@@ -17,10 +24,6 @@ const SAGE = 'hsl(155 25% 40%)'
|
||||
|
||||
type TFn = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
function compactKr(n: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', { notation: 'compact', maximumFractionDigits: 1 }).format(n)
|
||||
}
|
||||
|
||||
/** Shared pane chrome: hairline border, compact metric padding. */
|
||||
function Pane({
|
||||
title,
|
||||
@@ -60,7 +63,9 @@ function Pane({
|
||||
}
|
||||
|
||||
/** Monthly net result as plain SVG bars: muted months, the latest in sage
|
||||
* (terracotta when negative), a compact value label on the endpoint. */
|
||||
* (terracotta when negative). Every non-zero bar carries a compact value
|
||||
* label when they fit side by side, otherwise only the latest does; the
|
||||
* exact amounts always follow in a two-column list under the axis. */
|
||||
function ResultBarsPane({ report }: { report: KPIReport }) {
|
||||
const t = useTranslations('kpi')
|
||||
const months = report.months
|
||||
@@ -89,9 +94,18 @@ function ResultBarsPane({ report }: { report: KPIReport }) {
|
||||
const baseline = maxPos + maxNeg === 0 ? H - bottomPad : topPad + maxPos * pxPerKr
|
||||
const slot = W / months.length
|
||||
const barW = Math.min(30, slot * 0.62)
|
||||
const labels = months.map((m) => barLabel(m.net))
|
||||
const labelAll = allLabelsFit(labels, slot, lastActive)
|
||||
// Exact amounts as two columns of the year's months, read top to bottom.
|
||||
const half = Math.ceil(months.length / 2)
|
||||
const columns = [months.slice(0, half), months.slice(half)]
|
||||
// A month with no result movement at all reads muted, whether it lies
|
||||
// ahead of the last booking or before the first one (a mid-year start).
|
||||
const inactive = (m: KPIReport['months'][number]) =>
|
||||
m.income === 0 && m.expenses === 0 && m.net === 0
|
||||
|
||||
return (
|
||||
<Pane title={t('bars_title')} annotation={t('bars_unit')}>
|
||||
<Pane title={t('bars_title')} annotation={t('bars_unit')} className="sm:row-span-2">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H + 8}`}
|
||||
className="mt-3 h-auto w-full"
|
||||
@@ -120,14 +134,17 @@ function ResultBarsPane({ report }: { report: KPIReport }) {
|
||||
<rect x={x} y={y} width={barW} height={h} rx={3} fill={fill}>
|
||||
<title>{`${m.label}: ${formatCurrency(m.net)}`}</title>
|
||||
</rect>
|
||||
{isLast && (
|
||||
{(isLast || (labelAll && labels[i] !== '')) && (
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={m.net >= 0 ? y - 5 : y + h + 11}
|
||||
textAnchor="middle"
|
||||
style={{ font: '10.5px var(--font-body, ui-sans-serif)', fill: 'hsl(var(--muted-foreground))' }}
|
||||
style={{
|
||||
font: `${isLast ? LATEST_LABEL_FONT_PX : BAR_LABEL_FONT_PX}px var(--font-body, ui-sans-serif)`,
|
||||
fill: 'hsl(var(--muted-foreground))',
|
||||
}}
|
||||
>
|
||||
{compactKr(m.net)}
|
||||
{labels[i] || compactKr(m.net)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
@@ -139,6 +156,28 @@ function ResultBarsPane({ report }: { report: KPIReport }) {
|
||||
<span key={m.label}>{m.label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-x-6 border-t border-border pt-3 text-xs">
|
||||
{columns.map((column, c) => (
|
||||
<dl key={c} className="space-y-1">
|
||||
{column.map((m, j) => {
|
||||
return (
|
||||
<div key={`${m.label}-${c * half + j}`} className="flex items-baseline justify-between gap-3">
|
||||
<dt className="text-muted-foreground">{m.label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'tabular-nums',
|
||||
m.net < 0 && 'text-destructive',
|
||||
inactive(m) && 'text-muted-foreground/60',
|
||||
)}
|
||||
>
|
||||
{formatCurrency(m.net)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
))}
|
||||
</div>
|
||||
</Pane>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
allLabelsFit,
|
||||
barLabel,
|
||||
compactKr,
|
||||
estimateLabelWidth,
|
||||
BAR_LABEL_FONT_PX,
|
||||
LATEST_LABEL_FONT_PX,
|
||||
} from '../month-values'
|
||||
|
||||
// Twelve months in the 320-unit viewBox: the slot every label must fit in.
|
||||
const SLOT = 320 / 12
|
||||
|
||||
const NBSP = String.fromCharCode(160)
|
||||
const MINUS = String.fromCharCode(0x2212)
|
||||
|
||||
/** Intl emits no-break spaces and a real minus sign; compare on plain ASCII. */
|
||||
function ascii(s: string): string {
|
||||
return s.split(NBSP).join(' ').split(MINUS).join('-')
|
||||
}
|
||||
|
||||
function labelsFor(nets: number[]): string[] {
|
||||
return nets.map(barLabel)
|
||||
}
|
||||
|
||||
describe('compactKr', () => {
|
||||
it('formats in Swedish compact notation with at most one decimal', () => {
|
||||
expect(ascii(compactKr(12_000))).toBe('12 tn')
|
||||
expect(ascii(compactKr(-3_400))).toBe('-3,4 tn')
|
||||
expect(ascii(compactKr(950))).toBe('950')
|
||||
expect(ascii(compactKr(1_250_000))).toBe('1,3 mn')
|
||||
})
|
||||
|
||||
it('never prints a minus on a value that rounds to zero', () => {
|
||||
expect(ascii(compactKr(-0.04))).toBe('0')
|
||||
expect(ascii(compactKr(-0))).toBe('0')
|
||||
expect(ascii(compactKr(-0.05))).toBe('-0,1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('barLabel', () => {
|
||||
it('is blank for an empty month and for öre that would round to nothing', () => {
|
||||
expect(barLabel(0)).toBe('')
|
||||
expect(barLabel(-0.03)).toBe('')
|
||||
expect(barLabel(0.04)).toBe('')
|
||||
})
|
||||
|
||||
it('carries the compact value otherwise', () => {
|
||||
expect(ascii(barLabel(-0.05))).toBe('-0,1')
|
||||
expect(ascii(barLabel(12_000))).toBe('12 tn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateLabelWidth', () => {
|
||||
it('treats the no-break space and thin glyphs as narrower than digits', () => {
|
||||
expect(estimateLabelWidth(compactKr(12_000))).toBeLessThan(estimateLabelWidth('12345'))
|
||||
expect(estimateLabelWidth('')).toBe(0)
|
||||
})
|
||||
|
||||
it('grows with the font size', () => {
|
||||
const label = compactKr(12_000)
|
||||
expect(estimateLabelWidth(label, LATEST_LABEL_FONT_PX)).toBeGreaterThan(
|
||||
estimateLabelWidth(label, BAR_LABEL_FONT_PX),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('allLabelsFit', () => {
|
||||
it('labels every bar for a year of whole-thousand months, negatives included', () => {
|
||||
const labels = labelsFor([8_000, -9_000, 0, 12_000, 25_000, 0, -4_000, 15_000, 7_000, 0, 0, 0])
|
||||
expect(allLabelsFit(labels, SLOT, 8)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the single latest label once a decimal negative would collide', () => {
|
||||
// "-3,4 tn" is the widest common shape: seven glyphs beside a neighbour.
|
||||
const labels = labelsFor([8_200, -3_400, -3_400, 12_000, 0, 0, 0, 0, 0, 0, 0, 0])
|
||||
expect(allLabelsFit(labels, SLOT, 3)).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back for six-figure months', () => {
|
||||
const labels = labelsFor([123_000, -198_000, 45_000, 0, 0, 0, 0, 0, 0, 0, 0, 0])
|
||||
expect(allLabelsFit(labels, SLOT, 2)).toBe(false)
|
||||
})
|
||||
|
||||
it('measures the latest bar at its larger font when testing its neighbours', () => {
|
||||
// "8,2 tn" next to "8,2 tn": fine at the small size on both, too wide
|
||||
// once the right-hand one is the emphasised latest label.
|
||||
const labels = labelsFor([8_200, 8_200])
|
||||
expect(allLabelsFit(labels, SLOT)).toBe(true)
|
||||
expect(allLabelsFit(labels, SLOT, 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('never lets a single label run past its own slot, even with empty neighbours', () => {
|
||||
expect(allLabelsFit(labelsFor([-123_000, 0, 0]), SLOT)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores empty labels and treats an all-zero year as fitting', () => {
|
||||
expect(allLabelsFit(['', '', ''], SLOT, 2)).toBe(true)
|
||||
})
|
||||
|
||||
it('scales with the slot width, so fewer months allow wider labels', () => {
|
||||
const wide = labelsFor([-123_000, 145_000])
|
||||
expect(allLabelsFit(wide, SLOT, 1)).toBe(false)
|
||||
expect(allLabelsFit(wide, 320 / 4, 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user