Compare commits

..

No commits in common. "master" and "gh-pages" have entirely different histories.

38 changed files with 156 additions and 11784 deletions

23
.gitignore vendored
View File

@ -1,23 +0,0 @@
# 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*

View File

@ -1,4 +0,0 @@
{
"printWidth": 120,
"trailingComma": "all"
}

View File

@ -1,13 +0,0 @@
## 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/

22
asset-manifest.json Normal file
View File

@ -0,0 +1,22 @@
{
"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"
]
}

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

1
index.html Normal file
View File

@ -0,0 +1 @@
<!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>

View File

@ -1,56 +0,0 @@
{
"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"
}
}

View File

@ -0,0 +1,26 @@
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"
}
]);

View File

@ -1,16 +0,0 @@
<!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>

39
service-worker.js Normal file
View File

@ -0,0 +1,39 @@
/**
* 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: [/^\/_/,/\/[^/?]+\.[^/]+$/],
});

View File

@ -1,61 +0,0 @@
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>
);
}

View File

@ -1,28 +0,0 @@
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>
);

View File

@ -1,66 +0,0 @@
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]}
);
`;

View File

@ -1,79 +0,0 @@
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} />;
};

View File

@ -1,18 +0,0 @@
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>
&nbsp;· Sweat provided by <a href="https://zwift.com">Zwift</a> :-)
</Text>
);

View File

@ -1,31 +0,0 @@
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>
);
};

View File

@ -1,10 +0,0 @@
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;
`;

View File

@ -1,60 +0,0 @@
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>
);
};

View File

@ -1,93 +0,0 @@
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>
);
};

View File

@ -1,38 +0,0 @@
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>
);
};

View File

@ -1,20 +0,0 @@
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 `-`;
}
};

View File

@ -1,13 +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;
}

View File

@ -1,11 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@ -1,33 +0,0 @@
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);

View File

@ -0,0 +1,2 @@
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 */

View File

@ -0,0 +1 @@
{"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

View File

@ -0,0 +1,55 @@
/*
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

View File

@ -0,0 +1,2 @@
!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

View File

@ -1,25 +0,0 @@
{
"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"
]
}

11085
yarn.lock

File diff suppressed because it is too large Load Diff