feat(vocab): stabilize daily batch handling in SRS session and improve due item display
All checks were successful
Deploy to production / deploy (push) Successful in 3m8s

This commit is contained in:
Torsten Schulz (local)
2026-09-16 10:13:24 +02:00
parent 20505f7545
commit 57a0e89156
8 changed files with 63 additions and 44 deletions

View File

@@ -140,6 +140,7 @@ import apiClient from '@/utils/axios.js';
import { normalizeComparableWithNumberWords } from '@/utils/numberAnswerVariants.js';
const PRACTICE_MIN_EXPOSURES = 3;
// The original daily batch remains stable when the dialog is reopened.
const SRS_SESSION_STORAGE_VERSION = 2;
const HARD_REQUIRED_CONSECUTIVE_CORRECT = 5;
const MAX_DAILY_DUE = 50;
@@ -502,10 +503,7 @@ export default {
const dueIds = (this.pool || []).map((it) => it.id);
const stored = this.loadSrsSession();
// If the previous session is already complete, start fresh (new batch of due items).
if (stored && Number(stored.initialTotalDue || 0) > 0 && Array.isArray(stored.doneIds) && stored.doneIds.length >= stored.initialTotalDue) {
this.srsSession = null;
} else if (stored) {
if (stored) {
this.srsSession = stored;
} else {
this.srsSession = null;
@@ -543,16 +541,23 @@ export default {
try { this.saveSrsSession(); } catch (_) {}
}
const initialDueIds = Array.isArray(this.srsSession.initialDueIds)
? this.srsSession.initialDueIds.slice(0, MAX_DAILY_DUE)
: [];
const doneSet = new Set(Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : []);
const finalReviewIds = Array.isArray(this.srsSession.finalReviewIds) ? this.srsSession.finalReviewIds : [];
const finalReviewSet = new Set(finalReviewIds);
const availableIds = new Set(dueIds);
// Never fill a partly completed daily batch with additional overdue
// cards. The 50 cards chosen when the session began remain today's
// batch; answered cards reduce its remaining count.
this.srsQueueIds = [
...dueIds.filter((id) => !doneSet.has(id) && !finalReviewSet.has(id)),
...finalReviewIds.filter((id) => !doneSet.has(id) && availableIds.has(id))
...initialDueIds.filter((id) => availableIds.has(id) && !doneSet.has(id) && !finalReviewSet.has(id)),
...finalReviewIds.filter((id) => initialDueIds.includes(id) && !doneSet.has(id) && availableIds.has(id))
];
// Fallback: if SRS mode but queue is empty (e.g. mismatch between stored session and current pool), fall back to pool order
if (this.srsMode && Array.isArray(this.srsQueueIds) && this.srsQueueIds.length === 0) {
// Only a corrupted session without an original batch may use the pool
// as fallback. An empty, completed batch must remain empty today.
if (this.srsMode && initialDueIds.length === 0) {
const fallbackIds = (this.pool || []).map((it) => it.id).filter(Boolean);
if (fallbackIds.length > 0) {
const limited = fallbackIds.slice(0, MAX_DAILY_DUE);

View File

@@ -447,7 +447,7 @@
"courseFlowIntensiveStatusAction": "Tan-awa ang sunod nga balik-balik",
"courseFlowIntensiveStatusTitle": "Sunod nga balik-balik sa bokabularyo",
"courseFlowIntensiveStatusAllDone": "Wala pay dugang nga balik-balik sa bokabularyo nga nakaplano alang sa nahuman nga mga leksiyon.",
"courseFlowIntensiveStatusDue": "Karon / nalangan",
"courseFlowIntensiveStatusDue": "Nahibilin karon: {remaining} sa {quota} giplano · kinatibuk-ang angay: {total}",
"courseFlowIntensiveStatusProgress": "{count} ka termino",
"courseFlowIntensiveStatusNoDate": "Ang mga petsa nagsalig sa imong sakto nga tubag: 1, 3, 7, 14, 30, 60 ug 120 ka adlaw. Ang lisod nga mga termino gilain ug praktisahon sa lugar sa lisod nga mga termino.",
"courseFlowPracticeTitle": "Libre nga pagpalalom",
@@ -766,9 +766,9 @@
"quickReviewPromptTarget": "Type sa target pinulongan: \"{term}\"",
"quickReviewAcknowledge": "Read, continue",
"courseTodayPlanIntroNoDueReview": "Walay angay nga mubo nga balik-balik karon. Makita nimo ang sunod nga makatarunganon nga lakang sa block (limitado sa kabug-aton), unya ang intensive kung naay. Ang mubo nga balik-balik mobalik sa 1/3/7 ka adlaw.",
"srsDueStat": "SRS angay: {count}",
"srsDueStat": "Adlaw-adlaw nga balik-balik: {scheduled} sa {total} ka termino",
"srsEyebrow": "Dugay nga memorya",
"srsTitle": "{count} ka termino ang angay karon",
"srsTitle": "Karon: {scheduled} sa {total} ka angay balik-balikon",
"srsIntro": "Kini nga balik-balik gikan sa SRS nga plano sa matag pulong. Una kini kaysa bag-ong materyal kay nagpalig-on kini sa mga pulong nga hapit malimtan.",
"srsStart": "Sugdi ang daily review",
"courseTodayPlanIntroSrs": "Didaktik nga han-ay: una ang SRS daily review sa tagsa-tagsa ka pulong. Human ana, mubo nga review, padayon sa block, ug intensive review kung kinahanglan. Mao ni ang pagpalig-on sa daan nga materyal sa dili pa modugang ug bag-o."

View File

@@ -777,7 +777,7 @@
"courseFlowIntensiveStatusAction": "Nächste Wiederholungen prüfen",
"courseFlowIntensiveStatusTitle": "Nächste Vokabelwiederholungen",
"courseFlowIntensiveStatusAllDone": "Für abgeschlossene Lektionen sind derzeit keine weiteren Vokabelwiederholungen geplant.",
"courseFlowIntensiveStatusDue": "Heute / überfällig",
"courseFlowIntensiveStatusDue": "Heute noch: {remaining} von {quota} eingeplant · insgesamt fällig: {total}",
"courseFlowIntensiveStatusProgress": "{count} Begriffe",
"courseFlowIntensiveStatusNoDate": "Die Termine richten sich nach deinen richtigen Antworten: 1, 3, 7, 14, 30, 60 und 120 Tage. Schwer markierte Begriffe werden getrennt im Bereich „Schwere Begriffe“ geübt.",
"courseFlowPracticeTitle": "Freie Vertiefung",
@@ -876,9 +876,9 @@
"reviewTimeNow": "jetzt",
"reviewTimeTomorrow": "morgen",
"reviewTimeInDays": "in {count} Tagen",
"srsDueStat": "Tageswiederholung: {scheduled} Begriffe",
"srsDueStat": "Tageswiederholung: {scheduled} von {total} Begriffen",
"srsEyebrow": "Langzeitgedächtnis",
"srsTitle": "Heute: {scheduled} fällige Begriffe",
"srsTitle": "Heute: {scheduled} von {total} fälligen Begriffen",
"srsIntro": "Diese Wiederholung kommt aus dem SRS-Plan einzelner Begriffe. Pro Tag werden höchstens 50 Begriffe eingeplant; weitere fällige Begriffe bleiben für die nächste Tageswiederholung erhalten.",
"srsStart": "Tageswiederholung starten",
"courseTodayPlanIntroSrs": "Didaktische Reihenfolge: Zuerst die fällige SRS-Tageswiederholung einzelner Begriffe. Danach kommen Kurz-Wiederholungen, Blockfortschritt und ggf. intensive Wiederholung. So wird altes Material stabilisiert, bevor neues Material dazukommt."

View File

@@ -777,7 +777,7 @@
"courseFlowIntensiveStatusAction": "Check upcoming reviews",
"courseFlowIntensiveStatusTitle": "Upcoming vocabulary reviews",
"courseFlowIntensiveStatusAllDone": "No further vocabulary reviews are currently scheduled for completed lessons.",
"courseFlowIntensiveStatusDue": "Today / overdue",
"courseFlowIntensiveStatusDue": "Remaining today: {remaining} of {quota} planned · total due: {total}",
"courseFlowIntensiveStatusProgress": "{count} terms",
"courseFlowIntensiveStatusNoDate": "Dates are based on correct answers: 1, 3, 7, 14, 30, 60 and 120 days. Terms marked hard are practised separately in the hard-terms area.",
"courseFlowPracticeTitle": "Free practice",
@@ -876,9 +876,9 @@
"reviewTimeNow": "now",
"reviewTimeTomorrow": "tomorrow",
"reviewTimeInDays": "in {count} days",
"srsDueStat": "Daily review: {scheduled} terms",
"srsDueStat": "Daily review: {scheduled} of {total} terms",
"srsEyebrow": "Long-term memory",
"srsTitle": "Today: {scheduled} due terms",
"srsTitle": "Today: {scheduled} of {total} due terms",
"srsIntro": "This review comes from the SRS schedule of individual terms. At most 50 terms are planned per day; any additional due terms remain for the next daily review.",
"srsStart": "Start daily review",
"courseTodayPlanIntroSrs": "Pedagogical order: start with the due SRS daily review for individual terms. Then quick reviews, block progress, and intensive review if needed. This stabilizes old material before new material is added."

View File

@@ -756,7 +756,7 @@
"courseFlowIntensiveStatusAction": "Consultar próximos repasos",
"courseFlowIntensiveStatusTitle": "Próximos repasos de vocabulario",
"courseFlowIntensiveStatusAllDone": "Actualmente no hay más repasos de vocabulario programados para las lecciones completadas.",
"courseFlowIntensiveStatusDue": "Hoy / atrasado",
"courseFlowIntensiveStatusDue": "Pendientes hoy: {remaining} de {quota} planificados · total pendientes: {total}",
"courseFlowIntensiveStatusProgress": "{count} términos",
"courseFlowIntensiveStatusNoDate": "Las fechas se basan en tus respuestas correctas: 1, 3, 7, 14, 30, 60 y 120 días. Los términos marcados como difíciles se practican por separado en el área de términos difíciles.",
"courseFlowPracticeTitle": "Práctica libre",

View File

@@ -133,7 +133,7 @@
</div>
<div>
<button type="button" @click="orderParty()">
<button type="button" @click="orderParty()" :disabled="isOrderingParty">
{{ $t('falukant.reputation.party.order') }}
</button>
</div>
@@ -224,6 +224,7 @@ export default {
selectedNobilityIds: [],
servantRatio: 50,
relationshipId: null,
isOrderingParty: false,
weddingCandidates: [],
inProgressParties: [],
completedParties: [],
@@ -317,15 +318,23 @@ export default {
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
},
async orderParty() {
await apiClient.post('/api/falukant/party', {
partyTypeId: this.newPartyTypeId,
musicId: this.musicId,
banquetteId: this.banquetteId,
nobilityIds: this.selectedNobilityIds.map(n => n.id ?? n),
servantRatio: this.servantRatio,
relationshipId: this.relationshipId
});
this.toggleNewPartyView();
if (this.isOrderingParty) return;
this.isOrderingParty = true;
try {
await apiClient.post('/api/falukant/party', {
partyTypeId: this.newPartyTypeId,
musicId: this.musicId,
banquetteId: this.banquetteId,
nobilityIds: this.selectedNobilityIds.map(n => n.id ?? n),
servantRatio: this.servantRatio,
relationshipId: this.relationshipId
});
await this.loadParties();
this.relationshipId = null;
this.toggleNewPartyView();
} finally {
this.isOrderingParty = false;
}
},
getPartyDate(createdAt) {
// Feste finden 1 Tag nach der Bestellung statt

View File

@@ -317,11 +317,10 @@
<section class="dialog intensive-status-dialog" role="dialog" aria-modal="true" @click.stop>
<h3>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusTitle') }}</h3>
<p class="intensive-status-dialog__hint">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusNoDate') }}</p>
<p v-if="srsDailyCount === 0 && srsUpcoming.length === 0">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusAllDone') }}</p>
<p v-if="srsDueCount === 0 && srsUpcoming.length === 0">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusAllDone') }}</p>
<ul v-else class="intensive-status-dialog__list">
<li v-if="srsDailyCount > 0" class="intensive-status-dialog__item--due">
<span>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusDue') }}</span>
<strong>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusProgress', { count: srsDailyCount }) }}</strong>
<li v-if="srsDueCount > 0" class="intensive-status-dialog__item--due">
<span>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusDue', { remaining: srsDailyCount, quota: srsDailyQuota, total: srsDueCount }) }}</span>
</li>
<li v-for="entry in srsUpcoming" :key="entry.date">
<span>{{ formatSrsDate(entry.date) }}</span>
@@ -397,6 +396,7 @@ export default {
srsDueTotal: 0,
srsDailyLimit: 50,
srsTodayRemaining: null,
srsTodayQuota: null,
srsUpcoming: [],
srsLoading: false,
showIntensiveStatusDialog: false,
@@ -449,9 +449,17 @@ export default {
return Array.isArray(this.srsDueItems) ? this.srsDueItems.length : 0;
},
srsDailyCount() {
// Der Server ist die maßgebliche Quelle für fällige Begriffe. Ein alter
// localStorage-Sitzungsstand darf eine neue oder erneut fällige
// Tageswiederholung nicht unsichtbar machen.
// A valid current-day session represents the fixed daily batch. Without
// one, the server's due count determines the first batch (max. 50).
if (Number.isFinite(Number(this.srsTodayRemaining))) {
return Math.max(0, Number(this.srsTodayRemaining));
}
return Math.min(this.srsDueCount, this.srsDailyLimit);
},
srsDailyQuota() {
if (Number.isFinite(Number(this.srsTodayQuota))) {
return Math.max(0, Number(this.srsTodayQuota));
}
return Math.min(this.srsDueCount, this.srsDailyLimit);
},
hardVocabCount() {
@@ -588,13 +596,18 @@ export default {
&& session.dateKey === this.getLocalDateKey();
if (!isCurrentSession) {
this.srsTodayRemaining = null;
this.srsTodayQuota = null;
return;
}
const total = Math.max(0, Number(session.initialTotalDue) || 0);
const done = new Set(Array.isArray(session.doneIds) ? session.doneIds : []).size;
const initialIds = new Set(Array.isArray(session.initialDueIds) ? session.initialDueIds : []);
const done = new Set((Array.isArray(session.doneIds) ? session.doneIds : [])
.filter((id) => initialIds.has(id))).size;
this.srsTodayRemaining = Math.max(0, total - done);
this.srsTodayQuota = total;
} catch (_) {
this.srsTodayRemaining = null;
this.srsTodayQuota = null;
}
},
async refreshHardVocabList() {

View File

@@ -107,16 +107,8 @@ private:
JOIN falukant_type.party AS pt
ON pt.id = p.party_type_id
AND pt.tr = 'wedding'
JOIN falukant_data.falukant_user AS fu
ON fu.id = p.falukant_user_id
JOIN falukant_data."character" AS c
ON c.user_id = fu.id
JOIN falukant_data.relationship AS rel2
ON p.relationship_id = rel2.id
OR (
p.relationship_id IS NULL
AND (rel2.character1_id = c.id OR rel2.character2_id = c.id)
)
JOIN falukant_type.relationship AS rt2
ON rt2.id = rel2.relationship_type_id
AND rt2.tr = 'engaged'