feat(vocab): implement scheduled review management in VocabService and UI updates
All checks were successful
Deploy to production / deploy (push) Successful in 2m56s
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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user