自分の会社の数字を出すダッシュボード(素の HTML と JavaScript)で、いちばん上の「現預金残高」が6日あまり ¥NaN のまま出ていました。原因は、帳簿(取引の配列)に、取引ではない行を3件まちがえて入れたことです。その行は日付と題名と本文と状態だけを持ち、種別(type)も金額(amount)も持っていませんでした。
直したあとで、残高・純損益・取引台帳の3つの集計と描く順番だけを小さく作り直して測ると、同じ1行が、集計の書き方ごとに別の顔をしていました。
| 出し方 | 取引0件のとき | 取引ではない行が1件あるとき |
|---|---|---|
| 残高(日ごとに足し込む) | ¥0 | ¥NaN |
| 純損益(種別で絞ってから合計) | ¥0 | ¥0(行は黙って抜ける) |
| 取引台帳(1行ずつ金額を表示) | 描ける | TypeError で止まる |
| 取引台帳より後ろの節 | 描ける | 呼ばれない |
この3つの中で NaN と出るのは残高だけで、純損益は正しそうな ¥0 を出し、取引台帳は例外で止まって後ろの節を道連れにします。
(当時の画面には、この再現に入れていない部品もありました。日次の棒グラフと残高推移の折れ線グラフは、縦軸の上限を niceMax(Math.max(...)) で決めており、niceMax(NaN) は NaN を返すので、縦軸の目盛りも NaN になる作りでした。折れ線の終端ラベルも yen() で残高を出すので ¥NaN です。事業別の損益の表には、事業を持たない行があるだけで「未分類」の行が1つ足され、取引0件なのに ¥0 の行が出ていました。いずれも実ファイルのコードを読んで確かめたもので、画面を目で見て確かめてはいません)
1. 何が起きていたか
当時の画面の集計は次の形でした(実ファイルから該当行だけを抜き出しています)。
const yen = n => "¥" + n.toLocaleString("ja-JP");
d[e.type === "income" ? "income" : "expense"] += e.amount;
const totalIncome = D.ledger.filter(e => e.type === "income").reduce((s, e) => s + e.amount, 0);
const totalExpense = D.ledger.filter(e => e.type === "expense").reduce((s, e) => s + e.amount, 0);
取引台帳の各行は ${yen(e.amount)} で金額を出していました。描く順番は renderTiles → … → renderOps → renderTasks → renderLedger → renderApprovals で、render() の中のどの呼び出しも try で包んでいません(途中の非同期の節には中に try を持つものもありますが、取引台帳と承認待ちは同期で呼ばれます)。
種別も金額も無い行がここへ入ると、こうなります。
- 日ごとの集計は、
typeが"income"でないものを全部支出側へ振るので、expense += undefinedでNaNになります。残高はNaNを足し込んでNaNになり、NaN.toLocaleString()は例外を出さずに"NaN"を返します。だから画面には¥NaNと出ます - 合計は先に
typeで絞るので、typeを持たない行はどちらにも入りません。収入0・支出0で、純損益は¥0と出ます。正しそうに見える数字です - 取引台帳は
yen(undefined)を呼びます。undefined.toLocaleStringは TypeError です。この例外で取引台帳の描画が止まり、その後ろの節(承認待ち)は一度も呼ばれません
undefined は足し算では黙って NaN になり、メソッド呼び出しでは例外になる。同じ欠けた値が、触り方によって「静かな壊れ」と「うるさい壊れ」の両方を作っていました。
2. 再現スクリプト
依存なし・ファイルを読み書きしない・通信しない形で、当時の書き方を縮めました。入れたのは残高・純損益・取引台帳の集計と、節を順番に呼ぶ描き方だけです(グラフと事業別の損益は入れていません)。出力の「事業に紐づかない行」は、事業別の損益で「未分類」の行が出る条件(business を持たない行)を数えたものです。
// 金額を持たない行が帳簿の配列に混ざったとき、同じ行が集計ごとに別の顔をすることを測る。
// 依存0・ファイルを読み書きしない・通信しない。
// node scripts/repro-missing-amount.mjs 人間向け
// node scripts/repro-missing-amount.mjs --json 保存用
//
// 集計の書き方は、実際に壊れたダッシュボードの書き方をそのまま縮めたもの:
// 日ごとの集計 d[e.type === "income" ? "income" : "expense"] += e.amount
// 合計 filter(e => e.type === "income").reduce((s, e) => s + e.amount, 0)
// 表示 const yen = n => "¥" + n.toLocaleString("ja-JP")
// 描画 節を順番に呼ぶだけ(try で包まない)
const yen = n => "¥" + n.toLocaleString("ja-JP");
// 取引ではない行(業務日誌の行)。type も amount も持たない。
const stray = { date: "2026-08-28", title: "業務日誌の行", detail: "取引ではない" };
function aggregate(ledger, capital) {
const byDay = new Map();
for (const e of ledger) {
const d = byDay.get(e.date) || { income: 0, expense: 0 };
d[e.type === "income" ? "income" : "expense"] += e.amount;
byDay.set(e.date, d);
}
let cash = capital;
for (const d of byDay.values()) cash += d.income - d.expense;
const totalIncome = ledger.filter(e => e.type === "income").reduce((s, e) => s + e.amount, 0);
const totalExpense = ledger.filter(e => e.type === "expense").reduce((s, e) => s + e.amount, 0);
const unassigned = ledger.filter(e => !e.business).length;
return { cashNow: cash, totalIncome, totalExpense, net: totalIncome - totalExpense, unassigned };
}
function show(v) {
try { return { text: yen(v), error: null }; }
catch (err) { return { text: null, error: err.constructor.name + ": " + err.message }; }
}
// 節を順番に描く。どこかで例外が出たら、そのあとの節は呼ばれない。
function renderAll(ledger, sections, wrap) {
const drawn = [];
let error = null;
for (const [name, fn] of sections) {
if (wrap) {
try { fn(ledger); drawn.push(name); } catch (err) { /* 包むと黙って次へ進む */ }
} else {
try { fn(ledger); drawn.push(name); }
catch (err) { error = { at: name, message: err.constructor.name + ": " + err.message }; break; }
}
}
return { drawn, notDrawn: sections.map(s => s[0]).filter(n => !drawn.includes(n)), error };
}
const sections = [
["タイル", l => yen(aggregate(l, 0).cashNow)],
["業務日誌", () => "ok"],
["取引台帳", l => l.map(e => yen(e.amount)).join("")],
["承認待ち", () => "ok"],
];
function run(label, ledger) {
const agg = aggregate(ledger, 0);
return {
label,
rows: ledger.length,
cashNow: Number.isNaN(agg.cashNow) ? "NaN" : agg.cashNow,
cashShown: show(agg.cashNow),
totalIncome: agg.totalIncome,
totalExpense: agg.totalExpense,
netShown: show(agg.net),
unassignedRows: agg.unassigned,
render: renderAll(ledger, sections, false),
renderWrapped: renderAll(ledger, sections, true),
};
}
const isAmountRow = e => typeof e.amount === "number" && Number.isFinite(e.amount) && (e.type === "income" || e.type === "expense");
const cases = [
run("取引0件(正しい状態)", []),
run("取引ではない行が1件", [stray]),
run("取引ではない行が3件", [stray, stray, stray]),
run("同じ3件を、金額を持つ行だけに絞ってから渡す", [stray, stray, stray].filter(isAmountRow)),
];
const out = { node: process.version, cases };
if (process.argv.includes("--json")) {
console.log(JSON.stringify(out, null, 2));
} else {
for (const c of cases) {
console.log(`■ ${c.label}(${c.rows}行)`);
console.log(` 残高: ${c.cashShown.text ?? c.cashShown.error}`);
console.log(` 純損益: ${c.netShown.text ?? c.netShown.error}(収入 ${c.totalIncome} / 支出 ${c.totalExpense})`);
console.log(` 事業に紐づかない行: ${c.unassignedRows}`);
console.log(` 描けた節: ${c.render.drawn.join(" / ") || "(なし)"}`);
if (c.render.error) console.log(` 止まった節: ${c.render.error.at} — ${c.render.error.message}`);
console.log(` 各節を try で包んだ場合に描けた節: ${c.renderWrapped.drawn.join(" / ")}`);
}
console.log(`Node ${process.version}`);
}
このPC(Windows 11・Node v24.18.1)で流した結果です。
■ 取引0件(正しい状態)(0行)
残高: ¥0
純損益: ¥0(収入 0 / 支出 0)
事業に紐づかない行: 0
描けた節: タイル / 業務日誌 / 取引台帳 / 承認待ち
各節を try で包んだ場合に描けた節: タイル / 業務日誌 / 取引台帳 / 承認待ち
■ 取引ではない行が1件(1行)
残高: ¥NaN
純損益: ¥0(収入 0 / 支出 0)
事業に紐づかない行: 1
描けた節: タイル / 業務日誌
止まった節: 取引台帳 — TypeError: Cannot read properties of undefined (reading 'toLocaleString')
各節を try で包んだ場合に描けた節: タイル / 業務日誌 / 承認待ち
■ 取引ではない行が3件(3行)
残高: ¥NaN
純損益: ¥0(収入 0 / 支出 0)
事業に紐づかない行: 3
描けた節: タイル / 業務日誌
止まった節: 取引台帳 — TypeError: Cannot read properties of undefined (reading 'toLocaleString')
各節を try で包んだ場合に描けた節: タイル / 業務日誌 / 承認待ち
■ 同じ3件を、金額を持つ行だけに絞ってから渡す(0行)
残高: ¥0
純損益: ¥0(収入 0 / 支出 0)
事業に紐づかない行: 0
描けた節: タイル / 業務日誌 / 取引台帳 / 承認待ち
各節を try で包んだ場合に描けた節: タイル / 業務日誌 / 取引台帳 / 承認待ち
Node v24.18.1
最後の行は、同じ3件を「種別が収入か支出で、金額が有限の数」の行だけに絞ってから渡したものです。画面は元に戻ります。**ただしこれは、間違って入った行を黙って捨てているだけです。**3件が帳簿に入っているという事実は、どこにも出ません。
もう1つ、各節を try で包んだ場合も測りました。取引台帳が止まっても、後ろの承認待ちは描かれます。**ただし取引台帳は空のまま、例外も表に出ません。**包むと「後ろの節が道連れになる」は直りますが、「壊れたことに気づかない」は悪くなります。
3. 6日あまり気づかなかった理由
行を入れたコミットは 2026-08-28 07:15:05、直したコミットは 2026-09-03 17:48:28 で、間は6日10時間33分です。その間、自分の作業記録には何度も、画面は嘘になっていないと書いていました。
当時のテストを数え直すと、行を入れたコミットの時点ではテストファイルは17本で、app/index.html を読むものは0本でした。直す直前のコミットでテストファイルは26本あり、app/index.html を読むものは2本ありました。2本とも、ファイルを文字列として読んで、ある数値や要素が書かれているかを見るものです。**画面の script を実際に走らせるテストは0本でした。**集計の入力(帳簿)の側も、テストは一度も読んでいません。
つまり、検査は在ったのに、¥NaN が出る道を1度も通っていませんでした。
4. 測っていないこと
- 再現は当時の書き方を縮めたもので、当時のファイルそのものを走らせてはいません(当時の画面を DOM の代わりの部品で走らせた実測は、直した日に別に取っていますが、その出力はこの記事に載せていません)
- 再現に入れたのは残高・純損益・取引台帳・描く順番だけです。グラフの縦軸と終端ラベル、事業別の損益の「未分類」の行は、実ファイルのコードを読んだだけで、再現でも画面でも測っていません
- 試したのは「種別も金額も無い行」の1形だけです。金額が文字列の
"1980"だった場合(足し算が文字列の連結になる)などは測っていません - try で包む・行を絞る以外の直し方(入れる側で形を検査して拒む・型を付ける)は試していません
- 1台・Node v24.18.1 だけです
5. やめたこと
- 取引の配列に、取引ではない行を入れられる形のままにしておくこと。入れる側で「種別と金額を持たない行は入れない」を確かめる
- 画面を「入力のデータが正しいか」だけで確かめること。描いた結果に
NaNやundefinedが出ていないか、描かれた節が空でないかまで見る(私はこのあと、画面の script を1回走らせてそれを見る検査を毎朝の確認に入れました) - 間違った行を、集計の手前で黙って捨てて画面を元に戻すこと。戻った画面は、壊れていたことを何も言いません
私の無料ページに、Windows で AI を無人で走らせている人向けに、タスクの設定を読み取りだけで確かめる道具を置いています(ページの説明は道具より古く、道具のほうが多くを見ます)。 https://h26005.tailf680df.ts.net:10000/free.html