feat(vocab): implement scheduled review management in VocabService and UI updates
All checks were successful
Deploy to production / deploy (push) Successful in 2m56s

- Added methods in VocabService to handle scheduled review states, including normalization of review dates and management of review stages.
- Enhanced lesson state management to support review scheduling, improving the learning process for users.
- Updated VocabCourseView and VocabLessonView to display review statuses and due dates, providing clearer feedback on lesson progress and review requirements.
- Introduced new UI elements to indicate review status, enhancing user engagement and understanding of lesson timelines.
This commit is contained in:
Torsten Schulz (local)
2026-04-01 16:03:42 +02:00
parent 3ff8e4fc40
commit ac5d436a36
3 changed files with 716 additions and 22 deletions

View File

@@ -15,6 +15,17 @@ import { Op } from 'sequelize';
import { BISAYA_PHASE1_DIDACTICS } from '../scripts/bisaya-course-phase1.js';
export default class VocabService {
_normalizeIsoDate(value) {
if (!value) {
return '';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toISOString();
}
_clampInteger(value, { min = 0, max = 100000, fallback = 0 } = {}) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
@@ -155,7 +166,10 @@ export default class VocabService {
'exerciseAnswers',
'exerciseResults',
'exerciseRetryPending',
'exerciseRetryPendingSinceAttempts'
'exerciseRetryPendingSinceAttempts',
'reviewStage',
'reviewNextDueAt',
'reviewLastReviewedAt'
];
const hasKnownState = knownKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key));
if (!hasKnownState) {
@@ -185,10 +199,83 @@ export default class VocabService {
exerciseAnswers: this._sanitizeExerciseAnswers(value.exerciseAnswers),
exerciseResults: this._sanitizeExerciseResults(value.exerciseResults),
exerciseRetryPending: Boolean(value.exerciseRetryPending),
exerciseRetryPendingSinceAttempts: this._clampInteger(value.exerciseRetryPendingSinceAttempts, { max: 10000 })
exerciseRetryPendingSinceAttempts: this._clampInteger(value.exerciseRetryPendingSinceAttempts, { max: 10000 }),
reviewStage: this._clampInteger(value.reviewStage, { min: 0, max: 3 }),
reviewNextDueAt: this._normalizeIsoDate(value.reviewNextDueAt),
reviewLastReviewedAt: this._normalizeIsoDate(value.reviewLastReviewedAt)
};
}
_supportsScheduledReview(lessonData = null) {
const lessonType = String(lessonData?.lessonType || '').toLowerCase();
const didacticMode = String(lessonData?.didacticMode || '').toLowerCase();
if (lessonType === 'culture' || lessonType === 'review' || lessonType === 'vocab_review') {
return false;
}
if (didacticMode === 'intensive_review' || didacticMode === 'checkpoint') {
return false;
}
return true;
}
_removeManagedReviewState(lessonState = {}) {
const nextState = { ...(lessonState || {}) };
delete nextState.reviewStage;
delete nextState.reviewNextDueAt;
delete nextState.reviewLastReviewedAt;
return nextState;
}
_applyScheduledReviewState(lessonState = {}, {
previousCompleted = false,
nextCompleted = false,
shouldAdvanceReview = false,
lessonData = null,
now = new Date()
} = {}) {
const baseState = this._sanitizeLessonState(lessonState);
if (!this._supportsScheduledReview(lessonData) || !nextCompleted) {
return this._removeManagedReviewState(baseState);
}
const reviewIntervalsDays = [1, 3, 7];
const currentStage = this._clampInteger(baseState.reviewStage, { min: 0, max: reviewIntervalsDays.length });
const dueAtIso = this._normalizeIsoDate(baseState.reviewNextDueAt);
const dueAt = dueAtIso ? new Date(dueAtIso) : null;
const reviewLastReviewedAt = this._normalizeIsoDate(baseState.reviewLastReviewedAt);
const nowIso = this._normalizeIsoDate(now);
const nextState = {
...baseState,
reviewStage: currentStage,
reviewNextDueAt: dueAtIso,
reviewLastReviewedAt
};
if (!previousCompleted && shouldAdvanceReview) {
nextState.reviewStage = 0;
nextState.reviewLastReviewedAt = nowIso;
nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[0] * 24 * 60 * 60 * 1000));
return nextState;
}
if (!dueAtIso && currentStage < reviewIntervalsDays.length) {
nextState.reviewNextDueAt = this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[currentStage] * 24 * 60 * 60 * 1000));
}
if (!shouldAdvanceReview || !dueAt || Number.isNaN(dueAt.getTime()) || now.getTime() < dueAt.getTime()) {
return nextState;
}
const nextStage = Math.min(reviewIntervalsDays.length, currentStage + 1);
nextState.reviewStage = nextStage;
nextState.reviewLastReviewedAt = nowIso;
nextState.reviewNextDueAt = nextStage >= reviewIntervalsDays.length
? ''
: this._normalizeIsoDate(new Date(now.getTime() + reviewIntervalsDays[nextStage] * 24 * 60 * 60 * 1000));
return nextState;
}
_serializeLessonProgress(progress, lessonData = null) {
if (!progress) {
return null;
@@ -197,13 +284,21 @@ export default class VocabService {
const plainProgress = progress.get ? progress.get({ plain: true }) : { ...progress };
const targetScore = lessonData?.targetScorePercent || plainProgress.lesson?.targetScorePercent || 80;
const hasReachedTarget = (plainProgress.score || 0) >= targetScore;
const lessonState = this._sanitizeLessonState(plainProgress.lessonState);
const reviewStage = this._clampInteger(lessonState.reviewStage, { min: 0, max: 3 });
const reviewNextDueAt = this._normalizeIsoDate(lessonState.reviewNextDueAt);
const reviewDue = Boolean(reviewNextDueAt && reviewStage < 3 && new Date(reviewNextDueAt).getTime() <= Date.now());
return {
...plainProgress,
lessonState: this._sanitizeLessonState(plainProgress.lessonState),
lessonState,
targetScore,
hasReachedTarget,
needsReview: Boolean((lessonData?.requiresReview ?? plainProgress.lesson?.requiresReview) && !hasReachedTarget)
needsReview: Boolean((lessonData?.requiresReview ?? plainProgress.lesson?.requiresReview) && !hasReachedTarget),
reviewStage,
reviewNextDueAt,
reviewDue,
reviewCompleted: reviewStage >= 3
};
}
@@ -2313,6 +2408,7 @@ export default class VocabService {
const actualScore = Number(score) || 0;
const hasReachedTarget = actualScore >= targetScore;
const sanitizedLessonState = lessonState === undefined ? undefined : this._sanitizeLessonState(lessonState);
const didSubmitResult = completed !== undefined || score !== undefined;
// Prüfe, ob Lektion als abgeschlossen gilt (nur wenn Ziel erreicht oder explizit completed=true)
const isCompleted = Boolean(completed) || (hasReachedTarget && lessonData.requiresReview === false);
@@ -2351,10 +2447,38 @@ export default class VocabService {
if (sanitizedLessonState !== undefined) {
updates.lessonState = sanitizedLessonState;
}
const nextCompleted = updates.completed !== undefined ? Boolean(updates.completed) : Boolean(progress.completed);
const mergedLessonState = {
...this._sanitizeLessonState(progress.lessonState),
...(updates.lessonState || {})
};
updates.lessonState = this._applyScheduledReviewState(mergedLessonState, {
previousCompleted: Boolean(progress.completed),
nextCompleted,
shouldAdvanceReview: didSubmitResult && nextCompleted,
lessonData,
now: updates.completedAt || updates.lastAccessedAt || new Date()
});
await progress.update(updates);
} else if (isCompleted) {
progress.completed = true;
progress.completedAt = new Date();
progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState || {}, {
previousCompleted: false,
nextCompleted: true,
shouldAdvanceReview: didSubmitResult,
lessonData,
now: progress.completedAt
});
await progress.save();
} else if (sanitizedLessonState !== undefined) {
progress.lessonState = this._applyScheduledReviewState(sanitizedLessonState, {
previousCompleted: false,
nextCompleted: false,
shouldAdvanceReview: false,
lessonData,
now: new Date()
});
await progress.save();
}