Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Stivenz3/Nexu/llms.txt

Use this file to discover all available pages before exploring further.

Every Nexu lesson is a Firestore document stored under the lessons/{lessonId} collection, with two subcollections — blocks and questions — holding all the content. Progress for each learner is tracked separately in users/{uid}/lessonProgress/{lessonId}, so the course content is read-only and shared while individual progress is private to each user.

Firestore layout

lessons/
  lesson_01_higiene_personal/       ← lesson document (metadata)
    blocks/                         ← subcollection
      b01_video/
      b02_modulo_a/
      ...
      b08_exam/
    questions/                      ← subcollection
      q_p1i/
      q_e01/
      ...

users/
  {firebaseAuthUid}/
    lessonProgress/
      lesson_01_higiene_personal/   ← per-user progress document
Course content (lessons, blocks, questions) is written once by the seed script and only read by the app. Progress (lessonProgress) is created and updated by the app as the learner advances.

Lesson 1 example — lesson_01_higiene_personal

The following is the actual lesson.json seed file for Lesson 1:
{
  "lessonId": "lesson_01_higiene_personal",
  "title": "Higiene Personal del Manipulador de Alimentos",
  "order": 1,
  "normativeBase": "Resolución 2674 de 2013, Capítulo III, Arts. 11–14",
  "passingScore": 70,
  "estimatedMinutes": 60,
  "isActive": true,
  "totalBlocks": 8
}
Lesson 1 contains 8 blocks (b01_video through b08_exam) and 23 questions — 8 inline questions embedded in theory blocks and 15 questions in the exam pool.

LessonDoc type

interface LessonDoc {
  lessonId: string
  title: string
  order: number
  normativeBase: string
  passingScore: number
  estimatedMinutes: number
  isActive: boolean
  totalBlocks: number
}

The sequential block model

Blocks unlock one at a time, in order. Block N becomes available only when block N-1 has completed: true in the user’s lessonProgress. The first block (index 0) is always unlocked. The isBlockUnlocked function in progressService.ts encodes this rule directly:
export function isBlockUnlocked(
  blocks: LessonBlock[],
  progress: LessonProgress | null,
  blockIndex: number
): boolean {
  if (blockIndex === 0) return true
  if (!progress) return false
  const prevBlock = blocks[blockIndex - 1]
  const prevProgress = progress.blocksProgress[prevBlock.blockId]
  return Boolean(prevProgress?.completed)
}
Users can also re-navigate to blocks they have already completed, allowing review without forcing them to redo content. canNavigateToBlock handles this case:
export function canNavigateToBlock(
  blocks: LessonBlock[],
  progress: LessonProgress | null,
  blockIndex: number
): boolean {
  const block = blocks[blockIndex]
  if (!block) return false
  if (!progress) return blockIndex === 0

  // Already completed → free to revisit
  if (progress.blocksProgress[block.blockId]?.completed) return true

  // Exam block has its own gate
  if (block.type === 'exam') {
    return isExamUnlocked(blocks, progress)
  }

  return isBlockUnlocked(blocks, progress, blockIndex)
}

Exam unlock gate

The exam block only becomes interactive when every non-exam block has been completed. isExamUnlocked filters out the exam block itself and checks that all remaining blocks have completed: true:
export function isExamUnlocked(
  blocks: LessonBlock[],
  progress: LessonProgress | null
): boolean {
  if (!progress) return false
  const contentBlocks = blocks.filter((b) => b.type !== 'exam')
  if (contentBlocks.length === 0) return false
  return contentBlocks.every(
    (b) => progress.blocksProgress[b.blockId]?.completed === true
  )
}
LessonFlowPage calls isExamUnlocked to decide whether to enable the “Iniciar evaluación final” button. If the gate is not met, it shows the user which content block still needs to be completed.

Initial progress document — ensureLessonProgress

