Newer
Older
client_dwatch / docs / handoff / analysis / code-analysis-pure.md

【注記】 2026-09-03、実装とコメント・設計文書の食い違いを洗い出すために全ファイルを精読した記録です(3分割の1つ)。 ここで見つかった26件のうち、直すべき11件を docs/handoff/指示書D_実装解析の不整合.md に整理しています。 当時の記録であり、現行方針ではありません。 行番号は当時のものです。現状は docs/handoff.md を参照してください。

D-Watch 実装解説 — 純粋層(lib/judge / lib/config / lib/format / lib/types)

対象リポジトリ: C:\Users\daich\dev\d-watch(読み取りのみ。一切書き換えていません)

解析対象ファイル(9本):

パス 行数
C:\Users\daich\dev\d-watch\lib\judge\index.ts 21
C:\Users\daich\dev\d-watch\lib\judge\grade.ts 49
C:\Users\daich\dev\d-watch\lib\judge\escalation.ts 68
C:\Users\daich\dev\d-watch\lib\judge\advice.ts 93
C:\Users\daich\dev\d-watch\lib\judge\kpi.ts 72
C:\Users\daich\dev\d-watch\lib\config\index.ts 54
C:\Users\daich\dev\d-watch\lib\config\advice-rules.ts 129
C:\Users\daich\dev\d-watch\lib\format\date.ts 37
C:\Users\daich\dev\d-watch\lib\types\index.ts 341

参照した仕様文書: CLAUDE.md(§4 確定仕様 / §7 禁止事項 / §9 実装規約)、docs\decisions.md(D-020 / D-020b / D-021 / D-025 / D-026 / D-036 / D-041 / D-044)


この章を読む前に — 3つの専門用語

この4ディレクトリをまとめて「純粋層」と呼びます。読み進めるのに必要な用語を3つだけ先に置きます。

純粋関数(pure function)

平易な言い換え: 「同じものを入れたら、いつでも必ず同じものが出てくる箱」。中で外の世界に触らない関数のこと。

なぜ重要か: 電卓の「1+1」は誰がいつ押しても2です。これが純粋関数。逆に「今の残高を見て答えを変える」関数は、DBの中身次第で答えが変わるので、テストしても「今日は通ったが明日は落ちる」ことが起きます。

CLAUDE.md §9.4(298行目)に規約として明記されています。

- 判定関数は純粋関数にする(DBアクセスを混ぜない)

このリポジトリでの現物: 後述の「横断確認 1」のとおり、lib/judge 配下5ファイルの import は合計9行しかなく、そのすべてが lib/config(ただの定数)と lib/types(型だけ)と隣のファイルです。DBもログもファイル読み書きも一切入っていません。

しきい値の外出し(設定値の外部化)

平易な言い換え: 「90点」「70点」といった判定の区切り数字を、判定プログラムの中に直接書かず、別の1枚の紙(lib/config/index.ts)にまとめて書いておき、判定プログラムはその紙を見に行く、というやり方。

反対の言葉が「ハードコード」(コードに数字を直接埋め込むこと)。CLAUDE.md §7(215行目)で明確に禁止されています。

- ❌ **閾値(90/70/10、猶予30分、L2=2日/L3=3日)のハードコード**(設定値として外出し)

なぜ重要か: 90という数字が10箇所に散っていると、「85に変えたい」と言われたときに9箇所直して1箇所忘れる、という事故が起きます。1箇所にまとめてあれば1箇所直せば終わります。

生値(raw value)と丸め

平易な言い換え: 生値 = 計算したままの小数(例 90.4%)。丸め = 見せるときに整数にすること(例 90%)。

このリポジトリで最も事故りやすい規約が「判定は生値で、丸めは表示だけ」です。CLAUDE.md §4.3 は見出しそのものが「### 4.3 端数処理 ★事故りやすい★」(114行目)になっています。

// ✅ 正しい
const grade = judgeGrade(rawRate);        // 生値で判定
const display = Math.round(rawRate);       // 表示用に丸める

// ❌ 間違い(90.4%が丸めで90%になり、◎判定が消える)
const rounded = Math.round(rawRate);
const grade = judgeGrade(rounded);

docs\decisions.md D-020(80行目)にも同じことが書かれています。

★重要: 判定(◎○△×、L1〜L3)は丸める前の生値で比較する。 丸めた値で判定すると 90.4% が 90% になり ◎ 判定が消える


lib/types/index.ts

341行。関数は1つもありません。 型(データの形の宣言)だけを集めたファイルです。

import 文(実際のコードのまま)

import 文は1行もありません。 ファイル先頭は1〜6行目のコメントブロックで始まり、8行目の区切りコメント、10行目の最初の型宣言へ直行します。

/**
 * D-Watch 型定義
 *
 * `d-watch-api.yaml`(API契約の正)から起こしています。
 * YAMLを変更したら必ずこのファイルも合わせてください(CLAUDE.md 作業ルール1)。
 */

していない import の確認(重要): このファイルには lib/logger も lib/db も、そもそもいかなる import もありません。他の何にも依存しない、純粋層の最下層です。

専門用語: 「型(type)」= データの形の設計図。平易な言い換え: 「この箱には数字が入る」「この箱には ◎ か ○ か △ か × のどれかしか入らない」という約束書き。プログラムを動かす前(コンパイル時)に約束違反を機械が見つけてくれます。実行時には消えるので、動作速度には影響しません。

export しているもの — 全38件

型だけなので「シグネチャ」の代わりに宣言をそのまま並べます。列の「外部参照」は、lib/types/index.ts 自身を除いて app/ components/ lib/ 内で名前が出現する行数(grep 実測)です。

共通(8〜47行目)

行 宣言 意味 外部参照
10 `export type Grade = "◎" \ "○" \ "△" \ "×"` 清掃1回の4段階評価。この4文字以外は入れられない 11
11 `export type EscalationLevel = "L1" \ "L2" \ "L3"` 問題が続いたときの深刻度3段階 5
13 export interface Meta { total, page, per_page } 一覧APIが必ず返すページ情報 0(ListResponse 内でのみ使用)
24 `export type ErrorCode = "validation_failed" \ "not_found" \ "internal_error" \ "not_implemented"` APIが返すエラーの種類。4種のみ 18
30 export interface ApiError { error: { code, message, details? } } エラー応答の形 50
38 export interface ListResponse<T> { items: T[]; meta: Meta } 一覧応答の形。<T> に中身の型を差し込む 9
43 export interface SummaryListResponse<TItem, TSummary> 集計値付き一覧応答の形 12

19〜23行目のコメントが ErrorCode を union(決まった文字列のどれか)にした理由を述べています。

 * クライアントへ返す `error.code`。
 * ログの `error_code` と同一の変数を再利用し、ログ側で別文字列を組み立てない(logging-design.md §3)。
 * YAML は `type: string` のまま据え置き、内部 TS のみ union で強制する。

平易な言い換え: 「APIの外向け仕様書(YAML)では『文字列』と緩く書いておくが、社内のコードの中では4種類しか書けないよう機械に見張らせる」。ログとAPI応答で別の文字列を書いてしまう食い違いを防ぐためです。

<T>(ジェネリクス): 平易な言い換え「中身を後から差し込める容器」。ListResponse<CleanLogItem> と書けば「清掃ログの一覧」、ListResponse<DsDevice> と書けば「D-Sデバイスの一覧」になります。items と meta の形は共通で1回しか書かない、という節約です。

清掃詳細ログ(49〜91行目)

行 宣言 外部参照
51 export interface CleanLogItem 42
74 `export type AdviceRuleId = "R1" \ "R2" \ "R3" \ "R4" \ "B1" \ "B2" \ "B3"` (後述の重複あり)
76 export interface AdviceItem 0
86 export interface CleanLogAdviceResponse 7

CleanLogItem(51〜72行目)はこの層で最も重要な型です。純粋層の規約がフィールドのコメントに直接書き込まれています。

  • 64行目 /** タスク完了率(%)。未出発時は null。★丸める前の生値を入れる★ */ → completion_rate: number | null
  • 66行目 /** サーバ側算出。fixtureでは期待値を直書きしている */ → grade: Grade
  • 68行目 /** △以下または出発失敗の連続日数(スケジュール存在日ベース) */ → consecutive_problem_days: number
  • 71行目 /** 問題行のみ設定 */ → escalation_level: EscalationLevel | null

number | null という書き方(union 型 / nullable): 平易な言い換え「数字が入るか、それとも『無い』が入る」。ここでの null は「0%」ではなく「そもそも実施していない」を意味します。この区別がこの層の設計の背骨で、kpi.ts の分母0の扱い(後述)まで一貫しています。

★指摘★ AdviceRuleId の二重定義: 同じ union が2箇所で宣言されています。

  • lib/types/index.ts 74行目
  • lib/config/advice-rules.ts 16行目

lib/judge/advice.ts は2行目で config 側から import しています(import type { AdviceRuleId } from "@/lib/config/advice-rules";)。一方 lib/logger/events.ts は9行目で types 側から import しています。現状は中身が完全一致しているので実害は出ていませんが、片方だけ増やすと型チェックが通ったまま食い違います。(これは推測ではなく、両ファイルの実行を突き合わせた事実です。)

アラート(事前チェック、93〜126行目)

行 宣言 外部参照
95 `export type PrecheckSeverity = "precheck_1h" \ "precheck_30m" \ "launch_failed"` 3
96 `export type PrecheckStatus = "unresolved" \ "in_progress" \ "resolved"` 0
102 `export type PrecheckStatusFilter = "open" \ "unresolved" \ "in_progress" \ "resolved" \ "all"` 6
109 export interface PrecheckItem 21

98〜101行目のコメントが PrecheckStatus と PrecheckStatusFilter を分けた理由です。

 * GET /prechecks の `status` クエリパラメータ。
 * `open` = 未解消+対応中(D-054)。`PrecheckStatus` のスーパーセット

平易な言い換え: データそのものが取る状態は3つ(未解消・対応中・解消)。しかし画面の絞り込みボタンには「未対応(= 未解消 + 対応中)」というまとめ表示があるので、絞り込み用の語彙のほうが2語多い。混ぜると「データに open を保存してしまう」事故が起きるので別の型にしてある。

124行目のコメント /** Pudu APIで取得可能と確認済み */(battery_level: number | null)は、「この項目が本当に取れるのか」を実機確認した記録がコードに残されている例です。

レポート(128〜182行目)

行 宣言 外部参照
130 `export interface KpiValue { value: number; prev: number \ null; diff: number \ null }` 4
139 `export type FacilityStatus = "good" \ "warning" \ "bad"` 2
141 export interface FacilityPerformance 2
152 export interface ContinuousProblem 2
162 export type ErrorType(7分類) 0
171 export interface ErrorBreakdownItem 3
177 export interface ReportKpi 4

KpiValue のコメント(131〜136行目)が生値ルールを再宣言しています。

  /** 丸める前の生値 */
  value: number;
  /** 前期値。前期データなしは null */
  prev: number | null;
  /** value - prev */
  diff: number | null;

ErrorType(162〜169行目)は7分類 — handle_up / lost_location / offline / brush_error / emergency_stop / low_battery / other。161行目のコメントが /** エラー種別7分類 */ と件数まで書いています。

D-Security(184〜226行目)

行 宣言 外部参照
186 `export type DsConnectionStatus = "ok" \ "disconnected" \ "error"` 4
189 `export type DsDeviceStatusFilter = "all" \ "error" \ "ok"` 8
191 export interface DsDevice 27
200 `export type DsAlertType = "security_off" \ "disconnected"` 3
201 `export type DsAlertStatus = "pending" \ "in_progress" \ "done"` 0
204 `export type DsAlertTypeFilter = "all" \ "security_off" \ "disconnected"` 9
206 `export type DsAlertStatusFilter = "open" \ "all" \ "done"` 8
208 export interface DsAlert 32

ここでも「データの状態」と「絞り込み語彙」が分離されています。188行目 /** GET /ds-devices のstatusクエリパラメータ。error = "error" + "disconnected" */、205行目 /** GET /ds-alerts のstatusクエリパラメータ。open = pending + in_progress */。

214〜218行目に未確定事項が明示されている箇所があります。

  /**
   * 清掃エリア名。`/dashboard` の `security_banner` が要求する。
   * ★実DB上の取得元が不明なため fixtures では暫定のダミー値★
   * DB接続時に `prismaSource` 側で実際の取得元に差し替える。
   */
  section_name: string;

CLAUDE.md §7(219行目)の「❌ 仕様が不明な箇所を推測で実装すること(確認を求める)」に対し、推測実装せず「不明」と型のコメントに残す運用になっています。

マスタ(228〜264行目)

行 宣言 外部参照
230 export interface RobotSummary 0(Facility.robots 内でのみ使用)
237 export interface Facility 7
253 export interface Schedule 9

Facility の246行目に契約フラグの注意が入っています。

  /** D-Watch契約。facilities.contract 由来(customers.contract は参照しない) */
  monitoring_enabled: boolean;

CLAUDE.md §4.6(156〜161行目)と §7(214行目)の「❌ customers.contract の参照(削除依頼済み。facilities.contract を使う)」に対応します。

Schedule の258行目 /** 日月火水木金土(清掃する曜日=1)の7桁 */ weekdays: string — 平易な言い換え: 「月水金だけ清掃」を "0101010" のような7文字で表す。日曜が先頭。この7桁が、後述するエスカレーションの「スケジュール存在日」計算のもとになります(ただし計算自体は純粋層の外です)。

点検記録(266〜298行目)

行 宣言 外部参照
268 export type InspectionItemName(7項目の日本語リテラル) 4
277 export interface InspectionPhoto 3
283 export interface InspectionListItem 4
295 export interface InspectionDetail extends InspectionListItem 4

InspectionItemName(268〜275行目)は日本語文字列そのものが型になっています — "ブラシ" | "ダストボックス" | "フィルター" | "スクイジー" | "吸引ホース" | "汚水タンク" | "ブラシ裏面"。平易な言い換え: 「点検項目名はこの7つの日本語のどれかしか書けない」と機械に見張らせている。誤字(「スクイージー」など)が型チェックで弾かれます。

extends(295行目): 平易な言い換え「一覧行の情報を全部そのまま持ったうえで、notes と photos を追加した型」。同じ項目を二度書かないための仕組みです。

ダッシュボード(300〜341行目)

行 宣言 外部参照
302 `export type FailureType = "not_executed" \ "security_active" \ "control_error" \ "robot_error"` 0
308 export interface DashboardResponse 13

DashboardResponse には、この層の設計思想が最も濃く出ているコメントが3箇所あります。

313行目 — 分母0の扱い:

    /** 分母(success + failed + not_executed)が0なら null */
    success_rate: number | null;

317〜322行目 — 「予定」と「完了」の定義がずれる罠:

    /** スケジュールマスタの当日該当件数(active のみ)。清掃ログの行数ではない(D-049) */
    planned: number;
    /** 出発した件数(departed === true)。成功+失敗であり「完了した件数」ではない(D-049) */
    completed: number;
    /** completed / planned * 100。planned が 0 なら null */
    rate: number | null;

324〜330行目 — ?(オプショナル)を使う理由:

  /**
   * M2 では返さない(D-048。M3 でエラーテーブルを扱うときに実装する)。
   *
   * ★optional なのは「未実装」と「当日の失敗0件」を区別するため★
   * 空配列は「失敗0件」を意味するので、未実装をそれで表すと
   * UI が「失敗なし」として正常表示してしまう。プロパティ自体を持たせない。
   */
  failure_breakdown?: Array<{ ... }>;

平易な言い換え: 「箱を空っぽで渡す」と「箱そのものを渡さない」は違う意味にしてある。空っぽ = 「失敗は0件でした」、箱なし = 「まだ数えていません」。もし未実装を空配列で表すと、画面は「今日は失敗ゼロ、良好です」と嘘の安心を表示してしまう。null を「未実施」に、0 を「実施したが0%」に割り当てるのと同じ発想が、配列に対しても適用されています。


lib/config/index.ts

54行。関数は1つもありません。 定数(変わらない数字)だけを集めた「設定値の紙」です。

import 文(実際のコードのまま)

import 文は1行もありません。 1〜9行目のコメント、11行目から定数宣言です。

していない import の確認: lib/logger なし、lib/db なし、lib/types すら import していません。純粋層の中でも最も依存の少ないファイルです。

先頭コメント(1〜9行目)がこのファイルの存在理由そのものです。

/**
 * 設定値(閾値の外出し)
 *
 * CLAUDE.md 7. 禁止事項:
 *   ❌ 閾値(90/70/10、猶予30分、L2=2日/L3=3日)のハードコード
 *
 * 判定ロジックや画面から直接数値を書かず、必ずここを参照してください。
 * 将来的にはシステム管理画面の「運用設定」から変更できるようにします(F-10)。
 */

export しているもの — 全8件

export const GRADE_THRESHOLDS = { excellent: 90, good: 70, poor: 10 } as const;              // 11行目
export const ESCALATION_THRESHOLDS = { level2Days: 2, level3Days: 3 } as const;               // 20行目
export const LAUNCH_GRACE_MINUTES = 30;                                                       // 31行目
export const PRECHECK_MINUTES_BEFORE = [60, 30] as const;                                     // 34行目
export const PAGINATION = { defaultPerPage: 50, maxPerPage: 200 } as const;                   // 37行目
export const MAX_DATE_RANGE_DAYS = 31;                                                        // 43行目
export const FACILITY_STATUS_THRESHOLDS = { good: 90, warning: 75 } as const;                 // 46行目
export const SUCCESS_RATE_TARGET = 95 as const;                                               // 54行目

as const: 平易な言い換え「この数字はもう書き換えられない、と機械に宣言する」。付けないと、うっかり GRADE_THRESHOLDS.excellent = 85 と書けてしまいます。付けておくと型チェックでその代入が弾かれます。

GRADE_THRESHOLDS(11〜18行目)

export const GRADE_THRESHOLDS = {
  /** 完了率がこの値を「超える」と ◎(90.0 は ○ になる) */
  excellent: 90,
  /** 完了率がこの値を「超える」と ○(70.0 は △ になる) */
  good: 70,
  /** 完了率がこの値「未満」は ×(10.0 は × になる) */
  poor: 10,
} as const;

★★★ 重大な発見 — 16行目のコメントが実装と食い違っています ★★★

16行目は「10.0 は × になる」と書いていますが、実装(grade.ts 26行目 if (rate < GRADE_THRESHOLDS.poor) return "×";)では 10.0 は △ になります。 10 < 10 は偽なので × には行きません。

そして「△が正しい」ことは以下3箇所が裏付けています。

  1. docs\decisions.md D-020b(84行目): 「決定: 完了率 90.0 は ○、70.0 は △、10.0 は △(×ではない)」
  2. lib\judge\__tests__\grade.test.ts 26行目: { name: "△/×の境界(10%未満が×なので10.0は△)", rate: 10.0, grade: "△", display: "10%" }
  3. lib\fixtures\clean-logs.ts 75行目のコメント表: // 10.0 △ 10% ← 「10%未満」が×なので10.0は△(CLAUDE.md 4.1 が正)

つまり コメント文だけが古い(コードとテストと決定記録は正しい)。しかも同じ11〜18行目の中で、excellent と good のコメントは「90.0 は ○」「70.0 は △」と正しく「下のランクに落ちる」と書けているので、poor の1行だけが規則から外れた書き方になっています。D-020b(87〜88行目)はその規則をこう述べています。

理由: ◎は「90%超」、○は「70%超」と、境界値そのものは下のランクに落ちる規則で統一されている。×だけ「10%以下」にすると規則が崩れる

動作への影響: ありません(コメントは実行されない)。ただしこのコメントを信じて実装を「直す」と、テスト4件が落ち、D-020b の合意を破ることになります。修正すべきはコメント側です。

呼ばれている場所(grep 実測、テストを除く):

呼び出し元 行 用途
lib\judge\grade.ts 1, 24, 25, 26 ◎○△×の判定本体
lib\judge\advice.ts 1, 58 R2(10%未満)の発火条件
lib\fixtures\clean-logs.ts 2, 210 サンプルデータの連続日数付与
app\api\clean-logs\[id]\advice\route.ts 10, 76-78 ログに「使ったしきい値」を記録
components\features\detail-log\CleanLogTable.tsx 7, 128-137 画面の凡例表示

CleanLogTable.tsx 134行目は凡例を △ {GRADE_THRESHOLDS.poor}-{GRADE_THRESHOLDS.good - 1}% = 「△ 10-69%」と組み立てています。数字を直接書かず定数から作っているので、しきい値を変えれば凡例も自動で変わります。

advice\route.ts 75〜81行目でしきい値をログに残している理由は lib\logger\events.ts 127〜131行目に書かれています。

 * 入力の生値としきい値スナップショットを持つ理由:
 * `matchAdviceRules()` は `GRADE_THRESHOLDS`(R2 / B1)と
 * `ESCALATION_THRESHOLDS`(R3 / R4)の両方を読んでいる。
 * F-10(M5)でしきい値が編集可能になると、同じ入力でも判定日によって
 * 発火するルールが変わるため、結果だけでは後から再現できない(§5 の3点セット)。

平易な言い換え: しきい値を画面から変えられるようにすると、「去年この施設が×だったのはなぜか」を後で調べるとき、当時のしきい値がわからないと再現できない。だから判定するたびに「そのとき使った区切り数字」も一緒に記録しておく。

ESCALATION_THRESHOLDS(20〜25行目)

export const ESCALATION_THRESHOLDS = {
  /** 2日連続で L2 */
  level2Days: 2,
  /** 3日以上連続で L3 */
  level3Days: 3,
} as const;

CLAUDE.md §4.5(141〜145行目)の表と一致します。

レベル 条件 想定対応
L1 初回の問題 現場へ連絡(障害物確認・ブラシ清掃・再起動等)
L2 2日連続 現場責任者へのヒアリング・運用手順の再教育・消耗品交換
L3 3日以上連続 DFAオンサイト訪問・マップ再作成・機体交換検討

呼ばれている場所: lib\judge\escalation.ts(1, 56, 57行目)、lib\judge\advice.ts(1, 63, 68行目)、app\api\clean-logs\[id]\advice\route.ts(10, 79-80, 106-107行目)。

LAUNCH_GRACE_MINUTES(27〜31行目)

/**
 * 清掃失敗(未実施)と判定するまでの猶予(分)
 * D-025: 暫定値。運用しながら調整する
 */
export const LAUNCH_GRACE_MINUTES = 30;

CLAUDE.md §4.4(135行目): 「清掃失敗 | 予定開始時刻 + 猶予30分を経過してもタスク開始が確認できない(未実施)、またはタスクレポートが中断で返る」。docs\decisions.md D-025(110〜112行目)が [暫定] の札付きで同じ内容を記録しています。

★まだ呼ばれていません。★ grep 実測で、宣言している lib\config\index.ts 31行目以外に出現がありません。清掃失敗の判定(予定時刻+30分の経過チェック)自体がまだ実装されていない段階のため、と読めます(この因果関係の部分は推測)。「先に設定値だけ置いておき、使うときにハードコードしなくて済むようにしてある」状態です。

PRECHECK_MINUTES_BEFORE(33〜34行目)

/** 事前チェックのタイミング(分前) */
export const PRECHECK_MINUTES_BEFORE = [60, 30] as const;

CLAUDE.md §8(230行目)の用語定義: 「事前チェック | 清掃開始の60分前/30分前にロボット状態を確認する仕組み」。

★まだ呼ばれていません。★ grep 実測で宣言箇所(34行目)以外に出現なし。lib\types\index.ts 95行目の PrecheckSeverity = "precheck_1h" | "precheck_30m" | ... や CleanLogItem の status_1h / status_30m は文字列側で60分/30分を表現しており、この配列とは接続されていません。

PAGINATION(36〜40行目)

/** 一覧APIの共通デフォルト */
export const PAGINATION = { defaultPerPage: 50, maxPerPage: 200 } as const;

CLAUDE.md §9.3(288行目)「一覧APIは必ず meta: { total, page, per_page } を返す」に対応する数値側。平易な言い換え: 「何も指定しなければ1ページ50件、いくら要求されても200件までしか返さない」。上限がないと「1000万件くれ」でサーバが倒れます。

呼ばれている場所(8ファイル): lib\clean-logs\query.ts(5, 103, 104)、lib\ds-alerts\query.ts(5, 43, 44)、lib\ds-devices\query.ts(5, 35, 36)、lib\prechecks\query.ts(6, 49, 50)、components\features\alerts\AlertsScreen.tsx(9, 64)、components\features\detail-log\DetailLogScreen.tsx(10, 81)、components\features\ds-alerts\DsAlertsScreen.tsx(9, 55)、components\features\ds-logs\DsLogScreen.tsx(9, 63)。

4つの query.ts はいずれも同じ形(例: lib\clean-logs\query.ts 103〜104行目)で使っています。

    PAGINATION.maxPerPage,
    Math.max(1, toInt(searchParams.get("per_page"), PAGINATION.defaultPerPage))

MAX_DATE_RANGE_DAYS(42〜43行目)

/** 清掃詳細ログの日付範囲の上限(日) */
export const MAX_DATE_RANGE_DAYS = 31;

呼ばれている場所: lib\clean-logs\query.ts(5, 84, 88)、components\features\detail-log\DetailLogScreen.tsx(10, 95, 97)。両方とも「31日を超えたらエラー」という同じ検査で、エラー文言まで定数から組み立てています(query.ts 88行目 / DetailLogScreen.tsx 97行目 いずれも `date_from〜date_toは${MAX_DATE_RANGE_DAYS}日以内で指定してください` )。日数の計算に使われるのが後述の inclusiveDayCount() です。

FACILITY_STATUS_THRESHOLDS(45〜51行目)

/** 施設別パフォーマンスの良好/注意/要改善の境界(暫定値) */
export const FACILITY_STATUS_THRESHOLDS = {
  /** 稼働率がこの値以上なら良好 */
  good: 90,
  /** 稼働率がこの値以上なら注意、未満は要改善 */
  warning: 75,
} as const;

★注意: ここだけ「以上」で、GRADE_THRESHOLDS は「超える」です。★ コメントが明示的に「以上」と書き分けており、実装(kpi.ts 69〜70行目の >=)と一致しています。同じファイルに2種類の境界の向きが同居しているので、読むときに混同しないよう注意が必要な箇所です。

