/**
* ダッシュボード(F-01)の集計。**純粋関数のみ**(DBアクセスを混ぜない)。
*
* 定義の正は `docs/decisions.md` D-049。
*
* ★`lib/judge/kpi.ts` は使わない★
* kpi.ts は週次/月次/年次の集計期間の KPI(CLAUDE.md §4.2)用で、
* ここでやりたい日次の**件数カウント**とは別物。混ぜると分母の意味が変わる。
*
* ★丸めない★
* 率は生値のまま返し、表示用の丸めは画面側で行う(CLAUDE.md §4.3 / §9.3)。
*/
import type {
CleanLogItem,
DashboardResponse,
DsAlert,
Schedule,
} from "@/lib/types";
/**
* 成功とみなす判定(◎○)
*
* ★アラートの `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: "×"` のままなので、
* 後に回すと未実施に数えてしまう。
*/
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,
};
}
/**
* 日付文字列(YYYY-MM-DD)から曜日番号(0=日曜)を求める。
*
* `new Date("YYYY-MM-DD...").getDay()` は実行環境のTZで結果が変わるため使わない。
* UTC で組み立てて `getUTCDay()` を読むことで、どのTZでも同じ答えになる。
*/
function weekdayIndex(date: string): number | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!m) return null;
const [, y, mo, d] = m;
return new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d))).getUTCDay();
}
/**
* スケジュール進捗。
*
* `planned` を**スケジュール由来**にするのが要点(D-049)。
* 清掃ログの行数を使うと、収集処理が止まって当日の行が0件のときに
* 「本当に定休日」と「収集処理の障害」が区別できなくなる。
*
* @param logs 当日分に絞り込んだ清掃ログ
* @param date 対象日(YYYY-MM-DD, JST)
*/
export function computeScheduleProgress(
logs: CleanLogItem[],
schedules: Schedule[],
date: string
): DashboardResponse["schedule_progress"] {
const dow = weekdayIndex(date);
const planned =
dow === null
? 0
: schedules.filter((s) => s.active && s.weekdays[dow] === "1").length;
// 「完了」ではなく「出発」。成功+失敗の合計になる(D-049)
const completed = logs.filter((row) => row.departed).length;
return {
planned,
completed,
rate: planned === 0 ? null : (completed / planned) * 100,
};
}
/**
* セキュリティ復帰漏れのバナー。
*
* 対応済み(`done`)以外を未解消として扱う。
* 返すのは `facility_name` / `section_name` / `since` の3つだけで、
* 自由記述の `detail` / `memo` は含めない(§4 の記録禁止フィールド。
* レスポンスに載せる必要も無い)。
*/
export function computeSecurityBanner(
alerts: DsAlert[]
): DashboardResponse["security_banner"] {
return alerts
.filter((a) => a.alert_type === "security_off" && a.resolution_status !== "done")
.map((a) => ({
facility_name: a.facility_name,
section_name: a.section_name,
since: a.detected_at,
}));
}