Newer
Older
client_dwatch / docs / handoff / 指示書H_月跨ぎ週をTeamWorks方式に統一.md

指示書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行目付近)

    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

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つを追加します。

/** その月の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() を差し替える

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

/**
 * 月跨ぎ週は「月境界で分割する」(★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: ... } までを次に置き換えます。

  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 の分岐は変更しません。その下を次に置き換えます。

  // ★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)★

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

/**
 * 指定年月に存在しない週番号を、その月の最大週へ丸める(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 を保持しますが、 移動先の月にその週が存在するかを検証していません。
    // 変更前: 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 の更新