Compare commits
No commits in common. "gh-pages" and "master" have entirely different histories.
|
|
@ -0,0 +1,23 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"printWidth": 120,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
## Zwift workout editor
|
||||
|
||||
A React app for simpler creation of Zwift workouts with the aid of [zwiftout][] library and graphical visualizations.
|
||||
|
||||
→ [See it live!][live]
|
||||
|
||||
## Inspiration
|
||||
|
||||
Plot graphics inspired by the awesome [What's on Zwift?][whatsonzwift] website.
|
||||
|
||||
[zwiftout]: https://github.com/nene/zwiftout
|
||||
[live]: https://nene.github.io/workout-editor/
|
||||
[whatsonzwift]: https://whatsonzwift.com/workouts/
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"files": {
|
||||
"main.css": "/workout-editor/static/css/main.20aa8553.chunk.css",
|
||||
"main.js": "/workout-editor/static/js/main.454e9277.chunk.js",
|
||||
"main.js.map": "/workout-editor/static/js/main.454e9277.chunk.js.map",
|
||||
"runtime-main.js": "/workout-editor/static/js/runtime-main.766fd1ba.js",
|
||||
"runtime-main.js.map": "/workout-editor/static/js/runtime-main.766fd1ba.js.map",
|
||||
"static/js/2.136e6045.chunk.js": "/workout-editor/static/js/2.136e6045.chunk.js",
|
||||
"static/js/2.136e6045.chunk.js.map": "/workout-editor/static/js/2.136e6045.chunk.js.map",
|
||||
"index.html": "/workout-editor/index.html",
|
||||
"precache-manifest.b4060984c4b67ccf3d0ee613c097ced6.js": "/workout-editor/precache-manifest.b4060984c4b67ccf3d0ee613c097ced6.js",
|
||||
"service-worker.js": "/workout-editor/service-worker.js",
|
||||
"static/css/main.20aa8553.chunk.css.map": "/workout-editor/static/css/main.20aa8553.chunk.css.map",
|
||||
"static/js/2.136e6045.chunk.js.LICENSE.txt": "/workout-editor/static/js/2.136e6045.chunk.js.LICENSE.txt"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/js/runtime-main.766fd1ba.js",
|
||||
"static/js/2.136e6045.chunk.js",
|
||||
"static/css/main.20aa8553.chunk.css",
|
||||
"static/js/main.454e9277.chunk.js"
|
||||
]
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/workout-editor/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>Workout editor</title><link href="/workout-editor/static/css/main.20aa8553.chunk.css" rel="stylesheet"></head><body><a href="https://github.com/nene/workout-editor" style="position:absolute;top:0;right:0"><img loading="lazy" width="149" height="149" src="https://github.blog/wp-content/uploads/2008/12/forkme_right_gray_6d6d6d.png?resize=149%2C149" alt="Fork me on GitHub" data-recalc-dims="1"></a><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script>!function(e){function r(r){for(var n,i,l=r[0],f=r[1],a=r[2],c=0,s=[];c<l.length;c++)i=l[c],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&s.push(o[i][0]),o[i]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,l=1;l<t.length;l++){var f=t[l];0!==o[f]&&(n=!1)}n&&(u.splice(r--,1),e=i(i.s=t[0]))}return e}var n={},o={1:0},u=[];function i(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,i),t.l=!0,t.exports}i.m=e,i.c=n,i.d=function(e,r,t){i.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,r){if(1&r&&(e=i(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(i.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)i.d(t,n,function(r){return e[r]}.bind(null,n));return t},i.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(r,"a",r),r},i.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},i.p="/workout-editor/";var l=this["webpackJsonpworkout-editor"]=this["webpackJsonpworkout-editor"]||[],f=l.push.bind(l);l.push=r,l=l.slice();for(var a=0;a<l.length;a++)r(l[a]);var p=f;t()}([])</script><script src="/workout-editor/static/js/2.136e6045.chunk.js"></script><script src="/workout-editor/static/js/main.454e9277.chunk.js"></script></body></html>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"name": "workout-editor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Online editor for zwift workouts",
|
||||
"author": "Rene Saarsoo <github@triin.net>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nene/workout-editor.git"
|
||||
},
|
||||
"license": "GPL-3.0-or-later",
|
||||
"homepage": "https://nene.github.io/workout-editor",
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
"@types/jest": "^24.0.0",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^16.9.0",
|
||||
"@types/react-dom": "^16.9.0",
|
||||
"@types/styled-components": "^5.1.3",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-scripts": "3.4.3",
|
||||
"react-simple-code-editor": "^0.11.0",
|
||||
"styled-components": "^5.2.0",
|
||||
"typescript": "~3.7.2",
|
||||
"zwiftout": "^2.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"predeploy": "yarn build",
|
||||
"deploy": "gh-pages -d build",
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"gh-pages": "^3.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
self.__precacheManifest = (self.__precacheManifest || []).concat([
|
||||
{
|
||||
"revision": "9fd0f99d93f30dd813f5e3ac5fb82700",
|
||||
"url": "/workout-editor/index.html"
|
||||
},
|
||||
{
|
||||
"revision": "6885b61b44ced1e95fdc",
|
||||
"url": "/workout-editor/static/css/main.20aa8553.chunk.css"
|
||||
},
|
||||
{
|
||||
"revision": "b1a5fd53658789be2d1a",
|
||||
"url": "/workout-editor/static/js/2.136e6045.chunk.js"
|
||||
},
|
||||
{
|
||||
"revision": "eab6cd95c8b7f7a43af593f2585901b3",
|
||||
"url": "/workout-editor/static/js/2.136e6045.chunk.js.LICENSE.txt"
|
||||
},
|
||||
{
|
||||
"revision": "6885b61b44ced1e95fdc",
|
||||
"url": "/workout-editor/static/js/main.454e9277.chunk.js"
|
||||
},
|
||||
{
|
||||
"revision": "b5e368ec906fbe804156",
|
||||
"url": "/workout-editor/static/js/runtime-main.766fd1ba.js"
|
||||
}
|
||||
]);
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
|
@ -0,0 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Workout editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<a href="https://github.com/nene/workout-editor" style="position: absolute; top: 0; right: 0;">
|
||||
<img loading="lazy" width="149" height="149" src="https://github.blog/wp-content/uploads/2008/12/forkme_right_gray_6d6d6d.png?resize=149%2C149" alt="Fork me on GitHub" data-recalc-dims="1">
|
||||
</a>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* Welcome to your Workbox-powered service worker!
|
||||
*
|
||||
* You'll need to register this file in your web app and you should
|
||||
* disable HTTP caching for this file too.
|
||||
* See https://goo.gl/nhQhGp
|
||||
*
|
||||
* The rest of the code is auto-generated. Please don't update this file
|
||||
* directly; instead, make changes to your Workbox build configuration
|
||||
* and re-run your build process.
|
||||
* See https://goo.gl/2aRDsh
|
||||
*/
|
||||
|
||||
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");
|
||||
|
||||
importScripts(
|
||||
"/workout-editor/precache-manifest.b4060984c4b67ccf3d0ee613c097ced6.js"
|
||||
);
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
workbox.core.clientsClaim();
|
||||
|
||||
/**
|
||||
* The workboxSW.precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
self.__precacheManifest = [].concat(self.__precacheManifest || []);
|
||||
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});
|
||||
|
||||
workbox.routing.registerNavigationRoute(workbox.precaching.getCacheKeyForURL("/workout-editor/index.html"), {
|
||||
|
||||
blacklist: [/^\/_/,/\/[^/?]+\.[^/]+$/],
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { WorkoutPlot } from "./components/WorkoutPlot";
|
||||
import { WorkoutStats } from "./components/WorkoutStats";
|
||||
import { parse, ParseError, ValidationError, ZwiftoutException } from "zwiftout";
|
||||
import { ErrorMessage } from "./components/ErrorMessage";
|
||||
import styled from "styled-components";
|
||||
import { CodeEditor } from "./components/CodeEditor";
|
||||
import { ZwoOutput } from "./components/ZwoOutput";
|
||||
import { AppTitle } from "./components/AppTitle";
|
||||
import { Credits } from "./components/Credits";
|
||||
import { loadWorkout, saveWorkout } from "./storage";
|
||||
|
||||
const AppContainer = styled.div`
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
export function App() {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
const [workout, setWorkout] = useState(parse(""));
|
||||
const [error, setError] = useState<ZwiftoutException | undefined>(undefined);
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: string) => {
|
||||
setText(value);
|
||||
try {
|
||||
setWorkout(parse(value));
|
||||
setError(undefined);
|
||||
} catch (e) {
|
||||
if (e instanceof ParseError || e instanceof ValidationError) {
|
||||
setError(e);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
},
|
||||
[setText, setWorkout, setError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
saveWorkout(text);
|
||||
} else {
|
||||
setLoaded(true);
|
||||
onChange(loadWorkout());
|
||||
}
|
||||
}, [text, onChange, setLoaded, loaded]);
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<AppTitle />
|
||||
<CodeEditor onValueChange={onChange} value={text} error={error} />
|
||||
<WorkoutPlot intervals={workout.intervals} />
|
||||
{error && <ErrorMessage>{error.message}</ErrorMessage>}
|
||||
<WorkoutStats workout={workout} />
|
||||
<ZwoOutput workout={workout} />
|
||||
<Credits />
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import logo from "../logo.png";
|
||||
|
||||
const Title = styled.h1`
|
||||
font-weight: normal;
|
||||
`;
|
||||
|
||||
const Logo = styled.img.attrs({ src: logo, width: 45, height: 45 })`
|
||||
margin-right: 0.5em;
|
||||
vertical-align: bottom;
|
||||
`;
|
||||
|
||||
const Beta = styled.span`
|
||||
display: inline-block;
|
||||
color: #cc2222;
|
||||
opacity: 0.7;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
transform: rotate(20deg) translate(-15px, -8px);
|
||||
`;
|
||||
|
||||
export const AppTitle: React.FC<{}> = () => (
|
||||
<Title>
|
||||
<Logo />
|
||||
Workout editor <Beta>beta</Beta>
|
||||
</Title>
|
||||
);
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import styled from "styled-components";
|
||||
import { ZoneType } from "zwiftout";
|
||||
|
||||
type BaseBarProps = {
|
||||
// Percentage of total workout length
|
||||
durationPercentage: number;
|
||||
};
|
||||
|
||||
const BaseBar = styled.div<BaseBarProps>`
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
margin-right: 0.1%;
|
||||
/* exclude 0.1% margin from bar width */
|
||||
width: ${(props) => props.durationPercentage - 0.1}%;
|
||||
transition: width 0.1s, height 0.1s, background-color 0.1s;
|
||||
`;
|
||||
|
||||
export type BarProps = BaseBarProps & {
|
||||
// Percentage of maximum intensity in workout
|
||||
intensityPercentage: number;
|
||||
zone: ZoneType;
|
||||
};
|
||||
|
||||
const zoneColorsMap: Record<ZoneType, string> = {
|
||||
free: "linear-gradient(to top, rgba(204,204,204,1), rgba(255,255,255,0))",
|
||||
Z1: "#7f7f7f",
|
||||
Z2: "#338cff",
|
||||
Z3: "#59bf59",
|
||||
Z4: "#ffcc3f",
|
||||
Z5: "#ff6639",
|
||||
Z6: "#ff330c",
|
||||
};
|
||||
|
||||
export const Bar = styled(BaseBar)<BarProps>`
|
||||
border-radius: 10px;
|
||||
height: ${(props) => (props.zone === "free" ? 100 : props.intensityPercentage)}%;
|
||||
background: ${(props) => zoneColorsMap[props.zone]};
|
||||
`;
|
||||
|
||||
export type RangeBarProps = BaseBarProps & {
|
||||
// Percentage of maximum intensity in workout
|
||||
maxIntensityPercentage: number;
|
||||
// Percentage relative to `maxIntensityPercentage`
|
||||
relativeMinIntensityPercentage: number;
|
||||
startZone: ZoneType;
|
||||
endZone: ZoneType;
|
||||
direction: "up" | "down";
|
||||
};
|
||||
|
||||
const createUpPolygon = (middle: number) => `polygon(0% 100%, 100% 100%, 100% 0%, 0% ${middle}%)`;
|
||||
const createDownPolygon = (middle: number) => `polygon(0% 0%, 0% 100%, 100% 100%, 100% ${middle}%)`;
|
||||
|
||||
export const RangeBar = styled(BaseBar)<RangeBarProps>`
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
height: ${(props) => props.maxIntensityPercentage}%;
|
||||
clip-path: ${({ direction, relativeMinIntensityPercentage }) =>
|
||||
direction === "up"
|
||||
? createUpPolygon(relativeMinIntensityPercentage)
|
||||
: createDownPolygon(relativeMinIntensityPercentage)};
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
${(props) => zoneColorsMap[props.startZone]},
|
||||
${(props) => zoneColorsMap[props.endZone]}
|
||||
);
|
||||
`;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import React, { useCallback } from "react";
|
||||
import Editor from "react-simple-code-editor";
|
||||
import styled from "styled-components";
|
||||
import { ValidationError, ZwiftoutException } from "zwiftout";
|
||||
|
||||
const highlight = (code: string): string => {
|
||||
return code
|
||||
.replace(
|
||||
/^(Name|Author|Description|Warmup|Rest|Interval|Cooldown|FreeRide|Ramp):/gm,
|
||||
"<code class='keyword'>$&</code>",
|
||||
)
|
||||
.replace(/-?(\d{1,2}:)?\d{1,2}:\d{1,2}/g, "<code class='duration'>$&</code>")
|
||||
.replace(/\d+%/g, "<code class='intensity'>$&</code>")
|
||||
.replace(/\d+rpm/g, "<code class='cadence'>$&</code>")
|
||||
.replace(/\.\./g, "<code class='range'>$&</code>")
|
||||
.replace(/@(.*?)$/gm, "<code class='comment-start'>@</code><code class='comment'>$1</code>");
|
||||
};
|
||||
|
||||
export const BaseCodeEditor = styled(Editor).attrs({ padding: 10 })`
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
border: 1px inset #bbb;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
|
||||
code.keyword {
|
||||
font-weight: bold;
|
||||
}
|
||||
code.duration {
|
||||
color: #681caf;
|
||||
}
|
||||
code.intensity {
|
||||
color: #af391c;
|
||||
}
|
||||
code.cadence {
|
||||
color: #86af1c;
|
||||
}
|
||||
code.range {
|
||||
color: #888;
|
||||
}
|
||||
code.comment {
|
||||
font-style: italic;
|
||||
color: #888;
|
||||
.duration {
|
||||
color: #8d67af;
|
||||
}
|
||||
}
|
||||
code.comment-start {
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
color: #888;
|
||||
}
|
||||
code.parse-error {
|
||||
background-color: rgba(252, 152, 152, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
code.validation-error {
|
||||
background-color: rgba(252, 209, 152, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const highlightErrorLine = (code: string, error: ZwiftoutException): string => {
|
||||
const regex = new RegExp(`^((?:[^\\n]*?\\n){${error.loc.row}})([^\\n]*?)\\n`);
|
||||
const className = error instanceof ValidationError ? "validation-error" : "parse-error";
|
||||
return code.replace(regex, `$1<code class='${className}'>$2</code>\n`);
|
||||
};
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
error: ZwiftoutException | undefined;
|
||||
}
|
||||
|
||||
export const CodeEditor: React.FC<CodeEditorProps> = ({ value, onValueChange, error }) => {
|
||||
const highlightFn = useCallback((code: string) => highlight(error ? highlightErrorLine(code, error) : code), [error]);
|
||||
return <BaseCodeEditor value={value} onValueChange={onValueChange} highlight={highlightFn} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Text = styled.p`
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 3em;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 1em;
|
||||
color: gray;
|
||||
`;
|
||||
|
||||
export const Credits: React.FC<{}> = () => (
|
||||
<Text>
|
||||
Built by Rene Saarsoo. · Graphics inspired by <a href="https://whatsonzwift.com/workouts/">What's on Zwift?</a>
|
||||
· Sweat provided by <a href="https://zwift.com">Zwift</a> :-)
|
||||
</Text>
|
||||
);
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import React from "react";
|
||||
import { Workout, generateZwo } from "zwiftout";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Button = styled.a`
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 3px;
|
||||
padding: 3px 8px;
|
||||
margin-left: 16px;
|
||||
font-size: 14px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background-color: #eee;
|
||||
&:hover {
|
||||
background-color: #86af1c;
|
||||
border-color: #86af1c;
|
||||
}
|
||||
`;
|
||||
|
||||
// Creates .zwo file name from workout name
|
||||
const workoutFileName = (name: string): string => name.replace(/[^\w]/, "-").replace(/-+/, "-").toLowerCase() + ".zwo";
|
||||
|
||||
const downloadDataUrl = (xml: string): string => URL.createObjectURL(new Blob([xml], { type: "application/xml" }));
|
||||
|
||||
export const DownloadButton: React.FC<{ workout: Workout }> = ({ workout }) => {
|
||||
return (
|
||||
<Button download={workoutFileName(workout.name)} href={downloadDataUrl(generateZwo(workout))}>
|
||||
Download
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import styled from "styled-components";
|
||||
|
||||
export const ErrorMessage = styled.p`
|
||||
color: red;
|
||||
background: #fee;
|
||||
border-radius: 10px;
|
||||
border: 2px solid red;
|
||||
padding: 5px;
|
||||
margin: 10px 0;
|
||||
`;
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
Interval,
|
||||
Intensity,
|
||||
Duration,
|
||||
totalDuration,
|
||||
maximumIntensity,
|
||||
RangeIntensity,
|
||||
intensityValueToZoneType,
|
||||
} from "zwiftout";
|
||||
import { BarProps, Bar, RangeBar, RangeBarProps } from "./Bar";
|
||||
|
||||
const Plot = styled.div`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
height: 200px;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
margin: 10px 0;
|
||||
`;
|
||||
|
||||
const toBarProps = (interval: Interval, workoutDuration: Duration, maxIntensity: Intensity): BarProps => ({
|
||||
zone: interval.intensity.zone,
|
||||
durationPercentage: (interval.duration.seconds / workoutDuration.seconds) * 100,
|
||||
intensityPercentage: (interval.intensity.value / maxIntensity.value) * 100,
|
||||
});
|
||||
|
||||
const toRangeBarProps = (interval: Interval, workoutDuration: Duration, maxIntensity: Intensity): RangeBarProps => {
|
||||
const minIntensityPercentage =
|
||||
(Math.min(interval.intensity.start, interval.intensity.end) / maxIntensity.value) * 100;
|
||||
const maxIntensityPercentage =
|
||||
(Math.max(interval.intensity.start, interval.intensity.end) / maxIntensity.value) * 100;
|
||||
|
||||
return {
|
||||
durationPercentage: (interval.duration.seconds / workoutDuration.seconds) * 100,
|
||||
maxIntensityPercentage: maxIntensityPercentage,
|
||||
relativeMinIntensityPercentage: 100 - (minIntensityPercentage / maxIntensityPercentage) * 100,
|
||||
direction: interval.intensity.start < interval.intensity.end ? "up" : "down",
|
||||
startZone: intensityValueToZoneType(interval.intensity.start),
|
||||
endZone: intensityValueToZoneType(interval.intensity.end),
|
||||
};
|
||||
};
|
||||
|
||||
export const WorkoutPlot: React.FC<{ intervals: Interval[] }> = ({ intervals }) => {
|
||||
const workoutDuration = totalDuration(intervals);
|
||||
const maxIntensity = maximumIntensity(intervals);
|
||||
|
||||
return (
|
||||
<Plot>
|
||||
{intervals.map((interval, i) => {
|
||||
if (interval.intensity instanceof RangeIntensity) {
|
||||
return <RangeBar key={i} {...toRangeBarProps(interval, workoutDuration, maxIntensity)} />;
|
||||
} else {
|
||||
return <Bar key={i} {...toBarProps(interval, workoutDuration, maxIntensity)} />;
|
||||
}
|
||||
})}
|
||||
</Plot>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import React from "react";
|
||||
import { stats, Workout, Intensity, Duration } from "zwiftout";
|
||||
import { formatDuration } from "./formatDuration";
|
||||
import styled from "styled-components";
|
||||
|
||||
const formatIntensity = (intensity: Intensity): string => `${Math.round(intensity.value * 100)}%`;
|
||||
|
||||
type StatsProps = { label: string; value: string | number };
|
||||
|
||||
const StatsLine: React.FC<StatsProps> = ({ label, value }) => (
|
||||
<>
|
||||
<strong>{label}</strong> {value}
|
||||
</>
|
||||
);
|
||||
|
||||
const StatsListItem: React.FC<StatsProps> = (props) => (
|
||||
<li>
|
||||
<StatsLine {...props} />
|
||||
</li>
|
||||
);
|
||||
|
||||
const Container = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3fr;
|
||||
grid-template-areas:
|
||||
"summary zones"
|
||||
"xp xp ";
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
`;
|
||||
const SummarySection = styled.section`
|
||||
grid-area: summary;
|
||||
`;
|
||||
const ZonesSection = styled.section`
|
||||
grid-area: zones;
|
||||
`;
|
||||
const XpSection = styled.section`
|
||||
grid-area: xp;
|
||||
`;
|
||||
|
||||
const Header = styled.h2`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
`;
|
||||
const List = styled.ul`
|
||||
margin: 1m 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
`;
|
||||
const ZoneList = styled(List)`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
`;
|
||||
|
||||
const xpEquivalent = (xp: number, totalDuration: Duration): string => {
|
||||
const distanceInKm = Math.ceil(xp / 20);
|
||||
const durationInHours = totalDuration.seconds / 60 / 60;
|
||||
const speed = Math.round(distanceInKm / durationInHours);
|
||||
return `equivalent to riding ${distanceInKm} km at ${speed} km/h`;
|
||||
};
|
||||
|
||||
export const WorkoutStats: React.FC<{ workout: Workout }> = ({ workout }) => {
|
||||
const { totalDuration, averageIntensity, normalizedIntensity, tss, xp, zones } = stats(workout);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<SummarySection>
|
||||
<Header>Summary</Header>
|
||||
<List>
|
||||
<StatsListItem label="Duration:" value={formatDuration(totalDuration)} />
|
||||
<StatsListItem label="Average intensity:" value={formatIntensity(averageIntensity)} />
|
||||
<StatsListItem label="Normalized intensity:" value={formatIntensity(normalizedIntensity)} />
|
||||
<StatsListItem label="TSS:" value={Math.round(tss)} />
|
||||
</List>
|
||||
</SummarySection>
|
||||
<ZonesSection>
|
||||
<Header>Zone distribution</Header>
|
||||
<ZoneList>
|
||||
{zones.map((zone) => (
|
||||
<StatsListItem key={zone.name} label={zone.name} value={formatDuration(zone.duration)} />
|
||||
))}
|
||||
</ZoneList>
|
||||
</ZonesSection>
|
||||
<XpSection>
|
||||
<StatsLine label="Zwift XP:" value={`${xp} (${xpEquivalent(xp, totalDuration)})`} />;
|
||||
</XpSection>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import React, { useState } from "react";
|
||||
import { Workout, generateZwo } from "zwiftout";
|
||||
import styled from "styled-components";
|
||||
import { DownloadButton } from "./DownloadButton";
|
||||
|
||||
const Header = styled.h2`
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const ZwoCode = styled.pre`
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
`;
|
||||
|
||||
const ShowHideButton = styled.button`
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 3px;
|
||||
padding: 3px 8px;
|
||||
float: right;
|
||||
`;
|
||||
|
||||
export const ZwoOutput: React.FC<{ workout: Workout }> = ({ workout }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<Header>
|
||||
Generated Zwift workout file (.zwo):
|
||||
<DownloadButton workout={workout} />
|
||||
</Header>
|
||||
<ZwoCode>
|
||||
<ShowHideButton onClick={() => setExpanded(!expanded)}>{expanded ? "Hide ZWO" : "Show ZWO"}</ShowHideButton>
|
||||
{expanded ? generateZwo(workout) : ""}
|
||||
</ZwoCode>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { Duration } from "zwiftout";
|
||||
|
||||
const splitDuration = (duration: Duration) => ({
|
||||
hours: Math.floor(duration.seconds / 60 / 60),
|
||||
minutes: Math.floor(duration.seconds / 60) % 60,
|
||||
seconds: duration.seconds % 60,
|
||||
});
|
||||
|
||||
export const formatDuration = (duration: Duration): string => {
|
||||
const { hours, minutes, seconds } = splitDuration(duration);
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}min`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}min`;
|
||||
} else if (seconds > 0) {
|
||||
return `${seconds}sec`;
|
||||
} else {
|
||||
return `-`;
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
body {
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
background-color: #f7f7f2;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
||||
"Droid Sans", "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import "./index.css";
|
||||
import { App } from "./App";
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root"),
|
||||
);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
const defaultWorkout = `Name: Sample workout
|
||||
Description: Try changing it, and see what happens below.
|
||||
|
||||
Warmup: 10:00 30%..75%
|
||||
Interval: 15:00 100% 90rpm
|
||||
@ 00:00 Start off easy
|
||||
@ 01:00 Settle into rhythm
|
||||
@ 07:30 Half way through
|
||||
@ 14:00 Final minute, stay strong!
|
||||
Rest: 10:00 75%
|
||||
FreeRide: 20:00
|
||||
@ 00:10 Just have some fun, riding as you wish
|
||||
Cooldown: 10:00 70%..30%
|
||||
`;
|
||||
|
||||
const loadFromUrlHash = (): string | undefined => {
|
||||
if (document.location.hash) {
|
||||
// Skip the leading '#' when decoding
|
||||
return decodeURIComponent(document.location.hash.slice(1));
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const loadFromLocalStorage = (): string | undefined => {
|
||||
const text = localStorage.getItem("workout-editor-text");
|
||||
return !text || text.trim() === "" ? undefined : text;
|
||||
};
|
||||
|
||||
export const loadWorkout = (): string => {
|
||||
return loadFromUrlHash() ?? loadFromLocalStorage() ?? defaultWorkout;
|
||||
};
|
||||
|
||||
export const saveWorkout = (text: string) => localStorage.setItem("workout-editor-text", text);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
body{margin:0;padding:40px;background-color:#f7f7f2;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}code{font-family:source-code-pro,Menlo,Monaco,Consolas,"Courier New",monospace}
|
||||
/*# sourceMappingURL=main.20aa8553.chunk.css.map */
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"version":3,"sources":["index.css"],"names":[],"mappings":"AAAA,KACE,QAAS,CACT,YAAa,CACb,wBAAyB,CACzB,mJAC4C,CAC5C,kCAAmC,CACnC,iCACF,CAEA,KACE,yEACF","file":"main.20aa8553.chunk.css","sourcesContent":["body {\n margin: 0;\n padding: 40px;\n background-color: #f7f7f2;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Roboto\", \"Oxygen\", \"Ubuntu\", \"Cantarell\", \"Fira Sans\",\n \"Droid Sans\", \"Helvetica Neue\", sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\ncode {\n font-family: source-code-pro, Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\n"]}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,55 +0,0 @@
|
|||
/*
|
||||
object-assign
|
||||
(c) Sindre Sorhus
|
||||
@license MIT
|
||||
*/
|
||||
|
||||
/*!
|
||||
* The buffer module from node.js, for the browser.
|
||||
*
|
||||
* @author Feross Aboukhadijeh <feross@feross.org> <http://feross.org>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/*!
|
||||
* The buffer module from node.js, for the browser.
|
||||
*
|
||||
* @author Feross Aboukhadijeh <http://feross.org>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/** @license React v0.19.1
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,2 +0,0 @@
|
|||
!function(e){function r(r){for(var n,i,l=r[0],f=r[1],a=r[2],c=0,s=[];c<l.length;c++)i=l[c],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&s.push(o[i][0]),o[i]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,l=1;l<t.length;l++){var f=t[l];0!==o[f]&&(n=!1)}n&&(u.splice(r--,1),e=i(i.s=t[0]))}return e}var n={},o={1:0},u=[];function i(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,i),t.l=!0,t.exports}i.m=e,i.c=n,i.d=function(e,r,t){i.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},i.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,r){if(1&r&&(e=i(e)),8&r)return e;if(4&r&&"object"===typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(i.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)i.d(t,n,function(r){return e[r]}.bind(null,n));return t},i.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(r,"a",r),r},i.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},i.p="/workout-editor/";var l=this["webpackJsonpworkout-editor"]=this["webpackJsonpworkout-editor"]||[],f=l.push.bind(l);l.push=r,l=l.slice();for(var a=0;a<l.length;a++)r(l[a]);var p=f;t()}([]);
|
||||
//# sourceMappingURL=runtime-main.766fd1ba.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue