月初の午前0時半に1,200円の支出を入れたのに、画面の「10月の合計」が増えている——記録系のアプリで、いちばん最初に気づきにくい不具合は、この種類のものだと思います。
家計簿のように「入力して、保存して、月ごとに足す」アプリは、Rork のようなツールに最初の指示を出すとたいてい動くかたちで出てきます。ただ、動くことと、月末に数字が合うことは別の話です。最初の指示に一行ずつ足しておくと後から効く点は3つあり、金額・月の境目・入力欄の順に、手を動かして確かめました。
個人開発の合間に試した小さな検証ですが、先にお伝えしておきたいのは、ここで載せるコードを私自身が確かめたのは Node 22 の上までだということです。Rork が実際に生成するコードはその時々で違いますので、「生成されたコードがこの3点を守っているか」を見る物差しとして使っていただければと思います。
1. 金額は「円の整数」で持つ
小数で金額を持つと、足し算のたびに小さなずれが混ざります。手元で確かめると、0.1 + 0.2 + 0.3 は 0.6 にならず 0.6000000000000001 になりました。1980.1・320.2・99.9 の3つを足すと 2400.2 ちょうどに見えますが、これは偶然にそう見えているだけで、件数が増えれば表示のどこかで端数が顔を出します。
円で記録するなら、そもそも小数は要りません。最初の指示に次の一行を足します。
金額は円の整数(number)で保存し、小数は使わない。表示のときだけ桁区切りを付ける。外貨を扱う場合は、最小単位(セント)の整数で持つのが同じ考え方です。
2. 月の境目は「ローカル日付」から作る
月ごとの合計を出すとき、生成されたコードがよく使うのが toISOString().slice(0, 7) です。これは UTC の時刻から月を切り出します。日本の端末で 11月1日 00:30 の支出を入れると、次のようになりました。
const spent = new Date("2026-11-01T00:30:00+09:00");
spent.toISOString().slice(0, 7); // "2026-10" ← UTC の月
spent.getMonth() + 1; // 11 ← 端末のローカルの月(TZ=Asia/Tokyo)日本時間の午前9時までの支出は、UTC ではまだ前日です。だから毎月1日の朝9時前の記録だけが、前の月に入ってしまいます。月末に「1件だけ合わない」不具合が出る場合、ほぼこの形です。
最初の指示にはこう書いておきます。
月ごとの集計キーは、端末のローカル日付(getFullYear / getMonth)から作る。toISOString は集計に使わない。3. 入力欄の文字列は、保存の前に一度だけ整える
金額の入力欄に打たれる文字は、数字だけとは限りません。JavaScript の Number と parseInt に通すと、次のように割れます。
| 入力 | Number | parseInt |
|---|---|---|
| 1,200 | NaN | 1 |
| 1200(全角) | NaN | NaN |
| 1200円 | NaN | 1200 |
| (空欄) | 0 | NaN |
| 12.5 | 12.5 | 12 |
「1,200」が parseInt では 1 になる点は、見落とすと厄介です。エラーにならないまま、1円として保存されてしまいます。空欄が Number で 0 になるのも同じで、0円の支出が静かに積み上がります。
3つをまとめた関数と、動かして確かめたテスト
次の関数は、3点を一か所に集めたものです。
// ledger.mjs
// 金額は「円の整数」で持つ。入力文字列はここで一度だけ整える。
export function parseYen(input) {
const half = String(input)
.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0))
.replace(/[,、,\s円¥¥]/g, "");
if (!/^\d{1,9}$/.test(half)) return null; // 空・小数・文字混じりは受け付けない
return Number(half);
}
// 月のキーは端末のローカル日付から作る(toISOString は UTC なので使わない)
export function monthKey(date) {
const m = String(date.getMonth() + 1).padStart(2, "0");
return `${date.getFullYear()}-${m}`;
}
export function totalsByMonth(entries) {
const totals = {};
for (const e of entries) {
const key = monthKey(new Date(e.spentAt));
totals[key] = (totals[key] ?? 0) + e.amountYen;
}
return totals;
}テストは次のとおりです。
// ledger.test.mjs
import assert from "node:assert/strict";
import { parseYen, totalsByMonth } from "./ledger.mjs";
assert.equal(parseYen("1,200"), 1200);
assert.equal(parseYen("1200"), 1200);
assert.equal(parseYen("1200円"), 1200);
assert.equal(parseYen(" ¥1,200 "), 1200);
assert.equal(parseYen(""), null);
assert.equal(parseYen("12.5"), null);
const entries = [
{ spentAt: "2026-10-31T23:50:00+09:00", amountYen: 980 },
{ spentAt: "2026-11-01T00:30:00+09:00", amountYen: 1200 },
{ spentAt: "2026-11-15T12:00:00+09:00", amountYen: 300 },
];
assert.deepEqual(totalsByMonth(entries), { "2026-10": 980, "2026-11": 1500 });TZ=Asia/Tokyo node ledger.test.mjs では、すべて通りました。
想定外だった点:同じテストが、タイムゾーンしだいで落ちる
同じテストを TZ=UTC で動かすと、集計のところで落ちました。結果は 2026-10 が 2180、2026-11 が 300 です。11月1日 00:30(日本時間)の1,200円が、UTC の端末では 10月31日 15:30 として数えられるためです。
これは関数の間違いではなく、「月はその人の端末の暮らしている時間で決まる」という仕様どおりの動きです。ただ、テストを海外のサーバーや CI で動かすと、手元で通っていたものが落ちて戸惑います。テストの先頭で TZ を固定しておくのが、私の手元でいちばん手間のかからない対処でした。
最初の指示に、そのまま貼れる三行
金額は円の整数(number)で保存し、小数は使わない。
月ごとの集計キーは端末のローカル日付から作り、toISOString は集計に使わない。
金額の入力は、全角数字・カンマ・「円」を取り除いてから数字だけか確かめ、ダメなら保存せずに入力欄へ戻す。あとから直す指示は、一度生成された画面に手を入れることになります。最初から三行入れておくほうが、直しの回数が減ります。指示を小さく足していく進め方は 機能を一つずつ足すプロンプトの書き方 に、画面まわりの指示は UI 生成のプロンプトのパターン にまとめています。
付け加えるなら、同じ「月のずれ」は支出だけでなく、個人開発のアプリで広告収益や課金の売上を月別に並べる場面でも起こります。集計の前に、月のキーをどの時刻から作っているかを一度見ておくと安心です。
数字を扱うアプリは、持ち方と区切り方を最初に決めておきます。 個人開発で自分のアプリを作るときも、この線引きは変わりません。まずは今お手元の記録アプリで、毎月1日の朝9時前に入れた記録が、どの月に入っているかを一件だけ確かめてみていただければと思います。