diff --git a/src/components/features/Shift/ShiftForm/__mocks__/storyData.tsx b/src/components/features/Shift/ShiftForm/__mocks__/storyData.tsx index 70724abb..4f38936c 100644 --- a/src/components/features/Shift/ShiftForm/__mocks__/storyData.tsx +++ b/src/components/features/Shift/ShiftForm/__mocks__/storyData.tsx @@ -26,6 +26,24 @@ export const mockDates = [ "2026-01-27", ]; +// 水曜開始 2 週間(月曜起算で先頭 月火 / 末尾 火〜日 が期間外) +export const mockDatesMidWeekStart = [ + "2026-01-21", + "2026-01-22", + "2026-01-23", + "2026-01-24", + "2026-01-25", + "2026-01-26", + "2026-01-27", + "2026-01-28", + "2026-01-29", + "2026-01-30", + "2026-01-31", + "2026-02-01", + "2026-02-02", + "2026-02-03", +]; + export const mockTimeRange: TimeRange = { start: 9, end: 22, unit: 30 }; export const mockHolidays = ["2026-02-11"]; diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.stories.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.stories.tsx index ac52eb6a..b9bd607a 100644 --- a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.stories.tsx +++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { JotaiStoryWrapper, mockHolidays } from "../../__mocks__/storyData"; +import { JotaiStoryWrapper, mockDatesMidWeekStart, mockHolidays } from "../../__mocks__/storyData"; import { OverviewView } from "."; const meta = { @@ -43,3 +43,21 @@ export const WithHolidays: Story = { ), }; + +// 水曜開始 2 週間。月曜起算で先頭の月火と末尾の火〜日が期間外セルになる +export const MidWeekStart: Story = { + render: () => ( + + + + ), +}; + +// 日曜起算で同じ期間を表示するケース +export const SundayStart: Story = { + render: () => ( + + + + ), +}; diff --git a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx index 17d9159a..4d671edc 100644 --- a/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx +++ b/src/components/features/Shift/ShiftForm/pc/OverviewView/index.tsx @@ -5,26 +5,25 @@ import { useCallback, useMemo, useState } from "react"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { selectedDateAtom, shiftConfigAtom, shiftsAtom, viewModeAtom } from "../../stores"; import type { ShiftData, StaffType } from "../../types"; -import { getWeekdayLabel } from "../../utils/dateUtils"; +import { buildWeeklyGrid, getWeekdayLabel, type WeekStart } from "../../utils/dateUtils"; import { timeToMinutes } from "../../utils/timeConversion"; type DateInfo = { iso: string; label: string; wk: string; - weekIdx: number; + inRange: boolean; }; -const buildDateInfos = (dates: string[]): DateInfo[] => - dates.map((iso, i) => { - const d = dayjs(iso); - return { - iso, - label: `${d.month() + 1}/${d.date()}`, - wk: getWeekdayLabel(iso), - weekIdx: Math.floor(i / 7), - }; - }); +const toDateInfo = (cell: { iso: string; inRange: boolean }): DateInfo => { + const d = dayjs(cell.iso); + return { + iso: cell.iso, + label: `${d.month() + 1}/${d.date()}`, + wk: getWeekdayLabel(cell.iso), + inRange: cell.inRange, + }; +}; const dayColor = (iso: string, holidays: string[]): string => { const day = dayjs(iso).day(); @@ -45,22 +44,23 @@ const shiftHours = (range: [string, string] | null): number => { return minutes / 60; }; -export const OverviewView = () => { +type OverviewViewProps = { + weekStart?: WeekStart; +}; + +export const OverviewView = ({ weekStart = "mon" }: OverviewViewProps) => { const config = useAtomValue(shiftConfigAtom); const shifts = useAtomValue(shiftsAtom); const setSelectedDate = useSetAtom(selectedDateAtom); const setViewMode = useSetAtom(viewModeAtom); const { dates, holidays, isReadOnly, staffs } = config; - const dateInfos = useMemo(() => buildDateInfos(dates), [dates]); - const weekCount = Math.max(1, Math.ceil(dateInfos.length / 7)); + const weeks = useMemo( + () => buildWeeklyGrid(dates, weekStart).map((week) => week.map(toDateInfo)), + [dates, weekStart], + ); - const initialOpen = useMemo(() => { - const o: Record = {}; - for (let i = 0; i < weekCount; i++) o[i] = true; - return o; - }, [weekCount]); - const [open, setOpen] = useState(initialOpen); + const [open, setOpen] = useState>({}); const lookup = useMemo(() => { const map = new Map(); @@ -83,13 +83,12 @@ export const OverviewView = () => { return ( - {Array.from({ length: weekCount }).map((_, wi) => { - const wkDates = dateInfos.filter((d) => d.weekIdx === wi); + {weeks.map((wkDates, wi) => { if (wkDates.length === 0) return null; - const isOpen = !!open[wi]; + const isOpen = open[wi] !== false; return ( ( - - { + const inRangeDates = wkDates.filter((d) => d.inRange); + const rangeLabel = + inRangeDates.length > 0 ? `${inRangeDates[0].label} – ${inRangeDates[inRangeDates.length - 1].label}` : ""; + return ( + - {isOpen ? : } + + {isOpen ? : } + + + {rangeLabel} + + + ({inRangeDates.length}日) + - - {wkDates[0].label} – {wkDates[wkDates.length - 1].label} - - - ({wkDates.length}日) - - - {isOpen && ( - - )} - -); + {isOpen && ( + + )} + + ); +}; type WeekTableProps = { staffs: StaffType[]; @@ -204,26 +208,30 @@ const WeekTable = ({ staffs, wkDates, lookup, holidays, onDateClick, isReadOnly > スタッフ - {wkDates.map((d) => ( - onDateClick(d.iso)} - style={{ - padding: "10px 4px", - fontWeight: 600, - textAlign: "center", - cursor: isReadOnly ? "default" : "pointer", - }} - > - - {d.label} - - - {d.wk} + {wkDates.map((d) => { + const isClickable = !isReadOnly && d.inRange; + return ( + onDateClick(d.iso) : undefined} + style={{ + padding: "10px 4px", + fontWeight: 600, + textAlign: "center", + cursor: isClickable ? "pointer" : "default", + opacity: d.inRange ? 1 : 0.35, + }} + > + + {d.label} + + + {d.wk} + - - ))} + ); + })} {wkDates.map((d) => { - const asn = lookup.get(`${s.id}-${d.iso}`) ?? null; + const asn = d.inRange ? (lookup.get(`${s.id}-${d.iso}`) ?? null) : null; if (asn) total += shiftHours(asn); return ( @@ -272,7 +280,7 @@ const WeekTable = ({ staffs, wkDates, lookup, holidays, onDateClick, isReadOnly {asn[0]}–{asn[1]} ) : ( - + )} diff --git a/src/components/features/Shift/ShiftForm/utils/dateUtils.test.ts b/src/components/features/Shift/ShiftForm/utils/dateUtils.test.ts new file mode 100644 index 00000000..e9c13060 --- /dev/null +++ b/src/components/features/Shift/ShiftForm/utils/dateUtils.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "vitest"; +import { buildWeeklyGrid, getWeekStartDate } from "./dateUtils"; + +describe("getWeekStartDate", () => { + test("月曜起算(デフォルト)で水曜日の週開始日は直前の月曜日", () => { + // 2026-01-21 は水曜 + expect(getWeekStartDate("2026-01-21")).toBe("2026-01-19"); + }); + + test("月曜起算で月曜日の週開始日は当日", () => { + expect(getWeekStartDate("2026-01-19", "mon")).toBe("2026-01-19"); + }); + + test("月曜起算で日曜日の週開始日は直前の月曜日", () => { + // 2026-01-25 は日曜 + expect(getWeekStartDate("2026-01-25", "mon")).toBe("2026-01-19"); + }); + + test("日曜起算で水曜日の週開始日は直前の日曜日", () => { + expect(getWeekStartDate("2026-01-21", "sun")).toBe("2026-01-18"); + }); + + test("日曜起算で日曜日の週開始日は当日", () => { + expect(getWeekStartDate("2026-01-25", "sun")).toBe("2026-01-25"); + }); +}); + +describe("buildWeeklyGrid", () => { + test("月曜開始 7 日は 1 週・全て期間内", () => { + const dates = ["2026-01-19", "2026-01-20", "2026-01-21", "2026-01-22", "2026-01-23", "2026-01-24", "2026-01-25"]; + const grid = buildWeeklyGrid(dates); + expect(grid).toHaveLength(1); + expect(grid[0]).toHaveLength(7); + expect(grid[0].every((cell) => cell.inRange)).toBe(true); + expect(grid[0][0].iso).toBe("2026-01-19"); + expect(grid[0][6].iso).toBe("2026-01-25"); + }); + + test("水曜開始 14 日は 3 週・先頭月火と末尾火〜日が期間外(月曜起算)", () => { + const dates = Array.from({ length: 14 }, (_, i) => new Date(Date.UTC(2026, 0, 21 + i)).toISOString().slice(0, 10)); + const grid = buildWeeklyGrid(dates); + expect(grid).toHaveLength(3); + + // 週1: 月火が期間外、水〜日が期間内 + expect(grid[0][0]).toEqual({ iso: "2026-01-19", inRange: false }); + expect(grid[0][1]).toEqual({ iso: "2026-01-20", inRange: false }); + expect(grid[0][2]).toEqual({ iso: "2026-01-21", inRange: true }); + expect(grid[0][6]).toEqual({ iso: "2026-01-25", inRange: true }); + + // 週2: 全て期間内 + expect(grid[1].every((c) => c.inRange)).toBe(true); + expect(grid[1][0].iso).toBe("2026-01-26"); + + // 週3: 月火が期間内、水〜日が期間外 + expect(grid[2][0]).toEqual({ iso: "2026-02-02", inRange: true }); + expect(grid[2][1]).toEqual({ iso: "2026-02-03", inRange: true }); + expect(grid[2][2]).toEqual({ iso: "2026-02-04", inRange: false }); + expect(grid[2][6]).toEqual({ iso: "2026-02-08", inRange: false }); + }); + + test("日曜開始 7 日は月曜起算で 2 週・先頭月〜土が期間外、末尾日のみ期間内", () => { + // 2026-01-25 は日曜、2026-01-31 は土曜 + const dates = ["2026-01-25", "2026-01-26", "2026-01-27", "2026-01-28", "2026-01-29", "2026-01-30", "2026-01-31"]; + const grid = buildWeeklyGrid(dates); + expect(grid).toHaveLength(2); + + // 週1: 月〜土が期間外、日のみ期間内 + expect(grid[0].slice(0, 6).every((c) => !c.inRange)).toBe(true); + expect(grid[0][6]).toEqual({ iso: "2026-01-25", inRange: true }); + + // 週2: 月〜土が期間内、日が期間外 + expect(grid[1].slice(0, 6).every((c) => c.inRange)).toBe(true); + expect(grid[1][6]).toEqual({ iso: "2026-02-01", inRange: false }); + }); + + test("金曜開始 3 日は 1 週内・前後が期間外(月曜起算)", () => { + // 2026-01-23 金、24 土、25 日 + const dates = ["2026-01-23", "2026-01-24", "2026-01-25"]; + const grid = buildWeeklyGrid(dates); + expect(grid).toHaveLength(1); + expect(grid[0].map((c) => c.inRange)).toEqual([false, false, false, false, true, true, true]); + expect(grid[0][0].iso).toBe("2026-01-19"); + expect(grid[0][6].iso).toBe("2026-01-25"); + }); + + test("日曜起算で日曜開始 7 日は 1 週・全て期間内", () => { + const dates = ["2026-01-25", "2026-01-26", "2026-01-27", "2026-01-28", "2026-01-29", "2026-01-30", "2026-01-31"]; + const grid = buildWeeklyGrid(dates, "sun"); + expect(grid).toHaveLength(1); + expect(grid[0][0].iso).toBe("2026-01-25"); + expect(grid[0][6].iso).toBe("2026-01-31"); + expect(grid[0].every((c) => c.inRange)).toBe(true); + }); + + test("日曜起算で水曜開始 14 日は先頭日〜火が期間外", () => { + const dates = Array.from({ length: 14 }, (_, i) => new Date(Date.UTC(2026, 0, 21 + i)).toISOString().slice(0, 10)); + const grid = buildWeeklyGrid(dates, "sun"); + expect(grid).toHaveLength(3); + // 週1 は 2026-01-18 (日) 開始 + expect(grid[0][0]).toEqual({ iso: "2026-01-18", inRange: false }); + expect(grid[0][1]).toEqual({ iso: "2026-01-19", inRange: false }); + expect(grid[0][2]).toEqual({ iso: "2026-01-20", inRange: false }); + expect(grid[0][3]).toEqual({ iso: "2026-01-21", inRange: true }); + }); + + test("空配列を渡すと空配列を返す", () => { + expect(buildWeeklyGrid([])).toEqual([]); + }); +}); diff --git a/src/components/features/Shift/ShiftForm/utils/dateUtils.ts b/src/components/features/Shift/ShiftForm/utils/dateUtils.ts index 667bfaff..fa5e9380 100644 --- a/src/components/features/Shift/ShiftForm/utils/dateUtils.ts +++ b/src/components/features/Shift/ShiftForm/utils/dateUtils.ts @@ -78,3 +78,40 @@ export const getMonthKey = (date: string): string => { export const formatDateTime = (date: Date): string => { return dayjs(date).format("YYYY/M/D HH:mm"); }; + +// 週開始曜日 +export type WeekStart = "mon" | "sun"; + +// 週開始曜日から見たオフセット(0=週頭)を返す +const weekdayOffset = (date: string, weekStart: WeekStart): number => { + const day = dayjs(date).day(); // 0=日, 1=月, ..., 6=土 + return weekStart === "mon" ? (day + 6) % 7 : day; +}; + +// 指定日を含む週の「週開始日」ISO を返す +export const getWeekStartDate = (date: string, weekStart: WeekStart = "mon"): string => { + return dayjs(date).subtract(weekdayOffset(date, weekStart), "day").format("YYYY-MM-DD"); +}; + +// 期間 dates を 7 × N の週グリッドに変換 +// 返値: 各要素は { iso: "YYYY-MM-DD", inRange: boolean }。期間外セルも日付を持つ +export const buildWeeklyGrid = ( + dates: string[], + weekStart: WeekStart = "mon", +): Array> => { + if (dates.length === 0) return []; + const inRangeSet = new Set(dates); + const gridStart = dayjs(getWeekStartDate(dates[0], weekStart)); + const lastDate = dates[dates.length - 1]; + const gridEnd = dayjs(getWeekStartDate(lastDate, weekStart)).add(6, "day"); + const totalDays = gridEnd.diff(gridStart, "day") + 1; + const weeks: Array> = []; + for (let i = 0; i < totalDays; i += 7) { + const week = Array.from({ length: 7 }, (_, j) => { + const iso = gridStart.add(i + j, "day").format("YYYY-MM-DD"); + return { iso, inRange: inRangeSet.has(iso) }; + }); + weeks.push(week); + } + return weeks; +};