From e2c1147d7594a50104586297eabc6b3f6c9de087 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Apr 2026 09:14:30 +0200 Subject: [PATCH] feat(vocab): implement SRS features and enhance vocabulary management - Added new endpoints in vocabController for retrieving SRS due items and reviewing SRS items, improving spaced repetition support. - Updated vocabService to handle SRS item creation and scheduling, ensuring effective tracking of vocabulary exposure. - Enhanced vocabRouter with new routes for SRS functionalities, facilitating user interaction with spaced repetition features. - Modified VocabPracticeDialog and VocabCourseView to integrate SRS due items, providing users with timely review opportunities. - Updated translations and UI elements to reflect new SRS features, enhancing user experience and accessibility. --- backend/controllers/vocabController.js | 7 + .../20260417000000-add-vocab-srs-item.cjs | 49 +++ backend/models/associations.js | 8 + backend/models/community/vocab_srs_item.js | 94 ++++++ backend/models/index.js | 2 + backend/routers/vocabRouter.js | 2 + backend/services/vocabService.js | 306 +++++++++++++++++- backend/sql/add_vocab_srs_item.sql | 35 ++ .../socialnetwork/VocabPracticeDialog.vue | 33 +- .../src/i18n/locales/ceb/socialnetwork.json | 7 +- .../src/i18n/locales/de/socialnetwork.json | 13 +- .../src/i18n/locales/en/socialnetwork.json | 13 +- frontend/src/views/social/VocabCourseView.vue | 77 +++++ frontend/src/views/social/VocabLessonView.vue | 18 ++ 14 files changed, 648 insertions(+), 16 deletions(-) create mode 100644 backend/migrations/20260417000000-add-vocab-srs-item.cjs create mode 100644 backend/models/community/vocab_srs_item.js create mode 100644 backend/sql/add_vocab_srs_item.sql diff --git a/backend/controllers/vocabController.js b/backend/controllers/vocabController.js index b02b27a..db5591f 100644 --- a/backend/controllers/vocabController.js +++ b/backend/controllers/vocabController.js @@ -40,6 +40,13 @@ class VocabController { this.getVocabDistractorPool = this._wrapWithUser((userId, req) => this.service.getVocabDistractorPool(userId, req.params.courseId, req.query.beforeLessonId) ); + this.getCourseSrsDue = this._wrapWithUser((userId, req) => + this.service.getCourseSrsDue(userId, req.params.courseId, req.query) + ); + this.reviewSrsItem = this._wrapWithUser((userId, req) => + this.service.reviewSrsItem(userId, req.body), + { successStatus: 201 } + ); this.getCourseByShareCode = this._wrapWithUser((userId, req) => this.service.getCourseByShareCode(userId, req.body.shareCode)); this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body)); this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId)); diff --git a/backend/migrations/20260417000000-add-vocab-srs-item.cjs b/backend/migrations/20260417000000-add-vocab-srs-item.cjs new file mode 100644 index 0000000..c92d499 --- /dev/null +++ b/backend/migrations/20260417000000-add-vocab-srs-item.cjs @@ -0,0 +1,49 @@ +'use strict'; + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS community.vocab_srs_item ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES community."user"(id) ON DELETE CASCADE, + course_id INTEGER NOT NULL REFERENCES community.vocab_course(id) ON DELETE CASCADE, + lesson_id INTEGER NULL REFERENCES community.vocab_course_lesson(id) ON DELETE SET NULL, + item_key VARCHAR(80) NOT NULL, + learning TEXT NOT NULL, + reference TEXT NOT NULL, + direction VARCHAR(8) NOT NULL DEFAULT 'BOTH', + stage INTEGER NOT NULL DEFAULT 0, + interval_days INTEGER NOT NULL DEFAULT 0, + last_reviewed_at TIMESTAMP WITH TIME ZONE NULL, + next_due_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + correct_count INTEGER NOT NULL DEFAULT 0, + wrong_count INTEGER NOT NULL DEFAULT 0, + lapse_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_srs_item_user_key_unique UNIQUE (user_id, item_key) + ); + + CREATE INDEX IF NOT EXISTS idx_vocab_srs_item_due + ON community.vocab_srs_item (user_id, course_id, next_due_at); + + CREATE INDEX IF NOT EXISTS idx_vocab_srs_item_lesson + ON community.vocab_srs_item (user_id, course_id, lesson_id); + + COMMENT ON TABLE community.vocab_srs_item IS + 'Nutzerbezogener SRS-Fortschritt pro Vokabel/Phrase aus Sprachkursen.'; + COMMENT ON COLUMN community.vocab_srs_item.item_key IS + 'Stabiler deterministischer Schlüssel aus Kurs, Lektion und normalisiertem Begriffspaar.'; + COMMENT ON COLUMN community.vocab_srs_item.stage IS + 'SRS-Stufe. Höhere Stufen bedeuten längere Wiederholungsintervalle.'; + COMMENT ON COLUMN community.vocab_srs_item.next_due_at IS + 'Zeitpunkt, zu dem das Item wieder fällig ist.'; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP TABLE IF EXISTS community.vocab_srs_item; + `); + } +}; diff --git a/backend/models/associations.js b/backend/models/associations.js index 7ba66c1..d4e654c 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -126,6 +126,7 @@ import VocabCourseProgress from './community/vocab_course_progress.js'; import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js'; import VocabGrammarExercise from './community/vocab_grammar_exercise.js'; import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js'; +import VocabSrsItem from './community/vocab_srs_item.js'; import CalendarEvent from './community/calendar_event.js'; import Campaign from './match3/campaign.js'; import Match3Level from './match3/level.js'; @@ -1176,6 +1177,13 @@ export default function setupAssociations() { User.hasMany(VocabGrammarExerciseProgress, { foreignKey: 'userId', as: 'grammarExerciseProgress' }); VocabGrammarExerciseProgress.belongsTo(VocabGrammarExercise, { foreignKey: 'exerciseId', as: 'exercise' }); VocabGrammarExercise.hasMany(VocabGrammarExerciseProgress, { foreignKey: 'exerciseId', as: 'progress' }); + + VocabSrsItem.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + User.hasMany(VocabSrsItem, { foreignKey: 'userId', as: 'vocabSrsItems' }); + VocabSrsItem.belongsTo(VocabCourse, { foreignKey: 'courseId', as: 'course' }); + VocabCourse.hasMany(VocabSrsItem, { foreignKey: 'courseId', as: 'srsItems' }); + VocabSrsItem.belongsTo(VocabCourseLesson, { foreignKey: 'lessonId', as: 'lesson' }); + VocabCourseLesson.hasMany(VocabSrsItem, { foreignKey: 'lessonId', as: 'srsItems' }); // Calendar associations CalendarEvent.belongsTo(User, { foreignKey: 'userId', as: 'user' }); diff --git a/backend/models/community/vocab_srs_item.js b/backend/models/community/vocab_srs_item.js new file mode 100644 index 0000000..b5c2bbd --- /dev/null +++ b/backend/models/community/vocab_srs_item.js @@ -0,0 +1,94 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../utils/sequelize.js'; + +class VocabSrsItem extends Model {} + +VocabSrsItem.init({ + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id' + }, + courseId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'course_id' + }, + lessonId: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'lesson_id' + }, + itemKey: { + type: DataTypes.STRING(80), + allowNull: false, + field: 'item_key' + }, + learning: { + type: DataTypes.TEXT, + allowNull: false + }, + reference: { + type: DataTypes.TEXT, + allowNull: false + }, + direction: { + type: DataTypes.STRING(8), + allowNull: false, + defaultValue: 'BOTH' + }, + stage: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + }, + intervalDays: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: 'interval_days' + }, + lastReviewedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'last_reviewed_at' + }, + nextDueAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'next_due_at' + }, + correctCount: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: 'correct_count' + }, + wrongCount: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: 'wrong_count' + }, + lapseCount: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: 'lapse_count' + } +}, { + sequelize, + modelName: 'VocabSrsItem', + tableName: 'vocab_srs_item', + schema: 'community', + timestamps: true, + underscored: true +}); + +export default VocabSrsItem; diff --git a/backend/models/index.js b/backend/models/index.js index 353915a..85891cf 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -156,6 +156,7 @@ import VocabCourseProgress from './community/vocab_course_progress.js'; import VocabGrammarExerciseType from './community/vocab_grammar_exercise_type.js'; import VocabGrammarExercise from './community/vocab_grammar_exercise.js'; import VocabGrammarExerciseProgress from './community/vocab_grammar_exercise_progress.js'; +import VocabSrsItem from './community/vocab_srs_item.js'; import CalendarEvent from './community/calendar_event.js'; const models = { @@ -318,6 +319,7 @@ const models = { VocabGrammarExerciseType, VocabGrammarExercise, VocabGrammarExerciseProgress, + VocabSrsItem, // Calendar CalendarEvent, diff --git a/backend/routers/vocabRouter.js b/backend/routers/vocabRouter.js index 3159cb8..349f990 100644 --- a/backend/routers/vocabRouter.js +++ b/backend/routers/vocabRouter.js @@ -35,9 +35,11 @@ router.post('/courses/find-by-code', vocabController.getCourseByShareCode); router.get('/courses/:courseId/completed-lesson-vocabs', vocabController.getCompletedLessonVocabPool); router.get('/courses/:courseId/dictionary', vocabController.getCourseDictionary); router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool); +router.get('/courses/:courseId/srs/due', vocabController.getCourseSrsDue); router.get('/courses/:courseId', vocabController.getCourse); router.put('/courses/:courseId', vocabController.updateCourse); router.delete('/courses/:courseId', vocabController.deleteCourse); +router.post('/srs/review', vocabController.reviewSrsItem); // Lessons router.post('/courses/:courseId/lessons', vocabController.addLessonToCourse); diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 8540d6e..1d3bd5d 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -7,6 +7,7 @@ import VocabCourseProgress from '../models/community/vocab_course_progress.js'; import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js'; import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js'; import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js'; +import VocabSrsItem from '../models/community/vocab_srs_item.js'; import UserParamType from '../models/type/user_param.js'; import UserParam from '../models/community/user_param.js'; import { sequelize } from '../utils/sequelize.js'; @@ -15,6 +16,144 @@ import { Op } from 'sequelize'; import { BISAYA_PHASE1_DIDACTICS, BISAYA_DIDACTICS_FRAGMENTS } from '../scripts/bisaya-course-phase1.js'; export default class VocabService { + _normalizeSrsText(value) { + return String(value || '') + .trim() + .toLowerCase() + .normalize('NFKC') + .replace(/[\p{P}\p{S}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + + _buildSrsItemKey({ courseId, lessonId = null, learning, reference, direction = 'BOTH' }) { + const raw = [ + Number(courseId) || 0, + lessonId == null ? 'course' : Number(lessonId) || 0, + String(direction || 'BOTH').toUpperCase(), + this._normalizeSrsText(learning), + this._normalizeSrsText(reference) + ].join('|'); + return crypto.createHash('sha1').update(raw).digest('hex'); + } + + _decorateSrsVocabs(vocabs = [], { courseId, lessonId = null } = {}) { + return (Array.isArray(vocabs) ? vocabs : []) + .map((entry) => { + const learning = String(entry?.learning || '').trim(); + const reference = String(entry?.reference || '').trim(); + if (!learning || !reference || this._normalizeSrsText(learning) === this._normalizeSrsText(reference)) { + return null; + } + const direction = String(entry?.direction || 'BOTH').toUpperCase(); + const itemKey = this._buildSrsItemKey({ courseId, lessonId, learning, reference, direction }); + return { + ...entry, + id: entry?.id || itemKey, + itemKey, + courseId: Number(courseId) || null, + lessonId: lessonId == null ? null : Number(lessonId), + learning, + reference, + direction + }; + }) + .filter(Boolean); + } + + _calculateSrsSchedule(item, { correct, rating = null } = {}) { + const now = new Date(); + const previousStage = Math.max(0, Number(item?.stage) || 0); + const previousInterval = Math.max(0, Number(item?.intervalDays) || 0); + const normalizedRating = String(rating || '').toLowerCase(); + const isCorrect = Boolean(correct) && normalizedRating !== 'again'; + + if (!isCorrect) { + return { + stage: Math.max(0, previousStage - 1), + intervalDays: 0, + nextDueAt: new Date(now.getTime() + 10 * 60 * 1000), + lapseDelta: 1 + }; + } + + const intervals = [0, 1, 3, 7, 14, 30, 60, 120, 240]; + let nextStage = Math.min(intervals.length - 1, previousStage + 1); + + if (normalizedRating === 'hard') { + nextStage = Math.max(1, previousStage); + } + if (normalizedRating === 'easy') { + nextStage = Math.min(intervals.length - 1, previousStage + 2); + } + + let intervalDays = intervals[nextStage] ?? Math.max(1, previousInterval * 2); + if (normalizedRating === 'hard') { + intervalDays = Math.max(1, Math.ceil(Math.max(previousInterval, 1) * 1.2)); + } + + return { + stage: nextStage, + intervalDays, + nextDueAt: new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000), + lapseDelta: 0 + }; + } + + async _ensureSrsItems(userId, { courseId, lessonId = null, vocabs = [] } = {}) { + const decorated = this._decorateSrsVocabs(vocabs, { courseId, lessonId }); + if (!decorated.length) { + return []; + } + + const existing = await VocabSrsItem.findAll({ + where: { + userId, + itemKey: { + [Op.in]: decorated.map((entry) => entry.itemKey) + } + } + }); + const existingByKey = new Map(existing.map((entry) => [entry.itemKey, entry])); + const now = new Date(); + + const createdItems = []; + for (const entry of decorated) { + if (existingByKey.has(entry.itemKey)) { + continue; + } + const created = await VocabSrsItem.create({ + userId, + courseId: Number(courseId), + lessonId: entry.lessonId, + itemKey: entry.itemKey, + learning: entry.learning, + reference: entry.reference, + direction: entry.direction, + nextDueAt: now + }); + createdItems.push(created); + existingByKey.set(entry.itemKey, created); + } + + return decorated.map((entry) => { + const item = existingByKey.get(entry.itemKey); + return { + ...entry, + srs: item ? { + stage: item.stage, + intervalDays: item.intervalDays, + lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt), + nextDueAt: this._normalizeIsoDate(item.nextDueAt), + correctCount: item.correctCount, + wrongCount: item.wrongCount, + lapseCount: item.lapseCount, + isNew: createdItems.some((created) => created.itemKey === entry.itemKey) + } : null + }; + }); + } + _normalizeIsoDate(value) { if (!value) { return ''; @@ -1502,6 +1641,11 @@ export default class VocabService { if (!entry?.learning || !entry?.reference) return; mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry); }); + const vocabs = await this._ensureSrsItems(user.id, { + courseId: lesson.courseId, + lessonId: lesson.id, + vocabs: Array.from(mergedVocabs.values()) + }); return { lesson: { @@ -1510,7 +1654,7 @@ export default class VocabService { courseId: lesson.courseId, courseTitle: lesson.course.title }, - vocabs: Array.from(mergedVocabs.values()) + vocabs }; } @@ -1612,9 +1756,167 @@ export default class VocabService { mergedVocabs.set(`${entry.learning}-${entry.reference}`, entry); }); + const vocabs = await this._ensureSrsItems(user.id, { + courseId: course.id, + lessonId: null, + vocabs: Array.from(mergedVocabs.values()) + }); + return { courseId: course.id, - vocabs: Array.from(mergedVocabs.values()) + vocabs + }; + } + + async getCourseSrsDue(hashedUserId, courseId, query = {}) { + const user = await this._getUserByHashedId(hashedUserId); + const course = await VocabCourse.findByPk(courseId); + + if (!course) { + const err = new Error('Course not found'); + err.status = 404; + throw err; + } + + if (course.ownerUserId !== user.id && !course.isPublic) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + + const limit = this._clampInteger(query?.limit, { min: 1, max: 100, fallback: 30 }); + const now = new Date(); + const rows = await VocabSrsItem.findAll({ + where: { + userId: user.id, + courseId: Number(course.id), + nextDueAt: { + [Op.lte]: now + } + }, + order: [ + ['nextDueAt', 'ASC'], + ['wrongCount', 'DESC'], + ['stage', 'ASC'] + ], + limit + }); + + return { + courseId: course.id, + dueAt: now.toISOString(), + count: rows.length, + items: rows.map((item) => ({ + itemKey: item.itemKey, + courseId: item.courseId, + lessonId: item.lessonId, + learning: item.learning, + reference: item.reference, + direction: item.direction, + stage: item.stage, + intervalDays: item.intervalDays, + lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt), + nextDueAt: this._normalizeIsoDate(item.nextDueAt), + correctCount: item.correctCount, + wrongCount: item.wrongCount, + lapseCount: item.lapseCount + })) + }; + } + + async reviewSrsItem(hashedUserId, payload = {}) { + const user = await this._getUserByHashedId(hashedUserId); + const courseId = this._clampInteger(payload?.courseId, { min: 1, max: 1_000_000, fallback: 0 }); + if (!courseId) { + const err = new Error('Missing course id'); + err.status = 400; + throw err; + } + + const course = await VocabCourse.findByPk(courseId); + if (!course) { + const err = new Error('Course not found'); + err.status = 404; + throw err; + } + if (course.ownerUserId !== user.id && !course.isPublic) { + const err = new Error('Access denied'); + err.status = 403; + throw err; + } + + const learning = this._sanitizeShortString(payload?.learning, 1200); + const reference = this._sanitizeShortString(payload?.reference, 1200); + if (!learning || !reference) { + const err = new Error('Missing SRS item text'); + err.status = 400; + throw err; + } + + const lessonId = payload?.lessonId == null + ? null + : this._clampInteger(payload.lessonId, { min: 1, max: 1_000_000, fallback: 0 }) || null; + const direction = String(payload?.direction || 'BOTH').toUpperCase().slice(0, 8); + const itemKey = this._sanitizeShortString(payload?.itemKey, 80) + || this._buildSrsItemKey({ courseId, lessonId, learning, reference, direction }); + + const [item] = await VocabSrsItem.findOrCreate({ + where: { + userId: user.id, + itemKey + }, + defaults: { + userId: user.id, + courseId, + lessonId, + itemKey, + learning, + reference, + direction, + nextDueAt: new Date() + } + }); + + if ( + item.learning !== learning || + item.reference !== reference || + item.direction !== direction || + item.lessonId !== lessonId + ) { + item.learning = learning; + item.reference = reference; + item.direction = direction; + item.lessonId = lessonId; + } + + const correct = Boolean(payload?.correct); + const schedule = this._calculateSrsSchedule(item, { + correct, + rating: payload?.rating + }); + + item.stage = schedule.stage; + item.intervalDays = schedule.intervalDays; + item.lastReviewedAt = new Date(); + item.nextDueAt = schedule.nextDueAt; + if (correct) { + item.correctCount += 1; + } else { + item.wrongCount += 1; + item.lapseCount += schedule.lapseDelta; + } + await item.save(); + + return { + itemKey: item.itemKey, + correct, + stage: item.stage, + intervalDays: item.intervalDays, + lastReviewedAt: this._normalizeIsoDate(item.lastReviewedAt), + nextDueAt: this._normalizeIsoDate(item.nextDueAt), + correctCount: item.correctCount, + wrongCount: item.wrongCount, + lapseCount: item.lapseCount }; } diff --git a/backend/sql/add_vocab_srs_item.sql b/backend/sql/add_vocab_srs_item.sql new file mode 100644 index 0000000..5b297be --- /dev/null +++ b/backend/sql/add_vocab_srs_item.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS community.vocab_srs_item ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES community."user"(id) ON DELETE CASCADE, + course_id INTEGER NOT NULL REFERENCES community.vocab_course(id) ON DELETE CASCADE, + lesson_id INTEGER NULL REFERENCES community.vocab_course_lesson(id) ON DELETE SET NULL, + item_key VARCHAR(80) NOT NULL, + learning TEXT NOT NULL, + reference TEXT NOT NULL, + direction VARCHAR(8) NOT NULL DEFAULT 'BOTH', + stage INTEGER NOT NULL DEFAULT 0, + interval_days INTEGER NOT NULL DEFAULT 0, + last_reviewed_at TIMESTAMP WITH TIME ZONE NULL, + next_due_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + correct_count INTEGER NOT NULL DEFAULT 0, + wrong_count INTEGER NOT NULL DEFAULT 0, + lapse_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT vocab_srs_item_user_key_unique UNIQUE (user_id, item_key) +); + +CREATE INDEX IF NOT EXISTS idx_vocab_srs_item_due + ON community.vocab_srs_item (user_id, course_id, next_due_at); + +CREATE INDEX IF NOT EXISTS idx_vocab_srs_item_lesson + ON community.vocab_srs_item (user_id, course_id, lesson_id); + +COMMENT ON TABLE community.vocab_srs_item IS + 'Nutzerbezogener SRS-Fortschritt pro Vokabel/Phrase aus Sprachkursen.'; +COMMENT ON COLUMN community.vocab_srs_item.item_key IS + 'Stabiler deterministischer Schlüssel aus Kurs, Lektion und normalisiertem Begriffspaar.'; +COMMENT ON COLUMN community.vocab_srs_item.stage IS + 'SRS-Stufe. Höhere Stufen bedeuten längere Wiederholungsintervalle.'; +COMMENT ON COLUMN community.vocab_srs_item.next_due_at IS + 'Zeitpunkt, zu dem das Item wieder fällig ist.'; diff --git a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue index 6025589..f80578f 100644 --- a/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue +++ b/frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue @@ -15,7 +15,7 @@
+ {{ $t('socialnetwork.vocab.courses.srsDueStat', { count: srsDueCount }) }} {{ $t('socialnetwork.vocab.courses.courseFlowReviewStat', { count: dueReviewLessons.length }) }} {{ $t('socialnetwork.vocab.courses.courseFlowBlockStat', { block: currentBlockNumber || '—' }) }}
+
+
+ {{ $t('socialnetwork.vocab.courses.srsEyebrow') }} +

{{ $t('socialnetwork.vocab.courses.srsTitle', { count: srsDueCount }) }}

+

{{ $t('socialnetwork.vocab.courses.srsIntro') }}

+
+ +
+

{{ $t('socialnetwork.vocab.courses.courseTodayPlanTitle') }}

@@ -325,6 +337,8 @@ export default { course: null, progress: [], chapters: [], + srsDueItems: [], + srsLoading: false, showAddLessonDialog: false, assistantSettings: null, lessonFormTouched: false, @@ -366,6 +380,9 @@ export default { currentBlockNumber() { return this.currentLesson?.pedagogy?.blockNumber || null; }, + srsDueCount() { + return Array.isArray(this.srsDueItems) ? this.srsDueItems.length : 0; + }, dueReviewLessons() { return this.sortedLessons .filter((lesson) => { @@ -504,6 +521,7 @@ export default { const res = await apiClient.get(`/api/vocab/courses/${this.courseId}`); this.course = res.data; await this.loadProgress(); + await this.loadSrsDueItems(); if (this.course.languageId) { await this.loadChapters(); } @@ -522,6 +540,20 @@ export default { this.progress = []; } }, + async loadSrsDueItems() { + this.srsLoading = true; + try { + const { data } = await apiClient.get(`/api/vocab/courses/${this.courseId}/srs/due`, { + params: { limit: 40 } + }); + this.srsDueItems = Array.isArray(data?.items) ? data.items : []; + } catch (e) { + console.warn('Konnte SRS-Fälligkeiten nicht laden:', e); + this.srsDueItems = []; + } finally { + this.srsLoading = false; + } + }, async loadChapters() { try { const res = await apiClient.get(`/api/vocab/languages/${this.course.languageId}/chapters`); @@ -814,6 +846,17 @@ export default { lessonId: lesson.id }); }, + openSrsPractice() { + if (!this.srsDueItems.length) { + return; + } + this.$refs.practiceDialog?.open?.({ + courseId: this.courseId, + initialPool: this.srsDueItems, + srsMode: true, + onClose: () => this.loadSrsDueItems() + }); + }, openLessonReview(lessonId) { this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}/review`); }, @@ -1010,6 +1053,40 @@ export default { gap: 14px; } +.course-srs-plan { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; + padding: 16px 18px; + border-radius: 14px; + border: 1px solid rgba(102, 153, 126, 0.38); + background: linear-gradient(135deg, rgba(232, 247, 238, 0.95), rgba(255, 251, 240, 0.8)); +} + +.course-srs-plan__eyebrow { + display: inline-flex; + margin-bottom: 4px; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #4f7b60; +} + +.course-srs-plan h4, +.course-srs-plan p { + margin: 0; +} + +.course-srs-plan p { + margin-top: 6px; + color: var(--color-text-secondary, #5c534c); + font-size: 0.88rem; + line-height: 1.45; +} + .course-flow-card { padding: 16px; border: 1px solid var(--color-border); diff --git a/frontend/src/views/social/VocabLessonView.vue b/frontend/src/views/social/VocabLessonView.vue index bb64d55..1e3d5ac 100644 --- a/frontend/src/views/social/VocabLessonView.vue +++ b/frontend/src/views/social/VocabLessonView.vue @@ -3602,6 +3602,23 @@ export default { normalizeVocab(s) { return this.normalizeComparableText(s); }, + reportSrsReviewForCurrentQuestion(isCorrect) { + if (!this.currentVocabQuestion?.vocab || !this.courseId) { + return; + } + const vocab = this.currentVocabQuestion.vocab; + apiClient.post('/api/vocab/srs/review', { + courseId: this.courseId, + lessonId: vocab.lessonId || this.lessonId, + itemKey: vocab.itemKey || null, + learning: vocab.learning, + reference: vocab.reference, + direction: this.vocabTrainerDirection, + correct: Boolean(isCorrect) + }).catch((error) => { + console.warn('[VocabLessonView] SRS review could not be saved:', error); + }); + }, checkVocabAnswer() { if (!this.currentVocabQuestion) return; @@ -3638,6 +3655,7 @@ export default { stats.wrong++; this.queueFailedVocab(this.currentVocabQuestion.vocab); } + this.reportSrsReviewForCurrentQuestion(this.vocabTrainerLastCorrect); this.vocabTrainerAnswered = true;