import React, { useState, useEffect, useRef } from "react"; import { useLocation } from "react-router"; import "./Editor.css"; import { Colors, Zones } from "../Constants"; import Bar from "../Bar/Bar"; import Trapeze from "../Trapeze/Trapeze"; import FreeRide from "../FreeRide/FreeRide"; import Interval from "../Interval/Interval"; import Comment from "../Comment/Comment"; import EditComment from "../Comment/EditComment"; import Popup from "../Popup/Popup"; import Footer from "../Footer/Footer"; import Workouts from "../Workouts/Workouts"; import TimeAxis from "./TimeAxis"; import DistanceAxis from "./DistanceAxis"; import ZoneAxis from "./ZoneAxis"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTrash, faArrowRight, faArrowLeft, faFile, faSave, faUpload, faDownload, faComment, faBicycle, faCopy, faClock, faShareAlt, faTimesCircle, faList, faBiking, faRunning, faRuler, faPen, } from "@fortawesome/free-solid-svg-icons"; import { ReactComponent as WarmdownLogo } from "../../assets/warmdown.svg"; import { ReactComponent as WarmupLogo } from "../../assets/warmup.svg"; import { ReactComponent as IntervalLogo } from "../../assets/interval.svg"; import { ReactComponent as SteadyLogo } from "../../assets/steady.svg"; import Converter from "xml-js"; import helpers from "../helpers"; import firebase, { auth } from "../firebase"; import SaveForm from "../Forms/SaveForm"; import SignupForm from "../Forms/SignupForm"; import LoginForm from "../Forms/LoginForm"; import ForgotPasswordForm from "../Forms/ForgotPasswordForm"; import UpdatePasswordForm from "../Forms/UpdatePasswordForm"; import { Helmet } from "react-helmet"; import { RouteComponentProps } from "react-router-dom"; import ReactGA from "react-ga"; import RunningTimesEditor, { RunningTimes } from "./RunningTimesEditor"; import LeftRightToggle from "./LeftRightToggle"; import createWorkoutXml from "./createWorkoutXml"; import ShareForm from "../Forms/ShareForm"; import ReactTooltip from "react-tooltip"; export interface Bar { id: string; time: number; length?: number; type: string; power?: number; startPower?: number; endPower?: number; cadence: number; restingCadence?: number; onPower?: number; offPower?: number; onDuration?: number; offDuration?: number; repeat?: number; pace?: number; onLength?: number; offLength?: number; } export interface Instruction { id: string; text: string; time: number; length: number; } interface Message { visible: boolean; class?: string; text?: string; } type TParams = { id: string }; const loadRunningTimes = (): RunningTimes => { const missingRunningTimes: RunningTimes = { oneMile: "", fiveKm: "", tenKm: "", halfMarathon: "", marathon: "", }; const runningTimesJson = localStorage.getItem("runningTimes"); if (runningTimesJson) { return JSON.parse(runningTimesJson); } // Fallback to old localStorage keys const oneMile = localStorage.getItem("oneMileTime") || ""; const fiveKm = localStorage.getItem("fiveKmTime") || ""; const tenKm = localStorage.getItem("tenKmTime") || ""; const halfMarathon = localStorage.getItem("halfMarathonTime") || ""; const marathon = localStorage.getItem("marathonTime") || ""; if (oneMile || fiveKm || tenKm || halfMarathon || marathon) { return { oneMile, fiveKm, tenKm, halfMarathon, marathon }; } return missingRunningTimes; }; export type SportType = "bike" | "run"; export type DurationType = "time" | "distance"; const Editor = ({ match }: RouteComponentProps<TParams>) => { const { v4: uuidv4 } = require("uuid"); const S3_URL = "https://zwift-workout.s3-eu-west-1.amazonaws.com"; const [id, setId] = useState( match.params.id === "new" ? localStorage.getItem("id") || generateId() : match.params.id ); const [bars, setBars] = useState<Array<Bar>>( JSON.parse(localStorage.getItem("currentWorkout") || "[]") ); const [actionId, setActionId] = useState<string | undefined>(undefined); const [ftp, setFtp] = useState( parseInt(localStorage.getItem("ftp") || "200") ); const [weight, setWeight] = useState( parseInt(localStorage.getItem("weight") || "75") ); const [instructions, setInstructions] = useState<Array<Instruction>>( JSON.parse(localStorage.getItem("instructions") || "[]") ); const [tags, setTags] = useState( JSON.parse(localStorage.getItem("tags") || "[]") ); const [name, setName] = useState(localStorage.getItem("name") || ""); const [description, setDescription] = useState( localStorage.getItem("description") || "" ); const [author, setAuthor] = useState(localStorage.getItem("author") || ""); const [savePopupIsVisile, setSavePopupVisibility] = useState(false); const [sharePopupIsVisile, setSharePopupVisibility] = useState(false); const [user, setUser] = useState<firebase.User | null>(null); const [visibleForm, setVisibleForm] = useState("login"); // default form is login const canvasRef = useRef<HTMLInputElement>(null); const segmentsRef = useRef<HTMLInputElement>(null); const [segmentsWidth, setSegmentsWidth] = useState(1320); const [message, setMessage] = useState<Message>(); const [showWorkouts, setShowWorkouts] = useState(false); // bike or run const [sportType, setSportType] = useState<SportType>( (localStorage.getItem("sportType") as SportType) || "bike" ); // distance or time const [durationType, setDurationType] = useState<DurationType>( (localStorage.getItem("durationType") as DurationType) || "time" ); const [runningTimes, setRunningTimes] = useState(loadRunningTimes()); const [textEditorIsVisible, setTextEditorIsVisible] = useState(false); const [selectedInstruction, setSelectedInstruction] = useState<Instruction>(); const db = firebase.database(); const location = useLocation(); useEffect(() => { const params = new URLSearchParams(location.search); const mode = params.get("mode"); if (mode === "resetPassword") { setVisibleForm("updatePassword"); setSavePopupVisibility(true); } }, [location]); useEffect(() => { setMessage({ visible: true, class: "loading", text: "Loading.." }); db.ref("workouts/" + id) .once("value") .then(function (snapshot) { if (snapshot.val()) { // workout exist on server setAuthor(snapshot.val().author); setName(snapshot.val().name); setDescription(snapshot.val().description); setBars(snapshot.val().workout || []); setInstructions(snapshot.val().instructions || []); setTags(snapshot.val().tags || []); setDurationType(snapshot.val().durationType); setSportType(snapshot.val().sportType); localStorage.setItem("id", id); } else { // workout doesn't exist on cloud if (id === localStorage.getItem("id")) { // user refreshed the page } else { // treat this as new workout setBars([]); setInstructions([]); setName(""); setDescription(""); setAuthor(""); setTags([]); } localStorage.setItem("id", id); } console.log("useEffect firebase"); //finished loading setMessage({ visible: false }); }); auth.onAuthStateChanged((user) => { if (user) { setUser(user); } }); window.history.replaceState("", "", `/editor/${id}`); ReactGA.initialize("UA-55073449-9"); ReactGA.pageview(window.location.pathname + window.location.search); }, [id, db]); useEffect(() => { localStorage.setItem("currentWorkout", JSON.stringify(bars)); localStorage.setItem("ftp", ftp.toString()); localStorage.setItem("instructions", JSON.stringify(instructions)); localStorage.setItem("weight", weight.toString()); localStorage.setItem("name", name); localStorage.setItem("description", description); localStorage.setItem("author", author); localStorage.setItem("tags", JSON.stringify(tags)); localStorage.setItem("sportType", sportType); localStorage.setItem("durationType", durationType); localStorage.setItem("runningTimes", JSON.stringify(runningTimes)); setSegmentsWidth(segmentsRef.current?.scrollWidth || 1320); }, [ segmentsRef, bars, ftp, instructions, weight, name, description, author, tags, sportType, durationType, runningTimes, ]); function generateId() { return Math.random().toString(36).substr(2, 16); } function newWorkout() { console.log("New workout"); setId(generateId()); setBars([]); setInstructions([]); setName(""); setDescription(""); setAuthor(""); setTags([]); } function handleOnChange(id: string, values: Bar) { const index = bars.findIndex((bar) => bar.id === id); const updatedArray = [...bars]; updatedArray[index] = values; setBars(updatedArray); } function handleOnClick(id: string) { if (id === actionId) { setActionId(undefined); } else { setActionId(id); } } function handleKeyPress(event: React.KeyboardEvent<HTMLDivElement>) { if (event.target instanceof HTMLInputElement) { // Ignore key presses coming from input elements return; } if (event.target instanceof HTMLTextAreaElement) { // Ignore key presses coming from textarea elements return; } switch (event.keyCode) { case 8: removeBar(actionId || ""); // Prevent navigation to previous page event.preventDefault(); break; case 37: // reduce time removeTimeToBar(actionId || ""); break; case 39: // add time addTimeToBar(actionId || ""); break; case 38: // add power addPowerToBar(actionId || ""); break; case 40: // add power removePowerToBar(actionId || ""); break; default: //console.log(event.keyCode); break; } } function addBar( zone: number, duration: number = 300, cadence: number = 0, pace: number = 0, length: number = 200 ) { setBars((bars) => [ ...bars, { time: durationType === "time" ? duration : helpers.round( helpers.calculateTime(length, calculateSpeed(pace)), 1 ), length: durationType === "time" ? helpers.round( helpers.calculateDistance(duration, calculateSpeed(pace)), 1 ) : length, power: zone, cadence: cadence, type: "bar", id: uuidv4(), pace: pace, }, ]); } function addTrapeze( zone1: number, zone2: number, duration: number = 300, pace: number = 0, length: number = 1000, cadence: number = 0 ) { setBars((bars) => [ ...bars, { time: durationType === "time" ? duration : helpers.round( helpers.calculateTime(length, calculateSpeed(pace)), 1 ), length: durationType === "time" ? helpers.round( helpers.calculateDistance(duration, calculateSpeed(pace)), 1 ) : length, startPower: zone1, endPower: zone2, cadence: cadence, pace: pace, type: "trapeze", id: uuidv4(), }, ]); } function addFreeRide( duration: number = 600, cadence: number = 0, length: number = 1000 ) { setBars((bars) => [ ...bars, { time: durationType === "time" ? duration : 0, length: durationType === "time" ? 0 : length, cadence: cadence, type: "freeRide", id: uuidv4(), }, ]); } function addInterval( repeat: number = 3, onDuration: number = 30, offDuration: number = 120, onPower: number = 1, offPower: number = 0.5, cadence: number = 0, restingCadence: number = 0, pace: number = 0, onLength: number = 200, offLength: number = 200 ) { setBars((bars) => [ ...bars, { time: durationType === "time" ? (onDuration + offDuration) * repeat : helpers.round( helpers.calculateTime( (onLength + offLength) * repeat, calculateSpeed(pace) ), 1 ), length: durationType === "time" ? helpers.round( helpers.calculateDistance( (onDuration + offDuration) * repeat, calculateSpeed(pace) ), 1 ) : (onLength + offLength) * repeat, id: uuidv4(), type: "interval", cadence: cadence, restingCadence: restingCadence, repeat: repeat, onDuration: durationType === "time" ? onDuration : helpers.round( helpers.calculateTime( (onLength * 1) / onPower, calculateSpeed(pace) ), 1 ), offDuration: durationType === "time" ? offDuration : helpers.round( helpers.calculateTime( (offLength * 1) / offPower, calculateSpeed(pace) ), 1 ), onPower: onPower, offPower: offPower, pace: pace, onLength: durationType === "time" ? helpers.round( helpers.calculateDistance( (onDuration * 1) / onPower, calculateSpeed(pace) ), 1 ) : onLength, offLength: durationType === "time" ? helpers.round( helpers.calculateDistance( (offDuration * 1) / offPower, calculateSpeed(pace) ), 1 ) : offLength, }, ]); } function addInstruction(text = "", time = 0, length = 0) { setInstructions((instructions) => [ ...instructions, { text: text, time: time, length: length, id: uuidv4(), }, ]); } function changeInstruction(id: string, values: Instruction) { const index = instructions.findIndex( (instructions) => instructions.id === id ); const updatedArray = [...instructions]; updatedArray[index] = values; setInstructions(updatedArray); } function deleteInstruction(id: string) { const updatedArray = [...instructions]; setInstructions(updatedArray.filter((item) => item.id !== id)); } function removeBar(id: string) { const updatedArray = [...bars]; setBars(updatedArray.filter((item) => item.id !== id)); setActionId(undefined); } function addTimeToBar(id: string) { const updatedArray = [...bars]; const index = updatedArray.findIndex((bar) => bar.id === id); const element = updatedArray[index]; if (element && durationType === "time") { element.time = element.time + 5; element.length = (helpers.calculateDistance( element.time, calculateSpeed(element.pace || 0) ) * 1) / (element.power || 1); setBars(updatedArray); } if (element && durationType === "distance") { element.length = (element.length || 0) + 200; element.time = (helpers.calculateTime( element.length, calculateSpeed(element.pace || 0) ) * 1) / (element.power || 1); setBars(updatedArray); } } function removeTimeToBar(id: string) { const updatedArray = [...bars]; const index = updatedArray.findIndex((bar) => bar.id === id); const element = updatedArray[index]; if (element && element.time > 5 && durationType === "time") { element.time = element.time - 5; element.length = (helpers.calculateDistance( element.time, calculateSpeed(element.pace || 0) ) * 1) / (element.power || 1); setBars(updatedArray); } if (element && (element.length || 0) > 200 && durationType === "distance") { element.length = (element.length || 0) - 200; element.time = (helpers.calculateTime( element.length, calculateSpeed(element.pace || 0) ) * 1) / (element.power || 1); setBars(updatedArray); } } function addPowerToBar(id: string) { const updatedArray = [...bars]; const index = updatedArray.findIndex((bar) => bar.id === id); const element = updatedArray[index]; if (element && element.power) { element.power = parseFloat((element.power + 1 / ftp).toFixed(3)); if (durationType === "time") { element.length = (helpers.calculateDistance( element.time, calculateSpeed(element.pace || 0) ) * 1) / element.power; } else { element.time = (helpers.calculateTime( element.length, calculateSpeed(element.pace || 0) ) * 1) / element.power; } setBars(updatedArray); } } function removePowerToBar(id: string) { const updatedArray = [...bars]; const index = updatedArray.findIndex((bar) => bar.id === id); const element = updatedArray[index]; if (element && element.power && element.power >= Zones.Z1.min) { element.power = parseFloat((element.power - 1 / ftp).toFixed(3)); if (durationType === "time") { element.length = (helpers.calculateDistance( element.time, calculateSpeed(element.pace || 0) ) * 1) / element.power; } else { element.time = (helpers.calculateTime( element.length, calculateSpeed(element.pace || 0) ) * 1) / element.power; } setBars(updatedArray); } } function duplicateBar(id: string) { const index = bars.findIndex((bar) => bar.id === id); const element = [...bars][index]; if (element.type === "bar") addBar( element.power || 80, element.time, element.cadence, element.pace, element.length ); if (element.type === "freeRide") addFreeRide(element.time, element.cadence, element.length); if (element.type === "trapeze") addTrapeze( element.startPower || 80, element.endPower || 160, element.time, element.pace || 0, element.length, element.cadence ); if (element.type === "interval") addInterval( element.repeat, element.onDuration, element.offDuration, element.onPower, element.offPower, element.cadence, element.restingCadence, element.pace, element.onLength, element.offLength ); setActionId(undefined); } function moveLeft(id: string) { const index = bars.findIndex((bar) => bar.id === id); // not first position of array if (index > 0) { const updatedArray = [...bars]; const element = [...bars][index]; updatedArray.splice(index, 1); updatedArray.splice(index - 1, 0, element); setBars(updatedArray); } } function moveRight(id: string) { const index = bars.findIndex((bar) => bar.id === id); // not first position of array if (index < bars.length - 1) { const updatedArray = [...bars]; const element = [...bars][index]; updatedArray.splice(index, 1); updatedArray.splice(index + 1, 0, element); setBars(updatedArray); } } function saveWorkout() { setSavePopupVisibility(true); } function deleteWorkout() { // save to cloud (firebase) if logged in if (user) { const itemsRef = firebase.database().ref(); var updates: any = {}; updates[`users/${user.uid}/workouts/${id}`] = null; updates[`workouts/${id}`] = null; // save to firebase itemsRef .update(updates) .then(() => { newWorkout(); }) .catch((error) => { console.log(error); setMessage({ visible: true, class: "error", text: "Cannot delete workout", }); }); } } function shareWorkout() { if (user) { save(); setSharePopupVisibility(true); } else { saveWorkout(); } } function save() { setMessage({ visible: true, class: "loading", text: "Saving.." }); const xml = createWorkoutXml({ author, name, description, sportType, durationType, tags, bars, instructions, }); const file = new Blob([xml], { type: "application/xml" }); // save to cloud (firebase) if logged in if (user) { const itemsRef = firebase.database().ref(); const item = { id: id, name: name, description: description, author: author, workout: bars, tags: tags, instructions: instructions, userId: user.uid, updatedAt: Date(), sportType: sportType, durationType: durationType, }; const item2 = { name: name, description: description, updatedAt: Date(), sportType: sportType, durationType: durationType, workoutTime: helpers.formatDuration( helpers.getWorkoutLength(bars, durationType) ), workoutDistance: helpers.getWorkoutDistance(bars), }; var updates: any = {}; updates[`users/${user.uid}/workouts/${id}`] = item2; updates[`workouts/${id}`] = item; // save to firebase itemsRef .update(updates) .then(() => { //upload to s3 upload(file, false); setMessage({ visible: false }); }) .catch((error) => { console.log(error); setMessage({ visible: true, class: "error", text: "Cannot save this", }); }); } else { // download workout without saving setMessage({ visible: false }); } return file; } function logout() { console.log("logout"); auth.signOut().then(() => setUser(null)); } function downloadWorkout() { const tempFile = save(); const url = window.URL.createObjectURL(tempFile); var a = document.createElement("a"); document.body.appendChild(a); a.style.display = "none"; a.href = url; a.download = `${id}.zwo`; a.click(); window.URL.revokeObjectURL(url); } function handleUpload(file: Blob) { // ask user if they want to overwrite current workout first if (bars.length > 0) { if (!window.confirm("Are you sure you want to create a new workout?")) { return false; } } newWorkout(); upload(file, true); } function upload(file: Blob, parse = false) { fetch(process.env.REACT_APP_UPLOAD_FUNCTION || "https://zwiftworkout.netlify.app/.netlify/functions/upload", { method: "POST", body: JSON.stringify({ fileType: "zwo", fileName: `${id}.zwo`, }), }) .then((res) => res.json()) .then(function (data) { const signedUrl = data.uploadURL; // upload to S3 fetch(signedUrl, { method: "PUT", headers: { "Content-Type": "zwo", }, body: file, }) .then((response) => response.text()) .then((data) => { console.log("File uploaded"); // can parse now if (parse) fetchAndParse(id); }) .catch((error) => { console.error(error); }); }); } function fetchAndParse(id: string) { // remove previous workout // TODO fix for running distance based setBars([]); setInstructions([]); fetch(`${S3_URL}/${id}.zwo`) .then((response) => response.text()) .then((data) => { // remove xml comments data = data.replace(/<!--(.*?)-->/gm, ""); //now parse file const workout = Converter.xml2js(data); const workout_file = workout.elements[0]; if (workout_file.name === "workout_file") { // file is valid const authorIndex = workout_file.elements.findIndex( (element: { name: string }) => element.name === "author" ); if ( authorIndex !== -1 && workout_file.elements[authorIndex].elements ) { setAuthor(workout_file.elements[authorIndex].elements[0].text); } const nameIndex = workout_file.elements.findIndex( (element: { name: string }) => element.name === "name" ); if (nameIndex !== -1 && workout_file.elements[nameIndex].elements) { setName(workout_file.elements[nameIndex].elements[0].text); } const descriptionIndex = workout_file.elements.findIndex( (element: { name: string }) => element.name === "description" ); if ( descriptionIndex !== -1 && workout_file.elements[descriptionIndex].elements ) { setDescription( workout_file.elements[descriptionIndex].elements[0].text ); } const workoutIndex = workout_file.elements.findIndex( (element: { name: string }) => element.name === "workout" ); var totalTime = 0; workout_file.elements[workoutIndex].elements.map( (w: { name: string; attributes: { Power: any; PowerLow: string; Duration: string; PowerHigh: string; Cadence: string; CadenceResting: string; Repeat: string; OnDuration: string; OffDuration: string; OnPower: string; OffPower: string; Pace: string; }; elements: any; }) => { let duration = parseFloat(w.attributes.Duration); if (w.name === "SteadyState") addBar( parseFloat(w.attributes.Power || w.attributes.PowerLow), parseFloat(w.attributes.Duration), parseFloat(w.attributes.Cadence || "0"), parseInt(w.attributes.Pace || "0") ); if ( w.name === "Ramp" || w.name === "Warmup" || w.name === "Cooldown" ) addTrapeze( parseFloat(w.attributes.PowerLow), parseFloat(w.attributes.PowerHigh), parseFloat(w.attributes.Duration), parseInt(w.attributes.Pace || "0"), undefined, parseInt(w.attributes.Cadence) ); if (w.name === "IntervalsT") { addInterval( parseFloat(w.attributes.Repeat), parseFloat(w.attributes.OnDuration), parseFloat(w.attributes.OffDuration), parseFloat(w.attributes.OnPower), parseFloat(w.attributes.OffPower), parseInt(w.attributes.Cadence || "0"), parseInt(w.attributes.CadenceResting), parseInt(w.attributes.Pace || "0") ); duration = (parseFloat(w.attributes.OnDuration) + parseFloat(w.attributes.OffDuration)) * parseFloat(w.attributes.Repeat); } if (w.name === "FreeRide") addFreeRide( parseFloat(w.attributes.Duration), parseInt(w.attributes.Cadence) ); // check for instructions const textElements = w.elements; if (textElements && textElements.length > 0) { textElements.map( (t: { name: string; attributes: { message: string | undefined; timeoffset: string; }; }) => { if (t.name.toLowerCase() === "textevent") addInstruction( t.attributes.message, totalTime + parseFloat(t.attributes.timeoffset) ); return false; } ); } totalTime = totalTime + duration; // map functions expect return value return false; } ); } }) .catch((error) => { console.error(error); }); } function calculateSpeed(pace: number = 0) { if (sportType === "bike") { return 0; } else { // return speed in m/s // speed = distance / time const distances = [1.60934, 5, 10, 21.0975, 42.195]; const times = [ runningTimes.oneMile, runningTimes.fiveKm, runningTimes.tenKm, runningTimes.halfMarathon, runningTimes.marathon, ]; return (distances[pace] * 1000) / helpers.getTimeinSeconds(times[pace]); } } const renderBar = (bar: Bar) => ( <Bar key={bar.id} id={bar.id} time={bar.time} length={bar.length || 200} power={bar.power || 100} cadence={bar.cadence} ftp={ftp} weight={weight} sportType={sportType} durationType={durationType} pace={bar.pace || 0} speed={calculateSpeed(bar.pace || 0)} onChange={(id: string, value: any) => handleOnChange(id, value)} // Change any to Interface Bar? onClick={(id: string) => handleOnClick(id)} selected={bar.id === actionId} showLabel={true} /> ); const renderTrapeze = (bar: Bar) => ( <Trapeze key={bar.id} id={bar.id} time={bar.time} length={bar.length || 200} cadence={bar.cadence} startPower={bar.startPower || 80} endPower={bar.endPower || 160} ftp={ftp} sportType={sportType} durationType={durationType} pace={bar.pace || 0} speed={calculateSpeed(bar.pace || 0)} onChange={(id: string, value: any) => handleOnChange(id, value)} // Change any to Interface Bar? onClick={(id: string) => handleOnClick(id)} selected={bar.id === actionId} /> ); const renderFreeRide = (bar: Bar) => ( <FreeRide key={bar.id} id={bar.id} time={bar.time} length={bar.length} cadence={bar.cadence} durationType={durationType} sportType={sportType} onChange={(id: string, value: any) => handleOnChange(id, value)} // Change any to Interface Bar? onClick={(id: string) => handleOnClick(id)} selected={bar.id === actionId} /> ); const renderInterval = (bar: Bar) => ( <Interval key={bar.id} id={bar.id} repeat={bar.repeat || 3} onDuration={bar.onDuration || 10} offDuration={bar.offDuration || 50} onPower={bar.onPower || 250} offPower={bar.offPower || 120} onLength={bar.onLength || 200} offLength={bar.offLength || 200} cadence={bar.cadence} restingCadence={bar.restingCadence || 0} ftp={ftp} weight={weight} sportType={sportType} durationType={durationType} pace={bar.pace || 0} speed={calculateSpeed(bar.pace || 0)} handleIntervalChange={(id: string, value: any) => handleOnChange(id, value) } handleIntervalClick={(id: string) => handleOnClick(id)} selected={bar.id === actionId} /> ); const renderComment = (instruction: Instruction, index: number) => ( <Comment key={instruction.id} instruction={instruction} durationType={durationType} width={ durationType === "distance" ? parseInt(helpers.getWorkoutDistance(bars)) * 100 : helpers.getWorkoutLength(bars, durationType) / 3 } onChange={(id: string, values: Instruction) => changeInstruction(id, values) } onClick={(id: string) => setSelectedInstruction(instructions.find((i) => i.id === id)) } index={index} /> ); const renderRegistrationForm = () => { switch (visibleForm) { case "login": return ( <LoginForm login={setUser} showSignup={() => setVisibleForm("signup")} dismiss={() => setSavePopupVisibility(false)} showForgotPassword={() => setVisibleForm("forgotPassword")} /> ); case "signup": return ( <SignupForm signUp={setUser} showLogin={() => setVisibleForm("login")} dismiss={() => setSavePopupVisibility(false)} /> ); case "forgotPassword": return ( <ForgotPasswordForm dismiss={() => setSavePopupVisibility(false)} workoutId={id} /> ); case "updatePassword": return ( <UpdatePasswordForm dismiss={() => { setVisibleForm("login"); setSavePopupVisibility(true); }} /> ); default: break; } }; function setPace(value: string, id: string) { const index = bars.findIndex((bar) => bar.id === id); if (index !== -1) { const updatedArray = [...bars]; const element = [...updatedArray][index]; element.pace = parseInt(value); if (durationType === "time") { element.length = (helpers.calculateDistance( element.time, calculateSpeed(element.pace || 0) ) * 1) / (element.power || 1); } else { element.time = (helpers.calculateTime( element.length, calculateSpeed(element.pace || 0) ) * 1) / (element.power || 1); } setBars(updatedArray); } } function getPace(id: string) { const index = bars.findIndex((bar) => bar.id === id); if (index !== -1) { const element = [...bars][index]; return element.pace; } } function switchSportType(newSportType: SportType) { setSportType(newSportType); setDurationType(newSportType === "bike" ? "time" : "distance"); } function toggleTextEditor() { if (bars.length > 0 && !textEditorIsVisible) { if ( window.confirm( "Editing a workout from the text editor will overwrite current workout" ) ) setTextEditorIsVisible(!textEditorIsVisible); } else { setTextEditorIsVisible(!textEditorIsVisible); } } function transformTextToWorkout(textValue: string) { // reset each time setBars([]); setInstructions([]); // 2 minutes block at 112% FTP // 2 minutes block at 330 W // 30 seconds block at .. //console.log(textValue); const workoutBlocks = textValue.split("\n"); workoutBlocks.forEach((workoutBlock) => { if (workoutBlock.includes("steady")) { // generate a steady state block // extract watts const powerInWatts = workoutBlock.match(/([0-9]\d*w)/); const powerInWattsPerKg = workoutBlock.match(/([0-9]*.?[0-9]wkg)/); const powerInPercentageFtp = workoutBlock.match(/([0-9]\d*%)/); let power = powerInWatts ? parseInt(powerInWatts[0]) / ftp : 1; power = powerInWattsPerKg ? (parseFloat(powerInWattsPerKg[0]) * weight) / ftp : power; power = powerInPercentageFtp ? parseInt(powerInPercentageFtp[0]) / 100 : power; // extract duration in seconds const durationInSeconds = workoutBlock.match(/([0-9]\d*s)/); const durationInMinutes = workoutBlock.match(/([0-9]*:?[0-9][0-9]*m)/); let duration = durationInSeconds && parseInt(durationInSeconds[0]); duration = durationInMinutes ? parseInt(durationInMinutes[0].split(":")[0]) * 60 + (parseInt(durationInMinutes[0].split(":")[1]) || 0) : duration; // extract cadence in rpm const cadence = workoutBlock.match(/([0-9]\d*rpm)/); const rpm = cadence ? parseInt(cadence[0]) : undefined; // extract multiplier // const multiplier = workoutBlock.match(/([0-9]\d*x)/) // const nTimes = multiplier ? Array(parseInt(multiplier[0])) : Array(1) // for (var i = 0; i < nTimes.length; i++) addBar(power, duration || 300, rpm); } if ( workoutBlock.includes("ramp") || workoutBlock.includes("warmup") || workoutBlock.includes("cooldown") ) { // generate a steady ramp block // extract watts const startPowerInWatts = workoutBlock.match(/([0-9]\d*w)/); const startPowerInWattsPerKg = workoutBlock.match(/([0-9]*.?[0-9]wkg)/); const startPowerInPercentageFtp = workoutBlock.match(/([0-9]\d*%)/); let startPower = startPowerInWatts ? parseInt(startPowerInWatts[0]) / ftp : 1; startPower = startPowerInWattsPerKg ? (parseFloat(startPowerInWattsPerKg[0]) * weight) / ftp : startPower; startPower = startPowerInPercentageFtp ? parseInt(startPowerInPercentageFtp[0]) / 100 : startPower; // extract watts const endPowerInWatts = workoutBlock.match(/(-[0-9]\d*w)/); const endPowerInWattsPerKg = workoutBlock.match(/(-[0-9]*.?[0-9]wkg)/); const endPowerInPercentageFtp = workoutBlock.match(/-([0-9]\d*%)/); let endPower = endPowerInWatts ? Math.abs(parseInt(endPowerInWatts[0])) / ftp : 1; endPower = endPowerInWattsPerKg ? (Math.abs(parseFloat(endPowerInWattsPerKg[0])) * weight) / ftp : endPower; endPower = endPowerInPercentageFtp ? Math.abs(parseInt(endPowerInPercentageFtp[0])) / 100 : endPower; const durationInSeconds = workoutBlock.match(/([0-9]\d*s)/); const durationInMinutes = workoutBlock.match(/([0-9]*:?[0-9][0-9]*m)/); let duration = durationInSeconds && parseInt(durationInSeconds[0]); duration = durationInMinutes ? parseInt(durationInMinutes[0].split(":")[0]) * 60 + (parseInt(durationInMinutes[0].split(":")[1]) || 0) : duration; // extract cadence in rpm const cadence = workoutBlock.match(/([0-9]\d*rpm)/); const rpm = cadence ? parseInt(cadence[0]) : undefined; addTrapeze( startPower, endPower, duration || 300, undefined, undefined, rpm ); } if (workoutBlock.includes("freeride")) { const durationInSeconds = workoutBlock.match(/([0-9]\d*s)/); const durationInMinutes = workoutBlock.match(/([0-9]*:?[0-9][0-9]*m)/); let duration = durationInSeconds && parseInt(durationInSeconds[0]); duration = durationInMinutes ? parseInt(durationInMinutes[0].split(":")[0]) * 60 + (parseInt(durationInMinutes[0].split(":")[1]) || 0) : duration; // extract cadence in rpm const cadence = workoutBlock.match(/([0-9]\d*rpm)/); const rpm = cadence ? parseInt(cadence[0]) : undefined; addFreeRide(duration || 600, rpm); } if (workoutBlock.includes("interval")) { const multiplier = workoutBlock.match(/([0-9]\d*x)/); const nTimes = multiplier ? parseInt(multiplier[0]) : 3; const durationInSeconds = workoutBlock.match(/([0-9]\d*s)/); const durationInMinutes = workoutBlock.match(/([0-9]*:?[0-9][0-9]*m)/); let duration = durationInSeconds && parseInt(durationInSeconds[0]); duration = durationInMinutes ? parseInt(durationInMinutes[0].split(":")[0]) * 60 + (parseInt(durationInMinutes[0].split(":")[1]) || 0) : duration; const offDurationInSeconds = workoutBlock.match(/(-[0-9]\d*s)/); const offDurationInMinutes = workoutBlock.match( /(-[0-9]*:?[0-9][0-9]*m)/ ); let offDuration = offDurationInSeconds && Math.abs(parseInt(offDurationInSeconds[0])); offDuration = offDurationInMinutes ? Math.abs(parseInt(offDurationInMinutes[0].split(":")[0])) * 60 + (parseInt(offDurationInMinutes[0].split(":")[1]) || 0) : offDuration; // extract watts const startPowerInWatts = workoutBlock.match(/([0-9]\d*w)/); const startPowerInWattsPerKg = workoutBlock.match(/([0-9]*.?[0-9]wkg)/); const startPowerInPercentageFtp = workoutBlock.match(/([0-9]\d*%)/); let startPower = startPowerInWatts ? parseInt(startPowerInWatts[0]) / ftp : 1; startPower = startPowerInWattsPerKg ? (parseFloat(startPowerInWattsPerKg[0]) * weight) / ftp : startPower; startPower = startPowerInPercentageFtp ? parseInt(startPowerInPercentageFtp[0]) / 100 : startPower; // extract watts const endPowerInWatts = workoutBlock.match(/(-[0-9]\d*w)/); const endPowerInWattsPerKg = workoutBlock.match(/(-[0-9]*.?[0-9]wkg)/); const endPowerInPercentageFtp = workoutBlock.match(/-([0-9]\d*%)/); let endPower = endPowerInWatts ? Math.abs(parseInt(endPowerInWatts[0])) / ftp : 0.5; endPower = endPowerInWattsPerKg ? (Math.abs(parseFloat(endPowerInWattsPerKg[0])) * weight) / ftp : endPower; endPower = endPowerInPercentageFtp ? Math.abs(parseInt(endPowerInPercentageFtp[0])) / 100 : endPower; // extract cadence in rpm const cadence = workoutBlock.match(/([0-9]\d*rpm)/); const rpm = cadence ? parseInt(cadence[0]) : undefined; const restingCadence = workoutBlock.match(/(-[0-9]\d*rpm)/); const restingRpm = restingCadence ? Math.abs(parseInt(restingCadence[0])) : undefined; addInterval( nTimes, duration || 30, offDuration || 120, startPower, endPower, rpm, restingRpm ); } if (workoutBlock.includes("message")) { // extract message const message = workoutBlock.match(/["'](.*?)["']/); const text = message ? message[0] : ""; const durationInSeconds = workoutBlock.match(/([0-9]\d*s)/); const durationInMinutes = workoutBlock.match(/([0-9]*:?[0-9][0-9]*m)/); let duration = durationInSeconds && parseInt(durationInSeconds[0]); duration = durationInMinutes ? parseInt(durationInMinutes[0].split(":")[0]) * 60 + (parseInt(durationInMinutes[0].split(":")[1]) || 0) : duration; addInstruction(text, duration || 0); } }); } return ( // Adding tabIndex allows div element to receive keyboard events <div className="container" onKeyDown={handleKeyPress} tabIndex={0}> <Helmet> <title> {name ? `${name} - Zwift Workout Editor` : "My Workout - Zwift Workout Editor"} </title> <meta name="description" content={description} /> <meta property="og:title" content={ name ? `${name} - Zwift Workout Editor` : "My Workout - Zwift Workout Editor" } /> <meta property="og:description" content={description} /> <link rel="canonical" href={`https://www.zwiftworkout.com/editor/${id}`} /> <meta property="og:url" content={`https://www.zwiftworkout.com/editor/${id}`} /> </Helmet> {message?.visible && ( <div className={`message ${message.class}`}> {message.text} <button className="close" onClick={() => setMessage({ visible: false })} > <FontAwesomeIcon icon={faTimesCircle} size="lg" fixedWidth /> </button> </div> )} {showWorkouts && ( <Popup width="500px" dismiss={() => setShowWorkouts(false)}> {user ? <Workouts userId={user.uid} /> : renderRegistrationForm()} </Popup> )} {selectedInstruction && ( <EditComment instruction={selectedInstruction} onChange={(id: string, values: Instruction) => { changeInstruction(id, values); setSelectedInstruction(undefined); }} dismiss={() => setSelectedInstruction(undefined)} onDelete={(id: string) => { deleteInstruction(id); setSelectedInstruction(undefined); }} /> )} {savePopupIsVisile && ( <Popup width="500px" dismiss={() => setSavePopupVisibility(false)}> {user ? ( <SaveForm name={name} description={description} author={author} tags={tags} onNameChange={setName} onDescriptionChange={setDescription} onAuthorChange={setAuthor} onTagsChange={setTags} onSave={() => { save(); setSavePopupVisibility(false); }} onDismiss={() => setSavePopupVisibility(false)} onLogout={logout} /> ) : ( renderRegistrationForm() )} </Popup> )} {sharePopupIsVisile && ( <Popup width="500px" dismiss={() => setSharePopupVisibility(false)}> <ShareForm id={id} onDismiss={() => setSharePopupVisibility(false)} /> </Popup> )} <div className="info"> <div className="title"> <h1>{name}</h1> <div className="description">{description}</div> <p>{author ? `by ${author}` : ""}</p> </div> <div className="workout"> <div className="form-input"> <label>Workout Time</label> <input className="textInput" value={helpers.formatDuration( helpers.getWorkoutLength(bars, durationType) )} disabled /> </div> {sportType === "run" && ( <div className="form-input"> <label>Workout Distance</label> <input className="textInput" value={helpers.getWorkoutDistance(bars)} disabled /> </div> )} {sportType === "bike" && ( <div className="form-input"> <label title="Training Load">Training Load</label> <input className="textInput" value={helpers.getStressScore(bars, ftp)} disabled /> </div> )} {sportType === "run" && ( <LeftRightToggle<"time", "distance"> label="Duration Type" leftValue="time" rightValue="distance" leftIcon={faClock} rightIcon={faRuler} selected={durationType} onChange={setDurationType} /> )} <LeftRightToggle<"bike", "run"> label="Sport Type" leftValue="bike" rightValue="run" leftIcon={faBiking} rightIcon={faRunning} selected={sportType} onChange={switchSportType} /> </div> </div> {sportType === "run" && ( <RunningTimesEditor times={runningTimes} onChange={setRunningTimes} /> )} {textEditorIsVisible && sportType === "bike" && ( <div className="text-editor"> <textarea onChange={(e) => transformTextToWorkout(e.target.value)} rows={10} spellCheck={false} className="text-editor-textarea" placeholder="Add one block per line here: steady 3.0wkg 30s" ></textarea> <div className="text-editor-instructions"> <h2>Instructions</h2> <p> Every row correspond to a workout block. Scroll down to see some examples. </p> <h3>Blocks</h3> <p> <span>steady</span> <span>warmup</span> <span>cooldown</span>{" "} <span>ramp</span> <span>intervals</span> <span>freeride</span>{" "} <span>message</span> </p> <h3>Time</h3> <p> <span>30s</span> or <span>0:30m</span> </p> <h3>Power</h3> <p> <span>250w</span> or <span>3.0wkg</span> or <span>75%</span> (FTP) </p> <h3>Cadence</h3> <p> <span>120rpm</span> </p> <h2>Examples</h2> <h3>Steady block</h3> <p> <code>steady 3.0wkg 30s</code> <code>steady 120w 10m 85rpm</code> </p> <h3>Warmup / Cooldown / Ramp block</h3> <p> <code>warmup 2.0wkg-3.5wkg 10m</code> <code>cooldown 180w-100w 5m 110rpm</code> </p> <h3>Intervals</h3> <p> <code>interval 10x 30s-30s 4.0wkg-1.0wkg 110rpm-85rpm</code> <code>interval 3x 1:00m-5:00m 300w-180w</code> </p> <h3>Free Ride</h3> <p> <code>freeride 10m 85rpm</code> </p> <h3>Text Event</h3> <p> <code>message "Get ready to your first set!" 30s</code> <code>message "Last one!" 20:00m</code> </p> </div> </div> )} <div id="editor" className="editor"> {actionId && ( <div className="actions"> <button onClick={() => moveLeft(actionId)} title="Move Left"> <FontAwesomeIcon icon={faArrowLeft} size="lg" fixedWidth /> </button> <button onClick={() => moveRight(actionId)} title="Move Right"> <FontAwesomeIcon icon={faArrowRight} size="lg" fixedWidth /> </button> <button onClick={() => removeBar(actionId)} title="Delete"> <FontAwesomeIcon icon={faTrash} size="lg" fixedWidth /> </button> <button onClick={() => duplicateBar(actionId)} title="Duplicate"> <FontAwesomeIcon icon={faCopy} size="lg" fixedWidth /> </button> {sportType === "run" && ( <select name="pace" value={getPace(actionId)} onChange={(e) => setPace(e.target?.value, actionId)} className="selectInput" > <option value="0">1 Mile Pace</option> <option value="1">5K Pace</option> <option value="2">10K Pace</option> <option value="3">Half Marathon Pace</option> <option value="4">Marathon Pace</option> </select> )} </div> )} <div className="canvas" ref={canvasRef}> {actionId && ( <div className="fader" style={{ width: canvasRef.current?.scrollWidth }} onClick={() => setActionId(undefined)} ></div> )} <div className="segments" ref={segmentsRef}> {bars.map((bar) => { if (bar.type === "bar") { return renderBar(bar); } else if (bar.type === "trapeze") { return renderTrapeze(bar); } else if (bar.type === "freeRide") { return renderFreeRide(bar); } else if (bar.type === "interval") { return renderInterval(bar); } else { return false; } })} </div> <div className="slider"> {instructions.map((instruction, index) => renderComment(instruction, index) )} </div> {durationType === "time" ? ( <TimeAxis width={segmentsWidth} /> ) : ( <DistanceAxis width={segmentsWidth} /> )} </div> <ZoneAxis /> </div> <div className="cta"> {sportType === "bike" ? ( <div> <ReactTooltip effect="solid" /> <button className="btn btn-square" onClick={() => toggleTextEditor()} style={{ backgroundColor: "palevioletred" }} data-tip="New! Workout text editor!" > <FontAwesomeIcon icon={faPen} fixedWidth /> </button> <button className="btn btn-square" onClick={() => addBar(0.5)} style={{ backgroundColor: Colors.GRAY }} > Z1 </button> <button className="btn btn-square" onClick={() => addBar(Zones.Z2.min)} style={{ backgroundColor: Colors.BLUE }} > Z2 </button> <button className="btn btn-square" onClick={() => addBar(Zones.Z3.min)} style={{ backgroundColor: Colors.GREEN }} > Z3 </button> <button className="btn btn-square" onClick={() => addBar(Zones.Z4.min)} style={{ backgroundColor: Colors.YELLOW }} > Z4 </button> <button className="btn btn-square" onClick={() => addBar(Zones.Z5.min)} style={{ backgroundColor: Colors.ORANGE }} > Z5 </button> <button className="btn btn-square" onClick={() => addBar(Zones.Z6.min)} style={{ backgroundColor: Colors.RED }} > Z6 </button> </div> ) : ( <button className="btn" onClick={() => addBar(1, 300, 0, 0, 1000)} style={{ backgroundColor: Colors.WHITE }} > <SteadyLogo className="btn-icon" /> Steady Pace </button> )} <button className="btn" onClick={() => addTrapeze(0.25, 0.75)} style={{ backgroundColor: Colors.WHITE }} > <WarmupLogo className="btn-icon" /> Warm up </button> <button className="btn" onClick={() => addTrapeze(0.75, 0.25)} style={{ backgroundColor: Colors.WHITE }} > <WarmdownLogo className="btn-icon" /> Cool down </button> <button className="btn" onClick={() => addInterval()} style={{ backgroundColor: Colors.WHITE }} > <IntervalLogo className="btn-icon" /> Interval </button> <button className="btn" onClick={() => addFreeRide()} style={{ backgroundColor: Colors.WHITE }} > <FontAwesomeIcon icon={sportType === "bike" ? faBicycle : faRunning} size="lg" fixedWidth />{" "} Free {sportType === "bike" ? "Ride" : "Run"} </button> <button className="btn" onClick={() => addInstruction()} style={{ backgroundColor: Colors.WHITE }} > <FontAwesomeIcon icon={faComment} size="lg" fixedWidth /> Text Event </button> {sportType === "bike" && ( <div className="form-input"> <label htmlFor="ftp">FTP (W)</label> <input className="textInput" type="number" name="ftp" value={ftp} onChange={(e) => setFtp(parseInt(e.target.value))} /> </div> )} {sportType === "bike" && ( <div className="form-input"> <label htmlFor="weight">Body Weight (Kg)</label> <input className="textInput" type="number" name="weight" value={weight} onChange={(e) => setWeight(parseInt(e.target.value))} /> </div> )} <button className="btn" onClick={() => { if ( window.confirm("Are you sure you want to create a new workout?") ) newWorkout(); }} > <FontAwesomeIcon icon={faFile} size="lg" fixedWidth /> New </button> <button className="btn" onClick={() => saveWorkout()}> <FontAwesomeIcon icon={faSave} size="lg" fixedWidth /> Save </button> <button className="btn" onClick={() => { if (window.confirm("Are you sure you want to delete this workout?")) deleteWorkout(); }} > <FontAwesomeIcon icon={faTrash} size="lg" fixedWidth /> Delete </button> <button className="btn" onClick={() => downloadWorkout()}> <FontAwesomeIcon icon={faDownload} size="lg" fixedWidth /> Download </button> <input accept=".xml,.zwo" id="contained-button-file" type="file" style={{ display: "none" }} onChange={(e) => handleUpload(e.target.files![0])} /> <button className="btn" onClick={() => document.getElementById("contained-button-file")!.click() } > <FontAwesomeIcon icon={faUpload} size="lg" fixedWidth /> Upload </button> <button className="btn" onClick={() => setShowWorkouts(true)}> <FontAwesomeIcon icon={faList} size="lg" fixedWidth /> Workouts </button> <button className="btn" onClick={() => shareWorkout()}> <FontAwesomeIcon icon={faShareAlt} size="lg" fixedWidth /> Share </button> </div> <Footer /> </div> ); }; export default Editor;