> **【注記】** 2026-09-03、実装とコメント・設計文書の食い違いを洗い出すために全ファイルを精読した記録です（3分割の1つ）。
> ここで見つかった26件のうち、直すべき11件を `docs/handoff/指示書D_実装解析の不整合.md` に整理しています。
> **当時の記録であり、現行方針ではありません。** 行番号は当時のものです。現状は `docs/handoff.md` を参照してください。

# D-Watch 実装解説 — 画面と窓口（`app/` と `components/`）

対象リポジトリ: `C:\Users\daich\dev\d-watch`（読み取りのみ。変更していません）
解析日: 2026-09-02 / Next.js 16.3.3 / React 19.2.8（`package.json` L14-16）

この文書の読み方
- 各節は **「専門用語 → 平易な言い換え → 実際のコードで何が起きているか」** の順に書いています。
- 引用しているコードには必ず行番号（例: `L30`）を添えています。行番号は解析日時点のファイルのものです。
- コード内のコメントや `docs/decisions.md` に根拠がある場合は、その文をそのまま引用しています。
- 根拠となるコメントが**存在しない**推論には「★推測★」と明記しています。

---

## 0. 全体の地図 — 3層構造

D-Watch のフロント側は、ざっくり3つの層に分かれています。

| 層 | 場所 | 役割 | たとえるなら |
|---|---|---|---|
| 画面の置き場所（ルーティング） | `app/(dashboard)/*/page.tsx` | どの URL でどの画面を出すか決めるだけ。中身は書かない | 建物の**部屋番号のプレート** |
| 窓口（API ルート） | `app/api/*/route.ts` | ブラウザからの問い合わせに JSON で答える | 役所の**受付窓口** |
| 画面の中身（コンポーネント） | `components/**/*.tsx` | 実際の見た目と操作 | 部屋の**中の設備** |

そして計算・判定・データ取得は、この3層のさらに下の `lib/` に置かれています。
`CLAUDE.md` §9.6（L75）が **「判定結果（◎○△×、L1〜L3、ソート順）はAPIから受け取る。フロントで再計算しない」** と定めているため、
`components/` の中には原則として計算が入りません（実際にどうなっているかは §17 で検証しています）。

---

## 1. ルーティングの仕組み — フォルダ名がそのまま URL になる

### 1.1 用語

| 専門用語 | 平易な言い換え |
|---|---|
| ルーティング | 「この URL が来たら、この画面を出す」という対応表 |
| App Router | Next.js の新しい方式。対応表を**フォルダの形そのもの**で表現する |
| ファイルベースルーティング | 設定ファイルを書かず、フォルダを作るだけで URL が増える仕組み |
| ルートグループ | フォルダを作っても URL には出ない、整理専用のフォルダ |
| ネスト | 入れ子。外側の枠の中に内側の中身が入ること |

### 1.2 「フォルダ名 = URL」の実物

D-Watch の `app/` の中身（`find app -type f` の結果、抜粋）:

```
app/page.tsx                            → URL: /
app/(dashboard)/dashboard/page.tsx      → URL: /dashboard
app/(dashboard)/detail-log/page.tsx     → URL: /detail-log
app/(dashboard)/alerts/page.tsx         → URL: /alerts
app/(dashboard)/ds-logs/page.tsx        → URL: /ds-logs
app/(dashboard)/ds-alerts/page.tsx      → URL: /ds-alerts
app/(dashboard)/facilities/page.tsx     → URL: /facilities
app/(dashboard)/inspections/page.tsx    → URL: /inspections
app/(dashboard)/reports/page.tsx        → URL: /reports
app/(dashboard)/schedules/page.tsx      → URL: /schedules
app/(dashboard)/system/page.tsx         → URL: /system
app/api/dashboard/route.ts              → URL: /api/dashboard
app/api/clean-logs/route.ts             → URL: /api/clean-logs
app/api/clean-logs/[id]/areas/route.ts  → URL: /api/clean-logs/123/areas
```

**平易に言うと**: `app/` の下にフォルダを掘ると、掘った通りの道順が URL になります。
`app/` の下に `facilities` というフォルダを作り、その中に `page.tsx` を置いた。だから `/facilities` を開くとその画面が出る。
ルーターの設定ファイルは D-Watch のどこにも存在しません。フォルダがそのまま設定です。

### 1.3 これは推測ではない — Next.js が生成した対応表が実際にある

`.next/types/routes.d.ts`（`next dev` が自動生成するファイル。手で書くものではない）の L4-5 に、
Next.js 自身が読み取った URL 一覧がそのまま書かれています。

```
type AppRoutes = "/" | "/alerts" | "/dashboard" | "/detail-log" | "/ds-alerts" | "/ds-logs"
               | "/facilities" | "/inspections" | "/reports" | "/schedules" | "/system"
type AppRouteHandlerRoutes = "/api/clean-logs" | "/api/clean-logs/[id]/advice"
               | "/api/clean-logs/[id]/areas" | "/api/clean-logs/export"
               | "/api/dashboard" | "/api/settings/advice"
```

ここに **`(dashboard)` という文字が一度も出てきません**。これが次の 1.4 の証拠です。

※このファイルは最後に `next dev` を動かした時点のスナップショットで、現在は少し古くなっています
（`app/api/prechecks/route.ts` / `ds-alerts` / `ds-devices` が一覧に無い）。次に `next dev` を動かせば追加されます。
生成物なので手で直すものではありません。

### 1.4 `(dashboard)` — カッコで囲むと URL に出ない（ルートグループ）

`app/(dashboard)/dashboard/page.tsx` の URL は `/(dashboard)/dashboard` ではなく **`/dashboard`** です。

**平易に言うと**: フォルダ名を丸カッコで囲むと、「これは人間が整理するためのフォルダで、住所の一部ではない」という印になります。
書類を年度別のクリアファイルに分けても、書類の宛先住所は変わらないのと同じです。

**なぜ D-Watch はそれをやっているか**: 10画面すべてに同じサイドバーを付けたいからです。
`(dashboard)` フォルダの中に `layout.tsx`（§4）を1つ置くと、その中の10画面すべてが自動的にサイドバー付きになります。
一方 `app/page.tsx`（`/`）は `(dashboard)` の**外**にあるので、サイドバーは付きません（§3 の通り即リダイレクトするだけの画面なので不要）。

`CLAUDE.md` §9.1（L6-9）が構成を指定しています。

```
app/
  (dashboard)/           画面。モックの画面IDに合わせる
    dashboard/ detail-log/ alerts/ ds-logs/ ds-alerts/
    facilities/ inspections/ reports/ schedules/ system/
```

### 1.5 3種類の特別なファイル名

App Router では、ファイル名そのものに意味があります。D-Watch で使われているのは次の3つです。

| ファイル名 | 意味 | 平易な言い換え | D-Watch の実物 |
|---|---|---|---|
| `page.tsx` | その URL で表示される**画面**。これがあるフォルダだけが URL になる | 部屋の中身 | `app/(dashboard)/dashboard/page.tsx` |
| `layout.tsx` | 自分と**配下すべて**を包む**外枠**。画面を切り替えても作り直されない | 建物の共用部（廊下・受付） | `app/layout.tsx`, `app/(dashboard)/layout.tsx` |
| `route.ts` | 画面ではなく**データの窓口**。HTML ではなく JSON を返す | 役所の受付窓口 | `app/api/dashboard/route.ts` |

補足3点。

1. **`page.tsx` が無いフォルダは URL にならない。** `app/api/` には `page.tsx` が無いので、`/api` を開いても画面は出ません。
2. **`page.tsx` と `route.ts` は同じフォルダに同居できない。** どちらも「この URL の担当」なので衝突します。D-Watch は画面を `(dashboard)/` 配下、窓口を `api/` 配下にきれいに分けているため、この衝突は起きていません。
3. **`layout.tsx` は入れ子になる。** `/dashboard` を開くと、外側から順に
   `app/layout.tsx`（`<html>`〜`<body>`〜`ThemeProvider`）
   → `app/(dashboard)/layout.tsx`（`AppShell` = サイドバー）
   → `app/(dashboard)/dashboard/page.tsx`（`DashboardScreen`）
   の3枚が重なって1つの画面になります。

### 1.6 `[id]` — 角カッコは「ここは可変」

D-Watch には `app/api/clean-logs/[id]/areas/route.ts` があります。
角カッコで囲んだフォルダ名は**変数**で、`/api/clean-logs/12/areas` でも `/api/clean-logs/999/areas` でも
この1つのファイルが担当します。`12` や `999` は `params.id` として受け取れます
（`.next/types/routes.d.ts` L17-18 に `"/api/clean-logs/[id]/areas": { "id": string; }` と型が生成されています）。
今回の担当範囲外なので詳細は割愛しますが、フォルダ名の記法として押さえてください。

---

## 2. `app/layout.tsx` — アプリ全体の一番外側の枠

全 33 行。**アプリ内のすべての URL がこのファイルを通ります。**

### 2.1 import 文（実際のコードのまま）

```tsx
import type { Metadata } from "next";                                  // L1
import { Geist, Geist_Mono } from "next/font/google";                  // L2
import { ThemeProvider } from "@/components/theme/ThemeProvider";      // L3
import "./globals.css";                                                // L4
```

- L1 `import type` … 型だけを持ち込む書き方。**平易に**: 「この変数はこういう形をしているはず」という説明書だけを借りる宣言で、実行されるコードは1バイトも増えません。
- L2 `next/font/google` … Google Fonts をビルド時に取り込む仕組み。
- L4 `import "./globals.css"` … 名前を付けずに CSS を読み込むだけの import。これで `app/globals.css` の CSS 変数（`--bg`, `--ok` など）が全画面で有効になります。

### 2.2 export しているもの

| export | 形 | 説明 |
|---|---|---|
| `metadata` | オブジェクト（L16-19） | ブラウザのタブに出る題名と説明。Next.js が名前で見つけて `<head>` に変換する |
| `RootLayout` | **React コンポーネント**（`export default`, L21-33） | 全画面の外枠 |

`metadata` の実物（L16-19）:

```tsx
export const metadata: Metadata = {
  title: "D-Watch",
  description: "DFAロボット監視システム",
};
```

**平易に**: `<title>` タグを手で書く代わりに、決まった名前の変数を置いておくと Next.js が拾って `<head>` を組み立ててくれます。

### 2.3 中で何をしているか（順に）

**L6-9 / L11-14 — フォントの読み込み**

```tsx
const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});
```

`variable: "--font-geist-sans"` は「このフォントを CSS 変数 `--font-geist-sans` として使えるようにして」という指定です。
`subsets: ["latin"]` は「欧文だけダウンロードする」という指定（**平易に**: 使わない文字を落として通信量を減らす）。

この2つは `app/globals.css` L56-58 と対応しています。同ファイルにその理由がコメントで書かれています。

> 欧文は Geist、日本語は各OS標準の日本語フォントへ明示的にフォールバックさせる。
> Geist は日本語グリフを持たないため、日本語フォントを列挙しないと sans-serif に落ち、
> 閲覧環境に中国語フォントがある場合に漢字が中華字形で表示される。

つまり `subsets: ["latin"]` だけなのは意図的で、日本語は OS のフォントに任せる方針です。

**L21 — 受け取る引数の型**

```tsx
export default function RootLayout({ children }: LayoutProps<"/">) {
```

- `children`（子要素） … **平易に**: 「この枠の中に入れるもの」。誰が入るかは URL によって変わります。
- `LayoutProps<"/">` … Next.js が `.next/types/routes.d.ts` に**自動生成**したグローバル型。同ファイル L68-72 に定義があります。

```ts
type LayoutProps<LayoutRoute extends LayoutRoutes> = {
  params: Promise<ParamMap[LayoutRoute]>
  children: React.ReactNode
} & { [K in LayoutSlotMap[LayoutRoute]]: React.ReactNode }
```

**平易に**: 「`/` の layout が受け取るのは `params` と `children` です」という説明書を Next.js が書いてくれていて、それを名前で参照しているだけです。
import が要らないのは、生成側で `declare global` されているためです（同ファイル L42）。
なお `app/(dashboard)/layout.tsx` は同じ型を使わず手書きの型にしています（§4 参照）。

**L22-32 — 返している HTML**

```tsx
return (
  <html
    lang="ja"
    data-theme="dark"
    className={`${geistSans.variable} ${geistMono.variable}`}
  >
    <body>
      <ThemeProvider>{children}</ThemeProvider>
    </body>
  </html>
);
```

- `lang="ja"` … ページの言語。あとで `ThemeProvider` が JP/EN 切替に合わせて書き換えます（§14, L30）。
- `data-theme="dark"` … **ここが重要**。`app/globals.css` L1-2 が

  ```css
  :root,
  [data-theme="dark"] { --bg: #0f1419; ... }
  ```

  と書いてあり、L22 が `[data-theme="light"] { ... }` で色を上書きします。
  つまり **`<html>` の属性を1つ書き換えるだけで全画面の色が入れ替わる**設計です。
  最初から `"dark"` が書いてあるのは、`ThemeProvider` の初期値も `"dark"`（§14, L25）なので、
  サーバーが送った HTML とブラウザで動いた結果が食い違わないようにするためです（★この整合の意図はコメントに書かれていないので推測★。ただし両者が一致している事実は確認済み）。
- `className={...}` … L6/L11 で作ったフォント用クラスを `<html>` に付けています。これで `--font-geist-sans` が使えるようになります。
- `<ThemeProvider>{children}</ThemeProvider>` … 全画面をテーマ・言語の配布役で包んでいます（§14）。

---

## 3. `app/page.tsx` — トップページは何も表示しない

全 5 行。

```tsx
import { redirect } from "next/navigation";   // L1

export default function Home() {              // L3
  redirect("/dashboard");                      // L4
}
```

- export しているもの: `Home`（**React コンポーネント**、`export default`）。ただし JSX を返しません。
- L4 `redirect("/dashboard")` … **平易に**: 「ここには何も無いので `/dashboard` へ行ってください」とブラウザに指示します。
  `redirect()` は内部的に例外を投げて処理を打ち切る仕組みなので、`return` を書く必要がありません（この関数は値を返さずに終わります）。
- 条件分岐: **ありません。** `/` を開いた人は無条件に `/dashboard` へ飛びます。
- このファイルは `(dashboard)` の**外**にあるため、サイドバー（`AppShell`）は付きません。表示前に飛ぶので不要です。

---

## 4. `app/(dashboard)/layout.tsx` — 10画面共通のサイドバーを付ける枠

全 9 行。

```tsx
import { AppShell } from "@/components/layout/AppShell";   // L1

export default function DashboardGroupLayout({             // L3
  children,
}: {
  children: React.ReactNode;                               // L6
}) {
  return <AppShell>{children}</AppShell>;                  // L8
}
```

- export しているもの: `DashboardGroupLayout`（**React コンポーネント**、`export default`）。
- やっていること: 受け取った `children` を `AppShell` で包んで返す。**それだけ**です。条件分岐はありません。
- 型の書き方が `app/layout.tsx` と違う点: ルートは `LayoutProps<"/">` を使っていますが、ここは `{ children: React.ReactNode }` を手書きしています。
  理由は `.next/types/routes.d.ts` L7 が `type LayoutRoutes = "/"` となっており、**ルートグループの layout は生成型に含まれていない**ため、`LayoutProps<...>` に渡せる文字列が無いからです。手書きが必要な状況になっています（★この判断はコメントに書かれていないので理由の部分は推測★。生成型に含まれていない事実は確認済み）。
- `React.ReactNode` … **平易に**: 「画面に置けるもの全部」を表す型（タグ、文字列、数値、配列、null など）。

**この1ファイルが効いている範囲**: `(dashboard)/` の下の10画面すべて。
画面を切り替えても layout は再生成されないため、**サイドバーは作り直されず、テーマや言語の状態も保持されます**。

### 4.1 対になる `page.tsx` の実物

`app/(dashboard)/dashboard/page.tsx`（全 5 行）:

```tsx
import { DashboardScreen } from "@/components/features/dashboard/DashboardScreen";  // L1

export default function DashboardPage() {                                            // L3
  return <DashboardScreen />;                                                        // L4
}
```

`app/(dashboard)/reports/page.tsx`（全 5 行、まだ作っていない画面の例）:

```tsx
import { PlaceholderScreen } from "@/components/layout/PlaceholderScreen";  // L1

export default function ReportsPage() {                                      // L3
  return <PlaceholderScreen title="レポート" />;                             // L4
}
```

**この形が D-Watch の一貫した約束です**: `page.tsx` は「どのコンポーネントを出すか」だけを書く**1行の取り次ぎ役**で、
中身のロジックは `components/features/<画面名>/` に置く。`CLAUDE.md` §9.1（L18-21）の分け方に対応しています。

未実装画面（reports / schedules / facilities / inspections / system）は `PlaceholderScreen`（`components/layout/PlaceholderScreen.tsx`, 全15行）で
題名と「準備中」だけを出します（`lib/i18n.ts` L50 に `準備中: "Coming soon"` の対訳あり）。
`docs/decisions.md` D-053 が **「マスタが無い間は、機能しない絞り込みUIを画面に出さない」**「固定の選択肢だけがあって何も起きないUIは、不具合と区別できないため」と定めており、
未完成のものを中途半端に見せない方針と揃っています。

---

## 5. `app/api/dashboard/route.ts` — 窓口の1行ずつの読み解き

全 102 行。**★依頼どおり全行を追います。★**

### 5.1 用語

