@lssm/module.learning-journey 1.41.1 → 1.42.1

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 (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/dist/contracts/index.d.ts +5 -0
  4. package/dist/contracts/index.js +6 -1
  5. package/dist/contracts/models.d.ts +451 -0
  6. package/dist/contracts/models.d.ts.map +1 -0
  7. package/dist/contracts/models.js +388 -1
  8. package/dist/contracts/models.js.map +1 -0
  9. package/dist/contracts/onboarding.d.ts +666 -0
  10. package/dist/contracts/onboarding.d.ts.map +1 -0
  11. package/dist/contracts/onboarding.js +402 -1
  12. package/dist/contracts/onboarding.js.map +1 -0
  13. package/dist/contracts/operations.d.ts +280 -0
  14. package/dist/contracts/operations.d.ts.map +1 -0
  15. package/dist/contracts/operations.js +151 -1
  16. package/dist/contracts/operations.js.map +1 -0
  17. package/dist/contracts/shared.d.ts +5 -0
  18. package/dist/contracts/shared.d.ts.map +1 -0
  19. package/dist/contracts/shared.js +6 -1
  20. package/dist/contracts/shared.js.map +1 -0
  21. package/dist/docs/index.d.ts +1 -0
  22. package/dist/docs/index.js +1 -1
  23. package/dist/docs/learning-journey.docblock.d.ts +1 -0
  24. package/dist/docs/learning-journey.docblock.js +61 -5
  25. package/dist/docs/learning-journey.docblock.js.map +1 -0
  26. package/dist/engines/index.d.ts +4 -0
  27. package/dist/engines/index.js +5 -1
  28. package/dist/engines/srs.d.ts +111 -0
  29. package/dist/engines/srs.d.ts.map +1 -0
  30. package/dist/engines/srs.js +219 -1
  31. package/dist/engines/srs.js.map +1 -0
  32. package/dist/engines/streak.d.ts +100 -0
  33. package/dist/engines/streak.d.ts.map +1 -0
  34. package/dist/engines/streak.js +194 -1
  35. package/dist/engines/streak.js.map +1 -0
  36. package/dist/engines/xp.d.ts +97 -0
  37. package/dist/engines/xp.d.ts.map +1 -0
  38. package/dist/engines/xp.js +213 -1
  39. package/dist/engines/xp.js.map +1 -0
  40. package/dist/entities/ai.d.ts +232 -0
  41. package/dist/entities/ai.d.ts.map +1 -0
  42. package/dist/entities/ai.js +376 -1
  43. package/dist/entities/ai.js.map +1 -0
  44. package/dist/entities/course.d.ts +184 -0
  45. package/dist/entities/course.d.ts.map +1 -0
  46. package/dist/entities/course.js +316 -1
  47. package/dist/entities/course.js.map +1 -0
  48. package/dist/entities/flashcard.d.ts +170 -0
  49. package/dist/entities/flashcard.d.ts.map +1 -0
  50. package/dist/entities/flashcard.js +249 -1
  51. package/dist/entities/flashcard.js.map +1 -0
  52. package/dist/entities/gamification.d.ts +238 -0
  53. package/dist/entities/gamification.d.ts.map +1 -0
  54. package/dist/entities/gamification.js +392 -1
  55. package/dist/entities/gamification.js.map +1 -0
  56. package/dist/entities/index.d.ts +629 -0
  57. package/dist/entities/index.d.ts.map +1 -0
  58. package/dist/entities/index.js +45 -1
  59. package/dist/entities/index.js.map +1 -0
  60. package/dist/entities/learner.d.ts +224 -0
  61. package/dist/entities/learner.d.ts.map +1 -0
  62. package/dist/entities/learner.js +365 -1
  63. package/dist/entities/learner.js.map +1 -0
  64. package/dist/entities/onboarding.d.ts +190 -0
  65. package/dist/entities/onboarding.d.ts.map +1 -0
  66. package/dist/entities/onboarding.js +307 -1
  67. package/dist/entities/onboarding.js.map +1 -0
  68. package/dist/entities/quiz.d.ts +220 -0
  69. package/dist/entities/quiz.d.ts.map +1 -0
  70. package/dist/entities/quiz.js +370 -1
  71. package/dist/entities/quiz.js.map +1 -0
  72. package/dist/events.d.ts +796 -0
  73. package/dist/events.d.ts.map +1 -0
  74. package/dist/events.js +690 -1
  75. package/dist/events.js.map +1 -0
  76. package/dist/index.d.ts +20 -0
  77. package/dist/index.js +22 -1
  78. package/dist/learning-journey.feature.d.ts +12 -0
  79. package/dist/learning-journey.feature.d.ts.map +1 -0
  80. package/dist/learning-journey.feature.js +150 -1
  81. package/dist/learning-journey.feature.js.map +1 -0
  82. package/dist/track-spec.d.ts +129 -0
  83. package/dist/track-spec.d.ts.map +1 -0
  84. package/package.json +37 -30
@@ -0,0 +1,111 @@
1
+ //#region src/engines/srs.d.ts
2
+ /**
3
+ * Spaced Repetition System (SRS) Engine
4
+ *
5
+ * Implements the SM-2 algorithm variant for optimal flashcard scheduling.
6
+ *
7
+ * The algorithm calculates the optimal time to review a card based on:
8
+ * - User's rating of recall difficulty (again, hard, good, easy)
9
+ * - Current interval between reviews
10
+ * - Ease factor (how easy the card is for this user)
11
+ * - Number of successful repetitions
12
+ */
13
+ type CardRating = 'AGAIN' | 'HARD' | 'GOOD' | 'EASY';
14
+ interface SRSState {
15
+ /** Current interval in days */
16
+ interval: number;
17
+ /** Ease factor (typically 1.3 to 2.5+) */
18
+ easeFactor: number;
19
+ /** Number of successful repetitions */
20
+ repetitions: number;
21
+ /** Current learning step (for new cards) */
22
+ learningStep: number;
23
+ /** Whether card has graduated to review phase */
24
+ isGraduated: boolean;
25
+ /** Whether card is being relearned after a lapse */
26
+ isRelearning: boolean;
27
+ /** Number of times card was forgotten */
28
+ lapses: number;
29
+ }
30
+ interface ReviewResult {
31
+ /** New interval in days */
32
+ interval: number;
33
+ /** New ease factor */
34
+ easeFactor: number;
35
+ /** New repetition count */
36
+ repetitions: number;
37
+ /** Next review date */
38
+ nextReviewAt: Date;
39
+ /** New learning step */
40
+ learningStep: number;
41
+ /** Whether card has graduated */
42
+ isGraduated: boolean;
43
+ /** Whether card is being relearned */
44
+ isRelearning: boolean;
45
+ /** Updated lapse count */
46
+ lapses: number;
47
+ }
48
+ interface SRSConfig {
49
+ /** Learning steps in minutes [1, 10] = 1 min, 10 min */
50
+ learningSteps: number[];
51
+ /** Graduating interval in days */
52
+ graduatingInterval: number;
53
+ /** Easy interval (for easy button on new cards) */
54
+ easyInterval: number;
55
+ /** Relearning steps in minutes */
56
+ relearningSteps: number[];
57
+ /** Minimum ease factor */
58
+ minEaseFactor: number;
59
+ /** Maximum interval in days */
60
+ maxInterval: number;
61
+ /** Interval modifier (1.0 = 100%) */
62
+ intervalModifier: number;
63
+ /** New cards interval modifier */
64
+ newIntervalModifier: number;
65
+ /** Hard interval modifier */
66
+ hardIntervalModifier: number;
67
+ /** Easy bonus modifier */
68
+ easyBonus: number;
69
+ }
70
+ declare const DEFAULT_SRS_CONFIG: SRSConfig;
71
+ declare class SRSEngine {
72
+ private config;
73
+ constructor(config?: Partial<SRSConfig>);
74
+ /**
75
+ * Calculate the next review state based on rating.
76
+ */
77
+ calculateNextReview(state: SRSState, rating: CardRating, now?: Date): ReviewResult;
78
+ /**
79
+ * Get initial SRS state for a new card.
80
+ */
81
+ getInitialState(): SRSState;
82
+ /**
83
+ * Check if a card is due for review.
84
+ */
85
+ isDue(nextReviewAt: Date, now?: Date): boolean;
86
+ /**
87
+ * Calculate overdue days (negative if not yet due).
88
+ */
89
+ getOverdueDays(nextReviewAt: Date, now?: Date): number;
90
+ /**
91
+ * Handle learning phase (new cards).
92
+ */
93
+ private handleLearningCard;
94
+ /**
95
+ * Handle relearning phase (lapsed cards).
96
+ */
97
+ private handleRelearningCard;
98
+ /**
99
+ * Handle review phase (graduated cards).
100
+ */
101
+ private handleReviewCard;
102
+ private addMinutes;
103
+ private addDays;
104
+ }
105
+ /**
106
+ * Default SRS engine instance.
107
+ */
108
+ declare const srsEngine: SRSEngine;
109
+ //#endregion
110
+ export { CardRating, DEFAULT_SRS_CONFIG, ReviewResult, SRSConfig, SRSEngine, SRSState, srsEngine };
111
+ //# sourceMappingURL=srs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"srs.d.ts","names":[],"sources":["../../src/engines/srs.ts"],"sourcesContent":[],"mappings":";;AAcA;AAEA;AAiBA;AAmBA;AAyBA;AAeA;;;;;AAaS,KA3FG,UAAA,GA2FH,OAAA,GAAA,MAAA,GAAA,MAAA,GAAA,MAAA;AACJ,UA1FY,QAAA,CA0FZ;EAkBgB;EAeC,QAAA,EAAA,MAAA;EAAW;EAOF,UAAA,EAAA,MAAA;EAAW;EAAiB,WAAA,EAAA,MAAA;EA4O9C;;;;;;;;;UA7VI,YAAA;;;;;;;;gBAQD;;;;;;;;;;UAWC,SAAA;;;;;;;;;;;;;;;;;;;;;;cAyBJ,oBAAoB;cAepB,SAAA;;uBAGS,QAAQ;;;;6BAQnB,kBACC,kBACH,OACJ;;;;qBAkBgB;;;;sBAeC,YAAW;;;;+BAOF,YAAW;;;;;;;;;;;;;;;;;;;cA4O7B,WAAS"}
@@ -1 +1,219 @@
1
- const e={learningSteps:[1,10],graduatingInterval:1,easyInterval:4,relearningSteps:[10],minEaseFactor:1.3,maxInterval:365,intervalModifier:1,newIntervalModifier:.5,hardIntervalModifier:1.2,easyBonus:1.3};var t=class{config;constructor(t={}){this.config={...e,...t}}calculateNextReview(e,t,n=new Date){return!e.isGraduated&&!e.isRelearning?this.handleLearningCard(e,t,n):e.isRelearning?this.handleRelearningCard(e,t,n):this.handleReviewCard(e,t,n)}handleLearningCard(e,t,n){let r=this.config.learningSteps,i=e.learningStep,a=!1,o=0,s;switch(t){case`AGAIN`:i=0,o=r[0]??1,s=this.addMinutes(n,o);break;case`HARD`:o=r[i]??r[0]??1,s=this.addMinutes(n,o);break;case`GOOD`:i++,i>=r.length?(a=!0,o=this.config.graduatingInterval,s=this.addDays(n,o)):(o=r[i]??10,s=this.addMinutes(n,o));break;case`EASY`:a=!0,o=this.config.easyInterval,s=this.addDays(n,o);break}return{interval:a?o:0,easeFactor:e.easeFactor,repetitions:a?1:0,nextReviewAt:s,learningStep:i,isGraduated:a,isRelearning:!1,lapses:e.lapses}}handleRelearningCard(e,t,n){let r=this.config.relearningSteps,i=e.learningStep,a=!0,o=0,s;switch(t){case`AGAIN`:i=0,o=r[0]??10,s=this.addMinutes(n,o);break;case`HARD`:o=r[i]??r[0]??10,s=this.addMinutes(n,o);break;case`GOOD`:i++,i>=r.length?(a=!1,o=Math.max(1,Math.floor(e.interval*this.config.newIntervalModifier)),s=this.addDays(n,o)):(o=r[i]??10,s=this.addMinutes(n,o));break;case`EASY`:a=!1,o=Math.max(1,Math.floor(e.interval*this.config.newIntervalModifier*1.5)),s=this.addDays(n,o);break}return{interval:a?e.interval:o,easeFactor:e.easeFactor,repetitions:a?e.repetitions:e.repetitions+1,nextReviewAt:s,learningStep:i,isGraduated:!0,isRelearning:a,lapses:e.lapses}}handleReviewCard(e,t,n){let r,i=e.easeFactor,a=e.repetitions,o=!1,s=0,c=e.lapses;switch(t){case`AGAIN`:c++,o=!0,s=0,i=Math.max(this.config.minEaseFactor,i-.2),r=e.interval;let t=this.config.relearningSteps[0]??10;return{interval:r,easeFactor:i,repetitions:a,nextReviewAt:this.addMinutes(n,t),learningStep:s,isGraduated:!0,isRelearning:!0,lapses:c};case`HARD`:i=Math.max(this.config.minEaseFactor,i-.15),r=Math.max(e.interval+1,e.interval*this.config.hardIntervalModifier);break;case`GOOD`:r=e.interval*i*this.config.intervalModifier,a++;break;case`EASY`:i+=.15,r=e.interval*i*this.config.easyBonus*this.config.intervalModifier,a++;break}return r=Math.min(Math.round(r),this.config.maxInterval),r=Math.max(1,r),{interval:r,easeFactor:i,repetitions:a,nextReviewAt:this.addDays(n,r),learningStep:s,isGraduated:!0,isRelearning:o,lapses:c}}getInitialState(){return{interval:0,easeFactor:2.5,repetitions:0,learningStep:0,isGraduated:!1,isRelearning:!1,lapses:0}}isDue(e,t=new Date){return e<=t}getOverdueDays(e,t=new Date){let n=t.getTime()-e.getTime();return Math.floor(n/(1e3*60*60*24))}addMinutes(e,t){return new Date(e.getTime()+t*60*1e3)}addDays(e,t){return new Date(e.getTime()+t*24*60*60*1e3)}};const n=new t;export{e as DEFAULT_SRS_CONFIG,t as SRSEngine,n as srsEngine};
1
+ //#region src/engines/srs.ts
2
+ const DEFAULT_SRS_CONFIG = {
3
+ learningSteps: [1, 10],
4
+ graduatingInterval: 1,
5
+ easyInterval: 4,
6
+ relearningSteps: [10],
7
+ minEaseFactor: 1.3,
8
+ maxInterval: 365,
9
+ intervalModifier: 1,
10
+ newIntervalModifier: .5,
11
+ hardIntervalModifier: 1.2,
12
+ easyBonus: 1.3
13
+ };
14
+ var SRSEngine = class {
15
+ config;
16
+ constructor(config = {}) {
17
+ this.config = {
18
+ ...DEFAULT_SRS_CONFIG,
19
+ ...config
20
+ };
21
+ }
22
+ /**
23
+ * Calculate the next review state based on rating.
24
+ */
25
+ calculateNextReview(state, rating, now = /* @__PURE__ */ new Date()) {
26
+ if (!state.isGraduated && !state.isRelearning) return this.handleLearningCard(state, rating, now);
27
+ if (state.isRelearning) return this.handleRelearningCard(state, rating, now);
28
+ return this.handleReviewCard(state, rating, now);
29
+ }
30
+ /**
31
+ * Get initial SRS state for a new card.
32
+ */
33
+ getInitialState() {
34
+ return {
35
+ interval: 0,
36
+ easeFactor: 2.5,
37
+ repetitions: 0,
38
+ learningStep: 0,
39
+ isGraduated: false,
40
+ isRelearning: false,
41
+ lapses: 0
42
+ };
43
+ }
44
+ /**
45
+ * Check if a card is due for review.
46
+ */
47
+ isDue(nextReviewAt, now = /* @__PURE__ */ new Date()) {
48
+ return nextReviewAt <= now;
49
+ }
50
+ /**
51
+ * Calculate overdue days (negative if not yet due).
52
+ */
53
+ getOverdueDays(nextReviewAt, now = /* @__PURE__ */ new Date()) {
54
+ const diff = now.getTime() - nextReviewAt.getTime();
55
+ return Math.floor(diff / (1e3 * 60 * 60 * 24));
56
+ }
57
+ /**
58
+ * Handle learning phase (new cards).
59
+ */
60
+ handleLearningCard(state, rating, now) {
61
+ const steps = this.config.learningSteps;
62
+ let newStep = state.learningStep;
63
+ let isGraduated = false;
64
+ let interval = 0;
65
+ let nextReviewAt;
66
+ switch (rating) {
67
+ case "AGAIN":
68
+ newStep = 0;
69
+ interval = steps[0] ?? 1;
70
+ nextReviewAt = this.addMinutes(now, interval);
71
+ break;
72
+ case "HARD":
73
+ interval = steps[newStep] ?? steps[0] ?? 1;
74
+ nextReviewAt = this.addMinutes(now, interval);
75
+ break;
76
+ case "GOOD":
77
+ newStep++;
78
+ if (newStep >= steps.length) {
79
+ isGraduated = true;
80
+ interval = this.config.graduatingInterval;
81
+ nextReviewAt = this.addDays(now, interval);
82
+ } else {
83
+ interval = steps[newStep] ?? 10;
84
+ nextReviewAt = this.addMinutes(now, interval);
85
+ }
86
+ break;
87
+ case "EASY":
88
+ isGraduated = true;
89
+ interval = this.config.easyInterval;
90
+ nextReviewAt = this.addDays(now, interval);
91
+ break;
92
+ }
93
+ return {
94
+ interval: isGraduated ? interval : 0,
95
+ easeFactor: state.easeFactor,
96
+ repetitions: isGraduated ? 1 : 0,
97
+ nextReviewAt,
98
+ learningStep: newStep,
99
+ isGraduated,
100
+ isRelearning: false,
101
+ lapses: state.lapses
102
+ };
103
+ }
104
+ /**
105
+ * Handle relearning phase (lapsed cards).
106
+ */
107
+ handleRelearningCard(state, rating, now) {
108
+ const steps = this.config.relearningSteps;
109
+ let newStep = state.learningStep;
110
+ let isRelearning = true;
111
+ let interval = 0;
112
+ let nextReviewAt;
113
+ switch (rating) {
114
+ case "AGAIN":
115
+ newStep = 0;
116
+ interval = steps[0] ?? 10;
117
+ nextReviewAt = this.addMinutes(now, interval);
118
+ break;
119
+ case "HARD":
120
+ interval = steps[newStep] ?? steps[0] ?? 10;
121
+ nextReviewAt = this.addMinutes(now, interval);
122
+ break;
123
+ case "GOOD":
124
+ newStep++;
125
+ if (newStep >= steps.length) {
126
+ isRelearning = false;
127
+ interval = Math.max(1, Math.floor(state.interval * this.config.newIntervalModifier));
128
+ nextReviewAt = this.addDays(now, interval);
129
+ } else {
130
+ interval = steps[newStep] ?? 10;
131
+ nextReviewAt = this.addMinutes(now, interval);
132
+ }
133
+ break;
134
+ case "EASY":
135
+ isRelearning = false;
136
+ interval = Math.max(1, Math.floor(state.interval * this.config.newIntervalModifier * 1.5));
137
+ nextReviewAt = this.addDays(now, interval);
138
+ break;
139
+ }
140
+ return {
141
+ interval: isRelearning ? state.interval : interval,
142
+ easeFactor: state.easeFactor,
143
+ repetitions: isRelearning ? state.repetitions : state.repetitions + 1,
144
+ nextReviewAt,
145
+ learningStep: newStep,
146
+ isGraduated: true,
147
+ isRelearning,
148
+ lapses: state.lapses
149
+ };
150
+ }
151
+ /**
152
+ * Handle review phase (graduated cards).
153
+ */
154
+ handleReviewCard(state, rating, now) {
155
+ let newInterval;
156
+ let newEaseFactor = state.easeFactor;
157
+ let repetitions = state.repetitions;
158
+ let isRelearning = false;
159
+ let learningStep = 0;
160
+ let lapses = state.lapses;
161
+ switch (rating) {
162
+ case "AGAIN":
163
+ lapses++;
164
+ isRelearning = true;
165
+ learningStep = 0;
166
+ newEaseFactor = Math.max(this.config.minEaseFactor, newEaseFactor - .2);
167
+ newInterval = state.interval;
168
+ return {
169
+ interval: newInterval,
170
+ easeFactor: newEaseFactor,
171
+ repetitions,
172
+ nextReviewAt: this.addMinutes(now, this.config.relearningSteps[0] ?? 10),
173
+ learningStep,
174
+ isGraduated: true,
175
+ isRelearning: true,
176
+ lapses
177
+ };
178
+ case "HARD":
179
+ newEaseFactor = Math.max(this.config.minEaseFactor, newEaseFactor - .15);
180
+ newInterval = Math.max(state.interval + 1, state.interval * this.config.hardIntervalModifier);
181
+ break;
182
+ case "GOOD":
183
+ newInterval = state.interval * newEaseFactor * this.config.intervalModifier;
184
+ repetitions++;
185
+ break;
186
+ case "EASY":
187
+ newEaseFactor = newEaseFactor + .15;
188
+ newInterval = state.interval * newEaseFactor * this.config.easyBonus * this.config.intervalModifier;
189
+ repetitions++;
190
+ break;
191
+ }
192
+ newInterval = Math.min(Math.round(newInterval), this.config.maxInterval);
193
+ newInterval = Math.max(1, newInterval);
194
+ return {
195
+ interval: newInterval,
196
+ easeFactor: newEaseFactor,
197
+ repetitions,
198
+ nextReviewAt: this.addDays(now, newInterval),
199
+ learningStep,
200
+ isGraduated: true,
201
+ isRelearning,
202
+ lapses
203
+ };
204
+ }
205
+ addMinutes(date, minutes) {
206
+ return new Date(date.getTime() + minutes * 60 * 1e3);
207
+ }
208
+ addDays(date, days) {
209
+ return new Date(date.getTime() + days * 24 * 60 * 60 * 1e3);
210
+ }
211
+ };
212
+ /**
213
+ * Default SRS engine instance.
214
+ */
215
+ const srsEngine = new SRSEngine();
216
+
217
+ //#endregion
218
+ export { DEFAULT_SRS_CONFIG, SRSEngine, srsEngine };
219
+ //# sourceMappingURL=srs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"srs.js","names":["DEFAULT_SRS_CONFIG: SRSConfig","nextReviewAt: Date","newInterval: number"],"sources":["../../src/engines/srs.ts"],"sourcesContent":["/**\n * Spaced Repetition System (SRS) Engine\n *\n * Implements the SM-2 algorithm variant for optimal flashcard scheduling.\n *\n * The algorithm calculates the optimal time to review a card based on:\n * - User's rating of recall difficulty (again, hard, good, easy)\n * - Current interval between reviews\n * - Ease factor (how easy the card is for this user)\n * - Number of successful repetitions\n */\n\n// ============ Types ============\n\nexport type CardRating = 'AGAIN' | 'HARD' | 'GOOD' | 'EASY';\n\nexport interface SRSState {\n /** Current interval in days */\n interval: number;\n /** Ease factor (typically 1.3 to 2.5+) */\n easeFactor: number;\n /** Number of successful repetitions */\n repetitions: number;\n /** Current learning step (for new cards) */\n learningStep: number;\n /** Whether card has graduated to review phase */\n isGraduated: boolean;\n /** Whether card is being relearned after a lapse */\n isRelearning: boolean;\n /** Number of times card was forgotten */\n lapses: number;\n}\n\nexport interface ReviewResult {\n /** New interval in days */\n interval: number;\n /** New ease factor */\n easeFactor: number;\n /** New repetition count */\n repetitions: number;\n /** Next review date */\n nextReviewAt: Date;\n /** New learning step */\n learningStep: number;\n /** Whether card has graduated */\n isGraduated: boolean;\n /** Whether card is being relearned */\n isRelearning: boolean;\n /** Updated lapse count */\n lapses: number;\n}\n\nexport interface SRSConfig {\n /** Learning steps in minutes [1, 10] = 1 min, 10 min */\n learningSteps: number[];\n /** Graduating interval in days */\n graduatingInterval: number;\n /** Easy interval (for easy button on new cards) */\n easyInterval: number;\n /** Relearning steps in minutes */\n relearningSteps: number[];\n /** Minimum ease factor */\n minEaseFactor: number;\n /** Maximum interval in days */\n maxInterval: number;\n /** Interval modifier (1.0 = 100%) */\n intervalModifier: number;\n /** New cards interval modifier */\n newIntervalModifier: number;\n /** Hard interval modifier */\n hardIntervalModifier: number;\n /** Easy bonus modifier */\n easyBonus: number;\n}\n\n// ============ Default Configuration ============\n\nexport const DEFAULT_SRS_CONFIG: SRSConfig = {\n learningSteps: [1, 10], // 1 minute, 10 minutes\n graduatingInterval: 1, // 1 day\n easyInterval: 4, // 4 days\n relearningSteps: [10], // 10 minutes\n minEaseFactor: 1.3,\n maxInterval: 365, // 1 year\n intervalModifier: 1.0,\n newIntervalModifier: 0.5,\n hardIntervalModifier: 1.2,\n easyBonus: 1.3,\n};\n\n// ============ SRS Engine ============\n\nexport class SRSEngine {\n private config: SRSConfig;\n\n constructor(config: Partial<SRSConfig> = {}) {\n this.config = { ...DEFAULT_SRS_CONFIG, ...config };\n }\n\n /**\n * Calculate the next review state based on rating.\n */\n calculateNextReview(\n state: SRSState,\n rating: CardRating,\n now: Date = new Date()\n ): ReviewResult {\n // Handle new/learning cards\n if (!state.isGraduated && !state.isRelearning) {\n return this.handleLearningCard(state, rating, now);\n }\n\n // Handle relearning cards\n if (state.isRelearning) {\n return this.handleRelearningCard(state, rating, now);\n }\n\n // Handle graduated cards in review\n return this.handleReviewCard(state, rating, now);\n }\n\n /**\n * Get initial SRS state for a new card.\n */\n getInitialState(): SRSState {\n return {\n interval: 0,\n easeFactor: 2.5,\n repetitions: 0,\n learningStep: 0,\n isGraduated: false,\n isRelearning: false,\n lapses: 0,\n };\n }\n\n /**\n * Check if a card is due for review.\n */\n isDue(nextReviewAt: Date, now: Date = new Date()): boolean {\n return nextReviewAt <= now;\n }\n\n /**\n * Calculate overdue days (negative if not yet due).\n */\n getOverdueDays(nextReviewAt: Date, now: Date = new Date()): number {\n const diff = now.getTime() - nextReviewAt.getTime();\n return Math.floor(diff / (1000 * 60 * 60 * 24));\n }\n\n /**\n * Handle learning phase (new cards).\n */\n private handleLearningCard(\n state: SRSState,\n rating: CardRating,\n now: Date\n ): ReviewResult {\n const steps = this.config.learningSteps;\n let newStep = state.learningStep;\n let isGraduated = false;\n let interval = 0;\n let nextReviewAt: Date;\n\n switch (rating) {\n case 'AGAIN':\n // Reset to first step\n newStep = 0;\n interval = steps[0] ?? 1;\n nextReviewAt = this.addMinutes(now, interval);\n break;\n\n case 'HARD':\n // Stay at current step (or repeat first step)\n interval = steps[newStep] ?? steps[0] ?? 1;\n nextReviewAt = this.addMinutes(now, interval);\n break;\n\n case 'GOOD':\n // Move to next step\n newStep++;\n if (newStep >= steps.length) {\n // Graduate the card\n isGraduated = true;\n interval = this.config.graduatingInterval;\n nextReviewAt = this.addDays(now, interval);\n } else {\n interval = steps[newStep] ?? 10;\n nextReviewAt = this.addMinutes(now, interval);\n }\n break;\n\n case 'EASY':\n // Graduate immediately with easy interval\n isGraduated = true;\n interval = this.config.easyInterval;\n nextReviewAt = this.addDays(now, interval);\n break;\n }\n\n return {\n interval: isGraduated ? interval : 0,\n easeFactor: state.easeFactor,\n repetitions: isGraduated ? 1 : 0,\n nextReviewAt,\n learningStep: newStep,\n isGraduated,\n isRelearning: false,\n lapses: state.lapses,\n };\n }\n\n /**\n * Handle relearning phase (lapsed cards).\n */\n private handleRelearningCard(\n state: SRSState,\n rating: CardRating,\n now: Date\n ): ReviewResult {\n const steps = this.config.relearningSteps;\n let newStep = state.learningStep;\n let isRelearning = true;\n let interval = 0;\n let nextReviewAt: Date;\n\n switch (rating) {\n case 'AGAIN':\n // Reset to first relearning step\n newStep = 0;\n interval = steps[0] ?? 10;\n nextReviewAt = this.addMinutes(now, interval);\n break;\n\n case 'HARD':\n // Stay at current step\n interval = steps[newStep] ?? steps[0] ?? 10;\n nextReviewAt = this.addMinutes(now, interval);\n break;\n\n case 'GOOD':\n // Move to next step or graduate back to review\n newStep++;\n if (newStep >= steps.length) {\n isRelearning = false;\n // Use reduced interval after lapse\n interval = Math.max(\n 1,\n Math.floor(state.interval * this.config.newIntervalModifier)\n );\n nextReviewAt = this.addDays(now, interval);\n } else {\n interval = steps[newStep] ?? 10;\n nextReviewAt = this.addMinutes(now, interval);\n }\n break;\n\n case 'EASY':\n // Graduate immediately with slightly longer interval\n isRelearning = false;\n interval = Math.max(\n 1,\n Math.floor(state.interval * this.config.newIntervalModifier * 1.5)\n );\n nextReviewAt = this.addDays(now, interval);\n break;\n }\n\n return {\n interval: isRelearning ? state.interval : interval,\n easeFactor: state.easeFactor,\n repetitions: isRelearning ? state.repetitions : state.repetitions + 1,\n nextReviewAt,\n learningStep: newStep,\n isGraduated: true,\n isRelearning,\n lapses: state.lapses,\n };\n }\n\n /**\n * Handle review phase (graduated cards).\n */\n private handleReviewCard(\n state: SRSState,\n rating: CardRating,\n now: Date\n ): ReviewResult {\n let newInterval: number;\n let newEaseFactor = state.easeFactor;\n let repetitions = state.repetitions;\n let isRelearning = false;\n let learningStep = 0;\n let lapses = state.lapses;\n\n switch (rating) {\n case 'AGAIN':\n // Card lapsed - move to relearning\n lapses++;\n isRelearning = true;\n learningStep = 0;\n newEaseFactor = Math.max(\n this.config.minEaseFactor,\n newEaseFactor - 0.2\n );\n newInterval = state.interval; // Keep old interval for reference\n return {\n interval: newInterval,\n easeFactor: newEaseFactor,\n repetitions,\n nextReviewAt: this.addMinutes(\n now,\n this.config.relearningSteps[0] ?? 10\n ),\n learningStep,\n isGraduated: true,\n isRelearning: true,\n lapses,\n };\n\n case 'HARD':\n // Reduce interval slightly, reduce ease\n newEaseFactor = Math.max(\n this.config.minEaseFactor,\n newEaseFactor - 0.15\n );\n newInterval = Math.max(\n state.interval + 1,\n state.interval * this.config.hardIntervalModifier\n );\n break;\n\n case 'GOOD':\n // Standard interval increase\n newInterval =\n state.interval * newEaseFactor * this.config.intervalModifier;\n repetitions++;\n break;\n\n case 'EASY':\n // Larger interval increase, increase ease\n newEaseFactor = newEaseFactor + 0.15;\n newInterval =\n state.interval *\n newEaseFactor *\n this.config.easyBonus *\n this.config.intervalModifier;\n repetitions++;\n break;\n }\n\n // Apply bounds\n newInterval = Math.min(Math.round(newInterval), this.config.maxInterval);\n newInterval = Math.max(1, newInterval);\n\n return {\n interval: newInterval,\n easeFactor: newEaseFactor,\n repetitions,\n nextReviewAt: this.addDays(now, newInterval),\n learningStep,\n isGraduated: true,\n isRelearning,\n lapses,\n };\n }\n\n // ============ Helpers ============\n\n private addMinutes(date: Date, minutes: number): Date {\n return new Date(date.getTime() + minutes * 60 * 1000);\n }\n\n private addDays(date: Date, days: number): Date {\n return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);\n }\n}\n\n/**\n * Default SRS engine instance.\n */\nexport const srsEngine = new SRSEngine();\n"],"mappings":"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"}
@@ -0,0 +1,100 @@
1
+ //#region src/engines/streak.d.ts
2
+ /**
3
+ * Streak Tracking Engine
4
+ *
5
+ * Manages daily learning streaks with timezone support and freeze protection.
6
+ */
7
+ interface StreakState {
8
+ /** Current streak days */
9
+ currentStreak: number;
10
+ /** Longest streak ever */
11
+ longestStreak: number;
12
+ /** Last activity timestamp */
13
+ lastActivityAt: Date | null;
14
+ /** Last activity date (YYYY-MM-DD) */
15
+ lastActivityDate: string | null;
16
+ /** Available streak freezes */
17
+ freezesRemaining: number;
18
+ /** When a freeze was last used */
19
+ freezeUsedAt: Date | null;
20
+ }
21
+ interface StreakUpdateResult {
22
+ /** Updated streak state */
23
+ state: StreakState;
24
+ /** Whether streak was maintained */
25
+ streakMaintained: boolean;
26
+ /** Whether streak was lost */
27
+ streakLost: boolean;
28
+ /** Whether a freeze was used */
29
+ freezeUsed: boolean;
30
+ /** Whether this activity started a new streak */
31
+ newStreak: boolean;
32
+ /** Days missed (if streak was lost) */
33
+ daysMissed: number;
34
+ }
35
+ interface StreakConfig {
36
+ /** Timezone for the user */
37
+ timezone: string;
38
+ /** How many streak freezes to give per month */
39
+ freezesPerMonth: number;
40
+ /** Maximum freezes that can be accumulated */
41
+ maxFreezes: number;
42
+ /** Grace period in hours after midnight */
43
+ gracePeriodHours: number;
44
+ }
45
+ declare const DEFAULT_STREAK_CONFIG: StreakConfig;
46
+ declare class StreakEngine {
47
+ private config;
48
+ constructor(config?: Partial<StreakConfig>);
49
+ /**
50
+ * Update streak based on new activity.
51
+ */
52
+ update(state: StreakState, now?: Date): StreakUpdateResult;
53
+ /**
54
+ * Check streak status without recording activity.
55
+ */
56
+ checkStatus(state: StreakState, now?: Date): {
57
+ isActive: boolean;
58
+ willExpireAt: Date | null;
59
+ canUseFreeze: boolean;
60
+ daysUntilExpiry: number;
61
+ };
62
+ /**
63
+ * Manually use a freeze to protect streak.
64
+ */
65
+ useFreeze(state: StreakState, now?: Date): StreakState | null;
66
+ /**
67
+ * Award monthly freezes.
68
+ */
69
+ awardMonthlyFreezes(state: StreakState): StreakState;
70
+ /**
71
+ * Get initial streak state.
72
+ */
73
+ getInitialState(): StreakState;
74
+ /**
75
+ * Calculate streak milestones.
76
+ */
77
+ getMilestones(currentStreak: number): {
78
+ achieved: number[];
79
+ next: number | null;
80
+ };
81
+ /**
82
+ * Get date string in YYYY-MM-DD format.
83
+ */
84
+ private getDateString;
85
+ /**
86
+ * Get number of days between two date strings.
87
+ */
88
+ private getDaysBetween;
89
+ /**
90
+ * Add days to a date.
91
+ */
92
+ private addDays;
93
+ }
94
+ /**
95
+ * Default streak engine instance.
96
+ */
97
+ declare const streakEngine: StreakEngine;
98
+ //#endregion
99
+ export { DEFAULT_STREAK_CONFIG, StreakConfig, StreakEngine, StreakState, StreakUpdateResult, streakEngine };
100
+ //# sourceMappingURL=streak.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"streak.d.ts","names":[],"sources":["../../src/engines/streak.ts"],"sourcesContent":[],"mappings":";;AAQA;AAeA;AAeA;AAaA;AASa,UApDI,WAAA,CAoDQ;EAGK;EAAR,aAAA,EAAA,MAAA;EAON;EAAkB,aAAA,EAAA,MAAA;EAAoB;EAmF3C,cAAA,EA3IO,IA2IP,GAAA,IAAA;EACF;EAGS,gBAAA,EAAA,MAAA,GAAA,IAAA;EAwDC;EAAkB,gBAAA,EAAA,MAAA;EAAoB;EAe5B,YAAA,EAhNb,IAgNa,GAAA,IAAA;;AAaR,UA1NJ,kBAAA,CA0NI;EAAW;EA0DnB,KAAA,EAlRJ,WAkRqC;;;;;;;;;;;;UArQ7B,YAAA;;;;;;;;;;cAaJ,uBAAuB;cASvB,YAAA;;uBAGS,QAAQ;;;;gBAOd,mBAAkB,OAAoB;;;;qBAmF3C,mBACF;;kBAGS;;;;;;;mBAwDC,mBAAkB,OAAoB;;;;6BAe5B,cAAc;;;;qBAatB;;;;;;;;;;;;;;;;;;;;;;;;cA0DR,cAAY"}
@@ -1 +1,194 @@
1
- const e={timezone:`UTC`,freezesPerMonth:2,maxFreezes:5,gracePeriodHours:4};var t=class{config;constructor(t={}){this.config={...e,...t}}update(e,t=new Date){let n=this.getDateString(t),r={state:{...e},streakMaintained:!1,streakLost:!1,freezeUsed:!1,newStreak:!1,daysMissed:0};if(!e.lastActivityDate)return r.state.currentStreak=1,r.state.longestStreak=Math.max(1,e.longestStreak),r.state.lastActivityAt=t,r.state.lastActivityDate=n,r.newStreak=!0,r.streakMaintained=!0,r;if(e.lastActivityDate===n)return r.state.lastActivityAt=t,r.streakMaintained=!0,r;let i=this.getDaysBetween(e.lastActivityDate,n);if(i===1)return r.state.currentStreak=e.currentStreak+1,r.state.longestStreak=Math.max(r.state.currentStreak,e.longestStreak),r.state.lastActivityAt=t,r.state.lastActivityDate=n,r.streakMaintained=!0,r;r.daysMissed=i-1;let a=r.daysMissed;return a<=e.freezesRemaining?(r.state.freezesRemaining=e.freezesRemaining-a,r.state.freezeUsedAt=t,r.state.currentStreak=e.currentStreak+1,r.state.longestStreak=Math.max(r.state.currentStreak,e.longestStreak),r.state.lastActivityAt=t,r.state.lastActivityDate=n,r.freezeUsed=!0,r.streakMaintained=!0,r):(r.streakLost=!0,r.state.currentStreak=1,r.state.lastActivityAt=t,r.state.lastActivityDate=n,r.newStreak=!0,r)}checkStatus(e,t=new Date){if(!e.lastActivityDate)return{isActive:!1,willExpireAt:null,canUseFreeze:!1,daysUntilExpiry:0};let n=this.getDateString(t),r=this.getDaysBetween(e.lastActivityDate,n);if(r===0){let n=this.addDays(t,1);return n.setHours(23,59,59,999),{isActive:!0,willExpireAt:n,canUseFreeze:e.freezesRemaining>0,daysUntilExpiry:1}}if(r===1){let n=new Date(t);return n.setHours(23+this.config.gracePeriodHours,59,59,999),{isActive:!0,willExpireAt:n,canUseFreeze:e.freezesRemaining>0,daysUntilExpiry:0}}let i=r-1;return{isActive:i<=e.freezesRemaining,willExpireAt:null,canUseFreeze:i<=e.freezesRemaining,daysUntilExpiry:-i}}useFreeze(e,t=new Date){return e.freezesRemaining<=0?null:{...e,freezesRemaining:e.freezesRemaining-1,freezeUsedAt:t}}awardMonthlyFreezes(e){return{...e,freezesRemaining:Math.min(e.freezesRemaining+this.config.freezesPerMonth,this.config.maxFreezes)}}getInitialState(){return{currentStreak:0,longestStreak:0,lastActivityAt:null,lastActivityDate:null,freezesRemaining:this.config.freezesPerMonth,freezeUsedAt:null}}getMilestones(e){let t=[3,7,14,30,60,90,180,365,500,1e3];return{achieved:t.filter(t=>e>=t),next:t.find(t=>e<t)??null}}getDateString(e){return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`}getDaysBetween(e,t){let n=new Date(e),r=new Date(t).getTime()-n.getTime();return Math.floor(r/(1e3*60*60*24))}addDays(e,t){return new Date(e.getTime()+t*24*60*60*1e3)}};const n=new t;export{e as DEFAULT_STREAK_CONFIG,t as StreakEngine,n as streakEngine};
1
+ //#region src/engines/streak.ts
2
+ const DEFAULT_STREAK_CONFIG = {
3
+ timezone: "UTC",
4
+ freezesPerMonth: 2,
5
+ maxFreezes: 5,
6
+ gracePeriodHours: 4
7
+ };
8
+ var StreakEngine = class {
9
+ config;
10
+ constructor(config = {}) {
11
+ this.config = {
12
+ ...DEFAULT_STREAK_CONFIG,
13
+ ...config
14
+ };
15
+ }
16
+ /**
17
+ * Update streak based on new activity.
18
+ */
19
+ update(state, now = /* @__PURE__ */ new Date()) {
20
+ const todayDate = this.getDateString(now);
21
+ const result = {
22
+ state: { ...state },
23
+ streakMaintained: false,
24
+ streakLost: false,
25
+ freezeUsed: false,
26
+ newStreak: false,
27
+ daysMissed: 0
28
+ };
29
+ if (!state.lastActivityDate) {
30
+ result.state.currentStreak = 1;
31
+ result.state.longestStreak = Math.max(1, state.longestStreak);
32
+ result.state.lastActivityAt = now;
33
+ result.state.lastActivityDate = todayDate;
34
+ result.newStreak = true;
35
+ result.streakMaintained = true;
36
+ return result;
37
+ }
38
+ if (state.lastActivityDate === todayDate) {
39
+ result.state.lastActivityAt = now;
40
+ result.streakMaintained = true;
41
+ return result;
42
+ }
43
+ const daysSinceActivity = this.getDaysBetween(state.lastActivityDate, todayDate);
44
+ if (daysSinceActivity === 1) {
45
+ result.state.currentStreak = state.currentStreak + 1;
46
+ result.state.longestStreak = Math.max(result.state.currentStreak, state.longestStreak);
47
+ result.state.lastActivityAt = now;
48
+ result.state.lastActivityDate = todayDate;
49
+ result.streakMaintained = true;
50
+ return result;
51
+ }
52
+ result.daysMissed = daysSinceActivity - 1;
53
+ const freezesNeeded = result.daysMissed;
54
+ if (freezesNeeded <= state.freezesRemaining) {
55
+ result.state.freezesRemaining = state.freezesRemaining - freezesNeeded;
56
+ result.state.freezeUsedAt = now;
57
+ result.state.currentStreak = state.currentStreak + 1;
58
+ result.state.longestStreak = Math.max(result.state.currentStreak, state.longestStreak);
59
+ result.state.lastActivityAt = now;
60
+ result.state.lastActivityDate = todayDate;
61
+ result.freezeUsed = true;
62
+ result.streakMaintained = true;
63
+ return result;
64
+ }
65
+ result.streakLost = true;
66
+ result.state.currentStreak = 1;
67
+ result.state.lastActivityAt = now;
68
+ result.state.lastActivityDate = todayDate;
69
+ result.newStreak = true;
70
+ return result;
71
+ }
72
+ /**
73
+ * Check streak status without recording activity.
74
+ */
75
+ checkStatus(state, now = /* @__PURE__ */ new Date()) {
76
+ if (!state.lastActivityDate) return {
77
+ isActive: false,
78
+ willExpireAt: null,
79
+ canUseFreeze: false,
80
+ daysUntilExpiry: 0
81
+ };
82
+ const todayDate = this.getDateString(now);
83
+ const daysSinceActivity = this.getDaysBetween(state.lastActivityDate, todayDate);
84
+ if (daysSinceActivity === 0) {
85
+ const tomorrow = this.addDays(now, 1);
86
+ tomorrow.setHours(23, 59, 59, 999);
87
+ return {
88
+ isActive: true,
89
+ willExpireAt: tomorrow,
90
+ canUseFreeze: state.freezesRemaining > 0,
91
+ daysUntilExpiry: 1
92
+ };
93
+ }
94
+ if (daysSinceActivity === 1) {
95
+ const endOfDay = new Date(now);
96
+ endOfDay.setHours(23 + this.config.gracePeriodHours, 59, 59, 999);
97
+ return {
98
+ isActive: true,
99
+ willExpireAt: endOfDay,
100
+ canUseFreeze: state.freezesRemaining > 0,
101
+ daysUntilExpiry: 0
102
+ };
103
+ }
104
+ const missedDays = daysSinceActivity - 1;
105
+ return {
106
+ isActive: missedDays <= state.freezesRemaining,
107
+ willExpireAt: null,
108
+ canUseFreeze: missedDays <= state.freezesRemaining,
109
+ daysUntilExpiry: -missedDays
110
+ };
111
+ }
112
+ /**
113
+ * Manually use a freeze to protect streak.
114
+ */
115
+ useFreeze(state, now = /* @__PURE__ */ new Date()) {
116
+ if (state.freezesRemaining <= 0) return null;
117
+ return {
118
+ ...state,
119
+ freezesRemaining: state.freezesRemaining - 1,
120
+ freezeUsedAt: now
121
+ };
122
+ }
123
+ /**
124
+ * Award monthly freezes.
125
+ */
126
+ awardMonthlyFreezes(state) {
127
+ return {
128
+ ...state,
129
+ freezesRemaining: Math.min(state.freezesRemaining + this.config.freezesPerMonth, this.config.maxFreezes)
130
+ };
131
+ }
132
+ /**
133
+ * Get initial streak state.
134
+ */
135
+ getInitialState() {
136
+ return {
137
+ currentStreak: 0,
138
+ longestStreak: 0,
139
+ lastActivityAt: null,
140
+ lastActivityDate: null,
141
+ freezesRemaining: this.config.freezesPerMonth,
142
+ freezeUsedAt: null
143
+ };
144
+ }
145
+ /**
146
+ * Calculate streak milestones.
147
+ */
148
+ getMilestones(currentStreak) {
149
+ const milestones = [
150
+ 3,
151
+ 7,
152
+ 14,
153
+ 30,
154
+ 60,
155
+ 90,
156
+ 180,
157
+ 365,
158
+ 500,
159
+ 1e3
160
+ ];
161
+ return {
162
+ achieved: milestones.filter((m) => currentStreak >= m),
163
+ next: milestones.find((m) => currentStreak < m) ?? null
164
+ };
165
+ }
166
+ /**
167
+ * Get date string in YYYY-MM-DD format.
168
+ */
169
+ getDateString(date) {
170
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
171
+ }
172
+ /**
173
+ * Get number of days between two date strings.
174
+ */
175
+ getDaysBetween(dateStr1, dateStr2) {
176
+ const date1 = new Date(dateStr1);
177
+ const diffTime = new Date(dateStr2).getTime() - date1.getTime();
178
+ return Math.floor(diffTime / (1e3 * 60 * 60 * 24));
179
+ }
180
+ /**
181
+ * Add days to a date.
182
+ */
183
+ addDays(date, days) {
184
+ return new Date(date.getTime() + days * 24 * 60 * 60 * 1e3);
185
+ }
186
+ };
187
+ /**
188
+ * Default streak engine instance.
189
+ */
190
+ const streakEngine = new StreakEngine();
191
+
192
+ //#endregion
193
+ export { DEFAULT_STREAK_CONFIG, StreakEngine, streakEngine };
194
+ //# sourceMappingURL=streak.js.map