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

Feature detail

Planning / goal abstraction

Ordered phases, local-date deadlines, and recommendation roles let consumer apps turn progress counts into a clear next track.

goal

Worked example

Goal evaluation

The core turns count-based progress into recommendation state without knowing whether the units are concepts or sections.

Input

{
  "plan": {
    "id": "demo-plan",
    "label": "Exam 3 then Final",
    "phases": [
      {
        "id": "exam3-clean-sweep",
        "label": "Perfect Exam 3 first",
        "trackId": "exam3",
        "deadlineLocalDate": "2026-04-18",
        "deadlineBehavior": "stay_primary_until_complete"
      },
      {
        "id": "final-breadth-sweep",
        "label": "Then hit the final",
        "trackId": "final",
        "deadlineLocalDate": "2026-04-27",
        "deadlineBehavior": "advance_after_deadline"
      }
    ]
  },
  "snapshots": [
    {
      "phaseId": "exam3-clean-sweep",
      "trackId": "exam3",
      "completedUnits": 8,
      "totalUnits": 12
    },
    {
      "phaseId": "final-breadth-sweep",
      "trackId": "final",
      "completedUnits": 3,
      "totalUnits": 14
    }
  ],
  "context": {
    "localDate": "2026-04-16"
  }
}

Output

{
  "plan": {
    "id": "demo-plan",
    "label": "Exam 3 then Final",
    "phases": [
      {
        "id": "exam3-clean-sweep",
        "label": "Perfect Exam 3 first",
        "trackId": "exam3",
        "deadlineLocalDate": "2026-04-18",
        "deadlineBehavior": "stay_primary_until_complete"
      },
      {
        "id": "final-breadth-sweep",
        "label": "Then hit the final",
        "trackId": "final",
        "deadlineLocalDate": "2026-04-27",
        "deadlineBehavior": "advance_after_deadline"
      }
    ]
  },
  "localDate": "2026-04-16",
  "phases": [
    {
      "id": "exam3-clean-sweep",
      "label": "Perfect Exam 3 first",
      "trackId": "exam3",
      "deadlineLocalDate": "2026-04-18",
      "deadlineBehavior": "stay_primary_until_complete",
      "completedUnits": 8,
      "totalUnits": 12,
      "targetCompletedUnits": 12,
      "remainingUnits": 4,
      "progressRatio": 0.6666666666666666,
      "isComplete": false,
      "isActive": true,
      "timeStatus": "upcoming",
      "daysUntilDeadline": 2,
      "daysFromDeadline": 0,
      "recommendationRole": "primary"
    },
    {
      "id": "final-breadth-sweep",
      "label": "Then hit the final",
      "trackId": "final",
      "deadlineLocalDate": "2026-04-27",
      "deadlineBehavior": "advance_after_deadline",
      "completedUnits": 3,
      "totalUnits": 14,
      "targetCompletedUnits": 14,
      "remainingUnits": 11,
      "progressRatio": 0.21428571428571427,
      "isComplete": false,
      "isActive": false,
      "timeStatus": "upcoming",
      "daysUntilDeadline": 11,
      "daysFromDeadline": 0,
      "recommendationRole": "queued"
    }
  ],
  "activePhase": {
    "id": "exam3-clean-sweep",
    "label": "Perfect Exam 3 first",
    "trackId": "exam3",
    "deadlineLocalDate": "2026-04-18",
    "deadlineBehavior": "stay_primary_until_complete",
    "completedUnits": 8,
    "totalUnits": 12,
    "targetCompletedUnits": 12,
    "remainingUnits": 4,
    "progressRatio": 0.6666666666666666,
    "isComplete": false,
    "isActive": true,
    "timeStatus": "upcoming",
    "daysUntilDeadline": 2,
    "daysFromDeadline": 0,
    "recommendationRole": "primary"
  },
  "trackPriority": [
    "exam3",
    "final"
  ]
}

