Skip to main content
Mirror of LMS_Project/AUTH_USER_OPTIMIZE.md (written 13/10/2026). It was written from the code as it is on main in LMS_Backend, LMS_Frontend and LMS_Documentation, plus the captured payload LMS_Frontend/docs/api_samples/student_fivermail/02_GET_auth_user.full.json.
Status: DONE ✅ (19/10/2026). Shipped in the three repos on the chat+ai branch (backend AuthUserPayloadService + ?legacy=1, frontend Update 125, this site). What was built and what was left out on purpose is listed in §0 What shipped right below.Goal: a student who logs in on the web app should see the dashboard in well under a second of API time instead of 6–7 s, without touching the Flutter renderer (no wasm). The lever is the boot payload: GET /auth/user was 1.5 MB and is now ~20 KB.

0. What shipped (19/10/2026) ✅

Measured on 19/10/2026 on a local backend with a seeded student (2 trainings, 2 exams): slim 6.3 KB vs legacy 15.3 KB, 28 SQL queries. The phone app was run on the Android emulator against that backend: dashboard, My Trainings, Simulator, Exam Attempts, attempt review, course player and the new pull-to-refresh all work on the slim payload.

Table of contents

  1. What shipped (19/10/2026)
  2. What happens today (measured)
  3. Why it is slow: three costs, not one
  4. What the student app actually reads from the payload
  5. Target shape of GET /auth/user (student)
  6. Answers to the specific questions
  7. Backend plan (LMS_Backend)
  8. Frontend plan (LMS_Frontend, student side)
  9. Documentation plan (LMS_Documentation)
  10. Repo hygiene: the committed payload dump
  11. Rollout order and compatibility
  12. How to measure before / after
  13. Out of scope (but related)

1. What happens today (measured)

1.1 The boot sequence on the web app

Nothing is cached between boots. Every page reload re-downloads and re-parses the full payload.

1.2 What the 1.5 MB is made of

From 02_GET_auth_user.full.json (user id 1, student, company 1 — 1 550 159 bytes serialized): Counters over the whole document: "is_correct" appears 5 790 times, "content" 65 times.
The analysis page LMS_Documentation/api-reference/untitled-page.mdx (§1.2) says the quizzes nested in orders[].quizzes[] are summaries without quiz_questions. That is wrong for the committed sample: the owned exams carry 180 questions each. OrderResource serializes them with QuizResource in its default full shape and UserResource eager-loads quizzes.quiz_questions.*.

1.3 Where the shape comes from


2. Why it is slow: three costs, not one

  1. Database time (server). The eager-load tree is ~30 queries, but the $appends accessors add roughly 6 queries per Training and 5 per Quiz at serialization (reviews()->avg, reviews()->count, orders()->count ×2, chapters()->sum, chapters()->count). The sample serializes 4 trainings, 2 chapter quizzes, 7 exams (6 in orders + 1 in cart) → ~60 extra queries, each a real round-trip, and none of them are read by the student UI. Add the two notification queries and the wishlist/orders lazy loads that run inside the resource.
  2. Bytes on the wire (network). 1.5 MB of JSON. If the Plesk/nginx vhost does not gzip application/json this is 1.5 MB on a Tunisian mobile connection; gzipped it is still ~150 KB that has to be decoded.
  3. Parse and fan-out time (browser main thread). Dart’s jsonDecode + json_serializable fromJson for ~1 500 question objects, then handleStudentRole() and three separate call sites (overview_screen.dart:59-61, student_space.dart:27-29, student_statefulshell.dart:54-66) that walk orders[].trainings[].chapters[].lessons[] on every build to set lesson.isValidate. On CanvasKit web this all runs on the UI thread, so the spinner stays up while it runs.
Shrinking the payload fixes all three at once. Date formatting (question in §5.6) is not one of the costs.

3. What the student app actually reads from the payload

Traced in LMS_Frontend/lib (details in LMS_Frontend/authed_user_and_attempts.md and the agent trace of 13/10/2026). User model: lib/core/data/remote/user.dart:7-78. Fan-out: lib/core/providers/app_service.dart:212-264.

3.1 Read and needed at boot

3.2 Sent but never read by the Flutter app

