Repository: IraSoro/peri (ts)

Issue:
Add ovulation display
Mark the days of ovulation on the calendar

Pull request description:
Add ovulation display
Closed #169 

I added a display of ovulation. It looks like this:
![image](https://github.com/IraSoro/peri/assets/52165881/fc6150bb-7909-493d-87fc-8b7a0e70ba90)

If today is the ovulatory phase, it will look like this
![image](https://github.com/IraSoro/peri/assets/52165881/cd977386-fbb2-426e-8235-f095deedf536)

Ovulation is also displayed for future cycles
![image](https://github.com/IraSoro/peri/assets/52165881/0dc6f776-df3e-41c8-b494-ee62d7700fe3)

I decided not to display ovulation if there is only one cycle in the array. I also decided to display only one future period for this case.

I added a test and fixed the tests in the `Storage.ts`. In them, the length of the first cycle was not equal to 0. 

The function for calculating ovulation days `getOvulationDays` now has a lot of ordinary numbers. I know they are not understandable. I'll make the calculations clearer in another task when we change the array in storage.

Patch:
diff --git a/src/App.css b/src/App.css
index 53e7eb2..521399e 100644
--- a/src/App.css
+++ b/src/App.css
@@ -126,7 +126,14 @@ ion-datetime.edit-calendar::part(calendar-day active):focus {
 }
 
 ion-datetime.view-calendar::part(calendar-day active),
-ion-datetime.view-calendar::part(calendar-day active):focus {
+ion-datetime.view-calendar::part(calendar-day active):focus,
+ion-datetime.view-calendar-today-ovulation::part(calendar-day active),
+ion-datetime.view-calendar-today-ovulation::part(calendar-day active):focus {
   background-color: var(--ion-color-light);
   color: var(--ion-color-dark);
 }
+
+ion-datetime.view-calendar-today-ovulation::part(calendar-day today) {
+  color: var(--ion-color-ovulation);
+  border-color: var(--ion-color-ovulation);
+}
diff --git a/src/data/Storage.ts b/src/data/Storage.ts
index 7ed8137..5f660ca 100644
--- a/src/data/Storage.ts
+++ b/src/data/Storage.ts
@@ -93,6 +93,10 @@ function _todayPeriod(countOfCycles: number): Promise<void> {
     });
   }
 
+  if (countOfCycles > 1) {
+    cycles[0].cycleLength = 0;
+  }
+
   return storage.set.cycles(cycles);
 }
 
@@ -109,6 +113,10 @@ function _todayOvulation(countOfCycles: number): Promise<void> {
     });
   }
 
+  if (countOfCycles > 1) {
+    cycles[0].cycleLength = 0;
+  }
+
   return storage.set.cycles(cycles);
 }
 
@@ -125,6 +133,10 @@ function _tomorrowOvulation(countOfCycles: number): Promise<void> {
     });
   }
 
+  if (countOfCycles > 1) {
+    cycles[0].cycleLength = 0;
+  }
+
   return storage.set.cycles(cycles);
 }
 
@@ -141,6 +153,10 @@ function _menstrualPhase(countOfCycles: number): Promise<void> {
     });
   }
 
+  if (countOfCycles > 1) {
+    cycles[0].cycleLength = 0;
+  }
+
   return storage.set.cycles(cycles);
 }
 
@@ -157,6 +173,10 @@ function _follicularPhase(countOfCycles: number): Promise<void> {
     });
   }
 
+  if (countOfCycles > 1) {
+    cycles[0].cycleLength = 0;
+  }
+
   return storage.set.cycles(cycles);
 }
 
@@ -173,6 +193,10 @@ function _lutealPhase(countOfCycles: number): Promise<void> {
     });
   }
 
+  if (countOfCycles > 1) {
+    cycles[0].cycleLength = 0;
+  }
+
   return storage.set.cycles(cycles);
 }
 
@@ -189,6 +213,10 @@ function _delayOfCycle(countOfCycles: number): Promise<void> {
     });
   }
 
+  if (countOfCycles > 1) {
+    cycles[0].cycleLength = 0;
+  }
+
   return storage.set.cycles(cycles);
 }
 
@@ -212,8 +240,11 @@ function _randomMenstrualPhase(countOfCycles: number): Promise<void> {
     });
   }
 
-  const averagePeriod = getAverageLengthOfPeriod(cycles);
-  cycles[0].periodLength = averagePeriod;
+  if (countOfCycles > 1) {
+    const averagePeriod = getAverageLengthOfPeriod(cycles);
+    cycles[0].periodLength = averagePeriod;
+    cycles[0].cycleLength = 0;
+  }
 
   return storage.set.cycles(cycles);
 }
@@ -232,8 +263,11 @@ function _randomFollicularPhase(countOfCycles: number): Promise<void> {
     });
   }
 
-  const averagePeriod = getAverageLengthOfPeriod(cycles);
-  cycles[0].periodLength = averagePeriod;
+  if (countOfCycles > 1) {
+    const averagePeriod = getAverageLengthOfPeriod(cycles);
+    cycles[0].periodLength = averagePeriod;
+    cycles[0].cycleLength = 0;
+  }
 
   return storage.set.cycles(cycles);
 }
@@ -252,8 +286,11 @@ function _randomLutealPhase(countOfCycles: number): Promise<void> {
     });
   }
 
-  const averagePeriod = getAverageLengthOfPeriod(cycles);
-  cycles[0].periodLength = averagePeriod;
+  if (countOfCycles > 1) {
+    const averagePeriod = getAverageLengthOfPeriod(cycles);
+    cycles[0].periodLength = averagePeriod;
+    cycles[0].cycleLength = 0;
+  }
 
   return storage.set.cycles(cycles);
 }
@@ -272,8 +309,11 @@ function _randomDelayOfCycle(countOfCycles: number): Promise<void> {
     });
   }
 
-  const averagePeriod = getAverageLengthOfPeriod(cycles);
-  cycles[0].periodLength = averagePeriod;
+  if (countOfCycles > 1) {
+    const averagePeriod = getAverageLengthOfPeriod(cycles);
+    cycles[0].periodLength = averagePeriod;
+    cycles[0].cycleLength = 0;
+  }
 
   return storage.set.cycles(cycles);
 }