Real source excerpt

Goal types and evaluator

src/goal/index.ts
export type GoalDeadlineBehavior =
  | 'stay_primary_until_complete'
  | 'advance_after_deadline'

export type GoalTimeStatus = 'none' | 'upcoming' | 'today' | 'past_due'

export type GoalRecommendationRole = 'primary' | 'catch_up' | 'queued' | 'complete'

export interface GoalPhaseDefinition<TTrackId extends string = string> {
  id: string
  label: string
  trackId: TTrackId
  description?: string
  deadlineLocalDate?: string
  deadlineBehavior?: GoalDeadlineBehavior
  targetCompletedUnits?: number
}

export interface GoalPlan<TTrackId extends string = string> {
  id: string
  label: string
  phases: readonly GoalPhaseDefinition<TTrackId>[]
}

export interface GoalPhaseSnapshot<TTrackId extends string = string> {
  phaseId: string
  trackId: TTrackId
  completedUnits: number
  totalUnits: number
}

export interface GoalEvaluationContext {
  localDate?: string
}

export interface GoalPhaseState<TTrackId extends string = string>
  extends GoalPhaseDefinition<TTrackId> {
  completedUnits: number
  totalUnits: number
  targetCompletedUnits: number
  remainingUnits: number
  progressRatio: number
  isComplete: boolean
  isActive: boolean
  deadlineBehavior: GoalDeadlineBehavior
  timeStatus: GoalTimeStatus
  daysUntilDeadline: number | null
  daysFromDeadline: number | null
  recommendationRole: GoalRecommendationRole
}

export interface GoalPlanEvaluation<TTrackId extends string = string> {
  plan: GoalPlan<TTrackId>
  localDate: string | null
  phases: GoalPhaseState<TTrackId>[]
  activePhase: GoalPhaseState<TTrackId> | null
  trackPriority: TTrackId[]
}

export const DEFAULT_GOAL_DEADLINE_BEHAVIOR: GoalDeadlineBehavior = 'stay_primary_until_complete'

const LOCAL_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
const ROLE_PRIORITY: Record<GoalRecommendationRole, number> = {
  primary: 0,
  catch_up: 1,
  queued: 2,
  complete: 3,
}

const assertNonNegativeInteger = (value: number, label: string): number => {
  if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
    throw new Error(`${label} must be a non-negative integer`)
  }

  return value
}

const parseGoalLocalDate = (value: string, label: string): number => {
  if (!LOCAL_DATE_PATTERN.test(value)) {
    throw new Error(`${label} must use YYYY-MM-DD format`)
  }

  const [yearToken, monthToken, dayToken] = value.split('-')
  const year = Number(yearToken)
  const month = Number(monthToken)
  const day = Number(dayToken)
  const utc = Date.UTC(year, month - 1, day)
  const parsed = new Date(utc)

  if (
    Number.isNaN(parsed.getTime())
    || parsed.getUTCFullYear() !== year
    || parsed.getUTCMonth() !== month - 1
    || parsed.getUTCDate() !== day
  ) {
    throw new Error(`${label} must be a real calendar date`)
  }

  return utc
}

const compareGoalLocalDates = (left: string, right: string): number => {
  const leftUtc = parseGoalLocalDate(left, 'localDate')
  const rightUtc = parseGoalLocalDate(right, 'deadlineLocalDate')
  const MS_PER_DAY = 86_400_000
  return Math.round((leftUtc - rightUtc) / MS_PER_DAY)
}

