diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js
index 7b11b30..9d07935 100644
--- a/backend/services/vocabService.js
+++ b/backend/services/vocabService.js
@@ -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();
}
diff --git a/frontend/src/views/social/VocabCourseView.vue b/frontend/src/views/social/VocabCourseView.vue
index 5c9e8ec..5c24bbf 100644
--- a/frontend/src/views/social/VocabCourseView.vue
+++ b/frontend/src/views/social/VocabCourseView.vue
@@ -38,6 +38,112 @@
+
+
+
+
+
+
+
1
+
+
Fällige Wiederholung
+
Bereits abgeschlossene Lektionen, die heute wieder drankommen sollten.
+
+
+
+
+
+ Heute ist keine ältere Lektion als fällige Wiederholung markiert.
+
+
+
+
+
2
+
+
Aktueller Block
+
Hier liegt der nächste reguläre Fortschritt im Kurs.
+
+
+
+
+
+ Der aktuelle Block ist bereits abgeschlossen oder es gibt gerade keine offene Blocklektion.
+
+
+
+
+
3
+
+
Fällige Intensivphase
+
Verdichtete Wiederholung, sobald der Block davor weitgehend sitzt.
+
+
+
+
+
+ Aktuell ist keine neue Intensivphase freigeschaltet.
+
+
+
+
+
4
+
+
Freie Vertiefung
+
Abgeschlossene Lektionen für lockeres Nachtrainieren außerhalb des Pflichtpfads.
+
+
+
+
+
+ Sobald du erste Lektionen abgeschlossen hast, erscheinen sie hier für freies Nachtrainieren.
+
+
+
+
@@ -54,7 +160,7 @@
{{ course.lessons.length }} Lektionen
-
+
@@ -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;
}
diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue
index afa87a4..0a59d40 100644
--- a/frontend/src/views/social/VocabLessonView.vue
+++ b/frontend/src/views/social/VocabLessonView.vue
@@ -54,6 +54,17 @@
{{ effectiveExercises?.length || 0 }} {{ $t('socialnetwork.vocab.courses.exercisesShort') }}
+
+
+ {{ lessonReviewBadgeLabel }}
+ {{ lessonReviewHeadline }}
+
+
{{ lessonReviewHint }}
+
= 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;