# 指示書H — 月跨ぎ週を TeamWorks 方式（月境界で分割）に統一する

| 項目 | 内容 |
|---|---|
| 実行環境 | Claude Code（Opusがサブエージェント（opus）に段階ごと委譲。段階0〜5をすべて実施） |
| 実行状態 | **完了** |
| 実行日 | 2026-09-07 |
| 実行コミット | `85978b3`(段階0) `8a00ed0`(段階1-2) `b09dc5f`(段階3) `75804ef`(段階5) |
| 取得した決定番号 | D-073 |
| 前提 | `docs/decisions.md` の **D-071**（ルール本体と検証結果）と **D-070**（TeamWorks の回答原文）を先に読むこと |
| 着手前の基準 | `npm test` が **19ファイル / 389テスト 全パス**、`npx tsc --noEmit` エラー0、`npm run lint` 0（実測どおり） |
| 完了後 | `npm test` **19ファイル / 424テスト 全パス**、`npx tsc --noEmit` エラー0、`npm run lint` 0 |

---

## この指示書は何をするものか

**月跨ぎ週の帰属ルールを D-067 から D-071 へ差し替えます。**

| | 旧（D-067。いま実装されているもの） | ★新（D-071。TeamWorks 方式）★ |
|---|---|---|
| 考え方 | 1日を含む週が、その月の第1週 | **週を月境界で分割する** |
| 週の長さ | **必ず7日** | ★**1〜7日で変わる**★ |
| 週番号 | 1〜5 | ★**1〜6**★ |
| `2026-08-30`〜`08-31` | 9月第1週（`09-05` まで続く） | ★**8月第6週（2日で終わる）**★ |
| `2026-09-01`〜`09-05` | 同上（9月第1週の一部） | ★**9月第1週（5日）**★ |

**なぜ変えるのか**: ★**週次サマリを作るのは TeamWorks（D-023）で、DFA はそれを読むだけ。**★
DFA が別ルールを持つと、**確定週（サマリ由来）と当期（DFA がその場集計）で週の定義が変わり、
同じ画面の中で数字の意味が変わります。** 合わせる以外の選択肢がありません。

### ★先に知っておくこと: TeamWorks の定義書は「第1週〜第5週」で第6週が無い★

**テーブル定義書の転記**（`docs/handoff/analysis/table-definitions-03-schedule-summary.md:321-322`）には、
サマリ4テーブルの `week` カラムについてこう書かれています。

> `week` は「第1週 1, 第2週 2, 第3週 3, 第4週 4, 第5週 5 **0は月の集計値を表す**」
> （`month` は「**0は年の集計値を表す**」）

★**先方の W-1 の回答（8月第6週を認める）と、先方自身の定義書（週は1〜5）が矛盾しています。**★
**第5回の確認事項として先方に照会します**（`docs/handoff/TeamWorks確認事項_第5回_20260907.md`）。

★**ユーザー判断: `week` の上限は 6 として実装します。X-1 の回答は待ちません。**★
第6週は**カレンダー上、年平均2.6回実際に発生**します
（**31日月で1日が金曜または土曜、または30日月で1日が土曜**。
2025-03 / 2025-08 / 2025-11 / **2026-05** / **2026-08** / 2027-01 / 2027-05 / 2027-10 …）。
★**2026年5月・8月はすでに過ぎた月です。**★ 上限5では表現できない期間が現に存在するため、
**6まで実装するのが正しい**という判断です。

★**この矛盾はこの指示書の作業を止めません。**★
理由: この指示書が直すのは **DFA 側の暦計算**（`lib/format/period.ts`）で、
**週の切り方は W-1 の回答が正**です。矛盾しているのは
「**先方がサマリテーブルに第6週の行をどう格納するか**」であり、
それは `prismaSource`（サマリ読み込み）を実装するときに必要になる情報です。
**いまは fixtures で開発しており、サマリ読み込みは未実装です。**

★**ただし、この指示書で `week` の意味を勝手に変えないこと。**★
特に **`week = 0` は「月の集計値」を表す予約値**です。
`WEEK_MIN = 1` のまま（`week=0` は 422）にしておいてください。**0 を通す変更をしないこと。**

★**この指示書の期待値表は、2020〜2040 の全252か月を総当たりして検証済みです。**★
**表の数値を「たぶんこうだろう」で書き換えないこと。**
矛盾を見つけたら**実装を止めて報告**してください（`CLAUDE.md` §0-1）。

---

## 影響範囲（★調査済み。この5ファイル以外は触らなくてよい★）

| ファイル | 何が影響するか | 段階 |
|---|---|---|
| `d-watch-api.yaml:921-935` | `Week.maximum: 5` と、**その根拠を説明する `description`** | 段階0 |
| `lib/reports/query.ts:61` | `const WEEK_MAX = 5;`（エラー文言はテンプレート補間なので**自動追随します**） | 段階0 |
| `lib/format/__tests__/period.test.ts`（53件） | **約15件の期待値と、意図コメント。総当たりテストの不変条件** | 段階1 |
| `lib/format/period.ts` | `weekOfMonth()` / `periodRange()` / `shiftPeriod()` と冒頭コメント | 段階2 |
| `lib/reports/__tests__/query.test.ts`（44件） | **3件**（`week=6は422` が**逆になる** / `week=5は上限` / 422 の文言） | 段階1 |

★**書き換えが不要と確認できているもの**★（調査済み。触らないこと）

- `lib/fixtures/` の**サンプルデータに週番号のハードコードは1つもありません**。
  `lib/fixtures/reports.ts:49-68` は `todayJst()` からの相対で全期間を作っています
