1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

『◯日前の自分』を節目の日に見せる実装:過去記録の切り出しと節目判定を純関数に分ける

1
Posted at

『◯日前の自分』を節目の日に見せる実装:過去記録の切り出しと節目判定を純関数に分ける

習慣系や記録系のアプリで「30日前のあなたはこうでした」と見せる機能を作りたいが、日付計算と表示ロジックが絡み合ってテストしづらくなっている人向けの記事。タイムゾーンのずれや記録の欠損で、節目の日なのに何も出ない、という不具合に悩んでいる人にも向く。

結論

「過去の記録を切り出す処理」と「今日が節目かを判定する処理」を、どちらも副作用のない純関数に分ける。現在時刻はグローバルに取らず引数で受け取る。この2点で、日付まわりの不具合はテストで再現できるようになる。

サンプルはTypeScriptで書く。C言語の関数に置き換えても考え方は同じで、組み込みで時刻をHALから注入する設計と構造がよく似ている。

問題の深掘り

最初に書きがちなのは、1つの関数に全部を詰め込む実装だと思う。

function showPast() {
  const today = new Date();
  const records = db.getAll();
  const target = records.find(r => daysBetween(r.date, today) === 30);
  if (target) render(target);
}

動くには動く。ただ、この形は後から次の問題を生みやすい。

  • new Date() を内部で呼んでいるので、「30日目の当日」を狙ってテストできない。テストのたびに実行日が変わる。
  • 記録をちょうど30日前の1件だけ探しているので、その日に記録がなければ何も出ない。毎日記録する前提が崩れた瞬間に節目が消える。
  • 「何日前を節目とするか」というルールが、検索処理と表示処理の中に埋まっている。節目に100日を足したいだけなのに、複数箇所を触ることになる。

この手の問題が起きる根本の理由は、時刻の取得、記録の検索、節目の判断、画面への描画という性質の違う4つを1関数に混ぜていることだと思う。自分も同じ構造で詰まったことがあり、副業のcommitとダイエットの歩数を1つのキャラで育てるSaaSのALTERを作っているときに、この「過去の自分を見せる」部分を設計し直した。以下はその整理にあたる。

解決手順

ステップ1:現在時刻を引数にする

new Date() を関数の中で呼ばず、呼び出し側から日付を渡す。組み込みで言えば、関数内で直接タイマレジスタを読まず、時刻をパラメータで受け取る形にするのと同じ発想。

日付は時刻を持たない「日」の単位に正規化しておく。タイムゾーンの扱いは、記録時に決めたユーザのローカル日付を YYYY-MM-DD の文字列で持つのが扱いやすいと感じている。UTCの瞬間値で持つと、日本時間の深夜0時前後で「昨日」と「今日」がずれることが多い。

ステップ2:節目判定を純関数にする

「何日前を節目とするか」だけを答える関数を作る。

const MILESTONES = [7, 30, 100] as const;

export function milestoneFor(
  daysAgo: number
): (typeof MILESTONES)[number] | null {
  return MILESTONES.find(m => m === daysAgo) ?? null;
}

ここには日付も記録も出てこない。数値を受け取って、節目なら節目を返し、違えば null を返すだけ。節目を増やしたいときはこの配列だけを触る。

ステップ3:記録の切り出しを純関数にする

節目の日に記録が欠けている場合に備えて、「ちょうどその日」ではなく「その日以前で一番近い記録」を探す。

type Record = { date: string; value: number };

export function pickPast(
  records: readonly Record[],
  today: string,
  daysAgo: number
): Record | null {
  const target = addDays(today, -daysAgo);
  const candidates = records.filter(r => r.date <= target);
  if (candidates.length === 0) return null;
  return candidates.reduce((a, b) => (a.date > b.date ? a : b));
}

addDays も日付文字列を受けて日付文字列を返す純関数にしておく。記録の配列は変更しない。同じ入力なら必ず同じ出力になる。

ステップ4:2つを組み合わせて表示判断を返す

最後に、今日が節目なら過去の記録を返す関数で2つをつなぐ。描画はここに含めず、表示すべきデータだけを返す。

