Newer
Older
client_dwatch / docs / handoff / 指示書BH_清掃詳細ログとエラー一覧の並べ替え_20261104.md

指示書BH — 清掃詳細ログとエラー一覧の並べ替え(2026-11-04)

担当: Cursor。監督(Claude Code)がレビューする。 前提: develop の 38e8f5d 以降(施設ロボットマスタの並べ替え=先行例が入っている版)。 先行例: 38e8f5d — lib/facilities/query.ts(FACILITY_SORT_KEYS・parseFacilityQuery() の sort/order・sortFacilities())、 components/features/facilities/FacilitiesScreen.tsx(nextSort()・sortableTh()・changeSort())、d-watch-api.yaml の /facilities の sort/order。 ★作業の前に必ず読むこと★。この指示書は「同じ形を2画面に広げる」もので、★先行例と違うところ(§2-1 の表)★に特に注意すること。


0. ユーザーの指示(原文)

  • 2026-11-04「詳細ログやエラー一覧も項目でソートできるようにしたいです」
  • 監督の質問への回答(2026-11-04):
    • 清掃詳細ログの列: 「機種は含めて、事前チェックは除く」
    • 施設名: 「施設名は外す」(エラー一覧についても確認し「外す(6列)」)
    • 1回目のクリックの向き: 「悪いほうが上」
    • エラー一覧の「紐づく予定」: 並べ替えではなく ★絞り込みのプルダウン★ にする(軽いほう)。選択肢は ★3つ(すべて/予定あり/予定外)★
    • プルダウンの先頭の表示: 「予定:すべて」(監督の問い「今のまま/先頭だけ欄の名前を入れる」に「後者」。2026-11-04)
  • 監督が示し、ユーザーが異を唱えなかった既定(このまま実装する):
    • 並べ替えはサーバー側で全件に対して行う(ページ送りの前)
    • 同じ値どうしは、今の並び順(既定順)に従う
    • 空の値(完了率「-」・機種/シリアルなし・備考なし)は、向きに関係なく下
    • 日付・検知時刻は「新しい順 ↔ 古い順」の2状態
    • 完了率は丸める前の値で比べる
    • 清掃詳細ログの CSV は画面と同じ並び順
    • ダッシュボードの縮小表示には並べ替えを付けない
    • エラー一覧の編集中は並べ替えのボタンを押せない。保存直後は行を動かさない

1. 並べ替えられる列(これ以外は今のまま。見出しはボタンにしない)

1-1. 清掃詳細ログ(7列)

見出し sort の値 1回目(▲ asc) 2回目(▼ desc) 下に固定する行 見出しのそろえ(今のまま)
機種 model_name 文字の昇順 文字の降順 model_name が null 左
シリアル serial_number 文字の昇順 文字の降順 serial_number が null 左
日付 scheduled_at 古い順 ★無し(2状態)★ 無し 左
出発 departed ✗ が上 ✓ が上 結果待ち 中央
清掃結果 task_status 悪い順(§2-2 の順位) 良い順 結果待ち 左
完了率 completion_rate 低い順 高い順 完了率 null(出発失敗)→ その下に結果待ち 右
判定 grade × → △ → ○ → ◎ ◎ → ○ → △ → × 結果待ち 中央
  • ★並べ替えない列★: 施設名(ユーザー指示)・予定(日付と同じ値。日付の見出しで並べる)・事前チェック1回目/2回目(ユーザー指示)
  • ★日付は2状態★: 既定順が「新しい順」なので、日付の見出しは「既定(▼ 新しい順)↔ 古い順(▲)」を行き来する。 ★既定順のときは日付の見出しに ▼ を出す★(aria-sort="descending")。他の列で並べているあいだは日付は ⇅(★§12 で薄い △ に変更★)
  • 「下に固定する行」は ★asc でも desc でも一番下★(向きで反転させない)。固定する行どうしは既定順
  • そろえは今の CleanLogTable.tsx の見出しの textAlign を変えない(実物で確かめること。上の表と違ったら実物が正)

1-2. エラー一覧(6列)

見出し sort の値 1回目(▲ asc) 2回目(▼ desc) 下に固定する行
検知時刻 detected_at 古い順 ★無し(2状態)★ 無し
シリアル serial_number 文字の昇順 文字の降順 無し(--- も値として並べる)
種類 error_type 文字の昇順 文字の降順 無し
レベル error_level 文字の昇順(CRITICAL → ERROR → WARNING) 文字の降順 無し
対応状況 resolution_status 未解消 → 対応中 → 解消(DB の status 0→1→2) 解消 → 対応中 → 未解消 無し
備考 note 文字の昇順 文字の降順 note が null・空文字・空白だけ
  • ★並べ替えない列★: 施設名(ユーザー指示)・紐づく予定(→ §4 の絞り込み)・詳細(JP と EN で並びが変わるため)
  • ★検知時刻は2状態★(清掃詳細ログの日付と同じ。既定順のとき ▼)
  • ★レベルは文字の順★。重大度の順位表を作らないこと(D-077: 値を解釈しない)。今の3つの値では文字の順が重大度の順と一致するだけ
  • 見出しのそろえは6列とも左(今のまま)

1-3. 見出しの切り替え(両画面共通)

今の状態 押した列 次の状態
既定 / 別の列で並べ中 普通の列 その列の asc(▲)
その列の asc 同じ列 その列の desc(▼)
その列の desc 同じ列 既定(sort を送らない)
既定 / 別の列で並べ中 2状態の列(日付・検知時刻) その列の asc(▲ 古い順)
2状態の列の asc 同じ列 既定
  • ★1回目は必ず asc★(ユーザー指示「悪いほうが上」。完了率・判定・出発・清掃結果・対応状況は「asc = 悪いほう」になるよう §2 で定義してある)。 ★施設ロボットマスタは今のまま(1回目 desc = ○ が上)★ — 切り替えの部品を共通にしても、施設マスタの動きは変えない
  • 印: asc = ▲、desc = ▼、並べていない列 = 薄い ⇅(施設マスタと同じ)。★§12 で変更: 並べていない列は薄い △、印の色は白(ダーク)★
  • 並べ替えを変えたら ★同じ操作の中で setPage(1)★(useEffect で後追いしない)。queryString の useMemo の依存に sort を入れる
  • 並べ替えの状態は URL に載せない(再読み込みで既定に戻ってよい。今の絞り込みと同じ)

2. 並べ方の定義(API)

2-1. 共通の規則

  • 比較は「主キーの比較 → 同じなら既定順の比較」。★desc は主キーの比較だけを反転する★。 ★asc の結果を reverse() して desc を作らないこと★(同じ値の中まで逆になる。lib/facilities/query.ts の sortFacilities() の注記と同じ)
  • 「下に固定する行」は主キーの比較より前に分ける(向きの反転の外)。固定する行どうしは既定順
  • 既定順:
    • 清掃詳細ログ = compareCleanLogItems()(lib/db/clean-log-mapping.ts:394)
    • エラー一覧 = compareErrorReportKeys()(lib/error-reports/query.ts:134)
  • ★文字の比較は両画面とも < / >(コードポイント順)★。localeCompare を使わない(lib/masters/query.ts:62-66 の方針。実行環境で順が変わる)。大文字小文字は区別する
  • 完了率は ★生値★ で比べる(CLAUDE.md §4.3。89.5 / 90.0 / 90.1 は表示がどれも 90% だが、生値で比べて別の順位になる)

★★先行例(施設マスタ)と違うところ — ここを写し間違えると既定順が壊れる★★

施設マスタ(先行例) 今回(両画面)
sort を省略したとき "id" に正規化(既定値あり) ★null★(型は `CleanLogSortKey \ null/ErrorReportSortKey \ null)。★既定値を"scheduled_at"/"detected_at"にしないこと★(orderの既定がasc` なので古い順になってしまう)
order を省略したとき "desc" ★"asc"★(1回目は悪いほうが上のため)
sort が既定(null)のとき — order は検証する(不正なら 422)が、並びには使わない
2状態の列の desc(sort=scheduled_at&order=desc / sort=detected_at&order=desc) — ★パースの段階で sort: null に畳む★(既定順と同じ意味。422 にしない。画面は送らない)
sort が null のときの並べ替え関数 — ★並べ替えず、入力の写し([...items])をそのまま返す★(§7 罠1。fixtures 版の既定順を変えない)
型の名前 FacilitySortOrder ★TableSortOrder★(lib/table-sort.ts。Prisma の生成物に同名の SortOrder があるので使わない)

2-2. 清掃詳細ログの順位

  • 出発: ✗(departed false かつ result_pending false)= 0、✓ = 1。asc = ✗ が上。結果待ち(result_pending true)は下に固定
  • 判定: × = 1、△ = 2、○ = 3、◎ = 4。asc = × が上。結果待ちは下に固定(grade は "×" のままなので ★result_pending を先に見る★)
  • 清掃結果(task_status)。asc = 順位の小さい順。結果待ちは下に固定:| 順位 | 行 | |---|---| | 1 | 出発失敗(task_status が null で結果待ちでない) | | 2 | 5 清掃異常終了 | | 3 | 2 清掃中断 | | 4 | 3 と 6 清掃キャンセル(★同じ順位★。表示名が同じため) | | 5 | 表に無い値(「不明(n)」。7 以上・負の値など) | | 6 | 0 清掃開始前 | | 7 | 1 清掃中 | | 8 | 4 清掃正常終了 |★この順位表は lib/config/index.ts の CLEAN_TASK_STATUS_LABELS の隣に定数で置く★(CLEAN_TASK_STATUS_SORT_RANK など)。 コードに数字を直書きしない。判定(◎○△×)には使わない(表示の並びだけ)
  • 完了率: completion_rate の生値。null(出発失敗)は下に固定、結果待ちはさらにその下
  • 機種・シリアル: 文字の比較。null は下に固定
  • 日付: scheduled_at(予定外の行は started_at が入っている。今の既定順と同じ値)。Date.parse の差で比べる

2-3. エラー一覧の順位

  • 対応状況: DB の status(0 未解消 / 1 対応中 / 2 解消)の数値。asc = 未解消が上
  • 備考: 前後の空白を除いた文字列。空なら下に固定
  • 検知時刻・シリアル・種類・レベル: §1-2 の表のとおり

3. 期待値表(★監督が確定。テストはこれをそのまま使う。数字・順番を変えないこと★)

表S1 — 清掃詳細ログ(sortCleanLogs() の純粋関数のテスト。行は CleanLogItem としてテストの中で作る。置き場所 lib/clean-logs/__tests__/sort.test.ts)

行 id row_key scheduled_at(JST) 機種 シリアル 出発 task_status 完了率 判定
R1 101 tt-101 11-03 02:00 CC1 S-003 ✓ 4 95.0 ◎
R2 102 tt-102 11-03 02:00 CC1 S-001 ✓ 2 45.0 △
R3 103 tt-103 11-02 02:00 MT1 S-002 ✗(出発失敗) null null ×
R4 104 tt-104 11-03 03:00 null null 結果待ち(result_pending true・departed false) null null ×(画面「-」)
R5 ★null★ rp-9001 11-02 21:00 CC1 S-004 ✓ 6 9.9 ×
R6 105 tt-105 11-01 02:00 CC1 S-005 ✓ 7 89.5 ○
R7 106 tt-106 11-01 02:00 CC1 S-006 ✓ 3 90.0 ◎
R8 107 tt-107 11-02 01:00 CC1 S-007 ✓ 5 70.0 ○
  • R5 は予定外(unscheduled true・id null・report_id あり)。ほかは予定のある行(unscheduled false)
  • 既定順(compareCleanLogItems()): R4, R1, R2, R5, R3, R8, R6, R7
# 指定 期待する順
S1-1 sort null(入力は既定順 R4, R1, R2, R5, R3, R8, R6, R7) 同じ順で返る
S1-1b sort null(入力をわざと R8, R1, R7, R2, R6, R3, R5, R4 にする) ★その順のまま返る(並べ直さない)★
S1-2 scheduled_at asc R6, R7, R8, R3, R5, R1, R2, R4(同じ時刻の R6・R7 と R1・R2 は既定順のまま)
S1-4 completion_rate asc R5, R2, R8, R6, R7, R1, R3, R4
S1-5 completion_rate desc R1, R7, R6, R8, R2, R5, R3, R4
S1-6 grade asc R5, R3, R2, R8, R6, R1, R7, R4
S1-7 grade desc R1, R7, R8, R6, R2, R5, R3, R4
S1-8 departed asc R3, R1, R2, R5, R8, R6, R7, R4
S1-9 departed desc R1, R2, R5, R8, R6, R7, R3, R4
S1-10 task_status asc R3, R8, R2, R5, R7, R6, R1, R4(キャンセルの R5・R7 は既定順のまま)
S1-11 task_status desc R1, R6, R5, R7, R2, R8, R3, R4(★R5・R7 は逆にならない★)
S1-12 model_name asc R1, R2, R5, R8, R6, R7, R3, R4
S1-13 model_name desc R3, R1, R2, R5, R8, R6, R7, R4
S1-14 serial_number asc R2, R3, R1, R5, R6, R7, R8, R4
S1-15 serial_number desc R8, R7, R6, R5, R1, R3, R2, R4
S1-16 grade asc の結果を slice(3, 6) R8, R6, R1(並べ替えたあとの件数は 8)
S1-17 入力の配列 書き換えない(並べ替えの前後で元の配列の順が同じ)
  • S1-6 の × の中(R5・R3)と ◎ の中(R1・R7)は既定順のまま
  • S1-4 で 89.5(R6)と 90.0(R7)が別の位置になる(表示は両方 90%)
  • (S1-3 は欠番。scheduled_at desc はパースで null に畳むので §表S2 の S2-7 で見る)

表S2 — 清掃詳細ログのクエリとルート

# 何を 期待
S2-1 parseCleanLogQuery()・sort 省略 sort: null・order: "asc"
S2-2 sort= / sort=%20 省略と同じ
S2-3 sort=grade(order 省略) sort: "grade"・order: "asc"
S2-4 sort=facility_name 422(★並べ替えない列★)
S2-5 sort=precheck_1 / sort=foo / sort=Grade 422(大文字小文字は区別)
S2-6 order=up(sort 省略でも) 422(施設マスタと同じく検証する)
S2-7 sort=scheduled_at&order=desc 422 にならず、★sort: null★ に畳まれる
S2-8 422 の本文 入力値をエコーしない。details のキーはパラメータ名、中身は受け付ける値の一覧(lib/facilities/query.ts と同じ形)
S2-9 /clean-logs/export(app/api/clean-logs/export のテスト。db.cleanLogs のモックが表S1 の8行を返す)に sort=grade&order=asc CSV の行のシリアル列が S-004, S-002, S-001, S-007, S-005, S-003, S-006, 空 の順。不正な sort で 422
S2-10 /clean-logs(ルートのテスト)に sort を付けて per_page・page 2 2ページ目にも並べ替えが効き、meta.total は絞り込み後の件数(並べ替えで変わらない)

表E1 — エラー一覧(filterSortErrorReportKeys() の純粋関数のテスト。include_resolved true)

★行は ErrorReportKeySource(DB の行の形。clean_timetable_id は BigInt(501) / BigInt(CLEAN_TIMETABLE_ID_UNSCHEDULED) / null)で作り、 ★errorReportKeyFromRow() を通してから★ filterSortErrorReportKeys() に渡す(0 と NULL がどちらも予定外になることを、キーを手で作らずに確かめるため)。 置き場所は lib/error-reports/__tests__/query.test.ts の新しい describe。既存の表1 の定数(E1〜E9 など)とぶつからない名前にする(例: BH_E1)。

行 error_id 検知時刻(JST) シリアル 種類 レベル 対応状況(status) 備考(comment) clean_timetable_id
E-1 E-1 11-03 02:00 8111R-0002 Offline ERROR 0 未解消 null 501
E-2 E-2 11-03 02:00 --- SECURITY_ON CRITICAL 1 対応中 回線確認中 0
E-3 E-3 11-02 23:00 8111R-0001 BrushError WARNING 0 未解消 ""(空文字) NULL
E-4 E-4 11-02 22:00 DS-005 D-S_CONTROL_FAILURE CRITICAL 2 解消 復帰を確認 502
E-5 E-5 11-01 10:00 8111R-0001 Offline ERROR 1 対応中 null 503
E-10 E-10 11-02 22:00 8111R-0003 LowBattery WARNING 0 未解消 現場に連絡済み 504

既定順(compareErrorReportKeys()): E-1, E-2, E-3, E-10, E-4, E-5(同じ 11-02 22:00 は "E-10" < "E-4")

# 指定 期待する順
E1-1 sort null E-1, E-2, E-3, E-10, E-4, E-5
E1-2 detected_at asc E-5, E-10, E-4, E-3, E-1, E-2
E1-3 serial_number asc E-2, E-3, E-5, E-1, E-10, E-4(--- が先頭。同じシリアルの E-3・E-5 は既定順)
E1-4 serial_number desc E-4, E-10, E-1, E-3, E-5, E-2(★E-3・E-5 は逆にならない★)
E1-5 error_type asc E-3, E-4, E-10, E-1, E-5, E-2
E1-6 error_type desc E-2, E-1, E-5, E-10, E-4, E-3
E1-7 error_level asc E-2, E-4, E-1, E-5, E-3, E-10
E1-8 error_level desc E-3, E-10, E-1, E-5, E-2, E-4
E1-9 resolution_status asc E-1, E-3, E-10, E-2, E-5, E-4
E1-10 resolution_status desc E-4, E-2, E-5, E-1, E-3, E-10
E1-11 note asc E-2, E-4, E-10, E-1, E-3, E-5(回 U+56DE < 復 U+5FA9 < 現 U+73FE。空の3行は既定順で下)
E1-12 note desc E-10, E-4, E-2, E-1, E-3, E-5(空の3行は下のまま)
E1-13 schedule scheduled E-1, E-10, E-4, E-5
E1-14 schedule unscheduled E-2, E-3(clean_timetable_id が 0 と NULL の両方)
E1-15 schedule unscheduled + serial_number desc E-3, E-2
E1-16 include_resolved false・resolution_status asc E-1, E-3, E-10, E-2, E-5(E-4 は解消なので出ない)
E1-17 include_resolved false・schedule scheduled E-1, E-10, E-5(3件)