呼ばれている場所: lib\judge\kpi.ts(1, 66, 69, 70) のみ。ただし judgeFacilityStatus() 自体にまだ本番の呼び出し元がありません(後述)。

SUCCESS_RATE_TARGET(53〜54行目)

/** ダッシュボード(F-01)・レポート(F-04)の成功率カードに表示する目標値(%) */
export const SUCCESS_RATE_TARGET = 95 as const;

判定には使わず、画面に「目標: 95%」と出すためだけの値です。呼ばれている場所: components\features\dashboard\DashboardScreen.tsx(11, 122) — sub={`目標: ${SUCCESS_RATE_TARGET}%`}。


lib/config/advice-rules.ts

129行。関数は1つもありません。 「アドバイスの文言カタログ」です。

import 文(実際のコードのまま)

import 文は1行もありません。 1〜14行目のコメント、16行目から型と定数です。

していない import の確認: lib/logger なし、lib/db なし、lib/settings なし(コメントで言及だけしている)、ファイル読み書き(node:fs)なし。この「なし」が設計上とても重要です — 6行目のコメントは「編集された内容は data/settings/advice.json に保存され、ここの既定値を上書きします」と述べていますが、その JSON を読む処理はこのファイルにはありません。 読むのは別ファイル lib\settings\advice.ts の役目です(同ファイル4〜7行目でこのファイルから import している側)。

平易な言い換え: このファイルは「工場出荷時の初期設定が書かれた紙」。お客さんが設定を書き換えた内容は別の紙に書かれ、それを重ねる作業は別の担当者がやる。だからこの紙自体はファイルを読まないし、いつ読んでも同じ内容を返します(= 純粋)。

先頭コメント(1〜14行目)に分離の意図が明記されています。

 * ★文言(title / text / actions)はシステム管理の「運用設定」から編集できます★
 *   編集された内容は `data/settings/advice.json` に保存され、ここの既定値を上書きします。
 *   実行時は `lib/settings/advice.ts` の `loadAdviceRules()` を使ってください。
 *
 * ★発火条件(どのルールが該当するか)はここではなく `lib/judge/advice.ts` にあります★
 *   条件は仕様であり、文言は運用の都合で変わるものなので、意図的に分けています。
 *   文言を変えてもテストは壊れません。

根拠は docs\decisions.md D-036(122〜130行目)です。

  • 決定: 該当ルールの判定は lib/judge/advice.ts(テストで固定)、表示文言は運用設定から編集可能にする
  • 理由: 文言は運用しながら磨いていくもので、変えるたびにテストが壊れるのは不合理。一方で発火条件は仕様であり、勝手に変わってはいけない
  • 保存先: data/settings/advice.json(DFA側の設定ファイル)。メインDBには保存しない
  • 編集可能: title / text(推定原因)/ actions(推奨アクション)
  • 編集不可: level / icon / condition(発火条件の説明)

平易な言い換え: 「どんなときに警告を出すか」は会社間の取り決め(勝手に変えたら約束違反)。「その警告になんと書くか」は現場で磨いていく文章(何度でも変えていい)。この2つを同じファイルに書くと、文章を1文字直すたびに取り決めのテストが落ちて、だんだんテストを無視するようになる。だから分けてある。

export しているもの — 全4件

export type AdviceRuleId = "R1" | "R2" | "R3" | "R4" | "B1" | "B2" | "B3";        // 16行目
export interface AdviceRuleDefinition { ... }                                     // 18行目
export const DEFAULT_ADVICE_RULES: Record<AdviceRuleId, AdviceRuleDefinition>     // 34行目
export type EditableAdviceFields = Pick<AdviceRuleDefinition, "title" | "text" | "actions">;  // 126行目

AdviceRuleDefinition(18〜32行目)

export interface AdviceRuleDefinition {
  id: AdviceRuleId;
  phase: "A" | "B";
  /** 1=注意 2=警告 3=重大。行バッジの色は該当ルールの最大レベルで決まる */
  level: 1 | 2 | 3;
  icon: string;
  /** 見出し(編集可) */
  title: string;
  /** 推定原因(編集可) */
  text: string;
  /** 推奨アクション(編集可) */
  actions: string[];
  /** 発火条件の説明。画面の設定編集時に「どんなときに出るか」を示すため(編集不可) */
  condition: string;
}

編集可 / 編集不可がコメントで1項目ずつ明示されているのがこの型の特徴です。level: 1 | 2 | 3 は「1か2か3の数字しか入らない」型で、number にしていないので level: 7 は型チェックで弾かれます。

condition(31〜32行目)が面白い項目です。平易な言い換え: 「このアドバイスは何が起きたときに出るのか」の説明文。設定画面で文言を編集する人が「今どのルールの文章を直しているのか」を理解するために画面に出す用。プログラムはこの文字列で判定しません(判定は lib\judge\advice.ts のコードが行う)。つまり condition は人間向けの注記で、機械はそれを読まない — なので condition の文と実際の発火条件がずれても機械は気づけません(後述の突き合わせで実際に検証しました)。

DEFAULT_ADVICE_RULES(34〜123行目)

export const DEFAULT_ADVICE_RULES: Record<AdviceRuleId, AdviceRuleDefinition> = { ... };

Record<AdviceRuleId, AdviceRuleDefinition>: 平易な言い換え「R1〜R4・B1〜B3 の7つのキーすべてに、必ず定義が1つずつある辞書」。1つでも書き忘れると型チェックで落ちます。「B3 の文言を書き忘れて画面が空欄になる」事故を機械が防いでいます。

7ルールの中身(level / icon / condition):

id 行 phase level icon title condition(コメント上の説明)
R1 35-48 A 1 ⚠ 出発失敗(R1) 出発失敗(初回)
R2 49-62 A 1 ⚠ 完了率が極端に低い(R2) 完了率が10%未満
R3 63-76 A 2 🔶 2日連続で完了率低下(R3) 同一施設で2日以上連続して△以下または出発失敗
R4 77-90 A 3 🔴 複数日連続で問題継続(R4) 同一施設で3日以上連続して△以下または出発失敗
B1 93-102 B 1 🗺 マップ更新の確認 完了率が10〜69%(△判定)
B2 103-112 B 1 🔧 ハンドルアップ検知 1時間前または30分前のステータスがハンドルアップ
B3 113-122 B 1 📡 オフライン検知 1時間前または30分前のステータスがオフライン

level の設計: Phase A(R1〜R4)だけが 1→1→2→3 と上がっていき、Phase B(B1〜B3)は全部 1 です。21行目のコメント「行バッジの色は該当ルールの最大レベルで決まる」のとおり、この数字は後述の maxAdviceLevel() に食わせて行の色を決めるためだけに使われます。平易な言い換え: 「問題が長く続いているほど濃い色にする。エラーの種類(ハンドルが上がっている等)は種類が何であれ薄い色(注意)」。

各ルールの actions は3件(R1〜R4)または2件(B1〜B3)の配列です。例(R4、84〜88行目):

    actions: [
      "DFAオンサイト訪問の手配を検討",
      "マップ再作成の検討",
      "機体交換の検討",
    ],

これは CLAUDE.md §4.5 の L3 想定対応「DFAオンサイト訪問・マップ再作成・機体交換検討」(145行目)と一語ずつ対応しています。

R4 の text(83行目):

    text: "現場対応では解決しない構造的問題の可能性があります。DFAオンサイト訪問(マップ再作成・環境調査)の手配を検討してください。",

91〜92行目に Phase の区切りコメントがあります。

  // ---- Phase B(エラー種別ごとのアドバイス) ----

12〜13行目の「Phase A = CLAUDE.md 4.5 の R1〜R4 / Phase B = エラーコード別のアドバイス(モックに存在していたもの)」が対応。平易な言い換え: R1〜R4 は TeamWorks と合意した正式仕様。B1〜B3 は画面モック(d-watch-mock.jsx)に元から入っていた便利機能を引き継いだもの。出自が違うので区別している。

★condition 文と実装の突き合わせ結果(lib\judge\advice.ts との照合):

  • R1 の condition「出発失敗(初回)」 → 実装 53行目 if (!departed && consecutiveProblemDays <= 1)。「初回」を <= 1(0日または1日)で表現しており、condition 文からは 0 が含まれることが読み取れません。
  • R3 の condition「同一施設で2日以上連続して△以下または出発失敗」 → 実装 63行目 if (consecutiveProblemDays >= ESCALATION_THRESHOLDS.level2Days)。「同一施設で」「△以下または出発失敗」の部分は実装に存在しません — 渡ってくる consecutiveProblemDays がすでにその意味で数えられている前提です。CLAUDE.md §4.5 の表(62行目)では R3 が「同一施設で2日以上連続△以下」(出発失敗を含まない書き方)、R4 が「同一施設で3日以上連続(出発失敗 or △以下)」となっており、CLAUDE.md 側では R3 と R4 で対象が書き分けられていますが、実装では両方とも同じ consecutiveProblemDays 1つを見ているだけで、△以下と出発失敗を区別していません。advice-rules.ts 75行目の condition は R3 も「△以下または出発失敗」と書いているため、advice-rules.ts の condition のほうが実装に忠実です(CLAUDE.md 62行目の書き方が古いと読めます。どちらが「正」かの判断は仕様側の確認が必要 — ここは推測です)。
  • B1 の condition「完了率が10〜69%(△判定)」 → 実装 73行目 judgeGrade(completionRate) === "△"。実際の△の範囲は 10.0 以上 70.0 以下(70.0 も含む)なので、「10〜69%」という表記は 69.0 超〜70.0 の部分を取りこぼした書き方です。

EditableAdviceFields(125〜129行目)

/** 編集可能なフィールド(設定画面で書き換えられる範囲) */
export type EditableAdviceFields = Pick<
  AdviceRuleDefinition,
  "title" | "text" | "actions"
>;

Pick<T, K>: 平易な言い換え「元の型から、指定した項目だけを抜き出した新しい型を作る」。ここでは AdviceRuleDefinition の10項目から title / text / actions の3つだけを抜いた型を作っています。

なぜこう書くのか: 「編集できるのはこの3つ」というルールを、コメントではなく型として機械に守らせるため。もし設定画面から level を書き換えようとすると型チェックで弾かれます。D-036(128〜129行目)の「編集可能: title / text(推定原因)/ actions(推奨アクション)」「編集不可: level / icon / condition(発火条件の説明)」が、そのまま型になっています。3項目を手で書き写すのではなく Pick で参照しているので、元の型の項目名が変わればここも自動で追従します。

呼ばれている場所

export 呼び出し元 行
DEFAULT_ADVICE_RULES app\api\settings\advice\route.ts 10, 41, 42, 44, 67
DEFAULT_ADVICE_RULES lib\settings\advice.ts 4, 52, 53, 66, 74, 84
AdviceRuleDefinition lib\settings\advice.ts 5, 47, 50, 65, 80
AdviceRuleId lib\judge\advice.ts 2, 42, 43
AdviceRuleId lib\settings\advice.ts 6, 24, 52, 63, 79
AdviceRuleId app\api\settings\advice\route.ts 10, 66
EditableAdviceFields lib\settings\advice.ts 7, 24, 64

app\api\settings\advice\route.ts 41〜44行目は「既定値と違うか」を判定して「編集済み」フラグを立てる用途に使っています。

          r.title !== DEFAULT_ADVICE_RULES[r.id].title ||
          r.text !== DEFAULT_ADVICE_RULES[r.id].text ||
            JSON.stringify(DEFAULT_ADVICE_RULES[r.id].actions),

lib\settings\advice.ts 74行目が「既定値に編集内容を重ねる」本体です: return { ...DEFAULT_ADVICE_RULES[id], ...overrides[id] };。平易な言い換え: 出荷時設定を敷いた上に、書き換えられた項目だけを上から貼る(スプレッド構文 ...)。編集されていない項目は自動的に既定値のまま残ります。


lib/format/date.ts

37行、関数4本。日付・時刻を画面に出す形に整えるだけのファイルです。

import 文(実際のコードのまま)

import 文は1行もありません。 1〜4行目のコメント、7行目から関数です。

していない import の確認: lib/logger なし、lib/db なし、lib/types なし、日付ライブラリ(date-fns / dayjs / moment 等)も一切なし。ブラウザと Node.js に元から入っている Number.parseInt / Intl.DateTimeFormat / Date.parse / Math.floor だけで書かれています。

先頭コメント(1〜4行目)が方針そのものです。

/**
 * 表示用の日付・時刻整形。
 * APIの日時は ISO8601(JST, +09:00)。タイムゾーン変換せず文字列から切り出す。
 */

専門用語: ISO 8601。平易な言い換え「2026-08-27T02:00:00+09:00 という決まった書き方の日時文字列」。前半が日付、T を挟んで時刻、末尾の +09:00 が「これは日本時間です」の意味。CLAUDE.md §9.3(285行目)「日時は ISO 8601(JST, +09:00)。表示整形はコンポーネント側」が根拠です。

専門用語: タイムゾーン変換。平易な言い換え「日本時間を協定世界時(UTC)に直したり戻したりする計算」。このファイルは意図的にそれをやりません。

なぜやらないのか(設計判断として重要): Date オブジェクトに変換すると、プログラムが動いているパソコンの時計設定に引きずられます。日本時間の 02:00 が、サーバの設定次第で「前日の17:00」と表示されかねません。文字列 "2026-08-27T02:00:00+09:00" の11文字目から16文字目を切り出せば、どのパソコンで動かしても必ず "02:00" になります。平易な言い換え: 「計算せずに、書いてある文字をそのまま抜き出す」。地味ですが、深夜清掃(このシステムの主対象は02:00〜03:30の清掃)を扱う以上、日付が1日ずれる事故が最も怖いので、計算しないほうが安全という判断です。

export しているもの — 全4件

export function formatMd(iso: string): string                                   // 7行目
export function formatHm(iso: string): string                                   // 14行目
export function todayJst(): string                                              // 19行目
export function inclusiveDayCount(from: string, to: string): number | null      // 29行目

formatMd

/** 2026-08-27T02:00:00+09:00 → 8/27(モックの日付列) */
export function formatMd(iso: string): string {
  const month = Number.parseInt(iso.slice(5, 7), 10);
  const day = Number.parseInt(iso.slice(8, 10), 10);
  return `${month}/${day}`;
}

(6〜11行目)

(a) 引数と戻り値の業務上の意味

  • 引数 iso: APIが返した「清掃の予定日時」などの日時文字列。例 "2026-08-27T02:00:00+09:00"。
  • 戻り値: 一覧表の「日付」列に出す短い表記。例 "8/27"。

(b) 中で何をしているか(分岐なし・3行)

条件分岐は1つもありません。上から順に:

  1. 8行目 iso.slice(5, 7) — 文字列の6文字目から7文字目を切り出す。"2026-08-27T..." なら "08"。slice(5, 7) の 5 は「先頭を0番目と数えて5番目から」という数え方。"2026-" が5文字なので、その次の "08" が取れます。
  2. Number.parseInt("08", 10) で数値の 8 にする。第2引数の 10 は「10進数として読め」の指示。これを省くと環境によって先頭の 0 を8進数の合図と誤読する危険があるため、明示しています。ここが "08" を 8 にする(先頭のゼロを落とす)役目も兼ねています。
  3. 9行目 iso.slice(8, 10) — 9文字目から10文字目、つまり "27"。同じく数値 27 に。
  4. 10行目 テンプレート文字列で 8 と 27 をつなぎ "8/27"。

境界値・想定外入力の挙動: 引数が短すぎる文字列(例 "2026")だと slice が空文字を返し、Number.parseInt("") は NaN(数値でない)を返すので、結果は "NaN/NaN" になります。入力の検査は一切していません。 APIが必ず ISO 8601 を返すという前提に乗っています(実装を読んだ事実。「そう設計した」という記述はファイル内にないため、意図かどうかは推測)。

(c) なぜそう書いてあるか: 6行目のコメントに「(モックの日付列)」とあります。CLAUDE.md §9.6(308行目)「モック(d-watch-mock.jsx)の画面レイアウト・文言・遷移を正とする」に従い、モックの表示(8/27 形式。08/27 でも 2026/08/27 でもない)を再現するための関数です。

(d) 呼ばれている場所(6ファイル + テスト)

呼び出し元 行
app\api\clean-logs\export\route.ts 10(import), 45
components\features\alerts\AlertsScreen.tsx 10, 41
components\features\detail-log\CleanLogTable.tsx 8, 173
components\features\detail-log\DetailLogScreen.tsx 11, 183
components\features\ds-alerts\DsAlertsScreen.tsx 10, 34
components\features\ds-logs\DsLogScreen.tsx 10, 36
lib\clean-logs\__tests__\query.test.ts(テスト) 3, 27

AlertsScreen.tsx 41行目 / DsAlertsScreen.tsx 34行目 / DsLogScreen.tsx 36行目は3ファイルとも同じ組み合わせ方をしています — formatMd(iso) と formatHm(iso) を空白でつないで "8/27 02:00" を作っています。

app\api\clean-logs\export\route.ts 45行目は CSV 出力での利用です(csvCell(formatMd(r.scheduled_at)))。画面用の整形関数がAPI(CSV出力)からも呼ばれている点は、CLAUDE.md §9.3(285行目)「表示整形はコンポーネント側」の字面からは少し外れますが、CSVも人が見る出力なので同じ整形を使うのは自然です(この評価は筆者の解釈)。

formatHm

/** 2026-08-27T02:00:00+09:00 → 02:00 */
export function formatHm(iso: string): string {
  return iso.slice(11, 16);
}

(13〜16行目)

(a) 引数 iso: 日時文字列。戻り値: 「時:分」。例 "02:00"。秒は捨てます。

(b) 条件分岐なし、1行。slice(11, 16) で12文字目から16文字目を取ります。"2026-08-27T" がちょうど11文字なので、その次の5文字 "02:00" が取れます。formatMd と違って parseInt を通していないので、先頭のゼロが残ります("02:00"、"2:00" にはならない)。時刻は桁を揃えたいので正しい挙動です。

境界値: 短い文字列を渡せばそのぶん短い結果が返ります("2026-08-27" を渡すと空文字)。エラーにはなりません。

(c) 深夜清掃の時刻を「02:00」と2桁で揃えて表示するため。slice 一発なのは前述の「タイムゾーン変換をしない」方針の徹底。

(d) 呼ばれている場所(6ファイル + テスト)

呼び出し元 行
app\api\clean-logs\export\route.ts 10, 46
components\features\alerts\AlertsScreen.tsx 10, 41, 172
components\features\dashboard\SecurityBanner.tsx 1, 22
components\features\detail-log\CleanLogTable.tsx 8, 174
components\features\ds-alerts\DsAlertsScreen.tsx 10, 34
components\features\ds-logs\DsLogScreen.tsx 10, 36
lib\clean-logs\__tests__\query.test.ts(テスト) 3, 28

SecurityBanner.tsx 22行目はセキュリティ無効の警告帯で「(02:00以降)」の部分を作っています。

todayJst

/** JSTの今日を YYYY-MM-DD で返す */
export function todayJst(): string {
  return new Intl.DateTimeFormat("en-CA", {
    timeZone: "Asia/Tokyo",
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).format(new Date());
}

(18〜26行目)

(a) 引数と戻り値

  • 引数: なし。
  • 戻り値: 「日本時間で今日は何日か」を "2026-09-02" の形で返す。日付入力欄の初期値や、ダッシュボードの対象日として使われます。

★このファイルで唯一「純粋でない」関数です。★ 25行目の new Date() が「今の時刻」を外の世界(パソコンの時計)から取ってくるため、呼ぶたびに答えが変わり得ます。平易な言い換え: これは電卓ではなくカレンダー。lib/format は lib/judge と違って純粋関数の規約(CLAUDE.md §9.4 は lib/judge/ に対する規約)を課されていないので規約違反ではありませんが、「純粋層の中に1つだけ時計を見る関数がある」ことは押さえておく価値があります。

(b) 中で何をしているか(分岐なし)

  1. new Date() — パソコンの時計から現在時刻を取る。この時点では「世界のどこの時間として見るか」は決まっていない内部表現。
  2. new Intl.DateTimeFormat(...) — 表示ルールを作る。
    • timeZone: "Asia/Tokyo" — 日本時間として解釈させる。サーバがアメリカにあっても日本の日付が出ます。
    • year: "numeric" → 2026、month: "2-digit" → 09、day: "2-digit" → 02。2-digit は「2桁に0埋め」。
  3. .format(...) で文字列化。

★"en-CA" という指定がこの関数の急所です。★ 第1引数はロケール(言語・地域)で、出力の並び順と区切り文字を決めます。カナダ英語(en-CA)は日付を 2026-09-02(年-月-日、ハイフン区切り)で書く数少ないロケールで、これが HTML の日付入力欄(<input type="date">)が要求する YYYY-MM-DD 形式と一致します。

  • "ja-JP" にすると 2026/09/02(スラッシュ)になり形式が違う
  • "en-US" にすると 09/02/2026(月/日/年)になりもっと違う

平易な言い換え: 「日本の日付を、カナダ式の書き方で書かせる」。奇妙に見えますが、YYYY-MM-DD を素直に出してくれるロケールを選んだ結果です。手作業で年・月・日を取り出して0埋めする代わりに、標準機能に任せています。(この理由付けはコード内コメントに書かれていないため、実装から読み取った筆者の解釈です。en-CA が YYYY-MM-DD を出すことは仕様上の事実です。)

(c) なぜそう書いてあるか: 18行目のコメント「JSTの今日を YYYY-MM-DD で返す」が仕様。日本時間で締めなければ、深夜02:00の清掃が「前日の分」に数えられてしまいます。

(d) 呼ばれている場所(3ファイル)

呼び出し元 行 用途
app\api\dashboard\route.ts 23, 46 const date = dateRaw ?? todayJst();(日付指定がなければ今日)
components\features\dashboard\DashboardScreen.tsx 12, 24 const today = useMemo(() => todayJst(), []);
components\features\detail-log\DetailLogScreen.tsx 11, 35 const today = useMemo(() => todayJst(), []);

app\api\dashboard\route.ts 46行目の ??(null 合体演算子): 平易な言い換え「左が『無い』なら右を使う」。URLに日付が指定されていなければ今日を使う、という1行の書き方です。

useMemo(() => todayJst(), [])(React の仕組み): 平易な言い換え「画面が何度描き直されても、この計算は最初の1回だけ」。時計を見る関数なので、描き直すたびに呼ぶと画面内で日付が食い違う可能性があります。それを1回に固定しています。純粋でない関数を扱う側が、呼ぶ回数を1回に抑える形で使っている好例です。

inclusiveDayCount

/** YYYY-MM-DD 同士の日数(両端含む)。不正なら null */
export function inclusiveDayCount(from: string, to: string): number | null {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
    return null;
  }
  const start = Date.parse(`${from}T00:00:00+09:00`);
  const end = Date.parse(`${to}T00:00:00+09:00`);
  if (Number.isNaN(start) || Number.isNaN(end)) return null;
  return Math.floor((end - start) / 86_400_000) + 1;
}

(28〜37行目)

(a) 引数と戻り値の業務上の意味

  • 引数 from / to: ユーザーが清掃詳細ログの絞り込みで入れた「開始日」「終了日」。"2026-08-01" 形式。
  • 戻り値: 両端を含む日数。8/1〜8/31 なら 31(30ではない)。日付の形が違う、または実在しない日付なら null。
  • 何のためか: MAX_DATE_RANGE_DAYS(31日)を超える範囲指定を弾くため。平易な言い換え「1年分くれと言われたらDBが重いので31日までに制限する。その31日を数えるのがこの関数」。

(b) 中で何をしているか — 分岐は2つ、両方とも null を返す出口

分岐1(30〜32行目): 形が正しいか

  if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
    return null;
  }

専門用語: 正規表現(regular expression)。平易な言い換え「文字列のパターンを表す短い記号列」。/^\d{4}-\d{2}-\d{2}$/ を1文字ずつ読むとこうです。

記号 意味
^ 文字列の先頭から
\d{4} 数字がちょうど4個
- ハイフン1個
\d{2} 数字がちょうど2個
- ハイフン1個
\d{2} 数字がちょうど2個
$ ここで文字列が終わり

つまり「数字4-数字2-数字2 でぴったり終わる」。^ と $ があるので、前後にゴミが付いた "x2026-08-01" や "2026-08-01T00:00" は不合格です。

  • どちらに転ぶか: from と to のどちらか一方でも不合格なら(|| = または)null を返して即終了。両方合格なら次へ。
  • .test() は合格なら true を返すので、先頭の !(否定)で「不合格なら」に読み替えています。
  • 合格するが実在しない日付: "2026-99-99" は形だけは合っているのでこの分岐を通過します。それを次の分岐が捕まえます。

分岐2(33〜35行目): 実在する日付か

  const start = Date.parse(`${from}T00:00:00+09:00`);
  const end = Date.parse(`${to}T00:00:00+09:00`);
  if (Number.isNaN(start) || Number.isNaN(end)) return null;

Date.parse は日時文字列を「1970年1月1日からのミリ秒数」という1個の数字に変換します。平易な言い換え「日付を、ものさしの上の目盛りの数字に直す」。こうすると引き算ができます。

  • その日の午前0時(日本時間)に固定しています。時刻を付けずに Date.parse("2026-08-01") とすると UTC の0時として解釈される(= 日本時間の朝9時)ため、意図しない9時間のずれが混ざります。+09:00 を明示することでそれを封じています。ファイル冒頭3行目「タイムゾーン変換せず」の方針と整合します。
  • どちらに転ぶか: Date.parse は解釈できないとき NaN(Not a Number = 数字でない)を返します。start と end のどちらかでも NaN なら null を返して終了。
  • これで "2026-99-99" が捕まります。
  • Number.isNaN を使う理由: NaN は「自分自身と等しくない」という特殊な値で、start === NaN と書くと常に偽になり検査になりません。専用関数で調べる必要があります。

本体(36行目): 日数計算

  return Math.floor((end - start) / 86_400_000) + 1;
  • end - start — ミリ秒の差。
  • 86_400_000 — 1日のミリ秒数(24時間 × 60分 × 60秒 × 1000)。_(数値区切り)は桁を読みやすくするための飾りで、値は 86400000。86,400,000 のカンマ区切りと同じ役目です。
  • Math.floor(...) — 小数を切り捨て。
  • + 1 が「両端含む」の正体。8/1 と 8/1 の差は0ミリ秒 → 0 ÷ 86400000 = 0 → +1 で 1。「1日分ある」と数えます。

