@vue-skuilder/db 0.1.31-b → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{contentSource-ygoFw9oV.d.ts → contentSource-Bdwkvqa8.d.ts} +16 -0
  2. package/dist/{contentSource-B7nXusjk.d.cts → contentSource-DF1nUbPQ.d.cts} +16 -0
  3. package/dist/core/index.d.cts +34 -3
  4. package/dist/core/index.d.ts +34 -3
  5. package/dist/core/index.js +510 -50
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +510 -50
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BW7HvkMt.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
  10. package/dist/{dataLayerProvider-BfXUVDuG.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
  11. package/dist/impl/couch/index.d.cts +156 -4
  12. package/dist/impl/couch/index.d.ts +156 -4
  13. package/dist/impl/couch/index.js +730 -41
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +729 -41
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +3 -2
  18. package/dist/impl/static/index.d.ts +3 -2
  19. package/dist/impl/static/index.js +467 -31
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +467 -31
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +64 -3
  24. package/dist/index.d.ts +64 -3
  25. package/dist/index.js +948 -72
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +948 -72
  28. package/dist/index.mjs.map +1 -1
  29. package/package.json +3 -3
  30. package/src/core/interfaces/contentSource.ts +6 -0
  31. package/src/core/interfaces/courseDB.ts +6 -0
  32. package/src/core/interfaces/dataLayerProvider.ts +20 -0
  33. package/src/core/navigators/Pipeline.ts +414 -9
  34. package/src/core/navigators/PipelineAssembler.ts +23 -18
  35. package/src/core/navigators/PipelineDebugger.ts +35 -1
  36. package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
  37. package/src/core/navigators/generators/prescribed.ts +95 -0
  38. package/src/core/navigators/index.ts +12 -0
  39. package/src/impl/common/BaseUserDB.ts +4 -1
  40. package/src/impl/couch/CourseSyncService.ts +356 -0
  41. package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
  42. package/src/impl/couch/courseDB.ts +60 -13
  43. package/src/impl/couch/index.ts +1 -0
  44. package/src/impl/static/courseDB.ts +5 -0
  45. package/src/study/ItemQueue.ts +42 -0
  46. package/src/study/SessionController.ts +195 -22
  47. package/src/study/SpacedRepetition.ts +3 -1
  48. package/tests/core/navigators/Pipeline.test.ts +1 -1
  49. package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
package/dist/index.mjs CHANGED
@@ -855,8 +855,12 @@ __export(PipelineDebugger_exports, {
855
855
  buildRunReport: () => buildRunReport,
856
856
  captureRun: () => captureRun,
857
857
  mountPipelineDebugger: () => mountPipelineDebugger,
858
- pipelineDebugAPI: () => pipelineDebugAPI
858
+ pipelineDebugAPI: () => pipelineDebugAPI,
859
+ registerPipelineForDebug: () => registerPipelineForDebug
859
860
  });
861
+ function registerPipelineForDebug(pipeline) {
862
+ _activePipeline = pipeline;
863
+ }
860
864
  function getOrigin(card) {
861
865
  const firstEntry = card.provenance[0];
862
866
  if (!firstEntry) return "unknown";
@@ -884,6 +888,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
884
888
  origin: getOrigin(card),
885
889
  finalScore: card.score,
886
890
  provenance: card.provenance,
891
+ tags: card.tags,
887
892
  selected: selectedIds.has(card.cardId)
888
893
  }));
889
894
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -939,12 +944,13 @@ function mountPipelineDebugger() {
939
944
  win.skuilder = win.skuilder || {};
940
945
  win.skuilder.pipeline = pipelineDebugAPI;
941
946
  }
