Skip to content

Commit

Permalink
Better prediction of next menstruation (#20)
Browse files Browse the repository at this point in the history
* Added jest
* Refactored predictions and added test
  • Loading branch information
raae committed Sep 6, 2019
1 parent ad530a7 commit ea693ac
Show file tree
Hide file tree
Showing 13 changed files with 1,680 additions and 182 deletions.
4 changes: 4 additions & 0 deletions jest-preprocess.js
@@ -0,0 +1,4 @@
const babelOptions = {
presets: ["babel-preset-gatsby"],
}
module.exports = require("babel-jest").createTransformer(babelOptions)
16 changes: 16 additions & 0 deletions jest.config.js
@@ -0,0 +1,16 @@
module.exports = {
transform: {
"^.+\\.jsx?$": `<rootDir>/jest-preprocess.js`,
},
moduleNameMapper: {
".+\\.(css|styl|less|sass|scss)$": `identity-obj-proxy`,
".+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": `<rootDir>/__mocks__/file-mock.js`,
},
testPathIgnorePatterns: [`node_modules`, `.cache`, `public`],
transformIgnorePatterns: [`node_modules/(?!(gatsby)/)`],
globals: {
__PATH_PREFIX__: ``,
},
testURL: `http://localhost`,
setupFiles: [`<rootDir>/loadershim.js`],
}
Empty file added loadershim.js
Empty file.
9 changes: 7 additions & 2 deletions package.json
Expand Up @@ -10,7 +10,7 @@
"format": "prettier --write \"**/*.{js,jsx,json,md}\"",
"start": "npm run develop",
"serve": "gatsby serve",
"test": "echo \"Write tests! -> https://gatsby.dev/unit-testing\""
"test": "jest"
},
"dependencies": {
"@material-ui/core": "^4.3.2",
Expand All @@ -37,7 +37,12 @@
"typeface-seymour-one": "^0.0.71"
},
"devDependencies": {
"prettier": "^1.18.2"
"babel-jest": "^24.9.0",
"babel-preset-gatsby": "^0.2.12",
"identity-obj-proxy": "^3.0.0",
"jest": "^24.9.0",
"prettier": "^1.18.2",
"react-test-renderer": "^16.9.0"
},
"repository": {
"type": "git",
Expand Down
59 changes: 34 additions & 25 deletions src/components/MenstruationNote.js
Expand Up @@ -5,7 +5,7 @@ import { format } from "date-fns"
import { Paper, Button, Typography, makeStyles } from "@material-ui/core"

import useSettings from "../store/useSettings"
import usePredictions from "../store/usePredictions"
import useCycle from "../store/useCycle"

const useStyles = makeStyles((theme) => ({
root: {
Expand Down Expand Up @@ -40,44 +40,53 @@ const NoMenstruationTagSetting = () => {

const NotEnoughData = () => {
return (
<>
<Typography>
There is not enough data yet to give you personalized predictions. Keep
on tracking.
</Typography>
</>
<Typography>
There is not enough data yet to give you personalized predictions. Keep on
tracking.
</Typography>
)
}

const NextMenstruation = ({ cycleDay, nextMenstruation, tag }) => {
const NextNote = ({ nextStartDate, tag }) => {
if (!nextStartDate) return null

nextStartDate = new Date(nextStartDate)
return (
<>
<Typography gutterBottom>
Your are on day <strong>{cycleDay}</strong> of your current cycle.
</Typography>
<Typography gutterBottom>
Next <strong>#{tag}</strong> is coming around{" "}
<strong>{format(nextMenstruation, "eeee MMMM do")}</strong>.
</Typography>
</>
<Typography gutterBottom>
Next <strong>#{tag}</strong> is coming around{" "}
<strong>{format(nextStartDate, "eeee MMMM do")}</strong>.
</Typography>
)
}

const CycleDayNote = ({ cycleDay }) => {
if (!cycleDay) return null
return (
<Typography gutterBottom>
Your are on day <strong>{cycleDay}</strong> of your current cycle.
</Typography>
)
}

const MenstruationNote = () => {
const classes = useStyles()
const [{ menstruationSettings }] = useSettings()
const [{ currentCycleDay, nextMenstruation }] = usePredictions()
const [{ nextStartDate }, { getCurrentDayInCycle }] = useCycle()
const cycleDay = getCurrentDayInCycle(Date.now())
const menstruationTag = menstruationSettings.tag

let note = <NotEnoughData />
if (currentCycleDay) {
if (cycleDay || nextStartDate) {
note = (
<NextMenstruation
cycleDay={currentCycleDay}
nextMenstruation={nextMenstruation}
tag={menstruationSettings.tag}
></NextMenstruation>
<>
<CycleDayNote cycleDay={cycleDay}></CycleDayNote>
<NextNote
nextStartDate={nextStartDate}
tag={menstruationTag}
></NextNote>
</>
)
} else if (!menstruationSettings.tag) {
} else if (!menstruationTag) {
note = <NoMenstruationTagSetting />
}
return (
Expand Down
17 changes: 5 additions & 12 deletions src/components/MenstruationSettings.js
Expand Up @@ -17,7 +17,7 @@ import CancelIcon from "@material-ui/icons/Cancel"
import SubmitIcon from "@material-ui/icons/CheckCircle"

import useSettings from "../store/useSettings"
import usePredictions from "../store/usePredictions"
import useCycle from "../store/useCycle"

const useStyles = makeStyles((theme) => ({
root: {
Expand Down Expand Up @@ -165,7 +165,7 @@ const TagForm = ({ tag, onTagChange, onClose }) => {
const MenstruationSettings = () => {
const classes = useStyles()
const [{ menstruationSettings }, { setMenstruationSettings }] = useSettings()
const [{ averageCycle, defaultCycle }] = usePredictions()
const [{ averageLength }] = useCycle()
const [isEditing, setIsEditing] = useState(false)

return (
Expand Down Expand Up @@ -193,17 +193,10 @@ const MenstruationSettings = () => {
onClose={() => setIsEditing(false)}
/>
)}
{averageCycle && (
{averageLength > 1 && (
<Typography gutterBottom variant="body2" color="textSecondary">
POW! uses your tracked average cycle length of{" "}
<strong>{averageCycle} days</strong>.
</Typography>
)}
{!averageCycle && (
<Typography gutterBottom variant="body2" color="textSecondary">
POW! uses a default cycle length of{" "}
<strong>{defaultCycle} days</strong> until you have tracked a full
cycle.
Your tracked average cycle length is{" "}
<strong>{averageLength} days</strong>.
</Typography>
)}
</CardContent>
Expand Down
10 changes: 6 additions & 4 deletions src/store/constants.js
Expand Up @@ -12,10 +12,12 @@ export const DEFAULT_STATE = {
tag: "",
},
},
predictions: {
defaultCycle: 28,
averageCycle: null,
currentCycleStart: null,
cycle: {
startDates: [],
currentStartDate: null,
nextStartDate: null,
averageLength: null,
tags: {},
},
status: {
[INIT_STATUS_KEY]: false,
Expand Down
3 changes: 3 additions & 0 deletions src/store/useCycle/index.js
@@ -0,0 +1,3 @@
import useCycle from "./useCycle"

export default useCycle
74 changes: 74 additions & 0 deletions src/store/useCycle/useCycle.js
@@ -0,0 +1,74 @@
import { useEffect } from "react"
import { analyzeEntries, daysBetweenDates, addDaysToDate } from "./utils"

import { useStore } from "../store"
import useEntries from "../useEntries"
import useSettings from "../useSettings"

const useCycle = () => {
const [{ cycle }, setState] = useStore()
const [{ entriesByDate }] = useEntries()
const [{ menstruationSettings }] = useSettings()

const updateCycle = (newCycle) => {
setState((state) => ({
...state,
cycle: {
...state.cycle,
...newCycle,
},
}))
}

const getCycleValue = (key) => {
if (!cycle.hasOwnProperty(key)) {
console.warn(`No ${key} on cycle slice of state`)
}
return cycle[key]
}

const getCurrentStartDate = () => {
const startDates = getCycleValue("startDates")
if (startDates.length > 0) {
return startDates[0]
}
}

const getNextStartDate = () => {
const currentStartDate = getCurrentStartDate()
const averageLength = getCycleValue("averageLength")
if (averageLength && currentStartDate) {
return addDaysToDate(currentStartDate, averageLength)
}
}

const getCurrentDayInCycle = (date) => {
const currentStartDate = getCurrentStartDate()
if (currentStartDate) {
return daysBetweenDates(date, currentStartDate) + 1
}
}

useEffect(() => {
const tag = menstruationSettings.tag
const averageLength = cycle.averageLength
const newCycle = analyzeEntries({ entriesByDate, tag, averageLength })
updateCycle(newCycle)
console.log(newCycle)
}, [entriesByDate, menstruationSettings.tag])

return [
{
startDates: getCycleValue("startDates"),
currentStartDate: getCurrentStartDate(),
nextStartDate: getNextStartDate(),
averageLength: getCycleValue("averageLength"),
tags: getCycleValue("tags"),
},
{
getCurrentDayInCycle,
},
]
}

export default useCycle
79 changes: 79 additions & 0 deletions src/store/useCycle/utils.js
@@ -0,0 +1,79 @@
import { values, sum } from "lodash"
import { differenceInDays, addDays, format } from "date-fns"

const getLastInArray = (array) => {
const index = array.length > 0 ? array.length - 1 : 0
return array[index]
}

const replaceLastItemInArray = (array, item) => {
const index = array.length > 0 ? array.length - 1 : 0
array[index] = item
}

export const daysBetweenDates = (dateA, dateB) => {
if (!(dateA instanceof Date)) {
dateA = new Date(dateA)
}
if (!(dateB instanceof Date)) {
dateB = new Date(dateB)
}

if (isNaN(dateA.valueOf()) || isNaN(dateB.valueOf())) return 0

return Math.abs(differenceInDays(dateA, dateB))
}

export const addDaysToDate = (date, days) => {
if (!(date instanceof Date)) {
date = new Date(date)
}

if (isNaN(date.valueOf())) return

const newDate = addDays(date, days)
return format(newDate, "yyyy-MM-dd")
}

const entryHasTag = (entry, tag) => {
return entry.tags.includes(tag)
}

export const analyzeEntries = ({ entriesByDate, tag }) => {
const sortedEntries = values(entriesByDate).sort((a, b) =>
a.date > b.date ? -1 : 1
)

const startDates = []
const cycleLengths = []
let averageLength = undefined

for (let entry of sortedEntries) {
if (entryHasTag(entry, tag)) {
const lastStartDate = getLastInArray(startDates)
let difference = daysBetweenDates(entry.date, lastStartDate)

if (difference >= 14) {
startDates.push(entry.date)
cycleLengths.push(difference)
} else {
replaceLastItemInArray(startDates, entry.date)

if (difference > 0 && cycleLengths.length > 0) {
difference = getLastInArray(cycleLengths) + difference
replaceLastItemInArray(cycleLengths, difference)
}
}
}
}

if (cycleLengths.length > 0) {
averageLength = sum(cycleLengths) / cycleLengths.length
}

return {
startDates,
averageLength,
tags: {},
}
}

0 comments on commit ea693ac

Please sign in to comment.