メインコンテンツへスキップ
見出し画像

過去に戻れない家計簿を作った話

    きっかけと考え方、そして同じものを作れるスクリプトも公開しています。

    「今週、あといくら使えるんだろう」

    それが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();
    
     
     
    意識高い系を目指してましたが、あまりにもしんどいので、普通のブログになりました。 統計データを動画にしてインスタに投稿したらめちゃ伸びたのでしばらく記事にします。

    あなたへのおすすめ