diff --git a/README.md b/README.md index 5170f718c..1b600fc49 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ npm start | preStepsCount | number | 最初のタスクの前の空白を指定します。 | | locale | string | 月名の言語を指定します。利用可能な形式: ISO 639-2, Java Locale。 | | rtl | boolean | rtl モードを設定します。 | +| workHoursPerDay | number | 実績正規化で使用する 1 日あたりの稼働時間(時間単位)。未指定時は業務時間帯から算出されます。 | +| workdayStartTime | string | 実績正規化で使用する業務開始時刻("HH:mm")。未指定・不正時は "09:00" を使用します。 | +| workdayEndTime | string | 実績正規化で使用する業務終了時刻("HH:mm")。未指定・不正時は "18:00" を使用します。 | | calendar | [CalendarConfig](#calendarconfig) | 稼働日計算と日付表示のカレンダー設定を指定します。未指定の場合は従来の動作を維持します(オプトイン式)。 | ### CalendarConfig diff --git a/src/components/gantt/gantt.tsx b/src/components/gantt/gantt.tsx index dd65dcbc4..0636562b6 100644 --- a/src/components/gantt/gantt.tsx +++ b/src/components/gantt/gantt.tsx @@ -24,6 +24,7 @@ import { HorizontalScroll } from "../other/horizontal-scroll"; import { removeHiddenTasks, sortTasks } from "../../helpers/other-helper"; import { DEFAULT_VISIBLE_FIELDS } from "../../helpers/task-helper"; import { normalizeCalendarConfig } from "../../helpers/calendar-helper"; +import { normalizeActuals } from "../../helpers/actuals-helper"; import styles from "./gantt.module.css"; const DEFAULT_TASK_LIST_WIDTH = 450; @@ -49,6 +50,9 @@ export const Gantt: React.FunctionComponent = ({ preStepsCount = 1, locale = "en-GB", calendar, + workHoursPerDay, + workdayStartTime, + workdayEndTime, barFill = 60, barCornerRadius = 3, barProgressColor = "#a3a3ff", @@ -91,6 +95,19 @@ export const Gantt: React.FunctionComponent = ({ () => (calendar ? normalizeCalendarConfig(calendar) : undefined), [calendar] ); + const actualsOptions = useMemo( + () => ({ + calendarConfig, + workHoursPerDay, + workdayStartTime, + workdayEndTime, + }), + [calendarConfig, workHoursPerDay, workdayStartTime, workdayEndTime] + ); + const normalizedTasks = useMemo( + () => tasks.map(task => normalizeActuals(task, actualsOptions)), + [tasks, actualsOptions] + ); const wrapperRef = useRef(null); const taskListRef = useRef(null); @@ -104,7 +121,11 @@ export const Gantt: React.FunctionComponent = ({ const supportsPointerEvents = typeof window !== "undefined" && "PointerEvent" in window; const [dateSetup, setDateSetup] = useState(() => { - const [startDate, endDate] = ganttDateRange(tasks, viewMode, preStepsCount); + const [startDate, endDate] = ganttDateRange( + normalizedTasks, + viewMode, + preStepsCount + ); return { viewMode, dates: seedDates(startDate, endDate, viewMode) }; }); const [currentViewDate, setCurrentViewDate] = useState( @@ -142,9 +163,9 @@ export const Gantt: React.FunctionComponent = ({ useEffect(() => { let filteredTasks: Task[]; if (onExpanderClick) { - filteredTasks = removeHiddenTasks(tasks); + filteredTasks = removeHiddenTasks(normalizedTasks); } else { - filteredTasks = tasks; + filteredTasks = normalizedTasks; } filteredTasks = filteredTasks.sort(sortTasks); const [startDate, endDate] = ganttDateRange( @@ -183,7 +204,7 @@ export const Gantt: React.FunctionComponent = ({ ) ); }, [ - tasks, + normalizedTasks, viewMode, preStepsCount, rowHeight, @@ -306,9 +327,9 @@ export const Gantt: React.FunctionComponent = ({ if (ganttHeight) { setSvgContainerHeight(ganttHeight + headerHeight); } else { - setSvgContainerHeight(tasks.length * rowHeight + headerHeight); + setSvgContainerHeight(normalizedTasks.length * rowHeight + headerHeight); } - }, [ganttHeight, tasks, headerHeight, rowHeight]); + }, [ganttHeight, normalizedTasks, headerHeight, rowHeight]); useEffect(() => { return () => { @@ -389,7 +410,14 @@ export const Gantt: React.FunctionComponent = ({ } window.removeEventListener("resize", updateLeftScroller); }; - }, [tasks, fontFamily, fontSize, listCellWidth, taskListBodyRef, visibleFields]); + }, [ + normalizedTasks, + fontFamily, + fontSize, + listCellWidth, + taskListBodyRef, + visibleFields, + ]); const handleScrollY = (event: SyntheticEvent) => { if (scrollY !== event.currentTarget.scrollTop && !ignoreScrollLeftRef.current) { @@ -583,7 +611,7 @@ export const Gantt: React.FunctionComponent = ({ const gridProps: GridProps = { columnWidth, svgWidth, - tasks: tasks, + tasks: normalizedTasks, rowHeight, dates: dateSetup.dates, todayColor, @@ -651,6 +679,7 @@ export const Gantt: React.FunctionComponent = ({ onCellCommit, effortDisplayUnit, enableColumnDrag, + actualsOptions, }; return (
diff --git a/src/components/task-list/task-list.tsx b/src/components/task-list/task-list.tsx index 19411143c..f02c8bf32 100644 --- a/src/components/task-list/task-list.tsx +++ b/src/components/task-list/task-list.tsx @@ -15,6 +15,16 @@ import { Task, VisibleField, } from "../../types/public-types"; +import { + ActualsNormalizeOptions, + normalizeActuals, +} from "../../helpers/actuals-helper"; +import { + formatDate, + parseDateFromInput, + sanitizeEffortInput, +} from "../../helpers/task-helper"; +import { ParsedTime, parseTimeString } from "../../helpers/time-helper"; import { OverlayEditor } from "./overlay-editor"; export type EditingTrigger = "dblclick" | "enter" | "key"; @@ -50,6 +60,7 @@ export type TaskListProps = { visibleFields: VisibleField[]; effortDisplayUnit: EffortUnit; tasks: Task[]; + actualsOptions?: ActualsNormalizeOptions; taskListRef: React.RefObject; headerContainerRef?: React.RefObject; bodyContainerRef?: React.RefObject; @@ -93,6 +104,34 @@ export const DEFAULT_MIN_WIDTH = 32; export const getDefaultWidth = (field: VisibleField, rowWidth: string): number => field === "name" ? 140 : Number.parseInt(rowWidth, 10) || 155; +const isValidDate = (value?: Date) => + value instanceof Date && !Number.isNaN(value.getTime()); + +// invalid dates are treated as different to ensure normalization updates propagate +const isSameDate = (a?: Date, b?: Date): boolean => + !!a && !!b && isValidDate(a) && isValidDate(b) && a.getTime() === b.getTime(); + +const applyTimeToDate = ( + date: Date, + sourceDate: Date | undefined, + fallbackTime?: ParsedTime | null +) => { + const next = new Date(date); + if (sourceDate && isValidDate(sourceDate)) { + next.setHours( + sourceDate.getHours(), + sourceDate.getMinutes(), + sourceDate.getSeconds(), + sourceDate.getMilliseconds() + ); + return next; + } + if (fallbackTime) { + next.setHours(fallbackTime.hours, fallbackTime.minutes, 0, 0); + } + return next; +}; + export const TaskList: React.FC = ({ headerHeight, fontFamily, @@ -116,6 +155,7 @@ export const TaskList: React.FC = ({ onUpdateTask, onCellCommit, effortDisplayUnit, + actualsOptions, enableColumnDrag = true, onHorizontalScroll, }) => { @@ -265,6 +305,66 @@ export const TaskList: React.FC = ({ } const rowId = editingState.rowId; const columnId = editingState.columnId; + const task = tasks.find(row => row.id === rowId); + const resolveActualsCommit = () => { + if (!task) { + return null; + } + if (columnId !== "start" && columnId !== "end" && columnId !== "actualEffort") { + return null; + } + let parsedValue: number | Date | undefined; + if (columnId === "actualEffort") { + parsedValue = sanitizeEffortInput(value); + } else { + const parsedDate = parseDateFromInput(value); + if (!parsedDate) { + return null; + } + const sourceDate = columnId === "start" ? task.start : task.end; + const fallbackTime = parseTimeString( + columnId === "start" + ? actualsOptions?.workdayStartTime + : actualsOptions?.workdayEndTime + ); + parsedValue = applyTimeToDate(parsedDate, sourceDate, fallbackTime); + } + if (parsedValue === undefined) { + return null; + } + const invalidEndForRecalc = new Date("invalid"); + const draftTask = { + ...task, + [columnId]: parsedValue, + ...(columnId === "actualEffort" + ? { end: invalidEndForRecalc } + : {}), + } as Task; + const normalized = normalizeActuals(draftTask, actualsOptions ?? {}); + const updatedFields: Partial = {}; + if (!isSameDate(normalized.start, task.start)) { + updatedFields.start = normalized.start; + } + if (!isSameDate(normalized.end, task.end)) { + updatedFields.end = normalized.end; + } + if (normalized.actualEffort !== task.actualEffort) { + updatedFields.actualEffort = normalized.actualEffort; + } + const normalizedValue = + columnId === "actualEffort" + ? normalized.actualEffort !== undefined + ? `${normalized.actualEffort}` + : value + : columnId === "start" + ? formatDate(normalized.start) + : formatDate(normalized.end); + return { + normalizedValue, + updatedFields: Object.keys(updatedFields).length > 0 ? updatedFields : null, + }; + }; + const actualsCommit = resolveActualsCommit(); setEditingState(prev => { if ( prev.mode !== "editing" || @@ -277,7 +377,11 @@ export const TaskList: React.FC = ({ return { ...prev, pending: true, errorMessage: null }; }); try { - await onCellCommit({ rowId, columnId, value, trigger }); + const commitValue = actualsCommit?.normalizedValue ?? value; + await onCellCommit({ rowId, columnId, value: commitValue, trigger }); + if (actualsCommit?.updatedFields && onUpdateTask) { + onUpdateTask(rowId, actualsCommit.updatedFields); + } if (!mountedRef.current) { return; } @@ -324,7 +428,7 @@ export const TaskList: React.FC = ({ }); } }, - [editingState, onCellCommit] + [actualsOptions, editingState, onCellCommit, onUpdateTask, tasks] ); const selectCell = useCallback((rowId: string, columnId: VisibleField) => { diff --git a/src/helpers/actuals-helper.ts b/src/helpers/actuals-helper.ts new file mode 100644 index 000000000..3f3e66784 --- /dev/null +++ b/src/helpers/actuals-helper.ts @@ -0,0 +1,364 @@ +import { Task } from "../types/public-types"; +import { isWorkingDay, NormalizedCalendarConfig } from "./calendar-helper"; +import { parseTimeString } from "./time-helper"; + +export type ActualsNormalizeOptions = { + workHoursPerDay?: number; + workdayStartTime?: string; + workdayEndTime?: string; + calendarConfig?: NormalizedCalendarConfig; +}; + +type WorkdayWindow = { + startMinutes: number; + endMinutes: number; + workMinutesPerDay: number; + breakMinutes: number; +}; + +type ActualsContext = { + window: WorkdayWindow; + calendarConfig?: NormalizedCalendarConfig; +}; + +const DEFAULT_WORKDAY_START_MINUTES = 9 * 60; +const DEFAULT_WORKDAY_END_MINUTES = 18 * 60; +const DEFAULT_BREAK_MINUTES = 60; +const MINUTES_PER_HOUR = 60; + +const emittedWarnings = new Set(); + +const warnOnce = (key: string, message: string): void => { + if (!emittedWarnings.has(key) && typeof console !== "undefined") { + console.warn(message); + emittedWarnings.add(key); + } +}; + +const resolveWorkdayWindow = (options: ActualsNormalizeOptions): WorkdayWindow => { + const parsedStart = parseTimeString(options.workdayStartTime); + const parsedEnd = parseTimeString(options.workdayEndTime); + let startMinutes = + parsedStart !== null + ? parsedStart.hours * MINUTES_PER_HOUR + parsedStart.minutes + : DEFAULT_WORKDAY_START_MINUTES; + let endMinutes = + parsedEnd !== null + ? parsedEnd.hours * MINUTES_PER_HOUR + parsedEnd.minutes + : DEFAULT_WORKDAY_END_MINUTES; + if (endMinutes <= startMinutes) { + startMinutes = DEFAULT_WORKDAY_START_MINUTES; + endMinutes = DEFAULT_WORKDAY_END_MINUTES; + } + const windowMinutes = endMinutes - startMinutes; + const workHoursPerDay = options.workHoursPerDay; + const requestedWorkHours = + workHoursPerDay !== undefined && Number.isFinite(workHoursPerDay) && workHoursPerDay > 0 + ? workHoursPerDay + : undefined; + const defaultWorkMinutesPerDay = + windowMinutes <= DEFAULT_BREAK_MINUTES + ? windowMinutes + : windowMinutes - DEFAULT_BREAK_MINUTES; + let workMinutesPerDay: number; + if (requestedWorkHours !== undefined) { + const requestedMinutes = Math.round(requestedWorkHours * MINUTES_PER_HOUR); + if (requestedMinutes <= 0) { + warnOnce( + `gantt-actuals-workhours-too-small-${requestedWorkHours}`, + `[Gantt Actuals] workHoursPerDay (${requestedWorkHours}h) is too small and rounds to 0 minutes. ` + + `Falling back to the default work minutes per day derived from the workday window.` + ); + workMinutesPerDay = defaultWorkMinutesPerDay; + } else { + workMinutesPerDay = requestedMinutes; + } + } else { + workMinutesPerDay = defaultWorkMinutesPerDay; + } + if (workMinutesPerDay > windowMinutes) { + const windowHours = windowMinutes / MINUTES_PER_HOUR; + warnOnce( + `gantt-actuals-workhours-${requestedWorkHours}-${windowHours}`, + `[Gantt Actuals] workHoursPerDay (${requestedWorkHours}h) exceeds workday window (${windowHours}h). ` + + `Clamping to ${windowHours}h.` + ); + workMinutesPerDay = windowMinutes; + } + const breakMinutes = Math.max(0, windowMinutes - workMinutesPerDay); + return { + startMinutes, + endMinutes, + workMinutesPerDay, + breakMinutes, + }; +}; + +const startOfDay = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), date.getDate()); + +const addDays = (date: Date, days: number) => { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +}; + +const addMinutes = (date: Date, minutes: number) => + new Date(date.getTime() + minutes * 60000); + +const diffMinutes = (end: Date, start: Date) => + (end.getTime() - start.getTime()) / 60000; + +const toDateAtMinutes = (day: Date, minutes: number) => + new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, minutes); + +const buildWorkSegments = ( + day: Date, + window: WorkdayWindow +): Array<{ start: Date; end: Date }> => { + if (window.workMinutesPerDay <= 0) return []; + const dayStart = toDateAtMinutes(day, window.startMinutes); + const dayEnd = toDateAtMinutes(day, window.endMinutes); + if (window.breakMinutes <= 0) { + return [{ start: dayStart, end: dayEnd }]; + } + const beforeBreak = Math.floor(window.workMinutesPerDay / 2); + const afterBreak = window.workMinutesPerDay - beforeBreak; + const breakStart = addMinutes(dayStart, beforeBreak); + const breakEnd = addMinutes(breakStart, window.breakMinutes); + const segments: Array<{ start: Date; end: Date }> = []; + if (beforeBreak > 0) { + segments.push({ start: dayStart, end: breakStart }); + } + if (afterBreak > 0) { + segments.push({ start: breakEnd, end: dayEnd }); + } + return segments; +}; + +const resolveContext = (options: ActualsNormalizeOptions): ActualsContext => ({ + window: resolveWorkdayWindow(options), + calendarConfig: options.calendarConfig, +}); + +const isValidDate = (value?: Date) => + value instanceof Date && !Number.isNaN(value.getTime()); + +const isValidEffort = (value?: number) => + value !== undefined && Number.isFinite(value) && value >= 0; + +export const roundEffortToQuarterHour = ( + effort?: number +): number | undefined => { + if (!isValidEffort(effort)) { + return undefined; + } + const minutes = (effort as number) * MINUTES_PER_HOUR; + const scaled = minutes / 15; + // Guard against floating point precision around half steps. + const roundedMinutes = Math.floor(scaled + 0.5 + Number.EPSILON) * 15; + return roundedMinutes / MINUTES_PER_HOUR; +}; + +const recalcEffort = (start: Date, end: Date, context: ActualsContext) => { + let totalMinutes = 0; + let currentDay = startOfDay(start); + const endTime = end.getTime(); + while (currentDay.getTime() < endTime) { + if ( + !context.calendarConfig || + isWorkingDay(currentDay, context.calendarConfig) + ) { + const segments = buildWorkSegments(currentDay, context.window); + for (const segment of segments) { + const overlapStart = segment.start > start ? segment.start : start; + const overlapEnd = segment.end < end ? segment.end : end; + if (overlapStart < overlapEnd) { + totalMinutes += diffMinutes(overlapEnd, overlapStart); + } + } + } + currentDay = addDays(currentDay, 1); + } + const rounded = roundEffortToQuarterHour(totalMinutes / MINUTES_PER_HOUR); + return rounded ?? 0; +}; + +const deriveEnd = ( + start: Date, + effortHours: number, + context: ActualsContext +) => { + const roundedEffort = roundEffortToQuarterHour(effortHours); + if (roundedEffort === undefined) { + return undefined; + } + let remaining = Math.round(roundedEffort * MINUTES_PER_HOUR); + if (remaining === 0) { + return start; + } + let cursor = new Date(start); + while (remaining > 0) { + const day = startOfDay(cursor); + if ( + context.calendarConfig && + !isWorkingDay(day, context.calendarConfig) + ) { + cursor = toDateAtMinutes(addDays(day, 1), context.window.startMinutes); + continue; + } + const segments = buildWorkSegments(day, context.window); + if (segments.length === 0) { + cursor = toDateAtMinutes(addDays(day, 1), context.window.startMinutes); + continue; + } + for (const segment of segments) { + if (cursor < segment.start) { + cursor = segment.start; + } + if (cursor >= segment.end) { + continue; + } + const available = diffMinutes(segment.end, cursor); + if (remaining <= available) { + return addMinutes(cursor, remaining); + } + remaining -= available; + cursor = segment.end; + } + cursor = toDateAtMinutes(addDays(day, 1), context.window.startMinutes); + } + return cursor; +}; + +const deriveStart = ( + end: Date, + effortHours: number, + context: ActualsContext +) => { + const roundedEffort = roundEffortToQuarterHour(effortHours); + if (roundedEffort === undefined) { + return undefined; + } + let remaining = Math.round(roundedEffort * MINUTES_PER_HOUR); + if (remaining === 0) { + return end; + } + let cursor = new Date(end); + while (remaining > 0) { + const day = startOfDay(cursor); + if ( + context.calendarConfig && + !isWorkingDay(day, context.calendarConfig) + ) { + const prevDay = addDays(day, -1); + cursor = toDateAtMinutes(prevDay, context.window.endMinutes); + continue; + } + const segments = buildWorkSegments(day, context.window).slice().reverse(); + if (segments.length === 0) { + const prevDay = addDays(day, -1); + cursor = toDateAtMinutes(prevDay, context.window.endMinutes); + continue; + } + for (const segment of segments) { + if (cursor > segment.end) { + cursor = segment.end; + } + if (cursor <= segment.start) { + continue; + } + const available = diffMinutes(cursor, segment.start); + if (remaining <= available) { + return addMinutes(cursor, -remaining); + } + remaining -= available; + cursor = segment.start; + } + const prevDay = addDays(day, -1); + cursor = toDateAtMinutes(prevDay, context.window.endMinutes); + } + return cursor; +}; + +export const normalizeActuals = ( + task: Task, + options: ActualsNormalizeOptions = {} +): Task => { + const context = resolveContext(options); + const validStart = isValidDate(task.start) ? task.start : undefined; + const validEnd = isValidDate(task.end) ? task.end : undefined; + const validEffort = isValidEffort(task.actualEffort) + ? (task.actualEffort as number) + : undefined; + const hasValidRange = + validStart && + validEnd && + validStart.getTime() <= validEnd.getTime(); + let nextStart = task.start; + let nextEnd = task.end; + let nextEffort = task.actualEffort; + if (hasValidRange) { + nextEffort = recalcEffort(validStart as Date, validEnd as Date, context); + if (nextEffort !== task.actualEffort) { + console.debug("[Actuals] effort normalized", { + rowId: task.id, + field: "actualEffort", + reason: "start-end", + }); + } + } else if (validStart && validEffort !== undefined) { + const roundedEffort = roundEffortToQuarterHour(validEffort); + const derivedEnd = + roundedEffort === undefined + ? undefined + : deriveEnd(validStart as Date, roundedEffort, context); + if (derivedEnd) { + nextEnd = derivedEnd; + nextEffort = roundedEffort; + console.debug("[Actuals] end derived", { + rowId: task.id, + field: "end", + reason: "start-effort", + }); + } + } else if (validEnd && validEffort !== undefined) { + const roundedEffort = roundEffortToQuarterHour(validEffort); + const derivedStart = + roundedEffort === undefined + ? undefined + : deriveStart(validEnd as Date, roundedEffort, context); + if (derivedStart) { + nextStart = derivedStart; + nextEffort = roundedEffort; + console.debug("[Actuals] start derived", { + rowId: task.id, + field: "start", + reason: "end-effort", + }); + } + } + const nextTask: Task = { + ...task, + start: nextStart, + end: nextEnd, + actualEffort: nextEffort, + }; + const isSameDate = (a?: Date, b?: Date): boolean => { + if (a === b) return true; + if (!a || !b) return false; + const validA = isValidDate(a); + const validB = isValidDate(b); + if (!validA && !validB) { + // Treat two invalid dates as equal to keep normalization idempotent. + return true; + } + if (!validA || !validB) return false; + return a.getTime() === b.getTime(); + }; + const hasChange = + !isSameDate(nextTask.start as Date | undefined, task.start as Date | undefined) || + !isSameDate(nextTask.end as Date | undefined, task.end as Date | undefined) || + nextTask.actualEffort !== task.actualEffort; + return hasChange ? nextTask : task; +}; diff --git a/src/helpers/time-helper.ts b/src/helpers/time-helper.ts new file mode 100644 index 000000000..ad8f6d985 --- /dev/null +++ b/src/helpers/time-helper.ts @@ -0,0 +1,29 @@ +/** + * Parsed time value returned from parseTimeString with validated hours/minutes. + */ +export type ParsedTime = { + hours: number; + minutes: number; +}; + +/** + * Parse "HH:mm" strings into validated hour/minute pairs. + */ +export const parseTimeString = (value?: string): ParsedTime | null => { + if (!value) return null; + const match = value.trim().match(/^(\d{1,2}):(\d{2})$/); + if (!match) return null; + const hours = Number(match[1]); + const minutes = Number(match[2]); + if ( + Number.isNaN(hours) || + Number.isNaN(minutes) || + hours < 0 || + hours > 23 || + minutes < 0 || + minutes > 59 + ) { + return null; + } + return { hours, minutes }; +}; diff --git a/src/test/actuals-helper.test.tsx b/src/test/actuals-helper.test.tsx new file mode 100644 index 000000000..b51621b88 --- /dev/null +++ b/src/test/actuals-helper.test.tsx @@ -0,0 +1,127 @@ +import { normalizeActuals, roundEffortToQuarterHour } from "../helpers/actuals-helper"; +import { Task } from "../types/public-types"; + +const createTask = (overrides: Partial = {}): Task => ({ + id: "task-1", + name: "Task 1", + start: new Date(2026, 0, 6, 9, 0), + end: new Date(2026, 0, 6, 17, 0), + progress: 0, + type: "task", + ...overrides, +}); + +describe("normalizeActuals", () => { + it("recalculates effort from start/end when inconsistent", () => { + const task = createTask({ + start: new Date(2026, 0, 6, 9, 0), + end: new Date(2026, 0, 6, 11, 0), + actualEffort: 1, + }); + const normalized = normalizeActuals(task); + expect(normalized.actualEffort).toBe(2); + }); + + it("derives end from start and effort with rounding", () => { + const task = createTask({ + end: new Date("invalid"), + actualEffort: 3.13, + }); + const normalized = normalizeActuals(task); + expect(normalized.actualEffort).toBe(3.25); + expect(normalized.end.getHours()).toBe(12); + expect(normalized.end.getMinutes()).toBe(15); + }); + + it("derives start from end and effort", () => { + const task = createTask({ + start: new Date("invalid"), + end: new Date(2026, 0, 6, 18, 0), + actualEffort: 2, + }); + const normalized = normalizeActuals(task); + expect(normalized.start.getHours()).toBe(16); + expect(normalized.start.getMinutes()).toBe(0); + }); + + it("is idempotent when applied multiple times", () => { + const task = createTask({ + end: new Date("invalid"), + actualEffort: 1.13, + }); + const normalized = normalizeActuals(task); + const normalizedAgain = normalizeActuals(normalized); + expect(normalizedAgain.end.getTime()).toBe(normalized.end.getTime()); + expect(normalizedAgain.actualEffort).toBe(normalized.actualEffort); + }); + + it("reflects workHoursPerDay differences when deriving end", () => { + const base = { + start: new Date(2026, 0, 5, 9, 0), + end: new Date("invalid"), + actualEffort: 7, + }; + const endFor6 = normalizeActuals(createTask(base), { workHoursPerDay: 6 }).end; + const endFor8 = normalizeActuals(createTask(base), { workHoursPerDay: 8 }).end; + const endFor10 = normalizeActuals(createTask(base), { workHoursPerDay: 10 }).end; + expect(endFor6.getDate()).toBe(6); + expect(endFor6.getHours()).toBe(10); + expect(endFor8.getDate()).toBe(5); + expect(endFor8.getHours()).toBe(17); + expect(endFor10.getDate()).toBe(5); + expect(endFor10.getHours()).toBe(16); + }); + + it("keeps derived end within custom workday window", () => { + const task = createTask({ + start: new Date(2026, 0, 6, 18, 30), + end: new Date("invalid"), + actualEffort: 1, + }); + const normalized = normalizeActuals(task, { + workdayStartTime: "10:00", + workdayEndTime: "19:00", + }); + expect(normalized.end.getDate()).toBe(7); + expect(normalized.end.getHours()).toBe(10); + expect(normalized.end.getMinutes()).toBe(30); + }); +}); + +describe("roundEffortToQuarterHour", () => { + it("rounds to 0.25h with round-half-up", () => { + expect(roundEffortToQuarterHour(1.12)).toBe(1); + expect(roundEffortToQuarterHour(1.13)).toBe(1.25); + expect(roundEffortToQuarterHour(1.37)).toBe(1.25); + expect(roundEffortToQuarterHour(1.38)).toBe(1.5); + expect(roundEffortToQuarterHour(1.124)).toBe(1); + expect(roundEffortToQuarterHour(1.125)).toBe(1.25); + expect(roundEffortToQuarterHour(1.126)).toBe(1.25); + }); +}); + +describe("normalizeActuals warnings", () => { + it("warns once when workHoursPerDay exceeds window", () => { + jest.isolateModules(() => { + const { normalizeActuals: normalize } = + require("../helpers/actuals-helper") as typeof import("../helpers/actuals-helper"); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + try { + const task: Task = { + id: "task-2", + name: "Task 2", + start: new Date(2026, 0, 6, 9, 0), + end: new Date(2026, 0, 6, 18, 0), + progress: 0, + type: "task", + actualEffort: 4, + }; + normalize(task, { workHoursPerDay: 12 }); + normalize(task, { workHoursPerDay: 12 }); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); + }); +}); diff --git a/src/test/task-list-commit.test.tsx b/src/test/task-list-commit.test.tsx index 55d5fcaec..486af7ed3 100644 --- a/src/test/task-list-commit.test.tsx +++ b/src/test/task-list-commit.test.tsx @@ -16,23 +16,46 @@ const MockTaskListTable: React.FC = () => { > Start + +
Task 1
+
+ 2026-01-01 +
+
+ 1 +
); }; -const createTask = (): Task => ({ +const createTask = (overrides: Partial = {}): Task => ({ id: "task-1", name: "Task 1", - start: new Date(2026, 0, 1), - end: new Date(2026, 0, 2), + start: new Date(2026, 0, 1, 9, 0), + end: new Date(2026, 0, 1, 10, 0), progress: 0, type: "task", + ...overrides, }); -const renderTaskList = (onCellCommit: jest.Mock) => { +const renderTaskList = ( + onCellCommit: jest.Mock, + onUpdateTask?: jest.Mock, + tasks: Task[] = [createTask()] +) => { render( { scrollY={0} visibleFields={["name"] as VisibleField[]} effortDisplayUnit="MH" - tasks={[createTask()]} + tasks={tasks} taskListRef={React.createRef()} selectedTask={undefined} setSelectedTask={jest.fn()} onExpanderClick={jest.fn()} TaskListHeader={MockTaskListHeader} TaskListTable={MockTaskListTable} + onUpdateTask={onUpdateTask} onCellCommit={onCellCommit} /> ); @@ -119,4 +143,55 @@ describe("TaskList onCellCommit", () => { expect(screen.queryByTestId("overlay-editor")).toBeNull() ); }); + + it("normalizes actual effort commits and updates derived end", async () => { + const onCellCommit = jest.fn().mockResolvedValue(undefined); + const onUpdateTask = jest.fn(); + renderTaskList(onCellCommit, onUpdateTask, [ + createTask({ + start: new Date(2026, 0, 1, 9, 0), + end: new Date("invalid"), // ensure invalid dates are treated as changed + actualEffort: 1, + }), + ]); + fireEvent.click(screen.getByTestId("start-edit-effort")); + + const input = await screen.findByTestId("overlay-editor-input"); + fireEvent.change(input, { target: { value: "4.13" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + await waitFor(() => expect(onCellCommit).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onUpdateTask).toHaveBeenCalledTimes(1)); + const commitPayload = onCellCommit.mock.calls[0][0]; + expect(commitPayload.value).toBe("4.25"); + const update = onUpdateTask.mock.calls[0][1] as Partial; + expect(update.actualEffort).toBe(4.25); + expect(update.end).toBeInstanceOf(Date); + expect((update.end as Date).getHours()).toBe(14); + expect((update.end as Date).getMinutes()).toBe(15); + }); + + it("keeps time portion when editing start date", async () => { + const onCellCommit = jest.fn().mockResolvedValue(undefined); + const onUpdateTask = jest.fn(); + renderTaskList(onCellCommit, onUpdateTask, [ + createTask({ + start: new Date(2026, 0, 1, 13, 30), + end: new Date(2026, 0, 2, 18, 0), + actualEffort: 8, + }), + ]); + + fireEvent.click(screen.getByTestId("start-edit-start")); + const input = await screen.findByTestId("overlay-editor-input"); + fireEvent.change(input, { target: { value: "2026-01-02" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + await waitFor(() => expect(onCellCommit).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onUpdateTask).toHaveBeenCalledTimes(1)); + const update = onUpdateTask.mock.calls[0][1] as Partial; + expect(update.start).toBeInstanceOf(Date); + expect((update.start as Date).getHours()).toBe(13); + expect((update.start as Date).getMinutes()).toBe(30); + }); }); diff --git a/src/test/task-model.test.tsx b/src/test/task-model.test.tsx index 1c8c20d23..e0d6ba8f4 100644 --- a/src/test/task-model.test.tsx +++ b/src/test/task-model.test.tsx @@ -40,7 +40,7 @@ describe("Task data model extensions", () => { expect(screen.getAllByText("2026-01-01")).toHaveLength(2); expect(screen.getAllByText("2026-01-03")).toHaveLength(1); expect(screen.getAllByText("16MH")).not.toHaveLength(0); - expect(screen.getAllByText("8MH")).not.toHaveLength(0); + expect(screen.getAllByText("32MH")).not.toHaveLength(0); expect(screen.getByText("進行中")).toBeInTheDocument(); }); @@ -73,4 +73,27 @@ describe("Task data model extensions", () => { expect(serialized.plannedEnd).toBeDefined(); expect(TASK_STATUS_OPTIONS).toContain(serialized.status); }); + + it("normalizes actual effort before initial render", () => { + const task: Task = { + id: "Task-2", + name: "Actuals Task", + start: new Date(2026, 0, 1, 9, 0), + end: new Date(2026, 0, 1, 11, 0), + progress: 0, + type: "task", + actualEffort: 1, + }; + render( + {}} + listCellWidth="140px" + effortDisplayUnit="MH" + /> + ); + + expect(screen.getByText("2MH")).toBeInTheDocument(); + }); }); diff --git a/src/types/public-types.ts b/src/types/public-types.ts index 8b3e31bb1..854b91ed8 100644 --- a/src/types/public-types.ts +++ b/src/types/public-types.ts @@ -174,6 +174,18 @@ export interface DisplayOption { */ locale?: string; rtl?: boolean; + /** + * Working hours per day used for actuals normalization. + */ + workHoursPerDay?: number; + /** + * Workday start time in "HH:mm" format for actuals normalization. + */ + workdayStartTime?: string; + /** + * Workday end time in "HH:mm" format for actuals normalization. + */ + workdayEndTime?: string; /** * Calendar configuration for working day calculation and date display. * If not specified, no calendar customization is applied and