月次統計カードにNEWバッジを実装 - データ鮮度で自動点灯・自動消灯させる

開発financial-data

台湾旅行から戻った朝、5日分溜まった /make-diary の毎朝チェーンを再開した。7/8分の日記4本と統計チェーンを全部回し、最後は76ファイルを1コミットにまとめた。その途中で「既存データのバグでは」と疑って空振りした一幕と、月次統計ページに NEW バッジの自動減衰ロジックを実装させた本題を記録しておく。

5日ぶりの統計チェーン再開

前回実行が7/8なので、決算・統計データの取得チェーンは5日分の空白を埋める形になった。回した結果:

  • 決算スナップショット: 39銘柄すべて取得成功(ok=39 ng=0)。前日比の差分は5日ぶりなので変動が大きめに出た
  • 台湾ODM月次売上: 2026年6月分が14銘柄で新規追加。TSデータ35件を再生成
  • 台湾総輸出: 6月分を追記。品目内訳は情報通信が前年比+72.3%、電子部品が+32.8%と伸びが続いている
  • earnings-beat-scan: 直近24時間の決算から AZZ・LEVI・WDFC の3銘柄を抽出し、ガイダンスをコンセンサスと突合。AZZ は実績ビートのみで通年ガイダンスはほぼ横ばいだった

コミットは学習ゲート(/learn)のクイズに4問全問正解してから通した。76ファイル、+2998/-196行。

「既存データのバグ」を疑って空振りした

台湾総輸出の6月分を追記する途中で、一度手が止まった。台湾MOFのプレスリリースPDFから抽出した値と突き合わせたところ、2025年6月の記録値が誤っているように見えたからだ。「記録済みの59.9は誤りで、正しくは53.32。既存データのバグだ」と一度は断定しかけた。

ところが前後月まで検証させると、exportStats.ts は最初から正しい値を持っていた。誤っていたのはデータではなく突き合わせ方で、自分の勘違いだった。疑い→検証→撤回まで数分で済んだが、「バグだ」と思った瞬間に修正へ走らず、周辺月まで機械的に照合させたのが効いた。ここで雑に「修正」していたら、正しいデータを壊すところだった。

データが正しく1ヶ月分増えた結果、テストのハードコード値(29ヶ月→30ヶ月)を更新する必要が出た。これはデータ追記に伴う正当な更新なので素直に直した。

見出しの「##」をバグと誤診しかけた

日記記事のQA中にも一件、誤診しかけた。詳細記事を抜き打ちで開くと、見出しの先頭に「##」が生のまま表示されている。Markdownのパース不良か、HMRの反映漏れか。リロードしても消えず、他の記事でも同じ表示が出る。

dev サーバーの再起動に進む前に、ブラウザのDOMを直接掘らせた。すると正体は <span class="heading-hash" aria-hidden="true">## </span> という明示的なDOM要素だった。DocPage.vue が見出しレベルに応じて # を挿入する、Markdownソース風の意図的な装飾。つまり自分のサイトのデザインを、自分でバグと誤診しかけていた。

再発防止として .claude/rules/heading-hash-markers.md にルール化した。「見出しテキストの前に灰色の # があり本文が黒なら仕様、<h2> タグ自体が生成されず全体が1色のプレーンテキストなら本物のパース不良」という見分け方まで書いた。スクリーンショットの見た目ではなくDOMで判定する、が教訓。

本題: NEW バッジの自動減衰

チェーンが終わったあと、月次統計ページ(/memory-makers/makers)を開いてカードを上から順に眺め、右上の年月表記を一枚ずつ見比べていた。今回どの銘柄が6月分に更新されたのか、いちいち探すのが面倒くさい。かといって「更新されました」の強調が7月中ずっと点きっぱなしなのも邪魔だ。

この要望をそのまま伝えて、実装させた。設計はこうなった:

  1. 月次売上データの型に dataUpdatedAt を追加する。「値が最後に実際に変わった日時」で、Turso の updated_at 由来
/** データの値が最後に実際に変わった日時(ISO 8601 UTC)。NEW バッジの鮮度判定に使う */
dataUpdatedAt?: string
  1. 鮮度判定は純粋関数に切り出す。更新から4日以内なら NEW を出し、期間が過ぎれば自動で消える
export const FRESH_WINDOW_DAYS = 4

export function isFreshUpdate(dataUpdatedAt, now, windowDays = FRESH_WINDOW_DAYS) {
  if (!dataUpdatedAt) return false
  const t = Date.parse(dataUpdatedAt)
  if (Number.isNaN(t)) return false
  const diffMs = now.getTime() - t
  return diffMs >= 0 && diffMs < windowDays * DAY_MS  // 未来時刻(時計ズレ)は false
}

ポイントは「バッジを手で剥がす運用を最初から作らない」こと。月次データの更新は月初〜10日に集中するので、表示期間4日なら翌週明けには消える。判定はクライアント側の現在時刻で行い、SSGのHTMLには焼き込まない。ビルドし直さなくても時間経過だけでバッジが消灯する。

ブラウザで描画を確認させると、今朝更新した14銘柄にだけ NEW が点灯し、それ以外は通常表記のままだった。鮮度判定関数のカバレッジは100%。dataUpdatedAt なし・パース不能・未来時刻の異常系まで潰してある。

「減少傾向はなかったよね?」を記憶ではなくデータで検証

バッジ実装のあと、ふと確認したくなった。「月次チャートで減少傾向にあるものは、あんまりなかったよね?」という自分の理解が合っているか。

記憶で答えを済ませず、レプリカDBの実データで全35銘柄の直近YoYと直近3ヶ月の傾向を機械的にスキャンさせた。結果、理解は概ね合っていたが例外が2つ見つかった。どちらもサーバー周辺チップのカテゴリで、2026年6月時点で前年割れしている。カードを目視で流していただけでは拾えていなかった例外だ。

学び

  • 「既存データのバグだ」と思っても、修正の前に周辺データまで機械的に照合させる。今回は自分の勘違いで、雑に直していたら正しい値を壊していた
  • 表示の異常はスクリーンショットの見た目でなくDOMで判定する。意図的な装飾をバグと誤診しかけたら、その場でルールファイルに見分け方を書いておく
  • 「更新を強調したい、でもずっと光るのは邪魔」という要望は、手動運用ではなくデータ鮮度による自動減衰で解ける。剥がし忘れという運用課題ごと消える
  • 「〜はなかったはず」という自分の記憶は、DBがあるなら機械スキャンで裏を取る。35銘柄中2銘柄の例外は、目視では拾えていなかった