自作のダッシュボードに、タスクを「未着手 / 進行中 / 完了」の3列に並べるボードがあります。台帳(JavaScript のオブジェクト)にタスクを足すと、画面に1枚カードが増える作りです。
**台帳には150件あり、画面には118件しか出ていませんでした。**例外は出ず、壊れて出た undefined や NaN もありません(タスクの題名にはこれらの語を含むものがありますが、それは題名の文字です)。足りない32枚は、ただ描かれていなかっただけです。
1. いつから、何件消えていたか
台帳のファイルを変えた git の各コミットを1つずつ取り出し、そのコミットの時点でタスクが何件あり、そのうち画面に載る形(後述)のものが何件あったかを数え直しました。コミットは動かないので、あとで数え直しても同じ数が出ます。
| 見たもの | 実測 |
|---|---|
| 台帳を変えたコミット | 217個 |
| うちカードが1枚以上消えていたコミット | 96個 |
| 最初に1枚消えたコミット | 2026-09-07 13:30 |
| 直したコミット | 2026-10-08 10:52 |
| 消えていた期間 | 30.89日 |
| 直す直前 | 150件中 32件が画面に出ていない |
| 画面に出ていた件数が 118 のままだった期間 | 2026-09-22 19:09 〜 2026-10-08 07:14(15.50日・50コミット) |
その15.5日のあいだに、台帳のタスクは 125件から150件へ増えています。増えた25件は、1枚も画面に出ていません。
2. 消えたカードの内訳
画面の描き方はこうでした(該当する3行。字下げだけ外しています)。
const cols = [["todo", "未着手"], ["doing", "進行中"], ["done", "完了"]];
document.getElementById("taskBoard").innerHTML = cols.map(([key, label]) => {
const items = D.tasks.filter(t => t.status === key);
status が todo / doing / done のどれでもないタスクは、どの列にも入りません。直す直前の32件は、2つの形に分かれていました。
| 形 | 件数 | 最初に現れた時刻 |
|---|---|---|
欄の名前が status ではなく state
|
25件 | 2026-09-28 13:37 |
status の値が日本語(完了 3 / 進行中 2 / 未着手 2) |
7件 | 2026-09-07 13:30 |
どちらも、タスクを台帳へ書き足すときに私が書いたものです。一度 state で書いたあと、同じ形が続きました。次の書き足しが前の行を写したためと考えていますが、写したという記録は残っていません。
**最初に気づいたとき、私は「9日間」と書きました。**目に付いた原因(state の欄)のほうから期間を数えたからです(その起点の日付も、実際に現れた日より1日遅れていました)。コミットを全部数え直すと、日本語の値はそれより21日早く始まっていて、消えていた期間は31日でした。目に付いた原因だけで期間を数えると、期間は短く出ます。
3. 検査はなぜ緑だったか
この画面には検査がありました。画面の script を DOM の代わりの部品で実際に走らせ、描いた結果に NaN / undefined / Infinity / [object Object] が出ていないか、描かれた節が空でないかを見るものです。毎朝の確認の中で走らせていました。この31日のうち少なくとも4日は朝の確認そのものが動いていません。走った回のうち2回は赤になっていますが、それは別の壊れ(業務日誌に出た undefined と、経営連絡の日時に出た NaN)を捕まえたものです。カードが消えたことで赤になった回は1回もありません。
壊れていた最後のコミットの台帳でボードを描き、その4語を数えると 0 です(タスクの題名そのものに含まれる語は除いて数えました)。
この検査が見ているのは「描いたものが壊れていないか」で、「描くべきものが全部描かれたか」ではありません。**カードが黙って消える形は、描いた結果の中に何の痕跡も残さないので、壊れの語を探す検査では構造上見つかりません。**列も空ではありませんでした(118件は載っていた)。
4. 再現
依存なし・ファイルを読み書きしない・通信しないスクリプトで、同じ形を作りました。3件のタスクのうち1件だけ欄名か値をずらし、2つの検査にかけます。
- 検査A: 描いた結果に壊れの語が無いか
- 検査B: タスクの数と、描いたカードの数を id ごとに突き合わせる(id の集合ではなく個数で数える)
そろっている: 壊れの語 0 (検査A 緑) / タスク 3 描画 3 (検査B 緑)
欄名が state: 壊れの語 0 (検査A 緑) / タスク 3 描画 2 (検査B 赤 消えた T3)
値が日本語: 壊れの語 0 (検査A 緑) / タスク 3 描画 2 (検査B 赤 消えた T2)
| 台帳 | 壊れの語 | 検査A | 描画 / タスク | 検査B |
|---|---|---|---|---|
| そろっている | 0 | 緑 | 3 / 3 | 緑 |
欄名が state
|
0 | 緑 | 2 / 3 | 赤 |
| 値が日本語 | 0 | 緑 | 2 / 3 | 赤 |
スクリプトの全文です。
// 欄名が1つずれたカードは、描画から黙って消える。壊れの語を探す検査はそれを捕まえない。
// 依存0・ファイルを読み書きしない・通信しない。使い方: node repro-silent-card-drop.mjs [--json]
// 画面の描き方(列に載せるのは status が todo / doing / done のものだけ)
function render(tasks) {
const cols = ["todo", "doing", "done"];
return cols.map((key) => {
const items = tasks.filter((t) => t.status === key);
return `<h3>${key} (${items.length})</h3>` +
items.map((t) => `<div><span class="tid">${t.id}</span>${t.title}</div>`).join("");
}).join("");
}
// 検査A: 描いた結果に壊れの語が無いか(よくある形)
function brokenWords(html) {
const words = ["NaN", "undefined", "Infinity", "[object Object]"];
return words.reduce((n, w) => n + html.split(w).length - 1, 0);
}
// 検査B: タスクの数と、描いたカードの数を id ごとに突き合わせる
// (id の集合ではなく個数で数える。同じ id が2行ある台帳では、集合だと1枚描かれただけで両方とも載ったことになる)
function coverage(html, tasks) {
const shown = new Map();
for (const m of html.matchAll(/<span class="tid">([^<]*)<\/span>/g)) shown.set(m[1], (shown.get(m[1]) || 0) + 1);
const missing = [];
for (const t of tasks) {
const id = String(t.id);
const n = shown.get(id) || 0;
if (n > 0) shown.set(id, n - 1);
else missing.push(t.id);
}
return { expected: tasks.length, shown: tasks.length - missing.length, missing };
}
const cases = {
"そろっている": [
{ id: "T1", title: "a", status: "done" },
{ id: "T2", title: "b", status: "doing" },
{ id: "T3", title: "c", status: "todo" },
],
"欄名が state": [
{ id: "T1", title: "a", status: "done" },
{ id: "T2", title: "b", status: "doing" },
{ id: "T3", title: "c", state: "todo" },
],
"値が日本語": [
{ id: "T1", title: "a", status: "done" },
{ id: "T2", title: "b", status: "進行中" },
{ id: "T3", title: "c", status: "todo" },
],
};
const result = {};
for (const [name, tasks] of Object.entries(cases)) {
const html = render(tasks);
const cov = coverage(html, tasks);
result[name] = {
brokenWords: brokenWords(html),
checkA: brokenWords(html) === 0 ? "緑" : "赤",
expected: cov.expected,
shown: cov.shown,
missing: cov.missing,
checkB: cov.missing.length === 0 ? "緑" : "赤",
};
}
if (process.argv.includes("--json")) {
console.log(JSON.stringify(result, null, 2));
} else {
for (const [name, r] of Object.entries(result)) {
console.log(`${name}: 壊れの語 ${r.brokenWords} (検査A ${r.checkA}) / タスク ${r.expected} 描画 ${r.shown} (検査B ${r.checkB}${r.missing.length ? " 消えた " + r.missing.join(",") : ""})`);
}
}
5. 直したこと
- 32件の欄名と値を
statusとtodo/doing/doneに揃えた。あわせて、完了の記録が残っていたタスクをdoneにした(32件の外のタスクも含みます) - 検査に「id ごとに、タスクの数とカードの数が合うか」を足した。欄名の一覧を検査の中に写すのではなく、描いた結果のほうを数えるようにしたので、次に別の形でずれても数が合わなくなれば赤になります
- 実物の台帳でボードを描くテストを足した。直す前の台帳では、このテストは 150件中118件 と出て赤になります
検査Bを id の集合で書くと、足りません。直す直前の台帳は150行のうち10行が id の重複で(同じ id のタスクを複数行に分けて書いていました)、集合で突き合わせると欠けは32件ではなく 24件 に見えます。同じ id のカードが1枚でも描かれていれば、残りの行も載ったことになるからです。上の再現スクリプトと実際の検査は、どちらも個数で数えています。
6. 測っていないこと
- 画面の見た目(色・並び・折り返し)は見ていません。数えたのは描いたカードの id だけです
- 「描くべきもの」は台帳に入っているものだと決めています。台帳そのものから抜けていたものは、この数え方では見つかりません
- 台帳が読めない・ボードが描かれないといった、数える前に止まる場合に検査が黙らないかは、この記事では確かめていません
- 調べたのはこの画面のこのボード1つです。同じ画面の他の節で同じ形が無いかは、この記事では確かめていません
Windows で AI や定期処理を無人で走らせている方向けに、タスク スケジューラの設定を読み取りだけで点検する診断スクリプトを置いています。