DEV Community

Cover image for Shared eligibility doesn't mean shared progress
Kenji
Kenji

Posted on Edited on AI-assisted

Shared eligibility doesn't mean shared progress

A learner gets 90 on a sentence, tries again, and gets 94. Both attempts pass. Neither tells us whether the learner can still read that sentence 90 days later.

That distinction is easy to lose when adding a second kind of practice to an existing learning system. There is already a passing score, a progress record, and a review scheduler. Reusing them sounds reasonable until “passed” starts meaning two different things.

In Vocalace, our pronunciation app, sentence practice becomes available after a word reaches mastery. It then follows its own progression: pass the sentence once, wait 90 days, and pass its review. Sentence attempts must leave word mastery, word review scheduling, and daily practice-day counts untouched.

The interesting engineering problem is deciding how much those two progressions should share.

A prerequisite isn't ownership

A tempting abstraction would be a generic completePractice() function: check the score, advance progress, schedule a review, update daily activity. Add a practice-type argument and reuse it for sentences.

The trouble is that a passing score doesn't authorize the same writes in both workflows. A sentence attempt can read the word's mastery status to establish eligibility. It cannot change that status just because the same learner and vocabulary entry are involved.

I find it useful to separate two questions:

  • Is this learner allowed to start this activity?
  • What state can finishing it change?

The first can depend on another workflow. The second needs its own answer. This also comes up with certification after course completion, or a maintenance check after equipment approval. Sharing a prerequisite doesn't make their lifecycles interchangeable.

Keep the transition smaller than the record

Sentence progress lives on the existing vocabulary record: a stage, the first passing day, and a skip-until day. That storage choice doesn't require a shared transition function.

Keeping the fields together makes eligibility and sentence progress available on one record. The tradeoff is a weaker storage boundary: a broad update can touch unrelated progress. A separate table would make ownership more visible in the schema, at the cost of another relationship and its lifecycle. Neither layout decides which writes an attempt should be allowed to make.

For the transition itself, I'd pass only sentence progress. This standalone TypeScript example shows the boundary:

type SentenceProgress =
  | { stage: "unpassed" }
  | { stage: "waiting"; firstPassedDay: number }
  | { stage: "complete"; firstPassedDay: number };

const PASS_SCORE = 90;
const REVIEW_DAYS = 90;

function advanceSentence(
  progress: SentenceProgress,
  score: number,
  todayUtcDay: number,
): SentenceProgress {
  if (score < PASS_SCORE) return progress;

  if (progress.stage === "unpassed") {
    return { stage: "waiting", firstPassedDay: todayUtcDay };
  }

  if (
    progress.stage === "waiting" &&
    todayUtcDay >= progress.firstPassedDay + REVIEW_DAYS
  ) {
    return { stage: "complete", firstPassedDay: progress.firstPassedDay };
  }

  return progress;
}
Enter fullscreen mode Exit fullscreen mode

Day values here are integer UTC calendar-day numbers: milliseconds since the Unix epoch divided by 86_400_000, rounded down. The caller supplies today's value. The actual stored date can still be UTC midnight; the example uses day numbers to keep the arithmetic readable.

There is no word review date in this function's input. Persistence should apply only the sentence fields it owns, leaving unrelated fields alone.

Make the clock part of the example

Notice that passing while waiting doesn't replace firstPassedDay. Otherwise, an enthusiastic learner who practices every day could keep postponing the review.

A score below 90 also preserves that date. Failing an overdue review leaves it overdue; it doesn't start another 90-day wait.

These are the boundary cases I'd test alongside the transition. Day 0 means the first passing UTC day:

Before Attempt day Score Expected
Unpassed 0 90 Waiting, first pass day 0
Waiting since day 0 0 94 Still waiting since day 0
Waiting since day 0 90 89 Still waiting since day 0
Waiting since day 0 90 90 Complete

An explicit clock makes these cases ordinary inputs. No sleeping, changing the machine clock, or hoping a test doesn't cross midnight.

It also makes queue selection straightforward. Waiting entries become due when today's UTC day reaches the first passing day plus 90. Due reviews come before unpassed sentences; completed entries leave the queue. Practicing again before that boundary is allowed, but cannot complete the scheduled review.

When reviewing this kind of addition, I'd look beyond whether the new progress advances correctly. I'd also check what stays unchanged after every attempt: the original mastery state, its review date, and its daily counters. Those unchanged values are part of the feature's contract, even when both workflows share a database row.

Top comments (0)