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.ts b/src/test/utils/periods.spec.ts new file mode 100644 index 00000000..38482c30 --- /dev/null +++ b/src/test/utils/periods.spec.ts @@ -0,0 +1,210 @@ +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", () => { + 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 +describe("buildAllPossiblePeriods - Weekly variants", () => { + it("Weekly generates period for sub-week range", () => { + const result = buildAllPossiblePeriods("Weekly", moment("2024-12-30"), moment("2025-01-04")); + expect(result).toEqual(["2025W1"]); + }); + it("Weekly generates weeks", () => { + 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", 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", 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", 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", 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 +describe("buildAllPossiblePeriods - Monthly", () => { + it("generates monthly periods", () => { + const result = buildAllPossiblePeriods("Monthly", moment("2023-11-01"), moment("2024-03-31")); + expect(result).toEqual(["202311", "202312", "202401", "202402", "202403"]); + }); +}); + +// Yearly variants +describe("buildAllPossiblePeriods - Yearly", () => { + it("generates yearly periods", () => { + 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", moment("2023-03-01"), moment("2025-06-30")); + expect(result).toEqual(["2023April", "2024April"]); + }); + + it("generates FinancialJuly periods", () => { + 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", 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", moment("2023-10-01"), moment("2025-12-31")); + expect(result).toEqual(["2023Nov", "2024Nov"]); + }); +}); + +// Quarterly Variants +describe("buildAllPossiblePeriods - Quarterly", () => { + it("generates Quarterly periods", () => { + 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", moment("2023-10-01"), moment("2024-08-31")); + expect(result).toEqual(["2023NovQ4", "2024NovQ1", "2024NovQ2", "2024NovQ3", "2024NovQ4"]); + }); +}); + +// SixMonthly +describe("buildAllPossiblePeriods - SixMonthly", () => { + it("generates periods within same year", () => { + 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", 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", moment("2024-04-01"), moment("2024-09-30")); + expect(result).toEqual(["2024AprilS1"]); + }); + + it("generates periods across multiple years", () => { + 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", moment("2025-01-01"), moment("2025-04-30")); + expect(result).toEqual(["2025NovS1"]); + }); + + it("generates periods across multiple years", () => { + const result = buildAllPossiblePeriods("SixMonthlyNov", moment("2023-10-01"), moment("2024-12-31")); + expect(result).toEqual(["2023NovS2", "2024NovS1", "2024NovS2", "2025NovS1"]); + }); +}); + +// BiWeekly +describe("buildAllPossiblePeriods - BiWeekly", () => { + it("generates biweekly periods across multiple years", () => { + 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 +describe("buildAllPossiblePeriods - BiMonthly", () => { + it("generates bimontly periods across multiple years", () => { + const result = buildAllPossiblePeriods("BiMonthly", moment("2023-01-01"), moment("2024-03-31")); + expect(result).toEqual([ + "202301B", + "202302B", + "202303B", + "202304B", + "202305B", + "202306B", + "202401B", + "202402B", + ]); + }); +}); 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.js deleted file mode 100644 index 9ada6849..00000000 --- a/src/webapp/utils/periods.js +++ /dev/null @@ -1,47 +0,0 @@ -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"; - format = "YYYYMMDD"; - break; - case "Monthly": - unit = "months"; - format = "YYYYMM"; - break; - case "Yearly": - unit = "years"; - format = "YYYY"; - break; - case "Weekly": - unit = "weeks"; - format = "YYYY[W]W"; - break; - case "Quarterly": - unit = "quarters"; - format = "YYYY[Q]Q"; - break; - default: - throw new Error("Unsupported period type"); - } - - return generateDatesByPeriod({ startDate, endDate, format, unit }); -} - -function generateDatesByPeriod(options) { - const { startDate, endDate, unit, format } = options; - const dates = []; - for (const current = moment(startDate); current.isSameOrBefore(moment(endDate)); current.add(1, unit)) { - dates.push(current.format(format)); - } - return dates; -} diff --git a/src/webapp/utils/periods.ts b/src/webapp/utils/periods.ts new file mode 100644 index 00000000..a4c3089d --- /dev/null +++ b/src/webapp/utils/periods.ts @@ -0,0 +1,248 @@ +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 | undefined, + startDate: Moment | undefined, + endDate: Moment | undefined +): string[] { + if (!startDate || !endDate) { + return []; + } + + switch (periodType) { + case undefined: + return []; + case "Daily": + return generateDatesByPeriod({ startDate, endDate, format: "YYYYMMDD", unit: "days" }); + case "Monthly": + return generateDatesByPeriod({ startDate, endDate, format: "YYYYMM", unit: "months" }); + case "Yearly": + return generateDatesByPeriod({ startDate, endDate, format: "YYYY", unit: "years" }); + case "Weekly": + case "WeeklyWednesday": + case "WeeklyThursday": + case "WeeklySaturday": + case "WeeklySunday": + return generateWeeklyPeriods(periodType, startDate, endDate); + case "BiWeekly": + return generateBiWeeklyPeriods(startDate, endDate); + case "BiMonthly": + return generateBiMonthlyPeriods(startDate, endDate); + case "Quarterly": + return generateDatesByPeriod({ startDate, endDate, format: "YYYY[Q]Q", unit: "quarters" }); + case "QuarterlyNov": + return generateQuarterlyNovPeriods(startDate, endDate); + case "SixMonthly": + return generateSixMonthlyPeriods(startDate, endDate); + case "SixMonthlyApril": + 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"); + } +} + +function generateDatesByPeriod(options: Options): string[] { + const { startDate, endDate, unit, format } = options; + 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): number { + const dayMap = { + Weekly: 1, + WeeklyWednesday: 3, + WeeklyThursday: 4, + WeeklySaturday: 6, + WeeklySunday: 0, + }; + return dayMap[periodType] ?? 1; +} + +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 alignedStartDate: Moment = startDate.clone().isoWeekday(startDay); + const weeklyStart: Moment = alignedStartDate.isAfter(startDate) + ? alignedStartDate.clone().subtract(1, "week") + : alignedStartDate; + + 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): 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): string[] { + const dates: string[] = []; + const start: Moment = getBiWeekStartFromIsoWeek(startDate); + + 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}`); + } + + return dates; +} + +function generateBiMonthlyPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; + + const startMonth: number = Math.floor(startDate.month() / 2) * 2; + const monthBeginningDate: Moment = startDate.clone().month(startMonth).startOf("month"); + + 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`); + } + + return dates; +} + +function generateQuarterlyNovPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; + + const quarterStartDate: Moment = startDate.clone().add(2, "months").startOf("quarter").subtract(2, "months"); + + 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: number = Math.floor((current.month() + 2) / 3) + 1; + dates.push(`${year}NovQ${quarter}`); + } + } + + return dates; +} + +function generateSixMonthlyPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; + + const startMonth: number = startDate.month() < 6 ? 0 : 6; + const sixMonthlyStart: Moment = startDate.clone().month(startMonth).startOf("month"); + + 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}`); + } + + return dates; +} + +function generateSixMonthlyAprilPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; + + const year: number = startDate.month() >= 3 ? startDate.year() : startDate.year() - 1; + const sixMonthlyStart: Moment = moment({ year: year, month: 3, date: 1 }); + + 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}`); + } + } + + return dates; +} + +function generateSixMonthlyNovPeriods(startDate: Moment, endDate: Moment): string[] { + const dates: string[] = []; + + const sixMonthlyStart: Moment = + startDate.month() >= 5 + ? moment({ year: startDate.year(), month: 4, date: 1 }) + : moment({ year: startDate.year() - 1, month: 10, date: 1 }); + + 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 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}`); + } + } + + return dates; +} + +type FinancialType = "FinancialApril" | "FinancialJuly" | "FinancialOct" | "FinancialNov"; +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: number = startDate.year(); + const lastYear: number = endDate.year(); + + for (let year = firstYear; year <= lastYear; year++) { + const periodStart: Moment = moment({ year, month: startMonthIndex, date: 1 }); + const periodEnd: Moment = periodStart.clone().add(1, "year").subtract(1, "day"); + + if (periodStart.isSameOrAfter(startDate) && periodEnd.isSameOrBefore(endDate)) { + dates.push(`${year}${monthName}`); + } + } + + return dates; +}