Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ The action tracker input type allows scouts to record timestamped robot actions
"formResetBehavior": "reset",
"mode": "hold",
"timerDuration": 15,
"autoStopSeconds": 25,
"actions": [
{ "label": "Scored", "code": "score", "icon": "target" },
{ "label": "Picked Up", "code": "pickup", "icon": "package" },
Expand All @@ -405,6 +406,7 @@ The action tracker input type allows scouts to record timestamped robot actions
- `"tap"`: Records an instant timestamp when the button is tapped. Best for discrete events like scoring or picking up game pieces.
- `"hold"`: Records both start and end timestamps while the button is held down. Best for continuous actions like playing defense or climbing. Supports multi-touch for tracking overlapping actions.
- **timerDuration** (optional): Expected duration in seconds (e.g., 15 for auto, 135 for teleop). Used as a UI reference.
- **autoStopSeconds** (optional): Automatically stop the timer after this many seconds. When the timer reaches this limit, it stops, any active holds are finalized, and the action buttons are disabled. Useful to prevent the timer from running past the match phase duration (e.g., 25 for auto, 150 for teleop with some leeway).

#### Using Action Tracker in the Form

Expand Down
2 changes: 2 additions & 0 deletions config/2026/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
"defaultValue": null,
"mode": "hold",
"timerDuration": 15,
"autoStopSeconds": 25,
"actions": [
{ "label": "Scoring", "code": "scoring" },
{ "label": "Depot Collect", "code": "depot_collect" },
Expand Down Expand Up @@ -210,6 +211,7 @@
"defaultValue": null,
"mode": "hold",
"timerDuration": 135,
"autoStopSeconds": 150,
"actions": [
{ "label": "Scoring", "code": "scoring" },
{ "label": "Depot Collect", "code": "depot_collect" },
Expand Down
64 changes: 57 additions & 7 deletions src/components/inputs/ActionTrackerInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,53 @@ export default function ActionTrackerInput(props: ConfigurableInputProps) {

useEvent('resetFields', resetState);

// Auto-stop threshold in milliseconds (if configured)
const autoStopMs = data.autoStopSeconds ? data.autoStopSeconds * 1000 : null;

// Whether the timer has reached the auto-stop limit
const hasAutoStopped = autoStopMs !== null && !isRunning && elapsedTime >= autoStopMs;

// Timer update loop using requestAnimationFrame
const updateTimer = useCallback(() => {
const now = performance.now();
const elapsed = now - startTimeRef.current;
startTimeRef.current = now;
elapsedAccumulatorRef.current += elapsed;

// Auto-stop if we've exceeded the configured duration
if (autoStopMs && elapsedAccumulatorRef.current >= autoStopMs) {
elapsedAccumulatorRef.current = autoStopMs;
setElapsedTime(autoStopMs);
setIsRunning(false);

// Finalize any active holds at the auto-stop time
setActivePointers(prev => {
if (prev.size === 0) return prev;
const endTime = Number((autoStopMs / 1000).toFixed(1));
setActionLog(log => [
...log,
...Array.from(prev.values()).map(p => ({
actionCode: p.actionCode,
timestamp: p.startTime,
endTimestamp: endTime,
})),
]);
return new Map();
});

// Cancel any pending pointers
for (const pending of pendingPointersRef.current.values()) {
clearTimeout(pending.intentTimerId);
}
pendingPointersRef.current.clear();
pendingTapsRef.current.clear();

return;
}

setElapsedTime(elapsedAccumulatorRef.current);
animationFrameRef.current = requestAnimationFrame(updateTimer);
}, []);
}, [autoStopMs]);

// Effect to handle timer start/stop
useEffect(() => {
Expand Down Expand Up @@ -166,6 +204,9 @@ export default function ActionTrackerInput(props: ConfigurableInputProps) {
// Record an action
const recordAction = useCallback(
(actionCode: string) => {
// Don't record if timer has auto-stopped
if (hasAutoStopped) return;

// Auto-start timer if not running
if (!isRunning) {
startTimer();
Expand All @@ -179,7 +220,7 @@ export default function ActionTrackerInput(props: ConfigurableInputProps) {

setActionLog(prev => [...prev, { actionCode, timestamp: timestampSeconds }]);
},
[isRunning, startTimer],
[isRunning, startTimer, hasAutoStopped],
);

// Undo last action
Expand All @@ -199,6 +240,9 @@ export default function ActionTrackerInput(props: ConfigurableInputProps) {
// Hold mode: handle pointer down (start intent tracking)
const handlePointerDown = useCallback(
(e: React.PointerEvent, actionCode: string) => {
// Don't start new actions if timer has auto-stopped
if (hasAutoStopped) return;

if (isHoldMode) {
// Store element reference for use in setTimeout (e.currentTarget won't be valid later)
const element = e.currentTarget;
Expand Down Expand Up @@ -251,7 +295,7 @@ export default function ActionTrackerInput(props: ConfigurableInputProps) {
});
}
},
[isHoldMode, isRunning, startTimer],
[isHoldMode, isRunning, startTimer, hasAutoStopped],
);

// Handle pointer movement - detect scroll intent and cancel if needed
Expand Down Expand Up @@ -442,18 +486,24 @@ export default function ActionTrackerInput(props: ConfigurableInputProps) {
<div className="my-2 flex flex-col items-center gap-3">
{/* Timer display */}
<div className="flex flex-col items-center gap-1">
<div className="font-mono text-3xl font-bold dark:text-white">
<div className={cn(
"font-mono text-3xl font-bold dark:text-white",
hasAutoStopped && "text-muted-foreground"
)}>
{formattedTime}
</div>
{autoStarted && !isRunning && (
{hasAutoStopped && (
<span className="text-xs text-muted-foreground">(time&apos;s up)</span>
)}
{autoStarted && !isRunning && !hasAutoStopped && (
<span className="text-xs text-muted-foreground">(auto-started)</span>
)}
</div>

{/* Timer controls */}
<div className="flex gap-2">
{!isRunning ? (
<Button variant="outline" size="sm" onClick={startTimer}>
<Button variant="outline" size="sm" onClick={startTimer} disabled={hasAutoStopped}>
<Play className="mr-1 size-4" />
Start
</Button>
Expand Down Expand Up @@ -499,7 +549,7 @@ export default function ActionTrackerInput(props: ConfigurableInputProps) {
}}
onPointerCancel={handlePointerEnd}
onPointerLeave={handlePointerEnd}
disabled={data.disabled}
disabled={data.disabled || hasAutoStopped}
>
{action.icon && (
<DynamicIcon name={action.icon} className="size-5" />
Expand Down
6 changes: 6 additions & 0 deletions src/components/inputs/BaseInputProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ export const actionTrackerInputSchema = inputBaseSchema.extend({
.describe(
'Expected duration in seconds (for UI reference, e.g., 15 for auto, 135 for teleop)',
),
autoStopSeconds: z
.number()
.optional()
.describe(
'Automatically stop the timer after this many seconds. Useful to prevent the timer from running past the match phase duration.',
),
});

export const tbaTeamAndRobotInputSchema = inputBaseSchema.extend({
Expand Down
2 changes: 1 addition & 1 deletion src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const useQRScoutState = createStore<QRScoutState>(
initialState,
'qrScout',
{
version: 2,
version: 3,
},
);

Expand Down