表E2 — エラー一覧のクエリ(parseErrorReportQuery())

# クエリ 期待
E2-1 sort・order・schedule 省略 sort: null・order: "asc"・schedule: null(今と同じ並び・絞り込みなし)
E2-2 空文字・空白だけ 省略と同じ
E2-3 sort=facility_name / sort=schedule_link / sort=error_list_id / sort=foo 422
E2-4 order=up 422
E2-5 schedule=all / schedule=main / schedule=Scheduled 422(受け付けるのは scheduled と unscheduled だけ。「すべて」は送らない)
E2-6 422 の本文 S2-8 と同じ作法
E2-7 sort=detected_at&order=desc 422 にならず、★sort: null★ に畳まれる
E2-8 sort=error_level(order 省略) order: "asc"

4. エラー一覧の「紐づく予定」の絞り込み(プルダウン)

  • クエリパラメータ schedule: scheduled(予定あり)/ unscheduled(予定外)。省略 = すべて
  • ★判定は scheduledTimetableId()(lib/db/timetable-link.ts:20)の1本★。clean_timetable_id が 0 か NULL なら予定外(CLAUDE.md §4.7)。 ★=== null / { not: null } / === 0 を直書きしない★
  • 「予定あり」には、本清掃・事前チェック・予定不明(予定の表に id が見つからない)の行がすべて入る(★予定の表は引かない★。軽くするための決定)。 つまり ★unscheduled = 「紐づく予定」が「予定なし」(schedule_link: "none")の行、scheduled = それ以外★
  • 絞り込みはアプリ側(filterSortErrorReportKeys())で行う。meta.total は絞り込み後の件数
    • ★SQL の where に降ろさないこと★ — errorReportListWhere() は期間で既に OR を使っており、もう1つの OR を足すと条件が上書きされる事故が起きやすい。 1段目は今も条件に合う全件のキーを読んでいるので、アプリで振り分けても読む量は増えない
  • 画面: 絞り込み欄(<fieldset> の中)に <select> を足す。aria-label・title は「紐づく予定」。 選択肢は ★「予定:すべて」(値は空=送らない)「予定あり」「予定外」★(★文言はユーザー指示。変えない★。先頭だけ欄の名前を入れるのは、ほかのプルダウンと並んだときに何の欄か分かるように)
    • ★幅は固定★(既存の絞り込みの <select> と同じく styles.select + 固定幅のクラス。いちばん長い表示(EN の "Schedule: All" か "Unscheduled")と ▼ が収まる幅。JP/EN の切り替えでツールバーが動かないように。D-162)
    • 置く位置は「解消済みも表示」のチェックの左
    • 英訳: 「紐づく予定」"Schedule"(既存)、「予定外」"Unscheduled"(既存)、★「予定:すべて」"Schedule: All"・「予定あり」"Scheduled" を lib/i18n.ts に足す★(既にあるキーは足さない)
    • 変えたら setPage(1)(期間の入力と同じ書き方)。<fieldset disabled> の中なので編集中は自動で押せなくなる
  • D-Sec アラート(lib/ds-alerts/query.ts の sortDsAlerts())には関係させない

5. 実装の置き場所と変更点(★全件。漏らさないこと★)

段階1 — API 契約(★先に直す。CLAUDE.md §6-1★)

  • d-watch-api.yaml
    • /clean-logs と /clean-logs/export: sort(enum 7値。★default は書かない★。説明に「省略時は既定順」)・order(enum [asc, desc]・default: asc。説明に「施設マスタ(desc)と違い、1回目は悪いほうが上のため asc」「sort が省略でも検証するが並びには効かない」「sort=scheduled_at&order=desc は既定順と同じ」)。 §1-1・§2-2 の定義、「並べ替えは絞り込みのあと・ページングの前」「同じ値は既定順」「下に固定する行」を説明に書く。422 の説明に sort/order を足す。 既定順の記述(:494 付近と CleanLogItem の説明 :2239 付近)は「sort を省略したとき」と書き足す
    • /errors: sort(enum 6値。default なし)・order(/clean-logs と同じ)・schedule(enum [scheduled, unscheduled]。説明: unscheduled = schedule_link が none の行、scheduled = main / precheck / unknown の行。予定の表は引かない)。 ★:909-910 の「ソート指定のクエリパラメータは無い」を書き換える★。422 の説明に sort/order/schedule を足す。sort=detected_at&order=desc は既定順と同じ、と書く

段階2 — 共通の部品

  • lib/table-sort.ts(新規。純粋関数)
    • 型 TableSortOrder = "asc" | "desc"、SortState<K> = { key: K; order: TableSortOrder } | null
    • nextSortState(current, key, { first, twoState }) — §1-3 の表をそのまま実装。first は "asc" か "desc"(施設マスタは "desc")
    • displayOrder(state, key, defaultKey?) — 見出しに出す向き。state が null で key === defaultKey なら "desc"(既定順のとき日付・検知時刻に ▼)、 state があれば state.key === key ? state.order : null、それ以外は null。施設マスタは defaultKey を渡さない(今のまま)
    • テスト lib/__tests__/table-sort.test.ts:
      • nextSortState: §1-3 の5行 + 施設マスタの動き(first: "desc": 既定 → desc → asc → 既定)
      • displayOrder: 「null・日付 → desc」「null・他の列 → null」「{日付, asc}・日付 → asc」「{完了率, asc}・日付 → null」「null・defaultKey なし → null」
  • components/ui/SortableTh.tsx(新規)
    • <th aria-sort> の中に <button type="button">。props: 見出しの文字、order(displayOrder() の値)、onClick、disabled、ariaLabel(「○○で並べ替え」)、 ★align: "left" | "center" | "right"★(th の text-align とボタンの justify-content に反映。今の見出しのそろえを変えないため)、 ★children★(見出しの中に並べ替え以外のボタンを置く。清掃詳細ログの「判定」の「?」)
    • ★ボタンの中にボタンを入れない(HTML として不正)★。children があるときは、ボタンを width: auto にして、th の中でボタンと children を inline-flex で1行に並べる (width: 100% のままだと「?」が押し出されて、折り返さない見出しからはみ出す)
    • 印は aria-hidden。CSS は施設マスタの .thSortable 系(FacilitiesScreen.module.css:135-166 付近)を components/ui/SortableTh.module.css に移し、そろえを props で変えられるようにする
  • components/features/facilities/FacilitiesScreen.tsx と .module.css: 上の部品を使うように置き換える(align="center"・first: "desc")。★動きも見た目も1つも変えない★。 置き換えたら施設マスタの画面で ▼ → ▲ → 既定 を確かめる

段階3 — 清掃詳細ログの API

  • lib/types/index.ts: CleanLogSortKey(7値)を足す
  • lib/clean-logs/query.ts: ★既存の CleanLogQuery(:15。parseCleanLogQuery() が返す QueryParseResult<CleanLogQuery> の query)★ に sort: CleanLogSortKey | null・order: TableSortOrder を足す(★lib/types に同じ名前の型を作らない★)。 parseCleanLogQuery() に検証(表S2)。受け付ける値は配列1か所(CLEAN_LOG_SORT_KEYS)。2状態の列の desc は sort: null に畳む
    • ★今の result パラメータは空文字で 422 になる(:122-127)が、新しい sort/order は施設マスタに合わせて空文字 = 省略★。result の挙動は変えない
  • lib/clean-logs/sort.ts(新規): sortCleanLogs(items, sort, order)。表S1 のテストを lib/clean-logs/__tests__/sort.test.ts に
    • ★sort が null なら並べ替えず [...items] を返す★
    • ★filterCleanLogs() の中に入れないこと★(lib/clean-logs/__tests__/query.test.ts が「入力の順を保つ」ことに依存している)
    • ★データ層(db.cleanLogs()・compareCleanLogItems())の既定順を変えないこと★ — ダッシュボードの problem_logs(先頭5件)がこの順に依存している(app/api/dashboard/route.ts)
  • lib/config/index.ts: 清掃結果の順位表(§2-2)
  • app/api/clean-logs/route.ts: filterCleanLogs() のあと、slice() の前に sortCleanLogs()。capCleanLogs() の位置は今のまま。 ルート冒頭の手順コメントに「並べ替え(sortCleanLogs()。sort 省略時は並べ替えない)」を足す。capCleanLogs() の docstring に「filterCleanLogs() → sortCleanLogs() → ページ切り出しの後」と書き足す
  • app/api/clean-logs/export/route.ts: ★行数の上限の判定(filtered.length > MAX_CSV_ROWS の 422。:79 付近)の後、buildCleanLogCsv() の直前★に sortCleanLogs() (★入れ忘れると CSV だけ黙って既定順になる★。上限の判定より前に置くと、どうせ 422 で捨てる大量の行まで並べ替えることになる)
  • ついでに直す古いコメント(「表に無い値(6 など)は数字のまま」→ 今は 6 は清掃キャンセル、7 以上は「不明(7)」): lib/clean-logs/csv.ts:43 付近・lib/types/index.ts:316 付近・lib/i18n.ts:225 付近

