This commit is contained in:
Stian Aune
2019-11-09 22:53:39 +01:00
parent da71706841
commit a470b43548
28 changed files with 14374 additions and 1 deletions
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import {CentreBoi, LeftBoi, RightBoi} from "./components/Bois";
import {StatusContextProvider} from "./components/Contexts";
function App() {
return (
<StatusContextProvider>
<LeftBoi/>
<CentreBoi/>
<RightBoi/>
</StatusContextProvider>
);
}
export default App;
+44
View File
@@ -0,0 +1,44 @@
.Boi {
position: fixed;
z-index: 99999999;
color: #FFF;
background-color: rgba(0, 0, 0, 0.5);
padding: 0.25em 0.5ch;
font-size: 3vmax;
line-height: 1.33em;
font-family: sans-serif;
}
.Boi-left {
left: 0;
top: 50%;
transform: translate(0, -50%);
}
.Boi-centre {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.75);
font-size: 5vmax;
}
.Boi-centre-warning {
margin-top: 1em;
font-weight: 800;
font-size: 150%;
}
.Boi-centre-info {
font-size: 80%;
}
.Boi-centre-info td:first-child {
padding-right: 1ch;
}
.Boi-right {
right: 0;
bottom: 0;
font-size: 2vmax;
}
+186
View File
@@ -0,0 +1,186 @@
import React, {useContext, useEffect, useMemo, useState} from 'react';
import "./Bois.css";
import {StatusContext} from "./Contexts";
import {CalorieScore, CpmScore, DistanceScore, RpmScore, Timer} from "./Score";
import calculateDiff from "../helpers/diff";
import useKey from "../hooks/useKey";
import {COLOR_VERY_BAD} from "../helpers/color";
import {Milestones} from "./Milestones";
const Boi = ({type, children}) => {
return (
<div className={`Boi Boi-${type}`}>
{children}
</div>
)
};
export const LeftBoi = () => {
const {prevLongDiff, workoutStatus, program} = useContext(StatusContext);
if (workoutStatus === null || program === null) {
return null;
}
const {minutes, seconds, calories, distance, rpm} = workoutStatus;
const diff = calculateDiff(program, minutes, seconds, calories);
const cpm = calories / (minutes + (seconds / 60));
return (
<Boi type="left">
<Timer minutes={minutes} seconds={seconds}/>
<CalorieScore calories={calories} diff={diff} prevDiff={prevLongDiff}/>
<DistanceScore distance={distance}/>
<RpmScore rpm={rpm}/>
<CpmScore cpm={cpm}/>
</Boi>
);
};
export const CentreBoi = () => {
const {
state, program, setProgram, bike, setBike, bikes, programs,
start, pause, stop, create, workoutStatus,
} = useContext(StatusContext);
const [options, setOptions] = useState(null);
const [current, setCurrent] = useState(0);
useEffect(() => {
if (bikes === null && programs === null) {
return;
}
setOptions(state === "bike" ? bikes : programs);
}, [state, bike, program, bikes, programs]);
useKey("-", () => {
if (options === null) {
return false;
}
if (current > 0) {
setCurrent(current - 1);
} else {
setCurrent(options.length - 1);
}
return true;
});
useKey("+", () => {
if (options === null) {
return false;
}
if (current < options.length - 1) {
setCurrent(current + 1);
} else {
setCurrent(0);
}
return true;
});
useKey("Enter", () => {
if (state === "bike") {
setBike(options[current]);
return true;
} else if (state === "program") {
setProgram(options[current]);
create(options[current]);
return true;
} else if (state === "connected") {
start();
return true;
} else if (state === "started") {
pause();
return true;
}
return false;
});
useKey("Escape", () => {
if (state === "connected") {
stop();
return true;
}
});
if (state === "started" && workoutStatus !== null) {
const {minutes, seconds, calories} = workoutStatus;
const diff = calculateDiff(program, minutes, seconds, calories);
if (diff < 0) {
return (
<Boi type="centre">
<span className="Boi-centre-warning" style={{color: COLOR_VERY_BAD}}>
{diff}
</span>
</Boi>
);
} else {
return null;
}
}
if (bikes === null || programs === null) {
return <Boi type="centre">Laster inn...</Boi>;
}
return (
<Boi type="centre">
{state === "stopped" && (
<div className="Boi-centre-info">Inaktiv</div>
)}
{state === "bike" && (
<div className="Boi-centre-info">
Velg sykkel (+/-):
<br/>
<b>{options[current].name}</b>
</div>
)}
{state === "program" && (
<div className="Boi-centre-info">
Velg program (+/-):
<br/>
<b>{options[current].name}</b>
</div>
)}
{state === "connected" && (
<table className="Boi-centre-info">
<tr>
<td>Enter:</td>
<td><b>Start</b></td>
</tr>
<tr>
<td>Esc:</td>
<td><b>Avslutt</b></td>
</tr>
</table>
)}
{state === "unknown" && (
<div className="Boi-centre-info">Noe gikk galt</div>
)}
</Boi>
);
};
export const RightBoi = () => {
const {milestones} = useContext(StatusContext);
if ((milestones || []).length === 0) {
return null;
}
return (
<Boi type="right">
<Milestones milestones={milestones.sort((i, j) => j.minutes - i.minutes)}/>
</Boi>
);
};
+167
View File
@@ -0,0 +1,167 @@
import React, {createContext, useEffect, useState} from "react";
import {
connectWorkout,
createNewWorkout,
fetchActiveWorkouts, fetchBikes, fetchPrograms,
openWebsocket, pauseWorkout,
startWorkout,
stopWorkout
} from "../hooks/net";
import useMilestones from "../hooks/milestones";
export const StatusContext = createContext({
bike: null,
program: null,
bikes: null,
programs: null,
workout: null,
workoutStatus: null,
setBike: null,
setProgram: null,
state: null,
milestones: [],
create: () => null,
resume: () => null,
start: () => null,
pause: () => null,
stop: () => null,
});
export const StatusContextProvider = ({children}) => {
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");
const {milestones, prevDiff, prevLongDiff, msDispatch} = useMilestones();
const [socket, setSocket] = useState(null);
useEffect(() => {
if (programs === null) {
fetchPrograms().then(newPrograms => setPrograms(newPrograms));
}
}, [programs]);
useEffect(() => {
if (bikes === null) {
fetchBikes().then(newBikes => {
setBikes(newBikes);
if (newBikes.length === 1) {
setBike(newBikes[0]);
}
});
}
}, [bikes]);
useEffect(() => {
if (bike === null && program === null && workout === null) {
resume();
}
}, [bike, program, workout]);
useEffect(() => {
if (bikes === null || programs === null) {
setState("loading");
} else if (workout !== null) {
setState(workout.state || "unknown");
} else if (bike === null) {
setState("bike");
} else if (program === null) {
setState("program");
}
}, [bike, program, workout, bikes, programs]);
useEffect(() => {
if (socket === null) {
return;
}
if (workout === null) {
socket.close();
setSocket(null);
return;
}
socket.onmessage = ({data}) => {
onSocketMessage(workout, program, JSON.parse(data));
};
return () => {
socket.onmessage = null;
}
}, [socket, workout, program]);
function onSocketMessage(myWorkout, program, body) {
if (typeof body.workout !== "undefined") {
setWorkout({...body.workout, ...myWorkout});
}
if (typeof body.workoutStatusBackfill !== "undefined") {
body.workoutStatusBackfill.forEach(wsbf => {
setWorkoutStatus(wsbf);
msDispatch({type: "measure", payload: {...wsbf, program}});
});
}
if (typeof body.workoutStatus !== "undefined") {
setWorkoutStatus(body.workoutStatus);
msDispatch({type: "measure", payload: {...body.workoutStatus, program}});
}
}
async function create(myProgram = null) {
setWorkoutStatus(null);
let newWorkout = await createNewWorkout(bike, myProgram || program);
setWorkout(newWorkout);
setWorkout(await connectWorkout(newWorkout));
setSocket(openWebsocket(newWorkout));
}
async function resume() {
const activeWorkouts = await fetchActiveWorkouts();
const count = activeWorkouts.length;
const last = count > 0 ? activeWorkouts[count - 1] : null;
if (last !== null) {
setProgram(last.program);
setWorkout(last);
setBike(last.bike);
setSocket(openWebsocket(last));
}
}
async function start() {
setWorkout(await startWorkout(workout));
}
async function pause() {
setWorkout(await pauseWorkout(workout));
}
async function stop() {
await stopWorkout(workout);
setBike(null);
setProgram(null);
setWorkout(null);
msDispatch({type: "clear"});
}
return (
<StatusContext.Provider value={{
bike, program, workout, workoutStatus, state, milestones,
setBike, setProgram,
create, resume, start, pause, stop,
programs, bikes,
}}>
{children}
</StatusContext.Provider>
)
};
+17
View File
@@ -0,0 +1,17 @@
.Milestones {
font-size: 80%;
}
.Milestone-minutes,
.Milestone-calories,
.Milestone-diff {
text-align: right;
}
.Milestone-calories {
min-width: 4ch;
}
.Milestone-diff {
min-width: 3ch;
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import {diffString} from "../helpers/diff";
import "./Milestones.css";
import {colorByDiff} from "../helpers/color";
export const Milestones = ({milestones}) => (
<table className="Milestone">
{milestones.map(milestone => <Milestone {...milestone} />)}
</table>
);
const Milestone = ({minutes, calories, diff, prevDiff}) => (
<tr style={{color: colorByDiff(diff, prevDiff)}}>
<td className="Milestone-minutes">{minutes}</td>
<td className="Milestone-calories">{calories}</td>
<td className="Milestone-diff">{minutes % 5 === 0 ? diffString(diff) : ""}</td>
</tr>
);
+12
View File
@@ -0,0 +1,12 @@
.Score-value {
font-weight: 800;
}
.Score-suffix {
font-size: 80%;
}
.Timer-number {
font-size: 125%;
font-weight: 800;
}
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
import {COLOR_NEUTRAL, colorByDiff} from "../helpers/color";
import "./Score.css";
const Score = ({value, suffix = null, color = COLOR_NEUTRAL}) => (
<div className="Score">
<span className="Score-value" style={{color}}>{value}</span>
{suffix !== null && (
<>
&nbsp;
<span className="Score-suffix">{suffix}</span>
</>
)}
</div>
);
export const Timer = ({minutes, seconds}) => {
function pad(number) {
return number >= 10 ? `${number}` : `0${number}`;
}
return (
<div className="Timer">
<span className="Timer-number">{pad(minutes)}</span>
{" : "}
<span className="Timer-number">{pad(seconds)}</span>
</div>
);
};
export const CalorieScore = ({calories, diff, prevDiff}) => (
<Score value={calories} color={colorByDiff(diff, prevDiff)} suffix="kal"/>
);
export const DistanceScore = ({distance}) => <Score value={distance.toFixed(1)} suffix="km"/>;
export const RpmScore = ({rpm}) => <Score value={rpm} suffix="rpm"/>;
export const CpmScore = ({cpm}) => <Score value={cpm.toFixed(1)} suffix="kpm"/>;
+14
View File
@@ -0,0 +1,14 @@
export const COLOR_VERY_BAD = "#F66";
export const COLOR_BAD = "#FBB";
export const COLOR_NEUTRAL = "#FFF";
export const COLOR_GOOD = "#BFB";
export const COLOR_VERY_GOOD = "#6F6";
export const colorByDiff = (diff, prevDiff) => {
if (diff < 0) {
return diff < prevDiff ? COLOR_VERY_BAD : COLOR_BAD;
} else {
return diff > prevDiff ? COLOR_VERY_GOOD : COLOR_GOOD;
}
};
+25
View File
@@ -0,0 +1,25 @@
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))));
}
// Post-warmup
const trainedMinutes = Math.max(0, minutes - warmupMin);
const trainedSeconds = minutes >= warmupMin ? seconds : 0;
const postWarmup = Math.round((cpm * (trainedMinutes + (trainedSeconds / 60))));
// Sum
const target = Math.round(preWarmup + postWarmup);
return calories - target;
}
+47
View File
@@ -0,0 +1,47 @@
import {useReducer} from "react";
import calculateDiff from "../helpers/diff";
const DEFAULT = {
milestones: [],
prevDiff: 0,
prevLongDiff: 0,
};
function reducer(state, {type, payload}) {
switch (type) {
case "measure":
let {prevDiff, prevLongDiff, milestones} = state;
const {minutes, seconds, calories, program} = 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 ? prevDiff : prevLongDiff
});
if (isFive) {
newMilestones = newMilestones.filter(m => m.minutes % 5 === 0);
prevLongDiff = diff;
}
prevDiff = diff;
return {prevLongDiff, prevDiff, milestones: newMilestones};
case "clear":
return {...DEFAULT};
default:
throw new Error(`Unknown type: ${type}`)
}
}
export default function useMilestones() {
const [state, msDispatch] = useReducer(reducer, {...DEFAULT});
const {prevLongDiff, prevDiff, milestones} = state || {...DEFAULT};
return {milestones, prevDiff, prevLongDiff, msDispatch};
}
+58
View File
@@ -0,0 +1,58 @@
export async function fetchBikes() {
return await get("/bike");
}
export async function fetchPrograms() {
return await get("/program");
}
export async function fetchActiveWorkouts() {
return await get("/workout?active=true");
}
export async function createNewWorkout(bike, program) {
const bikeId = bike.id;
const programId = program.id;
return await post("/workout", {bikeId, programId});
}
export async function connectWorkout(workout) {
return await post(`/workout/${workout.id}/connect`);
}
export async function stopWorkout(workout) {
return await post(`/workout/${workout.id}/stop`);
}
export async function startWorkout(workout) {
return await post(`/workout/${workout.id}/start`);
}
export async function pauseWorkout(workout) {
return await post(`/workout/${workout.id}/pause`);
}
export function openWebsocket(workout) {
return new WebSocket(url(`/workout/${workout.id}/subscribe`, "ws"));
}
function get(path) {
return fetch(url(path), {
method: "GET",
}).then(r => r.json());
}
function post(path, data = {}) {
return fetch(url(path), {
method: "POST",
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}`;
}
+19
View File
@@ -0,0 +1,19 @@
import {useEffect} from "react";
export default function useKey(keyName, callback) {
useEffect(() => {
const handler = function (ev) {
if (ev.key === keyName) {
if (callback()) {
ev.preventDefault();
}
}
};
window.addEventListener("keydown", handler);
return () => {
window.removeEventListener("keydown", handler);
}
})
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import * as serviceWorker from './serviceWorker';
ReactDOM.render(<App />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

+135
View File
@@ -0,0 +1,135 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}