計測データの時系列グラフAPIを「期間窓ごとに欠損ゼロ」で返す設計

計測機器向けのSaaS案件で、測定値の推移を「1ヶ月 / 3ヶ月 / 半年 / 1年」で切り替える折れ線グラフのAPIを担当しました。一見「期間で集計して返すだけ」ですが、実運用に載せると「測定が無い期間をどう扱うか」が最大の設計判断になります。この記事では、欠損を null で埋めた連続バケットを生成し、そこに実測を差し込む設計を Laravel + Carbon 前提でまとめます。認証・認可は別テーマなので触れません。

概要

実装で効いてくるのは次の3点でした。

  1. 測定が無い期間もグラフ上に点(または区間)として存在させたい。でないと折れ線が詰まって時間感覚が壊れる
  2. 期間窓によってバケットの意味が変わる(1ヶ月=日単位、3ヶ月=十数日の区間、1年=月単位)のに、レスポンスの形は1種類に統一したい
  3. 測定は利用者の生活リズム任せでバラバラの時刻に飛んでくるので、素直に GROUP BY すると歯抜けになる

環境

  • PHP 8.1 / Laravel 9
  • MySQL(測定は1行1件で蓄積)
  • 日付処理は Carbon / CarbonPeriod
  • Web と iPad アプリの両クライアント(terminal パラメータで出し分け)

発生した問題

最初の実装は素朴でした。

// アンチパターン: 実測のある期間しか返らない
$rows = Measurement::query()
    ->where('member_id', $memberId)
    ->whereBetween('measured_at', [$from, $to])
    ->selectRaw('DATE(measured_at) as d, AVG(value) as v')
    ->groupBy('d')
    ->orderByDesc('d')
    ->get();

これだと測定した日しか行が返りません。フロントは返ってきた点を等間隔で並べるため、「3日測って2週間空けて、また3日測る」というリズムが、グラフ上では連続6点に潰れます。時間軸の空白がグラフから消えるのが致命的でした。

さらに、期間窓を増やすたびにレスポンスの形がバラバラになり、フロントに窓別の分岐が増殖していきました。

原因

原因は「バケット(時間の入れ物)を先に作らず、実測データからバケットを作っていた」ことに尽きます。

  • データが無い期間は行が存在しない(=欠損が表現できない)
  • 窓ごとにグルーピングキー(日 / 区間 / 月)が変わり、形が揃わない

裏返せば、「連続したバケットの骨組みを窓の定義から先に生成し、そこへ実測を左結合する」発想に切り替えれば、両方いっぺんに解けます。

対応方法

1. 期間窓を「バケットの定義」として持つ

窓ごとに違うのは粒度だけ、と捉えます。

enum GraphWindow: string
{
    case OneMonth   = '1m'; // 日単位(点)
    case ThreeMonth = '3m'; // 約10日区間
    case HalfYear   = '6m'; // 約15日区間
    case OneYear    = '1y'; // 月単位

    /** バケットを新しい順に返す */
    public function buckets(CarbonImmutable $now): array
    {
        return match ($this) {
            self::OneMonth   => $this->dailyBuckets($now, 31),
            self::ThreeMonth => $this->rangeBuckets($now, days: 10, count: 10),
            self::HalfYear   => $this->rangeBuckets($now, days: 15, count: 13),
            self::OneYear    => $this->monthlyBuckets($now, 12),
        };
    }
}

2. 形は同じ、section の意味だけを変える

各バケットは first_section / second_section / value の3つ組で表します。形は同じまま、窓によって section の意味だけをスライドさせるのが肝です。