const resolveTimeStatus = <TTrackId extends string>(
  phase: GoalPhaseDefinition<TTrackId>,
  context: GoalEvaluationContext
): Pick<GoalPhaseState<TTrackId>, 'timeStatus' | 'daysUntilDeadline' | 'daysFromDeadline'> => {
  if (!phase.deadlineLocalDate) {
    return {
      timeStatus: 'none',
      daysUntilDeadline: null,
      daysFromDeadline: null,
    }
  }

  parseGoalLocalDate(phase.deadlineLocalDate, 'deadlineLocalDate')

  if (!context.localDate) {
    return {
      timeStatus: 'none',
      daysUntilDeadline: null,
      daysFromDeadline: null,
    }
  }

  const dayDelta = compareGoalLocalDates(context.localDate, phase.deadlineLocalDate)
  if (dayDelta < 0) {
    return {
      timeStatus: 'upcoming',
      daysUntilDeadline: Math.abs(dayDelta),
      daysFromDeadline: 0,
    }
  }

  if (dayDelta === 0) {
    return {
      timeStatus: 'today',
      daysUntilDeadline: 0,
      daysFromDeadline: 0,
    }
  }

  return {
    timeStatus: 'past_due',
    daysUntilDeadline: 0,
    daysFromDeadline: dayDelta,
  }
}

const distinctTrackPriority = <TTrackId extends string>(
  phases: readonly GoalPhaseState<TTrackId>[]
): TTrackId[] => {
  const orderedPhases = phases
    .slice()
    .sort((left, right) => {
      if (left.recommendationRole !== right.recommendationRole) {
        return ROLE_PRIORITY[left.recommendationRole] - ROLE_PRIORITY[right.recommendationRole]
      }

      return 0
    })
  const seen = new Set<TTrackId>()
  const priority: TTrackId[] = []

  for (const phase of orderedPhases) {
    if (phase.recommendationRole === 'complete' || seen.has(phase.trackId)) {
      continue
    }

    seen.add(phase.trackId)
    priority.push(phase.trackId)
  }

  return priority
}

export function resolveGoalLocalDate(
  now: Date | number | string = new Date(),
  timeZone = 'UTC'
): string {
  const date = now instanceof Date ? now : new Date(now)
  if (Number.isNaN(date.getTime())) {
    throw new Error('resolveGoalLocalDate received an invalid date input')
  }

  const formatter = new Intl.DateTimeFormat('en-US', {
    timeZone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  })
  const parts = formatter.formatToParts(date)
  const year = parts.find((part) => part.type === 'year')?.value
  const month = parts.find((part) => part.type === 'month')?.value
  const day = parts.find((part) => part.type === 'day')?.value

  if (!year || !month || !day) {
    throw new Error('resolveGoalLocalDate could not resolve a local date')
  }

  return `${year}-${month}-${day}`
}