applied_coupons, classroom_joined_slugs, classroom_admin_slugs, owned_trainings, owned_exams, public_profile_website/description/phone_number/location/email, hide_public_profile, loyalty_points, trainings_as_instructor, reviews, wishlist, images_uploaded (already commented out), lessons[].content, every quiz_questions[] inside orders[].trainings and cart, every quiz_question_option_items[], explanation, is_correct, revenue_percentage, total_sales, total_active_sales, average_rating, total_ratings on nested quizzes/trainings, pivot.price.
Re-checked on 19/10/2026 (every read site of lib/ traced) — three keys of the original list WERE read:
  • default_revenue_percentage → User.revenuePercentage: the instructor create-training / create-quiz screens take the logged-in instructor as the selected instructor and read it (basic_info_screen.dart, quiz_basic_info_widget.dart, add_trainings_screen.dart, create_quiz.dart). Kept in the slim payload.
  • quiz_student_attempts[].quiz_questions → the exam-attempt review read the student’s answers (pivot) from the cached row. Replaced: the review now fetches the single attempt with show_quiz_student_answers?quiz_id&attempt_id.
  • orders[].quizzes[].quiz_questions → .length on the My Exams cards and the “no questions” start gate, plus the review fallback. Replaced by quiz_questions_count.
explanation, is_correct and quiz_question_option_items were read only on a fallback path (when GET /quiz/{id} fails); loyalty_points only in commented code. Everything else is confirmed unread.

3.3 Bugs caused by the current shape (they disappear with the new shape)


4. Target shape of GET /auth/user (student) (DONE ✅)

Principle: ids, labels, counters and card fields only. No question banks, no lesson bodies, no order history, no attempt history. Everything heavy moves to the endpoint of the page that needs it, fetched when that page opens. The trainings/exams cards reuse the same field names as MarketplacePreviewService::trainingCard() / examCard() so the Flutter Training / Quiz models already parse them; only the progress and attempts sub-objects are new.
Removed from the student payload: orders, cart (full), lessons, quiz_student_attempts, wishlist, reviews, trainings_as_instructor, images_uploaded, owned_trainings, owned_exams, applied_coupons, classroom_*_slugs, unused public_profile_* keys, hide_public_profile, loyalty_points, default_revenue_percentage. Staff roles (instructor / content manager / admin / super-admin): the student blocks (my_trainings, my_exams, validated_lesson_ids, cart_summary, certifications) are omitted; they get profile + company + role.permissions + notifications. Staff screens already use their own endpoints (instructor dashboard, admin lists). Before removing trainings_as_instructor, grep the instructor/admin Flutter code once; the Dart User model has no field for it, so it is safe as far as the current app goes. Estimated size for the sample student: ≈ 15–25 KB (vs 1 550 KB), ≈ 12 SQL queries (vs ~90).

5. Answers to the specific questions

5.1 “Why fetch the trainings with chapters, lessons and content after login?”

There is no reason left. The course player (/student-my-content/student-training-details/:id) does not use the /auth/user copy of the training: it calls GET /training/{id} on open (training_provider.dart:589-593) and reads lesson content from that. The only thing the boot payload’s chapter/lesson tree is used for is the client-side progress count, which §5.2 moves to the backend. So my_trainings[] can be the marketplace-card shape (what GET /preview-training/{idOrSlug} returns, minus is_owned, minus the free-preview lesson) plus a progress block. You keep using auth/user, as you asked; you just borrow the preview shape, not the preview endpoint.

5.2 “Can the 26/300 lessons and 3/5 quizzes be computed in the backend?”

Yes, and the backend already knows how: UserProgressAnalyticsController::trainings_progress() (GET /progress/trainings, LMS_Backend/app/Http/Controllers/UserProgressAnalyticsController.php:207-291) returns exactly lessons_total / lessons_completed / quizzes_total / quizzes_passed / overall_progress_percentage / is_completed per owned training, and quizzes_progress() (:399-481) returns attempts_count / best_score_percentage / passed per quiz. The Flutter app never calls either. Today the frontend computes it (training_progress_helper.dart:6-51, continue_learning_card.dart:136-173): walk every chapter and lesson of every training, compare against user.lessons ids, and compare attempts against passing_percentage. For the 415-lesson training that is a 415-iteration loop per training per build, and it is wrong anyway (bug B1). How to compute it in the backend without a per-training loop (the existing trainings_progress() loops and runs 4 queries per training; for the boot payload do it set-based, 4 queries total whatever the training count):
Cost is independent of the number of lessons (415 lessons is one COUNT). Two cheap indexes make it instant at scale (§6.4). If you later want zero runtime cost, materialize the counters in a student_training_progress table updated by toggle_lesson_validation and finish_student_quiz, but that is not needed for the first version; the set-based queries are already sub-10 ms.

