Newer
Older
client_dwatch / lib / format / period.ts
/**
 * レポート画面の期間(週次 / 月次 / ★年次★)を扱う純粋関数。
 *
 * 週の開始日・第N週・月初月末・当期判定をここに集約します。
 * 仕様の正は `CLAUDE.md` §4.7 と `d-watch-api.yaml` の `Period` / `Year` / `Month` / `Week`。
 *
 * ★タイムゾーン変換をしないこと★
 *   `lib/format/date.ts` の方針(ISO文字列から必要な部分を切り出す)と、
 *   `lib/dashboard/aggregate.ts` の `weekdayIndex` に従います。
 *   `new Date("2026-09-07").getDay()` は実行環境のTZで結果が変わるため使いません。
 *   このファイルの `Date` は「UTC上の日数計算機」としてのみ使い、必ず `getUTC*` で読みます。
 *
 * ★月跨ぎ週は「月境界で分割する」(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 しか出ないことをテストで固定しています。
 *
 * ★年次(yearly)は `month=0` / `week=0` が前提です★(指示書AQ 決定1。2026-09-30)
 *   確定期間のサマリに週・月・年の3粒度が入るようになったため追加しました。
 *   CLAUDE.md §4.7 のとおり、サマリの粒度は `week=0` が月次、`month=0 AND week=0` が年次です。
 *   `monthly` が `week` を無視するのと同じ扱いで、★`yearly` は `month` / `week` を無視します★
 *   (粒度タブを切り替えた直後に前の粒度の値が残っていても画面を落とさないため)。
 *   ★`weekOfMonth()` は年次に関与しません★(月跨ぎ週の帰属ルールの唯一の持ち主。D-071)。
 */

import { todayJst } from "@/lib/format/date";
import type { Period } from "@/lib/types";

const DAY_MS = 86_400_000;
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;

function pad(value: number, length: number): string {
  return String(value).padStart(length, "0");
}

/** YYYY-MM-DD → UTC 0時のミリ秒。形式不正・存在しない日なら null */
function toUtcMs(date: string): number | null {
  const matched = DATE_PATTERN.exec(date);
  if (!matched) return null;
  const year = Number(matched[1]);
  const month = Number(matched[2]);
  const day = Number(matched[3]);
  const ms = Date.UTC(year, month - 1, day);
  const roundTrip = new Date(ms);
  // 2026-02-30 や 2026-13-01 は Date.UTC が繰り上げてしまうので往復で弾く
  if (
    roundTrip.getUTCFullYear() !== year ||
    roundTrip.getUTCMonth() + 1 !== month ||
    roundTrip.getUTCDate() !== day
  ) {
    return null;
  }
  return ms;
}

function toDateString(ms: number): string {
  const d = new Date(ms);
  return `${pad(d.getUTCFullYear(), 4)}-${pad(d.getUTCMonth() + 1, 2)}-${pad(d.getUTCDate(), 2)}`;
}

/**
 * 引数の日付を UTC ミリ秒にする。不正なら例外。
 *
 * `weekOfMonth` は戻り値が nullable でない契約なので、
 * 不正な入力は黙って別の答えを返すより例外で落とします
 * (ここに来る日付はAPI側で検証済み、または内部で組み立てた文字列のはず)。
 */
function requireUtcMs(date: string, caller: string): number {
  const ms = toUtcMs(date);
  if (ms === null) {
    throw new RangeError(`${caller}: 日付は実在する YYYY-MM-DD で指定してください: ${date}`);
  }
  return ms;
}

/** その月の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);
}

/**
 * 月跨ぎ週は「月境界で分割する」(★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 };
}

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

/**
 * period/year/month/week から期間の [開始日, 終了日] を返す(両端含む、YYYY-MM-DD)
 *
 * - ★`yearly` は暦年で締める(`YYYY-01-01` 〜 `YYYY-12-31`)。`month` / `week` は無視する★
 * - `monthly` は暦月で締める(週とは独立。CLAUDE.md §4.7)。`week` は無視する
 * - `weekly` は日曜〜土曜だが★月境界で切る★ので1〜7日。帰属は `weekOfMonth()` に合わせる(★D-071★)
 * - 引数が不正、またはその月に存在しない週(5週しかない月の第6週など)なら null
 * - ★`week` は 0 が「月次」を表す予約値(D-072)。`weekly` で `week < 1` は不正なので null★
 */
