From d3fa5dd1c6562934f4273fd33b6371cbc93d4085 Mon Sep 17 00:00:00 2001 From: henrihaapalasiili Date: Fri, 20 Feb 2026 15:39:36 +0200 Subject: [PATCH 1/3] KAAV-3506 fix(timeline): fix compareAndUpdateDates cascade bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix NaN crash in objectUtil date cascade when initial_distance is null (alkaaGap, paattyyGap, lautakuntaGap fallback chains with ??-defaults) - Remove isLargeProject branch; all sizes use milloin_ehdotuksen_nahtavilla_paattyy - Add periaatteet/luonnos fallback to esillaolo_paattyy when lautakunta disabled - Fix field name kaynnistysvaihe_paattyy_pvm → kaynnistys_paattyy_pvm (K2 spec) - Enable 5 skipped tests, fix test typos and assertion order --- src/__tests__/utils/timeUtil.test.js | 20 +++++++++---------- src/utils/objectUtil.js | 11 ++++++++--- src/utils/timeUtil.js | 29 ++++++++++++++++------------ 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/__tests__/utils/timeUtil.test.js b/src/__tests__/utils/timeUtil.test.js index 24d61c5cd..afb4cc3f8 100644 --- a/src/__tests__/utils/timeUtil.test.js +++ b/src/__tests__/utils/timeUtil.test.js @@ -628,7 +628,7 @@ describe("compareAndUpdateDates function", () => { expect(test_data[key], `Key ${key} was not updated`).toBe(test_data[viimeistaan_items[key]]); } }); - test.skip("compareAndUpdateDates phase end dates correctly", () => { + test("compareAndUpdateDates phase end dates correctly", () => { const end_keys = [ "periaatteetvaihe_paattyy_pvm", "oasvaihe_paattyy_pvm", @@ -654,21 +654,21 @@ describe("compareAndUpdateDates function", () => { test_data["kaavaluonnos_lautakuntaan_1"] = true; test_data["kaavaluonnos_lautakuntaan_2"] = true; test_data["kaavaluonnos_lautakuntaan_3"] = false; - test_data["kaavehdotus_nahtaville_1"] = true; - test_data["kaavehdotus_uudelleen_nahtaville_2"] = true; - test_data["kaavehdotus_uudelleen_nahtaville_3"] = false; + test_data["kaavaehdotus_nahtaville_1"] = true; + test_data["kaavaehdotus_uudelleen_nahtaville_2"] = true; + test_data["kaavaehdotus_uudelleen_nahtaville_3"] = false; test_data["tarkistettu_ehdotus_lautakuntaan_1"] = true; test_data["tarkistettu_ehdotus_lautakuntaan_2"] = true; test_data["tarkistettu_ehdotus_lautakuntaan_3"] = false; test_data["tarkistettu_ehdotus_lautakuntaan_4"] = false; + timeUtil.compareAndUpdateDates(test_data); expect(test_data["oasvaihe_paattyy_pvm"]).toBe(test_data["milloin_oas_esillaolo_paattyy_2"]); expect(test_data["luonnosvaihe_paattyy_pvm"]).toBe(test_data["milloin_kaavaluonnos_lautakunnassa_2"]); - expect(test_data["ehdotusvaihe_paattyy_pvm"]).toBe(test_data["milloin_kaavaehdotus_lautakunnassa_2"]); + expect(test_data["ehdotusvaihe_paattyy_pvm"]).toBe(test_data["viimeistaan_lausunnot_ehdotuksesta_2"]); expect(test_data["tarkistettuehdotusvaihe_paattyy_pvm"]).toBe(test_data["milloin_tarkistettu_ehdotus_lautakunnassa_2"]); - timeUtil.compareAndUpdateDates(test_data); }); - test.skip("compareAndUpdateDates end dates, periaatteet with no lautakunta", () => { + test("compareAndUpdateDates end dates, periaatteet with no lautakunta", () => { test_data["periaatteetvaihe_paattyy_pvm"] = undefined; test_data["periaatteet_lautakuntaan_1"] = false; test_data["periaatteet_lautakuntaan_2"] = false; @@ -680,7 +680,7 @@ describe("compareAndUpdateDates function", () => { timeUtil.compareAndUpdateDates(test_data); expect(test_data["periaatteetvaihe_paattyy_pvm"]).toBe(test_data["milloin_periaatteet_esillaolo_paattyy"]); }); - test.skip("compareAndUpdateDates end dates, luonnos with no lautakunta", () => { + test("compareAndUpdateDates end dates, luonnos with no lautakunta", () => { test_data["luonnosvaihe_paattyy_pvm"] = undefined; test_data["kaavaluonnos_lautakuntaan_1"] = false; test_data["kaavaluonnos_lautakuntaan_2"] = false; @@ -692,7 +692,7 @@ describe("compareAndUpdateDates function", () => { timeUtil.compareAndUpdateDates(test_data); expect(test_data["luonnosvaihe_paattyy_pvm"]).toBe(test_data["milloin_luonnos_esillaolo_paattyy"]); }); - test.skip("compareAndUpdateDates end dates, ehdotus in XS size", () => { + test("compareAndUpdateDates end dates, ehdotus in XS size", () => { test_data["ehdotusvaihe_paattyy_pvm"] = undefined; test_data["kaavaprosessin_kokoluokka"] = "XS"; test_data["kaavaehdotus_lautakuntaan_1"] = false; @@ -705,7 +705,7 @@ describe("compareAndUpdateDates function", () => { timeUtil.compareAndUpdateDates(test_data); expect(test_data["ehdotusvaihe_paattyy_pvm"]).toBe(test_data["milloin_ehdotuksen_nahtavilla_paattyy"]); }); - test.skip("compareAndUpdateDates moves backwards start dates to match previous end dates", () => { + test("compareAndUpdateDates moves backwards start dates to match previous end dates", () => { test_data["periaatteetvaihe_alkaa_pvm"] = "2025-05-01"; test_data["kaynnistys_paattyy_pvm"] = "2025-06-01"; timeUtil.compareAndUpdateDates(test_data); diff --git a/src/utils/objectUtil.js b/src/utils/objectUtil.js index e5bd7b214..a6f837140 100644 --- a/src/utils/objectUtil.js +++ b/src/utils/objectUtil.js @@ -462,7 +462,9 @@ const checkForDecreasingValues = (arr, isAdd, field, disabledDates, oldDate, mov //Make next or previous or previous and 1 after previous dates follow the moved date if needed if (arr[currentIndex]?.key?.includes("kylk_maaraaika") || arr[currentIndex]?.key?.includes("kylk_aineiston_maaraaika") || arr[currentIndex]?.key?.includes("_lautakunta_aineiston_maaraaika")) { //maaraika in lautakunta moving - forward cascade to lautakunnassa - const lautakuntaResult = timeUtil.findAllowedLautakuntaDate(movedDate, arr[i + 1].initial_distance, disabledDates?.date_types[arr[i + 1]?.date_type]?.dates, false, disabledDates?.date_types[arr[i]?.date_type]?.dates); + // Use initial_distance, fall back to distance_from_previous, then default 21 (P7/L7/E8/T3 standard gap) + const lautakuntaGap = arr[i + 1].initial_distance ?? arr[i + 1].distance_from_previous ?? 21; + const lautakuntaResult = timeUtil.findAllowedLautakuntaDate(movedDate, lautakuntaGap, disabledDates?.date_types[arr[i + 1]?.date_type]?.dates, false, disabledDates?.date_types[arr[i]?.date_type]?.dates); arr[i + 1].value = new Date(lautakuntaResult).toISOString().split('T')[0]; indexToContinue = i + 1 @@ -536,7 +538,9 @@ const checkForDecreasingValues = (arr, isAdd, field, disabledDates, oldDate, mov const oldStartISO = arr[i + 1]?.value; const oldEndISO = arr[i + 2]?.value; const endAllowed = disabledDates?.date_types[arr[i + 2]?.date_type]?.dates || []; - const alkaaResult = timeUtil.findAllowedDate(movedDate, arr[i + 1].initial_distance, disabledDates?.date_types[arr[i]?.date_type]?.dates, false); + // Use initial_distance, fall back to distance_from_previous, then default 14 (P3/L3/O3 standard gap) + const alkaaGap = arr[i + 1].initial_distance ?? arr[i + 1].distance_from_previous ?? 14; + const alkaaResult = timeUtil.findAllowedDate(movedDate, alkaaGap, disabledDates?.date_types[arr[i]?.date_type]?.dates, false); arr[i + 1].value = new Date(alkaaResult).toISOString().split('T')[0]; indexToContinue = i + 1 if (!arr[currentIndex]?.key?.includes("kylk_maaraaika") && !arr[currentIndex]?.key?.includes("kylk_aineiston_maaraaika") && !arr[currentIndex]?.key?.includes("_lautakunta_aineiston_maaraaika") && !arr[currentIndex]?.key?.includes("lautakunnassa") && arr[currentIndex]?.key?.includes("maaraaika")) { @@ -550,7 +554,8 @@ const checkForDecreasingValues = (arr, isAdd, field, disabledDates, oldDate, mov const val = endAllowed.findIndex(d => d >= arr[i + 1].value); let kept = (val !== -1 && val + timespan < endAllowed.length) ? endAllowed[val + timespan] : null; if (!kept) { - kept = timeUtil.findAllowedDate(arr[i + 1].value, arr[i + 2].initial_distance, endAllowed, false); + const paattyyGap = arr[i + 2].initial_distance ?? arr[i + 2].distance_from_previous ?? 14; + kept = timeUtil.findAllowedDate(arr[i + 1].value, paattyyGap, endAllowed, false); } arr[i + 2].value = new Date(kept).toISOString().split('T')[0]; indexToContinue = i + 2 diff --git a/src/utils/timeUtil.js b/src/utils/timeUtil.js index 839b0ad43..82042bbcf 100644 --- a/src/utils/timeUtil.js +++ b/src/utils/timeUtil.js @@ -869,30 +869,36 @@ const compareAndUpdateDates = (data) => { }); //Check that phase end date line is moved to phases actual last date const buildPhasePairs = (size) => { - // L and XL have reversed order in ehdotus phase: lautakunta first, nähtävilläolo last - const isLargeProject = size === "XL" || size === "L"; + // Each entry: [dstField, primarySrcBase, fallbackSrcBase?] + // Primary is tried first; if getLatestDateValue returns null, fallback is tried. return [ - ["periaatteetvaihe_paattyy_pvm", "milloin_periaatteet_lautakunnassa"], + ["periaatteetvaihe_paattyy_pvm", "milloin_periaatteet_lautakunnassa", "milloin_periaatteet_esillaolo_paattyy"], ["oasvaihe_paattyy_pvm", "milloin_oas_esillaolo_paattyy"], - ["luonnosvaihe_paattyy_pvm", "milloin_kaavaluonnos_lautakunnassa"], - ["ehdotusvaihe_paattyy_pvm", isLargeProject ? "milloin_ehdotuksen_nahtavilla_paattyy" : "milloin_ehdotus_esillaolo_paattyy"], + ["luonnosvaihe_paattyy_pvm", "milloin_kaavaluonnos_lautakunnassa", "milloin_luonnos_esillaolo_paattyy"], + // All sizes use milloin_ehdotuksen_nahtavilla_paattyy for ehdotus end + // (milloin_ehdotus_esillaolo_paattyy does not exist in project data; alkaa differs by size but paattyy does not) + ["ehdotusvaihe_paattyy_pvm", "milloin_ehdotuksen_nahtavilla_paattyy"], ["tarkistettuehdotusvaihe_paattyy_pvm", "milloin_tarkistettu_ehdotus_lautakunnassa"], // hyvaksyminen & voimaantulo intentionally excluded (no paired controlling date specified) ]; }; const phasePairs = buildPhasePairs(data["kaavaprosessin_kokoluokka"]); - phasePairs.forEach(([dst, srcBase]) => { + phasePairs.forEach(([dst, srcBase, fallbackBase]) => { // Always pick the latest available date among base + suffixed variants - const latest = getLatestDateValue(srcBase); + let latest = getLatestDateValue(srcBase); + // If primary source yields nothing (e.g. lautakunta disabled), try fallback + if (!latest && fallbackBase) { + latest = getLatestDateValue(fallbackBase); + } if (latest && data[dst] !== latest) { data[dst] = latest; } }); - // Generic adjacency enforcement: each phase's start >= previous phase's end. - // Ordered phases including optional ones (periaatteet, luonnos) which may be absent. + // Enforce phase adjacency: next phase alkaa >= previous phase paattyy + // Spec: P1=K2, O1=P8|K2, L1=O6, E1=L8|O6, T1=E9, H1=T5, V1=H3 const orderedPhases = [ - { start: "kaynnistysvaihe_alkaa_pvm", end: "kaynnistysvaihe_paattyy_pvm" }, + { start: "kaynnistysvaihe_alkaa_pvm", end: "kaynnistys_paattyy_pvm" }, { start: "periaatteetvaihe_alkaa_pvm", end: "periaatteetvaihe_paattyy_pvm", optional: true }, { start: "oasvaihe_alkaa_pvm", end: "oasvaihe_paattyy_pvm" }, { start: "luonnosvaihe_alkaa_pvm", end: "luonnosvaihe_paattyy_pvm", optional: true }, @@ -902,7 +908,7 @@ const compareAndUpdateDates = (data) => { { start: "voimaantulovaihe_alkaa_pvm", end: "voimaantulovaihe_paattyy_pvm" } ]; - // Build a filtered sequence of phases that actually exist (have either start or end present) + // Build filtered sequence of phases that actually exist (have either start or end present) const existingPhases = orderedPhases.filter(p => data[p.start] || data[p.end]); for (let i = 1; i < existingPhases.length; i++) { @@ -911,7 +917,6 @@ const compareAndUpdateDates = (data) => { const prevEnd = validateAndNormalizeDate(data[prev.end]); const curStart = validateAndNormalizeDate(data[cur.start]); if (prevEnd && curStart && curStart < prevEnd) { - // Move current start forward to previous end data[cur.start] = prevEnd; } } From b49e9488c7f16b49456b0d15fa4df630e1be20ee Mon Sep 17 00:00:00 2001 From: henrihaapalasiili Date: Fri, 20 Feb 2026 15:45:42 +0200 Subject: [PATCH 2/3] recude duplication --- src/__tests__/utils/timeUtil.test.js | 88 ++++++++++++++-------------- 1 file changed, 43 insertions(+), 45 deletions(-) diff --git a/src/__tests__/utils/timeUtil.test.js b/src/__tests__/utils/timeUtil.test.js index afb4cc3f8..453a0b2d1 100644 --- a/src/__tests__/utils/timeUtil.test.js +++ b/src/__tests__/utils/timeUtil.test.js @@ -286,6 +286,14 @@ describe ("addDays and subtractDays with disabled dates", () => { }); describe("getDisabledDates for various phases", () => { + /** Assert every date in the array falls on a weekday (Mon-Fri) */ + const expectAllWeekdays = (dates) => { + for (const date of dates) { + const day = new Date(date).getDay(); + expect([0, 6].includes(day), `${date} is a weekend`).toBe(false); + } + }; + test("getDisabledDatesForProjectStart returns valid *allowed* dates", () => { const name = "projektin_kaynnistys_pvm"; const formValues = { @@ -303,10 +311,9 @@ describe("getDisabledDates for various phases", () => { expect(result[result.length-1]).toBe("2025-05-19"); //maintain 10 working days distance const nextDate = new Date(formValues["kaynnistys_paattyy_pvm"]); for (let date of result) { - let newDate = new Date(date); - expect(newDate < nextDate).toBe(true); - expect(newDate.getDay() !== 0 && newDate.getDay() !== 6).toBe(true); // Not weekend + expect(new Date(date) < nextDate).toBe(true); } + expectAllWeekdays(result); }); test("getDisabledDatesForApproval returns valid *allowed* dates", () => { const name = "hyvaksymispaatos_pvm"; @@ -323,10 +330,9 @@ describe("getDisabledDates for various phases", () => { expect(result[0]).toBe("2025-05-23"); // maintain 15 working days distance const previousDate = new Date(formValues["hyvaksyminenvaihe_alkaa_pvm"]); for (let date of result) { - let newDate = new Date(date); - expect(newDate > previousDate).toBe(true); - expect(newDate.getDay() !== 0 && newDate.getDay() !== 6).toBe(true); // Not weekend + expect(new Date(date) > previousDate).toBe(true); } + expectAllWeekdays(result); const resultXS = timeUtil.getDisabledDatesForApproval(name, formValues, matchingItem, dateTypes, "XS"); expect(resultXS[0]).toBe("2025-05-22"); // 1 extra day for XS/S }); @@ -365,10 +371,9 @@ describe("getDisabledDates for various phases", () => { expect(result_maaraika[0]).toBe("2025-08-11"); const previousDate_maaraika = new Date(formValues["tarkistettu_ehdotusvaihe_alkaa_pvm"]); for (let date of result_maaraika) { - let newDate = new Date(date); - expect(newDate > previousDate_maaraika).toBe(true); - expect([0, 6].includes(newDate.getDay())).toBe(false); + expect(new Date(date) > previousDate_maaraika).toBe(true); } + expectAllWeekdays(result_maaraika); const result_lautakunta = timeUtil.getDisabledDatesForLautakunta("milloin_tarkistettu_ehdotus_lautakunnassa", formValues, "tarkistettu_ehdotus", lautakuntaItem, kylkItem, dateTypes); const previousDate = new Date(formValues["tarkistettu_ehdotus_kylk_maaraaika"]); // 27 work days distance from maaraika (23rd), then next possible tuesday (30th) @@ -440,26 +445,17 @@ describe("getDisabledDates for various phases", () => { // Test maaraAika - should return disabled dates (working days only) const maaraAikaResult = timeUtil.getDisabledDatesForSizeXSXL(name, formValues, maaraAikaItem, dateTypes); expect(maaraAikaResult.length).toBeGreaterThan(0); - for (let date of maaraAikaResult) { - let newDate = new Date(date); - expect(newDate.getDay() !== 0 && newDate.getDay() !== 6).toBe(true); // Not weekend - } + expectAllWeekdays(maaraAikaResult); // Test alkaa - should return disabled dates after prerequisite const alkaaResult = timeUtil.getDisabledDatesForSizeXSXL("milloin_oas_esillaolo_alkaa", formValues, alkaaItem, dateTypes); expect(alkaaResult.length).toBeGreaterThan(0); - for (let date of alkaaResult) { - let newDate = new Date(date); - expect(newDate.getDay() !== 0 && newDate.getDay() !== 6).toBe(true); - } + expectAllWeekdays(alkaaResult); // Test paattyy - should return disabled dates (working days only) const paattyyResult = timeUtil.getDisabledDatesForSizeXSXL("milloin_oas_esillaolo_paattyy", formValues, paattyyItem, dateTypes); expect(paattyyResult.length).toBeGreaterThan(0); - for (let date of paattyyResult) { - let newDate = new Date(date); - expect(newDate.getDay() !== 0 && newDate.getDay() !== 6).toBe(true); // Not weekend - } + expectAllWeekdays(paattyyResult); }); test("getHighestLautakuntaDate returns correct date", () => { const formValues = { @@ -668,30 +664,32 @@ describe("compareAndUpdateDates function", () => { expect(test_data["ehdotusvaihe_paattyy_pvm"]).toBe(test_data["viimeistaan_lausunnot_ehdotuksesta_2"]); expect(test_data["tarkistettuehdotusvaihe_paattyy_pvm"]).toBe(test_data["milloin_tarkistettu_ehdotus_lautakunnassa_2"]); }); - test("compareAndUpdateDates end dates, periaatteet with no lautakunta", () => { - test_data["periaatteetvaihe_paattyy_pvm"] = undefined; - test_data["periaatteet_lautakuntaan_1"] = false; - test_data["periaatteet_lautakuntaan_2"] = false; - test_data["periaatteet_lautakuntaan_3"] = false; - test_data["periaatteet_lautakuntaan_4"] = false; - test_data["jarjestetaan_periaatteet_esillaolo_1"] = true; - test_data["jarjestetaan_periaatteet_esillaolo_2"] = false; - test_data["jarjestetaan_periaatteet_esillaolo_3"] = false; - timeUtil.compareAndUpdateDates(test_data); - expect(test_data["periaatteetvaihe_paattyy_pvm"]).toBe(test_data["milloin_periaatteet_esillaolo_paattyy"]); - }); - test("compareAndUpdateDates end dates, luonnos with no lautakunta", () => { - test_data["luonnosvaihe_paattyy_pvm"] = undefined; - test_data["kaavaluonnos_lautakuntaan_1"] = false; - test_data["kaavaluonnos_lautakuntaan_2"] = false; - test_data["kaavaluonnos_lautakuntaan_3"] = false; - test_data["kaavaluonnos_lautakuntaan_4"] = false; - test_data["jarjestetaan_luonnos_esillaolo_1"] = true; - test_data["jarjestetaan_luonnos_esillaolo_2"] = false; - test_data["jarjestetaan_luonnos_esillaolo_3"] = false; - timeUtil.compareAndUpdateDates(test_data); - expect(test_data["luonnosvaihe_paattyy_pvm"]).toBe(test_data["milloin_luonnos_esillaolo_paattyy"]); - }); + test.each([ + { + phase: "periaatteet", + endKey: "periaatteetvaihe_paattyy_pvm", + lautakuntaPrefix: "periaatteet_lautakuntaan", + esillaoloPrefix: "jarjestetaan_periaatteet_esillaolo", + expectedSrc: "milloin_periaatteet_esillaolo_paattyy", + }, + { + phase: "luonnos", + endKey: "luonnosvaihe_paattyy_pvm", + lautakuntaPrefix: "kaavaluonnos_lautakuntaan", + esillaoloPrefix: "jarjestetaan_luonnos_esillaolo", + expectedSrc: "milloin_luonnos_esillaolo_paattyy", + }, + ])("compareAndUpdateDates end dates, $phase with no lautakunta", + ({ endKey, lautakuntaPrefix, esillaoloPrefix, expectedSrc }) => { + test_data[endKey] = undefined; + for (let i = 1; i <= 4; i++) test_data[`${lautakuntaPrefix}_${i}`] = false; + test_data[`${esillaoloPrefix}_1`] = true; + test_data[`${esillaoloPrefix}_2`] = false; + test_data[`${esillaoloPrefix}_3`] = false; + timeUtil.compareAndUpdateDates(test_data); + expect(test_data[endKey]).toBe(test_data[expectedSrc]); + } + ); test("compareAndUpdateDates end dates, ehdotus in XS size", () => { test_data["ehdotusvaihe_paattyy_pvm"] = undefined; test_data["kaavaprosessin_kokoluokka"] = "XS"; From 1d7d12362533caaad0800dc1e6657d08ec54c1f4 Mon Sep 17 00:00:00 2001 From: henrihaapalasiili Date: Mon, 23 Feb 2026 16:12:33 +0200 Subject: [PATCH 3/3] =?UTF-8?q?KAAV-3557=20Lausunnot=20viimeist=C3=A4?= =?UTF-8?q?=C3=A4n=20syncs=20with=20n=C3=A4ht=C3=A4vill=C3=A4olo=20p=C3=A4?= =?UTF-8?q?=C3=A4ttyy=20and=20allows=20admin=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compareAndUpdateDates accepts optional previousPaattyyValues snapshot to detect when päättyy actually changed vs unchanged - When päättyy changes (any direction): force lausunnot = new päättyy - When päättyy unchanged: preserve user-set lausunnot >= päättyy (floor constraint) - Reducer captures pre-cascade päättyy snapshot before date adjustments - Remove Lausunnot viimeistään exclusion from extractAttributes - Add parameterized rule-based tests with mutation-verified coverage --- src/__tests__/utils/timeUtil.test.js | 92 ++++++++++++++++--- src/components/input/DeadlineInput.jsx | 3 +- .../EditProjectTimetableModal/index.jsx | 2 +- src/reducers/projectReducer.js | 11 ++- src/utils/timeUtil.js | 20 +++- 5 files changed, 108 insertions(+), 20 deletions(-) diff --git a/src/__tests__/utils/timeUtil.test.js b/src/__tests__/utils/timeUtil.test.js index 453a0b2d1..aaebb88c7 100644 --- a/src/__tests__/utils/timeUtil.test.js +++ b/src/__tests__/utils/timeUtil.test.js @@ -609,21 +609,87 @@ describe("compareAndUpdateDates function", () => { test_data = structuredClone(test_attribute_data) }); - test("compareAndUpdateDates viimeistaan_ dates correctly", () => { - const viimeistaan_items = { - "viimeistaan_lausunnot_ehdotuksesta": "milloin_ehdotuksen_nahtavilla_paattyy", - "viimeistaan_lausunnot_ehdotuksesta_2": "milloin_ehdotuksen_nahtavilla_paattyy_2", - "viimeistaan_lausunnot_ehdotuksesta_3": "milloin_ehdotuksen_nahtavilla_paattyy_3", - "viimeistaan_lausunnot_ehdotuksesta_4": "milloin_ehdotuksen_nahtavilla_paattyy_4" - } - for (let key in viimeistaan_items) { - test_data[key] = null; - } + // === LAUSUNNOT VIIMEISTÄÄN RULES === + // Helper: set paattyy and lausunnot for a given suffix + const setLausuntoPair = (data, paattyy, lausunnot, suffix = "") => { + data[`milloin_ehdotuksen_nahtavilla_paattyy${suffix}`] = paattyy; + data[`viimeistaan_lausunnot_ehdotuksesta${suffix}`] = lausunnot; + }; + const makeSnapshot = (values) => ({ + milloin_ehdotuksen_nahtavilla_paattyy: values[0] ?? null, + milloin_ehdotuksen_nahtavilla_paattyy_2: values[1] ?? null, + milloin_ehdotuksen_nahtavilla_paattyy_3: values[2] ?? null, + milloin_ehdotuksen_nahtavilla_paattyy_4: values[3] ?? null, + }); + + // RULE 1: paattyy changed (any direction) → lausunnot MUST equal new paattyy + test.each([ + { oldP: "2025-01-10", newP: "2025-01-15", oldL: "2025-01-10", desc: "forward, lausunnot was same as old paattyy" }, + { oldP: "2025-06-15", newP: "2025-06-10", oldL: "2025-06-15", desc: "backward, lausunnot was same as old paattyy" }, + { oldP: "2025-03-01", newP: "2025-04-01", oldL: "2025-05-01", desc: "forward, lausunnot was much later" }, + { oldP: "2025-08-20", newP: "2025-07-01", oldL: "2025-12-31", desc: "backward, lausunnot was much later" }, + { oldP: "2025-12-31", newP: "2026-01-02", oldL: "2025-12-31", desc: "across year boundary" }, + { oldP: "2025-02-28", newP: "2025-03-01", oldL: "2025-02-28", desc: "across month boundary" }, + { oldP: "2025-05-10", newP: "2025-05-09", oldL: "2025-05-10", desc: "one day backward" }, + { oldP: "2025-05-10", newP: "2025-05-11", oldL: "2025-05-10", desc: "one day forward" }, + ])("RULE: paattyy changed → lausunnot = new paattyy ($desc)", ({ oldP, newP, oldL }) => { + setLausuntoPair(test_data, newP, oldL); + timeUtil.compareAndUpdateDates(test_data, makeSnapshot([oldP])); + expect(test_data["viimeistaan_lausunnot_ehdotuksesta"]).toBe(newP); + }); + + // RULE 2: paattyy unchanged → lausunnot >= paattyy must be preserved + test.each([ + { paattyy: "2025-03-15", lausunnot: "2025-03-15", desc: "equal to paattyy" }, + { paattyy: "2025-03-15", lausunnot: "2025-03-16", desc: "one day later" }, + { paattyy: "2025-03-15", lausunnot: "2025-06-01", desc: "months later" }, + { paattyy: "2025-01-01", lausunnot: "2025-12-31", desc: "almost a year later" }, + ])("RULE: paattyy unchanged → preserve lausunnot >= paattyy ($desc)", ({ paattyy, lausunnot }) => { + setLausuntoPair(test_data, paattyy, lausunnot); + timeUtil.compareAndUpdateDates(test_data, makeSnapshot([paattyy])); + expect(test_data["viimeistaan_lausunnot_ehdotuksesta"]).toBe(lausunnot); + }); + + // RULE 3: paattyy unchanged, lausunnot invalid/before paattyy → floor to paattyy + test.each([ + { paattyy: "2025-06-15", lausunnot: "2025-06-14", desc: "one day before" }, + { paattyy: "2025-06-15", lausunnot: "2025-01-01", desc: "months before" }, + { paattyy: "2025-06-15", lausunnot: "", desc: "empty string" }, + { paattyy: "2025-06-15", lausunnot: null, desc: "null" }, + ])("RULE: paattyy unchanged, invalid lausunnot → floor to paattyy ($desc)", ({ paattyy, lausunnot }) => { + setLausuntoPair(test_data, paattyy, lausunnot); + timeUtil.compareAndUpdateDates(test_data, makeSnapshot([paattyy])); + expect(test_data["viimeistaan_lausunnot_ehdotuksesta"]).toBe(paattyy); + }); + + // RULE 4: no snapshot (EditProjectTimetableModal path) → only floor constraint + test.each([ + { paattyy: "2025-04-01", lausunnot: "2025-09-01", expected: "2025-09-01", desc: "later → preserved" }, + { paattyy: "2025-04-01", lausunnot: "2025-04-01", expected: "2025-04-01", desc: "equal → preserved" }, + { paattyy: "2025-04-01", lausunnot: "2025-03-01", expected: "2025-04-01", desc: "before → floored" }, + { paattyy: "2025-04-01", lausunnot: null, expected: "2025-04-01", desc: "null → floored" }, + { paattyy: "2025-04-01", lausunnot: "", expected: "2025-04-01", desc: "empty → floored" }, + ])("RULE: no snapshot → floor only ($desc)", ({ paattyy, lausunnot, expected }) => { + setLausuntoPair(test_data, paattyy, lausunnot); timeUtil.compareAndUpdateDates(test_data); - for (let key in viimeistaan_items) { - expect(test_data[key], `Key ${key} was not updated`).toBe(test_data[viimeistaan_items[key]]); - } + expect(test_data["viimeistaan_lausunnot_ehdotuksesta"]).toBe(expected); + }); + + // RULE 5: indexed fields are evaluated independently + test("RULE: each suffix syncs/preserves independently based on its own paattyy change", () => { + // _1: paattyy changed → sync + setLausuntoPair(test_data, "2025-05-20", "2025-08-01", ""); + // _2: paattyy NOT changed → preserve + setLausuntoPair(test_data, "2025-07-15", "2025-09-01", "_2"); + // _3: paattyy changed → sync + setLausuntoPair(test_data, "2025-12-01", "2025-11-15", "_3"); + const snapshot = makeSnapshot(["2025-05-10", "2025-07-15", "2025-11-01"]); + timeUtil.compareAndUpdateDates(test_data, snapshot); + expect(test_data["viimeistaan_lausunnot_ehdotuksesta"]).toBe("2025-05-20"); // synced + expect(test_data["viimeistaan_lausunnot_ehdotuksesta_2"]).toBe("2025-09-01"); // preserved + expect(test_data["viimeistaan_lausunnot_ehdotuksesta_3"]).toBe("2025-12-01"); // synced }); + test("compareAndUpdateDates phase end dates correctly", () => { const end_keys = [ "periaatteetvaihe_paattyy_pvm", diff --git a/src/components/input/DeadlineInput.jsx b/src/components/input/DeadlineInput.jsx index 6ad093e11..dd6ea8ea7 100644 --- a/src/components/input/DeadlineInput.jsx +++ b/src/components/input/DeadlineInput.jsx @@ -223,8 +223,7 @@ const DeadLineInput = ({ type='text' // type='date' works poorly with hds-DateInput disabled={!timetable_editable || disabledState || (!attributeData?.kaavan_vaihe.includes("Käynnistys") && - (input?.name?.includes("projektin_kaynnistys_pvm") || input?.name?.includes("kaynnistys_paattyy_pvm"))) || - (input?.name?.includes("viimeistaan_lausunnot_ehdotuksesta")) // Disabled in KAPI-190, re-enable for KAAV-3358 + (input?.name?.includes("projektin_kaynnistys_pvm") || input?.name?.includes("kaynnistys_paattyy_pvm"))) } error={error} aria-label={input.name} diff --git a/src/components/project/EditProjectTimetableModal/index.jsx b/src/components/project/EditProjectTimetableModal/index.jsx index 6a9f3b856..22dd6fbff 100644 --- a/src/components/project/EditProjectTimetableModal/index.jsx +++ b/src/components/project/EditProjectTimetableModal/index.jsx @@ -113,7 +113,7 @@ class EditProjectTimeTableModal extends Component { if (prevProps.attributeData && !isEqual(prevProps.attributeData, attributeData)) { let sectionAttributes = []; this.extractAttributes(deadlineSections, attributeData, sectionAttributes, (attribute, attributeData) => - attribute.label !== "Lausunnot viimeistään" && attributeData[attribute.name] + attributeData[attribute.name] ); this.setState({sectionAttributes}) //when UPDATE_DATE_TIMELINE updates attribute values diff --git a/src/reducers/projectReducer.js b/src/reducers/projectReducer.js index dadce7c48..ba8af8847 100644 --- a/src/reducers/projectReducer.js +++ b/src/reducers/projectReducer.js @@ -227,6 +227,13 @@ export const reducer = (state = initialState, action) => { const projectSize = updatedAttributeData?.kaavaprosessin_kokoluokka //Remove all keys that are still hidden in vistimeline so they are not moved in data and later saved const filteredAttributeData = objectUtil.filterHiddenKeysUsingSections(updatedAttributeData, deadlineSections); + // Snapshot paattyy values before cascade to detect changes for lausunnot auto-sync + const previousPaattyyValues = { + milloin_ehdotuksen_nahtavilla_paattyy: filteredAttributeData.milloin_ehdotuksen_nahtavilla_paattyy, + milloin_ehdotuksen_nahtavilla_paattyy_2: filteredAttributeData.milloin_ehdotuksen_nahtavilla_paattyy_2, + milloin_ehdotuksen_nahtavilla_paattyy_3: filteredAttributeData.milloin_ehdotuksen_nahtavilla_paattyy_3, + milloin_ehdotuksen_nahtavilla_paattyy_4: filteredAttributeData.milloin_ehdotuksen_nahtavilla_paattyy_4, + }; const moveToPast = filteredAttributeData[field] > newDate; //Save oldDate for comparison in checkforDecreasingValues const oldDate = filteredAttributeData[field]; @@ -266,8 +273,8 @@ export const reducer = (state = initialState, action) => { if (keepDuration && preservedEndValue && pairedEndKey) { filteredAttributeData[pairedEndKey] = preservedEndValue; } - //Updates viimeistaan lausunnot values to paattyy if paattyy date is greater - timeUtil.compareAndUpdateDates(filteredAttributeData) + //Updates viimeistaan lausunnot values to paattyy if paattyy date changed, or enforces floor constraint + timeUtil.compareAndUpdateDates(filteredAttributeData, previousPaattyyValues) // K1 = U1 sync: kaynnistysvaihe_alkaa_pvm always equals projektin_kaynnistys_pvm // Per timeline_requirements.md line 899: K1's "Generoitu ehdotus" = U1 diff --git a/src/utils/timeUtil.js b/src/utils/timeUtil.js index 82042bbcf..fc06efba4 100644 --- a/src/utils/timeUtil.js +++ b/src/utils/timeUtil.js @@ -797,7 +797,7 @@ const calculateDisabledDates = (nahtavillaolo, size, dateTypes, name, formValues : []; }; -const compareAndUpdateDates = (data) => { +const compareAndUpdateDates = (data, previousPaattyyValues) => { // Static pairs: viimeistaan lausunnot -> ehdotuksen nähtävillä päättyy variants const lausuntoPairs = [ ["viimeistaan_lausunnot_ehdotuksesta", "milloin_ehdotuksen_nahtavilla_paattyy"], @@ -864,7 +864,23 @@ const compareAndUpdateDates = (data) => { lausuntoPairs.forEach(([lausunto_date, paattyy_date]) => { const validPaattyyDate = validateAndNormalizeDate(data[paattyy_date]); if (validPaattyyDate) { - data[lausunto_date] = validPaattyyDate; + const currentLausuntoDate = validateAndNormalizeDate(data[lausunto_date]); + if (previousPaattyyValues) { + // Called from reducer with pre-cascade snapshot: sync lausunnot when paattyy changed + const prevPaattyy = validateAndNormalizeDate(previousPaattyyValues[paattyy_date]); + if (prevPaattyy !== validPaattyyDate) { + // Paattyy changed (any reason) -> force lausunnot to match new paattyy + data[lausunto_date] = validPaattyyDate; + } else if (!currentLausuntoDate || currentLausuntoDate < validPaattyyDate) { + // Paattyy did not change but lausunnot is empty or before paattyy -> floor constraint + data[lausunto_date] = validPaattyyDate; + } + } else { + // Called without snapshot (e.g. from EditProjectTimetableModal): apply floor constraint only + if (!currentLausuntoDate || currentLausuntoDate < validPaattyyDate) { + data[lausunto_date] = validPaattyyDate; + } + } } }); //Check that phase end date line is moved to phases actual last date