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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user