| 専門用語 | 平易な言い換え |
|---|---|
| API ルート / ルートハンドラ | ブラウザからの問い合わせに JSON で答える窓口 |
| `GET` | 「ください」というお願いの種類（データを取るだけで、何も変更しない） |
| `async` / `await` | 「時間がかかる作業を待つ」印。待っている間、他の処理を止めない |
| クエリパラメータ | URL の `?` 以降の付帯条件。`?date=2026-09-01` の `date=2026-09-01` |
| バリデーション | 受け取った値が正しい形かの検査 |
| HTTP ステータス | 結果の3桁の番号。200=成功 / 422=あなたの指定が変 / 500=こちら側の故障 |
| `Promise.all` | 複数の待ち作業を**同時に**走らせて全部終わるのを待つ |
| `try` / `catch` | 「失敗したらこう処理する」の囲い |

### 5.2 冒頭のコメント（L1-14）— 何を返さないかが明記されている

```
/**
 * GET /dashboard — ダッシュボード（清掃結果サマリ / スケジュール進捗 / セキュリティバナー）
 *
 * 仕様の正は `d-watch-api.yaml` の /dashboard、各値の定義は `docs/decisions.md` D-049 です。
 * 形は `app/api/clean-logs/route.ts`（動く見本）に合わせています。
 *
 * ★`failure_breakdown` は返しません（D-048）★
 *   4分類の分類元（エラーテーブル）が未定義のため M3 送りです。
 *   空配列ではなく**プロパティごと省略**します。空配列は「当日の失敗0件」を意味するため、
 *   未実装をそれで表すと UI が「失敗なし」として正常表示してしまいます。
 *
 * ★コンパクト版清掃詳細ログはこのAPIに含めません★
 *   YAML の記述通り、画面は GET /clean-logs?result=problem&date_from=当日 を再利用します。
 */
```

読みどころが2つあります。

1. **「返さない」を空配列ではなくプロパティ省略で表す。** `docs/decisions.md` D-048 に同じ判断が書かれています。
   > **★空配列ではなく、プロパティごと省略する**: 空配列は「当日の失敗が0件」を意味するため、未実装をそれで表すと UI が「失敗なし」として正常表示してしまいます。プロパティが無ければ UI 側で undefined（未実装）と `[]`（0件）を区別できる

   **平易に**: 「0件です」と「まだ作っていません」を混同すると、画面が「異常なし」と嘘をつきます。だから項目そのものを送りません。
   これが `DashboardScreen.tsx` L152 / L161 の分岐（§9.9）とペアになっています。
2. **画面に出すコンパクト版ログは、この窓口では返さない。** 既にある `/api/clean-logs` を条件付きで呼び直します。
   結果として画面は **窓口を2つ叩く**ことになります（§9.6）。

### 5.3 import 文（実際のコードのまま、L15-26）

```ts
import { NextResponse } from "next/server";
import { parseDate } from "@/lib/clean-logs/query";
import {
  computeScheduleProgress,
  computeSecurityBanner,
  computeSummary,
} from "@/lib/dashboard/aggregate";
import { db } from "@/lib/db";
import { todayJst } from "@/lib/format/date";
import { logEvent, withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, DashboardResponse } from "@/lib/types";
```

注目点。

- `parseDate` を **`lib/clean-logs/query`** から借りています。日付検査の規則を2箇所に書かないための共有です。
- 計算は全部 `lib/dashboard/aggregate` にあります。このファイル自身は**足し算を1つもしません**（`lib/dashboard/aggregate.ts` L1-12 に「**純粋関数のみ**（DBアクセスを混ぜない）」「★丸めない★ 率は生値のまま返し、表示用の丸めは画面側で行う（CLAUDE.md §4.3 / §9.3）」）。
- `@/` は `tsconfig.json` で定義されたプロジェクトルートの別名。**平易に**: `../../../lib/...` と数えなくて済むようにする省略記法。

### 5.4 `const ROUTE = "/api/dashboard";`（L28）

ログに出す自分の名前です。`lib/logger/api.ts` L15-16 に

> /** 実際のパス。`/api/v1/...` のような架空のパスを書かない */

とあり、**ログのパスと本物の URL をズレさせない**ために定数にしています。

### 5.5 `export async function GET(request: Request)`（L30）

```ts
export async function GET(request: Request) {
```

- **関数名 `GET` が URL の動作を決めます。** ファイル名（`route.ts`）が場所を決め、**関数名が「お願いの種類」を決める**という約束です。
  POST を受けたいなら `export async function POST(...)` を同じファイルに追加します。このファイルには `GET` しかないので、
  `/api/dashboard` に POST すると Next.js が 405（メソッド不許可）を返します。
- `async` … 中で `await`（待ち）を使う印。
- `request: Request` … 来た問い合わせそのもの。URL やヘッダを読めます。ここでは L32（URL）と、`withRequestId` の中で L71（ヘッダ）が読んでいます。
- `export` が必要な理由: Next.js が**外から名前で見つけて呼ぶ**ためです。export していないと窓口として認識されません。

### 5.6 `withRequestId` で包む理由（L31, L101）

```ts
return withRequestId(request, async () => {
  ...
});
```

**用語**: リクエスト ID（相関 ID）… 1回の問い合わせに振る整理番号。

**なぜ必要か**。1つの問い合わせを処理する間に、ログが複数行出ます（警告1行 + エラー1行など）。
同時に何人も使っているとログが混ざり、どの行が同じ問い合わせのものか分からなくなります。
整理番号を全行に付けておけば、あとで番号で絞って1件分の流れを再現できます。

実装は `lib/logger/context.ts` L70-73:

```ts
export function withRequestId<T>(request: Request, fn: () => T): T {
  const fromHeader = normalizeRequestId(request.headers.get(REQUEST_ID_HEADER));
  return runWithRequestId(fromHeader ?? crypto.randomUUID(), fn);
}
```

- `x-request-id` ヘッダ（`REQUEST_ID_HEADER`, L34）があればそれを使い、無ければ `crypto.randomUUID()` で新規発行。
- ヘッダの値をそのまま信じず `normalizeRequestId`（L52-58）で `/^[A-Za-z0-9._-]{1,128}$/`（L43）に合うかを検査します。理由は同ファイル L36-41 に明記:
  > `proxy.ts`（ステップ7）が入るまではクライアントが直接送れる位置にあり、長大な値や制御文字を入れられるとログが読めなくなる。形が合わない値は捨てて生成し直す。
  （※`proxy.ts` は現時点でリポジトリに存在しません。確認済み。）
- `runWithRequestId`（L21-23）は `AsyncLocalStorage`（L9, L15）に ID をしまいます。
  **平易に**: 「この処理の間だけ有効な引き出し」に整理番号を入れておくと、その中から呼ばれた**すべてのログ関数が引数なしで取り出せる**。
  `lib/logger/index.ts` L46-51 の `mixin()` が毎行 `request_id` を自動で足します。
- `lib/logger/context.ts` L68 のコメントが目的をそのまま述べています:
  > ★5ルートに同じラップ処理を書き写さないための共通ヘルパー★

**包む範囲**: L31 で始まり L101 で閉じています。つまり**ハンドラの中身が丸ごと**入っています。
検査・取得・集計・ログの全部が整理番号の圏内なので、どこで出たログにも番号が付きます。

### 5.7 クエリパラメータの取り出し（L32）

```ts
const { searchParams } = new URL(request.url);
```

`request.url`（例: `http://localhost:3000/api/dashboard?date=2026-09-01`）を `URL` に食わせると、
`?` 以降を扱う道具 `searchParams` が取れます。分割代入（`const { searchParams } = ...`）で必要な部分だけ抜いています。

### 5.8 検証と 422 の返し方（L34-44）

```ts
const dateRaw = searchParams.get("date");
if (dateRaw && !parseDate(dateRaw)) {
  const body: ApiError = {
    error: {
      code: "validation_failed",
      message: "dateの形式が不正です",
      details: { date: ["YYYY-MM-DD形式で指定してください"] },
    },
  };
  return NextResponse.json(body, { status: 422 });
}
```

1行ずつ。

- L34 `searchParams.get("date")` … `?date=...` が無ければ `null` が返ります。
- L35 の条件 `dateRaw && !parseDate(dateRaw)` … **「指定があって、かつ形が不正なとき」だけ**エラーにします。
  `dateRaw` が `null`（未指定）なら `&&` の左が偽なので `parseDate` は呼ばれず、エラーになりません。**未指定は正常**という設計です。
- `parseDate`（`lib/clean-logs/query.ts` L29-34）の中身:

  ```ts
  export function parseDate(value: string | null): Date | null {
    if (!value) return null;
    if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
    const d = new Date(`${value}T00:00:00+09:00`);
    return Number.isNaN(d.getTime()) ? null : d;
  }
  ```

  検査は2段。まず `2026-09-01` という**桁の形**（正規表現）、次に**実在する日付か**（`2026-02-31` は形は合うが `NaN` になる）。
  `+09:00` を足しているのは JST として解釈させるためで、実行環境のタイムゾーンに結果を左右されないようにしています。
- L36-42 の `body` … **平易に**: 返す紙の下書き。`ApiError` という型に当てているので、キー名を間違えるとビルド時に叱られます。
  `lib/types/index.ts` L30-36 が定義:

  ```ts
  export interface ApiError {
    error: { code: ErrorCode; message: string; details?: Record<string, string[]> | null };
  }
  ```

  `code` は自由文字列ではなく union（同 L24-28: `"validation_failed" | "not_found" | "internal_error" | "not_implemented"`）。
  `CLAUDE.md` §9.3（L53）の **「エラーレスポンスは `{ error: { code, message, details? } }` で統一」** をコードで強制した形です。
- `details` の形は `{ 項目名: [説明文, ...] }`。**平易に**: 「どの項目が、なぜダメか」を項目別に返すので、画面が入力欄の横に出せます。
- L43 `NextResponse.json(body, { status: 422 })` … 本文を JSON にして、番号 422 を付けて返す。
  **422 の意味**: 「文法は読めたが、内容が受け付けられない」。**平易に**: 書類の書式が違う、という返し方です（400 でも 500 でもない）。
- `return` しているので、**ここで処理は終わり**。以降の DB 取得は行われません。

**★見落としやすい点★**: この 422 は `logApiError` を呼びません。ログを出すのは 500 のときだけ（L69）です。
**平易に**: 「利用者の指定ミス」はアプリの故障ではないので、エラーログには残さないという切り分けです（★この意図はコメントに無いので推測★。ただし `/clean-logs`・`/prechecks` も同じく 422 ではログを出さず、3ルートで扱いが揃っていることは確認済み）。

### 5.9 既定値の決定（L45-46）

```ts
// 省略時は当日（＝昨夜分）。翌朝確認型の運用に合わせる
const date = dateRaw ?? todayJst();
```

- `??`（null 合体演算子）… 左が `null`/`undefined` のときだけ右を使う。**平易に**: 「指定が無ければ今日」。
- `todayJst()`（`lib/format/date.ts` L19-26）は `Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Tokyo", ... })` で JST の今日を `YYYY-MM-DD` にします。
  `en-CA` を選ぶのは、このロケールの既定表記が `2026-09-02` の形（年-月-日）だからです。
  サーバーのタイムゾーンが UTC でも日本時間の日付が得られます。
- コメントの「翌朝確認型」は `CLAUDE.md` §1 の運用像を指しています。夜間に清掃が走り、朝それを確認する使い方なので、既定が「今日」でよい、という筋です。

### 5.10 受け取るが無視するパラメータ（L48-50）

```ts
// group_id / customer_id は受け取るが現状は無視する。
// fixtures に顧客グループの絞り込みに使える対応表が無く、/clean-logs も同じ扱いのため、
// ここだけ先に実装すると2箇所で意味が違う状態になる。実DB接続時にまとめて対応する。
```

コードは1行もありません。**「意図的に何もしていない」ことを書き残したコメント**です。
`docs/decisions.md` D-053（「マスタが無い間は、機能しない絞り込みUIを画面に出さない」）と同じ判断で、
`app/api/prechecks/route.ts` L44-47 にもほぼ同文のコメントがあります。

### 5.11 データ取得 — `Promise.all` で3つ同時（L52-71）

```ts
let logs;
let schedules;
let dsAlerts;
try {
  [logs, schedules, dsAlerts] = await Promise.all([
    db.cleanLogs(),
    db.schedules(),
    db.dsAlerts(),
  ]);
} catch (e) {
  const body: ApiError = {
    error: {
      code: "internal_error",
      message: "データの取得に失敗しました",
      details: null,
    },
  };
  logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
  return NextResponse.json(body, { status: 500 });
}
```

**なぜ `let` を先に3つ宣言しているか（L52-54）**
`try { const [a,b,c] = ... }` と書くと、`a,b,c` は `try` の中括弧の中だけで有効になり、L75 以降で使えません。
そこで**外で宣言 → 中で代入**という形にしています。**平易に**: 箱を先に外に出しておいて、中身だけを try の中で詰める。

**なぜ `Promise.all` か**
3つの取得は互いに独立です。順番に待つと「①の時間 + ②の時間 + ③の時間」かかりますが、
同時に投げれば「一番遅い1つの時間」で済みます。**平易に**: 3人に同時に頼んで、全員から返ってくるのを待つ。

**`Promise.all` の失敗の性質（重要）**: 3つのうち**1つでも失敗すれば全体が失敗**し、`catch` に落ちます。
その場合、成功していた2つの結果は使われません。**平易に**: 部分的に表示するのではなく、まとめて「取得失敗」にする方針です。
（同じ「全部か無しか」の割り切りは、画面側 `DashboardScreen.tsx` L50-51 のコメントにも明文化されています。§9.6 参照。）

**`try` の範囲**: L55 から L71。**取得だけ**を囲んでいます。
L75 以降の集計（`computeSummary` など）は**囲みの外**です。
`lib/dashboard/aggregate.ts` の関数は例外を投げない純粋な計算なので、囲む必要がないという判断です（★この線引きの意図はコメントに無いので推測★。純粋関数であることは同ファイル L2 に「**純粋関数のみ**」と明記されています）。

**`catch` で返すもの**
- 本文は `{ error: { code: "internal_error", message: "データの取得に失敗しました", details: null } }`。
  **例外の生メッセージは入れません。** SQL 文や接続文字列が画面に漏れるのを防ぐためです（★この理由はこのファイルには書かれていないので推測★。ただし `lib/logger/api.ts` L7 に「生の例外メッセージは `error_message` に入らない（定型文のみ）」と同じ方針が明記されています）。
- `logApiError({ route, method, body, status: 500, err: e })` … **画面に返すのと同一の `body` をログにも渡す**のが要点。
  `lib/logger/api.ts` L4-6:
  > クライアントへ返す `ApiError` を**そのまま**受け取るのが要点。ログ側で code / message を組み立て直すとレスポンスとズレるため、同一オブジェクトから読む形にして構造的に防ぐ（docs/logging-design.md §3）。
- ログの中身は `lib/logger/api.ts` L33-45 で `event: "api.error"`, `route`, `method`, `error_code`, `error_message`, `http_status` の形になります。
  例外そのもの（`err`）は pino のシリアライザに渡され、スタックトレースはログ側にだけ残ります。
- そして `return` で 500 を返して終了。

**`db` とは何か**: `lib/db/index.ts` L129

```ts
export const db: DataSource = USE_FIXTURES ? fixtureSource : prismaSource;
```

`USE_FIXTURES`（同 L40: `process.env.USE_FIXTURES !== "false"`）で、**サンプルデータ**と**本物の DB** を切り替えます。
既定はサンプル側（環境変数に明示的に `"false"` を入れない限り fixtures）。
`CLAUDE.md` §9.5（L68）が **「APIルートは `USE_FIXTURES` で fixtures / Prisma を切り替える。**画面側は切替を意識しない**」** と定めており、
このファイルにも `DashboardScreen.tsx` にも `USE_FIXTURES` は出てきません。切替は `lib/db` の中に閉じています。

### 5.12 当日分の絞り込み（L73-75）

```ts
// 日付の切り出しは文字列で行う。scheduled_at は ISO8601(+09:00) なので
// 先頭10文字が JST の日付そのものになり、実行環境のTZに依存しない
const logsOfDay = logs.filter((row) => row.scheduled_at.slice(0, 10) === date);
```

`2026-09-01T23:00:00+09:00` の先頭10文字は `2026-09-01`。
**平易に**: 日付として計算し直すと「サーバーが UTC だと1日ずれる」事故が起きるので、**文字として先頭10文字を切って比べる**。
`Date` に変換しないのがミソです。同じやり方が `lib/prechecks/query.ts`（`filterPrechecksByDate` の直前のコメント）にも
「app/api/dashboard/route.ts と同じやり方」と明記され、共通ルールになっています。

### 5.13 集計の呼び出し（L77-79）

```ts
const summary = computeSummary(logsOfDay);
const scheduleProgress = computeScheduleProgress(logsOfDay, schedules, date);
const securityBanner = computeSecurityBanner(dsAlerts);
```

このファイルは**呼ぶだけ**で、中の計算をしていません。中身の要点だけ（`lib/dashboard/aggregate.ts`）:

- `computeSummary`（L31-59）… 1行ずつ見て `departed` が偽なら `not_executed`、◎○なら `success`、△×なら `failed`。
  `success_rate` は `denominator === 0 ? null : (success / denominator) * 100`（L57）で、**丸めません**（同 L10-11「★丸めない★ 表示用の丸めは画面側で行う」）。
  0件のとき `null` にするのは 0除算を避けるためで、画面は `displayRate()` が `"-"` にします（§9.8）。
- `computeScheduleProgress`（L84-103）… `planned` は**スケジュールマスタ**から曜日で数えた件数（L90-93）、
  `completed` は `departed === true` の行数（L96）、`rate` は `planned === 0` なら `null`。
  `docs/decisions.md` D-049:
  > **★`planned` は清掃ログの行数ではない**: 行数を使うと、収集処理が止まって当日の行が0件のときに「本当に定休日」と「収集処理の障害」が区別できなくなる
  > **`completed` は名前と意味がずれている**: **成功+失敗の合計**であり、「完了した件数」ではない