- `weekStart()` は月を見ないので、その8件のテストは**全部そのまま使えます**
- `periodLabel()` / `prevPeriodLabel()` の4件、`periodRange('monthly', …)` の暦月締めは**無関係**
- `lib/dashboard/aggregate.ts` / `lib/fixtures/schedules.ts` の `weekdays`（`"1111111"` 等）は
  ★**曜日ビットマスクで、週番号とは無関係**★。混同しないこと
- `week_start` / `week_end` / `start_date` / `end_date` というフィールドは**コードベースに存在しません**

---

## 作業の順序（★崩さないこと★）

`CLAUDE.md` §0-2 のとおり **「期待値表の確定 → テスト作成 → 実装」** の順です。
**期待値表はこの指示書で確定済み**なので、**テストを先に書いて赤くしてから**実装に入ってください。

| 段階 | 内容 | コミット |
|---|---|---|
| 段階0 | `d-watch-api.yaml` と `lib/reports/query.ts` の上限を 5 → 6 | 段階0で1コミット |
| 段階1 | テスト2ファイルを新期待値に差し替え（**この時点で赤くなる。それが正常**） | 段階1〜2をまとめて1コミット |
| 段階2 | `lib/format/period.ts` を差し替え（**緑にする**） | 同上 |
| 段階3 | 呼び出し側の影響確認と是正 | 段階3で1コミット |
| 段階5 | ★**`week` の型を `number \| null` → `number` にする（月次は `0`）**★ | 段階5で1コミット |
| 段階4 | `docs/decisions.md` へ記録・引き継ぎメモ・スラッシュコマンドの更新 | 段階4で1コミット |

★**段階5 は段階2が緑になってから、別コミットで行うこと。**★
週の帰属変更と型変更を1コミットに混ぜると、どちらが原因で落ちたのか分からなくなります。

★**段階ごとに `npx tsc --noEmit` と `npm test` を実行し、通ってからコミットすること**★（D-050）。

---

## 段階0 — 週番号の上限を 6 にする

### 0-1. `d-watch-api.yaml`（`Week` パラメータ。921行目付近）

```yaml
    Week:
      name: week
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 6          # ★5 から変更（D-071）★
```

★**`description` も直すこと**★。現在の `description` は
「`floor((offset+日数)/7)` で最大5になる」という**D-067 の根拠を説明した文章**です。
D-071 では成り立たないので、次の趣旨に書き換えてください。

> 月の第N週。週は日曜〜土曜だが、**月をまたぐ週は月境界で分割し、それぞれの月の週として数える**（D-071）。
> 各月の第1週は必ず1日から始まり、最終週は必ず末日で終わる。**週の長さは1〜7日で変わる。**
> 上限は6（1日が金曜または土曜の30日/31日月で第6週が発生する）。

★**これは D-069 の「2. `week` の上限は 5 のまま」を上書きします。**★
D-069 は「D-067 のルールでは第6週が発生しない」という前提で 5 に据え置いた判断で、
**D-071 では第6週が発生します**（2020〜2040 の252か月中 **55か月**。2026年は5月と8月）。

`CLAUDE.md` §6-1「API契約を変えたくなったら、先に `d-watch-api.yaml` を直す」に従い、
**この段階を最初に行います。**

### 0-2. `lib/reports/query.ts:61`

```ts
const WEEK_MAX = 6;   // ★5 から変更（D-071）★
```

422 のエラー文言（`period=weekly のときは${WEEK_MIN}〜${WEEK_MAX}で指定してください`）は
**テンプレート補間なので自動で `1〜6` になります。** 文言を手で書き換えないこと。

★**週数は暦から決まる定数で、運用で変える閾値ではありません。**★
`CLAUDE.md` §7 の「閾値のハードコード禁止」の対象外なので、`lib/config/` へ出す必要はありません。

---

## 段階1 — テストを新しい期待値に差し替える（★先にこれをやる★）

**この段階を終えた時点でテストは赤くなります。それが正常です。**

### 1-A. `lib/format/__tests__/period.test.ts`

★**ファイル冒頭コメント（13〜24行目付近）の「帰属は D-067」という前提を先に直すこと。**★
2026年の曜日一覧（`2026-08-01 土 / 2026-09-01 火` 等）は正しいので残してよいです。

#### 期待値表1: `weekOfMonth(date)`

