diff --git a/components/stats.js b/components/stats.js index b02e03f..a888de4 100644 --- a/components/stats.js +++ b/components/stats.js @@ -9,16 +9,17 @@ import cycleModule from '../lib/cycle' import {getCycleLengthStats as getCycleInfo} from '../lib/cycle-length' import {stats as labels} from './labels' import AppText from './app-text' +import { getCycleStartsSortedByDate } from '../db' export default class Stats extends Component { render() { - const allMensesStarts = cycleModule().getAllMensesStarts() + const allMensesStarts = getCycleStartsSortedByDate() const atLeastOneCycle = allMensesStarts.length > 1 let cycleLengths let numberOfCycles let cycleInfo if (atLeastOneCycle) { - cycleLengths = cycleModule().getCycleLength(allMensesStarts) + cycleLengths = cycleModule().getAllCycleLengths() numberOfCycles = cycleLengths.length if (numberOfCycles > 1) { cycleInfo = getCycleInfo(cycleLengths) diff --git a/db/index.js b/db/index.js index ce0212a..fe8c269 100644 --- a/db/index.js +++ b/db/index.js @@ -4,8 +4,11 @@ import nodejs from 'nodejs-mobile-react-native' import fs from 'react-native-fs' import restart from 'react-native-restart' import schemas from './schemas' +import cycleModule from '../lib/cycle' let db +let isMensesStart +let getMensesDaysRightAfter export async function openDb ({ hash, persistConnection }) { const realmConfig = {} @@ -32,9 +35,11 @@ export async function openDb ({ hash, persistConnection }) { )) if (persistConnection) db = connection + const cycle = cycleModule() + isMensesStart = cycle.isMensesStart + getMensesDaysRightAfter = cycle.getMensesDaysRightAfter } - export function getBleedingDaysSortedByDate() { return db.objects('CycleDay').filtered('bleeding != null').sorted('date', true) } @@ -45,9 +50,61 @@ export function getCycleDaysSortedByDate() { return db.objects('CycleDay').sorted('date', true) } +export function getCycleStartsSortedByDate() { + return db.objects('CycleDay').filtered('isCycleStart = true').sorted('date', true) +} + export function saveSymptom(symptom, cycleDay, val) { db.write(() => { - cycleDay[symptom] = val + if (bleedingValueDeleted(symptom, val)) { + cycleDay.bleeding = val + cycleDay.isCycleStart = false + maybeSetNewCycleStart(cycleDay, val) + } else if (bleedingValueAddedOrChanged(symptom, val)) { + cycleDay.bleeding = val + cycleDay.isCycleStart = isMensesStart(cycleDay) + maybeClearOldCycleStarts(cycleDay) + } else { + cycleDay[symptom] = val + } + }) + + function bleedingValueDeleted(symptom, val) { + return symptom === 'bleeding' && !val + } + + function bleedingValueAddedOrChanged(symptom, val) { + return symptom === 'bleeding' && val + } + + function maybeSetNewCycleStart(dayWithDeletedBleeding) { + // if a bleeding value is deleted, we need to check if + // there are any following bleeding days and if the + // next one of them is now a cycle start + const mensesDaysAfter = getMensesDaysRightAfter(dayWithDeletedBleeding) + if (!mensesDaysAfter.length) return + const nextOne = mensesDaysAfter[mensesDaysAfter.length - 1] + if (isMensesStart(nextOne)) { + nextOne.isCycleStart = true + } + } + + function maybeClearOldCycleStarts(cycleDay) { + // if we have a new bleeding day, we need to clear the + // menses start marker from all following days of this + // menses that may have been marked as start before + const mensesDaysAfter = getMensesDaysRightAfter(cycleDay) + mensesDaysAfter.forEach(day => day.isCycleStart = false) + } +} + +export function updateCycleStartsForAllCycleDays() { + db.write(() => { + getBleedingDaysSortedByDate().forEach(day => { + if (isMensesStart(day)) { + day.isCycleStart = true + } + }) }) } @@ -56,7 +113,8 @@ export function getOrCreateCycleDay(localDate) { if (!result) { db.write(() => { result = db.create('CycleDay', { - date: localDate + date: localDate, + isCycleStart: false }) }) } @@ -77,8 +135,10 @@ export function getPreviousTemperature(cycleDay) { return winner.temperature.value } -export function tryToCreateCycleDay(day, i) { +function tryToCreateCycleDayFromImport(day, i) { try { + // we cannot know this yet, gets detected afterwards + day.isCycleStart = false db.create('CycleDay', day) } catch (err) { const msg = `Line ${i + 1}(${day.date}): ${err.message}` @@ -106,7 +166,7 @@ export function getSchema() { export function tryToImportWithDelete(cycleDays) { db.write(() => { db.delete(db.objects('CycleDay')) - cycleDays.forEach(tryToCreateCycleDay) + cycleDays.forEach(tryToCreateCycleDayFromImport) }) } @@ -115,7 +175,7 @@ export function tryToImportWithoutDelete(cycleDays) { cycleDays.forEach((day, i) => { const existing = getCycleDay(day.date) if (existing) db.delete(existing) - tryToCreateCycleDay(day, i) + tryToCreateCycleDayFromImport(day, i) }) }) } diff --git a/db/schemas/2.js b/db/schemas/2.js new file mode 100644 index 0000000..6bf5cfb --- /dev/null +++ b/db/schemas/2.js @@ -0,0 +1,166 @@ +import cycleModule from '../../lib/cycle' + +const TemperatureSchema = { + name: 'Temperature', + properties: { + value: 'double', + exclude: 'bool', + time: { + type: 'string', + optional: true + }, + note: { + type: 'string', + optional: true + } + } +} + +const BleedingSchema = { + name: 'Bleeding', + properties: { + value: 'int', + exclude: 'bool' + } +} + +const MucusSchema = { + name: 'Mucus', + properties: { + feeling: 'int', + texture: 'int', + value: 'int', + exclude: 'bool' + } +} + +const CervixSchema = { + name: 'Cervix', + properties: { + opening: 'int', + firmness: 'int', + position: {type: 'int', optional: true }, + exclude: 'bool' + } +} + +const NoteSchema = { + name: 'Note', + properties: { + value: 'string' + } +} + +const DesireSchema = { + name: 'Desire', + properties: { + value: 'int' + } +} + +const SexSchema = { + name: 'Sex', + properties: { + solo: { type: 'bool', optional: true }, + partner: { type: 'bool', optional: true }, + condom: { type: 'bool', optional: true }, + pill: { type: 'bool', optional: true }, + iud: { type: 'bool', optional: true }, + patch: { type: 'bool', optional: true }, + ring: { type: 'bool', optional: true }, + implant: { type: 'bool', optional: true }, + diaphragm: { type: 'bool', optional: true }, + none: { type: 'bool', optional: true }, + other: { type: 'bool', optional: true }, + note: { type: 'string', optional: true } + } +} + +const PainSchema = { + name: 'Pain', + properties: { + cramps: { type: 'bool', optional: true }, + ovulationPain: { type: 'bool', optional: true }, + headache: { type: 'bool', optional: true }, + backache: { type: 'bool', optional: true }, + nausea: { type: 'bool', optional: true }, + tenderBreasts: { type: 'bool', optional: true }, + migraine: { type: 'bool', optional: true }, + other: { type: 'bool', optional: true }, + note: { type: 'string', optional: true } + } +} + +const CycleDaySchema = { + name: 'CycleDay', + primaryKey: 'date', + properties: { + date: 'string', + temperature: { + type: 'Temperature', + optional: true + }, + isCycleStart: 'bool', + bleeding: { + type: 'Bleeding', + optional: true + }, + mucus: { + type: 'Mucus', + optional: true + }, + cervix: { + type: 'Cervix', + optional: true + }, + note: { + type: 'Note', + optional: true + }, + desire: { + type: 'Desire', + optional: true + }, + sex: { + type: 'Sex', + optional: true + }, + pain: { + type: 'Pain', + optional: true + } + } +} + +export default { + schema: [ + CycleDaySchema, + TemperatureSchema, + BleedingSchema, + MucusSchema, + CervixSchema, + NoteSchema, + DesireSchema, + SexSchema, + PainSchema + ], + schemaVersion: 2, + migration: (oldRealm, newRealm) => { + if (oldRealm.schemaVersion >= 2) return + const oldBleedingDays = oldRealm.objects('CycleDay') + .filtered('bleeding != null') + .sorted('date', true) + + const { isMensesStart } = cycleModule({ + bleedingDaysSortedByDate: oldBleedingDays + }) + + const newBleedingDays = newRealm.objects('CycleDay') + .filtered('bleeding != null') + .sorted('date', true) + + oldBleedingDays.forEach((day, i) => { + newBleedingDays[i].isCycleStart = isMensesStart(day) + }) + } +} diff --git a/db/schemas/index.js b/db/schemas/index.js index eacf7dd..5b80738 100644 --- a/db/schemas/index.js +++ b/db/schemas/index.js @@ -1,4 +1,5 @@ import schema0 from './0.js' import schema1 from './1.js' +import schema2 from './2.js' -export default [schema0, schema1] \ No newline at end of file +export default [schema0, schema1, schema2] \ No newline at end of file diff --git a/lib/cycle.js b/lib/cycle.js index 5393eb9..0f47690 100644 --- a/lib/cycle.js +++ b/lib/cycle.js @@ -5,6 +5,7 @@ const DAYS = joda.ChronoUnit.DAYS export default function config(opts) { let bleedingDaysSortedByDate + let cycleStartsSortedByDate let cycleDaysSortedByDate let maxBreakInBleeding let maxCycleLength @@ -14,57 +15,22 @@ export default function config(opts) { // we only want to require (and run) the db module // when not running the tests bleedingDaysSortedByDate = require('../db').getBleedingDaysSortedByDate() + cycleStartsSortedByDate = require('../db').getCycleStartsSortedByDate() cycleDaysSortedByDate = require('../db').getCycleDaysSortedByDate() maxBreakInBleeding = 1 maxCycleLength = 99 minCyclesForPrediction = 3 } else { bleedingDaysSortedByDate = opts.bleedingDaysSortedByDate || [] + cycleStartsSortedByDate = opts.cycleStartsSortedByDate || [] cycleDaysSortedByDate = opts.cycleDaysSortedByDate || [] maxBreakInBleeding = opts.maxBreakInBleeding || 1 maxCycleLength = opts.maxCycleLength || 99 minCyclesForPrediction = opts.minCyclesForPrediction || 3 } - function findLatestMensesStart(bleedingDays) { - if (!bleedingDays.length) return null - - // assumes bleeding days are ordered latest first, and - // excluded values already removed - const lastMensesStart = bleedingDays.find((day, i) => { - return noBleedingDayWithinThreshold(day, bleedingDays.slice(i + 1)) - }) - - function noBleedingDayWithinThreshold(day, previousBleedingDays) { - const localDate = LocalDate.parse(day.date) - const threshold = localDate.minusDays(maxBreakInBleeding + 1).toString() - return !previousBleedingDays.some(({ date }) => date >= threshold) - } - - return lastMensesStart - } - function getLastMensesStartForDay(targetDateString) { - // the index of the first bleeding day before the target day - const index = bleedingDaysSortedByDate.findIndex(day => { - return day.date <= targetDateString && !day.bleeding.exclude - }) - - if (index < 0) return null - - const prevBleedingDays = bleedingDaysSortedByDate.slice(index) - return findLatestMensesStart(prevBleedingDays) - } - - function getFollowingMensesStartForDay(targetDateString) { - const followingBleedingDays = bleedingDaysSortedByDate - .filter(day => !day.bleeding.exclude) - .reverse() - - const firstBleedingDayAfterTargetDay = followingBleedingDays - .find(day => day.date > targetDateString) - - return firstBleedingDayAfterTargetDay + return cycleStartsSortedByDate.find(start => start.date <= targetDateString) } function getCycleDayNumber(targetDateString) { @@ -78,87 +44,123 @@ export default function config(opts) { return diffInDays + 1 } - function getCyclesBefore(targetCycleStartDay) { - return collectPreviousCycles([], targetCycleStartDay.date) - } - - function collectPreviousCycles(acc, startOfFollowingCycle) { - const cycle = getPreviousCycle(startOfFollowingCycle) - if (!cycle || !cycle.length) return acc - acc.push(cycle) - return collectPreviousCycles(acc, cycle[cycle.length - 1].date) - } - function getPreviousCycle(dateString) { - const startOfCycle = getLastMensesStartForDay(dateString) - if (!startOfCycle) return null - const dateBeforeStartOfCycle = LocalDate - .parse(startOfCycle.date) - .minusDays(1) - .toString() - - return getCycleForDay(dateBeforeStartOfCycle) - } - - function getCycleForDay(dayOrDate) { - const dateString = typeof dayOrDate === 'string' ? dayOrDate : dayOrDate.date const cycleStart = getLastMensesStartForDay(dateString) if (!cycleStart) return null - const cycleStartIndex = cycleDaysSortedByDate.indexOf(cycleStart) - const nextMensesStart = getFollowingMensesStartForDay(dateString) + const i = cycleStartsSortedByDate.indexOf(cycleStart) + const earlierCycleStart = cycleStartsSortedByDate[i + 1] + if (!earlierCycleStart) return null + return getCycleForCycleStartDay(earlierCycleStart) + } + + function getCyclesBefore(targetCycleStartDay) { + const startFromHere = cycleStartsSortedByDate.findIndex(start => { + return start.date < targetCycleStartDay.date + }) + if (startFromHere < 0) return null + return cycleStartsSortedByDate + .slice(startFromHere) + .map(getCycleForCycleStartDay) + } + + function getCycleForCycleStartDay(startDay) { + const cycleStartIndex = cycleDaysSortedByDate.indexOf(startDay) + const i = cycleStartsSortedByDate.indexOf(startDay) + const nextMensesStart = cycleStartsSortedByDate[i - 1] if (nextMensesStart) { return cycleDaysSortedByDate.slice( cycleDaysSortedByDate.indexOf(nextMensesStart) + 1, - cycleStartIndex + 1 + cycleStartIndex + 1, ) } else { return cycleDaysSortedByDate.slice(0, cycleStartIndex + 1) } } - function getAllMensesStarts(initialBleedingDays = bleedingDaysSortedByDate) { - return recurse(initialBleedingDays.filter(d => !d.bleeding.exclude)) + function getCycleForDay(dayOrDate) { + const dateString = typeof dayOrDate === 'string' ? dayOrDate : dayOrDate.date + const cycleStart = getLastMensesStartForDay(dateString) + if (!cycleStart) return null + return getCycleForCycleStartDay(cycleStart) + } - function recurse(bleedingDays, collectedDates) { - collectedDates = collectedDates || [] - const lastStart = findLatestMensesStart(bleedingDays) - if (!lastStart) { - return collectedDates - } else { - collectedDates.push(lastStart.date) - const index = bleedingDays.indexOf(lastStart) - const remainingDays = bleedingDays.slice(index + 1) - return recurse(remainingDays, collectedDates) - } + function isMensesStart(cycleDay) { + if (!cycleDay.bleeding || cycleDay.bleeding.exclude) return false + if (noBleedingDayWithinThresholdBefore(cycleDay)) return true + return false + + // checks that there are no relevant bleeding days before + // the input cycleDay (returns boolean) + function noBleedingDayWithinThresholdBefore(cycleDay) { + const localDate = LocalDate.parse(cycleDay.date) + const threshold = localDate.minusDays(maxBreakInBleeding + 1).toString() + const bleedingDays = bleedingDaysSortedByDate + const index = bleedingDays.findIndex(day => day.date === cycleDay.date) + const candidates = bleedingDays.slice(index + 1) + return !candidates.some(day => { + return day.date >= threshold && !day.bleeding.exclude + }) } } - function getCycleLength(cycleStartDates) { - const cycleLengths = [] - for (let i = 0; i < cycleStartDates.length - 1; i++) { - const nextCycleStart = LocalDate.parse(cycleStartDates[i]) - const cycleStart = LocalDate.parse(cycleStartDates[i + 1]) - const cycleLength = cycleStart.until(nextCycleStart, DAYS) - if (cycleLength <= maxCycleLength) { cycleLengths.push(cycleLength) } + // returns all bleeding days that belong to one menses directly following + // the cycle day. used to set or clear new cycle starts when the target day + // changes + function getMensesDaysRightAfter(cycleDay) { + const bleedingDays = bleedingDaysSortedByDate + .filter(d => !d.bleeding.exclude) + .reverse() + const firstFollowingBleedingDayIndex = bleedingDays.findIndex(day => { + return day.date > cycleDay.date + }) + return recurse(cycleDay, firstFollowingBleedingDayIndex, []) + + // we look at the current bleeding day as well as the next, and decide + // whether they belong to one menses. if they do, we collect them, once + // they don't, we're done + function recurse(day, nextIndex, mensesDays) { + const next = bleedingDays[nextIndex] + if (!next) return mensesDays + if (!isWithinThreshold(day, next)) return mensesDays + mensesDays.unshift(next) + return recurse(next, nextIndex + 1, mensesDays) } - return cycleLengths + + // checks whether the two days belong to one menses episode + function isWithinThreshold(bleedingDay, nextBleedingDay) { + const localDate = LocalDate.parse(bleedingDay.date) + const threshold = localDate.plusDays(maxBreakInBleeding + 1).toString() + return nextBleedingDay.date <= threshold + } + } + + function getAllCycleLengths() { + return cycleStartsSortedByDate + .map(day => LocalDate.parse(day.date)) + .reduce((lengths, cycleStart, i, startsAsLocalDates) => { + if (i === startsAsLocalDates.length - 1) return lengths + const prevCycleStart = startsAsLocalDates[i + 1] + const cycleLength = prevCycleStart.until(cycleStart, DAYS) + if (cycleLength <= maxCycleLength) { lengths.push(cycleLength) } + return lengths + }, []) } function getPredictedMenses() { - const allMensesStarts = getAllMensesStarts() + const allMensesStarts = cycleStartsSortedByDate const atLeastOneCycle = allMensesStarts.length > 1 if (!atLeastOneCycle || allMensesStarts.length < minCyclesForPrediction ) { return [] } - const cycleLengths = getCycleLength(allMensesStarts) + const cycleLengths = getAllCycleLengths() const cycleInfo = getCycleLengthStats(cycleLengths) const periodDistance = Math.round(cycleInfo.mean) let periodStartVariation if (cycleInfo.stdDeviation === null) { periodStartVariation = 2 - } else if (cycleInfo.stdDeviation < 1.5) { // threshold is choosen a little arbitrarily + } else if (cycleInfo.stdDeviation < 1.5) { // threshold is chosen a little arbitrarily periodStartVariation = 1 } else { periodStartVariation = 2 @@ -166,7 +168,7 @@ export default function config(opts) { if (periodDistance - 5 < periodStartVariation) { // otherwise predictions overlap return [] } - let lastStart = LocalDate.parse(allMensesStarts[0]) + let lastStart = LocalDate.parse(allMensesStarts[0].date) const predictedMenses = [] for (let i = 0; i < 3; i++) { lastStart = lastStart.plusDays(periodDistance) @@ -181,13 +183,15 @@ export default function config(opts) { return predictedMenses } + return { getCycleDayNumber, getCycleForDay, getPreviousCycle, getCyclesBefore, - getAllMensesStarts, - getCycleLength, - getPredictedMenses + getAllCycleLengths, + getPredictedMenses, + isMensesStart, + getMensesDaysRightAfter } } \ No newline at end of file diff --git a/lib/import-export/get-csv-column-names.js b/lib/import-export/get-csv-column-names.js index 3e8b425..d8626cd 100644 --- a/lib/import-export/get-csv-column-names.js +++ b/lib/import-export/get-csv-column-names.js @@ -7,6 +7,9 @@ export default function getColumnNamesForCsv() { const schema = getSchema() const model = schema[schemaName] return Object.keys(model).reduce((acc, key) => { + // we don't want to include isCycleStart, because that is + // a derived value + if (key === 'isCycleStart') return acc const prefixedKey = prefix ? [prefix, key].join('.') : key const childSchemaName = model[key].objectType if (!childSchemaName) { diff --git a/lib/import-export/import-from-csv.js b/lib/import-export/import-from-csv.js index c34ebc9..4731eea 100644 --- a/lib/import-export/import-from-csv.js +++ b/lib/import-export/import-from-csv.js @@ -1,6 +1,11 @@ import csvParser from 'csvtojson' import isObject from 'isobject' -import { getSchema, tryToImportWithDelete, tryToImportWithoutDelete } from '../../db' +import { + getSchema, + tryToImportWithDelete, + tryToImportWithoutDelete, + updateCycleStartsForAllCycleDays +} from '../../db' import getColumnNamesForCsv from './get-csv-column-names' export default async function importCsv(csv, deleteFirst) { @@ -49,6 +54,7 @@ export default async function importCsv(csv, deleteFirst) { } else { tryToImportWithoutDelete(cycleDays) } + updateCycleStartsForAllCycleDays() } function validateHeaders(headers) { diff --git a/test/cycle.spec.js b/test/cycle.spec.js index d61ee8b..77bf379 100644 --- a/test/cycle.spec.js +++ b/test/cycle.spec.js @@ -1,157 +1,250 @@ import chai from 'chai' import dirtyChai from 'dirty-chai' import cycleModule from '../lib/cycle' -import { LocalDate } from 'js-joda' const expect = chai.expect chai.use(dirtyChai) -function useBleedingDays(days) { - return cycleModule({ bleedingDaysSortedByDate: days }).getCycleDayNumber -} - -describe('getCycleDay', () => { +describe('getCycleDayNumber', () => { it('works for a simple example', () => { - const bleedingDays = [{ - date: '2018-05-10', - bleeding: { - value: 2 - } - }, { + const cycleStarts = [{ date: '2018-05-09', + isCycleStart: true, bleeding: { value: 2 } }, { date: '2018-05-03', - bleeding: { - value: 2 - } + isCycleStart: true, + bleeding: { value: 2 } }] - const getCycleDayNumber = useBleedingDays(bleedingDays) + const getCycleDayNumber = cycleModule({ + cycleStartsSortedByDate: cycleStarts + }).getCycleDayNumber const targetDate = '2018-05-17' const result = getCycleDayNumber(targetDate) expect(result).to.eql(9) }) - it('works if some bleedings are exluded', function () { - const bleedingDays = [{ - date: '2018-05-10', - bleeding: { - value: 2, - exclude: true - } - }, { - date: '2018-05-09', - bleeding: { - value: 2, - exclude: true - } - }, { - date: '2018-05-03', - bleeding: { - value: 2 - } - }] - const targetDate = '2018-05-17' - const getCycleDayNumber = useBleedingDays(bleedingDays) - const result = getCycleDayNumber(targetDate) - expect(result).to.eql(15) - }) - it('gets the correct number if the target day is not in the current cycle', () => { - const bleedingDays = [{ + const cycleStarts = [{ date: '2018-05-13', - bleeding: { - value: 2 - } - }, { - date: '2018-04-11', + isCycleStart: true, bleeding: { value: 2 } }, { date: '2018-04-10', - bleeding: { - value: 2 - } + isCycleStart: true, + bleeding: { value: 2 } }] const targetDate = '2018-04-27' - const getCycleDayNumber = useBleedingDays(bleedingDays) + const getCycleDayNumber = cycleModule({ + cycleStartsSortedByDate: cycleStarts + }).getCycleDayNumber const result = getCycleDayNumber(targetDate) expect(result).to.eql(18) }) it('gets the correct number if the target day is the only bleeding day', () => { - const bleedingDays = [{ + const cycleStarts = [{ date: '2018-05-13', - bleeding: { - value: 2 - } + isCycleStart: true, + bleeding: { value: 2 } }] const targetDate = '2018-05-13' - const getCycleDayNumber = useBleedingDays(bleedingDays) + const getCycleDayNumber = cycleModule({ + cycleStartsSortedByDate: cycleStarts + }).getCycleDayNumber const result = getCycleDayNumber(targetDate) expect(result).to.eql(1) }) - describe('getCycleDay returns null', () => { - it('if there are no bleeding days', function () { - const bleedingDays = [] - const targetDate = '2018-05-17' - const getCycleDayNumber = useBleedingDays(bleedingDays) - const result = getCycleDayNumber(targetDate) - expect(result).to.be.null() - }) + it('returns null if there are no bleeding days', function () { + const cycleStarts = [] + const targetDate = '2018-05-17' + const getCycleDayNumber = cycleModule({ + cycleStartsSortedByDate: cycleStarts + }).getCycleDayNumber + const result = getCycleDayNumber(targetDate) + expect(result).to.be.null() }) - describe('getCycleDay with cycle thresholds', () => { - const maxBreakInBleeding = 3 - it('disregards bleeding breaks shorter than max allowed bleeding break in a bleeding period', () => { - const bleedingDays = [{ - date: '2018-05-14', - bleeding: { - value: 2 - } - }, { - date: '2018-05-10', - bleeding: { - value: 2 - } - }] +}) - const targetDate = '2018-05-17' - const getCycleDayNumber = cycleModule({ - bleedingDaysSortedByDate: bleedingDays, - maxBreakInBleeding - }).getCycleDayNumber - const result = getCycleDayNumber(targetDate) - expect(result).to.eql(8) +describe('getPreviousCycle', () => { + it('gets previous cycle', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-07-05', + bleeding: { value: 2 } + }, + { + date: '2018-06-05', + bleeding: { value: 2 } + }, + { + date: '2018-05-05', + mucus: { value: 2 } + }, + { + date: '2018-05-04', + bleeding: { value: 2 } + }, + { + date: '2018-05-03', + bleeding: { value: 2 } + }, + { + date: '2018-04-05', + mucus: { value: 2 } + }, + { + date: '2018-04-04', + mucus: { value: 2 } + }, + { + date: '2018-04-03', + mucus: { value: 2 } + }, + { + date: '2018-04-02', + bleeding: { value: 2 } + }, + ] + + const cycleStarts = [ + '2018-07-05', + '2018-06-05', + '2018-05-03', + '2018-04-02' + ] + + const { getPreviousCycle } = cycleModule({ + cycleDaysSortedByDate, + cycleStartsSortedByDate: cycleDaysSortedByDate.filter(d => { + return cycleStarts.includes(d.date) + }) }) + const result = getPreviousCycle('2018-06-08') + expect(result).to.eql([ + { + date: '2018-05-05', + mucus: { value: 2 } + }, + { + date: '2018-05-04', + bleeding: { value: 2 } + }, + { + date: '2018-05-03', + bleeding: { value: 2 } + } + ]) + }) - it('counts bleeding breaks longer than maxAllowedBleedingBreak in a bleeding period', () => { - const bleedingDays = [{ - date: '2018-05-14', - bleeding: { - value: 2 - } - }, { - date: '2018-05-09', - bleeding: { - value: 2 - } - }] - const targetDate = '2018-05-17' - const getCycleDayNumber = cycleModule({ - bleedingDaysSortedByDate: bleedingDays, - maxBreakInBleeding - }).getCycleDayNumber - const result = getCycleDayNumber(targetDate) - expect(result).to.eql(4) + it('returns null when target day is not in a cyle', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-07-05', + }, + { + date: '2018-06-05', + }, + { + date: '2018-05-05', + }, + { + date: '2018-05-04', + }, + { + date: '2018-05-03', + }, + { + date: '2018-04-05', + }, + { + date: '2018-04-04', + mucus: { value: 2 } + }, + { + date: '2018-04-03', + }, + { + date: '2018-04-02', + }, + ] + + const cycleStarts = [] + + const { getPreviousCycle } = cycleModule({ + cycleDaysSortedByDate, + cycleStartsSortedByDate: cycleDaysSortedByDate.filter(d => { + return cycleStarts.includes(d.date) + }) }) + const result = getPreviousCycle('2018-06-08') + expect(result).to.eql(null) + }) + + it('returns null when there is no previous cycle', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-07-05', + bleeding: { value: 2 } + }, + { + date: '2018-06-05', + bleeding: { value: 2 } + }, + { + date: '2018-05-05', + mucus: { value: 2 } + }, + { + date: '2018-05-04', + bleeding: { value: 2 } + }, + { + date: '2018-05-03', + bleeding: { value: 2 } + }, + { + date: '2018-04-05', + mucus: { value: 2 } + }, + { + date: '2018-04-04', + mucus: { value: 2 } + }, + { + date: '2018-04-03', + mucus: { value: 2 } + }, + { + date: '2018-04-02', + bleeding: { value: 2 } + }, + ] + + const cycleStarts = [ + '2018-07-05', + '2018-06-05', + '2018-05-03', + '2018-04-02' + ] + + const { getPreviousCycle } = cycleModule({ + cycleDaysSortedByDate, + cycleStartsSortedByDate: cycleDaysSortedByDate.filter(d => { + return cycleStarts.includes(d.date) + }) + }) + const result = getPreviousCycle('2018-04-18') + expect(result).to.eql(null) }) }) @@ -196,9 +289,18 @@ describe('getCyclesBefore', () => { }, ] + const cycleStarts = [ + '2018-07-05', + '2018-06-05', + '2018-05-03', + '2018-04-02' + ] + const { getCyclesBefore } = cycleModule({ cycleDaysSortedByDate, - bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + cycleStartsSortedByDate: cycleDaysSortedByDate.filter(d => { + return cycleStarts.includes(d.date) + }) }) const result = getCyclesBefore(cycleDaysSortedByDate[0]) expect(result.length).to.eql(3) @@ -282,9 +384,18 @@ describe('getCycleForDay', () => { bleeding: { value: 2 } }, ] + const cycleStarts = [ + '2018-07-05', + '2018-06-05', + '2018-05-03', + '2018-04-02' + ] + const { getCycleForDay } = cycleModule({ cycleDaysSortedByDate, - bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + cycleStartsSortedByDate: cycleDaysSortedByDate.filter(d => { + return cycleStarts.includes(d.date) + }) }) it('gets cycle that has only one day', () => { @@ -350,16 +461,21 @@ describe('getPredictedMenses', () => { describe('cannot predict next menses', () => { it('if no bleeding is documented', () => { const cycleDaysSortedByDate = [ {} ] + const cycleStarts = [] const { getPredictedMenses } = cycleModule({ cycleDaysSortedByDate, bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding), + cycleStartsSortedByDate: cycleDaysSortedByDate.filter(d => { + return cycleStarts.includes(d.date) + }), maxCycleLength: 99, minCyclesForPrediction: 1 }) const result = getPredictedMenses() expect(result).to.eql([]) }) + it('if one bleeding is documented (no completed cycle)', () => { const cycleDaysSortedByDate = [ { @@ -367,16 +483,21 @@ describe('getPredictedMenses', () => { bleeding: { value: 2 } } ] + const cycleStarts = ['2018-06-02'] const { getPredictedMenses } = cycleModule({ cycleDaysSortedByDate, bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding), + cycleStartsSortedByDate: cycleDaysSortedByDate.filter(d => { + return cycleStarts.includes(d.date) + }), maxCycleLength: 99, minCyclesForPrediction: 1 }) const result = getPredictedMenses() expect(result).to.eql([]) }) + it('if number of cycles is below minCyclesForPrediction', () => { const cycleDaysSortedByDate = [ { @@ -392,10 +513,14 @@ describe('getPredictedMenses', () => { bleeding: { value: 2 } }, ] + const cycleStarts = ['2018-06-01', '2018-05-01'] const { getPredictedMenses } = cycleModule({ cycleDaysSortedByDate, - bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding), + cycleStartsSortedByDate: cycleDaysSortedByDate.filter(d => { + return cycleStarts.includes(d.date) + }), }) const result = getPredictedMenses() expect(result).to.eql([]) @@ -413,10 +538,12 @@ describe('getPredictedMenses', () => { bleeding: { value: 2 } } ] - + const cycleStarts = ['2018-07-15', '2018-07-01'] const { getPredictedMenses } = cycleModule({ cycleDaysSortedByDate, - bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding), + cycleStartsSortedByDate: cycleDaysSortedByDate.filter(d => { + return cycleStarts.includes(d.date) + }), minCyclesForPrediction: 1 }) const result = getPredictedMenses() @@ -445,6 +572,7 @@ describe('getPredictedMenses', () => { ] expect(result).to.eql(expectedResult) }) + it('if number of cycles is above minCyclesForPrediction', () => { const cycleDaysSortedByDate = [ { @@ -467,7 +595,8 @@ describe('getPredictedMenses', () => { const { getPredictedMenses } = cycleModule({ cycleDaysSortedByDate, - bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + cycleStartsSortedByDate: cycleDaysSortedByDate, + minCyclesForPrediction: 1 }) const result = getPredictedMenses() const expectedResult = [ @@ -489,6 +618,7 @@ describe('getPredictedMenses', () => { ] expect(result).to.eql(expectedResult) }) + it('3 cycles with little standard deviation', () => { const cycleDaysSortedByDate = [ { @@ -511,7 +641,7 @@ describe('getPredictedMenses', () => { const { getPredictedMenses } = cycleModule({ cycleDaysSortedByDate, - bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + cycleStartsSortedByDate: cycleDaysSortedByDate }) const result = getPredictedMenses() const expectedResult = [ @@ -533,6 +663,7 @@ describe('getPredictedMenses', () => { ] expect(result).to.eql(expectedResult) }) + it('3 cycles with bigger standard deviation', () => { const cycleDaysSortedByDate = [ { @@ -555,7 +686,7 @@ describe('getPredictedMenses', () => { const { getPredictedMenses } = cycleModule({ cycleDaysSortedByDate, - bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + cycleStartsSortedByDate: cycleDaysSortedByDate }) const result = getPredictedMenses() const expectedResult = [ @@ -586,49 +717,42 @@ describe('getPredictedMenses', () => { }) }) -describe('getAllMensesStart', () => { - it('works for one cycle start', () => { +describe('isMensesStart', () => { + it('works for simple menses start', () => { const cycleDaysSortedByDate = [ + { + date: '2018-05-04', + }, + { + date: '2018-05-03', + bleeding: { value: 1 } + }, + { + date: '2018-05-02', + bleeding: { value: 1 } + }, { date: '2018-05-01', bleeding: { value: 1 } + }, + { + date: '2018-04-30', } ] - const { getAllMensesStarts } = cycleModule({ + const { isMensesStart } = cycleModule({ cycleDaysSortedByDate, bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) }) - const result = getAllMensesStarts() - expect(result.length).to.eql(1) - expect(result).to.eql(['2018-05-01']) - }), - it('works for two cycle starts', () => { - const cycleDaysSortedByDate = [ - { - date: '2018-06-02', - bleeding: { value: 2 } - }, - { - date: '2018-06-01', - bleeding: { value: 2 } - }, - { - date: '2018-05-01', - bleeding: { value: 2 } - } - ] - - const { getAllMensesStarts } = cycleModule({ - cycleDaysSortedByDate, - bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) - }) - const result = getAllMensesStarts() - expect(result.length).to.eql(2) - expect(result).to.eql(['2018-06-01', '2018-05-01']) + const start = isMensesStart(cycleDaysSortedByDate[3]) + expect(start).to.be.true() + expect(isMensesStart(cycleDaysSortedByDate[0])).to.be.false() + expect(isMensesStart(cycleDaysSortedByDate[1])).to.be.false() + expect(isMensesStart(cycleDaysSortedByDate[2])).to.be.false() + expect(isMensesStart(cycleDaysSortedByDate[4])).to.be.false() }) - it('works for two cycle starts with excluded data', () => { + it('works with previous excluded value', () => { const cycleDaysSortedByDate = [ { date: '2018-06-01', @@ -639,50 +763,404 @@ describe('getAllMensesStart', () => { bleeding: { value: 2 } }, { - date: '2018-04-31', + date: '2018-04-30', bleeding: { value: 2 , exclude: true} }, ] - const { getAllMensesStarts } = cycleModule({ + const { isMensesStart } = cycleModule({ cycleDaysSortedByDate, bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) }) - const result = getAllMensesStarts() - expect(result.length).to.eql(2) - expect(result).to.eql(['2018-06-01', '2018-05-01']) + const start = isMensesStart(cycleDaysSortedByDate[1]) + expect(start).to.be.true() + const notStart = isMensesStart(cycleDaysSortedByDate[2]) + expect(notStart).to.be.false() }) - it('returns an empty array if no bleeding days are given', () => { - const cycleDaysSortedByDate = [ {} ] + it('returns false when day has no bleeding', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-06-01', + }, + { + date: '2018-05-01', + }, + { + date: '2018-04-30', + bleeding: { value: 2 , exclude: true} + }, + ] - const { getAllMensesStarts } = cycleModule({ + const { isMensesStart } = cycleModule({ cycleDaysSortedByDate, bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) }) - const result = getAllMensesStarts() - expect(result.length).to.eql(0) - expect(result).to.eql([]) + const start = isMensesStart(cycleDaysSortedByDate[0]) + expect(start).to.be.false() }) - it('is not slow with 500 menses starts', () => { - const startDate = LocalDate.parse('2018-10-01') - const cycleDaysSortedByDate = Array(500) - .fill(null) - .map((_, i) => { - return { - date: startDate.minusMonths(i).toString(), - bleeding: { value: 2 } + it('returns false when there is a previous bleeding day within the threshold', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-06-01', + }, + { + date: '2018-05-01', + }, + { + date: '2018-04-30', + bleeding: { value: 2 } + }, + { + date: '2018-04-29' + }, + { + date: '2018-04-28', + bleeding: { value: 2 } + }, + ] + + const { isMensesStart } = cycleModule({ + cycleDaysSortedByDate, + bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + }) + const start = isMensesStart(cycleDaysSortedByDate[2]) + expect(start).to.be.false() + }) + + it('returns true when there is a previous excluded bleeding day within the threshold', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-06-01', + }, + { + date: '2018-05-01', + }, + { + date: '2018-04-30', + bleeding: { value: 2 } + }, + { + date: '2018-04-29' + }, + { + date: '2018-04-28', + bleeding: { value: 2 , exclude: true} + }, + ] + + const { isMensesStart } = cycleModule({ + cycleDaysSortedByDate, + bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + }) + const start = isMensesStart(cycleDaysSortedByDate[2]) + expect(start).to.be.true() + }) + describe('with cycle thresholds', () => { + const maxBreakInBleeding = 3 + + it('disregards bleeding breaks equal to maxAllowedBleedingBreak in a bleeding period', () => { + const bleedingDays = [{ + date: '2018-05-14', + bleeding: { + value: 2 } - }) - const { getAllMensesStarts } = cycleModule({ - cycleDaysSortedByDate, - bleedingDaysSortedByDate: cycleDaysSortedByDate + }, { + date: '2018-05-10', + bleeding: { + value: 2 + } + }] + + const isMensesStart = cycleModule({ + bleedingDaysSortedByDate: bleedingDays, + maxBreakInBleeding + }).isMensesStart + const result = isMensesStart(bleedingDays[0]) + expect(result).to.be.false() + }) + + it('counts bleeding breaks longer than maxAllowedBleedingBreak in a bleeding period', () => { + const bleedingDays = [{ + date: '2018-05-14', + bleeding: { + value: 2 + } + }, { + date: '2018-05-09', + bleeding: { + value: 2 + } + }] + + const isMensesStart = cycleModule({ + bleedingDaysSortedByDate: bleedingDays, + maxBreakInBleeding + }).isMensesStart + const result = isMensesStart(bleedingDays[0]) + expect(result).to.be.true() + }) + }) +}) + +describe('getMensesDaysRightAfter', () => { + it('works for simple menses start', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-05-04', + }, + { + date: '2018-05-03', + bleeding: { value: 1 } + }, + { + date: '2018-05-02', + bleeding: { value: 1 } + }, + { + date: '2018-05-01', + bleeding: { value: 1 } + }, + { + date: '2018-04-30', + } + ] + + const { getMensesDaysRightAfter } = cycleModule({ + cycleDaysSortedByDate, + bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + }) + const days = getMensesDaysRightAfter(cycleDaysSortedByDate[3]) + expect(days).to.eql([ + { + date: '2018-05-03', + bleeding: { value: 1 } + }, + { + date: '2018-05-02', + bleeding: { value: 1 } + } + ]) + }) + + it('works when the day is not a bleeding day', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-05-04', + }, + { + date: '2018-05-03', + bleeding: { value: 1 } + }, + { + date: '2018-05-02', + bleeding: { value: 1 } + }, + { + date: '2018-05-01', + bleeding: { value: 1 } + }, + { + date: '2018-04-30', + bleeding: null + } + ] + + const { getMensesDaysRightAfter } = cycleModule({ + cycleDaysSortedByDate, + bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + }) + const days = getMensesDaysRightAfter(cycleDaysSortedByDate[4]) + expect(days).to.eql([ + { + date: '2018-05-03', + bleeding: { value: 1 } + }, + { + date: '2018-05-02', + bleeding: { value: 1 } + }, + { + date: '2018-05-01', + bleeding: { value: 1 } + } + ]) + }) + + it('ignores excluded values', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-05-04', + }, + { + date: '2018-05-03', + bleeding: { value: 1 } + }, + { + date: '2018-05-02', + bleeding: { value: 1, exclude: true } + }, + { + date: '2018-05-01', + bleeding: { value: 1 } + }, + { + date: '2018-04-30', + } + ] + + const { getMensesDaysRightAfter } = cycleModule({ + cycleDaysSortedByDate, + bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + }) + const days = getMensesDaysRightAfter(cycleDaysSortedByDate[3]) + expect(days).to.eql([ + { + date: '2018-05-03', + bleeding: { value: 1 } + } + ]) + }) + + it('returns empty when there are no bleeding days after', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-05-04', + }, + { + date: '2018-05-03', + }, + { + date: '2018-05-02', + }, + { + date: '2018-05-01', + bleeding: { value: 1 } + }, + { + date: '2018-04-30', + } + ] + + const { getMensesDaysRightAfter } = cycleModule({ + cycleDaysSortedByDate, + bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + }) + const days = getMensesDaysRightAfter(cycleDaysSortedByDate[3]) + expect(days).to.eql([]) + }) + + it('returns empty when there are no bleeding days within threshold', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-05-04', + bleeding: { value: 1 } + }, + { + date: '2018-05-03', + }, + { + date: '2018-05-02', + }, + { + date: '2018-05-01', + bleeding: { value: 1 } + }, + { + date: '2018-04-30', + } + ] + + const { getMensesDaysRightAfter } = cycleModule({ + cycleDaysSortedByDate, + bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + }) + const days = getMensesDaysRightAfter(cycleDaysSortedByDate[3]) + expect(days).to.eql([]) + }) + + it('includes days within the treshold', () => { + const cycleDaysSortedByDate = [ + { + date: '2018-05-04', + }, + { + date: '2018-05-05', + bleeding: { value: 1 } + }, + { + date: '2018-05-03', + bleeding: { value: 1 } + }, + { + date: '2018-05-01', + bleeding: { value: 1 } + }, + { + date: '2018-04-30', + } + ] + + const { getMensesDaysRightAfter } = cycleModule({ + cycleDaysSortedByDate, + bleedingDaysSortedByDate: cycleDaysSortedByDate.filter(d => d.bleeding) + }) + const days = getMensesDaysRightAfter(cycleDaysSortedByDate[3]) + expect(days).to.eql([ + { + date: '2018-05-05', + bleeding: { value: 1 } + }, + { + date: '2018-05-03', + bleeding: { value: 1 } + } + ]) + }) + describe('with cycle thresholds', () => { + const maxBreakInBleeding = 3 + + it('disregards bleeding breaks shorter than maxAllowedBleedingBreak in a bleeding period', () => { + const bleedingDays = [{ + date: '2018-05-14', + bleeding: { + value: 2 + } + }, { + date: '2018-05-10', + bleeding: { + value: 2 + } + }] + + const getMensesDaysRightAfter = cycleModule({ + bleedingDaysSortedByDate: bleedingDays, + maxBreakInBleeding + }).getMensesDaysRightAfter + const result = getMensesDaysRightAfter(bleedingDays[1]) + expect(result).to.eql([bleedingDays[0]]) + }) + + it('counts bleeding breaks longer than maxAllowedBleedingBreak in a bleeding period', () => { + const bleedingDays = [{ + date: '2018-05-14', + bleeding: { + value: 2 + } + }, { + date: '2018-05-09', + bleeding: { + value: 2 + } + }] + + const getMensesDaysRightAfter = cycleModule({ + bleedingDaysSortedByDate: bleedingDays, + maxBreakInBleeding + }).getMensesDaysRightAfter + const result = getMensesDaysRightAfter(bleedingDays[1]) + expect(result).to.eql([]) }) - const start = Date.now() - const result = getAllMensesStarts() - const duration = Date.now() - start - expect(result.length).to.eql(500) - expect(duration).to.be.lessThan(100) }) }) \ No newline at end of file