When a learner opens a lesson for the first time, ensureLessonProgress checks whether a lessonProgress document already exists. If it does not, it fetches the lesson’s blocks from Firestore and calls buildBlocksProgress to construct typed initial state for every block before writing the document:
export async function ensureLessonProgress(
  userId: string,
  lessonId: string,
  blocks?: LessonBlock[]
): Promise<LessonProgress>
buildBlocksProgress iterates over the blocks array and calls createInitialBlockProgress for each block, which returns the correct typed initial object based on block.type:
Block typeInitial progress shape
video{ completed: false, videoWatched: false, watchedPct: 0 }
theory (single question){ completed: false, questionAnswered: false, answeredCorrect: false }
theory (multiple questions){ completed: false, questionsAnswered: [false, false, …] }
game{ completed: false, gameScore: 0, errorsFound: 0, hintsUsed: 0, timeSpentSec: 0 }
exam{ completed: false, lastScore: 0, attempts: 0, passed: false }
Learners who already had a lessonProgress document from before this logic was introduced keep their existing document unchanged. Only new sessions generate the typed template from Firestore blocks.

Progress status values

The top-level status field on a LessonProgress document uses one of four string literals:
ValueMeaning
lockedLesson not yet accessible (previous lesson not passed)
in_progressLearner has started but not yet passed the final exam
passedLearner scored ≥ passingScore on the final exam
failedLearner submitted the exam but scored below passingScore
status is set to in_progress when the progress document is first created, and updated to passed or failed by saveExamResult after each exam submission.

Lesson service functions

The following functions in src/services/lessonService.ts load lesson content from Firestore. Course content is read-only from the app’s perspective; only the seed script writes to these collections.
// Returns all lessons where isActive === true, ordered by the `order` field ascending
export async function fetchActiveLessons(): Promise<LessonDoc[]>

// Returns a single lesson document by ID, or null if not found
export async function fetchLesson(lessonId: string): Promise<LessonDoc | null>

// Returns the blocks subcollection for a lesson, ordered by `order` ascending
export async function fetchLessonBlocks(lessonId: string): Promise<LessonBlock[]>

// Returns all questions in the lesson's questions subcollection (all types)
export async function fetchLessonQuestions(lessonId: string): Promise<LessonQuestion[]>
fetchActiveLessons is used by both LearningPathPage (to build the lesson list) and resolveEvalLessonId (to determine which lesson to target for the sidebar exam link). fetchLessonBlocks is called by LessonFlowPage on load and by several progress functions when the caller does not already have a blocks array in memory.

Progress write functions

The following functions in src/services/progressService.ts write or update the learner’s lessonProgress document in Firestore.

updateBlockProgress

Partially updates the progress entry for a single block without marking it complete. Useful for recording incremental state (e.g. how far through a multi-question theory block the learner has reached).
export async function updateBlockProgress(
  userId: string,
  lessonId: string,
  blockId: string,
  blockUpdate: Record<string, unknown>,
  lastBlockCompleted?: string
): Promise<void>

markBlockCompleted

Sets completed: true on a block’s progress entry and updates lastBlockCompleted on the parent document. Accepts an optional extra object whose fields are merged into the block progress before marking complete (e.g. { videoWatched: true, watchedPct: 100 } for a video block).
export async function markBlockCompleted(
  userId: string,
  lessonId: string,
  blockId: string,
  extra: Record<string, unknown> = {}
): Promise<void>

resolveEvalLessonId

Returns the lessonId of the last lesson the learner has unlocked — used by the sidebar’s “Evaluación” link to point to the correct lesson regardless of which page the learner is on.
export async function resolveEvalLessonId(userId: string): Promise<string>
The function loads the active lessons list and walks forward until it finds a lesson whose predecessor has not been passed. The first active lesson (lesson_01_higiene_personal) is always returned as the fallback if no progress exists at all.

Learning path — /ruta

LearningPathPage calls fetchActiveLessons() to load all lessons where isActive: true, ordered by the order field. It then fetches the progress document for each lesson and computes a display status:
  • Lesson 1 — always available (or in-progress / completed if the user has started)
  • Lesson N+1 — only available once lesson N has status: 'passed'
This means adding a new lesson to the learning path requires only setting isActive: true and assigning the correct order value in the lesson’s seed JSON — no frontend code changes are needed.

Full LessonProgress shape

interface LessonProgress {
  lessonId: string
  status: LessonProgressStatus          // 'locked' | 'in_progress' | 'passed' | 'failed'
  startedAt: unknown                    // Firestore ServerTimestamp
  completedAt: unknown | null
  lastBlockCompleted: string
  totalTimeSpentSec: number
  blocksProgress: BlocksProgress        // Record<blockId, BlockProgressEntry>
  evalAttempts: number[]                // score from each exam attempt
  bestEvalScore: number
}

Build docs developers (and LLMs) love