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