942
- var MAX_RUNS, runHistory, pipelineDebugAPI;
947
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
943
948
  var init_PipelineDebugger = __esm({
944
949
  "src/core/navigators/PipelineDebugger.ts"() {
945
950
  "use strict";
946
951
  init_navigators();
947
952
  init_logger();
953
+ _activePipeline = null;
948
954
  MAX_RUNS = 10;
949
955
  runHistory = [];
950
956
  pipelineDebugAPI = {
@@ -1146,6 +1152,21 @@ var init_PipelineDebugger = __esm({
1146
1152
  }
1147
1153
  console.groupEnd();
1148
1154
  },
1155
+ /**
1156
+ * Scan the full card space through the filter chain for the current user.
1157
+ *
1158
+ * Reports how many cards are well-indicated and how many are new.
1159
+ * Use this to understand how the search space grows during onboarding.
1160
+ *
1161
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
1162
+ */
1163
+ async diagnoseCardSpace(threshold) {
1164
+ if (!_activePipeline) {
1165
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
1166
+ return null;
1167
+ }
1168
+ return _activePipeline.diagnoseCardSpace({ threshold });
1169
+ },
1149
1170
  /**
1150
1171
  * Show help.
1151
1172
  */
@@ -1158,6 +1179,7 @@ Commands:
1158
1179
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
1159
1180
  .showCard(cardId) Show provenance trail for a specific card
1160
1181
  .explainReviews() Analyze why reviews were/weren't selected
1182
+ .diagnoseCardSpace() Scan full card space through filters (async)
1161
1183
  .showRegistry() Show navigator registry (classes + roles)
1162
1184
  .showStrategies() Show registry + strategy mapping from last run
1163
1185
  .listRuns() List all captured runs in table format
@@ -1169,7 +1191,7 @@ Commands:
1169
1191
  Example:
1170
1192
  window.skuilder.pipeline.showLastRun()
1171
1193
  window.skuilder.pipeline.showRun(1)
1172
- window.skuilder.pipeline.showCard('abc123')
1194
+ await window.skuilder.pipeline.diagnoseCardSpace()
1173
1195
  `);
1174
1196
  }
1175
1197
  };
@@ -1464,6 +1486,69 @@ var init_generators = __esm({
1464
1486
  }
1465
1487
  });
1466
1488
 
1489
+ // src/core/navigators/generators/prescribed.ts
1490
+ var prescribed_exports = {};
1491
+ __export(prescribed_exports, {
1492
+ default: () => PrescribedCardsGenerator
1493
+ });
1494
+ var PrescribedCardsGenerator;
1495
+ var init_prescribed = __esm({
1496
+ "src/core/navigators/generators/prescribed.ts"() {
1497
+ "use strict";
1498
+ init_navigators();
1499
+ init_logger();
1500
+ PrescribedCardsGenerator = class extends ContentNavigator {
1501
+ name;
1502
+ config;
1503
+ constructor(user, course, strategyData) {
1504
+ super(user, course, strategyData);
1505
+ this.name = strategyData.name || "Prescribed Cards";
1506
+ try {
1507
+ const parsed = JSON.parse(strategyData.serializedData);
1508
+ this.config = { cardIds: parsed.cardIds || [] };
1509
+ } catch {
1510
+ this.config = { cardIds: [] };
1511
+ }
1512
+ logger.debug(
1513
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1514
+ );
1515
+ }
1516
+ async getWeightedCards(limit, _context) {
1517
+ if (this.config.cardIds.length === 0) {
1518
+ return [];
1519
+ }
1520
+ const courseId = this.course.getCourseID();
1521
+ const activeCards = await this.user.getActiveCards();
1522
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1523
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1524
+ if (eligibleIds.length === 0) {
1525
+ logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1526
+ return [];
1527
+ }
1528
+ const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1529
+ cardId,
1530
+ courseId,
1531
+ score: 1,
1532
+ provenance: [
1533
+ {
1534
+ strategy: "prescribed",
1535
+ strategyName: this.strategyName || this.name,
1536
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1537
+ action: "generated",
1538
+ score: 1,
1539
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1540
+ }
1541
+ ]
1542
+ }));
1543
+ logger.info(
1544
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1545
+ );
1546
+ return cards;
1547
+ }
1548
+ };
1549
+ }
1550
+ });
1551
+
1467
1552
  // src/core/navigators/generators/srs.ts
1468
1553
  var srs_exports = {};
1469
1554
  __export(srs_exports, {
@@ -1658,6 +1743,7 @@ var init_ = __esm({
1658
1743
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1659
1744
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1660
1745
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1746
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1661
1747
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1662
1748
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1663
1749
  });
@@ -1858,6 +1944,8 @@ var init_hierarchyDefinition = __esm({
1858
1944
  if (userTagElo.count < minCount) return false;
1859
1945
  if (prereq.masteryThreshold?.minElo !== void 0) {
1860
1946
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1947
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1948
+ return true;
1861
1949
  } else {
1862
1950
  return userTagElo.score >= userGlobalElo;
1863
1951
  }
@@ -1933,14 +2021,38 @@ var init_hierarchyDefinition = __esm({
1933
2021
  };
1934
2022
  }
1935
2023
  }
2024
+ /**
2025
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
2026
+ *
2027
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
2028
+ * tags get boosted — steering the pipeline toward content that helps unlock
2029
+ * the gated material. Once the gate opens, the boost disappears.
2030
+ */
2031
+ getPreReqBoosts(unlockedTags, masteredTags) {
2032
+ const boosts = /* @__PURE__ */ new Map();
2033
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
2034
+ if (unlockedTags.has(tagId)) continue;
2035
+ for (const prereq of prereqs) {
2036
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
2037
+ if (masteredTags.has(prereq.tag)) continue;
2038
+ const existing = boosts.get(prereq.tag) ?? 1;
2039
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
2040
+ }
2041
+ }
2042
+ return boosts;
2043
+ }
1936
2044
  /**
1937
2045
  * CardFilter.transform implementation.
1938
2046
  *
1939
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
2047
+ * Two effects:
2048
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
2049
+ * 2. Cards carrying prereq tags of closed gates receive a configured
2050
+ * boost (preReqBoost), steering toward content that unlocks gates
1940
2051
  */
1941
2052
  async transform(cards, context) {
1942
2053
  const masteredTags = await this.getMasteredTags(context);
1943
2054
  const unlockedTags = this.getUnlockedTags(masteredTags);
2055
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1944
2056
  const gated = [];
1945
2057
  for (const card of cards) {
1946
2058
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1949,9 +2061,27 @@ var init_hierarchyDefinition = __esm({
1949
2061
  unlockedTags,
1950
2062
  masteredTags
1951
2063
  );
1952
- const LOCKED_PENALTY = 0.01;
1953
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1954
- const action = isUnlocked ? "passed" : "penalized";
2064
+ const LOCKED_PENALTY = 0.02;
2065
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
2066
+ let action = isUnlocked ? "passed" : "penalized";
2067
+ let finalReason = reason;
2068
+ if (isUnlocked && preReqBoosts.size > 0) {
2069
+ const cardTags = card.tags ?? [];
2070
+ let maxBoost = 1;
2071
+ const boostedPrereqs = [];
2072
+ for (const tag of cardTags) {
2073
+ const boost = preReqBoosts.get(tag);
2074
+ if (boost && boost > maxBoost) {
2075
+ maxBoost = boost;
2076
+ boostedPrereqs.push(tag);
2077
+ }
2078
+ }
2079
+ if (maxBoost > 1) {
2080
+ finalScore *= maxBoost;
2081
+ action = "boosted";
2082
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
2083
+ }
2084
+ }
1955
2085
  gated.push({
1956
2086
  ...card,
1957
2087
  score: finalScore,
@@ -1963,7 +2093,7 @@ var init_hierarchyDefinition = __esm({
1963
2093
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1964
2094
  action,
1965
2095
  score: finalScore,
1966
- reason
2096
+ reason: finalReason
1967
2097
  }
1968
2098
  ]
1969
2099
  });
@@ -2898,6 +3028,18 @@ __export(Pipeline_exports, {
2898
3028
  Pipeline: () => Pipeline
2899
3029
  });
2900
3030
  import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
3031
+ function globToRegex(pattern) {
3032
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
3033
+ const withWildcards = escaped.replace(/\*/g, ".*");
3034
+ return new RegExp(`^${withWildcards}$`);
3035
+ }
3036
+ function globMatch(value, pattern) {
3037
+ if (!pattern.includes("*")) return value === pattern;
3038
+ return globToRegex(pattern).test(value);
3039
+ }
3040
+ function cardMatchesTagPattern(card, pattern) {
3041
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
3042
+ }
2901
3043
  function logPipelineConfig(generator, filters) {
2902
3044
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2903
3045
  logger.info(
@@ -2932,6 +3074,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2932
3074
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2933
3075
  );
2934
3076
  }
3077
+ function logResultCards(cards) {
3078
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
3079
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
3080
+ for (let i = 0; i < cards.length; i++) {
3081
+ const c = cards[i];
3082
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
3083
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
3084
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
3085
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
3086
+ }).join(" | ");
3087
+ logger.info(
3088
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
3089
+ );
3090
+ }
3091
+ }
2935
3092
  function logCardProvenance(cards, maxCards = 3) {
2936
3093
  const cardsToLog = cards.slice(0, maxCards);
2937
3094
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2946,7 +3103,7 @@ function logCardProvenance(cards, maxCards = 3) {
2946
3103
  }
2947
3104
  }
2948
3105
  }
2949
- var Pipeline;
3106
+ var VERBOSE_RESULTS, Pipeline;
2950
3107
  var init_Pipeline = __esm({
2951
3108
  "src/core/navigators/Pipeline.ts"() {
2952
3109
  "use strict";
@@ -2954,9 +3111,31 @@ var init_Pipeline = __esm({
2954
3111
  init_logger();
2955
3112
  init_orchestration();
2956
3113
  init_PipelineDebugger();
3114
+ VERBOSE_RESULTS = true;
2957
3115
  Pipeline = class extends ContentNavigator {
2958
3116
  generator;
2959
3117
  filters;
3118
+ /**
3119
+ * Cached orchestration context. Course config and salt don't change within
3120
+ * a page load, so we build the orchestration context once and reuse it on
3121
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
3122
+ *
3123
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
3124
+ */
3125
+ _cachedOrchestration = null;
3126
+ /**
3127
+ * Persistent tag cache. Maps cardId → tag names.
3128
+ *
3129
+ * Tags are static within a session (they're set at card generation time),
3130
+ * so we cache them across pipeline runs. On replans, many of the same cards
3131
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
3132
+ */
3133
+ _tagCache = /* @__PURE__ */ new Map();
3134
+ /**
3135
+ * One-shot replan hints. Applied after the filter chain on the next
3136
+ * getWeightedCards() call, then cleared.
3137
+ */
3138
+ _ephemeralHints = null;
2960
3139
  /**
2961
3140
  * Create a new pipeline.
2962
3141
  *
@@ -2977,6 +3156,17 @@ var init_Pipeline = __esm({
2977
3156
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2978
3157
  });
2979
3158
  logPipelineConfig(generator, filters);
3159
+ registerPipelineForDebug(this);
3160
+ }
3161
+ /**
3162
+ * Set one-shot hints for the next pipeline run.
3163
+ * Consumed after one getWeightedCards() call, then cleared.
3164
+ *
3165
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
3166
+ */
3167
+ setEphemeralHints(hints) {
3168
+ this._ephemeralHints = hints;
3169
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
2980
3170
  }
2981
3171
  /**
2982
3172
  * Get weighted cards by running generator and applying filters.
@@ -2993,13 +3183,15 @@ var init_Pipeline = __esm({
2993
3183
  * @returns Cards sorted by score descending
2994
3184
  */
2995
3185
  async getWeightedCards(limit) {
3186
+ const t0 = performance.now();
2996
3187
  const context = await this.buildContext();
2997
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2998
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
3188
+ const tContext = performance.now();
3189
+ const fetchLimit = 500;
2999
3190
  logger.debug(
3000
3191
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
3001
3192
  );
3002
3193
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
3194
+ const tGenerate = performance.now();
3003
3195
  const generatedCount = cards.length;
3004
3196
  let generatorSummaries;
3005
3197
  if (this.generator.generators) {
@@ -3028,6 +3220,7 @@ var init_Pipeline = __esm({
3028
3220
  }
3029
3221
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
3030
3222
  cards = await this.hydrateTags(cards);
3223
+ const tHydrate = performance.now();
3031
3224
  const allCardsBeforeFiltering = [...cards];
3032
3225
  const filterImpacts = [];
3033
3226
  for (const filter of this.filters) {
@@ -3046,8 +3239,17 @@ var init_Pipeline = __esm({
3046
3239
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
3047
3240
  }
3048
3241
  cards = cards.filter((c) => c.score > 0);
3242
+ const hints = this._ephemeralHints;
3243
+ if (hints) {
3244
+ this._ephemeralHints = null;
3245
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
3246
+ }
3049
3247
  cards.sort((a, b) => b.score - a.score);
3248
+ const tFilter = performance.now();
3050
3249
  const result = cards.slice(0, limit);
3250
+ logger.info(
3251
+ `[Pipeline:timing] total=${(tFilter - t0).toFixed(0)}ms (context=${(tContext - t0).toFixed(0)} generate=${(tGenerate - tContext).toFixed(0)} hydrate=${(tHydrate - tGenerate).toFixed(0)} filter=${(tFilter - tHydrate).toFixed(0)})`
3252
+ );
3051
3253
  const topScores = result.slice(0, 3).map((c) => c.score);
3052
3254
  logExecutionSummary(
3053
3255
  this.generator.name,
@@ -3057,6 +3259,7 @@ var init_Pipeline = __esm({
3057
3259
  topScores,
3058
3260
  filterImpacts
3059
3261
  );
3262
+ logResultCards(result);
3060
3263
  logCardProvenance(result, 3);
3061
3264
  try {
3062
3265
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -3083,6 +3286,10 @@ var init_Pipeline = __esm({
3083
3286
  * to the WeightedCard objects. Filters can then use card.tags instead of
3084
3287
  * making individual getAppliedTags() calls.
3085
3288
  *
3289
+ * Uses a persistent tag cache across pipeline runs — tags are static within
3290
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
3291
+ * require a second DB query.
3292
+ *
3086
3293
  * @param cards - Cards to hydrate
3087
3294
  * @returns Cards with tags populated
3088
3295
  */
@@ -3090,14 +3297,128 @@ var init_Pipeline = __esm({
3090
3297
  if (cards.length === 0) {
3091
3298
  return cards;
3092
3299
  }
3093
- const cardIds = cards.map((c) => c.cardId);
3094
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
3300
+ const uncachedIds = [];
3301
+ for (const card of cards) {
3302
+ if (!this._tagCache.has(card.cardId)) {
3303
+ uncachedIds.push(card.cardId);
3304
+ }
3305
+ }
3306
+ if (uncachedIds.length > 0) {
3307
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
3308
+ for (const [cardId, tags] of freshTags) {
3309
+ this._tagCache.set(cardId, tags);
3310
+ }
3311
+ }
3312
+ const tagsByCard = /* @__PURE__ */ new Map();
3313
+ for (const card of cards) {
3314
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
3315
+ }
3095
3316
  logTagHydration(cards, tagsByCard);
3096
3317
  return cards.map((card) => ({
3097
3318
  ...card,
3098
- tags: tagsByCard.get(card.cardId) ?? []
3319
+ tags: this._tagCache.get(card.cardId) ?? []
3099
3320
  }));
3100
3321
  }
3322
+ // ---------------------------------------------------------------------------
3323
+ // Ephemeral hints application
3324
+ // ---------------------------------------------------------------------------
3325
+ /**
3326
+ * Apply one-shot replan hints to the post-filter card set.
3327
+ *
3328
+ * Order of operations:
3329
+ * 1. Exclude (remove unwanted cards)
3330
+ * 2. Boost (multiply scores)
3331
+ * 3. Require (inject must-have cards from the full pre-filter pool)
3332
+ *
3333
+ * @param cards - Post-filter cards (score > 0)
3334
+ * @param hints - The ephemeral hints to apply
3335
+ * @param allCards - Full pre-filter card pool (for require injection)
3336
+ */
3337
+ applyHints(cards, hints, allCards) {
3338
+ const beforeCount = cards.length;
3339
+ if (hints.excludeCards?.length) {
3340
+ cards = cards.filter(
3341
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
3342
+ );
3343
+ }
3344
+ if (hints.excludeTags?.length) {
3345
+ cards = cards.filter(
3346
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
3347
+ );
3348
+ }
3349
+ if (hints.boostTags) {
3350
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
3351
+ for (const card of cards) {
3352
+ if (cardMatchesTagPattern(card, pattern)) {
3353
+ card.score *= factor;
3354
+ card.provenance.push({
3355
+ strategy: "ephemeralHint",
3356
+ strategyId: "ephemeral-hint",
3357
+ strategyName: "Replan Hint",
3358
+ action: "boosted",
3359
+ score: card.score,
3360
+ reason: `boostTag ${pattern} \xD7${factor}`
3361
+ });
3362
+ }
3363
+ }
3364
+ }
3365
+ }
3366
+ if (hints.boostCards) {
3367
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
3368
+ for (const card of cards) {
3369
+ if (globMatch(card.cardId, pattern)) {
3370
+ card.score *= factor;
3371
+ card.provenance.push({
3372
+ strategy: "ephemeralHint",
3373
+ strategyId: "ephemeral-hint",
3374
+ strategyName: "Replan Hint",
3375
+ action: "boosted",
3376
+ score: card.score,
3377
+ reason: `boostCard ${pattern} \xD7${factor}`
3378
+ });
3379
+ }
3380
+ }
3381
+ }
3382
+ }
3383
+ const cardIds = new Set(cards.map((c) => c.cardId));
3384
+ const inject = (card, reason) => {
3385
+ if (!cardIds.has(card.cardId)) {
3386
+ const floorScore = Math.max(card.score, 1);
3387
+ cards.push({
3388
+ ...card,
3389
+ score: floorScore,
3390
+ provenance: [
3391
+ ...card.provenance,
3392
+ {
3393
+ strategy: "ephemeralHint",
3394
+ strategyId: "ephemeral-hint",
3395
+ strategyName: "Replan Hint",
3396
+ action: "boosted",
3397
+ score: floorScore,
3398
+ reason
3399
+ }
3400
+ ]
3401
+ });
3402
+ cardIds.add(card.cardId);
3403
+ }
3404
+ };
3405
+ if (hints.requireCards?.length) {
3406
+ for (const pattern of hints.requireCards) {
3407
+ for (const card of allCards) {
3408
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
3409
+ }
3410
+ }
3411
+ }
3412
+ if (hints.requireTags?.length) {
3413
+ for (const pattern of hints.requireTags) {
3414
+ for (const card of allCards) {
3415
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
3416
+ }
3417
+ }
3418
+ }
3419
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
3420
+ return cards;
3421
+ }
3101
3422
  /**
3102
3423
  * Build shared context for generator and filters.
3103
3424
  *
@@ -3115,7 +3436,10 @@ var init_Pipeline = __esm({
3115
3436
  } catch (e) {
3116
3437
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
3117
3438
  }
3118
- const orchestration = await createOrchestrationContext(this.user, this.course);
3439
+ if (!this._cachedOrchestration) {
3440
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
3441
+ }
3442
+ const orchestration = this._cachedOrchestration;
3119
3443
  return {
3120
3444
  user: this.user,
3121
3445
  course: this.course,
@@ -3159,6 +3483,87 @@ var init_Pipeline = __esm({
3159
3483
  }
3160
3484
  return [...new Set(ids)];
3161
3485
  }
3486
+ // ---------------------------------------------------------------------------
3487
+ // Card-space diagnostic
3488
+ // ---------------------------------------------------------------------------
3489
+ /**
3490
+ * Scan every card in the course through the filter chain and report
3491
+ * how many are "well indicated" (score >= threshold) for the current user.
3492
+ *
3493
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
3494
+ *
3495
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
3496
+ */
3497
+ async diagnoseCardSpace(opts) {
3498
+ const THRESHOLD = opts?.threshold ?? 0.1;
3499
+ const t0 = performance.now();
3500
+ const allCardIds = await this.course.getAllCardIds();
3501
+ let cards = allCardIds.map((cardId) => ({
3502
+ cardId,
3503
+ courseId: this.course.getCourseID(),
3504
+ score: 1,
3505
+ provenance: []
3506
+ }));
3507
+ cards = await this.hydrateTags(cards);
3508
+ const context = await this.buildContext();
3509
+ const filterBreakdown = [];
3510
+ for (const filter of this.filters) {
3511
+ cards = await filter.transform(cards, context);
3512
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
3513
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
3514
+ }
3515
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
3516
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
3517
+ let encounteredIds;
3518
+ try {
3519
+ const courseId = this.course.getCourseID();
3520
+ const seenCards = await this.user.getSeenCards(courseId);
3521
+ encounteredIds = new Set(seenCards);
3522
+ } catch {
3523
+ encounteredIds = /* @__PURE__ */ new Set();
3524
+ }
3525
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
3526
+ const byType = /* @__PURE__ */ new Map();
3527
+ for (const card of cards) {
3528
+ const type = card.cardId.split("-")[1] || "unknown";
3529
+ if (!byType.has(type)) {
3530
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
3531
+ }
3532
+ const entry = byType.get(type);
3533
+ entry.total++;
3534
+ if (card.score >= THRESHOLD) {
3535
+ entry.wellIndicated++;
3536
+ if (!encounteredIds.has(card.cardId)) entry.new++;
3537
+ }
3538
+ }
3539
+ const elapsed = performance.now() - t0;
3540
+ const result = {
3541
+ totalCards: allCardIds.length,
3542
+ threshold: THRESHOLD,
3543
+ wellIndicated: wellIndicatedIds.size,
3544
+ encountered: encounteredIds.size,
3545
+ wellIndicatedNew: wellIndicatedNew.length,
3546
+ byType: Object.fromEntries(byType),
3547
+ filterBreakdown,
3548
+ elapsedMs: Math.round(elapsed)
3549
+ };
3550
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
3551
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
3552
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
3553
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
3554
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
3555
+ logger.info(`[Pipeline:diagnose] By type:`);
3556
+ for (const [type, counts] of byType) {
3557
+ logger.info(
3558
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
3559
+ );
3560
+ }
3561
+ logger.info(`[Pipeline:diagnose] After each filter:`);
3562
+ for (const fb of filterBreakdown) {
3563
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
3564
+ }
3565
+ return result;
3566
+ }
3162
3567
  };
3163
3568
  }
3164
3569
  });
@@ -3263,23 +3668,25 @@ var init_PipelineAssembler = __esm({
3263
3668
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
3264
3669
  }
3265
3670
  }
3671
+ const courseId = course.getCourseID();
3672
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3673
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3674
+ if (!hasElo) {
3675
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3676
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3677
+ }
3678
+ if (!hasSrs) {
3679
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3680
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3681
+ }
3266
3682
  if (generatorStrategies.length === 0) {
3267
- if (filterStrategies.length > 0) {
3268
- logger.debug(
3269
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
3270
- );
3271
- const courseId = course.getCourseID();
3272
- generatorStrategies.push(createDefaultEloStrategy(courseId));
3273
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
3274
- } else {
3275
- warnings.push("No generator strategy found");
3276
- return {
3277
- pipeline: null,
3278
- generatorStrategies: [],
3279
- filterStrategies: [],
3280
- warnings
3281
- };
3282
- }
3683
+ warnings.push("No generator strategy found");
3684
+ return {
3685
+ pipeline: null,
3686
+ generatorStrategies: [],
3687
+ filterStrategies: [],
3688
+ warnings
3689
+ };
3283
3690
  }
3284
3691
  let generator;
3285
3692
  if (generatorStrategies.length === 1) {
@@ -3357,6 +3764,7 @@ var init_3 = __esm({
3357
3764
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
3358
3765
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
3359
3766
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3767
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
3360
3768
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
3361
3769
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
3362
3770
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -3405,8 +3813,10 @@ async function initializeNavigatorRegistry() {
3405
3813
  Promise.resolve().then(() => (init_elo(), elo_exports)),
3406
3814
  Promise.resolve().then(() => (init_srs(), srs_exports))
3407
3815
  ]);
3816
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
3408
3817
  registerNavigator("elo", eloModule.default);
3409
3818
  registerNavigator("srs", srsModule.default);
3819
+ registerNavigator("prescribed", prescribedModule.default);
3410
3820
  const [
3411
3821
  hierarchyModule,
3412
3822
  interferenceModule,
@@ -3461,6 +3871,7 @@ var init_navigators = __esm({
3461
3871
  Navigators = /* @__PURE__ */ ((Navigators2) => {
3462
3872
  Navigators2["ELO"] = "elo";
3463
3873
  Navigators2["SRS"] = "srs";
3874
+ Navigators2["PRESCRIBED"] = "prescribed";
3464
3875
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
3465
3876
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
3466
3877
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -3475,6 +3886,7 @@ var init_navigators = __esm({
3475
3886
  NavigatorRoles = {
3476
3887
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
3477
3888
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3889
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
3478
3890
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
3479
3891
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
3480
3892
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3639,6 +4051,12 @@ var init_navigators = __esm({
3639
4051
  async getWeightedCards(_limit) {
3640
4052
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3641
4053
  }
4054
+ /**
4055
+ * Set ephemeral hints for the next pipeline run.
4056
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
4057
+ */
4058
+ setEphemeralHints(_hints) {
4059
+ }
3642
4060
  };
3643
4061
  }
3644
4062
  });
@@ -3788,15 +4206,42 @@ var init_courseDB = __esm({
3788
4206
  // private log(msg: string): void {
3789
4207
  // log(`CourseLog: ${this.id}\n ${msg}`);
3790
4208
  // }
4209
+ /**
4210
+ * Primary database handle used for all **read** operations (queries, gets).
4211
+ *
4212
+ * When local sync is active, this points to the local PouchDB replica for
4213
+ * fast, network-free reads. Otherwise it points to the remote CouchDB.
4214
+ */
3791
4215
  db;
4216
+ /**
4217
+ * Remote database handle used for all **write** operations.
4218
+ *
4219
+ * Always points to the remote CouchDB so that writes (ELO updates, tag
4220
+ * mutations, admin operations) aggregate on the server. The local replica
4221
+ * is a read-only snapshot that refreshes on the next page load.
4222
+ *
4223
+ * When local sync is NOT active, this is the same instance as `this.db`.
4224
+ */
4225
+ remoteDB;
3792
4226
  id;
3793
4227
  _getCurrentUser;
3794
4228
  updateQueue;
3795
- constructor(id, userLookup) {
4229
+ /**
4230
+ * @param id - Course ID
4231
+ * @param userLookup - Async function returning the current user DB
4232
+ * @param localDB - Optional local PouchDB replica for reads. When provided,
4233
+ * `this.db` uses the local replica and `this.remoteDB` stays remote.
4234
+ * The UpdateQueue reads from remote and writes to remote (local `_rev`
4235
+ * values may be stale, so read-modify-write cycles must go through
4236
+ * the remote DB to avoid conflicts).
4237
+ */
4238
+ constructor(id, userLookup, localDB) {
3796
4239
  this.id = id;
3797
- this.db = getCourseDB2(this.id);
4240
+ const remote = getCourseDB2(this.id);
4241
+ this.remoteDB = remote;
4242
+ this.db = localDB ?? remote;
3798
4243
  this._getCurrentUser = userLookup;
3799
- this.updateQueue = new UpdateQueue(this.db);
4244
+ this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
3800
4245
  }
3801
4246
  getCourseID() {
3802
4247
  return this.id;
@@ -3884,7 +4329,7 @@ var init_courseDB = __esm({
3884
4329
  };
3885
4330
  }
3886
4331
  async removeCard(id) {
3887
- const doc = await this.db.get(id);
4332
+ const doc = await this.remoteDB.get(id);
3888
4333
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
3889
4334
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
3890
4335
  }
@@ -3905,7 +4350,7 @@ var init_courseDB = __esm({
3905
4350
  } catch (error) {
3906
4351
  logger.error(`Error removing card ${id} from tags: ${error}`);
3907
4352
  }
3908
- return this.db.remove(doc);
4353
+ return this.remoteDB.remove(doc);
3909
4354
  }
3910
4355
  async getCardDisplayableDataIDs(id) {
3911
4356
  logger.debug(id.join(", "));
@@ -4007,8 +4452,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4007
4452
  if (cardIds.length === 0) {
4008
4453
  return /* @__PURE__ */ new Map();
4009
4454
  }
4010
- const db = getCourseDB2(this.id);
4011
- const result = await db.query("getTags", {
4455
+ const result = await this.db.query("getTags", {
4012
4456
  keys: cardIds,
4013
4457
  include_docs: false
4014
4458
  });
@@ -4025,6 +4469,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4025
4469
  }
4026
4470
  return tagsByCard;
4027
4471
  }
4472
+ async getAllCardIds() {
4473
+ const result = await this.db.allDocs({
4474
+ startkey: "CARD-",
4475
+ endkey: "CARD-\uFFF0",
4476
+ include_docs: false
4477
+ });
4478
+ return result.rows.map((row) => row.id);
4479
+ }
4028
4480
  async addTagToCard(cardId, tagId, updateELO) {
4029
4481
  return await addTagToCard(
4030
4482
  this.id,
@@ -4091,10 +4543,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4091
4543
  }
4092
4544
  }
4093
4545
  async getCourseDoc(id, options) {
4094
- return await getCourseDoc(this.id, id, options);
4546
+ return await this.db.get(id, options);
4095
4547
  }
4096
4548
  async getCourseDocs(ids, options = {}) {
4097
- return await getCourseDocs(this.id, ids, options);
4549
+ return await this.db.allDocs({
4550
+ ...options,
4551
+ keys: ids
4552
+ });
4098
4553
  }
4099
4554
  ////////////////////////////////////
4100
4555
  // NavigationStrategyManager implementation
@@ -4128,7 +4583,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4128
4583
  }
4129
4584
  async addNavigationStrategy(data) {
4130
4585
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
4131
- return this.db.put(data).then(() => {
4586
+ return this.remoteDB.put(data).then(() => {
4132
4587
  });
4133
4588
  }
4134
4589
  updateNavigationStrategy(id, data) {
@@ -4685,6 +5140,234 @@ var init_adminDB2 = __esm({
4685
5140
  }
4686
5141
  });
4687
5142
 
5143
+ // src/impl/couch/CourseSyncService.ts
5144
+ var CourseSyncService;
5145
+ var init_CourseSyncService = __esm({
5146
+ "src/impl/couch/CourseSyncService.ts"() {
5147
+ "use strict";
5148
+ init_pouchdb_setup();
5149
+ init_couch();
5150
+ init_logger();
5151
+ CourseSyncService = class _CourseSyncService {
5152
+ static instance = null;
5153
+ entries = /* @__PURE__ */ new Map();
5154
+ constructor() {
5155
+ }
5156
+ static getInstance() {
5157
+ if (!_CourseSyncService.instance) {
5158
+ _CourseSyncService.instance = new _CourseSyncService();
5159
+ }
5160
+ return _CourseSyncService.instance;
5161
+ }
5162
+ /**
5163
+ * Reset the singleton (for testing).
5164
+ */
5165
+ static resetInstance() {
5166
+ if (_CourseSyncService.instance) {
5167
+ for (const [, entry] of _CourseSyncService.instance.entries) {
5168
+ if (entry.localDB) {
5169
+ entry.localDB.close().catch(() => {
5170
+ });
5171
+ }
5172
+ }
5173
+ _CourseSyncService.instance.entries.clear();
5174
+ }
5175
+ _CourseSyncService.instance = null;
5176
+ }
5177
+ // --------------------------------------------------------------------------
5178
+ // Public API
5179
+ // --------------------------------------------------------------------------
5180
+ /**
5181
+ * Ensure a course's local replica is synced.
5182
+ *
5183
+ * On first call for a course:
5184
+ * 1. Fetches CourseConfig from remote to check localSync.enabled
5185
+ * 2. If enabled, performs one-shot replication remote → local
5186
+ * 3. Pre-warms PouchDB view indices (elo, getTags)
5187
+ *
5188
+ * On subsequent calls: returns immediately if already synced, or awaits
5189
+ * the in-flight sync if one is in progress.
5190
+ *
5191
+ * Safe to call multiple times — concurrent calls coalesce to one sync.
5192
+ *
5193
+ * @param courseId - The course to sync
5194
+ * @param forceEnabled - Skip the CourseConfig check and sync regardless.
5195
+ * Useful when the caller already knows local sync is desired (e.g.,
5196
+ * LettersPractice hardcodes this).
5197
+ */
5198
+ async ensureSynced(courseId, forceEnabled) {
5199
+ const existing = this.entries.get(courseId);
5200
+ if (existing?.status.state === "ready") {
5201
+ return;
5202
+ }
5203
+ if (existing?.status.state === "disabled") {
5204
+ return;
5205
+ }
5206
+ if (existing?.readyPromise) {
5207
+ return existing.readyPromise;
5208
+ }
5209
+ const entry = {
5210
+ localDB: null,
5211
+ status: { state: "not-started" },
5212
+ readyPromise: null
5213
+ };
5214
+ this.entries.set(courseId, entry);
5215
+ entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
5216
+ return entry.readyPromise;
5217
+ }
5218
+ /**
5219
+ * Get the local PouchDB for a course, or null if not available.
5220
+ *
5221
+ * Returns null when:
5222
+ * - Local sync is not enabled for this course
5223
+ * - Sync has not been triggered yet
5224
+ * - Sync is still in progress
5225
+ * - Sync failed
5226
+ */
5227
+ getLocalDB(courseId) {
5228
+ const entry = this.entries.get(courseId);
5229
+ if (entry?.status.state === "ready" && entry.localDB) {
5230
+ return entry.localDB;
5231
+ }
5232
+ return null;
5233
+ }
5234
+ /**
5235
+ * Check whether a course has a ready local replica.
5236
+ */
5237
+ isReady(courseId) {
5238
+ return this.entries.get(courseId)?.status.state === "ready";
5239
+ }
5240
+ /**
5241
+ * Get detailed sync status for a course.
5242
+ */
5243
+ getStatus(courseId) {
5244
+ return this.entries.get(courseId)?.status ?? { state: "not-started" };
5245
+ }
5246
+ // --------------------------------------------------------------------------
5247
+ // Internal
5248
+ // --------------------------------------------------------------------------
5249
+ async performSync(courseId, entry, forceEnabled) {
5250
+ try {
5251
+ if (!forceEnabled) {
5252
+ entry.status = { state: "checking-config" };
5253
+ const enabled = await this.checkLocalSyncEnabled(courseId);
5254
+ if (!enabled) {
5255
+ entry.status = { state: "disabled" };
5256
+ entry.readyPromise = null;
5257
+ logger.debug(
5258
+ `[CourseSyncService] Local sync disabled for course ${courseId}`
5259
+ );
5260
+ return;
5261
+ }
5262
+ }
5263
+ entry.status = { state: "syncing" };
5264
+ const localDBName = this.localDBName(courseId);
5265
+ const localDB = new pouchdb_setup_default(localDBName);
5266
+ entry.localDB = localDB;
5267
+ const remoteDB = this.getRemoteDB(courseId);
5268
+ const syncStart = Date.now();
5269
+ logger.info(
5270
+ `[CourseSyncService] Starting one-shot replication for course ${courseId}`
5271
+ );
5272
+ const result = await this.replicate(remoteDB, localDB);
5273
+ const syncTimeMs = Date.now() - syncStart;
5274
+ logger.info(
5275
+ `[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
5276
+ );
5277
+ entry.status = { state: "warming-views" };
5278
+ const warmStart = Date.now();
5279
+ await this.warmViewIndices(localDB);
5280
+ const viewWarmTimeMs = Date.now() - warmStart;
5281
+ logger.info(
5282
+ `[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
5283
+ );
5284
+ entry.status = {
5285
+ state: "ready",
5286
+ docsReplicated: result.docs_written,
5287
+ syncTimeMs,
5288
+ viewWarmTimeMs
5289
+ };
5290
+ } catch (e) {
5291
+ const errorMsg = e instanceof Error ? e.message : String(e);
5292
+ logger.error(
5293
+ `[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
5294
+ );
5295
+ entry.status = { state: "error", error: errorMsg };
5296
+ entry.readyPromise = null;
5297
+ if (entry.localDB) {
5298
+ try {
5299
+ await entry.localDB.destroy();
5300
+ } catch {
5301
+ }
5302
+ entry.localDB = null;
5303
+ }
5304
+ }
5305
+ }
5306
+ /**
5307
+ * Check CourseConfig.localSync.enabled on the remote DB.
5308
+ */
5309
+ async checkLocalSyncEnabled(courseId) {
5310
+ try {
5311
+ const remoteDB = this.getRemoteDB(courseId);
5312
+ const config = await remoteDB.get("CourseConfig");
5313
+ return config.localSync?.enabled === true;
5314
+ } catch (e) {
5315
+ logger.warn(
5316
+ `[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
5317
+ );
5318
+ return false;
5319
+ }
5320
+ }
5321
+ /**
5322
+ * One-shot replication from remote to local.
5323
+ */
5324
+ replicate(source, target) {
5325
+ return new Promise((resolve, reject) => {
5326
+ void pouchdb_setup_default.replicate(source, target, {
5327
+ // One-shot, not live. Local is a read-only snapshot.
5328
+ }).on("complete", (info) => {
5329
+ resolve(info);
5330
+ }).on("error", (err) => {
5331
+ reject(err);
5332
+ });
5333
+ });
5334
+ }
5335
+ /**
5336
+ * Pre-warm PouchDB view indices by running a minimal query against each
5337
+ * design doc. This forces PouchDB to build the MapReduce index now
5338
+ * (during a loading phase) rather than on first pipeline query.
5339
+ */
5340
+ async warmViewIndices(localDB) {
5341
+ const viewsToWarm = ["elo", "getTags"];
5342
+ for (const viewName of viewsToWarm) {
5343
+ try {
5344
+ await localDB.query(viewName, { limit: 1 });
5345
+ logger.debug(
5346
+ `[CourseSyncService] Warmed view index: ${viewName}`
5347
+ );
5348
+ } catch (e) {
5349
+ logger.debug(
5350
+ `[CourseSyncService] Could not warm view ${viewName}: ${e}`
5351
+ );
5352
+ }
5353
+ }
5354
+ }
5355
+ /**
5356
+ * Get a remote PouchDB handle for a course.
5357
+ */
5358
+ getRemoteDB(courseId) {
5359
+ return getCourseDB2(courseId);
5360
+ }
5361
+ /**
5362
+ * Local DB naming convention.
5363
+ */
5364
+ localDBName(courseId) {
5365
+ return `coursedb-local-${courseId}`;
5366
+ }
5367
+ };
5368
+ }
5369
+ });
5370
+
4688
5371
  // src/impl/couch/auth.ts
4689
5372
  import fetch2 from "cross-fetch";
4690
5373
  async function getCurrentSession() {
@@ -4985,15 +5668,6 @@ function getCourseDB2(courseID) {
4985
5668
  createPouchDBConfig()
4986
5669
  );
4987
5670
  }
4988
- function getCourseDocs(courseID, docIDs, options = {}) {
4989
- return getCourseDB2(courseID).allDocs({
4990
- ...options,
4991
- keys: docIDs
4992
- });
4993
- }
4994
- function getCourseDoc(courseID, docID, options = {}) {
4995
- return getCourseDB2(courseID).get(docID, options);
4996
- }
4997
5671
  function filterAllDocsByPrefix2(db, prefix, opts) {
4998
5672
  const options = {
4999
5673
  startkey: prefix,
@@ -5024,6 +5698,7 @@ var init_couch = __esm({
5024
5698
  init_classroomDB2();
5025
5699
  init_courseAPI();
5026
5700
  init_courseDB();
5701
+ init_CourseSyncService();
5027
5702
  init_CouchDBSyncStrategy();
5028
5703
  isBrowser = typeof window !== "undefined";
5029
5704
  if (isBrowser) {
@@ -5324,6 +5999,9 @@ Currently logged-in as ${this._username}.`
5324
5999
  const id = row.id;
5325
6000
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
5326
6001
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
6002
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
6003
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
6004
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
5327
6005
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
5328
6006
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
5329
6007
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -6120,6 +6798,7 @@ var init_PouchDataLayerProvider = __esm({
6120
6798
  init_adminDB2();
6121
6799
  init_classroomDB2();
6122
6800
  init_courseDB();
6801
+ init_CourseSyncService();
6123
6802
  init_common();
6124
6803
  init_CouchDBSyncStrategy();
6125
6804
  CouchDataLayerProvider = class {
@@ -6159,7 +6838,22 @@ var init_PouchDataLayerProvider = __esm({
6159
6838
  return this.userDB;
6160
6839
  }
6161
6840
  getCourseDB(courseId) {
6162
- return new CourseDB(courseId, async () => this.getUserDB());
6841
+ const localDB = CourseSyncService.getInstance().getLocalDB(courseId);
6842
+ return new CourseDB(courseId, async () => this.getUserDB(), localDB ?? void 0);
6843
+ }
6844
+ /**
6845
+ * Trigger local sync for a course. Call during app initialization or
6846
+ * pre-session loading for courses that opt in via CourseConfig.localSync.
6847
+ *
6848
+ * Safe to call multiple times — concurrent calls coalesce. Returns when
6849
+ * sync is complete (or immediately if already synced / disabled).
6850
+ *
6851
+ * @param courseId - The course to sync locally
6852
+ * @param forceEnabled - Skip CourseConfig check and sync regardless.
6853
+ * Use when the caller already knows local sync is desired.
6854
+ */
6855
+ async ensureCourseSynced(courseId, forceEnabled) {
6856
+ return CourseSyncService.getInstance().ensureSynced(courseId, forceEnabled);
6163
6857
  }
6164
6858
  getCoursesDB() {
6165
6859
  return new CoursesDB(this._courseIDs);
@@ -6787,6 +7481,10 @@ var init_courseDB2 = __esm({
6787
7481
  }
6788
7482
  return tagsByCard;
6789
7483
  }
7484
+ async getAllCardIds() {
7485
+ const tagsIndex = await this.unpacker.getTagsIndex();
7486
+ return Object.keys(tagsIndex.byCard);
7487
+ }
6790
7488
  async addTagToCard(_cardId, _tagId) {
6791
7489
  throw new Error("Cannot modify tags in static mode");
6792
7490
  }
@@ -8337,8 +9035,10 @@ function newQuestionInterval(user, cardHistory) {
8337
9035
  const lastInterval = lastSuccessfulInterval(records);
8338
9036
  if (lastInterval > cardHistory.bestInterval) {
8339
9037
  cardHistory.bestInterval = lastInterval;
8340
- void user.update(cardHistory._id, {
9038
+ user.update(cardHistory._id, {
8341
9039
  bestInterval: lastInterval
9040
+ }).catch((e) => {
9041
+ logger.warn(`[SpacedRepetition] Failed to update bestInterval for ${cardHistory._id}: ${e?.message ?? e}`);
8342
9042
  });
8343
9043
  }
8344
9044
  if (currentAttempt.isCorrect) {
@@ -9004,6 +9704,46 @@ var ItemQueue = class {
9004
9704
  return null;
9005
9705
  }
9006
9706
  }
9707
+ /**
9708
+ * Atomically replace all queue contents with new items.
9709
+ *
9710
+ * Used by mid-session replanning to swap the queue without a window where
9711
+ * it's empty (avoiding dead-air if nextCard() is called concurrently).
9712
+ *
9713
+ * Preserves dequeueCount (cumulative across the session).
9714
+ * Resets seenCardIds to match the new contents — cards from the old queue
9715
+ * that don't appear in the new set can be re-added in future replans.
9716
+ */
9717
+ replaceAll(items, cardIdExtractor) {
9718
+ this.q = [];
9719
+ this.seenCardIds = [];
9720
+ for (const item of items) {
9721
+ const cardId = cardIdExtractor(item);
9722
+ if (!this.seenCardIds.includes(cardId)) {
9723
+ this.seenCardIds.push(cardId);
9724
+ this.q.push(item);
9725
+ }
9726
+ }
9727
+ }
9728
+ /**
9729
+ * Merge new items into the front of the queue, skipping duplicates.
9730
+ * Used by additive replans to inject high-quality candidates without
9731
+ * discarding the existing queue contents.
9732
+ */
9733
+ mergeToFront(items, cardIdExtractor) {
9734
+ let added = 0;
9735
+ const toInsert = [];
9736
+ for (const item of items) {
9737
+ const cardId = cardIdExtractor(item);
9738
+ if (!this.seenCardIds.includes(cardId)) {
9739
+ this.seenCardIds.push(cardId);
9740
+ toInsert.push(item);
9741
+ added++;
9742
+ }
9743
+ }
9744
+ this.q.unshift(...toInsert);
9745
+ return added;
9746
+ }
9007
9747
  get toString() {
9008
9748
  return `${typeof this.q[0]}:
9009
9749
  ` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
@@ -11087,7 +11827,7 @@ mountSessionDebugger();
11087
11827
 
11088
11828
  // src/study/SessionController.ts
11089
11829
  init_logger();
11090
- var SessionController = class extends Loggable {
11830
+ var SessionController = class _SessionController extends Loggable {
11091
11831
  _className = "SessionController";
11092
11832
  services;
11093
11833
  srsService;
@@ -11108,6 +11848,18 @@ var SessionController = class extends Loggable {
11108
11848
  newQ = new ItemQueue();
11109
11849
  failedQ = new ItemQueue();
11110
11850
  // END Session card stores
11851
+ /**
11852
+ * Promise tracking a currently in-progress replan, or null if idle.
11853
+ * Used by nextCard() to await completion before drawing from queues.
11854
+ */
11855
+ _replanPromise = null;
11856
+ /**
11857
+ * Number of well-indicated new cards remaining before the queue
11858
+ * degrades to poorly-indicated content. Decremented on each newQ
11859
+ * draw; when it hits 0, a replan is triggered automatically
11860
+ * (user state has changed from completing good cards).
11861
+ */
11862
+ _wellIndicatedRemaining = 0;
11111
11863
  startTime;
11112
11864
  endTime;
11113
11865
  _secondsRemaining;
@@ -11201,13 +11953,83 @@ var SessionController = class extends Loggable {
11201
11953
  "[SessionController] All content sources must implement getWeightedCards()."
11202
11954
  );
11203
11955
  }
11204
- await this.getWeightedContent();
11956
+ const wellIndicated = await this.getWeightedContent();
11957
+ this._wellIndicatedRemaining = wellIndicated;
11958
+ if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
11959
+ this.log(
11960
+ `[Init] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards in initial load`
11961
+ );
11962
+ }
11205
11963
  await this.hydrationService.ensureHydratedCards();
11206
11964
  startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
11207
11965
  this._intervalHandle = setInterval(() => {
11208
11966
  this.tick();
11209
11967
  }, 1e3);
11210
11968
  }
11969
+ /**
11970
+ * Request a mid-session replan. Re-runs the pipeline with current user state
11971
+ * and atomically replaces the newQ contents. Safe to call at any time during
11972
+ * a session — if called while a replan is already in progress, returns the
11973
+ * existing replan promise (no duplicate work).
11974
+ *
11975
+ * Does NOT affect reviewQ or failedQ.
11976
+ *
11977
+ * If nextCard() is called while a replan is in flight, it will automatically
11978
+ * await the replan before drawing from queues, ensuring the user always sees
11979
+ * cards scored against their latest state.
11980
+ *
11981
+ * Typical trigger: application-level code (e.g. after a GPC intro completion)
11982
+ * calls this to ensure newly-unlocked content appears in the session.
11983
+ */
11984
+ async requestReplan(hints) {
11985
+ if (this._replanPromise) {
11986
+ this.log("Replan already in progress, awaiting existing replan");
11987
+ return this._replanPromise;
11988
+ }
11989
+ if (hints) {
11990
+ for (const source of this.sources) {
11991
+ this.log(`[Hints] source type=${source.constructor.name}, hasMethod=${typeof source.setEphemeralHints}`);
11992
+ source.setEphemeralHints?.(hints);
11993
+ }
11994
+ }
11995
+ this.log(`Mid-session replan requested${hints ? ` (hints: ${JSON.stringify(hints)})` : ""}`);
11996
+ this._replanPromise = this._executeReplan();
11997
+ try {
11998
+ await this._replanPromise;
11999
+ } finally {
12000
+ this._replanPromise = null;
12001
+ }
12002
+ }
12003
+ /** Minimum well-indicated cards before an additive retry is attempted */
12004
+ static MIN_WELL_INDICATED = 5;
12005
+ /**
12006
+ * Score threshold for considering a card "well-indicated."
12007
+ * Cards below this score are treated as fallback filler — present only
12008
+ * because no strategy hard-removed them, but likely penalized by one
12009
+ * or more filters. Strategy-agnostic: the SessionController doesn't
12010
+ * know or care which strategy assigned the score.
12011
+ */
12012
+ static WELL_INDICATED_SCORE = 0.1;
12013
+ /**
12014
+ * Internal replan execution. Runs the pipeline, builds a new newQ,
12015
+ * atomically swaps it in, and triggers hydration for the new contents.
12016
+ *
12017
+ * If the initial replan produces fewer than MIN_WELL_INDICATED cards that
12018
+ * pass all hierarchy filters, one additive retry is attempted — merging
12019
+ * any new high-quality candidates into the front of the queue.
12020
+ */
12021
+ async _executeReplan() {
12022
+ const wellIndicated = await this.getWeightedContent({ replan: true });
12023
+ this._wellIndicatedRemaining = wellIndicated;
12024
+ if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
12025
+ this.log(
12026
+ `[Replan] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards after replan`
12027
+ );
12028
+ }
12029
+ await this.hydrationService.ensureHydratedCards();
12030
+ this.log(`Replan complete: newQ now has ${this.newQ.length} cards`);
12031
+ snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
12032
+ }
11211
12033
  addTime(seconds) {
11212
12034
  this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
11213
12035
  }
@@ -11263,6 +12085,9 @@ var SessionController = class extends Loggable {
11263
12085
  hydratedCache: {
11264
12086
  count: this.hydrationService.hydratedCount,
11265
12087
  cardIds: this.hydrationService.getHydratedCardIds()
12088
+ },
12089
+ replan: {
12090
+ inProgress: this._replanPromise !== null
11266
12091
  }
11267
12092
  };
11268
12093
  }
@@ -11275,7 +12100,20 @@ var SessionController = class extends Loggable {
11275
12100
  * 3. Uses SourceMixer to balance content across sources
11276
12101
  * 4. Populates review and new card queues with mixed results
11277
12102
  */
11278
- async getWeightedContent() {
12103
+ /**
12104
+ * Fetch weighted content from all sources and populate session queues.
12105
+ *
12106
+ * @param options.replan - If true, this is a mid-session replan rather than
12107
+ * initial session setup. Skips review queue population (avoiding duplicates),
12108
+ * atomically replaces newQ contents, and treats empty results as non-fatal.
12109
+ * @param options.additive - If true (replan only), merge new high-quality
12110
+ * candidates into the front of the existing newQ instead of replacing it.
12111
+ * @returns Number of "well-indicated" cards (passed all hierarchy filters)
12112
+ * in the new content. Returns -1 if no content was loaded.
12113
+ */
12114
+ async getWeightedContent(options) {
12115
+ const replan = options?.replan ?? false;
12116
+ const additive = options?.additive ?? false;
11279
12117
  const limit = 20;
11280
12118
  const batches = [];
11281
12119
  for (let i = 0; i < this.sources.length; i++) {
@@ -11294,6 +12132,10 @@ var SessionController = class extends Loggable {
11294
12132
  }
11295
12133
  }
11296
12134
  if (batches.length === 0) {
12135
+ if (replan) {
12136
+ this.log("Replan: no content from any source, keeping existing newQ");
12137
+ return -1;
12138
+ }
11297
12139
  throw new Error(
11298
12140
  `Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
11299
12141
  );
@@ -11305,10 +12147,12 @@ var SessionController = class extends Loggable {
11305
12147
  });
11306
12148
  await Promise.all(
11307
12149
  sourceIds.map(async (id) => {
11308
- try {
11309
- const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
11310
- this.courseNameCache.set(id, config.name);
11311
- } catch {
12150
+ if (!this.courseNameCache.has(id)) {
12151
+ try {
12152
+ const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
12153
+ this.courseNameCache.set(id, config.name);
12154
+ } catch {
12155
+ }
11312
12156
  }
11313
12157
  })
11314
12158
  );
@@ -11326,20 +12170,26 @@ var SessionController = class extends Loggable {
11326
12170
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
11327
12171
  const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
11328
12172
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
11329
- let report = "Mixed content session created with:\n";
11330
- for (const w of reviewWeighted) {
11331
- const reviewItem = {
11332
- cardID: w.cardId,
11333
- courseID: w.courseId,
11334
- contentSourceType: "course",
11335
- contentSourceID: w.courseId,
11336
- reviewID: w.reviewID,
11337
- status: "review"
11338
- };
11339
- this.reviewQ.add(reviewItem, reviewItem.cardID);
11340
- report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
12173
+ let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
12174
+ if (!replan) {
12175
+ for (const w of reviewWeighted) {
12176
+ const reviewItem = {
12177
+ cardID: w.cardId,
12178
+ courseID: w.courseId,
12179
+ contentSourceType: "course",
12180
+ contentSourceID: w.courseId,
12181
+ reviewID: w.reviewID,
12182
+ status: "review"
12183
+ };
12184
+ this.reviewQ.add(reviewItem, reviewItem.cardID);
12185
+ report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
11341
12186
  `;
12187
+ }
11342
12188
  }
12189
+ const wellIndicated = newWeighted.filter(
12190
+ (w) => w.score >= _SessionController.WELL_INDICATED_SCORE
12191
+ ).length;
12192
+ const newItems = [];
11343
12193
  for (const w of newWeighted) {
11344
12194
  const newItem = {
11345
12195
  cardID: w.cardId,
@@ -11348,11 +12198,23 @@ var SessionController = class extends Loggable {
11348
12198
  contentSourceID: w.courseId,
11349
12199
  status: "new"
11350
12200
  };
11351
- this.newQ.add(newItem, newItem.cardID);
12201
+ newItems.push(newItem);
11352
12202
  report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
11353
12203
  `;
11354
12204
  }
12205
+ if (additive) {
12206
+ const added = this.newQ.mergeToFront(newItems, (item) => item.cardID);
12207
+ report += `Additive merge: ${added} new cards added to front of newQ
12208
+ `;
12209
+ } else if (replan) {
12210
+ this.newQ.replaceAll(newItems, (item) => item.cardID);
12211
+ } else {
12212
+ for (const item of newItems) {
12213
+ this.newQ.add(item, item.cardID);
12214
+ }
12215
+ }
11355
12216
  this.log(report);
12217
+ return wellIndicated;
11356
12218
  }
11357
12219
  /**
11358
12220
  * Returns items that should be pre-hydrated.
@@ -11429,6 +12291,17 @@ var SessionController = class extends Loggable {
11429
12291
  }
11430
12292
  async nextCard(action = "dismiss-success") {
11431
12293
  this.dismissCurrentCard(action);
12294
+ if (this._replanPromise) {
12295
+ this.log("nextCard: awaiting in-flight replan before drawing");
12296
+ await this._replanPromise;
12297
+ }
12298
+ const REPLAN_BUFFER = 3;
12299
+ if (this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
12300
+ this.log(
12301
+ `[AutoReplan] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
12302
+ );
12303
+ void this.requestReplan();
12304
+ }
11432
12305
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
11433
12306
  this._currentCard = null;
11434
12307
  endSessionTracking();
@@ -11542,6 +12415,9 @@ var SessionController = class extends Loggable {
11542
12415
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
11543
12416
  } else if (this.newQ.peek(0)?.cardID === item.cardID) {
11544
12417
  this.newQ.dequeue((queueItem) => queueItem.cardID);
12418
+ if (this._wellIndicatedRemaining > 0) {
12419
+ this._wellIndicatedRemaining--;
12420
+ }
11545
12421
  } else if (this.failedQ.peek(0)?.cardID === item.cardID) {
11546
12422
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
11547
12423
  }