段階4 — 清掃詳細ログの画面

  • components/features/detail-log/DetailLogScreen.tsx: sort の状態(SortState<CleanLogSortKey>)。queryString に sort・order を載せる(既定のときは送らない)。 CSV のリンクは同じ queryString を使っているので自動で揃う(★揃っていることを確かめる★)。並べ替えを変えたら setPage(1)
  • components/features/detail-log/CleanLogTable.tsx: props sort?: SortState<CleanLogSortKey> と onSort?: (key: CleanLogSortKey) => void を足す(★省略可能★)。 onSort が渡されたときだけ7列の見出しを SortableTh にする(向きは displayOrder(sort, key, "scheduled_at"))。 ★ダッシュボード(compact)は渡さない → 今のまま★。「判定」の見出しの「?」ボタンは SortableTh の children に入れる
  • 列幅: table-layout: fixed・見出しは折り返さない。★印を足すと EN で見出しがはみ出す列がある★(出発 56px・日付 64px・完了率・清掃結果・判定など。事前調査の計算)。 並べ替える列の見出しの左右の余白を詰める(施設マスタは 12px → 6px)。★列の合計幅(最小 1134px)を広げないで収める★。収まらない列があれば監督に報告

段階5 — エラー一覧の API

  • lib/db/robot-error-select.ts の ROBOT_ERROR_LIST_KEY_SELECT(1段目)に serial_number・error_level・comment を足す(clean_timetable_id は既にある)。 ★robot_id / robot は足さない★(robot-error-select.test.ts が見張っている)。 :69-71 付近のコメント「clean_timetable_id は並べ替えには使わない」を「予定あり/予定外の絞り込みに使う。serial_number・error_level・comment は並べ替えに使う」に直す
  • lib/db/error-report-mapping.ts(★入力と出力で足すものが違う★):
    • ErrorReportKeySource(DB の列名)に clean_timetable_id: bigint | null・serial_number: string・error_level: string・comment: string | null
    • ErrorReportKey に serial_number・error_level・note: string | null(comment の生値。空かどうかは並べ替えの側で trim して決める)・scheduled: boolean(scheduledTimetableId(row.clean_timetable_id) !== null)
    • errorReportKeyFromRow() で詰め替える
  • lib/types/index.ts: ErrorReportSortKey(6値)・ErrorScheduleFilter("scheduled" | "unscheduled")を足す。 ★ErrorReportDbQuery(:807 付近。「引数は必須」の作法 :802)★ に sort: ErrorReportSortKey | null・order: TableSortOrder・schedule: ErrorScheduleFilter | null を足し、 :802-806 の docstring に「sort・order・schedule もアプリ側で処理する」と書き足す
  • lib/error-reports/query.ts: ★既存の ErrorReportQuery(:19。camelCase)★ にも同じ3つを足す(★lib/types に同じ名前の型を作らない★)。 parseErrorReportQuery() に検証(表E2)。filterSortErrorReportKeys() に schedule の絞り込み(key.scheduled を見る)と sort/order の並べ替え(表E1)。 compareErrorReportKeys() は既定順としてそのまま残す
  • lib/db/index.ts の errorReports()(Prisma 版 :1853 付近・fixtures 版 :715 付近): 新しいフィールドを filterSortErrorReportKeys() に渡すだけ。2段目は変えない
  • app/api/errors/route.ts: パース結果(camelCase)を ErrorReportDbQuery に詰め替えて渡す

段階6 — エラー一覧の画面

  • components/features/errors/ErrorsScreen.tsx: sort の状態と §4 のプルダウン。queryString(:258-276 付近)に sort・order・schedule を載せる
    • 6列の見出しを SortableTh(align="left"。向きは displayOrder(sort, key, "detected_at"))に。 ★disabled={editing}★(見出しは <fieldset> の外なので、明示しないと編集中も押せてしまう。押すと別ページの下書きが保存されずに消える — lib/errors/edit-drafts.ts)
    • ★保存後は取り直さない(今のまま)★。並べている列の値を変えた行は、次に読み込むまで今の位置に残る(「解消にしても次に取り直すまで消さない」:374 と同じ考え方)
    • 列数 COLUMN_COUNT(:71)と <colgroup> は変わらない
  • 列幅: 「DETECTED AT」は約113px で列は 120px。印を足すとはみ出す。段階4 と同じく余白を詰めて収める

段階4・6 共通 — lib/i18n.ts

  • 並べ替えボタンの aria-label の英訳(「機種で並べ替え」"Sort by model" など)と §4 の2語。 ★両画面ぶんをまとめて1か所に足す。両画面に共通の「シリアルで並べ替え」は1回だけ。既にあるキーは足さない★(同じキーを2回書くと tsc エラーになる)

6. 既存テストの扱い

  • ★新しい必須フィールド(sort・order・schedule、キーの serial_number など)を足したために、型エラーや toEqual の形の違いで壊れたテストは、 既定値(sort: null, order: "asc", schedule: null、行の新しい列)を足すだけなら直してよい★。★並び・件数・where の期待値は変えない★
  • 分かっているもの(行番号は目安):
    • lib/db/__tests__/fixture-clean-logs.test.ts:20-34 の memoryQuery()
    • lib/error-reports/__tests__/query.test.ts の key() ヘルパー(:30-40)・base(:55-63)・parse("") の toEqual(:207-221)。★表1(:70-166。監督確定)の期待値は変えない★
    • lib/db/__tests__/fixture-error-reports.test.ts の BASE
    • lib/db/__tests__/error-report-mapping.test.ts:390-399・:407-416・:440-448(キーと元の行の形)
    • app/api/errors/__tests__/route.test.ts:557-575(パース結果・1段目の select が定数と一致)
  • ★並び順を固定しているテストは変わらないはず★: app/api/clean-logs/__tests__/route.test.ts(:418 など)・lib/db/__tests__/unscheduled-clean-log.test.ts:190-224・ lib/db/__tests__/fixture-clean-logs.test.ts:212-229・lib/db/__tests__/fixture-error-reports.test.ts:161-191。★変わったら実装の誤り★
  • 既定値を足すだけでは直らないテストが出たら、直さずに監督に報告する

7. 既知の罠

  1. ★fixtures 版と Prisma 版で、同じ時刻の行の既定順が違う★(fixtures 版は scheduled_at だけで並べ直すため。lib/db/index.ts:631-633)。 新しい並べ替えは「同じ値なら compareCleanLogItems()」なので、sort を指定したときは両方で同じになる。★既定順(省略時)の食い違いは今回直さない★(ダッシュボード・既存テストに波及するため)。 だから ★sort が null のときは並べ替え関数が何もしない★(§2-1 の表)。fixtures の画面で既定順の同時刻の並びが Prisma の規則と違って見えても、それは今回の不具合ではない
  2. 結果待ちは要求ごとの「今」で決まるので、同じ並べ替えでも時間がたつと行が動く(仕様どおり)
  3. grade は結果待ちでも "×"。★result_pending を先に見る★
  4. 予定外の行の id は null。並べ替えの比較で id を使わない(既定順に任せる)
  5. task_status の 3 と 6 は同じ順位。6 を「キャンセル」と別扱いにしない
  6. 古い応答を捨てる仕組み(cancelled / result.query !== queryString)は、並べ替えを queryString に載せれば自動で効く。★別の取得経路を作らない★
  7. エラー一覧の 1段目に comment(TEXT)が増えるので転送量が少し増える(P1 の件数なら問題ない。D-160 追記2「解消済みも表示の全件読みはリリース前に見直す」の範囲)
  8. 清掃詳細ログは読み込み中に表ごと消える作りなので、見出しを押すとフォーカスが外れる(施設マスタも同じ。今回は直さない)

8. 画面で確かめること(fixtures)

★確かめ方★: USE_FIXTURES=true npm run build → USE_FIXTURES=true npx next start -p 3100 で開く(★:3000 のサーバーは触らない★)。終わったら :3100 を止める。

  • 清掃詳細ログ: 7列それぞれ ▲ → ▼ → 既定(日付は ▲ ↔ 既定。既定のとき日付に ▼)。並べたまま CSV を出し、CSV の行の順が画面と同じ。2ページ目にも並べ替えが効く。 ★見出しのそろえ(左・中央・右)が今と変わっていない★。「判定」の「?」が押せて、見出しからはみ出さない。 ダッシュボードの縮小表示の見出しが押せないこと。JP/EN 切り替えで見出しが1行に収まり、列からはみ出さない(th.scrollWidth <= th.clientWidth)
  • エラー一覧: 6列それぞれ同じ確認。編集中に見出しが押せない。 プルダウンで「予定外」→ ★予定外の行(fixtures の FX-ERR-04・FX-ERR-12(どちらも NULL)・FX-ERR-20(0)の3行)だけ★になり、件数も変わる。JP/EN で見出しとプルダウンの幅が動かない
  • 施設ロボットマスタ: 今までどおり ▼ → ▲ → 既定、見た目も同じ

