push notifs

This commit is contained in:
eggman20339 2025-01-17 14:06:07 -05:00
parent ab91a53b15
commit 49b71ae1c7
5 changed files with 700 additions and 409 deletions

125
package-lock.json generated
View File

@ -8,10 +8,13 @@
"name": "untitled2",
"version": "0.1.0",
"dependencies": {
"cors": "^2.8.5",
"cra-template": "1.2.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-scripts": "5.0.1"
"express": "^4.21.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-scripts": "5.0.1",
"web-push": "^3.6.7"
}
},
"node_modules/@alloc/quick-lru": {
@ -4672,6 +4675,17 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@ -5058,6 +5072,11 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg=="
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@ -5194,6 +5213,11 @@
"node-int64": "^0.4.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -5794,6 +5818,18 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@ -6650,6 +6686,14 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -7687,7 +7731,6 @@
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@ -8828,6 +8871,14 @@
"entities": "^2.0.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"engines": {
"node": ">=16"
}
},
"node_modules/http-deceiver": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
@ -10829,6 +10880,25 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -16116,17 +16186,16 @@
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"license": "Apache-2.0",
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
"node": ">=4.2.0"
}
},
"node_modules/unbox-primitive": {
@ -16421,6 +16490,44 @@
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-push/node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"engines": {
"node": ">= 14"
}
},
"node_modules/web-push/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/webidl-conversions": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",

View File

