Old-School Front-End / 2026 ・ No.13
いにしえのフロントエンド入門
第13回 集計と印刷 ── 月ごとに足して、棒グラフを手で描いて、紙に出す
公開
AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第13回は集計と印刷です。
これまでメニューにあった「ダッシュボード」は、押しても何も無いページでした。
今回はそこに、申請を月ごと・区分ごと・人ごとに足した表と、SVG で手描きした棒グラフを置きます。
そして、経理の部長が大好きな紙に、きれいに出るようにします。途中で、日付と時間帯の古い罠も踏みます。
今回のゴール
古井経理の部長から頼まれた。「7月からの経費を、月ごとにまとめて紙でくれ」。
新井紙……ですか。PDF じゃなくて?
古井紙だ。赤ペンで丸をつけるらしい。
新井グラフも要りますよね。Chart.js を入れれば一行ですよ。AI に頼めば3秒です。
古井ライブラリを使うのは悪くない。だが今日は手で描く。棒グラフは、結局のところ長方形を並べるだけだからな。中身が分かっていれば、ライブラリが変な絵を出したときに気づける。
いまここ:第13回
- 何を数えるか決める ── 承認済み・精算済みだけを足す。承認待ちは別の枠で見せる
- グループに分けて足す ── 月ごと・区分ごと・人ごと。どれも同じ関数で、鍵の作り方だけ変える
- 棒グラフを手で描く ── SVG の長方形を JavaScript で並べる。数字は表にも出す
- 紙に出す ── 印刷用の CSS で、メニューを消し、日時と印刷した人を足し、ページの切れ目を整える
相手は前回の練習用サーバーに、集計用の /api/report を足したものです。データも少し増やしました。7〜9月の分は、古井さんがこれまで Excel の台帳で管理していたものを移した、という設定です(番号が XL-2026-07-01 のような形なのはそのため)。申請者に佐藤 健さん([email protected] / demo-sato)も加わりました。どれもデモ専用の架空のデータです。
何を数えるか
「経費の合計」と一口に言っても、何を足すかで数字が変わります。申請中のものを入れるのか。差し戻されたものは? 下書きは? コードを書く前に、これを決めます。
足すのは「お金が出る・出た」ものだけ
今回は経理の部長と相談して(という設定で)、承認済みと精算済みを足すことにしました。申請中のものは、まだ出るか分からないので合計には入れず、「承認待ち」として別のカードに出します。下書きと差し戻しは数えません。この約束は、コードの先頭に名前をつけて置いておきます。
const COUNTED = ['approved', 'paid']; // 集計に入れるのは、お金が出る(出た)ものだけ
だれの申請が見えるかは、第11回と同じくサーバーが決めます。/api/report は、承認者には全員の申請を、申請者には自分の申請だけを返します。画面は、受け取ったものを足すだけです。
グループに分けて足す
月ごとの合計は、「申請を月で分けて、それぞれ足す」ことです。分ける目印(鍵)を作って、鍵ごとの箱に足していきます。箱には Map を使います。
鍵を作って、同じ鍵の箱に足していく
/** items を keyOf(item) ごとに分けて、件数と金額を足す */
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;
}
第7回の calcTotal は「全部を1つに足す」関数でした。sumBy は「鍵ごとに足す」版です。鍵の作り方を関数で渡すので、月ごとにも、区分ごとにも、人ごとにも使えます。?? は「左が無ければ右」。初めて出てきた鍵なら、0 件・0 円の箱から始めます。reduce を使って1行で書く人もいますが、ここでは第7回と同じ for…of で、一歩ずつ書きました。
使うところはこうなります。
const counted = report.claims.filter((c) => COUNTED.includes(c.status));
const waiting = report.claims.filter((c) => c.status === 'submitted');
const total = calcTotal(counted); // 第7回の関数
// 月ごと
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(['合計', String(counted.length), yen(total)], [1, 2]));
// 区分は申請ではなく明細の行についているので、行を集めてから足す
const lines = counted.flatMap((c) => c.rows);
const byCat = [...sumBy(lines, (r) => r.category)].sort((a, b) => b[1].amount - a[1].amount);
- 月の一覧は、Map からではなく monthRange で作る。Map には「申請があった月」しか入らないので、申請の無い月はグラフから黙って消えてしまう
- 区分は申請ではなく明細の行についているので、flatMap で行を一列に並べてから足す。ニューヨーク出張なら、交通費・宿泊費・会議費の3行に分かれて入る
新井Map じゃなくて、ふつうのオブジェクト {} でもできますよね?
古井できる。ただ、オブジェクトには最初から toString なんかがぶら下がっている。鍵に何が来るか分からないときは、何も入っていない Map のほうが安心だ。
日付は文字のまま切る
月の鍵は '2026-10-16'.slice(0, 7)、つまり先頭の7文字です。「日付なんだから、Date にして getMonth() で取るのが正しいのでは?」と思うかもしれません。やってみましょう。
# 同じ文字から作った Date の「月」(Chromium で時間帯を変えて実測) 時間帯 new Date('2026-07-01') new Date('2026-07-01T00:00') .slice(0, 7) 東京 7月 7月 2026-07 上海 7月 7月 2026-07 ロンドン 7月 7月 2026-07 ロサンゼルス 6月 7月 2026-07 ← 6月30日の夕方になった # ロサンゼルスでの new Date('2026-07-01') の中身:Tue Jun 30 2026 17:00:00
ロサンゼルスだけ、7月が6月になりました。日付だけの '2026-07-01' は、世界時(UTC)の7月1日0時として読まれる決まりです。その瞬間、東京は7月1日の朝9時ですが、ロサンゼルスはまだ6月30日の夕方5時。だから「何月?」と聞くと6月と答えます。困ったことに、時刻をつけた '2026-07-01T00:00' のほうは「その場所の0時」として読まれます。第9回では「今日」がずれましたが、今回は「月」がずれます。
同じ new Date('2026-07-01') が、場所によって別の日付になる
ダッシュボードで試しました。ロサンゼルスの時間のブラウザで開き、monthOf を「Date にしてから月を取る」版に差し替えたところです。
# 月ごとの表:ロサンゼルスの時間で開いたとき(Chromium で実測) 月 文字で切る(正しい) Date にしてから月を取る 7月 4 / 67,720 4 / 87,740 8月 4 / 57,660 4 / 74,100 9月 4 / 65,730 3 / 27,290 10月 6 / 90,971 6 / 90,971 表の行を足すと 17 件 / 280,101 合計の行 18 件 / 282,081
1日に出た申請が、ぜんぶ前の月に引っ越しました。7月1日の 1,980 円は6月に行き、表の範囲から消えています。表の行を足すと 17 件・280,101 円なのに、合計の行は 18 件・282,081 円。合計の行は申請を全部足しているので、ずれません。手で集計表を作る経理の人が、最後に縦と横を足して確かめる「検算」は、こういうときのためにあります。東京で試している限り、この罠は見えません。
月で分けるだけなら、日付を Date にしないのがいちばん安全です。画面に「10月」と出すときも、new Date(2026, 9, 1) のように年・月・日を数で渡して作ります。この形は、その場所の時間で作られるので、ずれません。
/** '2026-10-16' → '2026-10'。日付は Date にせず、文字のまま切る(四の罠) */
function monthOf(date) {
return date.slice(0, 7);
}
/** '2026-07' から '2026-10' までの月を、抜けなく並べる(申請のない月も 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' → その言語の月の書き方(ja なら「10月」) */
function monthLabel(month) {
const [y, m] = month.split('-').map(Number);
return new Date(y, m - 1, 1).toLocaleDateString(LOC, { month: 'short' }); // 年・月・日を数で渡すと、その場所の時間で作られる
}
棒グラフを手で描く
グラフは SVG で描きます。HTML の中に <svg id="chart-month"> を1つ置いておき、JavaScript で長方形(rect)と文字(text)を足していきます。まず座標の決まりを押さえます。
SVG の y は下へ増える
/** SVG の要素を作る。createElement ではなく createElementNS(名前空間が要る) */
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;
}
/** 縦の棒グラフを描く。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 の y は下向き。棒の上の端 = 軸の位置 − 棒の高さ
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);
}
いちばん大きい月の金額を「いっぱいの高さ」にして、ほかの月は割合で縮めます。棒の上には金額、下には月を書きます。色は SVG の属性ではなく CSS(.chart-bar { fill: #1f4e6b; })で付けました。viewBox で座標の広さを 640×240 と決めてあるので、画面の幅が変わっても、CSS の width: 100% だけで伸び縮みします。
ここに、いにしえの人が一度は踏む罠があります。svgEl の中は createElement ではなく createElementNS です。ふつうの createElement('rect') で作るとどうなるか。
# <svg> の中に rect を足してみる(Chromium で実測) 作り方 名前空間 中身 大きさ createElement http://www.w3.org/1999/xhtml HTMLUnknownElement 0 × 0 ← 描かれない createElementNS http://www.w3.org/2000/svg SVGRectElement 84 × 42
createElement で作った rect は、HTML の「知らない要素」(HTMLUnknownElement)になり、大きさ 0 で何も描かれません。エラーも出ません。SVG の要素は HTML とは別の名前空間(http://www.w3.org/2000/svg)に属しているので、それを名乗って作る必要があります。
グラフには role="img" と aria-labelledby を付け、下の説明文(figcaption)を名前にしました。数字そのものは、隣の表にも全部出しています。グラフは見てすぐ分かるための絵、正確な数字は表。印刷した紙に赤ペンで丸をつけるのも、たぶん表のほうです。
割合の端数
区分ごとの内訳には、全体に対する割合を小数1桁で出しました。足してみます。
# 区分ごとの割合(toFixed(1)。Chromium で実測) 区分 金額(円) 割合 交通費 126,170 44.7% 宿泊費 69,058 24.5% 会議・交際費 45,723 16.2% その他 30,280 10.7% 消耗品 10,850 3.8% 足すと 99.9%
99.9% になりました。計算の間違いではありません。それぞれを四捨五入すると、切り上げた分と切り捨てた分が打ち消し合わずに残ることがあるのです。合計の行に「100.0%」と書くと、紙の上で検算した人に「合わない」と言われます。そこで合計の行には割合を書かず、表の下に「足しても 100.0% にならないことがあります」と一言添えました。どうしても合わせたいときは、いちばん端数の大きいものに差を寄せる、といった決まりを経理と相談して決めます。
新井0.1% くらい、誰も気にしないですよ。
古井経理の部長は気にする。赤ペンで丸がつく。
紙に出す
「印刷する」ボタンがすることは、window.print() でブラウザの印刷の画面を開くだけです。紙にどう出るかは、CSS の @media print の中に書きます。画面と紙では、要るものが違います。
紙では押せないものを消し、紙でしか要らないものを足す
消す・足すは、Bootstrap のクラスでできます。d-print-none は印刷のときだけ消す、d-none d-print-block は画面では消して印刷のときだけ出す、です。
<header class="d-print-none navbar navbar-expand-md navbar-dark brand-bar"> … </header> <p class="print-head d-none d-print-block"><strong>経費レポート</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">ダッシュボード</h1> <button type="button" class="btn btn-outline-secondary d-print-none" id="print">印刷する</button> … <footer class="d-print-none py-4 text-center text-body-secondary"> … </footer>
印刷用の見出しには、範囲・印刷した日時・印刷した人を JavaScript で入れます。紙は机の上で混ざるので、「いつの、だれが出した紙か」が書いてあると助かります。残りは自分で CSS を書きます。
/* 第13回:区分の割合を表す横棒(背景色で塗る) */
.share-bar {
display: block;
height: .6rem;
border-radius: 2px;
background-color: #c0392b;
}
/* 第13回:印刷のとき */
@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; /* 途中でページが切れないように */
}
h2 {
break-after: avoid; /* 見出しだけがページの最後に残らないように */
}
.share-bar {
-webkit-print-color-adjust: exact; /* 古いブラウザ向け */
print-color-adjust: exact; /* 背景色も印刷する(ブラウザは初期設定で背景を省く) */
}
.report-list th,
.report-list td {
white-space: nowrap; /* 番号や日付を途中で折り返さない */
}
.report-list td:nth-child(4) {
white-space: normal; /* 件名だけは折り返してよい */
}
.print-head {
border-bottom: 2px solid #212529;
padding-bottom: .25rem;
}
}
- @page で紙の大きさと余白を決める
- break-inside: avoid で、カードや表の行、グラフのまとまりを途中で切らない
- print-color-adjust: exact で、横棒の背景色も印刷させる
最後の1つが要るのは、ブラウザが初期設定では背景色や背景画像を印刷しないからです(インクの節約のため。印刷の画面の「背景のグラフィック」にチェックを入れると印刷されます)。区分の横棒は背景色で塗っているので、そのままだと紙では消えます。一方、グラフの棒は SVG の fill、つまり絵そのものなので消えません。Chromium で PDF に出して確かめました。
# 印刷のときの見え方(Chromium。@media print をまねて、PDF にも出して実測) 調べたもの 結果 ヘッダー(d-print-none) display: none → 印刷されない 「印刷する」ボタン display: none 印刷用の見出し(d-print-block) display: block → 印刷だけに出る A4 の PDF 3 ページ。3ページ目の表にも見出しの行が付いた グラフの棒(SVG の fill) 背景を印刷しない設定でも出た 区分の横棒(CSS の背景色) print-color-adjust: exact あり → 出た / なし → 消えた
1ページ目にまとめと月ごとのグラフ、2ページ目に内訳と申請の一覧、3ページ目に一覧の続き。3ページ目の表にも、見出しの行がもう一度付いています(表の thead は、ページをまたぐと繰り返して印刷されます)。区分の内訳は1ページ目の下に入りきらなかったので、break-inside: avoid によって丸ごと2ページ目へ送られました。なお、実際にブラウザから印刷すると、ブラウザが余白に URL や日付を足すことがあります。これは印刷の画面の「ヘッダーとフッター」で消せます。
動かしてみる
練習用サーバーを立て、古井さん(承認者)と新井さん(申請者)で、Playwright で実際に開きました。ロサンゼルスの時間のブラウザと、背景を印刷しない PDF も試しています。
# だれが開いたか(Chromium で実測) 古井(承認者) 新井(申請者) 集計した経費 282,081 円 181,301 円 件数 18 件 9 件 承認待ち 11,900 円 0 円 人ごとの表 出る 出ない 集計に入れた申請 18 行 9 行
- 同じ dashboard.html でも、古井さんには全員の 18 件、新井さんには自分の 9 件。新井さんの画面には人ごとの表が出ない(サーバーも他人の申請を渡していない)
- 新井さんの承認待ちは 0 円。前回、0012 と 0007 が承認されたから
- ロサンゼルスの時間で Date にすると、1日の申請が前の月へずれ、表の行と合計の行が合わなくなった。文字のまま切る版では、どの時間帯でも同じ数字
- 背景を印刷しない設定では、print-color-adjust を外すと横棒が消え、付けると残った
集計の画面で大事なのは、見た目より数字が合っていることです。何を数えるかを決めて書いておく。日付は文字のまま扱う。表の行と合計の行が合うか、時間帯を変えて確かめる。グラフはその数字を絵にしたもので、紙に出すときは、紙を読む人に要るものだけを残します。
結 — 一枚まとめと次回予告
- 合計の前に、何を数えるかを決める。今回は承認済み・精算済み。承認待ちは別に見せる。
- グループに分けて足すのは Map と鍵。月の一覧は monthRange で作り、0 の月も消さない。
- 月で分けるだけなら、日付は Date にせず文字のまま切る。'2026-07-01' は世界時の0時として読まれる。
- SVG の要素は createElementNS で作る。y は下向き。正確な数字は表にも出す。
- 割合の端数は、足しても 100.0% にならないことがある。書き方を決めて、注記する。
- 印刷は @media print。d-print-none で消し、@page と break-inside で整え、背景色は print-color-adjust で。
新井部長に紙を渡したら、7月の福岡出張に赤丸がつきました。「これは何だ」って。
古井それはお前の出張だ。説明してこい。……次回は jQuery。第8〜10回で手で書いた DOM・イベント・通信を jQuery で書き直して、並べて比べる。いにしえの中の、さらにいにしえだ。
新井ドルマーク($)だらけのやつですね。
参考
- MDN — Date.parse() (date-only forms are UTC)
- MDN — Date() constructor
- MDN — Document: createElementNS() method
- MDN — SVG namespaces crash course
- MDN — Printing (CSS)
- MDN — print-color-adjust
- MDN — break-inside
- MDN — @page size
- Bootstrap 5.3 — Display property (Display in print)
ページの写真は、筆者が作ったデモページを、練習用のサーバー(Node.js)経由で2026年10月19日にクラウド上の Chromium(Playwright)で撮影したものです(ページの時計は 2026年10月17日 15:00 に固定)。撮影環境には Hiragino Sans がないため、日本語は Noto Sans CJK JP で表示されています。アカウント・申請・金額は、練習用サーバーの架空のデータです。集計の数字、時間帯ごとの Date の月、createElement と createElementNS の違い、割合の合計、印刷のときの表示と A4 の PDF(ページ数、横棒とグラフの有無)は、同じ Chromium を Playwright で操作・実行して記録した実際の値です。印刷の写真は、その PDF を画像にして並べたものです。各ページの HTML は Nu Html Checker(vnu)でエラーがないことを確かめています。登場人物は架空の人物です。
いにしえのフロントエンド入門 第13回 — 集計と印刷 / 2026.10