| 入力 | 曜日 | 期待 year | 期待 month | 期待 week | 何を固定するか |
|---|---|---|---|---|---|
| `2026-08-01` | 土 | 2026 | 8 | **1** | ★TeamWorks 回答★ 1日が土曜 → 第1週は1日だけ |
| `2026-08-02` | 日 | 2026 | 8 | **2** | 日曜 → 第2週の初日 |
| `2026-08-29` | 土 | 2026 | 8 | **5** | 土曜 → 第5週の最終日 |
| `2026-08-30` | 日 | 2026 | 8 | **6** | ★TeamWorks 回答★ 日曜だが8月に残る → 8月第6週。**旧D-067なら9月第1週だった** |
| `2026-08-31` | 月 | 2026 | 8 | **6** | ★TeamWorks 回答★ 月末 → 8月第6週 |
| `2026-09-01` | 火 | 2026 | 9 | **1** | ★TeamWorks 回答★ 火曜だが月初 → 9月第1週の初日 |
| `2026-09-05` | 土 | 2026 | 9 | **1** | ★TeamWorks 回答★ 土曜 → 9月第1週の最終日（5日間） |
| `2026-09-06` | 日 | 2026 | 9 | **2** | ★TeamWorks 回答★ 日曜 → 9月第2週 |
| `2026-09-12` | 土 | 2026 | 9 | **2** | ★TeamWorks 回答★ 土曜 → 9月第2週 |
| `2026-09-30` | 水 | 2026 | 9 | **5** | 月末が水曜 → 第5週（4日間） |
| `2026-07-26` | 日 | 2026 | **7** | **5** | ★**旧D-067では「8月第1週」だった**★。7月に残る |
| `2026-07-25` | 土 | 2026 | 7 | **4** | 期待値は旧と同じ。**ただし「7月は4週」という意図コメントは嘘になる（7月は5週）** |
| `2026-05-01` | 金 | 2026 | 5 | **1** | 1日が金曜の31日月 → この月も第6週が発生する |
| `2026-05-31` | 日 | 2026 | 5 | **6** | 日曜だが月末 → 5月第6週（**1日だけ**） |
| `2026-02-01` | 日 | 2026 | 2 | **1** | 1日が日曜の月 → 第1週が7日ちょうど |
| `2026-02-28` | 土 | 2026 | 2 | **4** | 28日・日曜始まりはちょうど4週（旧と同じ） |
| `2026-12-26` | 土 | 2026 | 12 | **4** | 期待値は旧と同じ。**「12月は4週」という意図コメントは嘘になる（12月は5週）** |
| `2026-12-27` | 日 | 2026 | **12** | **5** | ★**旧D-067では「2027年1月第1週」だった**★ |
| `2026-12-31` | 木 | 2026 | **12** | **5** | ★**旧D-067では「2027年1月第1週」だった**★ |
| `2027-01-01` | 金 | 2027 | 1 | **1** | 年初（旧と同じ） |
| `2025-12-28` | 日 | 2025 | **12** | **5** | ★**旧D-067では「2026年1月第1週」だった**★ |
| `2026-01-03` | 土 | 2026 | 1 | **1** | 同じ週の最終日（旧と同じ） |
| `2026-01-01` | 木 | 2026 | 1 | **1** | 年初。1日が木曜 |

★**期待値が変わるのは 6 件**★（`2026-08-30` / `08-31` / `07-26` / `12-27` / `12-31` / `2025-12-28`）。
★**期待値は変わらないが意図コメントが嘘になるのが 3 件**★
（`2026-07-25` の「7月は4週」/ `2026-12-26` の「12月は4週」/ `2026-08-01` の「第1週の最終日」）。
**コメントも必ず直してください。** 残すと次の担当者が誤解します。

#### 期待値表2: `periodRange('weekly', …)` — 2026年8月・9月の全週

| year | month | week | from | to | 日数 | 備考 |
|---|---|---|---|---|---|---|
| 2026 | 8 | 1 | `2026-08-01`(土) | `2026-08-01`(土) | 1 | ★1日だけの週★ |
| 2026 | 8 | 2 | `2026-08-02`(日) | `2026-08-08`(土) | 7 |  |
| 2026 | 8 | 3 | `2026-08-09`(日) | `2026-08-15`(土) | 7 |  |
| 2026 | 8 | 4 | `2026-08-16`(日) | `2026-08-22`(土) | 7 |  |
| 2026 | 8 | 5 | `2026-08-23`(日) | `2026-08-29`(土) | 7 |  |
| 2026 | 8 | 6 | `2026-08-30`(日) | `2026-08-31`(月) | 2 | ★月境界で切れる★ |
| 2026 | 9 | 1 | `2026-09-01`(火) | `2026-09-05`(土) | 5 | ★月境界で切れる★ |
| 2026 | 9 | 2 | `2026-09-06`(日) | `2026-09-12`(土) | 7 |  |
| 2026 | 9 | 3 | `2026-09-13`(日) | `2026-09-19`(土) | 7 |  |
| 2026 | 9 | 4 | `2026-09-20`(日) | `2026-09-26`(土) | 7 |  |
| 2026 | 9 | 5 | `2026-09-27`(日) | `2026-09-30`(水) | 4 | ★月境界で切れる★ |
| 2026 | 8 | 7 | **null** | **null** | — | 存在しない週 → null |
| 2026 | 9 | 6 | **null** | **null** | — | 存在しない週 → null |
| 2026 | 8 | 0 | **null** | **null** | — | week < 1 → null |
| 2026 | 13 | 1 | **null** | **null** | — | month 範囲外 → null |

★**既存テスト「その月に存在しない週は null（4週しかない月の第5週など）」は前提ごと崩れます。**★
`periodRange('weekly',2026,12,5)` と `('weekly',2026,7,5)` は
**旧ルールでは null でしたが、新ルールでは存在します**（`2026-12-27`〜`12-31` / `2026-07-26`〜`07-31`）。
**null になるケースを上の表の4件に差し替え、テスト名も「5週しかない月の第6週など」に直してください。**

#### 期待値表3: `periodRange('monthly', …)` — 週とは独立（★変更なし★）

| year | month | from | to |
|---|---|---|---|
| 2026 | 8 | `2026-08-01` | `2026-08-31` |
| 2026 | 9 | `2026-09-01` | `2026-09-30` |
| 2026 | 2 | `2026-02-01` | `2026-02-28` |
| 2024 | 2 | `2024-02-01` | `2024-02-29` |

#### 期待値表4: `shiftPeriod('weekly', …)`

