feat(vocab): implement upcoming vocabulary review tracking and update UI accordingly
This commit is contained in:
@@ -2031,6 +2031,21 @@ export default class VocabService {
|
|||||||
this._isTrainableSrsPair(item)
|
this._isTrainableSrsPair(item)
|
||||||
&& (!validKeys.size || validKeys.has(String(item.itemKey || '').trim()))
|
&& (!validKeys.size || validKeys.has(String(item.itemKey || '').trim()))
|
||||||
);
|
);
|
||||||
|
const scheduledRows = await VocabSrsItem.findAll({
|
||||||
|
where: { userId: user.id, courseId: Number(course.id) },
|
||||||
|
order: [['nextDueAt', 'ASC']]
|
||||||
|
});
|
||||||
|
const upcomingByDate = new Map();
|
||||||
|
scheduledRows
|
||||||
|
.filter((item) => (
|
||||||
|
this._isTrainableSrsPair(item)
|
||||||
|
&& (!validKeys.size || validKeys.has(String(item.itemKey || '').trim()))
|
||||||
|
&& new Date(item.nextDueAt).getTime() > now.getTime()
|
||||||
|
))
|
||||||
|
.forEach((item) => {
|
||||||
|
const date = new Date(item.nextDueAt).toISOString().slice(0, 10);
|
||||||
|
upcomingByDate.set(date, (upcomingByDate.get(date) || 0) + 1);
|
||||||
|
});
|
||||||
const rows = validDueRows.slice(0, limit);
|
const rows = validDueRows.slice(0, limit);
|
||||||
const totalDueCount = validDueRows.length;
|
const totalDueCount = validDueRows.length;
|
||||||
|
|
||||||
@@ -2046,6 +2061,7 @@ export default class VocabService {
|
|||||||
totalDueCount,
|
totalDueCount,
|
||||||
dailyLimit: DAILY_SRS_LIMIT,
|
dailyLimit: DAILY_SRS_LIMIT,
|
||||||
limit,
|
limit,
|
||||||
|
upcoming: Array.from(upcomingByDate, ([date, count]) => ({ date, count })).slice(0, 6),
|
||||||
items: rows.map((item) => ({
|
items: rows.map((item) => ({
|
||||||
itemKey: item.itemKey,
|
itemKey: item.itemKey,
|
||||||
courseId: item.courseId,
|
courseId: item.courseId,
|
||||||
|
|||||||
@@ -772,14 +772,13 @@
|
|||||||
"courseFlowBlockDescription": "Hier liegt der nächste reguläre Fortschritt im Kurs.",
|
"courseFlowBlockDescription": "Hier liegt der nächste reguläre Fortschritt im Kurs.",
|
||||||
"courseFlowBlockEmpty": "Der aktuelle Block ist bereits abgeschlossen oder es gibt gerade keine offene Blocklektion.",
|
"courseFlowBlockEmpty": "Der aktuelle Block ist bereits abgeschlossen oder es gibt gerade keine offene Blocklektion.",
|
||||||
"courseFlowIntensiveTitle": "Fällige Intensivphase",
|
"courseFlowIntensiveTitle": "Fällige Intensivphase",
|
||||||
"courseFlowIntensiveDescription": "Verdichtete Wiederholung, sobald der Block davor weitgehend sitzt.",
|
"courseFlowIntensiveDescription": "Zusätzliche Wiederholung für Begriffe, die du in der Tageswiederholung falsch beantwortet hast.",
|
||||||
"courseFlowIntensiveEmpty": "Aktuell ist keine neue Intensivphase freigeschaltet.",
|
"courseFlowIntensiveEmpty": "Aktuell sind keine Begriffe als schwer markiert.",
|
||||||
"courseFlowIntensiveStatusAction": "Freischaltung prüfen",
|
"courseFlowIntensiveStatusAction": "Nächste Wiederholungen prüfen",
|
||||||
"courseFlowIntensiveStatusTitle": "Status der Intensivphase",
|
"courseFlowIntensiveStatusTitle": "Nächste Vokabelwiederholungen",
|
||||||
"courseFlowIntensiveStatusAllDone": "Alle Intensivphasen dieses Kurses sind bereits abgeschlossen.",
|
"courseFlowIntensiveStatusAllDone": "Für abgeschlossene Lektionen sind derzeit keine weiteren Vokabelwiederholungen geplant.",
|
||||||
"courseFlowIntensiveStatusReady": "„{lesson}“ ist bereit und kann jetzt geöffnet werden.",
|
"courseFlowIntensiveStatusProgress": "{count} Begriffe",
|
||||||
"courseFlowIntensiveStatusProgress": "Für „{lesson}“ sind {done} von {total} vorausgehenden Lektionen im selben Block abgeschlossen.",
|
"courseFlowIntensiveStatusNoDate": "Die Termine richten sich nach deinen richtigen Antworten: 1, 3, 7, 14, 30, 60 und 120 Tage. Eine falsche Antwort wird als schwer markiert und kommt zeitnah wieder.",
|
||||||
"courseFlowIntensiveStatusNoDate": "Die Intensivphase wird nicht nach einer Wartezeit freigeschaltet, sondern sofort nach Abschluss dieser Lektionen.",
|
|
||||||
"courseFlowPracticeTitle": "Freie Vertiefung",
|
"courseFlowPracticeTitle": "Freie Vertiefung",
|
||||||
"courseFlowPracticeDescription": "Abgeschlossene Lektionen für lockeres Nachtrainieren außerhalb des Pflichtpfads.",
|
"courseFlowPracticeDescription": "Abgeschlossene Lektionen für lockeres Nachtrainieren außerhalb des Pflichtpfads.",
|
||||||
"courseFlowPracticeEmpty": "Sobald du erste Lektionen abgeschlossen hast, erscheinen sie hier für freies Nachtrainieren.",
|
"courseFlowPracticeEmpty": "Sobald du erste Lektionen abgeschlossen hast, erscheinen sie hier für freies Nachtrainieren.",
|
||||||
|
|||||||
@@ -772,14 +772,13 @@
|
|||||||
"courseFlowBlockDescription": "This is where the next regular progress step in the course lives.",
|
"courseFlowBlockDescription": "This is where the next regular progress step in the course lives.",
|
||||||
"courseFlowBlockEmpty": "The current block is already done or there is no open block lesson right now.",
|
"courseFlowBlockEmpty": "The current block is already done or there is no open block lesson right now.",
|
||||||
"courseFlowIntensiveTitle": "Due intensive review",
|
"courseFlowIntensiveTitle": "Due intensive review",
|
||||||
"courseFlowIntensiveDescription": "Condensed review once the block before it is mostly stable.",
|
"courseFlowIntensiveDescription": "Extra review for terms answered incorrectly in the daily review.",
|
||||||
"courseFlowIntensiveEmpty": "No new intensive review is unlocked right now.",
|
"courseFlowIntensiveEmpty": "No terms are currently marked as hard.",
|
||||||
"courseFlowIntensiveStatusAction": "Check availability",
|
"courseFlowIntensiveStatusAction": "Check upcoming reviews",
|
||||||
"courseFlowIntensiveStatusTitle": "Intensive review status",
|
"courseFlowIntensiveStatusTitle": "Upcoming vocabulary reviews",
|
||||||
"courseFlowIntensiveStatusAllDone": "All intensive review phases in this course are already complete.",
|
"courseFlowIntensiveStatusAllDone": "No further vocabulary reviews are currently scheduled for completed lessons.",
|
||||||
"courseFlowIntensiveStatusReady": "“{lesson}” is ready and can be opened now.",
|
"courseFlowIntensiveStatusProgress": "{count} terms",
|
||||||
"courseFlowIntensiveStatusProgress": "For “{lesson}”, {done} of {total} preceding lessons in the same block are complete.",
|
"courseFlowIntensiveStatusNoDate": "Dates are based on correct answers: 1, 3, 7, 14, 30, 60 and 120 days. An incorrect answer is marked hard and returns soon.",
|
||||||
"courseFlowIntensiveStatusNoDate": "The intensive review unlocks immediately after these lessons are complete; it does not have a waiting period.",
|
|
||||||
"courseFlowPracticeTitle": "Free practice",
|
"courseFlowPracticeTitle": "Free practice",
|
||||||
"courseFlowPracticeDescription": "Completed lessons for relaxed extra practice outside the required path.",
|
"courseFlowPracticeDescription": "Completed lessons for relaxed extra practice outside the required path.",
|
||||||
"courseFlowPracticeEmpty": "As soon as you complete your first lessons, they will appear here for free practice.",
|
"courseFlowPracticeEmpty": "As soon as you complete your first lessons, they will appear here for free practice.",
|
||||||
|
|||||||
@@ -196,22 +196,20 @@
|
|||||||
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveDescription') }}</p>
|
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveDescription') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="nextIntensiveReviewLesson" class="course-flow-card__list">
|
<div v-if="hardVocabCount > 0" class="course-flow-card__list">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="course-flow-lesson"
|
class="course-flow-lesson"
|
||||||
@click="openLesson(nextIntensiveReviewLesson.id)"
|
@click="openHardPractice"
|
||||||
>
|
>
|
||||||
<strong>{{ nextIntensiveReviewLesson.title }}</strong>
|
<strong>{{ $t('socialnetwork.vocab.courses.startHardVocabTrainer', { count: hardVocabCount }) }}</strong>
|
||||||
<span>{{ $t('socialnetwork.vocab.courses.lessonBlockLabel', { number: nextIntensiveReviewLesson.pedagogy?.blockNumber || '—' }) }}</span>
|
<span>{{ $t('socialnetwork.vocab.courses.practiceInTrainer') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<p v-else class="course-flow-card__empty">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveEmpty') }}</p>
|
||||||
<p class="course-flow-card__empty">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveEmpty') }}</p>
|
<button type="button" class="course-flow-card__status" @click="showIntensiveStatusDialog = true">
|
||||||
<button type="button" class="course-flow-card__status" @click="showIntensiveStatusDialog = true">
|
{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusAction') }}
|
||||||
{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusAction') }}
|
</button>
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="course-flow-card">
|
<article class="course-flow-card">
|
||||||
@@ -320,28 +318,14 @@
|
|||||||
<div v-if="showIntensiveStatusDialog" class="dialog-overlay" @click="showIntensiveStatusDialog = false">
|
<div v-if="showIntensiveStatusDialog" class="dialog-overlay" @click="showIntensiveStatusDialog = false">
|
||||||
<section class="dialog intensive-status-dialog" role="dialog" aria-modal="true" @click.stop>
|
<section class="dialog intensive-status-dialog" role="dialog" aria-modal="true" @click.stop>
|
||||||
<h3>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusTitle') }}</h3>
|
<h3>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusTitle') }}</h3>
|
||||||
<template v-if="intensiveReviewStatus.allCompleted">
|
<p class="intensive-status-dialog__hint">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusNoDate') }}</p>
|
||||||
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusAllDone') }}</p>
|
<p v-if="srsUpcoming.length === 0">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusAllDone') }}</p>
|
||||||
</template>
|
<ul v-else class="intensive-status-dialog__list">
|
||||||
<template v-else-if="intensiveReviewStatus.ready">
|
<li v-for="entry in srsUpcoming" :key="entry.date">
|
||||||
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusReady', { lesson: intensiveReviewStatus.lesson.title }) }}</p>
|
<span>{{ formatSrsDate(entry.date) }}</span>
|
||||||
</template>
|
<strong>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusProgress', { count: entry.count }) }}</strong>
|
||||||
<template v-else>
|
</li>
|
||||||
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusProgress', {
|
</ul>
|
||||||
done: intensiveReviewStatus.completedPrerequisiteCount,
|
|
||||||
total: intensiveReviewStatus.prerequisiteLessons.length,
|
|
||||||
lesson: intensiveReviewStatus.lesson.title
|
|
||||||
}) }}</p>
|
|
||||||
<p class="intensive-status-dialog__hint">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusNoDate') }}</p>
|
|
||||||
<ul class="intensive-status-dialog__list">
|
|
||||||
<li v-for="lesson in intensiveReviewStatus.missingPrerequisiteLessons" :key="lesson.id">
|
|
||||||
<span><strong>#{{ lesson.lessonNumber }}</strong> {{ lesson.title }}</span>
|
|
||||||
<button type="button" class="button-secondary" @click="openIntensivePrerequisite(lesson.id)">
|
|
||||||
{{ $t('socialnetwork.vocab.courses.courseTodayPlanOpen') }}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" class="button-secondary" @click="showIntensiveStatusDialog = false">{{ $t('general.close') }}</button>
|
<button type="button" class="button-secondary" @click="showIntensiveStatusDialog = false">{{ $t('general.close') }}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -411,6 +395,7 @@ export default {
|
|||||||
srsDueTotal: 0,
|
srsDueTotal: 0,
|
||||||
srsDailyLimit: 50,
|
srsDailyLimit: 50,
|
||||||
srsTodayRemaining: null,
|
srsTodayRemaining: null,
|
||||||
|
srsUpcoming: [],
|
||||||
srsLoading: false,
|
srsLoading: false,
|
||||||
showIntensiveStatusDialog: false,
|
showIntensiveStatusDialog: false,
|
||||||
showAddLessonDialog: false,
|
showAddLessonDialog: false,
|
||||||
@@ -495,48 +480,6 @@ export default {
|
|||||||
return !this.getLessonProgress(lesson.id, lesson)?.completed;
|
return !this.getLessonProgress(lesson.id, lesson)?.completed;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
nextIntensiveReviewLesson() {
|
|
||||||
return this.sortedLessons.find((lesson) => {
|
|
||||||
const isIntensive = this.isIntensiveReviewLesson(lesson);
|
|
||||||
if (!isIntensive) return false;
|
|
||||||
if (this.getLessonProgress(lesson.id, lesson)?.completed) return false;
|
|
||||||
|
|
||||||
const blockNumber = lesson.pedagogy?.blockNumber;
|
|
||||||
const blockLessons = this.sortedLessons.filter((candidate) => {
|
|
||||||
if ((candidate.pedagogy?.blockNumber || null) !== blockNumber) return false;
|
|
||||||
const candidateIsIntensive = this.isIntensiveReviewLesson(candidate);
|
|
||||||
return !candidateIsIntensive && candidate.lessonNumber < lesson.lessonNumber;
|
|
||||||
});
|
|
||||||
|
|
||||||
return blockLessons.length > 0 && blockLessons.every((candidate) => this.getLessonProgress(candidate.id, candidate)?.completed);
|
|
||||||
}) || null;
|
|
||||||
},
|
|
||||||
intensiveReviewStatus() {
|
|
||||||
const lesson = this.sortedLessons.find((candidate) => (
|
|
||||||
this.isIntensiveReviewLesson(candidate)
|
|
||||||
&& !this.getLessonProgress(candidate.id, candidate)?.completed
|
|
||||||
));
|
|
||||||
if (!lesson) {
|
|
||||||
return { allCompleted: true, ready: false, prerequisiteLessons: [], missingPrerequisiteLessons: [], completedPrerequisiteCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const prerequisiteLessons = this.sortedLessons.filter((candidate) => (
|
|
||||||
!this.isIntensiveReviewLesson(candidate)
|
|
||||||
&& candidate.pedagogy?.blockNumber === lesson.pedagogy?.blockNumber
|
|
||||||
&& candidate.lessonNumber < lesson.lessonNumber
|
|
||||||
));
|
|
||||||
const missingPrerequisiteLessons = prerequisiteLessons.filter((candidate) => (
|
|
||||||
!this.getLessonProgress(candidate.id, candidate)?.completed
|
|
||||||
));
|
|
||||||
return {
|
|
||||||
allCompleted: false,
|
|
||||||
lesson,
|
|
||||||
ready: prerequisiteLessons.length > 0 && missingPrerequisiteLessons.length === 0,
|
|
||||||
prerequisiteLessons,
|
|
||||||
missingPrerequisiteLessons,
|
|
||||||
completedPrerequisiteCount: prerequisiteLessons.length - missingPrerequisiteLessons.length
|
|
||||||
};
|
|
||||||
},
|
|
||||||
freePracticeLessons() {
|
freePracticeLessons() {
|
||||||
return this.sortedLessons
|
return this.sortedLessons
|
||||||
.filter((lesson) => {
|
.filter((lesson) => {
|
||||||
@@ -571,9 +514,6 @@ export default {
|
|||||||
};
|
};
|
||||||
this.dueReviewLessons.forEach((l) => push('review_due', l));
|
this.dueReviewLessons.forEach((l) => push('review_due', l));
|
||||||
this.pickPedagogicalBlockSteps().forEach((l) => push('block', l));
|
this.pickPedagogicalBlockSteps().forEach((l) => push('block', l));
|
||||||
if (this.nextIntensiveReviewLesson) {
|
|
||||||
push('intensive', this.nextIntensiveReviewLesson);
|
|
||||||
}
|
|
||||||
return out.slice(0, 8);
|
return out.slice(0, 8);
|
||||||
},
|
},
|
||||||
/** Optional: nur wenn kein didaktischer Pflichtplan – semantisch „könntest du, Pause oft besser“. */
|
/** Optional: nur wenn kein didaktischer Pflichtplan – semantisch „könntest du, Pause oft besser“. */
|
||||||
@@ -629,9 +569,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
isIntensiveReviewLesson(lesson) {
|
|
||||||
return lesson?.pedagogy?.didacticMode === 'intensive_review' || Boolean(lesson?.pedagogy?.isIntensiveReview);
|
|
||||||
},
|
|
||||||
displayCourseTitle(course) {
|
displayCourseTitle(course) {
|
||||||
return localizeVocabCourseTitle(course?.title, this.$i18n?.locale) || '';
|
return localizeVocabCourseTitle(course?.title, this.$i18n?.locale) || '';
|
||||||
},
|
},
|
||||||
@@ -729,11 +666,13 @@ export default {
|
|||||||
this.srsDueItems = Array.isArray(data?.items) ? data.items : [];
|
this.srsDueItems = Array.isArray(data?.items) ? data.items : [];
|
||||||
this.srsDueTotal = Number.isFinite(Number(data?.totalDueCount)) ? Number(data.totalDueCount) : this.srsDueItems.length;
|
this.srsDueTotal = Number.isFinite(Number(data?.totalDueCount)) ? Number(data.totalDueCount) : this.srsDueItems.length;
|
||||||
this.srsDailyLimit = Number.isFinite(Number(data?.dailyLimit)) ? Number(data.dailyLimit) : 50;
|
this.srsDailyLimit = Number.isFinite(Number(data?.dailyLimit)) ? Number(data.dailyLimit) : 50;
|
||||||
|
this.srsUpcoming = Array.isArray(data?.upcoming) ? data.upcoming : [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Konnte SRS-Fälligkeiten nicht laden:', e);
|
console.warn('Konnte SRS-Fälligkeiten nicht laden:', e);
|
||||||
this.srsDueItems = [];
|
this.srsDueItems = [];
|
||||||
this.srsDueTotal = 0;
|
this.srsDueTotal = 0;
|
||||||
this.srsDailyLimit = 50;
|
this.srsDailyLimit = 50;
|
||||||
|
this.srsUpcoming = [];
|
||||||
} finally {
|
} finally {
|
||||||
this.srsLoading = false;
|
this.srsLoading = false;
|
||||||
this.syncSrsSessionProgress();
|
this.syncSrsSessionProgress();
|
||||||
@@ -1001,9 +940,9 @@ export default {
|
|||||||
openLesson(lessonId) {
|
openLesson(lessonId) {
|
||||||
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
|
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
|
||||||
},
|
},
|
||||||
openIntensivePrerequisite(lessonId) {
|
formatSrsDate(value) {
|
||||||
this.showIntensiveStatusDialog = false;
|
const date = new Date(`${value}T12:00:00`);
|
||||||
this.openLesson(lessonId);
|
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat(this.$i18n?.locale || 'de-DE', { dateStyle: 'full' }).format(date);
|
||||||
},
|
},
|
||||||
todayPlanStepLabel(type) {
|
todayPlanStepLabel(type) {
|
||||||
const key = {
|
const key = {
|
||||||
|
|||||||
Reference in New Issue
Block a user