開発eurekapu-nuxt4

昨日仕上げた cash-3-topics.html の仕訳エンジンを、クリックでモーダル展開する3カラムレイアウトに作り替えた。アニメーションは requestAnimationFrame の手書きループから Web Animations API に全面置換し、仕訳カードの借方/貸方が試算表に吸い込まれる「飛行pill」演出を入れた。途中で純資産合計に当期純利益が含まれていないバグを踏み、ランタイム貸借検証関数とエラーバナーを後付けで足した。Vue化までやって 80→700 テスト全通過まで持っていった一日の記録。

やったこと(時系列)

朝、昨日の cash-3-topics.html を開き直して気付いた。「取引と仕訳候補」エリアが画面の左側に縦長で固まっていて、スマホで見ると仕訳と試算表が縦スクロール2画面ぶん離れる。学習者が仕訳ボタンを押した瞬間に試算表のどこが動いたのか、視線が追いつかない。

最初に決めたのは「クリックでモーダルを開いて、画面を3カラム占有させる」方針。仕訳候補・仕訳帳・残高試算表を横一列に並べて、ボタン押下から数値変動までを1画面で見せる。

モーダル化:backdrop・ESC・bodyスクロールロック

「取引と仕訳候補」エリアにクリックハンドラを刺し、<dialog> を fullscreen で開く実装にした。閉じる動線は3つ。

  • backdrop(モーダル外側)クリック
  • ESCキー押下
  • 取消ボタン

開いている間は document.body.style.overflow = 'hidden' でスクロールロック。閉じる関数で必ず解除する。最初これを取消ボタンに付け忘れていて、「モーダルは閉じたのに背景がスクロールできない」状態に陥った。close関数を1本に集約して、3つの動線すべてが同じ後始末を通るようにした。

アニメーションがカクつく → Web Animations API へ置換

数値の増減アニメは最初 requestAnimationFrame で自前ループを書いていた。仕訳をプッシュするたびに該当勘定のセル数値がカウントアップして、増減差額が緑(+)か赤(-)でフロート表示される演出。

これが、仕訳カードの借方/貸方が試算表のセルに吸い込まれる「飛行pill」アニメと同時に走ると、明らかにフレームが落ちた。Chromeの DevTools Performance で見ると、JSのメインスレッドが詰まっている。

Element.animate() に置き換えた。ブラウザのコンポジタスレッドにアニメーションが乗るので、メインスレッドが詰まってもpillの動きは滑らかに継続する。