| 起点 | delta | 期待 | 何を固定するか |
|---|---|---|---|
| 2026年8月 第6週 | `-1` | 2026年8月 第5週 | 8月第6週(2日)の前は8月第5週 |
| 2026年8月 第6週 | `+1` | 2026年9月 第1週 | ★8月第6週の次は9月第1週（月をまたぐ）★ |
| 2026年9月 第1週 | `-1` | 2026年8月 **第6週** | ★**旧D-067では 8月第5週 だった**★ |
| 2026年9月 第1週 | `+1` | 2026年9月 第2週 | 9月第1週の次は9月第2週 |
| 2026年8月 第1週 | `-1` | 2026年7月 **第5週** | ★**旧D-067では 7月第4週 だった**★。1日だけの週(08-01)の前 |
| 2026年8月 第1週 | `+1` | 2026年8月 第2週 | 1日だけの週の次は8月第2週 |
| 2026年9月 第5週 | `+1` | 2026年10月 第1週 | ★月末の週の次は10月第1週★ |
| 2026年1月 第1週 | `-1` | 2025年12月 **第5週** | ★年をまたぐ。**旧D-067では 2025年12月第4週 だった**★ |
| 2025年12月 第5週 | `+1` | 2026年1月 第1週 | ★年をまたいで戻る★ |
| 2026年8月 第5週 | `+1` | 2026年8月 **第6週** | ★**旧D-067では 9月第1週 だった。既存テスト名「最終週から +1 で翌月の第1週へ」は成立しません**★ |
| 2026年12月 第4週 | `+1` | 2026年12月 **第5週** | ★**旧D-067では 2027年1月第1週 だった**★ |

★**既存テスト「+1 と -1 は往復する（2026年を通しで確認）」（60回ループ）はそのまま残してください。**★
新実装でも往復性は成り立ちます（2020〜2040 の総当たりで確認済み）。
**これが落ちるなら実装が間違っています。** テストを緩めて通さないこと。

#### 期待値表5: 不変条件（★2020〜2040 を総当たり★）

既存の総当たりテスト（105〜130行目付近）は**削除ではなく書き換え**です。
`it(...)` のタイトルを **「★D-071 では週番号は最大6。第6週は発生する★（2020〜2040を総当たり）」**
に直し、前置きコメント（92〜104行目付近の「D-067 では第6週は発生しない」の長文）も
**全面的に書き換えてください。**

| 不変条件 | 期待 |
|---|---|
| 出現する週番号の集合 | ★**`[1,2,3,4,5,6]`**★（旧は `[1,2,3,4,5]`） |
| 年月ごとの週番号集合が欠番のない 1..N の連番 | **そのまま成立するので残す** |
| 週番号が6になる月の件数（252か月中） | **55件**（最初の3件: 2020-05 / 2020-08 / 2021-01） |
| 各月の週が、その月の全日をちょうど1回ずつ覆う | **違反0件** |
| 週の開始は「1日 or 日曜」 | **違反0件** |
| 週の終了は「末日 or 土曜」 | **違反0件** |
| 週の全日で `weekOfMonth()` が元の (年,月,週) を返す（往復） | **違反0件** |
| `periodRange(y, m, weeksInMonth+1)` | **必ず null** |
| 週の長さの分布（全1313週） | 1日=73 / 2日=71 / 3日=73 / 4日=74 / 5日=70 / 6日=73 / 7日=879 |
| ★1日しかない週★ | **73件 / 21年**（年2〜3回）。2026年は `2026-08-01` と `2026-05-31` |

#### `isCurrentPeriod` の既存テスト2件（★書き換え必須★）

| 既存テスト | いま | 新ルール |
|---|---|---|
| 「月跨ぎ週は8月の日でも『9月第1週』の当期に入る（★D-067★）」 | `(weekly,2026,9,1,"2026-08-30")` → **true** | ★**false**★（`08-30` は8月第6週）。**テスト名ごと差し替え** |
| 「存在しない期間 / 不正な today は false」 | `(weekly,2026,12,5,"2026-12-28")` → **false** | ★**true になってしまう**★（12月第5週が存在する）。**`(weekly,2026,12,6,…)` のような実在しない週に差し替えること** |

#### 追加してほしいテスト（★新ルール固有★）

| # | 何を固定するか |
|---|---|
| H-1 | ★**週の長さが1日になるケース**★ — `periodRange('weekly',2026,8,1)` が `from === to === '2026-08-01'` |
| H-2 | ★**月境界で切れるケース**★ — `periodRange('weekly',2026,8,6)` の `to` が `'2026-08-31'`（`'2026-09-05'` ではない） |
| H-3 | ★**同じ物理週が2つの期間に分かれること**★ — `weekOfMonth('2026-08-30')` と `weekOfMonth('2026-09-01')` が**別の期間を返す**（旧ルールでは同じ 9月第1週だった） |
| H-4 | ★**第6週の存在**★ — `periodRange('weekly',2026,8,6)` が null でなく、`periodRange('weekly',2026,9,6)` が null |
| H-5 | ★**全日被覆（往復）**★ — 2020〜2040 の各月で、`periodRange` の全週の日を並べるとその月の1日〜末日と一致し、各日で `weekOfMonth()` が元の (年,月,週) を返す |
| H-6 | ★**`shiftPeriod` が月と年を跨ぐこと**★ — 上の期待値表4 の11ケース全部 |

### 1-B. `lib/reports/__tests__/query.test.ts`（3件）