export function evaluateGoalPlan<TTrackId extends string>(
  plan: GoalPlan<TTrackId>,
  snapshots: readonly GoalPhaseSnapshot<TTrackId>[],
  context: GoalEvaluationContext = {}
): GoalPlanEvaluation<TTrackId> {
  if (context.localDate) {
    parseGoalLocalDate(context.localDate, 'localDate')
  }

  const phaseIds = new Set<string>()
  for (const phase of plan.phases) {
    if (phaseIds.has(phase.id)) {
      throw new Error(`Duplicate goal phase id: ${phase.id}`)
    }
    phaseIds.add(phase.id)
    if (phase.deadlineLocalDate) {
      parseGoalLocalDate(phase.deadlineLocalDate, 'deadlineLocalDate')
    }
    if (phase.targetCompletedUnits !== undefined) {
      assertNonNegativeInteger(phase.targetCompletedUnits, `targetCompletedUnits for phase ${phase.id}`)
    }
  }

  const snapshotMap = new Map<string, GoalPhaseSnapshot<TTrackId>>()
  for (const snapshot of snapshots) {
    if (snapshotMap.has(snapshot.phaseId)) {
      throw new Error(`Duplicate goal phase snapshot id: ${snapshot.phaseId}`)
    }
    assertNonNegativeInteger(snapshot.completedUnits, `completedUnits for phase ${snapshot.phaseId}`)
    assertNonNegativeInteger(snapshot.totalUnits, `totalUnits for phase ${snapshot.phaseId}`)
    snapshotMap.set(snapshot.phaseId, snapshot)
  }

  const baseStates = plan.phases.map((phase) => {
    const snapshot = snapshotMap.get(phase.id)
    if (snapshot && snapshot.trackId !== phase.trackId) {
      throw new Error(`Goal phase snapshot track mismatch for phase ${phase.id}`)
    }

    const completedUnits = snapshot?.completedUnits ?? 0
    const totalUnits = snapshot?.totalUnits ?? 0
    const targetCompletedUnits = phase.targetCompletedUnits ?? totalUnits
    const remainingUnits = Math.max(targetCompletedUnits - completedUnits, 0)
    const progressRatio = targetCompletedUnits <= 0
      ? 0
      : Math.min(completedUnits / targetCompletedUnits, 1)
    const isComplete = completedUnits >= targetCompletedUnits
    const deadlineBehavior = phase.deadlineBehavior ?? DEFAULT_GOAL_DEADLINE_BEHAVIOR
    const timeStatus = resolveTimeStatus(phase, context)

    return {
      ...phase,
      completedUnits,
      totalUnits,
      targetCompletedUnits,
      remainingUnits,
      progressRatio,
      isComplete,
      isActive: false,
      deadlineBehavior,
      timeStatus: timeStatus.timeStatus,
      daysUntilDeadline: timeStatus.daysUntilDeadline,
      daysFromDeadline: timeStatus.daysFromDeadline,
      recommendationRole: isComplete ? 'complete' : 'queued',
    } satisfies GoalPhaseState<TTrackId>
  })

  const firstIncompleteIndex = baseStates.findIndex((phase) => !phase.isComplete)
  let primaryIndex = -1
  const catchUpIndexes = new Set<number>()

  if (firstIncompleteIndex >= 0) {
    let candidateIndex = firstIncompleteIndex

    while (candidateIndex >= 0) {
      const candidatePhase = baseStates[candidateIndex]
      if (!candidatePhase) {
        throw new Error('Goal planner could not resolve an incomplete phase')
      }

      const shouldAdvance = (
        candidatePhase.timeStatus === 'past_due'
        && candidatePhase.deadlineBehavior === 'advance_after_deadline'
      )
      if (!shouldAdvance) {
        primaryIndex = candidateIndex
        break
      }

      const nextIncompleteIndex = baseStates.findIndex((phase, index) => (
        index > candidateIndex && !phase.isComplete
      ))
      if (nextIncompleteIndex < 0) {
        primaryIndex = candidateIndex
        break
      }

      catchUpIndexes.add(candidateIndex)
      candidateIndex = nextIncompleteIndex
    }
  }

  const evaluatedPhases = baseStates.map((phase, index) => {
    if (phase.isComplete) {
      return phase
    }

    if (index === primaryIndex) {
      return {
        ...phase,
        isActive: true,
        recommendationRole: 'primary' as const,
      }
    }

    if (catchUpIndexes.has(index)) {
      return {
        ...phase,
        recommendationRole: 'catch_up' as const,
      }
    }

    return {
      ...phase,
      recommendationRole: 'queued' as const,
    }
  })

  const activePhase = evaluatedPhases.find((phase) => phase.recommendationRole === 'primary') ?? null

  return {
    plan,
    localDate: context.localDate ?? null,
    phases: evaluatedPhases,
    activePhase,
    trackPriority: distinctTrackPriority(evaluatedPhases),
  }
}

Consumer example

Stats goal dashboard adapter

Stats converts concept snapshots into phase snapshots and leaves grade math plus launcher copy local.

Real source excerpt

Stats study-goal adapter

