Newer
Older
client_dwatch / lib / dashboard / latest-run.ts
/**
 * ダッシュボード(F-01)の「直近の清掃」の選び方(D-159 / 指示書AV 段階2-3)。
 * ★純粋関数のみ★(DBアクセスを混ぜない。`CLAUDE.md` §9.4)— fixtures でも Prisma でも同じ規則が走る(§9.5)。
 *
 * 期待値表の正は `lib/dashboard/__tests__/latest-run.test.ts`(表1 は監督確定。指示書AV §3)。
 *
 * ★★時刻の比較は `Date.parse()` したミリ秒で行う★★(指示書AV 4-4)— 文字列で比べない。
 * ★★`now` は呼び出し側(ルート)で1回だけ作ったものを受け取る★★(4-5)— ここで `new Date()` を作らない。
 *   窓・`today` と別の「現在」を使うと、境界でカードと表の母集団がずれる。
 * ★★`LAUNCH_GRACE_MINUTES` を使わない★★(D6 / 4-6)— 出発・◎○△× の判定には一切触れない。
 *
 * ★★「終了+落ち着き時間(30分)」の待ちは D-173 でやめた★★(ユーザー判断 2026-10-07。表2 C6)—
 *   ★候補は「予定の終了 ≤ 現在」★。終わってからレポートが届くまでのあいだは、行の側の旗
 *   `result_pending`(`isResultPending()`。データソースが詰める)で「結果待ち」と出す。
 *   旧: `DASHBOARD_LATEST_RUN.settleMinutes`(削除済み)。
 */
import { toJstIso } from "@/lib/format/date";
import type { CleanLogItem, CleanLogRunMeta } from "@/lib/types";

const MS_PER_DAY = 86_400_000;

/** `pickLatestRunPerSection()` の設定。値は `DASHBOARD_LATEST_RUN`(`lib/config`)から渡す */
export interface LatestRunOptions {
  /** 現在。★ルートで1回だけ作ったもの★ */
  now: Date;
  /** 何日前の予定まで遡るか(予定の開始で数える。ちょうどは含む) */
  lookbackDays: number;
}

/**
 * 直近モードで DB から読む日付範囲(JST の `YYYY-MM-DD`。両端を含む。表3)。
 *
 * ★窓は `pickLatestRunPerSection()` の判定より広くてよい★(細かい判定は純粋関数の側)。
 * ★`date_from` / `date_to` を `null` にしないこと★ — 全件読みに戻る(指示書AV 4-7)。
 * ★`toJstIso()` で日付を切り出す★(実行環境の TZ に依存しない。`getFullYear()` 等を使わない)。
 */
export function latestRunWindow(
  now: Date,
  lookbackDays: number
): { date_from: string; date_to: string } {
  return {
    date_from: toJstIso(new Date(now.getTime() - lookbackDays * MS_PER_DAY)).slice(0, 10),
    date_to: toJstIso(now).slice(0, 10),
  };
}

/**
 * ★清掃エリアごとに、終わった本清掃のうち開始が一番新しい1件★を選ぶ(指示書AV §2 D1〜D5)。
 *
 * 候補にする条件(★両方とも「含む」★。D4):
 *   - 予定の開始 ≥ 現在 − 遡り日数
 *   - 予定の終了 ≤ 現在(★清掃中は候補にしない★。D-173 で落ち着き時間をやめた)
 * 同じエリアで開始が同じなら ★id の大きいほう★(D5)。
 *
 * ★出発失敗(`departed: false`)も選ぶ★(D2。`computeSummary()` が未実施に数える)。
 * ★結果待ち(`result_pending: true`)も選ぶ★(D-173。`computeSummary()` が `pending` に数える)。
 * ★条件を満たす予定が無いエリアは出さない★(D3。0件の行を作らない)。
 * ★`isFutureSchedule()` は呼ばない★ — 未来の予定は `cleanLogs()` が既に外しており、「終わった」の条件で足りる。
 *
 * ★★不変条件(D9)★★: `rows` の各行に対応する `meta` がちょうど1件あること。
 *   無い(E1)・2件以上(E2)は例外にする — 黙って落とすと行が静かに消える。
 *   ★逆に、行の無い `meta`(未来の予定など)は無視する★(E6)。
 *
 * ★戻りは入力の順を保つ★(E4。順序の正はデータソース。ルートで並べ替え直さない)。
 * ★行を書き換えない・丸めない★(入力の行オブジェクトをそのまま返す)。
 */
export function pickLatestRunPerSection(
  rows: readonly CleanLogItem[],
  meta: readonly CleanLogRunMeta[],
  options: LatestRunOptions
): CleanLogItem[] {
  const metaById = new Map<number, CleanLogRunMeta>();
  for (const m of meta) {
    if (metaById.has(m.id)) {
      throw new Error(
        `[dashboard] pickLatestRunPerSection: clean_timetables(id=${m.id}) の予定の終了・清掃エリアが2件以上あります`
      );
    }
    metaById.set(m.id, m);
  }

  const nowMs = options.now.getTime();
  const earliestStartMs = nowMs - options.lookbackDays * MS_PER_DAY;

  /** section_key → 選ばれた行(開始のミリ秒と id で比べる) */
  const best = new Map<string, { id: number; startMs: number }>();
  for (const row of rows) {
    const m = metaById.get(row.id);
    if (m === undefined) {
      throw new Error(
        `[dashboard] pickLatestRunPerSection: clean_timetables(id=${row.id}) の予定の終了・清掃エリアがありません`
      );
    }
    const startMs = Date.parse(row.scheduled_at);
    const endMs = Date.parse(m.planned_end_at);
    if (startMs < earliestStartMs) continue;
    if (endMs > nowMs) continue;

    const current = best.get(m.section_key);
    if (
      current === undefined ||
      startMs > current.startMs ||
      (startMs === current.startMs && row.id > current.id)
    ) {
      best.set(m.section_key, { id: row.id, startMs });
    }
  }

  const pickedIds = new Set<number>();
  for (const v of best.values()) pickedIds.add(v.id);
  return rows.filter((row) => pickedIds.has(row.id));
}