init kinda

This commit is contained in:
eggman20339 2025-01-15 16:31:38 -05:00
parent ece2dadeea
commit dadfa40fcc
23 changed files with 19013 additions and 0 deletions

23
.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*

324
App.jsx Normal file
View 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
View 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
View 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)

BIN
alarm.mp3 Normal file

Binary file not shown.

17320
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View 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

Binary file not shown.

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

43
public/index.html Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View 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
View File

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

359
server.js Normal file
View 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
View 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
View 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
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();
});

13
src/index.css Normal file
View 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
View 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
View 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
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;

5
src/setupTests.js Normal file
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';