Old-School Front-End / 2026 ・ No.13

Old-School Front-End

Part 13: totals and printing — add up by month, draw a bar chart by hand, and put it on paper

Published

The dashboard for Ichiro Furui (approver), July to October 2026. Total counted ¥282,081, 18 claims, ¥11,900 awaiting approval. A bar chart of monthly totals: Jul 67,720, Aug 57,660, Sep 65,730, Oct 90,971, with the same numbers in a table on the right. Below, the category breakdown (Travel 126,170, 44.7%, and so on, with red bars) and totals by person (Taro Arai 9 claims ¥181,301, Ken Sato 5 claims ¥53,900, Hanako Yamada 4 claims ¥46,880)
This part's goal: a dashboard that adds up approved and paid claims by month, category and person. (The author's demo page, served by a practice server and captured in Chromium in the cloud while Playwright drove it. Captured on 19 October 2026 with the page clock fixed at 3:00 PM on 17 October 2026. The accounts are made up for this demo. The browser frame was drawn in for illustration.)

An “old-school” front-end course: no AI doing it for you, every line typed by hand. Part 13 is about totals and printing.
The menu has had a “Dashboard” link all along, leading to nothing.
This time it gets tables that add claims up by month, category and person, and a bar chart drawn by hand in SVG.
Then we make it print nicely on paper, which the head of accounts loves. Along the way we step on an old trap with dates and time zones.

1

This part's goal

FuruiThe head of accounts wants something: “Expenses since July, by month, on paper.”

AraiPaper…? Not a PDF?

FuruiPaper. Apparently there will be red pen.

AraiWe'll need a chart, then. Chart.js, one line. Three seconds with the AI.

FuruiNothing wrong with a library. But today we draw it ourselves. A bar chart is, in the end, just rectangles in a row. Know what's inside and you'll notice when a library draws something odd.

You are here: part 13

1 Setup 2 HTML 3 Forms 4 CSS basics 5 Layout 6 Bootstrap 7 JS basics 8 DOM, events 9 Validation 10 Fetch 11 Sign-in 12 Approvals 13 Charts, print 14 jQuery 15 Finish You are here Setup HTML CSS JavaScript Real app Finish
  • Decide what counts — add up only approved and paid claims; show the ones awaiting approval separately
  • Group and add — by month, category and person, all with one function and a different key
  • Draw the bar chart by hand — SVG rectangles placed with JavaScript; the numbers go in a table too
  • Put it on paper — print CSS that drops the menu, adds the time and who printed it, and tidies page breaks

On the other end is last part's practice server plus /api/report for the totals. There's a bit more data, too. The July to September claims, the story goes, were moved over from the Excel ledger Furui used to keep (hence numbers like XL-2026-07-01). A new claimant, Ken Sato ([email protected] / demo-sato), has joined. It's all made-up demo data.

2

What to count

“Total expenses” sounds simple, but the number depends on what goes in. Claims awaiting approval? Sent-back ones? Drafts? Decide that before writing any code.

Add up only what will be, or has been, paid out

Approved Paid Submitted Draft Sent back Counted paid out, or will be Awaiting (shown apart) Not counted not sent, or to redo Same word “total”, different numbers. Decide, then say so on the page The page doesn't decide; it's an agreement with accounts

After talking it over with the head of accounts (in the story), we count approved and paid claims. Submitted ones may not be paid yet, so they stay out of the total and appear on their own card as awaiting approval. Drafts and sent-back claims aren't counted. That agreement gets a name at the top of the code.

const COUNTED = ['approved', 'paid']; // Only count claims that will be (or have been) paid

As in part 11, the server decides whose claims you can see: /api/report gives approvers everyone's claims and claimants only their own. The page just adds up what it gets.

3

Grouping and adding up

Monthly totals mean “split the claims by month, then add up each group”. Make a label to group by (a key) and add to the box for that key. The boxes live in a Map.

Make a key, add to the box with that key

Example: four claims 2026-07-01 1,980 2026-07-18 49,600 2026-08-06 1,240 2026-09-01 38,440 new Map() '2026-07' { count: 2, amount: 51,580 } '2026-08' { count: 1, amount: 1,240 } '2026-09' { count: 1, amount: 38,440 } '2026-10' missing → 0 via monthRange 1 Make the key: monthOf(c.date), the first 7 characters 2 Take it out of the Map and add; start from { count: 0, amount: 0 } A Map only holds keys it has seen; empty months vanish By category or by person: the same sumBy, another key
/** Group items by keyOf(item) and add up the count and the amount */
function sumBy(items, keyOf) {
  const totals = new Map();
  for (const item of items) {
    const key = keyOf(item);
    const t = totals.get(key) ?? { count: 0, amount: 0 };
    t.count += 1;
    t.amount += item.amount;
    totals.set(key, t);
  }
  return totals;
}