境界値の挙動(lib\clean-logs\__tests__\query.test.ts 32〜36行目のテストが固定):

from to 戻り値 意味
2026-08-01 2026-08-01 1 同じ日 = 1日
2026-08-01 2026-08-31 31 8月まる1か月 = 31日(MAX_DATE_RANGE_DAYS とちょうど同じ = 通る)
2026-08-01 2026-09-01 32 1日オーバー → 呼び出し側が弾く
2026-08 何でも null 形が違う
2026-99-99 何でも null 実在しない
  • from と to が逆順のとき: 検査していません。inclusiveDayCount("2026-08-31", "2026-08-01") は end - start が負になり、-30 + 1 = -29 のような負の数を返します。null にもエラーにもなりません。呼び出し側は「31より大きいか」だけを見ているので(lib\clean-logs\query.ts 84行目 if (diffDays > MAX_DATE_RANGE_DAYS))、負の数は素通りします。逆順の範囲指定は「31日以内」として受理されます。(意図的な割り切りかどうかはコメントに書かれていないため不明 — 指摘事項として後述。)
  • Math.floor が必要な理由: 差は必ず86400000の倍数になるはずなので通常は不要です。夏時間(DST)を採用している地域の日付では1時間ずれた差が出ることがあり、切り捨てで吸収されます。日本には夏時間がないので、この Math.floor は保険です(この位置づけは筆者の解釈)。

(c) なぜそう書いてあるか: 28行目のコメント「YYYY-MM-DD 同士の日数(両端含む)。不正なら null」がそのまま仕様。null を返す設計(例外を投げるのではなく)は、この層全体の作法と揃っています — judgeGrade の null(未実施)、kpi.ts の分母0の null と同じ「答えられないときは null」という一貫した方針です。

(d) 呼ばれている場所(1ファイル + テスト)

呼び出し元 行 内容
components\features\detail-log\DetailLogScreen.tsx 11, 92, 95, 97 const rangeDays = inclusiveDayCount(dateFrom, dateTo); → 95行目 if (rangeDays !== null && rangeDays > MAX_DATE_RANGE_DAYS) でエラー表示
lib\clean-logs\__tests__\query.test.ts(テスト) 3, 34, 35, 36 上表の期待値を固定