5.3 “My Exams grid and the quiz-start page”

  • The grid (/student-my-content/exams) reads only card fields plus questions.length. Send quiz_questions_count (one withCount('quiz_questions'), no question rows) and stop sending quiz_questions inside orders[].quizzes[]. The card’s “no questions” guard becomes quiz_questions_count == 0.
  • The quiz-start page (/student-my-content/quiz-start/:id) already fetches what it needs when it opens: GET /quiz/{id} then GET /quiz/show_quiz_student_answers?quiz_id&is_exam=1. Keep that. Two fixes there: hoist the FutureProvider out of build() (it refires on every rebuild, quiz_home_main.dart:91-92), and stop shipping the answer key on GET /quiz/{id} to students before an attempt is finished (clean_up.md G.1 #5–6; out of scope for this file but the same is_correct problem).

5.4 “How and where are exam attempts calculated, and what is the fastest way?”

Today: Fastest and correct: the backend owns the arithmetic, the client only displays. Two grouped queries give every owned exam’s numbers in one go, with no per-exam loop:
Then allowed = max_attempts == 0 ? null : max_attempts * (reset_count + 1) and remaining = allowed - used (clamped at 0). Use MAX(attempt) for used so it matches the check in start_student_quiz. Put this attempts block on each my_exams[] card (boot) and on the response of GET /quiz/{id} for the calling student (quiz-start page), and add allowed_attempts + reset_count to each row of show_quiz_student_answers (the Flutter rules 1–2 already consume them, zero frontend change for B7).

5.5 “Attempts page: fetch only when /student-my-content/attempts opens”

Yes. Drop quiz_student_attempts from /auth/user. The page calls GET /quiz/show_quiz_student_answers?student_id={me}&is_exam=1 on open (it already does this after an exam is submitted, quiz_provider.dart:3020-3027). That endpoint returns the full rows the table needs (attempt, correct_answers, uncorrect_answers, score_percentage, finished_at, passed_at, reset_cycle), which fixes B2. One caveat: that endpoint eager-loads quiz_questions.image for every attempt, so for a student with 13 attempts on 180-question exams it is heavy for a list view. Add a ?summary=1 (or with_answers=0) flag that skips the quiz_questions relation; the review (eye icon) then fetches a single attempt with answers (?quiz_id=…&attempt_id=…, or a new GET /quiz/attempt/{id}/answers). Cache the list in QuizProvider with a loaded flag and invalidate it after finish_student_quiz.

5.6 Dates: 2026-06-19T07:40:12.000000Z vs serializeDate, cost, and which dates to keep

Why both formats appear. Every model overrides serializeDate() to Y-m-d H:i:s. That override only runs when the model is converted to an array/JSON. In UserResource / OrderResource the code returns the raw Carbon object ('created_at' => $this->created_at), and Laravel’s JSON encoder serializes a bare Carbon through Carbon’s own jsonSerialize() → ISO 8601 with microseconds in UTC. That is why orders[].created_at, cart.*, certifications[].created_at and quiz_student_attempts[].created_at are ISO while everything that goes through a model (notifications[], nested quizzes) is Y-m-d H:i:s. The sample has 185 ISO dates and 178 Y-m-d H:i:s dates. Is serializeDate a heavy calculation? No. It is one DateTime::format() call per date, about a microsecond. A thousand dates cost under a millisecond on either side (Dart DateTime.parse is similar). The cost you are seeing is the number of objects, not the date strings. Do not add a cached/pre-formatted column for this. Recommended approach:
  1. Pick one wire format for the whole API and stick to it. The existing convention is Y-m-d H:i:s in Africa/Tunis (backend CLAUDE.md §8), and the Flutter code depends on it (passedAt.substring(0,16) in exam_attempts_datasource.dart:107). Keep it. In resources, emit dates through the model format ($this->created_at?->format('Y-m-d H:i:s'), or a tiny Dates::wire() helper) instead of the raw Carbon. If one day the app needs real timezone handling for users outside Tunisia, switch everything to toIso8601String() (2026-06-19T07:40:12+01:00) in one release; do not mix.
  2. Only send dates the UI shows. In the student payload that is: my_trainings[].updated_at (the “Last updated MM/yyyy” chip), my_trainings[].progress.last_activity_at, my_exams[].attempts.last_attempt_at, notifications[].created_at, certifications[].created_at. Attempt rows (on their own page) keep created_at, finished_at, passed_at. Drop updated_at, deleted_at, archived_at, pivot timestamps everywhere else.
  3. Frontend: parse once, not on every build. Give the Dart models DateTime? fields with a small JsonConverter (Y-m-d H:i:s ⇄ DateTime) instead of String? + DateTime.parse inside build() (glass_training_card.dart:636-643, helper.dart:351,384). Formatting for display (MM/yyyy, “3 h ago”) stays in the client because it is locale-dependent (en/fr/ar).

5.7 Notifications: 3 at boot instead of 10

Yes. NotificationController::getUserNotifications() (:367-374) is a limit(10); make it limit(3) (or a $limit parameter, default 3) and keep getUserNotificationCount() for the badge. When the bell popup opens, call the existing paginated GET /notifications/user/{id}?company_id&per_page=10&page=1 (the Flutter client already has it, api_client.dart:1370-1449) and replace the provider list. Also trim the notification columns in the boot list to what the popup renders (id, title, message, notification_type_id, notif_read_status, created_at). The SharedPreferences “notificationCount$userId” subtraction in notification_provider.dart:903-925 can go: notification_count is already the unread count.

5.8 Cart

Only app_service.dart:223 reads user.cart; the cart page never re-fetches. Replace with cart_summary (id, items_count, totals) for the header badge, and have the cart page call GET /user/{id}/cart/2 on open (2 is Order::CART — the route is /user/{student_id}/cart/{type}, so the hard-coded cartId: 2 in cart_provider.dart:26 is the order type, not a bug; rename the parameter for clarity). The cart endpoint should serialize items in summary shape (no questions): check UsersController::cart() emits QuizResource(..., 'summary') / TrainingResource(..., 'summary') and not the full default.

6. Backend plan (LMS_Backend) (DONE ✅)

6.1 New payload builder (whitelist pattern, like MarketplacePreviewService / ClassroomPayloadService)

Create app/Services/AuthUserPayloadService.php:
Rules for the service: select columns explicitly, never return a model, never load quiz_questions, lessons.content, quiz_question_option_items, reviews, or the instructor email / default_revenue_percentage. Instructor columns = MarketplacePreviewService::INSTRUCTOR_COLUMNS. Reuse its file() / instructor() helpers (make them public static or extract a small PayloadFormatters trait) so cards look identical to the marketplace.

6.2 AuthController::auth_user()

  • Replace the User::with([...]) block + UserResource::make() with User::with(['image','company.companyImage','role.permissions'])->find(auth()->id()) and response()->json(app(AuthUserPayloadService::class)->build($user)). Keep $this->authorize('view', $user).
  • Transition flag (see §10): while the frontend switches, accept ?legacy=1 to return the old UserResource so a stale web bundle keeps working; delete the flag and UserResource’s order/attempt branches afterwards.

6.4 Indexes (migration)

student_lesson and quiz_student_attempts were created without composite indexes (2024_03_01_134652_create_student_lesson_table.php, 2024_03_31_025930_create_quiz_student_attempts_table.php; only the FK indexes exist). Add:
  • student_lesson (student_id, lesson_id, deleted_at)
  • quiz_student_attempts (student_id, quiz_id) and (quiz_id, student_id, finished_at)
  • order_items (order_id, training_id) / (order_id, quiz_id) if not already there
  • notifications (student_id, notif_archive_status, created_at)

6.5 Tests (tests/Feature/AuthUserPayloadTest.php, SQLite like the other exposure tests)

  1. Student payload has my_trainings, my_exams, validated_lesson_ids, cart_summary, certifications, 3 notifications max.
  2. Student payload never contains the strings is_correct, "content", quiz_questions", explanation, revenue_percentage, an instructor email, or the keys orders / quiz_student_attempts / lessons.
  3. Revoked exams/trainings are excluded; a training bought twice appears once.
  4. Progress numbers match fixtures (26 validated lessons → lessons_completed: 26; a passed quiz → quizzes_passed: 1).
  5. attempts.allowed is null for max_attempts = 0, equals max_attempts * (reset_count + 1) otherwise.
  6. Every date in the payload matches /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.
  7. Staff payload has no student blocks; query count assertion (DB::enableQueryLog, expect ≤ 15).

6.6 Clean-up in the same pass

  • UserResource.php: delete the dead commented blocks and the wishlist/orders lazy ->load() branches once the legacy flag is gone. Keep the class if admin tooling uses it (grep UserResource first).
  • Training::$appends / Quiz::$appends: nothing in this plan removes them globally (other endpoints rely on them), but every list-type endpoint should opt out (setAppends([]) or ->withAvg/withCount instead). Note it in clean_up.md G.2 as a follow-up.
  • Update LMS_Backend/CLAUDE.md §10/§12 with a line: “GET /auth/user is whitelist-based (AuthUserPayloadService); extend by adding to the whitelist, never by loading relations on the user.”
  • Update clean_up.md G.2 row “GET /auth/user” to ✅ when done.

7. Frontend plan (LMS_Frontend, student side) (DONE ✅)

7.1 Models (lib/core/data/remote/…, then dart run build_runner build -d)

7.2 Boot (lib/core/providers/app_service.dart)

  • handleStudentRole(): fill StudentTrainingProvider.myTrainings from user.myTrainings, QuizProvider.myExams from user.myExams, certificateStudent from user.certifications, NotificationProvider from the 3 notifications + count, CartProvider from cartSummary, StudentTrainingProvider.validatedLessonIds from user.validatedLessonIds. Delete the orders[].trainings / orders[].quizzes access_revoked filters (the backend already filtered) and the studentQuizAttempts / studentExamAttempts assignment.
  • Do not await GET /dashboard/user/{id}/progress in series at boot (:231). Either fire it from the “Learning activity” card when it mounts or run it in Future.wait with /auth/user.
  • Send ?legacy=0 (or nothing once the backend default flips, see §10).

7.3 Progress widgets: read, don’t compute

  • TrainingProgressProvider (training_progress_provider.dart:54-66): remove validateLessonsInTraining at boot and the per-build calls in overview_screen.dart:59-61, student_space.dart:27-29, student_statefulshell.dart:54-66. allTrainingsWithProgress becomes a thin wrapper around myTrainings (each already carries progress).
  • ContinueLearningCard (continue_learning_card.dart:136-173), enrolled_training_screens.dart:170-188, learner_progress_cards.dart:151-205, overview_screen.dart:640-655: replace calculateValidatedLessons / calculateTotalItems / quizzes.where(passed) with training.progress.lessonsCompleted / lessonsTotal / quizzesPassed / quizzesTotal. “Exams passed” stat = myExams.where((e) => e.attempts?.passed == true).length.
  • training_progress_helper.dart: keep only what the course player still needs (marking isValidate on the GET /training/{id} tree using validatedLessonIds); delete the rest. Fixes B1 and B4.
  • After POST /lesson/{id}/toggle_lesson_validation: update validatedLessonIds locally (and progress counters from the toggle response once the backend returns them) instead of GET /user/{id} (student_training_provider.dart:218-223).

7.4 My Exams grid and quiz-start

  • enrolled_quizzes_screen.dart:155,218, exam_card_with_progress.dart:38, quiz_attempt_listtile.dart:66: use quiz.quizQuestionsCount instead of questions?.length.
  • Show attempts.used / allowed and best_score_percentage on the card if wanted (data is now free).
  • quiz_home_main.dart:91-92: create the getQuizApi future in initState / memoize it.
  • exam_preview_page.dart + quiz_provider.dart:124-157: prefer the server attempts.allowed / remaining (from GET /quiz/{id} or the card); keep the rule chain only as a fallback. Fixes B7.

7.5 Attempts page (/student-my-content/attempts)

  • On route open (initState of ExamAttemptsDataTable / ExamAttemptsMobile, or a FutureProvider in the route builder): QuizProvider.loadStudentExamAttempts() → GET /quiz/show_quiz_student_answers?student_id&is_exam=1&summary=1. Keep a loaded flag + manual refresh; invalidate after finish_student_quiz.
  • exam_attempts_datasource.dart:155: myExams.firstWhere(... , orElse: ...) (an attempt can outlive access).
  • The review (eye icon) keeps openExamAttemptReview but fetches the single attempt with answers.
  • Fixes B2.

7.6 Notifications

  • Popup open (student_appbar_widget.dart:270-290): call GET /notifications/user/{id}?per_page=10 and replace provider.notifications; the 3 boot items are shown meanwhile.
  • Badge = user.notification_count directly; remove the SharedPreferences offset (notification_provider.dart:903-925).

7.7 Cart

  • Header badge from cartSummary.itemsCount.
  • Cart page initState: GET /user/{id}/cart/2 → CartProvider.userCart. Rename the cartId param to orderType.

7.8 Certificates

  • Certificate.fromJson reads training_id / quiz_id; the five “earned” checks (course_content_tree.dart:446, certificate_progress_dialog.dart:58, course_player_mobile.dart:372, enrolled_training_screens.dart:148, quiz_provider.dart:2979) work at boot. Fixes B3. Alternatively read training.certificationId / quiz.certificationId from the cards.

7.9 Optional, after the above: paint before the network

With a ~20 KB payload the blocking await in app.dart:52-56 costs one short round-trip, which is acceptable. If you want instant paint later: store the last /auth/user JSON in SharedPreferences, build the router from it immediately, refresh in the background and notifyListeners(). Not required for the 6–7 s problem.

7.10 Bookkeeping

  • Append an # Update N — GET /auth/user slimmed … entry to major_code_updates.md (append only, never truncate), with the B1–B7 bug list and the files touched.
  • Update LMS_Frontend/CLAUDE.md §3 (“myExams come from the login payload”) and authed_user_and_attempts.md (or delete the latter once the docs page is rewritten, §8).
  • clean_up.md App Speed Suggestions row 2 → ✅.

8. Documentation plan (LMS_Documentation) (DONE ✅)

Run mint broken-links and mint dev before pushing (push to main auto-deploys).

9. Repo hygiene: the committed payload dump (DONE ✅ — history not purged)

LMS_Frontend/docs/api_samples/student_fivermail/02_GET_auth_user.full.json (1.41 MB) is tracked (added in commit efa1cf9), not git-ignored, and contains a real account e-mail, the student’s whole purchase history and 5 790 is_correct flags, i.e. the answer keys of six commercial exams. capture.sh:7-8 explicitly refuses to commit the /quiz/{id} dumps for that very reason, yet the /auth/user dump holds the same keys. Nothing in the codebase reads the file (only capture.sh, authed_user_and_attempts.md, clean_up.md and graphify-out/). Plan:
  1. git rm the .full.json; keep 02_GET_auth_user.shape.json (6 KB, one item per list) after redacting the e-mail / names / phone.
  2. Add docs/api_samples/**/*.full.json to .gitignore; make capture.sh write full dumps to a git-ignored docs/api_samples/_local/ folder. Remove the hard-coded password from capture.sh (read EMAIL / PASSWORD from env only).
  3. Decide whether to purge history (git filter-repo on the path, then force-push + everyone re-clones). The account is a seeded test user on the test backend, so the privacy exposure is low, but the exam answer keys are real content. Recommendation: purge, since the repo is private and small.
  4. Delete the stray LMS_Frontend/gitignore file (no dot) noted in clean_up.md.
  5. Re-capture a new shape sample once the new payload ships, so the docs example is real.

10. Rollout order and compatibility (DONE ✅ — slim by default, ?legacy=1 kept)

The exams grid will break if the backend drops orders[].quizzes[].quiz_questions before the frontend reads quiz_questions_count (the card shows “0 questions” and refuses the tap). So ship additively, switch, then remove: Mobile (APK / iOS) users update later than web, which is the other reason to keep ?legacy=1 alive for a release.

11. How to measure before / after (measured on a local backend, see §0)

Record these for the seeded student on TEST before step 1 and after step 3: Also check the vhost: curl -sI -H 'Accept-Encoding: gzip, br' …/api/auth/user | grep -i content-encoding. If JSON is not compressed, enable gzip/brotli for application/json in the Plesk/nginx config; it is a free win independent of this plan.
  • GET /training/{id} (course player) still returns every lesson’s content and the chapter-quiz answer keys in one shot; clean_up.md G.1 #4 / G.2 row 2 already describe the fix (structure endpoint + per-lesson content).
  • GET /quiz/{id} ships is_correct to a student before the attempt starts (G.1 #5–6).
  • Training::$appends / Quiz::$appends fire N queries on every list endpoint in the app, not only here.
  • Code-splitting the Flutter bundle by role (clean_up.md App Speed row 1) is the next biggest web win after this.