init kinda
This commit is contained in:
parent
ece2dadeea
commit
dadfa40fcc
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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*
|
324
App.jsx
Normal file
324
App.jsx
Normal file
@ -0,0 +1,324 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const WEEKLY_SOLO_POINTS = 5;
|
||||
const WEEKLY_TOGETHER_POINTS = 5;
|
||||
const EARNED_POINTS_PASSWORD = 'password';
|
||||
|
||||
// Timer durations (in milliseconds)
|
||||
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
||||
const TWO_MINUTES = 2 * 60 * 1000;
|
||||
|
||||
function App() {
|
||||
const [soloPoints, setSoloPoints] = useState(WEEKLY_SOLO_POINTS);
|
||||
const [togetherPoints, setTogetherPoints] = useState(WEEKLY_TOGETHER_POINTS);
|
||||
const [earnedPoints, setEarnedPoints] = useState(0);
|
||||
const [log, setLog] = useState([]); // e.g., [{ type: 'solo', usedAt: 'ISOString' }, ...]
|
||||
const [lastReset, setLastReset] = useState(null); // e.g., 'YYYY-MM-DD'
|
||||
|
||||
// Timer states
|
||||
const [timerRunning, setTimerRunning] = useState(false);
|
||||
const [timeRemaining, setTimeRemaining] = useState(0);
|
||||
|
||||
// For storing the active timer ID (so we can clear it on unmount)
|
||||
const timerRef = useRef(null);
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
// -----------------------------
|
||||
// LOAD / SAVE localStorage
|
||||
// -----------------------------
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Check for weekly reset once data is loaded
|
||||
useEffect(() => {
|
||||
if (lastReset) {
|
||||
resetIfNewWeek();
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [lastReset]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadData = () => {
|
||||
try {
|
||||
const storedData = localStorage.getItem('pointsData');
|
||||
if (storedData) {
|
||||
const dataObj = JSON.parse(storedData);
|
||||
setSoloPoints(dataObj.soloPoints ?? WEEKLY_SOLO_POINTS);
|
||||
setTogetherPoints(dataObj.togetherPoints ?? WEEKLY_TOGETHER_POINTS);
|
||||
setEarnedPoints(dataObj.earnedPoints ?? 0);
|
||||
setLog(dataObj.log ?? []);
|
||||
setLastReset(dataObj.lastReset ?? null);
|
||||
} else {
|
||||
// If nothing in localStorage, use defaults
|
||||
saveData(
|
||||
WEEKLY_SOLO_POINTS,
|
||||
WEEKLY_TOGETHER_POINTS,
|
||||
0,
|
||||
[],
|
||||
null
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reading localStorage:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const saveData = (newSolo, newTogether, newEarned, newLog, newLastReset) => {
|
||||
const dataObj = {
|
||||
soloPoints: newSolo,
|
||||
togetherPoints: newTogether,
|
||||
earnedPoints: newEarned,
|
||||
log: newLog,
|
||||
lastReset: newLastReset
|
||||
};
|
||||
localStorage.setItem('pointsData', JSON.stringify(dataObj));
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// WEEKLY RESET LOGIC
|
||||
// -----------------------------
|
||||
// Helper: get Monday of current week as 'YYYY-MM-DD'
|
||||
const getMonday = (date = new Date()) => {
|
||||
const d = new Date(date);
|
||||
const day = d.getDay(); // 0=Sun, 1=Mon, ...
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
d.setDate(diff);
|
||||
return d.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const resetIfNewWeek = () => {
|
||||
const currentMonday = getMonday();
|
||||
if (!lastReset || lastReset !== currentMonday) {
|
||||
// Reset solo & together points. Earned points can remain or reset, your choice.
|
||||
setSoloPoints(WEEKLY_SOLO_POINTS);
|
||||
setTogetherPoints(WEEKLY_TOGETHER_POINTS);
|
||||
// setEarnedPoints(0); // Uncomment if you want to reset earned each Monday
|
||||
setLastReset(currentMonday);
|
||||
|
||||
saveData(
|
||||
WEEKLY_SOLO_POINTS,
|
||||
WEEKLY_TOGETHER_POINTS,
|
||||
earnedPoints, // or 0 if resetting
|
||||
log,
|
||||
currentMonday
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// USE A POINT
|
||||
// -----------------------------
|
||||
const usePoint = (pointType) => {
|
||||
if (pointType === 'solo' && soloPoints <= 0) {
|
||||
alert('No Solo points left!');
|
||||
return;
|
||||
}
|
||||
if (pointType === 'together' && togetherPoints <= 0) {
|
||||
alert('No Together points left!');
|
||||
return;
|
||||
}
|
||||
if (pointType === 'earned' && earnedPoints <= 0) {
|
||||
alert('No Earned points left!');
|
||||
return;
|
||||
}
|
||||
|
||||
let newSolo = soloPoints;
|
||||
let newTogether = togetherPoints;
|
||||
let newEarned = earnedPoints;
|
||||
|
||||
if (pointType === 'solo') newSolo = soloPoints - 1;
|
||||
if (pointType === 'together') newTogether = togetherPoints - 1;
|
||||
if (pointType === 'earned') newEarned = earnedPoints - 1;
|
||||
|
||||
// Log usage
|
||||
const usedAt = new Date().toISOString();
|
||||
const newLog = [...log, { type: pointType, usedAt }];
|
||||
|
||||
// Update states
|
||||
setSoloPoints(newSolo);
|
||||
setTogetherPoints(newTogether);
|
||||
setEarnedPoints(newEarned);
|
||||
setLog(newLog);
|
||||
|
||||
// Persist
|
||||
saveData(newSolo, newTogether, newEarned, newLog, lastReset);
|
||||
|
||||
// Start 15-minute timer
|
||||
alert(
|
||||
`Using 1 ${pointType.toUpperCase()} point at ${usedAt}. \n15-minute timer started.`
|
||||
);
|
||||
startTimer(FIFTEEN_MINUTES);
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// TIMERS (Client)
|
||||
// -----------------------------
|
||||
const startTimer = (duration) => {
|
||||
// If a timer is already running, clear it
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
|
||||
setTimerRunning(true);
|
||||
setTimeRemaining(duration);
|
||||
|
||||
// Set up a 1-second interval to update the countdown display
|
||||
let remaining = duration;
|
||||
intervalRef.current = setInterval(() => {
|
||||
remaining -= 1000;
|
||||
setTimeRemaining(remaining);
|
||||
if (remaining <= 0) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// When the timer finishes:
|
||||
timerRef.current = setTimeout(() => {
|
||||
clearInterval(intervalRef.current);
|
||||
setTimerRunning(false);
|
||||
setTimeRemaining(0);
|
||||
ring();
|
||||
}, duration);
|
||||
};
|
||||
|
||||
const ring = () => {
|
||||
const wantsFinishUp = window.confirm(
|
||||
'15 minutes are up! \nClick "OK" to start a 2-minute finish-up timer, or "Cancel" to stop.'
|
||||
);
|
||||
if (wantsFinishUp) {
|
||||
startTimer(TWO_MINUTES);
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// ADD EARNED POINT
|
||||
// -----------------------------
|
||||
const addEarnedPoint = () => {
|
||||
const pw = window.prompt('Enter password to add an earned point:');
|
||||
if (pw === EARNED_POINTS_PASSWORD) {
|
||||
const newEarned = earnedPoints + 1;
|
||||
setEarnedPoints(newEarned);
|
||||
saveData(soloPoints, togetherPoints, newEarned, log, lastReset);
|
||||
alert('1 Earned Point added!');
|
||||
} else {
|
||||
alert('Incorrect password!');
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// DISPLAY TIMER FORMAT
|
||||
// -----------------------------
|
||||
const formatTime = (ms) => {
|
||||
if (ms <= 0) return '00:00';
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const mm = minutes < 10 ? `0${minutes}` : minutes;
|
||||
const ss = seconds < 10 ? `0${seconds}` : seconds;
|
||||
return `${mm}:${ss}`;
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// RENDER
|
||||
// -----------------------------
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h1>Screen-Time Points</h1>
|
||||
<div style={styles.pointsRow}>
|
||||
<div style={styles.pointsBox}>
|
||||
<p>Solo Points: {soloPoints}</p>
|
||||
</div>
|
||||
<div style={styles.pointsBox}>
|
||||
<p>Together Points: {togetherPoints}</p>
|
||||
</div>
|
||||
<div style={styles.pointsBox}>
|
||||
<p>Earned Points: {earnedPoints}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.buttonsRow}>
|
||||
<button style={styles.button} onClick={() => usePoint('solo')}>
|
||||
Use SOLO Point
|
||||
</button>
|
||||
<button style={styles.button} onClick={() => usePoint('together')}>
|
||||
Use TOGETHER Point
|
||||
</button>
|
||||
<button style={styles.button} onClick={addEarnedPoint}>
|
||||
Add EARNED Point
|
||||
</button>
|
||||
<button style={styles.button} onClick={() => usePoint('earned')}>
|
||||
Use EARNED Point
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timer display */}
|
||||
{timerRunning && (
|
||||
<div style={{ margin: '20px', fontSize: '18px' }}>
|
||||
<strong>Timer Running: {formatTime(timeRemaining)}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Logs */}
|
||||
<div style={styles.logSection}>
|
||||
<h3>Recent Log (last 5)</h3>
|
||||
{log.slice(-5).reverse().map((entry, idx) => (
|
||||
<p key={idx}>
|
||||
[{entry.type}] used at {entry.usedAt}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Basic Styles
|
||||
// -----------------------------
|
||||
const styles = {
|
||||
container: {
|
||||
maxWidth: 600,
|
||||
margin: '40px auto',
|
||||
fontFamily: 'sans-serif',
|
||||
textAlign: 'center',
|
||||
},
|
||||
pointsRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: 20,
|
||||
},
|
||||
pointsBox: {
|
||||
border: '1px solid #aaa',
|
||||
borderRadius: 4,
|
||||
padding: '10px 20px',
|
||||
minWidth: 100,
|
||||
backgroundColor: '#f9f9f9'
|
||||
},
|
||||
buttonsRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: 30,
|
||||
},
|
||||
button: {
|
||||
padding: '10px 15px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#0066cc',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
},
|
||||
logSection: {
|
||||
textAlign: 'left',
|
||||
marginTop: 30,
|
||||
padding: '0 20px',
|
||||
},
|
||||
};
|
||||
|
||||
export default App;
|
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@ -0,0 +1,49 @@
|
||||
# ---------- STAGE 1: Build React Front End ----------
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root package.json/package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Copy client folder's package.json/package-lock.json
|
||||
COPY client/package*.json ./client/
|
||||
|
||||
# Install dependencies:
|
||||
# 1) Install root dependencies (for server)
|
||||
RUN npm install
|
||||
|
||||
# 2) Install client dependencies
|
||||
WORKDIR /app/client
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the React client code
|
||||
COPY client/ /app/client
|
||||
|
||||
# Build the React client
|
||||
RUN npm run build
|
||||
|
||||
# ---------- STAGE 2: Final Image ----------
|
||||
FROM node:18-alpine
|
||||
|
||||
# Create directory for your server
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root package.json/package-lock.json again
|
||||
COPY package*.json ./
|
||||
|
||||
# Install server dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy your server code
|
||||
COPY server.js ./
|
||||
|
||||
# Copy the compiled React build from STAGE 1
|
||||
COPY --from=build /app/client/build ./client/build
|
||||
|
||||
# We will serve on port 4000 (or your choice)
|
||||
EXPOSE 4000
|
||||
|
||||
# Start the server
|
||||
CMD [ "node", "server.js" ]
|
70
README.md
Normal file
70
README.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
17320
package-lock.json
generated
Normal file
17320
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "untitled2",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"cra-template": "1.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-scripts": "5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
BIN
public/alarm.mp3
Normal file
BIN
public/alarm.mp3
Normal file
Binary file not shown.
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
43
public/index.html
Normal file
43
public/index.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!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="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.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" />
|
||||
<!--
|
||||
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>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
359
server.js
Normal file
359
server.js
Normal file
@ -0,0 +1,359 @@
|
||||
/*
|
||||
server.js
|
||||
|
||||
- "Solo" and "Together" arrays:
|
||||
- Start with 5 each, index 0..4 are "base" points.
|
||||
- Additional "earned" points are appended (index >= 5).
|
||||
- Weekly reset => set ALL items to false (keep array lengths).
|
||||
- POST /add-points => appends new "false" items to "soloUsed" or "togetherUsed".
|
||||
- Admin route: /admin/remove-all-earned => remove appended points beyond index 4 in both arrays.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
// ----- CONFIG -----
|
||||
const PORT = 4000;
|
||||
|
||||
// Default base counts
|
||||
const SOLO_BASE_COUNT = 5;
|
||||
const TOGETHER_BASE_COUNT = 5;
|
||||
|
||||
// Passwords
|
||||
const POINTS_PASSWORD = 'mySecretPassword'; // for adding points
|
||||
const ADMIN_PASSWORD = 'adminSecret'; // for admin panel
|
||||
|
||||
// Timer durations (ms)
|
||||
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
||||
const TWO_MINUTES = 2 * 60 * 1000;
|
||||
|
||||
// ----- IN-MEMORY STATE -----
|
||||
// Start with 5 false for each
|
||||
let soloUsed = Array(SOLO_BASE_COUNT).fill(false);
|
||||
let togetherUsed = Array(TOGETHER_BASE_COUNT).fill(false);
|
||||
|
||||
// Logs: e.g. { type:'solo'|'together'|'movie'|'episode', index, usedAt:'ISOString' }
|
||||
let logs = [];
|
||||
|
||||
let lastReset = null;
|
||||
|
||||
// Timer
|
||||
let timerRunning = false;
|
||||
let timeRemaining = 0;
|
||||
let isPaused = false;
|
||||
let activeUsage = null; // e.g. { category:'solo'|'together', index }
|
||||
let ringing = false;
|
||||
|
||||
// -----------------------------
|
||||
// Weekly Reset Logic
|
||||
// -----------------------------
|
||||
function getMonday(d = new Date()) {
|
||||
const day = d.getDay(); // 0=Sun,1=Mon,...
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
d.setDate(diff);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function resetIfNewWeek() {
|
||||
const currentMonday = getMonday();
|
||||
if (!lastReset || lastReset !== currentMonday) {
|
||||
// Keep array lengths, reset all to false
|
||||
soloUsed = soloUsed.map(() => false);
|
||||
togetherUsed = togetherUsed.map(() => false);
|
||||
|
||||
lastReset = currentMonday;
|
||||
console.log('[Server] Weekly reset triggered:', currentMonday);
|
||||
}
|
||||
}
|
||||
|
||||
// Decrement the timer
|
||||
setInterval(() => {
|
||||
if (timerRunning && !isPaused && timeRemaining > 0) {
|
||||
timeRemaining -= 1000;
|
||||
if (timeRemaining <= 0) {
|
||||
// If finishing a 15-min => ring; if finishing 2-min => just end
|
||||
timerRunning = false;
|
||||
timeRemaining = 0;
|
||||
|
||||
// If we aren't already ringing, that means we ended the 15-min block => ring
|
||||
if (!ringing) {
|
||||
ringing = true;
|
||||
console.log('[Server] 15-minute timer ended => ringing=true');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// ----- EXPRESS -----
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Weekly reset check
|
||||
app.use((req, res, next) => {
|
||||
resetIfNewWeek();
|
||||
next();
|
||||
});
|
||||
|
||||
// GET /state => entire state
|
||||
app.get('/state', (req, res) => {
|
||||
res.json({
|
||||
soloUsed,
|
||||
togetherUsed,
|
||||
logs,
|
||||
lastReset,
|
||||
timerRunning,
|
||||
timeRemaining,
|
||||
isPaused,
|
||||
activeUsage,
|
||||
ringing,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /use-bubble => single bubble => 15-min
|
||||
app.post('/use-bubble', (req, res) => {
|
||||
const { category, index } = req.body;
|
||||
|
||||
if (timerRunning && !ringing && timeRemaining > 0) {
|
||||
return res.status(400).json({ error: 'A timer is already active.' });
|
||||
}
|
||||
// If ringing => auto-ignore
|
||||
if (ringing) {
|
||||
timerRunning = false;
|
||||
timeRemaining = 0;
|
||||
isPaused = false;
|
||||
activeUsage = null;
|
||||
ringing = false;
|
||||
console.log('[Server] Timer was ringing => auto-ignored, new usage starts.');
|
||||
}
|
||||
|
||||
let arr;
|
||||
if (category === 'solo') arr = soloUsed;
|
||||
else if (category === 'together') arr = togetherUsed;
|
||||
else return res.status(400).json({ error: 'Invalid category' });
|
||||
|
||||
if (index < 0 || index >= arr.length) {
|
||||
return res.status(400).json({ error: 'Index out of range' });
|
||||
}
|
||||
if (arr[index] === true) {
|
||||
return res.status(400).json({ error: 'That bubble is already used.' });
|
||||
}
|
||||
|
||||
// Mark used
|
||||
arr[index] = true;
|
||||
const usedAt = new Date().toISOString();
|
||||
logs.push({ type: category, index, usedAt });
|
||||
|
||||
// Start 15-min
|
||||
timerRunning = true;
|
||||
timeRemaining = FIFTEEN_MINUTES;
|
||||
isPaused = false;
|
||||
activeUsage = { category, index };
|
||||
console.log(`[Server] ${category} #${index + 1} => 15-min started`);
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /check-password => new endpoint to quickly validate POINTS_PASSWORD
|
||||
app.post('/check-password', (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (password !== POINTS_PASSWORD) {
|
||||
return res.status(403).json({ error: 'Incorrect password.' });
|
||||
}
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /add-points => appends "amount" new false items to soloUsed/togetherUsed
|
||||
app.post('/add-points', (req, res) => {
|
||||
const { password, category, amount } = req.body;
|
||||
// We could re-check password if we want.
|
||||
// If you rely on /check-password, then skip here or just do it again:
|
||||
if (password !== POINTS_PASSWORD) {
|
||||
return res.status(403).json({ error: 'Incorrect password.' });
|
||||
}
|
||||
if (!['solo', 'together'].includes(category)) {
|
||||
return res.status(400).json({ error: 'Invalid category' });
|
||||
}
|
||||
if (typeof amount !== 'number' || amount < 1 || amount > 4) {
|
||||
return res.status(400).json({ error: 'Amount must be 1..4' });
|
||||
}
|
||||
|
||||
if (category === 'solo') {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
soloUsed.push(false);
|
||||
}
|
||||
console.log(`[Server] +${amount} appended to soloUsed => length now ${soloUsed.length}`);
|
||||
} else {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
togetherUsed.push(false);
|
||||
}
|
||||
console.log(`[Server] +${amount} appended to togetherUsed => length now ${togetherUsed.length}`);
|
||||
}
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// Pause/Resume
|
||||
app.post('/pause-resume', (req, res) => {
|
||||
if (!timerRunning && timeRemaining <= 0) {
|
||||
return res.status(400).json({ error: 'No active timer.' });
|
||||
}
|
||||
if (ringing) {
|
||||
return res.status(400).json({ error: 'Timer is ringing, cannot pause.' });
|
||||
}
|
||||
isPaused = !isPaused;
|
||||
console.log('[Server] Timer pause =>', isPaused);
|
||||
return res.json({ success: true, isPaused });
|
||||
});
|
||||
|
||||
// Finish-up => only if ringing
|
||||
app.post('/finish-up', (req, res) => {
|
||||
if (!ringing) {
|
||||
return res.status(400).json({ error: 'Not currently ringing. 2-min only after 15-min ends.' });
|
||||
}
|
||||
timerRunning = true;
|
||||
timeRemaining = TWO_MINUTES;
|
||||
isPaused = false;
|
||||
ringing = false;
|
||||
console.log('[Server] 2-min finish-up started');
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// Cancel finish-up
|
||||
app.post('/cancel-finish-up', (req, res) => {
|
||||
if (!timerRunning || ringing || timeRemaining > TWO_MINUTES) {
|
||||
return res.status(400).json({ error: 'Not currently in finish-up timer.' });
|
||||
}
|
||||
timerRunning = false;
|
||||
timeRemaining = 0;
|
||||
isPaused = false;
|
||||
activeUsage = null;
|
||||
console.log('[Server] 2-min finish-up canceled');
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// Ignore ring => if ringing
|
||||
app.post('/ignore-ring', (req, res) => {
|
||||
if (!ringing) {
|
||||
return res.status(400).json({ error: 'Not currently ringing.' });
|
||||
}
|
||||
timerRunning = false;
|
||||
timeRemaining = 0;
|
||||
isPaused = false;
|
||||
activeUsage = null;
|
||||
ringing = false;
|
||||
console.log('[Server] Ring ignored => no 2-min started');
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// Movie => user picks 4
|
||||
app.post('/use-movie', (req, res) => {
|
||||
const { chosenPoints } = req.body;
|
||||
if (!Array.isArray(chosenPoints) || chosenPoints.length !== 4) {
|
||||
return res.status(400).json({ error: 'Must pick exactly 4 points' });
|
||||
}
|
||||
for (const p of chosenPoints) {
|
||||
let arr = p.category === 'solo' ? soloUsed : p.category === 'together' ? togetherUsed : null;
|
||||
if (!arr) return res.status(400).json({ error: 'Invalid category in chosen points.' });
|
||||
if (arr[p.index] === true) {
|
||||
return res.status(400).json({ error: 'A chosen point is already used.' });
|
||||
}
|
||||
}
|
||||
// Mark used
|
||||
for (const p of chosenPoints) {
|
||||
let arr = p.category === 'solo' ? soloUsed : togetherUsed;
|
||||
arr[p.index] = true;
|
||||
}
|
||||
const usedAt = new Date().toISOString();
|
||||
logs.push({ type: 'movie', index: null, usedAt });
|
||||
console.log('[Server] Movie => used 4 chosen points at', usedAt);
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// Episode => user picks 2
|
||||
app.post('/use-episode', (req, res) => {
|
||||
const { chosenPoints } = req.body;
|
||||
if (!Array.isArray(chosenPoints) || chosenPoints.length !== 2) {
|
||||
return res.status(400).json({ error: 'Must pick exactly 2 points' });
|
||||
}
|
||||
for (const p of chosenPoints) {
|
||||
let arr = p.category === 'solo' ? soloUsed : p.category === 'together' ? togetherUsed : null;
|
||||
if (!arr) return res.status(400).json({ error: 'Invalid category in chosen points.' });
|
||||
if (arr[p.index] === true) {
|
||||
return res.status(400).json({ error: 'A chosen point is already used.' });
|
||||
}
|
||||
}
|
||||
for (const p of chosenPoints) {
|
||||
let arr = p.category === 'solo' ? soloUsed : togetherUsed;
|
||||
arr[p.index] = true;
|
||||
}
|
||||
const usedAt = new Date().toISOString();
|
||||
logs.push({ type: 'episode', index: null, usedAt });
|
||||
console.log('[Server] Episode => used 2 chosen points at', usedAt);
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// ----- ADMIN -----
|
||||
app.post('/admin/login', (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (password !== ADMIN_PASSWORD) {
|
||||
return res.status(403).json({ error: 'Wrong admin password' });
|
||||
}
|
||||
return res.json({ success: true });
|
||||
});
|
||||
app.post('/admin/clear-logs', (req, res) => {
|
||||
logs = [];
|
||||
console.log('[Server] Logs cleared by admin');
|
||||
res.json({ success: true });
|
||||
});
|
||||
app.post('/admin/reset-usage', (req, res) => {
|
||||
// Keep array lengths, set all to false
|
||||
soloUsed = soloUsed.map(() => false);
|
||||
togetherUsed = togetherUsed.map(() => false);
|
||||
console.log('[Server] Usage reset by admin => all set false, arrays kept');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// This new route removes all appended points from both arrays, leaving the base 5
|
||||
app.post('/admin/remove-all-earned', (req, res) => {
|
||||
// Keep only the first 5 in each array
|
||||
soloUsed.splice(5);
|
||||
togetherUsed.splice(5);
|
||||
console.log('[Server] All earned points removed => each array back to length 5');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// For demonstration, these add/remove 1 from "soloUsed"
|
||||
app.post('/admin/add-earned', (req, res) => {
|
||||
soloUsed.push(false);
|
||||
console.log('[Server] Admin +1 to soloUsed => length now', soloUsed.length);
|
||||
res.json({ success: true });
|
||||
});
|
||||
app.post('/admin/remove-earned', (req, res) => {
|
||||
if (soloUsed.length <= 0) {
|
||||
return res.status(400).json({ error: 'No bubble to remove in soloUsed' });
|
||||
}
|
||||
soloUsed.pop();
|
||||
console.log('[Server] Admin removed last from soloUsed => length now', soloUsed.length);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/admin/clear-timer', (req, res) => {
|
||||
timerRunning = false;
|
||||
timeRemaining = 0;
|
||||
isPaused = false;
|
||||
activeUsage = null;
|
||||
ringing = false;
|
||||
console.log('[Server] Timer cleared by admin');
|
||||
res.json({ success: true });
|
||||
});
|
||||
app.post('/admin/expire-timer', (req, res) => {
|
||||
if (timeRemaining > 1) {
|
||||
timeRemaining = 1;
|
||||
console.log('[Server] Timer => set to 1s => about to end');
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[Server] Listening on port ${PORT}`);
|
||||
});
|
38
src/App.css
Normal file
38
src/App.css
Normal file
@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
667
src/App.js
Normal file
667
src/App.js
Normal file
@ -0,0 +1,667 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const SERVER_URL = 'http://localhost:4000';
|
||||
|
||||
export default function App() {
|
||||
// -----------------------------
|
||||
// SERVER STATE
|
||||
// -----------------------------
|
||||
const [soloUsed, setSoloUsed] = useState([]);
|
||||
const [togetherUsed, setTogetherUsed] = useState([]);
|
||||
const [logs, setLogs] = useState([]);
|
||||
|
||||
const [timerRunning, setTimerRunning] = useState(false);
|
||||
const [timeRemaining, setTimeRemaining] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [ringing, setRinging] = useState(false);
|
||||
|
||||
// Admin
|
||||
const [adminMode, setAdminMode] = useState(false);
|
||||
|
||||
// For status/error messages
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
|
||||
// For Movie/Episode selection
|
||||
const [chooseMovieMode, setChooseMovieMode] = useState(false);
|
||||
const [chooseEpisodeMode, setChooseEpisodeMode] = useState(false);
|
||||
const [chosen, setChosen] = useState([]); // e.g. [{ category:'solo', index }, ...]
|
||||
|
||||
// -----------------------------
|
||||
// "ADD POINTS" MULTI-STEP UI
|
||||
// -----------------------------
|
||||
// Step 1 => enter password + immediate check
|
||||
// Step 2 => pick category
|
||||
// Step 3 => pick amount
|
||||
const [showAddPointsOverlay, setShowAddPointsOverlay] = useState(false);
|
||||
const [addPointsStep, setAddPointsStep] = useState(1);
|
||||
const [pointsPassword, setPointsPassword] = useState('');
|
||||
const [pointsCategory, setPointsCategory] = useState(''); // 'solo' or 'together'
|
||||
const [pointsAmount, setPointsAmount] = useState(1);
|
||||
|
||||
// Audio ref for the alarm
|
||||
const alarmRef = useRef(null);
|
||||
|
||||
// Poll server
|
||||
useEffect(() => {
|
||||
fetchState();
|
||||
const id = setInterval(fetchState, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// If ringing changes => play/stop audio
|
||||
useEffect(() => {
|
||||
if (ringing) {
|
||||
playAlarm();
|
||||
} else {
|
||||
stopAlarm();
|
||||
}
|
||||
}, [ringing]);
|
||||
|
||||
// -----------------------------
|
||||
// FETCH STATE
|
||||
// -----------------------------
|
||||
async function fetchState() {
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/state`);
|
||||
const data = await res.json();
|
||||
setSoloUsed(data.soloUsed || []);
|
||||
setTogetherUsed(data.togetherUsed || []);
|
||||
setLogs(data.logs || []);
|
||||
setTimerRunning(data.timerRunning);
|
||||
setTimeRemaining(data.timeRemaining);
|
||||
setIsPaused(data.isPaused);
|
||||
setRinging(data.ringing);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStatusMessage('Error: cannot reach server');
|
||||
}
|
||||
}
|
||||
|
||||
// ALARM
|
||||
function playAlarm() {
|
||||
if (alarmRef.current) {
|
||||
alarmRef.current.loop = true;
|
||||
alarmRef.current.currentTime = 0;
|
||||
alarmRef.current.play().catch(e => {
|
||||
console.log('Alarm might be blocked until user interacts:', e);
|
||||
});
|
||||
}
|
||||
}
|
||||
function stopAlarm() {
|
||||
if (alarmRef.current) {
|
||||
alarmRef.current.pause();
|
||||
alarmRef.current.currentTime = 0;
|
||||
alarmRef.current.loop = false;
|
||||
}
|
||||
}
|
||||
|
||||
// SINGLE BUBBLE => 15 MIN
|
||||
async function handleBubbleClick(category, index) {
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/use-bubble`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ category, index }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
setStatusMessage(`Error: ${data.error}`);
|
||||
} else {
|
||||
setStatusMessage(`Used 1 ${category} (#${index+1}) => 15-min timer`);
|
||||
}
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
setStatusMessage('Error using bubble');
|
||||
}
|
||||
}
|
||||
|
||||
// TIMER
|
||||
async function handlePauseResume() {
|
||||
try {
|
||||
const res=await fetch(`${SERVER_URL}/pause-resume`,{ method:'POST' });
|
||||
const data=await res.json();
|
||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
||||
else setStatusMessage(`Timer paused => ${data.isPaused}`);
|
||||
} catch(err){
|
||||
console.error(err);
|
||||
setStatusMessage('Error pause/resume');
|
||||
}
|
||||
}
|
||||
async function handleFinishUp() {
|
||||
try {
|
||||
const res=await fetch(`${SERVER_URL}/finish-up`,{method:'POST'});
|
||||
const data=await res.json();
|
||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
||||
else setStatusMessage('2-min finish-up started');
|
||||
} catch(err){
|
||||
console.error(err);
|
||||
setStatusMessage('Error finish-up');
|
||||
}
|
||||
}
|
||||
async function handleCancelFinishUp(){
|
||||
try {
|
||||
const res=await fetch(`${SERVER_URL}/cancel-finish-up`,{ method:'POST'});
|
||||
const data=await res.json();
|
||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
||||
else setStatusMessage('Finish-up canceled');
|
||||
} catch(err){
|
||||
console.error(err);
|
||||
setStatusMessage('Error cancel finish-up');
|
||||
}
|
||||
}
|
||||
async function handleIgnoreRing(){
|
||||
try {
|
||||
const res=await fetch(`${SERVER_URL}/ignore-ring`,{ method:'POST'});
|
||||
const data=await res.json();
|
||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
||||
else setStatusMessage('Ignored ring (no 2-min started)');
|
||||
} catch(err){
|
||||
console.error(err);
|
||||
setStatusMessage('Error ignoring ring');
|
||||
}
|
||||
}
|
||||
|
||||
// MOVIE / EPISODE
|
||||
function startChooseMovie(){
|
||||
setChooseMovieMode(true);
|
||||
setChooseEpisodeMode(false);
|
||||
setChosen([]);
|
||||
setStatusMessage('Select exactly 4 points for a movie');
|
||||
}
|
||||
function startChooseEpisode(){
|
||||
setChooseEpisodeMode(true);
|
||||
setChooseMovieMode(false);
|
||||
setChosen([]);
|
||||
setStatusMessage('Select exactly 2 points for an episode');
|
||||
}
|
||||
function toggleChosen(category, index){
|
||||
const found=chosen.find(c=>c.category===category && c.index===index);
|
||||
if(found){
|
||||
setChosen(chosen.filter(c=> c!==found));
|
||||
} else {
|
||||
setChosen([...chosen, { category,index }]);
|
||||
}
|
||||
}
|
||||
async function submitMovie(){
|
||||
if(chosen.length!==4){
|
||||
setStatusMessage('Must pick exactly 4 points for a movie');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res=await fetch(`${SERVER_URL}/use-movie`,{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({ chosenPoints: chosen }),
|
||||
});
|
||||
const data=await res.json();
|
||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
||||
else setStatusMessage('Movie => used chosen 4 points');
|
||||
} catch(err){
|
||||
console.error(err);
|
||||
setStatusMessage('Error submitting movie');
|
||||
}
|
||||
setChooseMovieMode(false);
|
||||
setChosen([]);
|
||||
}
|
||||
async function submitEpisode(){
|
||||
if(chosen.length!==2){
|
||||
setStatusMessage('Must pick exactly 2 points for an episode');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res=await fetch(`${SERVER_URL}/use-episode`,{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({ chosenPoints: chosen }),
|
||||
});
|
||||
const data=await res.json();
|
||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
||||
else setStatusMessage('Episode => used chosen 2 points');
|
||||
} catch(err){
|
||||
console.error(err);
|
||||
setStatusMessage('Error submitting episode');
|
||||
}
|
||||
setChooseEpisodeMode(false);
|
||||
setChosen([]);
|
||||
}
|
||||
|
||||
// ADD POINTS => 3 step flow
|
||||
const [passwordChecked, setPasswordChecked] = useState(false); // if true => step2
|
||||
function openAddPointsFlow(){
|
||||
setShowAddPointsOverlay(true);
|
||||
setAddPointsStep(1);
|
||||
setPointsPassword('');
|
||||
setPointsCategory('');
|
||||
setPointsAmount(1);
|
||||
setPasswordChecked(false);
|
||||
}
|
||||
function closeAddPointsFlow(){
|
||||
setShowAddPointsOverlay(false);
|
||||
}
|
||||
// Step 1: user enters password => we check immediately
|
||||
async function checkPointsPassword() {
|
||||
if(!pointsPassword){
|
||||
setStatusMessage('Please enter a password');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res=await fetch(`${SERVER_URL}/check-password`,{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ password: pointsPassword }),
|
||||
});
|
||||
const data=await res.json();
|
||||
if(data.error){
|
||||
setStatusMessage(`Error: ${data.error}`);
|
||||
} else {
|
||||
// password correct => go to step2
|
||||
setPasswordChecked(true);
|
||||
setAddPointsStep(2);
|
||||
}
|
||||
} catch(err){
|
||||
console.error(err);
|
||||
setStatusMessage('Error checking password');
|
||||
}
|
||||
}
|
||||
// Step 2 => pick category
|
||||
function pickAddPointsCategory(cat){
|
||||
setPointsCategory(cat);
|
||||
setAddPointsStep(3);
|
||||
}
|
||||
// Step 3 => pick amount => do /add-points
|
||||
async function pickAddPointsAmount(amt){
|
||||
setPointsAmount(amt);
|
||||
// Now call POST /add-points
|
||||
try {
|
||||
const res=await fetch(`${SERVER_URL}/add-points`,{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
password: pointsPassword,
|
||||
category: pointsCategory,
|
||||
amount: amt
|
||||
})
|
||||
});
|
||||
const data=await res.json();
|
||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
||||
else setStatusMessage(`+${amt} points added to ${pointsCategory}`);
|
||||
} catch(err){
|
||||
console.error(err);
|
||||
setStatusMessage('Error adding points');
|
||||
}
|
||||
setShowAddPointsOverlay(false);
|
||||
}
|
||||
|
||||
// ADMIN
|
||||
async function handleAdminLogin(){
|
||||
const pw=prompt('Enter admin password:');
|
||||
if(!pw) return;
|
||||
try {
|
||||
const res=await fetch(`${SERVER_URL}/admin/login`,{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ password:pw }),
|
||||
});
|
||||
const data=await res.json();
|
||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
||||
else {
|
||||
setAdminMode(true);
|
||||
setStatusMessage('Admin mode enabled');
|
||||
}
|
||||
} catch(err){
|
||||
console.error(err);
|
||||
setStatusMessage('Error admin login');
|
||||
}
|
||||
}
|
||||
function handleAdminLogout(){
|
||||
setAdminMode(false);
|
||||
setStatusMessage('Admin mode disabled');
|
||||
}
|
||||
async function adminRequest(path){
|
||||
try {
|
||||
const res=await fetch(`${SERVER_URL}/admin/${path}`,{ method:'POST'});
|
||||
const data=await res.json();
|
||||
if(data.error) setStatusMessage(`Error: ${data.error}`);
|
||||
else setStatusMessage(`Admin => ${path} success`);
|
||||
} catch(err){
|
||||
console.error(err);
|
||||
setStatusMessage(`Error admin ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// RENDER BUBBLES
|
||||
function renderBubbles(usedArr, category){
|
||||
return usedArr.map((val, idx)=>{
|
||||
// If chosen => highlight
|
||||
const isChosen = chosen.find(c=> c.category===category && c.index===idx);
|
||||
const circleColor = val ? '#8B0000' : '#4caf50'; // used => dark red, unused => green
|
||||
|
||||
// Outline color for base vs appended
|
||||
let outline = (idx<5) ? '2px solid #5f7b99' : '2px solid #7f5f99';
|
||||
if(isChosen) outline='3px solid yellow';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
width:30, height:30,
|
||||
borderRadius:'50%',
|
||||
backgroundColor: circleColor,
|
||||
margin:5,
|
||||
cursor:'pointer',
|
||||
border: outline,
|
||||
boxSizing:'border-box'
|
||||
}}
|
||||
onClick={()=>{
|
||||
if(val){
|
||||
setStatusMessage('That bubble is already used.');
|
||||
return;
|
||||
}
|
||||
if(chooseMovieMode||chooseEpisodeMode){
|
||||
toggleChosen(category,idx);
|
||||
} else {
|
||||
handleBubbleClick(category,idx);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Are we in 2-min finish-up?
|
||||
const inFinishUp = timerRunning && !ringing && timeRemaining>0 && timeRemaining<=120000;
|
||||
|
||||
// Time formatting
|
||||
function formatTime(ms){
|
||||
if(ms<=0) return '00:00';
|
||||
const totalSec=Math.floor(ms/1000);
|
||||
const mm=Math.floor(totalSec/60);
|
||||
const ss=totalSec%60;
|
||||
return `${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
|
||||
}
|
||||
function formatLogDate(iso){
|
||||
const d=new Date(iso);
|
||||
const w=d.toLocaleDateString('en-US',{ weekday:'long'});
|
||||
const day=d.getDate();
|
||||
const ord=getOrdinal(day);
|
||||
const time=d.toLocaleTimeString('en-US',{ hour12:true,hour:'numeric',minute:'2-digit'});
|
||||
return `${w} ${day}${ord}, ${time}`;
|
||||
}
|
||||
function getOrdinal(n){
|
||||
const s=['th','st','nd','rd'];
|
||||
const v=n%100;
|
||||
return s[(v-20)%10]||s[v]||s[0];
|
||||
}
|
||||
|
||||
// RENDER
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h1>Screen-Time App (Remove All Earned + Immediate PW Check)</h1>
|
||||
|
||||
{statusMessage && <div style={styles.statusBox}>{statusMessage}</div>}
|
||||
|
||||
<audio ref={alarmRef} src="/alarm.mp3" />
|
||||
|
||||
{/* SOLO */}
|
||||
<section>
|
||||
<h2>Solo Points</h2>
|
||||
<div style={styles.bubbleRow}>
|
||||
{renderBubbles(soloUsed,'solo')}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* TOGETHER */}
|
||||
<section>
|
||||
<h2>Together Points</h2>
|
||||
<div style={styles.bubbleRow}>
|
||||
{renderBubbles(togetherUsed,'together')}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Add Points button => 3-step overlay */}
|
||||
<button style={styles.button} onClick={openAddPointsFlow}>
|
||||
Add Points
|
||||
</button>
|
||||
|
||||
{/* MOVIE / EPISODE */}
|
||||
<section style={{marginTop:20}}>
|
||||
{chooseMovieMode?(
|
||||
<div>
|
||||
<p>Select exactly 4 points => Movie</p>
|
||||
<button style={styles.button} onClick={submitMovie}>Submit Movie</button>
|
||||
<button style={styles.button} onClick={()=>{
|
||||
setChooseMovieMode(false);
|
||||
setChosen([]);
|
||||
}}>Cancel</button>
|
||||
</div>
|
||||
):(
|
||||
<button style={styles.button} onClick={startChooseMovie}>Movie (4 points)</button>
|
||||
)}
|
||||
{chooseEpisodeMode?(
|
||||
<div>
|
||||
<p>Select exactly 2 points => Episode</p>
|
||||
<button style={styles.button} onClick={submitEpisode}>Submit Episode</button>
|
||||
<button style={styles.button} onClick={()=>{
|
||||
setChooseEpisodeMode(false);
|
||||
setChosen([]);
|
||||
}}>Cancel</button>
|
||||
</div>
|
||||
):(
|
||||
<button style={styles.button} onClick={startChooseEpisode}>Episode (2 points)</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Timer */}
|
||||
<section style={{ marginTop:30 }}>
|
||||
{(timerRunning || timeRemaining>0) && (
|
||||
<div>
|
||||
<h3>Timer: {formatTime(timeRemaining)} {isPaused && '(Paused)'}</h3>
|
||||
<button style={styles.button} onClick={handlePauseResume}>
|
||||
{isPaused? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{ringing && (
|
||||
<div style={{ marginTop:10 }}>
|
||||
<p style={{ color:'yellow' }}>Timer ended! Alarm is ringing!</p>
|
||||
<button style={styles.button} onClick={handleFinishUp}>
|
||||
Finish Up (2 min)
|
||||
</button>
|
||||
<button style={styles.button} onClick={handleIgnoreRing}>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{inFinishUp && (
|
||||
<div style={{ marginTop:10 }}>
|
||||
<button style={styles.button} onClick={handleCancelFinishUp}>
|
||||
Cancel Finish Up
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Logs */}
|
||||
<section style={{ marginTop:20 }}>
|
||||
<h3>Recent Logs (last 5)</h3>
|
||||
{logs.slice(-5).reverse().map((entry,i)=>(
|
||||
<p key={i}>
|
||||
[{entry.type.toUpperCase()}]
|
||||
{entry.index!=null && ` #${entry.index+1}`}
|
||||
{' '}at {formatLogDate(entry.usedAt)}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* ADD POINTS OVERLAY */}
|
||||
{showAddPointsOverlay && (
|
||||
<div style={styles.overlay}>
|
||||
<div style={styles.addPointsContainer}>
|
||||
{addPointsStep === 1 && (
|
||||
<>
|
||||
<h3>Step 1: Enter Password</h3>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="password"
|
||||
value={pointsPassword}
|
||||
onChange={e=> setPointsPassword(e.target.value)}
|
||||
/>
|
||||
<button style={styles.button} onClick={checkPointsPassword}>
|
||||
Check Password
|
||||
</button>
|
||||
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{addPointsStep === 2 && (
|
||||
<>
|
||||
<h3>Step 2: Choose Category</h3>
|
||||
<div style={styles.buttonRow}>
|
||||
<button style={styles.button} onClick={()=> pickAddPointsCategory('solo')}>
|
||||
Solo
|
||||
</button>
|
||||
<button style={styles.button} onClick={()=> pickAddPointsCategory('together')}>
|
||||
Together
|
||||
</button>
|
||||
</div>
|
||||
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{addPointsStep === 3 && (
|
||||
<>
|
||||
<h3>Step 3: How many points?</h3>
|
||||
<div style={styles.buttonRow}>
|
||||
{[1,2,3,4].map(amt=>(
|
||||
<button
|
||||
key={amt}
|
||||
style={styles.button}
|
||||
onClick={()=> pickAddPointsAmount(amt)}
|
||||
>
|
||||
+{amt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Panel */}
|
||||
{adminMode?(
|
||||
<div style={styles.adminPanel}>
|
||||
<h2>Admin Panel</h2>
|
||||
<button style={styles.adminButton} onClick={()=>adminRequest('clear-logs')}>Clear Logs</button>
|
||||
<button style={styles.adminButton} onClick={()=>adminRequest('reset-usage')}>Reset Usage</button>
|
||||
{/* The new remove-all-earned points button */}
|
||||
<button style={styles.adminButton} onClick={()=>adminRequest('remove-all-earned')}>
|
||||
Remove All Earned
|
||||
</button>
|
||||
<button style={styles.adminButton} onClick={()=>adminRequest('add-earned')}>+1 (SoloUsed)</button>
|
||||
<button style={styles.adminButton} onClick={()=>adminRequest('remove-earned')}>-1 (SoloUsed)</button>
|
||||
<button style={styles.adminButton} onClick={()=>adminRequest('clear-timer')}>Clear Timer</button>
|
||||
<button style={styles.adminButton} onClick={()=>adminRequest('expire-timer')}>Expire Timer (1s)</button>
|
||||
<button style={styles.adminButton} onClick={handleAdminLogout}>Logout Admin</button>
|
||||
</div>
|
||||
):(
|
||||
<div style={{marginTop:30}}>
|
||||
<button style={styles.adminButton} onClick={handleAdminLogin}>
|
||||
Admin Login
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// STYLES
|
||||
// -----------------------------
|
||||
const styles = {
|
||||
container: {
|
||||
maxWidth:800,
|
||||
margin:'20px auto',
|
||||
fontFamily:'sans-serif',
|
||||
backgroundColor:'#222',
|
||||
color:'#fff',
|
||||
padding:20,
|
||||
borderRadius:6
|
||||
},
|
||||
statusBox: {
|
||||
backgroundColor:'#333',
|
||||
padding:10,
|
||||
marginBottom:10,
|
||||
borderRadius:4
|
||||
},
|
||||
bubbleRow: {
|
||||
display:'flex',
|
||||
flexWrap:'wrap',
|
||||
justifyContent:'center',
|
||||
marginTop:10,
|
||||
marginBottom:10
|
||||
},
|
||||
button: {
|
||||
backgroundColor:'#4caf50',
|
||||
border:'none',
|
||||
color:'#fff',
|
||||
padding:'8px 12px',
|
||||
margin:5,
|
||||
borderRadius:4,
|
||||
cursor:'pointer'
|
||||
},
|
||||
overlay: {
|
||||
position:'fixed',
|
||||
top:0, left:0, right:0, bottom:0,
|
||||
backgroundColor:'rgba(0,0,0,0.6)',
|
||||
display:'flex',
|
||||
justifyContent:'center',
|
||||
alignItems:'center',
|
||||
zIndex:9999
|
||||
},
|
||||
addPointsContainer: {
|
||||
backgroundColor:'#333',
|
||||
padding:20,
|
||||
borderRadius:8,
|
||||
width:'80%',
|
||||
maxWidth:400,
|
||||
textAlign:'center'
|
||||
},
|
||||
input: {
|
||||
width:'80%',
|
||||
padding:8,
|
||||
margin:'10px 0',
|
||||
borderRadius:4,
|
||||
border:'1px solid #999',
|
||||
fontSize:16
|
||||
},
|
||||
buttonRow: {
|
||||
display:'flex',
|
||||
justifyContent:'center',
|
||||
flexWrap:'wrap',
|
||||
marginTop:10,
|
||||
marginBottom:10
|
||||
},
|
||||
adminPanel: {
|
||||
marginTop:30,
|
||||
backgroundColor:'#333',
|
||||
padding:20,
|
||||
borderRadius:4
|
||||
},
|
||||
adminButton: {
|
||||
backgroundColor:'#9C27B0',
|
||||
border:'none',
|
||||
color:'#fff',
|
||||
padding:'6px 10px',
|
||||
margin:5,
|
||||
borderRadius:4,
|
||||
cursor:'pointer'
|
||||
}
|
||||
};
|
8
src/App.test.js
Normal file
8
src/App.test.js
Normal 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();
|
||||
});
|
13
src/index.css
Normal file
13
src/index.css
Normal file
@ -0,0 +1,13 @@
|
||||
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;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
17
src/index.js
Normal file
17
src/index.js
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</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();
|
1
src/logo.svg
Normal file
1
src/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
After Width: | Height: | Size: 2.6 KiB |
13
src/reportWebVitals.js
Normal file
13
src/reportWebVitals.js
Normal 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;
|
5
src/setupTests.js
Normal file
5
src/setupTests.js
Normal 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';
|
Loading…
x
Reference in New Issue
Block a user