| 既存テスト | 変更内容 |
|---|---|
| `it("week=6は422で弾かれる（第6週は存在しない）")` | ★**逆になります**★。**`week=6` は通るべき**。テスト名・期待値・`details` の文言すべて差し替え |
| `it("week=5は通る（上限）")` | **上限ではなくなる**。「`week=6` が上限」に直す |
| `it("period=weeklyでweek欠落なら422")` の `details` | `{ week: ["period=weekly のときは1〜5で指定してください"] }` → ★**`1〜6`**★ |

202行目付近のコメント
`// ★D-067 の下では第6週は存在しない。d-watch-api.yaml の Week は maximum: 5 のまま★`
も**差し替えてください。**

### 1-C. `lib/fixtures/__tests__/reports.test.ts` は書き換え不要（★ただし確認すること★）

`lib/fixtures/reports.ts:51` が `shiftPeriod("weekly", …, -1)` で「先週」を作っているため、
このファイルの2件（「KPI は今週 / 先週 / 今月 / 先月 の4行」「当期の行がちょうど1本」）は
**新実装の `shiftPeriod` の挙動に依存します。**

**期待値の書き換えは不要**です（週の範囲は互いに素なので4期間は常に別キーになります）が、
★**段階2の後に必ず `npm test` で通ることを確認してください。**★
落ちる場合は `shiftPeriod` の実装が間違っています。**fixture やテストを緩めて通さないこと。**

---

## 段階2 — `lib/format/period.ts` を差し替える

### 2-1. ファイル冒頭のコメント（★D-067 の説明が全部嘘になるので必ず直す★）

置き換える対象は次の2ブロックです。

- `★月跨ぎ週の帰属は「その月の1日を含む週が、その月の第1週」（D-067）★` から始まるブロック
- `★週番号の最大値は 5。第6週は発生しない★` から始まるブロック

置き換えるテキスト:

```
 * ★月跨ぎ週は「月境界で分割する」（D-071。TeamWorks のサマリ集計に合わせた）★
 *   週は日曜〜土曜だが、月をまたぐ週は1日で切り、それぞれの月の別々の週として数えます。
 *   各月の第1週は必ず1日から始まり、最終週は必ず末日で終わります。
 *
 *   ★週の長さは1〜7日で変わります。7日を前提にした算術を書かないこと★
 *
 *   | 週 | 帰属 | 日数 |
 *   |---|---|---|
 *   | 2026-08-01(土) | 2026年8月 第1週 | ★1日★ |
 *   | 2026-08-23(日)〜08-29(土) | 2026年8月 第5週 | 7日 |
 *   | 2026-08-30(日)〜08-31(月) | ★2026年8月 第6週★ | ★2日★ |
 *   | 2026-09-01(火)〜09-05(土) | ★2026年9月 第1週★ | ★5日★ |
 *   | 2026-09-06(日)〜09-12(土) | 2026年9月 第2週 | 7日 |
 *
 *   ★帰属のルールを知っているのは weekOfMonth() だけにしてあります★
 *   （periodRange() / shiftPeriod() も自前で帰属を計算せず weekOfMonth() と
 *   weeksInMonth() を使います。次にルールが変わったとき直す場所を1箇所に閉じ込めるため）
 *
 * ★週番号は 1〜6。第6週は発生します★
 *   D-067（旧ルール）では最大5でしたが、D-071 では「1日が金曜または土曜の30日/31日月」で
 *   第6週が発生します（2020〜2040 の252か月中55か月。2026年は5月と8月）。
 *   → d-watch-api.yaml の Week は maximum: 6 です（段階0で変更済み）。
 *   総当たり（2020〜2040）で 1〜6 しか出ないことをテストで固定しています。
```

### 2-2. 補助関数を差し替える

★**`firstWeekStartMs()` は不要になります。削除してください**★（新ルールでは使いません）。
`weekStartMs()` は `weekStart()` が使うので**残します**。次の4つを追加します。

```ts
/** その月の1日の曜日（0=日 … 6=土） */
function firstDayOfWeek(year: number, month: number): number {
  return new Date(Date.UTC(year, month - 1, 1)).getUTCDay();
}

/** その月の末日。Date.UTC(year, month, 0) は「翌月の0日」= その月の末日 */
function lastDayOfMonth(year: number, month: number): number {
  return new Date(Date.UTC(year, month, 0)).getUTCDate();
}

/** その月にいくつ週があるか（★1〜6★。D-071 では月境界で週を切るため月ごとに変わる） */
function weeksInMonth(year: number, month: number): number {
  return (
    1 + Math.floor((lastDayOfMonth(year, month) - 1 + firstDayOfWeek(year, month)) / 7)
  );
}

/** YYYY-MM-DD に日数を足す（負数で引く） */
function addDays(date: string, days: number): string {
  return toDateString(requireUtcMs(date, "addDays") + days * DAY_MS);
}
```

### 2-3. `weekOfMonth()` を差し替える

★**新ルールでは年月を日付からそのまま取ります。週末の属する月へずらしません。**★

```ts
/**
 * 月跨ぎ週は「月境界で分割する」（★D-071。D-067 / D-024 は破棄★）。第1週=1
 *
 * ★このリポジトリで帰属ルールを持っているのはこの関数だけです★（ファイル冒頭のコメント参照）
 * 返す `week` は 1〜6（★第6週は発生します★。理由は冒頭のコメント）。
 */
export function weekOfMonth(date: string): {
  year: number;
  month: number;
  week: number;
} {
  const target = new Date(requireUtcMs(date, "weekOfMonth"));
  const year = target.getUTCFullYear();
  const month = target.getUTCMonth() + 1;
  const day = target.getUTCDate();
  // 1日から数えて何本目の「日曜〜土曜の断片」に入るか。
  // 1日の曜日ぶんだけ下駄を履かせると、日曜で境界がそろう。
  const week = 1 + Math.floor((day - 1 + firstDayOfWeek(year, month)) / 7);
  return { year, month, week };
}
```

