From 85e3e952bf6cd25f8c1190f20d06be0a96b8b3a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:59:55 +0000 Subject: [PATCH 01/17] Initial plan From 3253202fe9e524fbbeaa1a2c82e5fbc5829daef3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 05:10:49 +0000 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=E5=AE=9F=E7=B8=BE=E3=83=90?= =?UTF-8?q?=E3=83=BC=E6=AD=A3=E8=A6=8F=E5=8C=96=E3=81=AE=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=E6=BA=96=E5=82=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装計画を整理し、正規化ロジックとUI統合の作業順を明確化する。 仕様準拠の最小差分で進めるためのチェックリストを提示する。 Co-authored-by: LevelCapTech <99854263+LevelCapTech@users.noreply.github.com> --- package-lock.json | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 72e739fa7..20a169beb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -755,6 +756,7 @@ "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -1696,6 +1698,7 @@ "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", @@ -2526,6 +2529,7 @@ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -4224,6 +4228,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4441,6 +4446,7 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -4897,6 +4903,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5004,6 +5011,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6120,6 +6128,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9362,6 +9371,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -12695,6 +12705,7 @@ "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -13942,6 +13953,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -15993,6 +16005,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -18385,6 +18398,7 @@ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -19064,6 +19078,7 @@ "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -19669,6 +19684,7 @@ "integrity": "sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/node": "*", @@ -20290,7 +20306,6 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -20321,6 +20336,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21826,24 +21842,6 @@ } } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -22089,6 +22087,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -22297,6 +22296,7 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -22412,6 +22412,7 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22792,6 +22793,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -22865,6 +22867,7 @@ "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -23341,6 +23344,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -23409,6 +23413,7 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, From 0e68c09f3f206ba7ad275d7cf5852e158b3920f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 05:25:20 +0000 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=E5=AE=9F=E7=B8=BE=E3=83=90?= =?UTF-8?q?=E3=83=BC=E3=81=AE=E6=AD=A3=E8=A6=8F=E5=8C=96=E3=83=AD=E3=82=B8?= =?UTF-8?q?=E3=83=83=E3=82=AF=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start/end 優先の正規化と編集時の派生更新を追加し、0.25h丸めと稼働時間設定を反映する。 受入条件を満たすテストとドキュメント更新を合わせて実施する。 Co-authored-by: LevelCapTech <99854263+LevelCapTech@users.noreply.github.com> --- README.md | 3 + package-lock.json | 43 ++- src/components/gantt/gantt.tsx | 45 +++- src/components/task-list/task-list.tsx | 66 ++++- src/helpers/actuals-helper.ts | 349 +++++++++++++++++++++++++ src/test/actuals-helper.test.tsx | 127 +++++++++ src/test/task-list-commit.test.tsx | 50 +++- src/test/task-model.test.tsx | 25 +- src/types/public-types.ts | 12 + 9 files changed, 680 insertions(+), 40 deletions(-) create mode 100644 src/helpers/actuals-helper.ts create mode 100644 src/test/actuals-helper.test.tsx 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/package-lock.json b/package-lock.json index 20a169beb..72e739fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,7 +95,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -756,7 +755,6 @@ "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, @@ -1698,7 +1696,6 @@ "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-module-imports": "^7.28.6", @@ -2529,7 +2526,6 @@ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -4228,7 +4224,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4446,7 +4441,6 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -4903,7 +4897,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5011,7 +5004,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6128,7 +6120,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9371,7 +9362,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -12705,7 +12695,6 @@ "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -13953,7 +13942,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -16005,7 +15993,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -18398,7 +18385,6 @@ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -19078,7 +19064,6 @@ "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -19684,7 +19669,6 @@ "integrity": "sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/node": "*", @@ -20306,6 +20290,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -20336,7 +20321,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21842,6 +21826,24 @@ } } }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -22087,7 +22089,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -22296,7 +22297,6 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -22412,7 +22412,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22793,7 +22792,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -22867,7 +22865,6 @@ "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -23344,7 +23341,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -23413,7 +23409,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, 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..2142c77fb 100644 --- a/src/components/task-list/task-list.tsx +++ b/src/components/task-list/task-list.tsx @@ -15,6 +15,15 @@ import { Task, VisibleField, } from "../../types/public-types"; +import { + ActualsNormalizeOptions, + normalizeActuals, +} from "../../helpers/actuals-helper"; +import { + formatDate, + parseDateFromInput, + sanitizeEffortInput, +} from "../../helpers/task-helper"; import { OverlayEditor } from "./overlay-editor"; export type EditingTrigger = "dblclick" | "enter" | "key"; @@ -50,6 +59,7 @@ export type TaskListProps = { visibleFields: VisibleField[]; effortDisplayUnit: EffortUnit; tasks: Task[]; + actualsOptions?: ActualsNormalizeOptions; taskListRef: React.RefObject; headerContainerRef?: React.RefObject; bodyContainerRef?: React.RefObject; @@ -116,6 +126,7 @@ export const TaskList: React.FC = ({ onUpdateTask, onCellCommit, effortDisplayUnit, + actualsOptions, enableColumnDrag = true, onHorizontalScroll, }) => { @@ -265,6 +276,53 @@ 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; + } + const parsedValue = + columnId === "actualEffort" + ? sanitizeEffortInput(value) + : parseDateFromInput(value); + if (parsedValue === undefined) { + return null; + } + const draftTask = { + ...task, + [columnId]: parsedValue, + ...(columnId === "actualEffort" + ? { end: new Date("invalid") } + : {}), + } as Task; + const normalized = normalizeActuals(draftTask, actualsOptions ?? {}); + const updatedFields: Partial = {}; + if (normalized.start.getTime() !== task.start.getTime()) { + updatedFields.start = normalized.start; + } + if (normalized.end.getTime() !== task.end.getTime()) { + 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 +335,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 +386,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..88e7d83bd --- /dev/null +++ b/src/helpers/actuals-helper.ts @@ -0,0 +1,349 @@ +import { Task } from "../types/public-types"; +import { isWorkingDay, NormalizedCalendarConfig } from "./calendar-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 parseTimeToMinutes = (value?: string): number | 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_PER_HOUR + minutes; +}; + +const resolveWorkdayWindow = (options: ActualsNormalizeOptions): WorkdayWindow => { + const parsedStart = parseTimeToMinutes(options.workdayStartTime); + const parsedEnd = parseTimeToMinutes(options.workdayEndTime); + let startMinutes = parsedStart ?? DEFAULT_WORKDAY_START_MINUTES; + let endMinutes = parsedEnd ?? 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; + let workMinutesPerDay = + requestedWorkHours !== undefined + ? Math.round(requestedWorkHours * MINUTES_PER_HOUR) + : windowMinutes <= DEFAULT_BREAK_MINUTES + ? windowMinutes + : windowMinutes - DEFAULT_BREAK_MINUTES; + 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; + 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 hasChange = + nextTask.start.getTime() !== task.start.getTime() || + nextTask.end.getTime() !== task.end.getTime() || + nextTask.actualEffort !== task.actualEffort; + return hasChange ? nextTask : task; +}; 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..a7748a73b 100644 --- a/src/test/task-list-commit.test.tsx +++ b/src/test/task-list-commit.test.tsx @@ -16,23 +16,37 @@ const MockTaskListTable: React.FC = () => { > Start +
Task 1
+
+ 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 +134,29 @@ 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(2026, 0, 1, 10, 0), + actualEffort: 1, + }), + ]); + fireEvent.click(screen.getByTestId("start-edit-effort")); + + const input = await screen.findByTestId("overlay-editor-input"); + fireEvent.change(input, { target: { value: "4" } }); + 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.actualEffort).toBe(4); + expect(update.end).toBeInstanceOf(Date); + expect((update.end as Date).getHours()).toBe(13); + expect((update.end as Date).getMinutes()).toBe(0); + }); }); 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 From bced832025f322d6412b4cdf6b00cf01e6ef13e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 05:29:04 +0000 Subject: [PATCH 04/17] =?UTF-8?q?fix:=20=E6=AD=A3=E8=A6=8F=E5=8C=96?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=81=B8?= =?UTF-8?q?=E3=81=AE=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 丸め処理の説明追加とコミット時の丸めテスト更新で意図を明確化する。 無効日時の扱いを命名で補足し、レビュー指摘を解消する。 Co-authored-by: LevelCapTech <99854263+LevelCapTech@users.noreply.github.com> --- src/components/task-list/task-list.tsx | 3 ++- src/helpers/actuals-helper.ts | 1 + src/test/task-list-commit.test.tsx | 10 ++++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/task-list/task-list.tsx b/src/components/task-list/task-list.tsx index 2142c77fb..1d37bc862 100644 --- a/src/components/task-list/task-list.tsx +++ b/src/components/task-list/task-list.tsx @@ -291,11 +291,12 @@ export const TaskList: React.FC = ({ if (parsedValue === undefined) { return null; } + const invalidEndForRecalc = new Date("invalid"); const draftTask = { ...task, [columnId]: parsedValue, ...(columnId === "actualEffort" - ? { end: new Date("invalid") } + ? { end: invalidEndForRecalc } : {}), } as Task; const normalized = normalizeActuals(draftTask, actualsOptions ?? {}); diff --git a/src/helpers/actuals-helper.ts b/src/helpers/actuals-helper.ts index 88e7d83bd..e7f6ad920 100644 --- a/src/helpers/actuals-helper.ts +++ b/src/helpers/actuals-helper.ts @@ -153,6 +153,7 @@ export const roundEffortToQuarterHour = ( } 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; }; diff --git a/src/test/task-list-commit.test.tsx b/src/test/task-list-commit.test.tsx index a7748a73b..b48de13f4 100644 --- a/src/test/task-list-commit.test.tsx +++ b/src/test/task-list-commit.test.tsx @@ -148,15 +148,17 @@ describe("TaskList onCellCommit", () => { fireEvent.click(screen.getByTestId("start-edit-effort")); const input = await screen.findByTestId("overlay-editor-input"); - fireEvent.change(input, { target: { value: "4" } }); + 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); + expect(update.actualEffort).toBe(4.25); expect(update.end).toBeInstanceOf(Date); - expect((update.end as Date).getHours()).toBe(13); - expect((update.end as Date).getMinutes()).toBe(0); + expect((update.end as Date).getHours()).toBe(14); + expect((update.end as Date).getMinutes()).toBe(15); }); }); From c8021978d4cc9636210657a0261c197a1a1fe9d2 Mon Sep 17 00:00:00 2001 From: Yoshio Nishiyama <99854263+LevelCapTech@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:20:37 +0900 Subject: [PATCH 05/17] Update src/helpers/actuals-helper.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/helpers/actuals-helper.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/helpers/actuals-helper.ts b/src/helpers/actuals-helper.ts index e7f6ad920..b4e40a7ff 100644 --- a/src/helpers/actuals-helper.ts +++ b/src/helpers/actuals-helper.ts @@ -342,9 +342,14 @@ export const normalizeActuals = ( end: nextEnd, actualEffort: nextEffort, }; + const isSameDate = (a?: Date, b?: Date): boolean => { + if (a === b) return true; + if (!a || !b) return false; + return Object.is(a.getTime(), b.getTime()); + }; const hasChange = - nextTask.start.getTime() !== task.start.getTime() || - nextTask.end.getTime() !== task.end.getTime() || + !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; }; From 7b2fdc4783904832af4971bb3677a33fd1fff673 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 07:26:15 +0000 Subject: [PATCH 06/17] =?UTF-8?q?fix:=20=E5=AE=9F=E7=B8=BE=E7=B7=A8?= =?UTF-8?q?=E9=9B=86=E3=81=AE=E6=99=82=E5=88=BB=E4=BF=9D=E6=8C=81=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start/end 編集時に時刻を維持し、invalid 日付比較の冪等性を改善する。 レビュー指摘に合わせてテストを追加し挙動を担保する。 Co-authored-by: LevelCapTech <99854263+LevelCapTech@users.noreply.github.com> --- src/components/task-list/task-list.tsx | 62 +++++++++++++++++++++++--- src/helpers/actuals-helper.ts | 6 ++- src/test/task-list-commit.test.tsx | 33 ++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/components/task-list/task-list.tsx b/src/components/task-list/task-list.tsx index 1d37bc862..8fd55e19a 100644 --- a/src/components/task-list/task-list.tsx +++ b/src/components/task-list/task-list.tsx @@ -103,6 +103,46 @@ export const DEFAULT_MIN_WIDTH = 32; export const getDefaultWidth = (field: VisibleField, rowWidth: string): number => field === "name" ? 140 : Number.parseInt(rowWidth, 10) || 155; +const parseTimeFromInput = (value?: string) => { + 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 }; +}; + +const applyTimeToDate = ( + date: Date, + sourceDate: Date | undefined, + fallbackTime?: { hours: number; minutes: number } | null +) => { + const next = new Date(date); + if (sourceDate && !Number.isNaN(sourceDate.getTime())) { + 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, @@ -284,14 +324,26 @@ export const TaskList: React.FC = ({ if (columnId !== "start" && columnId !== "end" && columnId !== "actualEffort") { return null; } - const parsedValue = - columnId === "actualEffort" - ? sanitizeEffortInput(value) - : parseDateFromInput(value); + 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 = parseTimeFromInput( + columnId === "start" + ? actualsOptions?.workdayStartTime + : actualsOptions?.workdayEndTime + ); + parsedValue = applyTimeToDate(parsedDate, sourceDate, fallbackTime); + } if (parsedValue === undefined) { return null; } - const invalidEndForRecalc = new Date("invalid"); + const invalidEndForRecalc = new Date("invalid"); // force derive end from effort const draftTask = { ...task, [columnId]: parsedValue, diff --git a/src/helpers/actuals-helper.ts b/src/helpers/actuals-helper.ts index b4e40a7ff..b60973976 100644 --- a/src/helpers/actuals-helper.ts +++ b/src/helpers/actuals-helper.ts @@ -345,7 +345,11 @@ export const normalizeActuals = ( const isSameDate = (a?: Date, b?: Date): boolean => { if (a === b) return true; if (!a || !b) return false; - return Object.is(a.getTime(), b.getTime()); + const validA = isValidDate(a); + const validB = isValidDate(b); + if (!validA && !validB) 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) || diff --git a/src/test/task-list-commit.test.tsx b/src/test/task-list-commit.test.tsx index b48de13f4..0ac27df10 100644 --- a/src/test/task-list-commit.test.tsx +++ b/src/test/task-list-commit.test.tsx @@ -16,6 +16,12 @@ const MockTaskListTable: React.FC = () => { > Start +