Part 7's calcTotal added everything into one number. sumBy is the per-key version. Because the key is made by a function you pass in, it works by month, by category and by person. ?? means “if the left is missing, use the right”: a new key starts from a box with 0 claims and 0 yen. Some people would write this in one line with reduce; here it's a step-by-step for…of, as in part 7.

Here is where it's used.

const counted = report.claims.filter((c) => COUNTED.includes(c.status));
const waiting = report.claims.filter((c) => c.status === 'submitted');
const total = calcTotal(counted); // the function from part 7

// by month
const byMonth = sumBy(counted, (c) => monthOf(c.date));
const months = monthRange(report.from, report.to).map((m) => ({ label: monthLabel(m), ...(byMonth.get(m) ?? { count: 0, amount: 0 }) }));
drawBars(document.querySelector('#chart-month'), months.map((m) => ({ label: m.label, value: m.amount })));
document.querySelector('#by-month').replaceChildren(...months.map((m) => row([m.label, String(m.count), yen(m.amount)], [1, 2])));
document.querySelector('#by-month-total').replaceChildren(row(['Total', String(counted.length), yen(total)], [1, 2]));

// Categories live on the line items, not the claims, so collect the rows first, then add
const lines = counted.flatMap((c) => c.rows);
const byCat = [...sumBy(lines, (r) => r.category)].sort((a, b) => b[1].amount - a[1].amount);
  • The list of months comes from monthRange, not the Map. A Map only holds months that had claims, so empty months would silently drop out of the chart
  • Categories live on the line items, not on the claims, so flatMap lays all the rows out first. The New York trip goes in as three rows: travel, hotel and meetings

AraiCouldn't a plain object {} do this instead of a Map?

FuruiIt could. But an object comes with things like toString already hanging off it. When you don't know what the keys will be, an empty Map is safer.

4

Cut dates as text

The month key is '2026-10-16'.slice(0, 7): the first seven characters. “It's a date, though. Isn't the proper way to make a Date and call getMonth()?” Let's try.

# The month of a Date made from the same text (measured in Chromium, changing the time zone)
time zone new Date('2026-07-01') new Date('2026-07-01T00:00') .slice(0, 7)
Tokyo Jul Jul 2026-07
Shanghai Jul Jul 2026-07
London Jul Jul 2026-07
Los Angeles Jun Jul 2026-07   ← 30 June, 5 PM

# What new Date('2026-07-01') holds in Los Angeles: Tue Jun 30 2026 17:00:00

Only in Los Angeles did July turn into June. A date-only '2026-07-01' is, by the rules, read as midnight UTC on 1 July. At that instant it's 9 AM on 1 July in Tokyo, but still 5 PM on 30 June in Los Angeles, so asking “which month?” gets June. To make it more confusing, '2026-07-01T00:00' with a time is read as local midnight. In part 9 it was “today” that shifted; this time it's the month.

The same new Date('2026-07-01') is a different date in different places

UTC 6/30 7/1 7/1 0:00 Tokyo 6/30 7/1 7/1 9:00 Los Angeles 6/30 7/1 6/30 17:00 new Date('2026-07-01') Date-only '2026-07-01' is read as midnight UTC With a time, '2026-07-01T00:00' is local midnight (the confusing part) Just grouping by month? Skip Date: '2026-07-01'.slice(0, 7)

We tried it on the dashboard: a browser on Los Angeles time, with monthOf swapped for a version that makes a Date first.

# The monthly table, opened on Los Angeles time (measured in Chromium)
month cut the text (right) Date first, then month
Jul  4 /  67,720  4 /  87,740
Aug  4 /  57,660  4 /  74,100
Sep  4 /  65,730  3 /  27,290
Oct  6 /  90,971  6 /  90,971

rows added up 17 / 280,101
the Total row 18 / 282,081

Every claim made on the 1st moved back a month. The ¥1,980 from 1 July went to June and fell off the table. The rows add up to 17 claims and ¥280,101, but the Total row says 18 and ¥282,081. The Total row adds up all the claims, so it doesn't shift. This is exactly why accountants who build totals by hand add up the rows and the columns at the end to cross-check. Testing only in Tokyo, you'd never see this trap.