9. 共通ルール

  • 期待値表(§3)を先にテストにする。数字・順番を変えない。合わないと思ったら実装を変えずに監督に報告
  • git add -A 禁止。★コミットはしない(監督がまとめてコミットする)★
  • 触らない: docs/decisions.md・docs/handoff/指示書AY_*・docs/handoff/設計_制御盤ログの取り出し_*・components/features/errors/ErrorContentPicker.tsx・lib/errors/error-content-label.ts とそのテスト・prisma/
  • DB に書き込まない。サーバー(:3000)を止めない
  • 完了したら tsc・lint(触ったファイル)・vitest 全件の件数と、段階ごとの変更ファイルを報告する

10. 決定の記録(監督が docs/decisions.md に後で書く。Cursor は書かない)

  • 並べ替える列(清掃詳細ログ7列・エラー一覧6列)と、施設名・事前チェック・紐づく予定・詳細を外した理由
  • 1回目は asc(悪いほうが上)。施設マスタは 1回目 desc のまま
  • sort 省略は null。2状態の列の desc は null に畳む。sort が null なら並べ替えない(fixtures の既定順を変えないため)
  • 清掃結果の順位表(§2-2)は監督の案(表示の並びだけ。判定には使わない)
  • 「紐づく予定」は並べ替えではなく3択の絞り込み(予定の表を引かないので軽い)。先頭の表示は「予定:すべて」(ユーザー回答「後者」)
  • 点検(2026-11-04): 4観点のレビューで38件の指摘、35件を反映(棄却3件: 固定行どうしの順の追加行・読み込み中のフォーカス・行番号の1行ずれ)

11. 追補 — 実装のレビュー指摘(2026-11-04。Cursor の1回目の実装に対して)

監督の確認結果: tsc・lint・vitest 全件(154ファイル・3778件)は通過。fixtures の画面(:3100)で §8 の確認はほぼ通った(並びの切り替え・▼▲⇅・CSV のリンク・ダッシュボードに出ない・「予定外」で3件・編集中に押せない・施設マスタは今までどおり・JP/EN ではみ出さない)。 4観点のコードレビューで27件の指摘、検証して21件が本物(重複・指示書どおり・範囲外の6件は棄却)。★下の全件を直すこと★。直したら §8 をもう一度通し、tsc・lint・vitest 全件の件数を報告する。

11-1. 必ず直す(画面が崩れている)

  • R1(high)components/ui/SortableTh.module.css・SortableTh.tsx: children があるとき(清掃詳細ログの「判定」だけ)、.thSortableWithChildren { display: inline-flex } が ★<th> そのもの★ に付いている。 監督が画面で実測: 「判定」の th が display: inline-flex になり、幅 69px(列は 96px)。th が表のセルでなくなり、見出しの下線が列の一部にしか引かれない。 → th には display を付けない。children があるときだけ th の中に <span className={styles.thInner}>(display: inline-flex; align-items: center; gap: 2px; max-width: 100%)を置き、その中にボタンと children を入れる。そろえは th の text-align に任せる。 直したら「判定」の th が display: table-cell・幅が列と同じ・中央そろえ・th.scrollWidth <= th.clientWidth を確かめる

11-2. 直す(仕様・契約・テストの縛り)

  • R2(medium)lib/clean-logs/sort.ts:18-19: 清掃結果の順位の「出発失敗=1」「表に無い値=5」が数字の直書き(§2-2 違反)。 → lib/config/index.ts の CLEAN_TASK_STATUS_SORT_RANK の隣に CLEAN_TASK_STATUS_SORT_RANK_NOT_DEPARTED = 1・CLEAN_TASK_STATUS_SORT_RANK_UNKNOWN = 5 を置き(または1つのオブジェクトにまとめ)、sort.ts はそれを読む。docstring に 1〜8 の表を全部書く
  • R3(medium)lib/clean-logs/__tests__/sort.test.ts: 順位の 0(清掃開始前=6)・1(清掃中=7)・負の値(不明=5)を縛るテストが無い。 → 表S1 は変えずに別の it を足す。task_status = 0, 1, 2, 3, 4, 5, 6, 7, -1, null と結果待ちの行を、わざと既定順と違う順で渡し、 asc: null → 5 → 2 → {3, 6} → {7, -1} → 0 → 1 → 4 → 結果待ち、desc: 4 → 1 → 0 → {7, -1} → {3, 6} → 2 → 5 → null → 結果待ち({} の中は既定順のまま。desc でも逆にならない)
  • R4(medium)lib/error-reports/__tests__/query.test.ts:474 付近: 表E1 の入力 BH_KEYS が既定順と同じ並びなので「同じ値どうしは既定順」を確かめられていない。 → 入力を既定順と違う順(例 [BH_E5, BH_E4, BH_E10, BH_E3, BH_E2, BH_E1])にして E1-1〜E1-17 すべてに渡す。★期待値(表E1)は変えない★
  • R5(medium)app/api/errors/__tests__/route.test.ts・lib/db/__tests__/fixture-error-reports.test.ts: ルート → データ層まで sort・order・schedule が届くことを確かめるテストが無い(既定値を足しただけ)。 → route.test.ts に include_resolved=true&schedule=unscheduled&sort=serial_number&order=desc のリクエストを1件(データ層に届いた query か items の順と meta.total)、sort=facility_name の 422 を1件。 fixture-error-reports.test.ts に db.errorReports({ ...BASE, include_resolved: true, schedule: "unscheduled" }) → FX-ERR-04・FX-ERR-12・FX-ERR-20 の3件
  • R6(medium)d-watch-api.yaml の /clean-logs の sort(:552 付近): 清掃結果の順位表が書かれていない(「悪い順↔良い順」だけ)。 → task_status の行に「出発失敗 → 5 清掃異常終了 → 2 清掃中断 → 3・6 清掃キャンセル(同じ順位)→ 表に無い値(不明(n))→ 0 清掃開始前 → 1 清掃中 → 4 清掃正常終了。表示の並びだけで判定には使わない」。grade の行に「結果待ちは grade が × でも下に固定」
  • R7(medium)d-watch-api.yaml の /clean-logs/export の 422(:645 付近): sort/order の不正値が書かれていない。 → 「sort/order の不正値(details はパラメータ名と受け付ける値の一覧。/clean-logs と同じ形)」を足し、行数超過の { total, max } と形が違うことも並べて書く
  • R8(low)d-watch-api.yaml の /errors の schedule(:1029 付近): 「空文字・空白だけは省略と同じ(すべて)」を足す
  • R9(low)d-watch-api.yaml:497・:961 付近: 「§下の」は意味が通らない → 「下のパラメータ sort / order の説明に従う」に直す
  • R10(low)app/api/clean-logs/__tests__/sort-route.test.ts:30-32: result=all で8行を全部通しているので「meta.total は絞り込み後の件数」を縛れていない。 → 8行より少なくなる絞り込み(例 result=problem)と sort=grade&order=asc&per_page=2&page=2 のケースを足す
  • R11(low)lib/clean-logs/__tests__/query.test.ts:662-674・lib/error-reports/__tests__/query.test.ts:363 付近: 422 の行が ok === false しか見ていない。 → S2-5・S2-6・E2-4・E2-5 に expect(parsed.status).toBe(422)。E2-6 で sort/order/schedule それぞれの details の形(キーがパラメータ名・中身が受け付ける値の一覧)。order=・order=%20・sort=%20 が省略と同じになるケース
  • R12(low)lib/__tests__/table-sort.test.ts: §1-3 の2つの遷移が縛られていない。 → nextSortState({key:"grade",order:"desc"}, "scheduled_at", {first:"asc",twoState:true}) → {scheduled_at, asc}、 nextSortState({key:"monitoring",order:"asc"}, "d_security", {first:"desc",twoState:false}) → {d_security, desc} の2件
  • R13(low)lib/clean-logs/__tests__/sort.test.ts と bh-s1-fixture.ts: 表S1 の8行が2か所に書かれている。 → sort.test.ts も bhS1RowsDefaultOrder() を import して使う(const [R4, R1, R2, R5, R3, R8, R6, R7] = bhS1RowsDefaultOrder();)。期待値は変えない
  • R14(low)app/api/clean-logs/export/__tests__/route.test.ts:151: it() の中の await import() はこのファイルの作法(冒頭 :3-7)違反 → ファイル冒頭で静的に import する