### 2-4. `periodRange()` の weekly 部分を差し替える

`monthly` の分岐は**変更しません**（暦月締めのまま）。
`const fromMs = firstWeekStartMs(...)` から `return { from, to: ... }` までを次に置き換えます。

```ts
  if (week === null || !Number.isInteger(week) || week < 1) return null;
  // ★その月に存在しない週は null（D-071 では月ごとに週数が違う）★
  if (week > weeksInMonth(year, month)) return null;

  const dowOfFirst = firstDayOfWeek(year, month);
  // 第1週は必ず1日から。第2週以降は月内の (week-1) 本目の日曜から
  const fromDay = week === 1 ? 1 : (week - 1) * 7 - dowOfFirst + 1;
  // 週の終わりは土曜。ただし★月末で切る★
  const dowOfFrom = (dowOfFirst + fromDay - 1) % 7;
  const toDay = Math.min(fromDay + (6 - dowOfFrom), lastDayOfMonth(year, month));
  return {
    from: `${pad(year, 4)}-${pad(month, 2)}-${pad(fromDay, 2)}`,
    to: `${pad(year, 4)}-${pad(month, 2)}-${pad(toDay, 2)}`,
  };
```

★**旧実装の「往復で検算して弾く」ロジックは削除してください。**★
`week > weeksInMonth(...)` で直接判定できるようになったためです
（旧実装は「存在しない週を指定すると帰属が翌月にずれる」性質を利用した間接的な判定でした）。

**JSDoc も直すこと**:
`- 引数が不正、またはその月に存在しない週（4週しかない月の第5週など）なら null`
→ `- 引数が不正、またはその月に存在しない週（5週しかない月の第6週など）なら null`。
`- weekly は日曜〜土曜。帰属は weekOfMonth() に合わせる（★D-067★）`
→ `- weekly は日曜〜土曜だが★月境界で切る★ので1〜7日。帰属は weekOfMonth() に合わせる（★D-071★）`。

### 2-5. `shiftPeriod()` の weekly 部分を差し替える

★**±7日では動かせなくなりました**★（週が7日でないため）。
**隣の週へは「その週の前日 / 翌日の帰属を求める」ことで移ります。**

`monthly` の分岐は**変更しません**。その下を次に置き換えます。

```ts
  // ★D-071 では週が7日でないので ±7日では動かせない。前日/翌日の帰属を求める★
  if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
    // 不正な年月はそのまま返す（画面のナビ用なので落とさない）
    return { year, month, week: week ?? 1 };
  }
  // weekly で week が無いときは第1週を起点にする。
  // 範囲外の週番号を渡されても落とさず、その月の週数に丸めてから動かす
  const currentWeek = Math.min(Math.max(week ?? 1, 1), weeksInMonth(year, month));
  const range = periodRange("weekly", year, month, currentWeek);
  if (!range) return { year, month, week: currentWeek };
  return weekOfMonth(delta < 0 ? addDays(range.from, -1) : addDays(range.to, 1));
```

**JSDoc も直すこと**:

```
 * - `weekly` は隣の週へ移る。★週が7日でないため ±7日ではなく
 *   「その週の前日 / 翌日の帰属」を weekOfMonth() で求め直す★（D-071）。
 *   第1週で `-1` なら前月の最終週、最終週で `+1` なら翌月の第1週になる
```

### 2-6. `periodLabel()` は変更しない（★確定。要判断は解消済み★）

`"2026年8月 第1週"` のままにします。**1日しかない週でもラベルは同じです。**

★**ユーザー判断（D-072）: 「第1週が1日しかなくても第1週」。特別な注記は入れません。**★
**画面に from〜to を出す変更もしません。** 2日しかない第6週（2026-08-30〜08-31）も
`第6週` とだけ表示します。**文言や画面を変えないこと**（モックが文言の正。`CLAUDE.md` §5）。

### 2-7. ★`clampWeek()` を追加する（D-072）★

存在しない週番号を、その月の最大週へ丸める関数を追加します。

```ts
/**
 * 指定年月に存在しない週番号を、その月の最大週へ丸める（D-072）。
 * 月次→週次の切替や月移動で、移動先の月に無い週番号が残るのを防ぐ。
 */
export function clampWeek(year: number, month: number, week: number): number {
  return Math.min(Math.max(week, 1), weeksInMonth(year, month));
}
```

★**`shiftPeriod` 内部の `week ?? 1` は変えないこと。**★
「週が未選択」と「選ばれた週が移動先の月に無い」は別の状況で、**後者だけをクランプします**。
前者は既存テスト（`weekly` で `week` が無いときは第1週から動かす）が固定しています。

**テストを追加すること**:

| 入力 | 期待 | 何を固定するか |
|---|---|---|
| `clampWeek(2026, 9, 6)` | **5** | 9月は5週なので第5週へ丸める |
| `clampWeek(2026, 8, 6)` | **6** | 8月は6週あるのでそのまま |
| `clampWeek(2026, 2, 5)` | **4** | 2026年2月は4週 |
| `clampWeek(2026, 9, 0)` | **1** | 下限も丸める |
| `clampWeek(2026, 9, 3)` | **3** | 範囲内はそのまま |

---

