From 95dc38e8694cb6eb48088ada5f52dd64ca693b07 Mon Sep 17 00:00:00 2001 From: danielededo <37807398+danielededo@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:11:49 +0000 Subject: [PATCH 1/2] feat(report): where the gross pay goes, as a waterfall The month's stacked bar is gone. The rows under "Where your gross pay goes" now step from the gross pay, through what is added to it (an expense refund, a contribution relief, a tax bonus), down each deduction to the net pay; each row has a span on a track that starts where the one before it ended. The gross pay's items open from its row, and the items some payroll software leaves out of the gross are listed under what is added rather than among them. A rounding under a euro with no item of its own makes no step. The list of items left out of the gross moves to the payslip package, so that the checks and the report share it. --- CHANGELOG.md | 5 ++ internal/extract/validate.go | 13 +---- internal/i18n/i18n.go | 4 ++ internal/payslip/payslip.go | 15 ++++++ internal/report/report.go | 88 ++++++++++++++++++++++---------- internal/report/report.html.tmpl | 31 ++++------- internal/report/report_test.go | 52 ++++++++++++++++--- web/test/e2e.test.mjs | 12 ++--- 8 files changed, 147 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 094f2c8..2e134a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ How to use this file: ### Changed +- "Where your gross pay goes" reads as a waterfall: from the gross pay, + what is added to it, such as an expense refund or a contribution relief, + then each deduction, down to the net pay, each with a span that starts + where the one before it ended. The gross pay's items open from its row, + and what is added is no longer counted in it. - On a wide screen the month takes two columns, what it is beside where the gross pay went, and the glossary two; the charts over time keep a size at which their text reads well, and the web app widens with the report. diff --git a/internal/extract/validate.go b/internal/extract/validate.go index 3c0c025..2df30bb 100644 --- a/internal/extract/validate.go +++ b/internal/extract/validate.go @@ -12,17 +12,6 @@ import ( // apprentice rate. A rate outside this list is not an error, only unverified. var inpsRates = []int64{919, 949, 584} -// outsideGross are item kinds that some payroll software leaves out of the -// printed gross because they are not taxable pay. -var outsideGross = map[string]bool{ - "earnings.reimbursement": true, - "earnings.contribution_relief": true, - "tax.credit": true, - "tax.return": true, - "earnings.family_allowance": true, - "rounding": true, -} - // validate fills totals that can be computed, runs the cross-checks, and sets // the status of each total: verified when a check confirms it, inconsistent // when a check contradicts it, extracted when no check applies. @@ -41,7 +30,7 @@ func validate(p *payslip.Payslip) { if hasItems { var taxable int64 for _, it := range p.Items { - if it.Side == payslip.Earning && !outsideGross[it.Key] { + if it.Side == payslip.Earning && !payslip.OutsideGross(it.Key) { taxable += it.Cents } } diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index ae02257..c084db7 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -203,6 +203,8 @@ var catalog = map[Lang]map[string]string{ "report.part.taxes": "Taxes", "report.part.taxes.text": "IRPEF and the regional and municipal surcharges, paid to the State for you.", "report.part.other": "Other deductions", + "report.part.plus": "Added to the gross pay", + "report.part.plus.text": "Paid with the net but not part of the gross pay: expense refunds, contribution reliefs, tax bonuses, roundings.", "report.part.other.fund": "Other deductions, mostly the pension fund", "report.part.other.text": "Everything else withheld, such as a pension fund, union dues or meal vouchers.", "report.earnings": "What makes up your gross pay", @@ -404,6 +406,8 @@ var catalog = map[Lang]map[string]string{ "report.part.taxes": "Imposte", "report.part.taxes.text": "IRPEF e addizionali regionale e comunale, versate allo Stato al posto tuo.", "report.part.other": "Altre trattenute", + "report.part.plus": "In più rispetto al lordo", + "report.part.plus.text": "Arrivano nel netto ma non fanno parte del lordo: rimborsi spese, esoneri contributivi, bonus fiscali, arrotondamenti.", "report.part.other.fund": "Altre trattenute, soprattutto fondo pensione", "report.part.other.text": "Tutto il resto trattenuto, come fondo pensione, quota sindacale o buoni pasto.", "report.earnings": "Cosa compone il lordo", diff --git a/internal/payslip/payslip.go b/internal/payslip/payslip.go index ca3225e..9ec034a 100644 --- a/internal/payslip/payslip.go +++ b/internal/payslip/payslip.go @@ -177,6 +177,21 @@ type Payslip struct { Checks []Check `json:"checks"` } +// outsideGross are item kinds that some payroll software leaves out of the +// printed gross because they are not taxable pay. +var outsideGross = map[string]bool{ + "earnings.reimbursement": true, + "earnings.contribution_relief": true, + "tax.credit": true, + "tax.return": true, + "earnings.family_allowance": true, + "rounding": true, +} + +// OutsideGross reports whether some payroll software leaves an item of this +// kind out of the printed gross. +func OutsideGross(key string) bool { return outsideGross[key] } + // Party is a person or a company named on a payslip. type Party struct { ID string `json:"id,omitempty"` diff --git a/internal/report/report.go b/internal/report/report.go index fa6e838..fb268e3 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -70,9 +70,11 @@ type Month struct { // Part is one share of the gross pay: the net or a kind of deduction. type Part struct { Key, Label, Amount, Text string - Color string - Width float64 // percent of the bar - Lines []Line + // From and Width place the part's span on its track, in percent of + // the gross pay and what is added to it: the parts step down from the + // gross pay to the net, like a waterfall. + From, Width float64 + Lines []Line } // Line is one printed item with its explanation. @@ -496,13 +498,15 @@ func month(l i18n.Lang, p payslip.Payslip, per insights.Period) Month { line := func(it payslip.Item) Line { return Line{Description: it.Description, Label: i18n.ItemLabel(l, it.Key), Amount: money.Format(it.Cents) + " €", Text: i18n.ItemText(l, it.Key)} } - var contrib, tax, other []Line + var contrib, tax, other, plus []Line fund := pensionInContributions(p) // aside sums what builds up for you; withheld is the part of it that // your own pay gave, also among the other deductions. var aside, withheld int64 for _, it := range p.Items { switch { + case it.Side == payslip.Earning && payslip.OutsideGross(it.Key) && outside(p): + plus = append(plus, line(it)) case it.Side == payslip.Earning: m.Earnings = append(m.Earnings, line(it)) case it.Key == "pension_fund.employee" && fund > 0, it.Key == "deductions.pension_fund": @@ -544,44 +548,74 @@ func month(l i18n.Lang, p payslip.Payslip, per insights.Period) Month { m.Fund = i18n.T(l, "report.fund.month", moneyValue(f.total())) } shares := split(p) - lines := [][]Line{nil, contrib, tax, other} - parts := make([]struct { + // From the gross pay, what is added to it, then each deduction, down + // to the net pay. + var added int64 + if p.Gross.Found() && p.Net.Found() && p.Deductions.Found() { + added = max(p.Net.Cents+p.Deductions.Cents-p.Gross.Cents, 0) + } + // Less than a euro with no item of its own is a rounding: no step. + if added < 100 && len(plus) == 0 { + added = 0 + } + steps := []struct { key string cents int64 - color string + sign string lines []Line - }, len(partKeys)) - for i, key := range partKeys { - parts[i].key, parts[i].cents, parts[i].color, parts[i].lines = key, shares[i], partColors[i], lines[i] + }{ + {"contrib", shares[1], "−", contrib}, + {"taxes", shares[2], "−", tax}, + {"other", shares[3], "−", other}, } - var total int64 - for _, pt := range parts { - total += pt.cents + top := shares[0] + shares[1] + shares[2] + shares[3] + if p.Gross.Found() { + top = p.Gross.Cents + added } - if total == 0 { + if top <= 0 { return m } - for _, pt := range parts { - if pt.cents == 0 && len(pt.lines) == 0 { + pct := func(c int64) float64 { return float64(c) * 100 / float64(top) } + if p.Gross.Found() { + m.Parts = append(m.Parts, Part{Key: "gross", Label: i18n.T(l, "report.earnings"), Amount: money.Format(p.Gross.Cents) + " €", Width: pct(p.Gross.Cents), Lines: m.Earnings}) + if added > 0 { + m.Parts = append(m.Parts, Part{Key: "plus", Label: i18n.T(l, "report.part.plus"), Amount: "+" + money.Format(added) + " €", + Text: i18n.T(l, "report.part.plus.text"), From: pct(p.Gross.Cents), Width: pct(added), Lines: plus}) + } + } + at := top + for _, st := range steps { + if st.cents == 0 && len(st.lines) == 0 { continue } - label := i18n.T(l, "report.part."+pt.key) - if pt.key == "other" && 2*withheld > pt.cents { + label := i18n.T(l, "report.part."+st.key) + if st.key == "other" && 2*withheld > st.cents { label = i18n.T(l, "report.part.other.fund") } - m.Parts = append(m.Parts, Part{ - Key: pt.key, - Label: label, - Amount: money.Format(pt.cents) + " €", - Text: i18n.T(l, "report.part."+pt.key+".text"), - Color: pt.color, - Width: float64(pt.cents) * 100 / float64(total), - Lines: pt.lines, - }) + at -= st.cents + m.Parts = append(m.Parts, Part{Key: st.key, Label: label, Amount: st.sign + money.Format(st.cents) + " €", + Text: i18n.T(l, "report.part."+st.key+".text"), From: pct(at), Width: pct(st.cents), Lines: st.lines}) } + m.Parts = append(m.Parts, Part{Key: "net", Label: i18n.T(l, "report.part.net"), Amount: money.Format(shares[0]) + " €", + Text: i18n.T(l, "report.part.net.text"), Width: pct(shares[0])}) return m } +// outside reports whether the payslip's printed gross leaves out the items +// that some payroll software leaves out: the other earnings add up to it. +func outside(p payslip.Payslip) bool { + if !p.Gross.Found() { + return false + } + var in int64 + for _, it := range p.Items { + if it.Side == payslip.Earning && !payslip.OutsideGross(it.Key) { + in += it.Cents + } + } + return in == p.Gross.Cents +} + // The shares of the gross pay, in the order the bar and the columns stack // them, with their colors. var ( diff --git a/internal/report/report.html.tmpl b/internal/report/report.html.tmpl index 7660899..416b185 100644 --- a/internal/report/report.html.tmpl +++ b/internal/report/report.html.tmpl @@ -123,15 +123,9 @@ p { margin: 0 0 12px; } .net .label { font-size: 14px; color: var(--ink-2); } .net .big { font-size: 44px; line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; } .summary { margin: 12px 0 0; font-size: 17px; max-width: 34em; } -.bar { display: flex; gap: 2px; height: 28px; margin: 12px 0 6px; } -.bar .seg { border: 0; padding: 0; min-width: 4px; cursor: pointer; } -.bar .seg:first-child { border-radius: 6px 0 0 6px; } -.bar .seg:last-child { border-radius: 0 6px 6px 0; } -.bar .seg:hover, .bar .seg:focus-visible { opacity: .8; } .hint { font-size: 14px; color: var(--muted); margin: 0 0 8px; } .part-net { --c: var(--series-1); } .part-contrib { --c: var(--series-2); } .part-taxes { --c: var(--series-3); } .part-other { --c: var(--series-4); } -.part-gross, .part-aside { --c: transparent; } -.bar .seg { background: var(--c); } +.part-gross { --c: var(--muted); } .part-aside { --c: transparent; } .parts { border-top: 1px solid var(--grid); } .part { margin: 0; border-bottom: 1px solid var(--grid); } .part > summary { display: flex; align-items: center; gap: 10px; padding: 12px 0; color: var(--ink); font-size: 16px; list-style: none; } @@ -139,6 +133,12 @@ p { margin: 0 0 12px; } .part > summary .key { flex: none; width: 12px; height: 12px; border-radius: 3px; background: var(--c); border: 1px solid var(--c); } .part > summary .name { flex: 1; } .part > summary .amount { font-variant-numeric: tabular-nums; font-weight: 600; } +/* Each part's span on the way from the gross pay to the net. */ +.part > summary { flex-wrap: wrap; } +.part > summary .track { order: 4; flex: 0 0 calc(100% - 40px); position: relative; height: 8px; margin: 0 0 0 22px; border-radius: 4px; background: color-mix(in srgb, var(--grid) 60%, transparent); } +.part > summary .fill { position: absolute; top: 0; bottom: 0; min-width: 3px; border-radius: 4px; background: var(--c); } +.part-gross > summary .amount, .part-net > summary .amount { font-weight: 700; } +.part-plus { --c: var(--good); } .part > summary::after { content: "›"; color: var(--muted); transition: transform .15s; } .part[open] > summary::after { transform: rotate(90deg); } .part .body { padding: 0 0 12px 22px; } @@ -550,14 +550,6 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); } hit.addEventListener("click", function () { if (pointer === "mouse") openMonth(i); }); }); }); - // A segment of the bar opens its line below. - document.querySelectorAll(".bar .seg").forEach(function (seg) { - seg.addEventListener("click", function () { - var month = seg.closest("section.month"); - month.querySelectorAll("details.part").forEach(function (d) { d.open = d.dataset.part === seg.dataset.part; }); - month.querySelector('details.part[data-part="' + seg.dataset.part + '"]').scrollIntoView({ block: "nearest" }); - }); - }); // Inside the web app the page shows the tabs: it is told which there // are, and asks for one in turn. if (embedded && tabs.length) { @@ -674,15 +666,10 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
{{if $m.Parts}}

