/**
* ダッシュボード(F-01)の集計。**純粋関数のみ**(DBアクセスを混ぜない)。
*
* 定義の正は `docs/decisions.md` D-049。
* ★ただしスケジュール進捗の対象日と `planned` はユーザー判断 2026-10-29「案B」で変えた★(`computeScheduleProgress()`)。
*
* ★`lib/judge/kpi.ts` は使わない★
* kpi.ts は週次/月次/年次の集計期間の KPI(CLAUDE.md §4.2)用で、
* ここでやりたい日次の**件数カウント**とは別物。混ぜると分母の意味が変わる。
*
* ★丸めない★
* 率は生値のまま返し、表示用の丸めは画面側で行う(CLAUDE.md §4.3 / §9.3)。
*/
import { toJstIso } from "@/lib/format/date";
import type { CleanLogItem, DashboardResponse, DsAlert } from "@/lib/types";
const MS_PER_DAY = 86_400_000;
/**
* 成功とみなす判定(◎○)
*
* ★アラートの `main_run_succeeded`(`lib/db/precheck-mapping.ts`。D-171)もこの定数を使う★ —
* 「本清掃は成功」の定義をダッシュボードの「成功」と揃えるため。★別の場所に同じ配列を書かないこと★
*/
export const SUCCESS_GRADES = ["◎", "○"] as const;
/** 失敗とみなす判定(△×) */
const FAILURE_GRADES = ["△", "×"] as const;
/**
* 清掃結果サマリ。
*
* `failed` を「出発した△×」に限定するのは、未出発の行を
* `not_executed` と `failed` の両方に数えて二重計上しないため。
*
* ★結果待ち(`result_pending`)は `pending` に数え、success / failed / not_executed に入れない★
* (ユーザー判断 2026-10-07。D-173)。★`success_rate` の分母にも入れない★(分母0なら null)。
* ★旗を `departed` より先に見ること★ — 結果待ちの行は `departed: false` / `grade: "×"` のままなので、
* 後に回すと未実施に数えてしまう。
*
* ★予定外タスクの行(`unscheduled: true`)も同じ規則で数える★(指示書BG §2-2)— `departed: true`・`result_pending: false`
* なので、★成功(◎○)か失敗(△×)のどちらかになり、未実施・結果待ちにはならない★(表U3 U3-1)。
*/
export function computeSummary(
logs: CleanLogItem[]
): DashboardResponse["summary"] {
let success = 0;
let failed = 0;
let notExecuted = 0;
let pending = 0;
for (const row of logs) {
if (row.result_pending) {
pending += 1;
continue;
}
if (!row.departed) {
notExecuted += 1;
continue;
}
if ((SUCCESS_GRADES as readonly string[]).includes(row.grade)) {
success += 1;
continue;
}
if ((FAILURE_GRADES as readonly string[]).includes(row.grade)) {
failed += 1;
}
}
// ★`pending` は分母に入れない★(D-173)
const denominator = success + failed + notExecuted;
return {
success,
failed,
not_executed: notExecuted,
pending,
success_rate: denominator === 0 ? null : (success / denominator) * 100,
};
}
/**
* スケジュール進捗の対象日(JST の `YYYY-MM-DD`)。
*
* ★`date` を省略したとき(直近モード)は JST の前日★(ユーザー判断 2026-10-29「案B」)—
* 「ダッシュボードは前日(10/28)の清掃結果なので、今日は未実施なので今日の件数で数えちゃいけない」。
* 朝に開くと、今夜の清掃(まだ始まっていない)を「出発 0 ÷ 予定 7」と数えていた。
* ★`date` を指定したときはその日★(従来どおり)。
* ★`now` は呼び出し側(ルート)で1回だけ作ったもの★(窓・直近の選び方と同じ「現在」を使う)。
* ★`toJstIso()` で日付を切り出す★(実行環境の TZ に依存しない。JST に夏時間は無いので24時間引いてよい)。
*
* @param date `date` クエリ(検証済み)。省略・空文字は `null` で渡す
*/
export function scheduleProgressTargetDate(now: Date, date: string | null): string {
if (date !== null) return date;
return toJstIso(new Date(now.getTime() - MS_PER_DAY)).slice(0, 10);
}
/**
* スケジュール進捗(ユーザー判断 2026-10-29「案B」)。
*
* - `planned` = ★対象日に開始予定の本清掃の件数 + 対象日に始まった予定外タスクの件数★ — 予定の表(`clean_timetables` +
* `clean_timetable_histories`、`precheck = 0`)から作った清掃ログの行と、予定外タスクの行(`unscheduled: true`。
* 日付は `started_at`)を数える(★A案★。指示書BG §2-2。ユーザー指示 2026-10-29「割合はA案で」。表U3 U3-3)。
* ★`clean_schedules` の曜日の設定は使わない★(2026-10-29 ステージングで曜日の設定 7件・予定の表 5件と食い違った。
* 旧 D-049 の「スケジュールマスタ由来」は廃止)。
* - `completed` = そのうち出発した件数(`departed`。成功+失敗であり「完了」ではない)。
* ★結果待ち・未実施は `planned` にだけ入る★(それが「進み具合」)。★予定外は常に出発済みなので両方に1件ずつ入る★。
* ★事前チェックの回は数えない★(清掃ログの行にならない。指示書BG §5)。
* - `rate` = completed ÷ planned × 100。★planned が 0 なら null★。★丸めない★(CLAUDE.md §4.3)
*
* ★施設の絞り込み・削除済みの施設/清掃エリアの除外は `logs` の側で済んでいる★(`db.cleanLogs()` と同じ行)。
* ★未来の予定は `logs` に入っていない★(`isFutureSchedule()`)— 前日の行は全部過去なので落ちない。
*
* @param logs 対象日を含む清掃ログ(★他の日の行が混ざっていてよい★。日付はここで絞る)
* @param date 対象日(YYYY-MM-DD, JST。`scheduleProgressTargetDate()` の戻り)
*/
export function computeScheduleProgress(
logs: readonly CleanLogItem[],
date: string
): DashboardResponse["schedule_progress"] {
// ★日付の切り出しは文字列で行う★ — `scheduled_at` は ISO8601(+09:00)なので先頭10文字が JST の日付
const ofDay = logs.filter((row) => row.scheduled_at.slice(0, 10) === date);
const planned = ofDay.length;
// 「完了」ではなく「出発」。成功+失敗の合計になる(結果待ちの行は `departed: false`)
const completed = ofDay.filter((row) => row.departed).length;
return {
date,
planned,
completed,
rate: planned === 0 ? null : (completed / planned) * 100,
};
}
/**
* セキュリティ復帰漏れのバナー。
*
* 解消(`resolved`)以外を未解消として扱う。
* ★DB のデータでは `security_off` が出ないので、DB 接続時は常に空★(指示書AX D4。コードは残す — U7)。
* 返すのは `facility_name` / `section_name` / `since` の3つだけで、
* 自由記述の `detail` / `note` は含めない(§4 の記録禁止フィールド。
* レスポンスに載せる必要も無い)。
*/
export function computeSecurityBanner(
alerts: DsAlert[]
): DashboardResponse["security_banner"] {
return alerts
.filter((a) => a.alert_type === "security_off" && a.resolution_status !== "resolved")
.map((a) => ({
facility_name: a.facility_name,
section_name: a.section_name,
since: a.detected_at,
}));
}