If all you need is to group by month, the safest thing is not to turn the date into a Date at all. To show “Oct” on screen, build the date from numbers, like new Date(2026, 9, 1); that form uses local time, so it doesn't shift.

/** '2026-10-16' → '2026-10'. Don't turn the date into a Date; just cut the text (the trap in section 4) */
function monthOf(date) {
  return date.slice(0, 7);
}

/** List every month from '2026-07' to '2026-10', none skipped (months with no claims show 0) */
function monthRange(from, to) {
  const months = [];
  let [y, m] = from.split('-').map(Number);
  const [toY, toM] = to.split('-').map(Number);
  while (y < toY || (y === toY && m <= toM)) {
    months.push(`${y}-${String(m).padStart(2, '0')}`);
    m += 1;
    if (m > 12) { y += 1; m = 1; }
  }
  return months;
}

/** '2026-10' → the month the way the language writes it ('Oct' in English) */
function monthLabel(month) {
  const [y, m] = month.split('-').map(Number);
  return new Date(y, m - 1, 1).toLocaleDateString(LOC, { month: 'short' }); // pass year, month and day as numbers and it is built in local time
}
5

Drawing a bar chart by hand

The chart is SVG. The HTML holds one <svg id="chart-month">, and JavaScript adds rectangles (rect) and text (text) to it. First, how the coordinates work.

In SVG, y grows downward

x y (0, 0) h y H − BOTTOM Origin: top left y grows downward y = H − BOTTOM − h Bar top; draw h down viewBox="0 0 640 240" Coordinates; CSS sizes it Bar height = amount ÷ largest amount; the biggest month fills it Make elements with createElementNS (measured in section 5)
/** Make an SVG element: createElementNS, not createElement (it needs the namespace) */
function svgEl(name, attrs = {}, text = '') {
  const el = document.createElementNS(SVG_NS, name);
  for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
  el.textContent = text;
  return el;
}

/** Draw a vertical bar chart. data = [{ label, value }] */
function drawBars(svg, data) {
  const W = 640, H = 240, TOP = 28, BOTTOM = 30;
  const max = Math.max(...data.map((d) => d.value), 1);
  const step = W / data.length;
  const barW = step * 0.55;
  const parts = [svgEl('line', { x1: 0, y1: H - BOTTOM, x2: W, y2: H - BOTTOM, class: 'chart-axis' })];
  data.forEach((d, i) => {
    const h = (H - TOP - BOTTOM) * d.value / max;
    const x = step * i + (step - barW) / 2;
    const y = H - BOTTOM - h; // SVG's y axis points down: top of a bar = axis position − bar height
    parts.push(svgEl('rect', { x, y, width: barW, height: h, class: 'chart-bar' }));
    parts.push(svgEl('text', { x: x + barW / 2, y: y - 8, class: 'chart-value' }, d.value.toLocaleString(LOC)));
    parts.push(svgEl('text', { x: x + barW / 2, y: H - 8, class: 'chart-label' }, d.label));
  });
  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
  svg.replaceChildren(...parts);
}

