feat(vocab): enhance lesson progress tracking and review scheduling
All checks were successful
Deploy to production / deploy (push) Successful in 2m48s

- Updated VocabService to include lessonId and lessonNumber in progress data, improving tracking accuracy.
- Modified getLessonProgress and lastProgressTouch methods to accept lesson parameters, enhancing flexibility in progress retrieval.
- Implemented formatReviewBadgeSchedule method to manage review scheduling notifications, providing clearer user feedback.
- Updated VocabCourseView to reflect changes in lesson progress handling, ensuring accurate display of review statuses and due dates.
- Expanded localization for review scheduling messages across multiple languages, enhancing user experience.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 13:27:24 +02:00
parent 153914d5d2
commit 9d663e4f2b
6 changed files with 93 additions and 26 deletions

View File

@@ -291,6 +291,8 @@ export default class VocabService {
return {
...plainProgress,
lessonId: Number(plainProgress.lessonId),
lessonNumber: lessonData?.lessonNumber ?? plainProgress.lesson?.lessonNumber ?? null,
lessonState,
targetScore,
hasReachedTarget,

View File

@@ -341,6 +341,10 @@
"reviewDueToday": "angay karon",
"reviewDueSinceOneDay": "angay na sukad 1 ka adlaw",
"reviewDueSinceDays": "angay na sukad {count} ka adlaw",
"reviewBadgeScheduleTomorrow": "sunod nga wave ugma",
"reviewBadgeScheduleInDays": "sunod nga wave sulod sa {count} ka adlaw",
"reviewBadgeScheduleToday": "gitakda ang wave karon",
"reviewBadgeScheduleOverdue": "nilapas na ang wave sukad {count} ka adlaw",
"reviewStageDay1": "Adlaw 1",
"reviewStageDay3": "Adlaw 3",
"reviewStageDay7": "Adlaw 7",

View File

@@ -696,6 +696,10 @@
"reviewDueToday": "heute fällig",
"reviewDueSinceOneDay": "seit 1 Tag fällig",
"reviewDueSinceDays": "seit {count} Tagen fällig",
"reviewBadgeScheduleTomorrow": "nächste Welle morgen",
"reviewBadgeScheduleInDays": "nächste Welle in {count} Tagen",
"reviewBadgeScheduleToday": "Welle heute vorgesehen",
"reviewBadgeScheduleOverdue": "Welle überfällig (seit {count} Tagen)",
"reviewStageDay1": "Tag 1",
"reviewStageDay3": "Tag 3",
"reviewStageDay7": "Tag 7",

View File

@@ -696,6 +696,10 @@
"reviewDueToday": "due today",
"reviewDueSinceOneDay": "due since 1 day",
"reviewDueSinceDays": "due since {count} days",
"reviewBadgeScheduleTomorrow": "next review wave tomorrow",
"reviewBadgeScheduleInDays": "next wave in {count} days",
"reviewBadgeScheduleToday": "wave slated for today",
"reviewBadgeScheduleOverdue": "wave overdue ({count} days)",
"reviewStageDay1": "Day 1",
"reviewStageDay3": "Day 3",
"reviewStageDay7": "Day 7",

View File

@@ -694,6 +694,10 @@
"reviewDueToday": "vence hoy",
"reviewDueSinceOneDay": "vence desde hace 1 día",
"reviewDueSinceDays": "vence desde hace {count} días",
"reviewBadgeScheduleTomorrow": "siguiente ola mañana",
"reviewBadgeScheduleInDays": "siguiente ola en {count} días",
"reviewBadgeScheduleToday": "ola prevista hoy",
"reviewBadgeScheduleOverdue": "ola atrasada ({count} días)",
"reviewStageDay1": "Día 1",
"reviewStageDay3": "Día 3",
"reviewStageDay7": "Día 7",

View File

@@ -69,7 +69,7 @@
@click="openLesson(lesson.id)"
>
<strong>{{ lesson.title }}</strong>
<span>{{ formatReviewDue(getLessonProgress(lesson.id)?.reviewNextDueAt) }}</span>
<span>{{ formatReviewDue(getLessonProgress(lesson.id, lesson)?.reviewNextDueAt) }}</span>
</button>
</div>
<p v-else class="course-flow-card__empty">{{ $t('socialnetwork.vocab.courses.courseFlowReviewEmpty') }}</p>
@@ -164,21 +164,21 @@
<div class="lesson-card__header">
<span class="lesson-number">#{{ lesson.lessonNumber }}</span>
<div class="lesson-status-content">
<span v-if="getLessonProgress(lesson.id)?.completed" class="badge completed">
<span v-if="getLessonProgress(lesson.id, lesson)?.completed" class="badge completed">
{{ $t('socialnetwork.vocab.courses.completed') }}
</span>
<span v-else-if="getLessonProgress(lesson.id)?.score" class="score">
{{ $t('socialnetwork.vocab.courses.score') }}: {{ getLessonProgress(lesson.id).score }}%
<span v-else-if="getLessonProgress(lesson.id, lesson)?.score" class="score">
{{ $t('socialnetwork.vocab.courses.score') }}: {{ getLessonProgress(lesson.id, lesson).score }}%
</span>
<span v-else class="status-new">
{{ $t('socialnetwork.vocab.courses.notStarted') }}
</span>
<span
v-if="getReviewBadgeLabel(getLessonProgress(lesson.id))"
v-if="getReviewBadgeLabel(getLessonProgress(lesson.id, lesson))"
class="review-badge"
:class="getReviewBadgeClass(getLessonProgress(lesson.id))"
:class="getReviewBadgeClass(getLessonProgress(lesson.id, lesson))"
>
{{ getReviewBadgeLabel(getLessonProgress(lesson.id)) }}
{{ getReviewBadgeLabel(getLessonProgress(lesson.id, lesson)) }}
</span>
</div>
</div>
@@ -199,10 +199,10 @@
:disabled="!canStartLesson(lesson)"
:title="!canStartLesson(lesson) ? $t('socialnetwork.vocab.courses.previousLessonRequired') : ''"
>
{{ getLessonProgress(lesson.id)?.completed ? $t('socialnetwork.vocab.courses.review') : $t('socialnetwork.vocab.courses.start') }}
{{ getLessonProgress(lesson.id, lesson)?.completed ? $t('socialnetwork.vocab.courses.review') : $t('socialnetwork.vocab.courses.start') }}
</button>
<button
v-if="getLessonProgress(lesson.id)?.completed"
v-if="canShowLessonTrainer(lesson)"
@click="openLessonPractice(lesson)"
class="btn-edit"
>
@@ -306,7 +306,7 @@ export default {
// Finde die erste nicht abgeschlossene Lektion
for (const lesson of this.sortedLessons) {
const progress = this.getLessonProgress(lesson.id);
const progress = this.getLessonProgress(lesson.id, lesson);
if (!progress || !progress.completed) {
return lesson;
}
@@ -321,12 +321,12 @@ export default {
dueReviewLessons() {
return this.sortedLessons
.filter((lesson) => {
const progress = this.getLessonProgress(lesson.id);
const progress = this.getLessonProgress(lesson.id, lesson);
return Boolean(progress?.completed && progress?.reviewDue);
})
.sort((a, b) => {
const left = this.getLessonProgress(a.id)?.reviewNextDueAt || '';
const right = this.getLessonProgress(b.id)?.reviewNextDueAt || '';
const left = this.getLessonProgress(a.id, a)?.reviewNextDueAt || '';
const right = this.getLessonProgress(b.id, b)?.reviewNextDueAt || '';
return left.localeCompare(right);
})
.slice(0, 4);
@@ -340,14 +340,14 @@ export default {
if (lessonBlock !== this.currentBlockNumber) {
return false;
}
return !this.getLessonProgress(lesson.id)?.completed;
return !this.getLessonProgress(lesson.id, lesson)?.completed;
});
},
nextIntensiveReviewLesson() {
return this.sortedLessons.find((lesson) => {
const isIntensive = lesson.pedagogy?.didacticMode === 'intensive_review' || lesson.pedagogy?.isIntensiveReview;
if (!isIntensive) return false;
if (this.getLessonProgress(lesson.id)?.completed) return false;
if (this.getLessonProgress(lesson.id, lesson)?.completed) return false;
const blockNumber = lesson.pedagogy?.blockNumber;
const blockLessons = this.sortedLessons.filter((candidate) => {
@@ -356,18 +356,18 @@ export default {
return !candidateIsIntensive && candidate.lessonNumber < lesson.lessonNumber;
});
return blockLessons.length > 0 && blockLessons.every((candidate) => this.getLessonProgress(candidate.id)?.completed);
return blockLessons.length > 0 && blockLessons.every((candidate) => this.getLessonProgress(candidate.id, candidate)?.completed);
}) || null;
},
freePracticeLessons() {
return this.sortedLessons
.filter((lesson) => {
const progress = this.getLessonProgress(lesson.id);
const progress = this.getLessonProgress(lesson.id, lesson);
return Boolean(progress?.completed) && !progress?.reviewDue;
})
.sort((a, b) => {
const left = this.lastProgressTouch(a.id) || '';
const right = this.lastProgressTouch(b.id) || '';
const left = this.lastProgressTouch(a.id, a) || '';
const right = this.lastProgressTouch(b.id, b) || '';
return right.localeCompare(left);
})
.slice(0, 4);
@@ -439,11 +439,21 @@ export default {
this.assistantSettings = null;
}
},
getLessonProgress(lessonId) {
return this.progress.find(p => p.lessonId === lessonId);
getLessonProgress(lessonId, lesson = null) {
const id = lessonId == null ? NaN : Number(lessonId);
const byId = this.progress.find((p) => Number(p.lessonId) === id);
if (byId) {
return byId;
}
const num = lesson?.lessonNumber;
if (num != null && Number.isFinite(Number(num))) {
const n = Number(num);
return this.progress.find((p) => Number(p.lessonNumber) === n) || null;
}
return null;
},
lastProgressTouch(lessonId) {
const progress = this.getLessonProgress(lessonId);
lastProgressTouch(lessonId, lesson = null) {
const progress = this.getLessonProgress(lessonId, lesson);
return progress?.lastAccessedAt || progress?.completedAt || progress?.updatedAt || '';
},
daysSince(dateString) {
@@ -492,6 +502,41 @@ export default {
}
return this.$t('socialnetwork.vocab.courses.reviewDueSinceDays', { count: diffDays });
},
formatReviewBadgeSchedule(reviewNextDueAt) {
if (!reviewNextDueAt) {
return '';
}
const dueTimestamp = new Date(reviewNextDueAt).getTime();
if (!Number.isFinite(dueTimestamp)) {
return '';
}
const diffMs = dueTimestamp - Date.now();
if (diffMs > 0) {
const untilDays = Math.ceil(diffMs / (24 * 60 * 60 * 1000));
if (untilDays <= 1) {
return this.$t('socialnetwork.vocab.courses.reviewBadgeScheduleTomorrow');
}
return this.$t('socialnetwork.vocab.courses.reviewBadgeScheduleInDays', { count: untilDays });
}
const diffDays = Math.floor((Date.now() - dueTimestamp) / (24 * 60 * 60 * 1000));
if (diffDays <= 0) {
return this.$t('socialnetwork.vocab.courses.reviewBadgeScheduleToday');
}
return this.$t('socialnetwork.vocab.courses.reviewBadgeScheduleOverdue', { count: diffDays });
},
canShowLessonTrainer(lesson) {
const p = this.getLessonProgress(lesson.id, lesson);
if (p?.completed) {
return true;
}
if (this.canStartLesson(lesson)) {
return true;
}
if (p && Number(p.score) > 0) {
return true;
}
return false;
},
getReviewStageLabel(progress) {
const stage = Number(progress?.reviewStage || 0);
if (stage === 0) return this.$t('socialnetwork.vocab.courses.reviewStageDay1');
@@ -511,8 +556,12 @@ export default {
if (progress.reviewCompleted) {
return stageLabel;
}
const dueLabel = this.formatReviewDue(progress.reviewNextDueAt);
return `${stageLabel} · ${dueLabel}`;
if (progress.reviewDue) {
const dueLabel = this.formatReviewDue(progress.reviewNextDueAt);
return `${stageLabel} · ${dueLabel}`;
}
const scheduleLabel = this.formatReviewBadgeSchedule(progress.reviewNextDueAt);
return scheduleLabel ? `${stageLabel} · ${scheduleLabel}` : stageLabel;
},
getReviewBadgeClass(progress) {
if (!progress?.completed) {
@@ -542,7 +591,7 @@ export default {
// Wenn es nicht die erste Lektion ist, prüfe ob die vorherige abgeschlossen wurde
if (currentIndex > 0) {
const previousLesson = this.sortedLessons[currentIndex - 1];
const previousProgress = this.getLessonProgress(previousLesson.id);
const previousProgress = this.getLessonProgress(previousLesson.id, previousLesson);
return previousProgress && previousProgress.completed;
}