diff --git a/package-lock.json b/package-lock.json index 05c24a0..a31b991 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 545788b..42cc54c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 0000000..28b18ff --- /dev/null +++ b/public/service-worker.js @@ -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('/'); + } + }) + ); +}); diff --git a/server.js b/server.js index bf8444f..c93b90a 100644 --- a/server.js +++ b/server.js @@ -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}`); }); diff --git a/src/App.js b/src/App.js index 7bcb1cd..277044a 100644 --- a/src/App.js +++ b/src/App.js @@ -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 { + 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 (
{ - 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 (
-

Mylin's Points

+

Screen-Time App (Push Notifications)

- {statusMessage &&
{statusMessage}
} + {statusMsg &&
{statusMsg}
} + {/* Audio for local ring (foreground only) */}
- {/* Add Points button => 3-step overlay */} + {/* Add Points (3-step) */} @@ -427,32 +520,37 @@ export default function App() {
{chooseMovieMode?(
-

Select exactly 4 points => Movie

+

Select 4 points for a Movie

-
):( - + )} + {chooseEpisodeMode?(
-

Select exactly 2 points => Episode

+

Select 2 points for an Episode

-
):( - + )}
- {/* Timer */} -
+ {/* Timer display */} +
{(timerRunning || timeRemaining>0) && (

Timer: {formatTime(timeRemaining)} {isPaused && '(Paused)'}

@@ -462,18 +560,14 @@ export default function App() {
)} {ringing && ( -
-

Timer ended! Alarm is ringing!

- - +
+

Timer ended! Alarm is ringing!

+ +
)} {inFinishUp && ( -
+
@@ -482,9 +576,9 @@ export default function App() {
{/* Logs */} -
+

Recent Logs (last 5)

- {logs.slice(-5).reverse().map((entry,i)=>( + {logs.slice(-5).reverse().map((entry, i)=>(

[{entry.type.toUpperCase()}] {entry.index!=null && ` #${entry.index+1}`} @@ -493,11 +587,11 @@ export default function App() { ))}

- {/* ADD POINTS OVERLAY */} + {/* Add Points Overlay */} {showAddPointsOverlay && (
-
- {addPointsStep === 1 && ( +
+ {addPointsStep===1 && ( <>

Step 1: Enter Password

Check Password - )} - - {addPointsStep === 2 && ( + {addPointsStep===2 && ( <>

Step 2: Choose Category

- -
- )} - - {addPointsStep === 3 && ( + {addPointsStep===3 && ( <>

Step 3: How many points?

- {[1,2,3,4].map(amt=>( + {[1,2,3,4].map(amt => ( ))}
- @@ -561,7 +662,6 @@ export default function App() {

Admin Panel

- {/* The new remove-all-earned points button */} @@ -569,7 +669,9 @@ export default function App() { - +
):(
@@ -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;