stats-exam-prep-game/lib/study-goal.ts @ 8b0761a202
export const studyGoalPlan: GoalPlan<ExamId> = {
  id: 'a-threshold-exam3-then-final',
  label: 'A-threshold sprint',
  phases: [
    {
      id: 'exam3-clean-sweep',
      label: 'Perfect Exam 3 first',
      trackId: 'exam3',
      description: 'Stay on Exam 3 until every concept has at least one clean independent proof.',
    },
    {
      id: 'final-breadth-sweep',
      label: 'Then hit everything on the Final',
      trackId: 'final',
      description: 'Once Exam 3 is covered cleanly, rotate through every cumulative final section.',
    },
  ],
}

const studyGoalPhaseIds = new Set<StudyGoalPhaseId>(['exam3-clean-sweep', 'final-breadth-sweep'])

const emptyStorageReader: Pick<Storage, 'getItem'> = {
  getItem: (_key: string) => null,
}

const normalizeStudyGoalDeadlineBehavior = (
  deadlineBehavior?: GoalDeadlineBehavior | LegacyGoalDeadlineBehavior
): GoalDeadlineBehavior | undefined => {
  if (deadlineBehavior === 'advance_after_deadline') return deadlineBehavior
  if (deadlineBehavior === 'stay_primary_until_complete') return deadlineBehavior
  if (deadlineBehavior === 'demote_to_catch_up_after_deadline') return 'advance_after_deadline'
  return undefined
}

const readStoredExamProgress = (
  storage: Pick<Storage, 'getItem'>,
  examId: ExamId
): StoredExamProgress => {
  const raw = storage.getItem(`${SESSION_STORAGE_PREFIX}:${examId}`)
  const conceptIds = getExamConceptIds(examId)

  if (!raw) {
    return {
      conceptProgress: mergeConceptProgress(conceptIds, {}),
      currentTurn: 1,
    }
  }

  try {
    const parsed = JSON.parse(raw) as SessionEnvelopeLike
    return {
      conceptProgress: mergeConceptProgress(conceptIds, parsed.conceptProgress),
      currentTurn: typeof parsed.currentTurn === 'number' && Number.isFinite(parsed.currentTurn)
        ? parsed.currentTurn
        : 1,
    }
  } catch {
    return {
      conceptProgress: mergeConceptProgress(conceptIds, {}),
      currentTurn: 1,
    }
  }
}

const countDueConcepts = (progress: ConceptProgressMap, currentTurn: number): number => Object.values(progress)
  .filter((concept) => concept.nextEligibleTurn <= currentTurn && !concept.mastered)
  .length

export const buildStudyGoalExamSnapshot = (
  examId: ExamId,
  conceptProgress: ConceptProgressMap,
  currentTurn: number
): StudyGoalExamSnapshot => {
  const concepts = Object.values(conceptProgress)

  return {
    examId,
    totalConcepts: concepts.length,
    cleanProofConcepts: concepts.filter((concept) => concept.proofCount > 0).length,
    touchedConcepts: concepts.filter((concept) => (
      concept.attempts > 0
      || concept.proofCount > 0
      || concept.supportedEvidenceCount > 0
      || concept.supplementalExposureCount > 0
    )).length,
    supportedConcepts: concepts.filter((concept) => concept.supportedEvidenceCount > 0 || concept.proofCount > 0).length,
    masteredConcepts: concepts.filter((concept) => concept.mastered).length,
    dueConcepts: countDueConcepts(conceptProgress, currentTurn),
  }
}

const buildPhaseSnapshots = (
  examSnapshots: Record<ExamId, StudyGoalExamSnapshot>
): GoalPhaseSnapshot<ExamId>[] => [
  {
    phaseId: 'exam3-clean-sweep',
    trackId: 'exam3',
    completedUnits: examSnapshots.exam3.cleanProofConcepts,
    totalUnits: examSnapshots.exam3.totalConcepts,
  },
  {
    phaseId: 'final-breadth-sweep',
    trackId: 'final',
    completedUnits: examSnapshots.final.touchedConcepts,
    totalUnits: examSnapshots.final.totalConcepts,
  },
]