The largest month gets the full height and the others shrink in proportion. Each bar has its amount above it and its month below. The colour comes from CSS (.chart-bar { fill: #1f4e6b; }), not an SVG attribute. Because viewBox fixes the coordinate space at 640×240, CSS width: 100% alone lets it grow and shrink with the screen.

Here is a trap every old-schooler has stepped in once. Inside svgEl it's createElementNS, not createElement. What happens with a plain createElement('rect')?

# Adding a rect inside <svg> (measured in Chromium)
made with namespace what it is size
createElement http://www.w3.org/1999/xhtml HTMLUnknownElement 0 × 0   <- not drawn
createElementNS http://www.w3.org/2000/svg SVGRectElement 84 × 42

A rect made with createElement becomes an HTML “unknown element” (HTMLUnknownElement), size 0, and nothing is drawn. No error, either. SVG elements belong to a different namespace from HTML (http://www.w3.org/2000/svg), and you have to name it when you make them.

The chart has role="img" and aria-labelledby, taking its name from the caption underneath (figcaption). The numbers themselves are all in the table next to it. The chart is a picture you take in at a glance; the exact numbers are in the table. The red pen on the printout will probably land on the table, too.

6

Rounded shares

The category breakdown shows each share of the total to one decimal place. Let's add them up.

# Shares by category (toFixed(1); measured in Chromium)
category amount (JPY) share
Travel   126,170  44.7%
Hotel    69,058  24.5%
Meetings    45,723  16.2%
Other    30,280  10.7%
Supplies    10,850   3.8%
added up  99.9%

99.9%. Not a calculation error: when you round each one separately, the bits rounded up and rounded down don't always cancel out. Write “100.0%” on the Total row and whoever checks the paper will say it doesn't add up. So the Total row has no share, and a line under the table says the shares may not add up to exactly 100.0%. If they must add up, agree a rule with accounts, such as giving the difference to the item with the largest remainder.

AraiNobody cares about 0.1%.

FuruiThe head of accounts does. With a red pen.

7

On paper

All the Print button does is open the browser's print screen with window.print(). How it looks on paper is written in CSS, inside @media print. Screen and paper need different things.

Drop what can't be clicked on paper; add what only paper needs

screen paper CSS header (menu, Sign out) d-print-none Print button d-print-none print heading (who, when) d-print-block chart bars (SVG fill) fill category bars (background) print-color-adjust table rows (page breaks) break-inside Drop what can't be clicked on paper; add what only paper needs By default, browsers don't print backgrounds (to save ink)

Bootstrap classes handle the dropping and adding. d-print-none hides something only when printing; d-none d-print-block hides it on screen and shows it only when printing.

<header class="d-print-none navbar navbar-expand-md navbar-dark brand-bar"> … </header>

<p class="print-head d-none d-print-block"><strong>Expense report</strong> <span id="print-meta"></span></p>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-1">
  <h1 class="h3 mb-0">Dashboard</h1>
  <button type="button" class="btn btn-outline-secondary d-print-none" id="print">Print</button>
…
<footer class="d-print-none py-4 text-center text-body-secondary"> … </footer>

JavaScript fills the print heading with the period, when it was printed and who printed it. Papers get mixed up on desks; “when, and by whom” helps. The rest is CSS we write ourselves.

/* Part 13: the category share bars (painted with a background colour) */
.share-bar {
  display: block;
  height: .6rem;
  border-radius: 2px;
  background-color: #c0392b;
}

/* Part 13: when printing */
@page {
  size: A4;
  margin: 14mm 12mm;
}
@media print {
  body {
    background: #fff !important;
    font-size: 10pt;
  }
  .container {
    max-width: none;
    padding: 0;
  }
  .keep,
  .card,
  tr {
    break-inside: avoid; /* don't split across pages */
  }
  h2 {
    break-after: avoid; /* don't leave a heading alone at the bottom of a page */
  }
  .share-bar {
    -webkit-print-color-adjust: exact; /* for older browsers */
    print-color-adjust: exact; /* print the background colour too (browsers skip backgrounds by default) */
  }
  .report-list th,
  .report-list td {
    white-space: nowrap; /* don't wrap numbers and dates */
  }
  .report-list td:nth-child(4) {
    white-space: normal; /* only the purpose may wrap */
  }
  .print-head {
    border-bottom: 2px solid #212529;
    padding-bottom: .25rem;
  }
}
  • @page sets the paper size and margins
  • break-inside: avoid keeps cards, table rows and the chart block from being split
  • print-color-adjust: exact makes the bars' background colour print

That last one is needed because by default, browsers don't print background colours or images (to save ink; ticking “Background graphics” on the print screen prints them). The category bars are painted with a background colour, so as they are, they vanish on paper. The chart bars, on the other hand, are SVG fill, part of the picture itself, so they stay. We checked by saving PDFs from Chromium.

# How it prints (Chromium: emulating @media print, and saving PDFs)
what we checked result
header (d-print-none) display: none → not printed
Print button display: none
print-only heading (d-print-block) display: block → only on paper
A4 PDF 3 pages; the table on page 3 got its header row too
chart bars (SVG fill) printed even without background graphics
category bars (CSS background) with print-color-adjust: exact → printed / without → gone
Three A4 pages saved as PDF, side by side. Page 1 starts with the heading “Expense report July 2026 – October 2026 ・ Oct 17, 2026, 3:00 PM ・ Ichiro Furui”; the header menu and the Print button are gone. Then the summary cards and the monthly chart and table. Page 2 has the category breakdown (with red bars), totals by person and the table of counted claims. Page 3 continues the table, with its header row repeated at the top
From Print to A4: the menu disappears, a heading with the time and who printed it appears, and the table keeps its header row across pages. (The author's demo page, served by a practice server and captured in Chromium in the cloud while Playwright drove it. Captured on 19 October 2026 with the page clock fixed at 3:00 PM on 17 October 2026. The accounts are made up for this demo. The browser frame was drawn in for illustration.)

Page 1 has the summary and the monthly chart, page 2 the breakdowns and the list of claims, page 3 the rest of the list. The table on page 3 has its header row again (a table's thead repeats when it runs across pages). The category breakdown didn't fit at the bottom of page 1, so break-inside: avoid sent the whole block to page 2. When you print from a real browser, it may add the URL and date in the margins; turn that off with “Headers and footers” on the print screen.

8

Taking it for a spin

We started the practice server and opened the dashboard with Playwright as Furui (approver) and as Arai (claimant). We also tried a browser on Los Angeles time and a PDF without background graphics.

Three scenes. 1 The dashboard as Taro Arai (claimant) sees it: ¥181,301 counted, 9 claims, ¥0 awaiting; by month Jul 51,580, Aug 5,200, Sep 38,440, Oct 86,081; no per-person table. 2 Monthly totals on Los Angeles time with dates turned into Date first: Jul 87,740 (4), Aug 74,100 (4), Sep 27,290 (3), Oct 90,971 (6), yet the Total row still says 18 and 282,081. 3 The category breakdown saved as a PDF with background graphics off and print-color-adjust removed: the numbers and shares are there, the red bars are gone
1 what a claimant sees, 2 Date on Los Angeles time, 3 printing without background graphics. (The author's demo page, served by a practice server and captured in Chromium in the cloud while Playwright drove it. Captured on 19 October 2026 with the page clock fixed at 3:00 PM on 17 October 2026. The accounts are made up for this demo. The browser frame was drawn in for illustration.)
# Who opened it (measured in Chromium)
 Furui (approver) Arai (claimant)
total counted ¥282,081 ¥181,301
claims 18 9
awaiting approval ¥11,900 ¥0
per-person table shown not shown
claims counted 18 rows 9 rows
  • Same dashboard.html: Furui sees all 18 claims, Arai his own 9. Arai's page has no per-person table (and the server never sent him anyone else's claims)
  • Arai has ¥0 awaiting approval, because 0012 and 0007 were approved last time
  • With Date on Los Angeles time, claims from the 1st slipped back a month and the rows no longer matched the Total row. Cutting the text gives the same numbers in every time zone
  • With background graphics off, the bars vanished without print-color-adjust and stayed with it
POINT

What matters on a totals page isn't the look; it's numbers that add up. Decide what counts and say so. Treat dates as text. Check that the rows match the Total row, in another time zone too. The chart is those numbers as a picture, and on paper, keep only what the reader of the paper needs.

§ — On one page, and what is next

  1. Before totalling, decide what counts. Here: approved and paid. Show awaiting approval separately.
  2. Group and add with a Map and a key. Build the month list with monthRange so empty months don't vanish.
  3. To group by month, don't make a Date; cut the text. '2026-07-01' is read as midnight UTC.
  4. Make SVG elements with createElementNS. y points down. Put the exact numbers in a table too.
  5. Rounded shares may not add up to 100.0%. Decide how to show them, and say so.
  6. Printing is @media print: hide with d-print-none, tidy with @page and break-inside, and use print-color-adjust for background colours.

AraiI gave the boss the printout, and the Fukuoka trip in July got a red circle. “What's this?”

FuruiThat's your trip. Go and explain it. …Next time: jQuery. We rewrite the DOM, events and fetching from parts 8 to 10 in jQuery and compare them side by side. The old school of the old school.

AraiThe one with dollar signs ($) everywhere.

References

The page photos show the author's demo pages, served by a practice server (Node.js) and captured on 19 October 2026 in Chromium (Playwright) in the cloud, with the page clock fixed at 3:00 PM on 17 October 2026. Segoe UI isn't available there, so the screenshots fall back to other system fonts. The accounts, claims and amounts are made-up data on the practice server. The totals, the month of a Date in each time zone, the difference between createElement and createElementNS, the sum of the shares, and the printed result (what shows when printing, the A4 PDFs, page count, and whether the bars and chart appear) are real values recorded by driving and running the same Chromium with Playwright. The print photo is those PDFs turned into images and laid side by side. Every page's HTML was checked with the Nu Html Checker (vnu) and showed no errors. The characters are fictional.

Old-School Front-End, part 13: totals and printing / 2026.10

← Back to all posts