Merge branch 'use-external-sympto' into 'master'
Use external sympto module See merge request bloodyhealth/drip!173
This commit is contained in:
+19
-3
@@ -1,4 +1,4 @@
|
||||
import getFertilityStatus from './sympto'
|
||||
import getFertilityStatus from 'sympto'
|
||||
import cycleModule from './cycle'
|
||||
import { useCervixObservable } from '../local-storage'
|
||||
import { fertilityStatus as labels } from '../i18n/en/labels'
|
||||
@@ -100,7 +100,23 @@ function formatStatus(phaseNameForDay, dateString, status) {
|
||||
}
|
||||
|
||||
function formatCycleForSympto(cycle) {
|
||||
const formatted = cycle.reduce((acc, oldDay) => {
|
||||
// deep clone
|
||||
const day = JSON.parse(JSON.stringify(oldDay));
|
||||
// remove excluded symptoms
|
||||
['bleeding', 'temperature', 'mucus', 'cervix'].forEach(symptomName => {
|
||||
if (day[symptomName] && day[symptomName].exclude) {
|
||||
delete day[symptomName]
|
||||
}
|
||||
});
|
||||
// change format
|
||||
['bleeding', 'temperature', 'mucus'].forEach(symptomName => {
|
||||
if (day[symptomName]) day[symptomName] = day[symptomName].value
|
||||
})
|
||||
acc.push(day)
|
||||
return acc
|
||||
}, [])
|
||||
// we get earliest last, but sympto wants earliest first
|
||||
cycle.reverse()
|
||||
return cycle
|
||||
formatted.reverse()
|
||||
return formatted
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
export default function (cycleDays, tempEvalEndIndex) {
|
||||
const notDetected = { detected: false }
|
||||
const cervixDays = cycleDays
|
||||
.filter(day => day.cervix && !day.cervix.exclude)
|
||||
.filter(day => typeof day.cervix.opening === 'number' && typeof day.cervix.firmness === 'number')
|
||||
|
||||
// we search for the day of cervix peak, which must:
|
||||
// * have fertile cervix values
|
||||
// * be followed by at least 3 days
|
||||
// * must happen prior to end of temperature evaluation
|
||||
// these 3 following days must all show infertile cervix values
|
||||
// if everything applies we must check the days until the end of temperature evaluation
|
||||
// during these relevantDays no fertile cervix must occur
|
||||
|
||||
for (let i = 0; i < cervixDays.length; i++) {
|
||||
const day = cervixDays[i]
|
||||
if (isClosedAndHard(day.cervix)) continue
|
||||
|
||||
// the three following days must be with closed and hard cervix (indicating an infertile cervix)
|
||||
const threeFollowingDays = cervixDays.slice(i + 1, i + 4)
|
||||
if (threeFollowingDays.length < 3) continue
|
||||
|
||||
const fertileCervixOccursIn3FollowingDays = threeFollowingDays.some(day => {
|
||||
return !isClosedAndHard(day.cervix)
|
||||
})
|
||||
if (fertileCervixOccursIn3FollowingDays) continue
|
||||
|
||||
const cycleDayIndex = cycleDays.indexOf(day)
|
||||
|
||||
// if temperature evaluation has been completed an we still haven't found
|
||||
// a candidate, there is no cervix shift
|
||||
if (cycleDayIndex > tempEvalEndIndex) return notDetected
|
||||
|
||||
// no other fertile cervix value may occur until temperature evaluation has
|
||||
// been completed
|
||||
const relevantDays = cycleDays
|
||||
.slice(cycleDayIndex + 1, tempEvalEndIndex + 1)
|
||||
.filter(day => day.cervix && !day.cervix.exclude)
|
||||
|
||||
const onlyClosedAndHardUntilEndOfTempEval = relevantDays.every(day => {
|
||||
return isClosedAndHard(day.cervix)
|
||||
})
|
||||
|
||||
if (onlyClosedAndHardUntilEndOfTempEval) {
|
||||
return {
|
||||
detected: true,
|
||||
cervixPeakBeforeShift: day,
|
||||
evaluationCompleteDay: threeFollowingDays[threeFollowingDays.length - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return notDetected
|
||||
}
|
||||
|
||||
function isClosedAndHard (cervixDay) {
|
||||
return cervixDay.opening === 0 && cervixDay.firmness === 0
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import getTemperatureShift from './temperature'
|
||||
import getMucusShift from './mucus'
|
||||
import getCervixShift from './cervix'
|
||||
import getPreOvulatoryPhase from './pre-ovulatory'
|
||||
import { LocalDate } from 'js-joda'
|
||||
import assert from 'assert'
|
||||
|
||||
export default function getSymptoThermalStatus(cycleInfo) {
|
||||
const { cycle, previousCycle, earlierCycles = [], secondarySymptom = 'mucus' } = cycleInfo
|
||||
throwIfArgsAreNotInRequiredFormat([cycle, ...earlierCycles])
|
||||
|
||||
const status = {
|
||||
phases: {}
|
||||
}
|
||||
|
||||
// if there was no first higher measurement in the previous cycle,
|
||||
// no infertile pre-ovulatory phase may be assumed
|
||||
if (previousCycle) {
|
||||
const statusForLast = getSymptoThermalStatus({
|
||||
cycle: previousCycle,
|
||||
secondarySymptom: secondarySymptom
|
||||
})
|
||||
if (statusForLast.temperatureShift) {
|
||||
const preOvuPhase = getPreOvulatoryPhase(
|
||||
cycle,
|
||||
[previousCycle, ...earlierCycles],
|
||||
secondarySymptom
|
||||
)
|
||||
if (preOvuPhase) {
|
||||
status.phases.preOvulatory = preOvuPhase
|
||||
if (status.phases.preOvulatory.cycleDays.length === cycle.length) {
|
||||
return status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO maybe add indicator if there was no preovuphase?
|
||||
status.phases.periOvulatory = {
|
||||
start: { date: null },
|
||||
cycleDays: []
|
||||
}
|
||||
const periPhase = status.phases.periOvulatory
|
||||
|
||||
if (status.phases.preOvulatory) {
|
||||
const prePhase = status.phases.preOvulatory
|
||||
const startDate = LocalDate.parse(prePhase.end.date).plusDays(1).toString()
|
||||
periPhase.start.date = startDate
|
||||
const lastPreDay = prePhase.cycleDays[prePhase.cycleDays.length - 1]
|
||||
periPhase.cycleDays = cycle.slice(cycle.indexOf(lastPreDay) + 1)
|
||||
} else {
|
||||
periPhase.start.date = cycle[0].date
|
||||
periPhase.cycleDays = [...cycle]
|
||||
}
|
||||
|
||||
const temperatureShift = getTemperatureShift(cycle)
|
||||
|
||||
if (!temperatureShift.detected) return status
|
||||
|
||||
const tempEvalEndIndex = cycle.indexOf(temperatureShift.evaluationCompleteDay)
|
||||
|
||||
let secondaryShift
|
||||
if (secondarySymptom === 'mucus') {
|
||||
secondaryShift = getMucusShift(cycle, tempEvalEndIndex)
|
||||
} else if (secondarySymptom === 'cervix') {
|
||||
secondaryShift = getCervixShift(cycle, tempEvalEndIndex)
|
||||
}
|
||||
|
||||
if (!secondaryShift.detected) return status
|
||||
|
||||
let periOvulatoryEnd
|
||||
const tempOver = temperatureShift.evaluationCompleteDay.date
|
||||
const secondarySymptomOver = secondaryShift.evaluationCompleteDay.date
|
||||
|
||||
if (tempOver >= secondarySymptomOver) {
|
||||
periOvulatoryEnd = temperatureShift.evaluationCompleteDay
|
||||
} else if (secondarySymptom > tempOver) {
|
||||
periOvulatoryEnd = secondaryShift.evaluationCompleteDay
|
||||
}
|
||||
|
||||
const previousPeriDays = periPhase.cycleDays
|
||||
const previousPeriEndIndex = previousPeriDays.indexOf(periOvulatoryEnd)
|
||||
|
||||
status.phases.postOvulatory = {
|
||||
start: {
|
||||
date: periOvulatoryEnd.date,
|
||||
time: '18:00'
|
||||
},
|
||||
cycleDays: previousPeriDays.slice(previousPeriEndIndex)
|
||||
}
|
||||
|
||||
periPhase.cycleDays = previousPeriDays.slice(0, previousPeriEndIndex + 1)
|
||||
periPhase.end = status.phases.postOvulatory.start
|
||||
|
||||
if (secondarySymptom === 'mucus') {
|
||||
status.mucusShift = secondaryShift
|
||||
} else if (secondarySymptom === 'cervix') {
|
||||
status.cervixShift = secondaryShift
|
||||
}
|
||||
|
||||
status.temperatureShift = temperatureShift
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
function throwIfArgsAreNotInRequiredFormat(cycles) {
|
||||
cycles.forEach(cycle => {
|
||||
assert.ok(Array.isArray(cycle), "Cycles must be arrays.")
|
||||
assert.ok(cycle.length > 0, "Cycle must not be empty.")
|
||||
assert.ok(cycle[0].bleeding !== null, "First cycle day should have bleeding.")
|
||||
assert.equal(typeof cycle[0].bleeding, 'object', "First cycle day must contain bleeding value.")
|
||||
assert.equal(typeof cycle[0].bleeding.value, 'number', "First cycle day bleeding value must be a number.")
|
||||
cycle.forEach(day => {
|
||||
assert.equal(typeof day.date, 'string', "Date must be given as a string.")
|
||||
assert.doesNotThrow(() => LocalDate.parse(day.date), "Date must be given in right string format.")
|
||||
if (day.temperature) assert.equal(typeof day.temperature.value, 'number', "Temperature value must be a number.")
|
||||
if (day.mucus) assert.equal(typeof day.mucus.value, 'number', "Mucus value must be a number.")
|
||||
if (day.mucus) assert.ok(day.mucus.value >= 0, "Mucus value must greater or equal to 0.")
|
||||
if (day.mucus) assert.ok(day.mucus.value <= 4, "Mucus value must be below 5.")
|
||||
if (day.cervix) assert.ok(day.cervix.opening >= 0, "Cervix opening value must be 0 or bigger")
|
||||
if (day.cervix) assert.ok(day.cervix.opening <= 2, "Cervix opening value must be 2 or smaller")
|
||||
if (day.cervix) assert.ok(day.cervix.firmness >= 0, "Cervix firmness value must be 0 or bigger")
|
||||
if (day.cervix) assert.ok(day.cervix.firmness <= 1, "Cervix firmness value must be 1 or smaller")
|
||||
assert.equal(typeof cycle[0].bleeding.value, 'number', "Bleeding value must be a number")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { LocalDate } from 'js-joda'
|
||||
import getNfpStatus from './index'
|
||||
|
||||
export default function (previousCycles, secondarySymptom) {
|
||||
const fhms = previousCycles
|
||||
.map(cycle => {
|
||||
const status = getNfpStatus({ cycle, secondarySymptom })
|
||||
if (status.temperatureShift) {
|
||||
const day = status.temperatureShift.firstHighMeasurementDay
|
||||
const firstCycleDayDate = LocalDate.parse(cycle[0].date)
|
||||
const fhmDate = LocalDate.parse(day.date)
|
||||
return fhmDate.compareTo(firstCycleDayDate) + 1
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(val => typeof val === 'number')
|
||||
|
||||
const preOvuLength = Math.min(...fhms) - 8
|
||||
|
||||
// pre ovu length may only be lengthened if we have more than 12 previous fhms
|
||||
// if pre ovu length is less than 5, it shortened even with fewer prev fhms
|
||||
if (preOvuLength < 5) return preOvuLength
|
||||
if (fhms.length >= 12) return preOvuLength
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
export default function (cycleDays, tempEvalEndIndex) {
|
||||
const notDetected = { detected: false}
|
||||
const mucusDays = cycleDays.filter(day => day.mucus && !day.mucus.exclude)
|
||||
let currentBestQuality = 0
|
||||
|
||||
for (let i = 0; i < mucusDays.length; i++) {
|
||||
const day = mucusDays[i]
|
||||
|
||||
if (day.mucus.value > currentBestQuality) {
|
||||
currentBestQuality = day.mucus.value
|
||||
}
|
||||
|
||||
// if mucus only changes from dry to nothing, it doesn't constitute a shift
|
||||
if (currentBestQuality < 2) continue
|
||||
|
||||
if (day.mucus.value !== currentBestQuality) continue
|
||||
|
||||
// the three following days must be of lower quality
|
||||
const threeFollowingDays = mucusDays.slice(i + 1, i + 4)
|
||||
if (threeFollowingDays.length < 3) continue
|
||||
|
||||
const bestQualityOccursIn3FollowingDays = threeFollowingDays.some(day => {
|
||||
return day.mucus.value >= currentBestQuality
|
||||
})
|
||||
if (bestQualityOccursIn3FollowingDays) continue
|
||||
|
||||
const cycleDayIndex = cycleDays.indexOf(day)
|
||||
|
||||
// if temperature evaluation has been completed an we still haven't found
|
||||
// a candidate, there is no mucus shift
|
||||
if (cycleDayIndex > tempEvalEndIndex) return notDetected
|
||||
|
||||
// no best quality day may occur until temperature evaluation has
|
||||
// been completed
|
||||
const relevantDays = cycleDays
|
||||
.slice(cycleDayIndex + 1, tempEvalEndIndex + 1)
|
||||
.filter(day => day.mucus && !day.mucus.exclude)
|
||||
|
||||
const noBestQualityUntilEndOfTempEval = relevantDays.every(day => {
|
||||
return day.mucus.value < currentBestQuality
|
||||
})
|
||||
|
||||
if (noBestQualityUntilEndOfTempEval) {
|
||||
return {
|
||||
detected: true,
|
||||
mucusPeak: day,
|
||||
evaluationCompleteDay: threeFollowingDays[threeFollowingDays.length - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return notDetected
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { LocalDate } from "js-joda"
|
||||
import apply8DayRule from './minus-8-day-rule'
|
||||
|
||||
export default function(cycle, previousCycles, secondarySymptom) {
|
||||
let preOvuPhaseLength = 5
|
||||
|
||||
const minus8DayRuleResult = apply8DayRule(previousCycles, secondarySymptom)
|
||||
if (minus8DayRuleResult) preOvuPhaseLength = minus8DayRuleResult
|
||||
|
||||
const startDate = LocalDate.parse(cycle[0].date)
|
||||
const preOvuEndDate = startDate.plusDays(preOvuPhaseLength - 1).toString()
|
||||
const maybePreOvuDays = cycle.slice(0, preOvuPhaseLength).filter(d => {
|
||||
return d.date <= preOvuEndDate
|
||||
})
|
||||
const preOvulatoryDays = getDaysUntilFertileSecondarySymptom(maybePreOvuDays, secondarySymptom)
|
||||
// if fertile mucus or cervix occurs on the 1st cycle day, there is no pre-ovu phase
|
||||
if (!preOvulatoryDays.length) return null
|
||||
|
||||
let endDate
|
||||
if (preOvulatoryDays.length === maybePreOvuDays.length) {
|
||||
endDate = preOvuEndDate
|
||||
} else {
|
||||
endDate = preOvulatoryDays[preOvulatoryDays.length - 1].date
|
||||
}
|
||||
|
||||
return {
|
||||
cycleDays: preOvulatoryDays,
|
||||
start: {
|
||||
date: preOvulatoryDays[0].date
|
||||
},
|
||||
end: {
|
||||
date: endDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDaysUntilFertileSecondarySymptom(days, secondarySymptom = 'mucus') {
|
||||
const firstFertileSecondarySymptomDayIndex = days.findIndex(day => {
|
||||
if (secondarySymptom === 'mucus') {
|
||||
return day.mucus && day.mucus.value > 1
|
||||
} else if (secondarySymptom === 'cervix') {
|
||||
return day.cervix && day.cervix.opening > 0
|
||||
|| day.cervix && day.cervix.firmness > 0
|
||||
}
|
||||
})
|
||||
|
||||
if (firstFertileSecondarySymptomDayIndex > -1) {
|
||||
return days.slice(0, firstFertileSecondarySymptomDayIndex)
|
||||
}
|
||||
return days
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
export default function (cycleDays) {
|
||||
const temperatureDays = cycleDays
|
||||
.filter(day => day.temperature && !day.temperature.exclude)
|
||||
.map(day => {
|
||||
return {
|
||||
originalCycleDay: day,
|
||||
temp: rounded(day.temperature.value, 0.05)
|
||||
}
|
||||
})
|
||||
|
||||
function getLtl(i) {
|
||||
const daysBefore = temperatureDays.slice(0, i).slice(-6)
|
||||
const temps = daysBefore.map(day => day.temp)
|
||||
return Math.max(...temps)
|
||||
}
|
||||
|
||||
for (let i = 0; i < temperatureDays.length; i++) {
|
||||
// need at least 6 low temps before we can detect a first high measurement
|
||||
if (i < 6) continue
|
||||
|
||||
// is the temp a candidate for a first high measurement?
|
||||
const ltl = getLtl(i)
|
||||
const temp = temperatureDays[i].temp
|
||||
if (temp <= ltl) continue
|
||||
|
||||
const shift = checkIfFirstHighMeasurement(temp, i, temperatureDays, ltl)
|
||||
|
||||
if (shift.detected) {
|
||||
shift.firstHighMeasurementDay = temperatureDays[i].originalCycleDay
|
||||
return shift
|
||||
}
|
||||
}
|
||||
|
||||
return { detected: false }
|
||||
}
|
||||
|
||||
function checkIfFirstHighMeasurement(temp, i, temperatureDays, ltl) {
|
||||
// need at least 3 high temps to form a high temperature level
|
||||
if (i > temperatureDays.length - 3) {
|
||||
return { detected: false }
|
||||
}
|
||||
const nextDaysAfterPotentialFhm = temperatureDays.slice(i + 1, i + 4)
|
||||
|
||||
return (
|
||||
getResultForRegularRule(nextDaysAfterPotentialFhm.slice(0, 2), ltl)) ||
|
||||
getResultForFirstExceptionRule(nextDaysAfterPotentialFhm, ltl) ||
|
||||
getResultForSecondExceptionRule(nextDaysAfterPotentialFhm, ltl) ||
|
||||
{ detected: false }
|
||||
}
|
||||
|
||||
function getResultForRegularRule(nextDaysAfterPotentialFhm, ltl) {
|
||||
if (!nextDaysAfterPotentialFhm.every(day => day.temp > ltl)) return false
|
||||
const thirdDay = nextDaysAfterPotentialFhm[1]
|
||||
if (isLessThan0Point2(thirdDay.temp - ltl)) return false
|
||||
return {
|
||||
detected: true,
|
||||
rule: 0,
|
||||
ltl,
|
||||
evaluationCompleteDay: thirdDay.originalCycleDay
|
||||
}
|
||||
}
|
||||
|
||||
function getResultForFirstExceptionRule(nextDaysAfterPotentialFhm, ltl) {
|
||||
if (nextDaysAfterPotentialFhm.length < 3) return false
|
||||
if (!nextDaysAfterPotentialFhm.every(day => day.temp > ltl)) return false
|
||||
const fourthDay = nextDaysAfterPotentialFhm[2]
|
||||
if (fourthDay.temp <= ltl) return false
|
||||
return {
|
||||
detected: true,
|
||||
rule: 1,
|
||||
ltl,
|
||||
evaluationCompleteDay: fourthDay.originalCycleDay
|
||||
}
|
||||
}
|
||||
|
||||
function getResultForSecondExceptionRule(nextDaysAfterPotentialFhm, ltl) {
|
||||
if (nextDaysAfterPotentialFhm.length < 3) return false
|
||||
if (secondOrThirdTempIsAtOrBelowLtl(nextDaysAfterPotentialFhm, ltl)) {
|
||||
const fourthDay = nextDaysAfterPotentialFhm[2]
|
||||
if (isBiggerOrEqual0Point2(fourthDay.temp - ltl)) {
|
||||
return {
|
||||
detected: true,
|
||||
rule: 2,
|
||||
ltl,
|
||||
evaluationCompleteDay: fourthDay.originalCycleDay
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function secondOrThirdTempIsAtOrBelowLtl(nextDaysAfterPotentialFhm, ltl) {
|
||||
const secondIsLow = nextDaysAfterPotentialFhm[0].temp <= ltl
|
||||
const thirdIsLow = nextDaysAfterPotentialFhm[1].temp <= ltl
|
||||
if ((secondIsLow || thirdIsLow) && !(secondIsLow && thirdIsLow)) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function rounded(val, step) {
|
||||
const inverted = 1 / step
|
||||
// we round the difference because of JS decimal weirdness
|
||||
return Math.round(val * inverted) / inverted
|
||||
}
|
||||
|
||||
|
||||
// since we're dealing with floats, there is some imprecision in comparisons,
|
||||
// so we add an error margin that is definitely much smaller than any possible
|
||||
// actual difference between values
|
||||
// see https://floating-point-gui.de/errors/comparison/ for background
|
||||
|
||||
const errorMargin = 0.0001
|
||||
|
||||
function isLessThan0Point2(val) {
|
||||
return val < (0.2 - errorMargin)
|
||||
}
|
||||
|
||||
function isBiggerOrEqual0Point2(val) {
|
||||
return val >= (0.2 - errorMargin)
|
||||
}
|
||||
Reference in New Issue
Block a user