// Before: 手書きrAFループ(メインスレッドで頑張る)
const tick = (now) => {
  const t = Math.min(1, (now - start) / duration)
  el.textContent = formatYen(from + (to - from) * easeOut(t))
  if (t < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)

// After: Web Animations API(コンポジタに任せる)
pill.animate(
  [
    { transform: `translate(${dx}px, ${dy}px) scale(1)`, opacity: 1 },
    { transform: 'translate(0, 0) scale(0.6)', opacity: 0 }
  ],
  { duration: 1500, easing: 'cubic-bezier(.2,.8,.2,1)' }
)

ついでに飛行pillの duration を 700ms から 1500ms に伸ばした。最初は「速いほうがキビキビしてよい」と思ったが、学習用途では「借方は試算表のここに、貸方はあそこに飛んでいく」の対応関係を目で追わせたい。少し長めにしてゲーム的な気持ちよさを足した。

placeholder行予約:プッシュのたびに試算表が下にずれる問題

仕訳をプッシュすると仕訳帳テーブルに行が増える。すると当然、その下にある残高試算表のY位置も下にずれる。飛行pillの着地点がフレームごとにずれていく。

仕訳帳に「最終行数」分の placeholder 行を最初から予約しておく方式に切り替えた。例題の最終仕訳数が分かっているので、開始時点で空行を全部敷いておき、プッシュのたびに実データで上書きする。試算表のY座標が固定されて、pillの着地点が常に同じ場所になる。

純資産合計に当期純利益が入っていないバグ

ここで一番焦った。仕訳を全部プッシュし終えた状態で残高試算表を見ると、借方合計と貸方合計が一致しない。電卓を叩き直して原因を特定したら、純資産合計の計算で当期純利益(re_ni)を足し忘れていた

// 修正前: 期末純資産が不足する
const sumsEq = sumsByCategory('equity')

// 修正後: 期末純資産 = 期首純資産 + 当期純利益
const totalEquity = sums.eq + re_ni

会計の構造を考えれば当然なのだが、コード上では純資産勘定のみを集計していて、PL側で算出した当期純利益を期末資本に振り戻す処理が抜けていた。

これが怖いのは、特定の例題では偶然合ってしまうケースがあること。たとえば当期純利益がゼロに近い設例だと気付かない。ランタイムで貸借バランスを検証して、ズレたら画面上にエラーバナーを出す関数を入れた。

const verifyBalance = (state) => {
  const debit = sums.assets + sums.expense
  const credit = sums.liability + sums.eq + sums.revenue + state.re_ni
  if (Math.abs(debit - credit) > 0.5) {
    showErrorBanner(`貸借不一致: 借方${debit} / 貸方${credit}`)
  }
}

例題ごとに verifyBalance(state) を呼ぶようにして、開発中にバグを踏んだら即座に画面が赤くなる仕掛けにした。これは将来の自分への保険でもある。

モーダル内が見にくい → zoomコントロール追加

3カラムを画面に詰め込むと、文字サイズが14pxくらいまで小さくなる場面が出てきた。学習用途で目を凝らすのは本末転倒。

モーダル右上に zoom コントロールを追加した。100/115/130/150/170/200% の6段階で、document.documentElement.style.zoom を切り替える。値は localStorage に保存して、次回モーダルを開いたときに復元する。初期値は 130% にした(自分で使ってみて一番ストレスがなかった倍率)。

const ZOOM_KEY = 'journal-modal-zoom'
const DEFAULT_ZOOM = 130

const loadZoom = () => {
  const saved = localStorage.getItem(ZOOM_KEY)
  return saved ? Number(saved) : DEFAULT_ZOOM
}

const applyZoom = (pct) => {
  modalRoot.style.zoom = `${pct}%`
  localStorage.setItem(ZOOM_KEY, String(pct))
}

zoom プロパティは古いCSS仕様だが、Chrome系では座標計算(getBoundingClientRect)が transform: scale() よりも素直に追従する。飛行pillの着地点計算でハマりたくなかったので zoom を選んだ。

Vue化と Vitest

HTML側でロジックが固まったところで、JournalExample.vue コンポーネントに切り出した。状態管理は Composition API、副作用(DOM操作・アニメーション起動)は onMounted 内に隔離。純粋関数(貸借合計の計算、検証ロジック)は別ファイルに切り出して Vitest でテストを書いた。

  • HTML側の純粋関数: 72→85テスト
  • Vue化後の総合: 80→700テスト

テストが700本に膨れたのは、6設例 × 各仕訳プッシュ後の貸借検証 × エッジケース(取消後の状態復元、placeholder行の整合性、zoom値の永続化)を全部回したため。テストを増やしたぶん、リファクタの怖さが消えた。

学び

  • アニメーションがカクついたら手書きrAFを疑う: Element.animate() に置き換えるだけでコンポジタスレッドに乗る。今回は飛行pillと数値カウントアップの同時実行で効いた
  • 貸借検証はランタイムで回す: 純資産+当期純利益のような会計の常識を、コードで明示的に検算する関数を入れる。テストだけだと「テストにない設例」で踏む
  • モーダルの後始末は1関数に集約する: bodyスクロールロックの解除、アニメーションのキャンセル、状態リセットを closeModal() 1本に閉じ込めると、3つの閉じ動線(backdrop/ESC/取消)すべてで漏れなく実行される
  • placeholder行で座標を固定する: テーブル行が増減するUIで他要素のY座標が動くと、アニメーション計算が破綻する。最終行数を予約しておく発想は他のインタラクティブ表でも使える

明日やること

  • JournalExample.vue を他の章ページ(売掛金、棚卸資産あたり)にも組み込む
  • zoom 200% でレイアウトが崩れる箇所が1つあるので、3カラムを2カラム+下段試算表に組み替える分岐を入れる
  • 飛行pillの着地音をVOICEVOX以外の効果音で鳴らすか検討(学習者の集中を切らないか要検証)