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
72 changes: 71 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ A QR Code-based scouting system for FRC
- [Individual Sections](#individual-sections)
- [Individual Fields](#individual-fields)
- [Using Multi-Select Input](#using-multi-select-input)
- [Using Multi-Counter Input](#using-multi-counter-input)
- [Using Image Input](#using-image-input)
- [Using Timer Input](#using-timer-input)
- [Using Action Tracker Input](#using-action-tracker-input)
Expand Down Expand Up @@ -100,7 +101,7 @@ The basic structure of the config.json file is as follows:

`title`: The name of this field

`type`: One of "text", "number", "boolean", "range", "select", "counter", "timer", "multi-select", "image", "action-tracker", "TBA-team-and-robot", or "TBA-match-number". Describes the type of input this is.
`type`: One of "text", "number", "boolean", "range", "select", "counter", "multi-counter", "timer", "multi-select", "image", "action-tracker", "TBA-team-and-robot", or "TBA-match-number". Describes the type of input this is.

`required`: a boolean indicating if this must be filled out before the QRCode is generated. If any field with this set to true is not filled out, QRScout will not generate a QRCode when the commit button is pressed.

Expand Down Expand Up @@ -209,6 +210,75 @@ For example, in a game where robots can score in multiple locations, you might c

This allows scouts to quickly record all locations where a robot successfully scored during a match.

### Using Multi-Counter Input

The multi-counter input type provides a quick-tap counter with multiple increment sizes. Instead of tapping +1 repeatedly, scouts can tap +1, +5, or +10 (and their negative counterparts) to rapidly approximate large counts. A prominent running tally confirms each press took effect.

#### Configuration in config.json

```json
{
"title": "Fuel Scored",
"type": "multi-counter",
"required": false,
"code": "fuelScored",
"description": "Approximate fuel scored during the match",
"formResetBehavior": "reset",
"defaultValue": 0
}
```

#### Multi-Counter Properties

- **defaultValue**: The initial value of the counter (typically 0).

#### Using Multi-Counter in the Form

The multi-counter displays:

1. **Running Tally**: A large number at the top showing the current count
2. **Subtract Row**: Three buttons (−1, −5, −10) for correcting mistakes
3. **Add Row**: Three buttons (+1, +5, +10) for incrementing the count

The value is floored at 0 — it cannot go negative.

Button feedback uses `active:` styling rather than `hover:` to avoid the sticky highlight issue common on touch devices (Android tablets, iPads).

#### Data Format

In the generated QR code, the multi-counter stores a single integer value representing the current tally. For example, if a scout tapped +10 three times and −1 twice, the QR code will contain `28`.

#### FRC Scouting Examples

Multi-counter is particularly useful for FRC scouting in scenarios where quantities are large and exact precision isn't critical:

- **Fuel/Game Piece Counting**: Quickly approximate how many game pieces a robot scored when individual counting would be too slow
- **Cycle Counting**: Track approximate number of cycles across a match
- **Points Estimation**: Rough point tallying during a match

For example, to track fuel scored during autonomous:

```json
{
"title": "Auto Fuel Scored",
"type": "multi-counter",
"required": false,
"code": "autoFuelScored",
"description": "Approximate fuel scored during autonomous",
"formResetBehavior": "reset",
"defaultValue": 0
}
```

This allows scouts to quickly tap +5 or +10 as game pieces stream in, rather than trying to count each one individually. The subtract buttons let them correct if they overshoot.

#### Best Practices for Multi-Counter

1. **Set Expectations**: Make sure scouts understand the count is a ballpark estimate, not an exact tally
2. **Use for High-Volume Counts**: Reserve this input for scenarios where counts are large enough that +1 tapping would be impractical
3. **Pair with Action Tracker**: For more precise timing data, combine with an action-tracker that records when scoring bursts happen
4. **Practice Before Competition**: Have scouts practice with the +5/+10 buttons to build muscle memory

### Using Image Input

The image input type allows you to display static images in your scouting form. This is useful for showing field layouts, robot diagrams, game piece locations, or any visual reference that helps scouts accurately record data.
Expand Down
18 changes: 18 additions & 0 deletions config/2026/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@
{
"name": "Autonomous",
"fields": [
{
"title": "Fuel Scored",
"description": "Approximate fuel scored during autonomous.",
"type": "multi-counter",
"required": false,
"code": "autoFuelScored",
"formResetBehavior": "reset",
"defaultValue": 0
},
{
"title": "Actions",
"description": "Track actions performed during autonomous.",
Expand Down Expand Up @@ -191,6 +200,15 @@
{
"name": "Teleop",
"fields": [
{
"title": "Fuel Scored",
"description": "Approximate fuel scored during teleop.",
"type": "multi-counter",
"required": false,
"code": "teleopFuelScored",
"formResetBehavior": "reset",
"defaultValue": 0
},
{
"title": "Alliance won auto?",
"description": "Did your alliance win the autonomous period?",
Expand Down
9 changes: 9 additions & 0 deletions src/components/inputs/BaseInputProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const inputTypeSchema = z
'range',
'select',
'counter',
'multi-counter',
'timer',
'multi-select',
'image',
Expand Down Expand Up @@ -68,6 +69,11 @@ export const counterInputSchema = inputBaseSchema.extend({
defaultValue: z.number().default(0).describe('The default value'),
});

export const multiCounterInputSchema = inputBaseSchema.extend({
type: z.literal('multi-counter'),
defaultValue: z.number().default(0).describe('The default value'),
});

export const rangeInputSchema = inputBaseSchema.extend({
type: z.literal('range'),
min: z.number().optional().describe('The minimum value'),
Expand Down Expand Up @@ -162,6 +168,7 @@ export const sectionSchema = z.object({
fields: z.array(
z.discriminatedUnion('type', [
counterInputSchema,
multiCounterInputSchema,
stringInputSchema,
numberInputSchema,
selectInputSchema,
Expand Down Expand Up @@ -324,6 +331,7 @@ export type MultiSelectInputData = z.infer<typeof multiSelectInputSchema>;
export type StringInputData = z.infer<typeof stringInputSchema>;
export type NumberInputData = z.infer<typeof numberInputSchema>;
export type CounterInputData = z.infer<typeof counterInputSchema>;
export type MultiCounterInputData = z.infer<typeof multiCounterInputSchema>;
export type RangeInputData = z.infer<typeof rangeInputSchema>;
export type BooleanInputData = z.infer<typeof booleanInputSchema>;
export type TimerInputData = z.infer<typeof timerInputSchema>;
Expand All @@ -343,6 +351,7 @@ export type InputPropsMap = {
select: SelectInputData;
'multi-select': MultiSelectInputData;
counter: CounterInputData;
'multi-counter': MultiCounterInputData;
timer: TimerInputData;
image: ImageInputData;
'action-tracker': ActionTrackerInputData;
Expand Down
3 changes: 3 additions & 0 deletions src/components/inputs/ConfigurableInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { InputTypes } from './BaseInputProps';
import ActionTrackerInput from './ActionTrackerInput';
import CheckboxInput from './CheckboxInput';
import CounterInput from './CounterInput';
import MultiCounterInput from './MultiCounterInput';
import ImageInput from './ImageInput';
import NumberInput from './NumberInput';
import RangeInput from './RangeInput';
Expand Down Expand Up @@ -31,6 +32,8 @@ export default function ConfigurableInput(props: ConfigurableInputProps) {
return <CheckboxInput {...props} key={props.code} />;
case 'counter':
return <CounterInput {...props} key={props.code} />;
case 'multi-counter':
return <MultiCounterInput {...props} key={props.code} />;
case 'range':
return <RangeInput {...props} key={props.code} />;
case 'timer':
Expand Down
84 changes: 84 additions & 0 deletions src/components/inputs/MultiCounterInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Button } from '@/components/ui/button';
import { useEvent } from '@/hooks';
import { inputSelector, updateValue, useQRScoutState } from '@/store/store';
import { useCallback, useEffect, useState } from 'react';
import { MultiCounterInputData } from './BaseInputProps';
import { ConfigurableInputProps } from './ConfigurableInput';

const INCREMENTS = [1, 5, 10];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] we could probably make this configurable in a future MR. These increments make sense for this year, but next year there might be something that prefers to count by 3s


export default function MultiCounterInput(props: ConfigurableInputProps) {
const data = useQRScoutState(
inputSelector<MultiCounterInputData>(props.section, props.code),
);

if (!data) {
return <div>Invalid input</div>;
}

const [value, setValue] = useState(data.defaultValue);

const resetState = useCallback(
({ force }: { force: boolean }) => {
if (force) {
setValue(data.defaultValue);
return;
}
switch (data.formResetBehavior) {
case 'reset':
setValue(data.defaultValue);
return;
case 'preserve':
return;
default:
return;
}
},
[data.defaultValue, data.formResetBehavior],
);

useEvent('resetFields', resetState);

const handleChange = useCallback(
(increment: number) => {
setValue(prev => Math.max(0, prev + increment));
},
[],
);

useEffect(() => {
updateValue(props.code, value);
}, [value, props.code]);

return (
<div className="flex flex-col items-center gap-3 py-3 px-2">
<div className="text-4xl font-bold tabular-nums dark:text-white">
{value}
</div>
<div className="flex w-full gap-2">
{INCREMENTS.map(inc => (
<Button
key={`sub-${inc}`}
variant="outline"
className="flex-1 text-base font-semibold h-12 text-destructive border-destructive/30 hover:bg-transparent hover:text-destructive active:bg-destructive/10"
onClick={() => handleChange(-inc)}
>
&minus;{inc}
</Button>
))}
</div>
<div className="flex w-full gap-2">
{INCREMENTS.map(inc => (
<Button
key={`add-${inc}`}
variant="outline"
className="flex-1 text-base font-semibold h-12 text-primary border-primary/30 hover:bg-transparent hover:text-primary active:bg-primary/10"
onClick={() => handleChange(inc)}
>
+{inc}
</Button>
))}
</div>
</div>
);
}