From a72c25a7252fcf8660a5e2e7bfd40d5178529634 Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:01:12 +0100 Subject: [PATCH 01/11] feat: Add weekly period variants and fix weekly period not using isoWeeks. Add tests for existing period types --- src/test/utils/periods.spec.js | 87 ++++++++++++++++++++++++++++++++++ src/webapp/utils/periods.js | 41 ++++++++++++++-- 2 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 src/test/utils/periods.spec.js diff --git a/src/test/utils/periods.spec.js b/src/test/utils/periods.spec.js new file mode 100644 index 00000000..b408bcb4 --- /dev/null +++ b/src/test/utils/periods.spec.js @@ -0,0 +1,87 @@ +import { describe, it, expect } from "@jest/globals"; +import { buildAllPossiblePeriods } from "../../webapp/utils/periods"; + +// Daily +describe("buildAllPossiblePeriods - Daily", () => { + it("generates daily periods", () => { + const result = buildAllPossiblePeriods("Daily", "2024-01-27", "2024-02-02"); + expect(result).toEqual(["20240127", "20240128", "20240129", "20240130", "20240131", "20240201", "20240202"]); + }); +}); + +// Weekly variants +describe("buildAllPossiblePeriods - Weekly variants", () => { + it("Weekly generates period for sub-week range", () => { + const result = buildAllPossiblePeriods("Weekly", "2024-12-30", "2025-01-04"); + expect(result).toEqual(["2025W1"]); + }); + it("Weekly generates weeks", () => { + const result = buildAllPossiblePeriods("Weekly", "2024-12-01", "2025-01-15"); + expect(result).toEqual(["2024W48", "2024W49", "2024W50", "2024W51", "2024W52", "2025W1", "2025W2", "2025W3"]); + }); + + it("WeeklyWednesday generates Wed-based weeks", () => { + const result = buildAllPossiblePeriods("WeeklyWednesday", "2024-01-01", "2024-01-31"); + expect(result).toEqual(["2023WedW52", "2024WedW1", "2024WedW2", "2024WedW3", "2024WedW4", "2024WedW5"]); + }); + + it("WeeklyThursday generates Thu-based weeks", () => { + const result = buildAllPossiblePeriods("WeeklyThursday", "2024-01-01", "2024-01-31"); + expect(result).toEqual(["2023ThuW52", "2024ThuW1", "2024ThuW2", "2024ThuW3", "2024ThuW4"]); + }); + + it("WeeklySaturday generates Sat-based weeks", () => { + const result = buildAllPossiblePeriods("WeeklySaturday", "2024-01-01", "2024-01-31"); + expect(result).toEqual(["2023SatW52", "2024SatW1", "2024SatW2", "2024SatW3", "2024SatW4"]); + }); + + it("WeeklySunday generates Sun-based weeks", () => { + const result = buildAllPossiblePeriods("WeeklySunday", "2024-01-01", "2024-01-31"); + expect(result).toEqual(["2023SunW52", "2024SunW1", "2024SunW2", "2024SunW3", "2024SunW4"]); + }); +}); + +// Monthly +describe("buildAllPossiblePeriods - Monthly", () => { + it("generates monthly periods", () => { + const result = buildAllPossiblePeriods("Monthly", "2023-11-01", "2024-03-31"); + expect(result).toEqual(["202311", "202312", "202401", "202402", "202403"]); + }); +}); + +// Yearly variants +describe("buildAllPossiblePeriods - Yearly", () => { + it("generates yearly periods", () => { + const result = buildAllPossiblePeriods("Yearly", "2020-01-01", "2024-12-31"); + expect(result).toEqual(["2020", "2021", "2022", "2023", "2024"]); + }); + + // Current financial variants dont behave like DHIS2 does, they are yearly periods + // it("generates FinancialApril periods", () => { + // const result = buildAllPossiblePeriods("FinancialApril", "2023-04-01", "2025-06-30"); + // expect(result).toEqual(["2024April", "2025April"]); + // }); + + // it("generates FinancialJuly periods", () => { + // const result = buildAllPossiblePeriods("FinancialJuly", "2023-07-01", "2025-09-30"); + // expect(result).toEqual(["2024July", "2025July"]); + // }); + + // it("generates FinancialOct periods", () => { + // const result = buildAllPossiblePeriods("FinancialOct", "2023-10-01", "2025-12-31"); + // expect(result).toEqual(["2024Oct", "2025Oct"]); + // }); + + // it("generates FinancialNov periods", () => { + // const result = buildAllPossiblePeriods("FinancialNov", "2023-11-01", "2025-03-31"); + // expect(result).toEqual(["2024Nov", "2025Nov"]); + // }); +}); + +// Quarterly +describe("buildAllPossiblePeriods - Quarterly", () => { + it("generates Quarterly periods", () => { + const result = buildAllPossiblePeriods("Quarterly", "2023-12-01", "2024-12-31"); + expect(result).toEqual(["2023Q4", "2024Q1", "2024Q2", "2024Q3", "2024Q4"]); + }); +}); diff --git a/src/webapp/utils/periods.js b/src/webapp/utils/periods.js index 9ada6849..0ecb41ea 100644 --- a/src/webapp/utils/periods.js +++ b/src/webapp/utils/periods.js @@ -23,9 +23,11 @@ export function buildAllPossiblePeriods(periodType, startDate, endDate) { format = "YYYY"; break; case "Weekly": - unit = "weeks"; - format = "YYYY[W]W"; - break; + case "WeeklyWednesday": + case "WeeklyThursday": + case "WeeklySaturday": + case "WeeklySunday": + return generateWeeklyPeriods(periodType, startDate, endDate); case "Quarterly": unit = "quarters"; format = "YYYY[Q]Q"; @@ -45,3 +47,36 @@ function generateDatesByPeriod(options) { } return dates; } + +function getWeekStartDay(periodType) { + const dayMap = { + Weekly: 1, + WeeklyWednesday: 3, + WeeklyThursday: 4, + WeeklySaturday: 6, + WeeklySunday: 0, + }; + return dayMap[periodType] ?? 1; +} + +function generateWeeklyPeriods(periodType, startDate, endDate) { + const dates = []; + const start = moment(startDate); + const end = moment(endDate); + const startDay = getWeekStartDay(periodType); + + const current = moment(start).isoWeekday(startDay); + if (current.isAfter(start)) { + current.subtract(1, "week"); + } + + const formatSuffix = periodType === "Weekly" ? "W" : periodType.replace("Weekly", "").substr(0, 3) + "W"; + + while (current.isSameOrBefore(end)) { + const weekNum = current.isoWeek(); + dates.push(`${current.isoWeekYear()}${formatSuffix}${weekNum}`); + current.add(1, "week"); + } + + return dates; +} From 78f7a4631fd4059fbb48115cf99a9ad9b7b8c8dd Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:04:04 +0100 Subject: [PATCH 02/11] feat: add SixMonthly period and test --- src/test/utils/periods.spec.js | 13 +++++++++++++ src/webapp/utils/periods.js | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/test/utils/periods.spec.js b/src/test/utils/periods.spec.js index b408bcb4..9b3bd19e 100644 --- a/src/test/utils/periods.spec.js +++ b/src/test/utils/periods.spec.js @@ -85,3 +85,16 @@ describe("buildAllPossiblePeriods - Quarterly", () => { expect(result).toEqual(["2023Q4", "2024Q1", "2024Q2", "2024Q3", "2024Q4"]); }); }); + +// SixMonthly +describe("buildAllPossiblePeriods - SixMonthly", () => { + it("generates periods within same year", () => { + const result = buildAllPossiblePeriods("SixMonthly", "2024-01-01", "2024-12-31"); + expect(result).toEqual(["2024S1", "2024S2"]); + }); + + it("generates periods across multiple years", () => { + const result = buildAllPossiblePeriods("SixMonthly", "2023-06-01", "2024-12-31"); + expect(result).toEqual(["2023S1", "2023S2", "2024S1", "2024S2"]); + }); +}); diff --git a/src/webapp/utils/periods.js b/src/webapp/utils/periods.js index 0ecb41ea..885a2c18 100644 --- a/src/webapp/utils/periods.js +++ b/src/webapp/utils/periods.js @@ -32,6 +32,8 @@ export function buildAllPossiblePeriods(periodType, startDate, endDate) { unit = "quarters"; format = "YYYY[Q]Q"; break; + case "SixMonthly": + return generateSixMonthlyPeriods(startDate, endDate); default: throw new Error("Unsupported period type"); } @@ -80,3 +82,20 @@ function generateWeeklyPeriods(periodType, startDate, endDate) { return dates; } + +function generateSixMonthlyPeriods(startDate, endDate) { + const dates = []; + const start = moment(startDate); + const end = moment(endDate); + + const startMonth = start.month() < 6 ? 0 : 6; + const current = moment(start).month(startMonth).startOf("month"); + + while (current.isSameOrBefore(end)) { + const half = current.month() < 6 ? 1 : 2; + dates.push(`${current.year()}S${half}`); + current.add(6, "months"); + } + + return dates; +} From 04e8ead6677233b14a3b4159beb26249a8ba8ff9 Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:07:33 +0100 Subject: [PATCH 03/11] feat: add SixMonthlyApril and Nov and tests --- src/test/utils/periods.spec.js | 24 +++++++++++++++ src/webapp/utils/periods.js | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/test/utils/periods.spec.js b/src/test/utils/periods.spec.js index 9b3bd19e..510709fe 100644 --- a/src/test/utils/periods.spec.js +++ b/src/test/utils/periods.spec.js @@ -98,3 +98,27 @@ describe("buildAllPossiblePeriods - SixMonthly", () => { expect(result).toEqual(["2023S1", "2023S2", "2024S1", "2024S2"]); }); }); + +describe("buildAllPossiblePeriods - SixMonthlyApril", () => { + it("generates periods within same year", () => { + const result = buildAllPossiblePeriods("SixMonthlyApril", "2024-04-01", "2024-09-30"); + expect(result).toEqual(["2024AprilS1"]); + }); + + it("generates periods across multiple years", () => { + const result = buildAllPossiblePeriods("SixMonthlyApril", "2022-10-01", "2024-09-30"); + expect(result).toEqual(["2022AprilS2", "2023AprilS1", "2023AprilS2", "2024AprilS1"]); + }); +}); + +describe("buildAllPossiblePeriods - SixMonthlyNov", () => { + it("generates periods within same year", () => { + const result = buildAllPossiblePeriods("SixMonthlyNov", "2025-01-01", "2025-04-30"); + expect(result).toEqual(["2025NovS1"]); + }); + + it("generates periods across multiple years", () => { + const result = buildAllPossiblePeriods("SixMonthlyNov", "2023-10-01", "2024-12-31"); + expect(result).toEqual(["2023NovS2", "2024NovS1", "2024NovS2", "2025NovS1"]); + }); +}); diff --git a/src/webapp/utils/periods.js b/src/webapp/utils/periods.js index 885a2c18..cad85674 100644 --- a/src/webapp/utils/periods.js +++ b/src/webapp/utils/periods.js @@ -34,6 +34,10 @@ export function buildAllPossiblePeriods(periodType, startDate, endDate) { break; case "SixMonthly": return generateSixMonthlyPeriods(startDate, endDate); + case "SixMonthlyApril": + return generateSixMonthlyAprilPeriods(startDate, endDate); + case "SixMonthlyNov": + return generateSixMonthlyNovPeriods(startDate, endDate); default: throw new Error("Unsupported period type"); } @@ -99,3 +103,53 @@ function generateSixMonthlyPeriods(startDate, endDate) { return dates; } + +function generateSixMonthlyAprilPeriods(startDate, endDate) { + const dates = []; + const start = moment(startDate); + const end = moment(endDate); + + const year = start.month() >= 3 ? start.year() : start.year() - 1; + const current = moment({ year: year, month: 3, day: 1 }); + + while (current.isSameOrBefore(end)) { + const periodEnd = moment(current).add(6, "months").subtract(1, "day"); + if (periodEnd.isSameOrAfter(start)) { + const displayYear = current.year(); + const semester = current.month() === 3 ? 1 : 2; + dates.push(`${displayYear}AprilS${semester}`); + } + current.add(6, "months"); + } + + return dates; +} + +function generateSixMonthlyNovPeriods(startDate, endDate) { + const dates = []; + const start = moment(startDate); + const end = moment(endDate); + + let current; + if (start.month() >= 5) { + current = moment({ year: start.year(), month: 4, date: 1 }); + } else { + current = moment({ year: start.year() - 1, month: 10, date: 1 }); + } + + while (current.isSameOrBefore(end)) { + const periodEnd = moment(current).add(6, "months").subtract(1, "day"); + + if (periodEnd.isSameOrAfter(start)) { + const isNovStart = current.month() === 10; + const semester = isNovStart ? 1 : 2; + const displayYear = isNovStart ? current.year() + 1 : current.year(); + + dates.push(`${displayYear}NovS${semester}`); + } + + current.add(6, "months"); + } + + return dates; +} From 0f6cea491fbfc93d17b490853231d6a4eee7e3d6 Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:09:23 +0100 Subject: [PATCH 04/11] feat: add BiWeekly and Nov and tests --- src/test/utils/periods.spec.js | 8 ++++++++ src/webapp/utils/periods.js | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/test/utils/periods.spec.js b/src/test/utils/periods.spec.js index 510709fe..157dc67d 100644 --- a/src/test/utils/periods.spec.js +++ b/src/test/utils/periods.spec.js @@ -122,3 +122,11 @@ describe("buildAllPossiblePeriods - SixMonthlyNov", () => { expect(result).toEqual(["2023NovS2", "2024NovS1", "2024NovS2", "2025NovS1"]); }); }); + +// BiWeekly +describe("buildAllPossiblePeriods - BiWeekly", () => { + it("generates biweekly periods across multiple years", () => { + const result = buildAllPossiblePeriods("BiWeekly", "2024-12-01", "2025-01-31"); + expect(result).toEqual(["2024BiW24", "2024BiW25", "2024BiW26", "2025BiW1", "2025BiW2", "2025BiW3"]); + }); +}); diff --git a/src/webapp/utils/periods.js b/src/webapp/utils/periods.js index cad85674..f908a885 100644 --- a/src/webapp/utils/periods.js +++ b/src/webapp/utils/periods.js @@ -28,6 +28,8 @@ export function buildAllPossiblePeriods(periodType, startDate, endDate) { case "WeeklySaturday": case "WeeklySunday": return generateWeeklyPeriods(periodType, startDate, endDate); + case "BiWeekly": + return generateBiWeeklyPeriods(startDate, endDate); case "Quarterly": unit = "quarters"; format = "YYYY[Q]Q"; @@ -87,6 +89,31 @@ function generateWeeklyPeriods(periodType, startDate, endDate) { return dates; } +function getBiWeekStartFromIsoWeek(startDate) { + const start = moment(startDate); + const isoWeekNumber = start.isoWeek(); + const startWeek = isoWeekNumber % 2 === 0 ? isoWeekNumber - 1 : isoWeekNumber; + return moment(start).isoWeekYear(start.isoWeekYear()).isoWeek(startWeek).startOf("isoWeek"); +} + +function generateBiWeeklyPeriods(startDate, endDate) { + const dates = []; + const start = getBiWeekStartFromIsoWeek(startDate); + const end = moment(endDate); + + const current = start.clone(); + + while (current.isSameOrBefore(end)) { + const isoWeek = current.isoWeek(); + const biWeekNum = Math.ceil(isoWeek / 2); + + dates.push(`${current.isoWeekYear()}BiW${biWeekNum}`); + current.add(2, "weeks"); + } + + return dates; +} + function generateSixMonthlyPeriods(startDate, endDate) { const dates = []; const start = moment(startDate); From 78db8d91e63f4bab9cc5e4e6d7da6f8ff19a5fb6 Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:10:37 +0100 Subject: [PATCH 05/11] feat: add BiMonthly and Nov and tests --- src/test/utils/periods.spec.js | 17 +++++++++++++++++ src/webapp/utils/periods.js | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/test/utils/periods.spec.js b/src/test/utils/periods.spec.js index 157dc67d..b12f061c 100644 --- a/src/test/utils/periods.spec.js +++ b/src/test/utils/periods.spec.js @@ -130,3 +130,20 @@ describe("buildAllPossiblePeriods - BiWeekly", () => { expect(result).toEqual(["2024BiW24", "2024BiW25", "2024BiW26", "2025BiW1", "2025BiW2", "2025BiW3"]); }); }); + +// BiMonthly +describe("buildAllPossiblePeriods - BiMonthly", () => { + it("generates bimontly periods across multiple years", () => { + const result = buildAllPossiblePeriods("BiMonthly", "2023-01-01", "2024-03-31"); + expect(result).toEqual([ + "202301B", + "202302B", + "202303B", + "202304B", + "202305B", + "202306B", + "202401B", + "202402B", + ]); + }); +}); diff --git a/src/webapp/utils/periods.js b/src/webapp/utils/periods.js index f908a885..7de93b57 100644 --- a/src/webapp/utils/periods.js +++ b/src/webapp/utils/periods.js @@ -30,6 +30,8 @@ export function buildAllPossiblePeriods(periodType, startDate, endDate) { return generateWeeklyPeriods(periodType, startDate, endDate); case "BiWeekly": return generateBiWeeklyPeriods(startDate, endDate); + case "BiMonthly": + return generateBiMonthlyPeriods(startDate, endDate); case "Quarterly": unit = "quarters"; format = "YYYY[Q]Q"; @@ -114,6 +116,23 @@ function generateBiWeeklyPeriods(startDate, endDate) { return dates; } +function generateBiMonthlyPeriods(startDate, endDate) { + const dates = []; + const start = moment(startDate); + const end = moment(endDate); + + const startMonth = Math.floor(start.month() / 2) * 2; + const current = moment(start).month(startMonth).startOf("month"); + + while (current.isSameOrBefore(end)) { + const biMonthNum = Math.floor(current.month() / 2) + 1; + dates.push(`${current.year()}${String(biMonthNum).padStart(2, "0")}B`); + current.add(2, "months"); + } + + return dates; +} + function generateSixMonthlyPeriods(startDate, endDate) { const dates = []; const start = moment(startDate); From e2d67f35c46f455881c4c4dacfb31cec8ff421ae Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:11:41 +0100 Subject: [PATCH 06/11] feat: add QuarterlyNov and Nov and tests --- src/test/utils/periods.spec.js | 7 ++++++- src/webapp/utils/periods.js | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/test/utils/periods.spec.js b/src/test/utils/periods.spec.js index b12f061c..e8c2e0ad 100644 --- a/src/test/utils/periods.spec.js +++ b/src/test/utils/periods.spec.js @@ -78,12 +78,17 @@ describe("buildAllPossiblePeriods - Yearly", () => { // }); }); -// Quarterly +// Quarterly Variants describe("buildAllPossiblePeriods - Quarterly", () => { it("generates Quarterly periods", () => { const result = buildAllPossiblePeriods("Quarterly", "2023-12-01", "2024-12-31"); expect(result).toEqual(["2023Q4", "2024Q1", "2024Q2", "2024Q3", "2024Q4"]); }); + + it("generates QuarterlyNov periods", () => { + const result = buildAllPossiblePeriods("QuarterlyNov", "2023-10-01", "2024-08-31"); + expect(result).toEqual(["2023NovQ4", "2024NovQ1", "2024NovQ2", "2024NovQ3", "2024NovQ4"]); + }); }); // SixMonthly diff --git a/src/webapp/utils/periods.js b/src/webapp/utils/periods.js index 7de93b57..69f799a2 100644 --- a/src/webapp/utils/periods.js +++ b/src/webapp/utils/periods.js @@ -36,6 +36,8 @@ export function buildAllPossiblePeriods(periodType, startDate, endDate) { unit = "quarters"; format = "YYYY[Q]Q"; break; + case "QuarterlyNov": + return generateQuarterlyNovPeriods(startDate, endDate); case "SixMonthly": return generateSixMonthlyPeriods(startDate, endDate); case "SixMonthlyApril": @@ -133,6 +135,27 @@ function generateBiMonthlyPeriods(startDate, endDate) { return dates; } +function generateQuarterlyNovPeriods(startDate, endDate) { + const dates = []; + const start = moment(startDate); + const end = moment(endDate); + + const current = start.add(2, "months").startOf("quarter").subtract(2, "months"); + + while (current.isSameOrBefore(end)) { + const year = current.year(); + if (current.month() >= 10) { + dates.push(`${year + 1}NovQ${1}`); + } else { + const quarter = Math.floor((current.month() + 2) / 3) + 1; + dates.push(`${year}NovQ${quarter}`); + } + current.add(3, "months"); + } + + return dates; +} + function generateSixMonthlyPeriods(startDate, endDate) { const dates = []; const start = moment(startDate); From 85c2dec6a2c0b2e96983b2b46abe032201b94db9 Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:30:45 +0100 Subject: [PATCH 07/11] feat: add Financial periods --- src/test/utils/periods.spec.js | 41 +++++++++++++++++----------------- src/webapp/utils/periods.js | 33 ++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/test/utils/periods.spec.js b/src/test/utils/periods.spec.js index e8c2e0ad..06883f68 100644 --- a/src/test/utils/periods.spec.js +++ b/src/test/utils/periods.spec.js @@ -56,26 +56,27 @@ describe("buildAllPossiblePeriods - Yearly", () => { expect(result).toEqual(["2020", "2021", "2022", "2023", "2024"]); }); - // Current financial variants dont behave like DHIS2 does, they are yearly periods - // it("generates FinancialApril periods", () => { - // const result = buildAllPossiblePeriods("FinancialApril", "2023-04-01", "2025-06-30"); - // expect(result).toEqual(["2024April", "2025April"]); - // }); - - // it("generates FinancialJuly periods", () => { - // const result = buildAllPossiblePeriods("FinancialJuly", "2023-07-01", "2025-09-30"); - // expect(result).toEqual(["2024July", "2025July"]); - // }); - - // it("generates FinancialOct periods", () => { - // const result = buildAllPossiblePeriods("FinancialOct", "2023-10-01", "2025-12-31"); - // expect(result).toEqual(["2024Oct", "2025Oct"]); - // }); - - // it("generates FinancialNov periods", () => { - // const result = buildAllPossiblePeriods("FinancialNov", "2023-11-01", "2025-03-31"); - // expect(result).toEqual(["2024Nov", "2025Nov"]); - // }); + it("generates FinancialApril periods", () => { + const result = buildAllPossiblePeriods("FinancialApril", "2023-03-01", "2025-06-30"); + expect(result).toEqual(["2023April", "2024April"]); + }); + + it("generates FinancialJuly periods", () => { + const result = buildAllPossiblePeriods("FinancialJuly", "2023-06-01", "2025-09-30"); + expect(result).toEqual(["2023July", "2024July"]); + }); + + it("generates FinancialOct periods", () => { + const result = buildAllPossiblePeriods("FinancialOct", "2023-09-01", "2025-12-31"); + expect(result).toEqual(["2023Oct", "2024Oct"]); + }); + + // FinancialNov seems bugged in DHIS2 as its not following the "Financial Year X" starts in Year X convention of the rest + // See: https://dhis2.atlassian.net/browse/DHIS2-18609 + it("generates FinancialNov periods", () => { + const result = buildAllPossiblePeriods("FinancialNov", "2023-10-01", "2025-12-31"); + expect(result).toEqual(["2023Nov", "2024Nov"]); + }); }); // Quarterly Variants diff --git a/src/webapp/utils/periods.js b/src/webapp/utils/periods.js index 69f799a2..abd7958b 100644 --- a/src/webapp/utils/periods.js +++ b/src/webapp/utils/periods.js @@ -1,14 +1,8 @@ import moment from "moment"; -import { getFinancialFormat, isFinancialPeriodType } from "./period"; export function buildAllPossiblePeriods(periodType, startDate, endDate) { let unit, format; - if (isFinancialPeriodType(periodType)) { - const financialUnitFormat = getFinancialFormat(periodType); - return generateDatesByPeriod({ startDate, endDate, ...financialUnitFormat }); - } - switch (periodType) { case "Daily": unit = "days"; @@ -44,6 +38,11 @@ export function buildAllPossiblePeriods(periodType, startDate, endDate) { return generateSixMonthlyAprilPeriods(startDate, endDate); case "SixMonthlyNov": return generateSixMonthlyNovPeriods(startDate, endDate); + case "FinancialApril": + case "FinancialJuly": + case "FinancialOct": + case "FinancialNov": + return generateFinancialPeriods(startDate, endDate, periodType); default: throw new Error("Unsupported period type"); } @@ -222,3 +221,25 @@ function generateSixMonthlyNovPeriods(startDate, endDate) { return dates; } + +function generateFinancialPeriods(startDate, endDate, financialType) { + const dates = []; + const monthName = financialType.replace("Financial", ""); + const startMonthIndex = moment().month(monthName).month(); + const start = moment(startDate); + const end = moment(endDate); + + const firstYear = start.year(); + const lastYear = end.year(); + + for (let year = firstYear; year <= lastYear; year++) { + const periodStart = moment({ year, month: startMonthIndex, date: 1 }); + const periodEnd = moment(periodStart).add(1, "year").subtract(1, "day"); + + if (periodStart.isSameOrAfter(start) && periodEnd.isSameOrBefore(end)) { + dates.push(`${year}${monthName}`); + } + } + + return dates; +} From e3382da57e94395d2694c9c9948066d3ec931b40 Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Tue, 13 Jan 2026 09:50:51 +0100 Subject: [PATCH 08/11] feat: switch periods.js to ts. --- src/domain/entities/DataForm.ts | 21 +++++++- .../{periods.spec.js => periods.spec.ts} | 47 ++++++++--------- src/webapp/utils/period.ts | 13 ----- src/webapp/utils/{periods.js => periods.ts} | 51 +++++++++++++------ 4 files changed, 79 insertions(+), 53 deletions(-) rename src/test/utils/{periods.spec.js => periods.spec.ts} (72%) delete mode 100644 src/webapp/utils/period.ts rename src/webapp/utils/{periods.js => periods.ts} (80%) diff --git a/src/domain/entities/DataForm.ts b/src/domain/entities/DataForm.ts index c26b0835..d76d6fff 100644 --- a/src/domain/entities/DataForm.ts +++ b/src/domain/entities/DataForm.ts @@ -10,7 +10,26 @@ export const dataFormTypeMap = { } as const; export const dataFormTypes = Object.values(dataFormTypeMap) as typeof dataFormTypeMap[keyof typeof dataFormTypeMap][]; export type DataFormType = typeof dataFormTypes[number]; -export type DataFormPeriod = "Daily" | "Monthly" | "Yearly" | "Weekly" | "Quarterly"; +export type DataFormPeriod = + | "Daily" + | "Monthly" + | "Yearly" + | "Weekly" + | "Quarterly" + | "BiWeekly" + | "BiMonthly" + | "WeeklyWednesday" + | "WeeklyThursday" + | "WeeklySaturday" + | "WeeklySunday" + | "QuarterlyNov" + | "SixMonthly" + | "SixMonthlyApril" + | "SixMonthlyNov" + | "FinancialApril" + | "FinancialJuly" + | "FinancialOct" + | "FinancialNov"; export function getTranslations() { return { diff --git a/src/test/utils/periods.spec.js b/src/test/utils/periods.spec.ts similarity index 72% rename from src/test/utils/periods.spec.js rename to src/test/utils/periods.spec.ts index 06883f68..68d8d2df 100644 --- a/src/test/utils/periods.spec.js +++ b/src/test/utils/periods.spec.ts @@ -1,10 +1,11 @@ +import moment from "moment"; import { describe, it, expect } from "@jest/globals"; import { buildAllPossiblePeriods } from "../../webapp/utils/periods"; // Daily describe("buildAllPossiblePeriods - Daily", () => { it("generates daily periods", () => { - const result = buildAllPossiblePeriods("Daily", "2024-01-27", "2024-02-02"); + const result = buildAllPossiblePeriods("Daily", moment("2024-01-27"), moment("2024-02-02")); expect(result).toEqual(["20240127", "20240128", "20240129", "20240130", "20240131", "20240201", "20240202"]); }); }); @@ -12,31 +13,31 @@ describe("buildAllPossiblePeriods - Daily", () => { // Weekly variants describe("buildAllPossiblePeriods - Weekly variants", () => { it("Weekly generates period for sub-week range", () => { - const result = buildAllPossiblePeriods("Weekly", "2024-12-30", "2025-01-04"); + const result = buildAllPossiblePeriods("Weekly", moment("2024-12-30"), moment("2025-01-04")); expect(result).toEqual(["2025W1"]); }); it("Weekly generates weeks", () => { - const result = buildAllPossiblePeriods("Weekly", "2024-12-01", "2025-01-15"); + const result = buildAllPossiblePeriods("Weekly", moment("2024-12-01"), moment("2025-01-15")); expect(result).toEqual(["2024W48", "2024W49", "2024W50", "2024W51", "2024W52", "2025W1", "2025W2", "2025W3"]); }); it("WeeklyWednesday generates Wed-based weeks", () => { - const result = buildAllPossiblePeriods("WeeklyWednesday", "2024-01-01", "2024-01-31"); + const result = buildAllPossiblePeriods("WeeklyWednesday", moment("2024-01-01"), moment("2024-01-31")); expect(result).toEqual(["2023WedW52", "2024WedW1", "2024WedW2", "2024WedW3", "2024WedW4", "2024WedW5"]); }); it("WeeklyThursday generates Thu-based weeks", () => { - const result = buildAllPossiblePeriods("WeeklyThursday", "2024-01-01", "2024-01-31"); + const result = buildAllPossiblePeriods("WeeklyThursday", moment("2024-01-01"), moment("2024-01-31")); expect(result).toEqual(["2023ThuW52", "2024ThuW1", "2024ThuW2", "2024ThuW3", "2024ThuW4"]); }); it("WeeklySaturday generates Sat-based weeks", () => { - const result = buildAllPossiblePeriods("WeeklySaturday", "2024-01-01", "2024-01-31"); + const result = buildAllPossiblePeriods("WeeklySaturday", moment("2024-01-01"), moment("2024-01-31")); expect(result).toEqual(["2023SatW52", "2024SatW1", "2024SatW2", "2024SatW3", "2024SatW4"]); }); it("WeeklySunday generates Sun-based weeks", () => { - const result = buildAllPossiblePeriods("WeeklySunday", "2024-01-01", "2024-01-31"); + const result = buildAllPossiblePeriods("WeeklySunday", moment("2024-01-01"), moment("2024-01-31")); expect(result).toEqual(["2023SunW52", "2024SunW1", "2024SunW2", "2024SunW3", "2024SunW4"]); }); }); @@ -44,7 +45,7 @@ describe("buildAllPossiblePeriods - Weekly variants", () => { // Monthly describe("buildAllPossiblePeriods - Monthly", () => { it("generates monthly periods", () => { - const result = buildAllPossiblePeriods("Monthly", "2023-11-01", "2024-03-31"); + const result = buildAllPossiblePeriods("Monthly", moment("2023-11-01"), moment("2024-03-31")); expect(result).toEqual(["202311", "202312", "202401", "202402", "202403"]); }); }); @@ -52,29 +53,29 @@ describe("buildAllPossiblePeriods - Monthly", () => { // Yearly variants describe("buildAllPossiblePeriods - Yearly", () => { it("generates yearly periods", () => { - const result = buildAllPossiblePeriods("Yearly", "2020-01-01", "2024-12-31"); + const result = buildAllPossiblePeriods("Yearly", moment("2020-01-01"), moment("2024-12-31")); expect(result).toEqual(["2020", "2021", "2022", "2023", "2024"]); }); it("generates FinancialApril periods", () => { - const result = buildAllPossiblePeriods("FinancialApril", "2023-03-01", "2025-06-30"); + const result = buildAllPossiblePeriods("FinancialApril", moment("2023-03-01"), moment("2025-06-30")); expect(result).toEqual(["2023April", "2024April"]); }); it("generates FinancialJuly periods", () => { - const result = buildAllPossiblePeriods("FinancialJuly", "2023-06-01", "2025-09-30"); + const result = buildAllPossiblePeriods("FinancialJuly", moment("2023-06-01"), moment("2025-09-30")); expect(result).toEqual(["2023July", "2024July"]); }); it("generates FinancialOct periods", () => { - const result = buildAllPossiblePeriods("FinancialOct", "2023-09-01", "2025-12-31"); + const result = buildAllPossiblePeriods("FinancialOct", moment("2023-09-01"), moment("2025-12-31")); expect(result).toEqual(["2023Oct", "2024Oct"]); }); // FinancialNov seems bugged in DHIS2 as its not following the "Financial Year X" starts in Year X convention of the rest // See: https://dhis2.atlassian.net/browse/DHIS2-18609 it("generates FinancialNov periods", () => { - const result = buildAllPossiblePeriods("FinancialNov", "2023-10-01", "2025-12-31"); + const result = buildAllPossiblePeriods("FinancialNov", moment("2023-10-01"), moment("2025-12-31")); expect(result).toEqual(["2023Nov", "2024Nov"]); }); }); @@ -82,12 +83,12 @@ describe("buildAllPossiblePeriods - Yearly", () => { // Quarterly Variants describe("buildAllPossiblePeriods - Quarterly", () => { it("generates Quarterly periods", () => { - const result = buildAllPossiblePeriods("Quarterly", "2023-12-01", "2024-12-31"); + const result = buildAllPossiblePeriods("Quarterly", moment("2023-12-01"), moment("2024-12-31")); expect(result).toEqual(["2023Q4", "2024Q1", "2024Q2", "2024Q3", "2024Q4"]); }); it("generates QuarterlyNov periods", () => { - const result = buildAllPossiblePeriods("QuarterlyNov", "2023-10-01", "2024-08-31"); + const result = buildAllPossiblePeriods("QuarterlyNov", moment("2023-10-01"), moment("2024-08-31")); expect(result).toEqual(["2023NovQ4", "2024NovQ1", "2024NovQ2", "2024NovQ3", "2024NovQ4"]); }); }); @@ -95,36 +96,36 @@ describe("buildAllPossiblePeriods - Quarterly", () => { // SixMonthly describe("buildAllPossiblePeriods - SixMonthly", () => { it("generates periods within same year", () => { - const result = buildAllPossiblePeriods("SixMonthly", "2024-01-01", "2024-12-31"); + const result = buildAllPossiblePeriods("SixMonthly", moment("2024-01-01"), moment("2024-12-31")); expect(result).toEqual(["2024S1", "2024S2"]); }); it("generates periods across multiple years", () => { - const result = buildAllPossiblePeriods("SixMonthly", "2023-06-01", "2024-12-31"); + const result = buildAllPossiblePeriods("SixMonthly", moment("2023-06-01"), moment("2024-12-31")); expect(result).toEqual(["2023S1", "2023S2", "2024S1", "2024S2"]); }); }); describe("buildAllPossiblePeriods - SixMonthlyApril", () => { it("generates periods within same year", () => { - const result = buildAllPossiblePeriods("SixMonthlyApril", "2024-04-01", "2024-09-30"); + const result = buildAllPossiblePeriods("SixMonthlyApril", moment("2024-04-01"), moment("2024-09-30")); expect(result).toEqual(["2024AprilS1"]); }); it("generates periods across multiple years", () => { - const result = buildAllPossiblePeriods("SixMonthlyApril", "2022-10-01", "2024-09-30"); + const result = buildAllPossiblePeriods("SixMonthlyApril", moment("2022-10-01"), moment("2024-09-30")); expect(result).toEqual(["2022AprilS2", "2023AprilS1", "2023AprilS2", "2024AprilS1"]); }); }); describe("buildAllPossiblePeriods - SixMonthlyNov", () => { it("generates periods within same year", () => { - const result = buildAllPossiblePeriods("SixMonthlyNov", "2025-01-01", "2025-04-30"); + const result = buildAllPossiblePeriods("SixMonthlyNov", moment("2025-01-01"), moment("2025-04-30")); expect(result).toEqual(["2025NovS1"]); }); it("generates periods across multiple years", () => { - const result = buildAllPossiblePeriods("SixMonthlyNov", "2023-10-01", "2024-12-31"); + const result = buildAllPossiblePeriods("SixMonthlyNov", moment("2023-10-01"), moment("2024-12-31")); expect(result).toEqual(["2023NovS2", "2024NovS1", "2024NovS2", "2025NovS1"]); }); }); @@ -132,7 +133,7 @@ describe("buildAllPossiblePeriods - SixMonthlyNov", () => { // BiWeekly describe("buildAllPossiblePeriods - BiWeekly", () => { it("generates biweekly periods across multiple years", () => { - const result = buildAllPossiblePeriods("BiWeekly", "2024-12-01", "2025-01-31"); + const result = buildAllPossiblePeriods("BiWeekly", moment("2024-12-01"), moment("2025-01-31")); expect(result).toEqual(["2024BiW24", "2024BiW25", "2024BiW26", "2025BiW1", "2025BiW2", "2025BiW3"]); }); }); @@ -140,7 +141,7 @@ describe("buildAllPossiblePeriods - BiWeekly", () => { // BiMonthly describe("buildAllPossiblePeriods - BiMonthly", () => { it("generates bimontly periods across multiple years", () => { - const result = buildAllPossiblePeriods("BiMonthly", "2023-01-01", "2024-03-31"); + const result = buildAllPossiblePeriods("BiMonthly", moment("2023-01-01"), moment("2024-03-31")); expect(result).toEqual([ "202301B", "202302B", diff --git a/src/webapp/utils/period.ts b/src/webapp/utils/period.ts deleted file mode 100644 index 9b1cabbe..00000000 --- a/src/webapp/utils/period.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function isFinancialPeriodType(periodType: FinancialPeriodType): boolean { - return periodType.startsWith("Financial"); -} - -export function getFinancialFormat(periodType: FinancialPeriodType): MomentUnitFormat { - return { unit: "years", format: `YYYY[${periodType.replace("Financial", "")}]` }; -} - -export type FinancialPeriodType = "FinancialApril" | "FinancialJuly" | "FinancialOct" | "FinancialNov"; -export type MomentUnitFormat = { - unit: string; - format: string; -}; diff --git a/src/webapp/utils/periods.js b/src/webapp/utils/periods.ts similarity index 80% rename from src/webapp/utils/periods.js rename to src/webapp/utils/periods.ts index abd7958b..a12f4b46 100644 --- a/src/webapp/utils/periods.js +++ b/src/webapp/utils/periods.ts @@ -1,7 +1,24 @@ -import moment from "moment"; +import moment, { Moment } from "moment"; +import { DataFormPeriod } from "../../domain/entities/DataForm"; + +interface Options { + startDate: Moment; + endDate: Moment; + format: string; + unit: moment.unitOfTime.DurationConstructor; +} + +export function buildAllPossiblePeriods( + periodType: DataFormPeriod, + startDate: Moment | undefined, + endDate: Moment | undefined +): string[] { + if (!startDate || !endDate) { + return []; + } -export function buildAllPossiblePeriods(periodType, startDate, endDate) { - let unit, format; + let unit: moment.unitOfTime.DurationConstructor; + let format: string; switch (periodType) { case "Daily": @@ -50,7 +67,7 @@ export function buildAllPossiblePeriods(periodType, startDate, endDate) { return generateDatesByPeriod({ startDate, endDate, format, unit }); } -function generateDatesByPeriod(options) { +function generateDatesByPeriod(options: Options) { const { startDate, endDate, unit, format } = options; const dates = []; for (const current = moment(startDate); current.isSameOrBefore(moment(endDate)); current.add(1, unit)) { @@ -59,7 +76,8 @@ function generateDatesByPeriod(options) { return dates; } -function getWeekStartDay(periodType) { +type WeeklyPeriodType = "Weekly" | "WeeklyWednesday" | "WeeklyThursday" | "WeeklySaturday" | "WeeklySunday"; +function getWeekStartDay(periodType: WeeklyPeriodType) { const dayMap = { Weekly: 1, WeeklyWednesday: 3, @@ -70,7 +88,7 @@ function getWeekStartDay(periodType) { return dayMap[periodType] ?? 1; } -function generateWeeklyPeriods(periodType, startDate, endDate) { +function generateWeeklyPeriods(periodType: WeeklyPeriodType, startDate: Moment, endDate: Moment) { const dates = []; const start = moment(startDate); const end = moment(endDate); @@ -81,7 +99,7 @@ function generateWeeklyPeriods(periodType, startDate, endDate) { current.subtract(1, "week"); } - const formatSuffix = periodType === "Weekly" ? "W" : periodType.replace("Weekly", "").substr(0, 3) + "W"; + const formatSuffix = periodType === "Weekly" ? "W" : periodType.replace("Weekly", "").substring(0, 3) + "W"; while (current.isSameOrBefore(end)) { const weekNum = current.isoWeek(); @@ -92,14 +110,14 @@ function generateWeeklyPeriods(periodType, startDate, endDate) { return dates; } -function getBiWeekStartFromIsoWeek(startDate) { +function getBiWeekStartFromIsoWeek(startDate: Moment) { const start = moment(startDate); const isoWeekNumber = start.isoWeek(); const startWeek = isoWeekNumber % 2 === 0 ? isoWeekNumber - 1 : isoWeekNumber; return moment(start).isoWeekYear(start.isoWeekYear()).isoWeek(startWeek).startOf("isoWeek"); } -function generateBiWeeklyPeriods(startDate, endDate) { +function generateBiWeeklyPeriods(startDate: Moment, endDate: Moment) { const dates = []; const start = getBiWeekStartFromIsoWeek(startDate); const end = moment(endDate); @@ -117,7 +135,7 @@ function generateBiWeeklyPeriods(startDate, endDate) { return dates; } -function generateBiMonthlyPeriods(startDate, endDate) { +function generateBiMonthlyPeriods(startDate: Moment, endDate: Moment) { const dates = []; const start = moment(startDate); const end = moment(endDate); @@ -134,7 +152,7 @@ function generateBiMonthlyPeriods(startDate, endDate) { return dates; } -function generateQuarterlyNovPeriods(startDate, endDate) { +function generateQuarterlyNovPeriods(startDate: Moment, endDate: Moment) { const dates = []; const start = moment(startDate); const end = moment(endDate); @@ -155,7 +173,7 @@ function generateQuarterlyNovPeriods(startDate, endDate) { return dates; } -function generateSixMonthlyPeriods(startDate, endDate) { +function generateSixMonthlyPeriods(startDate: Moment, endDate: Moment) { const dates = []; const start = moment(startDate); const end = moment(endDate); @@ -172,13 +190,13 @@ function generateSixMonthlyPeriods(startDate, endDate) { return dates; } -function generateSixMonthlyAprilPeriods(startDate, endDate) { +function generateSixMonthlyAprilPeriods(startDate: Moment, endDate: Moment) { const dates = []; const start = moment(startDate); const end = moment(endDate); const year = start.month() >= 3 ? start.year() : start.year() - 1; - const current = moment({ year: year, month: 3, day: 1 }); + const current = moment({ year: year, month: 3, date: 1 }); while (current.isSameOrBefore(end)) { const periodEnd = moment(current).add(6, "months").subtract(1, "day"); @@ -193,7 +211,7 @@ function generateSixMonthlyAprilPeriods(startDate, endDate) { return dates; } -function generateSixMonthlyNovPeriods(startDate, endDate) { +function generateSixMonthlyNovPeriods(startDate: Moment, endDate: Moment) { const dates = []; const start = moment(startDate); const end = moment(endDate); @@ -222,7 +240,8 @@ function generateSixMonthlyNovPeriods(startDate, endDate) { return dates; } -function generateFinancialPeriods(startDate, endDate, financialType) { +type FinancialType = "FinancialApril" | "FinancialJuly" | "FinancialOct" | "FinancialNov"; +function generateFinancialPeriods(startDate: Moment, endDate: Moment, financialType: FinancialType) { const dates = []; const monthName = financialType.replace("Financial", ""); const startMonthIndex = moment().month(monthName).month(); From 7d51393b58c0ea223f27f1298805be0f337eab23 Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:25:26 +0100 Subject: [PATCH 09/11] feat: add input validation tests, handle undefined periodType. --- src/test/utils/periods.spec.ts | 34 ++++++++++++++++++++++++++++++++++ src/webapp/utils/periods.ts | 4 +++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/test/utils/periods.spec.ts b/src/test/utils/periods.spec.ts index 68d8d2df..c49d232d 100644 --- a/src/test/utils/periods.spec.ts +++ b/src/test/utils/periods.spec.ts @@ -2,6 +2,40 @@ import moment from "moment"; import { describe, it, expect } from "@jest/globals"; import { buildAllPossiblePeriods } from "../../webapp/utils/periods"; +// Input validation +describe("buildAllPossiblePeriods - Input validation", () => { + it("returns empty array if startDate is missing", () => { + const result = buildAllPossiblePeriods("Monthly", undefined, moment("2024-12-31")); + expect(result).toEqual([]); + }); + + it("returns empty array if endDate is missing", () => { + const result = buildAllPossiblePeriods("Monthly", moment("2024-01-01"), undefined); + expect(result).toEqual([]); + }); + + it("returns empty array if both dates are missing", () => { + const result = buildAllPossiblePeriods("Monthly", undefined, undefined); + expect(result).toEqual([]); + }); + + it("returns empty array if startDate is after endDate", () => { + const result = buildAllPossiblePeriods("Monthly", moment("2024-12-31"), moment("2024-01-01")); + expect(result).toEqual([]); + }); + + it("returns empty array if periodType is undefined", () => { + const result = buildAllPossiblePeriods(undefined, moment("2024-01-01"), moment("2024-12-31")); + expect(result).toEqual([]); + }); + + it("throws error for unsupported period type", () => { + expect(() => + buildAllPossiblePeriods("UnsupportedType" as any, moment("2024-01-01"), moment("2024-12-31")) + ).toThrow("Unsupported period type"); + }); +}); + // Daily describe("buildAllPossiblePeriods - Daily", () => { it("generates daily periods", () => { diff --git a/src/webapp/utils/periods.ts b/src/webapp/utils/periods.ts index a12f4b46..fa454016 100644 --- a/src/webapp/utils/periods.ts +++ b/src/webapp/utils/periods.ts @@ -9,7 +9,7 @@ interface Options { } export function buildAllPossiblePeriods( - periodType: DataFormPeriod, + periodType: DataFormPeriod | undefined, startDate: Moment | undefined, endDate: Moment | undefined ): string[] { @@ -21,6 +21,8 @@ export function buildAllPossiblePeriods( let format: string; switch (periodType) { + case undefined: + return []; case "Daily": unit = "days"; format = "YYYYMMDD"; From c85884fba47b92597be5cdd665311360b99b1b46 Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:53:04 +0100 Subject: [PATCH 10/11] refactor: avoid variable mutation, add typing. --- src/webapp/utils/periods.ts | 218 +++++++++++++++++------------------- 1 file changed, 100 insertions(+), 118 deletions(-) diff --git a/src/webapp/utils/periods.ts b/src/webapp/utils/periods.ts index fa454016..a4c3089d 100644 --- a/src/webapp/utils/periods.ts +++ b/src/webapp/utils/periods.ts @@ -17,24 +17,15 @@ export function buildAllPossiblePeriods( return []; } - let unit: moment.unitOfTime.DurationConstructor; - let format: string; - switch (periodType) { case undefined: return []; case "Daily": - unit = "days"; - format = "YYYYMMDD"; - break; + return generateDatesByPeriod({ startDate, endDate, format: "YYYYMMDD", unit: "days" }); case "Monthly": - unit = "months"; - format = "YYYYMM"; - break; + return generateDatesByPeriod({ startDate, endDate, format: "YYYYMM", unit: "months" }); case "Yearly": - unit = "years"; - format = "YYYY"; - break; + return generateDatesByPeriod({ startDate, endDate, format: "YYYY", unit: "years" }); case "Weekly": case "WeeklyWednesday": case "WeeklyThursday": @@ -46,9 +37,7 @@ export function buildAllPossiblePeriods( case "BiMonthly": return generateBiMonthlyPeriods(startDate, endDate); case "Quarterly": - unit = "quarters"; - format = "YYYY[Q]Q"; - break; + return generateDatesByPeriod({ startDate, endDate, format: "YYYY[Q]Q", unit: "quarters" }); case "QuarterlyNov": return generateQuarterlyNovPeriods(startDate, endDate); case "SixMonthly": @@ -65,21 +54,24 @@ export function buildAllPossiblePeriods( default: throw new Error("Unsupported period type"); } - - return generateDatesByPeriod({ startDate, endDate, format, unit }); } -function generateDatesByPeriod(options: Options) { +function generateDatesByPeriod(options: Options): string[] { const { startDate, endDate, unit, format } = options; - const dates = []; - for (const current = moment(startDate); current.isSameOrBefore(moment(endDate)); current.add(1, unit)) { + const dates: string[] = []; + + for ( + let current: Moment = startDate.clone(); + current.isSameOrBefore(endDate); + current = current.clone().add(1, unit) + ) { dates.push(current.format(format)); } return dates; } type WeeklyPeriodType = "Weekly" | "WeeklyWednesday" | "WeeklyThursday" | "WeeklySaturday" | "WeeklySunday"; -function getWeekStartDay(periodType: WeeklyPeriodType) { +function getWeekStartDay(periodType: WeeklyPeriodType): number { const dayMap = { Weekly: 1, WeeklyWednesday: 3, @@ -90,174 +82,164 @@ function getWeekStartDay(periodType: WeeklyPeriodType) { return dayMap[periodType] ?? 1; } -function generateWeeklyPeriods(periodType: WeeklyPeriodType, startDate: Moment, endDate: Moment) { - const dates = []; - const start = moment(startDate); - const end = moment(endDate); - const startDay = getWeekStartDay(periodType); - - const current = moment(start).isoWeekday(startDay); - if (current.isAfter(start)) { - current.subtract(1, "week"); - } +function generateWeeklyPeriods(periodType: WeeklyPeriodType, startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; + const startDay: number = getWeekStartDay(periodType); + const formatSuffix: string = periodType === "Weekly" ? "W" : `${periodType.replace("Weekly", "").substring(0, 3)}W`; - const formatSuffix = periodType === "Weekly" ? "W" : periodType.replace("Weekly", "").substring(0, 3) + "W"; + const alignedStartDate: Moment = startDate.clone().isoWeekday(startDay); + const weeklyStart: Moment = alignedStartDate.isAfter(startDate) + ? alignedStartDate.clone().subtract(1, "week") + : alignedStartDate; - while (current.isSameOrBefore(end)) { - const weekNum = current.isoWeek(); - dates.push(`${current.isoWeekYear()}${formatSuffix}${weekNum}`); - current.add(1, "week"); + for (let current: Moment = weeklyStart; current.isSameOrBefore(endDate); current = current.clone().add(1, "week")) { + dates.push(`${current.isoWeekYear()}${formatSuffix}${current.isoWeek()}`); } return dates; } -function getBiWeekStartFromIsoWeek(startDate: Moment) { - const start = moment(startDate); - const isoWeekNumber = start.isoWeek(); - const startWeek = isoWeekNumber % 2 === 0 ? isoWeekNumber - 1 : isoWeekNumber; - return moment(start).isoWeekYear(start.isoWeekYear()).isoWeek(startWeek).startOf("isoWeek"); +function getBiWeekStartFromIsoWeek(startDate: Moment): Moment { + const isoWeekNumber: number = startDate.isoWeek(); + const startWeek: number = isoWeekNumber % 2 === 0 ? isoWeekNumber - 1 : isoWeekNumber; + return startDate.clone().isoWeekYear(startDate.isoWeekYear()).isoWeek(startWeek).startOf("isoWeek"); } -function generateBiWeeklyPeriods(startDate: Moment, endDate: Moment) { - const dates = []; - const start = getBiWeekStartFromIsoWeek(startDate); - const end = moment(endDate); - - const current = start.clone(); +function generateBiWeeklyPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; + const start: Moment = getBiWeekStartFromIsoWeek(startDate); - while (current.isSameOrBefore(end)) { - const isoWeek = current.isoWeek(); - const biWeekNum = Math.ceil(isoWeek / 2); + for (let current: Moment = start; current.isSameOrBefore(endDate); current = current.clone().add(2, "weeks")) { + const isoWeek: number = current.isoWeek(); + const biWeekNum: number = Math.ceil(isoWeek / 2); dates.push(`${current.isoWeekYear()}BiW${biWeekNum}`); - current.add(2, "weeks"); } return dates; } -function generateBiMonthlyPeriods(startDate: Moment, endDate: Moment) { - const dates = []; - const start = moment(startDate); - const end = moment(endDate); +function generateBiMonthlyPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; - const startMonth = Math.floor(start.month() / 2) * 2; - const current = moment(start).month(startMonth).startOf("month"); + const startMonth: number = Math.floor(startDate.month() / 2) * 2; + const monthBeginningDate: Moment = startDate.clone().month(startMonth).startOf("month"); - while (current.isSameOrBefore(end)) { - const biMonthNum = Math.floor(current.month() / 2) + 1; + for ( + let current: Moment = monthBeginningDate; + current.isSameOrBefore(endDate); + current = current.clone().add(2, "months") + ) { + const biMonthNum: number = Math.floor(current.month() / 2) + 1; dates.push(`${current.year()}${String(biMonthNum).padStart(2, "0")}B`); - current.add(2, "months"); } return dates; } -function generateQuarterlyNovPeriods(startDate: Moment, endDate: Moment) { - const dates = []; - const start = moment(startDate); - const end = moment(endDate); +function generateQuarterlyNovPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; - const current = start.add(2, "months").startOf("quarter").subtract(2, "months"); + const quarterStartDate: Moment = startDate.clone().add(2, "months").startOf("quarter").subtract(2, "months"); - while (current.isSameOrBefore(end)) { - const year = current.year(); + for ( + let current: Moment = quarterStartDate; + current.isSameOrBefore(endDate); + current = current.clone().add(3, "months") + ) { + const year: number = current.year(); if (current.month() >= 10) { dates.push(`${year + 1}NovQ${1}`); } else { - const quarter = Math.floor((current.month() + 2) / 3) + 1; + const quarter: number = Math.floor((current.month() + 2) / 3) + 1; dates.push(`${year}NovQ${quarter}`); } - current.add(3, "months"); } return dates; } -function generateSixMonthlyPeriods(startDate: Moment, endDate: Moment) { - const dates = []; - const start = moment(startDate); - const end = moment(endDate); +function generateSixMonthlyPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; - const startMonth = start.month() < 6 ? 0 : 6; - const current = moment(start).month(startMonth).startOf("month"); + const startMonth: number = startDate.month() < 6 ? 0 : 6; + const sixMonthlyStart: Moment = startDate.clone().month(startMonth).startOf("month"); - while (current.isSameOrBefore(end)) { - const half = current.month() < 6 ? 1 : 2; + for ( + let current: Moment = sixMonthlyStart; + current.isSameOrBefore(endDate); + current = current.clone().add(6, "months") + ) { + const half: number = current.month() < 6 ? 1 : 2; dates.push(`${current.year()}S${half}`); - current.add(6, "months"); } return dates; } -function generateSixMonthlyAprilPeriods(startDate: Moment, endDate: Moment) { - const dates = []; - const start = moment(startDate); - const end = moment(endDate); +function generateSixMonthlyAprilPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; - const year = start.month() >= 3 ? start.year() : start.year() - 1; - const current = moment({ year: year, month: 3, date: 1 }); + const year: number = startDate.month() >= 3 ? startDate.year() : startDate.year() - 1; + const sixMonthlyStart: Moment = moment({ year: year, month: 3, date: 1 }); - while (current.isSameOrBefore(end)) { - const periodEnd = moment(current).add(6, "months").subtract(1, "day"); - if (periodEnd.isSameOrAfter(start)) { - const displayYear = current.year(); + for ( + let current: Moment = sixMonthlyStart; + current.isSameOrBefore(endDate); + current = current.clone().add(6, "months") + ) { + const periodEnd: Moment = current.clone().add(6, "months").subtract(1, "day"); + if (periodEnd.isSameOrAfter(startDate)) { + const displayYear: number = current.year(); const semester = current.month() === 3 ? 1 : 2; dates.push(`${displayYear}AprilS${semester}`); } - current.add(6, "months"); } return dates; } -function generateSixMonthlyNovPeriods(startDate: Moment, endDate: Moment) { - const dates = []; - const start = moment(startDate); - const end = moment(endDate); +function generateSixMonthlyNovPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; - let current; - if (start.month() >= 5) { - current = moment({ year: start.year(), month: 4, date: 1 }); - } else { - current = moment({ year: start.year() - 1, month: 10, date: 1 }); - } + const sixMonthlyStart: Moment = + startDate.month() >= 5 + ? moment({ year: startDate.year(), month: 4, date: 1 }) + : moment({ year: startDate.year() - 1, month: 10, date: 1 }); - while (current.isSameOrBefore(end)) { - const periodEnd = moment(current).add(6, "months").subtract(1, "day"); + for ( + let current: Moment = sixMonthlyStart; + current.isSameOrBefore(endDate); + current = current.clone().add(6, "months") + ) { + const periodEnd: Moment = current.clone().add(6, "months").subtract(1, "day"); - if (periodEnd.isSameOrAfter(start)) { - const isNovStart = current.month() === 10; - const semester = isNovStart ? 1 : 2; - const displayYear = isNovStart ? current.year() + 1 : current.year(); + if (periodEnd.isSameOrAfter(startDate)) { + const isNovStart: boolean = current.month() === 10; + const semester: number = isNovStart ? 1 : 2; + const displayYear: number = isNovStart ? current.year() + 1 : current.year(); dates.push(`${displayYear}NovS${semester}`); } - - current.add(6, "months"); } return dates; } type FinancialType = "FinancialApril" | "FinancialJuly" | "FinancialOct" | "FinancialNov"; -function generateFinancialPeriods(startDate: Moment, endDate: Moment, financialType: FinancialType) { - const dates = []; - const monthName = financialType.replace("Financial", ""); - const startMonthIndex = moment().month(monthName).month(); - const start = moment(startDate); - const end = moment(endDate); +function generateFinancialPeriods(startDate: Moment, endDate: Moment, financialType: FinancialType): string[] { + const dates: string[] = []; + const monthName: string = financialType.replace("Financial", ""); + const startMonthIndex: number = moment().month(monthName).month(); - const firstYear = start.year(); - const lastYear = end.year(); + const firstYear: number = startDate.year(); + const lastYear: number = endDate.year(); for (let year = firstYear; year <= lastYear; year++) { - const periodStart = moment({ year, month: startMonthIndex, date: 1 }); - const periodEnd = moment(periodStart).add(1, "year").subtract(1, "day"); + const periodStart: Moment = moment({ year, month: startMonthIndex, date: 1 }); + const periodEnd: Moment = periodStart.clone().add(1, "year").subtract(1, "day"); - if (periodStart.isSameOrAfter(start) && periodEnd.isSameOrBefore(end)) { + if (periodStart.isSameOrAfter(startDate) && periodEnd.isSameOrBefore(endDate)) { dates.push(`${year}${monthName}`); } } From f4a930e22da19313aa65d4c79f65d6fe3f873c16 Mon Sep 17 00:00:00 2001 From: nshandra <34254522+nshandra@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:13:15 +0100 Subject: [PATCH 11/11] feat: add leap day and ISO weeks related tests --- src/test/utils/periods.spec.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/test/utils/periods.spec.ts b/src/test/utils/periods.spec.ts index c49d232d..38482c30 100644 --- a/src/test/utils/periods.spec.ts +++ b/src/test/utils/periods.spec.ts @@ -42,6 +42,11 @@ describe("buildAllPossiblePeriods - Daily", () => { const result = buildAllPossiblePeriods("Daily", moment("2024-01-27"), moment("2024-02-02")); expect(result).toEqual(["20240127", "20240128", "20240129", "20240130", "20240131", "20240201", "20240202"]); }); + + it("includes leap day in range", () => { + const result = buildAllPossiblePeriods("Daily", moment("2024-02-28"), moment("2024-03-01")); + expect(result).toEqual(["20240228", "20240229", "20240301"]); + }); }); // Weekly variants @@ -74,6 +79,16 @@ describe("buildAllPossiblePeriods - Weekly variants", () => { const result = buildAllPossiblePeriods("WeeklySunday", moment("2024-01-01"), moment("2024-01-31")); expect(result).toEqual(["2023SunW52", "2024SunW1", "2024SunW2", "2024SunW3", "2024SunW4"]); }); + + it("Weekly uses ISO week year", () => { + const result = buildAllPossiblePeriods("Weekly", moment("2019-12-30"), moment("2020-01-05")); + expect(result).toEqual(["2020W1"]); + }); + + it("includes week 53 in years that have 53 ISO weeks", () => { + const result = buildAllPossiblePeriods("Weekly", moment("2020-12-28"), moment("2021-01-03")); + expect(result).toEqual(["2020W53"]); + }); }); // Monthly @@ -170,6 +185,11 @@ describe("buildAllPossiblePeriods - BiWeekly", () => { const result = buildAllPossiblePeriods("BiWeekly", moment("2024-12-01"), moment("2025-01-31")); expect(result).toEqual(["2024BiW24", "2024BiW25", "2024BiW26", "2025BiW1", "2025BiW2", "2025BiW3"]); }); + + it("generates BiW27 period in a 53 ISO week year", () => { + const result = buildAllPossiblePeriods("BiWeekly", moment("2020-12-14"), moment("2021-01-17")); + expect(result).toEqual(["2020BiW26", "2020BiW27", "2021BiW1"]); + }); }); // BiMonthly