@ -3,10 +3,13 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"cors": "^2.8.5",
"cra-template": "1.2.0",
"express": "^4.21.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-scripts": "5.0.1"
"react-scripts": "5.0.1",
"web-push": "^3.6.7"
},
"scripts": {
"start": "react-scripts start",
@ -14,6 +17,7 @@
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"proxy": "http://localhost:4000",
"eslintConfig": {
"extends": [
"react-app",

45
public/service-worker.js Normal file
View File

@ -0,0 +1,45 @@
/* public/service-worker.js
Listens to 'push' events and shows a notification.
This file MUST be served via HTTPS or on localhost.
*/
self.addEventListener('push', function(event) {
if (!event.data) {
return;
}
const payload = event.data.json(); // {title, body}
const title = payload.title || 'Timer Notification';
const body = payload.body || 'Time is up!';
const options = {
body: body,
icon: '/icon.png', // optional
badge: '/badge.png' // optional
// any other notification options
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// Handle notification click
self.addEventListener('notificationclick', function(event) {
event.notification.close();
// Focus or open the site
event.waitUntil(
clients.matchAll({ type:'window' }).then( windowClients => {
// if we already have a window open, focus it
for (let client of windowClients) {
if (client.url.includes('/PointTracker') && 'focus' in client) {
return client.focus();
}
}
// otherwise, open a new one
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
});

333
server.js
View File

@ -1,102 +1,124 @@
/*
server.js
A single Node/Express server that:
1) Manages timers, logs, admin routes (same logic as before).
2) Serves the React build from "client/build".
Node/Express + web-push sample.
- Timer logic: 15 min usage => ring => optional 2 min => ignore or finish up
- Weekly reset for base arrays (soloUsed, togetherUsed)
- Push Subscriptions in memory
- Serves React build from "client/build"
Setup Steps:
1) npm install express cors path web-push
2) Generate VAPID keys: "npx web-push generate-vapid-keys"
3) Replace placeholders below
4) "npm run build" in your client folder, so "client/build" exists
5) node server.js => listens on port 4000
*/
const express = require('express');
const cors = require('cors');
const path = require('path'); // for serving the React build
const path = require('path');
const webPush = require('web-push');
// -- REPLACE WITH YOUR ACTUAL VAPID KEYS! --
const VAPID_PUBLIC_KEY = 'BCn73fh1YZV3rFbK9H234he4nNWhhEKnYiQ_UZ1U0nxR6Q6cKvTG6v05Uyq7KXh0FxwjPOV3jHR3DPLqF5Lyfm4';
const VAPID_PRIVATE_KEY = 'ysmxNfkY_V0CVBwL0UJb1BeYl0dgrF4vw09cNWfFW-M';
// Configure web-push
webPush.setVapidDetails(
'mailto:youremail@example.com',
VAPID_PUBLIC_KEY,
VAPID_PRIVATE_KEY
);
const app = express();
app.use(cors());
app.use(express.json());
// ----- CONFIG -----
const PORT = 80;
// In-memory push subscription store
// For a real app, store in DB
let pushSubscriptions = [];
// Default base counts
/* ------------------------------------------------------------------
TIMER / WEEKLY RESET LOGIC
------------------------------------------------------------------ */
const SOLO_BASE_COUNT = 5;
const TOGETHER_BASE_COUNT = 5;
// Passwords
const POINTS_PASSWORD = 'ParentsOnly25'; // for adding points
const ADMIN_PASSWORD = 'Bubb75i-i'; // 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 state
const FIFTEEN_MINUTES = 15 * 60 * 1000;
const TWO_MINUTES = 2 * 60 * 1000;
let timerRunning = false;
let timeRemaining = 0;
let isPaused = false;
let activeUsage = null; // e.g. { category:'solo'|'together', index }
let activeUsage = null; // e.g. { category:'solo'|'together', index:number }
let ringing = false;
// -----------------------------
// Weekly Reset Logic
// -----------------------------
// Weekly reset to set all usage to false if new Monday
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);
console.log('[Server] Weekly reset triggered on Monday =>', currentMonday);
}
}
// Decrement the timer
// Timer loop => decrement
setInterval(() => {
if (timerRunning && !isPaused && timeRemaining > 0) {
timeRemaining -= 1000;
if (timeRemaining <= 0) {
// If finishing a 15-min => ring; if finishing 2-min => just end
// Timer ended
timerRunning = false;
timeRemaining = 0;
// If we aren't already ringing, that means we ended the 15-min block => ring
// If we ended a 15-min block => ring
if (!ringing) {
ringing = true;
console.log('[Server] 15-minute timer ended => ringing=true');
console.log('[Server] 15-minute ended => ringing=true');
// Send push notification to all
sendPushToAll('Timer Ended!', 'Time is up! Tap for details.');
}
}
}
}, 1000);
// -----------------------------
// EXPRESS / MIDDLEWARE
// -----------------------------
app.use(cors());
app.use(express.json());
// Send push to all subscribed
function sendPushToAll(title, body) {
console.log('[Server] Sending push to', pushSubscriptions.length, 'subscribers');
const payload = JSON.stringify({ title, body });
// Weekly reset check on each request
pushSubscriptions.forEach((sub) => {
webPush.sendNotification(sub, payload).catch((err) => {
console.error('[Push Error]', err);
});
});
}
// Middleware => check weekly reset on each request
app.use((req, res, next) => {
resetIfNewWeek();
next();
});
// -----------------------------
// API ROUTES
// -----------------------------
/* ------------------------------------------------------------------
API ROUTES
------------------------------------------------------------------ */
// GET /state => return current usage/timer
app.get('/state', (req, res) => {
res.json({
soloUsed,
@ -111,63 +133,50 @@ app.get('/state', (req, res) => {
});
});
// Single bubble => 15-min timer
// 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 currently ringing => ignore
if (ringing) {
timerRunning = false;
timeRemaining = 0;
isPaused = false;
activeUsage = null;
ringing = false;
console.log('[Server] Timer was ringing => auto-ignored, new usage starts.');
console.log('[Server] Timer was ringing => auto-ignored => new usage starts.');
}
const { category, index } = req.body;
let arr = category === 'solo' ? soloUsed : category === 'together' ? togetherUsed : null;
if (!arr) 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]) return res.status(400).json({ error: 'That bubble is already used.' });
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 });
logs.push({ type: category, index, usedAt: new Date().toISOString() });
// 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 });
res.json({ success: true });
});
// Check password quickly
// check password
app.post('/check-password', (req, res) => {
const { password } = req.body;
if (password !== POINTS_PASSWORD) {
if (password !== 'mySecretPassword') {
return res.status(403).json({ error: 'Incorrect password.' });
}
return res.json({ success: true });
res.json({ success: true });
});
// Add points => appends "amount" new false items to solo/together
// add-points => append new false items
app.post('/add-points', (req, res) => {
const { password, category, amount } = req.body;
if (password !== POINTS_PASSWORD) {
return res.status(403).json({ error: 'Incorrect password.' });
if (password !== 'mySecretPassword') {
return res.status(403).json({ error: 'Wrong password' });
}
if (!['solo', 'together'].includes(category)) {
return res.status(400).json({ error: 'Invalid category' });
@ -175,19 +184,18 @@ app.post('/add-points', (req, res) => {
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}`);
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}`);
console.log(`[Server] +${amount} appended to togetherUsed => length now`, togetherUsed.length);
}
return res.json({ success: true });
res.json({ success: true });
});
// Pause/Resume
@ -200,10 +208,10 @@ app.post('/pause-resume', (req, res) => {
}
isPaused = !isPaused;
console.log('[Server] Timer pause =>', isPaused);
return res.json({ success: true, isPaused });
res.json({ success: true, isPaused });
});
// Finish-up => only if ringing
// 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.' });
@ -213,10 +221,10 @@ app.post('/finish-up', (req, res) => {
isPaused = false;
ringing = false;
console.log('[Server] 2-min finish-up started');
return res.json({ success: true });
res.json({ success: true });
});
// Cancel finish-up
// 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.' });
@ -226,10 +234,10 @@ app.post('/cancel-finish-up', (req, res) => {
isPaused = false;
activeUsage = null;
console.log('[Server] 2-min finish-up canceled');
return res.json({ success: true });
res.json({ success: true });
});
// Ignore ring => if ringing
// ignore-ring
app.post('/ignore-ring', (req, res) => {
if (!ringing) {
return res.status(400).json({ error: 'Not currently ringing.' });
@ -240,122 +248,145 @@ app.post('/ignore-ring', (req, res) => {
activeUsage = null;
ringing = false;
console.log('[Server] Ring ignored => no 2-min started');
return res.json({ success: true });
res.json({ success: true });
});
// Movie => user picks 4
// use-movie
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.' });
}
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;
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 });
logs.push({ type:'movie', index:null, usedAt:new Date().toISOString() });
res.json({ success: true });
});
// Episode => user picks 2
// use-episode
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.' });
}
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]) 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;
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 points at', usedAt);
return res.json({ success: true });
logs.push({ type:'episode', index:null, usedAt:new Date().toISOString()});
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' });
/* ------------------------------------------------------------------
PUSH NOTIFICATION ROUTES
------------------------------------------------------------------ */
// POST /subscribe => client sends { subscription }
app.post('/subscribe', (req, res)=>{
const sub = req.body.subscription;
// Check if already in pushSubscriptions?
pushSubscriptions.push(sub);
console.log('[Server] New subscription => total', pushSubscriptions.length);
res.json({ success:true });
});
// POST /unsubscribe => remove from array
app.post('/unsubscribe', (req, res)=>{
const sub = req.body.subscription;
pushSubscriptions = pushSubscriptions.filter(s => s.endpoint!==sub.endpoint);
console.log('[Server] Unsubscribe => total', pushSubscriptions.length);
res.json({ success:true });
});
/* ------------------------------------------------------------------
ADMIN
------------------------------------------------------------------ */
app.post('/admin/login',(req,res)=>{
const { password }=req.body;
if(password!=='adminSecret'){
return res.status(403).json({ error:'Wrong admin password' });
}
return res.json({ success: true });
res.json({ success:true });
});
app.post('/admin/clear-logs', (req, res) => {
logs = [];
app.post('/admin/clear-logs',(req,res)=>{
logs=[];
console.log('[Server] Logs cleared by admin');
res.json({ success: true });
res.json({ success:true });
});
app.post('/admin/reset-usage', (req, res) => {
soloUsed = soloUsed.map(() => false);
togetherUsed = togetherUsed.map(() => false);
console.log('[Server] Usage reset by admin => all set false, arrays kept');
res.json({ success: true });
app.post('/admin/reset-usage',(req,res)=>{
soloUsed=soloUsed.map(()=>false);
togetherUsed=togetherUsed.map(()=>false);
console.log('[Server] Usage reset by admin => all set false');
res.json({ success:true });
});
app.post('/admin/remove-all-earned', (req, res) => {
// keep only first 5 in each array
app.post('/admin/remove-all-earned',(req,res)=>{
// revert arrays to length 5
soloUsed.splice(5);
togetherUsed.splice(5);
console.log('[Server] All earned points removed => each array back to length 5');
res.json({ success: true });
console.log('[Server] All earned points removed => each array length=5');
res.json({ success:true });
});
app.post('/admin/add-earned', (req, res) => {
// For demonstration, 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 });
console.log('[Server] Admin +1 to soloUsed => length', 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' });
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 });
console.log('[Server] Admin removed last => length', soloUsed.length);
res.json({ success:true });
});
app.post('/admin/clear-timer', (req, res) => {
timerRunning = false;
timeRemaining = 0;
isPaused = false;
activeUsage = null;
ringing = false;
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 });
res.json({ success:true });
});
app.post('/admin/expire-timer', (req, res) => {
if (timeRemaining > 1) {
timeRemaining = 1;
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 });
res.json({ success:true });
});
// -----------------------------
// SERVE REACT BUILD
// -----------------------------
app.use(express.static(path.join(__dirname, 'build')));
/* ------------------------------------------------------------------
SERVE REACT BUILD
------------------------------------------------------------------ */
app.use(express.static(path.join(__dirname,'build')));
// Catch-all to serve index.html for any unknown route
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
app.get('*',(req,res)=>{
res.sendFile(path.join(__dirname,'build','index.html'));
});
// Start server
app.listen(PORT, () => {
/* ------------------------------------------------------------------
START SERVER
------------------------------------------------------------------ */
const PORT = 4000;
app.listen(PORT, ()=>{
console.log(`[Server] Listening on port ${PORT}`);
});

View File

@ -1,68 +1,75 @@
// src/App.js
import React, { useState, useEffect, useRef } from 'react';
const SERVER_URL = 'http://10.1.0.100:80';
// Replace with your actual public VAPID key
const VAPID_PUBLIC_KEY = 'BCn73fh1YZV3rFbK9H234he4nNWhhEKnYiQ_UZ1U0nxR6Q6cKvTG6v05Uyq7KXh0FxwjPOV3jHR3DPLqF5Lyfm4';
export default function App() {
function App() {
// -----------------------------
// SERVER STATE
// BASIC STATE & MESSAGING
// -----------------------------
const [statusMsg, setStatusMsg] = useState('');
// -----------------------------
// SERVER TIMER 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
// For managing “Movie” (4 points) or “Episode” (2 points)
const [chooseMovieMode, setChooseMovieMode] = useState(false);
const [chooseEpisodeMode, setChooseEpisodeMode] = useState(false);
const [chosen, setChosen] = useState([]); // e.g. [{ category:'solo', index }, ...]
const [chosenPoints, setChosenPoints] = useState([]); // e.g. [{ category:'solo', index:2 }, ...]
// -----------------------------
// "ADD POINTS" MULTI-STEP UI
// -----------------------------
// Step 1 => enter password + immediate check
// Step 2 => pick category
// Step 3 => pick amount
// For the multi-step “Add Points” flow
const [showAddPointsOverlay, setShowAddPointsOverlay] = useState(false);
const [addPointsStep, setAddPointsStep] = useState(1);
const [addPointsStep, setAddPointsStep] = useState(1); // 1 => password, 2 => category, 3 => amount
const [pointsPassword, setPointsPassword] = useState('');
const [pointsCategory, setPointsCategory] = useState(''); // 'solo' or 'together'
const [pointsCategory, setPointsCategory] = useState('');
const [pointsAmount, setPointsAmount] = useState(1);
// Audio ref for the alarm
// Admin mode
const [adminMode, setAdminMode] = useState(false);
// Audio ref for local “alarm” (only if the tab is foreground)
const alarmRef = useRef(null);
// Poll server
// -----------------------------
// ON MOUNT: FETCH STATE
// & REGISTER PUSH
// -----------------------------
useEffect(() => {
// Poll the server for timer state
fetchState();
const id = setInterval(fetchState, 1000);
return () => clearInterval(id);
const intervalId = setInterval(fetchState, 1000);
return () => clearInterval(intervalId);
}, []);
// If ringing changes => play/stop audio
useEffect(() => {
// Register service worker + request push
registerSWAndSubscribeToPush();
}, []);
// If ringing changes, try to play local alarm (if tab is foreground)
useEffect(() => {
if (ringing) {
playAlarm();
playLocalAlarm();
} else {
stopAlarm();
stopLocalAlarm();
}
}, [ringing]);
// -----------------------------
// FETCH STATE
// FUNCTIONS
// -----------------------------
async function fetchState() {
try {
const res = await fetch(`${SERVER_URL}/state`);
const res = await fetch('/state');
const data = await res.json();
setSoloUsed(data.soloUsed || []);
setTogetherUsed(data.togetherUsed || []);
@ -72,295 +79,377 @@ export default function App() {
setIsPaused(data.isPaused);
setRinging(data.ringing);
} catch (err) {
console.error(err);
setStatusMessage('Error: cannot reach server');
console.error('Fetch state error:', err);
setStatusMsg('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);
});
}
// ----- Local alarm (only if foreground) -----
function playLocalAlarm() {
if (!alarmRef.current) return;
alarmRef.current.loop = true;
alarmRef.current.currentTime = 0;
alarmRef.current.play().catch((err) => {
console.log('Alarm play might be blocked until user interacts:', err);
});
}
function stopAlarm() {
if (alarmRef.current) {
alarmRef.current.pause();
alarmRef.current.currentTime = 0;
alarmRef.current.loop = false;
}
function stopLocalAlarm() {
if (!alarmRef.current) return;
alarmRef.current.pause();
alarmRef.current.currentTime = 0;
alarmRef.current.loop = false;
}
// SINGLE BUBBLE => 15 MIN
// ----- Single bubble => 15-min -----
async function handleBubbleClick(category, index) {
try {
const res = await fetch(`${SERVER_URL}/use-bubble`, {
const res = await fetch('/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}`);
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMessage(`Used 1 ${category} (#${index+1}) => 15-min timer`);
setStatusMsg(`Using 1 ${category} bubble => 15-min timer started`);
}
} catch(err) {
} catch (err) {
console.error(err);
setStatusMessage('Error using bubble');
setStatusMsg('Error using bubble');
}
}
// TIMER
// ----- Pause/Resume -----
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){
const res = await fetch('/pause-resume', { method: 'POST' });
const data = await res.json();
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg(`Timer paused => ${data.isPaused}`);
}
} catch (err) {
console.error(err);
setStatusMessage('Error pause/resume');
setStatusMsg('Error: pause/resume');
}
}
// ----- Finish Up (2 min) -----
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){
const res = await fetch('/finish-up', { method: 'POST' });
const data = await res.json();
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg('2-minute 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');
setStatusMsg('Error finishing up');
}
}
// MOVIE / EPISODE
function startChooseMovie(){
// ----- Cancel Finish-Up -----
async function handleCancelFinishUp() {
try {
const res = await fetch('/cancel-finish-up', { method: 'POST' });
const data = await res.json();
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg('Finish-up canceled');
}
} catch (err) {
console.error(err);
setStatusMsg('Error canceling finish-up');
}
}
// ----- Ignore ring -----
async function handleIgnoreRing() {
try {
const res = await fetch('/ignore-ring', { method: 'POST' });
const data = await res.json();
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg('Ring ignored, no 2-min started');
}
} catch (err) {
console.error(err);
setStatusMsg('Error ignoring ring');
}
}
// ----- Movie/Episode usage -----
function startChooseMovie() {
setChooseMovieMode(true);
setChooseEpisodeMode(false);
setChosen([]);
setStatusMessage('Select exactly 4 points for a movie');
setChosenPoints([]);
setStatusMsg('Select exactly 4 points for a Movie');
}
function startChooseEpisode(){
function startChooseEpisode() {
setChooseEpisodeMode(true);
setChooseMovieMode(false);
setChosen([]);
setStatusMessage('Select exactly 2 points for an episode');
setChosenPoints([]);
setStatusMsg('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));
function toggleChosen(category, index) {
const found = chosenPoints.find(
(c) => c.category === category && c.index === index
);
if (found) {
setChosenPoints(chosenPoints.filter((c) => c !== found));
} else {
setChosen([...chosen, { category,index }]);
setChosenPoints([...chosenPoints, { category, index }]);
}
}
async function submitMovie(){
if(chosen.length!==4){
setStatusMessage('Must pick exactly 4 points for a movie');
async function submitMovie() {
if (chosenPoints.length !== 4) {
setStatusMsg('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 res = await fetch('/use-movie', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chosenPoints }),
});
const data=await res.json();
if(data.error) setStatusMessage(`Error: ${data.error}`);
else setStatusMessage('Movie => used chosen 4 points');
} catch(err){
const data = await res.json();
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg('Movie => used chosen 4 points');
}
} catch (err) {
console.error(err);
setStatusMessage('Error submitting movie');
setStatusMsg('Error submitting movie');
}
setChooseMovieMode(false);
setChosen([]);
setChosenPoints([]);
}
async function submitEpisode(){
if(chosen.length!==2){
setStatusMessage('Must pick exactly 2 points for an episode');
async function submitEpisode() {
if (chosenPoints.length !== 2) {
setStatusMsg('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 res = await fetch('/use-episode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chosenPoints }),
});
const data=await res.json();
if(data.error) setStatusMessage(`Error: ${data.error}`);
else setStatusMessage('Episode => used chosen 2 points');
} catch(err){
const data = await res.json();
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg('Episode => used chosen 2 points');
}
} catch (err) {
console.error(err);
setStatusMessage('Error submitting episode');
setStatusMsg('Error submitting episode');
}
setChooseEpisodeMode(false);
setChosen([]);
setChosenPoints([]);
}
// ADD POINTS => 3 step flow
const [passwordChecked, setPasswordChecked] = useState(false); // if true => step2
function openAddPointsFlow(){
// ----- Add Points (3-step flow) -----
function openAddPointsFlow() {
setShowAddPointsOverlay(true);
setAddPointsStep(1);
setPointsPassword('');
setPointsCategory('');
setPointsAmount(1);
setPasswordChecked(false);
}
function closeAddPointsFlow(){
function closeAddPointsFlow() {
setShowAddPointsOverlay(false);
}
// Step 1: user enters password => we check immediately
// Step 1: check password immediately
async function checkPointsPassword() {
if(!pointsPassword){
setStatusMessage('Please enter a password');
if (!pointsPassword) {
setStatusMsg('Please enter a password');
return;
}
try {
const res=await fetch(`${SERVER_URL}/check-password`,{
method:'POST',
headers:{'Content-Type':'application/json'},
const res = await fetch('/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}`);
const data = await res.json();
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
// password correct => go to step2
setPasswordChecked(true);
setStatusMsg('Password ok, pick category next');
setAddPointsStep(2);
}
} catch(err){
} catch (err) {
console.error(err);
setStatusMessage('Error checking password');
setStatusMsg('Error checking password');
}
}
// Step 2 => pick category
function pickAddPointsCategory(cat){
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
async function pickAddPointsAmount(amount) {
setPointsAmount(amount);
try {
const res=await fetch(`${SERVER_URL}/add-points`,{
method:'POST',
headers:{'Content-Type':'application/json'},
const res = await fetch('/add-points', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
password: pointsPassword,
category: pointsCategory,
amount: amt
})
amount,
}),
});
const data=await res.json();
if(data.error) setStatusMessage(`Error: ${data.error}`);
else setStatusMessage(`+${amt} points added to ${pointsCategory}`);
} catch(err){
const data = await res.json();
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg(`+${amount} points added to ${pointsCategory}`);
}
} catch (err) {
console.error(err);
setStatusMessage('Error adding points');
setStatusMsg('Error adding points');
}
setShowAddPointsOverlay(false);
}
// ADMIN
async function handleAdminLogin(){
const pw=prompt('Enter admin password:');
if(!pw) return;
// ----- 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 res = await fetch('/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 {
const data = await res.json();
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setAdminMode(true);
setStatusMessage('Admin mode enabled');
setStatusMsg('Admin mode enabled');
}
} catch(err){
} catch (err) {
console.error(err);
setStatusMessage('Error admin login');
setStatusMsg('Error admin login');
}
}
function handleAdminLogout(){
function handleAdminLogout() {
setAdminMode(false);
setStatusMessage('Admin mode disabled');
setStatusMsg('Admin mode disabled');
}
async function adminRequest(path){
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){
const res = await fetch(`/admin/${path}`, { method: 'POST' });
const data = await res.json();
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg(`Admin => ${path} success`);
}
} catch (err) {
console.error(err);
setStatusMessage(`Error admin ${path}`);
setStatusMsg(`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
// -----------------------------
// PUSH NOTIFICATIONS SETUP
// -----------------------------
async function registerSWAndSubscribeToPush() {
if (!('serviceWorker' in navigator)) {
console.log('Service workers not supported in this browser.');
return;
}
try {
// Register custom service worker from /public folder
const reg = await navigator.serviceWorker.register('/service-worker.js');
console.log('Service Worker registered:', reg);
// Request Notification permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission not granted.');
return;
}
// Subscribe
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
console.log('Push subscription:', subscription);
// Send subscription to server
await fetch('/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription }),
});
console.log('Subscribed to push notifications!');
} catch (err) {
console.error('SW registration or push subscription failed:', err);
}
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i=0; i<rawData.length; i++){
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// -----------------------------
// RENDER BUBBLES (solo/together)
// -----------------------------
function renderBubbles(usedArr, category) {
return usedArr.map((val, idx) => {
const isSelected = chosenPoints.find(
(c) => c.category === category && c.index === idx
);
const bubbleColor = 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';
let outline = (idx < 5) ? '2px solid #5f7b99' : '2px solid #7f5f99';
if (isSelected) outline = '3px solid yellow';
return (
<div
key={idx}
style={{
width:30, height:30,
borderRadius:'50%',
backgroundColor: circleColor,
margin:5,
cursor:'pointer',
width: 30,
height: 30,
borderRadius: '50%',
backgroundColor: bubbleColor,
margin: 5,
cursor: 'pointer',
border: outline,
boxSizing:'border-box'
boxSizing: 'border-box',
}}
onClick={()=>{
if(val){
setStatusMessage('That bubble is already used.');
onClick={() => {
if (val) {
setStatusMsg('That bubble is already used.');
return;
}
if(chooseMovieMode||chooseEpisodeMode){
toggleChosen(category,idx);
if (chooseMovieMode || chooseEpisodeMode) {
toggleChosen(category, idx);
} else {
handleBubbleClick(category,idx);
handleBubbleClick(category, idx);
}
}}
/>
@ -368,10 +457,10 @@ export default function App() {
});
}
// Are we in 2-min finish-up?
// Are we in the 2-min finish-up?
const inFinishUp = timerRunning && !ringing && timeRemaining>0 && timeRemaining<=120000;
// Time formatting
// Format time
function formatTime(ms){
if(ms<=0) return '00:00';
const totalSec=Math.floor(ms/1000);
@ -379,13 +468,14 @@ export default function App() {
const ss=totalSec%60;
return `${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
}
// Format logs
function formatLogDate(iso){
const d=new Date(iso);
const w=d.toLocaleDateString('en-US',{ weekday:'long'});
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}`;
const timeStr=d.toLocaleTimeString('en-US',{ hour12:true,hour:'numeric',minute:'2-digit' });
return `${w} ${day}${ord}, ${timeStr}`;
}
function getOrdinal(n){
const s=['th','st','nd','rd'];
@ -393,13 +483,16 @@ export default function App() {
return s[(v-20)%10]||s[v]||s[0];
}
// RENDER
// -----------------------------
// RENDER
// -----------------------------
return (
<div style={styles.container}>
<h1>Mylin's Points</h1>
<h1>Screen-Time App (Push Notifications)</h1>
{statusMessage && <div style={styles.statusBox}>{statusMessage}</div>}
{statusMsg && <div style={styles.statusBox}>{statusMsg}</div>}
{/* Audio for local ring (foreground only) */}
<audio ref={alarmRef} src="/alarm.mp3" />
{/* SOLO */}
@ -418,7 +511,7 @@ export default function App() {
</div>
</section>
{/* Add Points button => 3-step overlay */}
{/* Add Points (3-step) */}
<button style={styles.button} onClick={openAddPointsFlow}>
Add Points
</button>
@ -427,32 +520,37 @@ export default function App() {
<section style={{marginTop:20}}>
{chooseMovieMode?(
<div>
<p>Select exactly 4 points => Movie</p>
<p>Select 4 points for a Movie</p>
<button style={styles.button} onClick={submitMovie}>Submit Movie</button>
<button style={styles.button} onClick={()=>{
<button style={styles.button} onClick={()=> {
setChooseMovieMode(false);
setChosen([]);
setChosenPoints([]);
}}>Cancel</button>
</div>
):(
<button style={styles.button} onClick={startChooseMovie}>Movie (4 points)</button>
<button style={styles.button} onClick={startChooseMovie}>
Movie (4 points)
</button>
)}
{chooseEpisodeMode?(
<div>
<p>Select exactly 2 points => Episode</p>
<p>Select 2 points for an Episode</p>
<button style={styles.button} onClick={submitEpisode}>Submit Episode</button>
<button style={styles.button} onClick={()=>{
<button style={styles.button} onClick={()=> {
setChooseEpisodeMode(false);
setChosen([]);
setChosenPoints([]);
}}>Cancel</button>
</div>
):(
<button style={styles.button} onClick={startChooseEpisode}>Episode (2 points)</button>
<button style={styles.button} onClick={startChooseEpisode}>
Episode (2 points)
</button>
)}
</section>
{/* Timer */}
<section style={{ marginTop:30 }}>
{/* Timer display */}
<section style={{marginTop:30}}>
{(timerRunning || timeRemaining>0) && (
<div>
<h3>Timer: {formatTime(timeRemaining)} {isPaused && '(Paused)'}</h3>
@ -462,18 +560,14 @@ export default function App() {
</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 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 }}>
<div style={{marginTop:10}}>
<button style={styles.button} onClick={handleCancelFinishUp}>
Cancel Finish Up
</button>
@ -482,9 +576,9 @@ export default function App() {
</section>
{/* Logs */}
<section style={{ marginTop:20 }}>
<section style={{marginTop:20}}>
<h3>Recent Logs (last 5)</h3>
{logs.slice(-5).reverse().map((entry,i)=>(
{logs.slice(-5).reverse().map((entry, i)=>(
<p key={i}>
[{entry.type.toUpperCase()}]
{entry.index!=null && ` #${entry.index+1}`}
@ -493,11 +587,11 @@ export default function App() {
))}
</section>
{/* ADD POINTS OVERLAY */}
{/* Add Points Overlay */}
{showAddPointsOverlay && (
<div style={styles.overlay}>
<div style={styles.addPointsContainer}>
{addPointsStep === 1 && (
<div style={styles.addPointsDialog}>
{addPointsStep===1 && (
<>
<h3>Step 1: Enter Password</h3>
<input
@ -509,44 +603,51 @@ export default function App() {
<button style={styles.button} onClick={checkPointsPassword}>
Check Password
</button>
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
<button
style={{...styles.button, backgroundColor:'#999'}}
onClick={closeAddPointsFlow}
>
Cancel
</button>
</>
)}
{addPointsStep === 2 && (
{addPointsStep===2 && (
<>
<h3>Step 2: Choose Category</h3>
<div style={styles.buttonRow}>
<button style={styles.button} onClick={()=> pickAddPointsCategory('solo')}>
<button style={styles.button} onClick={()=>pickAddPointsCategory('solo')}>
Solo
</button>
<button style={styles.button} onClick={()=> pickAddPointsCategory('together')}>
<button style={styles.button} onClick={()=>pickAddPointsCategory('together')}>
Together
</button>
</div>
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
<button
style={{...styles.button, backgroundColor:'#999'}}
onClick={closeAddPointsFlow}
>
Cancel
</button>
</>
)}
{addPointsStep === 3 && (
{addPointsStep===3 && (
<>
<h3>Step 3: How many points?</h3>
<div style={styles.buttonRow}>
{[1,2,3,4].map(amt=>(
{[1,2,3,4].map(amt => (
<button
key={amt}
style={styles.button}
onClick={()=> pickAddPointsAmount(amt)}
onClick={()=>pickAddPointsAmount(amt)}
>
+{amt}
</button>
))}
</div>
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
<button
style={{...styles.button, backgroundColor:'#999'}}
onClick={closeAddPointsFlow}
>
Cancel
</button>
</>
@ -561,7 +662,6 @@ export default function App() {
<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>
@ -569,7 +669,9 @@ export default function App() {
<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>
<button style={styles.adminButton} onClick={handleAdminLogout}>
Logout Admin
</button>
</div>
):(
<div style={{marginTop:30}}>
@ -583,7 +685,7 @@ export default function App() {
}
// -----------------------------
// STYLES
// STYLES
// -----------------------------
const styles = {
container: {
@ -626,7 +728,7 @@ const styles = {
alignItems:'center',
zIndex:9999
},
addPointsContainer: {
addPointsDialog: {
backgroundColor:'#333',
padding:20,
borderRadius:8,
@ -665,3 +767,5 @@ const styles = {
cursor:'pointer'
}
};
export default App;