# 指示書F — F-04 レポート画面（M4）

## 実行状態

| 項目 | 値 |
|---|---|
| **状態** | **完了** |
| 作成 | 2026-09-03（作成時点の決定事項最終番号 **D-064**） |
| 実行環境 | Claude Code（Opusがサブエージェント（opus）に段階ごと委譲。段階0〜5をすべて実施） |
| 実行日 | 2026-09-07 |
| 実行コミット | `1ef4c33`(段階0/D-068) `234987e`(段階1) `daf3788`(段階2) `787f143`+`4c47412`(段階3) `7afe055`(段階4) `6ecfc2b`+`368e185`(段階5) |
| 取得した決定番号 | D-068 / D-069 |

> 実行後はこの表を更新してください。

> ★**この指示書を読んでいるあなたへ**★
> `.claude/commands/d-watch.md` に「実装は Cursor 側、Claude Code は指示書作成まで」とありますが、
> これは**2つの環境の役割分担**です。**Cursor の中で動いている Claude は実装担当**です。
> この指示書を渡されたなら、**調査や改訂ではなく実装を行ってください。**

---

## この指示書の位置づけ

**F-04 レポートは、未実装の画面のなかで最大のものです。** そして
**TeamWorks への確認待ち（B-α: D-Sec のテーブル不明 / B-β: 事前チェック時点の状態が無い）に
一切依存しません。** 回答を待たずに完成させられます。

材料はすでに揃っています。

| 材料 | 状態 |
|---|---|
| API契約 | `d-watch-api.yaml` に4本定義済み（`:408` / `:432` / `:475` / `:508`）※ただし段階0の修正が要る |
| 判定ロジック | `lib/judge/kpi.ts` 実装済み（4式すべて・分母0→null・丸めなし）＋テスト13グループ |
| サンプルデータ | `lib/fixtures/reports.ts` に7 export（**ただしどこからも参照されていない＝完全に未接続**） |
| 型 | `lib/types/index.ts:128-182` に6種 |
| 画面レイアウト | モック `d-watch-mock.jsx:654-828` |
| メニュー | `components/layout/AppShell.tsx:11` に `/reports` が**既にある**（変更不要） |
| 共通部品 | `StatCard` / `ProgressBar` は JSDoc に「F-04 で共用する」と**明記済み** |

**足りないもの**（この指示書で作るもの）:
`lib/reports/`（ディレクトリごと） / `app/api/reports/`（4ルート） /
`components/features/reports/` / 期間ヘルパー / DataSource の4メソッド / i18n の対訳。

---

## ★先に決まっていること（読み飛ばさないこと）★

**次の6点はすでに判断済みです。実装時に蒸し返さないでください。**

### 1. 期間指定は API 契約（`period` + `year` + `month` + `week`）に寄せる

**モックにある日付範囲の `<input type="date">` 2つ（`d-watch-mock.jsx:664` / `:666`）は作りません。**

- 理由: モックは日付範囲（`2026-03-01`〜`2026-03-27`）と週次/月次トグルの**両方**を持っており、
  「27日間の範囲を週次で見る」は意味が通りません。**モック側の設計ミス**と判断しました
- サマリテーブルは**週/月単位で集計済みの値**なので、任意の日付範囲を受け付けると
  将来 `prismaSource` でサマリを使えなくなります（毎回詳細テーブルから再集計する羽目になる）
- 画面は **「週次/月次トグル ＋ 期間ナビ」** にします:
  ```
  [週次][月次]        ‹  2026年3月 第4週  ›
  ```
- YAML の `Period` / `Year` / `Month` / `Week`（`:887-912`）を**そのまま**叩きます。変換ロジック不要

### 2. 当期（進行中の週/月）は「器だけ」作る

- 「要求された期間が当期かどうか」を判定する関数と、それによる分岐は**作ります**
- fixtures モードでは、当期なら `kpiCurrentPeriod`、確定期なら `kpiWeekly` / `kpiMonthly` を返すだけ
- **実際の集計ロジック（面積の積算・エラー分類）は書きません。** `prismaSource` 実装時に入れます
- これで `docs/spec.md` M4 の完了条件「サマリ参照と当期集計の切替」を形だけ満たします
- **画面に「当期」「集計中」「暫定値」等のバッジや注記を出さないこと。**
  `d-watch-api.yaml:23-24` が「この切替はAPI内部で吸収する」と明記しており、モックにも該当表示はありません

### 3. 年次（`yearly`）は F-04 の範囲外

`CLAUDE.md` §4.7 と D-023 は年次バッチを定義していますが、**モックの期間トグルは週次/月次の2つだけ**
（`d-watch-mock.jsx:681-686`）で、YAML の `Period` enum も `[weekly, monthly]` です。
**enum に `yearly` を追加しないこと。** 段階0で description にその旨を書きます。

### 4. グループ / 会社の `<select>` は画面に出さない（D-053）

モック `:668-673` にはありますが、**マスタ未整備のため機能しません。**
D-053 は「機能しない絞り込みUIを画面に出さない」と決めており、`DetailLogScreen` で削除済みの前例があります。
**API 側は `group_id` / `customer_id` を受け取って無視する**（他5ルートと同じ）。理由をコメントで残すこと。

### 5. KPI の表示は整数（D-020）

モックの4枚目は `4.2%` と小数を出していますが（`:688`）、これはハードコードのダミー値です。
**D-020「表示は小数第1位を四捨五入して整数」が正。** `displayRate()`（`lib/judge/grade.ts:36`）を使うこと。

- **前期比の差分（`diff`）も同じ規則で整数にする**（`▲2%` のように）
- ★**丸めた値で判定しないこと**★（`judgeFacilityStatus` には生値を渡す）

### 5b. 月跨ぎ週の帰属 ★★2026-09-07: ルール見直し中。段階1 に着手しないこと★★

> ★**緊急の注記（2026-09-07）**★
> TeamWorks から**この節と食い違うルール**の回答が来ました（D-068）。
> **先方は「週を月境界で分割する」**（`2026-08-30〜08-31` が8月第6週、`09-01〜09-05` が9月第1週）。
> **週次サマリを作るのは TeamWorks で、DFA はそれを読む**ため、**合わせる必要があります。**
> ★**`weekOfMonth()` / `periodRange()` / `shiftPeriod()` の実装（段階1）は、
> ルールが確定するまで着手しないでください。**★
> 段階0（YAML）と段階2以降（fixtures / DataSource / query / ルート / 画面）は影響を受けません。

### 旧: 月跨ぎ週の帰属は「その月の1日を含む週がその月の第1週」（★D-067。2026-09-07 時点では見直し中★）

`CLAUDE.md` §4.7 に**旧DFA案（週の開始日が属する年月）**が書かれていましたが、
**逆のルールに変更されました。** `CLAUDE.md` は修正済みです。

> **1日でも翌月が混ざれば、その週は翌月の第1週**

| 週 | 帰属 |
|---|---|
| `2026-08-23(日)〜08-29(土)` | 8月の週 |
| **`2026-08-30(日)〜09-05(土)`** | ★**9月 第1週**★ |
| `2026-09-06(日)〜09-12(土)` | 9月 第2週 |

**これで `weekOfMonth()` の実装が確定しました**（段階1）。
月次・年次は暦月・暦年の日単位で締める（週とは独立）点は変更ありません。

> ★**TeamWorks 側の週次サマリと帰属が一致しているかは未確認**★（第4回 W-1 で照会中）。
> 食い違っていた場合に備え、**帰属の判定を `weekOfMonth()` 1関数に閉じ込め、
> 他のコードがルールを知らない形にすること。**

### 6. 連続問題施設は「2日以上連続」で絞る

`docs/spec.md:58` は「連続問題施設（**L1〜L3**）」と書いていますが、
モック `:714` は `RANK.filter(f => f.cont >= 2)` で **L1 を除外**しており、
YAML の summary（`:478`）も「**2日以上連続**△以下 or 出発失敗」です。

**モックと YAML が一致しているので、2日以上（L2 / L3）が正。** spec.md の記述が誤りです。
**絞り込みは API 側で行うこと**（画面で絞らない）。`lib/fixtures/reports.ts` の L1 データ（`:226-233`）は
「絞り込みが効いていること」の確認用なので**消さないこと**。

---

## 着手前の確認

```
git status                 # クリーンであること
npm test                   # 16ファイル / 267テスト 全パス（これが基準値）
npx tsc --noEmit           # エラー0
```

`docs/decisions.md` の最終番号を確認すること。**2026-09-07 時点の最終は D-067**
（指示書D が D-062〜D-064、その後 TeamWorks 回答の反映などで D-065〜D-067 を取得）。
**この指示書で取る番号は D-068 から**です。★着手直前に必ず最終番号を確認すること★

---

## 段階0. `d-watch-api.yaml` を直す（★必ず最初にやること★）

**D-040「API契約はYAMLが先」。** 現状の YAML は `lib/types/index.ts` と3箇所で食い違っており、
このまま実装すると契約違反のコードができます。

### 0-1. `ContinuousProblem` を名前付きスキーマに切り出し、項目を揃える

現状 `:499-505` は**インラインの匿名スキーマ**で、`lib/types/index.ts:152-159` と食い違っています。

| 項目 | YAML 現状 | `lib/types` | 採用 |
|---|---|---|---|
| `facility_id` | **無し** | `number` | **追加する** |
| `facility_name` | string | `string` | そのまま |
| 連続日数 | **`consecutive_days`** | **`consecutive_problem_days`** | **`consecutive_problem_days` に統一** |
| `escalation_level` | enum `[L1,L2,L3]` | `EscalationLevel` | そのまま |
| `completion_rate` | number float | **無し** | **YAMLから削除**（下記） |
| `last_problem_on` | **無し** | `string` | **追加する** |
| `summary` | **無し** | `string` | **追加する** |

判断の根拠:

- **`consecutive_problem_days` が正。** YAML 自身が `CleanLogItem`（`:977`）と
  `FacilityPerformance`（`:1068`）でこの名前を使っており、`:504` だけが表記ゆれ
- **`facility_id` を足す。** 他の一覧（`FacilityPerformance:1058` / `DsDevice:1122` / `DsAlert:1152`）は
  全部持っています。行から施設へ遷移する導線に要ります
- **`completion_rate` は削除。** モック `:729` は「完了率 {n}%」を出していますが、
  `lib/fixtures/reports.ts` は `last_problem_on` + `summary` でデータを作っており、
  **モックの「完了率」より `summary`（「スケジュール存在日ベースで3日連続。カレンダー上は5日間」等）のほうが
  D-026 の説明として有用**です。画面は `summary` を出す形にします
  （モックからの逸脱になるので、**決定事項に記録すること**）

`components/schemas` に `ContinuousProblem` として切り出し、`:499-505` から `$ref` すること。

### 0-2. `ErrorBreakdownItem` を名前付きスキーマに切り出し、項目を揃える

現状 `:533-540` はインライン。`lib/types/index.ts:171-175` と食い違っています。

| 項目 | YAML 現状 | `lib/types` | 採用 |
|---|---|---|---|
| 種別キー | **`error_type`**: string、enum定義なし、example が**日本語** `"ハンドルアップ"` | **`type`**: `ErrorType`（英字 enum 7種） | **`type`（英字 enum）に統一** |
| `label` | **無し** | `string` | **追加する** |
| `count` | integer | `number` | そのまま |
| `rate` | number float「全エラー件数に対する割合(%)」 | **無し** | **YAMLに残し、`lib/types` に追加する** |

判断の根拠:

- **英字 enum + `label` が正。** YAML 自身の `/dashboard` の `failure_breakdown`（`:156-169`）が
  すでに `type`(英字enum) + `label` + `count` の3点セットを採用しています。
  **日本語文字列をキーにすると i18n・比較・DB保存のすべてで詰みます**
- enum の値は `lib/types/index.ts:162-169` のとおり:
  `handle_up` / `lost_location` / `offline` / `brush_error` / `emergency_stop` / `low_battery` / `other`
- **`rate` はサーバが返す。** YAML `:15-16` が「判定はサーバ側、フロントは表示のみ」を原則としており、
  モック `:766` も `{e.pct}%` を表示します。`lib/types` の `ErrorBreakdownItem` に `rate: number` を追加すること
- **`error_type` の値の分類ルールは DFA 側が決める**（D-057 の付随情報:「`error_type` は PUDU Cloud 由来で
  列挙が未定義。プログラム側で分類する必要がある。未知の値は『その他』に寄せる設計が必要」）。
  ただし**この指示書では分類ロジックを書きません**（fixtures が分類済みのデータを持っているため）

### 0-3. `ReportKpi` を名前付きスキーマに切り出す

`/reports/kpi` の 200 レスポンス（`:424-430`）はインラインです。
`lib/types/index.ts:177-182` の `ReportKpi` に対応する名前付きスキーマを `components/schemas` に作り、`$ref` すること。
前例: `PrecheckListResponse`（`:1017`）/ `DsDeviceListResponse`（`:1132`）/ `DsAlertListResponse`（`:1180`）。

### 0-4. `KpiValue.value` を nullable にする

`:1040` の `value` は非 nullable ですが、`lib/judge/kpi.ts` の4関数は
**分母0のとき `null` を返します**（`:15-16`「『予定が1件もない期間』と『予定はあったが全部失敗した』は
区別する必要がある」）。

現状 `toKpiValue(value: number, ...)`（`kpi.ts:54`）も `number` しか受けないため、
**全施設定休日の週の KPI を返す経路が型として存在しません。**

- YAML `:1040` に `nullable: true` を追加
- `lib/types/index.ts:132` を `value: number | null` に
- `lib/judge/kpi.ts:54` の `toKpiValue` の第1引数を `number | null` に、`diff` は
  **どちらかが null なら null** にする
- 前例: `/dashboard` の `success_rate`（`:133-138`）と `schedule_progress.rate`（`:150-155`）は
  すでに `nullable: true`

### 0-5. 4本すべてに `"422"` を追加する

レポート4本は `period` / `year` / `month` が **`required: true`** なのに、
**422 のレスポンス定義がありません。** `components/responses/ValidationError`（`:920-924`）が既にあるので、
4本とも `"422": { $ref: "#/components/responses/ValidationError" }` を追加すること。

### 0-6. `FacilityPerformance` に `serial_number` を追加する

`/reports/facility-performance` は `serial`（シリアル部分一致）で絞り込める契約（`:447-450`）なのに、
レスポンスの `FacilityPerformance`（`:1054-1068`）に `serial_number` がありません。
**絞り込みキーが結果に出ない**のは不整合です。`serial_number: string` を追加し、
`lib/types/index.ts:141-150` と `lib/fixtures/reports.ts` にも足すこと。

### 0-7. `/reports/error-breakdown` の `meta` の扱いを決める

`:526-540` は `items` のみで `meta` がなく、`CLAUDE.md` §9.3「一覧APIは必ず `meta` を返す」に反します。

**→ `meta` を足さず、「7分類固定でページングの余地がないため一覧APIの例外とする」と
description に明記すること。** `limit` パラメータも無く、常に7件（または0件）を返すためです。
**この判断を決定事項に記録すること。**

### 0-8. `Period` の description に年次の扱いを書く

`:887-894` の description に「**P1 では週次/月次のみ。年次は範囲外**（`CLAUDE.md` §4.7 の年次バッチは
サマリテーブルの生成単位であって、レポート画面の表示単位ではない）」を追記すること。

### 0-9. `limit` と `meta` の噛み合わせを説明で補う

`facility-performance`（`:458-461`）と `continuous-problems`（`:486-488`）は
**入力が `limit`（既定10）なのにレスポンスは `meta: {total, page, per_page}`** を返す契約です。
`page` / `per_page` パラメータはありません。

**契約は変えません**（モックの「他N件を表示」が limit を増やす形なので合理的）。
そのかわり **`Meta` の使い方を description で明示**すること:

- `meta.total` = **絞り込み後の全件数**（`limit` 適用前）。モックの「全{N}施設を表示」の N がこれ
- `meta.page` = 常に `1`
- `meta.per_page` = 実際に適用された `limit` の値

### 段階0の完了条件

- `npx tsc --noEmit` エラー0（`lib/types` の変更が既存コードを壊していないこと）
- `npm test` が **267件のまま**（`toKpiValue` の引数型変更でテストが落ちるなら、
  **テストを直すのではなく実装を見直すこと**。`kpi.test.ts:75-90` は既存の期待値表です）
- **ここで一度コミットする**（D-050。段階0だけで独立した意味を持つため）

---

## 段階1. 型と期間ヘルパー

### 1-1. `lib/format/period.ts` を新規作成（★テストを先に書くこと★）

`lib/format/` には現在 `date.ts` しかなく（`formatMd` / `formatHm` / `todayJst` / `inclusiveDayCount`）、
**週の開始日・第N週・月初月末・当期判定を求める関数がありません。**

次の純粋関数を作ること。**`CLAUDE.md` §9.4 に従い、期待値表のテストを先に書くこと。**