- `computeSecurityBanner`（L113-122）… `alert_type === "security_off"` かつ `resolution_status !== "done"` のものだけを、
  `facility_name` / `section_name` / `since` の**3項目に絞って**返します。同 L110-111:
  > 自由記述の `detail` / `memo` は含めない（§4 の記録禁止フィールド。レスポンスに載せる必要も無い）。

### 5.14 `data.anomaly` を出す条件（L81-93）★依頼の重点★

```ts
// スケジュール由来の予定件数と当日の清掃ログ行数が合わないことの記録（D-049）。
// 収集処理が止まると当日の行が作られないため、ここが最初の手がかりになる。
// 原因の断定は書かない（logging-design.md §7）。差分だけを残す。
if (logsOfDay.length !== scheduleProgress.planned) {
  logEvent("warn", {
    event: "data.anomaly",
    route: ROUTE,
    method: "GET",
    metric: "clean_log_rows_vs_scheduled",
    expected: scheduleProgress.planned,
    actual: logsOfDay.length,
  });
}
```

**まず誤解しやすい点**: `data.anomaly` は**レスポンスに入りません**。**ログに1行出るだけ**です。
画面には何も変化がなく、運用者がログを見たときだけ気づく類の記録です。

**出る条件**（L84）: `logsOfDay.length !== scheduleProgress.planned`
= 「今日の清掃ログの行数」と「今日の予定件数」が**一致しないとき**。

- 不等号ではなく `!==` なので、**多い場合も少ない場合も**出ます。
- 予定 0 件で行が 5 件あっても出ます（`0 !== 5`）。
- 一致していれば何も出ません（正常時にログを汚さない）。

**具体例**（★想定例。fixtures の実データではありません★）
- 予定 12 件 / ログ 12 行 → 一致。ログなし。
- 予定 12 件 / ログ 0 行 → `expected: 12, actual: 0` の warn が出る。**収集処理が止まった疑い**。
- 予定 0 件（全施設が定休日）/ ログ 0 行 → 一致。ログなし。

**なぜこの記録が必要か**: D-049 の
> **監視**: `planned` と当日の清掃ログ行数が乖離した場合は `data.anomaly` イベントを記録する。収集処理の障害検知になる。期待値と実測値だけを残し、原因は断定しない

**「原因の断定は書かない」の意味**: ログに `"収集処理が停止しています"` と書くと、実際は定休日設定ミスだった場合に
読んだ人を誤った方向へ誘導します。だから `expected` と `actual` の**数字だけ**を残し、解釈は人に任せます。
レベルが `"error"` ではなく `"warn"` なのも同じ理由で、**壊れたとは断定していない**という表明です（★レベル選択の理由はコメントに無いので推測★）。

### 5.15 正常系の返却（L95-100）

```ts
const body: DashboardResponse = {
  summary,
  schedule_progress: scheduleProgress,
  security_banner: securityBanner,
};
return NextResponse.json(body);
```

- キー名が `schedule_progress` / `security_banner` と**スネークケース**（下線区切り）。`CLAUDE.md` §9.2（L44）
  **「API のクエリパラメータ・レスポンスキー: snake_case（YAML準拠）」** に従っています。
  一方 TypeScript の変数名は `scheduleProgress` とキャメルケース（同 L42）。**境界で名前の流儀が切り替わります**。
- `failure_breakdown` は**キーごと存在しません**（§5.2 / D-048）。
  `lib/types/index.ts` L322-334 で `failure_breakdown?:` と `?`（省略可）になっているため、書かなくても型検査を通ります。
- `NextResponse.json(body)` に `status` を渡していないので既定の **200**。

### 5.16 このファイルの分岐の全リスト

| # | 行 | 条件 | 結果 |
|---|---|---|---|
| 1 | L71（`withRequestId` 内） | `x-request-id` ヘッダが規則に合う | その ID を使う / 合わなければ新規発行 |
| 2 | L35 | `date` の指定があり、形が不正 | 422 を返して終了（ログは出さない） |
| 3 | L46 | `date` が未指定 | `todayJst()` を使う |
| 4 | L61 | 3つの取得のいずれかが例外 | `api.error` をログに出し 500 を返して終了 |
| 5 | L84 | ログ行数 ≠ 予定件数 | `data.anomaly` を warn でログに出す（レスポンスは変わらない） |
| 6 | L100 | 上記を通過 | 200 で `{summary, schedule_progress, security_banner}` |

---

## 6. `app/api/clean-logs/route.ts` — 「動く見本」

全 60 行。冒頭 L4 に自己申告があります。

> ★これが「動く見本」です。他の一覧APIはこの形を真似して作ってください★

L6-7 も重要な運用ルールです。

> 仕様の正は `d-watch-api.yaml` の /clean-logs です。
> APIを変えたくなったら、先にYAMLを直してからコードに反映してください（CLAUDE.md 作業ルール1）。

L9-14 に処理の目次がそのまま書かれています。

> 1. クエリパラメータのパースとバリデーション（31日超は422）
> 2. データソース（fixtures または Prisma）からの取得
> 3. フィルタ（日付範囲 / 施設名 / 機種 / シリアル / 判定）
> 4. ページング
> 5. `{ items, meta }` 形式で返却

### 6.1 import 文（L16-21、実際のコードのまま）

```ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { filterCleanLogs, parseCleanLogQuery } from "@/lib/clean-logs/query";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, CleanLogItem, ListResponse } from "@/lib/types";
```

`/api/dashboard` との違いは `logEvent` を import していない点（`data.anomaly` に相当する監視がこの窓口には無い）。

### 6.2 export しているもの

| export | 形 |
|---|---|
| `GET` | **async 関数**（ルートハンドラ） |

`errorResponse`（L25-27）は export されていない**ファイル内ヘルパー関数**です。

```ts
function errorResponse(error: ApiError, status: number) {
  return NextResponse.json(error, { status });
}
```

### 6.3 中で何をしているか（L29-59、順に）

1. **L30** `withRequestId(request, async () => {...})` で全体を包む（§5.6 と同じ）。
2. **L31** `const { searchParams } = new URL(request.url);`
3. **L32** `const parsed = parseCleanLogQuery(searchParams);`
   検査と既定値の決定を**全部 lib に委ねています**。`/api/dashboard` が `parseDate` を直接呼んで自前で 422 を組んでいたのに対し、
   こちらは条件が多いので関数まるごと外に出す形です。
4. **L33-35** 分岐①

   ```ts
   if (!parsed.ok) {
     return errorResponse(parsed.error, parsed.status);
   }
   ```

   **用語**: 判別可能なユニオン型（discriminated union）… **平易に**: 「成功の箱」か「失敗の箱」のどちらかが返ってきて、
   `ok` という札を見ればどちらか分かる仕組み。TypeScript は `if (!parsed.ok)` を通り抜けた後は
   `parsed.query` が必ず存在すると理解するので、余計な `null` チェックが要りません。
   `parseCleanLogQuery`（`lib/clean-logs/query.ts`）の 422 は次の5種類です。
   - `date_from` の形が不正 → `{ date_from: ["YYYY-MM-DD形式で指定してください"] }`
   - `date_to` の形が不正
   - `date_from > date_to`（逆順）→ 「date_fromはdate_to以前の日付を指定してください」
   - 範囲が `MAX_DATE_RANGE_DAYS`（`lib/config/index.ts` L43 = **31日**）超 → 「date_from〜date_toは31日以内で指定してください」+ `{ date_to: ["指定された範囲はN日です"] }`
   - `result` が `problem` / `critical` / `all` 以外
   閾値 31 を直書きせず `lib/config` から読むのは `CLAUDE.md` §9.4（L63）**「閾値は `lib/config/` から読む。ハードコード禁止」** に従ったものです。
   `page` / `per_page` は**エラーにせず補正**します（`Math.max(1, ...)` と `Math.min(PAGINATION.maxPerPage, ...)`）。
   **平易に**: 「0ページ目」や「100万件ください」は叱らずに、安全な値へ丸めます。
5. **L37-46** 取得と分岐②

   ```ts
   let rows: CleanLogItem[];
   try {
     rows = await db.cleanLogs();
   } catch (e) {
     const body: ApiError = {
       error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
     };
     logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
     return NextResponse.json(body, { status: 500 });
   }
   ```

   `/api/dashboard` と**同じ文言・同じ形**です。1つしか取らないので `Promise.all` は使いません。
   `let rows` を try の外で宣言するのも同じ理由（§5.11）。
   なお **L45 は `errorResponse()` を使わず `NextResponse.json()` を直接呼んでいます**。同じ結果になりますが書き方が揃っていません（`/api/prechecks` L57 も同様）。害はありませんが、見本として読むときは気づく箇所です。
6. **L48-52** フィルタとページング

   ```ts
   const filtered = filterCleanLogs(rows, parsed.query);
   const { page, perPage } = parsed.query;
   const total = filtered.length;
   const start = (page - 1) * perPage;
   const items = filtered.slice(start, start + perPage);
   ```

   **用語**: ページング（ページネーション）… **平易に**: 全部を一度に返さず、20件ずつなど**小分けにして渡す**こと。
   `start = (page - 1) * perPage` … 1ページ目なら 0 番目から、2ページ目なら `perPage` 番目から。
   `total` は**絞り込んだ後・小分けする前**の件数です。これが「全部で何件あるか」の表示と「次のページがあるか」の判断に使われます。
7. **L54-58** 返却

   ```ts
   const body: ListResponse<CleanLogItem> = {
     items,
     meta: { total, page, per_page: perPage },
   };
   return NextResponse.json(body);
   ```

   `CLAUDE.md` §9.3（L54）**「一覧APIは必ず `meta: { total, page, per_page }` を返す」** に対応。
   型 `ListResponse<T>`（`lib/types/index.ts` L38-41）は中身の型を差し替えられる**ジェネリクス**（**平易に**: 「箱の型」だけ決めて、
   中に入れる物の種類はあとから指定する仕組み）。

### 6.4 分岐の全リスト

| # | 行 | 条件 | 結果 |
|---|---|---|---|
| 1 | L33 | `parsed.ok` が偽 | `parsed.status`（=422）で `parsed.error` を返す |
| 2 | L40 | `db.cleanLogs()` が例外 | `api.error` をログに出し 500 |
| 3 | L58 | 通過 | 200 で `{items, meta}` |

---

## 7. `app/api/prechecks/route.ts` — summary をフィルタ前に計算する例

全 83 行。§6 とほぼ同形なので、**違う所だけ**を詳しく見ます。

### 7.1 冒頭コメント（L1-8）

> 形は `app/api/clean-logs/route.ts`（動く見本）に合わせている。
> 仕様の正は `d-watch-api.yaml` の /prechecks、status=open の意味は docs/decisions.md D-054。
>
> ★対応状況の更新（PATCH /prechecks/{id}/status）は今回作らない。DB側の準備待ち★

このファイルには `GET` しかありません。だから更新（PATCH）はできません。**平易に**: 窓口に「変更受付」の担当者を置いていない状態です。

### 7.2 import 文（L9-21、実際のコードのまま）

```ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import {
  filterPrechecksByDate,
  filterPrechecksByStatus,
  parsePrecheckQuery,
  sortPrechecks,
  summarizePrechecks,
} from "@/lib/prechecks/query";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, PrecheckItem, SummaryListResponse } from "@/lib/types";
import type { PrecheckSummary } from "@/lib/prechecks/query";
```

日付フィルタと状況フィルタが**別々の関数**として import されている点に注目してください（L12-13）。
これが §7.4 の「間に summary を挟む」ために必要な分け方です。

### 7.3 検証（L32-42）

```ts
const query = parsePrecheckQuery(searchParams);
if (!query) {
  const body: ApiError = {
    error: {
      code: "validation_failed",
      message: "クエリパラメータの値が不正です",
      details: null,
    },
  };
  return errorResponse(body, 422);
}
```

`/clean-logs` は `{ok, error, status}` の箱を返しましたが、ここは **`null` か値かの2択**（`lib/prechecks/query.ts` L36-38: `PrecheckQuery | null`）。
そのため 422 のメッセージも `"クエリパラメータの値が不正です"` の**1種類のみ**で、`details` も `null` です。
**どの項目がダメかが利用者に伝わりません。** `/clean-logs` の方が親切な作りになっており、ここは見本に完全には揃っていません（観察。良し悪しの判断はコメントに無し）。

`parsePrecheckQuery`（`lib/prechecks/query.ts` L36-61）の中身:
- `date_from` / `date_to` が形不正 → `null`
- `status` の既定は `"open"`（L45: `searchParams.get("status") ?? "open"`）。`STATUS_FILTERS` に無い値は `null`
- `page` / `per_page` は `/clean-logs` と同様に補正

`status` の既定が `open` である根拠は `docs/decisions.md` D-054:
> GET /prechecks の `status` enum に `open` を追加する: `[open, unresolved, in_progress, resolved, all]`。既定値を `unresolved` から `open` に変更する。`open` = 未解消+対応中（`resolution_status !== "resolved"`）

### 7.4 ★summary をフィルタ前に計算する（L60-65）★ — このファイルの主題

```ts
const dated = filterPrechecksByDate(rows, query);
// ★summaryは日付範囲で絞った後・対応状況フィルタの前に計算する（D-054）★
//   状況フィルタの後だと、既定表示（status=open）で解消済みカードが常に0になり、
//   サマリーの役目を果たさなくなる。日付範囲を変えたときだけ summary が変わる
const summary: PrecheckSummary = summarizePrechecks(dated);
const sorted = sortPrechecks(filterPrechecksByStatus(dated, query.status));
```

**処理の順番が答えです。**

```
rows（全件）
  └→ filterPrechecksByDate（日付で絞る）→ dated
        ├→ summarizePrechecks(dated)          ← ★ここで数える★
        └→ filterPrechecksByStatus(dated, …)  → sortPrechecks → sorted → ページング → items
```

`dated` を**2方向に分岐**させているのが要点で、`summary` は状況フィルタの影響を受けません。

**なぜこの順番でなければならないか（具体例で）**
画面には「未解消 / 解消済み / 合計」の3枚のカードが並びます。既定の絞り込みは `status=open`（= 未解消+対応中）です。
もし summary を状況フィルタの**後**に計算すると、`open` で絞った集合には解消済みが1件も入っていないので、
**「解消済み: 0」と常に表示されます**。「今日は8件中3件片付いた」という情報が永久に見えません。
D-054 の追記がそのまま同じことを述べています。
> summary は status フィルタの前に計算する。状況で変わると既定表示で解消済みが常に0になり、サマリーの意味がなくなる

`lib/prechecks/query.ts` の `filterPrechecksByDate` 直前のコメントにも同じ注意が置かれ、**呼ばれる側にも書いてあります**。
> ★summary はこの関数の結果に対して計算する（status フィルタの前）★

**D-054 の背景（数字の実例）**
> fixturesの8件は未解消3+対応中2+解消3であり、`unresolved`(3)+`resolved`(3)=6 が `total`(8) と合わない状態だった

そこで `summary.unresolved` の意味を「未解消**+対応中**」に変え、`unresolved(5) + resolved(3) = total(8)` が成立するようにしました。
**★フィールド名は変えていません★**（D-054: 「名前を変えると型と実装の両方に波及するため」）。
つまり `unresolved` という名前は**字面より広い意味**を持っています。読むときの注意点です。

### 7.5 `meta.total` と `summary.total` は別物（L67-74）

```ts
const { page, perPage } = query;
// ★meta.total と summary.total は意味が違う★
//   meta.total   = 一覧（対応状況フィルタ後）の総件数。ページング計算用
//   summary.total = 日付範囲で絞った当日の全件（対応状況フィルタの影響を受けない）
//   status=all のときだけ一致する。status=open 等で絞ると meta.total < summary.total になりうる
const total = sorted.length;
const start = (page - 1) * perPage;
const items = sorted.slice(start, start + perPage);
```

**平易に**: 同じ「total」という言葉が2箇所に出てくるが、数える対象が違う。
`meta.total` は「いま一覧に出しうる件数（ページ送りの計算用）」、`summary.total` は「その日の全件」。
上の fixtures の例なら `status=open` のとき `meta.total = 5`、`summary.total = 8` になります。

### 7.6 返却（L76-81）

```ts
const body: SummaryListResponse<PrecheckItem, PrecheckSummary> = {
  summary,
  items,
  meta: { total, page, per_page: perPage },
};
return NextResponse.json(body);
```

`SummaryListResponse<TItem, TSummary>`（`lib/types/index.ts` L43-47）は
`ListResponse` に `summary` を足した型。**平易に**: 「集計 + 明細 + ページ情報」の3点セット。
`/clean-logs` は集計を持たないので `ListResponse`、`/prechecks` は持つので `SummaryListResponse`、と型で区別されています。

### 7.7 分岐の全リスト

| # | 行 | 条件 | 結果 |
|---|---|---|---|
| 1 | L33 | `parsePrecheckQuery` が `null` | 422（`details: null`、項目名なし） |
| 2 | L52 | `db.prechecks()` が例外 | `api.error` をログに出し 500 |
| 3 | L81 | 通過 | 200 で `{summary, items, meta}` |

---

## 8. 3つの窓口の比較表

