| 項目 | 値 |
|---|---|
| 状態 | 完了 |
| 作成 | 2026-09-03(作成時点の決定事項最終番号 D-064) |
| 実行環境 | Claude Code(Opusがサブエージェント(opus)に段階ごと委譲。段階0〜5をすべて実施) |
| 実行日 | 2026-09-07 |
| 実行コミット | 1ef4c33(段階0/D-068) 234987e(段階1) daf3788(段階2) 787f143+4c47412(段階3) 7afe055(段階4) 6ecfc2b+368e185(段階5) |
| 取得した決定番号 | D-068 / D-069 |
実行後はこの表を更新してください。
★この指示書を読んでいるあなたへ★
.claude/commands/d-watch.mdに「実装は Cursor 側、Claude Code は指示書作成まで」とありますが、 これは2つの環境の役割分担です。Cursor の中で動いている Claude は実装担当です。 この指示書を渡されたなら、調査や改訂ではなく実装を行ってください。
F-04 レポートは、未実装の画面のなかで最大のものです。 そして TeamWorks への確認待ち(B-α: D-Sec のテーブル不明 / B-β: 事前チェック時点の状態が無い)に 一切依存しません。 回答を待たずに完成させられます。
材料はすでに揃っています。
| 材料 | 状態 |
|---|---|
| API契約 | d-watch-api.yaml に4本定義済み(:408 / :432 / :475 / :508)※ただし段階0の修正が要る |
| 判定ロジック | lib/judge/kpi.ts 実装済み(4式すべて・分母0→null・丸めなし)+テスト13グループ |
| サンプルデータ | lib/fixtures/reports.ts に7 export(ただしどこからも参照されていない=完全に未接続) |
| 型 | lib/types/index.ts:128-182 に6種 |
| 画面レイアウト | モック d-watch-mock.jsx:654-828 |
| メニュー | components/layout/AppShell.tsx:11 に /reports が既にある(変更不要) |
| 共通部品 | StatCard / ProgressBar は JSDoc に「F-04 で共用する」と明記済み |
足りないもの(この指示書で作るもの): lib/reports/(ディレクトリごと) / app/api/reports/(4ルート) / components/features/reports/ / 期間ヘルパー / DataSource の4メソッド / i18n の対訳。
次の6点はすでに判断済みです。実装時に蒸し返さないでください。
period + year + month + week)に寄せるモックにある日付範囲の <input type="date"> 2つ(d-watch-mock.jsx:664 / :666)は作りません。
2026-03-01〜2026-03-27)と週次/月次トグルの両方を持っており、 「27日間の範囲を週次で見る」は意味が通りません。モック側の設計ミスと判断しましたprismaSource でサマリを使えなくなります(毎回詳細テーブルから再集計する羽目になる)[週次][月次] ‹ 2026年3月 第4週 ›
Period / Year / Month / Week(:887-912)をそのまま叩きます。変換ロジック不要kpiCurrentPeriod、確定期なら kpiWeekly / kpiMonthly を返すだけprismaSource 実装時に入れますdocs/spec.md M4 の完了条件「サマリ参照と当期集計の切替」を形だけ満たしますd-watch-api.yaml:23-24 が「この切替はAPI内部で吸収する」と明記しており、モックにも該当表示はありませんyearly)は F-04 の範囲外CLAUDE.md §4.7 と D-023 は年次バッチを定義していますが、モックの期間トグルは週次/月次の2つだけ (d-watch-mock.jsx:681-686)で、YAML の Period enum も [weekly, monthly] です。 enum に yearly を追加しないこと。 段階0で description にその旨を書きます。
<select> は画面に出さない(D-053)モック :668-673 にはありますが、マスタ未整備のため機能しません。 D-053 は「機能しない絞り込みUIを画面に出さない」と決めており、DetailLogScreen で削除済みの前例があります。 API 側は group_id / customer_id を受け取って無視する(他5ルートと同じ)。理由をコメントで残すこと。
モックの4枚目は 4.2% と小数を出していますが(:688)、これはハードコードのダミー値です。 D-020「表示は小数第1位を四捨五入して整数」が正。 displayRate()(lib/judge/grade.ts:36)を使うこと。
diff)も同じ規則で整数にする(▲2% のように)judgeFacilityStatus には生値を渡す)★緊急の注記(2026-09-07)★ TeamWorks からこの節と食い違うルールの回答が来ました(D-068)。 先方は「週を月境界で分割する」(
2026-08-30〜08-31が8月第6週、09-01〜09-05が9月第1週)。 週次サマリを作るのは TeamWorks で、DFA はそれを読むため、合わせる必要があります。 ★weekOfMonth()/periodRange()/shiftPeriod()の実装(段階1)は、 ルールが確定するまで着手しないでください。★ 段階0(YAML)と段階2以降(fixtures / DataSource / query / ルート / 画面)は影響を受けません。
CLAUDE.md §4.7 に旧DFA案(週の開始日が属する年月)が書かれていましたが、 逆のルールに変更されました。 CLAUDE.md は修正済みです。
1日でも翌月が混ざれば、その週は翌月の第1週
| 週 | 帰属 |
|---|---|
2026-08-23(日)〜08-29(土) |
8月の週 |
2026-08-30(日)〜09-05(土) |
★9月 第1週★ |
2026-09-06(日)〜09-12(土) |
9月 第2週 |
これで weekOfMonth() の実装が確定しました(段階1)。 月次・年次は暦月・暦年の日単位で締める(週とは独立)点は変更ありません。
★TeamWorks 側の週次サマリと帰属が一致しているかは未確認★(第4回 W-1 で照会中)。 食い違っていた場合に備え、帰属の判定を
weekOfMonth()1関数に閉じ込め、 他のコードがルールを知らない形にすること。
docs/spec.md:58 は「連続問題施設(L1〜L3)」と書いていますが、 モック :714 は RANK.filter(f => f.cont >= 2) で L1 を除外しており、 YAML の summary(:478)も「2日以上連続△以下 or 出発失敗」です。
モックと YAML が一致しているので、2日以上(L2 / L3)が正。 spec.md の記述が誤りです。 絞り込みは API 側で行うこと(画面で絞らない)。lib/fixtures/reports.ts の L1 データ(:226-233)は 「絞り込みが効いていること」の確認用なので消さないこと。
git status # クリーンであること npm test # 16ファイル / 267テスト 全パス(これが基準値) npx tsc --noEmit # エラー0
docs/decisions.md の最終番号を確認すること。2026-09-07 時点の最終は D-067 (指示書D が D-062〜D-064、その後 TeamWorks 回答の反映などで D-065〜D-067 を取得)。 この指示書で取る番号は D-068 からです。★着手直前に必ず最終番号を確認すること★
d-watch-api.yaml を直す(★必ず最初にやること★)D-040「API契約はYAMLが先」。 現状の YAML は lib/types/index.ts と3箇所で食い違っており、 このまま実装すると契約違反のコードができます。
ContinuousProblem を名前付きスキーマに切り出し、項目を揃える現状 :499-505 はインラインの匿名スキーマで、lib/types/index.ts:152-159 と食い違っています。
| 項目 | YAML 現状 | lib/types |
採用 |
|---|---|---|---|
facility_id |
無し | number |
追加する |
facility_name |
string | string |
そのまま |
| 連続日数 | consecutive_days |
consecutive_problem_days |
consecutive_problem_days に統一 |
escalation_level |
enum [L1,L2,L3] |
EscalationLevel |
そのまま |
completion_rate |
number float | 無し | YAMLから削除(下記) |
last_problem_on |
無し | string |
追加する |
summary |
無し | string |
追加する |
判断の根拠:
consecutive_problem_days が正。 YAML 自身が CleanLogItem(:977)と FacilityPerformance(:1068)でこの名前を使っており、:504 だけが表記ゆれfacility_id を足す。 他の一覧(FacilityPerformance:1058 / DsDevice:1122 / DsAlert:1152)は 全部持っています。行から施設へ遷移する導線に要りますcompletion_rate は削除。 モック :729 は「完了率 {n}%」を出していますが、 lib/fixtures/reports.ts は last_problem_on + summary でデータを作っており、 モックの「完了率」より summary(「スケジュール存在日ベースで3日連続。カレンダー上は5日間」等)のほうが D-026 の説明として有用です。画面は summary を出す形にします (モックからの逸脱になるので、決定事項に記録すること)components/schemas に ContinuousProblem として切り出し、:499-505 から $ref すること。
ErrorBreakdownItem を名前付きスキーマに切り出し、項目を揃える現状 :533-540 はインライン。lib/types/index.ts:171-175 と食い違っています。
| 項目 | YAML 現状 | lib/types |
採用 |
|---|---|---|---|
| 種別キー | error_type: string、enum定義なし、example が日本語 "ハンドルアップ" |
type: ErrorType(英字 enum 7種) |
type(英字 enum)に統一 |
label |
無し | string |
追加する |
count |
integer | number |
そのまま |
rate |
number float「全エラー件数に対する割合(%)」 | 無し | YAMLに残し、lib/types に追加する |
判断の根拠:
label が正。 YAML 自身の /dashboard の failure_breakdown(:156-169)が すでに type(英字enum) + label + count の3点セットを採用しています。 日本語文字列をキーにすると i18n・比較・DB保存のすべてで詰みますlib/types/index.ts:162-169 のとおり: handle_up / lost_location / offline / brush_error / emergency_stop / low_battery / otherrate はサーバが返す。 YAML :15-16 が「判定はサーバ側、フロントは表示のみ」を原則としており、 モック :766 も {e.pct}% を表示します。lib/types の ErrorBreakdownItem に rate: number を追加することerror_type の値の分類ルールは DFA 側が決める(D-057 の付随情報:「error_type は PUDU Cloud 由来で 列挙が未定義。プログラム側で分類する必要がある。未知の値は『その他』に寄せる設計が必要」)。 ただしこの指示書では分類ロジックを書きません(fixtures が分類済みのデータを持っているため)ReportKpi を名前付きスキーマに切り出す/reports/kpi の 200 レスポンス(:424-430)はインラインです。 lib/types/index.ts:177-182 の ReportKpi に対応する名前付きスキーマを components/schemas に作り、$ref すること。 前例: PrecheckListResponse(:1017)/ DsDeviceListResponse(:1132)/ DsAlertListResponse(:1180)。
KpiValue.value を nullable にする:1040 の value は非 nullable ですが、lib/judge/kpi.ts の4関数は 分母0のとき null を返します(:15-16「『予定が1件もない期間』と『予定はあったが全部失敗した』は 区別する必要がある」)。
現状 toKpiValue(value: number, ...)(kpi.ts:54)も number しか受けないため、 全施設定休日の週の KPI を返す経路が型として存在しません。
:1040 に nullable: true を追加lib/types/index.ts:132 を value: number | null にlib/judge/kpi.ts:54 の toKpiValue の第1引数を number | null に、diff は どちらかが null なら null にする/dashboard の success_rate(:133-138)と schedule_progress.rate(:150-155)は すでに nullable: true"422" を追加するレポート4本は period / year / month が required: true なのに、 422 のレスポンス定義がありません。 components/responses/ValidationError(:920-924)が既にあるので、 4本とも "422": { $ref: "#/components/responses/ValidationError" } を追加すること。
FacilityPerformance に serial_number を追加する/reports/facility-performance は serial(シリアル部分一致)で絞り込める契約(:447-450)なのに、 レスポンスの FacilityPerformance(:1054-1068)に serial_number がありません。 絞り込みキーが結果に出ないのは不整合です。serial_number: string を追加し、 lib/types/index.ts:141-150 と lib/fixtures/reports.ts にも足すこと。
/reports/error-breakdown の meta の扱いを決める:526-540 は items のみで meta がなく、CLAUDE.md §9.3「一覧APIは必ず meta を返す」に反します。
→ meta を足さず、「7分類固定でページングの余地がないため一覧APIの例外とする」と description に明記すること。 limit パラメータも無く、常に7件(または0件)を返すためです。 この判断を決定事項に記録すること。
Period の description に年次の扱いを書く:887-894 の description に「P1 では週次/月次のみ。年次は範囲外(CLAUDE.md §4.7 の年次バッチは サマリテーブルの生成単位であって、レポート画面の表示単位ではない)」を追記すること。
limit と meta の噛み合わせを説明で補うfacility-performance(:458-461)と continuous-problems(:486-488)は 入力が limit(既定10)なのにレスポンスは meta: {total, page, per_page} を返す契約です。 page / per_page パラメータはありません。
契約は変えません(モックの「他N件を表示」が limit を増やす形なので合理的)。 そのかわり Meta の使い方を description で明示すること:
meta.total = 絞り込み後の全件数(limit 適用前)。モックの「全{N}施設を表示」の N がこれmeta.page = 常に 1meta.per_page = 実際に適用された limit の値npx tsc --noEmit エラー0(lib/types の変更が既存コードを壊していないこと)npm test が 267件のまま(toKpiValue の引数型変更でテストが落ちるなら、 テストを直すのではなく実装を見直すこと。kpi.test.ts:75-90 は既存の期待値表です)lib/format/period.ts を新規作成(★テストを先に書くこと★)lib/format/ には現在 date.ts しかなく(formatMd / formatHm / todayJst / inclusiveDayCount)、 週の開始日・第N週・月初月末・当期判定を求める関数がありません。
次の純粋関数を作ること。CLAUDE.md §9.4 に従い、期待値表のテストを先に書くこと。
/** 週は日曜始まり(CLAUDE.md §4.7)。YYYY-MM-DD を受けてその週の日曜を返す */
export function weekStart(date: string): string;
/** 月跨ぎ週の帰属は「その月の1日を含む週がその月の第1週」(★D-067。D-024 は破棄★)。第1週=1 */
export function weekOfMonth(date: string): { year: number; month: number; week: number };
/** period/year/month/week から期間の [開始日, 終了日] を返す(両端含む、YYYY-MM-DD) */
export function periodRange(
period: "weekly" | "monthly", year: number, month: number, week: number | null
): { from: string; to: string } | null;
/** 要求された期間が「当期(進行中)」か。今日(JST)が期間内なら true */
export function isCurrentPeriod(
period: "weekly" | "monthly", year: number, month: number, week: number | null,
today?: string
): boolean;
/** 画面の期間ナビに出すラベル。例: "2026年3月 第4週" / "2026年3月" */
export function periodLabel(
period: "weekly" | "monthly", year: number, month: number, week: number | null
): string;
/** 前期比のラベル。"前週" / "前月" */
export function prevPeriodLabel(period: "weekly" | "monthly"): string;
/** 期間ナビの ‹ › で1つ前/後の期間へ */
export function shiftPeriod(
period: "weekly" | "monthly", year: number, month: number, week: number | null, delta: -1 | 1
): { year: number; month: number; week: number | null };
★実装上の注意★
lib/format/date.ts:2-3 の方針コメント(文字列スライスで処理)と、 lib/dashboard/aggregate.ts:64-72 の weekdayIndex(Date.UTC + getUTCDay() を使い、 「実行環境のTZで結果が変わるため new Date(str).getDay() は使わない」)に従うことisCurrentPeriod の today 引数はテスト用。省略時は todayJst() を使う (テストで「今日」を固定できないと検証不能になる)week の最大値(★2026-09-07 訂正★): 当初「月によっては第6週が発生する」と 想定していたが誤り。D-067 の帰属ルール下では1か月に属する週数は floor((offset+日数)/7)(offset=1日の曜日番号0〜6、日数28〜31)で決まり、 最大でも 5(第6週は発生しない)。2020〜2040年の全日で確認済み。 YAML の maximum は 5 のまま(段階0で6にしていた変更は差し戻し済み)。 weekOfMonth が1〜5のみを返すことをテストで固定することテストの境界値(lib/format/__tests__/period.test.ts を新規作成):
2026-08-30(日)〜09-05(土) → 9月 第1週(8/30・8/31 を含むが9月に数える)2026-08-23(日)〜08-29(土) → 8月の週(8月の最終週)2026-09-06(日)〜09-12(土) → 9月 第2週isCurrentPeriod: 期間の初日 / 最終日 / 前日 / 翌日shiftPeriod: 第1週から -1(前月の最終週へ)/ 12月から +1(翌年1月へ)lib/types/index.ts の調整段階0の YAML 変更に合わせて:
KpiValue.value を number | null に(0-4)ErrorBreakdownItem に rate: number を追加(0-2)FacilityPerformance に serial_number: string を追加(0-6)DataSource は「引数なしで全件返す」契約(lib/db/index.ts:42-46)なので、 ReportKpi(単一期間分)のままだとどの期間のものか分からず、週次/月次/当期を出し分けられません。 次の行型を追加すること:
/** DataSource が返す KPI の行。period/year/month/week で1行が特定される */
export interface ReportKpiRow {
period: "weekly" | "monthly";
year: number;
month: number;
/** monthly のときは null */
week: number | null;
kpi: ReportKpi;
}
FacilityPerformance / ContinuousProblem / ErrorBreakdownItem も 同じ理由で期間キーが要ります。 それぞれ ReportKpiRow と同じ4フィールドを持つラッパー型を作るか、 各型に直接 period / year / month / week を持たせるかは実装者の判断でよいが、 選んだ理由をコメントに残すこと。
これは API レスポンスの型ではありません。 API は期間で絞り込んだ結果だけを返すので、
ReportKpiRowなどがレスポンスに出ることはありません。YAML には追加しないこと。
DataSource の拡張lib/fixtures/reports.ts の補完現状の7 export は どこからも参照されていません(lib/fixtures/index.ts:14 で re-export はされている)。 段階2で初めて接続します。あわせて次を補完すること。
| # | 不足 | 対応 |
|---|---|---|
| 1 | 期間キーが無い | ReportKpiRow[] の形に組み直す。既存の kpiWeekly / kpiMonthly / kpiCurrentPeriod は変数として残したうえで、行データに埋め込むこと(値を変えないため) |
| 2 | continuousProblems が5件で「もっと表示」(limit 既定10)が発火しない |
11件以上に増やす(docs/fixtures.md §10 の要求) |
| 3 | 空配列ケースが errorBreakdownEmpty だけ |
facilityPerformanceEmpty / continuousProblemsEmpty を追加(§10 の要求) |
| 4 | serial_number が無い |
facilityPerformance の12件すべてに追加(0-6) |
| 5 | rate が無い |
errorBreakdown の7件すべてに追加(0-2)。合計56件なので 18/56*100 等の生値を入れる |
| 6 | ローカル kpi() が diff を小数第1位で丸めている(:25-31) |
lib/judge の toKpiValue を使うように直す。 同じ KpiValue を作る実装が2つあり、diff の値が食い違います(D-044「判定ロジックは切替の外側」の趣旨にも反する) |
★実在の店舗名を使わないこと★(D-043)。既存の12件はダミー名になっているので、追加分も同じ流儀で。
lib/fixtures/__tests__/reports.test.ts を新規作成(★重要★)lib/fixtures/clean-logs.ts は judgeGrade / judgeEscalationLevel を実際に通して 期待値を作っています(:57-59)が、reports.ts は素の直書きで、誰も整合性を検証していません。
次を検証するテストを作ること:
facilityPerformance[].status が judgeFacilityStatus(operation_rate.value) と一致するKpiValue で diff === value - prev(prev が null なら diff も null)errorBreakdown が 件数降順で、ErrorType 7種を重複なく網羅しているerrorBreakdown[].rate の合計が 100 に近い(浮動小数の誤差を許容)continuousProblems[].escalation_level が judgeEscalationLevel(consecutive_problem_days) と一致するcontinuousProblems がすべて2日以上(L1 の確認用データを除く。L1 が1件あることも固定する)facilityPerformance が稼働率の昇順(ワースト順)に並んでいるDataSource に4メソッドを追加lib/db/index.ts:47-56 の interface DataSource の末尾に追加:
/** レポートKPI。期間の絞り込みはAPIルート側 */ reportKpis(): Promise<ReportKpiRow[]>; /** 施設別パフォーマンス。並び替え・フィルタ・limit はAPIルート側 */ facilityPerformance(): Promise<FacilityPerformance[]>; /** 連続問題施設。2日以上の絞り込みと limit はAPIルート側 */ continuousProblems(): Promise<ContinuousProblem[]>; /** エラー種別内訳。降順ソートはAPIルート側 */ errorBreakdown(): Promise<ErrorBreakdownItem[]>;
fixtureSource(:60-85): 既存の async xxx() { return xxx; } の形で4つ追加prismaSource(:102-127): notImplemented("xxx") の形で4つ追加(:96-100 のパターン):29-38 に追加★引数を取らせないこと★(lib/db/index.ts:42-46「フィルタや集計はこの層では行わず、 APIルート側で組み立てます。実DB接続時にクエリへ落とすときの差分を小さくするため」)。
lib/reports/query.ts を新規作成lib/ds-alerts/query.ts(115行)と同じ構成順で作ること。
| 順 | 中身 |
|---|---|
| 1 | ファイルコメント(「GET /reports/* で使うクエリ処理。仕様の正は d-watch-api.yaml」) |
| 2 | import(@/lib/api/query-result → @/lib/config → @/lib/format/period → import type … @/lib/types) |
| 3 | 許容値の readonly 配列定数(PERIODS / FACILITY_STATUS_FILTERS) |
| 4 | ReportQuery interface(period / year / month / week / limit ほか) |
| 5 | private toInt() |
| 6 | parseReportQuery() — QueryParseResult<ReportQuery> を返す |
| 7 | filterXxx() 各種 |
| 8 | sortXxx() |
| 9 | サマリ(レポートには不要なら省く) |
parseReportQuery()★lib/api/query-result.ts の fail() と QueryParseResult<T> を使うこと★ (lib/clean-logs/query.ts にも同名の古い型が残っていますが、そちらは使わないこと。3-4 参照)
必須パラメータの検証(details に項目名を必ず載せる。/prechecks と同じ形):
| 条件 | code | message | details |
|---|---|---|---|
period 欠落 or enum外 |
validation_failed |
periodの値が不正です |
{ period: ["weekly / monthly のいずれかを指定してください"] } |
year 欠落 or 数値でない |
validation_failed |
yearの値が不正です |
{ year: [...] } |
month 欠落 or 1〜12 外 |
validation_failed |
monthの値が不正です |
{ month: ["1〜12で指定してください"] } |
period=weekly で week 欠落 or 1〜5 外 |
validation_failed |
weekの値が不正です |
{ week: ["period=weekly のときは1〜5で指定してください"] } |
period=monthly のとき week は無視する(YAML :912)。エラーにしないことlimit は Math.max(1, toInt(..., 10))。上限は PAGINATION.maxPerPage(lib/config/index.ts:37-40)group_id / customer_id はここで読まないこと(D-053)。理由をコメントで残すfilterFacilityPerformanceByStatus(items, status) — status=all なら素通しfilterFacilityPerformanceByName(items, facilityName) — 部分一致。空文字なら素通しfilterFacilityPerformanceBySerial(items, serial) — 部分一致sortFacilityPerformance(items) — 稼働率の昇順(ワースト順)。 ★value が null の行の位置を決めてコメントに書くこと★(末尾に置くのが妥当)。 同値のときは facility_id 昇順で安定させる(D-052 と同じ規則)filterContinuousProblems(items) — consecutive_problem_days >= 2(★先に決まっていること 6★)sortContinuousProblems(items) — 連続日数の降順。同値なら facility_id 昇順sortErrorBreakdown(items) — 件数の降順。同値なら type の定義順reportKpis() が返す ReportKpiRow[] から、要求された期間の行を選ぶ関数を作ること。
export function selectKpiRow( rows: ReportKpiRow[], query: ReportQuery ): ReportKpi | null;
isCurrentPeriod()(段階1)で当期かどうかを判定し、当期なら当期用の行を選ぶnull(ルート側で 404 にするか空の KPI を返すかは 4-1 で決める)lib/clean-logs/query.ts の重複型を消すlib/clean-logs/query.ts:21-23 に古い非ジェネリック版の QueryParseResult が残っています。 指示書D の 4-1 で lib/api/query-result.ts に共通化したときの消し残しです。
lib/api/query-result.ts の QueryParseResult<CleanLogQuery> を import する形に直し、 ローカル定義を削除すること。 同じ名前の型が2つある状態は、指示書D が解消しようとした不整合そのものです。
lib/reports/__tests__/query.test.ts を新規作成。 lib/ds-alerts/__tests__/query.test.ts:11-27 の形(row(overrides: Partial<T>) ファクトリを先頭に置く)を踏襲。
必ず含めるケース:
parsed.ok === false / status === 422 / details の中身を検証period=monthly のとき week が無視されることweek=6 が422で弾かれること(★D-067下では第6週は存在しないためmaximumは5のまま★)filterContinuousProblems が1日の行を落とすことsortFacilityPerformance で value: null の行が末尾に来ることapp/api/reports/{kpi,facility-performance,continuous-problems,error-breakdown}/route.ts を新規作成。
骨格は app/api/ds-alerts/route.ts を見本にすること(形A)。共通の形:
/**
* GET /reports/xxx — 日本語の説明
*
* 形は `app/api/ds-alerts/route.ts` に合わせている。
* 仕様の正は `d-watch-api.yaml` の /reports/xxx。
*/
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { parseReportQuery, /* ... */ } from "@/lib/reports/query";
import { withRequestId } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import type { ApiError, /* ... */ } from "@/lib/types";
const ROUTE = "/api/reports/xxx";
function errorResponse(error: ApiError, status: number) {
return NextResponse.json(error, { status });
}
export async function GET(request: Request) {
return withRequestId(request, async () => {
const { searchParams } = new URL(request.url);
const parsed = parseReportQuery(searchParams);
if (!parsed.ok) {
return errorResponse(parsed.error, parsed.status);
}
// group_id / customer_id は受け取っても無視する(D-053。他5ルートと同じ扱い)。
let rows;
try {
rows = await db.xxx();
} catch (e) {
const body: ApiError = {
error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
};
logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
return errorResponse(body, 500);
}
// フィルタ → ソート → limit の順。すべて try の外
// ...
return NextResponse.json(body);
});
}
★守ること★
try の中は await db.xxx() だけ。フィルタ・集計は try の外 (app/api/ds-alerts/route.ts:48-57 と同じ)errorResponse(body, 500) を使う(指示書D の 4-2 で形A 4本を統一済み。 NextResponse.json を直呼びしないこと)logApiError にはレスポンスと同一の body オブジェクトを渡す(lib/logger/api.ts:4-6)/reports/kpiselectKpiRow() で行を選ぶ。該当が無い場合の扱いを決めてコメントに書くこと (推奨: 4指標すべて { value: null, prev: null, diff: null } を返す。 404 にすると「データがまだ無い期間」と「不正な期間」の区別がつかなくなる)ReportKpi の素のオブジェクト(meta なし。/dashboard と同じ形)/reports/facility-performancelimit で切るmeta.total は limit 適用前・フィルタ適用後の件数(段階0-9)meta.page = 1 / meta.per_page = limit/reports/continuous-problemsfilterContinuousProblems()(2日以上)→ ソート(連続日数降順)→ limitmeta は 4-2 と同じ規則/reports/error-breakdownlimit なしmeta を返さない(段階0-7 の判断)。{ items } だけapp/(dashboard)/reports/page.tsx を置き換える現状(全5行):
import { PlaceholderScreen } from "@/components/layout/PlaceholderScreen";
export default function ReportsPage() {
return <PlaceholderScreen title="レポート" />;
}
→ ds-alerts/page.tsx と同じ薄いラッパーに:
import { ReportsScreen } from "@/components/features/reports/ReportsScreen";
export default function ReportsPage() {
return <ReportsScreen />;
}
components/layout/PlaceholderScreen.tsx を削除しないこと(facilities / inspections / schedules / system の4画面がまだ使っています)。
components/features/reports/ReportsScreen.tsx(+ .module.css)components/features/ds-alerts/DsAlertsScreen.tsx の骨格に従うこと。
"use client"const { t, theme } = useTheme(); / const c = THEME_TOKENS[theme];lib/api/client.ts の apiFetch 一択(db 直呼び・生 fetch は使わない)Promise.all で並列取得(DashboardScreen.tsx:43-48 の形)。 1本でも失敗したら画面全体をエラー表示にする(DashboardScreen.tsx:50-51 と同じ方針。理由をコメントで)queryString を useMemo、fetchReports を useCallback、useEffect で発火DsAlertsScreen.tsx:135-140 の形と文言)useState のみ。URL クエリ同期(useSearchParams)は使わない (全画面で前例がなく、この画面だけ違う作りにしない)THEME_TOKENS[theme] か CSS 変数d-watch-mock.jsx:654-828 を正とする)上から順に:
| # | 要素 | モックの行 | 備考 |
|---|---|---|---|
| 1 | ヘッダ(h1「レポート」+右端「CSVエクスポート」ボタン) | :657-660 |
CSVエクスポートは押しても何もしない仮実装にし、その旨をコメントで残す(/reports/* に export エンドポイントが無いため)。★勝手にエンドポイントを追加しないこと★ |
| 2 | フィルタ行(施設名 / シリアルNo. / ステータス / 検索) | :661-680 |
日付範囲の input 2つとグループ/会社の select は作らない(★先に決まっていること 1・4★) |
| 3 | 期間トグル+期間ナビ | :681-686 |
モックは週次/月次トグルのみ。期間ナビ(‹ ラベル ›)を新規に足す(★先に決まっていること 1★) |
| 4 | KPIカード 4列 | :687-705 |
下記 5-4 |
| 5 | 2カラム: 左「連続問題施設」/右「エラー種別の内訳」 | :708-771 |
下記 5-5 / 5-6 |
| 6 | 「施設別パフォーマンス(稼働率ワースト順)」表 | :774-826 |
下記 5-7 |
StatCard(components/ui/StatCard.tsx)はそのままでは使えません。 理由:
:696-701)が、 StatCard の sub は1行のテキストのみStatTone は "ok" | "wn" | "ng" | "ac" の4つで、モックの2枚目(出発成功率)が使う pr(青)が無い→ 次のどちらかを選び、理由をコメントに書くこと:
StatCard に delta(値・符号・色・ラベル)を追加し、StatTone に "pr" を足すcomponents/features/reports/KpiCard.tsx をレポート専用に新設する★前期比の色分けのルール★(モック :690-691。間違えやすいので必ずテストで固定すること):
稼働率 / 出発成功率 / 清掃完了率 → 上がったら良い(diff > 0 で ok 色) エラー発生率 → ★下がったら良い(diff < 0 で ok 色)★ 反転する
diff === 0 は → で中立色prev === null(新規施設)のときは ▲▼を表示しない(lib/judge/kpi.ts:53 に明記)displayRate() を使う2日以上連続で△以下または出発失敗の施設(モック :713 の原文)該当施設なし(モック :716 の原文)Lv{n} / {d}日(モック :727)。色は L3 が ng、L2 が wnsummary を出す(段階0-1 の判断)。 ★モックからの逸脱なので決定事項に記録すること★limit を増やして「他N件を表示 →」(モック :735)今週のエラー発生件数(種別ごと) とハードコードしている(:748)が、 週次/月次トグルに連動させること(今週 / 今月)。モックのバグですlabel)/バー/件数/割合(rate)components/ui/ProgressBar.tsx を使う(JSDoc に F-04 共用と明記済み)。 ただし tone は "ok"|"wn"|"ng"|"ac"|"pr" の5つで、モックの7色のうち txD / txM に相当するものが無い。tone を追加するか、個別に色を渡せるようにするか判断すること該当なし)汎用のテーブルコンポーネントは存在しません(CleanLogTable は清掃ログ専用)。 components/features/reports/ 配下に新規作成すること。
列(モック :782-790 の9列):
| # | 見出し | 書式 | 条件付き色分け |
|---|---|---|---|
| 1 | 施設名 | 太字 | — |
| 2 | 稼働率 | {n}% 太字 |
< 80 → ng / < 90 → wn / それ以外 ok |
| 3 | 前週 or 前月 | {n}% |
薄字固定 |
| 4 | 出発成功率 | {n}% 太字 |
< 80 → ng / < 90 → wn / それ以外 pr(青) ★稼働率と色が違う★ |
| 5 | 完了率 | {n}% |
通常色固定 |
| 6 | 前週 or 前月 | {n}% |
薄字固定 |
| 7 | エラー率 | {n}% |
> 8 → ng / それ以外 薄字 |
| 8 | 変化 | ▲{n} / ▼{n} / → |
> 0 → ok / < 0 → ng / 0 → 中立 |
| 9 | ステータス | <Pill> |
要改善 → ng / 注意 → wn / 良好 → ok |
★「変化」列の算出(モック :795-797)★:
overallDiff = Math.round((稼働率の差分 + 完了率の差分) / 2)
API はこの合成値を返しません。 フロントで計算すること(operation_rate.diff と completion_rate.diff から)。どちらかが null のときの扱いを決めてコメントに書くこと (推奨: どちらかが null なら → を出す)。
period に連動(前週 / 前月)全{N}施設を表示 → / 上位10件に戻す。モック :816-825){N}施設(モック :777)lib/i18n.ts にレポート用の対訳が1つもありません。 モック側の T 辞書(:138-147)から移植すること。
欠落しているもの: CSVエクスポート / 検索 / 全ステータス / 週次 / 月次 / 稼働率 / 出発成功率 / 清掃完了率 / エラー発生率 / 連続問題施設 / エラー種別の内訳 / 施設別パフォーマンス(稼働率ワースト順) / 要改善 / 注意 / 良好
★モックが t() を通していない文言について★ モックには「辞書に対訳があるのに t() を通していない」箇所があります (KPIカードのラベル4つ :695、完了率 :786、施設名 :782、ステータス :790 など)。 これはモックのバグと判断し、実装では t() を通すこと。 CLAUDE.md §9.6「JP/EN切替の器を壊さない」に従います。
前週 / 前月 / 他N件を表示 → / 全N施設を表示 → / 上位10件に戻す / 該当施設なし / 期間ナビのラベルも新規に対訳を作ること。
docs/decisions.md への記録(D-068 から)★着手直前に最終番号を確認してから番号を振ること★(衝突歴あり。2026-09-07 時点の最終は D-067)。
記録すべきもの:
| 内容 | 段階 |
|---|---|
レポートの期間指定は period+year+month+week に寄せ、モックの日付範囲UIは作らない |
先に決まっていること 1 |
| 当期は「器だけ」。実際の集計は prismaSource 実装時 | 先に決まっていること 2 |
| 年次(yearly)は F-04 の範囲外 | 先に決まっていること 3 |
ContinuousProblem は completion_rate ではなく summary を返す(モックからの逸脱) |
0-1 |
ErrorBreakdownItem は英字 enum + label + count + rate(/dashboard の failure_breakdown に揃える) |
0-2 |
KpiValue.value を nullable にする(分母0の期間を表現するため) |
0-4 |
/reports/error-breakdown は meta を返さない(一覧APIの例外。7分類固定でページング不要) |
0-7 |
week の上限は 5 のまま(当初「第6週が発生しうる」と想定したが誤りと判明。D-067下では最大5) |
段階1 |
連続問題施設は2日以上(docs/spec.md:58 の「L1〜L3」は誤記) |
先に決まっていること 6 |
| エラー種別内訳の空データ時の文言 | 5-6 |
docs/spec.md:58 の「連続問題施設(L1〜L3)」もあわせて修正すること。
| 対象 | 理由 |
|---|---|
| 当期その場集計の実装(面積の積算・エラー種別の分類) | ★器だけ作る。prismaSource 実装時★ |
CleanLogItem への cleaned_area / target_area 追加 |
当期集計を書かないので不要。実DBには clean_task_reports.task_area / clean_area が存在するので、必要になったときに足せる |
error_type の7分類マッピング |
fixtures が分類済みのデータを持つ。実DBの robot_error_reports.error_type からの分類は prismaSource 実装時(D-057) |
Period enum への yearly 追加 |
★F-04 の範囲外★ |
日付範囲(date_from/date_to)での絞り込み |
★API契約に寄せると決定済み★ |
| グループ / 会社の絞り込みUI | D-053。マスタ未整備 |
| CSVエクスポートの実装 | エンドポイントが契約に無い。ボタンだけ置いて仮実装にする |
prismaSource の実装 |
notImplemented() のまま |
PlaceholderScreen の削除 |
他4画面がまだ使う |
AppShell のメニュー変更 |
/reports は既にある |
URL クエリ同期(useSearchParams) |
全画面で前例がない。この画面だけ違う作りにしない |
PrecheckItem.id の number→string 化 |
別作業(D-061)。同時にやらない |
npx tsc --noEmit がエラー0npm test のテスト数が減っていないこと(基準値 267)。 段階1(period.test.ts)・段階2(reports.test.ts)・段階3(query.test.ts)の 3ファイル分が増えるのが期待値USE_FIXTURES=true で画面が実際に描画され、4本のAPIが 200 を返すことを確認する (npm run dev で /reports を開く)docs/decisions.md に記録(D-068 から)docs/spec.md:58 の「連続問題施設(L1〜L3)」を「2日以上連続」に修正