```ts
/** 週は日曜始まり（CLAUDE.md §4.7）。YYYY-MM-DD を受けてその週の日曜を返す */
export function weekStart(date: string): string;

/** 月跨ぎ週の帰属は「その月の1日を含む週がその月の第1週」（★D-067。D-024 は破棄★）。第1週=1 */
export function weekOfMonth(date: string): { year: number; month: number; week: number };

/** period/year/month/week から期間の [開始日, 終了日] を返す（両端含む、YYYY-MM-DD） */
export function periodRange(
  period: "weekly" | "monthly", year: number, month: number, week: number | null
): { from: string; to: string } | null;

/** 要求された期間が「当期（進行中）」か。今日（JST）が期間内なら true */
export function isCurrentPeriod(
  period: "weekly" | "monthly", year: number, month: number, week: number | null,
  today?: string
): boolean;

/** 画面の期間ナビに出すラベル。例: "2026年3月 第4週" / "2026年3月" */
export function periodLabel(
  period: "weekly" | "monthly", year: number, month: number, week: number | null
): string;

/** 前期比のラベル。"前週" / "前月" */
export function prevPeriodLabel(period: "weekly" | "monthly"): string;

/** 期間ナビの ‹ › で1つ前/後の期間へ */
export function shiftPeriod(
  period: "weekly" | "monthly", year: number, month: number, week: number | null, delta: -1 | 1
): { year: number; month: number; week: number | null };
```

**★実装上の注意★**

- **タイムゾーン変換をしないこと。** `lib/format/date.ts:2-3` の方針コメント（文字列スライスで処理）と、
  `lib/dashboard/aggregate.ts:64-72` の `weekdayIndex`（`Date.UTC` + `getUTCDay()` を使い、
  「実行環境のTZで結果が変わるため `new Date(str).getDay()` は使わない」）に従うこと
- `isCurrentPeriod` の `today` 引数は**テスト用**。省略時は `todayJst()` を使う
  （テストで「今日」を固定できないと検証不能になる）
- **`week` の最大値（★2026-09-07 訂正★）**: 当初「月によっては第6週が発生する」と
  想定していたが誤り。D-067 の帰属ルール下では1か月に属する週数は
  `floor((offset+日数)/7)`（offset=1日の曜日番号0〜6、日数28〜31）で決まり、
  最大でも **5**（第6週は発生しない）。2020〜2040年の全日で確認済み。
  **YAML の `maximum` は `5` のまま**（段階0で6にしていた変更は差し戻し済み）。
  `weekOfMonth` が1〜5のみを返すことをテストで固定すること

**テストの境界値**（`lib/format/__tests__/period.test.ts` を新規作成）:

- 月初が日曜の月 / 土曜の月（週数が変わるケース。★第6週は出ない★）
- **月跨ぎ週（★D-067 のルールを固定する★）**
  - `2026-08-30(日)〜09-05(土)` → **9月 第1週**（8/30・8/31 を含むが9月に数える）
  - `2026-08-23(日)〜08-29(土)` → 8月の週（8月の最終週）
  - `2026-09-06(日)〜09-12(土)` → 9月 第2週
- 年跨ぎ週（12月最終週が翌年1月に跨る／その逆）
- `isCurrentPeriod`: 期間の初日 / 最終日 / 前日 / 翌日
- `shiftPeriod`: 第1週から `-1`（前月の最終週へ）/ 12月から `+1`（翌年1月へ）

### 1-2. `lib/types/index.ts` の調整

段階0の YAML 変更に合わせて:

- `KpiValue.value` を `number | null` に（0-4）
- `ErrorBreakdownItem` に `rate: number` を追加（0-2）
- `FacilityPerformance` に `serial_number: string` を追加（0-6）
- **期間キーを持つ行型を新設**（下記）

`DataSource` は「引数なしで全件返す」契約（`lib/db/index.ts:42-46`）なので、
`ReportKpi`（単一期間分）のままだと**どの期間のものか分からず、週次/月次/当期を出し分けられません。**
次の行型を追加すること:

```ts
/** DataSource が返す KPI の行。period/year/month/week で1行が特定される */
export interface ReportKpiRow {
  period: "weekly" | "monthly";
  year: number;
  month: number;
  /** monthly のときは null */
  week: number | null;
  kpi: ReportKpi;
}
```

`FacilityPerformance` / `ContinuousProblem` / `ErrorBreakdownItem` も
**同じ理由で期間キーが要ります。** それぞれ `ReportKpiRow` と同じ4フィールドを持つラッパー型を作るか、
各型に直接 `period` / `year` / `month` / `week` を持たせるかは実装者の判断でよいが、
**選んだ理由をコメントに残すこと**。

> **これは API レスポンスの型ではありません。** API は期間で絞り込んだ結果だけを返すので、
> `ReportKpiRow` などがレスポンスに出ることはありません。**YAML には追加しないこと。**

---

## 段階2. fixtures の補完と `DataSource` の拡張

### 2-1. `lib/fixtures/reports.ts` の補完

現状の7 export は **どこからも参照されていません**（`lib/fixtures/index.ts:14` で re-export はされている）。
段階2で初めて接続します。あわせて次を補完すること。

| # | 不足 | 対応 |
|---|---|---|
| 1 | 期間キーが無い | `ReportKpiRow[]` の形に組み直す。既存の `kpiWeekly` / `kpiMonthly` / `kpiCurrentPeriod` は**変数として残したうえで**、行データに埋め込むこと（値を変えないため） |
| 2 | `continuousProblems` が5件で「もっと表示」（`limit` 既定10）が発火しない | **11件以上に増やす**（`docs/fixtures.md` §10 の要求） |
| 3 | 空配列ケースが `errorBreakdownEmpty` だけ | `facilityPerformanceEmpty` / `continuousProblemsEmpty` を追加（§10 の要求） |
| 4 | `serial_number` が無い | `facilityPerformance` の12件すべてに追加（0-6） |
| 5 | `rate` が無い | `errorBreakdown` の7件すべてに追加（0-2）。合計56件なので `18/56*100` 等の生値を入れる |
| 6 | ローカル `kpi()` が `diff` を小数第1位で丸めている（`:25-31`） | **`lib/judge` の `toKpiValue` を使うように直す。** 同じ `KpiValue` を作る実装が2つあり、`diff` の値が食い違います（D-044「判定ロジックは切替の外側」の趣旨にも反する） |

**★実在の店舗名を使わないこと★**（D-043）。既存の12件はダミー名になっているので、追加分も同じ流儀で。

### 2-2. `lib/fixtures/__tests__/reports.test.ts` を新規作成（★重要★）

`lib/fixtures/clean-logs.ts` は `judgeGrade` / `judgeEscalationLevel` を**実際に通して**
期待値を作っています（`:57-59`）が、**`reports.ts` は素の直書き**で、誰も整合性を検証していません。

次を検証するテストを作ること:

- `facilityPerformance[].status` が `judgeFacilityStatus(operation_rate.value)` と一致する
- すべての `KpiValue` で `diff === value - prev`（`prev` が null なら `diff` も null）
- `errorBreakdown` が **件数降順**で、`ErrorType` 7種を**重複なく網羅**している
- `errorBreakdown[].rate` の合計が 100 に近い（浮動小数の誤差を許容）
- `continuousProblems[].escalation_level` が `judgeEscalationLevel(consecutive_problem_days)` と一致する
- `continuousProblems` が**すべて2日以上**（L1 の確認用データを除く。L1 が1件あることも固定する）
- `facilityPerformance` が**稼働率の昇順（ワースト順）**に並んでいる

### 2-3. `DataSource` に4メソッドを追加

`lib/db/index.ts:47-56` の `interface DataSource` の末尾に追加:

```ts
  /** レポートKPI。期間の絞り込みはAPIルート側 */
  reportKpis(): Promise<ReportKpiRow[]>;
  /** 施設別パフォーマンス。並び替え・フィルタ・limit はAPIルート側 */
  facilityPerformance(): Promise<FacilityPerformance[]>;
  /** 連続問題施設。2日以上の絞り込みと limit はAPIルート側 */
  continuousProblems(): Promise<ContinuousProblem[]>;
  /** エラー種別内訳。降順ソートはAPIルート側 */
  errorBreakdown(): Promise<ErrorBreakdownItem[]>;
```

- `fixtureSource`（`:60-85`）: 既存の `async xxx() { return xxx; }` の形で4つ追加
- `prismaSource`（`:102-127`）: `notImplemented("xxx")` の形で4つ追加（`:96-100` のパターン）
- import を `:29-38` に追加

**★引数を取らせないこと★**（`lib/db/index.ts:42-46`「フィルタや集計はこの層では行わず、
APIルート側で組み立てます。実DB接続時にクエリへ落とすときの差分を小さくするため」）。

---

## 段階3. `lib/reports/query.ts` を新規作成

`lib/ds-alerts/query.ts`（115行）と**同じ構成順**で作ること。

| 順 | 中身 |
|---|---|
| 1 | ファイルコメント（「GET /reports/* で使うクエリ処理。仕様の正は `d-watch-api.yaml`」） |
| 2 | import（`@/lib/api/query-result` → `@/lib/config` → `@/lib/format/period` → `import type … @/lib/types`） |
| 3 | 許容値の `readonly` 配列定数（`PERIODS` / `FACILITY_STATUS_FILTERS`） |
| 4 | `ReportQuery` interface（`period` / `year` / `month` / `week` / `limit` ほか） |
| 5 | private `toInt()` |
| 6 | `parseReportQuery()` — **`QueryParseResult<ReportQuery>` を返す** |
| 7 | `filterXxx()` 各種 |
| 8 | `sortXxx()` |
| 9 | サマリ（レポートには不要なら省く） |

### 3-1. `parseReportQuery()`

**★`lib/api/query-result.ts` の `fail()` と `QueryParseResult<T>` を使うこと★**
（`lib/clean-logs/query.ts` にも同名の古い型が残っていますが、そちらは使わないこと。3-4 参照）

必須パラメータの検証（`details` に項目名を必ず載せる。`/prechecks` と同じ形）:

| 条件 | code | message | details |
|---|---|---|---|
| `period` 欠落 or enum外 | `validation_failed` | `periodの値が不正です` | `{ period: ["weekly / monthly のいずれかを指定してください"] }` |
| `year` 欠落 or 数値でない | `validation_failed` | `yearの値が不正です` | `{ year: [...] }` |
| `month` 欠落 or 1〜12 外 | `validation_failed` | `monthの値が不正です` | `{ month: ["1〜12で指定してください"] }` |
| `period=weekly` で `week` 欠落 or 1〜5 外 | `validation_failed` | `weekの値が不正です` | `{ week: ["period=weekly のときは1〜5で指定してください"] }` |

- `period=monthly` のとき `week` は**無視する**（YAML `:912`）。エラーにしないこと
- `limit` は `Math.max(1, toInt(..., 10))`。上限は `PAGINATION.maxPerPage`（`lib/config/index.ts:37-40`）
- **`group_id` / `customer_id` はここで読まないこと**（D-053）。理由をコメントで残す

### 3-2. フィルタとソート

- `filterFacilityPerformanceByStatus(items, status)` — `status=all` なら素通し
- `filterFacilityPerformanceByName(items, facilityName)` — 部分一致。空文字なら素通し
- `filterFacilityPerformanceBySerial(items, serial)` — 部分一致
- `sortFacilityPerformance(items)` — **稼働率の昇順（ワースト順）**。
  ★`value` が `null` の行の位置を決めてコメントに書くこと★（末尾に置くのが妥当）。
  同値のときは `facility_id` 昇順で安定させる（D-052 と同じ規則）
- `filterContinuousProblems(items)` — **`consecutive_problem_days >= 2`**（★先に決まっていること 6★）
- `sortContinuousProblems(items)` — 連続日数の降順。同値なら `facility_id` 昇順
- `sortErrorBreakdown(items)` — 件数の降順。同値なら `type` の定義順

### 3-3. 期間による行の選択

`reportKpis()` が返す `ReportKpiRow[]` から、要求された期間の行を選ぶ関数を作ること。

```ts
export function selectKpiRow(
  rows: ReportKpiRow[], query: ReportQuery
): ReportKpi | null;
```

- `isCurrentPeriod()`（段階1）で当期かどうかを判定し、**当期なら当期用の行を選ぶ**
- 該当行が無ければ `null`（ルート側で 404 にするか空の KPI を返すかは 4-1 で決める）
- ★**当期かどうかで実際の集計方法を変える処理は書かないこと**★（★先に決まっていること 2★）

### 3-4. 予備作業: `lib/clean-logs/query.ts` の重複型を消す

`lib/clean-logs/query.ts:21-23` に**古い非ジェネリック版の `QueryParseResult`** が残っています。
指示書D の 4-1 で `lib/api/query-result.ts` に共通化したときの消し残しです。

**`lib/api/query-result.ts` の `QueryParseResult<CleanLogQuery>` を import する形に直し、
ローカル定義を削除すること。** 同じ名前の型が2つある状態は、指示書D が解消しようとした不整合そのものです。

### 3-5. テスト

`lib/reports/__tests__/query.test.ts` を新規作成。
`lib/ds-alerts/__tests__/query.test.ts:11-27` の形（`row(overrides: Partial<T>)` ファクトリを先頭に置く）を踏襲。

**必ず含めるケース**:
- 必須パラメータ欠落4種で `parsed.ok === false` / `status === 422` / **`details` の中身**を検証
- `period=monthly` のとき `week` が無視されること
- `week=6` が422で弾かれること（★D-067下では第6週は存在しないため`maximum`は5のまま★）
- `filterContinuousProblems` が1日の行を落とすこと
- `sortFacilityPerformance` で `value: null` の行が末尾に来ること

---

## 段階4. APIルート4本

`app/api/reports/{kpi,facility-performance,continuous-problems,error-breakdown}/route.ts` を新規作成。

**骨格は `app/api/ds-alerts/route.ts` を見本にすること**（形A）。共通の形:

```ts
/**
 * GET /reports/xxx — 日本語の説明
 *
 * 形は `app/api/ds-alerts/route.ts` に合わせている。
 * 仕様の正は `d-watch-api.yaml` の /reports/xxx。
 */
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { parseReportQuery, /* ... */ } from "@/lib/reports/query";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, /* ... */ } from "@/lib/types";

const ROUTE = "/api/reports/xxx";

function errorResponse(error: ApiError, status: number) {
  return NextResponse.json(error, { status });
}

export async function GET(request: Request) {
  return withRequestId(request, async () => {
    const { searchParams } = new URL(request.url);
    const parsed = parseReportQuery(searchParams);
    if (!parsed.ok) {
      return errorResponse(parsed.error, parsed.status);
    }

    // group_id / customer_id は受け取っても無視する（D-053。他5ルートと同じ扱い）。

    let rows;
    try {
      rows = await db.xxx();
    } catch (e) {
      const body: ApiError = {
        error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
      };
      logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
      return errorResponse(body, 500);
    }

    // フィルタ → ソート → limit の順。すべて try の外
    // ...

    return NextResponse.json(body);
  });
}
```

**★守ること★**

- `try` の中は `await db.xxx()` **だけ**。フィルタ・集計は try の外
  （`app/api/ds-alerts/route.ts:48-57` と同じ）
- 500 経路は `errorResponse(body, 500)` を使う（**指示書D の 4-2 で形A 4本を統一済み**。
  `NextResponse.json` を直呼びしないこと）
- `logApiError` には**レスポンスと同一の `body` オブジェクト**を渡す（`lib/logger/api.ts:4-6`）

### 4-1. `/reports/kpi`

- `selectKpiRow()` で行を選ぶ。**該当が無い場合の扱いを決めてコメントに書くこと**
  （推奨: 4指標すべて `{ value: null, prev: null, diff: null }` を返す。
  404 にすると「データがまだ無い期間」と「不正な期間」の区別がつかなくなる）
- レスポンスは `ReportKpi` の**素のオブジェクト**（`meta` なし。`/dashboard` と同じ形）

### 4-2. `/reports/facility-performance`

- フィルタ（status / facility_name / serial）→ ソート（稼働率昇順）→ `limit` で切る
- `meta.total` は **`limit` 適用前・フィルタ適用後**の件数（段階0-9）
- `meta.page = 1` / `meta.per_page = limit`

### 4-3. `/reports/continuous-problems`

- `filterContinuousProblems()`（2日以上）→ ソート（連続日数降順）→ `limit`
- `meta` は 4-2 と同じ規則

### 4-4. `/reports/error-breakdown`

- ソート（件数降順）のみ。`limit` なし
- **`meta` を返さない**（段階0-7 の判断）。`{ items }` だけ

---

## 段階5. 画面

### 5-1. `app/(dashboard)/reports/page.tsx` を置き換える

現状（全5行）:
```tsx
import { PlaceholderScreen } from "@/components/layout/PlaceholderScreen";

export default function ReportsPage() {
  return <PlaceholderScreen title="レポート" />;
}
```

→ `ds-alerts/page.tsx` と同じ薄いラッパーに:
```tsx
import { ReportsScreen } from "@/components/features/reports/ReportsScreen";

