| 項目 | 内容 |
|---|---|
| 実行環境 | 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 がその場集計)で週の定義が変わり、 同じ画面の中で数字の意味が変わります。 合わせる以外の選択肢がありません。
テーブル定義書の転記(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)。
| ファイル | 何が影響するか | 段階 |
|---|---|---|
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)。
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 を直す」に従い、 この段階を最初に行います。
lib/reports/query.ts:61const WEEK_MAX = 6; // ★5 から変更(D-071)★
422 のエラー文言(period=weekly のときは${WEEK_MIN}〜${WEEK_MAX}で指定してください)は テンプレート補間なので自動で 1〜6 になります。 文言を手で書き換えないこと。
★週数は暦から決まる定数で、運用で変える閾値ではありません。★ CLAUDE.md §7 の「閾値のハードコード禁止」の対象外なので、lib/config/ へ出す必要はありません。
この段階を終えた時点でテストは赤くなります。それが正常です。
lib/format/__tests__/period.test.ts★ファイル冒頭コメント(13〜24行目付近)の「帰属は D-067」という前提を先に直すこと。★ 2026年の曜日一覧(2026-08-01 土 / 2026-09-01 火 等)は正しいので残してよいです。
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週の最終日」)。 コメントも必ず直してください。 残すと次の担当者が誤解します。
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週など」に直してください。
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 |
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 の総当たりで確認済み)。 これが落ちるなら実装が間違っています。 テストを緩めて通さないこと。
既存の総当たりテスト(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ケース全部 |
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 のまま★ も差し替えてください。
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 やテストを緩めて通さないこと。
lib/format/period.ts を差し替える置き換える対象は次の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 しか出ないことをテストで固定しています。
★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);
}
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 };
}
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★)。
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週になる
periodLabel() は変更しない(★確定。要判断は解消済み★)"2026年8月 第1週" のままにします。1日しかない週でもラベルは同じです。
★ユーザー判断(D-072): 「第1週が1日しかなくても第1週」。特別な注記は入れません。★ 画面に from〜to を出す変更もしません。 2日しかない第6週(2026-08-30〜08-31)も 第6週 とだけ表示します。文言や画面を変えないこと(モックが文言の正。CLAUDE.md §5)。
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 | 範囲内はそのまま |
★「週は7日」を前提にした算術が残っていると、静かに間違った数字が出ます。★
components/features/reports/ReportsScreen.tsx を確認する。(★調査済み。内容は下記★)
weekOfMonth() 任せで変更不要)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)にクランプされ、数字が出るweek=5 を保持)。 WEEK_MAX を 6 にしても 422 にはなりません(week=6 は上限内を通り、 期間キーが一致せず空KPIになるだけ)。今回まとめて直します。weekStart / weekOfMonth / periodRange / shiftPeriod の 全呼び出し箇所を grep して一覧にする(段階4の記録に載せる)7(日数)を使った算術を探す — * 7、+ 7、DAY_MS * 7、86400000 * 7、/ 7。 ★特に「1日あたりの平均」を合計÷7で出している箇所があれば、 実際の日数(periodRange の from〜to の日数)で割るように直すこと★1 | 2 | 3 | 4 | 5 のようなもの)lib/reports/ の KPI の分母が実データ由来か日数由来かを確認する。 ★実データ由来(清掃予定回数・清掃対象面積など。CLAUDE.md §4.2)なら 週の長さが変わっても壊れません★。日数由来のものがあればそれは壊れるので報告してください第N週 / Week N が5件までしか無いなら6件目を足す見つかったものは、直したものと直さなかったもの(理由つき)を段階4の記録に書いてください。
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の記録に書くこと) |
docs/decisions.md(★D-073 から。着手直前に最終番号を再確認★)d-watch-api.yaml の Week.maximum と description、lib/reports/query.ts の WEEK_MAX)と、★これが D-069 の「2. week の上限は 5 のまま」を上書きすること★week: number、月次は 0)★と、prismaSource を書くときの注意 (月次は week=0、週次は 1〜6、年次(month=0)は読まないので month >= 1 を条件に入れる)npm test のファイル数・テスト数 / npx tsc --noEmit / npm run lint)docs/handoff.md末尾に「## 2026-09-07(追記14)」を追加してください。追記10〜追記13 に倣うこと。 冒頭の「最新の追記」ポインタも更新すること(追記13 → 追記14)。
.claude/commands/d-watch.md| # | 内容 |
|---|---|
| 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 のこれを触らないこと |
npx tsc --noEmit エラー0npm test 全パス(期待値表1〜5 と H-1〜H-6 が入っていること)npm run lint 0d-watch-api.yaml の Week.maximum が 6、description が D-071 の説明になっているlib/reports/query.ts の WEEK_MAX が 6。★WEEK_MIN は 1 のまま★ (week=0 は先方のサマリで「月の集計値」を表す予約値。通してはいけない)USE_FIXTURES=true npm run dev で /reports が 200、レポートAPI4本が 2002026年9月 第1週 から ‹ を押すと 2026年8月 第6週 になること★ (旧ルールでは 8月第5週 だった。実際にブラウザで確認すること)docs/decisions.md / docs/handoff.md / .claude/commands/d-watch.md の更新