| | `/api/dashboard` | `/api/clean-logs` | `/api/prechecks` |
|---|---|---|---|
| 行数 | 102 | 60 | 83 |
| `withRequestId` | ○ L31 | ○ L30 | ○ L30 |
| 検証の場所 | ルート内で `parseDate` を直呼び | `parseCleanLogQuery`（`{ok,...}` の箱） | `parsePrecheckQuery`（`null` か値） |
| 422 の細かさ | 項目名つき（`details.date`） | 項目名つき・5種類 | **1種類のみ・`details: null`** |
| DB 取得 | `Promise.all` で**3本同時** | 1本 | 1本 |
| 500 の文言 | 「データの取得に失敗しました」 | 同一 | 同一 |
| 集計 | `lib/dashboard/aggregate` の3関数 | なし | `summarizePrechecks`（**フィルタ前**） |
| 監視ログ | `data.anomaly`（L84-93） | なし | なし |
| 返す形 | `{summary, schedule_progress, security_banner}` | `{items, meta}` | `{summary, items, meta}` |
| 意図的な未実装 | `failure_breakdown` を省略（D-048） | — | PATCH を作らない |
| 無視するパラメータ | `group_id` / `customer_id`（L48-50） | 同（D-053） | 同（L44-47） |

---

## 9. `components/features/dashboard/DashboardScreen.tsx` — 画面の本体

全 177 行。**ダッシュボードの中身は全部ここにあります。**

### 9.1 import 文（L1-18、実際のコードのまま）

```tsx
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CleanLogTable } from "@/components/features/detail-log/CleanLogTable";
import { Pill } from "@/components/ui/Pill";
import { StatCard } from "@/components/ui/StatCard";
import { useTheme } from "@/components/theme/ThemeProvider";
import { apiFetch, ApiFetchError } from "@/lib/api/client";
import { SUCCESS_RATE_TARGET } from "@/lib/config";
import { todayJst } from "@/lib/format/date";
import { displayRate } from "@/lib/judge";
import { THEME_TOKENS } from "@/lib/theme";
import type { CleanLogItem, DashboardResponse, ListResponse } from "@/lib/types";
import { ScheduleProgressCard } from "./ScheduleProgressCard";
import { SecurityBanner } from "./SecurityBanner";
import styles from "./DashboardScreen.module.css";
```

### 9.2 export しているもの

| export | 形 |
|---|---|
| `DashboardScreen` | **React コンポーネント**（名前付き export。`default` ではない） |

`app/(dashboard)/dashboard/page.tsx` L1 が `import { DashboardScreen } from ...` と波カッコで受けているのは、
これが名前付き export だからです。`page.tsx` 側は `export default` である必要があります（Next.js が既定 export を探すため）。

### 9.3 ★`"use client"`（L1）が何を意味するか★

**用語**: サーバーコンポーネント / クライアントコンポーネント

**平易な言い換え**
- **サーバーで描く**（既定）… サーバーが HTML を組み立て、完成品をブラウザに送る。**印刷済みのチラシを郵送する**イメージ。
  ブラウザに JavaScript を送らないので軽い。ただし**クリックに反応できず、状態も持てません**。
- **ブラウザで描く**（`"use client"` を付けたとき）… 部品の設計図と JavaScript をブラウザに送り、ブラウザ側で組み立てる。
  **組み立てキットを送る**イメージ。ボタンを押したら数字が変わる、といった動きが作れます。

**なぜ `DashboardScreen` に必要か**。次の4つは全部「ブラウザ側でないとできないこと」です。

1. `useState`（L26-29）で状態を持つ
2. `useEffect`（L63-66）で表示後に取得を走らせる
3. `onClick`（L108, L115）でクリックに反応する
4. `useTheme()`（L21）で `ThemeProvider` の Context を読む — `ThemeProvider` 自身が `"use client"`（§14）

**ファイル1行目に書く必要がある理由**: `"use client"` は**その位置から下流全体**への宣言です。
このファイルが `"use client"` なら、ここから import している `StatCard` や `Pill` も自動的にブラウザ側の部品として扱われます。
そのため `StatCard.tsx` や `Pill.tsx` には `"use client"` が**書かれていません**（確認済み）— 書く必要がないからです。

**D-Watch での実際の切り分け**（`grep` で確認）
- `"use client"` あり: `DashboardScreen.tsx` L1, `AppShell.tsx` L1, `ThemeProvider.tsx` L1, `PlaceholderScreen.tsx` L1、他の画面本体
- `"use client"` なし（サーバー側）: 全部の `page.tsx` / `layout.tsx`、`StatCard.tsx`、`ProgressBar.tsx`、`GradeBadge.tsx`、`Pill.tsx`、`ScheduleProgressCard.tsx`、`SecurityBanner.tsx`

つまり **「入口（page/layout）はサーバー、画面本体から下はブラウザ」** という境界の引き方です。
`page.tsx` が1行の取り次ぎで済んでいるのは、この境界を1箇所に固定するためでもあります。

### 9.4 ★React のフック（Hook）とは何か★

**用語**: フック（Hook）

**平易な言い換え**: `use` で始まる特別な関数群で、**コンポーネントに「記憶」や「タイミング」を持たせる道具**です。
コンポーネントは本来「同じ入力なら同じ絵を返す」だけの関数ですが、
実際の画面には「読み込み中かどうか」「取ってきたデータ」など**覚えておくべきこと**があります。フックがそれを担います。

大事な前提を2つ。

1. **コンポーネントは何度も呼ばれます。** 状態が変わるたびに関数の中が上から下まで再実行され、
   新しい絵が描かれます（**平易に**: パラパラ漫画の次のコマを描き直す）。この「もう一度描き直す」ことを**再描画（re-render）**と呼びます。
2. **フックは関数の一番上で、毎回同じ順番で呼ばなければなりません。** `if` の中に入れてはいけません。
   React は呼ばれた**順番**で「どの記憶がどれか」を対応づけているためです。
   `DashboardScreen` の L21-29 と L31 / L63 は、すべて `return` より前の階層に平らに並んでいます。

### 9.5 各フックを何のために使っているか（`DashboardScreen` の実物）

#### `useTheme()`（L21-22）— 自作フック

```tsx
const { t, theme } = useTheme();
const c = THEME_TOKENS[theme];
```

React 標準ではなく `ThemeProvider.tsx` L53-59 で自作したフックです（§14）。
`t` は翻訳関数、`theme` は `"dark"` か `"light"`。
L22 で `THEME_TOKENS[theme]` を引いて色の表 `c` を作りますが、**`c` を使っているのは L85 の1箇所だけ**です。

#### `useRouter()`（L23）

```tsx
const router = useRouter();
```

**平易に**: 「ページを移動させる装置」。L108 と L115 の `router.push("/detail-log")` で使います。
`<Link>` と違い、**JavaScript から任意のタイミングで**移動できます（クリックハンドラの中など）。

#### ★`useMemo`（L24）— なぜ `today` を固定するのか★

```tsx
const today = useMemo(() => todayJst(), []);
```

**用語**: `useMemo`（メモ化）

**平易な言い換え**: 「一度計算した答えを覚えておいて、次からは計算し直さずその答えを使う」。
第2引数の `[]`（空の配列）が「**覚え直すきっかけは何も無い**」という指定で、
つまり **この部品が画面に置かれてから消えるまで、`today` はずっと同じ文字列**になります。

**目的（2つ）**

1. **`fetchAll` の依存を安定させ、取得の無限ループを防ぐ。**
   L61 で `fetchAll` は `[today]` に依存し、L66 で `useEffect` は `[fetchAll]` に依存します。
   つまり `today` → `fetchAll` → `useEffect` という**鎖**になっています。
   `today` が描画ごとに変わると `fetchAll` が作り直され、`useEffect` が再実行され、状態が変わって再描画され…と回り続けます。
   `useMemo` で `today` を固定すると、この鎖の起点が動かなくなります。
2. **表示中に日付が変わってもズレない。**
   `todayJst()` は呼んだ瞬間の JST の日付を返します。夜間監視の画面を開いたまま日付が変わる可能性があるので、
   固定しないと **ヘッダの日付（L81）と、取得に使った日付（L39-40）が食い違う**危険があります。
   `useMemo` で開いた時点の日付に釘付けにしています。

**★推測の明示★**: L24 にコメントはありません。上の「目的」は、L61 の `[today]` / L66 の `[fetchAll]` という
実際の依存関係と、`todayJst()` の実装（`lib/format/date.ts` L19-26 が現在時刻を読む）から導いた読み解きです。
なお `today` は文字列（プリミティブ値）なので、日付が変わらない限り `useMemo` を外しても値としては等しく、
1 の無限ループは起きません。**実際に効いているのは主に 2 と、「毎描画で `Intl.DateTimeFormat` を作らない」節約**です。

#### `useState`（L26-29）— 4つの記憶

```tsx
const [data, setData] = useState<DashboardResponse | null>(null);
const [logs, setLogs] = useState<CleanLogItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
```

**用語**: `useState`（状態）

**平易な言い換え**: 「この部品が覚えておく付箋」。`useState(初期値)` は
**［今の値, 値を書き換える関数］** の2つ組を返します。書き換える関数を呼ぶと、**React が自動で画面を描き直します**。
`data = 新しい値` と直接代入しても画面は変わりません。必ず `setData(...)` を通します。

D-Watch の4つの付箋:

| 変数 | 型 | 初期値 | 何を覚えるか |
|---|---|---|---|
| `data` | `DashboardResponse \| null` | `null` | `/api/dashboard` の結果。まだ無い＝`null` |
| `logs` | `CleanLogItem[]` | `[]` | `/api/clean-logs` の明細（最大5件） |
| `loading` | `boolean` | **`true`** | 取得中かどうか |
| `error` | `string \| null` | `null` | エラー文言。無ければ `null` |

**`loading` の初期値が `true` である点が効いています。** 最初の1コマ目から「読み込み中…」が出るので、
一瞬「データ0件」の空の画面が見えることがありません。

#### `useCallback`（L31-61）— `fetchAll`

```tsx
const fetchAll = useCallback(async () => {
  ...
}, [today]);
```

**用語**: `useCallback`

**平易な言い換え**: `useMemo` の「関数版」。**関数そのものを覚えておいて、毎回作り直さない**。

**なぜ必要か**: JavaScript では、中身が同じ関数でも作り直すと「別物」と判定されます。
`useEffect(..., [fetchAll])`（L66）は「`fetchAll` が別物になったら実行し直す」という意味なので、
`useCallback` で包まずに毎描画で新しい関数を作ると、**取得 → 状態更新 → 再描画 → 新しい関数 → また取得**の無限ループになります。
`[today]` を依存に指定することで、「`today` が変わらない限り同じ関数」が保証されます。

**`fetchAll` の中身（L32-60、順に）**

```tsx
setLoading(true);      // L32
setError(null);        // L33
```
まず「読み込み中」に戻し、前回のエラーを消します。**再実行に備えた掃除**です。

```tsx
// コンパクト版ログは △×のみ・当日・上位5件に固定する（トグルは持たせない。
// 全件を見たいときは「全項目 →」で F-02 に遷移する切り分け）
const qs = new URLSearchParams({
  result: "problem",
  date_from: today,
  date_to: today,
  per_page: "5",
}).toString();
```
`URLSearchParams`（L37-42）は `?` 以降を安全に組み立てる標準機能。**平易に**: 文字列を手で `+` でつなぐと
記号の変換（エスケープ）を忘れて壊れるので、専用の道具に任せます。
できあがりは `result=problem&date_from=2026-09-02&date_to=2026-09-02&per_page=5`。
L35-36 のコメントが「切り替えボタンをあえて作らない」という設計判断を残しています。

#### ★`fetch` を2本 `Promise.all` で叩いている箇所（L43-46）★

```tsx
const [dashboard, logResp] = await Promise.all([
  apiFetch<DashboardResponse>("/api/dashboard"),
  apiFetch<ListResponse<CleanLogItem>>(`/api/clean-logs?${qs}`),
]);
setData(dashboard);
setLogs(logResp.items);
```

- **なぜ2本必要か**: `/api/dashboard` の冒頭コメント L12-13 が
  「★コンパクト版清掃詳細ログはこのAPIに含めません★ … 画面は GET /clean-logs?result=problem&date_from=当日 を再利用します」
  と決めているためです。**窓口を増やさず既にあるものを条件付きで呼び直す**方針の帰結です。
- **なぜ `Promise.all` か**: 2つは互いに独立なので、順番に待つ理由がありません。同時に投げて片方の待ち時間に他方を進めます（§5.11 と同じ考え）。
- **`apiFetch` は生の `fetch` ではありません。** `lib/api/client.ts` の自作関数で、同ファイル L4 に
  **★fetch を画面に直書きせず、必ずここを通すこと★** と明記され、理由が L6-15 に3点挙がっています。
  > - **認証方式が未確定（cookie 予定）。** 決まった際に付与処理を入れる場所がここになる。画面ごとに fetch が散っていると、認証の追加が全画面の修正になる。
  > - **エラー形式 `{ error: { code, message, details? } }` と一覧の `meta { total, page, per_page }` は CLAUDE.md §9.3 が全API共通と定めている。** 共通の形が決まっているものは、共通の場所で解釈するのが筋。
  > - `docs/spec.md` にあと9画面が控えており、呼び出しが数十箇所へ散る前に寄せる。
- `apiFetch<DashboardResponse>(...)` の `<...>` は「返ってくる JSON はこの形のはず」という**型の指定**です。
  実行時の検査ではなく、`lib/api/client.ts` L140 が `return body as T;` と型を被せているだけ（**平易に**: ラベルを貼る作業で、中身の検品はしていません）。
- `apiFetch` は成功なら本文、失敗なら `ApiFetchError` を**投げます**（同 L105, L124, L133）。
  投げるのは3種類: ネットワーク到達不能（`status: 0`）、HTTP エラー（`status` そのまま）、JSON として読めない（同 L132-138）。
  戻り値で成否を判断する形ではないので、呼ぶ側は `try/catch` が必須になります。

**`catch`（L49-57）**

```tsx
} catch (e) {
  // 2本のうちどちらが失敗しても画面全体をエラー表示にする単純な設計にしている。
  // 部分表示（片方だけ出す）はこの画面の要件にないため見送った
  setError(
    (e instanceof ApiFetchError ? e.serverMessage : null) ??
      "データの取得に失敗しました"
  );
  setData(null);
  setLogs([]);
}
```

- L50-51 のコメントが方針そのもの。**片方だけ出す部分表示はしない。**
- `e instanceof ApiFetchError ? e.serverMessage : null` … **平易に**: 「これは想定した種類のエラーか？」を確かめてから中身を読みます。
  想定外（プログラムのバグなど）なら `null` にして既定文へ。
- `?? "データの取得に失敗しました"` … サーバー由来の文言が無ければ画面側の定型文。
- `serverMessage` をわざわざ別のプロパティにしている理由は `lib/api/client.ts` L22-26 に明記されています。
  > `serverMessage` を独立させているのは、画面が「サーバーが返した文言を出す」か「画面側の既定文言を出す」かを判別できるようにするため。ここを `message` に混ぜると、HTML のエラーページが返ったときに `"<!DOCTYPE html>..."` のような文字列が画面に出てしまう。
- `setData(null)` / `setLogs([])` … **古い成功データを必ず消します。** 消さないと「エラー表示 + 前回の数字」という
  矛盾した画面になりえます。

**`finally`（L58-60）**

```tsx
} finally {
  setLoading(false);
}
```

**用語**: `finally` … 成功でも失敗でも**必ず**通る後片付けの節。
ここに `setLoading(false)` を置くことで、「エラー時に読み込み中が消えない」という取りこぼしが構造的に起きません。
`try` と `catch` の両方に書く方式だと、片方に書き忘れると画面が固まります。

#### `useEffect`（L63-66）

```tsx
useEffect(() => {
  // eslint-disable-next-line react-hooks/set-state-in-effect -- fetch 起点の初期表示
  void fetchAll();
}, [fetchAll]);
```

**用語**: `useEffect`（副作用フック）

**平易な言い換え**: 「**画面を描き終わったあとに**やること」を登録する仕組み。
データ取得・タイマー・外部への通知など、「絵を描く」以外の作業はここに書きます。
描画の途中で取得を始めると、描画と状態更新が絡んで動作が読めなくなるためです。

- 第2引数 `[fetchAll]` … **平易に**: 「`fetchAll` が別物に変わったときだけ、もう一度やる」。
  `fetchAll` は `useCallback([today])` で固定されているので、**実質「最初の1回だけ」**動きます。
- `void fetchAll();` … `void` は「この関数の戻り値（Promise）は使わない」という明示。
  `useEffect` に渡す関数は `async` にできない（返り値が後片付け関数と解釈される）ため、
  **中で呼んで結果を捨てる**この書き方になります。
- L64 の `eslint-disable-next-line react-hooks/set-state-in-effect -- fetch 起点の初期表示`
  … lint が「effect の中で state を更新するな」と警告するのを、理由付きで1行だけ黙らせています。
  `--` の後ろが理由です。**平易に**: 「これは分かっていてやっている。初期表示のための取得だから」。

### 9.6 フックの一覧（まとめ表）

| フック | 行 | 何のために |
|---|---|---|
| `useTheme`（自作） | L21 | `t()`（翻訳）と `theme`（配色名）を Context から受け取る |
| `useMemo` | L24 | `today` を開いた時点の日付に固定。表示と取得の日付をズレさせない / 依存の鎖の起点を安定させる |
| `useState` ×4 | L26-29 | `data` / `logs` / `loading` / `error` の4つを記憶する |
| `useCallback` | L31-61 | `fetchAll` を作り直さない。`useEffect` の無限ループ防止 |
| `useEffect` | L63-66 | 描画後に1回だけ `fetchAll()` を走らせる |
| `useRouter`（Next.js） | L23 | `router.push()` でカードのクリックから画面遷移する |

### 9.7 画面側の唯一の算術（L68-74）

```tsx
// 「成功 / N件」の N は schedule_progress.planned とは別概念（D-049）。
// planned はスケジュールマスタ由来、こちらは当日の清掃ログ行数（成功+失敗+未実施）。
// 1行 = 1ロボット×1スケジュール実行なので、施設数とは一致しない（1施設に複数台の
// ロボットがある場合は行数が施設数を上回る）。ラベルも「件」であって「施設」ではない。
const totalLogsToday = data
  ? data.summary.success + data.summary.failed + data.summary.not_executed
  : 0;
```

**★これが `components/` 内で唯一、画面がしている算術です★**（§17 で全件検証）。

- 三項演算子 `data ? ... : 0` で、`data` が `null`（未取得・エラー時）なら `0`。
- L68-71 のコメントが「なぜ `planned` を使わないのか」を4行かけて説明しています。
  `planned` はスケジュールマスタ由来（D-049）で、**分母として意味が違う**からです。
- **規約との関係**: `CLAUDE.md` §9.6（L75）は「判定結果（◎○△×、L1〜L3、ソート順）はAPIから受け取る。フロントで再計算しない」。
  ここで足しているのは**判定ではなく、API が返した3つの数の合計**です。禁止されている「判定の再計算」には当たりません。
  ただし「合計を画面で作っている」ことは事実で、API が同じ意味の値を持っていないためこうなっています（`DashboardResponse.summary` に合計のフィールドは無い。`lib/types/index.ts` L308-315 で確認）。**厳密に見れば、画面が意味を組み立てている唯一の箇所です。**

### 9.8 ★`loading` / `error` / `data` の3状態の出し分け★（L76-93）

```tsx
return (
  <div className={styles.page}>
    <div className={styles.header}>            {/* L78 — 常に表示 */}
      ...
      {data && data.security_banner.length > 0 && (   /* L84 */
        <Pill text={`D-Sec ${data.security_banner.length}件`} color={c.wn} />
      )}
    </div>

    {loading && <div className={styles.loading}>読み込み中…</div>}         {/* L90 */}
    {!loading && error && <div className={styles.error}>{error}</div>}     {/* L91 */}

    {!loading && !error && data && (                                       /* L93 */
      <> ... </>
    )}
  </div>
);
```

**用語**: 条件付きレンダリング / `&&` による短絡

**平易な言い換え**: JSX の中で `条件 && <タグ/>` と書くと、条件が真のときだけそのタグが出ます。
偽なら `false` になり、React は `false` を**何も描かない**ものとして扱います。

**3状態の実際の組み合わせ**

| 状況 | `loading` | `error` | `data` | 出るもの |
|---|---|---|---|---|
| 開いた直後 | `true` | `null` | `null` | ヘッダ + 「読み込み中…」 |
| 取得成功 | `false` | `null` | 値あり | ヘッダ + 本体（L94-173）全部 |
| 取得失敗 | `false` | 文言 | `null` | ヘッダ + 赤字のエラー文（`.error` は `color: var(--ng)`、CSS L99-103） |

**設計の要点**

1. **ヘッダ（L78-88）は3状態すべてで出ます。** 題名と日付は常に見えるので、エラー時も「何の画面か」が分かります。
2. **3つの条件は互いに排他になるよう書かれています。** L90 は `loading`、L91 は `!loading && error`、L93 は `!loading && !error && data`。
   **平易に**: 「読み込み中」と「エラー」が同時に出ることは構造上ありえません。
3. **L93 の `&& data` は型のためにも必要です。** `data` が `null` かもしれない状態では
   `data.summary.success`（L99）と書けません（TypeScript が「null の可能性がある」と止めます）。
   `data &&` を通すことで、それ以降 `data` が確実に存在すると TypeScript が理解します。
   **平易に**: 「中身があると確認してから開ける」を型検査が強制しています。
4. **L84 の Pill は `data` の有無を独立に確認しています。** ヘッダは3状態共通なので、
   L93 の保護の外にあり、ここで自分で `data &&` を書く必要があります。
   `c.wn`（`THEME_TOKENS[theme].wn` = ダークなら `#f59e0b`）を渡すのが `THEME_TOKENS` の唯一の使用箇所です。
5. `<>...</>`（L94, L173）は **フラグメント**。**平易に**: 余計な `<div>` を増やさずに複数のタグをまとめるための「透明な袋」。
   ここで `<div>` を挟むと、親の CSS グリッド（`.statGrid` など）の配置が崩れます。

### 9.9 本体の中身（L94-173）

**セキュリティバナー（L95）**

```tsx
<SecurityBanner alerts={data.security_banner} />
```

`components/features/dashboard/SecurityBanner.tsx`（全27行）は `alerts.length === 0` なら `return null`（L16）で**何も描きません**。
「0件なら消える」判断を呼ばれる側に閉じているので、呼ぶ側は条件を書かずに済みます。
同ファイル L10-13:
> モックは1件固定の文言だが、実データは複数施設が同時に未解消になりうるため、配列の全件を表示する（1件に絞ると対応漏れが見えなくなる）。

**KPI 4枚（L97-125）**

```tsx
<div className={styles.statGrid}>
  <StatCard value={String(data.summary.success)}  label={t("成功")}   sub={`/ ${totalLogsToday}件`} tone="ok" />
  <StatCard value={String(data.summary.failed)}   label={t("失敗")}   tone="ng" onClick={() => router.push("/detail-log")} />
  <StatCard value={String(data.summary.not_executed)} label={t("未実施")} sub={t("予定時刻経過・実績なし")} tone="wn" onClick={() => router.push("/detail-log")} />
  <StatCard
    // "-" は displayRate() の既存の null 表記をそのまま使う（アプリ全体で
    // completion_rate 等の null 表記と統一するため。ここだけ別の記号にしない）
    value={displayRate(data.summary.success_rate)} label={t("成功率")} sub={`目標: ${SUCCESS_RATE_TARGET}%`} tone="ac"
  />
</div>
```

- `String(...)` で数値を文字列に。`StatCard` の `value` は `string` 型だからです（§10）。
- **クリックできるのは2枚だけ**（失敗 L108、未実施 L115）。成功と成功率にはハンドラがありません。
  **平易に**: 「調べる必要があるもの」だけを押せるようにしています。
- `displayRate`（`lib/judge/grade.ts` L36-39）:

  ```ts
  export function displayRate(rate: number | null): string {
    if (rate === null) return "-";
    return `${Math.round(rate)}%`;
  }
  ```

  **丸めは表示層でだけ行う**（`CLAUDE.md` §9.3 L52「数値の丸めは表示層でのみ行う。ロジック層には生値を渡す」）。
  API は `86.666...` を返し、画面が `87%` にします。
- L118-119 のコメントが `"-"` を使う理由を残しています（アプリ全体で null の表記を統一する）。
- `SUCCESS_RATE_TARGET`（`lib/config/index.ts` L54 = `95`）を直書きせず設定から読む
  （§9.4 L63「閾値は `lib/config/` から読む。ハードコード禁止」）。同ファイル L53 のコメント:
  > /** ダッシュボード（F-01）・レポート（F-04）の成功率カードに表示する目標値（%） */
- `t("成功")` … 現在 `lib/i18n.ts` の `EN` 辞書に「成功」「失敗」「未実施」「成功率」の項目は**ありません**。
  そのため EN に切り替えても日本語のままになります（§15）。**器は通してあるが辞書が未整備**という状態です。

**清掃詳細ログ（L127-147）**

```tsx
<div className={styles.card}>
  <div className={styles.cardHeader}>
    <span className={styles.cardTitle}>{t("清掃詳細ログ")}</span>
    <Link href="/detail-log" className={styles.allLink}>{t("全項目 →")}</Link>
  </div>
  <div className={styles.tableWrap}>
    <CleanLogTable
      rows={logs} compact expandedId={null} adviceById={{}} adviceLoadingId={null}
      showGradeInfo={false} onToggleGradeInfo={() => {}} onToggleRow={() => {}} onOpenAreas={() => {}}
    />
  </div>
</div>
```

- `<Link href="/detail-log">`（L130）… ページ全体を読み直さずに移動する Next.js の部品。
  **平易に**: 通常の `<a>` は建物を建て直しますが、`Link` は部屋の中身だけ入れ替えます。
  カードのクリック（L108）が `router.push` なのは、`<Link>` で包める形の要素ではないから（★推測★）。
- `CleanLogTable` に渡している `onToggleRow={() => {}}` などは**何もしない空の関数**です。
  `compact`（真偽値のみを書くと `true` の意味）で簡易表示にしているため、行の展開などの機能を使いません。
  詳細ログ画面（F-02）と同じ部品を使い回すため、**要らない機能を空関数で無効化**しています。
  `expandedId={null}` / `adviceById={{}}` / `showGradeInfo={false}` も同じ趣旨です。
- `t("清掃詳細ログ")` は辞書にあり（`lib/i18n.ts` L31）EN では `"Cleaning Detail Log"` になります。`t("全項目 →")` は辞書に**ありません**。

**下段グリッドと `failure_breakdown`（L149-172）**

```tsx
<div
  className={styles.bottomGrid}
  style={{ gridTemplateColumns: data.failure_breakdown ? "1fr 1fr" : "1fr" }}
>
  <ScheduleProgressCard progress={data.schedule_progress} />
  {/*
    failure_breakdown は M2 では返さない（D-048）。undefined のときは
    このブロックごと非表示にする。M3 で値が来たら自動的に表示へ戻る作りにしておく
    （プロパティを消してしまうと、値が来たときにここを書き直す必要が出る）
  */}
  {data.failure_breakdown && (
    <div className={`${styles.card} ${styles.panel}`}>
      <div className={styles.failureHeading}>失敗の内訳</div>
      {data.failure_breakdown.map((f) => (
        <div key={f.type} className={styles.failureRow}>
          <span>{f.label}</span>
          <span>{f.count}</span>
        </div>
      ))}
    </div>
  )}
</div>
```

**ここが D-048 と画面がかみ合う場所です。**

- **L152**: `data.failure_breakdown ? "1fr 1fr" : "1fr"` … あれば2列、無ければ**1列**。
  1列にしないと、スケジュール進捗カードが左半分だけに寄って右が空白になります。
  **平易に**: 相棒がいないときは幅いっぱいに広がる。
- **L161**: `data.failure_breakdown &&` … キー自体が無い（`undefined`）ので偽になり、ブロックごと消えます。
- **なぜ空配列ではなくキー省略なのか**（D-048、再掲）:
  > 空配列は「当日の失敗が0件」を意味するため、未実装をそれで表すと UI が「失敗なし」として正常表示してしまいます。

  もし API が `failure_breakdown: []` を返したら、L161 の `[]` は**真**なので枠が出て、
  中の `.map()` が0回まわり、**「失敗の内訳」という見出しだけの空っぽの箱**が表示されます。
  読んだ人は「失敗ゼロだったのだな」と受け取ります。**それが嘘になるので、キーを送らない**、という筋です。
- L156-160 のコメントが「M3 で値が来たら自動的に表示へ戻る作りにしておく」と意図を残しています。
  **今は動かないコードをあえて残す**判断で、`docs/decisions.md` D-053 の「機能しない UI を出さない」と矛盾しません
  （ここは選択肢が出るのではなく、**丸ごと消えている**ため）。
- `key={f.type}`（L165）… 繰り返し描くときに1件ずつを見分けるための札。
  `SecurityBanner.tsx` L21 では一意な ID が無いため `key={\`${a.facility_name}-${a.section_name}-${i}\`}` と
  複数項目 + 添字を組み合わせています。

`ScheduleProgressCard`（`components/features/dashboard/ScheduleProgressCard.tsx`、全32行）にも同種の判断があります（L12-15）。
> ★rate が null のときはバーを描画しない（D-049）★
> planned が0（全施設が定休日）のときに 0% のバーを出すと「予定件数分の清掃がまだ0件しか終わっていない」と誤読される。
> 「本日の清掃予定はありません」という別メッセージに置き換える。

実装は L21-29 の三項演算子で、`progress.rate === null` なら `.empty` のメッセージ、そうでなければ数値 + `ProgressBar`。
**「0」と「該当なし」を絵として区別する**という、D-048 と同じ思想の別の現れ方です。

---

## 10. `components/ui/StatCard.tsx` — 計算をしない部品

全 35 行。

### 10.1 import 文（L1）

```tsx
import styles from "./StatCard.module.css";
```

**用語**: CSS Modules

**平易な言い換え**: CSS のクラス名を**このファイル専用**に自動で書き換えてくれる仕組み。
`.card` と書いても実際には `StatCard_card__x7f2q` のような固有名になるので、
他のファイルの `.card` と衝突しません。`styles.card` と JavaScript のオブジェクト経由で参照します。
`"use client"` は**ありません**（サーバーで描かれる部品）。

### 10.2 export しているもの

| export | 形 | 行 |
|---|---|---|
| `StatTone` | **型**（文字列の union） | L8 |
| `StatCard` | **React コンポーネント**（名前付き） | L24-35 |

`StatCardProps`（L10-17）は export されていない**ファイル内の型**です。

### 10.3 ★props（受け取る値）の一覧と型★

**用語**: props（プロパティ）

**平易な言い換え**: 部品に外から渡す設定値。関数の引数と同じで、**部品の中からは書き換えられません**（読み取り専用）。

```tsx
interface StatCardProps {
  /** 表示する値。数値の文字列化・丸めは呼び出し側（lib/judge の displayRate 等）の責務 */
  value: string;       // L12
  label: string;       // L13
  sub?: string;        // L14
  tone: StatTone;      // L15
  onClick?: () => void; // L16
}
```

| props | 型 | 必須 | 意味 | 何が渡されているか（`DashboardScreen`） |
|---|---|---|---|---|
| `value` | `string` | ○ | 大きく出す値。**数値ではなく文字列** | `String(data.summary.success)` / `displayRate(...)` |
| `label` | `string` | ○ | 小さい見出し | `t("成功")`, `t("失敗")`, `t("未実施")`, `t("成功率")` |
| `sub` | `string` | — （`?` = 省略可） | 値の下の補足 | `` `/ ${totalLogsToday}件` ``, `t("予定時刻経過・実績なし")`, `` `目標: ${SUCCESS_RATE_TARGET}%` `` |
| `tone` | `"ok" \| "wn" \| "ng" \| "ac"` | ○ | 色の意味づけ | `"ok"` / `"ng"` / `"wn"` / `"ac"` |
| `onClick` | `() => void` | — | 押されたときの処理 | 失敗と未実施のみ `() => router.push("/detail-log")` |

`value: string` が**数値ではない**点が設計の核心です。L11 のコメント:
> /** 表示する値。数値の文字列化・丸めは呼び出し側（lib/judge の displayRate 等）の責務 */

**平易に**: 「`86.66` を `87%` にする仕事はこの部品の担当ではない。完成した文字列をください」。
これにより `86.7%` にしたい画面が出てきても、この部品を直す必要がありません。

`tone` の型（L8）と、その名前を選んだ理由（L3-7）:
```tsx
export type StatTone = "ok" | "wn" | "ng" | "ac";
```
> ◎○△×のような判定色ではなく KPI の意味合い（成功/警告/失敗/参考値）を表す色分け。
> `color` ではなく `tone` という名前にしているのは、呼び出し側が生の色コードを
> 直書きできないようにするため（CLAUDE.md §9: 色はテーマ変数経由で指定する）。

**平易に**: `color="#ff0000"` と書けてしまうと、ダーク/ライト切替のときに色が追従しません。
だから「赤」ではなく「失敗（ng）」という**意味**だけを受け取り、実際の色は CSS 側の `var(--ng)` に任せます。
（対比: `Pill.tsx` L5 は `color: string` で**生の色コードを受け取ります**。だから `DashboardScreen` L85 が
`c.wn` = `THEME_TOKENS[theme].wn` を渡す必要があり、`THEME_TOKENS` を import する唯一の理由になっています。
`StatCard` の方が後から整えられた形と読めます — ★これは推測★。）

### 10.4 中で何をしているか（L24-35）

```tsx
export function StatCard({ value, label, sub, tone, onClick }: StatCardProps) {
  return (
    <div
      className={`${styles.card} ${styles[tone]} ${onClick ? styles.clickable : ""}`}
      onClick={onClick}
    >
      <div className={styles.label}>{label}</div>
      <div className={styles.value}>{value}</div>
      {sub && <div className={styles.sub}>{sub}</div>}
    </div>
  );
}
```

順に。

1. **L24** 分割代入で5つの props を取り出す。
2. **L27** クラス名を3つ連結する。
   - `styles.card` … 常に付く土台（背景・枠・角丸・余白。CSS L1-6）
   - `styles[tone]` … `tone` が `"ok"` なら `styles.ok`。**tone という文字列でクラスを引く**書き方。
     `StatCard.module.css` L32-58 が `.ok`/`.wn`/`.ng`/`.ac` の4つを定義し、それぞれ
     `border-left: 3px solid var(--ok)` と `.ok .value { color: var(--ok) }` を当てます。
     **色の実体は CSS 変数**なので、`<html data-theme>` が変わると自動で追従します。
   - `onClick ? styles.clickable : ""` … ハンドラがあるときだけ `cursor: pointer`（CSS L8-10）。
     **平易に**: 押せるものだけカーソルが指の形になる。押せないカードで空振りさせない配慮です。
3. **L28** `onClick={onClick}` … 受け取ったものをそのまま渡す。`undefined` なら何も起きません。
4. **L30-32** 中身を3行。`label` → `value` → `sub`。
   `{sub && <div ...>}` で、`sub` が無いときは要素ごと出しません（空の `<div>` が余白を作るのを避ける）。

### 10.5 ★計算をしていないことの確認と明記★

**このファイルには、算術演算・条件による値の加工・データの集計が一切ありません。** 全35行を確認した結果:

| 種類 | 有無 | 確認 |
|---|---|---|
| 算術演算（`+ - * /`） | **なし** | `+` は1つも登場しない |
| 丸め・書式化（`Math.round` / `toFixed`） | **なし** | L11 のコメントで呼び出し側の責務と明記 |
| 集計（`reduce` / `filter` / `map`） | **なし** | 配列を扱わない |
| 判定（◎○△×、しきい値比較） | **なし** | 数値の大小比較が1つもない |
| 唯一の分岐 | 表示の有無だけ | L27 `onClick ? ... : ""`（カーソル）、L32 `sub &&`（補足行の有無） |

つまり **`StatCard` は「渡された文字列をそのまま置く」だけの部品**です。
`CLAUDE.md` §9.6（L75）**「判定結果（◎○△×、L1〜L3、ソート順）はAPIから受け取る。フロントで再計算しない」** に
完全に沿った形で、この部品を見て「表示された数字が正しいか」を疑う必要はありません（疑うべきは `lib/dashboard/aggregate.ts` と API 側です）。

同ファイル L19-23 のコメントが再利用の意図を残しています。
> モックの Num 相当の統計カード。
> ダッシュボード（F-01）と レポート（F-04）の KPI 4列で共用するため、
> 画面固有のロジック（何のカードか等）は持たせない。

実際に `AlertsScreen.tsx` L102-104、`DsAlertsScreen.tsx` L95-97、`DsLogScreen.tsx` L101-103 が同じ `StatCard` を使っています。

**観察（規約違反ではないが記録）**: `<div onClick>`（L26-29）はボタン要素ではないため、
キーボードの Tab で移動できず Enter で押せません。`role`/`tabIndex`/`aria` の指定もありません。
クリックできるカードは失敗・未実施の2枚で、同じ遷移先への `<Link href="/detail-log">`（`DashboardScreen` L130）が
別に存在するため、機能として到達不能にはなっていません。

---

## 11. `components/ui/ProgressBar.tsx` — 唯一の防御的な計算

全 28 行。

### 11.1 import と export

```tsx
import styles from "./ProgressBar.module.css";   // L1
```

| export | 形 | 行 |
|---|---|---|
| `ProgressBar` | **React コンポーネント** | L17-28 |

`ProgressBarTone`（L3）と `ProgressBarProps`（L5-9）は**export されていません**。
`StatCard` が `StatTone` を export しているのと対照的です（★不揃いの理由はコメントに無いので推測★。
`tone` を外から型として指定する必要が生じていないだけと読めます）。

### 11.2 props

```tsx
type ProgressBarTone = "ok" | "wn" | "ng" | "ac" | "pr";   // L3

interface ProgressBarProps {
  /** 0〜100の割合。呼び出し側は生値を渡してよい（丸めは表示幅に影響しないため不要） */
  percent: number;               // L7
  tone?: ProgressBarTone;        // L8
}
```

| props | 型 | 必須 | 既定値 |
|---|---|---|---|
| `percent` | `number` | ○ | — |
| `tone` | `"ok" \| "wn" \| "ng" \| "ac" \| "pr"` | — | `"ok"`（L17 の `tone = "ok"`） |

`StatCard` と違い **`percent` は数値をそのまま受け取ります**。L6 のコメントに理由が書かれています。
> /** 0〜100の割合。呼び出し側は生値を渡してよい（丸めは表示幅に影響しないため不要） */

**平易に**: バーの長さは `86.666%` でも `87%` でも見た目が変わらないので、丸める必要がない。
`StatCard.value` が文字列なのは「文字として読ませる」から、`ProgressBar.percent` が数値なのは「幅にするだけ」だから、という違いです。
`tone` に `"pr"`（primary）が1つ多いのは、`ProgressBar.module.css` L26-28 に `.pr { background: var(--pr) }` があるためです。

### 11.3 中で何をしているか（L17-27）

```tsx
export function ProgressBar({ percent, tone = "ok" }: ProgressBarProps) {
  const safePercent = Number.isFinite(percent) ? percent : 0;
  const clamped = Math.max(0, Math.min(100, safePercent));
  return (
    <div className={styles.track}>
      <div
        className={`${styles.fill} ${styles[tone]}`}
        style={{ width: `${clamped}%` }}
      />
    </div>
  );
}
```

- **L18** `Number.isFinite(percent) ? percent : 0`
  **用語**: `NaN`（非数）/ `Infinity`（無限大）。**平易に**: `0 / 0` は `NaN`、`5 / 0` は `Infinity` という
  「数値のようで数値でない値」になります。これを CSS の `width` に入れると `width: NaN%` という壊れた指定になります。
- **L19** `Math.max(0, Math.min(100, safePercent))`
  **用語**: クランプ（clamp）。**平易に**: 範囲外の値を範囲の端で止める。
  `120` は `100` に、`-5` は `0` に。二重に包むことで「100 を超えない、かつ 0 を下回らない」を1行で表しています。
- **なぜここでやるか**（L14-16 のコメント）:
  > ★呼び出し元が null/未定義を渡さない前提だが、0除算由来のNaN/Infinityが
  >   万一渡ってもバーが壊れないよう、ここで最終防御として0にクランプする★
- **L20-26** 二重の `<div>`。外が溝（`.track`: 高さ6px、背景 `var(--bd)`、`overflow: hidden`、CSS L1-7）、
  内が塗り（`.fill` + tone のクラス、CSS L9-28）。`style={{ width: ... }}` は**値が動的なのでインラインで指定**します
  （CSS ファイルには書けない値なので、これは規約違反ではありません）。

### 11.4 「計算あり」の評価

L18-19 は算術演算です。ただし内容は **表示幅を壊さないための防御**であり、
業務上の意味を作る計算（率の算出、判定）ではありません。率そのものは `lib/dashboard/aggregate.ts` L101
（`rate: planned === 0 ? null : (completed / planned) * 100`）が計算し、
`null` のときは `ScheduleProgressCard` L21 がバーを描かずに別メッセージへ切り替えます（§9.9）。
**`ProgressBar` に `null` が届かない構造になっているうえで、さらに保険を掛けている**という位置づけです。

---

## 12. `components/ui/GradeBadge.tsx` — 判定記号を色付きの札にする

全 16 行。

### 12.1 import と export

```tsx
import type { Grade } from "@/lib/types";        // L1
import styles from "./GradeBadge.module.css";    // L2
```

| export | 形 | 行 |
|---|---|---|
| `GradeBadge` | **React コンポーネント** | L8-16 |

### 12.2 props

```tsx
interface GradeBadgeProps {
  grade: Grade;    // L5
}
```

props はこれ1つ。`Grade` は `lib/types` で定義された型で、`"◎"` `"○"` `"△"` `"×"` などの記号の union です。
**平易に**: 「文字列」ではなく「決まった4種類のどれか」と型で縛っているので、`"◯"`（別の丸）などを渡すと型検査で止まります。

### 12.3 中で何をしているか（L8-16）

```tsx
export function GradeBadge({ grade }: GradeBadgeProps) {
  const tone =
    grade === "◎" || grade === "○" ? "ok" : grade === "△" ? "wn" : "ng";
  return (
    <span className={`${styles.badge} ${styles[tone]}`} data-tone={tone}>
      {grade}
    </span>
  );
}
```

- **L9-10** 入れ子の三項演算子。読み下すと:
  - `◎` または `○` → `"ok"`（緑）
  - `△` → `"wn"`（橙）
  - **それ以外すべて** → `"ng"`（赤）

  最後が `× → "ng"` ではなく **`else → "ng"`** になっている点に注意してください。
  想定外の記号が来ても赤で表示されます。**平易に**: 分からないものは安全側（目立つ側）に倒す作りです（★この意図はコメントに無いので推測★）。
- **L12** `<span>` に2つのクラス（`.badge` = 28×28 の四角い札、`.ok`/`.wn`/`.ng` = 色）。
  `GradeBadge.module.css` L12-28 が `background: var(--ok-d)`（暗い緑）+ `color: var(--ok)`（明るい緑）+
  `border: 1px solid color-mix(in srgb, var(--ok) 20%, transparent)` を当てます。
  `color-mix` は **平易に**「その色を20%の濃さで薄めた枠線」。
- `data-tone={tone}` … クラスと同じ情報を HTML 属性にも出しています。CSS からは使われていません（`grep` で確認）。
  テストや外部からの選択に使える手がかりとして残されていると読めます（★推測★）。
- **L13** `{grade}` … 受け取った記号を**そのまま**表示。別の記号に置き換えたりしません。

### 12.4 規約との関係

`grade` は API が返した値です（`lib/types/index.ts` の `CleanLogItem.grade`）。
このファイルは**判定を行いません**。`◎○△×` を決めるのは `lib/judge/grade.ts` の `judgeGrade()` で、
`CLAUDE.md` §9.6（L75）の「判定結果はAPIから受け取る。フロントで再計算しない」に沿っています。
L9-10 でやっているのは **「記号 → 色の名前」の読み替え**であり、完了率から記号を求め直しているわけではありません。
（対比: `lib/judge/grade.ts` L41-48 の `isProblem()` は `judgeGrade()` を呼んで判定します。これは `lib/` 側の仕事です。）

---

## 13. `components/layout/AppShell.tsx` — サイドバー付きの外枠

全 152 行。`app/(dashboard)/layout.tsx` から呼ばれ、10画面すべてを包みます。

### 13.1 import 文（L1-6、実際のコードのまま）

```tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "@/components/theme/ThemeProvider";
import styles from "./AppShell.module.css";
```

`"use client"` が必要な理由は3つ: `usePathname()`（今の URL を読む）、`useTheme()`（Context を読む）、
テーマ/言語ボタンの `onClick`（L96, L104）。

### 13.2 export しているもの

| export | 形 | 行 |
|---|---|---|
| `AppShell` | **React コンポーネント**（名前付き） | L45-151 |

`NavItem`（L26-43）は export されていない**ファイル内コンポーネント**。
`MENU_MONITORING` / `MENU_DS` / `MENU_SETTINGS`（L8-24）も export されていない定数です。

### 13.3 メニュー定義（L8-24）

```tsx
const MENU_MONITORING = [
  { href: "/detail-log", id: "detailLog", label: "📋 清掃詳細ログ" },
  { href: "/alerts", id: "alert", label: "🔔 アラート" },
  { href: "/reports", id: "report", label: "📈 レポート" },
  { href: "/inspections", id: "inspection", label: "🔧 点検記録" },
] as const;
```

- **`as const`** … **平易に**: 「この配列と中身は書き換えない」という宣言。
  TypeScript が `href` を `string` ではなく `"/detail-log"` という**その文字そのものの型**として扱うため、
  存在しない URL を書くと（`Link` の型が厳しい設定なら）検出できます。
- `href` が `app/(dashboard)/` のフォルダ名と1対1で対応しています（§1.2 の表と一致）。
  **フォルダ名を変えたらここも直す必要がある**、という結びつきです。
- `id` は現状 `key` 用にしか使われていません（L122 など）。
- `label` に絵文字が入っています。これがそのまま `lib/i18n.ts` の**辞書キー**になります（§15）。
- 3つに分かれているのは、画面の見出し（L119 モニタリング / L128 D-Security / L137 設定）と対応させるためです。

### 13.4 `NavItem`（L26-43）

```tsx
function NavItem({ href, label, active }: { href: string; label: string; active: boolean }) {
  return (
    <Link href={href} className={`${styles.navItem} ${active ? styles.navItemActive : ""}`}>
      {label}
    </Link>
  );
}
```

props は3つ（`href` / `label` / `active`）。`active` が真のときだけ `navItemActive` クラスが付きます。
**平易に**: 今いる画面のメニューだけ色を変える。ロジックはこれだけです。

### 13.5 `AppShell` 本体（L45-151、順に）

```tsx
export function AppShell({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  const { theme, lang, toggleTheme, toggleLang, t } = useTheme();
```

- **L46** `usePathname()` … 今の URL のパス部分（`/dashboard` など）を返すフック。
- **L47** Context から5つ一度に取り出しています（`DashboardScreen` は `t` と `theme` の2つだけ）。

**L51-112 サイドバー上部（ブランドと2つのボタン）**

- L54-87 のロボットアイコンは**インライン SVG**（画像ファイルではなく図形の指示をそのまま HTML に書く形）。
  色に `fill="var(--pr)"` / `stroke="var(--pr)"` と**CSS 変数**を使っているので、
  ダーク（`#3b82f6`）とライト（`#2563eb`）でアイコンの色まで自動で切り替わります。
  `aria-hidden`（L54）は「読み上げソフトは無視してよい飾り」の印。
- L90 `{t("DFAロボット監視システム")}` … 辞書にあり（`lib/i18n.ts` L8）、EN では `"DFA Robot Monitoring System"`。
- **L93-100 テーマ切替ボタン**

  ```tsx
  <button type="button" className={styles.iconBtn} onClick={toggleTheme}
    aria-label={theme === "dark" ? "ライトモード" : "ダークモード"}>
    {theme === "dark" ? "☀" : "🌙"}
  </button>
  ```

  分岐が2つ（`aria-label` と表示文字）。どちらも **「今」ではなく「押したらこうなる」** を示しています。
  ダークのときに `☀` が出るのは「押すと明るくなる」という意味。**平易に**: スイッチには行き先を書く。
  `aria-label` は絵文字だけでは読み上げソフトが意味を伝えられないための代替テキストです。
- **L101-111 言語切替ボタン**

  ```tsx
  <button type="button" className={styles.langBtn} onClick={toggleLang}
    style={{
      background: lang === "en" ? "color-mix(in srgb, var(--pr) 9%, transparent)" : "var(--sf-h)",
      color: lang === "en" ? "var(--pr)" : "var(--tx-d)",
    }}>
    {lang === "ja" ? "EN" : "JP"}
  </button>
  ```

  こちらは分岐3つ（背景・文字色・ラベル）。EN のときだけ青系で強調され、
  「今、既定でない言語になっている」と分かります。表示文字は行き先（`ja` なら `EN`）。
  インライン `style` を使っていますが、値はすべて CSS 変数経由（`var(--pr)` など）なので
  「色はテーマ変数経由で指定する」方針からは外れていません。

**L113-146 ナビゲーション**

```tsx
<NavItem href="/dashboard" label={t("📊 ダッシュボード")} active={pathname === "/dashboard"} />
<div className={styles.section}>{t("モニタリング")}</div>
{MENU_MONITORING.map((m) => (
  <NavItem key={m.id} href={m.href} label={t(m.label)} active={pathname === m.href} />
))}
<div className={styles.section}>D-Security</div>
{MENU_DS.map((m) => ( ... ))}
<div className={styles.section}>{t("設定")}</div>
{MENU_SETTINGS.map((m) => ( ... ))}
```

- ダッシュボード（L114-118）だけ配列に入れず個別に書いています。見出しの外に単独で置きたいためです。
- **`active={pathname === m.href}`** … **完全一致**での比較。
  現状すべての画面が1階層なので問題ありませんが、将来 `/detail-log/123` のような下位 URL を作ると
  **メニューの色が消えます**（`"/detail-log/123" === "/detail-log"` は偽）。観察事項として記録します。
- `{配列.map(...)}` … **平易に**: 配列の各要素を1つずつタグに変換して並べる。`key` は要素を見分ける札。
- **L128 の `D-Security` だけ `t()` を通していません。** 製品名なので翻訳しない、という判断と読めます
  （★推測★。他の2つの見出し L119 / L137 は `t()` を通し、辞書にも `モニタリング: "MONITORING"` / `設定: "SETTINGS"` があります）。

**L147 / L149**

```tsx
<div className={styles.footer}>{t("DFA運用担当")}</div>
...
<main className={styles.main}>{children}</main>
```

`children` が入るのは `<main>` の中だけです。つまり **画面を切り替えても `<aside>`（サイドバー）は作り直されません**。
これが「レイアウトは再生成されない」という §1.5 の性質の実物で、
テーマ/言語の状態が画面遷移で失われない理由でもあります（状態を持つ `ThemeProvider` はさらに外側の `app/layout.tsx` にある）。

---

## 14. `components/theme/ThemeProvider.tsx` — ★React Context とは何か★

全 59 行。**アプリ全体のテーマと言語を配る仕組み。**

### 14.1 用語 — Context（コンテキスト）

**専門用語**: React Context

**平易な言い換え**: **「アプリ全体に配る館内放送」**。

これがない場合を考えてください。テーマの状態は `app/layout.tsx` の階層にあり、それを使う `StatCard` は
`layout → page → DashboardScreen → StatCard` と4階層下にあります。props だけで渡そうとすると、
**途中の全員が「自分は使わないのに次の人へ渡すだけ」の受け渡し役**をやらされます。
これを **props のバケツリレー（prop drilling）** と呼びます。
Context は「上で放送を流し、必要な人だけ受信機で聞く」形にして、途中の階層を経由しません。

Context の3点セット:

| 部品 | 平易に | D-Watch の実物 |
|---|---|---|
| `createContext` | 放送のチャンネルを作る | L22 |
| `<Provider value={...}>` | そのチャンネルに中身を流す | L49 |
| `useContext` | 受信機で聞く | L54（`useTheme` の中） |

### 14.2 import 文（L1-12、実際のコードのまま）

```tsx
"use client";

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";
import { translate, type Lang } from "@/lib/i18n";
import type { ThemeName } from "@/lib/theme";
```

`"use client"`（L1）が必須です。状態（`useState`）と Context を扱うため、サーバー側では動きません。
`app/layout.tsx`（サーバー側）が `<ThemeProvider>` を置いていますが、
**サーバーコンポーネントの中にクライアントコンポーネントを置くことはできます**（逆に、クライアント側から
サーバーコンポーネントを呼び出すことはできません）。この境界の引き方が D-Watch の構成の基本です。

### 14.3 export しているもの

| export | 形 | 行 |
|---|---|---|
| `ThemeProvider` | **React コンポーネント** | L24-51 |
| `useTheme` | **関数（自作フック）** | L53-59 |

`ThemeContextValue`（L14-20）と `ThemeContext`（L22）は**export されていません**。
**これが意図的で重要です**: 外から `ThemeContext` を直接触れないので、
利用者は必ず `useTheme()` を通ることになり、§14.6 の安全確認が必ず働きます。

### 14.4 配るものの中身（L14-22）

```tsx
interface ThemeContextValue {
  theme: ThemeName;            // "dark" | "light"
  lang: Lang;                  // "ja" | "en"
  toggleTheme: () => void;
  toggleLang: () => void;
  t: (text: string) => string;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);
```

- 「今の値」2つ（`theme`, `lang`）と「切り替える手段」2つ（`toggleTheme`, `toggleLang`）と「翻訳関数」1つ。
- `createContext<... | null>(null)` … **既定値を `null`** にしています。
  **平易に**: 「放送局が無い場所では `null` が聞こえる」。この `null` を §14.6 でエラーに変えます。

### 14.5 `ThemeProvider` の中身（L24-51、順に）

```tsx
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<ThemeName>("dark");   // L25
  const [lang, setLang] = useState<Lang>("ja");            // L26
```

初期値はダーク・日本語。`app/layout.tsx` L25 の `data-theme="dark"` と一致しているので、
サーバーが送った HTML とブラウザの初期状態が食い違わず、開いた瞬間に色がちらつきません。

```tsx
  useEffect(() => {                                              // L28
    document.documentElement.dataset.theme = theme;              // L29
    document.documentElement.lang = lang === "ja" ? "ja" : "en"; // L30
  }, [theme, lang]);                                             // L31
```

**このアプリで色が変わる仕掛けの中心はこの2行です。**

- L29 `document.documentElement` は `<html>` 要素。`dataset.theme = "light"` は
  HTML の `data-theme="light"` 属性を書き換えます。すると `app/globals.css` L22 の
  `[data-theme="light"] { --bg: #f1f5f9; ... }` が効き始め、**CSS 変数を使っている全要素の色が一斉に変わります**。
  **平易に**: 各部品に「色を変えろ」と伝えて回るのではなく、**元栓を1つひねる**方式です。
  だから `StatCard` などは色の切替を一切知りません（§10）。
- L30 は `<html lang>` の書き換え。読み上げソフトやブラウザの翻訳機能への申告です。
- なぜ `useEffect` の中なのか: `document` はブラウザにしか存在しないので、サーバー側の描画中に触ると落ちます。
  `useEffect` は**描画後にブラウザでだけ**走るので、安全な置き場所です。
- 依存 `[theme, lang]` … どちらかが変わったときだけ実行。**平易に**: 変わっていないなら触らない。

```tsx
  const toggleTheme = useCallback(() => {
    setTheme((prev) => (prev === "dark" ? "light" : "dark"));
  }, []);

  const toggleLang = useCallback(() => {
    setLang((prev) => (prev === "ja" ? "en" : "ja"));
  }, []);
```

- `setTheme((prev) => ...)` … **関数を渡す形**の更新。
  **平易に**: 「今が dark なら light に」と**その時点の値を受け取ってから**決めるので、
  古い値を掴んで間違える事故が起きません。`setTheme(theme === "dark" ? ...)` と書くと、
  クロージャが古い `theme` を覚えている場面で誤動作しえます。
- 依存が `[]`（空）… `prev` を使うので外の値に一切依存せず、**一度作れば作り直す必要がありません**。

```tsx
  const t = useCallback((text: string) => translate(lang, text), [lang]);
```

`t` は `lang` を**閉じ込んだ**翻訳関数です。**平易に**: 呼ぶ側が毎回「今は日本語です」と言わずに済むよう、
言語を先に埋め込んだ関数を配ります。だから画面側は `t("成功")` と書くだけで済みます。
依存が `[lang]` なので、言語が変わったときだけ新しい `t` が作られ、それを使っている画面が描き直されます。

```tsx
  const value = useMemo(
    () => ({ theme, lang, toggleTheme, toggleLang, t }),
    [theme, lang, toggleTheme, toggleLang, t]
  );

  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
```

- **`useMemo` の目的**: `value` はオブジェクトです。`{...}` は毎回**新しい物**として作られるため、
  包まずに書くと `ThemeProvider` が描き直されるたびに「Context の中身が変わった」と判定され、
  **`useTheme()` を使っている全画面が無駄に描き直されます**。
  `useMemo` で「本当に中身が変わったときだけ新しい物にする」ようにしています。
  Context を作るときの定番の型で、`useCallback` を4つ使っているのも**この `useMemo` を効かせるため**です
  （関数が毎回作り直されると依存配列が毎回変わり、`useMemo` が無意味になる）。
- L49 `<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>`
  … ここから内側の**すべての子孫**が `value` を読めるようになります。
  `app/layout.tsx` L29 でここに `{children}` が入るので、**アプリ全体**が範囲です。

### 14.6 `useTheme`（L53-59）— 自作フックと安全確認

```tsx
export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) {
    throw new Error("useTheme must be used within ThemeProvider");
  }
  return ctx;
}
```

- **`useContext(ThemeContext)`** … 最も近い `Provider` の `value` を取り出す。
  上に `Provider` が無ければ `createContext(null)` の既定値、つまり `null`。
- **L55-57 の分岐が2つの仕事をしています。**
  1. **早く、分かりやすく落ちる。** `Provider` の外で使うと `ctx.t is not a function` のような
     読みづらいエラーになりますが、ここで `"useTheme must be used within ThemeProvider"` と
     原因を名指しして止めます。**平易に**: 配線を間違えたときに、間違いの名前を言って止まる。
  2. **型を確定させる。** `if (!ctx) throw` を通り抜けた後、戻り値の型は `ThemeContextValue | null` ではなく
     `ThemeContextValue` になります。だから使う側は `useTheme()?.t` のような
     `?.`（存在確認）を書かずに `const { t } = useTheme()` と書けます（`DashboardScreen` L21, `AppShell` L47）。
- **これが「自作フック（custom hook）」です。** 名前が `use` で始まり、中で別のフック（`useContext`）を呼ぶ関数。
  **平易に**: よく使う手順に名前を付けて1つの道具にまとめたもの。React にとって特別な機能ではなく、命名の約束です。

### 14.7 観察 — 保存されない

`localStorage` / `sessionStorage` / Cookie への保存は**このファイルにありません**（`grep` で確認）。
そのため **ブラウザを再読み込みすると、テーマは常に `dark`、言語は常に `ja` に戻ります**。
画面遷移（`Link` による移動）では `ThemeProvider` が作り直されないので保持されます。
`CLAUDE.md` §9.6（L76）は **「ダーク/ライト、JP/EN切替の器を壊さない」** とだけ定めており、
**永続化は求めていません**。「器」（切替の仕組み）は動いている状態です。

---

## 15. `lib/i18n.ts` — `t()` は何をしているか

全 56 行。

### 15.1 import 文

**ありません。** 他のどこにも依存しない、独立した辞書ファイルです。

### 15.2 export しているもの

| export | 形 | 行 |
|---|---|---|
| `Lang` | **型**（`"ja" \| "en"`） | L1 |
| `translate` | **関数** | L53-56 |

`EN`（L7-51）は export されていない**ファイル内の定数**です。外から辞書をいじれません。

### 15.3 ★辞書のキーが日本語である点★

```ts
/**
 * モック（d-watch-mock.jsx）の対訳テーブル。
 * キーは日本語原文。EN時のみ差し替える。
 */
const EN: Record<string, string> = {
  "DFAロボット監視システム": "DFA Robot Monitoring System",
  "DFA運用担当": "DFA Operator",
  "📊 ダッシュボード": "📊 Dashboard",
  ...
  モニタリング: "MONITORING",
  設定: "SETTINGS",
  ...
  準備中: "Coming soon",
};
```

**用語**: `Record<string, string>` … **平易に**「文字列をキーに、文字列を値に持つ対応表」の型。

**よくある方式との違い**。一般的な多言語化では、キーに `dashboard.title` のような記号を使い、
日本語も英語も辞書から引きます。D-Watch は違います。

| | 一般的な方式 | D-Watch |
|---|---|---|
| キー | `dashboard.title` | `"ダッシュボード"`（日本語原文そのまま） |
| 日本語の出し方 | 辞書 `ja` から引く | **辞書を引かず、キーをそのまま返す** |
| 英語の出し方 | 辞書 `en` から引く | 辞書 `EN` から引く |
| 辞書に無いとき | キー（`dashboard.title`）が画面に出る | **日本語が出る** |

**この方式の良いところ**: コードを読めば画面に何が出るかが**そのまま分かります**。
`{t("清掃詳細ログ")}` と書いてあれば、日本語では「清掃詳細ログ」と出ます。
辞書が未整備でも日本語版は完全に動きます（実際、L7-51 は51項目しかなく、画面の文字の一部しか登録されていません）。

**この方式の弱いところ**: 日本語の文言を1文字直すと辞書のキーが合わなくなり、**英語だけ静かに元に戻ります**。
エラーにならないので気づきにくい。また、絵文字を含むキー（`"📊 ダッシュボード"`）は
`AppShell.tsx` L9-23 の `label` と**絵文字も空白も含めて完全一致**していなければなりません。

L20 の `モニタリング: "MONITORING"` のように**引用符が無いキー**が混ざっているのは、
JavaScript の識別子として使える文字（漢字・かなは使えます）だと引用符を省略できるためで、
`"📊 ダッシュボード"` のように空白や絵文字が入ると省略できません。**意味の違いはありません**。

### 15.4 `translate()` の中身（L53-56）

```ts
export function translate(lang: Lang, text: string): string {
  if (lang === "en" && EN[text]) return EN[text];
  return text;
}
```

**全部で2行。分岐は1つ**（`&&` を2条件と数えれば2つ）。

1. `lang === "en"` … 日本語のときは**辞書を一切見ません**。
2. `EN[text]` … 辞書に登録があるか。**平易に**: 対訳表を引いて、見つかればそれを返す。
3. どちらかが偽なら `return text` … **渡された文字をそのまま返します**。

**具体例で追う**

| 呼び出し | `lang` | 結果 | 理由 |
|---|---|---|---|
| `t("ダッシュボード")` | `ja` | `"ダッシュボード"` | 条件1で外れ、そのまま返す |
| `t("ダッシュボード")` | `en` | `"Dashboard"` | L30 に登録あり |
| `t("成功")` | `en` | **`"成功"`** | 辞書に無い（L7-51 に存在しない）→ そのまま返す |
| `t("清掃詳細ログ")` | `en` | `"Cleaning Detail Log"` | L31 に登録あり |
| `t("全項目 →")` | `en` | **`"全項目 →"`** | 辞書に無い |

**平易にまとめると**: `t()` は「英語モードで、対訳が登録されているときだけ差し替える。それ以外は素通し」の関数です。

**技術的な細かい点（実害はないが記録）**

- `EN[text]` を真偽値として使っているので、対訳が空文字列 `""` の項目は「無い」扱いになります。現状そのような項目はありません。
- `EN` はオブジェクトリテラルなので `Object.prototype` を継承しています。したがって
  `EN["constructor"]` や `EN["toString"]` は（辞書に無くても）関数を返して真になり、
  `translate("en", "toString")` は関数を文字列として返してしまいます。
  D-Watch のキーは日本語 UI 文言なので実際には起きませんが、原理上の穴です
  （`Object.create(null)` か `Object.hasOwn` を使えば閉じられます）。

### 15.5 呼ばれ方（実物）

`ThemeProvider.tsx` L41 が `translate` を `lang` ごと閉じ込めて `t` にし、
画面側は `useTheme()` から `t` を受け取って使います。**画面から `translate` を直接呼んでいる箇所はありません**（`grep` で確認）。
**平易に**: 「今の言語」を知っているのは `ThemeProvider` だけで、各画面は言語を意識せず `t()` を呼ぶだけ、という分担です。

---

## 16. `lib/theme.ts` — 色の表（JavaScript 側）

全 43 行。

### 16.1 import と export

import は**ありません**。

| export | 形 | 行 |
|---|---|---|
| `ThemeName` | **型**（`"dark" \| "light"`） | L1 |
| `THEME_TOKENS` | **定数オブジェクト**（`as const`） | L4-43 |

### 16.2 中身

```ts
export type ThemeName = "dark" | "light";

/** モック（d-watch-mock.jsx）の DARK / LIGHT を CSS変数名に対応づけたトークン */
export const THEME_TOKENS = {
  dark: {
    bg: "#0f1419", sf: "#1a2332", sfH: "#1e2a3a", bd: "#2a3a4e",
    pr: "#3b82f6", prD: "#1e3a5f",
    ok: "#22c55e", okD: "#0a3d1f",
    wn: "#f59e0b", wnD: "#3d2a06",
    ng: "#ef4444", ngD: "#3d1111", ngB: "#ff6b6b",
    tx: "#e2e8f0", txD: "#94a3b8", txM: "#64748b",
    ac: "#06b6d4",
  },
  light: { ... },     // L24-42
} as const;
```

**用語**: デザイントークン … **平易に**: 「色に役割の名前を付けた一覧表」。
`#22c55e` を覚える代わりに `ok`（成功）と呼びます。ダークとライトで実際の色は違っても、**役割名は同じ**です。

省略名の対応（`app/globals.css` L1-39 の CSS 変数と1対1）:

| キー | CSS 変数 | 役割 |
|---|---|---|
| `bg` | `--bg` | 背景（background） |
| `sf` / `sfH` | `--sf` / `--sf-h` | 面（surface）/ 面のホバー |
| `bd` | `--bd` | 枠線（border） |
| `pr` / `prD` | `--pr` / `--pr-d` | 主役色（primary）/ その暗い版 |
| `ok` / `okD` | `--ok` / `--ok-d` | 成功 / 成功の背景 |
| `wn` / `wnD` | `--wn` / `--wn-d` | 警告（warning） |
| `ng` / `ngD` / `ngB` | `--ng` / `--ng-d` / `--ng-b` | 失敗（negative）/ 背景 / 明るい版 |
| `tx` / `txD` / `txM` | `--tx` / `--tx-d` / `--tx-m` | 文字（text）/ 薄い / もっと薄い |
| `ac` | `--ac` | 強調（accent） |

`as const`（L43）… **平易に**: 「この表は書き換えない」。これにより `THEME_TOKENS.dark.ok` の型が
`string` ではなく `"#22c55e"` になり、`ThemeName` を使った `THEME_TOKENS[theme]` の参照が型安全になります。

### 16.3 ★同じ色が2箇所にある（重要な観察）★

`lib/theme.ts` L5-22（dark）と `app/globals.css` L1-19（dark）は**同じ16色を別々に書いています**。
L24-42（light）と `globals.css` L22-39（light）も同様です。値が完全に一致していることは確認済みですが、
**片方だけ直すとずれます**（`--ok` を変えても `THEME_TOKENS.dark.ok` は古い色のまま）。

なぜ二重に持っているのか。**CSS 変数は CSS の中でしか使えず、JavaScript が文字列として色を必要とする場面には届かない**からです。
D-Watch でその場面は1箇所だけです。

```tsx
// DashboardScreen.tsx L22
const c = THEME_TOKENS[theme];
// L85
<Pill text={`D-Sec ${data.security_banner.length}件`} color={c.wn} />
```

`Pill.tsx` L5 の props が `color: string`（生の色コード）で、L12 が

```tsx
style={{ background: `${color}22`, color }}
```

と **色コードの文字列に `"22"` を足して透明度を作っている**ためです（`#f59e0b` + `22` = `#f59e0b22`、
末尾2桁が不透明度でおよそ13%）。この文字列連結は CSS 変数（`var(--wn)`）ではできません。

**改善の余地（提案。コードに根拠なし）**: `Pill` を `StatCard` と同じ `tone` 方式にして
CSS 側で `color-mix(in srgb, var(--wn) 13%, transparent)`（`AppShell.module.css` や
`GradeBadge.module.css` L15 で既に使われている技法）を使えば、`THEME_TOKENS` への依存は無くなり、
色の定義を `globals.css` の1箇所に寄せられます。**★これは私の提案で、リポジトリのコメントや decisions.md には記載がありません★**

---

## 17. `CLAUDE.md` §9（実装規約）と実コードの対照

`CLAUDE.md` L235 から始まる §9 のうち、画面・窓口に関わる条項を実コードと突き合わせました。

### 17.1 §9.1 ディレクトリ構成（L237-270）

| 規約の記述 | 実物 | 一致 |
|---|---|---|
| `app/(dashboard)/` に10画面 | dashboard / detail-log / alerts / ds-logs / ds-alerts / facilities / inspections / reports / schedules / system の10個 | ○ |
| `app/api/clean-logs/route.ts`, `export/route.ts`, `[id]/advice/route.ts`, `[id]/areas/route.ts` | 4ファイルすべて存在 | ○ |
| `app/api/settings/advice/route.ts` | 存在 | ○ |
| `components/ui/` に Pill, FilterButton, GradeBadge 等 | Pill / FilterButton / GradeBadge / StatCard / ProgressBar の5つ | ○ |
| `components/features/` に画面固有 | dashboard / detail-log / alerts / ds-alerts / ds-logs の5つ | ○ |
| `components/layout/` に AppShell 等 | AppShell / PlaceholderScreen | ○ |
| `components/theme/` に ThemeProvider | ThemeProvider.tsx | ○ |

規約に**書かれていない**が実在するもの: `app/api/dashboard/route.ts`, `app/api/prechecks/route.ts`,
`app/api/ds-alerts/route.ts`, `app/api/ds-devices/route.ts`。
`CLAUDE.md` の §9.1 の一覧は `clean-logs` と `settings` を例示しているだけで網羅ではない（`api/` の説明が「APIルート」）と読めます。