export default function ReportsPage() {
  return <ReportsScreen />;
}
```

**`components/layout/PlaceholderScreen.tsx` を削除しないこと**（facilities / inspections /
schedules / system の4画面がまだ使っています）。

### 5-2. `components/features/reports/ReportsScreen.tsx`（+ `.module.css`）

`components/features/ds-alerts/DsAlertsScreen.tsx` の骨格に従うこと。

- `"use client"`
- `const { t, theme } = useTheme();` / `const c = THEME_TOKENS[theme];`
- **データ取得は `lib/api/client.ts` の `apiFetch` 一択**（`db` 直呼び・生 `fetch` は使わない）
- **4本を `Promise.all` で並列取得**（`DashboardScreen.tsx:43-48` の形）。
  **1本でも失敗したら画面全体をエラー表示にする**（`DashboardScreen.tsx:50-51` と同じ方針。理由をコメントで）
- `queryString` を `useMemo`、`fetchReports` を `useCallback`、`useEffect` で発火
- ローディング / エラー / 空データの3段（`DsAlertsScreen.tsx:135-140` の形と文言）
- 状態は `useState` のみ。**URL クエリ同期（`useSearchParams`）は使わない**
  （全画面で前例がなく、この画面だけ違う作りにしない）
- CSS は colocate した CSS Modules。色は `THEME_TOKENS[theme]` か CSS 変数

### 5-3. 画面の構成（モック `d-watch-mock.jsx:654-828` を正とする）

上から順に:

| # | 要素 | モックの行 | 備考 |
|---|---|---|---|
| 1 | ヘッダ（h1「レポート」＋右端「CSVエクスポート」ボタン） | `:657-660` | **CSVエクスポートは押しても何もしない仮実装**にし、その旨をコメントで残す（`/reports/*` に export エンドポイントが無いため）。★勝手にエンドポイントを追加しないこと★ |
| 2 | フィルタ行（施設名 / シリアルNo. / ステータス / 検索） | `:661-680` | **日付範囲の input 2つとグループ/会社の select は作らない**（★先に決まっていること 1・4★） |
| 3 | 期間トグル＋期間ナビ | `:681-686` | モックは週次/月次トグルのみ。**期間ナビ（‹ ラベル ›）を新規に足す**（★先に決まっていること 1★） |
| 4 | KPIカード 4列 | `:687-705` | 下記 5-4 |
| 5 | 2カラム: 左「連続問題施設」／右「エラー種別の内訳」 | `:708-771` | 下記 5-5 / 5-6 |
| 6 | 「施設別パフォーマンス（稼働率ワースト順）」表 | `:774-826` | 下記 5-7 |

### 5-4. KPIカード（4枚）

**`StatCard`（`components/ui/StatCard.tsx`）はそのままでは使えません。** 理由:

- モックのカードは**前期比の行（▲▼＋色）と補助行（前週: N%）の2行**を持つ（`:696-701`）が、
  `StatCard` の `sub` は1行のテキストのみ
- `StatTone` は `"ok" | "wn" | "ng" | "ac"` の4つで、**モックの2枚目（出発成功率）が使う `pr`（青）が無い**

**→ 次のどちらかを選び、理由をコメントに書くこと**:
1. `StatCard` に `delta`（値・符号・色・ラベル）を追加し、`StatTone` に `"pr"` を足す
2. `components/features/reports/KpiCard.tsx` をレポート専用に新設する

**★前期比の色分けのルール★**（モック `:690-691`。**間違えやすいので必ずテストで固定すること**）:

```
稼働率 / 出発成功率 / 清掃完了率 → 上がったら良い（diff > 0 で ok 色）
エラー発生率                     → ★下がったら良い（diff < 0 で ok 色）★ 反転する
```

- `diff === 0` は `→` で中立色
- `prev === null`（新規施設）のときは **▲▼を表示しない**（`lib/judge/kpi.ts:53` に明記）
- **表示は整数**（★先に決まっていること 5★）。`displayRate()` を使う

### 5-5. 連続問題施設

- 副題: `2日以上連続で△以下または出発失敗の施設`（モック `:713` の原文）
- **空データ時: `該当施設なし`**（モック `:716` の原文）
- バッジ: `Lv{n} / {d}日`（モック `:727`）。色は L3 が `ng`、L2 が `wn`
- 右側は**モックの「完了率 {n}%」ではなく `summary` を出す**（段階0-1 の判断）。
  ★モックからの逸脱なので決定事項に記録すること★
- 既定 10件、`limit` を増やして「他N件を表示 →」（モック `:735`）

### 5-6. エラー種別の内訳

- 副題: モックは `今週のエラー発生件数（種別ごと）` とハードコードしている（`:748`）が、
  **週次/月次トグルに連動させること**（`今週` / `今月`）。モックのバグです
- 行: 種別名（`label`）／バー／件数／割合（`rate`）
- **バーは `components/ui/ProgressBar.tsx` を使う**（JSDoc に F-04 共用と明記済み）。
  ただし `tone` は `"ok"|"wn"|"ng"|"ac"|"pr"` の5つで、モックの7色のうち
  `txD` / `txM` に相当するものが無い。**tone を追加するか、個別に色を渡せるようにするか判断すること**
- **空データ時のメッセージをモックが持っていません。** 決めて実装し、決定事項に記録すること
  （推奨: 連続問題施設に合わせて `該当なし`）

### 5-7. 施設別パフォーマンス表

**汎用のテーブルコンポーネントは存在しません**（`CleanLogTable` は清掃ログ専用）。
`components/features/reports/` 配下に新規作成すること。

列（モック `:782-790` の9列）:

| # | 見出し | 書式 | 条件付き色分け |
|---|---|---|---|
| 1 | 施設名 | 太字 | — |
| 2 | 稼働率 | `{n}%` 太字 | `< 80` → ng / `< 90` → wn / それ以外 ok |
| 3 | 前週 or 前月 | `{n}%` | 薄字固定 |
| 4 | 出発成功率 | `{n}%` 太字 | `< 80` → ng / `< 90` → wn / それ以外 **`pr`（青）** ★稼働率と色が違う★ |
| 5 | 完了率 | `{n}%` | 通常色固定 |
| 6 | 前週 or 前月 | `{n}%` | 薄字固定 |
| 7 | エラー率 | `{n}%` | `> 8` → ng / それ以外 薄字 |
| 8 | 変化 | `▲{n}` / `▼{n}` / `→` | `> 0` → ok / `< 0` → ng / `0` → 中立 |
| 9 | ステータス | `<Pill>` | 要改善 → ng / 注意 → wn / 良好 → ok |

**★「変化」列の算出（モック `:795-797`）★**:
```
overallDiff = Math.round((稼働率の差分 + 完了率の差分) / 2)
```
**API はこの合成値を返しません。** フロントで計算すること（`operation_rate.diff` と
`completion_rate.diff` から）。**どちらかが `null` のときの扱いを決めてコメントに書くこと**
（推奨: どちらかが null なら `→` を出す）。

- 3列目・6列目の見出しは `period` に連動（`前週` / `前月`）
- 既定 10件、フッタは2分岐（`全{N}施設を表示 →` / `上位10件に戻す`。モック `:816-825`）
- カードヘッダ右に `{N}施設`（モック `:777`）

### 5-8. i18n の対訳を追加

**`lib/i18n.ts` にレポート用の対訳が1つもありません。** モック側の T 辞書（`:138-147`）から移植すること。

欠落しているもの: `CSVエクスポート` / `検索` / `全ステータス` / `週次` / `月次` /
`稼働率` / `出発成功率` / `清掃完了率` / `エラー発生率` / `連続問題施設` / `エラー種別の内訳` /
`施設別パフォーマンス（稼働率ワースト順）` / `要改善` / `注意` / `良好`

**★モックが `t()` を通していない文言について★**
モックには「辞書に対訳があるのに `t()` を通していない」箇所があります
（KPIカードのラベル4つ `:695`、`完了率` `:786`、`施設名` `:782`、`ステータス` `:790` など）。
これは**モックのバグ**と判断し、**実装では `t()` を通すこと**。
`CLAUDE.md` §9.6「JP/EN切替の器を壊さない」に従います。

`前週` / `前月` / `他N件を表示 →` / `全N施設を表示 →` / `上位10件に戻す` /
`該当施設なし` / 期間ナビのラベルも**新規に対訳を作ること**。

---

## `docs/decisions.md` への記録（D-068 から）

★**着手直前に最終番号を確認してから番号を振ること**★（衝突歴あり。2026-09-07 時点の最終は **D-067**）。

記録すべきもの:

| 内容 | 段階 |
|---|---|
| レポートの期間指定は `period`+`year`+`month`+`week` に寄せ、モックの日付範囲UIは作らない | 先に決まっていること 1 |
| 当期は「器だけ」。実際の集計は prismaSource 実装時 | 先に決まっていること 2 |
| 年次（yearly）は F-04 の範囲外 | 先に決まっていること 3 |
| `ContinuousProblem` は `completion_rate` ではなく `summary` を返す（**モックからの逸脱**） | 0-1 |
| `ErrorBreakdownItem` は英字 enum + `label` + `count` + `rate`（`/dashboard` の `failure_breakdown` に揃える） | 0-2 |
| `KpiValue.value` を nullable にする（分母0の期間を表現するため） | 0-4 |
| `/reports/error-breakdown` は `meta` を返さない（一覧APIの例外。7分類固定でページング不要） | 0-7 |
| `week` の上限は 5 のまま（当初「第6週が発生しうる」と想定したが誤りと判明。D-067下では最大5） | 段階1 |
| 連続問題施設は2日以上（`docs/spec.md:58` の「L1〜L3」は誤記） | 先に決まっていること 6 |
| エラー種別内訳の空データ時の文言 | 5-6 |

`docs/spec.md:58` の「連続問題施設（L1〜L3）」も**あわせて修正すること**。

---

## ★やらないこと★

| 対象 | 理由 |
|---|---|
| **当期その場集計の実装**（面積の積算・エラー種別の分類） | ★器だけ作る。`prismaSource` 実装時★ |
| **`CleanLogItem` への `cleaned_area` / `target_area` 追加** | 当期集計を書かないので不要。実DBには `clean_task_reports.task_area` / `clean_area` が存在するので、必要になったときに足せる |
| **`error_type` の7分類マッピング** | fixtures が分類済みのデータを持つ。実DBの `robot_error_reports.error_type` からの分類は prismaSource 実装時（D-057） |
| **`Period` enum への `yearly` 追加** | ★F-04 の範囲外★ |
| **日付範囲（`date_from`/`date_to`）での絞り込み** | ★API契約に寄せると決定済み★ |
| **グループ / 会社の絞り込みUI** | D-053。マスタ未整備 |
| **CSVエクスポートの実装** | エンドポイントが契約に無い。ボタンだけ置いて仮実装にする |
| **`prismaSource` の実装** | `notImplemented()` のまま |
| **`PlaceholderScreen` の削除** | 他4画面がまだ使う |
| **`AppShell` のメニュー変更** | `/reports` は既にある |
| **URL クエリ同期（`useSearchParams`）** | 全画面で前例がない。この画面だけ違う作りにしない |
| **`PrecheckItem.id` の `number`→`string` 化** | 別作業（D-061）。同時にやらない |

---

## 完了条件

1. 段階0〜5 をすべて実施
2. `npx tsc --noEmit` がエラー0
3. `npm test` のテスト数が**減っていないこと**（**基準値 267**）。
   段階1（`period.test.ts`）・段階2（`reports.test.ts`）・段階3（`query.test.ts`）の
   3ファイル分が増えるのが期待値
4. **`USE_FIXTURES=true` で画面が実際に描画され、4本のAPIが 200 を返すこと**を確認する
   （`npm run dev` で `/reports` を開く）
5. 段階0の YAML 変更と、上の表の決定事項を `docs/decisions.md` に記録（**D-068 から**）
6. `docs/spec.md:58` の「連続問題施設（L1〜L3）」を「2日以上連続」に修正
7. **段階ごとにコミットしてよい**（むしろ推奨。段階0 / 段階1-2 / 段階3-4 / 段階5 の4回程度）。
   検証が通ったら**即コミット**（D-050。「レビュー前だから」で先送りしないこと）
8. 実行後、この指示書の冒頭「実行状態」表を更新する
