DEV Community

owen
owen

Posted on

Skip Is a Range: Honest Scoring for an Incomplete 100-Item Checklist

In my previous post, I wrote about preserving null in a 1–5 quiz instead of silently turning a skipped statement into zero. The implementation kept answers in React memory and made sharing an explicit user action.

The next checklist created a different version of the same modeling problem.

The Rice Purity Test on KinkWeave has 100 yes/no items. The page is 18+ and some prompts are sensitive, but the engineering question is ordinary: what should the result mean when a person skips an item or has not reached it yet?

The tempting implementation is 100 - yesCount. It is also wrong for an incomplete attempt. With ten unanswered items, that number assumes all ten answers are “No.” The interface would be displaying certainty the data does not contain.

I decided that an incomplete binary checklist should return an interval.

Preserve two kinds of missingness

The answer type has three explicit values:

export type RiceAnswer = 'yes' | 'no' | 'skip';
export type RiceAnswers = Record<number, RiceAnswer>;
Enter fullscreen mode Exit fullscreen mode

An unanswered item is not a fourth stored value. It is an absent key. That distinction lets the interface say whether somebody deliberately chose Skip or simply left an item untouched. Both remain unresolved for scoring, but they are not identical interaction states.

The scoring function counts all four cases and refuses to manufacture a definite score:

export function scoreRice(bank: RiceBank, answers: RiceAnswers) {
  validateRiceBank(bank);

  let yes = 0, no = 0, skipped = 0, unanswered = 0;
  for (const q of bank.questions) {
    const answer = answers[q.id];
    if (answer === 'yes') yes++;
    else if (answer === 'no') no++;
    else if (answer === 'skip') skipped++;
    else unanswered++;
  }

  const unresolved = skipped + unanswered;
  return {
    yes, no, skipped, unanswered, unresolved,
    complete: unresolved === 0,
    score: unresolved === 0 ? 100 - yes : null,
    min: 100 - yes - unresolved,
    max: 100 - yes,
  };
}
Enter fullscreen mode Exit fullscreen mode

The upper bound assumes every unresolved item would be No, so none of them lowers 100 - yes. The lower bound assumes every unresolved item would be Yes, so each one lowers it by one.

For example, suppose one item is Yes, one is No, one is explicitly skipped, and 97 are unanswered. The only honest result is 1–99 / 100. The code does not call either endpoint the score. score stays null, and the share text says the result is incomplete and “not a definite score.”

This is a small type-level choice with a visible product consequence: uncertainty survives all the way to the UI.

Reset should restore absence

Changing an answer introduces another easy bug. If Reset assigns No, the control looks cleared while the score still contains an invented answer.

The reducer instead deletes the key. The same reducer also invalidates the current result and any prepared share state whenever an answer changes:

case 'answer': {
  const answers = { ...state.answers };
  if (action.value === 'unanswered') delete answers[action.id];
  else answers[action.id] = action.value;

  return {
    ...state,
    answers,
    phase: 'questions',
    sharePreview: false,
    shareConfirmed: false,
    copyStatus: 'idle',
    revision: state.revision + 1,
  };
}
Enter fullscreen mode Exit fullscreen mode

That revision counter handles a less obvious race. Copying to the clipboard is asynchronous. A person can start a copy, change an answer, and then receive the old promise callback. The reducer accepts copied and copy-failed only when the callback revision still matches the current state, the result screen is active, the share preview is open, confirmation is still present, and a copy is actually in flight.

So a late callback cannot label a changed result as copied. Sharing also requires a preview followed by explicit confirmation; it is not a one-click side effect of viewing the result.

Validate the content contract at both boundaries

A score is only comparable when the input bank has the expected shape and edition. The validator requires exactly 100 questions, IDs 1 through 100 in order, non-empty reviewed text, and no duplicate text after trimming, whitespace normalization, and lowercasing. It also distinguishes the development fixture from the reviewed traditional bank.

if (!Array.isArray(bank.questions) || bank.questions.length !== 100) {
  throw new Error('Unreviewed or unsupported question bank');
}

const texts = new Set<string>();
for (const [index, q] of bank.questions.entries()) {
  if (q.id !== index + 1 || !q.text.trim()) {
    throw new Error('Invalid question manifest');
  }
  const text = q.text.trim().replace(/\s+/g, ' ').toLowerCase();
  if (texts.has(text)) throw new Error('Duplicate question');
  texts.add(text);
}
Enter fullscreen mode Exit fullscreen mode

The server-side loader validates the installed JSON and checks its pinned version and scoring mode. The API returns that fixed bank with private, no-store caching. The client validates the response again and accepts only the expected traditional edition and version. This is deliberate duplication: the server protects deployment integrity, while the client protects the state transition that begins an attempt.

Clearing during load is a state transition, not just a button

The question bank is fetched only after Start. That creates another race: a person can click Cancel loading or Clear while the request is in flight. Aborting the request is useful, but I did not make correctness depend on abort winning the race.

const request = useRef<AbortController | null>(null);
const generation = useRef(0);

const clear = useCallback(() => {
  generation.current++;
  request.current?.abort();
  request.current = null;
  setLoading(false);
  setError(false);
  analyticsCtl.clearAttempt();
  rawDispatch({ type: 'clear' });
}, [analyticsCtl]);

const controller = new AbortController();
request.current = controller;
const currentGeneration = generation.current;
const response = await fetch('/api/rice-questions', {
  cache: 'no-store',
  signal: controller.signal,
});

const bank: unknown = await response.json();
validateRiceBank(bank);
if (currentGeneration === generation.current) {
  rawDispatch({ type: 'start', bank });
}
Enter fullscreen mode Exit fullscreen mode

Clear increments a generation and aborts the active controller. Even if a response has already crossed the network boundary, its captured generation is stale, so it cannot repopulate the cleared session.

The same principle appears in the event journal. Events come from a closed allowlist, contain only the event name, test identifier, and bank version, and are recorded at most once per attempt. There are no answer IDs, timings, or counts in those event objects. An incomplete result view also does not become a completion event.

Tests should attack the uncertainty

The most valuable tests are not only the happy endpoints of 100 and 0. They assert that an empty attempt returns 0–100 with score: null; a mixed partial attempt returns the expected range; Reset removes the key; and an old clipboard revision cannot emit a successful copy event.

Those tests encode the product promise more directly than a snapshot can: missing data remains missing, and stale asynchronous work cannot rewrite the meaning of the current state.

The broader lesson for me is that incomplete input is not an error to hide. Sometimes it is the most accurate state the application has. On a binary checklist, preserving that state means returning bounds instead of pretending to know a point.

Where else have you found that a range, set, or explicit unknown was a better result than a convenient single number?

Top comments (0)