### 17.2 §9.2 命名（L272-279）

| 規約 | 検証 |
|---|---|
| ファイル/ディレクトリ: kebab-case | `detail-log`, `ds-alerts`, `clean-logs` ○ |
| コンポーネント: PascalCase | `DashboardScreen.tsx`, `StatCard.tsx`, `AppShell.tsx` ○ |
| 関数・変数: camelCase | `fetchAll`, `totalLogsToday`, `logsOfDay`, `scheduleProgress` ○ |
| API のクエリ・レスポンスキー: snake_case | `date_from`, `per_page`, `schedule_progress`, `security_banner`, `not_executed`, `success_rate` ○ |
| 型は `lib/types/` に集約、YAML のスキーマ名と揃える | `CleanLogItem`, `PrecheckItem`, `DashboardResponse` を `lib/types/index.ts` から import ○ |

例外1件: `lib/prechecks/query.ts` が `PrecheckSummary` を定義しており、`app/api/prechecks/route.ts` L21 が
`lib/types` ではなくそこから import しています（`import type { PrecheckSummary } from "@/lib/prechecks/query";`）。
「型は `lib/types/` に集約」から外れていますが、クエリ処理に固有の型なので実務上の判断と読めます（★推測★）。

### 17.3 §9.3 コーディング規約（L281-289）

| 規約 | 検証結果 |
|---|---|
| `any` 禁止 | 画面・窓口の対象ファイルに `any` は**0件**（`grep` で確認）。`lib/api/client.ts` は `unknown` を使い、`parseApiError` で形を検査してから読んでいます |
| API レスポンスは必ず型を定義してから実装 | `DashboardResponse`（`lib/types/index.ts` L308-343）等が定義済み。ルートは `const body: DashboardResponse = {...}` と型を明示 ○ |
| 日時は ISO 8601（JST, +09:00）。表示整形はコンポーネント側 | API は `scheduled_at` を ISO のまま返し、`SecurityBanner.tsx` L22 が `formatHm(a.since)` で `02:00` にする ○ |
| **数値の丸めは表示層でのみ行う** | `lib/dashboard/aggregate.ts` L57/L101 が生値を返し、`DashboardScreen` L120 の `displayRate()` が丸める ○ |
| エラーは `{ error: { code, message, details? } }` | 3ルートすべて `ApiError` 型を使用 ○。`lib/api/client.ts` の `parseApiError` が同じ形を読む ○ |
| 一覧APIは必ず `meta: { total, page, per_page }` | `/clean-logs` L56、`/prechecks` L79 ○（`/dashboard` は一覧ではないので対象外） |

### 17.4 §9.6 フロント（L306-311）★重点検証★

規約の全文は3行です。
> - モック（`d-watch-mock.jsx`）の画面レイアウト・文言・遷移を正とする
> - 判定結果（◎○△×、L1〜L3、ソート順）はAPIから受け取る。フロントで再計算しない
> - ダーク/ライト、JP/EN切替の器を壊さない

**1. モックを正とする** — 各コンポーネントが**モックの何行目に相当するか**をコメントで明記しています。
`ScheduleProgressCard.tsx` L10「モック514〜523行相当」、`SecurityBanner.tsx` L10「モック484〜487行相当」、
`ProgressBar.tsx` L12「モック521〜523行のバー相当」、`StatCard.tsx` L20「モックの Num 相当」。
`d-watch-mock.jsx`（163,623 バイト）が参照元として残っています。
モックから**あえて変えた**箇所には理由が添えられています（`SecurityBanner.tsx` L12-13:
「モックは1件固定の文言だが、実データは複数施設が同時に未解消になりうるため、配列の全件を表示する」）。

**2. フロントで再計算しない** — `components/` 全体で算術演算を検索した結果（`grep -rn "Math\.|/ 100|\* 100|toFixed|reduce("`）:

| 場所 | 内容 | 判定 |
|---|---|---|
| `ProgressBar.tsx` L18-19 | `Number.isFinite` / `Math.max` / `Math.min` | **判定でも集計でもない。**表示幅の防御。L14-16 に「最終防御として0にクランプする」と理由あり。**規約違反ではない** |
| `DashboardScreen.tsx` L72-74 | `data.summary.success + data.summary.failed + data.summary.not_executed` | **API が返した3値の足し算。**判定の再計算ではないが、**画面が意味（分母）を組み立てている唯一の箇所**。L68-71 に4行の理由コメントあり |

**それ以外の集計・判定は1箇所もありません。**
`AlertsScreen.tsx` L102-104 / `DsAlertsScreen.tsx` L95-97 / `DsLogScreen.tsx` L101-103 は
いずれも API の `body.summary` をそのまま `setSummary()` して表示するだけで、画面側で数えていません（確認済み）。
`GradeBadge.tsx` L9-10 の三項演算子は「記号→色名」の読み替えで、完了率から判定を作り直してはいません。
ソート順も画面では触らず、`/prechecks` 側が `sortPrechecks` で固定しています（D-052 / D-055）。

**3. 切替の器を壊さない** — `ThemeProvider`（テーマ・言語の状態）、`AppShell` の2つの切替ボタン、
`app/globals.css` の `[data-theme]` 2組、`lib/i18n.ts` の辞書がすべて生きています。
新しく作った `StatCard` / `ProgressBar` / `GradeBadge` はすべて色を `var(--*)` で参照しており、
**固定の色コードを持ちません**（`StatCard.tsx` L5-6 のコメントがその意図を明記）。器は保たれています。
未整備なのは辞書の項目数（51項目、§15.3）だけで、これは規約の対象外です。

### 17.5 その他の規約（画面・窓口に関わるもの）

- **§9.4「閾値は `lib/config/` から読む。ハードコード禁止」** … `SUCCESS_RATE_TARGET`（画面 L122）、
  `MAX_DATE_RANGE_DAYS`（`lib/clean-logs/query.ts`）、`PAGINATION` すべて `lib/config` 経由 ○
- **§9.5「画面側は `USE_FIXTURES` の切替を意識しない」** … `components/` 内に `USE_FIXTURES` は0件、
  `apiFetch` が `/api/...` を叩くだけ ○

---

## 18. 用語辞典（この文書に出た専門用語のまとめ）

| 専門用語 | 平易な言い換え | D-Watch の実物 |
|---|---|---|
| ルーティング | URL と画面の対応表 | `app/` のフォルダ構成そのもの |
| App Router | 対応表をフォルダの形で表す方式 | `app/` 配下すべて |
| ルートグループ | URL に出ない整理用フォルダ | `app/(dashboard)/` |
| `page.tsx` | その URL で出る画面 | `app/(dashboard)/dashboard/page.tsx` |
| `layout.tsx` | 自分と配下を包む外枠 | `app/layout.tsx`, `app/(dashboard)/layout.tsx` |
| `route.ts` | JSON を返すデータの窓口 | `app/api/dashboard/route.ts` |
| 動的セグメント（`[id]`） | URL の可変部分 | `app/api/clean-logs/[id]/areas/route.ts` |
| サーバーコンポーネント | サーバーで HTML を組んで送る（既定） | 全 `page.tsx`, `StatCard.tsx` |
| クライアントコンポーネント | ブラウザで組み立てる。`"use client"` が必要 | `DashboardScreen.tsx` L1 |
| props | 部品に外から渡す設定値。読み取り専用 | `StatCard` の `value`/`label`/`sub`/`tone`/`onClick` |
| フック（Hook） | `use` で始まる、記憶やタイミングの道具 | `useState`, `useEffect`, `useMemo`, `useCallback` |
| `useState` | 部品が覚えておく付箋 | `DashboardScreen` L26-29 の4つ |
| 再描画（re-render） | 状態が変わって画面を描き直すこと | `setLoading(false)` で発生 |
| `useEffect` | 描画後にやることの登録 | `DashboardScreen` L63-66 |
| `useMemo` | 計算結果を覚えて使い回す | `today` の固定（L24） |
| `useCallback` | 関数を覚えて作り直さない | `fetchAll`（L31-61） |
| 依存配列 | 「これが変わったらやり直す」の指定 | `[today]`, `[fetchAll]`, `[theme, lang]` |
| React Context | アプリ全体に配る館内放送 | `ThemeProvider` |
| props のバケツリレー | 使わない階層まで受け渡しさせられること | Context がそれを回避 |
| 自作フック | よく使う手順に名前を付けた関数 | `useTheme()` |
| 条件付きレンダリング | 条件が真のときだけ描く | `{loading && <div>読み込み中…</div>}` |
| フラグメント `<>` | 余計なタグを増やさない透明な袋 | `DashboardScreen` L94/L173 |
| `key` | 繰り返し要素を見分ける札 | `key={f.type}`（L165） |
| CSS Modules | クラス名をファイル専用に自動改名 | `styles.card` |
| CSS 変数 / デザイントークン | 色に役割名を付けた元栓 | `--ok`, `THEME_TOKENS` |
| クエリパラメータ | URL の `?` 以降の付帯条件 | `?result=problem&per_page=5` |
| バリデーション | 値の形の検査 | `parseDate`, `parseCleanLogQuery` |
| HTTP ステータス | 結果の3桁番号 | 200 / 422 / 500 |
| 422 | 「読めたが内容が受け付けられない」 | 日付の形が不正なとき |
| `Promise.all` | 複数の待ちを同時に走らせる | 窓口で3本、画面で2本 |
| `try` / `catch` / `finally` | 失敗時の処理 / 必ず通る後片付け | `fetchAll` L34/L49/L58 |
| ジェネリクス `<T>` | 箱の型だけ決め、中身は後から指定 | `ListResponse<CleanLogItem>` |
| 判別可能なユニオン型 | 札を見て成功／失敗を見分ける箱 | `parseCleanLogQuery` の `{ok: true \| false}` |
| クランプ | 範囲外を端で止める | `Math.max(0, Math.min(100, x))` |
| リクエスト ID（相関 ID） | 1回の問い合わせに振る整理番号 | `withRequestId` |
| `AsyncLocalStorage` | その処理の間だけ有効な引き出し | `lib/logger/context.ts` |
| 純粋関数 | 同じ入力なら必ず同じ出力。外に触らない | `lib/dashboard/aggregate.ts` |

---

## 19. 気づいた点の一覧（事実 / 観察 / 推測を分けて）

**事実（コードとコメントから確認）**
1. `page.tsx` は全画面が1行の取り次ぎ役で、ロジックを持たない。
2. 窓口3本すべてが `withRequestId` で包まれ、500 のときだけ `logApiError` を呼ぶ。422 では呼ばない。
3. `failure_breakdown` は API がキーごと省略し、画面が `data.failure_breakdown &&`（L161）と
   `gridTemplateColumns`（L152）の2箇所で「無い」場合に対応している（D-048）。
4. `/prechecks` は `summary` を日付フィルタ後・状況フィルタ前に計算する（D-054）。
5. `StatCard` は計算を一切していない（§10.5 の検証表）。
6. `components/` 内の算術は `ProgressBar` L18-19（表示防御）と `DashboardScreen` L72-74（3値の合計）の2箇所だけ。
7. テーマ切替は `<html data-theme>` の1属性の書き換えで全画面に効く（`ThemeProvider` L29 + `globals.css` L1/L22）。
8. `t()` は英語モードで辞書に登録がある場合だけ差し替え、それ以外は素通し。辞書は51項目。

**観察（規約違反ではないが記録に値する）**
9. 色の定義が `lib/theme.ts` と `app/globals.css` の**2箇所**にある。ずれても検知されない。用途は `Pill` の1箇所のみ（§16.3）。
10. `/prechecks` の 422 は `details: null` で項目名が返らない。`/clean-logs` は項目名つき（§7.3）。
11. `errorResponse()` ヘルパーがあるのに 500 の経路では使われていない（`/clean-logs` L45, `/prechecks` L57）。
12. `AppShell` の `active` 判定が `pathname === m.href` の完全一致。下位 URL を作ると選択表示が消える。
13. `StatCard` のクリック可能なカードが `<div onClick>` で、キーボード操作に対応していない。
14. テーマ・言語が永続化されず、再読み込みで `dark`/`ja` に戻る（規約は永続化を求めていない）。
15. `.next/types/routes.d.ts` は生成物で、現在 `/api/prechecks` 等が未反映（次の `next dev` で更新される）。
16. `lib/i18n.ts` の `EN[text]` はプロトタイプのキー（`toString` 等）に反応しうる。日本語 UI 文言では実害なし。

**推測（コードやコメントに根拠が無い読み解き。この文書では明示した箇所のみ）**
17. `app/(dashboard)/layout.tsx` が型を手書きしているのは、生成型 `LayoutRoutes` が `"/"` のみで
    ルートグループを含まないため（生成型の事実は確認済み、判断の理由は推測）。
18. 422 でログを出さないのは「利用者の指定ミスはアプリの障害ではない」という切り分け（3ルートで扱いが揃っている事実は確認済み）。
19. `data.anomaly` を `error` ではなく `warn` にしているのは、断定を避ける方針の一環。
20. `Pill` が `tone` 方式でないのは、`StatCard` より先に作られた形が残っているため。
21. `GradeBadge` の `else → "ng"` は、想定外の値を目立つ側に倒す安全側の設計。

---

## 付録: 解析したファイルの一覧

**指示された読み取り対象（14ファイル + CLAUDE.md §9）**
1. `C:\Users\daich\dev\d-watch\app\layout.tsx`（33行）
2. `C:\Users\daich\dev\d-watch\app\page.tsx`（5行）
3. `C:\Users\daich\dev\d-watch\app\(dashboard)\layout.tsx`（9行）
4. `C:\Users\daich\dev\d-watch\app\(dashboard)\dashboard\page.tsx`（5行）
5. `C:\Users\daich\dev\d-watch\app\api\dashboard\route.ts`（102行）
6. `C:\Users\daich\dev\d-watch\app\api\clean-logs\route.ts`（60行）
7. `C:\Users\daich\dev\d-watch\app\api\prechecks\route.ts`（83行）
8. `C:\Users\daich\dev\d-watch\components\features\dashboard\DashboardScreen.tsx`（177行）
9. `C:\Users\daich\dev\d-watch\components\ui\StatCard.tsx`（35行）
10. `C:\Users\daich\dev\d-watch\components\ui\ProgressBar.tsx`（28行）
11. `C:\Users\daich\dev\d-watch\components\ui\GradeBadge.tsx`（16行）
12. `C:\Users\daich\dev\d-watch\components\layout\AppShell.tsx`（152行）
13. `C:\Users\daich\dev\d-watch\components\theme\ThemeProvider.tsx`（59行）
14. `C:\Users\daich\dev\d-watch\lib\i18n.ts`（56行）
15. `C:\Users\daich\dev\d-watch\lib\theme.ts`（43行）
16. `C:\Users\daich\dev\d-watch\CLAUDE.md` §9（L235-311）

**裏取りのために追加で読んだファイル（20ファイル）**
17. `C:\Users\daich\dev\d-watch\app\globals.css`（CSS 変数の定義とフォント方針）
18. `C:\Users\daich\dev\d-watch\app\(dashboard)\reports\page.tsx`（未実装画面の例）
19. `C:\Users\daich\dev\d-watch\components\layout\PlaceholderScreen.tsx`
20. `C:\Users\daich\dev\d-watch\components\features\dashboard\ScheduleProgressCard.tsx`
21. `C:\Users\daich\dev\d-watch\components\features\dashboard\SecurityBanner.tsx`
22. `C:\Users\daich\dev\d-watch\components\ui\Pill.tsx`
23. `C:\Users\daich\dev\d-watch\components\ui\StatCard.module.css`
24. `C:\Users\daich\dev\d-watch\components\ui\ProgressBar.module.css`
25. `C:\Users\daich\dev\d-watch\components\ui\GradeBadge.module.css`
26. `C:\Users\daich\dev\d-watch\components\features\dashboard\DashboardScreen.module.css`
27. `C:\Users\daich\dev\d-watch\lib\api\client.ts`（`apiFetch` / `ApiFetchError`）
28. `C:\Users\daich\dev\d-watch\lib\logger\index.ts`（`logEvent` / `mixin`）
29. `C:\Users\daich\dev\d-watch\lib\logger\context.ts`（`withRequestId`）
30. `C:\Users\daich\dev\d-watch\lib\logger\api.ts`（`logApiError`）
31. `C:\Users\daich\dev\d-watch\lib\dashboard\aggregate.ts`（集計3関数）
32. `C:\Users\daich\dev\d-watch\lib\clean-logs\query.ts`（`parseDate` / `parseCleanLogQuery`）
33. `C:\Users\daich\dev\d-watch\lib\prechecks\query.ts`（`parsePrecheckQuery` / summary の順序）
34. `C:\Users\daich\dev\d-watch\lib\format\date.ts`（`todayJst` / `formatHm`）
35. `C:\Users\daich\dev\d-watch\lib\judge\grade.ts`（`displayRate`）
36. `C:\Users\daich\dev\d-watch\lib\config\index.ts`（`SUCCESS_RATE_TARGET` 等）
37. `C:\Users\daich\dev\d-watch\lib\db\index.ts`（`USE_FIXTURES` 切替）
38. `C:\Users\daich\dev\d-watch\lib\types\index.ts`（`ApiError` / `DashboardResponse` 等）
39. `C:\Users\daich\dev\d-watch\docs\decisions.md`（D-048 / D-049 / D-052 / D-053 / D-054）
40. `C:\Users\daich\dev\d-watch\package.json`（Next 16.3.3 / React 19.2.8）
41. `C:\Users\daich\dev\d-watch\.next\types\routes.d.ts`（生成された URL 一覧と `LayoutProps`）

**リポジトリへの変更**: なし（読み取りのみ）。