11-3. 直す(書き方・コメント)

  • R15(low)lib/clean-logs/sort.ts:72-103・lib/error-reports/query.ts:110 付近: (1) completion_rate の分岐の意味の無い if (aTier !== 0) return null; を消す。 (2) 両方 null の行どうしは、主キーの比較の前に明示的に 0 を返す(null < null や null - null に頼らない)。 (3) switch の default を { const _exhaustive: never = sort; return _exhaustive; } にする(キーを足して case を書き忘れたら tsc が止める)。sort.ts の2つと error-reports の1つ。 (4) コメントを足す: 結果待ちを先に見る理由(罠3: grade は "×" のまま)、desc は主キーだけ反転し reverse しない(§2-1)、完了率の固定行の段(0/1/2)の意味
  • R16(low)docstring: app/api/clean-logs/export/route.ts の冒頭に「sort/order も受け付け、CSV は画面と同じ並び」。sortCleanLogs() の直前に「上限の判定の後(捨てる行を並べ替えない)・入れ忘れると CSV だけ既定順」。 parseCleanLogQuery()・parseErrorReportQuery() の docstring に「空文字・空白は省略と同じ/不正値は 422(入力値をエコーしない)/2状態の列の desc は sort null に畳む/sort が null でも order は検証する」。 ErrorReportQuery の3フィールドに JSDoc(「省略時 null(既定順)」「省略時 asc」「null = すべて」)
  • R17(low)components/ui/SortableTh.module.css: 施設マスタから移したときに理由のコメントが落ちた。.sortButton・.sortMark・.thSortable の上に戻す — ★font: inherit を外さない(外すと日本語が中華字形に落ちる。CLAUDE.md §0-3)★・印の幅を固定する理由(⇅ と ▼▲ で文字が動かない)・余白 6px の理由。 CleanLogTable.module.css:15 付近と ErrorsScreen.module.css:252 付近の列幅の説明に「並べ替える見出しは SortableTh(左右 6px+間隔 3px+印 9px)」を1行足す
  • R18(low)押せないボタンの見た目(監督の判断): .sortButton:hover .sortMark { opacity: 1 } を .sortButton:hover:not(:disabled) .sortMark にする(押せないのに濃くならないように)。 ★編集中に並べ替える6列の見出しの文字だけが薄くなり、ほかの3列と濃さがまだらになる★ → ★見出しの文字は薄くしない★。押せないときは印(⇅▲▼)だけを薄くし、カーソルを default にする

11-4. 棄却した指摘(直さない)

  • 並べ替える見出しの余白 6px で、左・右そろえの列の文字が下のセルより 6px 外にずれる → §5 段階4 の指示どおり(承知のうえ)
  • 重複4件(th の inline-flex・await import・表S1 の2か所・CSS コメント)→ R1・R14・R13・R17 にまとめた
  • d-watch-api.yaml が js-yaml で読み込めない(bad indentation)→ ★今回の変更より前(HEAD 38e8f5d)からある★。範囲外。別の作業として監督が扱う

12. 追補2 — 並べ替えの印を白に・清掃詳細ログにも「予定あり/予定外」の絞り込み(2026-11-04)

§11 の修正は監督が確認済み(tsc・lint・vitest 154ファイル・3788件が通過、fixtures の画面で §8 を再確認)。その上に次の2つを足す。

12-0. ユーザーの指示(原文)と回答

  • 「並び替えのアイコンがわかりにくいので白い△で表示してください」
    • 監督の問い「白い△はどれか」への回答: ★「塗りのまま色だけ白」★ — 並べていない列は薄い △、昇順 ▲・降順 ▼ は塗りのまま白
  • 「詳細ログもエラー一覧と同じで予定外・予定ありでできるようにしてください」

12-1. 並べ替えの印(components/ui/SortableTh.*。3画面共通)