first_section second_section 意味
1ヶ月 測定日 測定時刻(無ければ 00:00:00
3ヶ月 / 半年 区間開始日 区間終了日 区間
1年 YYYY-MM null

フロントは「3つ組を新しい順に描く」だけでよく、窓ごとの分岐を持たずに済みます。

final class Bucket
{
    public function __construct(
        public readonly CarbonImmutable $start,
        public readonly CarbonImmutable $end,
        public readonly string $firstSection,
        public readonly ?string $secondSection,
    ) {}

    public function covers(CarbonInterface $at): bool
    {
        return $at->betweenIncluded($this->start, $this->end);
    }
}

3. 骨組みを作ってから実測を差し込む

「まず null で埋まった連続バケットを作り、実測を後乗せする」順序がすべてです。

final class GraphAssembler
{
    public function assemble(iterable $measurements, array $buckets): array
    {
        $slots = array_fill(0, count($buckets), []);
        foreach ($measurements as $m) {
            foreach ($buckets as $i => $bucket) {
                if ($bucket->covers($m->measured_at)) { $slots[$i][] = $m; break; }
            }
        }

        return array_map(fn (Bucket $b, array $hits) => [
            'first_section'  => $b->firstSection,
            'second_section' => $b->secondSection,
            'value'          => $this->representative($hits),
        ], $buckets, $slots);
    }

    /** 代表値:区間内は「最新の1件」を採用 */
    private function representative(array $hits): ?float
    {
        if ($hits === []) return null; // 欠損は null のまま残す
        usort($hits, fn ($a, $b) => $b->measured_at <=> $a->measured_at);
        return round($hits[0]->value, 2);
    }
}

代表値を「平均」にするか「最新1件」にするかは仕様判断です。測定タイミングがバラつく前提だったので、平均より「最後に測った値」の方が体感に合いました。ここは差し替えられるよう representative() に閉じ込めておきます。

4. 指標ごとに骨組みを使い回し、クエリは1本に

レスポンスは指標(体重・BMI…)の配列で、各指標が同じバケット列を持ちます。骨組みは窓ごとに1回だけ生成し、指標ループで使い回します。DBアクセスは期間全体で1回に集約し、指標×バケットでクエリを撃ちません。

$now     = CarbonImmutable::now();
$buckets = $window->buckets($now);
$from    = end($buckets)->start;

$byItem = Measurement::query()
    ->where('member_id', $memberId)
    ->whereBetween('measured_at', [$from, $now])
    ->orderBy('measured_at')
    ->get()
    ->groupBy('item_id');

実装例(レスポンス)

1年窓の1指標ぶん。second_sectionnull、実測が無い月は value: null で連続していることに注目してください。

{
  "title": "体重",
  "meas_body_comp_item_id": 1,
  "sort_number": 1,
  "target": 78.88,
  "initial": 109.84,
  "measures": [
    { "first_section": "2021-12", "second_section": null, "value": 73.9 },
    { "first_section": "2021-11", "second_section": null, "value": null },
    { "first_section": "2021-10", "second_section": null, "value": null }
  ]
}

3ヶ月窓なら同じ形のまま second_section が区間終了日に変わります。

確認方法

  1. 欠損の連続性:測定を1件だけ入れて各窓を叩き、measures の件数が窓の定義どおり(1年=12 など)に固定され、実測以外が全部 null であること
  2. 境界:バケット境界ちょうどの測定が「片方のバケットにだけ」入ること(両端重複に注意)
  3. 並び順:常に新しい順
  4. クエリ数:DB::listen() で発行が1本のままであること
public function test_正常系_assemble_実測が無い月はnullで連続して返る(): void
{
    $buckets  = GraphWindow::OneYear->buckets(CarbonImmutable::parse('2021-12-23'));
    $measures = (new GraphAssembler)->assemble(
        collect([new Measurement(measured_at: '2021-12-05', value: 73.9)]),
        $buckets,
    );

    $this->assertCount(12, $measures);
    $this->assertSame(73.9, $measures[0]['value']);
    $this->assertNull($measures[1]['value']);
}

注意点

  • タイムゾーン:バケット境界は必ず「集計に使う基準TZ」で切る。UTC保存→JST基準のズレが境界の測定を隣に押し込む
  • 代表値の仕様は先に握る:平均 / 最新 / 最大で見え方が変わる。後から変えると「グラフが変わった」とバグ報告になる
  • null を 0 に丸めない:「測っていない」と「0」は別物。折れ線は null で線を切るのが正しい
  • section の多義性を増やしすぎない:2〜3パターンが限界。それ以上は素直に型を分ける

まとめ

期間別グラフAPIは、バケットを実測から作らず、窓の定義から先に生成して実測を左結合する——この順序を守るだけで「欠損ゼロ」「レスポンス形の統一」「クエリ1本」が同時に手に入ります。GROUP BY 起点で歯抜けと窓別分岐に苦しんでいるなら、骨組み先行への切り替えを検討する価値があります。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください