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();
}

View File

@@ -38,6 +38,112 @@
</div>
</section>
<section v-if="course.lessons && course.lessons.length > 0" class="surface-card course-flow">
<div class="course-flow__header">
<div>
<span class="course-flow__eyebrow">Tagesfluss</span>
<h3>Heute sinnvoll weitermachen</h3>
<p>Die Reihenfolge folgt dem Konzept: fällige Wiederholung zuerst, dann aktueller Block, danach Intensivphase und freie Vertiefung.</p>
</div>
<div class="course-flow__stats">
<span class="course-flow__stat">Fällige Wiederholung: {{ dueReviewLessons.length }}</span>
<span class="course-flow__stat">Aktiver Block: {{ currentBlockNumber || '—' }}</span>
</div>
</div>
<div class="course-flow__grid">
<article class="course-flow-card">
<div class="course-flow-card__top">
<span class="course-flow-card__badge course-flow-card__badge--review">1</span>
<div>
<h4>Fällige Wiederholung</h4>
<p>Bereits abgeschlossene Lektionen, die heute wieder drankommen sollten.</p>
</div>
</div>
<div v-if="dueReviewLessons.length > 0" class="course-flow-card__list">
<button
v-for="lesson in dueReviewLessons"
:key="`due-${lesson.id}`"
type="button"
class="course-flow-lesson"
@click="openLesson(lesson.id)"
>
<strong>{{ lesson.title }}</strong>
<span>{{ formatReviewDue(getLessonProgress(lesson.id)?.reviewNextDueAt) }}</span>
</button>
</div>
<p v-else class="course-flow-card__empty">Heute ist keine ältere Lektion als fällige Wiederholung markiert.</p>
</article>
<article class="course-flow-card">
<div class="course-flow-card__top">
<span class="course-flow-card__badge course-flow-card__badge--block">2</span>
<div>
<h4>Aktueller Block</h4>
<p>Hier liegt der nächste reguläre Fortschritt im Kurs.</p>
</div>
</div>
<div v-if="currentBlockLessons.length > 0" class="course-flow-card__list">
<button
v-for="lesson in currentBlockLessons"
:key="`block-${lesson.id}`"
type="button"
class="course-flow-lesson"
@click="openLesson(lesson.id)"
>
<strong>{{ lesson.title }}</strong>
<span>#{{ lesson.lessonNumber }}</span>
</button>
</div>
<p v-else class="course-flow-card__empty">Der aktuelle Block ist bereits abgeschlossen oder es gibt gerade keine offene Blocklektion.</p>
</article>
<article class="course-flow-card">
<div class="course-flow-card__top">
<span class="course-flow-card__badge course-flow-card__badge--intensive">3</span>
<div>
<h4>Fällige Intensivphase</h4>
<p>Verdichtete Wiederholung, sobald der Block davor weitgehend sitzt.</p>
</div>
</div>
<div v-if="nextIntensiveReviewLesson" class="course-flow-card__list">
<button
type="button"
class="course-flow-lesson"
@click="openLesson(nextIntensiveReviewLesson.id)"
>
<strong>{{ nextIntensiveReviewLesson.title }}</strong>
<span>Block {{ nextIntensiveReviewLesson.pedagogy?.blockNumber || '—' }}</span>
</button>
</div>
<p v-else class="course-flow-card__empty">Aktuell ist keine neue Intensivphase freigeschaltet.</p>
</article>
<article class="course-flow-card">
<div class="course-flow-card__top">
<span class="course-flow-card__badge course-flow-card__badge--practice">4</span>
<div>
<h4>Freie Vertiefung</h4>
<p>Abgeschlossene Lektionen für lockeres Nachtrainieren außerhalb des Pflichtpfads.</p>
</div>
</div>
<div v-if="freePracticeLessons.length > 0" class="course-flow-card__list">
<button
v-for="lesson in freePracticeLessons"
:key="`practice-${lesson.id}`"
type="button"
class="course-flow-lesson"
@click="openLessonPractice(lesson)"
>
<strong>{{ lesson.title }}</strong>
<span>Im Trainer üben</span>
</button>
</div>
<p v-else class="course-flow-card__empty">Sobald du erste Lektionen abgeschlossen hast, erscheinen sie hier für freies Nachtrainieren.</p>
</article>
</div>
</section>
<div v-if="isOwner" class="owner-actions">
<button @click="showAddLessonDialog = true">{{ $t('socialnetwork.vocab.courses.addLesson') }}</button>
<button @click="editCourse">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
@@ -54,7 +160,7 @@
<span class="lessons-count">{{ course.lessons.length }} Lektionen</span>
</div>
<div class="lesson-cards">
<article v-for="lesson in course.lessons" :key="lesson.id" class="lesson-card">
<article v-for="lesson in sortedLessons" :key="lesson.id" class="lesson-card">
<div class="lesson-card__header">
<span class="lesson-number">#{{ lesson.lessonNumber }}</span>
<div class="lesson-status-content">
@@ -67,6 +173,13 @@
<span v-else class="status-new">
{{ $t('socialnetwork.vocab.courses.notStarted') }}
</span>
<span
v-if="getReviewBadgeLabel(getLessonProgress(lesson.id))"
class="review-badge"
:class="getReviewBadgeClass(getLessonProgress(lesson.id))"
>
{{ getReviewBadgeLabel(getLessonProgress(lesson.id)) }}
</span>
</div>
</div>
<div class="lesson-title-content">
@@ -180,24 +293,84 @@ export default {
isOwner() {
return this.course && this.course.ownerUserId === this.user?.id;
},
sortedLessons() {
if (!this.course?.lessons) {
return [];
}
return [...this.course.lessons].sort((a, b) => a.lessonNumber - b.lessonNumber);
},
currentLesson() {
if (!this.course || !this.course.lessons || this.course.lessons.length === 0) {
if (this.sortedLessons.length === 0) {
return null;
}
// Sortiere Lektionen nach lessonNumber
const sortedLessons = [...this.course.lessons].sort((a, b) => a.lessonNumber - b.lessonNumber);
// Finde die erste nicht abgeschlossene Lektion
for (const lesson of sortedLessons) {
for (const lesson of this.sortedLessons) {
const progress = this.getLessonProgress(lesson.id);
if (!progress || !progress.completed) {
return lesson;
}
}
// Alle Lektionen abgeschlossen - zeige die letzte Lektion
return sortedLessons[sortedLessons.length - 1];
return this.sortedLessons[this.sortedLessons.length - 1];
},
currentBlockNumber() {
return this.currentLesson?.pedagogy?.blockNumber || null;
},
dueReviewLessons() {
return this.sortedLessons
.filter((lesson) => {
const progress = this.getLessonProgress(lesson.id);
return Boolean(progress?.completed && progress?.reviewDue);
})
.sort((a, b) => {
const left = this.getLessonProgress(a.id)?.reviewNextDueAt || '';
const right = this.getLessonProgress(b.id)?.reviewNextDueAt || '';
return left.localeCompare(right);
})
.slice(0, 4);
},
currentBlockLessons() {
if (!this.currentBlockNumber) {
return [];
}
return this.sortedLessons.filter((lesson) => {
const lessonBlock = lesson.pedagogy?.blockNumber;
if (lessonBlock !== this.currentBlockNumber) {
return false;
}
return !this.getLessonProgress(lesson.id)?.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;
const blockNumber = lesson.pedagogy?.blockNumber;
const blockLessons = this.sortedLessons.filter((candidate) => {
if ((candidate.pedagogy?.blockNumber || null) !== blockNumber) return false;
const candidateIsIntensive = candidate.pedagogy?.didacticMode === 'intensive_review' || candidate.pedagogy?.isIntensiveReview;
return !candidateIsIntensive && candidate.lessonNumber < lesson.lessonNumber;
});
return blockLessons.length > 0 && blockLessons.every((candidate) => this.getLessonProgress(candidate.id)?.completed);
}) || null;
},
freePracticeLessons() {
return this.sortedLessons
.filter((lesson) => {
const progress = this.getLessonProgress(lesson.id);
return Boolean(progress?.completed) && !progress?.reviewDue;
})
.sort((a, b) => {
const left = this.lastProgressTouch(a.id) || '';
const right = this.lastProgressTouch(b.id) || '';
return right.localeCompare(left);
})
.slice(0, 4);
},
isLessonNumberValid() {
return Number(this.newLesson.lessonNumber) > 0;
@@ -269,29 +442,110 @@ export default {
getLessonProgress(lessonId) {
return this.progress.find(p => p.lessonId === lessonId);
},
lastProgressTouch(lessonId) {
const progress = this.getLessonProgress(lessonId);
return progress?.lastAccessedAt || progress?.completedAt || progress?.updatedAt || '';
},
daysSince(dateString) {
if (!dateString) {
return 0;
}
const timestamp = new Date(dateString).getTime();
if (!Number.isFinite(timestamp)) {
return 0;
}
const diff = Date.now() - timestamp;
return Math.max(0, Math.floor(diff / (24 * 60 * 60 * 1000)));
},
formatDaysSince(dateString) {
const days = this.daysSince(dateString);
if (days <= 0) {
return 'heute';
}
if (days === 1) {
return 'seit 1 Tag';
}
return `seit ${days} Tagen`;
},
formatReviewDue(reviewNextDueAt) {
if (!reviewNextDueAt) {
return 'jetzt fällig';
}
const dueTimestamp = new Date(reviewNextDueAt).getTime();
if (!Number.isFinite(dueTimestamp)) {
return 'jetzt fällig';
}
const diffMs = dueTimestamp - Date.now();
if (diffMs > 0) {
const untilDays = Math.ceil(diffMs / (24 * 60 * 60 * 1000));
if (untilDays <= 1) {
return 'morgen fällig';
}
return `in ${untilDays} Tagen fällig`;
}
const diffDays = Math.floor((Date.now() - dueTimestamp) / (24 * 60 * 60 * 1000));
if (diffDays <= 0) {
return 'heute fällig';
}
if (diffDays === 1) {
return 'seit 1 Tag fällig';
}
return `seit ${diffDays} Tagen fällig`;
},
getReviewStageLabel(progress) {
const stage = Number(progress?.reviewStage || 0);
if (stage === 0) return 'Tag 1';
if (stage === 1) return 'Tag 3';
if (stage === 2) return 'Tag 7';
if (stage >= 3) return 'Review abgeschlossen';
return '';
},
getReviewBadgeLabel(progress) {
if (!progress?.completed) {
return '';
}
const stageLabel = this.getReviewStageLabel(progress);
if (!stageLabel) {
return '';
}
if (progress.reviewCompleted) {
return stageLabel;
}
const dueLabel = this.formatReviewDue(progress.reviewNextDueAt);
return `${stageLabel} · ${dueLabel}`;
},
getReviewBadgeClass(progress) {
if (!progress?.completed) {
return '';
}
if (progress.reviewCompleted) {
return 'review-badge--done';
}
if (progress.reviewDue) {
return 'review-badge--due';
}
return 'review-badge--scheduled';
},
canStartLesson(lesson) {
if (!this.course || !this.course.lessons) {
return false;
}
// Sortiere Lektionen nach lessonNumber
const sortedLessons = [...this.course.lessons].sort((a, b) => a.lessonNumber - b.lessonNumber);
// Finde den Index der aktuellen Lektion
const currentIndex = sortedLessons.findIndex(l => l.id === lesson.id);
const currentIndex = this.sortedLessons.findIndex(l => l.id === lesson.id);
// Die erste Lektion kann immer gestartet werden
if (currentIndex === 0) {
return true;
}
// Wenn es nicht die erste Lektion ist, prüfe ob die vorherige abgeschlossen wurde
if (currentIndex > 0) {
const previousLesson = sortedLessons[currentIndex - 1];
const previousLesson = this.sortedLessons[currentIndex - 1];
const previousProgress = this.getLessonProgress(previousLesson.id);
return previousProgress && previousProgress.completed;
}
return false;
},
async addLesson() {
@@ -406,6 +660,7 @@ export default {
.course-hero,
.course-info,
.course-assistant,
.course-flow,
.lessons-list,
.course-state {
margin-bottom: 16px;
@@ -475,6 +730,150 @@ export default {
flex-wrap: wrap;
}
.course-flow {
padding: 20px;
}
.course-flow__header {
display: flex;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.course-flow__eyebrow {
display: inline-block;
margin-bottom: 8px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.course-flow__header h3,
.course-flow__header p {
margin: 0;
}
.course-flow__header p {
margin-top: 6px;
color: var(--color-text-secondary);
}
.course-flow__stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.course-flow__stat {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(93, 64, 55, 0.08);
color: #6d5446;
font-size: 0.82rem;
font-weight: 700;
}
.course-flow__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.course-flow-card {
padding: 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.72);
}
.course-flow-card__top {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.course-flow-card__top h4,
.course-flow-card__top p {
margin: 0;
}
.course-flow-card__top p {
margin-top: 4px;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.course-flow-card__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
font-weight: 800;
}
.course-flow-card__badge--review {
background: rgba(68, 138, 86, 0.16);
color: #2f6b3d;
}
.course-flow-card__badge--block {
background: rgba(248, 162, 43, 0.18);
color: #8a5411;
}
.course-flow-card__badge--intensive {
background: rgba(207, 78, 78, 0.16);
color: #a13f3f;
}
.course-flow-card__badge--practice {
background: rgba(34, 96, 164, 0.14);
color: #21598f;
}
.course-flow-card__list {
display: grid;
gap: 8px;
}
.course-flow-lesson {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: #fff;
text-align: left;
cursor: pointer;
}
.course-flow-lesson strong {
font-size: 0.95rem;
}
.course-flow-lesson span {
color: var(--color-text-secondary);
font-size: 0.84rem;
white-space: nowrap;
}
.course-flow-card__empty {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.92rem;
}
.share-code {
font-family: monospace;
}
@@ -663,6 +1062,31 @@ export default {
font-style: italic;
}
.review-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78em;
font-weight: 700;
line-height: 1.2;
}
.review-badge--scheduled {
background: rgba(34, 96, 164, 0.12);
color: #21598f;
}
.review-badge--due {
background: rgba(185, 99, 24, 0.14);
color: #8d5412;
}
.review-badge--done {
background: rgba(68, 138, 86, 0.14);
color: #2f6b3d;
}
.lesson-actions {
display: block;
}
@@ -787,6 +1211,10 @@ export default {
}
@media (max-width: 640px) {
.course-flow__grid {
grid-template-columns: 1fr;
}
.course-assistant {
flex-direction: column;
}

View File

@@ -54,6 +54,17 @@
<strong>{{ effectiveExercises?.length || 0 }} {{ $t('socialnetwork.vocab.courses.exercisesShort') }}</strong>
</div>
</div>
<div
v-if="lessonReviewBadgeLabel"
class="lesson-review-status"
:class="lessonReviewStatusClass"
>
<div class="lesson-review-status__top">
<span class="lesson-review-status__badge">{{ lessonReviewBadgeLabel }}</span>
<strong>{{ lessonReviewHeadline }}</strong>
</div>
<p>{{ lessonReviewHint }}</p>
</div>
<details
v-if="lessonPedagogy.phaseLabel || lessonPedagogy.newUnitTarget || lessonPedagogy.reviewWeight != null"
class="lesson-overview-more"
@@ -1212,6 +1223,57 @@ export default {
isIntensiveReview: false
};
},
lessonProgress() {
return this.lesson?.progress || null;
},
lessonReviewBadgeLabel() {
const progress = this.lessonProgress;
if (!progress?.completed) {
return '';
}
const stage = Number(progress.reviewStage || 0);
if (stage === 0) return 'Tag 1';
if (stage === 1) return 'Tag 3';
if (stage === 2) return 'Tag 7';
if (stage >= 3) return 'Review abgeschlossen';
return '';
},
lessonReviewStatusClass() {
const progress = this.lessonProgress;
if (!progress?.completed) {
return '';
}
if (progress.reviewCompleted) {
return 'lesson-review-status--done';
}
if (progress.reviewDue) {
return 'lesson-review-status--due';
}
return 'lesson-review-status--scheduled';
},
lessonReviewHeadline() {
const progress = this.lessonProgress;
if (!progress?.completed) {
return '';
}
if (progress.reviewCompleted) {
return 'Diese Lektion ist in der freien Vertiefung angekommen.';
}
if (progress.reviewDue) {
return 'Diese Review-Welle ist jetzt fällig.';
}
return 'Diese Lektion ist für die nächste Review-Welle vorgemerkt.';
},
lessonReviewHint() {
const progress = this.lessonProgress;
if (!progress?.completed) {
return '';
}
if (progress.reviewCompleted) {
return 'Die 1/3/7-Tage-Wiederholung ist abgeschlossen. Du kannst die Lektion jetzt flexibel weitertrainieren.';
}
return `Nächste Fälligkeit: ${this.formatLessonReviewDue(progress.reviewNextDueAt)}.`;
},
assistantAvailable() {
if (!this.assistantSettings) {
return false;
@@ -1993,6 +2055,31 @@ export default {
}
return this.$t('socialnetwork.vocab.courses.durationMinutes', { minutes });
},
formatLessonReviewDue(reviewNextDueAt) {
if (!reviewNextDueAt) {
return 'jetzt';
}
const dueTimestamp = new Date(reviewNextDueAt).getTime();
if (!Number.isFinite(dueTimestamp)) {
return 'jetzt';
}
const diffMs = dueTimestamp - Date.now();
if (diffMs > 0) {
const untilDays = Math.ceil(diffMs / (24 * 60 * 60 * 1000));
if (untilDays <= 1) {
return 'morgen';
}
return `in ${untilDays} Tagen`;
}
const overdueDays = Math.floor((Date.now() - dueTimestamp) / (24 * 60 * 60 * 1000));
if (overdueDays <= 0) {
return 'heute';
}
if (overdueDays === 1) {
return 'seit 1 Tag';
}
return `seit ${overdueDays} Tagen`;
},
getQuestionData(exercise) {
if (!exercise.questionData) return null;
return typeof exercise.questionData === 'string'
@@ -3040,6 +3127,61 @@ export default {
border-radius: 10px;
}
.lesson-review-status {
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(34, 96, 164, 0.18);
background: rgba(235, 244, 255, 0.86);
color: #21598f;
}
.lesson-review-status__top {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.lesson-review-status__badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
background: rgba(34, 96, 164, 0.14);
font-size: 0.78rem;
font-weight: 700;
}
.lesson-review-status strong,
.lesson-review-status p {
margin: 0;
}
.lesson-review-status p {
margin-top: 8px;
color: inherit;
}
.lesson-review-status--due {
border-color: rgba(185, 99, 24, 0.22);
background: rgba(255, 246, 226, 0.92);
color: #8d5412;
}
.lesson-review-status--due .lesson-review-status__badge {
background: rgba(185, 99, 24, 0.16);
}
.lesson-review-status--done {
border-color: rgba(68, 138, 86, 0.22);
background: rgba(239, 250, 242, 0.92);
color: #2f6b3d;
}
.lesson-review-status--done .lesson-review-status__badge {
background: rgba(68, 138, 86, 0.14);
}
.lesson-meta-label {
display: block;
margin-bottom: 6px;