first commit

This commit is contained in:
vanalmsick 2025-09-27 18:19:06 +01:00
commit e7f627801f
152 changed files with 35352 additions and 0 deletions

23
src-frontend/.gitignore vendored Normal file
View file

@ -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*

View file

@ -0,0 +1,21 @@
const Critters = require('critters-webpack-plugin');
module.exports = {
webpack: {
configure: (webpackConfig) => {
webpackConfig.devtool = 'source-map'; // always enable source maps
return webpackConfig;
},
plugins: {
add: [
new Critters({
// Critters options
preload: 'swap', // or 'body'
preloadFonts: true,
fonts: true,
noscriptFallback: true
})
]
}
}
};

19762
src-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

64
src-frontend/package.json Normal file
View file

@ -0,0 +1,64 @@
{
"name": "workout_challenge",
"version": "0.1.0",
"private": true,
"dependencies": {
"@reduxjs/toolkit": "^2.7.0",
"@sentry/react": "^9.40.0",
"@sentry/tracing": "^7.120.3",
"@tanstack/react-query": "^5.75.2",
"@tanstack/react-query-devtools": "^5.75.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"chart.js": "^4.4.9",
"chartjs-plugin-datalabels": "^2.2.0",
"lodash": "^4.17.21",
"lucide-react": "^0.507.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-content-loader": "^7.0.2",
"react-device-detect": "^2.2.3",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.5.1",
"react-scripts": "5.0.1",
"react-spinners": "^0.17.0",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@craco/craco": "^7.1.0",
"autoprefixer": "^10.4.21",
"critters-webpack-plugin": "^3.0.2",
"cssnano": "^7.1.0",
"postcss": "^8.5.6",
"postcss-import": "^16.1.0",
"postcss-nesting": "^13.0.1",
"tailwindcss": "^3.4.17"
}
}

View file

@ -0,0 +1,11 @@
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': 'postcss-nesting',
'cssnano': {
preset: 'default'
},
tailwindcss: {},
autoprefixer: {},
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1 @@
// env variables will be added here via supervisord

View file

@ -0,0 +1,3 @@
running.jpg - Image by <a href="https://pixabay.com/users/fotorech-5554393/?utm_source=link-attribution&utm_medium=referral&utm_campaign=image&utm_content=4782722">Daniel Reche</a> from <a href="https://pixabay.com//?utm_source=link-attribution&utm_medium=referral&utm_campaign=image&utm_content=4782722">Pixabay</a>
profile.png - Image by <a href="https://pixabay.com/users/raphaelsilva-4702998/?utm_source=link-attribution&utm_medium=referral&utm_campaign=image&utm_content=3814049">Raphael Silva</a> from <a href="https://pixabay.com//?utm_source=link-attribution&utm_medium=referral&utm_campaign=image&utm_content=3814049">Pixabay</a>
favicon.ico,apple-touch-icon.png,favicon-[X]x[Y].png - <a href="https://www.flaticon.com/free-icons/trophy" title="trophy icons">Trophy icons created by Maan Icons - Flaticon</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View file

