diff --git a/my-bois/package-lock.json b/my-bois/package-lock.json index 1cfbac1..1d86b60 100644 --- a/my-bois/package-lock.json +++ b/my-bois/package-lock.json @@ -1288,6 +1288,16 @@ "loader-utils": "^1.2.3" } }, + "@testing-library/react-hooks": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.2.1.tgz", + "integrity": "sha512-1OB6Ksvlk6BCJA1xpj8/WWz0XVd1qRcgqdaFAq+xeC6l61Ucj0P6QpA5u+Db/x9gU4DCX8ziR5b66Mlfg0M2RA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.4", + "@types/testing-library__react-hooks": "^3.0.0" + } + }, "@types/babel__core": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.3.tgz", @@ -1378,11 +1388,30 @@ "csstype": "^2.2.0" } }, + "@types/react-test-renderer": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-16.9.1.tgz", + "integrity": "sha512-nCXQokZN1jp+QkoDNmDZwoWpKY8HDczqevIDO4Uv9/s9rbGPbSpy8Uaxa5ixHKkcm/Wt0Y9C3wCxZivh4Al+rQ==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==" }, + "@types/testing-library__react-hooks": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.1.0.tgz", + "integrity": "sha512-QJc1sgH9DD6jbfybzugnP0sY8wPzzIq8sHDBuThzCr2ZEbyHIaAvN9ytx/tHzcWL5MqmeZJqiUm/GsythaGx3g==", + "dev": true, + "requires": { + "@types/react": "*", + "@types/react-test-renderer": "*" + } + }, "@types/yargs": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.3.tgz", diff --git a/my-bois/package.json b/my-bois/package.json index 3e9b84f..ad4dcc1 100644 --- a/my-bois/package.json +++ b/my-bois/package.json @@ -60,6 +60,7 @@ "workbox-webpack-plugin": "4.3.1" }, "devDependencies": { + "@testing-library/react-hooks": "^3.2.1", "@types/react": "^16.9.11" }, "scripts": { diff --git a/my-bois/src/components/Bois.jsx b/my-bois/src/components/Bois.jsx index a36cb50..e98cd07 100644 --- a/my-bois/src/components/Bois.jsx +++ b/my-bois/src/components/Bois.jsx @@ -2,11 +2,11 @@ import React, {useContext, useEffect, useState} from 'react'; import "./Bois.css"; import {StatusContext} from "./Contexts"; -import {CalorieScore, CpmScore, DistanceScore, RpmScore, Timer} from "./Score"; +import {CalorieScore, CpmScore, LevelScore, RpmScore, Timer} from "./Score"; import calculateDiff from "../helpers/diff"; import useKey from "../hooks/useKey"; import {Milestones} from "./Milestones"; -import {Info, InfoTable, StateFilter, Warning} from "./Misc"; +import {Info, InfoTable, StateFilter, Differ} from "./Misc"; const Boi = ({type, children}) => { return ( @@ -17,22 +17,23 @@ const Boi = ({type, children}) => { }; export const LeftBoi = () => { - const {prevLongDiff, workoutStatus, program, hidden} = useContext(StatusContext); - if (workoutStatus === null || program === null || hidden) { + const {prevLongDiff, workout, workoutStatus, program, hidden} = useContext(StatusContext); + if (workoutStatus === null || workout === null || program === null || hidden) { return null; } - const {minutes, seconds, calories, distance, rpm} = workoutStatus; - const diff = calculateDiff(program, minutes, seconds, calories); + const {cooldownMin} = workout; + const {minutes, seconds, calories, level, rpm} = workoutStatus; + const diff = calculateDiff({program, cooldownMin, minutes, seconds, calories}); const cpm = calories / (minutes + (seconds / 60)); return ( - + - + ); }; @@ -42,6 +43,9 @@ export const CentreBoi = () => { state, program, setProgram, bike, setBike, bikes, programs, start, pause, stop, create, workoutStatus, hidden, setHidden, + workout, + prevLongDiff, + toggleCooldown, } = useContext(StatusContext); const [options, setOptions] = useState(null); const [current, setCurrent] = useState(0); @@ -115,6 +119,12 @@ export const CentreBoi = () => { useKey(["H", "h"], () => showHide()); + useKey("*", () => { + if (state === "started") { + toggleCooldown(); + } + }); + function showHide() { setHidden(!hidden); } @@ -124,13 +134,14 @@ export const CentreBoi = () => { } if (state === "started" && workoutStatus !== null) { + const {cooldownMin} = workout; const {minutes, seconds, calories} = workoutStatus; - const diff = calculateDiff(program, minutes, seconds, calories); + const diff = calculateDiff({program, cooldownMin, minutes, seconds, calories}); - if (diff < 0) { + if (diff < 0 || (minutes > 0 && seconds === 0)) { return ( - {diff} + ); } else { @@ -147,15 +158,13 @@ export const CentreBoi = () => { return ""; } - if (options[current] === void(0)) { + if (options[current] === void (0)) { return ""; } return options[current].name; } - console.log(state); - return ( diff --git a/my-bois/src/components/Contexts.jsx b/my-bois/src/components/Contexts.jsx index b9f2047..1a5df6a 100644 --- a/my-bois/src/components/Contexts.jsx +++ b/my-bois/src/components/Contexts.jsx @@ -1,13 +1,15 @@ -import React, {createContext, useEffect, useState} from "react"; +import React, {createContext, useCallback, useEffect, useState} from "react"; import { connectWorkout, createNewWorkout, - fetchActiveWorkouts, fetchBikes, fetchPrograms, - openWebsocket, pauseWorkout, + fetchActiveWorkouts, + openWebsocket, + pauseWorkout, startWorkout, - stopWorkout + stopWorkout, updateCooldownMins } from "../hooks/net"; import useMilestones from "../hooks/milestones"; +import useOptions from "../hooks/options"; export const StatusContext = createContext({ bike: null, @@ -28,10 +30,9 @@ export const StatusContext = createContext({ }); export const StatusContextProvider = ({children}) => { + const {bikes, programs} = useOptions(); const [bike, setBike] = useState(null); const [program, setProgram] = useState(null); - const [bikes, setBikes] = useState(null); - const [programs, setPrograms] = useState(null); const [workout, setWorkout] = useState(null); const [workoutStatus, setWorkoutStatus] = useState(null); const [state, setState] = useState("offline"); @@ -39,21 +40,24 @@ export const StatusContextProvider = ({children}) => { const [socket, setSocket] = useState(null); const [hidden, setHidden] = useState(false); - useEffect(() => { - if (programs === null) { - fetchPrograms().then(newPrograms => setPrograms(newPrograms)); + const toggleCooldown = useCallback(async () => { + if (workout === null || workoutStatus === null) { + return; + } + + const {cooldownMin} = workout; + const {minutes} = workoutStatus; + + if (cooldownMin === -1) { + setWorkout(await updateCooldownMins(workout, minutes + 1)); + } else if (minutes < cooldownMin) { + setWorkout(await updateCooldownMins(workout, -1)); } - }, [programs]); + }, [workout, workoutStatus]); useEffect(() => { - if (bikes === null) { - fetchBikes().then(newBikes => { - setBikes(newBikes); - - if (newBikes.length === 1) { - setBike(newBikes[0]); - } - }); + if (bikes !== null && bikes.length === 1) { + setBike(bikes[0]); } }, [bikes]); @@ -94,15 +98,17 @@ export const StatusContextProvider = ({children}) => { } if (typeof body.workoutStatusBackfill !== "undefined") { + const cooldownMin = workout.cooldownMin; body.workoutStatusBackfill.forEach(wsbf => { setWorkoutStatus(wsbf); - msDispatch({type: "measure", payload: {...wsbf, program}}); + msDispatch({type: "measure", payload: {...wsbf, program, cooldownMin}}); }); } if (typeof body.workoutStatus !== "undefined") { + const cooldownMin = workout.cooldownMin; setWorkoutStatus(body.workoutStatus); - msDispatch({type: "measure", payload: {...body.workoutStatus, program}}); + msDispatch({type: "measure", payload: {...body.workoutStatus, program, cooldownMin}}); } }; @@ -162,6 +168,7 @@ export const StatusContextProvider = ({children}) => { programs, bikes, prevDiff, prevLongDiff, hidden, setHidden, + toggleCooldown, }}> {children} diff --git a/my-bois/src/components/Misc.css b/my-bois/src/components/Misc.css index 96e1899..40dc7a5 100644 --- a/my-bois/src/components/Misc.css +++ b/my-bois/src/components/Misc.css @@ -1,5 +1,5 @@ .Warning { - margin-top: 1em; + margin-top: 0; font-weight: 800; font-size: 150%; } diff --git a/my-bois/src/components/Misc.jsx b/my-bois/src/components/Misc.jsx index 30e59d1..47c34c3 100644 --- a/my-bois/src/components/Misc.jsx +++ b/my-bois/src/components/Misc.jsx @@ -1,7 +1,8 @@ import React from 'react'; -import {COLOR_VERY_BAD} from "../helpers/color"; +import {COLOR_VERY_BAD, colorByDiff} from "../helpers/color"; import "./Misc.css"; +import {diffString} from "../helpers/diff"; const Filter = ({bool, children}) => bool ? <>{children} : null; @@ -9,9 +10,9 @@ export const StateFilter = ({current, required, children}) => ( {children} ); -export const Warning = ({children}) => ( -
- {children} +export const Differ = ({diff, prevDiff}) => ( +
+ {diffString(diff)}
); diff --git a/my-bois/src/components/Score.css b/my-bois/src/components/Score.css index 68eb5ae..87fb749 100644 --- a/my-bois/src/components/Score.css +++ b/my-bois/src/components/Score.css @@ -9,4 +9,12 @@ .Timer-number { font-size: 125%; font-weight: 800; +} + +.Timer-cooldown { + color: #0BF; +} + +.Timer-awaits-cooldown { + color: #8FF; } \ No newline at end of file diff --git a/my-bois/src/components/Score.jsx b/my-bois/src/components/Score.jsx index f8d576d..755d40c 100644 --- a/my-bois/src/components/Score.jsx +++ b/my-bois/src/components/Score.jsx @@ -5,7 +5,7 @@ import "./Score.css"; const Score = ({value, suffix = null, color = COLOR_NEUTRAL}) => (
- {value} + {!isNaN(value) ? value : 0} {suffix !== null && ( <>   @@ -15,13 +15,24 @@ const Score = ({value, suffix = null, color = COLOR_NEUTRAL}) => (
); -export const Timer = ({minutes, seconds}) => { +export const Timer = ({minutes, seconds, cooldownMin}) => { function pad(number) { return number >= 10 ? `${number}` : `0${number}`; } + const hasCooldown = cooldownMin >= 0 && cooldownMin !== void(0); + const isCooldown = hasCooldown && minutes >= cooldownMin; + const awaitsCooldown = hasCooldown && !isCooldown; + + const classes = ["Timer"]; + if (isCooldown) { + classes.push("Timer-cooldown"); + } else if (awaitsCooldown) { + classes.push("Timer-awaits-cooldown"); + } + return ( -
+
{pad(minutes)} {" : "} {pad(seconds)} @@ -33,7 +44,7 @@ export const CalorieScore = ({calories, diff, prevDiff}) => ( ); -export const DistanceScore = ({distance}) => ; +export const LevelScore = ({level}) => ; export const RpmScore = ({rpm}) => ; diff --git a/my-bois/src/helpers/color.test.js b/my-bois/src/helpers/color.test.js new file mode 100644 index 0000000..4a43ad2 --- /dev/null +++ b/my-bois/src/helpers/color.test.js @@ -0,0 +1,27 @@ +import {COLOR_BAD, COLOR_GOOD, COLOR_VERY_BAD, COLOR_VERY_GOOD, colorByDiff} from "./color"; + +describe("colorByDiff", function () { + it("should show dark red for worse bad", () => { + expect(colorByDiff(-5, -2)).toBe(COLOR_VERY_BAD); + }); + + it("should show bright red for equal bad", () => { + expect(colorByDiff(-11, -11)).toBe(COLOR_BAD); + }); + + it("should show bright red for better bad", () => { + expect(colorByDiff(-1, -7)).toBe(COLOR_BAD); + }); + + it("should show dark green for better good", () => { + expect(colorByDiff(33, 25)).toBe(COLOR_VERY_GOOD); + }); + + it("should show bright green for equal good", () => { + expect(colorByDiff(10, 10)).toBe(COLOR_GOOD); + }); + + it("should show bright green for worse good", () => { + expect(colorByDiff(5, 7)).toBe(COLOR_GOOD); + }); +}); \ No newline at end of file diff --git a/my-bois/src/helpers/diff.js b/my-bois/src/helpers/diff.js index cdbee76..3575312 100644 --- a/my-bois/src/helpers/diff.js +++ b/my-bois/src/helpers/diff.js @@ -2,24 +2,39 @@ export function diffString(diff) { return diff < 0 ? `${diff}` : `+${diff}` } -export default function calculateDiff(program, minutes, seconds, calories) { - const {warmupMin, warmupCpm, cpm} = program; - - let preWarmup = 0; - if (warmupMin > 0) { - // Pre-warmup - const warmedUpMinutes = Math.min(minutes, warmupMin); - const warmedUpSeconds = minutes >= warmupMin ? 0 : seconds; - preWarmup = Math.round((warmupCpm * (warmedUpMinutes + (warmedUpSeconds / 60)))); - } +export default function calculateDiff({program, cooldownMin = null, minutes, seconds, calories}) { + const {warmupMin, warmupCpm, cpm, cooldownCpm} = program; + const actualWarmup = cooldownMin !== null && cooldownMin > 0 + ? Math.min(warmupMin, cooldownMin) + : warmupMin; + + // Minutes in each section + const minWarmup = calculateMins(0, actualWarmup, minutes, seconds); + const minMain = calculateMins(actualWarmup, cooldownMin, minutes, seconds); + const minCooldown = calculateMins(cooldownMin, null, minutes, seconds); + + // Expected calories in each section + const calWarmup = minWarmup * warmupCpm; + const calMain = minMain * cpm; + const calCooldown = minCooldown * cooldownCpm; + + return Math.round(calories - (calWarmup + calMain + calCooldown)); +} + +function calculateMins(minMinutes, maxMinutes, minutes, seconds) { + const fraction = seconds / 60; - // Post-warmup - const trainedMinutes = Math.max(0, minutes - warmupMin); - const trainedSeconds = minutes >= warmupMin ? seconds : 0; - const postWarmup = Math.round((cpm * (trainedMinutes + (trainedSeconds / 60)))); + if (minMinutes === null || minMinutes < 0) { + return 0.0; + } - // Sum - const target = Math.round(preWarmup + postWarmup); + if (minutes >= minMinutes) { + if (maxMinutes !== null && minutes >= maxMinutes) { + return maxMinutes - minMinutes; + } - return calories - target; + return (minutes - minMinutes) + fraction; + } else { + return 0.0; + } } \ No newline at end of file diff --git a/my-bois/src/helpers/diff.test.js b/my-bois/src/helpers/diff.test.js new file mode 100644 index 0000000..a929c28 --- /dev/null +++ b/my-bois/src/helpers/diff.test.js @@ -0,0 +1,89 @@ +import calculateDiff, {diffString} from "./diff"; + +const programWithoutWarmup = { + warmupMin: 0, + warmupCpm: 0, + cpm: 30, + cooldownCpm: 20, +}; + +const programWithWarmup = { + warmupMin: 10, + warmupCpm: 25, + cpm: 30, + cooldownCpm: 20, +}; + +describe('diffString', () => { + it("should show a plus before zero", () => { + expect(diffString(0)).toBe("+0"); + }); + + it("should show a plus before a positive number", () => { + expect(diffString(44)).toBe("+44"); + }); + + it("should show a minus when negatives", () => { + expect(diffString(-12)).toBe("-12"); + }); +}); + +describe("calculateDiff", () => { + it("should return zero at start", () => { + const diff = calculateDiff({ + program: programWithoutWarmup, + minutes: 0, + seconds: 0, + calories: 0, + }); + + expect(diff).toBe(0); + }); + + it("should expect 300 calories after 0' / 10'", () => { + const diff = calculateDiff({ + program: programWithoutWarmup, + minutes: 10, + seconds: 0, + calories: 305, + }); + + expect(diff).toBe(5); + }); + + it("should expect 865 calories after 10' / 20'50", () => { + const diff = calculateDiff({ + program: programWithWarmup, + minutes: 30, + seconds: 50, + calories: 250 + 600 + 25 - 17, + }); + + // Aim: 875 + expect(diff).toBe(-17); + }); + + it("should expect 755 calories after 10' / 15' / 7'45", () => { + const diff = calculateDiff({ + program: programWithWarmup, + cooldownMin: 25, + minutes: 32, + seconds: 45, + calories: 250 + 450 + 140 + 15, + }); + + expect(diff).toBe(0); + }); + + it("should expect 325 calories after 5' (half) / 0' / 10'", () => { + const diff = calculateDiff({ + program: programWithWarmup, + cooldownMin: 5, + minutes: 15, + seconds: 0, + calories: 325 + }); + + expect(diff).toBe(0); + }); +}); diff --git a/my-bois/src/hooks/milestones.js b/my-bois/src/hooks/milestones.js index d7d77aa..08a12c2 100644 --- a/my-bois/src/hooks/milestones.js +++ b/my-bois/src/hooks/milestones.js @@ -11,19 +11,21 @@ function reducer(state, {type, payload}) { switch (type) { case "measure": let {prevDiff, prevLongDiff, milestones} = state; - const {minutes, seconds, calories, program} = payload; + const {minutes, seconds, calories, program, cooldownMin} = payload; if (minutes === 0 || seconds !== 0) { return state; } const isFive = minutes % 5 === 0; - const diff = calculateDiff(program, minutes, seconds, calories); - let newMilestones = milestones.filter(m => m.minutes !== minutes); - newMilestones.push({ - minutes, seconds, calories, diff, - prevDiff: isFive ? prevLongDiff : prevDiff - }); + const diff = calculateDiff({program, minutes, seconds, calories, cooldownMin}); + let newMilestones = [...milestones]; + if (newMilestones.find(m => m.minutes === minutes) === void(0)) { + newMilestones.push({ + minutes, seconds, calories, diff, + prevDiff: isFive ? prevLongDiff : prevDiff + }); + } if (isFive) { newMilestones = newMilestones.filter(m => m.minutes % 5 === 0); diff --git a/my-bois/src/hooks/net.js b/my-bois/src/hooks/net.js index 858d321..01002fe 100644 --- a/my-bois/src/hooks/net.js +++ b/my-bois/src/hooks/net.js @@ -33,6 +33,10 @@ export async function pauseWorkout(workout) { return await post(`/workout/${workout.id}/pause`); } +export async function updateCooldownMins(workout, cooldownMin) { + return await put(`/workout/${workout.id}`, {cooldownMin}); +} + export function openWebsocket(workout) { return new WebSocket(url(`/workout/${workout.id}/subscribe`, "ws")); } @@ -53,6 +57,16 @@ function post(path, data = {}) { }).then(r => r.json()); } +function put(path, data = {}) { + return fetch(url(path), { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data) + }).then(r => r.json()); +} + function url(path, prefix = "http") { return `${prefix}://127.0.0.1:9999/api${path}`; } \ No newline at end of file diff --git a/my-bois/src/hooks/options.js b/my-bois/src/hooks/options.js new file mode 100644 index 0000000..b1d387a --- /dev/null +++ b/my-bois/src/hooks/options.js @@ -0,0 +1,23 @@ +import {useEffect, useState} from "react"; +import {fetchBikes, fetchPrograms} from "./net"; + +export default function useOptions() { + const [bikes, setBikes] = useState(null); + const [programs, setPrograms] = useState(null); + + useEffect(() => { + if (programs === null) { + fetchPrograms().then(newPrograms => setPrograms(newPrograms)); + } + }, [programs]); + + useEffect(() => { + if (bikes === null) { + fetchBikes().then(newBikes => { + setBikes(newBikes); + }); + } + }, [bikes]); + + return {bikes, programs}; +} \ No newline at end of file