@contractspec/module.learning-journey 1.57.0 → 1.58.0
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.
- package/dist/browser/contracts/index.js +578 -0
- package/dist/browser/contracts/models.js +193 -0
- package/dist/browser/contracts/onboarding.js +417 -0
- package/dist/browser/contracts/operations.js +326 -0
- package/dist/browser/contracts/shared.js +5 -0
- package/dist/browser/docs/index.js +124 -0
- package/dist/browser/docs/learning-journey.docblock.js +124 -0
- package/dist/browser/engines/index.js +526 -0
- package/dist/browser/engines/srs.js +198 -0
- package/dist/browser/engines/streak.js +159 -0
- package/dist/browser/engines/xp.js +171 -0
- package/dist/browser/entities/ai.js +343 -0
- package/dist/browser/entities/course.js +276 -0
- package/dist/browser/entities/flashcard.js +222 -0
- package/dist/browser/entities/gamification.js +340 -0
- package/dist/browser/entities/index.js +2136 -0
- package/dist/browser/entities/learner.js +329 -0
- package/dist/browser/entities/onboarding.js +301 -0
- package/dist/browser/entities/quiz.js +304 -0
- package/dist/browser/events.js +423 -0
- package/dist/browser/index.js +3833 -0
- package/dist/browser/learning-journey.capability.js +40 -0
- package/dist/browser/learning-journey.feature.js +56 -0
- package/dist/browser/track-spec.js +0 -0
- package/dist/contracts/index.d.ts +5 -5
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +578 -5
- package/dist/contracts/models.d.ts +426 -431
- package/dist/contracts/models.d.ts.map +1 -1
- package/dist/contracts/models.js +178 -372
- package/dist/contracts/onboarding.d.ts +621 -627
- package/dist/contracts/onboarding.d.ts.map +1 -1
- package/dist/contracts/onboarding.js +404 -388
- package/dist/contracts/operations.d.ts +243 -249
- package/dist/contracts/operations.d.ts.map +1 -1
- package/dist/contracts/operations.js +324 -148
- package/dist/contracts/shared.d.ts +1 -4
- package/dist/contracts/shared.d.ts.map +1 -1
- package/dist/contracts/shared.js +6 -6
- package/dist/docs/index.d.ts +2 -1
- package/dist/docs/index.d.ts.map +1 -0
- package/dist/docs/index.js +125 -1
- package/dist/docs/learning-journey.docblock.d.ts +2 -1
- package/dist/docs/learning-journey.docblock.d.ts.map +1 -0
- package/dist/docs/learning-journey.docblock.js +47 -58
- package/dist/engines/index.d.ts +4 -4
- package/dist/engines/index.d.ts.map +1 -0
- package/dist/engines/index.js +526 -4
- package/dist/engines/srs.d.ts +89 -92
- package/dist/engines/srs.d.ts.map +1 -1
- package/dist/engines/srs.js +197 -217
- package/dist/engines/streak.d.ts +84 -87
- package/dist/engines/streak.d.ts.map +1 -1
- package/dist/engines/streak.js +158 -192
- package/dist/engines/xp.d.ts +80 -83
- package/dist/engines/xp.d.ts.map +1 -1
- package/dist/engines/xp.js +170 -211
- package/dist/entities/ai.d.ts +199 -204
- package/dist/entities/ai.d.ts.map +1 -1
- package/dist/entities/ai.js +336 -368
- package/dist/entities/course.d.ts +149 -154
- package/dist/entities/course.d.ts.map +1 -1
- package/dist/entities/course.js +267 -306
- package/dist/entities/flashcard.d.ts +144 -149
- package/dist/entities/flashcard.d.ts.map +1 -1
- package/dist/entities/flashcard.js +217 -243
- package/dist/entities/gamification.d.ts +197 -202
- package/dist/entities/gamification.d.ts.map +1 -1
- package/dist/entities/gamification.js +331 -382
- package/dist/entities/index.d.ts +613 -618
- package/dist/entities/index.d.ts.map +1 -1
- package/dist/entities/index.js +2135 -43
- package/dist/entities/learner.d.ts +191 -196
- package/dist/entities/learner.d.ts.map +1 -1
- package/dist/entities/learner.js +322 -357
- package/dist/entities/onboarding.d.ts +164 -169
- package/dist/entities/onboarding.d.ts.map +1 -1
- package/dist/entities/onboarding.js +296 -301
- package/dist/entities/quiz.d.ts +184 -189
- package/dist/entities/quiz.d.ts.map +1 -1
- package/dist/entities/quiz.js +296 -361
- package/dist/events.d.ts +608 -614
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +421 -687
- package/dist/index.d.ts +8 -20
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3834 -22
- package/dist/learning-journey.capability.d.ts +3 -8
- package/dist/learning-journey.capability.d.ts.map +1 -1
- package/dist/learning-journey.capability.js +41 -46
- package/dist/learning-journey.feature.d.ts +1 -6
- package/dist/learning-journey.feature.d.ts.map +1 -1
- package/dist/learning-journey.feature.js +55 -155
- package/dist/node/contracts/index.js +578 -0
- package/dist/node/contracts/models.js +193 -0
- package/dist/node/contracts/onboarding.js +417 -0
- package/dist/node/contracts/operations.js +326 -0
- package/dist/node/contracts/shared.js +5 -0
- package/dist/node/docs/index.js +124 -0
- package/dist/node/docs/learning-journey.docblock.js +124 -0
- package/dist/node/engines/index.js +526 -0
- package/dist/node/engines/srs.js +198 -0
- package/dist/node/engines/streak.js +159 -0
- package/dist/node/engines/xp.js +171 -0
- package/dist/node/entities/ai.js +343 -0
- package/dist/node/entities/course.js +276 -0
- package/dist/node/entities/flashcard.js +222 -0
- package/dist/node/entities/gamification.js +340 -0
- package/dist/node/entities/index.js +2136 -0
- package/dist/node/entities/learner.js +329 -0
- package/dist/node/entities/onboarding.js +301 -0
- package/dist/node/entities/quiz.js +304 -0
- package/dist/node/events.js +423 -0
- package/dist/node/index.js +3833 -0
- package/dist/node/learning-journey.capability.js +40 -0
- package/dist/node/learning-journey.feature.js +56 -0
- package/dist/node/track-spec.js +0 -0
- package/dist/track-spec.d.ts +115 -118
- package/dist/track-spec.d.ts.map +1 -1
- package/dist/track-spec.js +1 -0
- package/package.json +237 -60
- package/dist/contracts/models.js.map +0 -1
- package/dist/contracts/onboarding.js.map +0 -1
- package/dist/contracts/operations.js.map +0 -1
- package/dist/contracts/shared.js.map +0 -1
- package/dist/docs/learning-journey.docblock.js.map +0 -1
- package/dist/engines/srs.js.map +0 -1
- package/dist/engines/streak.js.map +0 -1
- package/dist/engines/xp.js.map +0 -1
- package/dist/entities/ai.js.map +0 -1
- package/dist/entities/course.js.map +0 -1
- package/dist/entities/flashcard.js.map +0 -1
- package/dist/entities/gamification.js.map +0 -1
- package/dist/entities/index.js.map +0 -1
- package/dist/entities/learner.js.map +0 -1
- package/dist/entities/onboarding.js.map +0 -1
- package/dist/entities/quiz.js.map +0 -1
- package/dist/events.js.map +0 -1
- package/dist/learning-journey.capability.js.map +0 -1
- package/dist/learning-journey.feature.js.map +0 -1
|
@@ -1,21 +1,16 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/docs/learning-journey.docblock.ts
|
|
1
3
|
import { registerDocBlocks } from "@contractspec/lib.contracts/docs";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
tags: [
|
|
13
|
-
"learning",
|
|
14
|
-
"onboarding",
|
|
15
|
-
"journey",
|
|
16
|
-
"education"
|
|
17
|
-
],
|
|
18
|
-
body: `## Capabilities
|
|
4
|
+
var learningJourneyDocBlocks = [
|
|
5
|
+
{
|
|
6
|
+
id: "docs.learning-journey.engine",
|
|
7
|
+
title: "Learning Journey Engine",
|
|
8
|
+
summary: "Tracks learners, tracks/modules/steps, progress, quizzes, streaks, XP, and AI coaching hooks for product-integrated onboarding.",
|
|
9
|
+
kind: "reference",
|
|
10
|
+
visibility: "public",
|
|
11
|
+
route: "/docs/learning-journey/engine",
|
|
12
|
+
tags: ["learning", "onboarding", "journey", "education"],
|
|
13
|
+
body: `## Capabilities
|
|
19
14
|
|
|
20
15
|
- **Entities**: Learner, Track, Module, Step, Progress, Quiz, Flashcard, AI Coach, Gamification (XP, streaks, badges).
|
|
21
16
|
- **Contracts**: enroll/resume/advance steps, complete quizzes, record streaks, assign XP, fetch progress dashboards, onboarding list/progress/recordEvent.
|
|
@@ -45,13 +40,13 @@ registerDocBlocks([
|
|
|
45
40
|
|
|
46
41
|
## Example
|
|
47
42
|
|
|
48
|
-
|
|
43
|
+
${"```"}ts
|
|
49
44
|
import { learningJourneyEntities } from '@contractspec/module.learning-journey';
|
|
50
45
|
import { StreakEngine } from '@contractspec/module.learning-journey/engines';
|
|
51
46
|
|
|
52
47
|
const streak = new StreakEngine({ graceDays: 1 });
|
|
53
48
|
const updated = streak.compute({ lastActiveAt: new Date(), today: new Date() });
|
|
54
|
-
|
|
49
|
+
${"```"},
|
|
55
50
|
|
|
56
51
|
## Guardrails
|
|
57
52
|
|
|
@@ -60,16 +55,16 @@ const updated = streak.compute({ lastActiveAt: new Date(), today: new Date() });
|
|
|
60
55
|
- Emit analytics and audit logs for completions; respect \`prefers-reduced-motion\` in UIs consuming these specs.
|
|
61
56
|
- Track completion bonuses: \`completionXpBonus\`, \`completionBadgeKey\`, optional \`streakHoursWindow\` + \`streakBonusXp\`.
|
|
62
57
|
`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "docs.learning-journey.goal",
|
|
61
|
+
title: "Learning Journey \u2014 Goal",
|
|
62
|
+
summary: "Why the learning journey engine exists and the outcomes it targets.",
|
|
63
|
+
kind: "goal",
|
|
64
|
+
visibility: "public",
|
|
65
|
+
route: "/docs/learning-journey/goal",
|
|
66
|
+
tags: ["learning", "goal"],
|
|
67
|
+
body: `## Why it matters
|
|
73
68
|
- Provides a regenerable onboarding/education engine tied to product signals.
|
|
74
69
|
- Keeps tracks, steps, quizzes, and gamification consistent across surfaces.
|
|
75
70
|
|
|
@@ -80,16 +75,16 @@ const updated = streak.compute({ lastActiveAt: new Date(), today: new Date() });
|
|
|
80
75
|
## Success criteria
|
|
81
76
|
- Journey changes regenerate UI/API/events without drift.
|
|
82
77
|
- Analytics/audit hooks exist for completions and streaks.`
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "docs.learning-journey.usage",
|
|
81
|
+
title: "Learning Journey \u2014 Usage",
|
|
82
|
+
summary: "How to compose, bind, and regenerate journeys safely.",
|
|
83
|
+
kind: "usage",
|
|
84
|
+
visibility: "public",
|
|
85
|
+
route: "/docs/learning-journey/usage",
|
|
86
|
+
tags: ["learning", "usage"],
|
|
87
|
+
body: `## Setup
|
|
93
88
|
1) Include \`learningJourneyEntities\` in schema composition.
|
|
94
89
|
2) Register contracts/events from \`@contractspec/module.learning-journey\`.
|
|
95
90
|
3) Bind steps to real product events (e.g., deal.created, run.completed).
|
|
@@ -103,20 +98,16 @@ const updated = streak.compute({ lastActiveAt: new Date(), today: new Date() });
|
|
|
103
98
|
- Avoid hardcoded progression; keep engines declarative.
|
|
104
99
|
- Emit analytics/audit for completions; respect user locale/accessibility in presentations.
|
|
105
100
|
- Keep content free of PII; scope learners by org/tenant.`
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
"constraints",
|
|
117
|
-
"internal"
|
|
118
|
-
],
|
|
119
|
-
body: `## Constraints
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "docs.learning-journey.constraints",
|
|
104
|
+
title: "Learning Journey \u2014 Constraints & Safety",
|
|
105
|
+
summary: "Internal guardrails for progression, telemetry, and regeneration semantics.",
|
|
106
|
+
kind: "reference",
|
|
107
|
+
visibility: "internal",
|
|
108
|
+
route: "/docs/learning-journey/constraints",
|
|
109
|
+
tags: ["learning", "constraints", "internal"],
|
|
110
|
+
body: `## Constraints
|
|
120
111
|
- Progression (tracks/modules/steps) and engines (SRS, streaks, XP) must stay declarative in spec.
|
|
121
112
|
- Events to emit: learner.enrolled, step.completed, quiz.scored, streak.reset, xp.awarded.
|
|
122
113
|
- Regeneration should not change scoring/streak rules without explicit spec change.
|
|
@@ -129,8 +120,6 @@ const updated = streak.compute({ lastActiveAt: new Date(), today: new Date() });
|
|
|
129
120
|
- Add fixtures for streak/XP rule changes and quiz scoring.
|
|
130
121
|
- Ensure Notifications/Audit wiring persists for completions; analytics emitted for progress.
|
|
131
122
|
- Use Feature Flags to trial new tracks or reward rules; default safe/off.`
|
|
132
|
-
|
|
133
|
-
]
|
|
134
|
-
|
|
135
|
-
//#endregion
|
|
136
|
-
//# sourceMappingURL=learning-journey.docblock.js.map
|
|
123
|
+
}
|
|
124
|
+
];
|
|
125
|
+
registerDocBlocks(learningJourneyDocBlocks);
|
package/dist/engines/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export * from './srs';
|
|
2
|
+
export * from './xp';
|
|
3
|
+
export * from './streak';
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/engines/index.ts"],"names":[],"mappings":"AAAA,cAAc,OAAO,CAAC;AACtB,cAAc,MAAM,CAAC;AACrB,cAAc,UAAU,CAAC"}
|
package/dist/engines/index.js
CHANGED
|
@@ -1,5 +1,527 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// @bun
|
|
2
|
+
// src/engines/srs.ts
|
|
3
|
+
var DEFAULT_SRS_CONFIG = {
|
|
4
|
+
learningSteps: [1, 10],
|
|
5
|
+
graduatingInterval: 1,
|
|
6
|
+
easyInterval: 4,
|
|
7
|
+
relearningSteps: [10],
|
|
8
|
+
minEaseFactor: 1.3,
|
|
9
|
+
maxInterval: 365,
|
|
10
|
+
intervalModifier: 1,
|
|
11
|
+
newIntervalModifier: 0.5,
|
|
12
|
+
hardIntervalModifier: 1.2,
|
|
13
|
+
easyBonus: 1.3
|
|
14
|
+
};
|
|
4
15
|
|
|
5
|
-
|
|
16
|
+
class SRSEngine {
|
|
17
|
+
config;
|
|
18
|
+
constructor(config = {}) {
|
|
19
|
+
this.config = { ...DEFAULT_SRS_CONFIG, ...config };
|
|
20
|
+
}
|
|
21
|
+
calculateNextReview(state, rating, now = new Date) {
|
|
22
|
+
if (!state.isGraduated && !state.isRelearning) {
|
|
23
|
+
return this.handleLearningCard(state, rating, now);
|
|
24
|
+
}
|
|
25
|
+
if (state.isRelearning) {
|
|
26
|
+
return this.handleRelearningCard(state, rating, now);
|
|
27
|
+
}
|
|
28
|
+
return this.handleReviewCard(state, rating, now);
|
|
29
|
+
}
|
|
30
|
+
getInitialState() {
|
|
31
|
+
return {
|
|
32
|
+
interval: 0,
|
|
33
|
+
easeFactor: 2.5,
|
|
34
|
+
repetitions: 0,
|
|
35
|
+
learningStep: 0,
|
|
36
|
+
isGraduated: false,
|
|
37
|
+
isRelearning: false,
|
|
38
|
+
lapses: 0
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
isDue(nextReviewAt, now = new Date) {
|
|
42
|
+
return nextReviewAt <= now;
|
|
43
|
+
}
|
|
44
|
+
getOverdueDays(nextReviewAt, now = new Date) {
|
|
45
|
+
const diff = now.getTime() - nextReviewAt.getTime();
|
|
46
|
+
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
47
|
+
}
|
|
48
|
+
handleLearningCard(state, rating, now) {
|
|
49
|
+
const steps = this.config.learningSteps;
|
|
50
|
+
let newStep = state.learningStep;
|
|
51
|
+
let isGraduated = false;
|
|
52
|
+
let interval = 0;
|
|
53
|
+
let nextReviewAt;
|
|
54
|
+
switch (rating) {
|
|
55
|
+
case "AGAIN":
|
|
56
|
+
newStep = 0;
|
|
57
|
+
interval = steps[0] ?? 1;
|
|
58
|
+
nextReviewAt = this.addMinutes(now, interval);
|
|
59
|
+
break;
|
|
60
|
+
case "HARD":
|
|
61
|
+
interval = steps[newStep] ?? steps[0] ?? 1;
|
|
62
|
+
nextReviewAt = this.addMinutes(now, interval);
|
|
63
|
+
break;
|
|
64
|
+
case "GOOD":
|
|
65
|
+
newStep++;
|
|
66
|
+
if (newStep >= steps.length) {
|
|
67
|
+
isGraduated = true;
|
|
68
|
+
interval = this.config.graduatingInterval;
|
|
69
|
+
nextReviewAt = this.addDays(now, interval);
|
|
70
|
+
} else {
|
|
71
|
+
interval = steps[newStep] ?? 10;
|
|
72
|
+
nextReviewAt = this.addMinutes(now, interval);
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
case "EASY":
|
|
76
|
+
isGraduated = true;
|
|
77
|
+
interval = this.config.easyInterval;
|
|
78
|
+
nextReviewAt = this.addDays(now, interval);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
interval: isGraduated ? interval : 0,
|
|
83
|
+
easeFactor: state.easeFactor,
|
|
84
|
+
repetitions: isGraduated ? 1 : 0,
|
|
85
|
+
nextReviewAt,
|
|
86
|
+
learningStep: newStep,
|
|
87
|
+
isGraduated,
|
|
88
|
+
isRelearning: false,
|
|
89
|
+
lapses: state.lapses
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
handleRelearningCard(state, rating, now) {
|
|
93
|
+
const steps = this.config.relearningSteps;
|
|
94
|
+
let newStep = state.learningStep;
|
|
95
|
+
let isRelearning = true;
|
|
96
|
+
let interval = 0;
|
|
97
|
+
let nextReviewAt;
|
|
98
|
+
switch (rating) {
|
|
99
|
+
case "AGAIN":
|
|
100
|
+
newStep = 0;
|
|
101
|
+
interval = steps[0] ?? 10;
|
|
102
|
+
nextReviewAt = this.addMinutes(now, interval);
|
|
103
|
+
break;
|
|
104
|
+
case "HARD":
|
|
105
|
+
interval = steps[newStep] ?? steps[0] ?? 10;
|
|
106
|
+
nextReviewAt = this.addMinutes(now, interval);
|
|
107
|
+
break;
|
|
108
|
+
case "GOOD":
|
|
109
|
+
newStep++;
|
|
110
|
+
if (newStep >= steps.length) {
|
|
111
|
+
isRelearning = false;
|
|
112
|
+
interval = Math.max(1, Math.floor(state.interval * this.config.newIntervalModifier));
|
|
113
|
+
nextReviewAt = this.addDays(now, interval);
|
|
114
|
+
} else {
|
|
115
|
+
interval = steps[newStep] ?? 10;
|
|
116
|
+
nextReviewAt = this.addMinutes(now, interval);
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
case "EASY":
|
|
120
|
+
isRelearning = false;
|
|
121
|
+
interval = Math.max(1, Math.floor(state.interval * this.config.newIntervalModifier * 1.5));
|
|
122
|
+
nextReviewAt = this.addDays(now, interval);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
interval: isRelearning ? state.interval : interval,
|
|
127
|
+
easeFactor: state.easeFactor,
|
|
128
|
+
repetitions: isRelearning ? state.repetitions : state.repetitions + 1,
|
|
129
|
+
nextReviewAt,
|
|
130
|
+
learningStep: newStep,
|
|
131
|
+
isGraduated: true,
|
|
132
|
+
isRelearning,
|
|
133
|
+
lapses: state.lapses
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
handleReviewCard(state, rating, now) {
|
|
137
|
+
let newInterval;
|
|
138
|
+
let newEaseFactor = state.easeFactor;
|
|
139
|
+
let repetitions = state.repetitions;
|
|
140
|
+
let isRelearning = false;
|
|
141
|
+
let learningStep = 0;
|
|
142
|
+
let lapses = state.lapses;
|
|
143
|
+
switch (rating) {
|
|
144
|
+
case "AGAIN":
|
|
145
|
+
lapses++;
|
|
146
|
+
isRelearning = true;
|
|
147
|
+
learningStep = 0;
|
|
148
|
+
newEaseFactor = Math.max(this.config.minEaseFactor, newEaseFactor - 0.2);
|
|
149
|
+
newInterval = state.interval;
|
|
150
|
+
return {
|
|
151
|
+
interval: newInterval,
|
|
152
|
+
easeFactor: newEaseFactor,
|
|
153
|
+
repetitions,
|
|
154
|
+
nextReviewAt: this.addMinutes(now, this.config.relearningSteps[0] ?? 10),
|
|
155
|
+
learningStep,
|
|
156
|
+
isGraduated: true,
|
|
157
|
+
isRelearning: true,
|
|
158
|
+
lapses
|
|
159
|
+
};
|
|
160
|
+
case "HARD":
|
|
161
|
+
newEaseFactor = Math.max(this.config.minEaseFactor, newEaseFactor - 0.15);
|
|
162
|
+
newInterval = Math.max(state.interval + 1, state.interval * this.config.hardIntervalModifier);
|
|
163
|
+
break;
|
|
164
|
+
case "GOOD":
|
|
165
|
+
newInterval = state.interval * newEaseFactor * this.config.intervalModifier;
|
|
166
|
+
repetitions++;
|
|
167
|
+
break;
|
|
168
|
+
case "EASY":
|
|
169
|
+
newEaseFactor = newEaseFactor + 0.15;
|
|
170
|
+
newInterval = state.interval * newEaseFactor * this.config.easyBonus * this.config.intervalModifier;
|
|
171
|
+
repetitions++;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
newInterval = Math.min(Math.round(newInterval), this.config.maxInterval);
|
|
175
|
+
newInterval = Math.max(1, newInterval);
|
|
176
|
+
return {
|
|
177
|
+
interval: newInterval,
|
|
178
|
+
easeFactor: newEaseFactor,
|
|
179
|
+
repetitions,
|
|
180
|
+
nextReviewAt: this.addDays(now, newInterval),
|
|
181
|
+
learningStep,
|
|
182
|
+
isGraduated: true,
|
|
183
|
+
isRelearning,
|
|
184
|
+
lapses
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
addMinutes(date, minutes) {
|
|
188
|
+
return new Date(date.getTime() + minutes * 60 * 1000);
|
|
189
|
+
}
|
|
190
|
+
addDays(date, days) {
|
|
191
|
+
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
var srsEngine = new SRSEngine;
|
|
195
|
+
|
|
196
|
+
// src/engines/xp.ts
|
|
197
|
+
var DEFAULT_XP_CONFIG = {
|
|
198
|
+
baseValues: {
|
|
199
|
+
lesson_complete: 10,
|
|
200
|
+
quiz_pass: 20,
|
|
201
|
+
quiz_perfect: 50,
|
|
202
|
+
flashcard_review: 1,
|
|
203
|
+
course_complete: 200,
|
|
204
|
+
module_complete: 50,
|
|
205
|
+
streak_bonus: 5,
|
|
206
|
+
achievement_unlock: 0,
|
|
207
|
+
daily_goal_complete: 15,
|
|
208
|
+
first_lesson: 25,
|
|
209
|
+
onboarding_step: 5,
|
|
210
|
+
onboarding_complete: 50
|
|
211
|
+
},
|
|
212
|
+
scoreThresholds: [
|
|
213
|
+
{ min: 90, multiplier: 1.5 },
|
|
214
|
+
{ min: 80, multiplier: 1.25 },
|
|
215
|
+
{ min: 70, multiplier: 1 },
|
|
216
|
+
{ min: 60, multiplier: 0.75 },
|
|
217
|
+
{ min: 0, multiplier: 0.5 }
|
|
218
|
+
],
|
|
219
|
+
streakTiers: [
|
|
220
|
+
{ days: 365, bonus: 50 },
|
|
221
|
+
{ days: 180, bonus: 30 },
|
|
222
|
+
{ days: 90, bonus: 20 },
|
|
223
|
+
{ days: 30, bonus: 15 },
|
|
224
|
+
{ days: 14, bonus: 10 },
|
|
225
|
+
{ days: 7, bonus: 5 },
|
|
226
|
+
{ days: 3, bonus: 2 },
|
|
227
|
+
{ days: 1, bonus: 0 }
|
|
228
|
+
],
|
|
229
|
+
perfectScoreMultiplier: 1.5,
|
|
230
|
+
firstAttemptBonus: 10,
|
|
231
|
+
retryPenalty: 0.5,
|
|
232
|
+
speedBonusMultiplier: 1.2,
|
|
233
|
+
speedBonusThreshold: 0.8
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
class XPEngine {
|
|
237
|
+
config;
|
|
238
|
+
constructor(config = {}) {
|
|
239
|
+
this.config = {
|
|
240
|
+
...DEFAULT_XP_CONFIG,
|
|
241
|
+
...config,
|
|
242
|
+
baseValues: { ...DEFAULT_XP_CONFIG.baseValues, ...config.baseValues },
|
|
243
|
+
scoreThresholds: config.scoreThresholds || DEFAULT_XP_CONFIG.scoreThresholds,
|
|
244
|
+
streakTiers: config.streakTiers || DEFAULT_XP_CONFIG.streakTiers
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
calculate(input) {
|
|
248
|
+
const breakdown = [];
|
|
249
|
+
const baseXp = input.baseXp ?? this.config.baseValues[input.activity];
|
|
250
|
+
let totalXp = baseXp;
|
|
251
|
+
breakdown.push({
|
|
252
|
+
source: "base",
|
|
253
|
+
amount: baseXp
|
|
254
|
+
});
|
|
255
|
+
if (input.score !== undefined) {
|
|
256
|
+
const scoreMultiplier = this.getScoreMultiplier(input.score);
|
|
257
|
+
if (scoreMultiplier !== 1) {
|
|
258
|
+
const scoreBonus = Math.round(baseXp * (scoreMultiplier - 1));
|
|
259
|
+
totalXp += scoreBonus;
|
|
260
|
+
breakdown.push({
|
|
261
|
+
source: "score_bonus",
|
|
262
|
+
amount: scoreBonus,
|
|
263
|
+
multiplier: scoreMultiplier
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (input.score === 100) {
|
|
267
|
+
const perfectBonus = Math.round(baseXp * (this.config.perfectScoreMultiplier - 1));
|
|
268
|
+
totalXp += perfectBonus;
|
|
269
|
+
breakdown.push({
|
|
270
|
+
source: "perfect_score",
|
|
271
|
+
amount: perfectBonus,
|
|
272
|
+
multiplier: this.config.perfectScoreMultiplier
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (input.attemptNumber === 1 && !input.isRetry) {
|
|
277
|
+
totalXp += this.config.firstAttemptBonus;
|
|
278
|
+
breakdown.push({
|
|
279
|
+
source: "first_attempt",
|
|
280
|
+
amount: this.config.firstAttemptBonus
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (input.isRetry) {
|
|
284
|
+
const penalty = Math.round(totalXp * (1 - this.config.retryPenalty));
|
|
285
|
+
totalXp -= penalty;
|
|
286
|
+
breakdown.push({
|
|
287
|
+
source: "retry_penalty",
|
|
288
|
+
amount: -penalty,
|
|
289
|
+
multiplier: this.config.retryPenalty
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
if (input.currentStreak && input.currentStreak > 0) {
|
|
293
|
+
const streakBonus = this.getStreakBonus(input.currentStreak);
|
|
294
|
+
if (streakBonus > 0) {
|
|
295
|
+
totalXp += streakBonus;
|
|
296
|
+
breakdown.push({
|
|
297
|
+
source: "streak_bonus",
|
|
298
|
+
amount: streakBonus
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (baseXp > 0) {
|
|
303
|
+
totalXp = Math.max(1, totalXp);
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
totalXp: Math.round(totalXp),
|
|
307
|
+
baseXp,
|
|
308
|
+
breakdown
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
calculateStreakBonus(currentStreak) {
|
|
312
|
+
const bonus = this.getStreakBonus(currentStreak);
|
|
313
|
+
return {
|
|
314
|
+
totalXp: bonus,
|
|
315
|
+
baseXp: bonus,
|
|
316
|
+
breakdown: [
|
|
317
|
+
{
|
|
318
|
+
source: "streak_bonus",
|
|
319
|
+
amount: bonus
|
|
320
|
+
}
|
|
321
|
+
]
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
getXpForLevel(level) {
|
|
325
|
+
if (level <= 1)
|
|
326
|
+
return 0;
|
|
327
|
+
return Math.round(100 * Math.pow(level - 1, 1.5));
|
|
328
|
+
}
|
|
329
|
+
getLevelFromXp(totalXp) {
|
|
330
|
+
let level = 1;
|
|
331
|
+
let xpRequired = this.getXpForLevel(level + 1);
|
|
332
|
+
while (totalXp >= xpRequired && level < 1000) {
|
|
333
|
+
level++;
|
|
334
|
+
xpRequired = this.getXpForLevel(level + 1);
|
|
335
|
+
}
|
|
336
|
+
const xpForCurrentLevel = this.getXpForLevel(level);
|
|
337
|
+
const xpForNextLevel = this.getXpForLevel(level + 1);
|
|
338
|
+
return {
|
|
339
|
+
level,
|
|
340
|
+
xpInLevel: totalXp - xpForCurrentLevel,
|
|
341
|
+
xpForNextLevel: xpForNextLevel - xpForCurrentLevel
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
getScoreMultiplier(score) {
|
|
345
|
+
for (const threshold of this.config.scoreThresholds) {
|
|
346
|
+
if (score >= threshold.min) {
|
|
347
|
+
return threshold.multiplier;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return 1;
|
|
351
|
+
}
|
|
352
|
+
getStreakBonus(streak) {
|
|
353
|
+
for (const tier of this.config.streakTiers) {
|
|
354
|
+
if (streak >= tier.days) {
|
|
355
|
+
return tier.bonus;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
var xpEngine = new XPEngine;
|
|
362
|
+
|
|
363
|
+
// src/engines/streak.ts
|
|
364
|
+
var DEFAULT_STREAK_CONFIG = {
|
|
365
|
+
timezone: "UTC",
|
|
366
|
+
freezesPerMonth: 2,
|
|
367
|
+
maxFreezes: 5,
|
|
368
|
+
gracePeriodHours: 4
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
class StreakEngine {
|
|
372
|
+
config;
|
|
373
|
+
constructor(config = {}) {
|
|
374
|
+
this.config = { ...DEFAULT_STREAK_CONFIG, ...config };
|
|
375
|
+
}
|
|
376
|
+
update(state, now = new Date) {
|
|
377
|
+
const todayDate = this.getDateString(now);
|
|
378
|
+
const result = {
|
|
379
|
+
state: { ...state },
|
|
380
|
+
streakMaintained: false,
|
|
381
|
+
streakLost: false,
|
|
382
|
+
freezeUsed: false,
|
|
383
|
+
newStreak: false,
|
|
384
|
+
daysMissed: 0
|
|
385
|
+
};
|
|
386
|
+
if (!state.lastActivityDate) {
|
|
387
|
+
result.state.currentStreak = 1;
|
|
388
|
+
result.state.longestStreak = Math.max(1, state.longestStreak);
|
|
389
|
+
result.state.lastActivityAt = now;
|
|
390
|
+
result.state.lastActivityDate = todayDate;
|
|
391
|
+
result.newStreak = true;
|
|
392
|
+
result.streakMaintained = true;
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
if (state.lastActivityDate === todayDate) {
|
|
396
|
+
result.state.lastActivityAt = now;
|
|
397
|
+
result.streakMaintained = true;
|
|
398
|
+
return result;
|
|
399
|
+
}
|
|
400
|
+
const daysSinceActivity = this.getDaysBetween(state.lastActivityDate, todayDate);
|
|
401
|
+
if (daysSinceActivity === 1) {
|
|
402
|
+
result.state.currentStreak = state.currentStreak + 1;
|
|
403
|
+
result.state.longestStreak = Math.max(result.state.currentStreak, state.longestStreak);
|
|
404
|
+
result.state.lastActivityAt = now;
|
|
405
|
+
result.state.lastActivityDate = todayDate;
|
|
406
|
+
result.streakMaintained = true;
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
result.daysMissed = daysSinceActivity - 1;
|
|
410
|
+
const freezesNeeded = result.daysMissed;
|
|
411
|
+
if (freezesNeeded <= state.freezesRemaining) {
|
|
412
|
+
result.state.freezesRemaining = state.freezesRemaining - freezesNeeded;
|
|
413
|
+
result.state.freezeUsedAt = now;
|
|
414
|
+
result.state.currentStreak = state.currentStreak + 1;
|
|
415
|
+
result.state.longestStreak = Math.max(result.state.currentStreak, state.longestStreak);
|
|
416
|
+
result.state.lastActivityAt = now;
|
|
417
|
+
result.state.lastActivityDate = todayDate;
|
|
418
|
+
result.freezeUsed = true;
|
|
419
|
+
result.streakMaintained = true;
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
result.streakLost = true;
|
|
423
|
+
result.state.currentStreak = 1;
|
|
424
|
+
result.state.lastActivityAt = now;
|
|
425
|
+
result.state.lastActivityDate = todayDate;
|
|
426
|
+
result.newStreak = true;
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
checkStatus(state, now = new Date) {
|
|
430
|
+
if (!state.lastActivityDate) {
|
|
431
|
+
return {
|
|
432
|
+
isActive: false,
|
|
433
|
+
willExpireAt: null,
|
|
434
|
+
canUseFreeze: false,
|
|
435
|
+
daysUntilExpiry: 0
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const todayDate = this.getDateString(now);
|
|
439
|
+
const daysSinceActivity = this.getDaysBetween(state.lastActivityDate, todayDate);
|
|
440
|
+
if (daysSinceActivity === 0) {
|
|
441
|
+
const tomorrow = this.addDays(now, 1);
|
|
442
|
+
tomorrow.setHours(23, 59, 59, 999);
|
|
443
|
+
return {
|
|
444
|
+
isActive: true,
|
|
445
|
+
willExpireAt: tomorrow,
|
|
446
|
+
canUseFreeze: state.freezesRemaining > 0,
|
|
447
|
+
daysUntilExpiry: 1
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (daysSinceActivity === 1) {
|
|
451
|
+
const endOfDay = new Date(now);
|
|
452
|
+
endOfDay.setHours(23 + this.config.gracePeriodHours, 59, 59, 999);
|
|
453
|
+
return {
|
|
454
|
+
isActive: true,
|
|
455
|
+
willExpireAt: endOfDay,
|
|
456
|
+
canUseFreeze: state.freezesRemaining > 0,
|
|
457
|
+
daysUntilExpiry: 0
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
const missedDays = daysSinceActivity - 1;
|
|
461
|
+
return {
|
|
462
|
+
isActive: missedDays <= state.freezesRemaining,
|
|
463
|
+
willExpireAt: null,
|
|
464
|
+
canUseFreeze: missedDays <= state.freezesRemaining,
|
|
465
|
+
daysUntilExpiry: -missedDays
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
useFreeze(state, now = new Date) {
|
|
469
|
+
if (state.freezesRemaining <= 0) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
...state,
|
|
474
|
+
freezesRemaining: state.freezesRemaining - 1,
|
|
475
|
+
freezeUsedAt: now
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
awardMonthlyFreezes(state) {
|
|
479
|
+
return {
|
|
480
|
+
...state,
|
|
481
|
+
freezesRemaining: Math.min(state.freezesRemaining + this.config.freezesPerMonth, this.config.maxFreezes)
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
getInitialState() {
|
|
485
|
+
return {
|
|
486
|
+
currentStreak: 0,
|
|
487
|
+
longestStreak: 0,
|
|
488
|
+
lastActivityAt: null,
|
|
489
|
+
lastActivityDate: null,
|
|
490
|
+
freezesRemaining: this.config.freezesPerMonth,
|
|
491
|
+
freezeUsedAt: null
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
getMilestones(currentStreak) {
|
|
495
|
+
const milestones = [3, 7, 14, 30, 60, 90, 180, 365, 500, 1000];
|
|
496
|
+
const achieved = milestones.filter((m) => currentStreak >= m);
|
|
497
|
+
const next = milestones.find((m) => currentStreak < m) ?? null;
|
|
498
|
+
return { achieved, next };
|
|
499
|
+
}
|
|
500
|
+
getDateString(date) {
|
|
501
|
+
const year = date.getFullYear();
|
|
502
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
503
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
504
|
+
return `${year}-${month}-${day}`;
|
|
505
|
+
}
|
|
506
|
+
getDaysBetween(dateStr1, dateStr2) {
|
|
507
|
+
const date1 = new Date(dateStr1);
|
|
508
|
+
const date2 = new Date(dateStr2);
|
|
509
|
+
const diffTime = date2.getTime() - date1.getTime();
|
|
510
|
+
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
|
511
|
+
}
|
|
512
|
+
addDays(date, days) {
|
|
513
|
+
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
var streakEngine = new StreakEngine;
|
|
517
|
+
export {
|
|
518
|
+
xpEngine,
|
|
519
|
+
streakEngine,
|
|
520
|
+
srsEngine,
|
|
521
|
+
XPEngine,
|
|
522
|
+
StreakEngine,
|
|
523
|
+
SRSEngine,
|
|
524
|
+
DEFAULT_XP_CONFIG,
|
|
525
|
+
DEFAULT_STREAK_CONFIG,
|
|
526
|
+
DEFAULT_SRS_CONFIG
|
|
527
|
+
};
|