RGreview-game-corethe engine behind concept-first review games

Feature detail

Readiness scoring and phase state

A coarse 0–100 readiness signal and a six-value phase vocabulary (not_started, learning, practicing, mastered, shaky, tracked_in_quiz) that products can roll up without overclaiming.

readinessscheduler/phase-state

Worked example

Readiness rollup

Each unit gets a coarse 0–100 score plus phase; aggregateReadiness rolls children up without pretending to be an assessment instrument.

Input

{
  "children": [
    {
      "unitId": "factoring:recognition",
      "score": 82,
      "phase": "practicing",
      "lastPracticedAt": "2026-04-17T12:00:00Z",
      "dueAt": "2026-04-19T12:00:00Z"
    },
    {
      "unitId": "factoring:setup",
      "score": 100,
      "phase": "mastered",
      "lastPracticedAt": "2026-04-14T12:00:00Z",
      "dueAt": "2026-04-22T12:00:00Z"
    }
  ]
}

Output

{
  "recognition": {
    "unitId": "factoring:recognition",
    "score": 82,
    "phase": "practicing",
    "lastPracticedAt": "2026-04-17T12:00:00Z",
    "dueAt": "2026-04-19T12:00:00Z"
  },
  "setup": {
    "unitId": "factoring:setup",
    "score": 100,
    "phase": "mastered",
    "lastPracticedAt": "2026-04-14T12:00:00Z",
    "dueAt": "2026-04-22T12:00:00Z"
  },
  "aggregate": {
    "unitId": "factoring:recognition",
    "score": 91,
    "phase": "practicing",
    "lastPracticedAt": "2026-04-17T12:00:00Z",
    "dueAt": "2026-04-19T12:00:00Z"
  }
}

Real source excerpt

Readiness types, phase baselines, and aggregation

src/readiness/index.ts
export type ReadinessScore = {
  unitId: string
  score: number
  phase: PhaseState
  lastPracticedAt: string | null
  dueAt: string | null
}

const PHASE_BASELINES: Record<PhaseState, number> = {
  not_started: 0,
  learning: 42,
  practicing: 68,
  mastered: 92,
  shaky: 34,
  tracked_in_quiz: 28,
}

const clamp = (value: number, minimum = 0, maximum = 100): number => (
  Math.min(maximum, Math.max(minimum, value))
)

const parseDate = (value: string | null | undefined): Date | null => {
  if (!value) return null
  const parsed = new Date(value)
  return Number.isNaN(parsed.getTime()) ? null : parsed
}

const diffDays = (left: Date, right: Date): number => {
  const msPerDay = 24 * 60 * 60 * 1000
  return Math.round((left.getTime() - right.getTime()) / msPerDay)
}

const scoreAttempts = (attempts: readonly ReadinessAttempt[]): number => {
  if (attempts.length === 0) return 0

  const recentAttempts = attempts.slice(-6)
  const correctnessPoints = recentAttempts.reduce((total, attempt) => (
    total + (attempt.correct ? 1 : 0)
  ), 0) / recentAttempts.length

  const cadenceSamples = recentAttempts
    .map((attempt) => attempt.cadenceDays)
    .filter((value): value is number => typeof value === 'number' && Number.isFinite(value))

  const cadenceBonus = cadenceSamples.length === 0
    ? 0
    : Math.min(6, cadenceSamples.reduce((total, days) => total + Math.min(days, 4), 0) / cadenceSamples.length)

  return correctnessPoints * 18 + cadenceBonus
}

const scoreRecency = (
  lastPracticedAt: string | null,
  dueAt: string | null,
  now: Date,
): number => {
  const practiced = parseDate(lastPracticedAt)
  const due = parseDate(dueAt)

  let total = 0
  if (practiced) {
    const days = diffDays(now, practiced)
    if (days <= 2) total += 6
    else if (days <= 7) total += 2
    else if (days <= 14) total -= 4
    else total -= 8
  }

  if (due) {
    const daysUntilDue = diffDays(due, now)
    if (daysUntilDue < 0) total -= 10
    else if (daysUntilDue <= 2) total -= 4
  }

  return total
}

const deriveAggregatePhase = (children: readonly ReadinessScore[], score: number): PhaseState => {
  if (children.length === 0) return 'not_started'
  if (children.every((child) => child.phase === 'mastered')) return 'mastered'
  if (children.some((child) => child.phase === 'shaky') && score < 70) return 'shaky'
  if (score >= 60) return 'practicing'
  if (score >= 35) return children.some((child) => child.phase === 'tracked_in_quiz')
    ? 'learning'
    : 'learning'
  if (children.some((child) => child.phase === 'tracked_in_quiz')) return 'tracked_in_quiz'
  return 'not_started'
}

const latestIso = (values: readonly string[]): string | null => (
  [...values].sort((left, right) => new Date(right).getTime() - new Date(left).getTime())[0] ?? null
)

const earliestIso = (values: readonly string[]): string | null => (
  [...values].sort((left, right) => new Date(left).getTime() - new Date(right).getTime())[0] ?? null
)

export const computeReadiness = (
  input: ReadinessComputationInput,
  now = new Date(),
): ReadinessScore => {
  const attempts = input.attempts ?? []
  const score = clamp(
    PHASE_BASELINES[input.phase]
      + scoreAttempts(attempts)
      + scoreRecency(input.lastPracticedAt ?? null, input.dueAt ?? null, now),
  )

  return {
    unitId: input.unitId,
    score: Math.round(score),
    phase: input.phase,
    lastPracticedAt: input.lastPracticedAt ?? null,
    dueAt: input.dueAt ?? null,
  }
}