状態 今 ★変更後★
並べていない列 ⇅(--tx-m・不透明度 0.5) ★△(U+25B3。白抜き)・色 --sort-mark・不透明度 0.5★
asc ▲(--tx) ★▲・色 --sort-mark・不透明度 1★
desc ▼(--tx) ★▼・色 --sort-mark・不透明度 1★
マウスを重ねた(押せるときだけ) 並べていない印が不透明度 1 同じ(変えない)
押せない(エラー一覧の編集中) 印だけ 0.35・見出しの文字は薄くしない・カーソル default 同じ(変えない。§11 R18)
  • ★色はトークン --sort-mark を新しく作る★(app/globals.css):
    • ダーク(:root, [data-theme="dark"]): ★#ffffff★(ユーザー指示の「白」)
    • ライト([data-theme="light"]): ★#1e293b(ライトの --tx と同じ値)★ — ライトの表の背景は --sf = #ffffff なので、白にすると印が背景に溶けて消える(コントラスト 1.0:1)。★監督の判断★
    • ★.sortMark に #fff を直書きしないこと★(ライトで消える)。テーマは data-theme 属性だけで切り替わる(prefers-color-scheme は使っていない)
  • ★印の字のフォントをそろえる★: 今の ▲▼ は Geist Fallback(Arial)で描かれ、△ と ⇅ は Arial に無いので日本語フォント(Windows では Yu Gothic UI)で描かれる(監督が Windows のフォントの cmap で確認)。 このままだと並べていない △ と並べた ▲▼ で三角の大きさ・太さ・高さがそろわない。 → ★日本語フォントの並びを app/globals.css のトークンにして1か所にまとめる★(CLAUDE.md §0-3「globals.css のフォントスタックを正とする」。並びの写しを CSS モジュールに作らない):
    • app/globals.css に、テーマのブロックとは別に :root { --font-ja: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic UI", "Yu Gothic", "Noto Sans JP", Meiryo, sans-serif; } を置く(フォントはテーマによらないので、ライトのブロックには書かない)
    • body の font-family を var(--font-geist-sans), var(--font-ja); にする(並びの中身は今と同じなので body の見た目は変わらない)。:55-57 のコメントは残す
    • SortableTh.module.css の .sortMark, .sortMarkActive に font-family: var(--font-ja); を付ける。理由をコメントに書く(▲▼ を Geist Fallback(Arial)ではなく △ と同じ日本語フォントで描かせるため。並びの正は globals.css の --font-ja)
    • ★日本語フォントを外さない・中国語フォントを足さない★(CLAUDE.md §0-3)
  • ★幅 9px の箱(width: 9px・font-size: 9px・text-align: center)は残す★(印が変わっても見出しの文字が動かない)
  • aria-sort・aria-label・title は変えない(印は aria-hidden)
  • 詳細ログの行を開く印(CleanLogTable.tsx の chevron ▼▲。--tx-m・10px)は★変えない★
  • コメントの ⇅ を直す: SortableTh.module.css(2か所)、lib/table-sort.ts:46(「▲▼⇅」→「▲▼△」)

承知のうえの点(直さない):

  • 施設マスタは1回目が desc なので、△(上向き)を押すと ▼ になる。詳細ログ・エラー一覧は △ → ▲
  • 詳細ログの「判定」の見出しの印 △ は、判定バッジの △(同じ字 U+25B3)と同じ形になる(色と大きさで見分ける。ユーザーが選んだ形)

12-2. 清掃詳細ログの「予定あり/予定外」の絞り込み

エラー一覧(§4)と同じ3択を、清掃詳細ログの API・CSV・画面に足す。

意味(エラー一覧とは中身が違う。YAML にもそう書く):

  • unscheduled(予定外)= ★CleanLogItem.unscheduled が true の行★(予定外タスク。clean_task_reports.clean_timetable_id が 0 か NULL。判定はデータ層で scheduledTimetableId() 済み)
  • scheduled(予定あり)= ★それ以外の行★(予定の表=本清掃の行。レポートの無い未実施・結果待ちの行も入る)。★clean_timetable_id の値では決めない★ — clean_timetable_id が 0 以外なのに予定の表に無いレポートは、今までどおり一覧に出ない(予定ありにも予定外にも入らない。エラー一覧の「予定あり」はこの形も含むので、中身が違う)
  • 省略・空文字・空白だけ = すべて

共通にするもの(★1か所にまとめる★。2画面で規則がずれないように。既知の罠「表示と関門は同じ道具を通す」):

  • 新規 lib/api/schedule-filter.ts(lib/api/master-id.ts と同じ置き場所・同じ書き方の純粋関数):
    • export const SCHEDULE_FILTERS = ["scheduled", "unscheduled"] as const(★この順★。details の文言に使う)
    • export function parseScheduleFilter(searchParams: URLSearchParams): QueryParseResult<ScheduleFilter | null> — searchParams.get("schedule")?.trim() || null。配列に無い値は fail(422, "validation_failed", "scheduleの値が不正です", { schedule: ["scheduled / unscheduled のいずれかを指定してください"] })。★入力値をエコーしない★。大文字小文字は区別する(Scheduled は 422)。 判定は今の lib/error-reports/query.ts:187 と同じく SCHEDULE_FILTERS.includes(raw as ScheduleFilter)(★キャストを外すと as const の配列では TS2345★)。 呼び出し側の受け方は parseMasterIdFilters() と同じ: const schedule = parseScheduleFilter(searchParams); if (!schedule.ok) return schedule;、戻り値の query には schedule: schedule.query
    • export function matchesScheduleFilter(isScheduled: boolean, filter: ScheduleFilter | null): boolean — filter === null なら true、"scheduled" なら isScheduled、"unscheduled" なら !isScheduled。switch に never の網羅チェック
    • ★引数は「予定あり=true」で固定★。呼び出し側: エラー一覧は key.scheduled、清掃詳細ログは ★!r.unscheduled★(向きが逆。取り違えても型エラーにならないので、両方向をテストで縛る)
  • lib/types/index.ts: ErrorScheduleFilter を ★ScheduleFilter★ に名前を変える(使っているのは lib/error-reports/query.ts・lib/types/index.ts・ErrorsScreen.tsx だけ。別名は残さない。★名前を変えたら同じ段階で3ファイルの参照も新しい名前に直す★)。注記を「GET /errors と GET /clean-logs・/clean-logs/export の schedule」に直す
  • lib/error-reports/query.ts: ERROR_SCHEDULE_FILTERS(:36)は消し、:186-191 の検証を parseScheduleFilter() に(:218 は schedule: schedule.query)、:280-281 を matchesScheduleFilter(key.scheduled, query.schedule) に置き換える。 ★検証の順(sort → order → schedule)・message・details の形は変えない★ → エラー一覧の既存テスト(E2-1・E2-2・E2-5・E2-6、route.test.ts、fixture-error-reports.test.ts)は★1文字も変えずに通ること★
  • 新規 components/ui/ScheduleFilterSelect.tsx: <select> と3つの選択肢・aria-label/title(「紐づく予定」)を持つ部品。 props は value: ScheduleFilter | null、onChange: (next: ScheduleFilter | null) => void、className: string(★CSS は持たない★。詳細ログの .select は余白 4px 8px、エラー一覧は 6px 10px と違うので、各画面の styles.select と固定幅のクラスを渡す)。 選択肢: 値 "" =「予定:すべて」、"scheduled" =「予定あり」、"unscheduled" =「予定外」(★文言は §4 のまま。変えない★)。t() の取り方は今の ErrorsScreen.tsx に合わせる
    • ErrorsScreen.tsx の今の <select>(:608-621 付近)をこの部品に置き換える(見た目・位置・<fieldset> の中は変えない)。画面ローカルの型 ScheduleFilterState は不要になれば消す

清掃詳細ログ側:

  • lib/clean-logs/query.ts:
    • CleanLogQuery に schedule: ScheduleFilter | null(JSDoc「省略・空白は null=すべて」)
    • parseCleanLogQuery(): ★sort・order の検証の後★に parseScheduleFilter()。失敗ならそれを返す。docstring に schedule を足す
    • filterCleanLogs(): if (!matchesScheduleFilter(!r.unscheduled, query.schedule)) return false;(機種・シリアルの後。result との AND)
    • ★CleanLogDbQuery(データ層)には足さない★(db.cleanLogs() はダッシュボードも使う。データ層で行を落とすとダッシュボードの数え方に波及する。絞り込みの正は filterCleanLogs())。lib/types/index.ts の CleanLogDbQuery の「ここに入れないもの」に schedule を書き足す
  • app/api/clean-logs/route.ts・export/route.ts: 本体は変えない(filterCleanLogs() を通るので一覧・meta.total・CSV・CSV の行数上限の判定のすべてに効く)。冒頭の手順コメントに「予定あり/予定外」を足す
  • components/features/detail-log/DetailLogScreen.tsx:
    • state scheduleFilter: ScheduleFilter | null(既定 null)
    • queryString の useMemo で if (scheduleFilter) params.set("schedule", scheduleFilter)、★依存配列にも入れる★(入れ忘れると選んでも取り直さず、CSV のリンクも変わらない。例外は出ない)
    • ★置く位置: ツールバー左の結果ボタン「全件」の直後★(絞り込みのボタンと並べる。監督の判断。エラー一覧は文字入力の後)。 ★ツールバーは flex-wrap で、プルダウンを左右どちらに置いても1行の幅は同じだけ(約166px)増える★。ホールディングスのプルダウンが出るとき(fixtures でも出る)は、ビューポートがおよそ 1440px より狭いと右の組(施設名・機種・シリアル・CSV)が2行目の左端に落ちる(1366px では落ちる。変更前は1行)。 ★承知のうえ。CSS で1行に押し込まない(.toolbar の flex-wrap・幅・余白は変えない)★
    • className={${styles.select} ${styles.scheduleFilterSelect}}、変えたら★同じ操作の中で setPage(1)★(useEffect で後追いしない)
    • 並べ替えの状態と同じく URL には載せない
  • DetailLogScreen.module.css: .scheduleFilterSelect(エラー一覧の .scheduleFilterSelect と同じ: 幅 160px 固定・overflow: hidden・text-overflow: ellipsis・white-space: nowrap)。JP/EN でツールバーが動かないこと(D-162)
  • d-watch-api.yaml(★先に直す。CLAUDE.md §6-1★):
    • /clean-logs の parameters(order の後、Page の前)と /clean-logs/export の parameters(order の後)に schedule(enum: [scheduled, unscheduled]、default は書かない)。説明: 上の「意味」・省略と空文字・空白だけはすべて・未知の値は 422
    • 両方の 422 の説明に「schedule の不正値(details はパラメータ名と受け付ける値の一覧。sort/order と同じ形)」
    • /errors の schedule の説明に「清掃詳細ログの schedule とは予定ありの中身が違う」と1行
  • lib/i18n.ts: ★足すキーは無い★(「予定:すべて」「予定あり」「予定外」「紐づく予定」は既にある)

12-3. 期待値表(★監督が確定。テストはこれをそのまま使う★)

表P — parseScheduleFilter()(新規 lib/api/__tests__/schedule-filter.test.ts)と parseCleanLogQuery()(lib/clean-logs/__tests__/query.test.ts)

# クエリ 結果
P-1 なし null
P-2 schedule= null
P-3 schedule=%20 null
P-4 schedule=scheduled "scheduled"
P-5 schedule=unscheduled "unscheduled"
P-6 schedule=%20unscheduled%20 "unscheduled"(前後の空白を落とす。エラー一覧と同じ)
P-7 schedule=all・schedule=main・schedule=Scheduled・schedule=UNSCHEDULED 422。message「scheduleの値が不正です」、details ★{ schedule: ["scheduled / unscheduled のいずれかを指定してください"] }(toEqual で丸ごと)★。本文に入力値が出ない
P-8 sort=bad&schedule=bad(parseCleanLogQuery()) 422 で details のキーは ★sort だけ★(先に sort を見る)

表M — matchesScheduleFilter()(同じテストファイル)

# isScheduled filter 結果
M-1 true null true
M-2 false null true
M-3 true "scheduled" true
M-4 false "scheduled" false
M-5 true "unscheduled" false
M-6 false "unscheduled" true

表F — GET /clean-logs(app/api/clean-logs/__tests__/sort-route.test.ts に足す。行は bhS1RowsDefaultOrder() の8行。予定外は rp-9001(×)の1行だけ)

# クエリ meta.total items の row_key(この順)
F-1 result=all&schedule=unscheduled 1 rp-9001
F-2 result=all&schedule=scheduled 7 rp-9001 を除いた7行。順は result=all(schedule なし)の結果から rp-9001 を抜いたものと同じ
F-3 result=problem&schedule=scheduled 2 tt-102・tt-103(result=problem の結果から rp-9001 を抜いた順)
F-4 result=problem&schedule=unscheduled 1 rp-9001
F-5 result=critical&schedule=unscheduled 1 rp-9001
F-6 result=all&schedule=scheduled&sort=grade&order=asc&per_page=2&page=1 7 result=all&sort=grade&order=asc の並びから rp-9001 を抜いた先頭2行
F-7 schedule=all — 422(details のキーは schedule)
F-8 result=all&schedule= 8 schedule なしと同じ(空文字は「すべて」。ルートを通しても 422 にならない)
  • F-2・F-3・F-6 の「抜いた順」は、テストの中で schedule なしの応答から rp-9001 を filter して作ってよい(数字を手で書き写さない)。★件数(7・2)は直書きで縛る★

表X — GET /clean-logs/export(app/api/clean-logs/export/__tests__/route.test.ts に足す。同じ8行)

# クエリ CSV のデータ行
X-1 result=all&schedule=unscheduled 1行。予定時刻の列が「予定外」
X-2 result=all&schedule=scheduled 7行。予定時刻の列に「予定外」が★1つも無い★
X-3 schedule=bad 422(JSON。details のキーは schedule)

エラー一覧(回帰): 既存のエラー一覧のテストは★変えずに★全部通ること。matchesScheduleFilter(key.scheduled, …) の向きは既存の fixture-error-reports.test.ts(FX-ERR-04・12・20 の3件)が縛っている

12-4. 既存テストの扱い

  • lib/db/__tests__/fixture-clean-logs.test.ts:20-37 の memoryQuery() は CleanLogQuery の全フィールドを書いているので、schedule: null を足す(★入れ忘れると tsc と next build で落ちるうえ、vitest も落ちる★ — schedule が undefined だと matchesScheduleFilter() は filter === null を通らずに never の分岐へ入り、undefined(偽)を返す。そのため filterCleanLogs() が全行を落とし、#2(:52-69)の expect(sql).toEqual(memory) が赤になる。★段階4では query.ts の変更と同じ手順の中で足し、そのあとで vitest を回す★)
  • それ以外の既存テストの期待値は変えない(schedule 省略=すべてなので件数・並びは変わらない)。変えたくなったら実装を変えずに監督に報告

12-5. 既知の罠(§7 に追加)

  1. ★向きが逆★: エラー一覧は key.scheduled(true=予定あり)、清掃詳細ログは r.unscheduled(true=予定外)。matchesScheduleFilter() には★必ず「予定あり=true」を渡す★
  2. schedule を result と同じ書き方(?? "all" で受ける)にすると schedule= が 422 になり、エラー一覧と規則が分かれる。parseScheduleFilter() だけを使う。清掃詳細ログ側は query.test.ts の表P(P-2・P-3・P-6)と表F の F-8 が縛る
  3. fixtures の清掃詳細ログの予定外は2行だけ(rp-9200000000000000001=施設1・◎、rp-9200000000000000002=施設11・×・清掃キャンセル)。★画面の既定(△×のみ)で「予定外」を選ぶと1件★、「全件」で2件。1件しか出ないのは不具合ではない。★既定(△×のみ)は1ページしかないので、ページを戻す確認は「全件」で行う(12-6)★
  4. ステージングでは清掃詳細ログ 636件のうち 596件が予定外、予定ありは 40件(2026-11-04 監督が API で数えた。先方投入のデータ。時間で変わる)。予定と実績がまだつながっていないため。「予定あり」が少ないのは不具合ではない
  5. △ を描く日本語フォントが無い環境では sans-serif に落ちる。印も body も同じ --font-ja(日本語フォントを先に)を使うので、中国語フォントには落ちにくい。★--font-ja の並びを短くしない・CSS モジュールに写しを作らない★

12-6. 画面で確かめること(fixtures。§8 と同じやり方で :3100)

  • 3画面: 並べていない列の印が★薄い白の △★、押すと★白の ▲/▼★(施設マスタは △ → ▼ → ▲ → △)。△ と ▲▼ の大きさ・高さがそろって見える。見出しの文字は動かない(印を変えても th の幅が変わらない)
  • ライトテーマ(表示設定で切り替え): 印が★濃い紺★で見える(白で消えていない)
  • エラー一覧の編集中: 印だけ薄くなり、見出しの文字は他の列と同じ濃さ(§11 R18 のまま)
  • 清掃詳細ログ: 「全件」の右にプルダウン。既定(△×のみ)で「予定外」→ 1件(施設11の×)。「全件」にすると2件。「予定あり」→ 予定外の行(予定の欄が「予定外」)が消える。 ★ページの戻しは「全件」で確かめる★(既定の△×のみは48件で1ページしかない): 「全件」(fixtures で283件・6ページ)で2ページ目を開き、プルダウンを「予定あり」(281件)に切り替えると1ページ目に戻ること。 ★「予定外」への切り替えで確かめないこと★ — 2件で1ページに収まるので、setPage(1) を入れ忘れていても DetailLogScreen.tsx:237-238 の保険(応答の page が総ページ数を超えたら最終ページへ寄せる)で1ページ目に戻って見える(件数は実行日で少し変わるが、全件と予定ありが2ページ以上・予定外が1ページという関係は変わらない)。 CSV のリンクに schedule= が付き、CSV の行も同じに絞られる。 ★ビューポート 1366px と 1536px の2つの幅で見る★: 1536px ではツールバーが1行。1366px では右の組が2行目に落ちるが不具合ではない(12-2)。 どちらの幅でも、プルダウン自身の幅(160px)が JP/EN や選んだ値で変わらないこと(左端の位置は、前の結果ボタンの文言の幅だけ JP/EN で数 px 動く。結果ボタンは今も JP/EN で幅が変わるので承知のうえ)
  • フォント: git grep -n "Hiragino" -- app components lib のヒットが app/globals.css の --font-ja の1行だけであること。ダーク・ライトとも △ と ▲▼ の大きさ・高さがそろうこと
  • エラー一覧のプルダウンは見た目も動き(「予定外」で3件)も今までどおり

12-7. 段階(この順で。§9 の共通ルールはそのまま)

  1. d-watch-api.yaml(12-2 の YAML)
  2. lib/api/schedule-filter.ts+テスト(表P・表M)、lib/types/index.ts(名前の変更と注記)
  3. lib/error-reports/query.ts を共通関数に置き換え → ★エラー一覧の既存テストが変えずに通ることを先に確かめる★
  4. lib/clean-logs/query.ts、ルートのコメント、fixture-clean-logs.test.ts の schedule: null、表P(P-1〜P-8)・表F・表X のテスト。 ★表P の P-1〜P-7 は lib/clean-logs/__tests__/query.test.ts で parseCleanLogQuery() にも it.each で当てる★(ほかのパラメータは付けない)。ok のケース(P-1〜P-6)は parsed.query.schedule を見る。422 のケース(P-7)は status 422・message「scheduleの値が不正です」・details を toEqual で丸ごと見る
  5. components/ui/ScheduleFilterSelect.tsx、ErrorsScreen.tsx の置き換え、DetailLogScreen.tsx・.module.css
  6. app/globals.css(--sort-mark・--font-ja と body の font-family)、SortableTh.tsx・.module.css(印・色・フォント・コメント)、lib/table-sort.ts:46 のコメント
  7. tsc・lint・vitest 全件の件数と、段階ごとの変更ファイルを報告(★報告は日本語★)

12-8. 決定の記録(監督が docs/decisions.md に後で書く)

  • 並べ替えの印: 並べていない列=薄い △、asc ▲・desc ▼ は塗りのまま。色はダーク=白、ライト=#1e293b(白は背景に溶けるため。監督の判断)。印の字は日本語フォントでそろえる
  • 清掃詳細ログにも schedule(エラー一覧と同じ3択・同じ文言)。予定ありの中身は2画面で違う(エラー一覧=clean_timetable_id が 0・NULL 以外、清掃詳細ログ=予定の表の行)
  • 検証と判定は lib/api/schedule-filter.ts の1か所、プルダウンは components/ui/ScheduleFilterSelect.tsx の1か所(CSS は各画面)
  • 置き場所: 清掃詳細ログは「全件」の右(エラー一覧は「解消済みも表示」の左のまま)。1366px 前後の幅では詳細ログのツールバーが2行になる(承知のうえ)
  • 日本語フォントの並びを app/globals.css の --font-ja にまとめ、body と並べ替えの印の両方がそれを使う(並びの写しを作らない)
  • 点検(2026-11-04): 4観点で12件の指摘。反証で確かめて6件が本物(12-4 の理由・ツールバーの折り返し・表P を parseCleanLogQuery にも当てる・フォントの並びの写し・ページの戻しの確かめ方)。棄却6件のうち、書き方の補足になる5件(includes のキャスト・:218 の受け方・名前変更と参照・予定ありの中身・JP/EN の数 px)は本文に一言ずつ足した。棄却: 段階ごとに tsc を通す決まりの追加(§9 は最後だけ求める)
  • 却下: 白抜き △▽ にする案・並べた列だけ印を出す案(ユーザーが「塗りのまま色だけ白」を選んだ)。schedule をデータ層(SQL)に降ろす案(ダッシュボードに波及するため)

13. 追補3 — 表示の文言を「予定なし」に統一(2026-11-04。★Cursor ではなく監督側で直した★)

§12 の実装は監督が確認済み(tsc・lint・vitest 155ファイル・3826件、fixtures の画面)。そのあとのユーザー指示: 「文言を予定なし、予定ありに統一して/プルダウンだけじゃなくて、表の表示も、cursorでなくこちらで直して」

  • ★画面・CSV に出る「予定外」を「予定なし」に変えた★: プルダウンの選択肢(components/ui/ScheduleFilterSelect.tsx)、清掃詳細ログ・ダッシュボードの小さい表・CSV の「予定」列(CLEAN_LOG_UNSCHEDULED_LABEL。lib/format/clean-result.ts)、清掃面積の注記(CleanAreaScreen.tsx)。英訳は既存の「予定なし」"No schedule"
  • エラー一覧の「紐づく予定」列はもともと「予定なし」(予定のある行は「本清掃」「事前チェック(N分前)」「予定不明」と中身を出す。変えない)
  • ★変えないもの★: API のパラメータ値(schedule=scheduled / unscheduled)・型名・関数名・CleanLogItem.unscheduled・コメントや仕様書の概念語「予定外タスク」(契約と内部の用語。画面に出る文字だけを変えた)
  • §4・§12 の本文の「予定外」(選択肢の文言)は、この §13 で「予定なし」に読み替える