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`(削除済み)。
 *
 * ★★予定外タスクも候補にする★★(指示書BG §2-2。ユーザー指示 2026-10-29)—
 *   開始 = 清掃結果の `started_at`(= `CleanLogItem.scheduled_at`)、終了 = `ended_at`(null なら開始。メタの `planned_end_at`)。
 *   ★行とメタは `row_key` で突き合わせる★(予定外の行は `id` が null)。
 */
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),
  };
}

/**
 * 同じ清掃エリアで開始が同じ2行のどちらを採るか(D5 を予定外タスクへ広げた。指示書BG §2-2)。★正なら `a` を採る★。
 *
 * - ★予定のある行どうし★: ★id の大きいほう★(D5。今と同じ)
 * - ★予定のある行と予定外の行★: ★予定のある行★(予定外の行の判定は清掃結果そのものなので、予定の結果を優先して見せる。
 *   監督の判断待ちの決め事 — どちらでも件数の合計は変わらない)
 * - ★予定外の行どうし★: ★`report_id` の大きいほう★(桁数 → 辞書順。19桁の文字列を数値にしない)
 */
function preferOnTie(a: CleanLogItem, b: CleanLogItem): boolean {
  if (a.id !== null && b.id !== null) return a.id > b.id;
  if (a.id !== null) return true;
  if (b.id !== null) return false;
  const ra = a.report_id ?? "";
  const rb = b.report_id ?? "";
  return ra.length !== rb.length ? ra.length > rb.length : ra > rb;
}

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

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

  /** section_key → 選ばれた行(開始のミリ秒と `preferOnTie()` で比べる) */
  const best = new Map<string, { row: CleanLogItem; startMs: number }>();
  for (const row of rows) {
    const m = metaByKey.get(row.row_key);
    // ★メタの無い行は選ばない(例外にしない。指示書BB A2)★ — 件数は `missingRunMetaIds()`、ログはルート
    if (m === undefined) continue;
    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 && preferOnTie(row, current.row))
    ) {
      best.set(m.section_key, { row, startMs });
    }
  }

  const pickedKeys = new Set<string>();
  for (const v of best.values()) pickedKeys.add(v.row.row_key);
  return rows.filter((row) => pickedKeys.has(row.row_key));
}

/**
 * ★メタの無い行の `row_key`★(指示書BB A2・D-182)— `cleanLogs()` と `cleanLogRunMeta()` は並列に読むので、間に行が増えると起きうる。
 * `pickLatestRunPerSection()` はその行を選ばない(例外にしない)。★ログはルートが出す★(純粋関数のまま)。
 * ★旧: id を返していた★(予定外タスクの行は id が null なので `row_key` にした。指示書BG §2-2)
 */
export function missingRunMetaIds(rows: readonly CleanLogItem[], meta: readonly CleanLogRunMeta[]): string[] {
  const keys = new Set(meta.map((m) => m.row_key));
  return rows.filter((row) => !keys.has(row.row_key)).map((row) => row.row_key);
}