★API 側(lib\clean-logs\query.ts)はこの関数を呼んでおらず、同じ計算式を書き直しています。★ query.ts の import は5〜6行目の2本だけで、@/lib/format/date は含まれていません。31日超の検査は 82〜91行目で自前に行っています。

    const diffDays =
      Math.floor((dateTo.getTime() - dateFrom.getTime()) / 86_400_000) + 1;
    if (diffDays > MAX_DATE_RANGE_DAYS) {

Math.floor(... / 86_400_000) + 1 の部分が inclusiveDayCount() 36行目と完全に同一です(Date オブジェクトを受け取るか文字列を受け取るかだけが違う)。

★ただし、逆順の扱いだけは API 側のほうが厳格です★: query.ts 74〜81行目が先に逆順を弾いています。

  if (dateFrom && dateTo) {
    if (dateFrom > dateTo) {
      return fail(
        422,
        "validation_failed",
        "date_fromはdate_to以前の日付を指定してください"
      );
    }

つまり API は逆順を 422 エラーで拒否するが、inclusiveDayCount() は負の数を静かに返す(前述)。画面側(DetailLogScreen.tsx)は inclusiveDayCount() しか使っていないので、逆順を入力しても画面ではエラーが出ず、APIに投げてから 422 が返る、という二段構えになっています。平易な言い換え: 最終的には守られているが、守っている場所が2箇所に分かれていて、それぞれ強さが違う。指摘事項として後述します。

DetailLogScreen.tsx 95行目の rangeDays !== null && に注目してください。平易な言い換え「日数が数えられなかった(形が不正だった)ときは、範囲オーバーのエラーは出さない」。日付欄が入力途中で "2026-08" の状態のときに「31日以内で指定してください」と怒られるのは筋違いなので、null は素通りさせています。null を「0日」として扱っていたらここでおかしなエラーが出ていました。null と 0 を区別する設計が、画面の挙動として実際に効いている例です。


lib/judge/index.ts

21行。関数は1つもありません。 4ファイルへの「入口」を1つにまとめるだけのファイルです。

import 文(実際のコードのまま)

import 文は1行もありません。 代わりに export * from が4行あります(18〜21行目)。

export * from "./grade";
export * from "./escalation";
export * from "./kpi";
export * from "./advice";

専門用語: バレルファイル(barrel file)/再エクスポート。平易な言い換え「4つの引き出しの中身を、1つの窓口から全部出せるようにする転送口」。

これがあると何が違うか: 呼び出し側は4行書く代わりに1行で済みます。

// この index.ts があるおかげでこう書ける(app\api\clean-logs\[id]\advice\route.ts 12行目 の実物)
import { judgeEscalationLevel, matchAdviceRules, maxAdviceLevel } from "@/lib/judge";

// なければ3行必要
// import { judgeEscalationLevel } from "@/lib/judge/escalation";
// import { matchAdviceRules, maxAdviceLevel } from "@/lib/judge/advice";

実際、呼び出し側は全員 @/lib/judge を指しています(app\api\clean-logs\export\route.ts 11行目、components\features\dashboard\DashboardScreen.tsx 13行目、components\features\detail-log\CleanLogTable.tsx 9行目、components\features\detail-log\DetailLogScreen.tsx 12行目、lib\fixtures\clean-logs.ts 3行目、テスト4本)。平易な言い換え: 「判定のことは lib/judge に聞けばいい」という1つの住所で統一されている。中を grade.ts と kpi.ts に分けたり合わせたりしても、呼ぶ側は書き換えなくて済みます。

していない import の確認: lib/logger なし、lib/db なし。転送口なので当然ですが、ここに何か1つ混ぜると lib/judge を import した全員がそれを引き込むことになるため、純粋層の防波堤として最も重要な1ファイルでもあります。

中身の1〜17行目 — このディレクトリ全体の憲法

コード(4行)よりコメント(17行)のほうが長いファイルです。全文が設計上の宣言なので引用します。

/**
 * 判定ロジック
 *
 * ★このディレクトリは USE_FIXTURES の切替の「外側」です★
 *   サンプルデータで開発している間も、本番と同じロジックが走ります(D-044)。
 *
 * ★変更するときは必ずテストを先に直してください★
 *   ◎○△×・L1〜L3・KPI計算・丸め・アドバイス発火条件は、
 *   入力→出力の期待値表をテストで固定しています。
 *   バイブコーディングで唯一妥協してはいけない箇所です(D-041)。
 *
 * 仕様の正: CLAUDE.md 4.1〜4.5
 * 期待値表: docs/fixtures.md 「判定の境界値」
 *
 * なお、アドバイスの「文言」は判定ではなく運用設定です。
 * `lib/config/advice-rules.ts`(既定値)と `lib/settings/advice.ts`(編集内容)を参照。
 */

3つの宣言を1つずつ解きます。

宣言1: 「USE_FIXTURES の切替の外側」(4〜5行目)

専門用語: フィクスチャ(fixtures)/環境変数による切替。平易な言い換え: 本物のデータベースにつなぐ前に、手で作った偽データで画面を作っておく。USE_FIXTURES というスイッチで「偽データ」と「本物のDB」を切り替える。

「切替の外側」とは、この判定ロジックだけはスイッチの影響を受けないという意味です。偽データを使っていても、判定は本番と同じコードが動きます。根拠は docs\decisions.md D-044(204行目):

  • 設計上の注意: 判定ロジック(lib/judge/)は切替の外側に置く。fixture開発中も本番同等のロジックが走るようにする

CLAUDE.md §9.5(303行目)も同じことを言っています: 「判定ロジックは切替の外側に置く(fixtureのデータにも本番同等の判定を通す)」。

なぜ重要か(平易に): もし偽データ用に別の簡易判定を書いてしまうと、開発中は画面に ◎ が出ていたのに、DBにつないだ瞬間に ○ になる、という事故が起きます。「偽データでも判定は本物」なら、DB接続は「データの出所が変わるだけ」で済みます。実装上は lib\fixtures\clean-logs.ts 57行目・59行目が偽データを作るときにも本物の judgeGrade() / judgeEscalationLevel() を呼んでいることで実現されています(後述)。

宣言2: 「変更するときは必ずテストを先に直す」(7〜10行目)

根拠は docs\decisions.md D-041(190〜192行目):

D-041 [確定] 判定ロジックは期待値表テストで固定

  • ◎○△×・L1〜L3・KPI計算・丸めは、入力→出力の期待値表を先に作りテストで固定する
  • バイブコーディングで唯一妥協してはいけない箇所。 生成コードの目視確認だけで済ませない

専門用語: 期待値表テスト(table-driven test)。平易な言い換え「入れる値と出るべき答えの一覧表を先に作り、機械に全行を答え合わせさせる」。

専門用語: バイブコーディング(vibe coding)。平易な言い換え「AIに生成させたコードを、細部まで読まずに『動いてるからOK』で採用する進め方」。D-041 はそれを全面否定しているのではなく、「ここだけは目視で済ませるな」と1箇所を指定しているのがポイントです。平易な言い換え: 画面の見た目はざっと見て直せばいいが、◎○△×の判定は取引先との合意事項なので、機械に毎回検算させろ。

CLAUDE.md §9.4(293〜296行目)が具体的な表の中身まで指定しています。

- **テストを先に書く。** 入力→期待値の表を作り、境界値を必ず含める
  - 完了率: 90.0 / 90.1 / 70.0 / 70.1 / 10.0 / 9.9 / 0 / 未実施(null)
  - 連続日数: 1日 / 2日 / 3日 / スケジュール非存在日を挟むケース
  - 丸め: 90.4 → 表示90だが判定は◎、89.5 → 表示90だが判定は○

実際のテストファイルは4本あります: lib\judge\__tests__\grade.test.ts(3020バイト)、escalation.test.ts(4105バイト)、advice.test.ts(6171バイト)、kpi.test.ts(3504バイト)。

宣言3: 「文言は判定ではない」(15〜16行目)

前述の D-036 の再掲。lib/judge は「どのルールが該当するか」だけを返し、文字を持ちません。


lib/judge/grade.ts

49行、関数3本。◎○△× を決める、このシステムの心臓部です。

import 文(実際のコードのまま)

import { GRADE_THRESHOLDS } from "@/lib/config";
import type { Grade } from "@/lib/types";

(1〜2行目。これで全部です)

していない import の確認(規約チェック)

禁止されるもの 状態
lib/logger(ログ出力) import していません
lib/db(DBアクセス) import していません
@prisma/client import していません
node:fs(ファイル読み書き) import していません
next/*(フレームワーク) import していません
react import していません
lib/fixtures(サンプルデータ) import していません(逆に fixtures 側から呼ばれている)
lib/settings(運用設定の読み込み) import していません

CLAUDE.md §9.4(298行目)「判定関数は純粋関数にする(DBアクセスを混ぜない)」を満たしています。

import type という書き方(2行目): 平易な言い換え「これは型(設計図)だけを借りる import であって、実行時には何も持ってこない」と明示する書き方。import type と書くと、コンパイル後の JavaScript からこの行は完全に消えます。なぜ重要か: 実行時の依存を1本も増やさずに型チェックだけ受けられます。tsconfig.json の "isolatedModules": true 設定下では、型だけを import するときにこの書き方が推奨されます。

1行目の GRADE_THRESHOLDS は import type ではありません(実際の数字を実行時に使うため)。この1行が「しきい値の外出し」の実物です。

関数コメント(4〜21行目)

/**
 * ◎○△× の判定
 *
 * ★必ず丸める前の生値を渡すこと★
 *   完了率 90.4% は、表示は「90%」だが判定は ◎。
 *   丸めてから判定すると ◎ が消える(CLAUDE.md 4.3 / D-020)。
 *
 * | 判定 | 条件 |
 * |---|---|
 * | ◎ | 完了率 90%超 |
 * | ○ | 完了率 70%超 |
 * | △ | 完了率 10〜69% |
 * | × | 完了率 10%未満、または未実施 |
 *
 * 閾値は `lib/config` から読む(ハードコード禁止)。
 *
 * @param rate 清掃完了率(%)。未実施は null
 */

この表は CLAUDE.md §4.1(98〜103行目)の表をそのまま写したものです。

export しているもの — 全3件

export function judgeGrade(rate: number | null): Grade                                          // 22行目
export function displayRate(rate: number | null): string                                        // 36行目
export function isProblem(input: { departed: boolean; completionRate: number | null }): boolean // 42行目

★注意: judgeGrade の引数は rate 1つだけです。departed(出発したか)は受け取りません。「未実施」は rate に null を入れることで表現します。出発可否を併せて見る役目は別関数の isProblem() が担っています。

judgeGrade

export function judgeGrade(rate: number | null): Grade {
  if (rate === null) return "×";
  if (rate > GRADE_THRESHOLDS.excellent) return "◎";
  if (rate > GRADE_THRESHOLDS.good) return "○";
  if (rate < GRADE_THRESHOLDS.poor) return "×";
  return "△";
}

(22〜28行目)

(a) 引数と戻り値の業務上の意味

  • 引数 rate: その日その施設の清掃完了率(%)の生値。「ロボットが実際に清掃した床面積 ÷ 清掃すべき床面積 × 100」(CLAUDE.md §4.2、docs\decisions.md D-021)。90.4 のように小数のまま渡します。null は「清掃を実施していない」(ロボットが出発しなかった、等)。0% とは意味が違います。
  • 戻り値: 一覧表の各行に出す4段階のマーク。◎ = 上出来、○ = 良好、△ = 要注意、× = だめ。この1文字が、後続のエスカレーション(何日続いたか)とアドバイス(何をすべきか)の起点になります。

(b) 中で何をしているか — 分岐5本を上から順に、全部

平易な言い換え: 「上から順に関門を置き、最初に引っかかった関門の答えを返して終わり」。1つ通過するたびに return しているので、下の関門は「上の関門を全部すり抜けた値」だけを見ます。この「早期リターン」の並び順が挙動を決めます。

# 行 条件 真のとき 偽のとき
1 23 rate === null "×" を返して終了 #2 へ
2 24 rate > 90(GRADE_THRESHOLDS.excellent) "◎" を返して終了 #3 へ
3 25 rate > 70(GRADE_THRESHOLDS.good) "○" を返して終了 #4 へ
4 26 rate < 10(GRADE_THRESHOLDS.poor) "×" を返して終了 #5 へ
5 27 (条件なし・残り全部) — "△" を返す

1つずつ、なぜその順序かまで含めて。

#1(23行目)if (rate === null) return "×";

  • ===(厳密等価): 平易な言い換え「型まで含めてぴったり同じか」。==(緩い等価)だと undefined も 0 も条件次第で通ってしまいますが、=== null は「本当に null のときだけ」です。
  • なぜ最初か: これがないと null > 90 のような比較が起きます。JavaScript では null > 90 は偽、null < 10 は真になる(null が 0 に変換される)ため、#4 に落ちて偶然 × になります。答えは同じですが、「偶然そうなる」コードは読めません。先頭で明示的に弾いています。
  • 業務上の意味: 「実施していない = ×」。CLAUDE.md §4.1 の「× | 完了率 10%未満、または未実施」の後半がこの1行です。

#2(24行目)if (rate > GRADE_THRESHOLDS.excellent) return "◎";

  • >(超える)であって >=(以上)ではない。ここが全体で最も重要な1文字です。
  • GRADE_THRESHOLDS.excellent は 90(lib\config\index.ts 13行目)。
  • 90.0 ちょうどのとき: 90 > 90 は偽 → ◎ にならず #3 へ落ちる。
  • 仕様は CLAUDE.md §4.1 の「◎ | 完了率 90%超」。「超」は「より大きい」で、90 自身は含みません。

#3(25行目)if (rate > GRADE_THRESHOLDS.good) return "○";

  • ここに来ている値は「90 以下」であることが確定しています(#2 をすり抜けたので)。
  • GRADE_THRESHOLDS.good は 70。
  • 70.0 ちょうどのとき: 70 > 70 は偽 → ○ にならず #4 へ落ちる。
  • 90.0 のとき: 90 > 70 は真 → ○。これが「90.0 は ○」の正体です。

#4(26行目)if (rate < GRADE_THRESHOLDS.poor) return "×";

  • ここに来ている値は「70 以下」が確定。
  • GRADE_THRESHOLDS.poor は 10。<(未満)なので 10 自身は含みません。
  • 10.0 ちょうどのとき: 10 < 10 は偽 → × にならず #5 へ落ちて △。
  • 9.9 のとき: 9.9 < 10 は真 → ×。
  • 0 のとき: 0 < 10 は真 → ×。

#5(27行目)return "△";

  • 条件なし。#1〜#4 のすべてをすり抜けた値が来ます。すり抜けた条件を合わせると「null でない、かつ 90 以下、かつ 70 以下、かつ 10 以上」= 10 以上 70 以下。
  • 平易な言い換え: 「上でも下でもない真ん中は全部 △」。△ の範囲をどこにも書いていないのに、上下から挟むことで自動的に決まる書き方です。なぜこう書くか: △ の範囲を if (rate >= 10 && rate <= 70) と明示的に書くと、しきい値を変えたときに「◎の下限」と「△の上限」の2箇所を直す必要が生まれ、片方だけ直して隙間や重複ができます。上下から挟めば隙間が原理的に生まれません。

(b') ★境界値の完全な挙動表★

指定された8値 + テストにある2値、計10値の追跡結果です。各行の「通った関門」は上の #1〜#5 に対応します。

入力 rate #1 === null #2 > 90 #3 > 70 #4 < 10 結果 displayRate() の表示
90.4 偽 真 — — ◎ "90%"
90.1 偽 真 — — ◎ "90%"
90.0 偽 偽 真 — ○ "90%"
89.5 偽 偽 真 — ○ "90%"
70.1 偽 偽 真 — ○ "70%"
70.0 偽 偽 偽 偽 △ "70%"
10.1 偽 偽 偽 偽 △ "10%"
10.0 偽 偽 偽 偽 △ "10%"
9.9 偽 偽 偽 真 × "10%"
0 偽 偽 偽 真 × "0%"
null 真 — — — × "-"

特に注意すべき3行:

  1. 90.4 → ◎ なのに表示は「90%」。表示だけ見ると 90.0 の行(○)と区別がつきません。平易な言い換え: 画面に「90% ◎」と「90% ○」が並んで見える。これは不整合ではなく仕様です(CLAUDE.md §4.3、D-020)。もし丸めてから判定していたら、90.4 は 90 になり ○ に落ちて、施設の評価が実際より低く出ます。
  2. 89.5 → ○ なのに表示は「90%」。Math.round(89.5) は 90 に切り上がるため。表示上「90%」なのに ○ です。CLAUDE.md §9.4(296行目)が「丸め: 90.4 → 表示90だが判定は◎、89.5 → 表示90だが判定は○」と、この2ケースを名指しでテスト必須にしています。
  3. 10.0 → △(× ではない)。理由は docs\decisions.md D-020b の全文(82〜91行目):

D-020b [確定] 判定の境界値はすべて「下のランクに落ちる」

  • 決定: 完了率 90.0 は ○、70.0 は △、10.0 は △(×ではない)
  • 背景: docs/fixtures.md の境界値表で 10.0 を × と記載していたが、CLAUDE.md 4.1 の「× = 完了率10%未満」と矛盾していた。M1の期待値表テストで検出
  • 理由: ◎は「90%超」、○は「70%超」と、境界値そのものは下のランクに落ちる規則で統一されている。×だけ「10%以下」にすると規則が崩れる
  • 対応: docs/fixtures.md の表を修正。CLAUDE.md は変更なし(こちらが正)
  • 補足: 完了率がちょうど10.000%になる実データはまず発生しないため運用影響はないが、テストで値を固定する以上どちらかに決める必要があった

平易な言い換え: 「境界の数字ぴったりのときは、いつも下のランクに落とす」という1つの規則で3つの境界を統一した。90 ぴったりは ◎ ではなく ○、70 ぴったりは ○ ではなく △、10 ぴったりは △ ではなく × ……ではなく △。ここだけ「落ちる」の向きが違うように見えますが、規則は「境界値は下側のランクに属する」ではなく「境界値は上のランクの条件を満たさない」です。× の条件は「10未満」なので、10 は「× の条件を満たさない」→ 1つ上の △。

さらに docs\decisions.md 89行目の「対応: docs/fixtures.md の表を修正。CLAUDE.md は変更なし(こちらが正)」が示すとおり、この10.0問題はテストによって実際に発見されたバグです。D-041 の「テストで固定する」方針が実際に成果を上げた記録になっています。

(b'') CLAUDE.md §9.4 の指定リストとの突き合わせ

CLAUDE.md §9.4(294〜296行目)が必須と指定した値と、実際のテスト(lib\judge\__tests__\grade.test.ts 19〜30行目)の対応:

CLAUDE.md §9.4 の指定 grade.test.ts に存在するか 期待値 実装の結果 一致
90.0 21行目 ○ ○ ✅
90.1 20行目 ◎ ◎ ✅
70.0 24行目 △ △ ✅
70.1 23行目 ○ ○ ✅
10.0 26行目 △ △ ✅
9.9 27行目 × × ✅
0 28行目 × × ✅
未実施(null) 29行目 × × ✅
丸め 90.4 → 判定◎ 38〜41行目(別の it) ◎ ◎ ✅
丸め 89.5 → 判定○ 22行目 ○ ○ ✅
(指定外)10.1 25行目 △ △ ✅

指定された10項目すべてがテストに存在し、実装の挙動と一致しています。 テストファイルの5〜13行目のコメントが、表を3箇所(テスト・docs/fixtures.md・lib/fixtures/clean-logs.ts)で同期させる運用を宣言しています。

/**
 * ◎○△× 判定の期待値表テスト
 *
 * ★この表は docs/fixtures.md 「2.1 判定の境界値」と同じ値です★
 *   lib/fixtures/clean-logs.ts の boundaryCases も同じ10ケースを持っています。
 *   画面表示とテストで挙動が一致していることを目視でも確認できます。
 *
 * 表を変えたくなったら、先に CLAUDE.md 4.1 の仕様を確認してください。
 */

そして lib\fixtures\clean-logs.ts 63〜79行目には同じ10ケースが画面表示用のサンプルデータとしても用意されています。

// §2.1 判定の境界値 ★最重要★
//
// この10ケースは判定ロジックのテスト(M1)と同じ値を使うこと。
//
//   完了率  期待判定  表示(整数丸め)
//   90.1     ◎        90%     ← 丸めると90だが◎(丸めてから判定すると消える)
//   90.0     ○        90%     ← 「90%超」なので90.0は○
//   89.5     ○        90%
//   70.1     ○        70%
//   70.0     △        70%
//   10.1     △        10%
//   10.0     △        10%     ← 「10%未満」が×なので10.0は△(CLAUDE.md 4.1 が正)
//    9.9     ×        10%
//    0       ×         0%
//   null     ×         -      ← 未実施

平易な言い換え: 同じ10ケースが「テストの答え合わせ表」と「画面に出るサンプルデータ」の両方になっている。機械が検算するだけでなく、人が画面を開いても境界の挙動を目で確かめられます。

(c) なぜそう書いてあるか(根拠の引用)

  1. しきい値を直接書かない理由 — 18行目のコメント「閾値は lib/config から読む(ハードコード禁止)」。CLAUDE.md §7(215行目)「❌ 閾値(90/70/10、猶予30分、L2=2日/L3=3日)のハードコード(設定値として外出し)」。
  2. 生値を要求する理由 — 7〜9行目のコメント「★必ず丸める前の生値を渡すこと★ 完了率 90.4% は、表示は「90%」だが判定は ◎。丸めてから判定すると ◎ が消える(CLAUDE.md 4.3 / D-020)」。
  3. 境界値の向き — D-020b(前掲)。

(d) 呼ばれている場所

呼び出し元 行 文脈
lib\judge\advice.ts 3(import), 73 B1 ルールの発火判定 judgeGrade(completionRate) === "△"
lib\judge\grade.ts(同ファイル内) 47 isProblem() の中
lib\fixtures\clean-logs.ts 3(import), 57 サンプルデータ生成時に grade: judgeGrade(input.completion_rate)
lib\judge\__tests__\grade.test.ts(テスト) 2, 34, 39, 40 期待値表

★注目: API ルートからは直接呼ばれていません。★ grep 実測で app/ 配下に judgeGrade の呼び出しがありません。清掃ログ一覧の grade 項目は、lib\fixtures\clean-logs.ts 57行目でサンプルデータを作る段階で確定済みの値がそのまま返っています(lib\types\index.ts 66行目のコメント「サーバ側算出。fixtureでは期待値を直書きしている」に対応)。

また lib\logger\events.ts 85〜103行目には judge.grade というログイベントの型 JudgeGradeLogEvent が定義済みですが、grep 実測で event: "judge.grade" を実際に出力しているコードはありません(定義とユニオン型への登録だけ。lib\logger\events.ts 90行目と221行目)。judge.escalation と judge.advice は app\api\clean-logs\[id]\advice\route.ts 65行目・100行目で実際に出力されているので、judge.grade だけログ出力の実装が追いついていない状態です。DB接続後に judgeGrade をサーバ側で呼ぶようになった時点で使う準備、と読めます(この解釈は推測)。

displayRate

/**
 * 表示用の丸め
 *
 * 小数第1位を四捨五入して整数にする(例: 91.4 → 91、89.5 → 90)。
 * ★この値で judgeGrade を呼ばないこと★
 */
export function displayRate(rate: number | null): string {
  if (rate === null) return "-";
  return `${Math.round(rate)}%`;
}

(30〜39行目)

(a) 引数と戻り値の業務上の意味

  • 引数 rate: 完了率などの率の生値。null は「実施していない」または「分母が0で計算できなかった」。
  • 戻り値: 画面にそのまま出せる文字列。"91%" や "-"。数値ではなく文字列を返すのがポイントです。

(b) 中で何をしているか — 分岐1本

# 行 条件 真のとき 偽のとき
1 37 rate === null "-" を返して終了 #2 へ
2 38 (条件なし) — Math.round(rate) に % を付けて返す
  • "-" を返す設計: null のときに "0%" を出すと「清掃したが0%だった」と読めてしまいます。ハイフンなら「データなし」と読めます。null と 0 を区別する方針が表示層にも通っている例です。
  • Math.round: 小数第1位を四捨五入。91.4 → 91、91.5 → 92、89.5 → 90。
  • Math.round の注意点(負の値): JavaScript の Math.round(-0.5) は -0 になります(-1 ではない)。率が負になる場面は業務上ないので影響しませんが、Math.round は厳密には「四捨五入」ではなく「0.5 を常に大きい方へ寄せる」規則です。

(c) なぜそう書いてあるか

  • 33行目「小数第1位を四捨五入して整数にする(例: 91.4 → 91、89.5 → 90)」。CLAUDE.md §4.3(116行目)「表示: 小数第1位を四捨五入して整数で表示する(例: 91.4% → 91%)」と一致。
  • 34行目「★この値で judgeGrade を呼ばないこと★」 — 関数のコメント自体が誤用を禁止しています。CLAUDE.md §7(213行目)「❌ 表示用に丸めた値で判定すること(4.3参照)」。
  • docs\decisions.md D-020(77〜80行目)に整数表示になった経緯があります: 「経緯: 当初「小数第2位を四捨五入して第1位まで」で提案したが、DFA判断で整数表示に変更」。

★戻り値を文字列にしていることが、実は誤用防止として効いています。★ displayRate() は "91%" という文字列を返すので、judgeGrade(displayRate(x)) と書こうとすると型チェックでエラーになります(judgeGrade は number | null を要求)。34行目のコメントの禁止事項が、コメントだけでなく型でも守られている、という設計です(この読み取りは筆者の解釈ですが、型の帰結としては事実です)。

(d) 呼ばれている場所

呼び出し元 行 文脈
app\api\clean-logs\export\route.ts 11(import), 50 CSV の完了率列 displayRate(r.completion_rate)
components\features\dashboard\DashboardScreen.tsx 13, 118-120 成功率カード value={displayRate(data.summary.success_rate)}
components\features\detail-log\CleanLogTable.tsx 9, 190 一覧表の完了率セル
components\features\detail-log\DetailLogScreen.tsx 12, 184 completionLabel: displayRate(row.completion_rate)
lib\judge\__tests__\grade.test.ts(テスト) 2, 46, 50, 51 displayRate(91.4) → "91%"、displayRate(91.5) → "92%"
lib\judge\__tests__\kpi.test.ts(テスト) 4, 55 displayRate(completionRate(904, 1000)) → "90%"

DashboardScreen.tsx 118〜119行目のコメントが "-" の扱いを他画面と揃える意図を述べています。

              // "-" は displayRate() の既存の null 表記をそのまま使う(アプリ全体で

components\ui\StatCard.tsx 11行目のコメントも責務分担を明示しています。

  /** 表示する値。数値の文字列化・丸めは呼び出し側(lib/judge の displayRate 等)の責務 */

平易な言い換え: 表示部品(カード)は「渡された文字をそのまま出す」だけ。丸めをどこでやるかがブレないよう、部品側のコメントで宣言してある。

isProblem

/** 「問題あり」= △以下、または出発失敗 */
export function isProblem(input: {
  departed: boolean;
  completionRate: number | null;
}): boolean {
  if (!input.departed) return true;
  const grade = judgeGrade(input.completionRate);
  return grade === "△" || grade === "×";
}

(41〜49行目)

(a) 引数と戻り値の業務上の意味

  • 引数 input.departed: スケジュール通りに出発したか。false = 予定時刻(+猶予30分)を過ぎても清掃が始まらなかった、または途中で中断(CLAUDE.md §4.4、135行目)。
  • 引数 input.completionRate: 完了率の生値。出発しなかった場合は null。
  • 戻り値: その日を「問題あり」として数えるか。true = 連続問題日数のカウント対象。

引数を1つのオブジェクトにまとめている理由: 平易な言い換え「isProblem(true, 95) と書くと、どっちがどっちか読めなくなるので、isProblem({ departed: true, completionRate: 95 }) と名前を付けて渡す」。特に boolean と number | null の2引数は順序を間違えても型チェックが通ってしまう組み合わせがあり得るので、名前付きにするのは事故防止です(この評価は筆者の解釈)。

(b) 中で何をしているか — 分岐2本

# 行 条件 真のとき 偽のとき
1 46 !input.departed(出発していない) true(問題あり)を返して終了 #2 へ
2 47-48 `grade === "△" \ \ grade === "×"` true false

#1(46行目)if (!input.departed) return true;

  • ! は否定。departed が false のとき条件が真になります。
  • 完了率を一切見ずに問題ありと判定します。平易な言い換え: 出発しなかった日は、完了率がどうであれ問題。
  • 重要な帰結: departed: false, completionRate: 95 という組み合わせでも true になります。テスト(lib\judge\__tests__\grade.test.ts 58行目)が実際にこれを固定しています: expect(isProblem({ departed: false, completionRate: 95 })).toBe(true);。平易な言い換え: 「出発してないのに95%完了」は本来ありえないデータだが、万一そう来ても「問題あり」に倒す。安全側に倒す設計です。

#2(47〜48行目)

  • ここに来る値は departed === true が確定。
  • judgeGrade() を呼んで4段階を出し、△ か × なら問題あり。◎ と ○ なら問題なし。
  • ||(または): どちらか片方が真なら真。
  • なぜ judgeGrade を呼ぶのか: 「10%未満なら問題」と数字を直接書くのではなく、判定結果の文字で書いています。しきい値が変わっても judgeGrade が追従するので、ここは直す必要がありません。平易な言い換え: 点数の線引きは1箇所(judgeGrade)だけが知っていればいい。

(b') 境界値の挙動

departed completionRate judgeGrade の結果 isProblem
false null (呼ばれない) true
false 95 (呼ばれない) true
true 90.1 ◎ false
true 90.0 ○ false
true 70.1 ○ false
true 70.0 △ true
true 10.0 △ true
true 9.9 × true
true null × true

テスト(lib\judge\__tests__\grade.test.ts 55〜69行目)が4ケースを固定しています。

    expect(isProblem({ departed: false, completionRate: null })).toBe(true);
    expect(isProblem({ departed: false, completionRate: 95 })).toBe(true);
    expect(isProblem({ departed: true, completionRate: 70.0 })).toBe(true); // △
    expect(isProblem({ departed: true, completionRate: 9.9 })).toBe(true); // ×
    expect(isProblem({ departed: true, completionRate: 90.1 })).toBe(false); // ◎
    expect(isProblem({ departed: true, completionRate: 70.1 })).toBe(false); // ○

注目: 70.0 が true(問題あり)になります。平易な言い換え: 完了率ちょうど70%は「○ではなく△」なので、問題ありとして連続日数に数え始めます。境界の1つの決定(D-020b)が、エスカレーションの起点にまで波及していることがわかる例です。

(c) なぜそう書いてあるか

41行目のコメント「「問題あり」= △以下、または出発失敗」がそのまま定義。CLAUDE.md §4.5(63行目)の「R4 | 同一施設で3日以上連続(出発失敗 or △以下)」の「出発失敗 or △以下」を1つの関数に切り出したもの、と読めます。また lib\types\index.ts 68行目のコメント「△以下または出発失敗の連続日数(スケジュール存在日ベース)」も同じ定義です。

(d) 呼ばれている場所

★まだ本番コードから呼ばれていません。★ grep 実測での出現箇所:

出現箇所 行 種別
lib\judge\grade.ts(宣言) 42 定義
lib\judge\escalation.ts 23 偶然の同名(ScheduleDayResult.isProblem というプロパティ名。関数呼び出しではない)
lib\judge\escalation.ts 35 同上(days[i].isProblem のプロパティ参照)
lib\judge\__tests__\grade.test.ts 2, 57, 58, 62, 63, 67, 68 テスト
lib\judge\__tests__\escalation.test.ts 13, 62-64, 76-80 テスト(プロパティ名として)

app/(API・画面)と lib/fixtures/ からの呼び出しはゼロです。

なぜ呼ばれていないのか: escalation.ts の 13〜15行目が説明になっています。

 * この関数は「スケジュール存在日だけに絞り込んだ結果」を受け取る前提です。
 * どの日がスケジュール存在日かの判断(曜日計算、active フラグ、日跨ぎ等)は
 * データ取得側の責任とし、判定ロジックには持ち込みません。

isProblem() は「1日分の結果から ScheduleDayResult.isProblem を作る」ための関数ですが、その ScheduleDayResult[] を組み立てる処理(DBから日ごとの結果を引いてスケジュール存在日に絞る処理)自体がまだ実装されていません。現状の consecutive_problem_days は lib\fixtures\clean-logs.ts 209〜210行目でサンプルデータ生成時に手で決め打ちされています。

          consecutive_problem_days:
            rate === null || rate < GRADE_THRESHOLDS.good ? 1 : 0,

★ここに小さな不整合があります★: このサンプル生成コードは「rate が null、または rate < 70」を問題ありの条件にしています。一方 isProblem() の正しい条件は「departed が false、または △以下(= rate <= 70)」。rate === 70.0 のとき、isProblem() は true(△なので問題あり)ですが、この fixtures のコードは 70 < 70 が偽なので 0(問題なし)にします。境界1点だけずれています。実際、境界値サンプル(lib\fixtures\clean-logs.ts 85行目)では 70.0 のケースに consecutive_problem_days: 1 を手で指定して辻褄を合わせており、buildHistory() で自動生成する履歴データ側(195〜210行目)だけがこの式を使っています。履歴データは 72 以上か 90.5 以上など 70.0 ちょうどを生成しない値域(195行目 rate = 72 + r * 15;、197行目 rate = 90.5 + r * 9;)なので、現状は実害が出ません。ただし isProblem() を使わず条件を再実装しているため、しきい値が変わったときにずれる構造です。(実害がないことは値域から確認した事実、「構造的にずれる」という評価は筆者の解釈。)


lib/judge/escalation.ts

68行、関数3本 + 型1本。「問題が何日続いているか」から L1/L2/L3 を決めるファイルです。

import 文(実際のコードのまま)

import { ESCALATION_THRESHOLDS } from "@/lib/config";
import type { EscalationLevel } from "@/lib/types";

(1〜2行目。これで全部です)

していない import の確認: lib/logger なし、lib/db なし、@prisma/client なし、node:fs なし、next/* なし、react なし、lib/format(日付計算)なし。

★「lib/format を import していない」ことが特に重要です。★ この関数は「連続した日」を数えるのに日付計算を一切しません。date フィールドを受け取っていますが(19〜21行目)、中では一度も参照していません。数えるのは配列の並び順だけです。理由は次項。

ファイル冒頭のコメント(4〜16行目)— 設計の核心

/**
 * エスカレーション(L1〜L3)の判定
 *
 * ★「連続」はカレンダー日ではなく「スケジュール存在日の連続」で数える(D-026)★
 *
 * 例)月水金スケジュールの施設で 月・水・金 と3回連続で失敗した場合、
 *     カレンダー上は5日間だが、スケジュール存在日の連続は3日なので L3。
 *     火・木は清掃予定がないため、連続を途切れさせない。
 *
 * この関数は「スケジュール存在日だけに絞り込んだ結果」を受け取る前提です。
 * どの日がスケジュール存在日かの判断(曜日計算、active フラグ、日跨ぎ等)は
 * データ取得側の責任とし、判定ロジックには持ち込みません。
 */

専門用語: スケジュール存在日。平易な言い換え「その施設で清掃の予定が入っている日」。月水金だけ清掃する店なら、月・水・金がスケジュール存在日で、火・木・土・日は違います。

根拠は docs\decisions.md D-026(114〜116行目):

D-026 [確定] 「連続」はスケジュール存在日の連続

  • 決定: L2(2日連続)/ L3(3日以上連続)のカウントは、カレンダー日ではなく清掃予定がある日の連続で数える
  • 理由: 定休日等でスケジュールがない日を挟んでも、問題の連続性は途切れていないため

CLAUDE.md §4.5(154行目)も同文です: 「「連続」はカレンダー日ではなく「スケジュール存在日の連続」で数えます。清掃予定がない日は連続を途切れさせません。」

なぜこれが重要か(平易に): 月水金だけ清掃する店で、月・水・金と3回失敗したとします。カレンダーで見れば月曜から金曜の5日間で、途中の火・木は「問題なし」です。しかし火・木はそもそも清掃していないので「問題なし」と数えるのは誤りです。「清掃した日だけ」を並べれば3連続で、最も重い L3(オンサイト訪問検討)になります。カレンダー日で数えると「1日連続」(金曜だけ)になり、L1(現場へ電話)で済ませてしまいます。同じ現象なのに対応レベルが2段階変わる。

★責務分離の宣言(13〜15行目)が設計上とても重要です★: 「どの日がスケジュール存在日か」の判断(曜日計算、active フラグの確認、日跨ぎの処理)をこの関数の中でやらないと明言しています。

平易な言い換え: この関数は「渡された配列を後ろから数える」だけの単純な仕事に徹する。「どの日を渡すか」を決めるのは呼ぶ側の仕事。

なぜ分けるのか: 曜日計算やDBの active フラグ確認を混ぜると、この関数は純粋関数でなくなり(DBを見る必要が出る)、テストに DB が必要になります。分けておけば、テストは [true, true, false] のような配列を手で作るだけで済みます。実際 lib\judge\__tests__\escalation.test.ts 11〜14行目は文字列 "110" から配列を作るヘルパーで済ませています。

    isProblem: c === "1",

export しているもの — 全4件

export interface ScheduleDayResult { date: string; isProblem: boolean }                       // 19行目
export function countConsecutiveProblemDays(days: ScheduleDayResult[]): number                // 32行目
export function judgeEscalationLevel(consecutiveDays: number): EscalationLevel | null         // 52行目
export function judgeEscalation(days: ScheduleDayResult[]): { consecutiveDays: number; level: EscalationLevel | null }  // 62行目

ScheduleDayResult(18〜24行目)

/** スケジュール存在日1日分の結果 */
export interface ScheduleDayResult {
  /** YYYY-MM-DD */
  date: string;
  /** その日が「問題あり」だったか(△以下 or 出発失敗) */
  isProblem: boolean;
}

平易な言い換え: 「何月何日に、問題があったか、なかったか」の2項目だけ。完了率も、出発したかも、施設名も持ちません。判定に必要な最小限まで削ぎ落とした形です。

isProblem: boolean の値は grade.ts の isProblem() 関数が計算するもの(△以下 or 出発失敗)ですが、前述のとおりその接続はまだ実装されていません。

★date は宣言されているのに、この3関数のどこからも読まれていません。★ grep 実測で days[i].date のような参照はありません。読まれるのは isProblem だけ(35行目)。

なぜ持っているのか(推測を含む): (1) デバッグ・ログで「どの日が問題だったか」を見るため、(2) 呼び出し側が日付順に並べたことを型として示すため、(3) 将来「最後に問題が出た日」(lib\types\index.ts 157行目の ContinuousProblem.last_problem_on)を返す拡張のため — のいずれかと読めますが、コード内にその説明はないため推測です。現時点では「使っていない項目」です。

countConsecutiveProblemDays

/**
 * 直近から遡って、問題が何日続いているかを数える。
 *
 * @param days スケジュール存在日のみの結果。日付昇順(古い→新しい)で渡すこと
 * @returns 直近の連続問題日数。最新日が問題なしなら 0
 */
export function countConsecutiveProblemDays(days: ScheduleDayResult[]): number {
  let count = 0;
  for (let i = days.length - 1; i >= 0; i -= 1) {
    if (!days[i].isProblem) break;
    count += 1;
  }
  return count;
}

(26〜39行目)

(a) 引数と戻り値の業務上の意味

  • 引数 days: その施設の清掃予定がある日だけを、古い日から新しい日の順に並べた配列。例: 月水金の店で3週間分なら9要素。
  • 戻り値: 今この瞬間から遡って、問題が何日続いているか。最新日が問題なしなら 0。
  • 業務上の意味: この数字が「現場に電話するだけ(L1)か、オンサイト訪問を検討する(L3)か」を決めます。

(b) 中で何をしているか — ★走査の向きが最重要★

専門用語: 走査(イテレーション/ループ)。平易な言い換え「配列の要素を1つずつ順に見ていくこと」。

1行ずつ:

33行目 let count = 0; — 数えた日数を入れる箱。0 から始めます。

34行目 for (let i = days.length - 1; i >= 0; i -= 1) { — ★この行が「どちらの端から数えるか」を決めています★

for 文の3つの部分を分解します。

部分 書かれていること 意味
初期化 let i = days.length - 1 配列の最後の要素から始める。要素が9個なら i = 8(0から数えるので最後は8番目)
継続条件 i >= 0 i が 0 以上の間だけ続ける。0 は先頭の要素
更新 i -= 1 1回ごとに i を1減らす

平易な言い換え: 配列を後ろから前へ(新しい日から古い日へ)逆向きに歩く。

★なぜこの向きなのか★

コメント(27行目)が「直近から遡って、問題が何日続いているかを数える」と述べています。29行目「日付昇順(古い→新しい)で渡すこと」と組み合わせると:

  • 渡される配列は 古い → 新しい の順
  • 知りたいのは「今、何日続いているか」
  • だから 新しい端(配列の末尾)から 数え始める

前から数えたらどうなるか(具体例で): [問題, 問題, 問題, なし, 問題](5日分、左が古い)という配列を考えます。

  • 後ろから(実装のとおり): 5日目=問題 → 数える(1)。4日目=なし → break で終了。答え 1。「今の連続は1日」→ L1。正しい。
  • 前から(もし逆に書いたら): 1日目=問題(1)、2日目=問題(2)、3日目=問題(3)、4日目=なし → 終了。答え 3。「3日連続」→ L3。誤り。

平易な言い換え: 前から数えると「昔3日続いた」というもう終わった問題を「今3日続いている」と報告してしまう。すでに直った施設にオンサイト訪問を手配することになります。だから必ず新しい方から数える。

35行目 if (!days[i].isProblem) break; — ★break が「連続」の定義そのものです★

  • days[i].isProblem はその日が問題だったか。! で否定して「問題なしなら」。
  • break: 平易な言い換え「ループを今すぐ全部やめる」。
  • 問題なしの日に当たった瞬間に数えるのをやめる。これが「連続」の意味です。もし break の代わりに continue(この1回を飛ばして次へ)と書いてあったら、飛び飛びの問題日を全部足してしまい「連続」ではなく「累計」になります。
  • どちらに転ぶか: 問題なし → break してループ終了、count は現在値のまま return。問題あり → break せず36行目へ。

36行目 count += 1; — 1日分数える。

38行目 return count; — ループが終わったら(break したか、先頭まで到達したか)現在の count を返す。

(b') 境界値・具体例の全網羅

テスト(lib\judge\__tests__\escalation.test.ts 17〜37行目)が固定している全ケース。表記 "110" は「古い日から順に、問題あり・問題あり・問題なし」の意味です(テスト11〜14行目のヘルパーが文字列を配列に変換)。

入力(古い→新しい) 戻り値 どこで break したか 意味
"1" 1 先頭まで到達(break なし) 1日だけ、それが問題
"11" 2 先頭まで到達 2日連続
"111" 3 先頭まで到達 3日連続
"11111" 5 先頭まで到達 5日連続
"1110" 0 1回目(最新日が問題なし) 昔3日続いたが直近は正常 → 0
"0000" 0 1回目 全日正常
"11011" 2 3回目(末尾から3つ目が 0) 直近2日連続。その前の2日は数えない
"10111" 3 4回目 直近3日連続
[](空配列) 0 ループが1回も回らない データがない

特に重要な2行:

  • "1110" → 0: 「過去に3日続いたが今は正常」を 0 と答える。テストの説明文(25行目付近)が「最新日が問題なし」ケースとして固定しています。平易な言い換え: 直った施設は数えない。走査の向きが正しいことをこの1ケースが証明します。
  • "11011" → 2: 「今2日連続、その前に1日正常、さらに前に2日連続」。合計4日問題があったが答えは 2。平易な言い換え: いったん正常に戻ったら、そこで連続はリセットされる。累計ではなく連続。
  • [] → 0: days.length - 1 が -1 になり、継続条件 i >= 0 が最初から偽なのでループが1回も回りません。count は初期値 0 のまま。エラーにならず 0 を返す設計です(例外を投げない = この層の一貫した方針)。

(c) なぜそう書いてあるか(根拠の引用)

  • 27行目「直近から遡って、問題が何日続いているかを数える。」— 走査の向きの根拠。
  • 29行目「@param days スケジュール存在日のみの結果。日付昇順(古い→新しい)で渡すこと」— 入力の並び順を呼び出し側の義務として宣言。この前提が破られると答えが逆になるので、コメントで明示するしかない部分です(型では並び順を表現できません)。
  • 30行目「@returns 直近の連続問題日数。最新日が問題なしなら 0」— "1110" → 0 の根拠。
  • D-026(前掲)— 「スケジュール存在日のみ」の根拠。

★注意点: 「日付昇順で渡すこと」はコメントによる約束であり、機械が検査していません。降順(新しい→古い)で渡してもエラーにならず、静かに間違った答えを返します。date フィールドを持っているのに使っていないので、「実は昇順か」を検査する余地はあります(現状していないという事実の指摘。すべきかどうかは設計判断)。

(d) 呼ばれている場所

呼び出し元 行 種別
lib\judge\escalation.ts(同ファイル) 66 judgeEscalation() の中
lib\judge\__tests__\escalation.test.ts 3, 19-22, 26-27, 31-32, 36 テスト

★本番コード(app/ や lib/fixtures/)からの直接の呼び出しはありません。★ ただし同ファイルの judgeEscalation() 経由で使われる設計で、その judgeEscalation() 自体も本番からは呼ばれていません(次項)。

judgeEscalationLevel

/**
 * 連続日数からエスカレーションレベルを決める。
 *
 * | レベル | 条件 |
 * |---|---|
 * | L1 | 初回の問題(1日) |
 * | L2 | 2日連続 |
 * | L3 | 3日以上連続 |
 *
 * @returns 問題がない(0日)場合は null
 */
export function judgeEscalationLevel(
  consecutiveDays: number
): EscalationLevel | null {
  if (consecutiveDays <= 0) return null;
  if (consecutiveDays >= ESCALATION_THRESHOLDS.level3Days) return "L3";
  if (consecutiveDays >= ESCALATION_THRESHOLDS.level2Days) return "L2";
  return "L1";
}

(41〜59行目)

(a) 引数と戻り値の業務上の意味

  • 引数 consecutiveDays: スケジュール存在日ベースの連続問題日数(countConsecutiveProblemDays() の出力、または DB に保存済みの consecutive_problem_days)。
  • 戻り値: 対応の重さ 3段階、または null(対応不要)。
    • L1 = 現場へ連絡(障害物確認・ブラシ清掃・再起動等)
    • L2 = 現場責任者へのヒアリング・運用手順の再教育・消耗品交換
    • L3 = DFAオンサイト訪問・マップ再作成・機体交換検討
    • null = 問題なし。lib\types\index.ts 70〜71行目「/* 問題行のみ設定 / escalation_level: EscalationLevel | null;」のとおり、正常行にはレベルを付けません。

対応内容は CLAUDE.md §4.5(141〜145行目)の表から。

(b) 中で何をしているか — ★分岐4本、順序が最重要★

# 行 条件 真のとき 偽のとき
1 55 consecutiveDays <= 0 null を返して終了 #2 へ
2 56 consecutiveDays >= 3(level3Days) "L3" を返して終了 #3 へ
3 57 consecutiveDays >= 2(level2Days) "L2" を返して終了 #4 へ
4 58 (条件なし) — "L1" を返す

★走査の向き(countConsecutiveProblemDays)とは別の「順序」の話です。ここは「重い方から先に判定する」順序です。★

#1(55行目)if (consecutiveDays <= 0) return null;

  • <= 0(0以下)であって === 0(0と等しい)ではない点に注目。平易な言い換え: 0 だけでなく、マイナスが来ても「問題なし」に倒す。
  • なぜ <= なのか(推測を含む): 連続日数が負になることは業務上ありえませんが、計算ミスやデータ不整合で -1 が来たときに null(安全側)に倒れます。=== 0 だと -1 は #2 #3 をすり抜けて "L1" になり、存在しない問題にアラートが立ちます。コメントには理由が書かれていないため、この読み取りは推測です。ただし「0以下は null」という挙動自体は事実です。
  • 50行目のコメント「@returns 問題がない(0日)場合は null」が根拠。

#2(56行目)if (consecutiveDays >= ESCALATION_THRESHOLDS.level3Days) return "L3";

  • level3Days は 3(lib\config\index.ts 24行目)。
  • >=(以上)。3 ちょうどで L3 になります。
  • ★重い方(L3)を先に判定するのが正解の順序★。もし L2 を先に書いたら:
    // ❌ こう書いたら壊れる
    if (consecutiveDays >= 2) return "L2";   // 5日連続もここで L2 になってしまう
    if (consecutiveDays >= 3) return "L3";   // 永遠に到達しない
    平易な言い換え: 「2日以上」は5日連続にも当てはまるので、軽い条件を先に置くと重い判定に一生たどり着けません。上から順に見て最初に当たったところで止まる仕組みなので、厳しい条件を上に置く必要があります。judgeGrade が ◎(90超)→ ○(70超)→ ×(10未満)と並べているのと同じ構造です。
  • >= なので上限がない: 5日でも100日でも L3。テスト(lib\judge\__tests__\escalation.test.ts 46行目)が明示しています: { name: "5日連続(長期化してもL3のまま)", consecutive: 5, level: "L3" }。CLAUDE.md §4.5(145行目)「L3 | 3日以上連続」のとおり L3 が最上位です。

#3(57行目)if (consecutiveDays >= ESCALATION_THRESHOLDS.level2Days) return "L2";

  • level2Days は 2。ここに来る値は「3未満」が確定しているので、実質「2 のとき」だけが該当します。
  • 2 ちょうどで L2。

#4(58行目)return "L1";

  • ここに来る値は「0より大きい、かつ 2未満」= 実質 1(整数なら)。
  • 平易な言い換え: 「初回の問題」。CLAUDE.md §4.5(143行目)「L1 | 初回の問題」、コメント45行目「L1 | 初回の問題(1日)」。
  • 小数が来た場合: 1.5 なら #1〜#3 をすり抜けて L1 になります。日数なので小数は来ませんが、型は number で整数に制約していません(事実の指摘)。

(b') 境界値の全網羅

テスト(lib\judge\__tests__\escalation.test.ts 41〜47行目)の期待値表がそのまま境界の一覧です。

  const table: Array<{ name: string; consecutive: number; level: string | null }> = [
    { name: "問題なし", consecutive: 0, level: null },
    { name: "初回の問題", consecutive: 1, level: "L1" },
    { name: "2日連続", consecutive: 2, level: "L2" },
    { name: "3日連続", consecutive: 3, level: "L3" },
    { name: "5日連続(長期化してもL3のまま)", consecutive: 5, level: "L3" },
  ];

実装から導かれる全域:

入力 通った分岐 結果
-1(想定外) #1 真 null
0 #1 真 null
1 #1偽 #2偽 #3偽 → #4 L1
2 #1偽 #2偽 #3真 L2
3 #1偽 #2真 L3
4 #2 真 L3
5 #2 真 L3
100 #2 真 L3

CLAUDE.md §9.4(295行目)の指定「連続日数: 1日 / 2日 / 3日 / スケジュール非存在日を挟むケース」との突き合わせ: 1日・2日・3日は上表のとおりテスト済み(41〜47行目)。「スケジュール非存在日を挟むケース」は judgeEscalation のテスト(54〜83行目)でカバーされています(次項)。指定4項目すべて充足。

(c) なぜそう書いてあるか(根拠の引用)

  • 43〜48行目のコメント表が CLAUDE.md §4.5(141〜145行目)の写し。
  • 50行目「@returns 問題がない(0日)場合は null」。
  • しきい値を ESCALATION_THRESHOLDS から読む理由 — CLAUDE.md §7(215行目)の「L2=2日/L3=3日」のハードコード禁止。この規約のおかげで、実装には数字の 2 も 3 も現れません(56〜57行目には level3Days level2Days という名前しかない)。平易な言い換え: 「3日以上でL3」という取り決めが変わったとき、直すのは設定ファイルの1箇所だけ。

(d) 呼ばれている場所

呼び出し元 行 文脈
app\api\clean-logs\[id]\advice\route.ts 12(import), 98 本番の呼び出し。const escalationLevel = judgeEscalationLevel(log.consecutive_problem_days);
lib\fixtures\clean-logs.ts 3(import), 59 サンプルデータ生成時 escalation_level: judgeEscalationLevel(consecutive)
lib\judge\escalation.ts(同ファイル) 67 judgeEscalation() の中
lib\judge\__tests__\escalation.test.ts 5, 50 テスト

この3関数の中で唯一、本番のAPIルートから直接呼ばれている関数です。

app\api\clean-logs\[id]\advice\route.ts 98〜110行目では、判定直後にログを出しています。

    const escalationLevel = judgeEscalationLevel(log.consecutive_problem_days);
    logEvent("info", {
      event: "judge.escalation",
      route: ROUTE,
      method: "GET",
      facility_id: log.facility_id,
      consecutive_problem_days: log.consecutive_problem_days,
      thresholds_used: {
        level2Days: ESCALATION_THRESHOLDS.level2Days,
        level3Days: ESCALATION_THRESHOLDS.level3Days,
      },
      result_level: escalationLevel,
    });

★この形が純粋層の使い方の教科書です★: judgeEscalationLevel() 自体はログを出しません(lib/logger を import していない)。ログを出すのは呼び出し側です。平易な言い換え: 判定する人は計算だけする。記録に残すのは受け取った人の仕事。こうしておけば、判定関数はテストのときにログの後片付けが不要で、逆に本番では「入力・使ったしきい値・結果」の3点セットを残せます(理由は lib\logger\events.ts 105〜107行目・127〜131行目)。

注目: 本番ルートは log.consecutive_problem_days(DB/fixtures に既に入っている数字)をそのまま渡しており、countConsecutiveProblemDays() で数え直していません。連続日数を数える処理は現状 fixtures 側の決め打ち(lib\fixtures\clean-logs.ts 209〜210行目)です。

judgeEscalation

/** 結果配列から直接レベルを求めるショートカット */
export function judgeEscalation(days: ScheduleDayResult[]): {
  consecutiveDays: number;
  level: EscalationLevel | null;
} {
  const consecutiveDays = countConsecutiveProblemDays(days);
  return { consecutiveDays, level: judgeEscalationLevel(consecutiveDays) };
}

(61〜68行目)

(a) 引数と戻り値の業務上の意味

  • 引数 days: スケジュール存在日だけの結果配列(古い→新しい順)。
  • 戻り値: 2つの値を1つのオブジェクトで返します。
    • consecutiveDays: 連続日数(画面の「◯日連続」表示や、アドバイス判定への入力に使う)
    • level: L1/L2/L3 または null(行バッジの表示に使う)

なぜ2つ返すのか: 呼び出し側は両方欲しいのが普通です。level だけ返すと「何日続いているか」を出すために呼び出し側が countConsecutiveProblemDays() をもう一度呼ぶ必要があり、同じ計算を2回することになります。1回の呼び出しで両方返せば無駄がなく、しかも2つの値が必ず整合します(別々に呼ぶと、間に配列が変わって食い違う余地が生まれます)。

実際 lib\types\index.ts 68〜71行目の CleanLogItem は consecutive_problem_days と escalation_level の両方を持っており、この戻り値の形と一致します。

(b) 中で何をしているか — 分岐なし・2行

条件分岐は1つもありません。

  1. 66行目 const consecutiveDays = countConsecutiveProblemDays(days); — まず日数を数える。
  2. 67行目 return { consecutiveDays, level: judgeEscalationLevel(consecutiveDays) }; — 数えた日数をそのままレベル判定に渡し、両方を返す。

{ consecutiveDays, level: ... } という書き方(省略記法): 平易な言い換え「consecutiveDays: consecutiveDays と2回書く代わりに1回で済ませる書き方」。変数名とプロパティ名が同じときに使えます。

専門用語: 関数合成(composition)。平易な言い換え「小さい関数を2つつなげて、少し大きい仕事をする関数を作る」。61行目のコメントが「ショートカット」と呼んでいるのがこれです。

★なぜ2つに分けたうえで、つなげる関数も用意するのか★

  • 分けている理由: countConsecutiveProblemDays(配列を数える)と judgeEscalationLevel(数からレベル)は独立にテストできます。前者は「向き」のテスト、後者は「境界」のテスト。混ざっていると「配列を9個作らないと L3 の境界がテストできない」ことになります。
  • つなげる関数も置く理由: 呼び出し側が毎回2行書くのは面倒で、順番を間違える余地もあります。
  • 平易な言い換え: 部品としては分けておき、よく使う組み合わせには名前を付けて置いておく。

(b') 境界値・具体例 — ★D-026 の検証テストが最重要★

テスト(lib\judge\__tests__\escalation.test.ts 54〜95行目)の describe 名が「judgeEscalation — スケジュール存在日ベースの連続 ★最重要★」となっています。

正しいケース(59〜70行目)— 月水金スケジュールで3連続失敗

      { date: "2026-08-03", isProblem: true }, // 月
      { date: "2026-08-05", isProblem: true }, // 水
      { date: "2026-08-07", isProblem: true }, // 金
    ];
    const result = judgeEscalation(scheduleDays);

配列に火(8/4)と木(8/6)が入っていないことに注目してください。清掃予定がない日なので、そもそも渡しません。結果は consecutiveDays: 3 / level: "L3"。カレンダー上は5日間(8/3〜8/7)だが、連続は3日。

間違ったケース(73〜82行目)— カレンダー日で渡してしまった場合

      { date: "2026-08-03", isProblem: true },
      { date: "2026-08-04", isProblem: false }, // ← 本来含めてはいけない
      { date: "2026-08-05", isProblem: true },
      { date: "2026-08-06", isProblem: false }, // ← 本来含めてはいけない
      { date: "2026-08-07", isProblem: true },
    ];
    expect(judgeEscalation(wrong).level).toBe("L1");

★このテストが秀逸です★: 「間違った渡し方をすると L1 になってしまう」ことをわざとテストで固定しているのです。

平易な言い換え: 「こう間違えると、こう壊れる」という失敗例を、テストとして永久保存してある。将来この関数を書き換えた人が、うっかりカレンダー日ベースに戻してしまったら、このテストの意味が変わって気づけます。また新しく読む人は、このテストを読むだけで「なぜスケジュール存在日で数える必要があるのか」を理解できます。

L3(オンサイト訪問検討)と L1(現場へ電話)の差 = 2段階。同じ現象に対する対応が、渡し方を間違えるだけで2段階軽くなります。

その他のケース(85〜95行目)

入力 level 意味
"1" "L1" 1日
"11" "L2" 2日連続
"1110" null 過去に3日続いたが直近は正常 → レベルなし

最後の "1110" → null は、countConsecutiveProblemDays が 0 を返し、judgeEscalationLevel(0) が #1 分岐で null を返す、という2段の合成の結果です。平易な言い換え: 直った施設にはバッジを出さない。

(c) なぜそう書いてあるか

61行目「/* 結果配列から直接レベルを求めるショートカット /」。それ以上の説明はありません。設計意図はファイル冒頭(4〜16行目)の D-026 の説明と、2つの部品関数のコメントに分散しています。

(d) 呼ばれている場所

★まだ本番コードから呼ばれていません。★ grep 実測での出現箇所:

出現箇所 行 種別
lib\judge\escalation.ts(宣言) 62 定義
lib\judge\advice.ts 24 コメントでの言及のみ(「consecutiveProblemDays は judgeEscalation() が算出した値を渡してください。」)
lib\logger\events.ts 106 コメントでの言及のみ(「judgeEscalation() 呼び出し直後。」)
lib\judge\__tests__\escalation.test.ts 4, 67, 82, 86, 90, 94 テスト

app/ からも lib/fixtures/ からも呼ばれていません。

なぜ呼ばれていないのか: 引数の ScheduleDayResult[](スケジュール存在日だけに絞った日別結果の配列)を組み立てる処理がまだ無いためです。escalation.ts 13〜15行目が「どの日がスケジュール存在日かの判断(曜日計算、active フラグ、日跨ぎ等)はデータ取得側の責任」と宣言しており、その「データ取得側」がまだ実装されていません。

代わりに現状は:

  • 本番: app\api\clean-logs\[id]\advice\route.ts 98行目が judgeEscalationLevel(log.consecutive_problem_days) と、既に数えられている数字を渡す形で下位関数だけを使っている。
  • サンプル: lib\fixtures\clean-logs.ts 209〜210行目が連続日数を決め打ちしている。

平易な言い換え: 「数える部品」と「レベルを決める部品」の両方が完成していて、レベルを決める部品だけが実際に使われている。数える部品は、DBから日別の結果を引く処理ができたら接続される。lib\logger\events.ts 106行目が「judgeEscalation() 呼び出し直後」にログを出す前提で書かれているのも、将来 judgeEscalation() を呼ぶ設計になっていることの裏付けです(ただし現在のルートは judgeEscalationLevel() を呼んでおり、ログのコメントとは食い違っています — 指摘事項として後述)。


lib/judge/advice.ts

93行、関数2本 + 型1本。「この行にどのアドバイスを出すか」を決めるファイルです。文言は一切持ちません。

import 文(実際のコードのまま)

import { ESCALATION_THRESHOLDS, GRADE_THRESHOLDS } from "@/lib/config";
import type { AdviceRuleId } from "@/lib/config/advice-rules";
import { judgeGrade } from "./grade";

(1〜3行目。これで全部です)

していない import の確認

禁止・注目されるもの 状態
lib/logger import していません
lib/db import していません
@prisma/client import していません
node:fs import していません
next/* / react import していません
DEFAULT_ADVICE_RULES(文言本体) ★import していません★
lib/settings/advice(編集された文言) ★import していません★

★最重要の「していない」は下2つです。★ 2行目で advice-rules.ts から import しているのは AdviceRuleId という型だけ(import type なので実行時には消える)。文言の実体 DEFAULT_ADVICE_RULES には触っていません。

平易な言い換え: この関数は「R3 と R4 と B3 が該当します」とラベルの名前だけを返します。「2日連続で完了率低下(R3)」という日本語の文章は一切知りません。文章を貼るのは呼び出し側(app\api\clean-logs\[id]\advice\route.ts 85〜96行目)の仕事です。

根拠は 8〜11行目のコメントと D-036(前掲):

 * ★ここは「どのルールが該当するか」だけを決めます★
 *   表示する文言は `lib/config/advice-rules.ts` の既定値と、
 *   運用設定での上書き(`lib/settings/advice.ts`)から取得します。
 *   文言を変えてもこの関数とテストは影響を受けません。

なぜ重要か: もしこの関数が文言を返していたら、「文章を1文字直すたびに判定のテストが落ちる」ことになります。D-036 の理由(docs\decisions.md 125〜126行目):

  • 理由: 文言は運用しながら磨いていくもので、変えるたびにテストが壊れるのは不合理。一方で発火条件は仕様であり、勝手に変わってはいけない

実際にテスト(lib\judge\__tests__\advice.test.ts 5〜11行目)がその効果を宣言しています。

/**
 * 対応アドバイスの発火条件テスト
 *
 * ★ここでテストしているのは「どのルールが該当するか」だけです★
 *   文言(title / text / actions)は運用設定で編集できるため、テストしません。
 *   文言を変えてもこのテストは壊れません。
 */

3行目 import { judgeGrade } from "./grade"; は相対パス(./grade)です。1〜2行目の @/lib/...(プロジェクト起点の絶対パス)と書き方が違います。平易な言い換え: 同じフォルダの中の隣のファイルは「隣」と書き、別フォルダは住所を書く。tsconfig.json の "paths": { "@/*": ["./*"] } が @/ をプロジェクトルートに割り当てています。

ファイル冒頭のコメント(5〜28行目)

/**
 * 対応アドバイスの発火判定(Phase A: R1〜R4 + Phase B: B1〜B3)
 *
 * ★ここは「どのルールが該当するか」だけを決めます★
 *   表示する文言は `lib/config/advice-rules.ts` の既定値と、
 *   運用設定での上書き(`lib/settings/advice.ts`)から取得します。
 *   文言を変えてもこの関数とテストは影響を受けません。
 *
 * | # | 条件 |
 * |---|---|
 * | R1 | 出発失敗(初回) |
 * | R2 | 完了率×(10%未満) |
 * | R3 | 同一施設で2日以上連続△以下 |
 * | R4 | 同一施設で3日以上連続(出発失敗 or △以下) |
 * | B1 | 完了率10〜69%(マップ更新の確認) |
 * | B2 | ハンドルアップ検知 |
 * | B3 | オフライン検知 |
 *
 * ★「連続」はスケジュール存在日の連続★(D-026)
 *   `consecutiveProblemDays` は `judgeEscalation()` が算出した値を渡してください。
 *
 * 複数該当した場合はすべて返します(モックの挙動を踏襲)。
 * 行バッジの色は `maxAdviceLevel()` で決めます。
 */

R1〜R4 の行は CLAUDE.md §4.5(58〜63行目)の表の写しです。B1〜B3 はモック由来(advice-rules.ts 13行目「Phase B = エラーコード別のアドバイス(モックに存在していたもの)」)。

export しているもの — 全3件

export interface AdviceInput {
  departed: boolean;
  completionRate: number | null;
  consecutiveProblemDays: number;
  status1h?: string;
  status30m?: string;
}                                                                    // 29〜40行目
export function matchAdviceRules(input: AdviceInput): AdviceRuleId[] // 42行目
export function maxAdviceLevel(rules: Array<{ level: number }>): number  // 89行目

AdviceInput(29〜40行目)

export interface AdviceInput {
  /** スケジュール通り出発したか */
  departed: boolean;
  /** 清掃完了率(%)。未実施は null。★丸める前の生値★ */
  completionRate: number | null;
  /** スケジュール存在日ベースの連続問題日数 */
  consecutiveProblemDays: number;
  /** 1時間前のステータス */
  status1h?: string;
  /** 30分前のステータス */
  status30m?: string;
}

5項目の業務上の意味:

項目 意味 必須か
departed スケジュール通りに出発したか。false = 未実施 or 中断 必須
completionRate 完了率の生値(%)。未実施は null 必須(null 可)
consecutiveProblemDays スケジュール存在日ベースの連続問題日数 必須
status1h 清掃60分前のロボット状態(「待機中」「ハンドルアップ」等) 任意(?)
status30m 清掃30分前のロボット状態 任意(?)

?(オプショナル)の意味: 平易な言い換え「この項目は無くてもいい」。無いとき値は undefined になります。事前チェックの記録が取れていないケース(ロボットが電源オフだった等)を想定していると読めます(推測)。型は string で、lib\types\index.ts 59行目のコメント「待機中 / 充電中 / ハンドルアップ / 緊急停止 / オフライン / セキュリティ有効」のとおり6種類の日本語が入りますが、union 型で縛られていません(CLAUDE.md §9.3 の any 禁止には触れないものの、InspectionItemName のように日本語 union にする余地はある — 事実の指摘)。

status1h / status30m の名前は CleanLogItem の status_1h / status_30m(lib\types\index.ts 59〜61行目)に対応します。API は snake_case、TS 内部は camelCase という CLAUDE.md §9.2(276行目・278行目)の使い分けです。呼び出し側(advice\route.ts 57〜58行目)が status1h: log.status_1h, と変換しています。

matchAdviceRules

export function matchAdviceRules(input: AdviceInput): AdviceRuleId[] {
  const matched: AdviceRuleId[] = [];
  const {
    departed,
    completionRate,
    consecutiveProblemDays,
    status1h,
    status30m,
  } = input;

  // R1: 出発失敗(初回)。2日目以降は R3 / R4 が担当する
  if (!departed && consecutiveProblemDays <= 1) {
    matched.push("R1");
  }

  // R2: 完了率×(10%未満)。出発しなかった場合は completionRate が null なので対象外
  if (completionRate !== null && completionRate < GRADE_THRESHOLDS.poor) {
    matched.push("R2");
  }

  // R3: 2日以上連続
  if (consecutiveProblemDays >= ESCALATION_THRESHOLDS.level2Days) {
    matched.push("R3");
  }

  // R4: 3日以上連続。R3と同時に出ることがある(段階的な対応を促すため意図的)
  if (consecutiveProblemDays >= ESCALATION_THRESHOLDS.level3Days) {
    matched.push("R4");
  }

  // B1: △判定(完了率10〜69%)のときにマップ更新を疑う
  if (completionRate !== null && judgeGrade(completionRate) === "△") {
    matched.push("B1");
  }

  // B2 / B3: 事前チェックのステータス由来
  if (status1h === "ハンドルアップ" || status30m === "ハンドルアップ") {
    matched.push("B2");
  }
  if (status1h === "オフライン" || status30m === "オフライン") {
    matched.push("B3");
  }

  return matched;
}

(42〜86行目)

(a) 引数と戻り値の業務上の意味

  • 引数: 上記 AdviceInput の5項目。「その日その施設の清掃が、出発したか・何%終わったか・何日続いて問題か・事前チェックで何が見えていたか」。
  • 戻り値: AdviceRuleId[] = 該当したルール名の配列。例 ["R3", "R4", "B3"]。該当なしは空配列 []。
  • 業務上の意味: 一覧の行をクリックすると開く「対応アドバイス」パネルに、どの助言カードを何枚出すかが決まります。

(b) 中で何をしているか — ★judgeGrade と決定的に違う構造★

★この関数は「最初に当たったら終わり」ではありません。7つの条件を全部順に調べ、当たったものを全部リストに積みます。★

judgeGrade は if ... return で早期終了する「排他的な分類」(◎か○か△か×のどれか1つ)。matchAdviceRules は if ... push で積み上げる「非排他的な収集」です。

平易な言い換え: 健康診断で「あなたの体型は普通です」(1つだけ選ぶ)と、「血圧が高め・視力が低下・虫歯あり」(当てはまるものを全部挙げる)の違い。アドバイスは後者です。

根拠は 26行目のコメント: 「複数該当した場合はすべて返します(モックの挙動を踏襲)」。

平易な言い換え(なぜ全部返すのか): 「3日連続で問題が続いていて、しかもオフラインだった」なら、対応すべきことは2つ(オンサイト訪問の検討 + Wi-Fiルーターの再起動依頼)。どちらか1つに絞って表示すると、もう片方をやり忘れます。

43行目 const matched: AdviceRuleId[] = []; — 空のリストを用意。

44〜50行目 — 分割代入(destructuring)

  const {
    departed,
    completionRate,
    consecutiveProblemDays,
    status1h,
    status30m,
  } = input;

平易な言い換え「input.departed と毎回書く代わりに、中身を5つの変数に一度で取り出しておく」。以下の条件式が input. の繰り返しなく読めるようになります。

7つの条件を全部、順に

① R1(52〜55行目)

  // R1: 出発失敗(初回)。2日目以降は R3 / R4 が担当する
  if (!departed && consecutiveProblemDays <= 1) {
    matched.push("R1");
  }
  • 見ているもの: departed(出発したか)と consecutiveProblemDays(連続日数)の2つ。
  • &&(かつ): 両方が真のときだけ。
  • どちらに転ぶか:
    • 出発した(departed === true)→ !departed が偽 → R1 なし(完了率がいくら低くても R1 は出ない)
    • 出発しなかった、かつ 連続日数が 0 または 1 → R1 あり
    • 出発しなかった、かつ 連続日数が 2 以上 → R1 なし
  • ★<= 1 が「初回」の実装★: 平易な言い換え「今日が1日目(または連続カウントが立っていない)ときだけ R1 を出す。2日目以降は別のルール(R3/R4)に任せる」。52行目のコメント「2日目以降は R3 / R4 が担当する」がそのまま理由です。
  • なぜ === 1 ではなく <= 1 なのか: consecutiveProblemDays が 0 のケースを取り込むためです。連続日数の計算がまだ実装されていない場面や、集計前の行で 0 が入っている可能性があります。=== 1 にすると 0 のとき R1 が出ず、「出発失敗しているのにアドバイスが1枚も出ない」空パネルになります。(この読み取りは筆者の解釈。コメントに <= 1 を選んだ理由は書かれていません。)
  • 重複回避の設計: R1(level 1)と R3(level 2)と R4(level 3)が同時に出ると、「現場に電話してください」と「オンサイト訪問を検討してください」が並んで温度差が生じます。<= 1 で R1 を初日に限定することで、日数が進むにつれてアドバイスが入れ替わる構造になっています。

② R2(57〜60行目)

  // R2: 完了率×(10%未満)。出発しなかった場合は completionRate が null なので対象外
  if (completionRate !== null && completionRate < GRADE_THRESHOLDS.poor) {
    matched.push("R2");
  }
  • 見ているもの: completionRate のみ。
  • 2段の条件:
    1. completionRate !== null — null を先に弾く。
    2. completionRate < GRADE_THRESHOLDS.poor(= < 10)。
  • なぜ null チェックが先に必要か(重要): JavaScript では null < 10 は 真になります(null が 0 に変換される)。この検査を省くと、出発しなかった行(completionRate === null)に「現場の通路状況を確認してください」というアドバイスが出てしまいます。出発していないのに通路を確認させるのは無意味です。57行目のコメント「出発しなかった場合は completionRate が null なので対象外」が明示的にこれを述べています。
  • どちらに転ぶか(境界値):
completionRate !== null < 10 R2
null 偽 (評価されない) なし
0 真 真 あり
9.9 真 真 あり
10.0 真 偽(10 < 10) なし
45 真 偽 なし
  • ★10.0 の扱いが judgeGrade と完全に一致しています★。judgeGrade(10.0) は △、R2 は「×(10%未満)」なので出ない。同じ GRADE_THRESHOLDS.poor と同じ < を使っているので、原理的にずれません。 テスト(lib\judge\__tests__\advice.test.ts 63〜73行目)が固定:
    expect(matchAdviceRules(input({ completionRate: 9.9 }))).toContain("R2");
    const r = matchAdviceRules(input({ completionRate: 10.0 }));
    expect(matchAdviceRules(input({ completionRate: 0 }))).toContain("R2");
  • && の短絡評価(short-circuit): 平易な言い換え「左が偽ならその時点で偽と決まるので、右は計算しない」。completionRate が null のとき右の比較は実行されません。

③ R3(62〜65行目)

  // R3: 2日以上連続
  if (consecutiveProblemDays >= ESCALATION_THRESHOLDS.level2Days) {
    matched.push("R3");
  }
  • 見ているもの: consecutiveProblemDays のみ。departed も completionRate も見ません。
  • level2Days は 2。>=(以上)。
  • どちらに転ぶか: 連続日数 0 → なし。1 → なし。2 → あり。3 → あり。5 → あり。100 → あり。
  • ★上限がないので、R3 は L2 以上のすべてで出続けます★。「2日連続のとき限定」ではありません。
  • judgeEscalationLevel との違い: あちらは「L3 なら L2 は返さない」(排他)。こちらは「3日連続でも R3 は出る」(非排他)。同じ level2Days / level3Days を使っているのに、排他か非排他かが違います。 これは次項のコメントが理由を述べています。

④ R4(67〜70行目)

  // R4: 3日以上連続。R3と同時に出ることがある(段階的な対応を促すため意図的)
  if (consecutiveProblemDays >= ESCALATION_THRESHOLDS.level3Days) {
    matched.push("R4");
  }
  • level3Days は 3。>=。
  • どちらに転ぶか: 0/1/2 → なし。3 → あり。4以上 → あり。
  • ★67行目のコメントが「R3 と R4 が同時に出る」を意図的だと明言しています★: 「R3と同時に出ることがある(段階的な対応を促すため意図的)」。
  • 平易な言い換え: 3日連続の施設には「現場責任者にヒアリングしてください(R3)」と「オンサイト訪問を検討してください(R4)」の両方を出す。オンサイト訪問は日程調整が必要で今日はできないので、今日やれること(ヒアリング)も同時に示す。片方に絞ると「訪問待ちで今日は何もしない」空白が生まれます。
  • これがバグに見えないよう、コメントで「意図的」と1語入れてあるのが重要です。コメントがなければ後任者が「R3 と R4 が重複している、直そう」と考える可能性があります。

⑤ B1(72〜75行目)

  // B1: △判定(完了率10〜69%)のときにマップ更新を疑う
  if (completionRate !== null && judgeGrade(completionRate) === "△") {
    matched.push("B1");
  }
  • 見ているもの: completionRate のみ。
  • ★judgeGrade() を呼んで文字で比較しています★ — 「10以上70以下」と数字で書いていません。
  • なぜ judgeGrade を呼ぶのか: △ の範囲は「◎でも○でも×でもない残り」(judgeGrade #5 分岐)です。ここで >= 10 && <= 70 と書き直すと、しきい値を変えたときに △ の範囲が judgeGrade とずれます。平易な言い換え: △ の定義を知っているのは judgeGrade だけ、という一元管理。
  • completionRate !== null が必要な理由: judgeGrade(null) は "×" を返すので、実は null でも === "△" が偽になり B1 は出ません。つまりこの null チェックは論理的には不要です。 それでも書いてあるのは、(1) R2 と形を揃えて読みやすくする、(2) judgeGrade の null 時の戻り値に依存しない(将来 judgeGrade が null を返すようになっても壊れない)ため、と読めますが、コメントに理由はないため推測です。
  • どちらに転ぶか(境界値):
completionRate judgeGrade B1
null (!== null で弾かれる) なし
9.9 × なし
10.0 △ あり
45 △ あり
69.9 △ あり
70.0 △ あり
70.1 ○ なし
95 ◎ なし

テスト(lib\judge\__tests__\advice.test.ts 120〜125行目):

    expect(matchAdviceRules(input({ completionRate: 45 }))).toContain("B1");
    expect(matchAdviceRules(input({ completionRate: 69.9 }))).toContain("B1");
    expect(matchAdviceRules(input({ completionRate: 70.1 }))).not.toContain("B1");

★指摘: 70.0 は B1 が出ます。★ テストは 69.9(出る)と 70.1(出ない)を固定していますが、70.0 ちょうどは固定していません。実装では △ なので B1 が出ます。72行目のコメントとルール定義(advice-rules.ts 101行目)の「完了率10〜69%」という表記からは、70.0 で出ることが読み取れません。動作は D-020b(70.0 は △)と整合しているので実装が正しく、説明文の書き方が不正確です。

⑥ B2(77〜80行目)

  // B2 / B3: 事前チェックのステータス由来
  if (status1h === "ハンドルアップ" || status30m === "ハンドルアップ") {
    matched.push("B2");
  }
  • 見ているもの: status1h と status30m の2つ。
  • ||(または): どちらか片方でも「ハンドルアップ」なら該当。
  • なぜ「または」なのか: 平易な言い換え「60分前に見たときハンドルが上がっていた」なら、その後30分で誰かが直していても記録として残す価値がある。逆に「60分前は正常、30分前に上がった」も当然拾う。片方でも見えたら報告する(見逃しを避ける)設計です。
  • どちらに転ぶか:
status1h status30m B2
"待機中" "待機中" なし
"ハンドルアップ" "待機中" あり
"待機中" "ハンドルアップ" あり
"ハンドルアップ" "ハンドルアップ" あり(1回だけ push。重複しません)
undefined undefined なし
  • ★日本語の文字列を直接比較しています★(=== "ハンドルアップ")。平易な言い換え: DBに入っている日本語の状態名と1文字ずつ一致するかを見ている。もしDB側の表記が「ハンドルUP」に変わったら、この条件は静かに false になり、B2 が一切出なくなります(エラーにはならない)。これは CLAUDE.md §7(215行目)が禁じている「しきい値のハードコード」とは別種ですが、マジックストリング(コードに直接書かれた意味のある文字列)という同じ性質の弱点です。lib\types\index.ts 59行目に6種類のステータス名がコメントで列挙されているので、そこを union 型にして定数化する余地があります(改善案。現状の指摘は事実)。

⑦ B3(81〜83行目)

  if (status1h === "オフライン" || status30m === "オフライン") {
    matched.push("B3");
  }

B2 と完全に同じ構造で、文字列が「オフライン」。テスト(141〜142行目):

    expect(matchAdviceRules(input({ status1h: "オフライン" }))).toContain("B3");
    expect(matchAdviceRules(input({ status30m: "オフライン" }))).toContain("B3");

85行目 return matched; — 積み上げたリストを返す。

(b') ★戻り値の順序が固定されていること★

条件を書いた順に push しているので、戻り値の並び順は必ず R1 → R2 → R3 → R4 → B1 → B2 → B3 の部分列になります。

平易な言い換え: 該当したものだけを、いつも同じ順番で並べて返す。「B3, R1」のような順序になることはありません。

なぜ重要か: テストが toEqual(["R3", "R4", "B3"]) と順序込みで期待値を書いているため(147〜157行目)。toEqual は配列の順序も比較します。また画面のアドバイスカードの並び順も、この順序で「重い順ではなく、Phase A → Phase B」になります。

(b'') ★複数該当の全パターン検証★

テスト(lib\judge\__tests__\advice.test.ts 146〜172行目)が固定している2つの複合ケースを、7条件すべてで追跡します。

ケース1(147〜158行目): 3日連続のオフライン出発失敗

入力: departed: false, completionRate: null, consecutiveProblemDays: 3, status1h: "オフライン", status30m: "オフライン"

条件 判定 理由
① R1 !departed && days <= 1 ✗ !false = 真だが、3 <= 1 が偽 → R1 は出ない
② R2 rate !== null && rate < 10 ✗ null !== null が偽
③ R3 days >= 2 ✓ 3 >= 2 真
④ R4 days >= 3 ✓ 3 >= 3 真
⑤ B1 rate !== null && grade === "△" ✗ null で弾かれる
⑥ B2 ハンドルアップ ✗ 「オフライン」なので不一致
⑦ B3 オフライン ✓ 両方一致(1回 push)

結果: ["R3", "R4", "B3"] — テスト157行目の期待値と一致。

★R1 が出ていない点が設計の要です★: 出発失敗しているのに R1(出発失敗)が出ません。3日目なので R3/R4 が担当する、という 52行目のコメントどおりの動作です。平易な言い換え: 3日目に「事前チェックのアラートを確認してください」と初日と同じ助言を出すのは、もう試した後なので無意味。より踏み込んだ助言に切り替わる。

ケース2(160〜171行目): 初回のハンドルアップ出発失敗

入力: departed: false, completionRate: null, consecutiveProblemDays: 1, status1h: "ハンドルアップ", status30m: "ハンドルアップ"

条件 判定 理由
① R1 ✓ !false 真、1 <= 1 真
② R2 ✗ null
③ R3 days >= 2 ✗ 1 >= 2 偽
④ R4 days >= 3 ✗ 偽
⑤ B1 ✗ null
⑥ B2 ✓ 一致
⑦ B3 ✗ 不一致

結果: ["R1", "B2"] — テスト170行目の期待値と一致。

平易な言い換え: 初日なので「出発失敗(R1): 事前チェックを確認」+ 「ハンドルアップ検知(B2): 現場にハンドルを下ろすよう連絡」。R1 が「何を確認すべきか」を、B2 が「具体的に何が起きているか」を示す組み合わせになります。

理論上ありうる最大該当数の検討(実装から導出、テストにはないケース):

  • R1 と R3 は同時に出るか → !departed && days <= 1 と days >= 2 は両立しません(days が1以下と2以上を同時に満たせない)。R1 と R3/R4 は排他。
  • R2 と B1 は同時に出るか → R2 は < 10、B1 は △(10以上70以下)。排他。
  • 最大は departed: false ではなく departed: true で completionRate: 45、days: 3、両ステータスが別々に「ハンドルアップ」「オフライン」の場合 → R3 + R4 + B1 + B2 + B3 = 5件。

(c) なぜそう書いてあるか(根拠の引用まとめ)

行 コメント 対応する仕様
52 「R1: 出発失敗(初回)。2日目以降は R3 / R4 が担当する」 CLAUDE.md §4.5(60行目)「R1 \ 出発失敗(初回)」
57 「R2: 完了率×(10%未満)。出発しなかった場合は completionRate が null なので対象外」 CLAUDE.md §4.5(61行目)「R2 \ 完了率×(10%未満)」
62 「R3: 2日以上連続」 CLAUDE.md §4.5(62行目)「R3 \ 同一施設で2日以上連続△以下」
67 「R4: 3日以上連続。R3と同時に出ることがある(段階的な対応を促すため意図的)」 CLAUDE.md §4.5(63行目)「R4 \ 同一施設で3日以上連続(出発失敗 or △以下)」
72 「B1: △判定(完了率10〜69%)のときにマップ更新を疑う」 モック由来(Phase B)
77 「B2 / B3: 事前チェックのステータス由来」 モック由来(Phase B)
23-24 「★「連続」はスケジュール存在日の連続★(D-026)consecutiveProblemDays は judgeEscalation() が算出した値を渡してください。」 D-026
26 「複数該当した場合はすべて返します(モックの挙動を踏襲)」 CLAUDE.md §9.6(308行目)モックを正とする

★24行目の指示と実際の呼び出しが食い違っています★: コメントは「judgeEscalation() が算出した値を渡してください」と言っていますが、実際の呼び出し元(app\api\clean-logs\[id]\advice\route.ts 53〜59行目)は judgeEscalation() を呼んでおらず、log.consecutive_problem_days(fixtures で決め打ちされた値)を渡しています。現在は judgeEscalation() を呼ぶ経路が存在しません(前述のとおり judgeEscalation() は本番未使用)。指摘事項として後述します。

(d) 呼ばれている場所

呼び出し元 行 文脈
app\api\clean-logs\[id]\advice\route.ts 12(import), 53 本番の呼び出し
lib\judge\__tests__\advice.test.ts 2, 26, 31, 39, 46, 53, 63, 67, 73, 77, 86, 94, 102, 110, 120, 121, 125, 130, 136, 141, 142, 148, 161 テスト(23箇所)

本番の呼び出し(advice\route.ts 53〜59行目):

    const matchedIds = matchAdviceRules({
      departed: log.departed,
      completionRate: log.completion_rate,
      consecutiveProblemDays: log.consecutive_problem_days,
      status1h: log.status_1h,
      status30m: log.status_30m,
    });

snake_case(API・DB側)から camelCase(TS内部)への変換がここで行われています。

その直後(61〜83行目)にログ出力があり、61〜63行目のコメントが無条件にログを出す判断の理由を述べています。

    // 判定根拠の記録。記録条件は設けず無条件に出す
    // (このルートは行クリックで1件だけ呼ばれるため件数の問題が無く、
    //  条件分岐を入れると「根拠が必要な場面に限って記録が無い」事故のほうが起きやすい)

平易な言い換え: 「問題のある行だけログを出す」という条件を付けると、その条件の判断自体を間違えたときに、まさに調べたい行のログが無い、という最悪の事態になる。1件ずつしか呼ばれないAPIなので、全部出しておく。

そしてこの関数自身はログを出していません(lib/logger 未 import)。判定と記録が分離されている設計の実例です。

maxAdviceLevel

/** 該当ルールの最大レベル。行バッジの色分けに使う。該当なしは0 */
export function maxAdviceLevel(
  rules: Array<{ level: number }>
): number {
  return rules.reduce((max, r) => Math.max(max, r.level), 0);
}

(88〜93行目)

(a) 引数と戻り値の業務上の意味

  • 引数 rules: 該当したアドバイスの定義オブジェクトの配列。level というプロパティを持っていれば何でもよい(後述)。
  • 戻り値: その中で最も重いレベルの数字。該当なしなら 0。
  • 業務上の意味: 一覧の行に付けるバッジの色を決めます。1 = 注意(薄い色)、2 = 警告、3 = 重大(濃い色)、0 = バッジなし。advice-rules.ts 21行目「1=注意 2=警告 3=重大。行バッジの色は該当ルールの最大レベルで決まる」。

平易な言い換え: 3枚のアドバイスカードが出ていて、それぞれ「注意・警告・重大」だったら、行の色は一番重い「重大」に合わせる。一覧をざっと見て、最も深刻な行が最も目立つようにするためです。

(b) 中で何をしているか — 分岐なし・1行

専門用語: reduce(畳み込み/集約)。平易な言い換え「配列の要素を1つずつ見ながら、手元の1個の値を更新していき、最後に残った値を答えにする」。合計を出す、最大値を出す、といった「多数 → 1つ」の計算に使います。

rules.reduce((max, r) => Math.max(max, r.level), 0) を分解します。

部分 意味
rules 対象の配列
(max, r) => ... 各要素で実行する処理。max が「これまでの最大値」、r が「今見ている要素」
Math.max(max, r.level) これまでの最大値と、今の要素のレベルの、大きい方
, 0 ★初期値 0★(reduce の第2引数)

動作の追跡(テスト176〜179行目のケース: R3=2, R4=3, B3=1):

手番 max(開始時) r.level Math.max max(終了時)
初期 — — — 0
1回目 0 2(R3) max(0,2) 2
2回目 2 3(R4) max(2,3) 3
3回目 3 1(B3) max(3,1) 3
結果 3

テスト179行目 expect(maxAdviceLevel(rules)).toBe(3); // R4 が level 3 と一致。並び順に関係なく最大が出ることが3回目(1 が来ても 3 のまま)で確認できます。

★初期値 0 が2つの役目を果たしています★

  1. 空配列のとき 0 を返す。テスト183行目 expect(maxAdviceLevel([])).toBe(0);。初期値を渡さない reduce は、空配列で例外を投げます(TypeError: Reduce of empty array with no initial value)。初期値 0 があるのでエラーになりません。88行目のコメント「該当なしは0」がこの挙動の宣言です。
  2. level は最小 1 なので、0 は「どのレベルより低い」安全な出発点になります。

平易な言い換え: 「まだ何も見ていない状態のレベルは0」と決めておけば、1件もなければ0のまま、1件でもあればそれ以上になる。空のときにエラーで落ちる心配もない。

もう1つのテスト(186〜188行目): R1(level 1)と B2(level 1)だけなら 1。

    const rules = [DEFAULT_ADVICE_RULES.R1, DEFAULT_ADVICE_RULES.B2];
    expect(maxAdviceLevel(rules)).toBe(1);

(b') ★引数の型 Array<{ level: number }> が巧妙です★

専門用語: 構造的部分型(structural typing)/ダックタイピング。平易な言い換え「型の名前ではなく、形が合っていれば受け入れる」という TypeScript の性質。

この関数は AdviceRuleDefinition[] を要求していません。level: number を持つオブジェクトの配列なら何でも受け取ります。

なぜそうしているのか: 本番の呼び出し元(advice\route.ts 115行目)が渡しているのは AdviceRuleDefinition ではなく、API 応答用に組み替えた別のオブジェクトです。86〜96行目:

    const advices = matchedIds.map((ruleId) => {
      const r = rules[ruleId];
      return {
        rule: r.id,
        level: r.level,
        icon: r.icon,
        title: r.title,
        estimated_cause: r.text,      // ← text から名前が変わっている
        recommended_actions: r.actions, // ← actions から名前が変わっている
      };
    });

そして 115行目 max_level: maxAdviceLevel(advices),。

この advices の要素の型は AdviceItem(lib\types\index.ts 76〜84行目)で、AdviceRuleDefinition とは別物です(text が estimated_cause に、condition が無い)。それでも level: number を持っているので受け取れます。

平易な言い換え: 「レベルという数字を持っているもの」の集まりから最大を出すだけなので、それ以外の項目は何でもいい。既定値の定義でも、API応答の形でも、テストで手作りした { level: 2 } でも動く。関数が必要以上のことを知らない設計です。

テストは両方の形で呼んでいます — 176〜178行目は DEFAULT_ADVICE_RULES から取った AdviceRuleDefinition、187行目も同じ。本番は AdviceItem。同じ関数が2つの型を受けているのが実際に効いている例です。

level: 1 | 2 | 3 ではなく level: number にしている理由: 1 | 2 | 3 にすると、AdviceItem(level: 1 | 2 | 3)は通るが、テストで { level: 2 } と書いたものが number と推論されて通らなくなる、といった摩擦が生じます。緩めることで再利用しやすくしています(この読み取りは筆者の解釈)。

(c) なぜそう書いてあるか

  • 88行目「/* 該当ルールの最大レベル。行バッジの色分けに使う。該当なしは0 /」— 用途と空配列の挙動の両方が1行に書かれています。
  • 27行目(ファイル冒頭)「行バッジの色は maxAdviceLevel() で決めます。」
  • advice-rules.ts 21行目「1=注意 2=警告 3=重大。行バッジの色は該当ルールの最大レベルで決まる」

(d) 呼ばれている場所

呼び出し元 行 文脈
app\api\clean-logs\[id]\advice\route.ts 12(import), 115 本番の呼び出し max_level: maxAdviceLevel(advices),
lib\judge\__tests__\advice.test.ts 2, 179, 183, 188 テスト

advice\route.ts 112〜117行目が API 応答の組み立てです。

    const body: CleanLogAdviceResponse = {
      escalation_level: escalationLevel,
      consecutive_problem_days: log.consecutive_problem_days,
      max_level: maxAdviceLevel(advices),
      advices,
    };

CleanLogAdviceResponse(lib\types\index.ts 86〜91行目)の4項目がここで揃います。平易な言い換え: 「レベルはL3」「3日連続」「行の色は3(重大)」「アドバイス3枚」の4点セットを1回の応答で返す。画面は再計算せずこれを表示するだけ(CLAUDE.md §9.6、309行目「判定結果(◎○△×、L1〜L3、ソート順)はAPIから受け取る。フロントで再計算しない」)。


lib/judge/kpi.ts

72行、関数7本(うち1本は非公開)。4つのKPI(率)の計算と、施設の3段階評価を担当します。

import 文(実際のコードのまま)

import { FACILITY_STATUS_THRESHOLDS } from "@/lib/config";
import type { FacilityStatus, KpiValue } from "@/lib/types";

(1〜2行目。これで全部です)

していない import の確認

禁止・注目されるもの 状態
lib/logger import していません
lib/db import していません
@prisma/client import していません
node:fs import していません
next/* / react import していません
GRADE_THRESHOLDS import していません(このファイルは ◎○△× を扱わない)
./grade(displayRate 等) import していません(13行目のコメントで言及するだけ)

★./grade を import していない点が設計として重要です★: 13行目のコメントは「表示するときだけ displayRate() で整数に丸めます」と述べていますが、このファイルは自分では丸めません。生値を返して、丸めるかどうかは呼び出し側に委ねています。CLAUDE.md §9.3(286行目)「数値の丸めは表示層でのみ行う。ロジック層には生値を渡す」の実践です。

ファイル冒頭のコメント(4〜17行目)

/**
 * KPI4指標の計算(CLAUDE.md 4.2 / D-021)
 *
 *   稼働率       = 清掃実施回数 ÷ 清掃予定回数 × 100
 *   出発成功率   = 定刻出発回数 ÷ 清掃予定回数 × 100
 *   清掃完了率   = Σ清掃面積 ÷ Σ清掃対象面積 × 100   ← ★面積ベース(時間ベースではない)★
 *   エラー発生率 = エラー発生回数 ÷ 清掃実施回数 × 100
 *
 * ★戻り値は丸める前の生値★
 *   表示するときだけ `displayRate()` で整数に丸めます。
 *
 * 分母が0のときは null を返します(0%ではない)。
 * 「予定が1件もない期間」と「予定はあったが全部失敗した」は区別する必要があるためです。
 */

4つの式は CLAUDE.md §4.2(105〜113行目)の写しです。

稼働率       = 清掃実施回数 ÷ 清掃予定回数 × 100
出発成功率   = 定刻出発回数 ÷ 清掃予定回数 × 100
清掃完了率   = Σ清掃面積 ÷ Σ清掃対象面積 × 100   ← 面積ベース(時間ベースではない)
エラー発生率 = エラー発生回数 ÷ 清掃実施回数 × 100

専門用語: KPI(Key Performance Indicator、重要業績評価指標)。平易な言い換え「その施設のロボット清掃がうまくいっているかを表す代表的な数字」。ここでは4つ。

★「Σ(シグマ)」: 「合計」の記号。「Σ清掃面積」= 期間内の全清掃の面積を足し合わせたもの。平易な言い換え: 1回ごとの完了率を平均するのではなく、面積を全部足してから割る。1回ごとの平均だと「小さいエリアの100%」と「広いエリアの50%」が同じ重みになってしまいます。

docs\decisions.md D-021(93〜95行目)が「面積ベース」の根拠です。

D-021 [確定] 清掃完了率は面積ベース

  • 決定: Σ清掃面積 ÷ Σ清掃対象面積 × 100
  • 背景: 時間ベース / ルート距離ベースの可能性もあったが、Pudu Cloud管理画面の算出ロジックが面積ベースであることを確認(確認事項#13)

平易な言い換え(なぜこれが確定事項として記録されているか): 完了率の出し方には少なくとも3通り(面積・時間・走行距離)あり、どれを選ぶかで数字が変わります。ロボットメーカー(Pudu)の管理画面が面積で計算していることを確認したので、それに合わせた。もし時間ベースで実装していたら、Pudu の画面と D-Watch の画面で違う完了率が出て、どちらが正しいか分からなくなります。

export しているもの — 全6件(+ 非公開1件)

function rate(numerator: number, denominator: number): number | null                  // 20行目(★export していない★)
export function operationRate(executed: number, planned: number): number | null       // 26行目
export function launchRate(onTime: number, planned: number): number | null            // 31行目
export function completionRate(cleanedArea: number, targetArea: number): number | null // 36行目
export function errorRate(errors: number, executed: number): number | null            // 44行目
export function toKpiValue(value: number, prev: number | null): KpiValue               // 54行目
export function judgeFacilityStatus(operationRateValue: number): FacilityStatus         // 68行目

rate(非公開のヘルパー)

/** 分母0を null にする除算 */
function rate(numerator: number, denominator: number): number | null {
  if (denominator === 0) return null;
  return (numerator / denominator) * 100;
}

(19〜23行目)

★export が付いていません。★ 平易な言い換え「このファイルの中だけで使う道具。外からは呼べない」。

専門用語: プライベート関数/内部ヘルパー。なぜ非公開にするのか: 外に出すと rate(3, 7) のような「何の率か分からない呼び出し」が他のファイルから書けてしまいます。名前付きの4関数(operationRate 等)だけを公開すれば、呼び出し側は必ず「何の率か」を名前で宣言することになります(この読み取りは筆者の解釈ですが、公開/非公開の事実は export の有無から明白です)。

(a) 引数と戻り値

  • numerator(分子)、denominator(分母)。
  • 戻り値: パーセント(0〜100 の範囲になることが多いが上限の制約はない)、または null。

(b) 中で何をしているか — 分岐1本

# 行 条件 真のとき 偽のとき
1 21 denominator === 0 null を返して終了 #2 へ
2 22 (条件なし) — (numerator / denominator) * 100 を返す

★if (denominator === 0) return null; が、このファイル全体で最も重要な1行です。★

もしこの行がなかったら何が起きるか(平易に): JavaScript では 0 で割ってもエラーになりません。代わりに:

式 結果 画面での見え方
0 / 0 * 100 NaN 「NaN%」と表示される(意味不明)
5 / 0 * 100 Infinity 「Infinity%」と表示される
-5 / 0 * 100 -Infinity 「-Infinity%」

平易な言い換え: 「予定が0件」の施設で Math.round(NaN) を計算すると NaN のままで、画面に「NaN%」と出ます。これは不具合に見えますが、原因が「割り算」だと気づくのに時間がかかります。入口で null に変換しておけば、displayRate() が "-" を表示します。

(c) なぜ 0% ではなく null なのか — ★指定された最重要問い★

根拠は 15〜16行目のコメント(引用):

 * 分母が0のときは null を返します(0%ではない)。
 * 「予定が1件もない期間」と「予定はあったが全部失敗した」は区別する必要があるためです。

平易な言い換え(具体例で): ある施設の稼働率を見たいとします。

状況 清掃予定 清掃実施 もし 0% を返したら null を返すと
A: 全部失敗 20回 0回 0% と表示 0% と表示(0/20*100 = 0。正しく0%)
B: 予定なし(新規契約前、休業中など) 0回 0回 0% と表示 - と表示

0% を返す設計だと、A と B が画面上で区別できません。 A は「ロボットが20回全部失敗した緊急事態」、B は「そもそも清掃予定を入れていない」。まったく違う話なのに、同じ「0%」に見える。

さらに深刻なのは B が「最悪の施設」として扱われることです。judgeFacilityStatus(0) は "bad"(要改善)を返します。休業中の施設が「要改善」と赤く表示され、対応リストの上位に来てしまいます。

★この「null と 0 を区別する」方針は、この層の全域で一貫しています★:

場所 null の意味 0 の意味
judgeGrade(rate) 未実施(清掃していない) 完了率0%(清掃したが1%も終わらなかった)
kpi.ts の rate() 分母が0(母数がない) 実績が0(母数はあるが実績ゼロ)
displayRate(rate) "-" と表示 "0%" と表示
KpiValue.prev / .diff 前期データなし(新規施設) 前期と同じ(差が0)
DashboardResponse.failure_breakdown プロパティ自体なし = 未実装 空配列 = 失敗0件
judgeEscalationLevel(0) → null レベルなし(問題なし) —
inclusiveDayCount() の null 日付が不正で数えられない —

平易な言い換え: 「無い」と「ゼロ」を最後まで混ぜない。プログラムの世界では両方とも「何もない」に見えますが、業務では「まだやっていない」と「やったけど成果ゼロ」は全然違う意味です。

lib\types\index.ts 313行目、322行目の success_rate: number | null / rate: number | null にも同じ方針のコメントが付いています。

(d) 呼ばれている場所

呼び出し元 行
lib\judge\kpi.ts(同ファイル内) 27, 32, 40, 45

非公開なので、このファイルの4関数からのみ。 外部からの呼び出しは不可能です。

operationRate(稼働率)

/** 稼働率 = 清掃実施回数 ÷ 清掃予定回数 */
export function operationRate(executed: number, planned: number): number | null {
  return rate(executed, planned);
}

(25〜28行目)

(a) 引数と戻り値の業務上の意味

  • executed: 清掃実施回数。期間内に実際に清掃が行われた回数。
  • planned: 清掃予定回数。スケジュール上、清掃すべきだった回数。
  • 戻り値: 稼働率(%)の生値。予定が0回なら null。
  • 平易な言い換え: 「やるべきだった20回のうち、何回できたか」。18回なら90%。

(b) 中で何をしているか — 分岐なし・1行

rate(executed, planned) に丸投げ。引数の順序が「分子, 分母」であることを、関数名と引数名(executed / planned)で読めるようにしています。

★この関数の存在意義は「引数の順番を間違えないこと」です★: rate(planned, executed) と書いても型は通ります(両方 number)。operationRate(executed, planned) なら引数名が業務用語なので、逆に書けば読んだ人が気づきます。

(b') 境界値(テスト lib\judge\__tests__\kpi.test.ts 21〜32行目)

    expect(operationRate(141, 145)).toBeCloseTo(97.2413, 3);
    expect(operationRate(100, 100)).toBe(100);
    expect(operationRate(0, 100)).toBe(0);
    expect(operationRate(0, 0)).toBeNull();
executed planned 結果 意味
141 145 97.2413… ★小数のまま返る(丸めない)★
100 100 100 全部実施
0 100 0 100回予定して1回も実施せず = 0%(null ではない)
0 0 null 予定が1件もない = 計算不能

★operationRate(0, 100) が 0 で、operationRate(0, 0) が null。この2行が「null と 0 の区別」の実物です。★

toBeCloseTo(97.2413, 3)(テスト23行目): 平易な言い換え「小数第3位まで一致していればよい」という比較。141 / 145 * 100 = 97.24137931... は無限に続くので、toBe(97.2413) と厳密比較はできません。逆に言えば、テストが小数で比較していること自体が「丸めていない」ことの証拠です。もし関数が丸めていたら toBe(97) で書けます。

(c) なぜそう書いてあるか: 25行目のコメント「稼働率 = 清掃実施回数 ÷ 清掃予定回数」。CLAUDE.md §4.2(108行目)。

(d) 呼ばれている場所

★まだ本番コードから呼ばれていません。★

出現箇所 行 種別
lib\judge\kpi.ts(宣言) 26 定義
lib\judge\__tests__\kpi.test.ts 8, 21, 23, 24, 25, 30 テスト

app/ と components/ と lib/fixtures/ からの呼び出しはゼロです。理由はレポート画面(F-04)のKPI集計がまだ実装されていないため、と読めます(lib\fixtures\reports.ts は存在するが、KPI値を直書きしていると推測。このファイルは本調査の対象外なので未確認)。

★注意: 同種の計算が別の場所で再実装されています★: lib\dashboard\aggregate.ts の 57行目と 101行目:

    success_rate: denominator === 0 ? null : (success / denominator) * 100,
    rate: planned === 0 ? null : (completed / planned) * 100,

これは kpi.ts の rate() とまったく同じロジック(分母0なら null、そうでなければ ÷ して ×100)を手で書き直したものです。rate() が非公開(export なし)なので外から使えず、operationRate() を使うと引数名(executed / planned)が意味と合わない場面もある、という事情と読めます(推測)。現状は挙動が一致していますが、「分母0は null」という方針が2箇所に分かれているので、片方だけ変えるとずれます。指摘事項として後述します。

launchRate(出発成功率)

/** 出発成功率 = 定刻出発回数 ÷ 清掃予定回数 */
export function launchRate(onTime: number, planned: number): number | null {
  return rate(onTime, planned);
}

(30〜33行目)

(a)

  • onTime: 定刻出発回数。予定時刻(+猶予30分、LAUNCH_GRACE_MINUTES)以内に清掃を開始できた回数。
  • planned: 清掃予定回数。operationRate と同じ分母です。
  • 戻り値: 出発成功率(%)の生値。予定0回なら null。
  • 平易な言い換え: 「20回のうち、時間通りに動き出せたのは何回か」。

★operationRate との違い(重要)★: 分母は同じ「予定回数」ですが、分子が違います。

  • operationRate: 実施できた回数(遅れても実施すれば数える)
  • launchRate: 定刻に出発できた回数(遅刻は数えない)

平易な言い換え: 稼働率は「やったか」、出発成功率は「時間通りにやったか」。30分遅れて清掃した場合、稼働率には入るが出発成功率には入りません(この解釈は式と CLAUDE.md §4.4 の猶予30分の定義から導いたもので、コード内に明記はありません)。

(b) 分岐なし・1行。rate(onTime, planned)。

(b') 境界値(テスト34〜42行目)

    expect(launchRate(85, 100)).toBe(85);
    expect(launchRate(0, 0)).toBeNull();
onTime planned 結果
85 100 85
0 0 null

(c) 30行目のコメント。CLAUDE.md §4.2(109行目)「出発成功率 = 定刻出発回数 ÷ 清掃予定回数 × 100」。

(d) ★まだ本番コードから呼ばれていません。★ 出現は lib\judge\kpi.ts 31行目(宣言)と lib\judge\__tests__\kpi.test.ts 7, 34, 37, 41行目(テスト)のみ。

completionRate(清掃完了率)

/** 清掃完了率 = Σ清掃面積 ÷ Σ清掃対象面積(★面積ベース★) */
export function completionRate(
  cleanedArea: number,
  targetArea: number
): number | null {
  return rate(cleanedArea, targetArea);
}

(35〜41行目)

(a)

  • cleanedArea: Σ清掃面積。期間内に実際に清掃できた床面積の合計(㎡)。
  • targetArea: Σ清掃対象面積。清掃すべきだった床面積の合計(㎡)。
  • 戻り値: 清掃完了率(%)の生値。対象面積が0なら null。
  • 平易な言い換え: 「掃くべき床が合計1000㎡あって、実際に掃けたのが870㎡なら87%」。

★引数名が cleanedArea / targetArea(面積)であることが D-021 の実装表現です★: もし時間ベースなら cleanedMinutes / targetMinutes になります。引数名が計算方法の宣言になっているので、時間を渡そうとした人が名前で気づきます。35行目のコメントも「(★面積ベース★)」と星印付きで強調しています。

★この関数の出力が judgeGrade() の入力になります★ — ここが純粋層の2つの系統(KPI計算と◎○△×判定)の接続点です。ただし現状その接続は本番コードには存在しません(両方とも本番未使用のため)。

(b) 分岐なし・1行。rate(cleanedArea, targetArea)。

(b') 境界値(テスト45〜60行目)— ★丸めとの関係を検証するテストが入っています★

    expect(completionRate(870, 1000)).toBe(87);
    expect(completionRate(1000, 1000)).toBe(100);
    const r = completionRate(904, 1000);
    expect(displayRate(r)).toBe("90%");
    expect(completionRate(0, 0)).toBeNull();
cleanedArea targetArea 戻り値 displayRate() judgeGrade()
870 1000 87 "87%" ○
1000 1000 100 "100%" ◎
904 1000 90.4 "90%" ◎
0 0 null "-" ×(未実施扱い)

★3行目(904 / 1000)が §4.3 の丸め問題そのものです★: 面積 904㎡ ÷ 1000㎡ = 90.4%。表示は "90%"、判定は ◎。テスト53〜55行目が completionRate() と displayRate() をつなげて検証しており、「計算 → 表示」と「計算 → 判定」の両経路が正しいことを1つのテストで示しています。

平易な言い換え: 「904㎡掃けた」という事実から 90.4% が出て、画面には90%と出るが評価は◎。もし completionRate() が内部で丸めて 90 を返していたら、評価が ○ に落ちます。戻り値を生値にしている理由がこの1ケースに凝縮されています。

(c) 35行目のコメント + CLAUDE.md §4.2(110行目)+ D-021(93〜95行目、前掲)。

(d) ★まだ本番コードから呼ばれていません。★ 出現は lib\judge\kpi.ts 36行目(宣言)と lib\judge\__tests__\kpi.test.ts 3, 45, 47, 48, 53, 59行目(テスト)のみ。

★注意: 名前の衝突★ completionRate は3つの異なるものの名前として使われています。

  1. この関数(lib\judge\kpi.ts 36行目)
  2. AdviceInput のプロパティ(lib\judge\advice.ts 33行目 completionRate: number | null)
  3. CleanLogItem のプロパティ(lib\types\index.ts 65行目、snake_case で completion_rate)
  4. KpiValue のフィールド名(lib\types\index.ts 146行目、ReportKpi 180行目)

lib\judge\advice.ts は judgeGrade を import していますが、completionRate 関数は import していないので衝突は起きていません。ただし lib/judge/index.ts が両方を再エクスポートしているため、import { completionRate } from "@/lib/judge" と書くとこの関数が取れます。混乱の余地がある命名です(事実の指摘)。

errorRate(エラー発生率)

/** エラー発生率 = エラー発生回数 ÷ 清掃実施回数(分母は「予定」ではなく「実施」) */
export function errorRate(errors: number, executed: number): number | null {
  return rate(errors, executed);
}

(43〜46行目)

(a)

  • errors: エラー発生回数(ハンドルアップ、オフライン、ブラシ異常など。ErrorType の7分類、lib\types\index.ts 162〜169行目)。
  • executed: 清掃実施回数。
  • 戻り値: エラー発生率(%)の生値。実施0回なら null。

★43行目のコメント「(分母は「予定」ではなく「実施」)」が最重要の注記です★

平易な言い換え(なぜ「実施」なのか): エラーは「清掃をやったときに起きるもの」です。予定20回のうち5回しか実施せず、その5回で2回エラーが出た場合:

分母の選び方 計算 結果 読み方
実施回数(正しい) 2 ÷ 5 40% 「動かした2.5回に1回エラーが出る」= 機体の信頼性
予定回数(誤り) 2 ÷ 20 10% 「あまりエラーが出ていない優良機体」に見える

予定を分母にすると、「そもそも動いていない機体」がエラー率の低い優良機体に見えてしまいます。 4つのKPIのうち3つ(稼働率・出発成功率・完了率)は「予定」または「対象面積」が分母ですが、エラー発生率だけ分母が違うので、間違えやすい箇所としてコメントで明示されています。

CLAUDE.md §4.2(111行目)「エラー発生率 = エラー発生回数 ÷ 清掃実施回数 × 100」が根拠です。

(b) 分岐なし・1行。rate(errors, executed)。

(b') 境界値(テスト63〜71行目)

    expect(errorRate(9, 90)).toBe(10);
    expect(errorRate(0, 0)).toBeNull();
errors executed 結果
9 90 10
0 0 null

★errorRate(0, 0) の null の意味が他と少し違います★: 「清掃を1回も実施していないので、エラー率という概念が存在しない」。0% と表示すると「エラーゼロの優良施設」に見えます。平易な言い換え: 走っていない車の故障率は0%ではなく「測定不能」。

(c) 43行目のコメント。CLAUDE.md §4.2(111行目)。

(d) ★まだ本番コードから呼ばれていません。★ 出現は lib\judge\kpi.ts 44行目(宣言)と lib\judge\__tests__\kpi.test.ts 5, 63, 66, 70行目(テスト)のみ。

toKpiValue

/**
 * 前期比つきのKPI値を組み立てる。
 *
 * 前期データがない(新規施設など)場合、prev と diff は null になり、
 * 画面では▲▼を表示しません。
 */
export function toKpiValue(value: number, prev: number | null): KpiValue {
  return {
    value,
    prev,
    diff: prev === null ? null : value - prev,
  };
}

(48〜60行目)

(a) 引数と戻り値の業務上の意味

  • 引数 value: 今期のKPI値(生値)。number(null 不可)であることに注目。
  • 引数 prev: 前期のKPI値。前期データがなければ null(新規契約した施設、前期はまだ集計されていない等)。
  • 戻り値 KpiValue: { value, prev, diff } の3点セット(lib\types\index.ts 130〜137行目)。
  • 業務上の意味: レポート画面のKPIカードに「稼働率 97.2%(前期比 ▲2.3)」と出すためのデータ。

平易な言い換え: 「今期の数字」「前期の数字」「その差」を1つの箱にまとめる。画面は差の符号を見て赤い▼か緑の▲を出す。

(b) 中で何をしているか — 分岐1本(三項演算子)

    diff: prev === null ? null : value - prev,

専門用語: 三項演算子(conditional operator) 条件 ? 真のとき : 偽のとき。平易な言い換え「if を1行で書く記法」。以下と同じ意味です。

let diff;
if (prev === null) { diff = null; } else { diff = value - prev; }
# 条件 真のとき 偽のとき
1 prev === null diff = null diff = value - prev
  • value と prev はそのまま入れます(加工しません)。
  • diff だけ計算します。

★prev が null なら diff も必ず null★: 平易な言い換え「比べる相手がいなければ、差も存在しない」。もし null を 0 として扱うと、diff = value - 0 = value となり、新規施設の稼働率97%が「前期比 +97」という異常な表示になります。

(b') 境界値の全網羅(テスト74〜91行目)

    const v = toKpiValue(91.2, 89.5);
    expect(toKpiValue(85.0, 90.0).diff).toBe(-5);
    const v = toKpiValue(86.0, null);
value prev diff 意味
91.2 89.5 1.7000000000000028(後述) 改善
85.0 90.0 -5 悪化(マイナスになる)
86.0 null null 前期データなし → ▲▼を出さない

★91.2 - 89.5 について(浮動小数点数の注意)★: JavaScript で 91.2 - 89.5 を計算すると、厳密には 1.7000000000000028 になります(1.7 ではない)。平易な言い換え: コンピュータは小数を2進数で近似して持つので、10進数の小数を引き算すると末尾に微小な誤差が出ます。

テスト(lib\judge\__tests__\kpi.test.ts 75〜80行目)は、この誤差を承知した書き方になっています。

    const v = toKpiValue(91.2, 89.5);
    expect(v.value).toBe(91.2);
    expect(v.prev).toBe(89.5);
    expect(v.diff).toBeCloseTo(1.7, 5);

value と prev は toBe(厳密一致)、diff だけ toBeCloseTo(1.7, 5)(小数第5位まで一致でよい)。value と prev は加工せず入れるだけなので厳密に一致しますが、diff は引き算をするので誤差が出る — テストの書き方が実装の性質を正しく反映しています。一方 83行目の toKpiValue(85.0, 90.0).diff は toBe(-5)(厳密一致)で書けています。85 - 90 は誤差が出ない組み合わせだからです。

表示時に displayRate()(Math.round)を通せば "2%" になるので、画面上の問題は起きません。

★value が null を受け付けない設計★: toKpiValue(value: number, ...) なので、completionRate() の戻り値(number | null)をそのまま渡すと型エラーになります。

// これは型チェックで落ちる
toKpiValue(completionRate(0, 0), null);   // 左が number | null

平易な言い換え: 「今期の値が計算不能(分母0)だった場合、KPIカードをどう出すか」をこの関数は決めていません。呼び出し側が null チェックしてから渡す必要があります。まだ呼び出し元がないので、この扱いは未決定のままです(事実の指摘。意図的な設計かは不明)。

(c) なぜそう書いてあるか

49〜53行目のコメント(引用):

 * 前期比つきのKPI値を組み立てる。
 *
 * 前期データがない(新規施設など)場合、prev と diff は null になり、
 * 画面では▲▼を表示しません。

「画面では▲▼を表示しません」まで書いてあるのが重要です。null を返す判断が、画面の見え方とセットで記録されています。lib\types\index.ts 133〜136行目のコメントも同じ内容です。

CLAUDE.md §4.7(172行目)「進行中の期間(当週・当月・当年)はサマリテーブルに存在しない。 詳細テーブルからその場集計すること」と D-023(101〜104行目)から、「前期データがない」状況が実際に起こることが分かります。

(d) 呼ばれている場所

★まだ本番コードから呼ばれていません。★

出現箇所 行 種別
lib\judge\kpi.ts(宣言) 54 定義
lib\judge\__tests__\kpi.test.ts 9, 74, 76, 83, 87 テスト

judgeFacilityStatus

/**
 * 施設別パフォーマンスの 良好 / 注意 / 要改善
 *
 * 算出基準はTeamWorks確認事項の回答待ちのため、暫定で稼働率ベース。
 * 閾値は `lib/config` の FACILITY_STATUS_THRESHOLDS から読む。
 */
export function judgeFacilityStatus(operationRateValue: number): FacilityStatus {
  if (operationRateValue >= FACILITY_STATUS_THRESHOLDS.good) return "good";
  if (operationRateValue >= FACILITY_STATUS_THRESHOLDS.warning) return "warning";
  return "bad";
}

(62〜72行目)

(a) 引数と戻り値の業務上の意味

  • 引数 operationRateValue: 稼働率(%)の生値。operationRate() の出力を想定。
  • 戻り値 FacilityStatus: "good"(良好)/ "warning"(注意)/ "bad"(要改善)の3段階。
  • 業務上の意味: レポート画面の「施設別パフォーマンス」一覧で、各施設に付ける3色のラベル。lib\types\index.ts 141〜150行目の FacilityPerformance.status に入ります。

引数名が operationRateValue(operationRate ではない): 平易な言い換え「同じファイルに operationRate という関数があるので、引数名にそのまま使うと名前がぶつかる。だから Value を付けて避けている」。技術的には引数がローカルスコープで関数を隠すだけですが、関数内で operationRate() を呼べなくなるので回避しています(この読み取りは筆者の解釈。命名の事実は明白)。

(b) 中で何をしているか — 分岐3本

★しきい値をどこから読んでいるか(指定された問い): 1行目 import { FACILITY_STATUS_THRESHOLDS } from "@/lib/config"; で lib/config/index.ts から読んでいます。具体的には lib\config\index.ts 46〜51行目。

/** 施設別パフォーマンスの良好/注意/要改善の境界(暫定値) */
export const FACILITY_STATUS_THRESHOLDS = {
  /** 稼働率がこの値以上なら良好 */
  good: 90,
  /** 稼働率がこの値以上なら注意、未満は要改善 */
  warning: 75,
} as const;

GRADE_THRESHOLDS(90/70/10)とは別の定数です。 数字の 90 が両方に出てきますが、意味が違います。

定数 90 の意味 比較演算子
GRADE_THRESHOLDS.good = 90 完了率が 90 超なら ◎ >(超える)
FACILITY_STATUS_THRESHOLDS.good = 90 稼働率が 90 以上なら良好 >=(以上)

★演算子の向きが違います。★ judgeGrade は >、judgeFacilityStatus は >=。90.0 ちょうどのとき、完了率なら ○(下のランク)、稼働率なら "good"(上のランク)。 同じ数字なのに境界の扱いが逆です。

これは書き間違いではなく、lib\config\index.ts 48行目「稼働率がこの値以上なら良好」と、lib\config\index.ts 12行目「完了率がこの値を「超える」と ◎」がそれぞれ明示的に書き分けられています。テスト(lib\judge\__tests__\kpi.test.ts 95〜100行目)も judgeFacilityStatus(90.0) に // 境界(以上) とコメントを付けて "good" を期待しています。

分岐表:

# 行 条件 真のとき 偽のとき
1 69 operationRateValue >= 90(good) "good" を返して終了 #2 へ
2 70 operationRateValue >= 75(warning) "warning" を返して終了 #3 へ
3 71 (条件なし) — "bad" を返す

★judgeGrade と同じ「上から順に、厳しい条件を先に」構造★: >= は「以上」なので、96.3 は >= 90 にも >= 75 にも当てはまります。良好を先に判定しないと、すべてが "warning" になります。

★null を受け取らない★: 引数は number のみ。operationRate() の戻り値は number | null なので、そのまま渡すと型エラーになります。呼び出し側が「予定0件の施設の status をどうするか」を先に決める必要があります。FacilityPerformance.status(lib\types\index.ts 148行目)は FacilityStatus で null 不可なので、「予定0件の施設をどう表示するか」は未解決の設計課題です(事実の指摘。まだ呼び出し元がないため決める必要が生じていない)。

(b') 境界値の全網羅(テスト93〜101行目)

    expect(judgeFacilityStatus(96.3)).toBe("good");
    expect(judgeFacilityStatus(90.0)).toBe("good"); // 境界(以上)
    expect(judgeFacilityStatus(89.9)).toBe("warning");
    expect(judgeFacilityStatus(75.0)).toBe("warning"); // 境界(以上)
    expect(judgeFacilityStatus(74.9)).toBe("bad");
    expect(judgeFacilityStatus(68.0)).toBe("bad");
入力 #1 >= 90 #2 >= 75 結果
96.3 真 — good
90.0 真(90 >= 90) — good ← ★境界。judgeGrade とは逆に上へ★
89.9 偽 真 warning
75.0 偽 真(75 >= 75) warning ← ★境界。上へ★
74.9 偽 偽 bad
68.0 偽 偽 bad
0 偽 偽 bad

★judgeGrade との境界の向きの比較(混同注意)★

値 judgeGrade(完了率) judgeFacilityStatus(稼働率)
90.0 ○(◎ではない。境界は下へ) good(境界は上へ)
90.1 ◎ good
89.9 ○ warning

平易な言い換え: 完了率90%ちょうどは「◎ではなく○」だが、稼働率90%ちょうどは「良好」。同じ「90」でも、片方は厳しく、片方は甘い。 これは仕様の書き方(「超」と「以上」)の違いをそのままコードにした結果で、コード側の判断ではありません。読むときに最も混同しやすい箇所です。

(c) なぜそう書いてあるか — ★暫定であることの明示★

63〜67行目のコメント(引用):

 * 施設別パフォーマンスの 良好 / 注意 / 要改善
 *
 * 算出基準はTeamWorks確認事項の回答待ちのため、暫定で稼働率ベース。
 * 閾値は `lib/config` の FACILITY_STATUS_THRESHOLDS から読む。

★「TeamWorks確認事項の回答待ち」= 仕様が未確定である★。lib\config\index.ts 45行目も「(暫定値)」と書いています。

平易な言い換え: 「施設を良好/注意/要改善に分ける基準」は、まだ取引先(TeamWorks)に確認中。返事が来るまでは「稼働率だけで決める」暫定ルールで動かす。4つのKPIのうち1つしか見ていないので、「稼働率は高いがエラーだらけ」の施設が「良好」になる可能性があります。

これは CLAUDE.md §7(219行目)「❌ 仕様が不明な箇所を推測で実装すること(確認を求める)」に対する扱いとして、「推測で確定させず、暫定と明記して動くものを置く」というやり方です。禁止事項が禁じているのは「推測を仕様として実装すること」で、暫定であることを明示して置くのは別扱い、と読めます(この解釈は筆者のもの。コメントには「暫定」と書いてあるだけです)。

(d) 呼ばれている場所

★まだ本番コードから呼ばれていません。★

出現箇所 行 種別
lib\judge\kpi.ts(宣言) 68 定義
lib\judge\__tests__\kpi.test.ts 6, 93, 95, 96, 97, 98, 99, 100 テスト

レポート画面(F-04)の施設別パフォーマンス一覧が未実装のため、と読めます。


横断確認 1 — 禁止された import をしていないかの検証

CLAUDE.md §9.4(298行目)「判定関数は純粋関数にする(DBアクセスを混ぜない)」の遵守状況です。

検証方法

lib/judge/*.ts lib/config/*.ts lib/format/*.ts lib/types/*.ts の全9ファイルに対して、以下2つの grep を実行しました。

  1. 全 import 行の抽出: grep -n "^import\|^} from\|require(" <9ファイル>
  2. 禁止パターンの検索: grep -n "lib/logger\|lib/db\|prisma\|@prisma\|node:fs\|\"fs\"\|lib/settings\|lib/fixtures\|next/\|react" <9ファイル>

結果1: 純粋層の全 import 文(9行のみ・これが全部です)

lib/judge/advice.ts:1:import { ESCALATION_THRESHOLDS, GRADE_THRESHOLDS } from "@/lib/config";
lib/judge/advice.ts:2:import type { AdviceRuleId } from "@/lib/config/advice-rules";
lib/judge/advice.ts:3:import { judgeGrade } from "./grade";
lib/judge/escalation.ts:1:import { ESCALATION_THRESHOLDS } from "@/lib/config";
lib/judge/escalation.ts:2:import type { EscalationLevel } from "@/lib/types";
lib/judge/grade.ts:1:import { GRADE_THRESHOLDS } from "@/lib/config";
lib/judge/grade.ts:2:import type { Grade } from "@/lib/types";
lib/judge/kpi.ts:1:import { FACILITY_STATUS_THRESHOLDS } from "@/lib/config";
lib/judge/kpi.ts:2:import type { FacilityStatus, KpiValue } from "@/lib/types";

lib/judge/index.ts / lib/config/index.ts / lib/config/advice-rules.ts / lib/format/date.ts / lib/types/index.ts の5ファイルには import 文が1行もありません。(lib/judge/index.ts は export * from が4行あるだけ)

依存の内訳(9行の分類)

依存先 件数 種別
@/lib/config(定数) 4 値の import(実行時に必要)
@/lib/config/advice-rules(型のみ) 1 import type(実行時に消える)
@/lib/types(型のみ) 3 import type(実行時に消える)
./grade(同ディレクトリ内) 1 値の import

★実行時に実際に読み込まれるのは、lib/config/index.ts と lib/judge/grade.ts の2ファイルだけです。★ 型の import(4件)はコンパイル時に消えるので、lib/judge を使う側は「定数の入った小さなファイル」以外を引き込みません。

結果2: 禁止パターンの検索 — ★ヒット4件すべてコメント文★

ファイル:行 内容 種別
lib/judge/advice.ts:10 * 運用設定での上書き(\lib/settings/advice.ts`)から取得します。` コメント
lib/judge/index.ts:16 * \lib/config/advice-rules.ts`(既定値)と `lib/settings/advice.ts`(編集内容)を参照。` コメント
lib/config/advice-rules.ts:6 * 実行時は \lib/settings/advice.ts` の `loadAdviceRules()` を使ってください。` コメント
lib/types/index.ts:217 * DB接続時に \prismaSource` 側で実際の取得元に差し替える。` コメント

4件すべてが JSDoc コメント内での「ファイル名の言及」であり、import 文ではありません。

判定: ★違反なし★

禁止項目 9ファイル中の import 件数
lib/logger(ログ出力) 0件
lib/db(DB切替レイヤ) 0件
@prisma/client / prisma 0件
node:fs / "fs"(ファイル読み書き) 0件
lib/settings(設定ファイル読み込み) 0件
lib/fixtures(サンプルデータ) 0件
next/*(フレームワーク) 0件
react 0件
外部ライブラリ全般(date-fns / lodash 等) 0件

依存の向きも正しい方向を保っています(矢印は「が依存する」):

lib/types  ←  lib/judge/{grade,escalation,kpi}      (型のみ)
lib/config ←  lib/judge/{grade,escalation,kpi,advice}(定数)
lib/judge/grade ← lib/judge/advice                   (同層内)
lib/format ← (誰にも依存しない)
lib/types  ← (誰にも依存しない)
lib/config ← (誰にも依存しない)

そして純粋層の「外」から純粋層へ:
lib/judge  ←  app/api/*, components/features/*, lib/fixtures/*
lib/config ←  app/api/*, components/features/*, lib/{clean-logs,ds-alerts,ds-devices,prechecks,settings,fixtures}/*
lib/format ←  app/api/*, components/features/*

逆向きの矢印(純粋層が外を参照する)は1本もありません。 循環依存もありません。

★1点だけ補足: lib/format/date.ts の todayJst() は「純粋関数」ではありません★(25行目 new Date() が現在時刻を読む)。ただしこれは import の規約違反ではなく、CLAUDE.md §9.4 の純粋関数規約は lib/judge/ に対するものです。lib/judge 配下の10関数はすべて純粋です(同じ入力で必ず同じ出力)。


横断確認 2 — まだ呼び出し元が無い関数の一覧

「呼び出し元」は app/ components/ lib/(__tests__ を除く) からの呼び出しを指します。テストからの呼び出しは「呼び出し元あり」に数えていません。

本番コードから呼ばれていない関数 — 9件

# 関数 宣言箇所 テストの有無 呼ばれていない理由(コード上の根拠)
1 isProblem lib\judge\grade.ts:42 あり(grade.test.ts 55〜69行目) ScheduleDayResult[] を組み立てる処理が未実装。escalation.ts 13〜15行目が「データ取得側の責任」と宣言しており、その取得側がまだ無い
2 countConsecutiveProblemDays lib\judge\escalation.ts:32 あり(escalation.test.ts 17〜37行目) 同上。judgeEscalation() 経由でのみ使う設計で、その judgeEscalation() 自体が未使用
3 judgeEscalation lib\judge\escalation.ts:62 あり(escalation.test.ts 54〜95行目) 同上。本番は下位の judgeEscalationLevel() に既存の日数を直接渡している(advice\route.ts:98)
4 operationRate lib\judge\kpi.ts:26 あり(kpi.test.ts 21〜32行目) レポート画面(F-04)のKPI集計が未実装
5 launchRate lib\judge\kpi.ts:31 あり(kpi.test.ts 34〜42行目) 同上
6 completionRate lib\judge\kpi.ts:36 あり(kpi.test.ts 45〜60行目) 同上
7 errorRate lib\judge\kpi.ts:44 あり(kpi.test.ts 63〜71行目) 同上。加えてエラーテーブルの扱いは D-048 で M3 送り(lib\types\index.ts 325行目「M2 では返さない(D-048。M3 でエラーテーブルを扱うときに実装する)」)
8 toKpiValue lib\judge\kpi.ts:54 あり(kpi.test.ts 74〜91行目) 同上
9 judgeFacilityStatus lib\judge\kpi.ts:68 あり(kpi.test.ts 93〜101行目) 同上。加えて 65行目「算出基準はTeamWorks確認事項の回答待ちのため、暫定で稼働率ベース」

★9件すべてにテストが存在します。★ つまり「使われていないが、動作は保証されている」状態です。D-041(「入力→出力の期待値表を先に作りテストで固定する」)の順序どおり、テストが実装より先、実装が利用より先に並んでいます。

内訳の特徴: lib/judge/kpi.ts は6つの export のうち6つすべてが未使用(非公開の rate() は内部で使用)。ファイル単位で丸ごと待機状態です。

本番コードから呼ばれていない定数 — 2件

# 定数 宣言箇所 根拠
1 LAUNCH_GRACE_MINUTES lib\config\index.ts:31 清掃失敗判定(予定時刻+30分の経過チェック、CLAUDE.md §4.4)が未実装。宣言箇所以外に grep のヒットなし
2 PRECHECK_MINUTES_BEFORE lib\config\index.ts:34 事前チェックの実行スケジューリングが未実装。宣言箇所以外に grep のヒットなし

本番コードから参照されていない型 — 7件

lib\types\index.ts 自身を除いた grep で外部参照が 0 件だった型(ただし同ファイル内で他の型の一部として使われているものを含みます):

型 行 状況
Meta 13 ListResponse / SummaryListResponse の中で使用(外部からは名前を直接参照しない)
AdviceItem 76 CleanLogAdviceResponse.advices の中で使用。advice\route.ts は構造的に一致するオブジェクトを組み立てているが AdviceItem の名前は書いていない
PrecheckStatus 96 PrecheckItem.resolution_status で使用。外部は PrecheckStatusFilter を使う
ErrorType 162 ErrorBreakdownItem.type で使用
DsAlertStatus 201 DsAlert.resolution_status で使用
RobotSummary 230 Facility.robots で使用
FailureType 302 DashboardResponse.failure_breakdown[].type で使用。failure_breakdown 自体が M2 未実装(D-048)

7件すべて「同ファイル内で使われている」ので、死んだコードではありません。 外部から名前を直接 import する必要がなかっただけです。

定義済みだが出力されていないログイベント — 1件

ログイベント 型の宣言 状況
judge.grade(JudgeGradeLogEvent) lib\logger\events.ts:89(ユニオン登録は221行目) ★event: "judge.grade" を実際に出力しているコードが grep で0件★。judge.escalation(advice\route.ts:100)と judge.advice(advice\route.ts:65)は出力されている

judgeGrade() を本番のAPIルートから呼ぶ経路が無い(現状は lib\fixtures\clean-logs.ts:57 で fixtures 生成時に呼ぶだけ)ため、と読めます。平易な言い換え: 「◎○△×を判定したときのログを残す準備」はできているが、判定する場所がまだ fixtures の中だけなので出番が来ていない。


横断確認 3 — 境界値の挙動と CLAUDE.md の記述の食い違い

★この節が本調査の最重要成果です。★ 4件の食い違いを検出しました。実装(コード)とテストは全4件で一致しており、食い違っているのは説明文(コメント・仕様書の表)側です。

★食い違い1(最重要): lib/config/index.ts 16行目のコメントが実装と逆★

該当箇所 — lib\config\index.ts 16行目:

  /** 完了率がこの値「未満」は ×(10.0 は × になる) */
  poor: 10,
記述/挙動
このコメントの主張 完了率 10.0 は ×
実装の挙動(lib\judge\grade.ts:26 if (rate < GRADE_THRESHOLDS.poor) return "×";) 10 < 10 は偽なので × にならず、次の行(27行目)で △
テストの期待値(lib\judge\__tests__\grade.test.ts:26) { name: "△/×の境界(10%未満が×なので10.0は△)", rate: 10.0, grade: "△", display: "10%" } = △
docs\decisions.md D-021 前後の D-020b(84行目) 「完了率 90.0 は ○、70.0 は △、10.0 は △(×ではない)」= △
lib\fixtures\clean-logs.ts 75行目のコメント表 「10.0 △ 10% ← 「10%未満」が×なので10.0は△(CLAUDE.md 4.1 が正)」= △
CLAUDE.md §4.1(103行目) 「× \ 完了率 10%未満、または未実施」→ 10.0 は「未満」でないので × ではない = △(間接的だが整合)

★5つの情報源のうち4つが「△」、lib/config/index.ts 16行目のコメントだけが「×」。★

同じコメントブロック内の他2行は正しく書けています(lib\config\index.ts 12行目・14行目):

  /** 完了率がこの値を「超える」と ◎(90.0 は ○ になる) */
  excellent: 90,
  /** 完了率がこの値を「超える」と ○(70.0 は △ になる) */
  good: 70,

12行目「90.0 は ○ になる」= 1つ上のランクに行かず下に落ちる、14行目「70.0 は △ になる」= 同じく下に落ちる。ところが 16行目だけ「10.0 は × になる」= 下に落ちると書いてある。3行を並べると規則的に見えてしまうのが厄介です。

なぜ 16行目だけ誤りなのか: D-020b(87〜88行目)が規則を「境界値そのものは下のランクに落ちる」と説明していますが、正確には「境界値は上のランクの条件を満たさない」です。

境界 上のランクの条件 境界値ちょうどは条件を満たすか 結果
90 ◎ = 「90超」 満たさない 1つ下の ○
70 ○ = 「70超」 満たさない 1つ下の △
10 △ = 「×でないもの」(× = 「10未満」) — 10 は「10未満」でないので × ではない = △

平易な言い換え: 90 と 70 は「上のランクに入る条件」を数字で書いてあるので、ちょうどの値は下に落ちます。しかし 10 は「下のランク(×)に入る条件」を書いてあるので、ちょうどの値は下に落ちず上に残ります。3つ並べると「境界値は下に落ちる」に見えますが、10 だけは逆向きなのです。

動作への影響: ありません(コメントは実行されません)。

★危険性★: このコメントを信じて実装を「直す」と、

  1. lib\judge\__tests__\grade.test.ts 26行目のテストが落ちる
  2. lib\judge\__tests__\advice.test.ts 67行目(completionRate: 10.0 で R2 が出ないことを確認)が落ちる
  3. D-020b の合意([確定])を破る
  4. lib\fixtures\clean-logs.ts 75行目と 87行目のサンプルデータの意味が変わる

推奨: 16行目のコメントを「完了率がこの値『未満』は ×(10.0 は △ になる。D-020b)」等に修正。コードとテストは変更不要。

食い違い2: CLAUDE.md §4.1 の「△ = 10〜69%」が 69.0〜70.0 を取りこぼしている

該当箇所 — CLAUDE.md §4.1(102行目)と、その写しである lib\judge\grade.ts 15行目、lib\config\advice-rules.ts 101行目、lib\judge\advice.ts 19行目・72行目:

| △ | 完了率 10〜69% |
記述/挙動
この表の主張 △ の範囲は 10〜69%
実装の挙動 △ の範囲は 10 以上 70 以下(judgeGrade #5 分岐に落ちる条件)
取りこぼし 69.0 超〜70.0(例: 69.5、70.0)が表の文言からは読み取れない

具体例: 完了率 69.5% の行。

  • 表の読み方: 「10〜69%」に入らない。「70%超」でもない。→ どのランクか分からない
  • 実装: 69.5 > 90偽 → 69.5 > 70偽 → 69.5 < 10偽 → △

なぜこの書き方なのか(推測): 表示が整数に丸められる(CLAUDE.md §4.3)ため、「表示上の 10% から 69% までが △」という意味で書かれたと読めます。表示 70% は「69.5〜70.4」を含むので、△(69.5〜70.0)と ○(70.1〜70.4)が混在します。

動作への影響: ありません。実装は D-020b の規則で一貫しています。

★ただし B1 ルールの説明文に実害の芽があります★: lib\config\advice-rules.ts 101行目の condition: "完了率が10〜69%(△判定)" は、運用設定画面に表示される文章(advice-rules.ts 30〜31行目「発火条件の説明。画面の設定編集時に「どんなときに出るか」を示すため(編集不可)」)。つまりこの不正確な表記が画面上でユーザーに見えます。完了率 70.0% の行に B1 が出たとき、設定画面の説明を読んだ人は「10〜69%と書いてあるのに70%で出た」と混乱します。

★テストのカバー漏れも1点★: lib\judge\__tests__\advice.test.ts 120〜125行目は B1 について 45(出る)/ 69.9(出る)/ 70.1(出ない)をテストしていますが、70.0 をテストしていません。grade.test.ts 24行目は judgeGrade(70.0) = △ をテストしているので、B1 が 70.0 で出ることは論理的に確定していますが、B1 の期待値表としては境界1点が未固定です。CLAUDE.md §9.4(294行目)は完了率の境界に 70.0 を含めるよう指定しています。

推奨: 表記を「10%以上70%以下」等に統一するか、少なくとも advice-rules.ts 101行目の condition を修正。加えて advice.test.ts に completionRate: 70.0 で B1 が出るケースを追加。

食い違い3: CLAUDE.md §4.5 の R3 の条件に「出発失敗」が書かれていない

該当箇所 — CLAUDE.md §4.5(62〜63行目):

| R3 | 同一施設で2日以上連続△以下 | 現場責任者に状況をヒアリング |
| R4 | 同一施設で3日以上連続(出発失敗 or △以下) | DFAオンサイト訪問を検討 |
記述/挙動
CLAUDE.md の主張 R3 は「△以下」のみ、R4 は「出発失敗 or △以下」。R3 と R4 で対象が違う
実装の挙動(lib\judge\advice.ts 63行目・68行目) 両方とも同じ consecutiveProblemDays 1つだけを見ている。△以下と出発失敗を区別する仕組みが存在しない
lib\config\advice-rules.ts の condition(75行目・89行目) R3 = 「同一施設で2日以上連続して△以下または出発失敗」、R4 = 「同一施設で3日以上連続して△以下または出発失敗」。両方に「または出発失敗」がある
lib\judge\advice.ts 17行目のコメント表 ` R3 同一施設で2日以上連続△以下 `(CLAUDE.md の写し。「出発失敗」なし)
lib\types\index.ts 68行目 「/ △以下または出発失敗の連続日数(スケジュール存在日ベース) */」= consecutive_problem_days は両方を含む**
lib\judge\grade.ts 41行目 「/ 「問題あり」= △以下、または出発失敗 */」= isProblem は両方を含む**

★結論: consecutive_problem_days は定義上「△以下 or 出発失敗」の連続日数なので、R3 も自動的に出発失敗を含みます。★ 実装で R3 を「△以下のみ」に限定するには、AdviceInput に「△以下だけの連続日数」という別の項目が必要ですが、存在しません。

つまり:

  • advice-rules.ts 75行目の condition(「または出発失敗」あり)が実装に忠実
  • CLAUDE.md 62行目と advice.ts 17行目(「または出発失敗」なし)が実装と食い違う

動作への影響: あります。出発失敗が2日連続した施設に R3 が出ます。 CLAUDE.md 62行目を厳密に読むと、その場合 R3 は出るべきではありません。

具体例: 2日連続で出発失敗(両日 departed: false, completionRate: null, consecutiveProblemDays: 2):

  • 実装: R1 なし(2 <= 1 偽)、R2 なし(null)、R3 あり、R4 なし、B1 なし → ["R3"]
  • CLAUDE.md 62行目を厳密に読むと: 「△以下」が2日連続していないので R3 は出ないはず。R4 も3日未満なので出ない。アドバイスが1枚も出ない

どちらが望ましいか: 実装(R3 が出る)のほうが業務上妥当と思われます。2日連続で出発できていない施設に「アドバイスなし」を返すのは不親切です。したがって CLAUDE.md 62行目の記述が古い/不正確と読めますが、これは仕様側の確認が必要な事項です(TeamWorks との合意事項なので、勝手にどちらかを正としてはいけません。CLAUDE.md §4 の冒頭 92〜93行目「ここに書かれた数値・ロジックは TeamWorks との合意事項です。変更したくなった場合は勝手に変えず、docs/decisions.md を確認し、必要なら合意を取り直してください。」)。この段落の「望ましさ」の評価は筆者の推測です。

推奨: CLAUDE.md §4.5 の R3 の条件を「同一施設で2日以上連続(出発失敗 or △以下)」に揃えるか、あるいは仕様として R3 を △以下限定にするなら AdviceInput に項目を追加する。まず仕様確認。

食い違い4: lib/judge/advice.ts 24行目の指示と実際の呼び出しが違う

該当箇所 — lib\judge\advice.ts 23〜24行目:

 * ★「連続」はスケジュール存在日の連続★(D-026)
 *   `consecutiveProblemDays` は `judgeEscalation()` が算出した値を渡してください。
記述/挙動
コメントの指示 judgeEscalation() の算出値を渡すこと
実際の呼び出し(app\api\clean-logs\[id]\advice\route.ts 53〜59行目) consecutiveProblemDays: log.consecutive_problem_days(DB/fixtures の既存値)を渡している
judgeEscalation() の使用状況 ★本番コードから1度も呼ばれていない★(横断確認2 参照)
lib\logger\events.ts 106行目 「judgeEscalation() 呼び出し直後。」とコメントしているが、実際に judge.escalation を出しているのは judgeEscalationLevel() の直後(advice\route.ts 98〜110行目)

動作への影響: 現時点ではありません。lib\fixtures\clean-logs.ts の consecutive_problem_days が「スケジュール存在日ベースで正しく数えられた値」であるという前提でサンプルデータが作られているためです(lib\fixtures\clean-logs.ts 102行目「スケジュール非存在日を挟む連続 3 L3 R4 ← カレンダー上は5日」というケースも用意されています)。

★DB接続時のリスク★: D-026 の「スケジュール存在日で数える」は、DBの日別データから consecutive_problem_days を計算する処理を書く時点で初めて実装されます。その処理が countConsecutiveProblemDays() を使わず自前で数えると、escalation.test.ts 73〜82行目が「間違った渡し方をすると L1 になる」とテストで固定した罠にそのまま落ちます。平易な言い換え: 正しい部品(judgeEscalation())は完成してテスト済みなのに、まだ誰も使っていない。使わずに書き直されると、テストが守っているはずの規則がすり抜けます。

推奨: DB接続時に judgeEscalation()(または countConsecutiveProblemDays())を実際に通す経路を作る。作らないなら 24行目のコメントを実態に合わせる。

食い違いなしを確認した項目

検証対象 結果
CLAUDE.md §9.4(294行目)の完了率境界8値(90.0/90.1/70.0/70.1/10.0/9.9/0/null) 全8値がテストに存在し、実装と一致(横断確認4の表参照)
CLAUDE.md §9.4(296行目)の丸め2ケース(90.4→◎、89.5→○) 両方一致(grade.test.ts 22行目・38〜41行目)
CLAUDE.md §9.4(295行目)の連続日数(1/2/3日 + 非存在日を挟む) 全4ケースがテストに存在(escalation.test.ts 41〜47行目・54〜83行目)
CLAUDE.md §4.1 の ◎(90%超)・○(70%超) 実装の > と一致
CLAUDE.md §4.2 の KPI 4式 kpi.ts の4関数の引数名・順序と一致(面積ベース含む)
CLAUDE.md §4.5 の L1/L2/L3 の日数条件 judgeEscalationLevel の >= と一致
CLAUDE.md §7(215行目)の閾値ハードコード禁止 判定コード内に 90/70/10/2/3 のリテラルは1つもない(すべて GRADE_THRESHOLDS / ESCALATION_THRESHOLDS / FACILITY_STATUS_THRESHOLDS 経由)
CLAUDE.md §9.3(283行目)の any 禁止 9ファイル中に any の使用なし(grep で確認)
lib\config\index.ts 48行目・50行目の「以上」表記と judgeFacilityStatus の >= 一致(GRADE_THRESHOLDS の「超」と混同しないよう書き分けられている)

横断確認 4 — judgeGrade 境界値の最終確認表

指定された8値 + テストが持つ2値の、実装トレース結果です。判定は lib\judge\grade.ts 22〜28行目、しきい値は lib\config\index.ts 11〜18行目。

入力 23行目 === null 24行目 > 90 25行目 > 70 26行目 < 10 判定 displayRate() CLAUDE.md §9.4 指定 テスト(grade.test.ts) 一致
90.4 偽 真 — — ◎ "90%" ○(296行目「90.4 → 表示90だが判定は◎」) 38〜41行目 ✅
90.1 偽 真 — — ◎ "90%" ○(294行目) 20行目 ✅
90.0 偽 偽 真 — ○ "90%" ○(294行目) 21行目 ✅
89.5 偽 偽 真 — ○ "90%" ○(296行目「89.5 → 表示90だが判定は○」) 22行目 ✅
70.1 偽 偽 真 — ○ "70%" ○(294行目) 23行目 ✅
70.0 偽 偽 偽 偽 △ "70%" ○(294行目) 24行目 ✅
10.1 偽 偽 偽 偽 △ "10%" 指定外 25行目 ✅
10.0 偽 偽 偽 偽 △ "10%" ○(294行目) 26行目 ✅ ※食い違い1参照
9.9 偽 偽 偽 真 × "10%" ○(294行目) 27行目 ✅
0 偽 偽 偽 真 × "0%" ○(294行目) 28行目 ✅
null 真 — — — × "-" ○(294行目) 29行目 ✅

判定の分布: ◎ = 90超、○ = 70超〜90以下、△ = 10以上〜70以下、× = 10未満 または null。隙間も重複もありません(上下から挟む書き方の帰結)。

表示が同じで判定が違う組み合わせ(画面で並ぶと混乱しやすい):

表示 ありうる判定 該当する生値の範囲
"90%" ◎ と ○ の両方 ◎ = 90.0超〜90.4、○ = 89.5〜90.0
"70%" ○ と △ の両方 ○ = 70.0超〜70.4、△ = 69.5〜70.0
"10%" △ と × の両方 △ = 10.0〜10.4、× = 9.5〜9.9

平易な言い換え: 画面に「90% ◎」と「90% ○」が並ぶことがあり、それは正しい。表示は四捨五入した整数、判定は小数のままの値で決めているからです。この3行が CLAUDE.md §4.3 の見出し「★事故りやすい★」の理由そのものです。


横断確認 5 — 純粋層の全体像(1枚のまとめ)

ファイルと関数の対応

ファイル 関数(export) 関数(非公開) 型(export) 定数(export)
lib\types\index.ts 0 0 38 0
lib\config\index.ts 0 0 0 8
lib\config\advice-rules.ts 0 0 3 1
lib\format\date.ts 4 0 0 0
lib\judge\index.ts 0(4ファイルを再エクスポート) 0 0 0
lib\judge\grade.ts 3 0 0 0
lib\judge\escalation.ts 3 0 1 0
lib\judge\advice.ts 2 0 1 0
lib\judge\kpi.ts 6 1(rate) 0 0
合計 18 1 43 9

3つの判定関数の構造比較(読むときの混同防止)

judgeGrade judgeEscalationLevel matchAdviceRules judgeFacilityStatus
分岐の型 排他(1つだけ返す) 排他(1つだけ返す) 非排他(全部返す) 排他(1つだけ返す)
書き方 if ... return if ... return if ... push if ... return
順序の原則 厳しい方(◎)を先に 重い方(L3)を先に 順序は結果の並び順を決めるだけ 高い方(good)を先に
境界の演算子 > / <(超/未満) >=(以上) < と >= の混在 >=(以上)
境界値ちょうどの行き先 下のランク その日数のレベル — 上のランク
読むしきい値 GRADE_THRESHOLDS ESCALATION_THRESHOLDS 両方 FACILITY_STATUS_THRESHOLDS
null の扱い 引数で受け付け、× にする <= 0 を null にする null を明示的に弾く 受け付けない(型で拒否)

★覚えるべき1点★: 完了率の境界(90/70/10)は「超・未満」で境界値が下に落ち、稼働率の境界(90/75)は「以上」で境界値が上に残ります。 数字の 90 が両方に出てくるので、最も混同しやすい箇所です。

「無い」と「ゼロ」の区別 — 層全体で一貫した1つの方針

場所 null(無い) 0(ゼロ)
judgeGrade 清掃を実施していない → × 実施したが 0% → ×
kpi.ts の rate() 分母が0(母数なし) → 計算不能 実績0(母数はある) → 0%
displayRate "-" "0%"
KpiValue.prev / .diff 前期データなし → ▲▼を出さない 前期と同値 → 差0
judgeEscalationLevel 問題なし → バッジなし (0日は null に含まれる)
inclusiveDayCount 日付が不正 → 検査をスキップ (0日はありえない。最小1)
escalation_level 正常行 → レベルなし —
DashboardResponse.failure_breakdown プロパティ自体なし → 未実装 空配列 → 失敗0件

平易な言い換え(この層で最も学ぶ価値のある考え方): プログラムでは「何もない」を表す方法が2つあります(null と 0、undefined と空配列)。業務では「まだやっていない」と「やったが成果ゼロ」はまったく違う話なので、この2つを最後まで混ぜない。混ぜると、休業中の施設が「稼働率0%の要改善施設」として赤く表示され、未実装の機能が「失敗0件の良好な状態」として緑で表示されます。

コメントが担っている役割(この層の特徴)

純粋層9ファイル計864行のうち、コメントが占める割合が非常に高いのが特徴です(例: lib\judge\index.ts は21行中17行がコメント、lib\judge\kpi.ts は72行中約30行)。コメントは4種類に分類できます。

種類 例 何を守っているか
禁止の宣言 grade.ts 34行目「★この値で judgeGrade を呼ばないこと★」 型では防げない誤用
前提の宣言 escalation.ts 29行目「日付昇順(古い→新しい)で渡すこと」 型では表現できない配列の並び順
意図の宣言 advice.ts 67行目「R3と同時に出ることがある(段階的な対応を促すため意図的)」 「バグに見える正しい挙動」が直されないよう
根拠の参照 kpi.ts 4行目「(CLAUDE.md 4.2 / D-021)」 なぜこの式なのかを後から追えるよう

平易な言い換え: 「何をしているか」はコードを読めば分かるので、コメントは「やってはいけないこと」「渡す側が守るべき前提」「わざとこうしている」「誰と合意した仕様か」を書いている。コードを日本語に訳し直したコメント(// 90より大きければ◎を返す のような無意味なもの)は1つもありません。

この層が「変更に強い」理由 — 3つの分離

  1. しきい値の分離(lib/config): 判定コードには数字が1つも書かれていません(grade.ts 24〜26行目は GRADE_THRESHOLDS.excellent 等の名前だけ)。90を85に変えるなら1ファイル1行。
  2. 文言の分離(lib/config/advice-rules.ts + lib/settings/advice.ts): 判定は "R3" というラベルだけを返し、日本語を持ちません。文章を直しても判定テストは落ちません(D-036)。
  3. 記録の分離(lib/logger を import しない): 判定はログを出しません。出すのは呼び出し側(advice\route.ts 64〜83行目)。だから判定関数はテストで単体実行でき、本番では「入力・使ったしきい値・結果」の3点セットを残せます。

平易な言い換え: 「変わりやすいもの(数字・文章・記録の要件)」を、判定という「変わってはいけないもの」から全部外に出した。だから判定のテストは、運用の都合では絶対に壊れません。これが D-041 の「バイブコーディングで唯一妥協してはいけない箇所」を、実際に妥協せずに保つための構造です。