better buttonboxing

This commit is contained in:
legop3
2026-06-29 02:18:53 -04:00
parent 8843bd6acf
commit f118f6456a
22 changed files with 350 additions and 76 deletions
+46 -3
View File
@@ -127,17 +127,51 @@ export default function AlertFeed({ scale = 1 }) {
const buttons = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
const goal = Number.isFinite(button.goal) ? button.goal : 0;
/*
Event fields win over session fields because the button-box event is the
exact outcome of this press. Session sync follows immediately after, but
using it first can make fast toasts briefly show stale count/limit data.
*/
const goal = Number.isFinite(payload.goal) ? payload.goal : Number.isFinite(button.goal) ? button.goal : 0;
const dailyCount = Number.isFinite(payload.dailyCount)
? payload.dailyCount
: Number.isFinite(button.dailyCount)
? button.dailyCount
: 0;
const dailyLimit = Number.isFinite(payload.dailyLimit)
? payload.dailyLimit
: Number.isFinite(button.dailyLimit)
? button.dailyLimit
: null;
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
const rewardName =
typeof button.rewardName === 'string' && button.rewardName.trim()
? button.rewardName.trim()
: 'Unassigned';
const rewardDescription =
typeof button.rewardDescription === 'string' && button.rewardDescription.trim()
? button.rewardDescription.trim()
: null;
const description =
typeof payload.description === 'string' && payload.description.trim()
? payload.description.trim()
: null;
pushAlert({
id: `buttonbox-active-${buttonId}`,
kind: 'buttonbox-active',
lifetimeMs: BUTTONBOX_LIFETIME_MS,
payload: { buttonId, count, goal, rewardNumber, rewardName },
payload: {
buttonId,
count,
goal,
dailyCount,
dailyLimit,
limited: Boolean(payload.limited),
description,
rewardNumber,
rewardName,
rewardDescription,
},
});
}
socket.on('buttonBox:increment', onButtonIncrement);
@@ -458,8 +492,17 @@ function AlertToast({ alert }) {
goal={payload.goal}
rewardNumber={payload.rewardNumber}
rewardName={payload.rewardName}
className="bg-cyan-900/45"
rewardDescription={payload.rewardDescription}
dailyCount={payload.dailyCount}
dailyLimit={payload.dailyLimit}
limited={payload.limited}
className={payload.limited ? 'bg-red-950/70 ring-1 ring-red-500/70' : 'bg-cyan-900/45'}
/>
{payload.description ? (
<p className={['mt-1 text-center text-xs font-semibold', payload.limited ? 'text-red-200' : 'text-cyan-100'].join(' ')}>
{payload.description}
</p>
) : null}
</div>
);
}
+56 -1
View File
@@ -11,6 +11,7 @@ import CardFrame from '../CardFrame/index.jsx';
const FLASH_MS = 420;
const REWARD_FLASH_MS = 1200;
const LIMIT_FLASH_MS = 800;
const BUTTON_TONES = {
1: 262,
2: 330,
@@ -37,15 +38,20 @@ export default function ButtonBoxPanel() {
count: 0,
goal: 0,
rewardName: null,
rewardDescription: null,
rewardId: null,
rewardNumber: null,
dailyCount: 0,
dailyLimit: null,
lastRewardAt: null,
});
}, [buttonBoxButtons]);
const [incFlash, setIncFlash] = useState({});
const [limitFlash, setLimitFlash] = useState({});
const [rewardFlash, setRewardFlash] = useState({});
const timersRef = useRef(new Map());
const limitTimersRef = useRef(new Map());
const rewardTimersRef = useRef(new Map());
const prevRewardAtRef = useRef({});
const audioCtxRef = useRef(null);
@@ -107,6 +113,24 @@ export default function ButtonBoxPanel() {
function onIncrement(payload = {}) {
const buttonId = Number(payload.buttonId);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
if (payload.limited) {
/*
Capped presses are intentionally visible but not celebratory: red
feedback tells users the physical press was received, while skipping
the tone keeps it distinct from real reward progress.
*/
setLimitFlash((current) => ({ ...current, [buttonId]: true }));
const oldTimer = limitTimersRef.current.get(buttonId);
if (oldTimer) {
clearTimeout(oldTimer);
}
const timer = setTimeout(() => {
setLimitFlash((current) => ({ ...current, [buttonId]: false }));
limitTimersRef.current.delete(buttonId);
}, LIMIT_FLASH_MS);
limitTimersRef.current.set(buttonId, timer);
return;
}
setIncFlash((current) => ({ ...current, [buttonId]: true }));
const oldTimer = timersRef.current.get(buttonId);
if (oldTimer) {
@@ -126,6 +150,23 @@ export default function ButtonBoxPanel() {
};
}, [effectiveAlertVolume, socket]);
useEffect(
() => () => {
/*
The panel owns short visual timers for press feedback. Clearing them on
unmount avoids leaving stale timeout callbacks behind if the panel is
hidden during a burst of button-box activity.
*/
timersRef.current.forEach((timer) => clearTimeout(timer));
timersRef.current.clear();
limitTimersRef.current.forEach((timer) => clearTimeout(timer));
limitTimersRef.current.clear();
rewardTimersRef.current.forEach((timer) => clearTimeout(timer));
rewardTimersRef.current.clear();
},
[],
);
return (
<CardFrame title="Button Box" bodyClassName="space-y-0.5 text-base">
<div className="grid grid-cols-4 gap-0.5">
@@ -133,11 +174,17 @@ export default function ButtonBoxPanel() {
const id = Number(button.id);
const count = Number.isFinite(button.count) ? button.count : 0;
const goal = Number.isFinite(button.goal) ? button.goal : 0;
const dailyCount = Number.isFinite(button.dailyCount) ? button.dailyCount : 0;
const dailyLimit = Number.isFinite(button.dailyLimit) ? button.dailyLimit : null;
const rewardName = typeof button.rewardName === 'string' && button.rewardName.trim()
? button.rewardName.trim()
: 'Unassigned';
const rewardDescription = typeof button.rewardDescription === 'string' && button.rewardDescription.trim()
? button.rewardDescription.trim()
: null;
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
const incActive = Boolean(incFlash[id]);
const limitActive = Boolean(limitFlash[id]);
const rewardActive = Boolean(rewardFlash[id]);
return (
@@ -146,9 +193,17 @@ export default function ButtonBoxPanel() {
buttonId={id}
count={count}
goal={goal}
dailyCount={dailyCount}
dailyLimit={dailyLimit}
rewardNumber={rewardNumber}
rewardName={rewardName}
className={[incActive ? 'bg-cyan-900/45' : '', rewardActive ? 'bg-fuchsia-900/45' : ''].filter(Boolean).join(' ')}
rewardDescription={rewardDescription}
limited={limitActive}
className={[
incActive ? 'bg-cyan-900/45' : '',
limitActive ? 'bg-red-950/70 ring-1 ring-red-500/70' : '',
rewardActive ? 'bg-fuchsia-900/45' : '',
].filter(Boolean).join(' ')}
/>
);
})}
+25 -1
View File
@@ -7,13 +7,37 @@ export default function ButtonBoxTile({
goal,
rewardNumber,
rewardName,
rewardDescription,
dailyCount,
dailyLimit,
limited = false,
className = '',
}) {
/*
The server owns the actual cap decision, but the tile still derives a
display-only "limit reached" state so normal session snapshots and immediate
capped press toasts render the same daily status text.
*/
const hasDailyLimit = Number.isFinite(dailyLimit) && dailyLimit > 0;
const safeDailyCount = Number.isFinite(dailyCount) ? dailyCount : 0;
const limitReached = Boolean(limited) || (hasDailyLimit && safeDailyCount >= dailyLimit);
const cleanDescription = typeof rewardDescription === 'string' && rewardDescription.trim()
? rewardDescription.trim()
: null;
return (
<article className={['surface px-0.5 py-0.5 text-center', className].filter(Boolean).join(' ')}>
<article className={['surface px-1 py-0.5 text-center', className].filter(Boolean).join(' ')}>
<p className="text-xs text-slate-400">Button {buttonId}</p>
<p className="text-sm font-semibold text-white">{count} / {goal}</p>
<p className="truncate text-[0.7rem] text-slate-300">#{rewardNumber} {rewardName}</p>
{cleanDescription ? (
<p className="line-clamp-2 text-[0.65rem] leading-tight text-slate-400">{cleanDescription}</p>
) : null}
{hasDailyLimit ? (
<p className={['text-[0.65rem] font-semibold leading-tight', limitReached ? 'text-red-300' : 'text-cyan-200'].join(' ')}>
{limitReached ? 'Daily limit reached' : `Today ${safeDailyCount} / ${dailyLimit}`}
</p>
) : null}
</article>
);
}