export function periodRange(
  period: Period,
  year: number,
  month: number,
  week: number
): { from: string; to: string } | null {
  if (!Number.isInteger(year)) return null;

  // ★年次は `month=0` / `week=0` が前提(CLAUDE.md §4.7)★
  // 下の月チェック(`month < 1` で null)より前に返すこと。month=0 で落ちてしまう。
  // 終端は平年・うるう年とも 12-31 で変わらない(2/29 の有無は範囲の内側の話)。
  if (period === "yearly") {
    return { from: `${pad(year, 4)}-01-01`, to: `${pad(year, 4)}-12-31` };
  }

  if (!Number.isInteger(month)) return null;
  if (month < 1 || month > 12) return null;

  if (period === "monthly") {
    // Date.UTC(year, month, 0) は「翌月の0日」= その月の末日
    const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
    return {
      from: `${pad(year, 4)}-${pad(month, 2)}-01`,
      to: `${pad(year, 4)}-${pad(month, 2)}-${pad(lastDay, 2)}`,
    };
  }

  // ★`week < 1` が「週の指定なし / 月次(0)」。weekly では不正なので null★
  if (!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)}`,
  };
}

/**
 * 要求された期間が「当期(進行中)」か。今日(JST)が期間内なら true
 *
 * @param today ★テスト用★。省略時は `todayJst()`(テストで「今日」を固定できないと検証不能になる)
 */
export function isCurrentPeriod(
  period: Period,
  year: number,
  month: number,
  week: number,
  today?: string
): boolean {
  const range = periodRange(period, year, month, week);
  if (!range) return false;
  const target = today ?? todayJst();
  if (!DATE_PATTERN.test(target)) return false;
  // YYYY-MM-DD は辞書順 = 日付順なので文字列比較で足りる(TZ変換をしない)
  return range.from <= target && target <= range.to;
}

/**
 * 要求された期間が「終わった期間(確定期間)」か。期間の最終日が今日(JST)より前なら true
 *
 * ★当期でも未来でもない、を1本で判定する★(`!isCurrentPeriod()` は未来の期間でも true になる)。
 * 存在しない期間(5週しかない月の第6週など)は false。
 *
 * @param today ★テスト用★。省略時は `todayJst()`
 */
export function isPastPeriod(
  period: Period,
  year: number,
  month: number,
  week: number,
  today?: string
): boolean {
  const range = periodRange(period, year, month, week);
  if (!range) return false;
  const target = today ?? todayJst();
  if (!DATE_PATTERN.test(target)) return false;
  return range.to < target;
}

/** 画面の期間ナビに出すラベル。例: "2026年3月 第4週" / "2026年3月" / ★"2026年"★ */
export function periodLabel(
  period: Period,
  year: number,
  month: number,
  week: number
): string {
  // ★年次は `month=0` / `week=0` なので年だけ。「2026年0月」と出さないこと★
  if (period === "yearly") return `${year}年`;
  const base = `${year}年${month}月`;
  // monthly は week を持たない(★0 が入る。D-072★)。weekly で week < 1 なのは
  // 呼び出し側の状態が未確定なときで、ラベルは表示専用なので月だけのラベルに落として
  // 画面を壊さない。★「第0週」と表示しないこと★
  if (period === "monthly" || week < 1) return base;
  return `${base} 第${week}週`;
}

/**
 * 前期比のラベル。"前週" / "前月" / ★"前年"★
 *
 * ★呼び出し側(`ReportsScreen`)はこの戻り値を `t()` の辞書キーとして使います★。
 * EN表示のために `lib/i18n.ts` へ ★`前年: "Prev Year"`★ が要ります
 * (`前週` / `前月` は既存。`lib/i18n.ts` は担当Aの範囲外なので未追加)。
 */
export function prevPeriodLabel(period: Period): string {
  if (period === "yearly") return "前年";
  return period === "weekly" ? "前週" : "前月";
}

/**
 * 期間ナビの ‹ › で1つ前/後の期間へ
 *
 * - ★`yearly` は年を ±1。`month` / `week` は ★0★ を返す(年次を表す予約値。CLAUDE.md §4.7)★
 * - `monthly` は月を ±1(年を跨ぐ)。`week` は ★0★ を返す(月次を表す予約値。D-072)
 * - `weekly` は隣の週へ移る。★週が7日でないため ±7日ではなく
 *   「その週の前日 / 翌日の帰属」を `weekOfMonth()` で求め直す★(D-071)。
 *   第1週で `-1` なら前月の最終週、最終週で `+1` なら翌月の第1週になる
 */
export function shiftPeriod(
  period: Period,
  year: number,
  month: number,
  week: number,
  delta: -1 | 1
): { year: number; month: number; week: number } {
  // ★年次は `month=0 AND week=0` が粒度の目印なので、渡された month / week は 0 に落とす★
  // (粒度タブを切り替えた直後は前の粒度の値が残っていることがある)
  if (period === "yearly") {
    return { year: year + delta, month: 0, week: 0 };
  }

  if (period === "monthly") {
    const monthIndex = year * 12 + (month - 1) + delta;
    return {
      year: Math.floor(monthIndex / 12),
      month: (((monthIndex % 12) + 12) % 12) + 1,
      // ★月次は week=0(D-072。サマリ4テーブルの「月の集計値」と同じ表し方)★
      week: 0,
    };
  }

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