This commit is contained in:
eggman20339 2025-01-17 14:35:01 -05:00
parent d0c8e3988a
commit 8e5b0ef2ba
2 changed files with 253 additions and 354 deletions

225
server.js
View File

@ -1,18 +1,11 @@
/*
server.js
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
- Listens on port 80
- Uses "web-push" for push notifications (replace VAPID keys!)
- Timer logic: 15-min usage => ring => optional 2-min => or ignore
- Weekly reset for base usage arrays
- 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');
@ -20,13 +13,15 @@ const cors = require('cors');
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';
// -----------------------------------------------------------------
// REPLACE THESE with your actual VAPID keys from "web-push generate-vapid-keys"
// -----------------------------------------------------------------
const VAPID_PUBLIC_KEY = 'BGTl7xYXEr2gY_O6gmVGYy0DTFlm6vepYUmkt8_6P9PHwOJcHsPZ5CUSEzsoCq7CszPwMyUbq0nG6xjrzJMWZOg';
const VAPID_PRIVATE_KEY = 'jCSzm4m7EQtv_pKw1Ao1cP4KIvoip2NVpfUiEgJnVR4';
// Configure web-push
webPush.setVapidDetails(
'mailto:youremail@example.com',
'mailto:someone@example.com', // can be any mailto:
VAPID_PUBLIC_KEY,
VAPID_PRIVATE_KEY
);
@ -35,8 +30,7 @@ const app = express();
app.use(cors());
app.use(express.json());
// In-memory push subscription store
// For a real app, store in DB
// In-memory push subscriptions (for real usage, store in DB)
let pushSubscriptions = [];
/* ------------------------------------------------------------------
@ -57,10 +51,10 @@ const TWO_MINUTES = 2 * 60 * 1000;
let timerRunning = false;
let timeRemaining = 0;
let isPaused = false;
let activeUsage = null; // e.g. { category:'solo'|'together', index:number }
let activeUsage = null; // e.g. { category:'solo'|'together', index:number }
let ringing = false;
// Weekly reset to set all usage to false if new Monday
// Weekly reset => keep array lengths, set 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);
@ -73,24 +67,22 @@ function resetIfNewWeek() {
soloUsed = soloUsed.map(() => false);
togetherUsed = togetherUsed.map(() => false);
lastReset = currentMonday;
console.log('[Server] Weekly reset triggered on Monday =>', currentMonday);
console.log('[Server] Weekly reset triggered =>', currentMonday);
}
}
// Timer loop => decrement
// Timer loop => decrement once per second
setInterval(() => {
if (timerRunning && !isPaused && timeRemaining > 0) {
timeRemaining -= 1000;
if (timeRemaining <= 0) {
// Timer ended
timerRunning = false;
timeRemaining = 0;
// If we ended a 15-min block => ring
// If this was the 15-min block => ring
if (!ringing) {
ringing = true;
console.log('[Server] 15-minute ended => ringing=true');
// Send push notification to all
sendPushToAll('Timer Ended!', 'Time is up! Tap for details.');
sendPushToAll('Timer ended!', 'Time is up! Tap for details.');
}
}
}
@ -100,7 +92,6 @@ setInterval(() => {
function sendPushToAll(title, body) {
console.log('[Server] Sending push to', pushSubscriptions.length, 'subscribers');
const payload = JSON.stringify({ title, body });
pushSubscriptions.forEach((sub) => {
webPush.sendNotification(sub, payload).catch((err) => {
console.error('[Push Error]', err);
@ -108,7 +99,7 @@ function sendPushToAll(title, body) {
});
}
// Middleware => check weekly reset on each request
// Middleware => check weekly reset
app.use((req, res, next) => {
resetIfNewWeek();
next();
@ -118,7 +109,7 @@ app.use((req, res, next) => {
API ROUTES
------------------------------------------------------------------ */
// GET /state => return current usage/timer
// GET /state => current usage/timer
app.get('/state', (req, res) => {
res.json({
soloUsed,
@ -138,20 +129,20 @@ app.post('/use-bubble', (req, res) => {
if (timerRunning && !ringing && timeRemaining > 0) {
return res.status(400).json({ error: 'A timer is already active.' });
}
// If currently ringing => ignore
// If currently ringing => auto-ignore
if (ringing) {
timerRunning = false;
timeRemaining = 0;
isPaused = false;
activeUsage = null;
ringing = false;
console.log('[Server] Timer was ringing => auto-ignored => new usage starts.');
console.log('[Server] Timer was ringing => auto-ignored => new usage');
}
const { category, index } = req.body;
let arr = category === 'solo' ? soloUsed : category === 'together' ? togetherUsed : null;
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.' });
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: 'Bubble already used' });
arr[index] = true;
logs.push({ type: category, index, usedAt: new Date().toISOString() });
@ -163,16 +154,16 @@ app.post('/use-bubble', (req, res) => {
res.json({ success: true });
});
// check password
// POST /check-password => verify "mySecretPassword"
app.post('/check-password', (req, res) => {
const { password } = req.body;
if (password !== 'mySecretPassword') {
return res.status(403).json({ error: 'Incorrect password.' });
return res.status(403).json({ error: 'Incorrect password' });
}
res.json({ success: true });
});
// add-points => append new false items
// POST /add-points => append new false items
app.post('/add-points', (req, res) => {
const { password, category, amount } = req.body;
if (password !== 'mySecretPassword') {
@ -181,16 +172,16 @@ app.post('/add-points', (req, res) => {
if (!['solo', 'together'].includes(category)) {
return res.status(400).json({ error: 'Invalid category' });
}
if (typeof amount !== 'number' || amount < 1 || amount > 4) {
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++) {
if (category==='solo') {
for (let i=0; i<amount; i++) {
soloUsed.push(false);
}
console.log(`[Server] +${amount} appended to soloUsed => length now`, soloUsed.length);
} else {
for (let i = 0; i < amount; i++) {
for (let i=0; i<amount; i++) {
togetherUsed.push(false);
}
console.log(`[Server] +${amount} appended to togetherUsed => length now`, togetherUsed.length);
@ -198,91 +189,87 @@ app.post('/add-points', (req, res) => {
res.json({ success: true });
});
// Pause/Resume
// POST /pause-resume => toggles paused
app.post('/pause-resume', (req, res) => {
if (!timerRunning && timeRemaining <= 0) {
if(!timerRunning && timeRemaining<=0){
return res.status(400).json({ error: 'No active timer.' });
}
if (ringing) {
if(ringing){
return res.status(400).json({ error: 'Timer is ringing, cannot pause.' });
}
isPaused = !isPaused;
console.log('[Server] Timer pause =>', isPaused);
res.json({ success: true, isPaused });
});
// finish-up => only if ringing
app.post('/finish-up', (req, res) => {
if (!ringing) {
// POST /finish-up => only if ringing => 2-min
app.post('/finish-up',(req,res)=>{
if(!ringing){
return res.status(400).json({ error: 'Not currently ringing. 2-min only after 15-min ends.' });
}
timerRunning = true;
timeRemaining = TWO_MINUTES;
isPaused = false;
ringing = false;
console.log('[Server] 2-min finish-up started');
res.json({ success: true });
timerRunning=true;
timeRemaining=TWO_MINUTES;
isPaused=false;
ringing=false;
res.json({ success:true });
});
// cancel-finish-up
app.post('/cancel-finish-up', (req, res) => {
if (!timerRunning || ringing || timeRemaining > TWO_MINUTES) {
return res.status(400).json({ error: 'Not currently in finish-up timer.' });
// POST /cancel-finish-up => end the 2-min early
app.post('/cancel-finish-up',(req,res)=>{
if(!timerRunning || ringing || timeRemaining> TWO_MINUTES){
return res.status(400).json({ error: 'Not in finish-up timer.' });
}
timerRunning = false;
timeRemaining = 0;
isPaused = false;
activeUsage = null;
console.log('[Server] 2-min finish-up canceled');
res.json({ success: true });
timerRunning=false;
timeRemaining=0;
isPaused=false;
activeUsage=null;
res.json({ success:true });
});
// ignore-ring
app.post('/ignore-ring', (req, res) => {
if (!ringing) {
// POST /ignore-ring => skip the 2-min if rung
app.post('/ignore-ring',(req,res)=>{
if(!ringing){
return res.status(400).json({ error: 'Not currently ringing.' });
}
timerRunning = false;
timeRemaining = 0;
isPaused = false;
activeUsage = null;
ringing = false;
console.log('[Server] Ring ignored => no 2-min started');
res.json({ success: true });
timerRunning=false;
timeRemaining=0;
isPaused=false;
activeUsage=null;
ringing=false;
res.json({ success:true });
});
// 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' });
// POST /use-movie => pick 4 points
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.' });
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' });
if(arr[p.index]) return res.status(400).json({ error:'Point already used' });
}
for (const p of chosenPoints) {
let arr = p.category==='solo'?soloUsed: togetherUsed;
for(const p of chosenPoints){
let arr = p.category==='solo'?soloUsed:togetherUsed;
arr[p.index] = true;
}
logs.push({ type:'movie', index:null, usedAt:new Date().toISOString() });
res.json({ success: true });
logs.push({ type:'movie', index:null, usedAt:new Date().toISOString()});
res.json({ success:true });
});
// 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' });
// POST /use-episode => pick 2 points
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) {
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]) return res.status(400).json({ error:'A chosen point is already used.' });
if(!arr) return res.status(400).json({ error:'Invalid category' });
if(arr[p.index]) return res.status(400).json({ error:'Point already used' });
}
for (const p of chosenPoints) {
let arr = p.category==='solo'?soloUsed: togetherUsed;
for(const p of chosenPoints){
let arr = p.category==='solo'?soloUsed:togetherUsed;
arr[p.index]=true;
}
logs.push({ type:'episode', index:null, usedAt:new Date().toISOString()});
@ -292,25 +279,24 @@ app.post('/use-episode', (req, res) => {
/* ------------------------------------------------------------------
PUSH NOTIFICATION ROUTES
------------------------------------------------------------------ */
// POST /subscribe => client sends { subscription }
app.post('/subscribe', (req, res)=>{
// POST /subscribe => store 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);
console.log('[Server] New subscription => total:', pushSubscriptions.length);
res.json({ success:true });
});
// POST /unsubscribe => remove from array
app.post('/unsubscribe', (req, res)=>{
// POST /unsubscribe => remove subscription
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);
console.log('[Server] Unsubscribe => total:', pushSubscriptions.length);
res.json({ success:true });
});
/* ------------------------------------------------------------------
ADMIN
ADMIN ROUTES
------------------------------------------------------------------ */
app.post('/admin/login',(req,res)=>{
const { password }=req.body;
@ -319,57 +305,42 @@ app.post('/admin/login',(req,res)=>{
}
res.json({ success:true });
});
app.post('/admin/clear-logs',(req,res)=>{
logs=[];
console.log('[Server] Logs cleared by admin');
res.json({ success:true });
});
app.post('/admin/reset-usage',(req,res)=>{
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)=>{
// revert arrays to length 5
soloUsed.splice(5);
togetherUsed.splice(5);
console.log('[Server] All earned points removed => each array length=5');
res.json({ success:true });
});
// 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', 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' });
return res.status(400).json({ error:'No bubble to remove' });
}
soloUsed.pop();
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;
console.log('[Server] Timer cleared by admin');
res.json({ success:true });
});
app.post('/admin/expire-timer',(req,res)=>{
if(timeRemaining>1){
timeRemaining=1;
console.log('[Server] Timer => set to 1s => about to end');
}
res.json({ success:true });
});
@ -377,16 +348,14 @@ app.post('/admin/expire-timer',(req,res)=>{
/* ------------------------------------------------------------------
SERVE REACT BUILD
------------------------------------------------------------------ */
app.use(express.static(path.join(__dirname,'build')));
app.get('*',(req,res)=>{
res.sendFile(path.join(__dirname,'build','index.html'));
app.use(express.static(path.join(__dirname, 'build')));
app.get('*', (req,res) => {
res.sendFile(path.join(__dirname, 'build', 'index.html'));
});
/* ------------------------------------------------------------------
START SERVER
START SERVER ON PORT 80
------------------------------------------------------------------ */
const PORT = 80;
app.listen(PORT, ()=>{
console.log(`[Server] Listening on port ${PORT}`);
app.listen(80, () => {
console.log('[Server] Listening on port 80');
});

View File

@ -1,18 +1,12 @@
// src/App.js
import React, { useState, useEffect, useRef } from 'react';
// Replace with your actual public VAPID key
const VAPID_PUBLIC_KEY = 'BCn73fh1YZV3rFbK9H234he4nNWhhEKnYiQ_UZ1U0nxR6Q6cKvTG6v05Uyq7KXh0FxwjPOV3jHR3DPLqF5Lyfm4';
// REPLACE with your public VAPID key from "web-push generate-vapid-keys"
const VAPID_PUBLIC_KEY = 'BGTl7xYXEr2gY_O6gmVGYy0DTFlm6vepYUmkt8_6P9PHwOJcHsPZ5CUSEzsoCq7CszPwMyUbq0nG6xjrzJMWZOg';
function App() {
// -----------------------------
// BASIC STATE & MESSAGING
// -----------------------------
const [statusMsg, setStatusMsg] = useState('');
// -----------------------------
// SERVER TIMER STATE
// -----------------------------
// Timer state
const [soloUsed, setSoloUsed] = useState([]);
const [togetherUsed, setTogetherUsed] = useState([]);
const [logs, setLogs] = useState([]);
@ -21,41 +15,35 @@ function App() {
const [isPaused, setIsPaused] = useState(false);
const [ringing, setRinging] = useState(false);
// For managing “Movie” (4 points) or “Episode” (2 points)
// Movie/Episode selection
const [chooseMovieMode, setChooseMovieMode] = useState(false);
const [chooseEpisodeMode, setChooseEpisodeMode] = useState(false);
const [chosenPoints, setChosenPoints] = useState([]); // e.g. [{ category:'solo', index:2 }, ...]
const [chosenPoints, setChosenPoints] = useState([]);
// For the multi-step “Add Points flow
// Add Points flow
const [showAddPointsOverlay, setShowAddPointsOverlay] = useState(false);
const [addPointsStep, setAddPointsStep] = useState(1); // 1 => password, 2 => category, 3 => amount
const [addPointsStep, setAddPointsStep] = useState(1);
const [pointsPassword, setPointsPassword] = useState('');
const [pointsCategory, setPointsCategory] = useState('');
const [pointsAmount, setPointsAmount] = useState(1);
// Admin mode
const [adminMode, setAdminMode] = useState(false);
// Audio ref for local “alarm” (only if the tab is foreground)
// Local "alarm" audio (only if page is foreground)
const alarmRef = useRef(null);
// -----------------------------
// ON MOUNT: FETCH STATE
// & REGISTER PUSH
// -----------------------------
useEffect(() => {
// Poll the server for timer state
// Poll server state
fetchState();
const intervalId = setInterval(fetchState, 1000);
return () => clearInterval(intervalId);
const id = setInterval(fetchState, 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
// Register service worker + request push
registerSWAndSubscribeToPush();
// Register SW + push
registerServiceWorkerAndSubscribe();
}, []);
// If ringing changes, try to play local alarm (if tab is foreground)
useEffect(() => {
if (ringing) {
playLocalAlarm();
@ -64,12 +52,9 @@ function App() {
}
}, [ringing]);
// -----------------------------
// FUNCTIONS
// -----------------------------
async function fetchState() {
try {
const res = await fetch('/state');
const res = await fetch('/state'); // same origin => server on port 80
const data = await res.json();
setSoloUsed(data.soloUsed || []);
setTogetherUsed(data.togetherUsed || []);
@ -84,13 +69,12 @@ function App() {
}
}
// ----- 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);
alarmRef.current.play().catch(err => {
console.log('Audio play blocked until user interaction:', err);
});
}
function stopLocalAlarm() {
@ -100,7 +84,6 @@ function App() {
alarmRef.current.loop = false;
}
// ----- Single bubble => 15-min -----
async function handleBubbleClick(category, index) {
try {
const res = await fetch('/use-bubble', {
@ -112,7 +95,7 @@ function App() {
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg(`Using 1 ${category} bubble => 15-min timer started`);
setStatusMsg(`Using 1 ${category} => 15-min started`);
}
} catch (err) {
console.error(err);
@ -120,7 +103,6 @@ function App() {
}
}
// ----- Pause/Resume -----
async function handlePauseResume() {
try {
const res = await fetch('/pause-resume', { method: 'POST' });
@ -132,11 +114,10 @@ function App() {
}
} catch (err) {
console.error(err);
setStatusMsg('Error: pause/resume');
setStatusMsg('Error pause/resume');
}
}
// ----- Finish Up (2 min) -----
async function handleFinishUp() {
try {
const res = await fetch('/finish-up', { method: 'POST' });
@ -144,15 +125,13 @@ function App() {
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg('2-minute finish-up started');
setStatusMsg('2-min finish-up started');
}
} catch (err) {
console.error(err);
setStatusMsg('Error finishing up');
}
}
// ----- Cancel Finish-Up -----
async function handleCancelFinishUp() {
try {
const res = await fetch('/cancel-finish-up', { method: 'POST' });
@ -164,11 +143,9 @@ function App() {
}
} catch (err) {
console.error(err);
setStatusMsg('Error canceling finish-up');
setStatusMsg('Error cancel finish-up');
}
}
// ----- Ignore ring -----
async function handleIgnoreRing() {
try {
const res = await fetch('/ignore-ring', { method: 'POST' });
@ -176,7 +153,7 @@ function App() {
if (data.error) {
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg('Ring ignored, no 2-min started');
setStatusMsg('Ring ignored');
}
} catch (err) {
console.error(err);
@ -184,7 +161,7 @@ function App() {
}
}
// ----- Movie/Episode usage -----
// Movie/Episode
function startChooseMovie() {
setChooseMovieMode(true);
setChooseEpisodeMode(false);
@ -198,33 +175,31 @@ function App() {
setStatusMsg('Select exactly 2 points for an Episode');
}
function toggleChosen(category, index) {
const found = chosenPoints.find(
(c) => c.category === category && c.index === index
);
const found = chosenPoints.find(c => c.category===category && c.index===index);
if (found) {
setChosenPoints(chosenPoints.filter((c) => c !== found));
setChosenPoints(chosenPoints.filter(c => c !== found));
} else {
setChosenPoints([...chosenPoints, { category, index }]);
}
}
async function submitMovie() {
if (chosenPoints.length !== 4) {
if (chosenPoints.length!==4) {
setStatusMsg('Must pick exactly 4 points for a movie');
return;
}
try {
const res = await fetch('/use-movie', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chosenPoints }),
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) {
const data=await res.json();
if(data.error){
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg('Movie => used chosen 4 points');
}
} catch (err) {
} catch(err){
console.error(err);
setStatusMsg('Error submitting movie');
}
@ -232,23 +207,23 @@ function App() {
setChosenPoints([]);
}
async function submitEpisode() {
if (chosenPoints.length !== 2) {
setStatusMsg('Must pick exactly 2 points for an Episode');
if(chosenPoints.length!==2){
setStatusMsg('Must pick exactly 2 points for an episode');
return;
}
try {
const res = await fetch('/use-episode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chosenPoints }),
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) {
const data=await res.json();
if(data.error){
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg('Episode => used chosen 2 points');
}
} catch (err) {
} catch(err){
console.error(err);
setStatusMsg('Error submitting episode');
}
@ -256,7 +231,7 @@ function App() {
setChosenPoints([]);
}
// ----- Add Points (3-step flow) -----
// Add Points flow
function openAddPointsFlow() {
setShowAddPointsOverlay(true);
setAddPointsStep(1);
@ -267,186 +242,171 @@ function App() {
function closeAddPointsFlow() {
setShowAddPointsOverlay(false);
}
// Step 1: check password immediately
// Step 1 => check password
async function checkPointsPassword() {
if (!pointsPassword) {
if(!pointsPassword){
setStatusMsg('Please enter a password');
return;
}
try {
const res = await fetch('/check-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pointsPassword }),
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) {
const data=await res.json();
if(data.error){
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg('Password ok, pick category next');
setStatusMsg('Password OK, pick category next');
setAddPointsStep(2);
}
} catch (err) {
} catch(err){
console.error(err);
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(amount) {
setPointsAmount(amount);
// Step 3 => pick amount => /add-points
async function pickAddPointsAmount(amt){
setPointsAmount(amt);
try {
const res = await fetch('/add-points', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
const res=await fetch('/add-points',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
password: pointsPassword,
category: pointsCategory,
amount,
}),
amount: amt
})
});
const data = await res.json();
if (data.error) {
const data=await res.json();
if(data.error){
setStatusMsg(`Error: ${data.error}`);
} else {
setStatusMsg(`+${amount} points added to ${pointsCategory}`);
setStatusMsg(`+${amt} points added to ${pointsCategory}`);
}
} catch (err) {
} catch(err){
console.error(err);
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('/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) {
const data=await res.json();
if(data.error){
setStatusMsg(`Error: ${data.error}`);
} else {
setAdminMode(true);
setStatusMsg('Admin mode enabled');
}
} catch (err) {
} catch(err){
console.error(err);
setStatusMsg('Error admin login');
}
}
function handleAdminLogout() {
function handleAdminLogout(){
setAdminMode(false);
setStatusMsg('Admin mode disabled');
}
async function adminRequest(path) {
async function adminRequest(path){
try {
const res = await fetch(`/admin/${path}`, { method: 'POST' });
const data = await res.json();
if (data.error) {
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) {
} catch(err){
console.error(err);
setStatusMsg(`Error admin ${path}`);
}
}
// -----------------------------
// PUSH NOTIFICATIONS SETUP
// -----------------------------
async function registerSWAndSubscribeToPush() {
if (!('serviceWorker' in navigator)) {
console.log('Service workers not supported in this browser.');
// Push subscription
async function registerServiceWorkerAndSubscribe(){
if(!('serviceWorker' in navigator)){
console.log('No service worker support 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
// Request notifications
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission not granted.');
if(permission!=='granted'){
console.log('User denied notifications');
return;
}
// Subscribe
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
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 }),
// Send 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);
console.log('Subscribed to push!');
} catch(err){
console.error('SW or push subscription failed:', err);
}
}
function urlBase64ToUint8Array(base64String) {
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);
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 (isSelected) outline = '3px solid yellow';
// Render Bubbles
function renderBubbles(usedArr, category){
return usedArr.map((val,idx)=>{
const isChosen = chosenPoints.find(c=> c.category===category && c.index===idx);
const bubbleColor = val? '#8B0000':'#4caf50'; // used => dark red, unused => green
let outline = (idx<5)? '2px solid #5f7b99':'2px solid #7f5f99'; // base vs appended
if(isChosen) outline='3px solid yellow';
return (
<div
key={idx}
style={{
width: 30,
height: 30,
borderRadius: '50%',
width:30, height:30, borderRadius:'50%',
backgroundColor: bubbleColor,
margin: 5,
cursor: 'pointer',
border: outline,
boxSizing: 'border-box',
margin:5, cursor:'pointer',
border: outline, boxSizing:'border-box'
}}
onClick={() => {
if (val) {
setStatusMsg('That bubble is already used.');
onClick={()=>{
if(val){
setStatusMsg('That bubble is already used');
return;
}
if (chooseMovieMode || chooseEpisodeMode) {
if(chooseMovieMode||chooseEpisodeMode){
toggleChosen(category, idx);
} else {
handleBubbleClick(category, idx);
@ -457,10 +417,9 @@ function App() {
});
}
// Are we in the 2-min finish-up”?
// Are we in the 2-min finish-up
const inFinishUp = timerRunning && !ringing && timeRemaining>0 && timeRemaining<=120000;
// Format time
function formatTime(ms){
if(ms<=0) return '00:00';
const totalSec=Math.floor(ms/1000);
@ -468,14 +427,13 @@ 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 dayOfWeek=d.toLocaleDateString('en-US',{ weekday:'long'});
const day=d.getDate();
const ord=getOrdinal(day);
const timeStr=d.toLocaleTimeString('en-US',{ hour12:true,hour:'numeric',minute:'2-digit' });
return `${w} ${day}${ord}, ${timeStr}`;
const timeStr=d.toLocaleTimeString('en-US',{ hour12:true,hour:'numeric',minute:'2-digit'});
return `${dayOfWeek} ${day}${ord}, ${timeStr}`;
}
function getOrdinal(n){
const s=['th','st','nd','rd'];
@ -483,87 +441,82 @@ function App() {
return s[(v-20)%10]||s[v]||s[0];
}
// -----------------------------
// RENDER
// -----------------------------
return (
<div style={styles.container}>
<h1>Screen-Time App (Push Notifications)</h1>
<h1>Screen-Time on Port 80</h1>
{statusMsg && <div style={styles.statusBox}>{statusMsg}</div>}
{statusMsg && (
<div style={styles.statusBox}>{statusMsg}</div>
)}
{/* Audio for local ring (foreground only) */}
<audio ref={alarmRef} src="/alarm.mp3" />
{/* SOLO */}
<section>
<h2>Solo Points</h2>
<div style={styles.bubbleRow}>
{renderBubbles(soloUsed,'solo')}
{renderBubbles(soloUsed, 'solo')}
</div>
</section>
{/* TOGETHER */}
<section>
<h2>Together Points</h2>
<div style={styles.bubbleRow}>
{renderBubbles(togetherUsed,'together')}
{renderBubbles(togetherUsed, 'together')}
</div>
</section>
{/* Add Points (3-step) */}
<button style={styles.button} onClick={openAddPointsFlow}>
Add Points
</button>
{/* MOVIE / EPISODE */}
<section style={{marginTop:20}}>
{chooseMovieMode?(
<div>
<p>Select 4 points for a Movie</p>
<p>Select exactly 4 points => Movie</p>
<button style={styles.button} onClick={submitMovie}>Submit Movie</button>
<button style={styles.button} onClick={()=> {
<button style={styles.button} onClick={()=>{
setChooseMovieMode(false);
setChosenPoints([]);
}}>Cancel</button>
</div>
):(
<button style={styles.button} onClick={startChooseMovie}>
Movie (4 points)
Movie (4)
</button>
)}
{chooseEpisodeMode?(
<div>
<p>Select 2 points for an Episode</p>
<p>Select exactly 2 points => Episode</p>
<button style={styles.button} onClick={submitEpisode}>Submit Episode</button>
<button style={styles.button} onClick={()=> {
<button style={styles.button} onClick={()=>{
setChooseEpisodeMode(false);
setChosenPoints([]);
}}>Cancel</button>
</div>
):(
<button style={styles.button} onClick={startChooseEpisode}>
Episode (2 points)
Episode (2)
</button>
)}
</section>
{/* Timer display */}
<section style={{marginTop:30}}>
<section style={{ marginTop:30 }}>
{(timerRunning || timeRemaining>0) && (
<div>
<h3>Timer: {formatTime(timeRemaining)} {isPaused && '(Paused)'}</h3>
<button style={styles.button} onClick={handlePauseResume}>
{isPaused? 'Resume' : 'Pause'}
{isPaused? 'Resume':'Pause'}
</button>
</div>
)}
{ringing && (
<div style={{marginTop:10}}>
<p style={{color:'yellow'}}>Timer ended! Alarm is ringing!</p>
<button style={styles.button} onClick={handleFinishUp}>Finish Up (2 min)</button>
<button style={styles.button} onClick={handleIgnoreRing}>Ignore</button>
<button style={styles.button} onClick={handleFinishUp}>
Finish Up (2 min)
</button>
<button style={styles.button} onClick={handleIgnoreRing}>
Ignore
</button>
</div>
)}
{inFinishUp && (
@ -575,10 +528,9 @@ function App() {
)}
</section>
{/* Logs */}
<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}`}
@ -587,7 +539,6 @@ function App() {
))}
</section>
{/* Add Points Overlay */}
{showAddPointsOverlay && (
<div style={styles.overlay}>
<div style={styles.addPointsDialog}>
@ -603,10 +554,7 @@ 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>
</>
@ -615,17 +563,10 @@ function App() {
<>
<h3>Step 2: Choose Category</h3>
<div style={styles.buttonRow}>
<button style={styles.button} onClick={()=>pickAddPointsCategory('solo')}>
Solo
</button>
<button style={styles.button} onClick={()=>pickAddPointsCategory('together')}>
Together
</button>
<button style={styles.button} onClick={()=> pickAddPointsCategory('solo')}>Solo</button>
<button style={styles.button} onClick={()=> pickAddPointsCategory('together')}>Together</button>
</div>
<button
style={{...styles.button, backgroundColor:'#999'}}
onClick={closeAddPointsFlow}
>
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
Cancel
</button>
</>
@ -634,20 +575,17 @@ function App() {
<>
<h3>Step 3: How many points?</h3>
<div style={styles.buttonRow}>
{[1,2,3,4].map(amt => (
{[1,2,3,4].map(n=>(
<button
key={amt}
key={n}
style={styles.button}
onClick={()=>pickAddPointsAmount(amt)}
onClick={()=> pickAddPointsAmount(n)}
>
+{amt}
+{n}
</button>
))}
</div>
<button
style={{...styles.button, backgroundColor:'#999'}}
onClick={closeAddPointsFlow}
>
<button style={{...styles.button, backgroundColor:'#999'}} onClick={closeAddPointsFlow}>
Cancel
</button>
</>
@ -656,22 +594,17 @@ function App() {
</div>
)}
{/* Admin Panel */}
{adminMode?(
<div style={styles.adminPanel}>
<h2>Admin Panel</h2>
<button style={styles.adminButton} onClick={()=>adminRequest('clear-logs')}>Clear Logs</button>
<button style={styles.adminButton} onClick={()=>adminRequest('reset-usage')}>Reset Usage</button>
<button style={styles.adminButton} onClick={()=>adminRequest('remove-all-earned')}>
Remove All Earned
</button>
<button style={styles.adminButton} onClick={()=>adminRequest('remove-all-earned')}>Remove All Earned</button>
<button style={styles.adminButton} onClick={()=>adminRequest('add-earned')}>+1 (SoloUsed)</button>
<button style={styles.adminButton} onClick={()=>adminRequest('remove-earned')}>-1 (SoloUsed)</button>
<button style={styles.adminButton} onClick={()=>adminRequest('clear-timer')}>Clear Timer</button>
<button style={styles.adminButton} onClick={()=>adminRequest('expire-timer')}>Expire Timer (1s)</button>
<button style={styles.adminButton} onClick={handleAdminLogout}>
Logout Admin
</button>
<button style={styles.adminButton} onClick={handleAdminLogout}>Logout Admin</button>
</div>
):(
<div style={{marginTop:30}}>
@ -684,9 +617,6 @@ function App() {
);
}
// -----------------------------
// STYLES
// -----------------------------
const styles = {
container: {
maxWidth:800,