/**
* ダッシュボード(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));
}