【注記】 2026-09-03、実装とコメント・設計文書の食い違いを洗い出すために全ファイルを精読した記録です(3分割の1つ)。 ここで見つかった26件のうち、直すべき11件を
docs/handoff/指示書D_実装解析の不整合.mdに整理しています。 当時の記録であり、現行方針ではありません。 行番号は当時のものです。現状はdocs/handoff.mdを参照してください。
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 に根拠がある場合は、その文をそのまま引用しています。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 で検証しています)。
| 専門用語 | 平易な言い換え |
|---|---|
| ルーティング | 「この URL が来たら、この画面を出す」という対応表 |
| App Router | Next.js の新しい方式。対応表をフォルダの形そのもので表現する |
| ファイルベースルーティング | 設定ファイルを書かず、フォルダを作るだけで URL が増える仕組み |
| ルートグループ | フォルダを作っても 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 のどこにも存在しません。フォルダがそのまま設定です。
.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 を動かせば追加されます。 生成物なので手で直すものではありません。
(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/
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点。
page.tsx が無いフォルダは URL にならない。 app/api/ には page.tsx が無いので、/api を開いても画面は出ません。page.tsx と route.ts は同じフォルダに同居できない。 どちらも「この URL の担当」なので衝突します。D-Watch は画面を (dashboard)/ 配下、窓口を api/ 配下にきれいに分けているため、この衝突は起きていません。layout.tsx は入れ子になる。 /dashboard を開くと、外側から順に app/layout.tsx(<html>〜<body>〜ThemeProvider) → app/(dashboard)/layout.tsx(AppShell = サイドバー) → app/(dashboard)/dashboard/page.tsx(DashboardScreen) の3枚が重なって1つの画面になります。[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; } と型が生成されています)。 今回の担当範囲外なので詳細は割愛しますが、フォルダ名の記法として押さえてください。
app/layout.tsx — アプリ全体の一番外側の枠全 33 行。アプリ内のすべての URL がこのファイルを通ります。
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
import type … 型だけを持ち込む書き方。平易に: 「この変数はこういう形をしているはず」という説明書だけを借りる宣言で、実行されるコードは1バイトも増えません。next/font/google … Google Fonts をビルド時に取り込む仕組み。import "./globals.css" … 名前を付けずに CSS を読み込むだけの import。これで app/globals.css の CSS 変数(--bg, --ok など)が全画面で有効になります。| export | 形 | 説明 |
|---|---|---|
metadata |
オブジェクト(L16-19) | ブラウザのタブに出る題名と説明。Next.js が名前で見つけて <head> に変換する |
RootLayout |
React コンポーネント(export default, L21-33) |
全画面の外枠 |
metadata の実物(L16-19):
export const metadata: Metadata = {
title: "D-Watch",
description: "DFAロボット監視システム",
};
平易に: <title> タグを手で書く代わりに、決まった名前の変数を置いておくと Next.js が拾って <head> を組み立ててくれます。
L6-9 / L11-14 — フォントの読み込み
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 — 受け取る引数の型
export default function RootLayout({ children }: LayoutProps<"/">) {
children(子要素) … 平易に: 「この枠の中に入れるもの」。誰が入るかは URL によって変わります。LayoutProps<"/"> … Next.js が .next/types/routes.d.ts に自動生成したグローバル型。同ファイル L68-72 に定義があります。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
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 が
: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)。app/page.tsx — トップページは何も表示しない全 5 行。
import { redirect } from "next/navigation"; // L1
export default function Home() { // L3
redirect("/dashboard"); // L4
}
Home(React コンポーネント、export default)。ただし JSX を返しません。redirect("/dashboard") … 平易に: 「ここには何も無いので /dashboard へ行ってください」とブラウザに指示します。 redirect() は内部的に例外を投げて処理を打ち切る仕組みなので、return を書く必要がありません(この関数は値を返さずに終わります)。/ を開いた人は無条件に /dashboard へ飛びます。(dashboard) の外にあるため、サイドバー(AppShell)は付きません。表示前に飛ぶので不要です。app/(dashboard)/layout.tsx — 10画面共通のサイドバーを付ける枠全 9 行。
import { AppShell } from "@/components/layout/AppShell"; // L1
export default function DashboardGroupLayout({ // L3
children,
}: {
children: React.ReactNode; // L6
}) {
return <AppShell>{children}</AppShell>; // L8
}
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 は再生成されないため、サイドバーは作り直されず、テーマや言語の状態も保持されます。
page.tsx の実物app/(dashboard)/dashboard/page.tsx(全 5 行):
import { DashboardScreen } from "@/components/features/dashboard/DashboardScreen"; // L1
export default function DashboardPage() { // L3
return <DashboardScreen />; // L4
}
app/(dashboard)/reports/page.tsx(全 5 行、まだ作っていない画面の例):
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は、不具合と区別できないため」と定めており、 未完成のものを中途半端に見せない方針と揃っています。
app/api/dashboard/route.ts — 窓口の1行ずつの読み解き全 102 行。★依頼どおり全行を追います。★
| 専門用語 | 平易な言い換え |
|---|---|
| API ルート / ルートハンドラ | ブラウザからの問い合わせに JSON で答える窓口 |
GET |
「ください」というお願いの種類(データを取るだけで、何も変更しない) |
async / await |
「時間がかかる作業を待つ」印。待っている間、他の処理を止めない |
| クエリパラメータ | URL の ? 以降の付帯条件。?date=2026-09-01 の date=2026-09-01 |
| バリデーション | 受け取った値が正しい形かの検査 |
| HTTP ステータス | 結果の3桁の番号。200=成功 / 422=あなたの指定が変 / 500=こちら側の故障 |
Promise.all |
複数の待ち作業を同時に走らせて全部終わるのを待つ |
try / catch |
「失敗したらこう処理する」の囲い |
/** * 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つあります。
docs/decisions.md D-048 に同じ判断が書かれています。
平易に: 「0件です」と「まだ作っていません」を混同すると、画面が「異常なし」と嘘をつきます。だから項目そのものを送りません。 これが★空配列ではなく、プロパティごと省略する: 空配列は「当日の失敗が0件」を意味するため、未実装をそれで表すと UI が「失敗なし」として正常表示してしまいます。プロパティが無ければ UI 側で undefined(未実装)と
[](0件)を区別できる
DashboardScreen.tsx L152 / L161 の分岐(§9.9)とペアになっています。/api/clean-logs を条件付きで呼び直します。 結果として画面は 窓口を2つ叩くことになります(§9.6)。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/... と数えなくて済むようにする省略記法。const ROUTE = "/api/dashboard";(L28)ログに出す自分の名前です。lib/logger/api.ts L15-16 に
/* 実際のパス。
/api/v1/...のような架空のパスを書かない /
とあり、ログのパスと本物の URL をズレさせないために定数にしています。
export async function GET(request: Request)(L30)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 していないと窓口として認識されません。withRequestId で包む理由(L31, L101)return withRequestId(request, async () => {
...
});
用語: リクエスト ID(相関 ID)… 1回の問い合わせに振る整理番号。
なぜ必要か。1つの問い合わせを処理する間に、ログが複数行出ます(警告1行 + エラー1行など)。 同時に何人も使っているとログが混ざり、どの行が同じ問い合わせのものか分からなくなります。 整理番号を全行に付けておけば、あとで番号で絞って1件分の流れを再現できます。
実装は lib/logger/context.ts L70-73:
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 で閉じています。つまりハンドラの中身が丸ごと入っています。 検査・取得・集計・ログの全部が整理番号の圏内なので、どこで出たログにも番号が付きます。
const { searchParams } = new URL(request.url);
request.url(例: http://localhost:3000/api/dashboard?date=2026-09-01)を URL に食わせると、 ? 以降を扱う道具 searchParams が取れます。分割代入(const { searchParams } = ...)で必要な部分だけ抜いています。
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行ずつ。
searchParams.get("date") … ?date=... が無ければ null が返ります。dateRaw && !parseDate(dateRaw) … 「指定があって、かつ形が不正なとき」だけエラーにします。 dateRaw が null(未指定)なら && の左が偽なので parseDate は呼ばれず、エラーになりません。未指定は正常という設計です。parseDate(lib/clean-logs/query.ts L29-34)の中身:
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 として解釈させるためで、実行環境のタイムゾーンに結果を左右されないようにしています。body … 平易に: 返す紙の下書き。ApiError という型に当てているので、キー名を間違えるとビルド時に叱られます。 lib/types/index.ts L30-36 が定義:
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 の形は { 項目名: [説明文, ...] }。平易に: 「どの項目が、なぜダメか」を項目別に返すので、画面が入力欄の横に出せます。NextResponse.json(body, { status: 422 }) … 本文を JSON にして、番号 422 を付けて返す。 422 の意味: 「文法は読めたが、内容が受け付けられない」。平易に: 書類の書式が違う、という返し方です(400 でも 500 でもない)。return しているので、ここで処理は終わり。以降の DB 取得は行われません。★見落としやすい点★: この 422 は logApiError を呼びません。ログを出すのは 500 のときだけ(L69)です。 平易に: 「利用者の指定ミス」はアプリの故障ではないので、エラーログには残さないという切り分けです(★この意図はコメントに無いので推測★。ただし /clean-logs・/prechecks も同じく 422 ではログを出さず、3ルートで扱いが揃っていることは確認済み)。
// 省略時は当日(=昨夜分)。翌朝確認型の運用に合わせる 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 の運用像を指しています。夜間に清掃が走り、朝それを確認する使い方なので、既定が「今日」でよい、という筋です。// group_id / customer_id は受け取るが現状は無視する。 // fixtures に顧客グループの絞り込みに使える対応表が無く、/clean-logs も同じ扱いのため、 // ここだけ先に実装すると2箇所で意味が違う状態になる。実DB接続時にまとめて対応する。
コードは1行もありません。「意図的に何もしていない」ことを書き残したコメントです。 docs/decisions.md D-053(「マスタが無い間は、機能しない絞り込みUIを画面に出さない」)と同じ判断で、 app/api/prechecks/route.ts L44-47 にもほぼ同文のコメントがあります。
Promise.all で3つ同時(L52-71)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
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 の中に閉じています。
// 日付の切り出しは文字列で行う。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 と同じやり方」と明記され、共通ルールになっています。
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 の記録禁止フィールド。レスポンスに載せる必要も無い)。
data.anomaly を出す条件(L81-93)★依頼の重点★// スケジュール由来の予定件数と当日の清掃ログ行数が合わないことの記録(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)。具体例(★想定例。fixtures の実データではありません★)
expected: 12, actual: 0 の warn が出る。収集処理が止まった疑い。なぜこの記録が必要か: D-049 の
監視:
plannedと当日の清掃ログ行数が乖離した場合はdata.anomalyイベントを記録する。収集処理の障害検知になる。期待値と実測値だけを残し、原因は断定しない
「原因の断定は書かない」の意味: ログに "収集処理が停止しています" と書くと、実際は定休日設定ミスだった場合に 読んだ人を誤った方向へ誘導します。だから expected と actual の数字だけを残し、解釈は人に任せます。 レベルが "error" ではなく "warn" なのも同じ理由で、壊れたとは断定していないという表明です(★レベル選択の理由はコメントに無いので推測★)。
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。| # | 行 | 条件 | 結果 |
|---|---|---|---|
| 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} |
app/api/clean-logs/route.ts — 「動く見本」全 60 行。冒頭 L4 に自己申告があります。
★これが「動く見本」です。他の一覧APIはこの形を真似して作ってください★
L6-7 も重要な運用ルールです。
仕様の正は
d-watch-api.yamlの /clean-logs です。 APIを変えたくなったら、先にYAMLを直してからコードに反映してください(CLAUDE.md 作業ルール1)。
L9-14 に処理の目次がそのまま書かれています。
- クエリパラメータのパースとバリデーション(31日超は422)
- データソース(fixtures または Prisma)からの取得
- フィルタ(日付範囲 / 施設名 / 機種 / シリアル / 判定)
- ページング
{ items, meta }形式で返却
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 に相当する監視がこの窓口には無い)。
| export | 形 |
|---|---|
GET |
async 関数(ルートハンドラ) |
errorResponse(L25-27)は export されていないファイル内ヘルパー関数です。
function errorResponse(error: ApiError, status: number) {
return NextResponse.json(error, { status });
}
withRequestId(request, async () => {...}) で全体を包む(§5.6 と同じ)。const { searchParams } = new URL(request.url);const parsed = parseCleanLogQuery(searchParams); 検査と既定値の決定を全部 lib に委ねています。/api/dashboard が parseDate を直接呼んで自前で 422 を組んでいたのに対し、 こちらは条件が多いので関数まるごと外に出す形です。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万件ください」は叱らずに、安全な値へ丸めます。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 も同様)。害はありませんが、見本として読むときは気づく箇所です。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 は絞り込んだ後・小分けする前の件数です。これが「全部で何件あるか」の表示と「次のページがあるか」の判断に使われます。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)は中身の型を差し替えられるジェネリクス(平易に: 「箱の型」だけ決めて、 中に入れる物の種類はあとから指定する仕組み)。| # | 行 | 条件 | 結果 |
|---|---|---|---|
| 1 | L33 | parsed.ok が偽 |
parsed.status(=422)で parsed.error を返す |
| 2 | L40 | db.cleanLogs() が例外 |
api.error をログに出し 500 |
| 3 | L58 | 通過 | 200 で {items, meta} |
app/api/prechecks/route.ts — summary をフィルタ前に計算する例全 83 行。§6 とほぼ同形なので、違う所だけを詳しく見ます。
形は
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)はできません。平易に: 窓口に「変更受付」の担当者を置いていない状態です。
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 を挟む」ために必要な分け方です。
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 が形不正 → nullstatus の既定は "open"(L45: searchParams.get("status") ?? "open")。STATUS_FILTERS に無い値は nullpage / per_page は /clean-logs と同様に補正status の既定が open である根拠は docs/decisions.md D-054:
GET /prechecks の
statusenum にopenを追加する:[open, unresolved, in_progress, resolved, all]。既定値をunresolvedからopenに変更する。open= 未解消+対応中(resolution_status !== "resolved")
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 という名前は字面より広い意味を持っています。読むときの注意点です。
meta.total と summary.total は別物(L67-74)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 になります。
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、と型で区別されています。
| # | 行 | 条件 | 結果 |
|---|---|---|---|
| 1 | L33 | parsePrecheckQuery が null |
422(details: null、項目名なし) |
| 2 | L52 | db.prechecks() が例外 |
api.error をログに出し 500 |
| 3 | L81 | 通過 | 200 で {summary, items, meta} |
/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) |
components/features/dashboard/DashboardScreen.tsx — 画面の本体全 177 行。ダッシュボードの中身は全部ここにあります。
"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";
| export | 形 |
|---|---|
DashboardScreen |
React コンポーネント(名前付き export。default ではない) |
app/(dashboard)/dashboard/page.tsx L1 が import { DashboardScreen } from ... と波カッコで受けているのは、 これが名前付き export だからです。page.tsx 側は export default である必要があります(Next.js が既定 export を探すため)。
"use client"(L1)が何を意味するか★用語: サーバーコンポーネント / クライアントコンポーネント
平易な言い換え
"use client" を付けたとき)… 部品の設計図と JavaScript をブラウザに送り、ブラウザ側で組み立てる。 組み立てキットを送るイメージ。ボタンを押したら数字が変わる、といった動きが作れます。なぜ DashboardScreen に必要か。次の4つは全部「ブラウザ側でないとできないこと」です。
useState(L26-29)で状態を持つuseEffect(L63-66)で表示後に取得を走らせるonClick(L108, L115)でクリックに反応する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箇所に固定するためでもあります。
用語: フック(Hook)
平易な言い換え: use で始まる特別な関数群で、コンポーネントに「記憶」や「タイミング」を持たせる道具です。 コンポーネントは本来「同じ入力なら同じ絵を返す」だけの関数ですが、 実際の画面には「読み込み中かどうか」「取ってきたデータ」など覚えておくべきことがあります。フックがそれを担います。
大事な前提を2つ。
if の中に入れてはいけません。 React は呼ばれた順番で「どの記憶がどれか」を対応づけているためです。 DashboardScreen の L21-29 と L31 / L63 は、すべて return より前の階層に平らに並んでいます。DashboardScreen の実物)useTheme()(L21-22)— 自作フック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)const router = useRouter();
平易に: 「ページを移動させる装置」。L108 と L115 の router.push("/detail-log") で使います。 <Link> と違い、JavaScript から任意のタイミングで移動できます(クリックハンドラの中など)。
useMemo(L24)— なぜ today を固定するのか★const today = useMemo(() => todayJst(), []);
用語: useMemo(メモ化)
平易な言い換え: 「一度計算した答えを覚えておいて、次からは計算し直さずその答えを使う」。 第2引数の [](空の配列)が「覚え直すきっかけは何も無い」という指定で、 つまり この部品が画面に置かれてから消えるまで、today はずっと同じ文字列になります。
目的(2つ)
fetchAll の依存を安定させ、取得の無限ループを防ぐ。 L61 で fetchAll は [today] に依存し、L66 で useEffect は [fetchAll] に依存します。 つまり today → fetchAll → useEffect という鎖になっています。 today が描画ごとに変わると fetchAll が作り直され、useEffect が再実行され、状態が変わって再描画され…と回り続けます。 useMemo で today を固定すると、この鎖の起点が動かなくなります。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つの記憶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)— fetchAllconst fetchAll = useCallback(async () => {
...
}, [today]);
用語: useCallback
平易な言い換え: useMemo の「関数版」。関数そのものを覚えておいて、毎回作り直さない。
なぜ必要か: JavaScript では、中身が同じ関数でも作り直すと「別物」と判定されます。 useEffect(..., [fetchAll])(L66)は「fetchAll が別物になったら実行し直す」という意味なので、 useCallback で包まずに毎描画で新しい関数を作ると、取得 → 状態更新 → 再描画 → 新しい関数 → また取得の無限ループになります。 [today] を依存に指定することで、「today が変わらない限り同じ関数」が保証されます。
fetchAll の中身(L32-60、順に)
setLoading(true); // L32 setError(null); // L33
まず「読み込み中」に戻し、前回のエラーを消します。再実行に備えた掃除です。
// コンパクト版ログは △×のみ・当日・上位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)★const [dashboard, logResp] = await Promise.all([
apiFetch<DashboardResponse>("/api/dashboard"),
apiFetch<ListResponse<CleanLogItem>>(`/api/clean-logs?${qs}`),
]);
setData(dashboard);
setLogs(logResp.items);
/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)
} catch (e) {
// 2本のうちどちらが失敗しても画面全体をエラー表示にする単純な設計にしている。
// 部分表示(片方だけ出す)はこの画面の要件にないため見送った
setError(
(e instanceof ApiFetchError ? e.serverMessage : null) ??
"データの取得に失敗しました"
);
setData(null);
setLogs([]);
}
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)
} finally {
setLoading(false);
}
用語: finally … 成功でも失敗でも必ず通る後片付けの節。 ここに setLoading(false) を置くことで、「エラー時に読み込み中が消えない」という取りこぼしが構造的に起きません。 try と catch の両方に書く方式だと、片方に書き忘れると画面が固まります。
useEffect(L63-66)useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch 起点の初期表示
void fetchAll();
}, [fetchAll]);
用語: useEffect(副作用フック)
平易な言い換え: 「画面を描き終わったあとにやること」を登録する仕組み。 データ取得・タイマー・外部への通知など、「絵を描く」以外の作業はここに書きます。 描画の途中で取得を始めると、描画と状態更新が絡んで動作が読めなくなるためです。
[fetchAll] … 平易に: 「fetchAll が別物に変わったときだけ、もう一度やる」。 fetchAll は useCallback([today]) で固定されているので、実質「最初の1回だけ」動きます。void fetchAll(); … void は「この関数の戻り値(Promise)は使わない」という明示。 useEffect に渡す関数は async にできない(返り値が後片付け関数と解釈される)ため、 中で呼んで結果を捨てるこの書き方になります。eslint-disable-next-line react-hooks/set-state-in-effect -- fetch 起点の初期表示 … lint が「effect の中で state を更新するな」と警告するのを、理由付きで1行だけ黙らせています。 -- の後ろが理由です。平易に: 「これは分かっていてやっている。初期表示のための取得だから」。| フック | 行 | 何のために |
|---|---|---|
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() でカードのクリックから画面遷移する |
// 「成功 / 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。planned を使わないのか」を4行かけて説明しています。 planned はスケジュールマスタ由来(D-049)で、分母として意味が違うからです。CLAUDE.md §9.6(L75)は「判定結果(◎○△×、L1〜L3、ソート順)はAPIから受け取る。フロントで再計算しない」。 ここで足しているのは判定ではなく、API が返した3つの数の合計です。禁止されている「判定の再計算」には当たりません。 ただし「合計を画面で作っている」ことは事実で、API が同じ意味の値を持っていないためこうなっています(DashboardResponse.summary に合計のフィールドは無い。lib/types/index.ts L308-315 で確認)。厳密に見れば、画面が意味を組み立てている唯一の箇所です。loading / error / data の3状態の出し分け★(L76-93)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) |
設計の要点
loading、L91 は !loading && error、L93 は !loading && !error && data。 平易に: 「読み込み中」と「エラー」が同時に出ることは構造上ありえません。&& data は型のためにも必要です。 data が null かもしれない状態では data.summary.success(L99)と書けません(TypeScript が「null の可能性がある」と止めます)。 data && を通すことで、それ以降 data が確実に存在すると TypeScript が理解します。 平易に: 「中身があると確認してから開ける」を型検査が強制しています。data の有無を独立に確認しています。 ヘッダは3状態共通なので、 L93 の保護の外にあり、ここで自分で data && を書く必要があります。 c.wn(THEME_TOKENS[theme].wn = ダークなら #f59e0b)を渡すのが THEME_TOKENS の唯一の使用箇所です。<>...</>(L94, L173)は フラグメント。平易に: 余計な <div> を増やさずに複数のタグをまとめるための「透明な袋」。 ここで <div> を挟むと、親の CSS グリッド(.statGrid など)の配置が崩れます。セキュリティバナー(L95)
<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)
<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)。displayRate(lib/judge/grade.ts L36-39):
export function displayRate(rate: number | null): string {
if (rate === null) return "-";
return `${Math.round(rate)}%`;
}丸めは表示層でだけ行う(CLAUDE.md §9.3 L52「数値の丸めは表示層でのみ行う。ロジック層には生値を渡す」)。 API は 86.666... を返し、画面が 87% にします。"-" を使う理由を残しています(アプリ全体で 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)
<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)
<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 と画面がかみ合う場所です。
data.failure_breakdown ? "1fr 1fr" : "1fr" … あれば2列、無ければ1列。 1列にしないと、スケジュール進捗カードが左半分だけに寄って右が空白になります。 平易に: 相棒がいないときは幅いっぱいに広がる。data.failure_breakdown && … キー自体が無い(undefined)ので偽になり、ブロックごと消えます。もし API が空配列は「当日の失敗が0件」を意味するため、未実装をそれで表すと UI が「失敗なし」として正常表示してしまいます。
failure_breakdown: [] を返したら、L161 の [] は真なので枠が出て、 中の .map() が0回まわり、「失敗の内訳」という見出しだけの空っぽの箱が表示されます。 読んだ人は「失敗ゼロだったのだな」と受け取ります。それが嘘になるので、キーを送らない、という筋です。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 と同じ思想の別の現れ方です。
components/ui/StatCard.tsx — 計算をしない部品全 35 行。
import styles from "./StatCard.module.css";
用語: CSS Modules
平易な言い換え: CSS のクラス名をこのファイル専用に自動で書き換えてくれる仕組み。 .card と書いても実際には StatCard_card__x7f2q のような固有名になるので、 他のファイルの .card と衝突しません。styles.card と JavaScript のオブジェクト経由で参照します。 "use client" はありません(サーバーで描かれる部品)。
| export | 形 | 行 |
|---|---|---|
StatTone |
型(文字列の union) | L8 |
StatCard |
React コンポーネント(名前付き) | L24-35 |
StatCardProps(L10-17)は export されていないファイル内の型です。
用語: props(プロパティ)
平易な言い換え: 部品に外から渡す設定値。関数の引数と同じで、部品の中からは書き換えられません(読み取り専用)。
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):
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 の方が後から整えられた形と読めます — ★これは推測★。)
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>
);
}
順に。
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)。 平易に: 押せるものだけカーソルが指の形になる。押せないカードで空振りさせない配慮です。onClick={onClick} … 受け取ったものをそのまま渡す。undefined なら何も起きません。label → value → sub。 {sub && <div ...>} で、sub が無いときは要素ごと出しません(空の <div> が余白を作るのを避ける)。このファイルには、算術演算・条件による値の加工・データの集計が一切ありません。 全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)が 別に存在するため、機能として到達不能にはなっていません。
components/ui/ProgressBar.tsx — 唯一の防御的な計算全 28 行。
import styles from "./ProgressBar.module.css"; // L1
| export | 形 | 行 |
|---|---|---|
ProgressBar |
React コンポーネント | L17-28 |
ProgressBarTone(L3)と ProgressBarProps(L5-9)はexport されていません。 StatCard が StatTone を export しているのと対照的です(★不揃いの理由はコメントに無いので推測★。 tone を外から型として指定する必要が生じていないだけと読めます)。
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) } があるためです。
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>
);
}
Number.isFinite(percent) ? percent : 0 用語: NaN(非数)/ Infinity(無限大)。平易に: 0 / 0 は NaN、5 / 0 は Infinity という 「数値のようで数値でない値」になります。これを CSS の width に入れると width: NaN% という壊れた指定になります。Math.max(0, Math.min(100, safePercent)) 用語: クランプ(clamp)。平易に: 範囲外の値を範囲の端で止める。 120 は 100 に、-5 は 0 に。二重に包むことで「100 を超えない、かつ 0 を下回らない」を1行で表しています。★呼び出し元が null/未定義を渡さない前提だが、0除算由来のNaN/Infinityが 万一渡ってもバーが壊れないよう、ここで最終防御として0にクランプする★
<div>。外が溝(.track: 高さ6px、背景 var(--bd)、overflow: hidden、CSS L1-7)、 内が塗り(.fill + tone のクラス、CSS L9-28)。style={{ width: ... }} は値が動的なのでインラインで指定します (CSS ファイルには書けない値なので、これは規約違反ではありません)。L18-19 は算術演算です。ただし内容は 表示幅を壊さないための防御であり、 業務上の意味を作る計算(率の算出、判定)ではありません。率そのものは lib/dashboard/aggregate.ts L101 (rate: planned === 0 ? null : (completed / planned) * 100)が計算し、 null のときは ScheduleProgressCard L21 がバーを描かずに別メッセージへ切り替えます(§9.9)。 ProgressBar に null が届かない構造になっているうえで、さらに保険を掛けているという位置づけです。
components/ui/GradeBadge.tsx — 判定記号を色付きの札にする全 16 行。
import type { Grade } from "@/lib/types"; // L1
import styles from "./GradeBadge.module.css"; // L2
| export | 形 | 行 |
|---|---|---|
GradeBadge |
React コンポーネント | L8-16 |
interface GradeBadgeProps {
grade: Grade; // L5
}
props はこれ1つ。Grade は lib/types で定義された型で、"◎" "○" "△" "×" などの記号の union です。 平易に: 「文字列」ではなく「決まった4種類のどれか」と型で縛っているので、"◯"(別の丸)などを渡すと型検査で止まります。
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>
);
}
◎ または ○ → "ok"(緑)△ → "wn"(橙)"ng"(赤)× → "ng" ではなく else → "ng" になっている点に注意してください。 想定外の記号が来ても赤で表示されます。平易に: 分からないものは安全側(目立つ側)に倒す作りです(★この意図はコメントに無いので推測★)。<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 で確認)。 テストや外部からの選択に使える手がかりとして残されていると読めます(★推測★)。{grade} … 受け取った記号をそのまま表示。別の記号に置き換えたりしません。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/ 側の仕事です。)
components/layout/AppShell.tsx — サイドバー付きの外枠全 152 行。app/(dashboard)/layout.tsx から呼ばれ、10画面すべてを包みます。
"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)。
| export | 形 | 行 |
|---|---|---|
AppShell |
React コンポーネント(名前付き) | L45-151 |
NavItem(L26-43)は export されていないファイル内コンポーネント。 MENU_MONITORING / MENU_DS / MENU_SETTINGS(L8-24)も export されていない定数です。
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)。NavItem(L26-43)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 クラスが付きます。 平易に: 今いる画面のメニューだけ色を変える。ロジックはこれだけです。
AppShell 本体(L45-151、順に)export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { theme, lang, toggleTheme, toggleLang, t } = useTheme();
usePathname() … 今の URL のパス部分(/dashboard など)を返すフック。DashboardScreen は t と theme の2つだけ)。L51-112 サイドバー上部(ブランドと2つのボタン)
fill="var(--pr)" / stroke="var(--pr)" とCSS 変数を使っているので、 ダーク(#3b82f6)とライト(#2563eb)でアイコンの色まで自動で切り替わります。 aria-hidden(L54)は「読み上げソフトは無視してよい飾り」の印。{t("DFAロボット監視システム")} … 辞書にあり(lib/i18n.ts L8)、EN では "DFA Robot Monitoring System"。<button type="button" className={styles.iconBtn} onClick={toggleTheme}
aria-label={theme === "dark" ? "ライトモード" : "ダークモード"}>
{theme === "dark" ? "☀" : "🌙"}
</button>分岐が2つ(aria-label と表示文字)。どちらも 「今」ではなく「押したらこうなる」 を示しています。 ダークのときに ☀ が出るのは「押すと明るくなる」という意味。平易に: スイッチには行き先を書く。 aria-label は絵文字だけでは読み上げソフトが意味を伝えられないための代替テキストです。<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 ナビゲーション
<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) => ( ... ))}
active={pathname === m.href} … 完全一致での比較。 現状すべての画面が1階層なので問題ありませんが、将来 /detail-log/123 のような下位 URL を作ると メニューの色が消えます("/detail-log/123" === "/detail-log" は偽)。観察事項として記録します。{配列.map(...)} … 平易に: 配列の各要素を1つずつタグに変換して並べる。key は要素を見分ける札。D-Security だけ t() を通していません。 製品名なので翻訳しない、という判断と読めます (★推測★。他の2つの見出し L119 / L137 は t() を通し、辞書にも モニタリング: "MONITORING" / 設定: "SETTINGS" があります)。L147 / L149
<div className={styles.footer}>{t("DFA運用担当")}</div>
...
<main className={styles.main}>{children}</main>
children が入るのは <main> の中だけです。つまり 画面を切り替えても <aside>(サイドバー)は作り直されません。 これが「レイアウトは再生成されない」という §1.5 の性質の実物で、 テーマ/言語の状態が画面遷移で失われない理由でもあります(状態を持つ ThemeProvider はさらに外側の app/layout.tsx にある)。
components/theme/ThemeProvider.tsx — ★React Context とは何か★全 59 行。アプリ全体のテーマと言語を配る仕組み。
専門用語: 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 の中) |
"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 の構成の基本です。
| export | 形 | 行 |
|---|---|---|
ThemeProvider |
React コンポーネント | L24-51 |
useTheme |
関数(自作フック) | L53-59 |
ThemeContextValue(L14-20)と ThemeContext(L22)はexport されていません。 これが意図的で重要です: 外から ThemeContext を直接触れないので、 利用者は必ず useTheme() を通ることになり、§14.6 の安全確認が必ず働きます。
interface ThemeContextValue {
theme: ThemeName; // "dark" | "light"
lang: Lang; // "ja" | "en"
toggleTheme: () => void;
toggleLang: () => void;
t: (text: string) => string;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
theme, lang)と「切り替える手段」2つ(toggleTheme, toggleLang)と「翻訳関数」1つ。createContext<... | null>(null) … 既定値を null にしています。 平易に: 「放送局が無い場所では null が聞こえる」。この null を §14.6 でエラーに変えます。ThemeProvider の中身(L24-51、順に)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 とブラウザの初期状態が食い違わず、開いた瞬間に色がちらつきません。
useEffect(() => { // L28
document.documentElement.dataset.theme = theme; // L29
document.documentElement.lang = lang === "ja" ? "ja" : "en"; // L30
}, [theme, lang]); // L31
このアプリで色が変わる仕掛けの中心はこの2行です。
document.documentElement は <html> 要素。dataset.theme = "light" は HTML の data-theme="light" 属性を書き換えます。すると app/globals.css L22 の [data-theme="light"] { --bg: #f1f5f9; ... } が効き始め、CSS 変数を使っている全要素の色が一斉に変わります。 平易に: 各部品に「色を変えろ」と伝えて回るのではなく、元栓を1つひねる方式です。 だから StatCard などは色の切替を一切知りません(§10)。<html lang> の書き換え。読み上げソフトやブラウザの翻訳機能への申告です。useEffect の中なのか: document はブラウザにしか存在しないので、サーバー側の描画中に触ると落ちます。 useEffect は描画後にブラウザでだけ走るので、安全な置き場所です。[theme, lang] … どちらかが変わったときだけ実行。平易に: 変わっていないなら触らない。 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 を使うので外の値に一切依存せず、一度作れば作り直す必要がありません。const t = useCallback((text: string) => translate(lang, text), [lang]);
t は lang を閉じ込んだ翻訳関数です。平易に: 呼ぶ側が毎回「今は日本語です」と言わずに済むよう、 言語を先に埋め込んだ関数を配ります。だから画面側は t("成功") と書くだけで済みます。 依存が [lang] なので、言語が変わったときだけ新しい t が作られ、それを使っている画面が描き直されます。
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 が無意味になる)。<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> … ここから内側のすべての子孫が value を読めるようになります。 app/layout.tsx L29 でここに {children} が入るので、アプリ全体が範囲です。useTheme(L53-59)— 自作フックと安全確認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。Provider の外で使うと ctx.t is not a function のような 読みづらいエラーになりますが、ここで "useTheme must be used within ThemeProvider" と 原因を名指しして止めます。平易に: 配線を間違えたときに、間違いの名前を言って止まる。if (!ctx) throw を通り抜けた後、戻り値の型は ThemeContextValue | null ではなく ThemeContextValue になります。だから使う側は useTheme()?.t のような ?.(存在確認)を書かずに const { t } = useTheme() と書けます(DashboardScreen L21, AppShell L47)。use で始まり、中で別のフック(useContext)を呼ぶ関数。 平易に: よく使う手順に名前を付けて1つの道具にまとめたもの。React にとって特別な機能ではなく、命名の約束です。localStorage / sessionStorage / Cookie への保存はこのファイルにありません(grep で確認)。 そのため ブラウザを再読み込みすると、テーマは常に dark、言語は常に ja に戻ります。 画面遷移(Link による移動)では ThemeProvider が作り直されないので保持されます。 CLAUDE.md §9.6(L76)は 「ダーク/ライト、JP/EN切替の器を壊さない」 とだけ定めており、 永続化は求めていません。「器」(切替の仕組み)は動いている状態です。
lib/i18n.ts — t() は何をしているか全 56 行。
ありません。 他のどこにも依存しない、独立した辞書ファイルです。
| export | 形 | 行 | |
|---|---|---|---|
Lang |
型(`"ja" \ | "en"`) | L1 |
translate |
関数 | L53-56 |
EN(L7-51)は export されていないファイル内の定数です。外から辞書をいじれません。
/**
* モック(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 の識別子として使える文字(漢字・かなは使えます)だと引用符を省略できるためで、 "📊 ダッシュボード" のように空白や絵文字が入ると省略できません。意味の違いはありません。
translate() の中身(L53-56)export function translate(lang: Lang, text: string): string {
if (lang === "en" && EN[text]) return EN[text];
return text;
}
全部で2行。分岐は1つ(&& を2条件と数えれば2つ)。
lang === "en" … 日本語のときは辞書を一切見ません。EN[text] … 辞書に登録があるか。平易に: 対訳表を引いて、見つかればそれを返す。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 を使えば閉じられます)。ThemeProvider.tsx L41 が translate を lang ごと閉じ込めて t にし、 画面側は useTheme() から t を受け取って使います。画面から translate を直接呼んでいる箇所はありません(grep で確認)。 平易に: 「今の言語」を知っているのは ThemeProvider だけで、各画面は言語を意識せず t() を呼ぶだけ、という分担です。
lib/theme.ts — 色の表(JavaScript 側)全 43 行。
import はありません。
| export | 形 | 行 | |
|---|---|---|---|
ThemeName |
型(`"dark" \ | "light"`) | L1 |
THEME_TOKENS |
定数オブジェクト(as const) |
L4-43 |
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] の参照が型安全になります。
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箇所だけです。
// 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 が
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 には記載がありません★
CLAUDE.md §9(実装規約)と実コードの対照CLAUDE.md L235 から始まる §9 のうち、画面・窓口に関わる条項を実コードと突き合わせました。
| 規約の記述 | 実物 | 一致 |
|---|---|---|
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ルート」)と読めます。
| 規約 | 検証 |
|---|---|
| ファイル/ディレクトリ: 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/ に集約」から外れていますが、クエリ処理に固有の型なので実務上の判断と読めます(★推測★)。
| 規約 | 検証結果 |
|---|---|
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 は一覧ではないので対象外) |
規約の全文は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)だけで、これは規約の対象外です。
lib/config/ から読む。ハードコード禁止」 … SUCCESS_RATE_TARGET(画面 L122)、 MAX_DATE_RANGE_DAYS(lib/clean-logs/query.ts)、PAGINATION すべて lib/config 経由 ○USE_FIXTURES の切替を意識しない」 … components/ 内に USE_FIXTURES は0件、 apiFetch が /api/... を叩くだけ ○| 専門用語 | 平易な言い換え | 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 |
事実(コードとコメントから確認)
page.tsx は全画面が1行の取り次ぎ役で、ロジックを持たない。withRequestId で包まれ、500 のときだけ logApiError を呼ぶ。422 では呼ばない。failure_breakdown は API がキーごと省略し、画面が data.failure_breakdown &&(L161)と gridTemplateColumns(L152)の2箇所で「無い」場合に対応している(D-048)。/prechecks は summary を日付フィルタ後・状況フィルタ前に計算する(D-054)。StatCard は計算を一切していない(§10.5 の検証表)。components/ 内の算術は ProgressBar L18-19(表示防御)と DashboardScreen L72-74(3値の合計)の2箇所だけ。<html data-theme> の1属性の書き換えで全画面に効く(ThemeProvider L29 + globals.css L1/L22)。t() は英語モードで辞書に登録がある場合だけ差し替え、それ以外は素通し。辞書は51項目。観察(規約違反ではないが記録に値する)
lib/theme.ts と app/globals.css の2箇所にある。ずれても検知されない。用途は Pill の1箇所のみ(§16.3)。/prechecks の 422 は details: null で項目名が返らない。/clean-logs は項目名つき(§7.3)。errorResponse() ヘルパーがあるのに 500 の経路では使われていない(/clean-logs L45, /prechecks L57)。AppShell の active 判定が pathname === m.href の完全一致。下位 URL を作ると選択表示が消える。StatCard のクリック可能なカードが <div onClick> で、キーボード操作に対応していない。dark/ja に戻る(規約は永続化を求めていない)。.next/types/routes.d.ts は生成物で、現在 /api/prechecks 等が未反映(次の next dev で更新される)。lib/i18n.ts の EN[text] はプロトタイプのキー(toString 等)に反応しうる。日本語 UI 文言では実害なし。推測(コードやコメントに根拠が無い読み解き。この文書では明示した箇所のみ)
app/(dashboard)/layout.tsx が型を手書きしているのは、生成型 LayoutRoutes が "/" のみで ルートグループを含まないため(生成型の事実は確認済み、判断の理由は推測)。data.anomaly を error ではなく warn にしているのは、断定を避ける方針の一環。Pill が tone 方式でないのは、StatCard より先に作られた形が残っているため。GradeBadge の else → "ng" は、想定外の値を目立つ側に倒す安全側の設計。指示された読み取り対象(14ファイル + CLAUDE.md §9)
C:\Users\daich\dev\d-watch\app\layout.tsx(33行)C:\Users\daich\dev\d-watch\app\page.tsx(5行)C:\Users\daich\dev\d-watch\app\(dashboard)\layout.tsx(9行)C:\Users\daich\dev\d-watch\app\(dashboard)\dashboard\page.tsx(5行)C:\Users\daich\dev\d-watch\app\api\dashboard\route.ts(102行)C:\Users\daich\dev\d-watch\app\api\clean-logs\route.ts(60行)C:\Users\daich\dev\d-watch\app\api\prechecks\route.ts(83行)C:\Users\daich\dev\d-watch\components\features\dashboard\DashboardScreen.tsx(177行)C:\Users\daich\dev\d-watch\components\ui\StatCard.tsx(35行)C:\Users\daich\dev\d-watch\components\ui\ProgressBar.tsx(28行)C:\Users\daich\dev\d-watch\components\ui\GradeBadge.tsx(16行)C:\Users\daich\dev\d-watch\components\layout\AppShell.tsx(152行)C:\Users\daich\dev\d-watch\components\theme\ThemeProvider.tsx(59行)C:\Users\daich\dev\d-watch\lib\i18n.ts(56行)C:\Users\daich\dev\d-watch\lib\theme.ts(43行)C:\Users\daich\dev\d-watch\CLAUDE.md §9(L235-311)裏取りのために追加で読んだファイル(20ファイル)
C:\Users\daich\dev\d-watch\app\globals.css(CSS 変数の定義とフォント方針)C:\Users\daich\dev\d-watch\app\(dashboard)\reports\page.tsx(未実装画面の例)C:\Users\daich\dev\d-watch\components\layout\PlaceholderScreen.tsxC:\Users\daich\dev\d-watch\components\features\dashboard\ScheduleProgressCard.tsxC:\Users\daich\dev\d-watch\components\features\dashboard\SecurityBanner.tsxC:\Users\daich\dev\d-watch\components\ui\Pill.tsxC:\Users\daich\dev\d-watch\components\ui\StatCard.module.cssC:\Users\daich\dev\d-watch\components\ui\ProgressBar.module.cssC:\Users\daich\dev\d-watch\components\ui\GradeBadge.module.cssC:\Users\daich\dev\d-watch\components\features\dashboard\DashboardScreen.module.cssC:\Users\daich\dev\d-watch\lib\api\client.ts(apiFetch / ApiFetchError)C:\Users\daich\dev\d-watch\lib\logger\index.ts(logEvent / mixin)C:\Users\daich\dev\d-watch\lib\logger\context.ts(withRequestId)C:\Users\daich\dev\d-watch\lib\logger\api.ts(logApiError)C:\Users\daich\dev\d-watch\lib\dashboard\aggregate.ts(集計3関数)C:\Users\daich\dev\d-watch\lib\clean-logs\query.ts(parseDate / parseCleanLogQuery)C:\Users\daich\dev\d-watch\lib\prechecks\query.ts(parsePrecheckQuery / summary の順序)C:\Users\daich\dev\d-watch\lib\format\date.ts(todayJst / formatHm)C:\Users\daich\dev\d-watch\lib\judge\grade.ts(displayRate)C:\Users\daich\dev\d-watch\lib\config\index.ts(SUCCESS_RATE_TARGET 等)C:\Users\daich\dev\d-watch\lib\db\index.ts(USE_FIXTURES 切替)C:\Users\daich\dev\d-watch\lib\types\index.ts(ApiError / DashboardResponse 等)C:\Users\daich\dev\d-watch\docs\decisions.md(D-048 / D-049 / D-052 / D-053 / D-054)C:\Users\daich\dev\d-watch\package.json(Next 16.3.3 / React 19.2.8)C:\Users\daich\dev\d-watch\.next\types\routes.d.ts(生成された URL 一覧と LayoutProps)リポジトリへの変更: なし(読み取りのみ)。