export function pastSelf(
  records: readonly Record[],
  today: string,
  firstDate: string
) {
  const elapsed = diffDays(firstDate, today);
  const m = milestoneFor(elapsed);
  if (m === null) return null;
  const past = pickPast(records, today, m);
  return past ? { milestone: m, record: past } : null;
}

節目の起点は「使い始めた日」からの経過日数にしている。画面側はこの戻り値を受けて、null なら何も出さず、値があれば描画するだけになる。

コード/実例

テストは日付を固定して書ける。これが純関数に分ける一番の利点だと思う。

import { describe, it, expect } from "vitest";

describe("pickPast", () => {
  const records = [
    { date: "2025-01-01", value: 70.0 },
    { date: "2025-01-05", value: 69.5 },
  ];

  it("ちょうどその日の記録があればそれを返す", () => {
    expect(pickPast(records, "2025-01-31", 26)?.date).toBe("2025-01-05");
  });

  it("その日に記録がなければ直前の記録を返す", () => {
    expect(pickPast(records, "2025-01-31", 20)?.date).toBe("2025-01-05");
  });

  it("対象日より前に記録が1件もなければnullを返す", () => {
    expect(pickPast(records, "2025-01-31", 60)).toBeNull();
  });
});

describe("milestoneFor", () => {
  it("節目の日数ならその値を返す", () => {
    expect(milestoneFor(30)).toBe(30);
  });
  it("節目でなければnullを返す", () => {
    expect(milestoneFor(31)).toBeNull();
  });
});

実行日に依存しないので、CIで毎日同じ結果になる。月またぎや、うるう年の2月29日をまたぐケースも、日付を書き換えるだけで追加できる。

落とし穴と対処

実際に設計し直すなかで引っかかった点を挙げる。

日付をDateオブジェクトのまま比較してずれた。 ローカル時刻とUTCが混ざり、日本時間の深夜に節目が1日ずれた。日付は YYYY-MM-DD の文字列に正規化して、比較も文字列で行うようにしたら安定した。この形式は辞書順と時系列順が一致するので、<= でそのまま比較できる。

欠損の許容範囲を決めていなかった。 直前の記録を探す実装だと、3か月前の記録が「30日前の自分」として出てしまう。実用上は、対象日から何日以内なら有効とみなすかの上限が要る。上限を超えたら null を返すようにして、嘘の比較を見せないほうが安全だと判断した。この上限値も定数として切り出しておくと調整しやすい。

純関数にしても、境界の呼び出しは別途必要。 現在日付をどこで取るかは、結局どこかで決める必要がある。自分は画面の最上位で1回だけ日付を取り、そこから下へ引数で流す形にした。取得箇所が1つなので、挙動を追うのが楽になった。

節目を増やしたら表示が連続した。 7日、30日、100日に加えて14日などを足すと、近い日が続いて毎日何か出るようになり、特別感が薄れる。節目は増やしすぎないほうがいい、というのが運用して得た実感。

関連ツール

副業のcommitとダイエットの歩数を1つのキャラで育てる、焦らせない人生OSを作っている。節目の日に過去の自分を見せる仕組みもこの考え方で組んでいる。全職業を無料で使えるプランがある。

→ ALTER

まとめ

過去の自分を節目の日に見せる機能は、記録の切り出しと節目判定を純関数に分けるとテストしやすくなる。現在時刻は引数で受け取り、日付は文字列に正規化して扱う。欠損には上限付きで直前の記録を返す。

節目の定義が配列1つに集まるので、後から仕様を変えるときも触る場所が少なく済む。組み込みの時刻注入と同じ発想で、C言語側の設計にもそのまま応用できると思う。


著者:ぽん(@pon_freelance)
C言語実務23年、組み込み/制御系。
副業で技術記事販売と自作ツール販売をやっている。

書いているもの:

  • ALTER - 副業の commit とダイエットの歩数を1つのキャラで育てる、焦らせない人生 OS(SaaS)
    (その他:(なし))
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?