@ -0,0 +1,44 @@
<!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"/>
<meta name="theme-color" content="#000000"/>
<meta name="description" content="Compete with friends and coworkers across devices using the metrics you want respecting your privacy."/>
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon-32x32.png" sizes="32x32"/>
<link rel="icon" type="image/png" href="%PUBLIC_URL%/favicon-16x16.png" sizes="16x16"/>
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"/>
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png"/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"/>
<script src="%PUBLIC_URL%/config.js"></script>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Workout Challenge</title>
</head>
<body class="bg-gray-100 dark:bg-gray-900 ">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -0,0 +1,20 @@
{
"short_name": "Workout Challenge",
"name": "Compete with friends and coworkers across devices using the metrics you want respecting your privacy.",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "apple-touch-icon.png",
"type": "image/png",
"sizes": "192x192"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

33
src-frontend/src/App.css Normal file
View file

@ -0,0 +1,33 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
.bg-light-blue {
background-color: #6387bc;
}
.text-light-blue, .hover\:text-light-blue:hover {
color: #6387bc;
}

48
src-frontend/src/App.js Normal file
View file

@ -0,0 +1,48 @@
import React, {useEffect, useState} from "react";
import {useSelector, useDispatch, Provider} from 'react-redux';
import './App.css';
import { store } from './utils/store';
import {BrowserRouter as Router, Routes, Route, useLocation} from "react-router-dom";
import {
WelcomePage,
RegisterPage,
LogInPage,
ResetPasswordPage,
SetNewPasswordPage,
NotFound,
LogoutPage
} from "./pages/Public";
import MySpace from "./pages/MySpace";
import Competition from "./pages/Competition";
import {InitStravaLink, ReturnStravaLink} from "./pages/StravaLink";
function App() {
return (
<Router>
<Routes>
<Route excat path="/" element={<WelcomePage />} />
<Route excat path="signup" element={<RegisterPage />} />
<Route excat path="login" element={<LogInPage />} />
<Route excat path="logout" element={<LogoutPage />} />
<Route excat path="password" element={<ResetPasswordPage />} />
<Route excat path="password/reset/:id/:token" element={<SetNewPasswordPage />} />
<Route excat path="dashboard" element={<MySpace />} />
<Route path="competition/:id" element={<Competition />} />
<Route excat path="strava/link" element={<InitStravaLink />} />
<Route excat path="strava/return" element={<ReturnStravaLink />} />
{/* Add the catch-all route last */}
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
);
}
export default App;

View file

@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View file

@ -0,0 +1,259 @@
import React, {use, useEffect, useState} from "react";
import {Modal, MultiForm, SaveButton, DeleteButton, PlaceholderModal} from "./basicComponents";
import {useAddTeamMutation, useDeleteTeamMutation, useGetTeamsQuery} from "../utils/reducers/teamsSlice";
import _ from "lodash";
import {
useAddGoalMutation,
useDeleteGoalMutation,
useGetGoalsQuery,
useUpdateGoalMutation
} from "../utils/reducers/goalsSlice";
import {BarLoader, BeatLoader} from "react-spinners";
import {deepDiff, compareDictLists} from "../utils/miscellaneous";
import {useDispatch} from "react-redux";
const fields = {
"name": {
"type": "text",
"required": true,
"read_only": false,
"label": "Goal Name",
"value": "Move Goal",
},
"goal": {
"type": "number",
"required": true,
"read_only": false,
"value": "1200",
"label": "Goal",
"width": "w-1/3",
},
"metric": {
"type": "select",
"required": true,
"read_only": false,
"label": "Metric",
"value": "kcal",
"width": "w-1/3",
"selectList": [
{
"value": "min",
"label": "Time (Minutes)"
},
{
"value": "num",
"label": "Number of times (x)"
},
{
"value": "kcal",
"label": "Calories (Kcal)"
},
{
"value": "km",
"label": "Distance (Km)"
},
{
"value": "kj",
"label": "Effort (Kilojoules)"
}
]
},
"period": {
"type": "select",
"required": true,
"read_only": false,
"label": "Period",
"value": "week",
"width": "w-1/3",
"selectList": [
{
"value": "day",
"label": "per day"
},
{
"value": "week",
"label": "per week"
},
{
"value": "month",
"label": "per month"
},
{
"value": "competition",
"label": "during the competition"
}
]
},
"min_per_workout": {
"type": "number",
"required": false,
"read_only": false,
"label": "Minimum per workout",
"placeholder": "Leave empty to not floor",
"width": "w-1/2",
},
"max_per_workout": {
"type": "number",
"required": false,
"read_only": false,
"label": "Maximum per workout",
"placeholder": "Leave empty to not cap",
"width": "w-1/2",
},
"min_per_day": {
"type": "number",
"required": false,
"read_only": false,
"label": "Minimum per day",
"placeholder": "Leave empty to not floor",
"width": "w-1/2",
},
"max_per_day": {
"type": "number",
"required": false,
"read_only": false,
"label": "Maximum per day",
"placeholder": "Leave empty to not cap",
"value": "750",
"width": "w-1/2",
},
"min_per_week": {
"type": "number",
"required": false,
"read_only": false,
"label": "Minimum per week",
"placeholder": "Leave empty to not floor",
"width": "w-1/2",
},
"max_per_week": {
"type": "number",
"required": false,
"read_only": false,
"label": "Maximum per week",
"placeholder": "Leave empty to not cap",
"value": "3600",
"width": "w-1/2",
},
"count_steps_as_walks": {
"type": "checkbox",
"required": false,
"read_only": false,
"label": "Count steps as walks (double counting is taken care of but manual steps entry required)",
"value": false,
"width": "w-full",
},
}
export default function ActivityGoalsForm({competitionId, setModalState}) {
const {
data: goals,
refetch: goalsRefetch,
error: goalsError,
isLoading: goalsLoading,
isSuccess: goalsIsSuccess,
isFetching: goalsIsFetching,
} = useGetGoalsQuery();
const [updateGoal, {
data: updateGoalData,
error: updateGoalError,
isLoading: updateGoalIsLoading,
isSuccess: updateGoalIsSuccess
}] = useUpdateGoalMutation();
const [createGoal, {
data: createGoalData,
error: createGoalError,
isLoading: createGoalIsLoading,
isSuccess: createGoalIsSuccess
}] = useAddGoalMutation();
const [deleteGoal, {
error: deleteGoalError,
isLoading: deleteGoalIsLoading,
isSuccess: deleteGoalIsSuccess
}] = useDeleteGoalMutation();
const filteredGoals = _.filter(goals || [], item => item?.competition == competitionId).map((item, index) => ({ ...item, index }));
const [values, setValues] = useState(undefined);
const [fieldErrors, setFieldErrors] = useState({});
const [formError, setFormError] = useState('');
async function handleSubmit() {
setFieldErrors({});
setFormError('');
let noErrors = true;
const { newEntries, deletedEntries, changedEntries } = compareDictLists(filteredGoals, values);
for (const newItem of newEntries) {
const result = await createGoal({...newItem, competition: competitionId});
if (result.hasOwnProperty('error')) {
noErrors = false;
console.error('Create Goal Error', result.error);
setFormError(formError + 'Error (' + result?.error?.status + ') when creating goal "' + newItem?.name + '": ' + result?.error?.data?.detail + '; ');
} else {
console.log('Added Goal', newItem, result);
}
}
for (const deletedItem of deletedEntries) {
const result = await deleteGoal(deletedItem.id);
if (result.hasOwnProperty('error')) {
noErrors = false;
console.error('Delete Goal Error', result.error);
setFormError(formError + 'Error (' + result?.error?.status + ') when deleting goal "' + deletedItem?.name + '" (' + deletedItem?.id + '): ' + result?.error?.data?.detail + '; ');
} else {
console.log('Deleted Goal', deletedItem, result);
}
}
for (const changedItem of changedEntries) {
const result = await updateGoal({id: changedItem.id, ...Object.fromEntries(Object.entries(changedItem.changes).map(([key, value]) => [key, value.to]))});
if (result.hasOwnProperty('error')) {
noErrors = false;
console.error('Update Goal Error', result.error);
setFieldErrors({...fieldErrors, [`${changedItem.index}`]: result.error.data});
} else {
console.log('Changed Goal', changedItem, result);
}
}
if (noErrors) {
document.body.classList.remove('body-no-scroll');
setModalState(false);
window.alert('Saved. The points might need to be re-calculated. Thus changes can take up to 10 minutes to reflect on the competition page for all users.');
}
}
function handleDiscard() {
setModalState(false);
setValues([...filteredGoals.map(item => ({ ...item }))]);
}
useEffect(() => {
if (goalsIsSuccess && values === undefined && filteredGoals) {
setValues([...filteredGoals.map(item => ({ ...item }))]);
}
}, [goalsIsSuccess]);
return (
<Modal title="Activity Goals" landscape={false} setShowModal={setModalState} isLoading={goalsLoading || createGoalIsLoading || deleteGoalIsLoading ||updateGoalIsLoading}>
<MultiForm fields={fields} values={values} setValues={setValues} errors={fieldErrors}/>
<div className="text-center text-red-500 text-xs italic">{formError}</div>
<div className="relative flex justify-between items-center">
<DeleteButton onClick={handleDiscard} highlighted={false} label={"Discard Changes"} larger={true} />
<SaveButton onClick={handleSubmit} highlighted={true} larger={true} />
</div>
</Modal>
)
}

View file

@ -0,0 +1,547 @@
import React, {useEffect, useState} from "react";
import {
Plus,
Trash2,
Save,
CopyPlus,
UsersRound,
Flag,
Settings,
UserRoundPlus,
RefreshCw,
Pencil,
ThumbsUp,
ExternalLink,
DoorOpen,
Scale,
UserRoundPen,
} from "lucide-react";
import {BeatLoader} from "react-spinners";
import { isMobile } from "react-device-detect";
import TimeField from "./customTimefieldInput";
export function Modal({setShowModal, title = null, landscape = false, isLoading = false, children}) {
const backgroundClick = (e) => {
if (e.target.classList.contains('modal-background')) {
closeModal();
}
}
const closeModal = () => {
document.body.classList.remove('body-no-scroll');
setShowModal(false);
}
useEffect(() => {
document.body.classList.add('body-no-scroll');
}, []);
return (
<div
className="modal-background fixed inset-0 z-50 bg-white bg-opacity-80 dark:bg-black dark:bg-opacity-80 overflow-y-auto sm:p-4"
onClick={(e) => backgroundClick(e)}
>
<div className="modal-background min-h-screen flex items-center justify-center">
<div
className={"relative bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 " + ((landscape) ? "max-w-4xl" : "max-w-2xl") +
" w-full space-y-4 max-sm:w-full max-sm:min-h-screen max-sm:rounded-none max-sm:p-4 max-sm:m-0 max-sm:shadow-none"}
>
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold">{title}</h2>
<button className="text-gray-500 hover:text-gray-700 text-2xl leading-none"
onClick={() => closeModal()}
>
&times;
</button>
</div>
{
(isLoading) ? (
<div className="w-full h-64 flex items-center justify-center">
<BeatLoader height={6} width={200} color="rgb(209 213 219)"/>
</div>
) :
(
<>
{children}
</>
)
}
</div>
</div>
</div>
)
}
export function FormInput({
name,
value = "",
setValue,
selectList = [],
suggestions = [],
label = null,
type = "text",
placeholder = null,
required = false,
readOnly = false,
disabled = false,
tabIndex = null,
autoFocus = false,
autoComplete = "off",
pattern = null,
width = "w-full",
highlight = false,
errorMsg = null,
}) {
let additionalClasses = "";
if (readOnly) {
additionalClasses += " text-gray-500 dark:text-gray-500 " + ((highlight) ? "": " bg-gray-100 dark:bg-gray-700 ");
}
if (disabled) {
additionalClasses += " text-gray-500 dark:text-gray-500 cursor-not-allowed " + ((highlight) ? "": " bg-gray-100 dark:bg-gray-700 ");
}
return (
<div className={"px-4 " + width}>
<fieldset>
{/* Checkbox Input */}
{
(type === "checkbox") ? (
<input
type="checkbox"
className="mr-2 leading-tight"
name={name}
tabIndex={tabIndex}
readOnly={readOnly}
disabled={disabled}
autoFocus={!isMobile && autoFocus}
checked={value}
onChange={(e) => setValue(!value)}
/>
) : null
}
{/* Input Label */}
{(label) ? <label
className="w-full text-gray-700 dark:text-gray-400 text-sm font-bold mb-2 mr-4"
onClick={(type === "checkbox") ? (e) => setValue(!value): null}
>{label}{(required) ? "*" : null}{(errorMsg) ?
<span className="text-red-600 font-normal italic"> ({errorMsg})</span> : null}</label> : null}
{/* Input Element */}
{
(type === "checkbox") ? (
<> {/* Checkbox Input Element has to go before the label */} </>
) :
((type === "time-cursor") || (!isMobile && type === "duration")) ? (
<TimeField
value={value}
onChange={(e) => setValue(e.target.value)}
input={<input type="text" className={"w-full shadow border rounded py-2 px-3 text-gray-700 dark:bg-gray-900 dark:text-gray-400 leading-tight focus:outline-none focus:shadow-outline" + (highlight ? " bg-blue-100 dark:bg-blue-950 ": "") + additionalClasses} />}
showSeconds={true}
/>
) :
(type === "radio") ? (
<>
{/* Radio Select Input Element */}
{selectList.map((item, index) => (
<label key={index} className="inline-flex items-center mr-4 text-gray-700 text-sm">
<input type="radio" className="form-radio text-gray-700"
name={name}
tabIndex={tabIndex}
disabled={disabled}
autoFocus={!isMobile && autoFocus}
checked={(item.value === value) ? true : null}
onChange={(e) => setValue(e.target.value)}
value={item.value}
/>
<span className="ml-2">{item.label}</span>
</label>
))}
</>
) :
(type === "select") ? (
<>
{/* Dropdown Input Element */}
<select
className={"w-full shadow border rounded py-2 px-3 text-gray-700 dark:bg-gray-800 dark:text-gray-400 leading-tight focus:outline-none focus:shadow-outline" + (highlight ? " bg-sky-50 dark:bg-sky-950 border border-blue-300 ": "") + additionalClasses}
name={name}
tabIndex={tabIndex}
required={required}
disabled={disabled}
autoFocus={!isMobile && autoFocus}
value={(value === null) ? '' : value}
onChange={(e) => setValue(e.target.value)}
>
{(placeholder !== false) && <option value="">{(placeholder) ? placeholder : "Select an option"}</option>}
{selectList.map((item, index) => (
<option key={index} value={item.value}>{item.label}</option>
))}
</select>
</>
) :
(
<>
{/* All Other Input Elements */}
<input
className={"w-full shadow border rounded py-2 px-3 text-gray-700 dark:text-gray-400 dark:placeholder-gray-600 leading-tight focus:outline-none focus:shadow-outline" + (highlight ? " bg-sky-50 dark:bg-sky-950 border border-blue-300 ": " dark:bg-gray-900 ") + additionalClasses}
name={name}
type={(type === "duration") ? "time" : type}
placeholder={placeholder}
tabIndex={tabIndex}
required={required}
readOnly={readOnly}
disabled={disabled}
autoFocus={!isMobile && autoFocus}
autoComplete={autoComplete}
pattern={pattern}
value={(value === null) ? '' : value}
list={name + "-suggestions"}
onChange={(e) => setValue(e.target.value)}
/>
</>
)
}
{/* Input User Suggestions */}
{
(suggestions.length > 0) ? (
<datalist id={name + "-suggestions"}>
{suggestions.map((item, index) => (
<option key={index} value={item}/>
))}
</datalist>
) : null
}
</fieldset>
</div>
)
}
export function SingleForm({fields, values, setValues, errors = {}}) {
const initialValues = Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, value.value])
);
return (
<div className="flex flex-wrap">
{Object.entries(fields).map(([fieldName, fieldKwargs]) => (
<FormInput key={fieldName} {...fieldKwargs} value={values[fieldName]} errorMsg={errors[fieldName]}
setValue={(value) => setValues({...values, [fieldName]: value})}/>
))}
</div>
)
}
export function MultiForm({fields, values, setValues, errors = {}}) {
//const [values, setValues] = useState([]);
const addRow = () => {
const initialValues = Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, value.value])
);
setValues([...values, {...initialValues}]);
};
const deleteRow = (index) => {
const updated = values.filter((_, i) => i !== index);
setValues(updated);
};
const handleChange = (index, field, value) => {
const updated = [...values];
updated[index][field] = value;
setValues(updated);
};
useEffect(() => {
if (values?.length === 0) {
//addRow();
}
})
return (
<div>
{values?.map((value_row, index) => (
<div key={index} className="relative border border-gray-300 rounded-lg p-4 mb-4">
<button className="absolute top-2 right-2 text-gray-500 hover:text-red-500"
onClick={() => deleteRow(index)}
>
<Trash2 className="h-5 w-5"/>
</button>
<div className="flex flex-wrap">
{Object.entries(fields).map(([fieldName, fieldKwargs]) => (
<FormInput key={fieldName} {...fieldKwargs} value={value_row[fieldName]}
errorMsg={errors?.[index]?.[fieldName]}
setValue={(value) => handleChange(index, fieldName, value)}/>
))}
</div>
</div>
))}
<div className="relative flex justify-center items-center">
<AddButton additionalClasses=" hover:text-green-800 " onClick={addRow} highlighted={false} larger={false}/>
</div>
</div>
)
}
function GenericButton({onClick, icon, label, highlighted, larger, IconObject, isLoading, additionalClasses}) {
const [dots, setDots] = useState("");
useEffect(() => {
if (!isLoading) {
setDots("");
return;
}
const interval = setInterval(() => {
setDots(prev => (prev.length < 3 ? prev + "." : ""));
}, 300);
return () => clearInterval(interval);
}, [isLoading]);
return (
<button
className={"flex items-center gap-2 transition hover:shadow " + (larger ? (label ? " px-5 py-2.5 font-semibold rounded-full " : " px-3 py-3 rounded-2xl ") : (label ? " px-4 py-2 rounded-full " : " p-2 rounded-2xl ")) + (isLoading ? " bg-white hover:bg-white shadow-none border border-gray-200 dark:bg-gray-800 dark:hover:bg-gray-800 " : (highlighted ? " bg-sky-800 text-white hover:bg-sky-700 " : " bg-gray-100 hover:bg-gray-300 dark:bg-gray-900 dark:hover:bg-gray-700 ")) + additionalClasses}
onClick={onClick}
disabled={isLoading}
>
{icon ? <IconObject className={(larger ? "h-4 w-4" : "h-3 w-3")}/> : null}
{label ? <span className="text-sm">{label}{isLoading ? dots : null}</span> : null}
</button>
)
}
export function SaveButton({
onClick,
icon = true,
label = "Save",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={Save} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function SaveAndAddButton({
onClick,
icon = true,
label = "Save and add another",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={CopyPlus} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function DeleteButton({
onClick,
icon = true,
label = "Delete",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={Trash2} isLoading={isLoading}
additionalClasses={" hover:text-red-800 " + additionalClasses}/>
}
export function AddButton({
onClick,
icon = true,
label = "Add",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={Plus} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function EditButton({
onClick,
icon = true,
label = "Edit",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={Pencil} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function ChangeOwnerButton({
onClick,
icon = true,
label = "Transfer Ownership",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={UserRoundPen} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function ChangeTeamButton({
onClick,
icon = true,
label = "Change Team",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={UsersRound} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function JoinButton({
onClick,
icon = true,
label = "Join",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={UserRoundPlus} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function LeaveButton({
onClick,
icon = true,
label = "Leave Competition",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={DoorOpen} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function ShareButton({
onClick,
icon = true,
label = "Invite Others",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={ExternalLink} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function ModifyGoalsButton({
onClick,
icon = true,
label = "Modify Goals",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={Flag} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function FairGoalsButton({
onClick,
icon = true,
label = "Goal Equalizer",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={Scale} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function SettingsButton({
onClick,
icon = true,
label = "Settings",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={Settings} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function RefreshButton({
onClick,
icon = true,
label = "Refresh",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={RefreshCw} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function SyncStravaButton({
onClick,
icon = true,
label = "Re-Sync with Strava",
highlighted = false,
larger = false,
isLoading = false,
additionalClasses = "",
}) {
return <GenericButton onClick={onClick} icon={icon} label={label} highlighted={highlighted} larger={larger}
IconObject={RefreshCw} isLoading={isLoading} additionalClasses={additionalClasses}/>
}
export function StravaButton({onClick, additionalClasses = "", label = "Strava"}) {
return (
<button
className={"flex items-center gap-1 text-orange-500 border border-strava bg-white dark:bg-gray-900 hover:bg-strava hover:text-white hover:shadow text-sm font-medium rounded-md transition p-0 " + additionalClasses}
onClick={onClick}>
<img src="/strava_logo.png" alt="Strava" className="w-7 h-7 rounded-tl-sm rounded-bl-sm"/>
<span className={"pl-1 pr-2 py-1 " + ((label.includes("Like") || label.includes("Follow")) ? "max-lg:hidden" : "")}>{label}</span>
{
(label.includes("Like") || label.includes("Follow")) ? (
<span className="max-sm:hidden lg:hidden pl-1 pr-2 py-1">
{
(label.includes("Like")) ? (
<ThumbsUp className="h-4 w-4"/>
) : (
<UserRoundPlus className="h-4 w-4"/>
)
}
</span>
) : null
}
</button>
)
}

View file

@ -0,0 +1,168 @@
import React, {useEffect, useState} from "react";
import {
useAddCompetitionMutation,
useDeleteCompetitionMutation,
useUpdateCompetitionMutation
} from "../utils/reducers/competitionsSlice";
import {useNavigate} from "react-router-dom";
import {ChangeOwnerButton, DeleteButton, Modal, SaveButton, SingleForm} from "./basicComponents";
const fields = {
"name": {
"type": "text",
"required": true,
"read_only": false,
"label": "Competition Name",
"width": "max-sm:w-full w-1/2",
"autoFocus": true,
},
"start_date": {
"type": "date",
"required": true,
"read_only": false,
"label": "Start Date",
"width": "max-sm:w-1/2 w-1/4",
},
"end_date": {
"type": "date",
"required": true,
"read_only": false,
"label": "End Date",
"width": "max-sm:w-1/2 w-1/4",
},
"has_teams": {
"type": "checkbox",
"required": false,
"read_only": false,
"label": "Users can compete in teams",
},
"organizer_assigns_teams": {
"type": "checkbox",
"required": false,
"read_only": false,
"label": "Only organizer can assign teams",
},
}
export default function CompetitionForm({competition, setModalState, setShowTransferCompetitionModal}) {
const navigate = useNavigate();
const [values, setValues] = useState({});
const [fieldErrors, setFieldErrors] = useState({});
const [formError, setFormError] = useState('');
const [updateEntry, {
data: updateData,
error: updateError,
isLoading: updateIsLoading,
isSuccess: updateIsSuccess
}] = useUpdateCompetitionMutation();
const [createEntry, {
data: createData,
error: createError,
isLoading: createIsLoading,
isSuccess: createIsSuccess
}] = useAddCompetitionMutation();
const [deleteEntry, {
error: deleteError,
isLoading: deleteIsLoading,
isSuccess: deleteIsSuccess
}] = useDeleteCompetitionMutation();
// Overall form error message
useEffect(() => {
if (updateError !== undefined) {
setFormError('Update Error (' + updateError?.status?.toLocaleString() + ' ' + updateError?.originalStatus?.toLocaleString() + '): ' + updateError?.message);
} else if (createError !== undefined) {
setFormError('Create Error (' + createError?.status?.toLocaleString() + ' ' + createError?.originalStatus?.toLocaleString() + '): ' + createError?.message);
} else if (deleteError !== undefined) {
setFormError('Delete Error (' + deleteError?.status?.toLocaleString() + ' ' + deleteError?.originalStatus?.toLocaleString() + '): ' + deleteError?.message);
}
}, [updateError, createError, deleteError])
// load current form values
useEffect(() => {
if (competition !== undefined) {
setValues(competition);
}
}, [])
// conditionally show/hide organizer_assigns_teams
const finalFields = {...fields};
if (!values.has_teams) {
delete finalFields.organizer_assigns_teams;
}
// form action button left
async function handleDiscard() {
if (competition !== undefined) {
// delete competition
try {
const confirmation = window.confirm('You are deleting this competition. This is irreversible. Are you sure?');
if (confirmation) {
const result = await deleteEntry(values.id).unwrap();
console.log('Delete Competition success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
navigate('/dashboard/');
}
} catch (err) {
console.error('Delete Competition failed', err);
}
} else {
// discard competition
setValues({});
setModalState(false);
document.body.classList.remove('body-no-scroll');
}
}
// form action button right
async function handleSubmit() {
if (competition !== undefined) {
// update competition
try {
const result = await updateEntry(values).unwrap();
console.log('Update Competition success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
window.alert('Saved. Changes might take up to 10 minutes to reflect on the competition page for all users.');
} catch (err) {
console.error('Update Competition failed', err);
setFieldErrors(err.data);
}
} else {
// create competition
try {
const result = await createEntry(values).unwrap();
console.log('Create Competition success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
navigate(`/competition/${result.id}`);
} catch (err) {
console.error('Create Competition failed', err);
setFieldErrors(err.data);
}
}
}
return (
<Modal title="Competition" landscape={true} setShowModal={setModalState} isLoading={updateIsLoading || createIsLoading || deleteIsLoading}>
<SingleForm fields={finalFields} values={values} setValues={setValues} errors={fieldErrors}/>
<div className="text-center text-red-500 text-xs italic">{formError}</div>
<div className="relative flex justify-between items-center">
<DeleteButton onClick={handleDiscard} label={(competition !== undefined) ? "Delete" : "Discard"} highlighted={false} larger={true} />
{(competition !== undefined) && <ChangeOwnerButton onClick={() => {setModalState(false); setShowTransferCompetitionModal(true);}} label={"Transfer Ownership"} highlighted={false} larger={true} />}
<SaveButton onClick={handleSubmit} label={(competition !== undefined) ? "Update" : "Create"} highlighted={true} larger={true} />
</div>
</Modal>
)
}

View file

@ -0,0 +1,224 @@
// slightly modified from https://www.npmjs.com/package/react-simple-timefield
import React from 'react';
const DEFAULT_COLON = ':';
const DEFAULT_VALUE_SHORT = `00${DEFAULT_COLON}00`;
const DEFAULT_VALUE_FULL = `00${DEFAULT_COLON}00${DEFAULT_COLON}00`;
export function isNumber(value) {
const number = Number(value);
return !isNaN(number) && String(value) === String(number);
}
export function formatTimeItem(value) {
return `${value || ''}00`.substr(0, 2);
}
export function validateTimeAndCursor(
showSeconds = false,
value = '',
defaultValue = '',
colon = DEFAULT_COLON,
cursorPosition = 0
) {
const [oldH, oldM, oldS] = defaultValue.split(colon);
let newCursorPosition = Number(cursorPosition);
let [newH, newM, newS] = String(value).split(colon);
newH = formatTimeItem(newH);
// allow any hour input
// if (Number(newH[0]) > 2) {
// newH = oldH;
// newCursorPosition -= 1;
// } else if (Number(newH[0]) === 2) {
// if (Number(oldH[0]) === 2 && Number(newH[1]) > 3) {
// newH = `2${oldH[1]}`;
// newCursorPosition -= 2;
// } else if (Number(newH[1]) > 3) {
// newH = '23';
// }
// }
newM = formatTimeItem(newM);
if (Number(newM[0]) > 5) {
newM = oldM;
newCursorPosition -= 1;
}
if (showSeconds) {
newS = formatTimeItem(newS);
if (Number(newS[0]) > 5) {
newS = oldS;
newCursorPosition -= 1;
}
}
const validatedValue = showSeconds
? `${newH}${colon}${newM}${colon}${newS}`
: `${newH}${colon}${newM}`;
return [validatedValue, newCursorPosition];
}
export default class TimeField extends React.Component {
static defaultProps = {
showSeconds: false,
input: null,
style: {},
colon: DEFAULT_COLON
};
constructor(props) {
super(props);
const _showSeconds = Boolean(props.showSeconds);
const _defaultValue = _showSeconds ? DEFAULT_VALUE_FULL : DEFAULT_VALUE_SHORT;
const _colon = props.colon && props.colon.length === 1 ? props.colon : DEFAULT_COLON;
const [validatedTime] = validateTimeAndCursor(
_showSeconds,
this.props.value,
_defaultValue,
_colon
);
this.state = {
value: validatedTime,
_colon,
_showSeconds,
_defaultValue,
_maxLength: _defaultValue.length
};
this.onInputChange = this.onInputChange.bind(this);
}
componentDidUpdate(prevProps) {
if (this.props.value !== prevProps.value) {
const [validatedTime] = validateTimeAndCursor(
this.state._showSeconds,
this.props.value,
this.state._defaultValue,
this.state._colon
);
this.setState({value: validatedTime});
}
}
onInputChange(event, callback) {
const oldValue = this.state.value;
const inputEl = event.target;
const inputValue = inputEl.value;
const position = inputEl.selectionEnd || 0;
const isTyped = inputValue.length > oldValue.length;
const cursorCharacter = inputValue[position - 1];
const addedCharacter = isTyped ? cursorCharacter : null;
const removedCharacter = isTyped ? null : oldValue[position];
const replacedSingleCharacter =
inputValue.length === oldValue.length ? oldValue[position - 1] : null;
const colon = this.state._colon;
let newValue = oldValue;
let newPosition = position;
if (addedCharacter !== null) {
if (position > this.state._maxLength) {
newPosition = this.state._maxLength;
} else if ((position === 3 || position === 6) && addedCharacter === colon) {
newValue = `${inputValue.substr(0, position - 1)}${colon}${inputValue.substr(
position + 1
)}`;
} else if ((position === 3 || position === 6) && isNumber(addedCharacter)) {
newValue = `${inputValue.substr(0, position - 1)}${colon}${addedCharacter}${inputValue.substr(
position + 2
)}`;
newPosition = position + 1;
} else if (isNumber(addedCharacter)) {
newValue =
inputValue.substr(0, position - 1) +
addedCharacter +
inputValue.substr(position + 1);
if (position === 2 || position === 5) {
newPosition = position + 1;
}
} else {
newPosition = position - 1;
}
} else if (replacedSingleCharacter !== null) {
if (isNumber(cursorCharacter)) {
if (position - 1 === 2 || position - 1 === 5) {
newValue = `${inputValue.substr(0, position - 1)}${colon}${inputValue.substr(
position
)}`;
} else {
newValue = inputValue;
}
} else {
newValue = oldValue;
newPosition = position - 1;
}
} else if (
typeof cursorCharacter !== 'undefined' &&
cursorCharacter !== colon &&
!isNumber(cursorCharacter)
) {
newValue = oldValue;
newPosition = position - 1;
} else if (removedCharacter !== null) {
if ((position === 2 || position === 5) && removedCharacter === colon) {
newValue = `${inputValue.substr(0, position - 1)}0${colon}${inputValue.substr(
position
)}`;
newPosition = position - 1;
} else {
newValue = `${inputValue.substr(0, position)}0${inputValue.substr(position)}`;
}
}
const [validatedTime, validatedCursorPosition] = validateTimeAndCursor(
this.state._showSeconds,
newValue,
oldValue,
colon,
newPosition
);
this.setState({value: validatedTime}, () => {
inputEl.selectionStart = validatedCursorPosition;
inputEl.selectionEnd = validatedCursorPosition;
callback(event, validatedTime);
});
event.persist();
}
render() {
const {value} = this.state;
const {onChange, style, showSeconds, input, inputRef, colon, ...props} =
this.props;
const onChangeHandler = (event) =>
this.onInputChange(event, (e, v) => onChange && onChange(e, v));
if (input) {
return React.cloneElement(input, {
...props,
value,
style,
onChange: onChangeHandler
});
}
return (
<input
type="text"
{...props}
ref={inputRef}
value={value}
onChange={onChangeHandler}
style={{width: showSeconds ? 54 : 35, ...style}}
/>
);
}
}

View file

@ -0,0 +1,116 @@
import '../utils/Modals.css';
import React, {useEffect, useState} from "react";
import {Link} from "react-router-dom";
import WorkoutForm from "./workoutForm";
function validateForm({actionData, fields}) {
let valid = true;
let errors = {};
let error_lst = [];
Object.entries(fields).forEach(([key, prop]) => {
if (prop.required && !actionData[key]) {
valid = false;
errors[key] = 'This field is required.';
error_lst.push(key + ': ' + errors[key]);
}
});
const error_msg = error_lst.join(', ');
return {valid, errors, error_msg};
}
export default function DynamicForm({setModalState, fields, setAction, actionData, setActionData, secondaryAction, errors, setErrors, children}) {
const handleChange = (e, field) => {
setActionData({
...actionData,
[field]: e.target.value
});
//console.log(field, e.target.value);
};
const handleSubmit = (e, button) => {
e.preventDefault();
const {valid, errors, error_msg} = validateForm({actionData, fields});
setErrors(error_msg);
if (valid) {
if (button === 'SAVE') {
setAction('SAVE');
} else if (button === 'ADD') {
setAction('ADD');
} else if ('DELETE') {
setAction('DELETE');
}
}
};
return (
<div className="w-full">
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-xl px-8 pt-6 pb-8 mb-4 ">
<div className="flex flex-wrap">
{Object.entries(fields).map(([key, prop], i) => (
<div className={"px-4 py-2 modal_" + key} key={key}>
<label className="w-full block text-gray-700 text-sm font-bold mb-2">
{prop.label}{(prop.required) ? ("*") : (null)}
</label>
{(prop.type === "choice") ? (
<select className="w-full shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
name={key}
value={actionData[key] || ''}
tabIndex={i}
required={prop.required}
autoFocus={i===0}
onChange={(e) => handleChange(e, key)}>
{prop.choices.map((choice, i) => (
<option key={i} value={choice.value}>{choice.display_name}</option>
))}
</select>
) : (
<input
type={prop.type}
name={key}
id={key}
className={((prop.type === "checkbox") ? "h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500" : "w-full shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline") + ((prop.read_only) ? " bg-gray-100 text-gray-500" : "")}
required={prop.required}
readOnly={prop.read_only}
placeholder={null}
autoFocus={i===0}
tabIndex={i}
onChange={(e) => handleChange(e, key)}
value={actionData[key] || ''}
checked={prop.type === "checkbox" && actionData[key]}
/>
)}
</div>
))}
</div>
<div className="flex flex-row-reverse items-center justify-between mt-3.5">
<button type="submit"
onClick={(e) => handleSubmit(e, 'SAVE')}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 mx-4 px-5 rounded focus:outline-none focus:shadow-outline">
Save
</button>
{(secondaryAction === 'ADD') ? (
<button onClick={(e) => handleSubmit(e, 'ADD')}
className="text-blue-500 hover:bg-blue-500 hover:text-white bg-white font-bold border-1 border-blue-500 py-2 mx-4 px-5 rounded focus:outline-none focus:shadow-outline">
Save and add another
</button>
) : (secondaryAction === 'DELETE') ? (
<button onClick={(e) => handleSubmit(e, 'DELETE')}
className="text-blue-500 hover:bg-blue-500 hover:text-white bg-white font-bold border-1 border-blue-500 py-2 mx-4 px-5 rounded focus:outline-none focus:shadow-outline">
Delete
</button>
) : null}
</div>
<p id="errors" className="text-red-500 text-xs italic mt-5">{errors}</p>
{children}
</form>
</div>
);
}

View file

@ -0,0 +1,22 @@
import DynamicForm from "./dynamicForms";
import {X} from 'lucide-react';
export default function DynamicModal({setModalState, children}) {
const handleClick = (event) => {
// Ensure the click is on the background, not on a child element
if (event.target === event.currentTarget) {
setModalState(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-white bg-opacity-80 dark:bg-black dark:bg-opacity-80 flex items-center justify-center" onClick={handleClick}>
<div className="w-11/12 md:w-3/4 lg:w-2/3 xl:w-1/2">
<X className="relative top-0 right-0 m-4 cursor-pointer" onClick={() => setModalState(false)}/>
{children}
</div>
</div>
)
}

View file

@ -0,0 +1,173 @@
import {useUpdateUserMutation} from "../utils/reducers/usersSlice";
import React, {useEffect, useState} from "react";
import {Modal, SaveButton, SingleForm} from "./basicComponents";
const fields = {
"gender": {
"type": "select",
"required": false,
"read_only": false,
"label": "Gender",
"width": "max-sm:w-full w-2/3",
"selectList": [
{
"value": "M",
"label": "Male"
},
{
"value": "F",
"label": "Female"
}
]
},
"age": {
"type": "number",
"required": false,
"read_only": false,
"disabled": false,
"label": "Age (years)",
"placeholder": 35,
"width": "max-sm:w-full w-2/3",
},
"height": {
"type": "number",
"required": false,
"read_only": false,
"disabled": false,
"label": "Height (cm)",
"placeholder": 180,
"width": "max-sm:w-full w-2/3",
},
"weight": {
"type": "number",
"required": false,
"read_only": false,
"disabled": false,
"label": "Weight (kg)",
"placeholder": 75,
"width": "max-sm:w-full w-2/3",
},
"bmr_kcal": {
"type": "number",
"required": false,
"read_only": true,
"disabled": true,
"label": "Daily BMR (kcal)",
"width": "max-sm:w-full w-2/3",
},
"scaling_kcal": {
"type": "number",
"required": false,
"read_only": true,
"disabled": true,
"highlight": true,
"label": "Equalizing Effort Factor (% for kcal)",
"width": "max-sm:w-full w-2/3",
},
"step_length": {
"type": "number",
"required": false,
"read_only": true,
"disabled": true,
"label": "Running Step Length (m)",
"width": "max-sm:w-full w-2/3",
},
"scaling_distance": {
"type": "number",
"required": false,
"read_only": true,
"disabled": true,
"highlight": true,
"label": "Equalizing Distance Factor (% for km)",
"width": "max-sm:w-full w-2/3",
},
}
export default function GoalEqualizerForm({user, setModalState}) {
const [values, setValues] = useState({});
const [fieldErrors, setFieldErrors] = useState({});
const [formError, setFormError] = useState('');
const [updateEntry, {
data: updateData,
error: updateError,
isLoading: updateIsLoading,
isSuccess: updateIsSuccess
}] = useUpdateUserMutation();
// Overall form error message
useEffect(() => {
if (updateError !== undefined) {
setFormError('Update Error (' + updateError?.status?.toLocaleString() + ' ' + updateError?.originalStatus?.toLocaleString() + '): ' + updateError?.message);
}
}, [updateError])
// load current form values
useEffect(() => {
if (user !== undefined) {
setValues({...values, gender: (user.gender === null || user.gender === '') ? 'M' : user.gender});
}
}, [])
// calculate factors
useEffect(() => {
const gender = (values.gender === undefined || values.gender === '') ? 'M' : values.gender;
const age = (values.age === undefined || values.age === '') ? 35 : values.age;
const height = (values.height === undefined || values.height === '') ? 180 : values.height;
const weight = (values.weight === undefined || values.weight === '') ? 75 : values.weight;
let bmr;
let step_length;
if (gender === 'F') {
bmr = (10 * weight + 6.25 * height - 5 * age - 161) * 1.2;
step_length = 0.60 * height / 100;
} else {
bmr = (10 * weight + 6.25 * height - 5 * age + 5) * 1.2;
step_length = 0.65 * height / 100;
}
setValues({...values, bmr_kcal: Math.round(bmr), scaling_kcal: Math.round( bmr / 2046 * 100 * 100) / 100, step_length: Math.round(step_length * 100) / 100, scaling_distance: Math.round(step_length / 1.17 * 100 * 10) / 10});
}, [values.gender, values.age, values.height, values.weight])
// form action button right
async function handleSubmit() {
// update personal scaling factors
try {
const result = await updateEntry({id: 'me', scaling_kcal: Math.round(values.scaling_kcal * 100) / 10000, scaling_distance: Math.round(values.scaling_distance * 100) / 10000}).unwrap();
console.log('Update Personal Scaling Factors success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
window.alert('Saved. The re-calculation of your competition points might take a few minutes.');
} catch (err) {
console.error('Update Personal Scaling Factors failed', err);
setFieldErrors(err.data);
}
}
return (
<Modal title="Equalize Goals" landscape={false} setShowModal={setModalState} isLoading={updateIsLoading}>
<p className="text-gray-700 dark:text-gray-300">Everyone has a unique <b>Basal Metabolic Rate (BMR)</b>, dependent on factors like age, gender, height, and weight. To ensure a fair competition, the calculator below estimates your personal equalizing factors, which will be used to scale the competition goals. Your inputs <u>stay on your device</u> only the final two equalizing percent factors (blue fields) are saved to equalize your points.</p>
<p className="text-gray-500 text-sm italic">You still don't trust it? Check the <a className="text-blue-500 hover:underline" target="_blank" href="https://github.com/vanalmsick/workout_challenge/blob/main/src-frontend/src/forms/equalizerForm.js#L149">public source code yourself (here)</a>!</p>
<SingleForm fields={fields} values={values} setValues={setValues} errors={fieldErrors}/>
<div className="text-center text-red-500 text-xs italic">{formError}</div>
<div className="text-center text-gray-700 dark:text-gray-300 text-xs italic"><b>Current Effort Factor:</b> {Math.round(user.scaling_kcal * 100)}% / <b>Current Distance Factor:</b> {Math.round(user.scaling_distance * 100)}%</div>
<div className="relative flex justify-between items-center">
<SaveButton onClick={handleSubmit} label="Update" highlighted={true} larger={true} />
</div>
</Modal>
)
}

View file

@ -0,0 +1,87 @@
import React, {useEffect, useState} from "react";
import {useJoinCompetitionMutation} from "../utils/reducers/joinSlice";
import {useNavigate} from "react-router-dom";
import {JoinButton, Modal, SingleForm} from "./basicComponents";
import {competitionsApi} from "../utils/reducers/competitionsSlice";
import {usersApi} from "../utils/reducers/usersSlice";
import {useDispatch} from "react-redux";
const fields = {
"join_code": {
"type": "text",
"required": true,
"read_only": false,
"label": "Competition Join Code",
"width": "max-sm:w-full w-2/3",
"autoFocus": true,
},
}
export default function JoinCompetitionForm({setModalState, join_code= null}) {
const navigate = useNavigate();
const dispatch = useDispatch();
const [values, setValues] = useState({});
const [fieldErrors, setFieldErrors] = useState({});
const [formError, setFormError] = useState('');
const [updateEntry, {
data: updateData,
error: updateError,
isLoading: updateIsLoading,
isSuccess: updateIsSuccess
}] = useJoinCompetitionMutation();
// Overall form error message
useEffect(() => {
if (updateError !== undefined) {
setFormError('Error (' + updateError?.status?.toLocaleString() + ' ' + updateError?.originalStatus?.toLocaleString() + '): ' + updateError?.data?.message);
}
}, [updateError])
// load current form values
useEffect(() => {
if (join_code !== undefined && join_code !== null) {
setValues({join_code: join_code});
joinCompetition(join_code, false);
}
}, [])
// form action button right
async function handleSubmit() {
joinCompetition(values.join_code, join_code === null);
}
// function to join competition
async function joinCompetition(joinCode, redirect = true) {
try {
const result = await updateEntry(joinCode).unwrap();
console.log('Join Competition success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
if (redirect) {
navigate('/competition/' + result.competition);
}
dispatch(competitionsApi.util.invalidateTags(['Competition']));
dispatch(usersApi.util.invalidateTags(['User']));
} catch (err) {
console.error('Join Competition failed', err);
setFieldErrors(err.data);
setFormError(err.message);
}
}
return (
<Modal title="Join Competition" landscape={false} setShowModal={setModalState} isLoading={updateIsLoading}>
<SingleForm fields={fields} values={values} setValues={setValues} errors={fieldErrors}/>
<div className="text-center text-red-500 text-xs italic">{formError}</div>
<div className="relative flex justify-end items-end">
<JoinButton onClick={handleSubmit} label="Join Competition" highlighted={true} larger={true} />
</div>
</Modal>
)
}

View file

@ -0,0 +1,146 @@
import React, {useEffect, useState} from "react";
import {useJoinTeamMutation} from "../utils/reducers/joinSlice";
import {useAddTeamMutation, useDeleteTeamMutation, useGetTeamsQuery} from "../utils/reducers/teamsSlice";
import {PlusIcon, UsersRound, Trash2} from "lucide-react";
import {BeatLoader} from "react-spinners";
import {FormInput, Modal} from "./basicComponents";
export default function JoinTeamForm({competition, setModalState, user, isOwner}) {
const {
data: teams,
refetch: teamsRefetch,
error: teamsError,
isLoading: teamsLoading,
isSuccess: teamsIsSuccess,
isFetching: teamsIsFetching,
} = useGetTeamsQuery();
const [createTeam, {
data: createData,
error: createError,
isLoading: createIsLoading,
isSuccess: createIsSuccess
}] = useAddTeamMutation();
const [deleteTeam, {
error: deleteError,
isLoading: deleteIsLoading,
isSuccess: deleteIsSuccess
}] = useDeleteTeamMutation();
const [joinTeam, {
data: joinData,
error: joinError,
isLoading: joinIsLoading,
isSuccess: joinIsSuccess
}] = useJoinTeamMutation();
const filteredTeams = teams?.filter(item => item.competition === competition?.id);
const myTeamId = filteredTeams?.find(t => t.user.includes(user?.id))?.id;
const usedIds = new Set(filteredTeams?.flatMap(team => team?.user));
const usersWithoutTeams = competition?.user_info?.filter(u => !usedIds.has(u.id));
async function handleTeamChange(kwargs) {
await joinTeam(kwargs);
console.log('Changed teams:', kwargs);
teamsRefetch();
};
async function handleTeamCreate(e) {
e.preventDefault();
const result = await createTeam({competition: competition.id, name: e.target.teamName.value});
console.log('Created new team:', result);
e.target.reset();
if (!isOwner) {
handleTeamChange({team: result.data.id});
}
};
return (
<Modal landscape={false} setShowModal={setModalState} isLoading={false}>
{filteredTeams?.map((team, teamidx) => (
<div key={teamidx} className="p-4">
<div className="flex justify-between items-center mb-2 border-b border-t py-2">
<h2 className="text-lg font-bold mr-auto">{team.name}</h2>
{((!team.my) ? (
<>
{((isOwner && team.user.length === 0) ? (
<button onClick={() => deleteTeam(team.id)}
className="flex items-center gap-2 px-4 py-2 h-9 mr-2 bg-gray-100 dark:bg-gray-900 rounded-full hover:bg-gray-300 dark:hover:bg-gray-700 transition">
<Trash2 className="w-3 h-3"/>
</button>
) : null)}
{(!isOwner) && (
<button onClick={() => handleTeamChange({team: team.id})} className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-900 rounded-full hover:bg-gray-300 dark:hover:bg-gray-700 transition">
<UsersRound className="w-3 h-3"/>
<span className="text-sm break-keep">Join Team</span>
</button>
)}
</>
)
: <div className="text-sm pr-4">My Team</div>
)}
</div>
<ul className="list-disc list-inside text-gray-700 dark:text-gray-300">
{team?.user_info?.map((user, useridx) => (
<li key={useridx} className="py-0.5">
{user.username}
{(isOwner) && <FormInput width="inline-block w-1/3 text-sm" type="select" placeholder={false} selectList={filteredTeams?.map(team => ({value: team.id, label: team.name}))} setValue={(team_id) => handleTeamChange({user: user.id, team: team_id})} value={team.id}/>}
</li>
))}
</ul>
</div>
))}
{(usersWithoutTeams?.length > 0) && (
<div className="p-4">
<div className="flex justify-between items-center mb-2 border-b border-t py-2"><h2
className="text-lg font-bold mr-auto">Participants without a team</h2>
<div className="text-sm pr-4">Add them to your team!</div>
</div>
<ul className="list-disc list-inside text-gray-700 dark:text-gray-300">
{usersWithoutTeams?.map((userI, useridx) => (
<li key={useridx} className="py-0.5">
{userI.username}
{(isOwner) ? (
<FormInput width="inline-block w-1/3 text-sm" type="select" selectList={filteredTeams?.map(team => ({value: team.id, label: team.name}))} setValue={(team_id) => handleTeamChange({user: userI.id, team: team_id})} />
) : (userI.id === user?.id || myTeamId === undefined) ? null : (
<button onClick={() => handleTeamChange({user: userI.id, team: myTeamId})} className="inline-flex items-center gap-2 px-4 ml-4 py-2 bg-gray-100 dark:bg-gray-900 rounded-full hover:bg-gray-300 dark:hover:bg-gray-700 transition">
<UsersRound className="w-3 h-3"/>
<span className="text-sm break-keep">Add to my team</span>
</button>
)
}
</li>
))}
</ul>
</div>
)}
<div className="p-4">
<h2 className="text-lg font-bold mb-2 border-b border-t py-2 mb-3">Create New Team</h2>
<form onSubmit={handleTeamCreate} className="flex items-center space-x-2">
<input
type="text"
name="teamName"
placeholder="Enter team name"
required={true}
disabled={teamsLoading}
className="flex-1 border rounded px-3 py-2 focus:outline-none focus:ring-2 dark:bg-gray-900 focus:ring-blue-400"
/>
{(teamsLoading || teamsIsFetching) ? (
<BeatLoader color="rgb(209 213 219)"/>
) : (
<button type="submit" disabled={teamsLoading} className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-900 rounded-full hover:bg-gray-300 dark:hover:bg-gray-700 transition">
<PlusIcon className="w-3 h-3"/>
<span className="text-sm break-keep">
{(isOwner) ? 'Create Team': 'Create & Join'}
</span>
</button>
)}
</form>
</div>
</Modal>
)
}

View file

@ -0,0 +1,90 @@
import React, {useEffect, useState} from "react";
import {useUpdateUserMutation} from "../utils/reducers/usersSlice";
import {Modal, SaveButton, SingleForm} from "./basicComponents";
const fields = {
"goal_active_days": {
"type": "number",
"required": false,
"read_only": false,
"label": "Active Days (num)",
"placeholder": "Leave empty for no goal",
"width": "max-sm:w-full w-1/3",
},
"goal_workout_minutes": {
"type": "number",
"required": false,
"read_only": false,
"label": "Workout Minutes (min)",
"placeholder": "Leave empty for no goal",
"width": "max-sm:w-full w-1/3",
},
"goal_distance": {
"type": "number",
"required": false,
"read_only": false,
"label": "Distance (km)",
"placeholder": "Leave empty for no goal",
"width": "max-sm:w-full w-1/3",
},
}
export default function PersonalGoalsForm({user, setModalState}) {
const [values, setValues] = useState({});
const [fieldErrors, setFieldErrors] = useState({});
const [formError, setFormError] = useState('');
const [updateEntry, {
data: updateData,
error: updateError,
isLoading: updateIsLoading,
isSuccess: updateIsSuccess
}] = useUpdateUserMutation();
// Overall form error message
useEffect(() => {
if (updateError !== undefined) {
setFormError('Update Error (' + updateError?.status?.toLocaleString() + ' ' + updateError?.originalStatus?.toLocaleString() + '): ' + updateError?.message);
}
}, [updateError])
// load current form values
useEffect(() => {
if (user !== undefined) {
setValues({goal_active_days: user.goal_active_days, goal_workout_minutes: user.goal_workout_minutes, goal_distance: user.goal_distance});
}
}, [])
// form action button right
async function handleSubmit() {
// update personal goals
try {
const cleanedValues = Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value === "" ? null : value])); // replace empty strings with null
const result = await updateEntry({id: 'me', ...cleanedValues}).unwrap();
console.log('Update Personal Goals success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
} catch (err) {
console.error('Update Personal Goals failed', err);
setFieldErrors(err.data);
}
}
return (
<Modal title="Personal Goals" landscape={true} setShowModal={setModalState} isLoading={updateIsLoading}>
<SingleForm fields={fields} values={values} setValues={setValues} errors={fieldErrors}/>
<div className="text-center text-red-500 text-xs italic">{formError}</div>
<div className="text-xs text-center italic text-gray-500"><b>Note:</b> These personal goals are only visible to you and they do not impact any competition you are taking part of.</div>
<div className="relative flex justify-end items-end">
<SaveButton onClick={handleSubmit} label="Update" highlighted={true} larger={true} />
</div>
</Modal>
)
}

View file

@ -0,0 +1,222 @@
import {useDeleteUserMutation, usersApi, useUpdateUserMutation} from "../utils/reducers/usersSlice";
import React, {useEffect, useState} from "react";
import {DeleteButton, Modal, SaveButton, SingleForm, StravaButton} from "./basicComponents";
import {useNavigate} from "react-router-dom";
import {useUnlinkStravaMutation} from "../utils/reducers/linkSlice";
import {useDispatch} from "react-redux";
const fields = {
"email": {
"type": "email",
"required": true,
"read_only": false,
"label": "Email",
"width": "max-sm:w-full w-1/2",
},
"username": {
"type": "text",
"required": true,
"read_only": false,
"label": "Public Username",
"width": "max-sm:w-full w-1/2",
},
"first_name": {
"type": "text",
"required": true,
"read_only": false,
"label": "First Name",
"width": "max-sm:w-full w-1/3",
},
"last_name": {
"type": "text",
"required": true,
"read_only": false,
"label": "Last Name",
"width": "max-sm:w-full w-1/3",
},
"gender": {
"type": "select",
"required": true,
"read_only": false,
"label": "Gender",
"width": "max-sm:w-full w-1/3",
"selectList": [
{
"value": "M",
"label": "Male"
},
{
"value": "F",
"label": "Female"
},
{
"value": "O",
"label": "Other"
},
{
"value": "",
"label": "Unknown"
}
]
},
"strava_athlete_id": {
"type": "number",
"required": false,
"read_only": true,
"disabled": true,
"label": "Strava Athlete ID",
"width": "max-sm:w-full w-1/2",
},
"strava_last_synced_at": {
"type": "datetime-local",
"required": false,
"read_only": true,
"disabled": true,
"label": "Last Strava Sync",
"width": "max-sm:w-full w-1/2",
},
"strava_allow_follow": {
"type": "checkbox",
"required": false,
"read_only": false,
"label": "Allow others to follow me on Strava",
},
"email_mid_week": {
"type": "checkbox",
"required": false,
"read_only": false,
"label": "Send me mid-week streak email",
},
}
export default function SettingsForm({user, setModalState, setLinkStrava}) {
const navigate = useNavigate();
const dispatch = useDispatch();
const [values, setValues] = useState({});
const [fieldErrors, setFieldErrors] = useState({});
const [formError, setFormError] = useState('');
const [updateEntry, {
data: updateData,
error: updateError,
isLoading: updateIsLoading,
isSuccess: updateIsSuccess
}] = useUpdateUserMutation();
const [deleteEntry, {
error: deleteError,
isLoading: deleteIsLoading,
isSuccess: deleteIsSuccess
}] = useDeleteUserMutation();
const [unlinkStrava, {
data: unlinkData,
error: unlinkError,
isLoading: unlinkIsLoading,
isSuccess: unlinkIsSuccess
}] = useUnlinkStravaMutation();
// Overall form error message
useEffect(() => {
if (updateError !== undefined) {
setFormError('Update Error (' + updateError?.status?.toLocaleString() + ' ' + updateError?.originalStatus?.toLocaleString() + '): ' + updateError?.message);
} else if (deleteError !== undefined) {
setFormError('Delete Error (' + deleteError?.status?.toLocaleString() + ' ' + deleteError?.originalStatus?.toLocaleString() + '): ' + deleteError?.message);
} else if (unlinkError !== undefined) {
setFormError('Strava Unlink Error (' + unlinkError?.status?.toLocaleString() + ' ' + unlinkError?.originalStatus?.toLocaleString() + '): ' + unlinkError?.message);
}
}, [updateError, deleteError, unlinkError])
// load current form values
useEffect(() => {
if (user !== undefined) {
setValues(user);
}
}, [])
// form action button left
async function handleDelete() {
// delete account
try {
const confirmation = window.confirm('You are deleting your account. All workouts and the competitions you organised will be deleted. This is irreversible. Are you sure?');
if (confirmation) {
const result = await deleteEntry(user.id).unwrap();
console.log('Delete User success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
navigate('/logout');
}
} catch (err) {
console.error('Delete User failed', err);
}
}
// form action button right
async function handleSubmit() {
// update personal details
try {
const result = await updateEntry({
id: 'me',
...values,
email: values.email.toLowerCase()
}).unwrap();
console.log('Update Personal Settings success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
window.alert('Saved. Strava and username changes might take up to 10 minutes to reflect on the competition page for all users.');
} catch (err) {
console.error('Update Personal Settings failed', err);
setFieldErrors(err.data);
}
}
// form action button Strava linkage
async function handleStravaLinkage({linked}) {
if (linked) {
// currently linked - unlink
try {
const result = await unlinkStrava().unwrap();
console.log('Unlink Strava success:', result);
setModalState(false);
dispatch(usersApi.util.invalidateTags(['User']));
document.body.classList.remove('body-no-scroll');
} catch (err) {
console.error('Unlink Strava failed', err);
setFieldErrors(err.data);
}
} else {
// currently unlinked - link
setModalState(false);
document.body.classList.remove('body-no-scroll');
setLinkStrava(true);
}
}
return (
<Modal title="Personal Setting" landscape={true} setShowModal={setModalState} isLoading={updateIsLoading || deleteIsLoading || unlinkIsLoading}>
<SingleForm fields={fields} values={values} setValues={setValues} errors={fieldErrors}/>
<div className="text-center text-red-500 text-xs italic">{formError}</div>
<div className="px-4">
<StravaButton
label={(user.strava_athlete_id ? "Unlink" : "Link") + " Strava Account"}
onClick={() => handleStravaLinkage({linked: user.strava_athlete_id !== null && user.strava_athlete_id !== undefined && user.strava_athlete_id !== ''})}
/>
</div>
<div className="relative flex justify-between items-center">
<DeleteButton onClick={handleDelete} label="Delete Account" highlighted={false} larger={true} />
<SaveButton onClick={handleSubmit} label="Update" highlighted={true} larger={true} />
</div>
</Modal>
)
}

View file

@ -0,0 +1,26 @@
import React, {useEffect, useState} from "react";
import {Modal} from "./basicComponents";
export default function CompetitionInviteModal({competition, setModalState}) {
const hostUrl = window.location.origin;
const url = hostUrl + '?join=' + competition.join_code;
return (
<Modal title="Invite Friends" landscape={false} setShowModal={setModalState} isLoading={false}>
<div className="text-gray-800 dark:text-gray-200 leading-relaxed">
<p><b>Link to join:</b> {url}</p>
<p><b>Join code:</b> {competition.join_code}</p>
</div>
<div className="relative bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300 rounded-xl p-4">
<button
onClick={() => navigator.clipboard.writeText(document.getElementById('code-block').innerText)}
className="absolute top-2 right-2 text-sm bg-gray-200 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-700 px-2 py-1 rounded">
Copy
</button>
<pre id="code-block" className="overflow-x-auto whitespace-pre-wrap text-sm">
<code>Hi, I am taking part in the "{competition.name}" competition.<br/>It would be even more fun if you'd join, too.<br/>Here is the link to join: {url}</code>
</pre>
</div>
</Modal>
)
}

View file

@ -0,0 +1,71 @@
import React, {useEffect, useState} from "react";
import {Modal} from "./basicComponents";
import {ChevronDown, ChevronUp, ExternalLink} from "lucide-react";
const SENTRY_DSN = window.RUNTIME_CONFIG?.REACT_APP_SENTRY_DSN;
const AccordionItem = ({title, content, link}) => {
const [isOpen, setIsOpen] = useState(false);
if (link) {
return (
<div className="border-b">
<a
href={link}
target="_blank"
className="w-full flex justify-between items-center py-3 text-left font-medium text-gray-800 dark:text-gray-200 hover:underline"
>
<span>{title}</span>
<ExternalLink className="w-4 h-4"/>
</a>
</div>
);
}
return (
<div className="border-b">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex justify-between items-center py-3 text-left font-medium text-gray-800 dark:text-gray-200 hover:underline"
>
<span>{title}</span>
{isOpen ? <ChevronUp className="w-4 h-4"/> : <ChevronDown className="w-4 h-4"/>}
</button>
{isOpen && (
<div className="pb-4 text-gray-600 dark:text-gray-400">
<div dangerouslySetInnerHTML={{ __html: content }} />
</div>
)}
</div>
);
};
function AccordionMenu() {
const items = [
{title: "How are points calculated?", content: "Each 1% towards an Activity Goal earns you 1 point. E.g. if the workout goal is 100 minutes, working out 50 minutes earns you 50 points. However, there can be upper and lower limits above/below which you don't earn any points (activities that were capped/floored are indicated with an *asterix). Hover with the mouse above a goal to see its limits or above the workout's asterix for more details."},
{title: "My workouts don't show up in the Strava App!", content: "This ia a Strava app automatic import error (e.g. due to no internet connection when finishing a workout). Go to the Strava App -> You -> Settings -> Manage an app or device -> e.g. for an Apple Watch click on the 'Service: Health' App -> click 'Add' next to the workout that wasn't automatically imported."},
{title: "See Source Code", link: "https://github.com/vanalmsick/workout_challenge"},
{title: "Suggest a Feature", link: "https://github.com/vanalmsick/workout_challenge/discussions/categories/ideas"},
{title: "Report a Bug", link: "https://github.com/vanalmsick/workout_challenge/issues"},
{title: "Help developing", link: "https://github.com/vanalmsick/workout_challenge#do-you-want-to-help--contribute"},
{title: "What data is saved and how is it handled?", content: "No data is sold/shared to/with anyone. If you delete your account all data is unrecoverably deleted. There might be backups containing your user data for a few more weeks until the retention period is exceeded. " + ((SENTRY_DSN !== undefined && SENTRY_DSN !== null && SENTRY_DSN !== '') ? "<a class='text-blue-500 hover:underline' target='_blank' href='https://sentry.io/'>Sentry.io</a> error and performance monitoring is enabled. In line with EU GDPR, if errors occur these are reported anonymized (no 'Personal-Identifiable-Information') to the administrator on top of some basic statics like loading speed of approx. 25% of sessions to detect malfunctions. Please see Sentry.io's data privacy policy. " : "") + "No user statistics or other analytics are collected by the website itself. The data you see when using the app is the data saved (e.g. personal profile, workout data, competition signups, points)."},
{title: "Credits", content: "This is an Open Source project under the SSPL v1.0 license on <a class='text-blue-500 hover:underline' target='_blank' href='https://github.com/vanalmsick/workout_challenge'>github.com/vanalmsick/workout_challenge</a>. See <a class='text-blue-500 hover:underline' target='_blank' href='/credits.txt'>here for stock image credits</a>."},
];
return (
<div className="max-w-xl mx-auto divide-y divide-gray-300">
{items.map((item, index) => (
<AccordionItem key={index} {...item} />
))}
</div>
);
}
export default function SupportModal({setModalState}) {
return (
<Modal title="Information & Help" landscape={false} setShowModal={setModalState} isLoading={false}>
<AccordionMenu/>
</Modal>
)
}

View file

@ -0,0 +1,93 @@
import React, {useEffect, useState} from "react";
import {
competitionsApi,
useUpdateCompetitionMutation
} from "../utils/reducers/competitionsSlice";
import {Modal, SaveButton, SingleForm} from "./basicComponents";
import {useGetUsersQuery} from "../utils/reducers/usersSlice";
import {useDispatch} from "react-redux";
const fields = {
"owner": {
"type": "select",
"required": true,
"read_only": false,
"placeholder": false,
"label": "New Competition Owner",
"width": "max-sm:w-full w-2/3",
"autoFocus": true,
},
}
export default function TransferOwnershipForm({competition, setModalState}) {
const dispatch = useDispatch();
const [values, setValues] = useState({});
const [fieldErrors, setFieldErrors] = useState({});
const [formError, setFormError] = useState('');
const [finalFields, setFinalFields] = useState(fields);
const {
data: users,
error: userError,
isLoading: userLoading,
isFetching: userFetching,
} = useGetUsersQuery();
const [updateEntry, {
data: updateData,
error: updateError,
isLoading: updateIsLoading,
isSuccess: updateIsSuccess
}] = useUpdateCompetitionMutation();
// Overall form error message
useEffect(() => {
if (updateError !== undefined) {
setFormError('Update Error (' + updateError?.status?.toLocaleString() + ' ' + updateError?.originalStatus?.toLocaleString() + '): ' + updateError?.message);
}
}, [updateError])
// load current form values
useEffect(() => {
if (competition !== undefined) {
setValues(competition);
}
}, [])
// load current form choices
useEffect(() => {
if (users !== undefined) {
setFinalFields({owner: {...fields['owner'], selectList: users.map(user => ({value: user.id, label: user.username}))}});
}
}, [users])
// form action button right
async function handleSubmit() {
// update competition
try {
const result = await updateEntry({id: competition.id, owner: parseInt(values['owner'])}).unwrap();
console.log('Update Competition Ownership success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
dispatch(competitionsApi.util.invalidateTags(['Competition']));
} catch (err) {
console.error('Update Competition Ownership failed', err);
setFieldErrors(err.data);
}
}
return (
<Modal title="Transfer Competition Ownership" landscape={true} setShowModal={setModalState} isLoading={updateIsLoading}>
<SingleForm fields={finalFields} values={values} setValues={setValues} errors={fieldErrors}/>
<div className="text-center text-red-500 text-xs italic">{formError}</div>
<div className="relative flex justify-end items-center">
<SaveButton onClick={handleSubmit} label={"Update"} highlighted={true} larger={true} />
</div>
</Modal>
)
}

View file

@ -0,0 +1,340 @@
import {
useAddWorkoutMutation,
useDeleteWorkoutMutation, useGetWorkoutByIdQuery,
useUpdateWorkoutMutation
} from "../utils/reducers/workoutsSlice";
import React, {useEffect, useState} from "react";
import {AddButton, DeleteButton, Modal, SaveButton, SingleForm} from "./basicComponents";
import {statsApi} from "../utils/reducers/statsSlice";
import {feedApi} from "../utils/reducers/feedSlice";
import {useDispatch} from "react-redux";
export const workoutTypes = {
"Steps": {"label": "Total Daily Steps", "label_short": "Steps"},
"Badminton": {"label": "Badminton", "label_short": "Badminton"},
"Ride": {"label": "Biking/Cycling", "label_short": "Cycling"},
"EBikeRide": {"label": "Biking/Cycling (E-Bike)", "label_short": "Cycling"},
"GravelRide": {"label": "Biking/Cycling (Gravel)", "label_short": "Cycling"},
"Handcycle": {"label": "Biking/Cycling (Handcycle)", "label_short": "Cycling"},
"Velomobile": {"label": "Biking/Cycling (Velomobile)", "label_short": "Cycling"},
"VirtualRide": {"label": "Biking/Cycling (Virtual)", "label_short": "Cycling"},
"Canoeing": {"label": "Canoe", "label_short": "Canoe"},
"Crossfit": {"label": "Crossfit", "label_short": "Crossfit"},
"Elliptical": {"label": "Elliptical", "label_short": "Elliptical"},
"Golf": {"label": "Golf", "label_short": "Golf"},
"HighIntensityIntervalTraining": {"label": "High Intensity Interval Training (HIIT)", "label_short": "HIIT"},
"Hike": {"label": "Hike", "label_short": "Hike"},
"IceSkate": {"label": "Ice Skate", "label_short": "Ice Skate"},
"InlineSkate": {"label": "Inline Skate", "label_short": "Inline Skate"},
"Kayaking": {"label": "Kayak", "label_short": "Kayak"},
"Kitesurf": {"label": "Kitesurf", "label_short": "Kitesurf"},
"MountainBikeRide": {"label": "Mountain-Biking/Cycling", "label_short": "Mountain-Biking"},
"EMountainBikeRide": {"label": "Mountain-Biking/Cycling (E-Bike)", "label_short": "Mountain-Biking"},
"Pickleball": {"label": "Pickleball", "label_short": "Pickleball"},
"Pilates": {"label": "Pilates", "label_short": "Pilates"},
"Racquetball": {"label": "Racquetball", "label_short": "Racquetball"},
"RockClimbing": {"label": "Rock Climbing", "label_short": "Climbing"},
"Rowing": {"label": "Rowing (Outdoor)", "label_short": "Rowing"},
"VirtualRow": {"label": "Rowing (Virtual)", "label_short": "Rowing"},
"Run": {"label": "Run", "label_short": "Run"},
"TrailRun": {"label": "Run (Trail)", "label_short": "Run"},
"VirtualRun": {"label": "Run (Treadmill / Vitual)", "label_short": "Run"},
"Sail": {"label": "Sail", "label_short": "Sail"},
"Skateboard": {"label": "Skateboard", "label_short": "Skateboard"},
"AlpineSki": {"label": "Ski (Alpine)", "label_short": "Ski"},
"BackcountrySki": {"label": "Ski (Backcountry)", "label_short": "Ski"},
"NordicSki": {"label": "Ski (Nordic)", "label_short": "Ski"},
"RollerSki": {"label": "Ski (Roller/Inliner)", "label_short": "Ski"},
"Snowboard": {"label": "Snowboard", "label_short": "Snowboard"},
"Soccer": {"label": "Soccer / Football", "label_short": "Soccer"},
"Squash": {"label": "Squash", "label_short": "Squash"},
"StairStepper": {"label": "Stair Stepper", "label_short": "Stepper"},
"StandUpPaddling": {"label": "Stand-up Paddling", "label_short": "SUP"},
"Surfing": {"label": "Surf", "label_short": "Surf"},
"Swim": {"label": "Swim", "label_short": "Swim"},
"TableTennis": {"label": "Table Tennis", "label_short": "Table Tennis"},
"Tennis": {"label": "Tennis", "label_short": "Tennis"},
"Walk": {"label": "Walk", "label_short": "Walk"},
"Snowshoe": {"label": "Walk (Snowshoe)", "label_short": "Walk"},
"WeightTraining": {"label": "Weight Training", "label_short": "Weights"},
"Wheelchair": {"label": "Wheelchair", "label_short": "Wheelchair"},
"Windsurf": {"label": "Windsurf", "label_short": "Windsurf"},
"Yoga": {"label": "Yoga", "label_short": "Yoga"},
"Workout": {"label": "Other Workout", "label_short": "Other"}
}
const fields = {
"sport_type": {
"type": "select",
"required": true,
"read_only": false,
"label": "Sport type",
"value": "Run",
"width": "max-sm:w-full w-1/2",
"autoFocus": true,
"selectList": Object.entries(workoutTypes).map(([key, value]) => ({
value: key,
...value
}))
},
"start_datetime": {
"type": "datetime-local",
"required": true,
"read_only": false,
"label": "Start Date & Time",
"width": "max-sm:w-full w-1/2",
},
"duration": {
"type": "duration",
"required": true,
"read_only": false,
"label": "Duration (hh:mm[:ss])",
"width": "max-sm:w-full w-1/2",
},
"intensity_category": {
"type": "select",
"required": false,
"read_only": false,
"label": "Intensity",
"value": 2,
"width": "max-sm:w-full w-1/2",
"selectList": [
{
"value": 1,
"label": "Easy (Could do another one later today)"
},
{
"value": 2,
"label": "Moderate (Done for today but tomorrow is a new day)"
},
{
"value": 3,
"label": "Hard (Will definitely feel this workout tomorrow)"
},
{
"value": 4,
"label": "All Out (Can't do another one tomorrow)"
}
]
},
"kcal": {
"type": "decimal",
"required": false,
"read_only": false,
"label": "Kcal",
"max_digits": 7,
"decimal_places": 2,
"width": "max-sm:w-full w-1/2",
"placeholder": "Estimated if left empty"
},
"distance": {
"type": "decimal",
"required": false,
"read_only": false,
"label": "Distance (km)",
"max_digits": 7,
"decimal_places": 2,
"width": "max-sm:w-full w-1/2",
"placeholder": "Only if applicable"
}
}
const steps_fields = {
"sport_type": fields["sport_type"],
"start_date": {
"type": "date",
"required": true,
"read_only": false,
"label": "Date",
"width": "max-sm:w-full w-1/2",
},
"steps": {
"type": "number",
"required": true,
"read_only": false,
"label": "Total Daily Steps",
"width": "max-sm:w-full w-1/2",
"placeholder": "Number of total steps"
},
}
function formatTime(totalSeconds) {
const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
const seconds = String(Math.round(totalSeconds % 60)).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
}
export default function WorkoutForm({id, setModalState, scaling_distance}) {
const dispatch = useDispatch();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayString = yesterday.toISOString();
const defaultValues = {
"sport_type": "Steps",
"start_date": yesterdayString.substring(0, 10),
"start_datetime": yesterdayString.substring(0, 10) + "T20:00",
"duration": "00:30:10",
"intensity_category": 1,
};
const [values, setValues] = useState({...defaultValues});
const [fieldErrors, setFieldErrors] = useState({});
const [formError, setFormError] = useState('');
const {
data: initWorkout,
error: initError,
isLoading: iniLoading
} = useGetWorkoutByIdQuery(id, {skip: id === true});
const [updateEntry, {
data: updateData,
error: updateError,
isLoading: updateIsLoading,
isSuccess: updateIsSuccess
}] = useUpdateWorkoutMutation();
const [createEntry, {
data: createData,
error: createError,
isLoading: createIsLoading,
isSuccess: createIsSuccess
}] = useAddWorkoutMutation();
const [deleteEntry, {
error: deleteError,
isLoading: deleteIsLoading,
isSuccess: deleteIsSuccess
}] = useDeleteWorkoutMutation();
// Overall form error message
useEffect(() => {
if (initError !== undefined) {
setFormError('Get Error (' + initError?.status?.toLocaleString() + ' ' + initError?.originalStatus?.toLocaleString() + '): ' + initError?.message);
} else if (updateError !== undefined) {
setFormError('Update Error (' + updateError?.status?.toLocaleString() + ' ' + updateError?.originalStatus?.toLocaleString() + '): ' + updateError?.message);
} else if (createError !== undefined) {
setFormError('Create Error (' + createError?.status?.toLocaleString() + ' ' + createError?.originalStatus?.toLocaleString() + '): ' + createError?.message);
} else if (deleteError !== undefined) {
setFormError('Delete Error (' + deleteError?.status?.toLocaleString() + ' ' + deleteError?.originalStatus?.toLocaleString() + '): ' + deleteError?.message);
}
}, [initError, updateError, createError, deleteError])
// load current form values
useEffect(() => {
if (initWorkout !== undefined) {
setValues({...initWorkout, start_date: initWorkout.start_datetime.substring(0, 10)});
}
}, [initWorkout])
// form action button left
async function handleDiscard() {
if (id !== true) {
// delete workout
try {
const result = await deleteEntry(values.id).unwrap();
console.log('Delete Workout success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
} catch (err) {
console.error('Delete Workout failed', err);
}
} else {
// save and add another
try {
let tmpValues = {...values};
if (tmpValues.sport_type === "Steps") {
tmpValues.start_datetime = tmpValues.start_date + "T23:59";
} else {
tmpValues.steps = null;
}
if (tmpValues.duration.length === 5) {
tmpValues.duration += ":00";
}
const result = await createEntry(tmpValues).unwrap();
console.log('Create Workout success:', result);
setValues({...defaultValues});
} catch (err) {
console.error('Create Workout failed', err);
setFieldErrors(err.data);
}
}
dispatch(statsApi.util.invalidateTags(['Stats']));
dispatch(feedApi.util.invalidateTags(['Feed']));
}
// form action button right
async function handleSubmit() {
let tmpValues = {...values};
if (tmpValues.sport_type === "Steps") {
tmpValues.start_datetime = tmpValues.start_date + "T23:59";
} else {
tmpValues.steps = null;
}
if (tmpValues.duration.length === 5) {
tmpValues.duration += ":00";
}
if (id !== true) {
// update workout
try {
const result = await updateEntry(tmpValues).unwrap();
console.log('Update Workout success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
} catch (err) {
console.error('Update Workout failed', err);
setFieldErrors(err.data);
}
} else {
// create workout
try {
const result = await createEntry(tmpValues).unwrap();
console.log('Create Workout success:', result);
setModalState(false);
document.body.classList.remove('body-no-scroll');
} catch (err) {
console.error('Create Workout failed', err);
setFieldErrors(err.data);
}
}
dispatch(statsApi.util.invalidateTags(['Stats']));
dispatch(feedApi.util.invalidateTags(['Feed']));
}
const [activeFields, setActiveFields] = useState(fields);
// if workout type is walk, add additional field "steps" for people to estimate time and distance
useEffect(() => {
if (values.sport_type === "Steps") {
setActiveFields(steps_fields);
} else {
setActiveFields(fields);
}
}, [values.sport_type])
return (
<Modal title="Workout" landscape={true} setShowModal={setModalState} isLoading={iniLoading || updateIsLoading || createIsLoading || deleteIsLoading}>
<SingleForm fields={activeFields} values={values} setValues={setValues} errors={fieldErrors}/>
<div className="text-center text-red-500 text-xs italic">{formError}</div>
{(id !== true && values.sport_type !== "Steps" && (values?.strava_id === null || values?.strava_id === '')) ? <div className="text-center text-orange-500 text-xs italic"><b>Note:</b> Empty the kcal field to re-calculate after changes to the workout type, duration, or intensity.</div> : null}
<div className="relative flex justify-between items-center">
{
(id !== true) ? (
<DeleteButton onClick={handleDiscard} label="Delete" highlighted={false} larger={true}/>
) : (
<AddButton additionalClasses=" hover:text-green-800 " onClick={handleDiscard} label="Save and add another" highlighted={false} larger={true}/>
)
}
<SaveButton onClick={handleSubmit} label={(id !== true) ? "Update" : "Save"} highlighted={true} larger={true}/>
</div>
</Modal>
)
}

View file

@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
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;
}
.body-no-scroll {
overflow: hidden;
position: fixed;
width: 100%;
}
.bg-strava, .hover\:bg-strava:hover {
background-color: #fc4c02;
}
.text-strava {
color: #fc4c02;
}
.border-strava {
border-color: #fc4c02;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

59
src-frontend/src/index.js Normal file
View file

@ -0,0 +1,59 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import {Provider} from 'react-redux';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import store from './utils/store';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import * as Sentry from "@sentry/react";
import {BrowserTracing} from "@sentry/tracing";
import {ReactQueryDevtools} from '@tanstack/react-query-devtools';
// Optional Sentry monitoring
const SENTRY_DSN = window.RUNTIME_CONFIG?.REACT_APP_SENTRY_DSN;
if (SENTRY_DSN !== undefined && SENTRY_DSN !== null && SENTRY_DSN !== '') {
console.log('Sentry error monitoring is enabled.');
Sentry.init({
dsn: SENTRY_DSN,
environment: "frontend",
integrations: [
Sentry.browserTracingIntegration(),
Sentry.browserProfilingIntegration(),
Sentry.replayIntegration({
// Additional SDK configuration goes in here, for example:
maskAllText: true,
blockAllMedia: true,
}),
Sentry.feedbackIntegration({
// Additional SDK configuration goes in here, for example:
colorScheme: "system",
}),
],
sendDefaultPii: false,
tracesSampleRate: 0.25,
replaysSessionSampleRate: 0.05,
replaysOnErrorSampleRate: 1.0,
});
}
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<App/>
</Provider>
</QueryClientProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View file

@ -0,0 +1,968 @@
import {useNavigate, useNavigationType, useParams} from 'react-router-dom';
import React, {useEffect, useState} from "react";
import NavMenu from "../utils/navMenu";
import {competitionsApi, useGetCompetitionByIdQuery} from "../utils/reducers/competitionsSlice";
import {
ArrowDownToLine,
ArrowUpToLine,
UsersRound,
} from "lucide-react";
import {Bar, Line} from 'react-chartjs-2';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
LineElement,
PointElement,
Filler,
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import {statsApi, useGetStatsByIdQuery} from "../utils/reducers/statsSlice";
import {useGetUserByIdQuery} from "../utils/reducers/usersSlice";
import _ from "lodash";
import {SectionLoader} from "../utils/loaders";
import {useGetFeedByIdQuery} from "../utils/reducers/feedSlice";
import CompetitionForm from "../forms/competitionForm";
import JoinTeamForm from "../forms/joinTeamForm";
import ActivityGoalsForm from "../forms/activityGoalsForm";
import {
ChangeTeamButton, LeaveButton,
ModifyGoalsButton,
RefreshButton,
SettingsButton, ShareButton,
StravaButton
} from "../forms/basicComponents";
import {BoxSection, ErrorBoxSection, PageWrapper, useDarkMode} from "../utils/miscellaneous";
import {workoutTypes} from "../forms/workoutForm";
import CompetitionInviteModal from "../forms/shareModal";
import {useDispatch} from "react-redux";
import {useLeaveCompetitionMutation} from "../utils/reducers/joinSlice";
import TransferOwnershipForm from "../forms/transferOwnershipForm";
import {teamsApi} from "../utils/reducers/teamsSlice";
ChartJS.register(LineElement, PointElement, CategoryScale, LinearScale, Filler, Tooltip, Legend, BarElement, ChartDataLabels);
function CompetitionHead({competition, feed, isOwner}) {
const [showEditCompetitionModal, setShowEditCompetitionModal] = useState(false);
const [showInviteCompetitionModal, setShowInviteCompetitionModal] = useState(false);
const [showTransferCompetitionModal, setShowTransferCompetitionModal] = useState(false);
const [countTotal, setCountTotal] = useState(0);
const [countGroups, setCountGroups] = useState({});
useEffect(() => {
const filteredFeed = _.filter(_.values(feed), item => item.workout !== null && item.workout__sport_type !== 'Steps');
const totalCount = filteredFeed.length;
setCountTotal(totalCount);
const grouped = _.mapValues(_.groupBy(_.values(filteredFeed), 'workout__sport_type'), group => group.length);
const sorted = _.fromPairs(_.orderBy(_.toPairs(grouped), ([, value]) => value, 'desc'));
const limited = Object.fromEntries(Object.entries(sorted).slice(0, 4));
setCountGroups(limited);
}, [feed]);
const navigate = useNavigate();
const dispatch = useDispatch();
const [leaveCompetition, {
error: leaveError,
isLoading: leaveIsLoading,
isSuccess: leaveIsSuccess
}] = useLeaveCompetitionMutation();
async function triggerLeaveCompetition() {
const confirmation = window.confirm('Are you sure you want to leave the competition? Your earned points for yourself and your team will be unrecoverably deleted and you loose your spot on the leaderboard.');
if (confirmation) {
try {
const data = await leaveCompetition(competition.id).unwrap();
console.log('Successfully left competition:', data);
dispatch(competitionsApi.util.invalidateTags([{ type: 'Competition', id: competition.id }]));
navigate('/dashboard');
} catch (err) {
console.error('Error leaving completion:', err);
window.alert('Error leaving competition. Please try again.');
}
}
}
return (
<BoxSection additionalClasses="mb-4">
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center sm:gap-6 sm:py-4">
<div className="space-y-1 pl-0 sm:pl-6 pb-3 sm:pb-0 text-center sm:text-left">
<p className="text-2xl font-semibold">{competition.name}</p>
<p className="font-small text-gray-500">{competition.start_date_fmt} - {competition.end_date_fmt}</p>
</div>
<div className="flex p-3">
<div className="flex items-center px-4">
<div className="text-5xl font-semibold pe-2">{countTotal}</div>
<div className="uppercase text-xs tracking-wide text-gray-500">Total Competition<br/>Workouts
</div>
</div>
{Object.entries(countGroups).map(([label, count], index) => (
<div key={"stat" + index} className="flex flex-col px-4 text-center hidden lg:block">
<div className="text-3xl font-semibold text-left">{count}</div>
<div className="uppercase text-xs tracking-wide text-gray-500">{workoutTypes[label].label_short}</div>
</div>
))}
</div>
<div className="p-2 sm:p-4">
{
(isOwner) ? <SettingsButton additionalClasses="mx-auto sm:ml-auto sm:mr-0 my-1" onClick={() => setShowEditCompetitionModal(competition.id)}/> :
<LeaveButton additionalClasses="mx-auto sm:ml-auto sm:mr-0 my-1" onClick={() => triggerLeaveCompetition()} isLoading={leaveIsLoading} />
}
<ShareButton additionalClasses="mx-auto sm:ml-auto sm:mr-0 my-1" onClick={() => setShowInviteCompetitionModal(true)} />
</div>
</div>
{(showEditCompetitionModal) && <CompetitionForm setModalState={setShowEditCompetitionModal} setShowTransferCompetitionModal={setShowTransferCompetitionModal} competition={competition}/>}
{(showInviteCompetitionModal) && <CompetitionInviteModal setModalState={setShowInviteCompetitionModal} competition={competition}/>}
{(showTransferCompetitionModal) && <TransferOwnershipForm setModalState={setShowTransferCompetitionModal} competition={competition}/>}
</BoxSection>
)
}
function ChartThisWeek({history}) {
const isDarkMode = useDarkMode();
const data = {
labels: history['Legend'],
datasets: [
{
label: 'Me',
data: history['Me'],
backgroundColor: 'rgb(99, 135, 188)',
borderRadius: 5,
clip: false,
},
{
label: 'My Team',
data: history['My Team'],
backgroundColor: 'rgb(75, 192, 192)',
borderRadius: 5,
clip: false,
hidden: true,
},
{
label: 'Average',
data: history['Average'],
backgroundColor: 'rgb(156, 163, 175)',
borderRadius: 5,
clip: false,
hidden: true,
},
],
};
const options = {
scales: {
x: {
display: true,
ticks: {display: true},
grid: {display: false},
},
y: {display: false},
},
layout: {
padding: {
top: 30, // Adjust as needed
},
},
plugins: {
legend: {
display: true,
position: 'bottom', // move legend to bottom
labels: {
boxWidth: 12,
padding: 20,
},
},
tooltip: false,
datalabels: {
anchor: 'end',
align: 'end',
color: isDarkMode ? '#fff' : '#000',
font: {weight: 'bold'},
},
},
};
return (
<Bar data={data} options={options} plugins={[ChartDataLabels]}/>
)
}
function ChartHistory({history}) {
const data = {
labels: history['Legend'],
datasets: [
{
label: 'Me',
data: history['Me'],
borderColor: 'rgb(99, 135, 188)',
tension: 0.3, // slight smoothing
fill: false,
spanGaps: true,
},
{
label: 'My Team',
data: history['My Team'],
borderColor: 'rgb(75, 192, 192)',
tension: 0.3, // slight smoothing
fill: false,
spanGaps: true,
},
{
label: 'Average',
data: history['Average'],
borderColor: 'rgb(156, 163, 175)',
tension: 0.3, // slight smoothing
fill: false,
spanGaps: true,
},
],
};
const options = {
scales: {
x: {display: false},
y: {
display: true,
position: 'right',
grid: {display: false},
ticks: {
padding: 10,
color: '#666',
},
},
},
layout: {
padding: {
left: 20,
right: 5,
top: 10,
},
},
plugins: {
legend: {
display: true,
position: 'bottom', // move legend to bottom
labels: {
boxWidth: 12,
padding: 20,
},
},
datalabels: {display: false},
},
};
return (
<Line data={data} options={options}/>
)
}
function AwardsBox({competition}) {
return (
<div className="bg-white rounded-lg shadow-md p-6 mr-2 mb-4">
<div className="flex flex-row">
<span
className="mx-4 flex text-gray-500 uppercase font-bold items-center justify-center"><p>My Awards</p></span>
<div className="h-full w-px bg-gray-300"></div>
<div className="relative h-full w-[80px]">
<img src="/gold_medal.png" alt="Background" className="w-full h-full object-cover"/>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-gray-500 text-sm text-center">Your Text Here</h2>
</div>
</div>
<div className="relative h-full w-[80px]">
<img src="/bronce_medal.png" alt="Background" className="w-full h-full object-cover"/>
<div className="absolute inset-0 flex items-center justify-center">
<h2 className="text-gray-300 text-sm text-center">Your Text Here</h2>
</div>
</div>
<span className="ml-auto mx-4 flex text-light-blue font-semibold items-center justify-center"><p>View All</p></span>
</div>
</div>
)
}
function TeamLeaderboardBox({stats, competition, user, teamId, isOwner}) {
const dispatch = useDispatch();
const [showChangeTeamModal, setShowChangeTeamModal] = useState(false);
function setShowChangeTeamModalMiddleware(state) {
if (state === false) {
dispatch(statsApi.util.invalidateTags([{ type: 'Stats', id: competition.id }]));
}
setShowChangeTeamModal(state);
}
return (
<>
<BoxSection>
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center border-b-2 pb-3">
<span className="mx-4 text-gray-500 uppercase font-bold">Team Leaderboard</span>
{(!competition.organizer_assigns_teams || isOwner) && (
<div className="p-0 mt-2.5 sm:mt-0">
<ChangeTeamButton onClick={() => setShowChangeTeamModalMiddleware(true)} larger={false}/>
</div>
)}
</div>
<table className="min-w-full my-2">
<tbody>
{(stats.leaderboard.team.length === 0) ? (
<tr className="hover:bg-gray-100 dark:hover:bg-gray-900 border-b">
<td className="py-2 px-4 pb-3 text-center text-gray-500">Create the first team!
</td>
</tr>
) : (
stats.leaderboard.team.map((team, index) => (
<tr key={"leader_team" + index}
className={((parseInt(teamId) === team.workout__user__my_teams__id) ? "bg-sky-50 dark:bg-sky-950 " : "") + "hover:bg-gray-100 dark:hover:bg-gray-900 border-b"}>
<td className="py-2 px-2">
<span className="font-semibold">#{team.rank}</span>
</td>
<td className="py-2 px-2">
<span className="font-semibold">{team.name}</span>
</td>
<td className="py-2 px-2 group relative inline-block cursor-pointer">
<span className="text-sm text-gray-500 flex items-center gap-1">
<UsersRound className="h-3.5 w-3.5"/> {team.members.length}
</span>
<div
className="absolute top-full left-1/2 -translate-x-1/2 mt-2 w-48 p-2 bg-white dark:bg-gray-800 border dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none group-hover:pointer-events-auto z-10">
<p className="text-sm font-semibold">Members:</p>
<ul className="text-sm list-disc pl-5">
{team.members.map((user, usr_index) => (
<li key={"leader_user" + usr_index}>{user.username} {Math.round(user.total_capped, 0).toLocaleString()}P</li>
))}
</ul>
</div>
</td>
<td className="py-2 px-2 text-right">
<span
className="font-semibold">{Math.round(team.total_capped, 0).toLocaleString()}P</span>
</td>
</tr>
))
)}
</tbody>
</table>
{(competition.organizer_assigns_teams) ? <div className="pt-1 w-full text-center text-sm text-gray-500 italic"><b>Note:</b> The organizer assigns teams!</div> : null}
</BoxSection>
{(showChangeTeamModal) && <JoinTeamForm setModalState={setShowChangeTeamModalMiddleware} competition={competition} user={user} isOwner={isOwner}/>}
</>
)
}
function IndividualLeaderboardBox({stats, userId}) {
return (
<BoxSection>
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center border-b-2 pb-3">
<span className="mx-4 text-gray-500 uppercase font-bold">Participant Leaderboard</span>
</div>
<table className="min-w-full my-2">
<tbody>
{(stats.leaderboard.individual.length === 0) ? (
<tr className="hover:bg-gray-100 dark:hover:bg-gray-900 border-b">
<td className="py-2 px-4 pb-3 text-center text-gray-500">Here participants will show up!
</td>
</tr>
) : (
stats.leaderboard.individual.map((person, index) => (
<tr key={"leader_user" + index} className={((userId === person.workout__user__id) ? "bg-sky-50 dark:bg-sky-950 " : "") + "hover:bg-gray-100 dark:hover:bg-gray-900 border-b"}>
<td className="py-2 px-2">
<span className="font-semibold">#{person.rank}</span>
</td>
<td className="py-2 px-2">
<span className="font-semibold">{person.username}</span>
</td>
<td className="py-2 px-2">
{(person.strava_allow_follow === true && person.strava_athlete_id) && (
<StravaButton label={"Follow"} onClick={() => {
window.open("https://www.strava.com/athletes/" + person.strava_athlete_id, "_blank")
}}/>
)}
</td>
<td className="py-2 px-2 text-right">
<span
className="font-semibold">{Math.round(person.total_capped, 0).toLocaleString()}P</span>
</td>
</tr>
))
)}
</tbody>
</table>
</BoxSection>
)
}
function FeedBox({feed, refreshCompetition, competitionIsRefreshing}) {
return (
<BoxSection>
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center border-b-2 pb-3">
<span className="mx-4 text-gray-500 uppercase font-bold">Activity Feed</span>
<div className="p-0 mt-2.5 sm:mt-0">
<RefreshButton onClick={() => refreshCompetition()}
label={"Refresh" + (competitionIsRefreshing ? "ing" : "") + " Competition"}
larger={false} isLoading={competitionIsRefreshing}/>
</div>
</div>
<table className="min-w-full my-2">
<tbody>
{(feed.length === 0) ? (
<tr className="hover:bg-gray-100 dark:hover:bg-gray-900 border-b">
<td className="py-2 px-4 pb-3 text-center text-gray-500">Here participants' activities will show
up!
</td>
</tr>
) : (feed.map((entry, index) => {
return (
<tr key={"feed" + index} className="hover:bg-gray-100 dark:hover:bg-gray-900 border-b">
<td className="py-2 px-4 text-sm md:text-base">
<span className="font-semibold">{entry.workout__start_datetime_fmt.date_readable}</span><br/>
<span className="text-sm hidden sm:block">{entry.workout__start_datetime_fmt.time_24h}</span>
</td>
<td className="py-2 px-4 block md:table-cell">
{/* Mobile view (stacked) */}
<div className="md:hidden">
<div className="font-medium">{entry.workout__user__username}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">{(entry.workout__sport_type === "Steps") ? entry.workout__steps?.toLocaleString() : Math.round(parseFloat(entry.workout__duration) / 60, 0).toLocaleString() + "min"}<span className="font-semibold"> {workoutTypes[entry.workout__sport_type].label_short}</span></div>
</div>
{/* Desktop view (normal) */}
<div className="hidden md:block">{entry.workout__user__username}</div>
</td>
<td className="py-2 px-4 hidden md:table-cell">{(entry.workout__sport_type === "Steps") ? entry.workout__steps?.toLocaleString() : Math.round(parseFloat(entry.workout__duration) / 60, 0).toLocaleString() + "min"}<span
className="font-semibold"> {workoutTypes[entry.workout__sport_type].label_short}</span>
</td>
<td className="py-2 px-0 sm:px-4">
{(entry.workout__user__strava_allow_follow && entry.workout__strava_id) ? (
<StravaButton label={"Like Activity"} additionalClasses={"hidden sm:flex"}
onClick={() => {
window.open("https://www.strava.com/activities/" + entry.workout__strava_id, "_blank")
}}/>
) : null}
</td>
<td className="py-2 px-4 group relative inline-block pt-5 cursor-pointer">
<span
className="">+{Math.round(entry.points_capped, 0).toLocaleString()}P{(entry.points_capped !== entry.points_raw) ?
<span className="text-gray-500">*</span> : null}</span>
<div
className="absolute top-full left-1/2 -translate-x-1/2 mt-2 w-48 p-2 bg-white border dark:bg-gray-800 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none group-hover:pointer-events-auto z-10">
<p className="text-sm font-semibold">Breakdown:</p>
<ul className="text-sm list-disc pl-5">
{entry.details.map((detail, detail_index) => (
<li key={"feed" + detail_index + "detail" + detail_index}>{detail.goal__name} +{Math.round(detail.points_capped, 0).toLocaleString()}P {(detail.points_raw !== detail.points_capped) ? (
<span
className="text-gray-500 italic"> (uncapped +{Math.round(detail.points_raw, 0).toLocaleString()}P)</span>) : null}</li>
))}
</ul>
</div>
</td>
</tr>
)
}
))}
</tbody>
</table>
</BoxSection>
)
}
function ActivityGoalsBox({user, stats, feed, competitionId, userId, isOwner}) {
const [showModifyGoals, setShowModifyGoals] = useState(false);
const goals = stats.competition.goals;
const [finalGoals, setFinalGoals] = useState(goals);
useEffect(() => {
const now = new Date();
// daily goal - get today 00:00 o'clock
const today = new Date();
today.setHours(0, 0, 0, 0);
const epochTimeToday = Math.floor(today.getTime() / 1000); // In seconds
// week goal - get Monday epoch time
const day = now.getDay(); // 0 (Sun) to 6 (Sat)
const diffMonday = (day + 6) % 7; // Days since last Monday
const lastMonday = new Date(now);
lastMonday.setDate(now.getDate() - diffMonday);
lastMonday.setHours(0, 0, 0, 0);
const epochTimeMonday = Math.floor(lastMonday.getTime() / 1000); // In seconds
// month goal - get first of month
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
firstOfMonth.setHours(0, 0, 0, 0);
const epochTimeMonth = Math.floor(firstOfMonth.getTime() / 1000); // In seconds
const filteredCompetition = _.filter(feed || [], item => item.workout__user === userId);
const filteredDay = _.filter(filteredCompetition, item => item.workout__start_datetime_fmt.epoch >= epochTimeToday);
const filteredWeek = _.filter(filteredCompetition, item => item.workout__start_datetime_fmt.epoch >= epochTimeMonday);
const filteredMonth = _.filter(filteredCompetition, item => item.workout__start_datetime_fmt.epoch >= epochTimeMonth);
let tmpGoals = [];
for (const goal of goals) {
let filteredList = [];
if (goal.period === 'day') {
filteredList = filteredDay;
} else if (goal.period === 'week') {
filteredList = filteredWeek;
} else if (goal.period === 'month') {
filteredList = filteredMonth;
} else if (goal.period === 'competition') {
filteredList = filteredCompetition;
}
let scaling = 1;
if (['kcal', 'kj'].includes(goal.metric)) {
scaling = user?.scaling_kcal ?? 1;
} else if (['km'].includes(goal.metric)) {
scaling = user?.scaling_distance ?? 1;
}
tmpGoals.push({
...goal,
goal: goal.goal * scaling,
min_per_workout: goal.min_per_workout !== null ? goal.min_per_workout * scaling : null,
max_per_workout: goal.max_per_workout !== null ? goal.max_per_workout * scaling : null,
min_per_day: goal.min_per_day !== null ? goal.min_per_day * scaling : null,
max_per_day: goal.max_per_day !== null ? goal.max_per_day * scaling : null,
min_per_week: goal.min_per_week !== null ? goal.min_per_week * scaling : null,
max_per_week: goal.max_per_week !== null ? goal.max_per_week * scaling : null,
points_capped: _.sumBy(_.flatMap(filteredList, 'details').filter(item => item.goal === goal.id), 'points_capped'),
points_raw: _.sumBy(_.flatMap(filteredList, 'details').filter(item => item.goal === goal.id), 'points_raw'),
})
}
setFinalGoals(tmpGoals);
}, [stats, feed, userId]);
return (
<BoxSection>
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center border-b-2 pb-3">
<span className="mx-4 text-gray-500 uppercase font-bold">Activity Goals</span>
{isOwner && (
<div className="p-0 mt-2.5 sm:mt-0">
<ModifyGoalsButton onClick={() => setShowModifyGoals(true)}/>
</div>
)}
</div>
<div className="flex flex-col">
{finalGoals.map((goal, index) => (
<div key={"activitygoal" + index}
className="bg-gray-100 dark:bg-gray-900 rounded-lg p-5 m-4 mb-1 group relative">
<div className="flex flex-col px-4 text-left">
<div className="flex flex-row justify-between items-center text-gray-500 mb-0.5">
<div className="tracking-wide"><span className="font-semibold">{goal.name}</span>
</div>
<div>{Math.round(goal.goal).toLocaleString()} {goal.metric} <span
className="text-xs">/ {goal.period}</span>
</div>
</div>
<div className="flex flex-row pt-2.5 pb-1 justify-between items-center">
<div className="w-full bg-gray-200 dark:bg-gray-800 rounded-full h-4"
style={{width: "75%"}}>
<div className="h-4 rounded-full"
style={{
width: Math.min(goal.points_capped, 100) + "%",
backgroundColor: "rgb(99, 135, 188)"
}}></div>
</div>
<div className="text-sky-800 text-right"
style={{width: "25%"}}>{Math.round(goal.points_capped).toLocaleString()} P<span
className="text-sm"></span>
</div>
</div>
<div className="text-sm text-gray-400 pt-1.5 hidden group-hover:block">
<span className="font-semibold">Limits: </span>
{(!(goal.min_per_workout || goal.max_per_workout || goal.min_per_day || goal.max_per_day || goal.min_per_week || goal.max_per_week)) && (
<>None</>
)}
{(goal.min_per_workout) && (
<><ArrowDownToLine
className="w-4 h-4 inline"/> {Math.round(goal.min_per_workout).toLocaleString()} </>
)}
{(goal.max_per_workout) && (
<><ArrowUpToLine
className="w-4 h-4 inline"/> {Math.round(goal.max_per_workout).toLocaleString()} </>
)}
{(goal.min_per_workout || goal.max_per_workout) && (
<span className="text-xs">{goal.metric} / workout </span>
)}
{(goal.min_per_day) && (
<><ArrowDownToLine
className="w-4 h-4 inline"/> {Math.round(goal.min_per_day).toLocaleString()} </>
)}
{(goal.max_per_day) && (
<><ArrowUpToLine
className="w-4 h-4 inline"/> {Math.round(goal.max_per_day).toLocaleString()} </>
)}
{(goal.min_per_day || goal.max_per_day) && (
<span className="text-xs">{goal.metric} / day </span>
)}
{(goal.min_per_week) && (
<><ArrowDownToLine
className="w-4 h-4 inline"/> {Math.round(goal.min_per_week).toLocaleString()} </>
)}
{(goal.max_per_week) && (
<><ArrowUpToLine
className="w-4 h-4 inline"/> {Math.round(goal.max_per_week).toLocaleString()} </>
)}
{(goal.min_per_week || goal.max_per_week) && (
<span className="text-xs">{goal.metric} / week </span>
)}
{
(['kcal', 'kj', 'km'].includes(goal.metric) && (Math.abs(user.scaling_distance - 1) >= 0.01 || Math.abs(user.scaling_kcal - 1) >= 0.01)) && (
<>
<br/>
<span className="font-semibold">Equalizing Factor: </span>
{
(goal.metric === 'km') ? (
<span className="text-xs">{Math.round(user.scaling_distance * 100 * 10) / 10}% x {Math.round(goal.goal / user.scaling_distance).toLocaleString()} {goal.metric}</span>
) : (
<span className="text-xs">{Math.round(user.scaling_kcal * 100 * 100) / 100}% x {Math.round(goal.goal / user.scaling_kcal).toLocaleString()} {goal.metric}</span>
)
}
</>
)
}
</div>
</div>
</div>
))}
</div>
{
(showModifyGoals) ?
<ActivityGoalsForm setModalState={setShowModifyGoals} competitionId={competitionId}/> : null
}
</BoxSection>
);
}
function getWeekDates() {
const today = new Date();
const day = today.getDay(); // 0 (Sun) - 6 (Sat)
const diffToMonday = (day === 0 ? -6 : 1) - day;
const monday = new Date(today);
monday.setDate(today.getDate() + diffToMonday);
return Array.from({length: 7}, (_, i) => {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
const offset = Math.floor((today - d) / (1000 * 60 * 60 * 24));
return {
date: d.toLocaleDateString('en-CA'), // Canadian locale uses YYYY-MM-DD format by default
offset: offset,
dateObj: d
};
});
}
function Activity7DaysBox({stats, userId, teamId}) {
const [chartData, setChartData] = useState({'labels': [], 'Me': [], 'My Team': [], 'Average': []});
useEffect(() => {
let tmpLegend = [];
let tmpMe = [];
let tmpTeam = [];
let tmpAll = [];
const participantCount = Math.max(1, stats.competition?.active_member_count);
const teamMemberCount = Math.max(1, stats.teams[teamId]?.active_member_count);
for (const entry of getWeekDates()) {
tmpLegend.push(entry.dateObj.toLocaleDateString('en-US', {weekday: 'short'}));
tmpMe.push(Math.round(stats?.timeseries?.user?.[userId]?.[entry.offset]?.total * 10) / 10 || 0);
tmpTeam.push(Math.round(stats?.timeseries?.team?.[teamId]?.[entry.offset]?.total / teamMemberCount * 10) / 10 || 0);
tmpAll.push(Math.round(stats?.timeseries?.all?.[entry.offset]?.total / participantCount * 10) / 10 || 0);
}
setChartData({
'Legend': tmpLegend,
'Me': tmpMe,
'My Team': tmpTeam,
'Average': tmpAll
});
}, [stats, userId, teamId]);
return (
<BoxSection>
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center border-b-2 pb-3">
<span className="mx-4 text-gray-500 uppercase font-bold">This Week</span>
</div>
<div className="my-3">
<ChartThisWeek history={chartData}/>
</div>
</BoxSection>)
}
function getDateRange(start_date, end_date) {
const start = new Date(start_date);
const today = new Date();
const end = end_date ? new Date(end_date) : today;
const finalEnd = end > today ? today : end;
const dates = [];
let current = new Date(start);
while (current <= finalEnd) {
const offset = Math.floor((today - current) / (1000 * 60 * 60 * 24));
dates.push({
date: current.toLocaleDateString('en-CA'), // Canadian locale uses YYYY-MM-DD format by default
offset: offset,
dateObj: new Date(current)
});
current.setDate(current.getDate() + 1);
}
return dates;
}
function ActivityCompetitionBox({stats, userId, teamId}) {
const [chartData, setChartData] = useState({'labels': [], 'Me': [], 'My Team': [], 'Average': []});
useEffect(() => {
let tmpLegend = ['Start'];
let tmpMe = [0];
let prevMe = 0;
let tmpTeam = [0];
let prevTeam = 0;
let tmpAll = [0];
let prevAll = 0;
const participantCount = Math.max(1, stats.competition?.active_member_count);
const teamMemberCount = Math.max(1, stats.teams[teamId]?.active_member_count);
for (const entry of getDateRange(stats?.competition?.start_date, stats?.competition?.end_date)) {
tmpLegend.push(entry.dateObj.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })); // Mon, Jan 5
tmpMe.push((stats?.timeseries?.user?.[userId]?.[entry.offset]?.total + prevMe) || null);
prevMe += (stats?.timeseries?.user?.[userId]?.[entry.offset]?.total || 0);
tmpTeam.push((stats?.timeseries?.team?.[teamId]?.[entry.offset]?.total / teamMemberCount + prevTeam) || null);
prevTeam += (stats?.timeseries?.team?.[teamId]?.[entry.offset]?.total / teamMemberCount || 0);
tmpAll.push((stats?.timeseries?.all?.[entry.offset]?.total / participantCount + prevAll) || null);
prevAll += (stats?.timeseries?.all?.[entry.offset]?.total / participantCount || 0);
}
setChartData({
'Legend': tmpLegend,
'Me': tmpMe,
'My Team': tmpTeam,
'Average': tmpAll
});
}, [stats, userId, teamId]);
return (
<BoxSection>
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center border-b-2 pb-3">
<span className="mx-4 text-gray-500 uppercase font-bold">The Trend</span>
</div>
<div className="my-3">
<ChartHistory history={chartData}/>
</div>
</BoxSection>
)
}
export default function Competition() {
const navType = useNavigationType();
useEffect(() => {
if (navType === "POP") {
document.body.classList.remove("body-no-scroll");
}
}, [navType]);
const dispatch = useDispatch();
const {id} = useParams();
const {
data: user,
error: userError,
isLoading: userLoading,
} = useGetUserByIdQuery('me');
const {
data: competition,
error: competitionError,
isLoading: competitionLoading,
refetch: refreshCompetition,
isFetching: competitionFetching,
} = useGetCompetitionByIdQuery(id);
const {
data: feed,
error: feedError,
isLoading: feedLoading,
refetch: refreshFeed,
isFetching: feedFetching,
} = useGetFeedByIdQuery(id, {
pollingInterval: 90000, // 90 seconds
});
const {
data: stats,
error: statsError,
isLoading: statsLoading,
refetch: refreshStats,
isFetching: statsFetching,
} = useGetStatsByIdQuery(id, {
pollingInterval: 90000, // 90 seconds
});
const isOwner = (user !== undefined) && (user?.id === competition?.owner);
const [teamId, setTeamId] = useState(undefined);
useEffect(() => {
if (stats?.teams && user?.my_teams) {
const tmpTeamId = Object.keys(stats?.teams).find(item => user?.my_teams.includes(parseInt(item)));
setTeamId(tmpTeamId);
}
}, [stats, user])
function refreshPage() {
refreshCompetition();
refreshFeed();
refreshStats();
dispatch(teamsApi.util.invalidateTags(['Team']));
}
if (competitionError) {
console.log('Error retrieving competition (' + id + '):', competitionError);
return <PageWrapper additionClasses="h-screen flex items-center justify-center"><ErrorBoxSection
errorMsg={competitionError?.status + ' / ' + (competitionError?.error || competitionError?.message || competitionError?.data?.detail)}/></PageWrapper>;
}
return (
<PageWrapper>
<NavMenu page={id}/>
<div className="container mx-auto p-4">
{
(competitionLoading || feedLoading) ? (
<SectionLoader height={"h-48 mb-4"} />
) : (statsError) ? (
<ErrorBoxSection additionalClasses='mb-4' errorMsg={competitionError?.status + ' / ' + (competitionError?.error || competitionError?.message || competitionError?.data?.detail)}/>
) : (
<CompetitionHead competition={competition} feed={feed} isOwner={isOwner} />
)
}
{/* KPI bar */}
<div className="flex flex-col xl:flex-row">
<div className="w-full xl:w-1/3">
{
(statsLoading || feedLoading || userLoading) ? (
<SectionLoader/>
) : (statsError) ? (
<ErrorBoxSection additionalClasses="mb-4"
errorMsg={statsError?.status + ' / ' + (statsError?.error || statsError?.message || statsError?.data?.detail)}/>
) : (
<ActivityGoalsBox user={user} stats={stats} feed={feed} competitionId={id} userId={user?.id} isOwner={isOwner} />
)
}
</div>
<div className="w-full xl:w-1/3 my-4 xl:my-0 xl:mx-4">
{
(statsLoading || userLoading) ? (
<SectionLoader/>
) : (statsError) ? (
<ErrorBoxSection
errorMsg={statsError?.status + ' / ' + (statsError?.error || statsError?.message || statsError?.data?.detail)}/>
) : (
<Activity7DaysBox feed={feed} stats={stats} userId={user?.id} teamId={teamId}/>
)
}
</div>
<div className="w-full xl:w-1/3 ">
{
(statsLoading || userLoading) ? (
<SectionLoader/>
) : (statsError) ? (
<ErrorBoxSection
errorMsg={statsError?.status + ' / ' + (statsError?.error || statsError?.message || statsError?.data?.detail)}/>
) : (
<ActivityCompetitionBox feed={feed} stats={stats} userId={user?.id} teamId={teamId}/>
)
}
</div>
</div>
{/* Leaderboards & Activity */}
<div className="flex flex-col xl:flex-row mt-4">
{/* Activity Feed left on xl, below otherwise */}
<div className="order-2 xl:order-1 w-full xl:w-2/3 xl:pr-2">
{
(feedLoading) ? <SectionLoader height={"h-80"}/> : (feedError) ? (
<ErrorBoxSection
errorMsg={feedError?.status + ' / ' + (feedError?.error || feedError?.message || feedError?.data?.detail)}/>
) : (
<FeedBox feed={feed} refreshCompetition={refreshPage}
competitionIsRefreshing={competitionFetching || feedFetching || statsFetching}/>
)
}
</div>
{/* Leaderboards above Activity on sm/md/lg, right on xl */}
<div className="order-1 xl:order-2 w-full xl:w-1/3 mb-1 xl:mb-0 flex flex-col md:flex-row xl:flex-col xl:pl-2">
{ (competition?.has_teams === false) ? null : (
<div className="w-full md:w-1/2 xl:w-full pr-0 md:pr-2 xl:pr-0 mb-4">
{
(statsLoading || competitionLoading) ? (
<SectionLoader/>
) : (statsError) ? (
<ErrorBoxSection
errorMsg={statsError?.status + ' / ' + (statsError?.error || statsError?.message || statsError?.data?.detail)}/>
) : (
<TeamLeaderboardBox stats={stats} competition={competition} user={user} teamId={teamId} isOwner={isOwner}/>
)
}
</div>
)}
<div className="w-full md:w-1/2 xl:w-full pl-0 md:pl-2 xl:pl-0 mb-4">
{
(statsLoading) ? (
<SectionLoader/>
) : (statsError) ? (
<ErrorBoxSection
errorMsg={statsError?.status + ' / ' + (statsError?.error || statsError?.message || statsError?.data?.detail)}/>
) : (
<IndividualLeaderboardBox stats={stats} userId={user?.id}/>
)
}
</div>
</div>
</div>
</div>
</PageWrapper>
)
}

View file

@ -0,0 +1,35 @@
.streak-table {
@apply border-t-2 border-[#f0f0f0] dark:max-md:border-[#111827] md:border-t-0;
.cell-mon, th:nth-child(1), td:nth-child(1) {
border-left: 2px solid #f0f0f0;
@apply pl-[2px] sm:pl-[20px] md:pl-[30px] dark:max-md:border-[#111827];
}
.cell-sun, th:nth-child(7), td:nth-child(7) {
border-right: 2px solid #f0f0f0;
@apply pr-[2px] sm:pr-[20px] md:pr-[30px] dark:max-md:border-[#111827];
}
.cell-streak, th:nth-child(8), td:nth-child(8) {
padding-left: 30px;
padding-right: 15px;
}
th {
@apply pt-[5px] sm:pt-[12px] md:pt-[0px];
}
tr:nth-child(5) td {
@apply pb-[7px] sm:pb-[10px] md:pb-[0px];
}
}
.bg-light-blue-hover:hover {
background-color: #6387bc;
color: white;
}
.bg-streak-blue {
background-color: #6387bc;
}

View file

@ -0,0 +1,176 @@
import {useEffect, useState} from 'react'
import {QRCodeSVG} from 'qrcode.react';
import {usersApi} from "../utils/reducers/usersSlice";
import {useDispatch} from "react-redux";
import {workoutsApi} from "../utils/reducers/workoutsSlice";
import {statsApi} from "../utils/reducers/statsSlice";
const steps = [
{title: "Welcome", content: "Let's find out how to best navigate the Workout Challenge!", img: null},
{
title: "Navigation",
content: "At the top, switch between your personal (private) and competition (public) dashboards.",
img: "/how_to_screen_1.png"
},
{
title: "Your Profile",
content: "See your lifetime stats, as well as change profile and privacy settings here.",
img: "/how_to_screen_2.png"
},
{
title: "Last 30 Day Stats",
content: "See your personal stats and streak over the last 30 days.",
img: "/how_to_screen_3.png"
},
{
title: "Your 7 Day Personal Goals",
content: "See and change your rolling 7 day activity goals.",
img: "/how_to_screen_4.png"
},
{
title: "Your Workouts",
content: "Add manually or automatically via Strava your workouts.",
img: "/how_to_screen_5.png"
},
{
title: "Your Competitions",
content: "Create your own or join other people's competitions.",
img: "/how_to_screen_6.png"
},
]
export function HowToScreen({setModal}) {
const [current, setCurrent] = useState(0)
document.body.classList.add("body-no-scroll");
return (
<div className="fixed inset-0 z-50 bg-white bg-opacity-80 dark:bg-black dark:bg-opacity-80 flex items-center justify-center overflow-auto">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-2xl w-full text-center space-y-4 max-h-[100vh] overflow-y-auto">
<h2 className="text-xl font-semibold">{steps[current].title}</h2>
<p className="text-gray-600 dark:text-gray-400">{steps[current].content}</p>
<img src={steps[current].img}/>
<div className="flex justify-between mt-6">
<button
className="text-sm text-gray-500"
onClick={() => {
if (current === 0) {
document.body.classList.remove("body-no-scroll");
setModal(false); // Close the modal
//setLinkStrava(true); // Open Strava link screen
} else {
setCurrent((c) => Math.max(0, c - 1)); // Go to previous step
}
}
}
>
{current === 0 ? 'Skip' : 'Back'}
</button>
<button
className="bg-blue-600 text-white px-4 py-2 rounded-full disabled:opacity-50"
onClick={() => {
if (current === steps.length - 1) {
document.body.classList.remove("body-no-scroll");
setModal(false); // Close the modal
//setLinkStrava(true); // Open Strava link screen
} else {
setCurrent((c) => Math.min(steps.length - 1, c + 1)); // Go to next step
}
}}
>
{current === steps.length - 1 ? 'Finish' : 'Next'}
</button>
</div>
</div>
</div>
)
}
export function LinkStravaScreen({setModal}) {
const [current, setCurrent] = useState(0)
const domain = window.location.origin;
const url = domain + '/strava/link/';
const dispatch = useDispatch();
function refreshWorkouts() {
dispatch(workoutsApi.util.invalidateTags(['Workout']));
dispatch(usersApi.util.invalidateTags(['User']));
dispatch(statsApi.util.invalidateTags(['Stats']));
}
useEffect(() => {
document.body.classList.add("body-no-scroll");
}, [])
return (
<div className="fixed inset-0 z-50 bg-white bg-opacity-80 dark:bg-black dark:bg-opacity-80 flex items-center justify-center overflow-auto">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 max-w-2xl w-full text-center space-y-4 max-h-[100vh] overflow-y-auto">
{
current === 0 ? (
<>
<h2 className="text-xl font-semibold">Automatic workout import via Strava</h2>
<p className="text-gray-600 dark:text-gray-400">Either manually enter your workouts or link
your free Strava
account for automatic workout import.</p>
<img src="/how_to_strava_sync.png"/>
<p>Only absolutely necessary metrics are synced with Strava:</p>
<ul>
<li> Sport type</li>
<li> Start time & duration</li>
<li> Workout id</li>
<li> Distance, kcal, kj, avg. watt, avg. heart rate</li>
<li> That's it nothing more! </li>
</ul>
<p className="text-gray-500 text-sm italic">You still don't trust it? Check the <a
className="text-blue-500 hover:underline" target="_blank"
href="https://github.com/vanalmsick/workout_challenge/blob/main/src-backend/custom_user/strava.py#L126-L162">public
source code yourself (here)</a>!</p>
</>
) : (
<>
<h2 className="text-xl font-semibold">Connect Strava</h2>
<p className="text-gray-600 dark:text-gray-400">Scan the QR code with your phone or click
the link below to connect Strava.</p>
<div className="flex justify-center items-center">
<QRCodeSVG value={url} title={"QR code to link to Strava account"} size={200}
level={"L"}/>
</div>
<p className="text-gray-600 dark:text-gray-400">Or <a
className="text-blue-500 hover:underline" href={url}
target="_blank">click this link</a></p>
</>
)
}
<div className="flex justify-between mt-6">
<button className="text-sm text-gray-500"
onClick={() => {
if (current === 0) {
document.body.classList.remove("body-no-scroll");
setModal(false); // Close the modal
} else {
setCurrent(0); // Go to previous step
}
}}
>
{current === 0 ? 'Close without linking' : 'Back'}
</button>
<button className="bg-blue-600 text-white px-4 py-2 rounded-full"
onClick={() => {
if (current === 1) {
document.body.classList.remove("body-no-scroll");
setModal(false); // Close the modal
refreshWorkouts(); // refresh workouts in case Strava was linked on phone via QR code
} else {
setCurrent(1); // Go to next step
}
}}
>
{current === 1 ? 'Close' : "Let\'s link Strava"}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,797 @@
import './Dashboard.css';
import React, {useEffect, useState} from "react";
import {
Check,
CheckCheck,
Dumbbell,
Flame,
Timer,
Ruler,
} from 'lucide-react';
import {useGetWorkoutsQuery, workoutsApi} from "../utils/reducers/workoutsSlice";
import WorkoutForm, {workoutTypes} from "../forms/workoutForm";
import _ from 'lodash';
import {useGetUserByIdQuery, usersApi} from "../utils/reducers/usersSlice";
import {useGetCompetitionsQuery} from "../utils/reducers/competitionsSlice";
import CompetitionForm from "../forms/competitionForm";
import PersonalGoalsForm from "../forms/personalGoalsForm";
import SettingsForm from "../forms/settingsForm";
import {useLocation, useNavigate, useNavigationType, useSearchParams} from "react-router-dom";
import NavMenu from "../utils/navMenu";
import JoinCompetitionForm from "../forms/joinCompetitionForm";
import {HowToScreen, LinkStravaScreen} from "./HowTo";
import {
AddButton, EditButton, FairGoalsButton,
JoinButton,
ModifyGoalsButton,
SettingsButton, StravaButton,
SyncStravaButton
} from "../forms/basicComponents";
import {BoxSection, ErrorBoxSection, PageWrapper} from "../utils/miscellaneous";
import {SectionLoader} from "../utils/loaders";
import {useDispatch} from "react-redux";
import GoalEqualizerForm from "../forms/equalizerForm";
import {useLazySyncStravaQuery} from "../utils/reducers/linkSlice";
import {statsApi, useGetStatsByIdQuery} from "../utils/reducers/statsSlice";
import {feedApi} from "../utils/reducers/feedSlice";
import {BeatLoader} from "react-spinners";
function WelcomeBox({user, workouts, setLinkStrava}) {
const [showEditSettingsModal, setShowEditSettingsModal] = useState(false);
const [showGoalEqualizerModal, setShowGoalEqualizerModal] = useState(false);
const [countTotal, setCountTotal] = useState(0);
const [countGroups, setCountGroups] = useState({});
useEffect(() => {
if (workouts !== undefined) {
const filteredWorkouts = _.filter(workouts || [], item => item.sport_type !== 'Steps');
setCountTotal(filteredWorkouts.length);
const grouped = _.mapValues(_.groupBy(_.values(filteredWorkouts), 'sport_type'), group => group.length);
const sorted = _.fromPairs(_.orderBy(_.toPairs(grouped), ([, value]) => value, 'desc'));
const limited = Object.fromEntries(Object.entries(sorted).slice(0, 4));
setCountGroups(limited);
}
}, [workouts]);
return (
<BoxSection additionalClasses={"mb-4"}>
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center sm:gap-6 sm:py-4">
{/* Message */}
<div className="flex flex-col gap-2 px-5 pb-2 sm:flex-row sm:items-center sm:gap-6 sm:py-2.5">
<img className="mx-auto block h-24 rounded-full sm:mx-0 sm:shrink-0"
src="/profile.png"
alt=""/>
<div className="space-y-2 text-center sm:text-left">
<div className="space-y-0.5">
<p className="font-small text-gray-500">Welcome back,</p>
<p className="text-2xl font-semibold">{user.first_name}</p>
</div>
</div>
</div>
{/* Workout Stats */}
<div className="flex p-3">
<div className="flex items-center px-4">
<div className="text-5xl font-semibold pe-2">{countTotal}</div>
<div className="uppercase text-xs tracking-wide text-gray-500">Total Lifetime<br/>Workouts
</div>
</div>
{Object.entries(countGroups).map(([label, count], index) => (
<div key={"stat" + index} className="flex flex-col px-4 text-center hidden lg:block">
<div className="text-3xl font-semibold text-left">{count}</div>
<div
className="uppercase text-xs tracking-wide text-gray-500">{workoutTypes[label].label_short}</div>
</div>
))}
</div>
{/* Setting Buttons */}
<div className="p-3 sm:p-4">
<SettingsButton additionalClasses="mx-auto sm:ml-auto sm:mr-0 my-1"
onClick={() => setShowEditSettingsModal(true)}/>
<FairGoalsButton additionalClasses="mx-auto sm:ml-auto sm:mr-0 my-1"
onClick={() => setShowGoalEqualizerModal(true)}/>
</div>
{(showEditSettingsModal) && (
<SettingsForm user={user} setModalState={setShowEditSettingsModal} setLinkStrava={setLinkStrava}/>
)}
{(showGoalEqualizerModal) && (
<GoalEqualizerForm user={user} setModalState={setShowGoalEqualizerModal}/>
)}
</div>
</BoxSection>
)
}
function WorkoutsBox({workouts, user, setLinkStrava}) {
const [showEditWorkoutModal, setShowEditWorkoutModal] = useState(false);
const stravaLinked = user?.strava_athlete_id !== null;
const dispatch = useDispatch();
const [triggerStravaSync, { data: stravaSyncData, isFetching: stravaSyncIsFetching, error: stravaSyncError, isSuccess: stravaSyncIsSuccess }] = useLazySyncStravaQuery();
useEffect(() => {
if (stravaSyncIsFetching !== undefined && stravaSyncIsFetching !== true) {
if (stravaSyncIsSuccess) {
dispatch(workoutsApi.util.invalidateTags(['Workout']));
dispatch(usersApi.util.invalidateTags(['User']));
dispatch(statsApi.util.invalidateTags(['Stats']));
dispatch(feedApi.util.invalidateTags(['Feed']));
console.log("Strava sync successful!");
} else if (stravaSyncError) {
dispatch(workoutsApi.util.invalidateTags(['Workout']));
dispatch(usersApi.util.invalidateTags(['User']));
if (stravaSyncError?.status === 429) {
console.log("Strava sync denied! Too many requests!");
window.alert(`${stravaSyncError?.data?.message}`);
} else {
console.log("Strava sync failed!", stravaSyncError);
window.alert("Strava sync failed! Unknown error. Please try again later or wait till 4 am for scheduled sync.");
}
}
}
}, [stravaSyncIsFetching]);
return (
<BoxSection>
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center border-b-2 pb-3">
<span className="mx-4 text-gray-500 uppercase font-bold mb-1.5 sm:mb-0">My Workouts</span>
<div className="p-0">
{
(stravaLinked) ? <SyncStravaButton additionalClasses="my-0.5 sm:my-0" isLoading={stravaSyncIsFetching} onClick={() => triggerStravaSync()}/> :
<StravaButton additionalClasses="my-0.5 sm:my-0" label={"Link Strava for Automatic Import"} onClick={() => setLinkStrava(true)}/>
}
</div>
<div className="p-0">
<AddButton additionalClasses="my-0.5 sm:my-0" label={"Add Workout Manually"} onClick={() => setShowEditWorkoutModal(true)}/>
</div>
</div>
<table className="min-w-full my-2">
<tbody>
{(workouts.length === 0) ? (
<tr className="hover:bg-gray-100 dark:hover:bg-gray-900 border-b">
<td className="py-2 px-4 pb-3 text-center text-gray-500">Add workouts manually or link Strava for automatic workout import!
</td>
</tr>
) : (
workouts.map((workout, iWorkout) => (
<tr key={"workout" + iWorkout} className="hover:bg-gray-100 dark:hover:bg-gray-900 border-b">
<td className="py-2 px-4 text-sm md:text-base">
<span className="font-semibold">{workout.start_datetime_fmt.date_readable}</span><br/>
<span className="text-sm hidden sm:block">{workout.start_datetime_fmt.time_24h}</span>
</td>
<td className="py-2 px-4 text-sm md:text-base">
{/* Mobile view (stacked) */}
<div className="md:hidden">
<div className="font-base">{(workout.sport_type === "Steps") ? workout.steps?.toLocaleString() : workout.duration.substring(0, 5)} <span className="font-semibold">{workoutTypes[workout.sport_type].label_short}</span></div>
{(workout.sport_type !== "Steps") && <div className="text-sm text-gray-600 dark:text-gray-400">{Math.round(workout.kcal).toLocaleString()}<span className="text-sm"> kcal < /span></div>}
</div>
{/* Desktop view (normal) */}
<div className="hidden md:block">{(workout.sport_type === "Steps") ? workout.steps?.toLocaleString() : workout.duration.substring(0, 5)} <span className="font-semibold text-base">{workoutTypes[workout.sport_type].label_short}</span> {(workout.distance && workout.sport_type !== "Steps") ? (<span className="hidden sm:inline">({workout.distance}km)</span>) : (null)}</div>
</td>
<td className="py-2 px-4 hidden md:table-cell">
{(workout.kcal) ? (
(workout.sport_type !== "Steps") && (<>{Math.round(workout.kcal).toLocaleString()} <span className="text-sm"> kcal < /span></>)
) : null}
</td>
<td className="py-2 px-4">
<EditButton additionalClasses={"mx-auto"}
onClick={() => setShowEditWorkoutModal(workout.id)} label={false}
larger={true}/>
</td>
</tr>
))
)}
</tbody>
</table>
{(showEditWorkoutModal) && (
<WorkoutForm setModalState={setShowEditWorkoutModal} id={showEditWorkoutModal} scaling_distance={parseFloat(user?.scaling_distance || "1.0")}/>
)}
</BoxSection>
)
}
function CompetitionRow({competition, user}) {
const {
data: stats,
error: statsError,
isLoading: statsLoading,
refetch: refreshStats,
isFetching: statsFetching,
} = useGetStatsByIdQuery(competition.id, {
pollingInterval: 90000, // 90 seconds
});
const [teamId, setTeamId] = useState(undefined);
useEffect(() => {
if (stats?.teams && user?.my_teams) {
const tmpTeamId = Object.keys(stats?.teams).find(item => user?.my_teams.includes(parseInt(item)));
setTeamId(tmpTeamId);
}
}, [stats, user])
const navigate = useNavigate();
const handleClick = (id) => {
return navigate(`/competition/${id}`);
}
return (
<tr onClick={() => handleClick(competition.id)}
className="hover:bg-gray-100 dark:hover:bg-gray-900 border-b cursor-pointer">
<td className="py-2 px-4">
<span className="font-semibold">{competition.name}</span><br/>
<span className="text-sm text-gray-400">{competition.start_date_fmt} - {competition.end_date_fmt}</span>
</td>
<td className="py-2 px-4 text-right text-sm">
{(statsLoading) ? (
<div><BeatLoader color="rgb(209 213 219)" /></div>
) : (stats.competition.start_date_count >= 0) ? (
((stats.users[user.id]?.rank == null) ? (
<span className="text-gray-400">Time to work out!</span>
) : (
<>No. <span className="text-xl font-semibold">{stats.users[user.id]?.rank}</span>
{(competition.has_teams) ? (
<span className="text-gray-400 italic"><br/><span className="font-semibold">My Team:</span> #{stats.teams[teamId]?.rank}</span>
) : null
}
</>
))
) :
<span className="text-gray-400">Not yet started</span>
}
</td>
</tr>
)
}
function CompetitionsBox({user, competitions, setJoinCompetition}) {
const [showEditCompetitionModal, setShowEditCompetitionModal] = useState(false);
return (
<BoxSection additionalClasses={"mb-4"}>
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center border-b-2 pb-3">
<span className="mx-4 text-gray-500 uppercase font-bold mb-1.5 sm:mb-0">My Competitions</span>
<div className="p-0">
<JoinButton additionalClasses="my-0.5 sm:my-0" onClick={() => setJoinCompetition(true)}/>
</div>
<div className="p-0">
<AddButton additionalClasses="my-0.5 sm:my-0" label={"Create"}
onClick={() => setShowEditCompetitionModal(true)}/>
</div>
</div>
<table className="min-w-full my-2">
<tbody>
{(competitions.length === 0) ? (
<tr className="hover:bg-gray-100 dark:hover:bg-gray-900 border-b">
<td className="py-2 px-4 pb-3 text-center text-gray-500">Crate or join a competition!
</td>
</tr>
) : (
competitions.map((competition, iCompetition) => (
<CompetitionRow key={"comp" + iCompetition} competition={competition} user={user} />
))
)}
</tbody>
</table>
{(showEditCompetitionModal) && (
<CompetitionForm setModalState={setShowEditCompetitionModal}/>
)}
</BoxSection>
)
}
function getLast5WeeksRange() {
let cnt = 35;
const today = new Date();
const currentDay = today.getDay(); // 0 (Sun) - 6 (Sat)
const isMonday = currentDay === 1;
// Find this week's Monday
const thisMonday = new Date(today);
const diffToMonday = (currentDay === 0 ? -6 : 1) - currentDay;
thisMonday.setDate(today.getDate() + diffToMonday);
// Find Monday 5 weeks ago
const start = new Date(thisMonday);
// If today is Monday, subtract 35 days (5 weeks), otherwise subtract 28 days (4 weeks)
start.setDate(thisMonday.getDate() - (isMonday ? 35 : 28));
// Find this week's Sunday
const end = new Date(thisMonday);
end.setDate(thisMonday.getDate() + 6); // Sunday of this week
// Collect all dates
const dates = [];
const current = new Date(start);
while (current <= end) {
const offset = Math.floor((current - today) / (1000 * 60 * 60 * 24));
dates.push({
date: current.toLocaleDateString('en-CA'), // Canadian locale uses YYYY-MM-DD format by default
week: Math.floor((cnt - 1) / 7) * (-1),
offset: offset,
dateObj: new Date(current),
day: current.getDate(), // Add day number (1-31)
month: current.getMonth() + 1, // Add month number (1-12)
year: current.getFullYear(), // Add year number (e.g., 2025)
monthStr: current.toLocaleDateString('en-US', {month: 'short'}), // Jan, Feb, ...
});
current.setDate(current.getDate() + 1);
cnt--;
}
return dates;
}
function ThirtyDayStats({thirtyDayStats}) {
return (
<div className="w-full">
<div className="relative flex pb-5 pt-3 items-center text-sm">
<span className="flex-shrink mx-4 text-gray-500 uppercase"><span
className="font-bold">30 Day Activity</span> {thirtyDayStats.startDate} - {thirtyDayStats.endDate}</span>
<div className="flex-grow border-t border-gray-100 border-t-2"></div>
</div>
<div className="flex p-3">
<div className="flex flex-col px-4 text-left">
<div className="text-xs tracking-wide text-gray-500">Active Days</div>
<div className="text-6xl text-left">{thirtyDayStats.activeDays}</div>
</div>
</div>
<div className="flex flex-wrap p-3 pt-1 space-y-3 space-x-1 sm:space-x-4">
<div className="flex items-center">
<Dumbbell className="w-6 h-6 text-gray-500"/>
<div className="flex flex-col px-4 text-left">
<div className="text-xs tracking-wide text-gray-500">Workouts</div>
<div className="text-3xl">{thirtyDayStats.workouts}</div>
</div>
</div>
<div className="flex items-center">
<Timer className="w-6 h-6 text-gray-500"/>
<div className="flex flex-col px-4 text-left">
<div className="text-xs tracking-wide text-gray-500">Time</div>
<div><span
className="text-3xl">{Math.floor(thirtyDayStats.time / 3600).toLocaleString()}</span>hr <span
className="text-3xl">{Math.floor((thirtyDayStats.time % 3600) / 60)}</span>min
</div>
</div>
</div>
<div className="flex items-center">
<Flame className="w-6 h-6 text-gray-500"/>
<div className="flex flex-col px-4 text-left">
<div className="text-xs tracking-wide text-gray-500">Calories</div>
<div><span className="text-3xl">{thirtyDayStats.kcal.toLocaleString()}</span>kcal</div>
</div>
</div>
<div className="flex items-center">
<Ruler className="w-6 h-6 text-gray-500"/>
<div className="flex flex-col px-4 text-left">
<div className="text-xs tracking-wide text-gray-500">Distance</div>
<div><span className="text-3xl">{Math.round(thirtyDayStats.distance).toLocaleString()}</span>km
</div>
</div>
</div>
</div>
<div className="relative flex py-5 items-center text-sm">
<div className="flex-grow border-t border-gray-100 border-t-2"></div>
</div>
</div>
)
}
function SevenDayStats({sevenDayStats, user}) {
const [showEditGoalsModal, setShowEditGoalsModal] = useState(false);
return (
<div className="w-full">
<div className="relative flex py-5 items-center text-sm xl:hidden block">
<div className="flex-grow border-t border-gray-100 border-t-2"></div>
</div>
<div className="flex flex-col items-center justify-between sm:flex-row sm:items-center">
<span className="text-sm mx-4 text-gray-500 uppercase"><span
className="font-bold">Personal Goals</span> 7 Days Rolling</span>
<div className="p-0">
<ModifyGoalsButton additionalClasses="mt-2.5 sm:my-0" onClick={() => setShowEditGoalsModal(true)}
label={"Update Goals"}/>
</div>
</div>
<div className="flex flex-col mt-3 sm:mt-0 sm:overflow-x-auto sm:flex-row sm:space-x-4">
{sevenDayStats.map((goal, idx) => (
<div key={idx} className="bg-gray-100 dark:bg-gray-900 rounded-lg p-6 mb-4 sm:mb-0 sm:m-4">
<div className="flex flex-col px-4 text-left" style={{width: '220px'}}>
<div className="tracking-wide text-gray-500 mb-0.5">{goal.name}</div>
<div className="text-2xl text-sky-800 text-left mb-2">
{goal.value.toLocaleString()} / {goal.target.toLocaleString()}
<span className="text-sm">{goal.unit}</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-800 rounded-full h-4">
<div className="h-4 rounded-full" style={{
width: Math.min(goal.value / goal.target * 100, 100) + '%',
backgroundColor: '#6387bc'
}}></div>
</div>
</div>
</div>
))}
</div>
{(showEditGoalsModal) && <PersonalGoalsForm user={user} setModalState={setShowEditGoalsModal}/>}
</div>
)
}
function splitIntoChunks(arr, chunkSize = 7) {
const result = [];
for (let i = 0; i < arr.length; i += chunkSize) {
result.push(arr.slice(i, i + chunkSize));
}
return result;
}
function CalendarStats({workouts, last5Weeks}) {
const [tableDateData, setTableDateData] = useState([]);
const [tableStreakData, setTableStreakData] = useState({});
const [weekStreak, setWeekStreak] = useState(0);
const today = new Date();
const isMonday = today.getDay() === 1;
const weeks = (isMonday ? 5 : 4);
useEffect(() => {
const filteredWorkouts = _.filter(workouts || [], item => item.sport_type !== 'Steps');
// chunked table data
const hasWorkout = (list, value) => list.some(obj => obj.start_datetime_fmt.days_ago === -value) ? 1 : 0;
const combinedData = last5Weeks.map(week => ({...week, hasWorkout: hasWorkout(filteredWorkouts, week.offset)}));
const chunkedData = splitIntoChunks(combinedData);
setTableDateData(chunkedData);
const workoutsPerWeek = _.mapValues(_.groupBy(filteredWorkouts || [], 'start_datetime_fmt.weeksAgo'), items => _.sumBy(items, 'duration_seconds'));
setTableStreakData(workoutsPerWeek);
// streak number
let weekStreak = -1;
let i = -1;
let stillStreak = true;
while (stillStreak) {
// workout done - add one to streak
if (workoutsPerWeek[i + 1] > 0) {
weekStreak++;
// no workout done - break streak but only if this is not the current week
} else if (i !== -1) {
stillStreak = false;
}
i++;
}
setWeekStreak(weekStreak + 1);
}, [workouts, last5Weeks]);
return (
<div className="w-full py-5">
<table className="streak-table text-center mx-auto text-xs sm:text-sm">
<thead>
<tr className="font-semibold text-gray-500">
<th>M</th>
<th>T</th>
<th>W</th>
<th>T</th>
<th>F</th>
<th>S</th>
<th>S</th>
<th className="hidden md:block">Streak</th>
</tr>
</thead>
<tbody>
{/* Iterate over weeks/rows */}
{tableDateData.map((week, idxWeek) => (
<tr key={"week" + idxWeek}>
{/* Iterate over days/cols */}
{week.map((day, idxDay) => (
<td key={"day" + idxDay} className="py-0 px-1 sm:px-2">
{/* Month 3 letters */}
<div className="mx-auto flex items-center justify-center text-gray-400 h-5">
{((idxDay === 0 && idxWeek === 0) || day.day === 1) ? day.monthStr : null}
</div>
{/* Day Number */}
<div
className={"mx-auto flex items-center justify-center w-8 h-8 rounded-full font-semibold " + ((day.offset <= 0 && day.offset > -30) ? (day.hasWorkout > 0 ? "bg-sky-800 text-white" : "") : (day.hasWorkout > 0 ? "bg-gray-100 dark:bg-gray-700 text-gray-300 dark:text-gray-500" : "text-gray-300 dark:text-gray-500"))}>
{day.day}
</div>
{/* Today dot */}
<div
className={"mx-auto flex items-center justify-center w-1.5 h-1.5 mt-1 rounded-full" + (day.offset === 0 ? " bg-red-600" : null)}></div>
</td>
))}
{/* Week Streak */}
<td className="py-0 px-1 sm:px-2 hidden md:block">
<div
className="mx-auto flex items-center justify-center text-gray-300 h-5 font-bold">
{(tableStreakData[weeks - idxWeek + 1] > 0 && tableStreakData[weeks - idxWeek] > 0) ? "|" : ""}
</div>
<div
className={"mx-auto flex items-center justify-center w-8 h-8 rounded-full font-semibold text-white " + ((tableStreakData[weeks - idxWeek] > 0) ? "bg-streak-blue" : "bg-gray-200 dark:bg-gray-700")}>
{(tableStreakData[weeks - idxWeek] >= 9000) ? // double tick: 9000 = 150 minutes as recommended by the WHO
<CheckCheck className="w-5 h-5"/> : (tableStreakData[weeks - idxWeek] > 0) ? // single tick: any workout
<Check className="w-5 h-5"/> : ""}
</div>
<div
className="mx-auto flex items-center justify-center w-1.5 h-1.5 mt-1 rounded-full text-gray-300 font-bold">
{(tableStreakData[weeks - idxWeek - 1] > 0 && tableStreakData[weeks - idxWeek] > 0) ? "|" : ""}
</div>
</td>
</tr>
))}
<tr>
<td colSpan="7"
className="cell-mon cell-sun text-sm text-center bg-gray-100 md:bg-white dark:bg-gray-900 dark:md:bg-gray-800 py-2">
<div className="md:hidden mx-auto flex items-center justify-center"><Check
className="w-4 h-4 mt-1 mr-2"/> {weekStreak} Week Streak
</div>
</td>
<td className="cell-streak hidden md:block" style={{color: '#6387bc'}}>
<span className="text-xl font-semibold">{weekStreak}</span><br/>
<span className="text-sm">weeks</span>
</td>
</tr>
</tbody>
</table>
</div>
)
}
function StatsBox({workouts, user}) {
const [thirtyDayStats, setThirtyDayStats] = useState({activeDays: 0, workouts: 0, distance: 0, kcal: 0, time: 0});
const [sevenDayStats, setSevenDayStats] = useState([]);
const last5WeeksList = getLast5WeeksRange();
useEffect(() => {
// 30 day stats
const filtered30Days = _.filter(workouts || [], item => item.start_datetime_fmt.days_ago < 30 && item.sport_type !== 'Steps');
setThirtyDayStats({
activeDays: _.uniqBy(filtered30Days, 'start_datetime_fmt.date_iso').length,
workouts: filtered30Days.length,
distance: Math.round(_.sumBy(filtered30Days, item => +item.distance || 0) * 10) / 10,
kcal: Math.round(_.sumBy(filtered30Days, item => +item.kcal || 0)),
time: Math.round(_.sumBy(filtered30Days, item => +item.duration_seconds || 0)),
startDate: _.find(last5WeeksList, {offset: -29})?.dateObj?.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
}),
endDate: _.find(last5WeeksList, {offset: 0})?.dateObj?.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
}),
})
// 7 day goals
const filtered7Days = _.filter(workouts || [], item => item.start_datetime_fmt.days_ago < 7 && item.sport_type !== 'Steps');
let newGoals = [];
if (user.goal_active_days !== null) {
newGoals.push({
name: 'Active Days',
value: _.uniqBy(filtered7Days, 'start_datetime_fmt.date_iso').length,
target: user.goal_active_days,
unit: ''
});
}
if (user.goal_workout_minutes !== null) {
newGoals.push({
name: 'Time Goal',
value: Math.round(_.sumBy(filtered7Days, item => +item.duration_seconds || 0) / 60),
target: user.goal_workout_minutes,
unit: 'min'
});
}
if (user.goal_distance !== null) {
newGoals.push({
name: 'Distance',
value: Math.round(_.sumBy(filtered7Days, item => +item.distance || 0)),
target: user.goal_distance,
unit: 'km'
});
}
setSevenDayStats(newGoals);
}, [workouts, user]);
return (
<>
<div className="w-full xl:flex-1 flex flex-col overflow-hidden mr-10">
<ThirtyDayStats thirtyDayStats={thirtyDayStats}/>
<div className="w-full xl:hidden">
<CalendarStats workouts={workouts} last5Weeks={last5WeeksList}/>
</div>
<SevenDayStats sevenDayStats={sevenDayStats} user={user}/>
</div>
<div className="xl:w-fit hidden xl:block xl:flex-none">
<CalendarStats workouts={workouts} last5Weeks={last5WeeksList}/>
</div>
</>
)
}
export default function MySpace() {
const navType = useNavigationType();
useEffect(() => {
if (navType === "POP") {
document.body.classList.remove("body-no-scroll");
}
}, [navType]);
const {
data: user,
error: userError,
isLoading: userLoading,
} = useGetUserByIdQuery('me');
const {
data: workouts,
error: workoutsError,
isLoading: workoutsIsLoading,
refetch: refetchWorkouts,
isFetching: workoutsIsFetching,
} = useGetWorkoutsQuery(undefined, {
pollingInterval: 10800000, // 3 hours
});
const {
data: competitions,
error: competitionError,
isLoading: competitionLoading,
isSuccess: competitionIsSuccess
} = useGetCompetitionsQuery(undefined, {
pollingInterval: 10800000, // 3 hours
});
const [searchParams, setSearchParams] = useSearchParams();
const {search} = useLocation();
const query = new URLSearchParams(search);
const searchTermWelcome = query.get('welcome'); // null if not present
const searchTermJoin = query.get('join'); // null if not present
const [welcomeStep, setWelcomeStep] = useState(0);
const [welcomeMessage, setWelcomeMessage] = useState(false);
const [linkStrava, setLinkStrava] = useState(false);
const [joinCompetition, setJoinCompetition] = useState(false);
useEffect(() => {
if (searchTermWelcome !== null && welcomeMessage === false && linkStrava === false && joinCompetition === false) {
// Step 1: Join competition
if (welcomeStep === 0) {
if (searchTermJoin !== null) {
setJoinCompetition(searchTermJoin);
}
setWelcomeStep(1);
// Step 2: Welcome message
} else if (welcomeStep === 1) {
searchParams.delete('join');
setSearchParams(searchParams);
setWelcomeMessage(true);
setWelcomeStep(2);
// Step 3: Link Strava
} else if (welcomeStep === 2) {
setLinkStrava(true);
setWelcomeStep(3);
searchParams.delete('welcome');
setSearchParams(searchParams);
}
}
}, [welcomeStep, welcomeMessage, linkStrava, joinCompetition])
if (userError) {
console.log('Error retrieving user:', userError);
return <PageWrapper additionClasses="h-screen flex items-center justify-center"><ErrorBoxSection
errorMsg={userError?.status + ' / ' + (userError?.error || userError?.message || userError?.data?.detail)}/></PageWrapper>;
}
return (
<PageWrapper>
<NavMenu page={'my'}/>
<div className="container mx-auto p-4">
<div className="w-full">
{
(userLoading || workoutsIsLoading) ? (
<SectionLoader height={"h-48 mb-4"}/>
) : (userError) ? (
<ErrorBoxSection additionalClasses="mb-4"
errorMsg={userError?.status + ' / ' + (userError?.error || userError?.message || userError?.data?.detail)}/>
) : (
<WelcomeBox user={user} workouts={workouts} setLinkStrava={setLinkStrava}/>
)
}
</div>
{
(userLoading || workoutsIsLoading) ? (
<SectionLoader height={"w-full h-80 mb-4"}/>
) : (workoutsError) ? (
<ErrorBoxSection additionalClasses="mb-4"
errorMsg={workoutsError?.status + ' / ' + (workoutsError?.error || workoutsError?.message || workoutsError?.data?.detail)}/>
) : (
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 w-full flex flex-col xl:flex-row mb-4">
<StatsBox workouts={workouts} user={user}/>
</div>
)
}
<div className="w-full flex flex-col xl:flex-row">
<div className="order-2 w-full xl:order-1 xl:w-2/3 xl:mr-2">
{
(userLoading || workoutsIsLoading) ? (
<SectionLoader height={"h-80"}/>
) : (workoutsError) ? (
<ErrorBoxSection
errorMsg={workoutsError?.status + ' / ' + (workoutsError?.error || workoutsError?.message || workoutsError?.data?.detail)}/>
) : (
<WorkoutsBox workouts={workouts} user={user} setLinkStrava={setLinkStrava}/>
)
}
</div>
<div className="order-1 w-full xl:order-2 xl:w-1/3 xl:ml-2">
{
(userLoading || competitionLoading) ? (
<SectionLoader/>
) : (competitionError) ? (
<ErrorBoxSection additionalClasses="mb-4"
errorMsg={competitionError?.status + ' / ' + (competitionError?.error || competitionError?.message || competitionError?.data?.detail)}/>
) : (
<CompetitionsBox user={user} competitions={competitions} setJoinCompetition={setJoinCompetition}/>
)
}
</div>
</div>
</div>
{welcomeMessage && <HowToScreen setModal={setWelcomeMessage}/>}
{linkStrava && <LinkStravaScreen setModal={setLinkStrava}/>}
{joinCompetition && <JoinCompetitionForm setModalState={setJoinCompetition} join_code={searchTermJoin}/>}
</PageWrapper>
)
}

View file

@ -0,0 +1,778 @@
import React, {useEffect, useState} from "react";
import {Link, useLocation, useNavigationType, useParams} from "react-router-dom";
import {useDispatch} from 'react-redux';
import {useNavigate} from 'react-router-dom';
import {BarLoader, MoonLoader} from "react-spinners";
import {usersApi} from '../utils/reducers/usersSlice';
import {workoutsApi} from '../utils/reducers/workoutsSlice';
import {competitionsApi} from '../utils/reducers/competitionsSlice';
import {statsApi} from '../utils/reducers/statsSlice';
import {feedApi} from '../utils/reducers/feedSlice';
import {PageWrapper} from "../utils/miscellaneous";
import {sentryError} from "../utils/reducers/baseQueryWithReauth";
function BaseHome({children}) {
const navType = useNavigationType();
useEffect(() => {
if (navType === "POP") {
document.body.classList.remove("body-no-scroll");
}
}, [navType]);
return (
<div className="relative min-h-screen bg-cover bg-center"
style={{backgroundImage: "url('/running.webp')"}}>
<div className="absolute inset-0 bg-black/50 z-0"></div>
<div className="relative z-10 flex items-center justify-center min-h-screen px-0 md:px-4">
<div className="p-8 rounded-xl shadow-lg max-w-2xl text-center text-white my-4">
<h1 className="text-4xl md:text-5xl font-bold mb-6">Workout Challenge</h1>
<div>
<p className="text-l md:text-xl mb-8">
Compete with friends and co-workers <b>across devices</b> <small
className="hidden md:inline-block"> (Apple / Android / Garmin /
etc.) </small> <br className="hidden lg:inline-block"/>
using the <b>metrics you want</b> to use <small className="hidden md:inline-block"> (km /
minutes / kcal / # of times /
etc.) </small> <br className="hidden lg:inline-block"/>
<b>respecting your privacy</b><small className="hidden md:inline-block"> (no data is sold or
shared and only for the competition
necessary data synced with Strava)</small>
</p>
</div>
{children}
</div>
</div>
</div>
)
}
function useWaitForLocalStorage(key, expectedValue, interval = 500) {
const [matched, setMatched] = useState(false);
useEffect(() => {
const check = () => {
const value = localStorage.getItem(key);
if (value === expectedValue) {
setMatched(true);
}
};
check(); // Initial check
if (!matched) {
const id = setInterval(() => {
check();
}, interval);
return () => clearInterval(id);
}
}, [key, expectedValue, matched]);
return matched;
}
function LogoutPage() {
const dispatch = useDispatch();
const navigate = useNavigate();
console.log('Clear localStorage as new user wants to register');
dispatch(usersApi.util.resetApiState());
dispatch(workoutsApi.util.resetApiState());
dispatch(competitionsApi.util.resetApiState());
dispatch(statsApi.util.resetApiState());
dispatch(feedApi.util.resetApiState());
localStorage.clear();
const matched = useWaitForLocalStorage("refresh_token", null);
if (matched) {
navigate("/login");
}
;
return (
<BaseHome>
<div className="flex justify-center">
<LoadingForm/>
</div>
</BaseHome>
)
}
function WelcomePage() {
const location = useLocation();
return (
<BaseHome>
<div>
<Link to={`/signup/${location.search}`}
className="bg-sky-800 text-white shadow-2xl mx-2 px-6 py-3 rounded-full font-semibold hover:bg-sky-700 transition">
Create Account
</Link>
<Link to={`/login/${location.search}`}
className="bg-white text-sky-800 shadow-2xl mx-2 px-6 py-3 rounded-full font-semibold hover:bg-gray-300 transition">
Log In
</Link>
</div>
</BaseHome>
);
}
const waitForLocalStorage = (key, timeout = 5000) =>
new Promise((resolve, reject) => {
const start = Date.now();
const interval = setInterval(() => {
const val = localStorage.getItem(key);
if (val !== null) {
clearInterval(interval);
resolve(val);
} else if (Date.now() - start > timeout) {
clearInterval(interval);
reject(new Error('Timeout waiting for localStorage key'));
}
}, 100);
});
const LoadingForm = () => {
return (
<div className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 flex items-center justify-center"
style={{minWidth: '310px'}}>
<BarLoader height={6} width={200}/>
</div>
)
}
const apiCreateAccount = async (email, first_name, last_name, gender, password) => {
try {
const response = await fetch((process.env.REACT_APP_BACKEND_URL || '') + '/api/user/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email.toLowerCase(),
first_name: first_name,
last_name: last_name,
gender: gender,
password: password
}),
});
if (response.ok) {
console.log('Registration Success');
return [true, undefined];
} else {
console.log('Registration Error:', response.status, response.statusText);
let error_msg = 'Registration Error (' + response.status + '): ' + response.statusText + ', ';
try {
const error = await response.json();
for (const key in error) {
error_msg += key + ': ' + error[key] + ', ';
}
} catch (e) {
error_msg += ' Unknown error';
}
return [false, error_msg];
}
} catch (error) {
console.error('Network or server error during registration:', error);
// Capture network errors in Sentry
sentryError({
result: error,
errorSource: 'manual-api',
endpointName: 'register',
});
return [false, 'Network or server error occurred. Please try again.'];
}
}
const apiLogin = async (email, password) => {
try {
const response = await fetch((process.env.REACT_APP_BACKEND_URL || '') + '/api/token/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email.toLowerCase(),
password: password
}),
});
if (response.ok) {
console.log('Login Successful');
const token = await response.json();
localStorage.setItem('access_token', token.access);
localStorage.setItem('refresh_token', token.refresh);
return [true, undefined];
} else {
console.log('Login Error:', response.status, response.statusText);
let parsedError = null;
try {
parsedError = await response.json();
} catch (e) {
parsedError = null;
}
return [false, response.statusText + ' (' + response.status + ') - ' + (parsedError ? parsedError.detail : 'Unknown error')];
}
} catch (error) {
console.error('Network or server error during login:', error);
// Capture network errors in Sentry
sentryError({
result: error,
errorSource: 'manual-api',
endpointName: 'login',
});
return [false, 'Network or server error occurred. Please try again.'];
}
};
const apiRequestNewPassword = async (email) => {
try {
const response = await fetch((process.env.REACT_APP_BACKEND_URL || '') + '/api/password-reset/request/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
}),
});
if (response.ok) {
console.log('Password Reset Request Successful');
return [true, undefined];
} else {
console.log('Password Reset Request Error:', response.status, response.statusText, response);
let parsedError = null;
try {
parsedError = await response.json();
} catch (e) {
parsedError = null;
}
return [false, response.statusText + ' (' + response.status + ') - ' + (parsedError ? parsedError.detail : 'Unknown error')];
}
} catch (error) {
console.error('Network or server error during password reset request:', error);
// Capture network errors in Sentry
sentryError({
result: error,
errorSource: 'manual-api',
endpointName: 'new-password-request',
});
return [false, 'Network or server error occurred. Please try again.'];
}
};
const apiSetNewPassword = async (uid, token, newPassword) => {
try {
const response = await fetch((process.env.REACT_APP_BACKEND_URL || '') + '/api/password-reset/confirm/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
uid: uid,
token: token,
new_password: newPassword,
}),
});
if (response.ok) {
console.log('Set New Password Successful');
return [true, undefined];
} else {
console.log('Set New Password Error:', response.status, response.statusText, response);
let parsedError = null;
try {
parsedError = await response.json();
} catch (e) {
parsedError = null;
}
return [false, response.statusText + ' (' + response.status + ') - ' + (parsedError ? parsedError.detail : 'Unknown error')];
}
} catch (error) {
console.error('Network or server error during password reset:', error);
// Capture network errors in Sentry
sentryError({
result: error,
errorSource: 'manual-api',
endpointName: 'set-new-password',
});
return [false, 'Network or server error occurred. Please try again.'];
}
}
const apiRefreshToken = async (refreshToken) => {
try {
const response = await fetch((process.env.REACT_APP_BACKEND_URL || '') + '/api/token/refresh/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh: refreshToken,
}),
});
if (response.ok) {
console.log('Token Refresh Successful');
const token = await response.json();
localStorage.setItem('access_token', token.access);
return [true, undefined];
} else {
console.log('Token Refresh Error:', response.status, response.statusText);
let error = null;
try {
error = await response.json();
} catch (e) {
error = { detail: 'Unknown error' };
}
localStorage.removeItem('refresh_token');
return [false, response.statusText + ' (' + response.status + ') - ' + error.detail];
}
} catch (error) {
console.error('Network or server error during token refresh:', error);
localStorage.removeItem('refresh_token');
// Capture network errors in Sentry
sentryError({
result: error,
errorSource: 'manual-api',
endpointName: 'refresh-token',
});
return [false, 'Network or server error occurred during token refresh. Please try again.'];
}
};
function RegisterPage() {
const dispatch = useDispatch();
const location = useLocation();
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState([]);
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
const email = e.target.email.value;
const first_name = e.target.first_name.value;
const last_name = e.target.last_name.value;
const gender = e.target.gender.value;
const password1 = e.target.password1.value;
const password2 = e.target.password2.value;
if (typeof (email) === "undefined" || email === null || email === "") {
setErrorMessage(['Please enter an email address.']);
} else if (typeof (first_name) === "undefined" || first_name === null || first_name === "") {
setErrorMessage(['Please enter a first name.']);
} else if (typeof (password1) === "undefined" || password1 === null || password1 === "") {
setErrorMessage(['Please enter a password.']);
} else if (password1 !== password2) {
setErrorMessage(['Passwords do not match.']);
} else {
setIsLoading(true);
const [success_register, msg_register] = await apiCreateAccount(email, first_name, last_name, gender, password1);
const [success_login, msg_login] = await apiLogin(email, password1);
const params = new URLSearchParams(location.search);
params.set('welcome', 'true');
if (success_register && success_login) {
await waitForLocalStorage('access_token');
console.log('Register and Login Successful - redirect ', localStorage.getItem('access_token'));
navigate(`/dashboard/?${params.toString()}`);
} else if (!success_register) {
setErrorMessage(msg_register.split(", "));
} else if (!success_login) {
setErrorMessage(['Successful Registration', 'Login ' + msg_login]);
navigate(`/dashboard/?${params.toString()}`);
}
setIsLoading(false);
}
};
const [gender, setGender] = useState('');
const handleDropDownChange = (e) => {
setGender(e.target.value);
}
useEffect(() => {
console.log('Clear localStorage as new user wants to register');
dispatch(usersApi.util.resetApiState());
dispatch(workoutsApi.util.resetApiState());
dispatch(competitionsApi.util.resetApiState());
dispatch(statsApi.util.resetApiState());
dispatch(feedApi.util.resetApiState());
localStorage.clear();
}, []);
return (
<BaseHome>
{
isLoading ? <LoadingForm/> : (
<div className="flex justify-center">
<form className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" style={{minWidth: '310px'}}
onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
Email*
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="email" type="text" placeholder="Email" autoFocus="True" tabIndex="1"/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="first_name">
First Name*
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="first_name" type="text" placeholder="First Name" tabIndex="2"/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="last_name">
Last Name
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="last_name" type="text" placeholder="Last Name" tabIndex="3"/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="gender">
Gender
</label>
<select
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="gender" value={gender} tabIndex="4" onChange={handleDropDownChange}>
<option value=''>--Please choose an option--</option>
<option value='M'>Male</option>
<option value='F'>Female</option>
<option value='O'>Other</option>
<option value=''>Don't want to tell</option>
</select>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password1">
Password*
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password1" type="password" placeholder="******************" tabIndex="5"/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password2">
Repeat Password*
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password2" type="password" placeholder="******************" tabIndex="6"/>
</div>
<div className="flex items-center justify-between">
<button
className="bg-sky-800 hover:bg-sky-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline mr-2 sm:mr-10"
type="submit" tabIndex="7">
Create Account
</button>
<Link to={`/login/${location.search}`}
className="inline-block align-baseline font-bold text-sm text-sky-800 hover:text-sky-600 ml-2"
tabIndex="8">
Go to SignIn
</Link>
</div>
<p id="errors" className="text-red-500 text-xs italic mt-5">
{errorMessage.map((item, index) => (
<span key={'error' + index}>{item}<br/></span>
))}
</p>
</form>
</div>
)}
</BaseHome>
);
}
function LogInPage() {
const dispatch = useDispatch();
const location = useLocation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const params = new URLSearchParams(window.location.search);
// handle submit/login action from login form
async function handleSubmit(e) {
e.preventDefault();
setErrorMessage(null);
setIsLoading(true);
const email = e.target.email.value;
const password = e.target.password.value;
const [success, msg] = await apiLogin(email, password);
if (success) {
// success logging in - redirect to dashboard
await waitForLocalStorage('access_token');
setIsLoading(false);
console.log('redirect', localStorage.getItem('access_token'));
if (params.has('redirect')) {
const redirectUrl = decodeURIComponent(params.get('redirect'));
console.log('Redirect to:', redirectUrl);
navigate(redirectUrl);
} else {
navigate(`/dashboard/${location.search}`);
}
} else {
// error logging in - user try again
setErrorMessage(msg);
setIsLoading(false);
}
}
// check if refreshToken already exists and user is already logged in
async function checkRefreshToken(refreshToken) {
console.log('refresh_token already exists - check if still valid');
setIsLoading(true);
const [success, msg] = await apiRefreshToken(refreshToken);
if (success) {
// success refreshing access_token - redirecting to dashboard
await waitForLocalStorage('access_token');
console.log('refresh_token exists and is valid - redirect ', localStorage.getItem('access_token'));
navigate(`/dashboard/${location.search}`);
} else {
// error refreshing access_token - manual login required
localStorage.removeItem('refresh_token');
console.log('refresh_token exists but expired - new login required');
}
setIsLoading(false);
}
useEffect(() => {
dispatch(usersApi.util.resetApiState());
dispatch(workoutsApi.util.resetApiState());
dispatch(competitionsApi.util.resetApiState());
dispatch(statsApi.util.resetApiState());
dispatch(feedApi.util.resetApiState());
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken !== null) {
localStorage.removeItem('access_token');
checkRefreshToken(refreshToken);
}
}, []);
return (
<BaseHome children={
<div className="flex justify-center">
{
isLoading ? <LoadingForm/> : (
<form className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" style={{minWidth: '310px'}}
onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
Email
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="email" type="text" placeholder="Email" autoFocus="True" tabIndex="1"
required={true}/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password" type="password" placeholder="******************" tabIndex="2"
required={true}/>
<Link to={`/password/`} className="button italic text-sm text-sky-800 hover:text-sky-600"
tabIndex="3">
Forgot Password?
</Link>
</div>
<div className="flex items-center justify-between">
<button
className="bg-sky-800 hover:bg-sky-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline mr-2 sm:mr-16"
type="submit" tabIndex="4">
Sign In
</button>
<Link to={`/signup/${location.search}`}
className="inline-block align-baseline font-bold text-sm text-sky-800 hover:text-sky-600 ml-2"
tabIndex="5">
Create Account
</Link>
</div>
<p className="text-red-500 text-xs italic mt-5">{errorMessage}</p>
</form>
)
}
</div>
}/>
);
}
function ResetPasswordPage() {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
// handle submit/reset action from reset password form
async function handleSubmit(e) {
e.preventDefault();
setErrorMessage(null);
setIsLoading(true);
const email = e.target.email.value;
const [success, msg] = await apiRequestNewPassword(email);
if (success) {
// success reset request - redirect to start page
window.alert('Success! Please check your email for a reset link.');
setIsLoading(false);
console.log('redirect to login page');
navigate(`/`);
} else {
// error reset request - user try again
setErrorMessage(msg);
setIsLoading(false);
}
}
return (
<BaseHome children={
<div className="flex justify-center">
{
isLoading ? <LoadingForm/> : (
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" style={{minWidth: '310px'}}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email" autoFocus="True">
Email
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="email" type="text" placeholder="Email" autoFocus="True" tabIndex="1"/>
</div>
<div className="flex items-center justify-between">
<button
className="bg-sky-800 hover:bg-sky-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline mr-2 sm:mr-16"
type="submit" tabIndex="2">
Reset Password
</button>
<Link to="/login"
className="inline-block align-baseline font-bold text-sm text-sky-800 hover:text-sky-600 ml-2"
tabIndex="3">
Back to SignIn
</Link>
</div>
<p className="text-red-500 text-xs italic mt-5">{ errorMessage }</p>
</form>
)}
</div>
}/>
);
}
function SetNewPasswordPage() {
const {id, token} = useParams();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
// handle submit/reset action from reset password form
async function handleSubmit(e) {
e.preventDefault();
setErrorMessage(null);
setIsLoading(true);
const password1 = e.target.password1.value;
const password2 = e.target.password2.value;
if (typeof (password1) === "undefined" || password1 === null || password1 === "") {
setErrorMessage(['Please enter a password.']);
setIsLoading(false);
} else if (password1 !== password2) {
setErrorMessage(['Passwords do not match.']);
setIsLoading(false);
} else {
const [success, msg] = await apiSetNewPassword(id, token, password1);
if (success) {
// success reset password - redirect to login page
setIsLoading(false);
console.log('redirect to login page');
navigate(`/login/`);
} else {
// error resetting password - user try again
setErrorMessage(msg);
setIsLoading(false);
}
}
}
return (
<BaseHome children={
<div className="flex justify-center">
{
isLoading ? <LoadingForm/> : (
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4" style={{minWidth: '45%'}}>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password1">
Password
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password1" type="password" placeholder="******************" tabIndex="1" autoFocus={true}/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password2">
Repeat Password
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password2" type="password" placeholder="******************" tabIndex="2"/>
</div>
<div className="flex items-center justify-between">
<button
className="bg-sky-800 hover:bg-sky-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline mx-auto sm:mx-16"
type="submit" tabIndex="3">
Reset Password
</button>
</div>
<p className="text-red-500 text-xs italic mt-5">{ errorMessage }</p>
</form>
)}
</div>
}/>
);
}
// NotFound page
const NotFound = () => {
return (
<PageWrapper>
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-4xl font-bold mb-4">404</h1>
<p className="text-xl mb-4">Page Not Found</p>
<p className="mb-8">The page you're looking for doesn't exist or has been moved.</p>
<Link to="/dashboard" className="text-blue-500 hover:text-blue-700">
Go to Home
</Link>
</div>
</PageWrapper>
);
};
export {WelcomePage, NotFound, RegisterPage, LogInPage, LogoutPage, ResetPasswordPage, SetNewPasswordPage};

View file

@ -0,0 +1,149 @@
import {useGetUserByIdQuery, usersApi} from "../utils/reducers/usersSlice";
import {ClipLoader} from "react-spinners";
import React, {useEffect} from "react";
import {useLocation, useNavigate} from "react-router-dom";
import {useDeleteWorkoutMutation, workoutsApi} from "../utils/reducers/workoutsSlice";
import {useLinkStravaMutation} from "../utils/reducers/linkSlice";
import {useDispatch} from "react-redux";
import {ErrorBoxSection, PageWrapper} from "../utils/miscellaneous";
import {SectionLoader} from "../utils/loaders";
export function InitStravaLink() {
const {
data: user,
error: userError,
isLoading: userIsLoading,
isSuccess: userIsSuccess
} = useGetUserByIdQuery('me');
const baseUrl = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ''}/strava/return/`;
const encodedBaseUrl = encodeURIComponent(baseUrl);
const isIOS = () => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
};
const urlSecondPart = 'client_id=156364&response_type=code&approval_prompt=force&scope=profile:read_all,activity:read_all&redirect_uri=' + encodedBaseUrl;
let urlFirstPart = '';
if (isIOS()) {
urlFirstPart = 'strava://oauth/mobile/authorize?';
} else {
urlFirstPart = 'https://www.strava.com/oauth/mobile/authorize?';
}
console.log('Strava linkage url:', baseUrl);
useEffect(() => {
// redirect if user valid and logged in
if (userIsSuccess) {
console.log('Redirect to Strava Auth page');
window.location.href = (urlFirstPart + urlSecondPart);
}
}, [userIsSuccess]);
// loading screen
if (userIsLoading) return (
<PageWrapper additionClasses="h-screen flex items-center justify-center">
<SectionLoader height={"w-2/3 h-80 mb-4"}/>
</PageWrapper>
);
// error catching
if (userError) return (
<PageWrapper additionClasses="h-screen flex items-center justify-center">
<ErrorBoxSection error={userError}/>
</PageWrapper>
)
// redirect screen
return (
<PageWrapper>
If you are not redirected automatically, follow this <a className="text-blue-500 hover:underline"
href={(urlFirstPart + urlSecondPart)}>link to
Strava</a>.
</PageWrapper>
)
}
export function ReturnStravaLink() {
const navigate = useNavigate();
const dispatch = useDispatch();
const [linkStrava, {
data: linkStravaData,
error: linkStravaError,
isLoading: linkStravaIsLoading,
isSuccess: linkStravaIsSuccess,
isError: linkStravaIsError,
}] = useLinkStravaMutation();
const {search} = useLocation();
const query = new URLSearchParams(search);
const searchCode = query.get('code'); // null if not present
const searchScope = query.get('scope'); // null if not present
const [errorMsg, setErrorMsg] = React.useState(null);
useEffect(() => {
if (!(linkStravaIsLoading || linkStravaIsSuccess || linkStravaIsError)) {
if (searchCode === null) {
// send user back to set up link page
console.log('No auth strava code');
setErrorMsg('No auth code received from Strava. Please try again.');
navigate('/strava/link');
} else {
linkStrava(searchCode)
.unwrap()
.then(() => {
// successful linkage - redirect user to dashboard
console.log('Successfully linked Strava');
dispatch(workoutsApi.util.invalidateTags(['Workout']));
dispatch(usersApi.util.invalidateTags(['User']));
navigate('/dashboard');
})
.catch((err) => {
// send user back to set up link page
console.error('Strava linkage error (1):', err);
setErrorMsg(`Strava linkage error (${err?.status} / ${err?.data?.message}). Please try again.`);
});
}
}
}, [])
useEffect(() => {
if (linkStravaError) {
console.error('Strava linkage error (2):', linkStravaError);
setErrorMsg(`Strava linkage error - ${linkStravaError?.status} / ${linkStravaError?.data?.message}. Please try again.`);
}
}, [linkStravaError])
// error message
if (errorMsg) {
return (
<PageWrapper additionClasses="h-screen flex items-center justify-center">
<div className="text-center">
<p className="p-2">{errorMsg}</p>
<p className="p-0.5"><a className="text-blue-500 hover:underline" href='/strava/link'>Click here
to <b>try again linking Strava</b></a></p>
<p className="p-0.5"><a className="text-blue-500 hover:underline" href='/dashboard'>Or go back to
the <b>Dashboard</b></a></p>
</div>
</PageWrapper>
)
}
// loading screen
return (
<PageWrapper additionClasses="h-screen flex items-center justify-center">
<SectionLoader height={"w-2/3 h-80 mb-4"} message={"Hang in there! Importing your workouts from Strava..."} />
</PageWrapper>
)
}

View file

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View file

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View file

@ -0,0 +1,107 @@
/* Workout Modal */
.modal_sport_type {
width: 45%;
}
.modal_start_date {
width: 25%;
}
.modal_start_time {
width: 15%;
}
.modal_duration {
width: 15%;
}
.modal_intensity_category {
width: 60%;
}
.modal_kcal {
width: 20%;
}
.modal_distance {
width: 20%;
}
@media screen and (max-width: 600px) {
.modal_sport_type, .modal_start_date, .modal_start_time, .modal_duration, .modal_intensity_category, .modal_kcal, .modal_distance {
width: 100%;
}
}
/* Competition Modal */
.modal_name {
width: 50%;
}
.modal_start_date {
width: 25%;
}
.modal_end_date {
width: 25%;
}
@media screen and (max-width: 600px) {
.modal_name, .modal_start_date, .modal_end_date {
width: 100%;
}
}
/* Personal Goals Modal */
.modal_goal_active_days {
width: 33%;
}
.modal_goal_workout_minutes {
width: 33%;
}
.modal_goal_distance {
width: 33%;
}
@media screen and (max-width: 600px) {
.modal_goal_active_days, .modal_goal_workout_minutes, .modal_goal_distance {
width: 100%;
}
}
/* Settings Modal */
.modal_email {
width: 50%;
}
.modal_username {
width: 33%;
}
.modal_first_name {
width: 33%;
}
.modal_last_name {
width: 33%;
}
.modal_gender {
width: 33%;
}
@media screen and (max-width: 600px) {
.modal_email, .modal_username, .modal_first_name, .modal_last_name, .modal_gender {
width: 100%;
}
}

View file

@ -0,0 +1,84 @@
import ContentLoader from "react-content-loader";
import React from "react";
import {BeatLoader} from "react-spinners";
import {BoxSection} from "./miscellaneous";
function TextLoader({lines = [{width: "410", height: "6"}, {width: "380", height: "6"}, {width: "178", height: "6"}]}) {
let width = 0;
let height = 0;
let lineProps = []
for (const line of lines) {
lineProps.push({width: line.width, height: line.height, x: 0, y: height})
width = Math.max(width, parseInt(line.width) + 10);
height += (parseInt(line.height) + 10);
}
return (
<ContentLoader
speed={2}
width={width}
height={height}
viewBox={"0 0 " + width + " " + height}
backgroundColor="#f3f3f3"
foregroundColor="#ecebeb"
>
{lineProps.map((line, index) => (
<rect key={"line" + index} x={line.x} y={line.y} rx="3" ry="3" width={line.width} height={line.height}/>
))}
</ContentLoader>
)
}
function TableLoader({cols = [{width: "100"}, {width: "100"}, {width: "200"}, {width: "150"}, {width: "50"}]}, rows = 5) {
let width = 0;
let height = 70;
let lineProps = []
for (let i = 0; i < rows; i++) {
let width_i = 20;
for (const col of cols) {
lineProps.push({width: col.width, height: 25, x: width_i, y: height})
width_i += parseInt(col.width) + 20;
width = Math.max(width, width_i);
}
height += (20 + 20);
}
return (
<ContentLoader
width={width}
height={height}
viewBox={"0 0 " + width + " " + height}
backgroundColor="#f3f3f3"
foregroundColor="#ecebeb"
>
<rect x="20" y="0" rx="5" ry="5" width="153" height="40"/>
{lineProps.map((line, index) => (
<rect key={"line" + index} x={line.x} y={line.y} rx="4" ry="4" width={line.width} height={line.height}/>
))}
</ContentLoader>
)
}
TableLoader.metadata = {
name: 'Mohd Arif Un',
github: 'arif-un',
description: 'Data Table skeleton',
filename: 'DataTable',
}
function SectionLoader({height = "h-64", message = null}) {
return (
<BoxSection additionalClasses={"flex flex-col items-center justify-center " + height}>
{(message !== null) && <><div className="text-gray-800 dark:text-gray-200 mb-3">{message}</div></>}
<div><BeatLoader color="rgb(209 213 219)" /></div>
</BoxSection>
)
}
export {TextLoader, TableLoader, SectionLoader};

View file

@ -0,0 +1,17 @@
export const loadState = () => {
try {
const serialized = localStorage.getItem('appState');
return serialized ? JSON.parse(serialized) : undefined;
} catch {
return undefined;
}
};
export const saveState = (state) => {
try {
const serialized = JSON.stringify(state);
localStorage.setItem('appState', serialized);
} catch {
// Ignore write errors
}
};

View file

@ -0,0 +1,120 @@
import React from "react";
import {AlertCircle} from "lucide-react";
import { useEffect, useState } from 'react';
import { createAsyncThunk } from '@reduxjs/toolkit';
import {useDispatch} from "react-redux";
function throwErrorWithCode(message, errorCode) {
const error = new Error(message);
error.code = errorCode;
error.status = errorCode;
error.statusText = message;
error.ok = false;
throw error;
}
function deepDiff(obj1, obj2) {
const diff = {};
const keys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
keys.forEach(key => {
const val1 = obj1[key];
const val2 = obj2[key];
if (typeof val1 === 'object' && val1 && typeof val2 === 'object' && val2) {
const nested = deepDiff(val1, val2);
if (Object.keys(nested).length > 0) diff[key] = nested;
} else if (val1 !== val2) {
diff[key] = {from: val1, to: val2};
}
});
return diff;
}
function compareDictLists(oldDict, newDict) {
const oldMap = Object.fromEntries(oldDict.map(item => [item.id, item]));
const newMap = Object.fromEntries(newDict.filter(i => i.id).map(item => [item.id, item]));
const newEntries = newDict.filter(item => !item.id);
const deletedEntries = oldDict.filter(item => !newMap[item.id]);
const changedEntries = [];
for (const id in newMap) {
if (oldMap[id]) {
const diff = deepDiff(oldMap[id], newMap[id]);
if (Object.keys(diff).length > 0) {
changedEntries.push({id, index: oldMap[id]?.index, changes: diff});
}
}
}
return {newEntries, deletedEntries, changedEntries};
}
function BoxSection({additionalClasses = '', children}) {
return (
<div className={"bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 " + additionalClasses}>
{children}
</div>
)
}
export const resetStoreAsync = createAsyncThunk('store/reset', async (_, {dispatch}) => {
dispatch({type: 'RESET_STORE'});
});
function ErrorBoxSection({errorMsg, additionalClasses = ''}) {
const dispatch = useDispatch();
async function handleReload() {
await dispatch(resetStoreAsync());
console.log('Store has been reset');
window.location.reload();
}
return (
<BoxSection additionalClasses={"flex items-center justify-center " + additionalClasses}>
<div
className="flex items-start gap-3 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded max-w-md">
<AlertCircle className="w-20 h-20 mt-1 text-red-700"/>
<div>
<p className="font-semibold">Oops, that didn't work!</p>
<p>Please <a href='' className='text-blue-500 hover:underline' onClick={() => handleReload()}>reset & reload the page (click here)</a>. If the issue remains, <a className='text-blue-500 hover:underline' target='_self' href="/logout">log out (click here)</a> and log back in. If it still persists, contact the administrator.</p>
<br/>
<p className="font-semibold italic">This error occurred:</p>
<p className="bg-red-200 text-sm p-2 rounded font-mono">{errorMsg}</p>
</div>
</div>
</BoxSection>
)
}
function PageWrapper({additionClasses = '', children}) {
return (
<div className={"min-h-screen bg-gray-100 dark:bg-gray-900 dark:text-white p-2 sm:p-6 " + additionClasses}>
{children}
</div>
)
}
function useDarkMode() {
const [isDarkMode, setIsDarkMode] = useState(
window.matchMedia('(prefers-color-scheme: dark)').matches
);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => setIsDarkMode(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return isDarkMode;
}
export {throwErrorWithCode, deepDiff, compareDictLists, PageWrapper, BoxSection, ErrorBoxSection, useDarkMode};

View file

@ -0,0 +1,57 @@
import {useGetCompetitionsQuery} from "./reducers/competitionsSlice";
import {Link} from "react-router-dom";
import React, {useState} from "react";
import CompetitionForm from "../forms/competitionForm";
import {LogOut, BadgeHelp} from "lucide-react";
import SupportModal from "../forms/supportModal";
export default function NavMenu({page}) {
const [showEditCompetitionModal, setShowEditCompetitionModal] = useState(false);
const [showSupportModal, setShowSupportModal] = useState(false);
const {
data: competitions,
error: competitionError,
isLoading: competitionLoading,
isSuccess: competitionIsSuccess
} = useGetCompetitionsQuery();
return (
<>
<div className="overflow-x-auto mx-2">
<div className="flex items-center justify-between">
<div className="mr-auto"></div>
<div className="bg-white dark:bg-gray-700 rounded-full shadow-sm w-max mx-auto">
<nav className="flex space-x-1 sm:space-x-4 text-sm font-medium text-gray-600 whitespace-nowrap">
<Link to='/dashboard'
className={"px-4 py-2 rounded-full transition-colors " + ((page === 'my' ? "bg-sky-800 text-white" : "hover:text-light-blue dark:text-white"))}>My
Space
</Link>
{(competitionIsSuccess) ? Object.entries(competitions).map(([_, competition], i) => (
<Link key={"key" + competition.id} to={`/competition/${competition.id}`}
className={"px-4 py-2 rounded-full transition-colors " + ((page === `${competition.id}` ? "bg-sky-800 text-white" : "hover:text-light-blue dark:text-white"))}>
{competition.name}
</Link>
)) : null}
<div onClick={() => setShowEditCompetitionModal(true)}
className="px-4 py-2 rounded-full transition-colors hover:text-light-blue dark:text-white cursor-pointer">+
Create Competition
</div>
</nav>
</div>
<div className="flex pl-2 space-x-2 ml-auto">
<Link to={'/logout'} className="bg-white dark:bg-gray-700 rounded-full shadow-sm w-max p-2">
<LogOut className="w-5 h-5"/>
</Link>
<button onClick={() => setShowSupportModal(true)} className="bg-white dark:bg-gray-700 rounded-full shadow-sm w-max p-2">
<BadgeHelp className="w-5 h-5"/>
</button>
</div>
</div>
</div>
{(showEditCompetitionModal) && <CompetitionForm setModalState={setShowEditCompetitionModal} id={showEditCompetitionModal}/>}
{(showSupportModal) && <SupportModal setModalState={setShowSupportModal}/>}
</>
);
}

View file

@ -0,0 +1,35 @@
import {createSlice} from '@reduxjs/toolkit';
const authSlice = createSlice({
name: 'auth',
initialState: {
authToken: null,
refreshToken: null,
isAuthenticated: false,
},
reducers: {
setToken: (state, action) => {
state.authToken = action.payload.authToken;
state.refreshToken = action.payload.refreshToken;
state.isAuthenticated = true;
localStorage.setItem('refresh_token', action.payload.refreshToken);
localStorage.setItem('access_token', action.payload.authToken);
},
updateToken: (state, action) => {
state.authToken = action.payload.authToken;
state.isAuthenticated = true;
localStorage.setItem('access_token', action.payload.authToken);
},
logout: (state) => {
state.authToken = null;
state.refreshToken = null;
state.isAuthenticated = false;
localStorage.removeItem('refresh_token');
localStorage.removeItem('access_token');
},
},
});
export const {setToken, updateToken, logout} = authSlice.actions;
export default authSlice.reducer;

View file

@ -0,0 +1,120 @@
import {fetchBaseQuery} from '@reduxjs/toolkit/query/react';
import * as Sentry from '@sentry/react';
import {throwErrorWithCode} from '../miscellaneous';
console.log('API URL:', process.env.REACT_APP_BACKEND_URL);
const baseQuery = fetchBaseQuery({
baseUrl: (process.env.REACT_APP_BACKEND_URL || '') + '/api/',
prepareHeaders: (headers) => {
const token = localStorage.getItem('access_token');
headers.set('Content-Type', 'application/json');
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
},
});
export function sentryError({result, errorSource, endpointName = undefined, queryArgs = undefined}) {
Sentry.withScope((scope) => {
// Add request query args
scope.setContext('Request', {
...queryArgs,
endpointName,
});
// Add error message
scope.setContext('Error', {
...result.error,
error: result.error?.error,
});
// Add API request specific performance data
const requestUrl = (process.env.REACT_APP_BACKEND_URL || '') + '/api/' + (queryArgs?.args?.url || '');
const resourceTimings = performance.getEntriesByType('resource')
.filter(entry => entry.name.includes(requestUrl))
.pop();
if (resourceTimings) {
scope.setContext('Request Timing', {
duration: resourceTimings.duration,
fetchStart: resourceTimings.fetchStart,
responseEnd: resourceTimings.responseEnd,
requestStart: resourceTimings.requestStart,
responseStart: resourceTimings.responseStart,
dnsTime: resourceTimings.domainLookupEnd - resourceTimings.domainLookupStart,
tcpTime: resourceTimings.connectEnd - resourceTimings.connectStart,
});
}
// Add additional properties
scope.setTag('network.online', navigator?.onLine);
scope.setTag('network.connection', navigator?.connection?.effectiveType);
scope.setTag('error.source', errorSource);
scope.setTag('error.status', result.error?.originalStatus || result.error?.status);
if ((result.error?.originalStatus || result.error?.status) >= 500) {
scope.setTag('error.type', 'server');
} else if ((result.error?.originalStatus || result.error?.status) >= 400) {
scope.setTag('error.type', 'client');
}
// Raise error
Sentry.captureException(
new Error(`API Request failed: ${result.error?.originalStatus || result.error?.status}`)
);
});
}
export const baseQueryWithReauth = async (args, api, extraOptions) => {
let result = await baseQuery(args, api, extraOptions);
// report to Sentry if not 401 (login access token needs refreshing) and 403 (forbidden - strava access rights insufficient) and 429 (too many strava sync requests) and 404 (not found after entry deletion)
if (result.error && result.error.status !== 401 && result.error.status !== 403 && result.error.status !== 429 && result.error.status !== 404) {
sentryError({
result: result,
errorSource: 'rtk-query',
endpointName: api?.endpoint,
queryArgs: {args, extraOptions},
});
}
// if 401 forbidden error refresh the access token
if (result.error && result.error.status === 401) {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
const currentUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?redirect=${currentUrl}`; // force redirect
throw throwErrorWithCode('(Error 401) The user is not authenticated (no refresh token). Please re-login.', 401);
}
// Try to refresh the token
const refreshResult = await baseQuery(
{
url: '/token/refresh/',
method: 'POST',
body: {refresh: refreshToken},
},
api,
extraOptions
);
if (refreshResult.data.access) {
// Save new tokens
localStorage.setItem('access_token', refreshResult.data.access);
if (refreshResult.data.refresh) {
localStorage.setItem('refresh_token', refreshResult.data.refresh);
}
// Retry original request
result = await baseQuery(args, api, extraOptions);
} else {
const currentUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?redirect=${currentUrl}`; // force redirect
throw throwErrorWithCode('(Error 401) The user is not authenticated (refresh token expired). Please re-login.', 401);
}
}
return result;
};

View file

@ -0,0 +1,58 @@
import {createApi} from '@reduxjs/toolkit/query/react';
import {baseQueryWithReauth} from './baseQueryWithReauth';
export const competitionsApi = createApi({
reducerPath: 'competitionsApi',
baseQuery: baseQueryWithReauth,
tagTypes: ['Competition'],
keepUnusedDataFor: 60 * 60 * 12, // 12 hours cache (default is 60s)
refetchOnMountOrArgChange: 60 * 60 * 3, // Refetch if older than 3 hours
endpoints: (builder) => ({
getCompetitions: builder.query({
query: (params = {}) => ({
url: `competition/`, //?${new URLSearchParams(params).toString()}
method: 'GET',
params: params,
}),
providesTags: (result = []) => result.length ? [...result.map(({id}) => ({ type: 'Competition', id })), { type: 'Competition' }] : [{ type: 'Competition' }],
}),
getCompetitionById: builder.query({
query: (id) => ({
url: `competition/${id}/`,
method: 'GET',
}),
providesTags: (result, error, id) => [{type: 'Competition', id}],
}),
addCompetition: builder.mutation({
query: (newCompetition) => ({
url: 'competition/',
method: 'POST',
body: newCompetition,
}),
invalidatesTags: ['Competition'],
}),
updateCompetition: builder.mutation({
query: ({id, ...patch}) => ({
url: `competition/${id}/`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, {id}) => [{type: 'Competition', id}],
}),
deleteCompetition: builder.mutation({
query: (id) => ({
url: `competition/${id}/`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [{type: 'Competition', id}],
}),
}),
});
export const {
useGetCompetitionsQuery,
useGetCompetitionByIdQuery,
useAddCompetitionMutation,
useUpdateCompetitionMutation,
useDeleteCompetitionMutation,
} = competitionsApi;

View file

@ -0,0 +1,14 @@
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: state => state + 1,
decrement: state => state - 1,
reset: () => 0,
},
});
export const { increment, decrement, reset } = counterSlice.actions;
export default counterSlice.reducer;

View file

@ -0,0 +1,33 @@
import {createApi} from '@reduxjs/toolkit/query/react';
import {baseQueryWithReauth} from './baseQueryWithReauth';
import {convertToLocalTimezone, dateFormatter} from "./workoutsSlice";
export const feedApi = createApi({
reducerPath: 'feedApi',
baseQuery: baseQueryWithReauth,
keepUnusedDataFor: 60 * 60 * 3, // 3 hours cache (default is 60s)
refetchOnMountOrArgChange: 60 * 15, // Refetch if older than 15 minutes
endpoints: (builder) => ({
getFeedById: builder.query({
query: (id) => ({
url: `feed/${id}/`,
method: 'GET',
}),
transformResponse: (response) => {
// Convert timezone for all activites in the response
return response.map(activity => {
return {
...activity,
workout__start_datetime_fmt: dateFormatter(activity.workout__start_datetime, activity.workout__sport_type === 'Steps'), // format datetime
workout__start_datetime: convertToLocalTimezone(activity.workout__start_datetime, activity.workout__sport_type === 'Steps'), // convert to local timezone
};
});
},
providesTags: (result, error, id) => [{type: 'Feed', id}],
}),
}),
});
export const {
useGetFeedByIdQuery,
} = feedApi;

View file

@ -0,0 +1,58 @@
import {createApi} from '@reduxjs/toolkit/query/react';
import {baseQueryWithReauth} from './baseQueryWithReauth';
export const goalsApi = createApi({
reducerPath: 'goalsApi',
baseQuery: baseQueryWithReauth,
tagTypes: ['Goal'],
keepUnusedDataFor: 60 * 60 * 12, // 12 hours cache (default is 60)
refetchOnMountOrArgChange: 60 * 60 * 3, // Refetch if older than 3 hours
endpoints: (builder) => ({
getGoals: builder.query({
query: (params = {}) => ({
url: `goal/`, //?${new URLSearchParams(params).toString()}
method: 'GET',
params: params,
}),
providesTags: (result = []) => result.length ? [...result.map(({id}) => ({ type: 'Goal', id })), { type: 'Goal' }] : [{ type: 'Goal' }],
}),
getGoalById: builder.query({
query: (id) => ({
url: `goal/${id}/`,
method: 'GET',
}),
providesTags: (result, error, id) => [{type: 'Goal', id}],
}),
addGoal: builder.mutation({
query: (newGoal) => ({
url: 'goal/',
method: 'POST',
body: newGoal,
}),
invalidatesTags: ['Goal'],
}),
updateGoal: builder.mutation({
query: ({id, ...patch}) => ({
url: `goal/${id}/`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, {id}) => [{type: 'Goal', id}],
}),
deleteGoal: builder.mutation({
query: (id) => ({
url: `goal/${id}/`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [{type: 'Goal', id}],
}),
}),
});
export const {
useGetGoalsQuery,
useGetGoalByIdQuery,
useAddGoalMutation,
useUpdateGoalMutation,
useDeleteGoalMutation,
} = goalsApi;

View file

@ -0,0 +1,34 @@
import {createApi} from '@reduxjs/toolkit/query/react';
import {baseQueryWithReauth} from './baseQueryWithReauth';
export const joinApi = createApi({
reducerPath: 'joinApi',
baseQuery: baseQueryWithReauth,
endpoints: (builder) => ({
joinCompetition: builder.mutation({
query: (join_code) => ({
url: `join/competition/${join_code}/`,
method: 'POST',
}),
}),
leaveCompetition: builder.mutation({
query: (id) => ({
url: `join/competition/${id}/`,
method: 'DELETE',
}),
}),
joinTeam: builder.mutation({
query: (params = {}) => ({
url: `join/team/`,
method: 'POST',
params: params,
}),
}),
}),
});
export const {
useJoinCompetitionMutation,
useLeaveCompetitionMutation,
useJoinTeamMutation,
} = joinApi;

View file

@ -0,0 +1,34 @@
import {createApi} from '@reduxjs/toolkit/query/react';
import {baseQueryWithReauth} from './baseQueryWithReauth';
export const linkApi = createApi({
reducerPath: 'linkApi',
baseQuery: baseQueryWithReauth,
endpoints: (builder) => ({
linkStrava: builder.mutation({
query: (code) => ({
url: `strava/link/${code}/`,
method: 'POST',
}),
}),
unlinkStrava: builder.mutation({
query: () => ({
url: `strava/unlink/`,
method: 'POST',
}),
}),
syncStrava: builder.query({
query: () => ({
url: `strava/sync/`,
method: 'GET',
}),
}),
}),
});
export const {
useLinkStravaMutation,
useUnlinkStravaMutation,
useGetSyncStravaQuery,
useLazySyncStravaQuery
} = linkApi;

View file

@ -0,0 +1,27 @@
import { createSlice } from '@reduxjs/toolkit';
const modalQueueSlice = createSlice({
name: 'modalQueue',
initialState: {
queue: [], // array of { type, props }
currentModal: null,
},
reducers: {
enqueueModal: (state, action) => {
state.queue.push(action.payload);
if (!state.currentModal) {
state.currentModal = state.queue.shift();
}
},
closeModal: (state) => {
state.currentModal = state.queue.shift() || null;
},
clearQueue: (state) => {
state.queue = [];
state.currentModal = null;
},
},
});
export const { enqueueModal, closeModal, clearQueue } = modalQueueSlice.actions;
export default modalQueueSlice.reducer;

View file

@ -0,0 +1,32 @@
import {createApi} from '@reduxjs/toolkit/query/react';
import {baseQueryWithReauth} from './baseQueryWithReauth';
export const pointsApi = createApi({
reducerPath: 'pointsApi',
baseQuery: baseQueryWithReauth,
tagTypes: ['Point'],
keepUnusedDataFor: 60 * 60 * 3, // 3 hours cache (default is 60)
refetchOnMountOrArgChange: 60 * 15, // Refetch if older than 15 minutes
endpoints: (builder) => ({
getPoints: builder.query({
query: (params = {}) => ({
url: `point/`, //?${new URLSearchParams(params).toString()}
method: 'GET',
params: params,
}),
providesTags: (result = []) => result.length ? [...result.map(({id}) => ({ type: 'Point', id })), { type: 'Point' }] : [{ type: 'Point' }],
}),
getPointById: builder.query({
query: (id) => ({
url: `point/${id}/`,
method: 'GET',
}),
providesTags: (result, error, id) => [{type: 'Point', id}],
}),
}),
});
export const {
useGetPointsQuery,
useGetPointByIdQuery,
} = pointsApi;

View file

@ -0,0 +1,22 @@
import {createApi} from '@reduxjs/toolkit/query/react';
import {baseQueryWithReauth} from './baseQueryWithReauth';
export const statsApi = createApi({
reducerPath: 'statsApi',
baseQuery: baseQueryWithReauth,
keepUnusedDataFor: 60 * 60 * 3, // 3 hours cache (default is 60)
refetchOnMountOrArgChange: 60 * 15, // Refetch if older than 15 minutes
endpoints: (builder) => ({
getStatsById: builder.query({
query: (id) => ({
url: `stats/${id}/`,
method: 'GET',
}),
providesTags: (result, error, id) => [{type: 'Stats', id}],
}),
}),
});
export const {
useGetStatsByIdQuery,
} = statsApi;

View file

@ -0,0 +1,58 @@
import {createApi} from '@reduxjs/toolkit/query/react';
import {baseQueryWithReauth} from './baseQueryWithReauth';
export const teamsApi = createApi({
reducerPath: 'teamsApi',
baseQuery: baseQueryWithReauth,
tagTypes: ['Team'],
keepUnusedDataFor: 60 * 60 * 12, // 12 hours cache (default is 60)
refetchOnMountOrArgChange: 60 * 60, // Refetch if older than 1 hour
endpoints: (builder) => ({
getTeams: builder.query({
query: (params = {}) => ({
url: `team/`, //?${new URLSearchParams(params).toString()}
method: 'GET',
params: params,
}),
providesTags: (result = []) => result.length ? [...result.map(({id}) => ({ type: 'Team', id })), { type: 'Team' }] : [{ type: 'Team' }],
}),
getTeamById: builder.query({
query: (id) => ({
url: `team/${id}/`,
method: 'GET',
}),
providesTags: (result, error, id) => [{type: 'Team', id}],
}),
addTeam: builder.mutation({
query: (newTeam) => ({
url: 'team/',
method: 'POST',
body: newTeam,
}),
invalidatesTags: ['Team'],
}),
updateTeam: builder.mutation({
query: ({id, ...patch}) => ({
url: `team/${id}/`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, {id}) => [{type: 'Team', id}],
}),
deleteTeam: builder.mutation({
query: (id) => ({
url: `team/${id}/`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [{type: 'Team', id}],
}),
}),
});
export const {
useGetTeamsQuery,
useGetTeamByIdQuery,
useAddTeamMutation,
useUpdateTeamMutation,
useDeleteTeamMutation,
} = teamsApi;

View file

@ -0,0 +1,100 @@
import {createApi} from '@reduxjs/toolkit/query/react';
import {baseQueryWithReauth} from './baseQueryWithReauth';
import {convertToLocalTimezone, dateFormatter} from "./workoutsSlice";
export const usersApi = createApi({
reducerPath: 'usersApi',
baseQuery: baseQueryWithReauth,
tagTypes: ['User'],
keepUnusedDataFor: 60 * 60 * 12, // 12 hours cache (default is 60s)
refetchOnMountOrArgChange: 60 * 60 * 3, // Refetch if older than 3 hours
endpoints: (builder) => ({
getUsers: builder.query({
query: (params = {}) => ({
url: `user/`, //?${new URLSearchParams(params).toString()}
method: 'GET',
params: params,
}),
transformResponse: (response) => {
return response.map(user => {
return {
...user,
strava_last_synced_at_fmt: dateFormatter(user.strava_last_synced_at), // format datetime
strava_last_synced_at: convertToLocalTimezone(user.strava_last_synced_at), // convert to local timezone
};
});
},
providesTags: (result = []) => {
const tags = result.map(({ id }) => ({ type: 'User', id }));
if (result.some(user => user.id === 'me')) {
tags.push({ type: 'User', id: 'me' });
}
tags.push({ type: 'User' });
return tags;
},
}),
getUserById: builder.query({
query: (id) => ({
url: `user/${id}/`,
method: 'GET',
}),
transformResponse: (response) => {
return {
...response,
strava_last_synced_at_fmt: dateFormatter(response.strava_last_synced_at),
strava_last_synced_at: convertToLocalTimezone(response.strava_last_synced_at),
};
},
providesTags: (result, error, id) => {
const tags = [{ type: 'User', id }];
if (id === 'me') {
tags.push({ type: 'User', id: 'me' });
}
return tags;
},
}),
addUser: builder.mutation({
query: (newUser) => ({
url: 'user/',
method: 'POST',
body: newUser,
}),
invalidatesTags: ['User'],
}),
updateUser: builder.mutation({
query: ({id, ...patch}) => ({
url: `user/${id}/`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, {id}) => {
const tags = [{ type: 'User', id }];
if (result?.my === true) {
tags.push({ type: 'User', id: 'me' });
}
return tags;
},
}),
deleteUser: builder.mutation({
query: (id) => ({
url: `user/${id}/`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => {
const tags = [{ type: 'User', id }];
if (id === 'me') {
tags.push({ type: 'User', id: 'me' });
}
return tags;
},
}),
}),
});
export const {
useGetUsersQuery,
useGetUserByIdQuery,
useAddUserMutation,
useUpdateUserMutation,
useDeleteUserMutation,
} = usersApi;

View file

@ -0,0 +1,176 @@
import {createApi} from '@reduxjs/toolkit/query/react';
import {baseQueryWithReauth} from './baseQueryWithReauth';
/**
* Converts a date string with timezone to local timezone
* @param {string} dateString - Date string in format '2025-06-28T18:04:59+01:00'
* @param {boolean} convertEoD - If true, converts time to 23:59:00 of the closest day
* @returns {string} Date string in local timezone without timezone info
*/
export const convertToLocalTimezone = (dateString, convertEoD = false) => {
if (!dateString) return dateString;
const d = new Date(dateString);
// overwrite timestamp for workout "steps"
if (convertEoD) {
const hours = d.getHours();
// If time is after 12:00, set to 23:59 of same day
// If time is before 12:00, set to 23:59 of previous day
if (hours < 12) {
d.setDate(d.getDate() - 1);
}
d.setHours(23, 59, 0);
}
const pad = num => String(num).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
/**
* Adds local timezone offset to a date string that doesn't have timezone info
* @param {string} dateString - Date string in format '2025-06-28T18:04:59'
* @returns {string} Date string with local timezone offset
*/
export const addLocalTimezone = (dateString) => {
if (!dateString) return dateString;
const date = new Date(dateString);
const offset = -date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(offset) / 60);
const offsetMinutes = Math.abs(offset) % 60;
const offsetSign = offset >= 0 ? '+' : '-';
const offsetString = `${offsetSign}${String(offsetHours).padStart(2, '0')}:${String(offsetMinutes).padStart(2, '0')}`;
return `${dateString}${offsetString}`;
};
export const dateFormatter = (dateString, convertEoD = false) => {
const date = new Date(dateString);
// overwrite timestamp for workout "steps"
if (convertEoD) {
const hours = date.getHours();
// If time is after 12:00, set to 23:59 of same day
// If time is before 12:00, set to 23:59 of previous day
if (hours < 12) {
date.setDate(date.getDate() - 1);
}
date.setHours(23, 59, 0);
}
const dateDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const today = new Date();
const todayDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const currentDay = today.getDay(); // 0 (Sun) - 6 (Sat)
// prev week's Sunday
const prevSunday = new Date(today);
prevSunday.setDate(today.getDate() - ((currentDay !== 0) ? currentDay : 7)); // go back to Sunday
const prevSundayDate = new Date(prevSunday.getFullYear(), prevSunday.getMonth(), prevSunday.getDate());
// Calculate difference in days/weeks
const daysAgo = Math.floor((todayDate - dateDate) / (24 * 60 * 60 * 1000));
const weeksAgo = Math.floor((prevSundayDate - dateDate) / (7 * 24 * 60 * 60 * 1000)) + 1;
return {
epoch: Math.floor(date.getTime() / 1000), // Unix epoch in seconds
date_iso: date.toLocaleDateString('en-CA'), // Canadian locale uses YYYY-MM-DD format by default
date_readable: date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
}), // Mon, Jan 5
time_24h: date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}), // HH:MM
weeksAgo: weeksAgo,
days_ago: daysAgo,
};
};
export const workoutsApi = createApi({
reducerPath: 'workoutsApi',
baseQuery: baseQueryWithReauth,
tagTypes: ['Workout'],
keepUnusedDataFor: 60 * 60 * 12, // 12 hours cache (default is 60)
refetchOnMountOrArgChange: 60 * 60, // Refetch if older than 1 hour
endpoints: (builder) => ({
getWorkouts: builder.query({
query: (params = {}) => ({
url: `workout/`, //?${new URLSearchParams(params).toString()}
method: 'GET',
params: params,
}),
transformResponse: (response) => {
return response.map(workout => {
return {
...workout,
start_datetime_fmt: dateFormatter(workout.start_datetime, workout.sport_type === 'Steps'), // format datetime
start_datetime: convertToLocalTimezone(workout.start_datetime, workout.sport_type === 'Steps'), // convert to local timezone
};
});
},
// providesTags: (result = []) => result.map(({id}) => ({type: 'Workout', id})),
providesTags: (result = []) => result.length ? [...result.map(({id}) => ({ type: 'Workout', id })), { type: 'Workout' }] : [{ type: 'Workout' }],
}),
getWorkoutById: builder.query({
query: (id) => ({
url: `workout/${id}/`,
method: 'GET',
}),
transformResponse: (response) => {
return {
...response,
start_datetime_fmt: dateFormatter(response.start_datetime, response.sport_type === 'Steps'),
start_datetime: convertToLocalTimezone(response.start_datetime, response.sport_type === 'Steps'),
};
},
providesTags: (result, error, id) => [{type: 'Workout', id}],
}),
addWorkout: builder.mutation({
query: (newWorkout) => ({
url: 'workout/',
method: 'POST',
body: {
...newWorkout,
start_datetime: addLocalTimezone(newWorkout.start_datetime), // add timezone
},
}),
invalidatesTags: ['Workout'],
}),
updateWorkout: builder.mutation({
query: ({id, ...patch}) => ({
url: `workout/${id}/`,
method: 'PATCH',
body: {
...patch,
// Only modify start_datetime if it exists in the patch
...(patch.start_datetime && {
start_datetime: addLocalTimezone(patch.start_datetime) // add timezone
}),
},
}),
invalidatesTags: (result, error, {id}) => [{type: 'Workout', id}],
}),
deleteWorkout: builder.mutation({
query: (id) => ({
url: `workout/${id}/`,
method: 'DELETE',
}),
invalidatesTags: (result, error, id) => [{type: 'Workout', id}],
}),
}),
});
export const {
useGetWorkoutsQuery,
useGetWorkoutByIdQuery,
useAddWorkoutMutation,
useUpdateWorkoutMutation,
useDeleteWorkoutMutation,
} = workoutsApi;

View file

@ -0,0 +1,67 @@
import {combineReducers, configureStore} from '@reduxjs/toolkit';
import {loadState, saveState} from './localStorage';
import counterReducer from './reducers/counterSlice';
import authReducer from './reducers/authSlice';
import modalQueueReducer from './reducers/modalSlice';
import {setupListeners} from "@reduxjs/toolkit/query";
import {workoutsApi} from './reducers/workoutsSlice';
import {usersApi} from './reducers/usersSlice';
import {competitionsApi} from "./reducers/competitionsSlice";
import {teamsApi} from "./reducers/teamsSlice";
import {goalsApi} from "./reducers/goalsSlice";
import {pointsApi} from "./reducers/pointsSlice";
import {statsApi} from "./reducers/statsSlice";
import {feedApi} from "./reducers/feedSlice";
import {joinApi} from "./reducers/joinSlice";
import {linkApi} from "./reducers/linkSlice";
const appReducer = combineReducers({
counter: counterReducer,
auth: authReducer,
modalQueue: modalQueueReducer,
[workoutsApi.reducerPath]: workoutsApi.reducer,
[usersApi.reducerPath]: usersApi.reducer,
[competitionsApi.reducerPath]: competitionsApi.reducer,
[teamsApi.reducerPath]: teamsApi.reducer,
[goalsApi.reducerPath]: goalsApi.reducer,
[pointsApi.reducerPath]: pointsApi.reducer,
[statsApi.reducerPath]: statsApi.reducer,
[feedApi.reducerPath]: feedApi.reducer,
[joinApi.reducerPath]: joinApi.reducer,
[linkApi.reducerPath]: linkApi.reducer,
});
// root reducer that handles RESET_STORE
const rootReducer = (state, action) => {
if (action.type === 'RESET_STORE') {
state = undefined; // wipes the whole redux state, including RTK Query caches
}
return appReducer(state, action);
};
const preloadedState = loadState();
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(workoutsApi.middleware)
.concat(usersApi.middleware)
.concat(competitionsApi.middleware)
.concat(teamsApi.middleware)
.concat(goalsApi.middleware)
.concat(pointsApi.middleware)
.concat(statsApi.middleware)
.concat(feedApi.middleware)
.concat(joinApi.middleware)
.concat(linkApi.middleware),
preloadedState,
});
store.subscribe(() => {
saveState(store.getState());
});
setupListeners(store.dispatch);
export default store;

View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}