{{index $.T "report.where"}}

-{{if $m.Earnings}}
-{{index $.T "report.earnings"}}{{$m.GrossTotal}} -
    {{range $m.Earnings}}{{template "line" .}}{{end}}
-
{{end}} -
{{range $m.Parts}}{{end}}
{{range $m.Parts}}
-{{.Label}}{{.Amount}} -

{{.Text}}

+{{.Label}}{{.Amount}} +
{{with .Text}}

{{.}}

{{end}} {{if .Lines}}
    {{range .Lines}}{{template "line" .}}{{end}}
{{end}}
{{end}}
diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 77a96b6..a0e7325 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "html" + "math" "slices" "strings" "testing" @@ -62,21 +63,37 @@ func TestBuild(t *testing.T) { if d.Trend == "" { t.Error("no trend for three months") } + // The parts step down from the gross pay to the net, each starting + // where the one before it ended, and the net pay with the deductions + // make up the whole. var width float64 keys := map[string]bool{} + at := 100.0 for _, p := range m.Parts { - width += p.Width keys[p.Key] = true - if p.Text == "" || p.Amount == "" { + if p.Amount == "" || (p.Text == "" && p.Key != "gross") { t.Errorf("part %+v", p) } + switch p.Key { + case "gross", "plus": + case "net": + width += p.Width + default: + width += p.Width + if math.Abs(p.From+p.Width-at) > 0.01 { + t.Errorf("%s spans %.2f+%.2f, want to end at %.2f", p.Key, p.From, p.Width, at) + } + at = p.From + } } - if !keys["net"] || !keys["contrib"] || !keys["taxes"] || width < 99.9 || width > 100.1 { + if m.Parts[0].Key != "gross" || m.Parts[len(m.Parts)-1].Key != "net" || !keys["contrib"] || !keys["taxes"] || width < 99.9 || width > 100.1 { t.Errorf("parts %+v sum to %.2f%%", m.Parts, width) } lines := len(m.Earnings) + len(m.SetAside) for _, p := range m.Parts { - lines += len(p.Lines) + if p.Key != "gross" { + lines += len(p.Lines) + } } if lines != len(slips[2].Items) { t.Errorf("%d lines for %d items", lines, len(slips[2].Items)) @@ -489,8 +506,10 @@ func TestPensionFundInContributions(t *testing.T) { if m.AsideTotal != "960,00 €" || m.Withheld != "Of this, 700,00 € is withheld from your pay: you also find it among the other deductions." || m.Fund != "" { t.Errorf("set aside %q, withheld %q, fund %q", m.AsideTotal, m.Withheld, m.Fund) } - if other := m.Parts[len(m.Parts)-1]; other.Label != "Other deductions, mostly the pension fund" { - t.Errorf("other deductions labeled %q", other.Label) + for _, pt := range m.Parts { + if pt.Key == "other" && pt.Label != "Other deductions, mostly the pension fund" { + t.Errorf("other deductions labeled %q", pt.Label) + } } // A TFR kept by the employer is set aside but not paid into the fund. kept := slip(9, 70000, 142430) @@ -642,3 +661,24 @@ func TestYears(t *testing.T) { t.Errorf("one year: %d views", len(one.Views)) } } + +// TestAddedToGross shows what reaches the net pay without being part of +// the gross, such as a contribution relief, as a step up after the gross. +func TestAddedToGross(t *testing.T) { + a := func(c int64) payslip.Amount { return payslip.Amount{Cents: c, Status: payslip.Verified} } + p := payslip.Payslip{Year: 2026, Month: 3, Kind: payslip.Monthly, + Items: []payslip.Item{ + {Description: "Retribuzione", Key: "earnings.base_salary", Side: payslip.Earning, Cents: 200000}, + {Description: "Esonero contributivo", Key: "earnings.contribution_relief", Side: payslip.Earning, Cents: 10000}, + }, + Gross: a(200000), Net: a(160000), Deductions: a(50000), SocialContributions: a(18000), Taxes: a(32000)} + m := Build(i18n.EN, []payslip.Payslip{p}, "test").Months[0] + var got []string + for _, pt := range m.Parts { + got = append(got, fmt.Sprintf("%s %s %.0f+%.0f %d", pt.Key, pt.Amount, pt.From, pt.Width, len(pt.Lines))) + } + want := []string{"gross 2.000,00 € 0+95 1", "plus +100,00 € 95+5 1", "contrib −180,00 € 91+9 0", "taxes −320,00 € 76+15 0", "net 1.600,00 € 0+76 0"} + if !slices.Equal(got, want) { + t.Errorf("parts =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} diff --git a/web/test/e2e.test.mjs b/web/test/e2e.test.mjs index 369b4d6..fbc01ef 100644 --- a/web/test/e2e.test.mjs +++ b/web/test/e2e.test.mjs @@ -112,17 +112,17 @@ test('reads payslips in every style and rotation and shows the report', async () 'ok: — 1 payslip(s) read', ]); const frame = page.frames().find((f) => f !== page.mainFrame()); - await frame.waitForSelector('section.month:not([hidden]) .bar'); + await frame.waitForSelector('section.month:not([hidden]) .parts'); // The frame grows to its content: shorter on this wide window, where // the month takes two columns. await page.waitForFunction(() => parseInt(document.getElementById('report').style.height, 10) > 400); - // One payslip at a time: the picker switches, and a segment of the bar + // One payslip at a time: the picker switches, and a step from the gross // opens its explanation. const shown = () => frame.$$eval('section.month', (ms) => ms.filter((m) => !m.hidden).map((m) => m.dataset.month)); assert.deepEqual(await shown(), ['1']); await frame.click('.picker button[data-month="0"]'); assert.deepEqual(await shown(), ['0']); - await frame.click('section.month:not([hidden]) .bar .seg.part-taxes'); + await frame.click('section.month:not([hidden]) details.part-taxes summary'); assert.equal(await frame.$eval('section.month:not([hidden]) details.part-taxes', (d) => d.open), true); assert.match(await frame.$eval('section.month:not([hidden]) details.part-taxes', (d) => d.textContent), /IRPEF/); assert.deepEqual(seen.errors, []); @@ -208,7 +208,7 @@ test('the report frame fits its content on a phone', async () => { await page.setInputFiles('#files', [synth('phone.pdf')]); await statuses(page); const frame = page.frames().find((f) => f !== page.mainFrame()); - await frame.waitForSelector('section.month:not([hidden]) .bar'); + await frame.waitForSelector('section.month:not([hidden]) .parts'); const height = () => page.$eval('#report', (f) => parseInt(f.style.height, 10) || 0); await page.waitForFunction(() => parseInt(document.getElementById('report').style.height, 10) > 500); const first = await height(); @@ -234,7 +234,7 @@ test('shows the latest of many months first', async () => { await page.setInputFiles('#files', files); await statuses(page); const frame = page.frames().find((f) => f !== page.mainFrame()); - await frame.waitForSelector('section.month:not([hidden]) .bar'); + await frame.waitForSelector('section.month:not([hidden]) .parts'); // The frame is laid out hidden first; what counts is the report shown. await page.waitForSelector('#report:not(.pending)'); const visible = await frame.$$eval('section.month', (ms) => ms.filter((m) => getComputedStyle(m).display !== 'none').map((m) => m.querySelector('.when').textContent)); @@ -475,7 +475,7 @@ test('makes an anonymized copy that reads as the same payslip', async () => { await page.setInputFiles('#files', [copy]); assert.deepEqual(await statuses(page), ['ok: — 1 payslip(s) read']); const frame = page.frames().find((f) => f !== page.mainFrame()); - await frame.waitForSelector('section.month:not([hidden]) .bar'); + await frame.waitForSelector('section.month:not([hidden]) .parts'); assert.match(await frame.content(), /1\.667,40/); assert.deepEqual(seen.errors, []); onlyThisSite(seen); From 99bcd32218a890a59079157880e0f7075b2afc33 Mon Sep 17 00:00:00 2001 From: danielededo <37807398+danielededo@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:03:08 +0000 Subject: [PATCH 2/2] feat(report): the month opens with one line less The line with the month and whether the numbers add up, and the "Net pay" label under it, become one line: "Net pay for October 2026", with the arrows to the month before and after. Whether the numbers add up moves beside the gross pay under the amount. --- CHANGELOG.md | 2 ++ internal/i18n/i18n.go | 2 ++ internal/report/report.go | 2 ++ internal/report/report.html.tmpl | 8 +++----- web/test/e2e.test.mjs | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e134a5..3bd70b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ How to use this file: ### Changed +- The month opens with one line less: "Net pay for October 2026" over the + amount, and whether the numbers add up beside the gross pay below it. - "Where your gross pay goes" reads as a waterfall: from the gross pay, what is added to it, such as an expense refund or a contribution relief, then each deduction, down to the net pay, each with a span that starts diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index c084db7..f836880 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -129,6 +129,7 @@ var catalog = map[Lang]map[string]string{ "report.privacy": "Made on your device with no network connection. It holds your payslip data: keep it private.", "report.disclaimer": "cedolo explains what your payslips say; it is not tax, legal or labour advice and it can be wrong. For decisions, ask your payroll office, a CAF, a patronato or union office, or a consulente del lavoro.", "report.net": "Net pay", + "report.net_of": "Net pay for %s", "report.gross": "Gross pay", "report.deductions": "Deductions", "report.vs_previous": "%s vs previous month", @@ -332,6 +333,7 @@ var catalog = map[Lang]map[string]string{ "report.privacy": "Creato sul tuo dispositivo, senza connessioni a internet. Contiene i dati dei tuoi cedolini: tienilo riservato.", "report.disclaimer": "cedolo spiega cosa dicono i tuoi cedolini; non è una consulenza fiscale, legale o del lavoro e può sbagliare. Per decidere, rivolgiti all'ufficio paghe, a un CAF, a un patronato o sindacato, o a un consulente del lavoro.", "report.net": "Netto", + "report.net_of": "Netto di %s", "report.gross": "Lordo", "report.deductions": "Trattenute", "report.vs_previous": "%s rispetto al mese precedente", diff --git a/internal/report/report.go b/internal/report/report.go index fb268e3..11ebecf 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -47,6 +47,7 @@ type Month struct { Prev, Next string // the index of the month before and after, empty at either end Status Status StatusText string + NetOf string // "Netto di ottobre 2026", over the net pay Net string Gross string // "of 2.194,50 € gross pay" Delta string // change of the net from the previous month, empty when unknown @@ -474,6 +475,7 @@ func month(l i18n.Lang, p payslip.Payslip, per insights.Period) Month { Status: st, StatusText: i18n.T(l, "report."+string(st)), Net: amount(p.Net), + NetOf: i18n.T(l, "report.net_of", per.Label), } m.Reread = reread(l, p) if p.Adjustment { diff --git a/internal/report/report.html.tmpl b/internal/report/report.html.tmpl index 416b185..f0d9472 100644 --- a/internal/report/report.html.tmpl +++ b/internal/report/report.html.tmpl @@ -118,9 +118,7 @@ p { margin: 0 0 12px; } .yearpick button[aria-pressed="true"] { background: var(--raised); color: var(--ink); font-weight: 600; box-shadow: 0 1px 2px rgb(0 0 0 / .12); } .month { margin-top: 24px; } .when { color: var(--ink-2); margin: 0; } -.when .status { font-size: 14px; margin-left: 8px; } .net { margin: 4px 0 0; display: flex; flex-direction: column; } -.net .label { font-size: 14px; color: var(--ink-2); } .net .big { font-size: 44px; line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; } .summary { margin: 12px 0 0; font-size: 17px; max-width: 34em; } .hint { font-size: 14px; color: var(--muted); margin: 0 0 8px; } @@ -642,9 +640,9 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); } {{range $i, $m := .Months}}
-

{{$m.Label}} {{$m.StatusText}}{{if gt (len $.Months) 1}}{{end}}

-

{{index $.T "report.net"}}{{$m.Net}}

-{{if $m.Gross}}

{{$m.Gross}}

{{end}} +

{{$m.NetOf}}{{if gt (len $.Months) 1}}{{end}}

+

{{$m.Net}}

+

{{with $m.Gross}}{{.}} · {{end}}{{$m.StatusText}}

{{if $m.Summary}}

{{$m.Summary}}

{{end}} {{range $m.SummaryWhy}}

{{.}}

{{end}} {{with $m.Reread}}

{{.}}

{{end}} diff --git a/web/test/e2e.test.mjs b/web/test/e2e.test.mjs index fbc01ef..9771c6a 100644 --- a/web/test/e2e.test.mjs +++ b/web/test/e2e.test.mjs @@ -239,7 +239,7 @@ test('shows the latest of many months first', async () => { await page.waitForSelector('#report:not(.pending)'); const visible = await frame.$$eval('section.month', (ms) => ms.filter((m) => getComputedStyle(m).display !== 'none').map((m) => m.querySelector('.when').textContent)); assert.equal(visible.length, 1); - assert.match(visible[0], /^September 2026/); + assert.match(visible[0], /^Net pay for September 2026/); assert.equal(await frame.$eval('.picker button[aria-pressed="true"]', (b) => b.textContent), 'Sep 26'); // On a phone the row of months is wider than the screen: it opens // scrolled to the marked month, not to January.