diff --git a/src/pages/TabHome.tsx b/src/pages/TabHome.tsx
index dc6a0d1..3c199e5 100644
--- a/src/pages/TabHome.tsx
+++ b/src/pages/TabHome.tsx
@@ -14,7 +14,7 @@ import {
 import { App } from "@capacitor/app";
 import { Capacitor } from "@capacitor/core";
 import { useTranslation } from "react-i18next";
-import { parseISO } from "date-fns";
+import { parseISO, startOfToday, format } from "date-fns";
 import { CyclesContext } from "../state/Context";
 
 import { storage } from "../data/Storage";
@@ -32,6 +32,7 @@ import {
   isPeriodToday,
   isMarkedFutureDays,
   getForecastPeriodDays,
+  getOvulationDays,
 } from "../state/CalculationLogics";
 import { getCurrentTranslation } from "../utils/translation";
 
@@ -84,10 +85,15 @@ const ViewCalendar = (props: SelectCalendarProps) => {
 
   const lastPeriodDays = getLastPeriodDays(cycles);
   const forecastPeriodDays = getForecastPeriodDays(cycles);
+  const ovulationDays = getOvulationDays(cycles);
 
   return (
     <IonDatetime
-      className="view-calendar"
+      className={
+        ovulationDays.includes(format(startOfToday(), "yyyy-MM-dd"))
+          ? "view-calendar-today-ovulation"
+          : "view-calendar"
+      }
       color="light"
       presentation="date"
       locale={getCurrentTranslation()}
@@ -107,6 +113,12 @@ const ViewCalendar = (props: SelectCalendarProps) => {
             textColor: "#43348d",
             backgroundColor: "rgba(var(--ion-color-light-basic-rgb), 0.8)",
           };
+        } else if (ovulationDays.includes(isoDateString)) {
+          return {
+            textColor: "var(--ion-color-ovulation)",
+            backgroundColor: "var(--ion-color-light)",
+            fontWeight: "bold",
+          };
         }
 
         return undefined;
diff --git a/src/state/CalculationLogics.ts b/src/state/CalculationLogics.ts
index 3870e1a..702c4bf 100644
--- a/src/state/CalculationLogics.ts
+++ b/src/state/CalculationLogics.ts
@@ -388,6 +388,9 @@ export function getForecastPeriodDays(cycles: Cycle[]) {
     nextCycleStart = nowDate;
   }
   addForecastDates(nextCycleStart);
+  if (cycles.length === 1) {
+    return forecastDates;
+  }
 
   const cycleCount = 6;
   for (let i = 0; i < cycleCount; ++i) {
@@ -425,3 +428,71 @@ export function isMarkedFutureDays(periodDays: string[]) {
     return startOfDay(new Date(date)) > today;
   });
 }
+
+export function getOvulationDays(cycles: Cycle[]) {
+  if (cycles.length < 2) {
+    return [];
+  }
+  const averageCycle = getAverageLengthOfCycle(cycles);
+  const dayOfCycle = getDayOfCycle(cycles);
+  const ovulationDates = [];
+
+  for (const cycle of cycles) {
+    const startOfCycle = startOfDay(new Date(cycle.startDate));
+    let finishOfCycle;
+    if (cycle.cycleLength === 0) {
+      if (dayOfCycle > averageCycle) {
+        finishOfCycle = addDays(startOfCycle, dayOfCycle - 17);
+      } else {
+        finishOfCycle = addDays(startOfCycle, averageCycle - 16);
+      }
+    } else {
+      finishOfCycle = addDays(startOfCycle, cycle.cycleLength - 16);
+    }
+
+    for (let i = 0; i < 4; ++i) {
+      const newDate = addDays(finishOfCycle, i);
+      ovulationDates.push(format(newDate, "yyyy-MM-dd"));
+    }
+  }
+
+  return ovulationDates.concat(getFutureOvulationDays(cycles));
+}
+
+export function getFutureOvulationDays(cycles: Cycle[]) {
+  if (cycles.length === 0) {
+    return [];
+  }
+  const lengthOfCycle = getAverageLengthOfCycle(cycles);
+  const dayOfCycle = getDayOfCycle(cycles);
+  const nowDate = startOfToday();
+
+  let nextCycleStart;
+  const ovulationDates: string[] = [];
+
+  function addOvulationDates(startDate: Date) {
+    for (let i = 0; i < 4; ++i) {
+      ovulationDates.push(
+        format(addDays(startDate, lengthOfCycle - 16 + i), "yyyy-MM-dd"),
+      );
+    }
+  }
+
+  if (dayOfCycle <= lengthOfCycle) {
+    nextCycleStart = addDays(
+      startOfDay(new Date(cycles[0].startDate)),
+      lengthOfCycle,
+    );
+  } else {
+    nextCycleStart = nowDate;
+  }
+  addOvulationDates(nextCycleStart);
+
+  const cycleCount = 5;
+  for (let i = 0; i < cycleCount; ++i) {
+    nextCycleStart = addDays(nextCycleStart, lengthOfCycle);
+    addOvulationDates(nextCycleStart);
+  }
+
+  return ovulationDates;
+}
diff --git a/src/theme/variables.css b/src/theme/variables.css
index 44d96fc..f80cff7 100644
--- a/src/theme/variables.css
+++ b/src/theme/variables.css
@@ -140,6 +140,13 @@ http://ionicframework.com/docs/theming/ */
   --ion-color-blackout-basic-contrast-rgb: 0, 0, 0;
   --ion-color-blackout-basic-shade: #867fa8;
   --ion-color-blackout-basic-tint: #a29bc5;
+
+  --ion-color-ovulation: #3c9b9e;
+  --ion-color-ovulation-rgb: 60, 155, 158;
+  --ion-color-ovulation-contrast: #ffffff;
+  --ion-color-ovulation-contrast-rgb: 255, 255, 255;
+  --ion-color-ovulation-shade: #35888b;
+  --ion-color-ovulation-tint: #50a5a8;
 }
 
 .ion-color-basic {
@@ -213,3 +220,12 @@ http://ionicframework.com/docs/theming/ */
   --ion-color-shade: var(--ion-color-blackout-basic-shade);
   --ion-color-tint: var(--ion-color-blackout-basic-tint);
 }
+
+.ion-color-ovulation {
+  --ion-color-base: var(--ion-color-ovulation);
+  --ion-color-base-rgb: var(--ion-color-ovulation-rgb);
+  --ion-color-contrast: var(--ion-color-ovulation-contrast);
+  --ion-color-contrast-rgb: var(--ion-color-ovulation-contrast-rgb);
+  --ion-color-shade: var(--ion-color-ovulation-shade);
+  --ion-color-tint: var(--ion-color-ovulation-tint);
+}


Test patch:
diff --git a/src/tests/CalculationLogic.test.ts b/src/tests/CalculationLogic.test.ts
index 34feaf2..fbfef48 100644
--- a/src/tests/CalculationLogic.test.ts
+++ b/src/tests/CalculationLogic.test.ts
@@ -26,6 +26,7 @@ import {
   getLengthOfLastPeriod,
   isMarkedFutureDays,
   getForecastPeriodDays,
+  getOvulationDays,
 } from "../state/CalculationLogics";
 
 describe("getOvulationStatus", () => {
@@ -1071,3 +1072,79 @@ describe("getForecastPeriodDays", () => {
     expect(getForecastPeriodDays(cycles)).toEqual(forecastDays);
   });
 });
+
+describe("getOvulationDays", () => {
+  test("cycles array is empty", () => {
+    // @ts-expect-error mocked `t` method
+    jest.spyOn(i18n, "t").mockImplementation((key) => key);
+    expect(getOvulationDays([])).toEqual([]);
+  });
+
+  test("cycles array has 1 item", () => {
+    // @ts-expect-error mocked `t` method
+    jest.spyOn(i18n, "t").mockImplementation((key) => key);
+
+    const cycles: Cycle[] = [];
+    let date = addDays(startOfToday(), 20);
+
+    date = subDays(date, 28);
+    cycles.push({
+      cycleLength: 0,
+      periodLength: 6,
+      startDate: date.toString(),
+    });
+
+    expect(getOvulationDays(cycles)).toEqual([]);
+  });
+
+  test("cycles array has a 6 items", () => {
+    // @ts-expect-error mocked `t` method
+    jest.spyOn(i18n, "t").mockImplementation((key) => key);
+
+    const cycles: Cycle[] = [];
+    let date = addDays(startOfToday(), 20);
+
+    for (let i = 0; i < 6; ++i) {
+      date = subDays(date, 28);
+      cycles.push({
+        cycleLength: 28,
+        periodLength: 6,
+        startDate: date.toString(),
+      });
+    }
+    cycles[0].cycleLength = 0;
+
+    const ovulationDays = [];
+    for (const cycle of cycles) {
+      const startOfCycle = startOfDay(new Date(cycle.startDate));
+      let finishOfCycle;
+      if (cycle.cycleLength === 0) {
+        finishOfCycle = addDays(startOfCycle, 28 - 16);
+      } else {
+        finishOfCycle = addDays(startOfCycle, cycle.cycleLength - 16);
+      }
+
+      for (let i = 0; i < 4; ++i) {
+        const newDate = addDays(finishOfCycle, i);
+        ovulationDays.push(format(newDate, "yyyy-MM-dd"));
+      }
+    }
+
+    let nextCycleStart = addDays(startOfDay(new Date(cycles[0].startDate)), 28);
+    for (let i = 0; i < 4; ++i) {
+      ovulationDays.push(
+        format(addDays(nextCycleStart, 28 - 16 + i), "yyyy-MM-dd"),
+      );
+    }
+    for (let i = 0; i < 5; ++i) {
+      nextCycleStart = addDays(nextCycleStart, 28);
+      for (let i = 0; i < 4; ++i) {
+        ovulationDays.push(
+          format(addDays(nextCycleStart, 28 - 16 + i), "yyyy-MM-dd"),
+        );
+      }
+    }
+
+    expect(getOvulationDays(cycles)).toEqual(ovulationDays);
+  });
+});