## 段階3 — 呼び出し側の影響を確認して是正する

★**「週は7日」を前提にした算術が残っていると、静かに間違った数字が出ます。**★

1. **`components/features/reports/ReportsScreen.tsx` を確認する。**（★調査済み。内容は下記★）
   - **76行目付近のコメント「monthly のときも週番号は保持しておく（週次に戻したときに
     1〜5 の値が要るため）」を「1〜6」に直す**（コメントのみ。ロジックは `weekOfMonth()` 任せで変更不要）
   - **71行目の「帰属ルール（D-067）は weekOfMonth() だけが知っている」を D-071 に直す**
   - ★**週番号を 5 で clamp している箇所は無いことを確認済み**★。週の選択UI（第1週〜第5週の列挙）も
     **存在しません**（期間ナビは ‹ › の相対移動だけ）。`periodLabel()` は `week` を埋めるだけなので
     「第6週」もそのまま出ます

   ★**`navigatePeriod` にクランプを入れること（D-072。ユーザー判断で実装が決まりました）**★
   `navigatePeriod`（186行目付近）は monthly で月を動かしても `week` の state を保持しますが、
   **移動先の月にその週が存在するかを検証していません。**

   ```ts
   // 変更前: setWeek(next.week ?? week);
   setWeek(next.week ?? clampWeek(next.year, next.month, week));
   ```

   **具体例**: `2026年8月 第6週` を表示中に月次へ切替 → `9月` へ移動 → 週次へ戻す
   - **変更前**: `week=6` のまま要求 → 9月は5週しかないので**KPIが4指標すべて null の空表示**
   - **変更後**: `9月 第5週`（09-27〜09-30）にクランプされ、**数字が出る**

   ★**これは旧ルールでも起きていた既存の穴です**★（4週しかない2026年2月に `week=5` を保持）。
   `WEEK_MAX` を 6 にしても **422 にはなりません**（`week=6` は上限内を通り、
   期間キーが一致せず空KPIになるだけ）。**今回まとめて直します。**
2. `weekStart` / `weekOfMonth` / `periodRange` / `shiftPeriod` の
   **全呼び出し箇所を grep して一覧にする**（段階4の記録に載せる）
3. **`7`（日数）を使った算術**を探す — `* 7`、`+ 7`、`DAY_MS * 7`、`86400000 * 7`、`/ 7`。
   ★特に「1日あたりの平均」を**合計÷7**で出している箇所があれば、
   **実際の日数（`periodRange` の from〜to の日数）で割るように直すこと**★
4. **週番号の上限 5 のハードコード**を探す — 週の選択UI（第1週〜第5週の列挙）、
   バリデーション、型の union（`1 | 2 | 3 | 4 | 5` のようなもの）
5. `lib/reports/` の KPI の**分母が実データ由来か日数由来か**を確認する。
   ★実データ由来（清掃予定回数・清掃対象面積など。`CLAUDE.md` §4.2）なら
   週の長さが変わっても壊れません★。日数由来のものがあれば**それは壊れるので報告**してください
6. i18n の対訳に `第N週` / `Week N` が5件までしか無いなら**6件目を足す**

**見つかったものは、直したものと直さなかったもの（理由つき）を段階4の記録に書いてください。**

---

## 段階5 — `week` の型を `number` にする（月次は `0`。D-072）

★**段階2が緑になってから着手すること。**★ 週の帰属変更とは独立した変更です。

### なぜ変えるのか

**月次の表し方が層ごとに違っていました。**

| 層 | 変更前 | ★変更後★ |
|---|---|---|
| API のクエリ | `period=monthly` + `week` は無視 | ★**変更しない**★ |
| バリデーション | monthly のとき `week` を読み捨てて **`null`** | 読み捨てて ★**`0`**★ |
| 内部の行キー・関数引数 | `week: number \| null` | ★**`week: number`**★ |
| DB（サマリ4テーブル） | `week = 0`（NOT NULL） | 変更なし |

★**「月次＝`week` が 0」が API 境界から DB まで一直線になり、`prismaSource` の変換が消えます。**★

### 直す場所

| ファイル | 変更 |
|---|---|
| `lib/types/index.ts:223` | `week: number \| null` → ★**`week: number`**★ |
| `lib/reports/query.ts` の `parseReportQuery` | monthly のとき `week = null` → ★**`week = 0`**★ |
| `lib/reports/query.ts:274-282` の `matchesPeriod` | ★**条件分岐が不要になる**★。`row.week === query.week` の単純比較にする |
| `lib/format/period.ts` の `periodRange` / `periodLabel` / `isCurrentPeriod` / `shiftPeriod` | 引数を `week: number` に。**`week ?? 1` は `week < 1 ? 1 : week`**、`week === null` の判定は ★**`week < 1`**★ に |
| `lib/fixtures/reports.ts` | 月次の行の `week: null` → ★**`week: 0`**★（2箇所） |
| `components/features/reports/ReportsScreen.tsx` | `setWeek(next.week ?? clampWeek(...))` の `??` が使えなくなる。**`next.week` が 0（monthly）のときにクランプ後の値を入れる**形に直す |
| テスト3ファイル | 月次の `week: null` を `week: 0` に。★**`periodLabel('weekly', y, m, 0)` が「YYYY年M月」を返すこと**★を固定 |

### ★守ること★

| # | 内容 |
|---|---|
| 1 | ★**`WEEK_MIN = 1` は下げない**★。`period=weekly` で `week=0` は **422 のまま**。0 にするのは monthly の読み捨てだけ |
| 2 | ★**`d-watch-api.yaml` は変更しない**★。API 契約は「monthly では `week` を無視」のまま。**クエリで `week=0` を要求する形にしないこと** |
| 3 | ★**`periodLabel` が「第0週」と出さないこと**★。`week < 1` なら月だけのラベルに落とす（既存の `week === null` と同じ挙動） |
| 4 | `periodRange('weekly', y, m, 0)` は ★**null**★（`week < 1` なので不正） |
| 5 | 年次サマリ（`month = 0`）は **P1 では読まない**。`prismaSource` を書くときに `month >= 1` を条件に入れる（この指示書の範囲外だが段階4の記録に書くこと） |

---

## 段階4 — 記録

### `docs/decisions.md`（★D-073 から。着手直前に最終番号を再確認★）

- 段階0の変更（`d-watch-api.yaml` の `Week.maximum` と `description`、`lib/reports/query.ts` の
  `WEEK_MAX`）と、★**これが D-069 の「2. `week` の上限は 5 のまま」を上書きすること**★
- 段階3で見つかった呼び出し側の影響と是正内容（**直さなかったものは理由も**）
- ★**段階5 の型変更（`week: number`、月次は 0）**★と、`prismaSource` を書くときの注意
  （月次は `week=0`、週次は 1〜6、**年次（`month=0`）は読まないので `month >= 1` を条件に入れる**）
- 実測値（`npm test` のファイル数・テスト数 / `npx tsc --noEmit` / `npm run lint`）
- 段階2-6 の「1日しかない週の表示」について、**画面に from〜to が出ているかの調査結果**

### `docs/handoff.md`

**末尾に「## 2026-09-07（追記14）」を追加**してください。追記10〜追記13 に倣うこと。
**冒頭の「最新の追記」ポインタも更新すること**（追記13 → 追記14）。

- 月跨ぎ週の実装が D-071 に追随したこと。
  **追記11 の「直す3箇所」の表は消化済みと明記**（ルール自体の確定は追記12 に記録済み）
- ★**W-1 は解決済み**★。**残る外部待ちは「第5回の回答（X-1 / X-2 / Y-1〜Y-3）」と
  「DB更新用アカウント」**（★2026-09-07 追記13 の一覧を正とすること。W-1 を外すだけにしないこと★）
- 段階3で見つかった影響と是正内容
- 次にやること（追記11 の一覧から W-1 を外したもの）

### `.claude/commands/d-watch.md`

- 「外部待ち」から **W-1 を外す**（→ **DB更新用アカウントのみ**）
- 「最初の報告」で照合させているテスト数と決定番号を**実測値に更新**する
- 「最新の追記」を**追記14**にする

---

## ★踏まないでほしい地雷★

| # | 内容 |
|---|---|
| 1 | ★**期待値表の数値を書き換えないこと**★。2020〜2040 総当たりで検証済みです。矛盾を見つけたら**実装を止めて報告**（`CLAUDE.md` §0-1） |
| 2 | ★**「週＝7日」の算術を新たに書かないこと**★。週の長さは `periodRange` の from〜to から求めます |
| 3 | ★**`weekOfMonth()` 以外に帰属ルールを持たせないこと**★。次にルールが変わったとき直す場所が増えます |
| 4 | ★**テストを緩めて通さないこと**★。特に「+1 と -1 は往復する」と「全日被覆」は新ルールでも成立します。落ちるなら実装が間違っています |
| 5 | **`cwd` のドライブレターは必ず大文字 `C:\`**。小文字だと Vitest が全滅します |
| 6 | ★**`--reporter=basic` は Vitest 4 で削除されています**★。`Failed to load custom Reporter` で起動失敗するのに**終了コードは 0** を返すため、成功と誤認します。`--reporter=dot` か `--reporter=default` を使うこと |
| 7 | **`monthly` の暦月締めは変更しない**（週とは独立。`CLAUDE.md` §4.7） |
| 8 | **タイムゾーン変換をしないこと**。`Date` は「UTC上の日数計算機」として使い、必ず `getUTC*` で読みます |
| 9 | **検証が通ったら即コミットする**（D-050）。Cursor のチェックポイント復元で成果物が消えた事故があります |
| 10 | **決定番号は追記直前に最終番号を確認する**。★2026-09-07 に D-068 が二重に採番される衝突が実際に起きました★ |
| 11 | `weekdays`（`"1111111"` 等）は★**曜日ビットマスクで週番号とは無関係**★。`lib/dashboard/aggregate.ts` / `lib/fixtures/schedules.ts` のこれを触らないこと |

---

## 完了条件

1. `npx tsc --noEmit` エラー0
2. `npm test` 全パス（**期待値表1〜5 と H-1〜H-6 が入っていること**）
3. `npm run lint` 0
4. `d-watch-api.yaml` の `Week.maximum` が 6、`description` が D-071 の説明になっている
5. `lib/reports/query.ts` の `WEEK_MAX` が 6。★**`WEEK_MIN` は 1 のまま**★
   （`week=0` は先方のサマリで「月の集計値」を表す予約値。通してはいけない）
6. `USE_FIXTURES=true npm run dev` で `/reports` が 200、レポートAPI4本が 200
7. ★**期間ナビの ‹ › で `2026年9月 第1週` から `‹` を押すと `2026年8月 第6週` になること**★
   （旧ルールでは 8月第5週 だった。**実際にブラウザで確認すること**）
8. `docs/decisions.md` / `docs/handoff.md` / `.claude/commands/d-watch.md` の更新
