
過去に戻れない家計簿を作った話
きっかけと考え方、そして同じものを作れるスクリプトも公開しています。
「今週、あといくら使えるんだろう」
それがiPhoneのホーム画面に出ていたら便利だな、と思った。
アプリを開いて、項目を選んで、履歴を探して……ということはなるべくしたくない。ウィジェットに残額が出ていて、使った金額をパッと記録できればいい。
毎週の予算は1万円。
使った分を引いて、余ったら翌週に繰り越す。使いすぎた分も、翌週に引き継ぐ。
既存のアプリでは、欲しい形にぴったり合うものが見つからなかった。だから、ChatGPTと相談しながら、Scriptableというアプリで自分用のウィジェットを作ってみた。
タップすると金額の入力画面が開く。580円と入れれば、残額から580円引かれる。
実際のウィジェットはこんな感じです。

せっかくなので、日ごとの支出も並べてみた。お金を使わなかった日は「NMD」。ノーマネーデーというやつだ。
ここで、ひとつ思いついた。
「その日が終わって、何も入力されていなかったら、自動でNMDにしたらいいのでは?」
でも、すぐに引っかかった。
入力がない日は、お金を使っていない日なのか。それとも、ただ記録を忘れた日なのか。
アプリには、その区別がつかない。
記録忘れまでNMDにすると、実際よりお金が残っているように見える。かといって、毎晩きちんと入力して、使わなかった日も忘れずボタンを押してください、というのもなんだかなと思う。
そもそも、何のために作ったんだっけ。
目的は、お金の使い方の全体像をつかむことだった。毎日欠かさず記録すること自体が目的だったわけではない。
人が必ず毎日金額を入れることを前提にした設計は、ちょっと美しくない気がした。余白がない。
疲れている日もある。忘れる日もある。何日か経ってから、また使おうと思うこともある。
そのときに、抜けた日を全部埋めないと役に立たない道具だと、戻ってくるのが面倒になる。
だから、あえて過去には遡れないようにした。直前の1件を取り消すことはできても、3日前の記録を今から直すことはできない。
不便に思うかもしれない。でも、これは狙ってやっている。
遡れるようにすると、「あの日は何に使ったんだっけ」と思い出す作業が発生する。完璧に埋めたくなって、結局面倒になってやめてしまう。
それなら、過去は諦めて、次の日から記録すればいい。週の全体像さえ見えていれば、1日単位の完璧さはいらない。
遡れないことが、逆に続けるハードルを下げている。
細かく記録したい日は、細かく。面倒な週は、まとめて。しばらく忘れても、またそこから使える。
人の生活には波があるのだから、道具にもそれを受け止める余地があってほしい。
同じものを作ってみたい人へ
特別な開発環境は要りません。用意するのは無料のiPhoneアプリ「Scriptable」だけです。
1. App StoreでScriptableをインストール
2. Scriptableで新規スクリプトを作成し、名前を「週予算」にする
3. 下のコードを丸ごと貼り付けて、一度実行する
4. ホーム画面にScriptableの中サイズウィジェットを追加し、Scriptを「週予算」に設定する
まだ小さな予算ウィジェットを作っているだけだけど、考えたいことが少し変わってきた。
毎日続けさせるにはどうするか。
その前に、続かなかったときも役に立つだろうか。
もしよかったら、チップをいただけたら励みになります。反応があれば、CSVでデータを書き出せる機能も追加しようと思っています。
// Scriptable: 週予算。中サイズ推奨。Parameterは空欄。
// 日本時間・月曜始まり。初週も1万円。
const WEEKLY = 10000;
const DAY = 86400000;
const today = Math.floor((Date.now() + 9 * 3600000) / DAY);
const monday = d =>
d - (new Date(d * DAY).getUTCDay() + 6) % 7;
const week = monday(today);
const yen = n => n.toLocaleString('ja-JP') + '円';
const date = d => {
const x = new Date(d * DAY);
return `${x.getUTCMonth() + 1}/${x.getUTCDate()}`;
};
const fm = FileManager.local();
const path = fm.joinPath(
fm.documentsDirectory(), 'weekly-budget-v1.json'
);
let data;
function save() {
if (fm.fileExists(path))
fm.writeString(path + '.bak', fm.readString(path));
fm.writeString(path, JSON.stringify(data));
}
function totals() {
const before = data.logs
.filter(x => x.day < week)
.reduce((s, x) => s + x.amount, 0);
const spent = data.logs
.filter(x => x.day >= week && x.day < week + 7)
.reduce((s, x) => s + x.amount, 0);
const carry =
Math.round((week - data.start) / 7) * data.budget - before;
return {
carry, spent,
left: data.budget + carry - spent
};
}
async function message(title, body) {
const a = new Alert();
a.title = title;
a.message = body;
a.addAction('OK');
await a.presentAlert();
}
function makeWidget() {
const w = new ListWidget();
w.backgroundColor = new Color('142522');
w.setPadding(12, 14, 12, 14);
w.url = URLScheme.forRunningScript();
w.refreshAfterDate = new Date(Date.now() + 15 * 60 * 1000);
const add = (parent, str, size, color = 'FFFFFF') => {
const t = parent.addText(str);
t.font = Font.systemFont(size);
t.textColor = new Color(color);
t.lineLimit = 1;
t.minimumScaleFactor = 0.5;
return t;
};
if (!data) {
add(w, 'アプリで一度実行してください', 14);
return w;
}
const t = totals();
add(w, `${date(week)}〜${date(week + 6)} 今週あと`,
12, 'BDD1CA');
const big = add(
w, yen(t.left), 32,
t.left < 0 ? 'FF9B94' : 'BFF7D0'
);
big.font = Font.boldSystemFont(32);
add(w, `支出 ${yen(t.spent)} / 繰越 ${yen(t.carry)}`,
10, 'BDD1CA');
if (config.widgetFamily !== 'small') {
w.addSpacer(8);
const row = w.addStack();
for (let i = 0; i < 7; i++) {
if (i) row.addSpacer();
const d = week + i;
const col = row.addStack();
col.layoutVertically();
const amount = data.logs
.filter(x => x.day === d)
.reduce((s, x) => s + x.amount, 0);
add(col, `${'月火水木金土日'[i]} ${date(d)}`,
9, d === today ? 'BFF7D0' : 'BDD1CA');
add(col,
(d < today || data.nmd.includes(d)) ? 'NMD' :
amount ? amount.toLocaleString('ja-JP') : '—', 11);
}
}
w.addSpacer();
add(w, 'タップで支出追加・NMD', 10, 'BDD1CA');
return w;
}
async function run() {
if (fm.fileExists(path)) {
data = JSON.parse(fm.readString(path));
if (data.version !== 1 ||
!Number.isInteger(data.start) ||
!Number.isInteger(data.budget) ||
!Array.isArray(data.logs) ||
!Array.isArray(data.nmd))
throw new Error(
'保存データを読み取れません。データは初期化していません。'
);
} else if (!config.runsInWidget) {
data = {
version: 1, start: week, budget: WEEKLY,
logs: [], nmd: []
};
save();
}
if (!config.runsInWidget) {
const t = totals();
const a = new Alert();
a.title = `今週あと ${yen(t.left)}`;
a.message =
`週予算 ${yen(data.budget)}/繰越 ${yen(t.carry)}\n` +
'今日使った金額を追加(合計に加算)';
const field = a.addTextField('例:580');
field.setNumberPadKeyboard();
a.addAction('支出を追加');
a.addAction('今日をNMDにする');
a.addAction('直前の支出を取り消す');
a.addAction('今日のNMDを解除');
a.addAction('ウィジェットをプレビュー');
a.addCancelAction('閉じる');
const choice = await a.presentAlert();
if (choice === 0) {
const raw = a.textFieldValue(0).trim()
.replace(/[0-9]/g,
c => String.fromCharCode(c.charCodeAt(0) - 65248))
.replace(/[,,]/g, '');
const amount = Number(raw);
if (!/^\d+$/.test(raw) ||
!Number.isSafeInteger(amount) ||
amount <= 0 || amount > 100000000) {
await message('金額を確認',
'1〜100,000,000円の整数で入力してください。');
} else {
data.logs.push({ day: today, amount });
data.nmd = data.nmd.filter(d => d !== today);
save();
}
} else if (choice === 1) {
if (data.logs.some(x => x.day === today)) {
await message('今日は支出があります',
'NMDにするには、今日の支出を取り消してください。');
} else if (!data.nmd.includes(today)) {
data.nmd.push(today);
save();
}
} else if (choice === 2) {
const last = data.logs[data.logs.length - 1];
if (last) {
const c = new Alert();
c.title =
`${date(last.day)}の${yen(last.amount)}を取り消す?`;
c.addDestructiveAction('取り消す');
c.addCancelAction('戻る');
if (await c.presentAlert() === 0) {
data.logs.pop();
save();
}
} else {
await message('記録はありません',
'取り消す支出がありません。');
}
} else if (choice === 3) {
data.nmd = data.nmd.filter(d => d !== today);
save();
} else if (choice === 4) {
await makeWidget().presentMedium();
}
}
Script.setWidget(makeWidget());
}
try {
await run();
} catch (e) {
if (config.runsInWidget) {
const w = new ListWidget();
w.addText('読み込みエラー:アプリで実行してください');
Script.setWidget(w);
} else {
await message('エラー', String(e));
}
}
Script.complete();