Compare commits
20 Commits
d50f78d91d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbfdd8ccc | ||
|
|
57a0e89156 | ||
|
|
20505f7545 | ||
|
|
01ee72c700 | ||
|
|
b48d03ab70 | ||
|
|
c462001ff0 | ||
|
|
3d235a621a | ||
|
|
0a9174c486 | ||
|
|
f317ed9ef0 | ||
|
|
1149814f64 | ||
|
|
86f3b4c247 | ||
|
|
90686388c6 | ||
|
|
7edb9d79e5 | ||
|
|
d9a46dcb34 | ||
|
|
c4977ec826 | ||
|
|
b2e5d3d950 | ||
|
|
504f527a3f | ||
|
|
76da586b8e | ||
|
|
a3e2aaece8 | ||
|
|
691e840c7f |
@@ -67,7 +67,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# App-Code-Änderungen, die einen echten Deploy benötigen
|
||||
if grep -E '^(frontend/|backend/)' changed-files.txt \
|
||||
if grep -E '^(frontend/|backend/|update(-frontend|-backend)?\.sh$|update\.sh$|deploy-yourpart-bluegreen\.sh$|falukant-models\.env$)' changed-files.txt \
|
||||
| grep -Ev "$COURSE_SCRIPT_PATTERN" >/dev/null; then
|
||||
echo "app_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
|
||||
@@ -102,6 +102,10 @@ class FalukantController {
|
||||
this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId));
|
||||
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId), { blockInDebtorsPrison: true });
|
||||
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId), { blockInDebtorsPrison: true });
|
||||
this.acceptChildMarriageProposal = this._wrapWithUser((userId, req) =>
|
||||
this.service.acceptChildMarriageProposal(userId, req.params.childCharacterId, req.body.proposedCharacterId), { blockInDebtorsPrison: true });
|
||||
this.advanceChildWooing = this._wrapWithUser((userId, req) =>
|
||||
this.service.advanceChildWooing(userId, req.params.childCharacterId), { blockInDebtorsPrison: true });
|
||||
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId), { blockInDebtorsPrison: true });
|
||||
this.cancelWooing = this._wrapWithUser(async (userId) => {
|
||||
try {
|
||||
@@ -162,8 +166,8 @@ class FalukantController {
|
||||
|
||||
this.getPartyTypes = this._wrapWithUser((userId) => this.service.getPartyTypes(userId));
|
||||
this.createParty = this._wrapWithUser((userId, req) => {
|
||||
const { partyTypeId, musicId, banquetteId, nobilityIds, servantRatio } = req.body;
|
||||
return this.service.createParty(userId, partyTypeId, musicId, banquetteId, nobilityIds, servantRatio);
|
||||
const { partyTypeId, musicId, banquetteId, nobilityIds, servantRatio, relationshipId } = req.body;
|
||||
return this.service.createParty(userId, partyTypeId, musicId, banquetteId, nobilityIds, servantRatio, relationshipId);
|
||||
}, { successStatus: 201, blockInDebtorsPrison: true });
|
||||
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ import newsService from '../services/newsService.js';
|
||||
export default {
|
||||
async getNews(req, res) {
|
||||
const counter = Math.max(0, parseInt(req.query.counter, 10) || 0);
|
||||
const count = Math.min(6, Math.max(1, parseInt(req.query.count, 10) || 1));
|
||||
const language = (req.query.language || 'de').slice(0, 10);
|
||||
const category = (req.query.category || 'top').slice(0, 50);
|
||||
|
||||
try {
|
||||
const { results, nextPage } = await newsService.getNews({ counter, language, category });
|
||||
const { results, nextPage } = await newsService.getNews({ counter, count, language, category });
|
||||
res.json({ results, nextPage });
|
||||
} catch (error) {
|
||||
console.error('News getNews:', error);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The generated exercise for "Wege & Verkehr" used a bare `{gap}` while its
|
||||
* instruction asked for a route description. A gap must be embedded in the
|
||||
* route so the learner can infer the expected direction from the context.
|
||||
*/
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE community.vocab_grammar_exercise AS exercise
|
||||
SET title = 'Wege & Verkehr: Satz im Kontext ergänzen',
|
||||
question_data = '{"type":"gap_fill","text":"{gap}. Tuo. Wala. Duol ra.","gaps":1}'::jsonb,
|
||||
answer_data = '{"type":"gap_fill","answers":["Diretso"]}'::jsonb,
|
||||
explanation = 'Die vollständige Wegbeschreibung lautet: "Diretso. Tuo. Wala. Duol ra."'
|
||||
FROM community.vocab_course_lesson AS lesson
|
||||
WHERE exercise.lesson_id = lesson.id
|
||||
AND (lesson.id = 1532 OR (lesson.course_id = 1 AND lesson.lesson_number = 55))
|
||||
AND lesson.title = 'Wege & Verkehr'
|
||||
AND exercise.question_data->>'type' = 'gap_fill'
|
||||
AND exercise.question_data->>'text' = '{gap}'
|
||||
AND exercise.answer_data->'answers' @> '["tuo"]'::jsonb;
|
||||
`);
|
||||
},
|
||||
|
||||
async down() {
|
||||
// Do not restore the context-free task.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
'use strict';
|
||||
|
||||
/** Replaces tautological vocabulary prompts in the Cebu travel lesson. */
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||
const baseWhere = `
|
||||
exercise.lesson_id = lesson.id
|
||||
AND (lesson.id = 1532 OR (lesson.course_id = 1 AND lesson.lesson_number = 55))
|
||||
AND lesson.title = 'Wege & Verkehr'
|
||||
`;
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE community.vocab_grammar_exercise AS exercise
|
||||
SET title = 'Wege & Verkehr: Fahrzeug finden',
|
||||
instruction = 'Wähle das passende Wort für die Situation.',
|
||||
question_data = '{"type":"multiple_choice","question":"Du suchst ein Fahrzeug für die Fahrt. Welches Wort passt?","options":["hunong","sakyanan","jeepney","dalan"]}'::jsonb,
|
||||
answer_data = '{"type":"multiple_choice","correctAnswer":1}'::jsonb,
|
||||
explanation = 'Sakyanan bedeutet Fahrzeug oder Auto.'
|
||||
FROM community.vocab_course_lesson AS lesson
|
||||
WHERE ${baseWhere} AND exercise.exercise_number = 1;
|
||||
`, { transaction });
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE community.vocab_grammar_exercise AS exercise
|
||||
SET title = 'Wege & Verkehr: Mit dem Jeepney fahren',
|
||||
instruction = 'Wähle das passende Wort für die Reisesituation.',
|
||||
question_data = '{"type":"multiple_choice","question":"Du möchtest auf Cebu mit dem typischen Sammeltaxi fahren. Welches Wort passt?","options":["hunong","sakyanan","jeepney","dalan"]}'::jsonb,
|
||||
answer_data = '{"type":"multiple_choice","correctAnswer":2}'::jsonb,
|
||||
explanation = 'Ein Jeepney ist ein typisches öffentliches Sammeltaxi auf den Philippinen.'
|
||||
FROM community.vocab_course_lesson AS lesson
|
||||
WHERE ${baseWhere} AND exercise.exercise_number = 2;
|
||||
`, { transaction });
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE community.vocab_grammar_exercise AS exercise
|
||||
SET title = 'Wege & Verkehr: Weg zum Hafen ergänzen',
|
||||
instruction = 'Jemand erklärt dir den Weg zum Hafen. Welche Richtungsangabe fehlt?',
|
||||
question_data = '{"type":"gap_fill","text":"{gap}. Tuo. Wala. Duol ra.","gaps":1}'::jsonb,
|
||||
answer_data = '{"type":"gap_fill","answers":["Diretso"]}'::jsonb,
|
||||
explanation = 'Diretso = geradeaus, tuo = rechts, wala = links und duol ra = es ist nur nah.'
|
||||
FROM community.vocab_course_lesson AS lesson
|
||||
WHERE ${baseWhere} AND exercise.exercise_number = 3;
|
||||
`, { transaction });
|
||||
});
|
||||
},
|
||||
|
||||
async down() {
|
||||
// The former questions were tautological or context-free.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
/** Replaces a context-free food gap with a clearly answerable table setting. */
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE community.vocab_grammar_exercise AS exercise
|
||||
SET title = 'Essen & Trinken: Auf dem Tisch ergänzen',
|
||||
instruction = 'Auf dem Tisch stehen Reis, Fisch und Wasser. Ergänze das fehlende Bisaya-Wort.',
|
||||
question_data = '{"type":"gap_fill","text":"{gap}, isda ug tubig.","gaps":1}'::jsonb,
|
||||
answer_data = '{"type":"gap_fill","answers":["Kan-on"]}'::jsonb,
|
||||
explanation = 'Kan-on bedeutet gekochter Reis. Der ganze Ausdruck lautet: „Kan-on, isda ug tubig.“'
|
||||
FROM community.vocab_course_lesson AS lesson
|
||||
WHERE exercise.lesson_id = lesson.id
|
||||
AND lesson.lesson_number = 8
|
||||
AND lesson.title = 'Essen & Trinken'
|
||||
AND exercise.exercise_number = 3
|
||||
AND exercise.title = 'Essen & Trinken: Kernmuster ergänzen';
|
||||
`);
|
||||
},
|
||||
|
||||
async down() {
|
||||
// Do not restore the ambiguous exercise.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
/** Aligns the question "Where are they?" with its stored kitchen answer. */
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE community.vocab_grammar_exercise AS exercise
|
||||
SET title = 'Personen in der Küche',
|
||||
instruction = 'Jemand fragt, wo die Personen sind. Antworte, dass sie in der Küche sind.',
|
||||
question_data = '{"type":"dialog_completion","question":"Welche Antwort passt?","dialog":["A: Asa sila?","B: ..."]}'::jsonb,
|
||||
answer_data = '{"modelAnswer":"Naa sila sa kusina.","correct":["Naa sila sa kusina.","Naa sila sa kusina"]}'::jsonb,
|
||||
explanation = '„Asa sila?“ fragt: Wo sind sie? „Naa sila sa kusina.“ bedeutet: Sie sind in der Küche.'
|
||||
FROM community.vocab_course_lesson AS lesson
|
||||
WHERE exercise.lesson_id = lesson.id
|
||||
AND lesson.lesson_number = 12
|
||||
AND lesson.title = 'Haus & Familie'
|
||||
AND exercise.exercise_number = 13
|
||||
AND exercise.title = 'Nach der Küche fragen';
|
||||
`);
|
||||
},
|
||||
|
||||
async down() {
|
||||
// Do not restore the semantically mismatched dialogue.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
/** The expected answers include "ko", so the prompt must name first person. */
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE community.vocab_grammar_exercise AS exercise
|
||||
SET instruction = 'Bilde „ich gehe“ mit „adto“ in Vergangenheit, Gegenwart und Zukunft. Verwende in jeder Form „ko“.'
|
||||
FROM community.vocab_course_lesson AS lesson
|
||||
WHERE exercise.lesson_id = lesson.id
|
||||
AND lesson.lesson_number = 15
|
||||
AND lesson.title = 'Zeitformen - Grundlagen'
|
||||
AND exercise.exercise_number = 5
|
||||
AND exercise.title = 'Zeitmuster anwenden';
|
||||
`);
|
||||
},
|
||||
|
||||
async down() {
|
||||
// The old instruction omitted the required person marker.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
/** The original body-part gap had several equally plausible answers. */
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE community.vocab_grammar_exercise AS exercise
|
||||
SET instruction = 'Frage auf Bisaya: „Tut dir der Bauch weh? Geht es dir schon besser?“'
|
||||
FROM community.vocab_course_lesson AS lesson
|
||||
WHERE exercise.lesson_id = lesson.id
|
||||
AND lesson.lesson_number = 26
|
||||
AND lesson.title = 'Gesundheit & Wohlbefinden'
|
||||
AND exercise.exercise_number = 2
|
||||
AND exercise.title = 'Gesundheitsfrage ergänzen';
|
||||
`);
|
||||
},
|
||||
|
||||
async down() {
|
||||
// The old wording did not identify the intended body part.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE community.vocab_grammar_exercise AS exercise
|
||||
SET instruction = 'Lehne eine Einladung höflich ab: „Heute lieber nicht. Ein anderes Mal.“'
|
||||
FROM community.vocab_course_lesson AS lesson
|
||||
WHERE exercise.lesson_id = lesson.id
|
||||
AND lesson.course_id = 1
|
||||
AND lesson.lesson_number = 28
|
||||
AND lesson.title = 'Höflichkeitsformen praktisch'
|
||||
AND exercise.exercise_number = 2
|
||||
AND exercise.title = 'Weich ablehnen';
|
||||
`);
|
||||
},
|
||||
|
||||
async down() {}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TABLE falukant_data.party
|
||||
ADD COLUMN IF NOT EXISTS relationship_id INTEGER NULL
|
||||
REFERENCES falukant_data.relationship(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS party_relationship_id_idx
|
||||
ON falukant_data.party (relationship_id);
|
||||
`);
|
||||
},
|
||||
async down(queryInterface) {
|
||||
await queryInterface.sequelize.query(`ALTER TABLE falukant_data.party DROP COLUMN IF EXISTS relationship_id;`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE community.vocab_grammar_exercise AS exercise
|
||||
SET instruction = 'Frage: „Wo ist deine Tasche?“ Bitte danach: „Nimm deine Tasche.“'
|
||||
FROM community.vocab_course_lesson AS lesson
|
||||
WHERE exercise.lesson_id = lesson.id
|
||||
AND lesson.course_id = 1
|
||||
AND lesson.lesson_number = 30
|
||||
AND lesson.title = 'Kinder & Familie'
|
||||
AND exercise.exercise_number = 2
|
||||
AND exercise.title = 'Kindersatz ergänzen';
|
||||
`);
|
||||
},
|
||||
|
||||
async down() {}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
DELETE FROM community.vocab_srs_item
|
||||
WHERE lower(trim(learning)) = 'vergangenheit'
|
||||
AND lower(trim(reference)) IN ('ni-kaon ko ganiha', 'nikaon ko ganiha');
|
||||
`);
|
||||
},
|
||||
|
||||
async down() {}
|
||||
};
|
||||
@@ -633,6 +633,7 @@ export default function setupAssociations() {
|
||||
|
||||
FalukantUser.hasMany(Party, { foreignKey: 'falukantUserId', as: 'parties' });
|
||||
Party.belongsTo(FalukantUser, { foreignKey: 'falukantUserId', as: 'partyUser' });
|
||||
Party.belongsTo(Relationship, { foreignKey: 'relationshipId', as: 'marriageRelationship' });
|
||||
|
||||
Party.belongsToMany(TitleOfNobility, {
|
||||
through: PartyInvitedNobility,
|
||||
|
||||
@@ -14,6 +14,11 @@ Party.init({
|
||||
allowNull: false,
|
||||
field: 'falukant_user_id'
|
||||
},
|
||||
relationshipId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'relationship_id'
|
||||
},
|
||||
musicTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
@@ -41,4 +46,4 @@ Party.init({
|
||||
timestamps: true,
|
||||
underscored: true});
|
||||
|
||||
export default Party;
|
||||
export default Party;
|
||||
|
||||
@@ -49,6 +49,8 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget);
|
||||
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
|
||||
router.post('/family/cancel-wooing', falukantController.cancelWooing);
|
||||
router.post('/family/set-heir', falukantController.setHeir);
|
||||
router.post('/family/children/:childCharacterId/accept-marriage-proposal', falukantController.acceptChildMarriageProposal);
|
||||
router.post('/family/children/:childCharacterId/advance-wooing', falukantController.advanceChildWooing);
|
||||
router.post('/family/lover', falukantController.createLoverRelationship);
|
||||
router.post('/family/marriage/spend-time', falukantController.spendTimeWithSpouse);
|
||||
router.post('/family/marriage/gift', falukantController.giftToSpouse);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import newsController from '../controllers/newsController.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', authenticate, newsController.getNews.bind(newsController));
|
||||
// News are shown on the public landing page as well as in the dashboard.
|
||||
// The endpoint only exposes cached third-party headlines, no user data.
|
||||
router.get('/', newsController.getNews.bind(newsController));
|
||||
|
||||
export default router;
|
||||
|
||||
176
backend/scripts/bisaya-course-cebu-travel-extension.js
Normal file
176
backend/scripts/bisaya-course-cebu-travel-extension.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/** Ergänzende Lektionen für Reisen und längere Aufenthalte auf Cebu. */
|
||||
export const BISAYA_CEBU_TRAVEL_DIDACTICS = {
|
||||
'Cebu ankommen & einchecken': {
|
||||
learningGoals: [
|
||||
'Bei der Ankunft höflich grüßen und einchecken.',
|
||||
'Nach Zimmer und Schlüssel fragen.',
|
||||
'Eine Buchung kurz bestätigen.'
|
||||
],
|
||||
corePatterns: [
|
||||
{ target: 'Maayong adlaw.', gloss: 'Guten Tag.' },
|
||||
{ target: 'Naa koy reservation.', gloss: 'Ich habe eine Reservierung.' },
|
||||
{ target: 'Pwede ko mag-check in?', gloss: 'Kann ich einchecken?' },
|
||||
{ target: 'Asa ang akong kwarto?', gloss: 'Wo ist mein Zimmer?' },
|
||||
{ target: 'Salamat kaayo.', gloss: 'Vielen Dank.' }
|
||||
],
|
||||
speakingPrompts: [{
|
||||
title: 'Ankunft im Hotel',
|
||||
prompt: 'Begrüße die Rezeption, sage, dass du eine Reservierung hast, und frage nach deinem Zimmer.',
|
||||
cue: 'Maayong adlaw. Naa koy reservation. Asa ang akong kwarto?'
|
||||
}],
|
||||
practicalTasks: [{
|
||||
title: 'Check-in',
|
||||
text: 'Führe einen kurzen Check-in mit Begrüßung, Reservierung und Zimmerfrage.'
|
||||
}]
|
||||
},
|
||||
'Unterkunft & kleine Probleme': {
|
||||
learningGoals: [
|
||||
'Wichtige Wörter zur Unterkunft verstehen.',
|
||||
'Freundlich nach Schlüssel, Wasser oder Handtuch fragen.',
|
||||
'Ein kleines Problem im Zimmer kurz benennen.'
|
||||
],
|
||||
corePatterns: [
|
||||
{ target: 'kwarto', gloss: 'Zimmer' },
|
||||
{ target: 'yawe', gloss: 'Schlüssel' },
|
||||
{ target: 'tuwalya', gloss: 'Handtuch' },
|
||||
{ target: 'tubig', gloss: 'Wasser' },
|
||||
{ target: 'Asa ang yawe sa kwarto?', gloss: 'Wo ist der Zimmerschlüssel?' },
|
||||
{ target: 'Pwede mangayo ug tubig?', gloss: 'Kann ich um Wasser bitten?' },
|
||||
{ target: 'Naay problema sa kwarto.', gloss: 'Es gibt ein Problem im Zimmer.' }
|
||||
],
|
||||
speakingPrompts: [{
|
||||
title: 'An der Rezeption',
|
||||
prompt: 'Frage nach dem Zimmerschlüssel und bitte anschließend um Wasser oder ein Handtuch.',
|
||||
cue: 'Asa ang yawe sa kwarto? Pwede mangayo ug tubig?'
|
||||
}],
|
||||
practicalTasks: [{
|
||||
title: 'Kleines Hotelproblem',
|
||||
text: 'Beschreibe höflich ein kleines Problem im Zimmer und bitte um Hilfe.'
|
||||
}]
|
||||
},
|
||||
'Restaurant & Bestellen': {
|
||||
learningGoals: [
|
||||
'Im Restaurant höflich bestellen.',
|
||||
'Nach Wasser und Preis fragen.',
|
||||
'Auf Essen freundlich reagieren.'
|
||||
],
|
||||
corePatterns: [
|
||||
{ target: 'Gusto ko ani.', gloss: 'Ich möchte das.' },
|
||||
{ target: 'Pwede mangayo ug tubig?', gloss: 'Kann ich um Wasser bitten?' },
|
||||
{ target: 'Tagpila ni?', gloss: 'Wie viel kostet das?' },
|
||||
{ target: 'Lami kaayo.', gloss: 'Sehr lecker.' },
|
||||
{ target: 'Mubayad ko.', gloss: 'Ich möchte bezahlen.' }
|
||||
],
|
||||
speakingPrompts: [{
|
||||
title: 'Bestellung',
|
||||
prompt: 'Bestelle etwas, bitte um Wasser, frage nach dem Preis und reagiere auf das Essen.',
|
||||
cue: 'Gusto ko ani. Pwede mangayo ug tubig? Tagpila ni? Lami kaayo.'
|
||||
}],
|
||||
practicalTasks: [{
|
||||
title: 'Im Restaurant',
|
||||
text: 'Führe eine kurze Bestellung vom Wunsch bis zum Bezahlen.'
|
||||
}]
|
||||
},
|
||||
'Markt, Souvenirs & Bezahlen': {
|
||||
learningGoals: [
|
||||
'Am Markt nach Preis und Menge fragen.',
|
||||
'Freundlich auf einen Preis reagieren.',
|
||||
'Einen Einkauf abschließen.'
|
||||
],
|
||||
corePatterns: [
|
||||
{ target: 'Pila ni tanan?', gloss: 'Wie viel kostet das alles?' },
|
||||
{ target: 'Mahal ra.', gloss: 'Das ist teuer.' },
|
||||
{ target: 'Barato ra.', gloss: 'Das ist günstig.' },
|
||||
{ target: 'Kuhaon nako ni.', gloss: 'Ich nehme das.' },
|
||||
{ target: 'Naay sukli?', gloss: 'Gibt es Wechselgeld?' }
|
||||
],
|
||||
speakingPrompts: [{
|
||||
title: 'Souvenir kaufen',
|
||||
prompt: 'Frage nach dem Gesamtpreis, reagiere freundlich und sage, dass du den Artikel nimmst.',
|
||||
cue: 'Pila ni tanan? Mahal ra. Kuhaon nako ni.'
|
||||
}],
|
||||
practicalTasks: [{
|
||||
title: 'Marktgespräch',
|
||||
text: 'Führe ein kurzes, höfliches Gespräch beim Kauf eines Souvenirs.'
|
||||
}]
|
||||
},
|
||||
'Ausflüge, Inseln & Transport': {
|
||||
learningGoals: [
|
||||
'Nach Hafen, Fahrt und Ausstieg fragen.',
|
||||
'Einen einfachen Ausflug auf Cebu oder zu einer Insel planen.',
|
||||
'Richtungs- und Transportwörter im Zusammenhang anwenden.'
|
||||
],
|
||||
corePatterns: [
|
||||
{ target: 'Asa ang pantalan?', gloss: 'Wo ist der Hafen?' },
|
||||
{ target: 'Kanus-a ang biyahe?', gloss: 'Wann ist die Fahrt?' },
|
||||
{ target: 'Moadto ko sa isla.', gloss: 'Ich fahre auf die Insel.' },
|
||||
{ target: 'Asa ta manaog?', gloss: 'Wo steigen wir aus?' },
|
||||
{ target: 'Diretso lang.', gloss: 'Einfach geradeaus.' }
|
||||
],
|
||||
speakingPrompts: [{
|
||||
title: 'Zum Hafen',
|
||||
prompt: 'Frage nach dem Hafen und der Abfahrtszeit. Sage danach, dass du auf eine Insel fahren möchtest.',
|
||||
cue: 'Asa ang pantalan? Kanus-a ang biyahe? Moadto ko sa isla.'
|
||||
}],
|
||||
practicalTasks: [{
|
||||
title: 'Ausflug planen',
|
||||
text: 'Plane einen kurzen Ausflug: Hafen finden, Fahrtzeit erfragen und richtig aussteigen.'
|
||||
}]
|
||||
},
|
||||
'Hilfe unterwegs & Reiseprobleme': {
|
||||
learningGoals: [
|
||||
'In einer einfachen Problemsituation Hilfe holen.',
|
||||
'Eine verlorene Tasche oder ein gesundheitliches Problem benennen.',
|
||||
'Höflich und klar reagieren.'
|
||||
],
|
||||
corePatterns: [
|
||||
{ target: 'Kinahanglan nako ug tabang.', gloss: 'Ich brauche Hilfe.' },
|
||||
{ target: 'Nawala ang akong bag.', gloss: 'Meine Tasche ist verloren gegangen.' },
|
||||
{ target: 'Asa ang botika?', gloss: 'Wo ist die Apotheke?' },
|
||||
{ target: 'Tawag ug tabang, palihug.', gloss: 'Ruf bitte Hilfe.' },
|
||||
{ target: 'Okay ra ko.', gloss: 'Mir geht es gut / Es ist in Ordnung.' }
|
||||
],
|
||||
speakingPrompts: [{
|
||||
title: 'Unterwegs Hilfe holen',
|
||||
prompt: 'Sage, dass du Hilfe brauchst, und erkläre kurz, ob du deine Tasche verloren hast oder eine Apotheke suchst.',
|
||||
cue: 'Kinahanglan nako ug tabang. Nawala ang akong bag. Asa ang botika?'
|
||||
}],
|
||||
practicalTasks: [{
|
||||
title: 'Notfall-Minirolle',
|
||||
text: 'Bitte klar und höflich um Hilfe und nenne ein konkretes Problem.'
|
||||
}]
|
||||
},
|
||||
'Cebu-Reisepfad – Abschluss': {
|
||||
learningGoals: [
|
||||
'Die wichtigsten Reisesituationen auf Cebu zusammenhängend anwenden.',
|
||||
'Bei Unterkunft, Essen, Transport und Hilfe selbstständig reagieren.',
|
||||
'Höflichkeit und kurze, klare Sätze sicher einsetzen.'
|
||||
],
|
||||
corePatterns: [
|
||||
{ target: 'Naa koy reservation.', gloss: 'Ich habe eine Reservierung.' },
|
||||
{ target: 'Gusto ko ani.', gloss: 'Ich möchte das.' },
|
||||
{ target: 'Asa ang pantalan?', gloss: 'Wo ist der Hafen?' },
|
||||
{ target: 'Kinahanglan nako ug tabang.', gloss: 'Ich brauche Hilfe.' },
|
||||
{ target: 'Amping.', gloss: 'Pass auf dich auf.' }
|
||||
],
|
||||
speakingPrompts: [{
|
||||
title: 'Ein Reisetag auf Cebu',
|
||||
prompt: 'Verbinde Unterkunft, Essen, Transport und Hilfe in einem kurzen Reisetag.',
|
||||
cue: 'Naa koy reservation. Gusto ko ani. Asa ang pantalan? Amping.'
|
||||
}],
|
||||
practicalTasks: [{
|
||||
title: 'Cebu-Reiseprobe',
|
||||
text: 'Spiele einen Reisetag nach: einchecken, essen, zum Hafen fahren und bei Bedarf Hilfe holen.'
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
export const BISAYA_CEBU_TRAVEL_LESSONS = [
|
||||
{ week: 16, day: 2, num: 155, type: 'conversation', title: 'Cebu ankommen & einchecken', desc: 'Einchecken, Reservierung und Zimmerfrage auf Cebu', targetMin: 18, targetScore: 78, review: false, cultural: 'Ein höflicher Gruß und ein kurzes Salamat kaayo öffnen viele Gespräche freundlich.' },
|
||||
{ week: 16, day: 2, num: 156, type: 'vocab', title: 'Unterkunft & kleine Probleme', desc: 'Zimmer, Schlüssel, Wasser und einfache Bitten an der Rezeption', targetMin: 16, targetScore: 82, review: true, cultural: null },
|
||||
{ week: 16, day: 3, num: 157, type: 'conversation', title: 'Restaurant & Bestellen', desc: 'Bestellen, nach Wasser und Preis fragen und bezahlen', targetMin: 18, targetScore: 78, review: false, cultural: 'Lami kaayo ist eine warme, natürliche Reaktion auf gutes Essen.' },
|
||||
{ week: 16, day: 3, num: 158, type: 'vocab', title: 'Markt, Souvenirs & Bezahlen', desc: 'Preise, Wechselgeld und höflicher Einkauf auf dem Markt', targetMin: 16, targetScore: 82, review: true, cultural: null },
|
||||
{ week: 16, day: 4, num: 159, type: 'conversation', title: 'Ausflüge, Inseln & Transport', desc: 'Hafen, Fahrt und Ausstieg für Ausflüge auf Cebu', targetMin: 18, targetScore: 78, review: false, cultural: 'Bei Fahrten mit Jeepney, Bus oder Boot helfen kurze Orts- und Zeitfragen am meisten.' },
|
||||
{ week: 16, day: 4, num: 160, type: 'conversation', title: 'Hilfe unterwegs & Reiseprobleme', desc: 'Tasche verloren, Apotheke finden und Hilfe holen', targetMin: 18, targetScore: 78, review: false, cultural: null },
|
||||
{ week: 16, day: 5, num: 161, type: 'review', title: 'Cebu-Reisepfad – Abschluss', desc: 'Prüfung und Wiederholung der wichtigsten Reisesituationen', targetMin: 22, targetScore: 82, review: false, cultural: 'Kurze, klare und höfliche Sätze sind unterwegs wirksamer als lange Erklärungen.' }
|
||||
];
|
||||
@@ -18,6 +18,7 @@ import { BISAYA_DIDACTICS_24_43, BISAYA_LESSONS_24_43_BY_NUMBER, BISAYA_RELATION
|
||||
import { BISAYA_PHASE3_DIDACTICS, BISAYA_PHASE3_LESSONS } from './bisaya-course-phase3-extension.js';
|
||||
import { BISAYA_PHASE4_DIDACTICS, BISAYA_PHASE4_LESSONS } from './bisaya-course-phase4-extension.js';
|
||||
import { BISAYA_PHASE5_DIDACTICS, BISAYA_PHASE5_LESSONS } from './bisaya-course-phase5-extension.js';
|
||||
import { BISAYA_CEBU_TRAVEL_DIDACTICS, BISAYA_CEBU_TRAVEL_LESSONS } from './bisaya-course-cebu-travel-extension.js';
|
||||
|
||||
function withTypeName(exerciseTypeName, exercise) {
|
||||
return {
|
||||
@@ -33,7 +34,8 @@ const GENERATED_BISAYA_DIDACTICS = {
|
||||
...BISAYA_DIDACTICS_24_43,
|
||||
...BISAYA_PHASE3_DIDACTICS,
|
||||
...BISAYA_PHASE4_DIDACTICS,
|
||||
...BISAYA_PHASE5_DIDACTICS
|
||||
...BISAYA_PHASE5_DIDACTICS,
|
||||
...BISAYA_CEBU_TRAVEL_DIDACTICS
|
||||
};
|
||||
|
||||
const SAFE_EXERCISE_UPDATE_TITLES = new Set([
|
||||
@@ -49,7 +51,8 @@ const SAFE_EXERCISE_UPDATE_TITLES = new Set([
|
||||
'Bitten & Fragen',
|
||||
...BISAYA_PHASE3_LESSONS.map((lesson) => lesson.title),
|
||||
...BISAYA_PHASE4_LESSONS.map((lesson) => lesson.title),
|
||||
...BISAYA_PHASE5_LESSONS.map((lesson) => lesson.title)
|
||||
...BISAYA_PHASE5_LESSONS.map((lesson) => lesson.title),
|
||||
...BISAYA_CEBU_TRAVEL_LESSONS.map((lesson) => lesson.title)
|
||||
]);
|
||||
|
||||
function normalizeText(value) {
|
||||
@@ -516,6 +519,14 @@ function getChoiceQuestion(lesson, didactics) {
|
||||
}
|
||||
}
|
||||
|
||||
function getConcreteChoiceQuestion(lesson, didactics, pattern) {
|
||||
const normalizedPattern = normalizeText(pattern).toLocaleLowerCase('de');
|
||||
if (normalizedPattern === 'mas maayo na ka?') {
|
||||
return 'Eine Person war krank. Du möchtest fragen, ob es ihr inzwischen besser geht. Welche Formulierung passt?';
|
||||
}
|
||||
return getChoiceQuestion(lesson, didactics);
|
||||
}
|
||||
|
||||
function pickDistractors(pattern, allPatterns, count) {
|
||||
return allPatterns
|
||||
.filter((entry) => entry !== pattern)
|
||||
@@ -536,7 +547,7 @@ function buildChoiceExercise(lesson, didactics, pattern, allPatterns, variant =
|
||||
// used as the question for a single-answer multiple-choice exercise.
|
||||
const question = normalizedPattern?.gloss
|
||||
? `Wie sagt man auf Bisaya: „${normalizedPattern.gloss}“?`
|
||||
: getChoiceQuestion(lesson, didactics);
|
||||
: getConcreteChoiceQuestion(lesson, didactics, pattern);
|
||||
|
||||
return {
|
||||
exerciseTypeId: 2,
|
||||
@@ -599,7 +610,8 @@ function buildGapExercise(lessonTitle, pattern) {
|
||||
|
||||
function buildContextGapExercise(lesson, didactics, pattern) {
|
||||
const speakingPrompt = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts[0] : null;
|
||||
const cue = normalizeText(speakingPrompt?.cue || '').toLowerCase();
|
||||
const scenarioCue = normalizeText(speakingPrompt?.cue || '');
|
||||
const cue = scenarioCue.toLowerCase();
|
||||
// A contextual prompt must test a pattern that actually occurs in its
|
||||
// scenario. Otherwise a cultural keyword such as "respeto" can be paired
|
||||
// with an invitation-declining situation.
|
||||
@@ -608,14 +620,26 @@ function buildContextGapExercise(lesson, didactics, pattern) {
|
||||
.find((candidate) => candidate && cue.includes(normalizeText(candidate).toLowerCase()));
|
||||
// Do not hide a complete sentence behind one blank. Learners need enough
|
||||
// visible context to know which word or short phrase belongs in the gap.
|
||||
const gapExercise = scenarioPattern
|
||||
? buildGapExercise(lesson.title, scenarioPattern)
|
||||
: buildGapExercise(lesson.title, pattern);
|
||||
const scenarioSteps = scenarioCue
|
||||
.split(/[.!?]+/)
|
||||
.map((step) => step.trim())
|
||||
.filter(Boolean);
|
||||
const cueWordCount = scenarioCue
|
||||
.replace(/[.,?!]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.length;
|
||||
// A multi-step cue or a short word sequence is already the concrete
|
||||
// scenario. Do not reduce a route or a table setting to a bare word.
|
||||
const gapTarget = scenarioSteps.length >= 2 || cueWordCount >= 3
|
||||
? scenarioCue
|
||||
: (scenarioPattern || pattern);
|
||||
const gapExercise = buildGapExercise(lesson.title, gapTarget);
|
||||
if (!gapExercise) return null;
|
||||
|
||||
return {
|
||||
...gapExercise,
|
||||
title: `${lesson.title}: Kernmuster ergänzen`,
|
||||
title: `${lesson.title}: Satz im Kontext ergänzen`,
|
||||
instruction: `Vervollständige die Formulierung passend zur Situation: ${getScenarioPrompt(lesson, didactics)}`
|
||||
};
|
||||
}
|
||||
@@ -634,7 +658,7 @@ function buildSentenceExercise(lessonTitle, pattern) {
|
||||
instruction: 'Ordne die Wörter zu einem korrekten Bisaya-Satz.',
|
||||
questionData: {
|
||||
type: 'sentence_building',
|
||||
question: `Baue das Kernmuster aus der Lektion "${lessonTitle}".`,
|
||||
question: 'Ordne diese Wörter zu einem vollständigen Bisaya-Satz.',
|
||||
tokens
|
||||
},
|
||||
answerData: {
|
||||
@@ -1090,7 +1114,7 @@ const BISAYA_EXERCISES = {
|
||||
{
|
||||
exerciseTypeId: 1,
|
||||
title: 'Gesundheitsfrage ergänzen',
|
||||
instruction: 'Fülle die Lücken mit den passenden Bisaya-Wörtern.',
|
||||
instruction: 'Frage auf Bisaya: „Tut dir der Bauch weh? Geht es dir schon besser?“',
|
||||
questionData: {
|
||||
type: 'gap_fill',
|
||||
text: 'Sakit imong {gap}? Mas maayo na {gap}?',
|
||||
@@ -1236,7 +1260,7 @@ const BISAYA_EXERCISES = {
|
||||
{
|
||||
exerciseTypeId: 1,
|
||||
title: 'Weich ablehnen',
|
||||
instruction: 'Fülle die Lücken.',
|
||||
instruction: 'Lehne eine Einladung höflich ab: „Heute lieber nicht. Ein anderes Mal.“',
|
||||
questionData: {
|
||||
type: 'gap_fill',
|
||||
text: 'Dili lang sa {gap}. Sunod na {gap}.',
|
||||
@@ -1318,7 +1342,7 @@ const BISAYA_EXERCISES = {
|
||||
{
|
||||
exerciseTypeId: 1,
|
||||
title: 'Nachfragen ergänzen',
|
||||
instruction: 'Fülle die Lücken.',
|
||||
instruction: 'Frage: „Wo ist deine Tasche?“ Bitte danach: „Nimm deine Tasche.“',
|
||||
questionData: {
|
||||
type: 'gap_fill',
|
||||
text: 'Hinay-hinay {gap}. Unsay pasabot {gap}?',
|
||||
@@ -2413,23 +2437,21 @@ const BISAYA_EXERCISES = {
|
||||
explanation: '„Naa sila sa …“ = Sie sind in/am …'
|
||||
},
|
||||
withTypeName('dialog_completion', {
|
||||
title: 'Nach der Küche fragen',
|
||||
instruction: 'Ergänze die passende Antwort (Ortsangaben wie didto/luyo kommen in der nächsten Lektion).',
|
||||
title: 'Personen in der Küche',
|
||||
instruction: 'Jemand fragt, wo die Personen sind. Antworte, dass sie in der Küche sind.',
|
||||
questionData: {
|
||||
type: 'dialog_completion',
|
||||
question: 'Welche Antwort passt?',
|
||||
dialog: ['A: Asa ang kusina?', 'B: ...']
|
||||
dialog: ['A: Asa sila?', 'B: ...']
|
||||
},
|
||||
answerData: {
|
||||
modelAnswer: 'Naa sila sa kusina.',
|
||||
correct: [
|
||||
'Naa sila sa kusina.',
|
||||
'Naa siya sa kusina.',
|
||||
'Naa ko sa kusina.',
|
||||
'Sa kusina.'
|
||||
'Naa sila sa kusina'
|
||||
]
|
||||
},
|
||||
explanation: 'Mit „Naa … sa kusina“ kannst du sagen, wo sich jemand befindet – dasselbe Muster wie „Naa sila sa kusina.“ in der Lektion.'
|
||||
explanation: '„Asa sila?“ fragt: Wo sind sie? „Naa sila sa kusina.“ bedeutet: Sie sind in der Küche.'
|
||||
}),
|
||||
withTypeName('situational_response', {
|
||||
title: 'Zu Hause kurz beschreiben',
|
||||
@@ -2696,7 +2718,7 @@ const BISAYA_EXERCISES = {
|
||||
},
|
||||
withTypeName('gap_fill', {
|
||||
title: 'Zeitmuster anwenden',
|
||||
instruction: 'Verwende das Verb "adto" in Vergangenheit, Gegenwart und Zukunft. Trage jede Form in das passende Feld ein.',
|
||||
instruction: 'Bilde „ich gehe“ mit „adto“ in Vergangenheit, Gegenwart und Zukunft. Verwende in jeder Form „ko“.',
|
||||
questionData: {
|
||||
type: 'gap_fill',
|
||||
text: 'Vergangenheit: {gap} | Gegenwart: {gap} | Zukunft: {gap}',
|
||||
|
||||
@@ -16,6 +16,7 @@ import { BISAYA_DIDACTICS_24_43, BISAYA_LESSONS_24_43, BISAYA_RELATIONSHIP_ANCHO
|
||||
import { BISAYA_PHASE3_DIDACTICS, BISAYA_PHASE3_LESSONS } from './bisaya-course-phase3-extension.js';
|
||||
import { BISAYA_PHASE4_DIDACTICS, BISAYA_PHASE4_LESSONS } from './bisaya-course-phase4-extension.js';
|
||||
import { BISAYA_PHASE5_DIDACTICS, BISAYA_PHASE5_LESSONS } from './bisaya-course-phase5-extension.js';
|
||||
import { BISAYA_CEBU_TRAVEL_DIDACTICS, BISAYA_CEBU_TRAVEL_LESSONS } from './bisaya-course-cebu-travel-extension.js';
|
||||
|
||||
const LESSON_DIDACTICS = {
|
||||
'Begrüßungen & Höflichkeit': {
|
||||
@@ -373,7 +374,7 @@ const LESSON_DIDACTICS = {
|
||||
speakingPrompts: [
|
||||
{
|
||||
title: 'Auf dem Tisch',
|
||||
prompt: 'Nenne drei Dinge, die auf dem Tisch stehen oder die du essen und trinken möchtest.',
|
||||
prompt: 'Auf dem Tisch stehen Reis, Fisch und Wasser. Ergänze das fehlende Bisaya-Wort.',
|
||||
cue: 'Kan-on, isda ug tubig.'
|
||||
}
|
||||
],
|
||||
@@ -868,7 +869,8 @@ const LESSON_DIDACTICS = {
|
||||
...BISAYA_DIDACTICS_24_43,
|
||||
...BISAYA_PHASE3_DIDACTICS,
|
||||
...BISAYA_PHASE4_DIDACTICS,
|
||||
...BISAYA_PHASE5_DIDACTICS
|
||||
...BISAYA_PHASE5_DIDACTICS,
|
||||
...BISAYA_CEBU_TRAVEL_DIDACTICS
|
||||
};
|
||||
|
||||
const LESSONS = [
|
||||
@@ -993,10 +995,11 @@ const LESSONS = [
|
||||
|
||||
...BISAYA_PHASE3_LESSONS,
|
||||
...BISAYA_PHASE4_LESSONS,
|
||||
...BISAYA_PHASE5_LESSONS
|
||||
...BISAYA_PHASE5_LESSONS,
|
||||
...BISAYA_CEBU_TRAVEL_LESSONS
|
||||
];
|
||||
|
||||
const BISAYA_FAMILY_COURSE_TITLE = 'Bisaya für Familien - Alltag & Stabilisierung';
|
||||
const BISAYA_FAMILY_COURSE_TITLE = 'Bisaya für Cebu – Reise, Familie & Alltag';
|
||||
|
||||
async function createBisayaCourse(languageId, ownerHashedId) {
|
||||
try {
|
||||
@@ -1034,7 +1037,7 @@ async function createBisayaCourse(languageId, ownerHashedId) {
|
||||
const course = await VocabCourse.create({
|
||||
ownerUserId: user.id,
|
||||
title: BISAYA_FAMILY_COURSE_TITLE,
|
||||
description: 'Lerne Bisaya (Cebuano) praxisnah für den Familienalltag. Der Pfad verbindet Schnellstart, Alltagsmodule und Stabilisierungsblöcke mit Spiralwiederholung, Fehlertraining und freier Produktion.',
|
||||
description: 'Lerne Bisaya (Cebuano) praxisnah für Reisen und längere Aufenthalte auf Cebu – mit Unterkunft, Essen, Markt, Ausflügen, Familie, Höflichkeit und Hilfe unterwegs.',
|
||||
languageId: Number(languageId),
|
||||
difficultyLevel: 1,
|
||||
isPublic: true,
|
||||
|
||||
69
backend/scripts/extend-bisaya-course-cebu-travel.js
Normal file
69
backend/scripts/extend-bisaya-course-cebu-travel.js
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
/** Fügt Bisaya-Kursen den optionalen Reisepfad „Cebu – Reise & Alltag“ hinzu. */
|
||||
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import VocabCourse from '../models/community/vocab_course.js';
|
||||
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
|
||||
import { BISAYA_CEBU_TRAVEL_DIDACTICS, BISAYA_CEBU_TRAVEL_LESSONS } from './bisaya-course-cebu-travel-extension.js';
|
||||
|
||||
async function extendBisayaCourseForCebuTravel() {
|
||||
await sequelize.authenticate();
|
||||
const [language] = await sequelize.query(
|
||||
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
|
||||
{ type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
if (!language) throw new Error('Bisaya-Sprache nicht gefunden.');
|
||||
|
||||
const courses = await VocabCourse.findAll({ where: { languageId: language.id } });
|
||||
let addedLessons = 0;
|
||||
for (const course of courses) {
|
||||
await course.update({
|
||||
title: 'Bisaya für Cebu – Reise, Familie & Alltag',
|
||||
description: 'Praxisnahes Cebuano für Reisen und längere Aufenthalte auf Cebu: Unterkunft, Essen, Markt, Ausflüge, Familie, Höflichkeit und Hilfe unterwegs.'
|
||||
});
|
||||
for (const lessonData of BISAYA_CEBU_TRAVEL_LESSONS) {
|
||||
const existing = await VocabCourseLesson.findOne({
|
||||
where: { courseId: course.id, lessonNumber: lessonData.num }
|
||||
});
|
||||
if (existing) continue;
|
||||
const didactics = BISAYA_CEBU_TRAVEL_DIDACTICS[lessonData.title];
|
||||
await VocabCourseLesson.create({
|
||||
courseId: course.id,
|
||||
chapterId: null,
|
||||
lessonNumber: lessonData.num,
|
||||
title: lessonData.title,
|
||||
description: lessonData.desc,
|
||||
weekNumber: lessonData.week,
|
||||
dayNumber: lessonData.day,
|
||||
lessonType: lessonData.type,
|
||||
culturalNotes: lessonData.cultural,
|
||||
learningGoals: didactics.learningGoals,
|
||||
corePatterns: didactics.corePatterns,
|
||||
grammarFocus: didactics.grammarFocus || [],
|
||||
speakingPrompts: didactics.speakingPrompts || [],
|
||||
practicalTasks: didactics.practicalTasks || [],
|
||||
targetMinutes: lessonData.targetMin,
|
||||
targetScorePercent: lessonData.targetScore,
|
||||
requiresReview: lessonData.review,
|
||||
didacticMode: 'travel_practice',
|
||||
phaseLabel: 'Cebu – Reise & Alltag',
|
||||
blockNumber: 8,
|
||||
difficultyWeight: 1,
|
||||
newUnitTarget: 8,
|
||||
reviewWeight: lessonData.review ? 2 : 1,
|
||||
isIntensiveReview: false
|
||||
});
|
||||
addedLessons += 1;
|
||||
console.log(`✅ Kurs ${course.id}: Lektion ${lessonData.num} – ${lessonData.title} ergänzt`);
|
||||
}
|
||||
}
|
||||
console.log(`Cebu-Reisepfad: ${addedLessons} Lektionen ergänzt.`);
|
||||
}
|
||||
|
||||
extendBisayaCourseForCebuTravel()
|
||||
.then(() => sequelize.close())
|
||||
.catch((error) => {
|
||||
console.error('❌ Fehler:', error);
|
||||
sequelize.close();
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -27,6 +27,7 @@ const SAFE_SYNC_STEPS = {
|
||||
'backend/scripts/extend-bisaya-course-phase3.js',
|
||||
'backend/scripts/extend-bisaya-course-phase4.js',
|
||||
'backend/scripts/extend-bisaya-course-phase5.js',
|
||||
'backend/scripts/extend-bisaya-course-cebu-travel.js',
|
||||
// Alte Zusatzlektionen 44/45 teilen Nummern mit Phase 3. Erst der
|
||||
// Content-Schritt stellt ihre sichtbaren Zahlentitel wieder her; danach
|
||||
// kann die Didaktik die Zahlmuster statt der Besuchsmuster einspielen.
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { BISAYA_PHASE3_DIDACTICS } from './bisaya-course-phase3-extension.js';
|
||||
import { BISAYA_PHASE4_DIDACTICS } from './bisaya-course-phase4-extension.js';
|
||||
import { BISAYA_PHASE5_DIDACTICS } from './bisaya-course-phase5-extension.js';
|
||||
import { BISAYA_CEBU_TRAVEL_DIDACTICS } from './bisaya-course-cebu-travel-extension.js';
|
||||
|
||||
/** Alte Kurstitel → aktueller Schlüssel in LESSON_DIDACTICS (bestehende Datenbanken). */
|
||||
export const LEGACY_DIDACTICS_TITLE_MAP = {
|
||||
@@ -501,7 +502,8 @@ export const LESSON_DIDACTICS = {
|
||||
...BISAYA_DIDACTICS_24_43,
|
||||
...BISAYA_PHASE3_DIDACTICS,
|
||||
...BISAYA_PHASE4_DIDACTICS,
|
||||
...BISAYA_PHASE5_DIDACTICS
|
||||
...BISAYA_PHASE5_DIDACTICS,
|
||||
...BISAYA_CEBU_TRAVEL_DIDACTICS
|
||||
};
|
||||
|
||||
function resolveDidacticsForLesson(lesson) {
|
||||
|
||||
@@ -94,9 +94,9 @@ function calcAge(birthdate) {
|
||||
|
||||
// Ein realer Tag entspricht einem Falukant-Spieljahr.
|
||||
const FAMILY_AGE = Object.freeze({
|
||||
MIN_WOOING: 12,
|
||||
MIN_WOOING: 10,
|
||||
MIN_MARRIAGE: 14,
|
||||
MIN_HOUSEHOLD_AND_CHILDREN: 16,
|
||||
MIN_HOUSEHOLD_AND_CHILDREN: 14,
|
||||
});
|
||||
|
||||
async function getFalukantUserOrFail(hashedId) {
|
||||
@@ -3820,7 +3820,7 @@ class FalukantService extends BaseService {
|
||||
const childChars = childCharIds.length
|
||||
? await FalukantCharacter.findAll({
|
||||
where: { id: childCharIds },
|
||||
attributes: ['id', 'birthdate', 'gender'],
|
||||
attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility'],
|
||||
include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }]
|
||||
})
|
||||
: [];
|
||||
@@ -3892,6 +3892,42 @@ class FalukantService extends BaseService {
|
||||
otherParent: otherParentId != null ? (otherParentMap[otherParentId] || null) : null,
|
||||
};
|
||||
});
|
||||
const childIds = children.map((child) => child.childCharacterId);
|
||||
const childRelationships = childIds.length
|
||||
? await Relationship.findAll({
|
||||
where: { [Op.or]: [{ character1Id: { [Op.in]: childIds } }, { character2Id: { [Op.in]: childIds } }] },
|
||||
include: [
|
||||
{ model: RelationshipType, as: 'relationshipType', attributes: ['tr'] },
|
||||
{ model: FalukantCharacter, as: 'character1', attributes: ['id'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] },
|
||||
{ model: FalukantCharacter, as: 'character2', attributes: ['id'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] },
|
||||
],
|
||||
attributes: ['id', 'character1Id', 'character2Id', 'nextStepProgress']
|
||||
})
|
||||
: [];
|
||||
const childRelationshipMap = new Map();
|
||||
for (const relation of childRelationships) {
|
||||
const type = relation.relationshipType?.tr;
|
||||
if (!['wooing', 'engaged', 'married'].includes(type)) continue;
|
||||
const childId = childIds.includes(relation.character1Id) ? relation.character1Id : relation.character2Id;
|
||||
const partner = relation.character1Id === childId ? relation.character2 : relation.character1;
|
||||
childRelationshipMap.set(childId, {
|
||||
id: relation.id,
|
||||
status: type,
|
||||
progress: relation.nextStepProgress || 0,
|
||||
partnerName: partner?.definedFirstName?.name || 'Unbekannt',
|
||||
});
|
||||
}
|
||||
for (const child of children) {
|
||||
child.relationship = childRelationshipMap.get(child.childCharacterId) || null;
|
||||
child.possiblePartners = [];
|
||||
const kid = childCharMap[child.childCharacterId];
|
||||
if (!child.hasName || child.relationship || !kid || calcAge(kid.birthdate) < FAMILY_AGE.MIN_WOOING) continue;
|
||||
child.possiblePartners = await this.getPossiblePartners(kid.id);
|
||||
if (child.possiblePartners.length === 0) {
|
||||
await this.createPossiblePartners(kid.id, kid.gender, kid.regionId, kid.titleOfNobility, calcAge(kid.birthdate));
|
||||
child.possiblePartners = await this.getPossiblePartners(kid.id);
|
||||
}
|
||||
}
|
||||
// Sort children globally by relation createdAt ascending (older first)
|
||||
children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt));
|
||||
const inProgress = ['wooing', 'engaged', 'married'];
|
||||
@@ -4637,7 +4673,7 @@ class FalukantService extends BaseService {
|
||||
gender: { [Op.ne]: requestingCharacterGender },
|
||||
regionId: requestingRegionId,
|
||||
birthdate: { [Op.lte]: new Date(Date.now() - FAMILY_AGE.MIN_WOOING * 24 * 60 * 60 * 1000) },
|
||||
createdAt: { [Op.lt]: new Date(new Date() - 12 * 24 * 60 * 60 * 1000) },
|
||||
createdAt: { [Op.lt]: new Date(Date.now() - FAMILY_AGE.MIN_WOOING * 24 * 60 * 60 * 1000) },
|
||||
titleOfNobility: { [Op.between]: [requestingCharacterTitleOfNobility - 1, requestingCharacterTitleOfNobility + 1] }
|
||||
},
|
||||
order: [
|
||||
@@ -4684,7 +4720,7 @@ class FalukantService extends BaseService {
|
||||
|| calcAge(character.birthdate) < FAMILY_AGE.MIN_WOOING
|
||||
|| calcAge(proposedCharacter.birthdate) < FAMILY_AGE.MIN_WOOING
|
||||
) {
|
||||
const error = new Error('Werbung ist erst ab 12 Spieljahren möglich');
|
||||
const error = new Error('Werbung ist erst ab 10 Spieljahren möglich');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
@@ -4723,6 +4759,60 @@ class FalukantService extends BaseService {
|
||||
return { success: true, message: 'Marriage proposal accepted', relationshipId: newRel.id };
|
||||
}
|
||||
|
||||
async acceptChildMarriageProposal(hashedUserId, childCharacterId, proposedCharacterId) {
|
||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||
const childId = Number(childCharacterId);
|
||||
const partnerId = Number(proposedCharacterId);
|
||||
if (!Number.isInteger(childId) || !Number.isInteger(partnerId)) throw { status: 400, message: 'Ungültige Partnerauswahl' };
|
||||
|
||||
const childRelation = await ChildRelation.findOne({
|
||||
where: { childCharacterId: childId, [Op.or]: [{ fatherCharacterId: user.character.id }, { motherCharacterId: user.character.id }] }
|
||||
});
|
||||
if (!childRelation) throw { status: 404, message: 'Kind gehört nicht zu deinem Charakter' };
|
||||
|
||||
const [child, partner, proposal, activeRelation] = await Promise.all([
|
||||
FalukantCharacter.findByPk(childId, { attributes: ['id', 'birthdate'] }),
|
||||
FalukantCharacter.findByPk(partnerId, { attributes: ['id', 'birthdate'] }),
|
||||
MarriageProposal.findOne({ where: { requesterCharacterId: childId, proposedCharacterId: partnerId } }),
|
||||
Relationship.findOne({ where: { [Op.or]: [{ character1Id: childId }, { character2Id: childId }] }, include: [{ model: RelationshipType, as: 'relationshipType', where: { tr: { [Op.in]: ['wooing', 'engaged', 'married'] } } }] })
|
||||
]);
|
||||
if (!proposal || !child || !partner) throw { status: 404, message: 'Partnervorschlag nicht gefunden' };
|
||||
if (activeRelation) throw { status: 409, message: 'Das Kind ist bereits in einer Beziehung' };
|
||||
if (calcAge(child.birthdate) < FAMILY_AGE.MIN_WOOING || calcAge(partner.birthdate) < FAMILY_AGE.MIN_WOOING) {
|
||||
throw { status: 422, message: 'Verkuppelung ist erst ab 10 Spieljahren möglich' };
|
||||
}
|
||||
if (Number(user.money) < Number(proposal.cost)) throw { status: 422, message: 'Nicht genügend Guthaben' };
|
||||
const wooingType = await RelationshipType.findOne({ where: { tr: 'wooing' } });
|
||||
const moneyResult = await updateFalukantUserMoney(user.id, -proposal.cost, 'Child marriage arrangement', user.id);
|
||||
if (!moneyResult.success) throw new Error('Geld konnte nicht abgezogen werden');
|
||||
const relationship = await Relationship.create({
|
||||
character1Id: childId,
|
||||
character2Id: partnerId,
|
||||
relationshipTypeId: wooingType.id,
|
||||
nextStepProgress: Math.ceil(FalukantService.WOOING_PROGRESS_TARGET / 2),
|
||||
});
|
||||
await MarriageProposal.destroy({ where: { requesterCharacterId: childId } });
|
||||
return { success: true, relationshipId: relationship.id };
|
||||
}
|
||||
|
||||
async advanceChildWooing(hashedUserId, childCharacterId) {
|
||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||
const childId = Number(childCharacterId);
|
||||
const isOwnChild = await ChildRelation.findOne({
|
||||
where: { childCharacterId: childId, [Op.or]: [{ fatherCharacterId: user.character.id }, { motherCharacterId: user.character.id }] },
|
||||
attributes: ['childCharacterId']
|
||||
});
|
||||
if (!isOwnChild) throw { status: 404, message: 'Kind gehört nicht zu deinem Charakter' };
|
||||
const relationship = await Relationship.findOne({
|
||||
where: { [Op.or]: [{ character1Id: childId }, { character2Id: childId }] },
|
||||
include: [{ model: RelationshipType, as: 'relationshipType', where: { tr: 'wooing' } }]
|
||||
});
|
||||
if (!relationship) throw { status: 409, message: 'Keine laufende Werbung für dieses Kind' };
|
||||
const engagedType = await RelationshipType.findOne({ where: { tr: 'engaged' } });
|
||||
await relationship.update({ nextStepProgress: 0, relationshipTypeId: engagedType.id });
|
||||
return { success: true, status: 'engaged' };
|
||||
}
|
||||
|
||||
async cancelWooing(hashedUserId) {
|
||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||
if (!user || !user.character) {
|
||||
@@ -5608,44 +5698,37 @@ class FalukantService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async getWeddingCandidates(falukantUser) {
|
||||
const parent = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['id'] });
|
||||
if (!parent) return [];
|
||||
const childRelations = await ChildRelation.findAll({
|
||||
where: { [Op.or]: [{ fatherCharacterId: parent.id }, { motherCharacterId: parent.id }] },
|
||||
attributes: ['childCharacterId']
|
||||
});
|
||||
const familyCharacterIds = [parent.id, ...childRelations.map((relation) => relation.childCharacterId)];
|
||||
const relationships = await Relationship.findAll({
|
||||
where: { [Op.or]: [{ character1Id: { [Op.in]: familyCharacterIds } }, { character2Id: { [Op.in]: familyCharacterIds } }] },
|
||||
include: [
|
||||
{ model: RelationshipType, as: 'relationshipType', where: { tr: 'engaged' } },
|
||||
{ model: FalukantCharacter, as: 'character1', attributes: ['id', 'birthdate'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] },
|
||||
{ model: FalukantCharacter, as: 'character2', attributes: ['id', 'birthdate'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] },
|
||||
],
|
||||
attributes: ['id', 'character1Id', 'character2Id']
|
||||
});
|
||||
return relationships
|
||||
.filter((relationship) => calcAge(relationship.character1.birthdate) >= FAMILY_AGE.MIN_MARRIAGE
|
||||
&& calcAge(relationship.character2.birthdate) >= FAMILY_AGE.MIN_MARRIAGE)
|
||||
.map((relationship) => ({
|
||||
relationshipId: relationship.id,
|
||||
label: `${relationship.character1.definedFirstName?.name || 'Unbekannt'} & ${relationship.character2.definedFirstName?.name || 'Unbekannt'}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async getPartyTypes(hashedUserId) {
|
||||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
||||
const character = await FalukantCharacter.findOne({
|
||||
where: { userId: falukantUser.id },
|
||||
attributes: ['id', 'birthdate'],
|
||||
});
|
||||
const engagedCount = character && calcAge(character.birthdate) >= FAMILY_AGE.MIN_MARRIAGE
|
||||
? await Relationship.count({
|
||||
include: [
|
||||
{
|
||||
model: RelationshipType,
|
||||
as: 'relationshipType',
|
||||
where: { tr: 'engaged' },
|
||||
required: true
|
||||
},
|
||||
{
|
||||
model: FalukantCharacter,
|
||||
as: 'character1',
|
||||
where: { userId: falukantUser.id },
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: FalukantCharacter,
|
||||
as: 'character2',
|
||||
where: { userId: falukantUser.id },
|
||||
required: false
|
||||
}
|
||||
],
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ '$character1.user_id$': falukantUser.id },
|
||||
{ '$character2.user_id$': falukantUser.id }
|
||||
]
|
||||
}
|
||||
})
|
||||
: 0;
|
||||
const weddingCandidates = await this.getWeddingCandidates(falukantUser);
|
||||
const orConditions = [{ forMarriage: false }];
|
||||
if (engagedCount > 0) {
|
||||
if (weddingCandidates.length > 0) {
|
||||
orConditions.push({ forMarriage: true });
|
||||
}
|
||||
const partyTypes = await PartyType.findAll({
|
||||
@@ -5656,10 +5739,10 @@ class FalukantService extends BaseService {
|
||||
});
|
||||
const musicTypes = await MusicType.findAll();
|
||||
const banquetteTypes = await BanquetteType.findAll();
|
||||
return { partyTypes, musicTypes, banquetteTypes };
|
||||
return { partyTypes, musicTypes, banquetteTypes, weddingCandidates };
|
||||
}
|
||||
|
||||
async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio) {
|
||||
async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio, relationshipId = null) {
|
||||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
||||
const since = new Date(Date.now() - 24 * 3600 * 1000);
|
||||
const already = await Party.findOne({
|
||||
@@ -5694,34 +5777,9 @@ class FalukantService extends BaseService {
|
||||
|
||||
const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['id', 'birthdate', 'titleOfNobility'] });
|
||||
if (ptype.forMarriage) {
|
||||
if (!character || calcAge(character.birthdate) < FAMILY_AGE.MIN_MARRIAGE) {
|
||||
const error = new Error('Eine Hochzeit ist erst ab 14 Spieljahren möglich');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
const engagement = await Relationship.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ character1Id: character.id },
|
||||
{ character2Id: character.id },
|
||||
],
|
||||
},
|
||||
include: [{ model: RelationshipType, as: 'relationshipType', where: { tr: 'engaged' } }],
|
||||
attributes: ['character1Id', 'character2Id'],
|
||||
});
|
||||
const partnerId = engagement?.character1Id === character.id
|
||||
? engagement.character2Id
|
||||
: engagement?.character1Id;
|
||||
const partner = partnerId
|
||||
? await FalukantCharacter.findByPk(partnerId, { attributes: ['birthdate'] })
|
||||
: null;
|
||||
if (!engagement) {
|
||||
const error = new Error('Für eine Hochzeitsfeier musst du mit deinem Partner verlobt sein');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
if (!partner || calcAge(partner.birthdate) < FAMILY_AGE.MIN_MARRIAGE) {
|
||||
const error = new Error('Beide Verlobten müssen mindestens 14 Spieljahre alt sein');
|
||||
const validRelationshipIds = (await this.getWeddingCandidates(falukantUser)).map((candidate) => candidate.relationshipId);
|
||||
if (!validRelationshipIds.includes(Number(relationshipId))) {
|
||||
const error = new Error('Für eine Hochzeitsfeier muss ein verlobtes Paar ab 14 Spieljahren ausgewählt werden');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
@@ -5748,6 +5806,7 @@ class FalukantService extends BaseService {
|
||||
falukantUserId: falukantUser.id,
|
||||
musicTypeId: musicId,
|
||||
banquetteTypeId: banquetteId,
|
||||
relationshipId: ptype.forMarriage ? Number(relationshipId) : null,
|
||||
servantRatio,
|
||||
cost: cost
|
||||
});
|
||||
@@ -5796,24 +5855,14 @@ class FalukantService extends BaseService {
|
||||
|
||||
async getNotBaptisedChildren(hashedUserId) {
|
||||
const falukantUser = await getFalukantUserOrFail(hashedUserId);
|
||||
const userCharacterIds = (await FalukantCharacter.findAll({
|
||||
where: { userId: falukantUser.id },
|
||||
attributes: ['id'],
|
||||
raw: true,
|
||||
})).map((character) => character.id);
|
||||
if (userCharacterIds.length === 0) return [];
|
||||
const children = await ChildRelation.findAll({
|
||||
include: [
|
||||
{
|
||||
model: FalukantCharacter,
|
||||
as: 'father',
|
||||
where: {
|
||||
userId: falukantUser.id,
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: FalukantCharacter,
|
||||
as: 'mother',
|
||||
where: {
|
||||
userId: falukantUser.id,
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: FalukantCharacter,
|
||||
as: 'child',
|
||||
@@ -5829,6 +5878,10 @@ class FalukantService extends BaseService {
|
||||
],
|
||||
where: {
|
||||
nameSet: false,
|
||||
[Op.or]: [
|
||||
{ fatherCharacterId: { [Op.in]: userCharacterIds } },
|
||||
{ motherCharacterId: { [Op.in]: userCharacterIds } },
|
||||
],
|
||||
},
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
@@ -104,20 +104,21 @@ async function getCachedNews({ language = 'de', category = 'top', minArticles =
|
||||
* @param {number} options.counter - Index des Artikels (0 = erster, 1 = zweiter, …)
|
||||
* @param {string} [options.language]
|
||||
* @param {string} [options.category]
|
||||
* @param {number} [options.count] - Anzahl aufeinanderfolgender Artikel
|
||||
* @returns {Promise<{ results: Array, nextPage: string|null }>}
|
||||
*/
|
||||
async function getNews({ counter = 0, language = 'de', category = 'top' }) {
|
||||
async function getNews({ counter = 0, count = 1, language = 'de', category = 'top' }) {
|
||||
const neededIndex = Math.max(0, counter);
|
||||
const requestedCount = Math.min(6, Math.max(1, Number.parseInt(count, 10) || 1));
|
||||
|
||||
// Mindestens so viele Artikel laden wie benötigt
|
||||
const articles = await getCachedNews({
|
||||
language,
|
||||
category,
|
||||
minArticles: neededIndex + 1
|
||||
minArticles: neededIndex + requestedCount
|
||||
});
|
||||
|
||||
const single = articles[neededIndex] ? [articles[neededIndex]] : [];
|
||||
return { results: single, nextPage: null };
|
||||
return { results: articles.slice(neededIndex, neededIndex + requestedCount), nextPage: null };
|
||||
}
|
||||
|
||||
export default { getNews };
|
||||
|
||||
@@ -3617,7 +3617,7 @@ export default class VocabService {
|
||||
const seed = (Number(lessonId) * 100003) >>> 0;
|
||||
const percentage = 40 + (seed % 21);
|
||||
const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100));
|
||||
return this._seededShuffle(list.slice(), seed).slice(0, targetCount);
|
||||
return this._selectConcreteExamExercises(list, targetCount, seed);
|
||||
}
|
||||
|
||||
_selectCheckpointExamExercises(exercises = [], lessonId) {
|
||||
@@ -3628,7 +3628,73 @@ export default class VocabService {
|
||||
// Checkpoints: smaller sample ~10-30%
|
||||
const percentage = 10 + (seed % 21);
|
||||
const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100));
|
||||
return this._seededShuffle(list.slice(), seed).slice(0, targetCount);
|
||||
return this._selectConcreteExamExercises(list, targetCount, seed);
|
||||
}
|
||||
|
||||
_getExamExerciseQuestionType(exercise) {
|
||||
const questionData = typeof exercise?.questionData === 'string'
|
||||
? JSON.parse(exercise.questionData)
|
||||
: (exercise?.questionData || {});
|
||||
return String(questionData.type || exercise?.exerciseType?.name || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
_isGenericPatternPrompt(exercise) {
|
||||
const questionData = typeof exercise?.questionData === 'string'
|
||||
? JSON.parse(exercise.questionData)
|
||||
: (exercise?.questionData || {});
|
||||
const prompt = String(questionData.question || questionData.text || exercise?.instruction || '');
|
||||
return /(?:kernmuster|zentrales? muster)/i.test(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Review and checkpoint exams must be concrete and reliably checkable.
|
||||
* Prefer gap fills (one is included whenever available), then multiple
|
||||
* choice, and use open "core pattern" prompts only when there is no other
|
||||
* material available at all.
|
||||
*/
|
||||
_selectConcreteExamExercises(exercises = [], targetCount, seed) {
|
||||
const list = Array.isArray(exercises) ? exercises : [];
|
||||
const groups = {
|
||||
gap: [],
|
||||
multipleChoice: [],
|
||||
concreteOther: [],
|
||||
fallback: []
|
||||
};
|
||||
|
||||
for (const exercise of list) {
|
||||
const type = this._getExamExerciseQuestionType(exercise);
|
||||
if (type === 'gap_fill') {
|
||||
groups.gap.push(exercise);
|
||||
} else if (type === 'multiple_choice') {
|
||||
groups.multipleChoice.push(exercise);
|
||||
} else if (!this._isGenericPatternPrompt(exercise)
|
||||
&& !['speaking_from_memory', 'reading_aloud'].includes(type)) {
|
||||
groups.concreteOther.push(exercise);
|
||||
} else {
|
||||
groups.fallback.push(exercise);
|
||||
}
|
||||
}
|
||||
|
||||
const selected = [];
|
||||
const addUntilFull = (items, groupSeed) => {
|
||||
for (const exercise of this._seededShuffle(items, groupSeed)) {
|
||||
if (selected.length >= targetCount) break;
|
||||
selected.push(exercise);
|
||||
}
|
||||
};
|
||||
|
||||
// A gap fill is a good, unambiguous opening question and avoids exams
|
||||
// consisting only of abstract sentence-building prompts. Keep the rest
|
||||
// balanced so a larger exam does not become a list of gaps only.
|
||||
const shuffledGaps = this._seededShuffle(groups.gap, (seed ^ 0x1f123bb5) >>> 0);
|
||||
if (shuffledGaps.length > 0) {
|
||||
selected.push(shuffledGaps.shift());
|
||||
}
|
||||
addUntilFull(groups.multipleChoice, (seed ^ 0x39a1f8c7) >>> 0);
|
||||
addUntilFull(shuffledGaps, (seed ^ 0x2d98c6f1) >>> 0);
|
||||
addUntilFull(groups.concreteOther, (seed ^ 0x5bd1e995) >>> 0);
|
||||
addUntilFull(groups.fallback, (seed ^ 0x7f4a7c15) >>> 0);
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,28 +7,34 @@ verwendet daher die Differenz zwischen `CURRENT_DATE` und `birthdate` in Tagen.
|
||||
|
||||
| Bereich | Mindestalter | Regel |
|
||||
| --- | ---: | --- |
|
||||
| Werbung | 12 | Partner können vorgeschlagen und ein Vorschlag kann angenommen werden. |
|
||||
| Werbung | 10 | Partner können vorgeschlagen und ein Vorschlag kann angenommen werden. |
|
||||
| Hochzeit | 14 | Eine Verlobung kann durch eine Hochzeitsfeier vollzogen werden. |
|
||||
| Gemeinsamer Haushalt und Kinder | 16 | Erst dann darf die Spielmechanik eine Schwangerschaft oder Geburt aus einer Ehe erzeugen. |
|
||||
| Gemeinsamer Haushalt und Kinder | 14 | Ab dem Heiratsalter darf die Spielmechanik eine Schwangerschaft oder Geburt aus einer Ehe erzeugen. |
|
||||
|
||||
Die Regeln gelten für **beide** Personen einer Beziehung. Sie bilden bewusst
|
||||
Spielphasen ab; es gibt keine individuelle Prüfung körperlicher Reife und keine
|
||||
explizite Sexualmechanik.
|
||||
|
||||
Eltern können ihre benannten Kinder ab 10 Spieljahren verkuppeln. Diese
|
||||
Werbung ist verkürzt: Nach der Auswahl eines Kandidaten genügt ein weiterer
|
||||
Verkuppelungsschritt zur Verlobung. Die Hochzeitsfeier bleibt trotzdem ab 14
|
||||
Spieljahren erforderlich und wird dabei ausdrücklich dem verlobten Paar
|
||||
zugeordnet. Ab diesem Alter kann die normale automatische Kinderlogik greifen.
|
||||
|
||||
## Bereits im Backend und Daemon umgesetzt
|
||||
|
||||
- `backend/services/falukantService.js`
|
||||
- erzeugt Heiratsvorschläge nur für mindestens 12 Jahre alte Partner;
|
||||
- erzeugt Heiratsvorschläge nur für mindestens 10 Jahre alte Partner;
|
||||
- prüft beim Annehmen eines Vorschlags beide Alter erneut;
|
||||
- bietet den Hochzeitstyp erst ab 14 an und prüft beim Bestellen der Feier
|
||||
beide Verlobte serverseitig;
|
||||
- plant die Hochzeits-Schwangerschaft erst, wenn beide mindestens 16 sind.
|
||||
- plant die Hochzeits-Schwangerschaft erst, wenn beide mindestens 14 sind.
|
||||
- `src/valuerecalculationworker.h`
|
||||
- vollzieht die Hochzeit nach der mindestens einen Tag alten Hochzeitsfeier
|
||||
nur, wenn beide Verlobte mindestens 14 sind.
|
||||
- `src/usercharacterworker.h`
|
||||
- berücksichtigt für die bestehende zufällige Ehe-Kinderlogik ausschließlich
|
||||
Paare, bei denen beide mindestens 16 sind.
|
||||
Paare, bei denen beide mindestens 14 sind.
|
||||
|
||||
## Auftrag für den externen Daemon
|
||||
|
||||
@@ -55,13 +61,13 @@ Jeder automatische Empfängnis- oder Geburtenkandidat muss beide Bedingungen
|
||||
erfüllen:
|
||||
|
||||
```sql
|
||||
mother.birthdate <= CURRENT_DATE - INTERVAL '16 days'
|
||||
AND father.birthdate <= CURRENT_DATE - INTERVAL '16 days'
|
||||
mother.birthdate <= CURRENT_DATE - INTERVAL '14 days'
|
||||
AND father.birthdate <= CURRENT_DATE - INTERVAL '14 days'
|
||||
```
|
||||
|
||||
Das gilt sowohl für die Zufallslogik als auch für einen geplanten
|
||||
`pregnancy_due_at`-Pfad. Beim geplanten Pfad soll der Daemon die Felder nicht
|
||||
leeren, solange mindestens ein Elternteil noch unter 16 ist; die Schwangerschaft
|
||||
leeren, solange mindestens ein Elternteil noch unter 14 ist; die Schwangerschaft
|
||||
wird erst verarbeitet, sobald beide die Grenze erreicht haben. Admin-Tools
|
||||
können weiterhin ein bewusstes, separat protokolliertes Override anbieten.
|
||||
|
||||
@@ -69,8 +75,8 @@ können weiterhin ein bewusstes, separat protokolliertes Override anbieten.
|
||||
|
||||
- 13/13 Jahre, verlobt, Hochzeitsfeier älter als 24 Stunden: bleibt `engaged`.
|
||||
- 14/14 Jahre, verlobt, Hochzeitsfeier älter als 24 Stunden: wird `married`.
|
||||
- Verheiratet, ein Elternteil 15: keine automatische Schwangerschaft/Geburt.
|
||||
- Verheiratet, beide 16: normaler Schwangerschafts-/Geburtspfad ist möglich.
|
||||
- Verheiratet, ein Elternteil 13: keine automatische Schwangerschaft/Geburt.
|
||||
- Verheiratet, beide 14: normaler Schwangerschafts-/Geburtspfad ist möglich.
|
||||
|
||||
## Bestehende Daten
|
||||
|
||||
|
||||
4
falukant-models.env
Normal file
4
falukant-models.env
Normal file
@@ -0,0 +1,4 @@
|
||||
# Versioned Falukant source-model release. Keep both values together when
|
||||
# publishing a new release attachment.
|
||||
FALUKANT_MODELS_URL=https://git.tsschulz.de/torsten/yourpart3-assets/releases/download/falukant-models-v1/falukant-models-v1.tar.gz
|
||||
FALUKANT_MODELS_SHA256=b7e4c168c3e3fb3b17d73e142472da13b7084e01cccdea24add087c7991f1fe2
|
||||
@@ -207,10 +207,17 @@ export default {
|
||||
.app-section-bar__back {
|
||||
flex: 0 0 auto;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.app-section-bar__back:hover:not(:disabled) {
|
||||
background: var(--color-secondary-soft);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-section-bar {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -80,6 +80,13 @@
|
||||
"title": "Sugdi na",
|
||||
"text": "Pwede na nimo gamiton, sulayan ug hatagan og feedback. Pagrehistro pinaagi sa “{register}” o sugdi ang random chat."
|
||||
},
|
||||
"news": {
|
||||
"kicker": "Bag-ong balita",
|
||||
"title": "Balita",
|
||||
"loading": "Gikarga ang balita …",
|
||||
"empty": "Walay balita nga anaa karon.",
|
||||
"unavailable": "Dili magamit ang balita karon."
|
||||
},
|
||||
"languageTrainerSeo": {
|
||||
"title": "Language trainers para sa adlaw-adlaw (beginner)",
|
||||
"introBefore": "Ang YourPart adunay duha ka",
|
||||
|
||||
@@ -447,6 +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": "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",
|
||||
@@ -765,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."
|
||||
|
||||
@@ -775,6 +775,20 @@
|
||||
"setAsHeir": "Als Erben festlegen",
|
||||
"heirSetSuccess": "Das Kind wurde erfolgreich als Erbe festgelegt.",
|
||||
"heirSetError": "Fehler beim Festlegen des Erben.",
|
||||
"matchmaking": "Verkuppelung",
|
||||
"selectPartner": "Partner auswählen",
|
||||
"arrange": "Werbung arrangieren",
|
||||
"arrangeSuccess": "Die Werbung des Kindes wurde arrangiert.",
|
||||
"arrangeError": "Die Verkuppelung konnte nicht durchgeführt werden.",
|
||||
"completeWooing": "Werbung abschließen",
|
||||
"engagedSuccess": "Das Kind ist nun verlobt.",
|
||||
"weddingHint": "Hochzeitsfeier ab 14 Jahren möglich.",
|
||||
"noPartnerProposal": "Noch keine passenden Vorschläge.",
|
||||
"relationshipStatus": {
|
||||
"wooing": "Werbung läuft",
|
||||
"engaged": "Verlobt",
|
||||
"married": "Verheiratet"
|
||||
},
|
||||
"actions": "Aktionen",
|
||||
"none": "Keine Kinder vorhanden.",
|
||||
"detailButton": "Details anzeigen",
|
||||
|
||||
@@ -80,6 +80,13 @@
|
||||
"title": "Mitmachen",
|
||||
"text": "Du kannst die Plattform bereits nutzen, testen und Feedback geben. Registriere dich über „{register}“ oder starte unverbindlich den Random‑Chat."
|
||||
},
|
||||
"news": {
|
||||
"kicker": "Aktuelles",
|
||||
"title": "News",
|
||||
"loading": "News werden geladen …",
|
||||
"empty": "Zurzeit sind keine News verfügbar.",
|
||||
"unavailable": "News sind gerade nicht verfügbar."
|
||||
},
|
||||
"languageTrainerSeo": {
|
||||
"title": "Sprachtrainer fuer den Alltag (Anfaenger)",
|
||||
"introBefore": "YourPart bietet zwei",
|
||||
|
||||
@@ -777,6 +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 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",
|
||||
@@ -875,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."
|
||||
|
||||
@@ -80,6 +80,13 @@
|
||||
"title": "Get started",
|
||||
"text": "You can already use, test and give feedback. Register via “{register}” or start the random chat."
|
||||
},
|
||||
"news": {
|
||||
"kicker": "Latest",
|
||||
"title": "News",
|
||||
"loading": "Loading news …",
|
||||
"empty": "There is no news available at the moment.",
|
||||
"unavailable": "News are currently unavailable."
|
||||
},
|
||||
"languageTrainerSeo": {
|
||||
"title": "Language trainers for everyday use (beginners)",
|
||||
"introBefore": "YourPart offers two",
|
||||
|
||||
@@ -777,6 +777,7 @@
|
||||
"courseFlowIntensiveStatusAction": "Check upcoming reviews",
|
||||
"courseFlowIntensiveStatusTitle": "Upcoming vocabulary reviews",
|
||||
"courseFlowIntensiveStatusAllDone": "No further vocabulary reviews are currently scheduled for completed lessons.",
|
||||
"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",
|
||||
@@ -875,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."
|
||||
|
||||
@@ -80,6 +80,13 @@
|
||||
"title": "Participa",
|
||||
"text": "Ya puedes usar la plataforma, probarla y darnos tu opinión. Regístrate mediante “{register}” o inicia el chat aleatorio sin compromiso."
|
||||
},
|
||||
"news": {
|
||||
"kicker": "Actualidad",
|
||||
"title": "Noticias",
|
||||
"loading": "Cargando noticias …",
|
||||
"empty": "No hay noticias disponibles en este momento.",
|
||||
"unavailable": "Las noticias no están disponibles en este momento."
|
||||
},
|
||||
"languageTrainerSeo": {
|
||||
"title": "Entrenadores de idiomas para el dia a dia (principiantes)",
|
||||
"introBefore": "YourPart ofrece dos",
|
||||
|
||||
@@ -756,6 +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": "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",
|
||||
|
||||
@@ -80,6 +80,13 @@
|
||||
"title": "Se joindre à",
|
||||
"text": "Vous pouvez déjà utiliser la plateforme, la tester et donner votre avis. Inscrivez-vous via « {register} » ou démarrez le chat aléatoire sans engagement."
|
||||
},
|
||||
"news": {
|
||||
"kicker": "Actualités",
|
||||
"title": "Nouvelles",
|
||||
"loading": "Chargement des nouvelles …",
|
||||
"empty": "Aucune nouvelle n’est disponible pour le moment.",
|
||||
"unavailable": "Les nouvelles ne sont pas disponibles actuellement."
|
||||
},
|
||||
"languageTrainerSeo": {
|
||||
"title": "Formations langues pour le quotidien (debutants)",
|
||||
"introBefore": "YourPart propose deux",
|
||||
|
||||
@@ -314,6 +314,7 @@
|
||||
<th>{{ $t('falukant.family.children.otherParent') }}</th>
|
||||
<th>{{ $t('falukant.family.children.age') }}</th>
|
||||
<th>{{ $t('falukant.family.children.heir') }}</th>
|
||||
<th>{{ $t('falukant.family.children.matchmaking') }}</th>
|
||||
<th>{{ $t('falukant.family.children.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -365,6 +366,38 @@
|
||||
{{ $t('falukant.family.children.setAsHeir') }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="child-matchmaking-cell">
|
||||
<template v-if="child.relationship">
|
||||
<strong>{{ child.relationship.partnerName }}</strong>
|
||||
<span>{{ $t('falukant.family.children.relationshipStatus.' + child.relationship.status) }}</span>
|
||||
<button
|
||||
v-if="child.relationship.status === 'wooing'"
|
||||
type="button"
|
||||
@click="advanceChildWooing(child)"
|
||||
>
|
||||
{{ $t('falukant.family.children.completeWooing') }}
|
||||
</button>
|
||||
<span v-else-if="child.relationship.status === 'engaged'" class="child-matchmaking-hint">
|
||||
{{ $t('falukant.family.children.weddingHint') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="child.hasName && child.possiblePartners?.length">
|
||||
<select v-model="selectedChildProposalIds[child.childCharacterId]">
|
||||
<option :value="null">{{ $t('falukant.family.children.selectPartner') }}</option>
|
||||
<option v-for="proposal in child.possiblePartners" :key="proposal.id" :value="proposal.proposedCharacterId">
|
||||
{{ proposal.proposedCharacterName }} ({{ proposal.proposedCharacterAge }})
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!selectedChildProposalIds[child.childCharacterId]"
|
||||
@click="acceptChildProposal(child)"
|
||||
>
|
||||
{{ $t('falukant.family.children.arrange') }}
|
||||
</button>
|
||||
</template>
|
||||
<span v-else-if="child.hasName">{{ $t('falukant.family.children.noPartnerProposal') }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button @click="showChildDetails(child)">
|
||||
{{ $t('falukant.family.children.detailButton') }}
|
||||
@@ -670,6 +703,7 @@ export default {
|
||||
},
|
||||
pregnancy: null,
|
||||
selectedChild: null,
|
||||
selectedChildProposalIds: {},
|
||||
pendingFamilyRefresh: null,
|
||||
familyTab: 'partner',
|
||||
visualSettings: { ...FALUKANT_VISUAL_DEFAULTS },
|
||||
@@ -854,6 +888,9 @@ export default {
|
||||
const response = await apiClient.get('/api/falukant/family');
|
||||
this.relationships = response.data.relationships;
|
||||
this.children = response.data.children;
|
||||
this.selectedChildProposalIds = Object.fromEntries(
|
||||
this.children.map((child) => [child.childCharacterId, this.selectedChildProposalIds[child.childCharacterId] || null])
|
||||
);
|
||||
this.lovers = response.data.lovers;
|
||||
this.politicalFreeLoverSlots = Number(response.data.politicalFreeLoverSlots) || 0;
|
||||
this.possibleLovers = response.data.possibleLovers || [];
|
||||
@@ -978,6 +1015,30 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async acceptChildProposal(child) {
|
||||
const proposedCharacterId = this.selectedChildProposalIds[child.childCharacterId];
|
||||
if (!proposedCharacterId) return;
|
||||
try {
|
||||
await apiClient.post(`/api/falukant/family/children/${child.childCharacterId}/accept-marriage-proposal`, { proposedCharacterId });
|
||||
await this.loadFamilyData();
|
||||
showSuccess(this, this.$t('falukant.family.children.arrangeSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Error arranging child marriage:', error);
|
||||
showError(this, error?.response?.data?.message || this.$t('falukant.family.children.arrangeError'));
|
||||
}
|
||||
},
|
||||
|
||||
async advanceChildWooing(child) {
|
||||
try {
|
||||
await apiClient.post(`/api/falukant/family/children/${child.childCharacterId}/advance-wooing`);
|
||||
await this.loadFamilyData();
|
||||
showSuccess(this, this.$t('falukant.family.children.engagedSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Error advancing child wooing:', error);
|
||||
showError(this, error?.response?.data?.message || this.$t('falukant.family.children.arrangeError'));
|
||||
}
|
||||
},
|
||||
|
||||
async setLoverMaintenance(lover, maintenanceLevel) {
|
||||
try {
|
||||
await apiClient.post(`/api/falukant/family/lover/${lover.relationshipId}/maintenance`, {
|
||||
|
||||
@@ -76,6 +76,15 @@
|
||||
</label>
|
||||
|
||||
<div v-if="newPartyTypeId" class="party-options">
|
||||
<label v-if="selectedPartyType?.forMarriage">
|
||||
Hochzeitspaar:
|
||||
<select v-model.number="relationshipId">
|
||||
<option :value="null">Bitte Verlobte auswählen</option>
|
||||
<option v-for="candidate in weddingCandidates" :key="candidate.relationshipId" :value="candidate.relationshipId">
|
||||
{{ candidate.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('falukant.reputation.party.music.label') }}:
|
||||
<select v-model.number="musicId">
|
||||
@@ -124,7 +133,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="button" @click="orderParty()">
|
||||
<button type="button" @click="orderParty()" :disabled="isOrderingParty">
|
||||
{{ $t('falukant.reputation.party.order') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -214,6 +223,9 @@ export default {
|
||||
nobilityTitles: [],
|
||||
selectedNobilityIds: [],
|
||||
servantRatio: 50,
|
||||
relationshipId: null,
|
||||
isOrderingParty: false,
|
||||
weddingCandidates: [],
|
||||
inProgressParties: [],
|
||||
completedParties: [],
|
||||
reputation: null,
|
||||
@@ -235,6 +247,7 @@ export default {
|
||||
this.partyTypes = data.partyTypes;
|
||||
this.musicTypes = data.musicTypes;
|
||||
this.banquetteTypes = data.banquetteTypes;
|
||||
this.weddingCandidates = data.weddingCandidates || [];
|
||||
this.musicId = this.musicTypes[0]?.id;
|
||||
this.banquetteId = this.banquetteTypes[0]?.id;
|
||||
},
|
||||
@@ -305,14 +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
|
||||
});
|
||||
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
|
||||
@@ -343,6 +365,10 @@ export default {
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
}
|
||||
,
|
||||
selectedPartyType() {
|
||||
return this.partyTypes.find((type) => type.id === this.newPartyTypeId) || null;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
const tabFromQuery = this.$route?.query?.tab;
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<template>
|
||||
<div class="home-logged-in">
|
||||
<section class="dashboard-hero surface-card">
|
||||
<header class="dashboard-hero">
|
||||
<div class="dashboard-hero__copy">
|
||||
<span class="dashboard-kicker">{{ $t('home.dashboard.kicker') }}</span>
|
||||
<h1>{{ $t('home.dashboard.title') }}</h1>
|
||||
<p class="dashboard-subtitle">
|
||||
{{ $t('home.dashboard.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="dashboard-toolbar surface-card">
|
||||
<div class="dashboard-toolbar">
|
||||
<button
|
||||
v-if="!editMode"
|
||||
type="button"
|
||||
@@ -47,25 +43,7 @@
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-overview">
|
||||
<article class="overview-card surface-card">
|
||||
<span class="overview-card__label">{{ $t('home.dashboard.overview.activeWidgetsLabel') }}</span>
|
||||
<strong>{{ widgets.length }}</strong>
|
||||
<p>{{ $t('home.dashboard.overview.activeWidgetsText') }}</p>
|
||||
</article>
|
||||
<article class="overview-card surface-card">
|
||||
<span class="overview-card__label">{{ $t('home.dashboard.overview.availableModulesLabel') }}</span>
|
||||
<strong>{{ widgetTypeOptions.length }}</strong>
|
||||
<p>{{ $t('home.dashboard.overview.availableModulesText') }}</p>
|
||||
</article>
|
||||
<article class="overview-card surface-card">
|
||||
<span class="overview-card__label">{{ $t('home.dashboard.overview.editModeLabel') }}</span>
|
||||
<strong>{{ editMode ? $t('home.dashboard.overview.editModeActive') : $t('home.dashboard.overview.editModeInactive') }}</strong>
|
||||
<p>{{ editMode ? $t('home.dashboard.overview.editModeActiveText') : $t('home.dashboard.overview.editModeInactiveText') }}</p>
|
||||
</article>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="loadError"
|
||||
@@ -83,12 +61,6 @@
|
||||
v-else
|
||||
class="dashboard-shell"
|
||||
>
|
||||
<div class="dashboard-shell__header">
|
||||
<div>
|
||||
<h2>{{ $t('home.dashboard.sectionTitle') }}</h2>
|
||||
<p>{{ $t('home.dashboard.sectionIntro') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="dashboardGridRef"
|
||||
class="dashboard-grid"
|
||||
@@ -382,85 +354,29 @@ export default {
|
||||
|
||||
.dashboard-hero {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 26px;
|
||||
margin-bottom: 18px;
|
||||
background: var(--color-surface);
|
||||
border-left: 3px solid var(--color-secondary);
|
||||
min-height: 40px;
|
||||
padding: 4px 0 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dashboard-hero__copy {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.dashboard-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-secondary-soft);
|
||||
color: #28643d;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-hero h1 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
max-width: 58ch;
|
||||
}
|
||||
|
||||
.dashboard-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.overview-card__label {
|
||||
display: inline-block;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.overview-card strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.9rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.overview-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
min-width: 300px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-edit,
|
||||
@@ -508,28 +424,8 @@ export default {
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.dashboard-shell__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-shell__header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.dashboard-shell__header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
/* Widgets are the content; do not wrap them in an additional overview card. */
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
@@ -614,8 +510,8 @@ export default {
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-toolbar {
|
||||
@@ -623,14 +519,6 @@ export default {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,28 @@
|
||||
<PasswordResetDialog ref="passwordResetDialog" />
|
||||
</div>
|
||||
|
||||
<section class="public-news surface-card" aria-labelledby="public-news-title">
|
||||
<div class="public-news__header">
|
||||
<div>
|
||||
<span class="panel-kicker">{{ $t('home.nologin.news.kicker') }}</span>
|
||||
<h2 id="public-news-title">{{ $t('home.nologin.news.title') }}</h2>
|
||||
</div>
|
||||
<span v-if="newsLoading" class="public-news__status">{{ $t('home.nologin.news.loading') }}</span>
|
||||
</div>
|
||||
<p v-if="newsError" class="public-news__status">{{ $t('home.nologin.news.unavailable') }}</p>
|
||||
<div v-else-if="news.length" class="public-news__grid">
|
||||
<article v-for="article in news" :key="article.article_id || article.link || article.title" class="public-news__article">
|
||||
<span v-if="article.pubDate" class="public-news__date">{{ formatNewsDate(article.pubDate) }}</span>
|
||||
<a v-if="article.link" :href="article.link" target="_blank" rel="noopener noreferrer" class="public-news__title">
|
||||
{{ article.title }}
|
||||
</a>
|
||||
<h3 v-else class="public-news__title">{{ article.title }}</h3>
|
||||
<p v-if="article.description" class="public-news__description">{{ summarizeNews(article.description) }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else-if="!newsLoading" class="public-news__status">{{ $t('home.nologin.news.empty') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="seo-content surface-card" aria-label="Sprachtrainer">
|
||||
<h2>{{ $t('home.nologin.languageTrainerSeo.title') }}</h2>
|
||||
<p>
|
||||
@@ -160,6 +182,9 @@ export default {
|
||||
oauthProviders: [],
|
||||
oauthLoading: false,
|
||||
isStoryCollapsed: true,
|
||||
news: [],
|
||||
newsLoading: true,
|
||||
newsError: false,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
@@ -190,6 +215,36 @@ export default {
|
||||
this.oauthProviders = [];
|
||||
}
|
||||
},
|
||||
getNewsLanguage() {
|
||||
const language = String(this.$i18n?.locale || 'de').toLowerCase();
|
||||
return ['de', 'en', 'es', 'fr'].includes(language) ? language : 'en';
|
||||
},
|
||||
async loadNews() {
|
||||
this.newsLoading = true;
|
||||
this.newsError = false;
|
||||
try {
|
||||
const { data } = await apiClient.get('/api/news', {
|
||||
params: { language: this.getNewsLanguage(), category: 'top', count: 3 }
|
||||
});
|
||||
this.news = Array.isArray(data?.results) ? data.results.filter((article) => article?.title) : [];
|
||||
} catch (error) {
|
||||
this.news = [];
|
||||
this.newsError = true;
|
||||
} finally {
|
||||
this.newsLoading = false;
|
||||
}
|
||||
},
|
||||
formatNewsDate(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString(this.$i18n?.locale || 'de-DE', {
|
||||
day: 'numeric', month: 'short', year: 'numeric'
|
||||
});
|
||||
},
|
||||
summarizeNews(description) {
|
||||
const text = String(description || '').replace(/\s+/g, ' ').trim();
|
||||
return text.length > 210 ? `${text.slice(0, 207).trimEnd()}…` : text;
|
||||
},
|
||||
startOAuthLogin(providerSlug) {
|
||||
if (this.oauthLoading) {
|
||||
return;
|
||||
@@ -229,7 +284,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.loadOAuthProviders();
|
||||
await Promise.all([this.loadOAuthProviders(), this.loadNews()]);
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
@@ -554,6 +609,71 @@ export default {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.public-news {
|
||||
width: min(100%, 1120px);
|
||||
margin: 24px auto 0;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.public-news__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.public-news__header .panel-kicker {
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.public-news__header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.public-news__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.public-news__article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.public-news__date,
|
||||
.public-news__status {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.public-news__title {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.public-news__title:hover {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.public-news__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.seo-content h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 8px 0;
|
||||
@@ -626,7 +746,8 @@ export default {
|
||||
.story-columns,
|
||||
.access-split,
|
||||
.login-fields,
|
||||
.oauth-provider-list {
|
||||
.oauth-provider-list,
|
||||
.public-news__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,8 +317,11 @@
|
||||
<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="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="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>
|
||||
<strong>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusProgress', { count: entry.count }) }}</strong>
|
||||
@@ -393,6 +396,7 @@ export default {
|
||||
srsDueTotal: 0,
|
||||
srsDailyLimit: 50,
|
||||
srsTodayRemaining: null,
|
||||
srsTodayQuota: null,
|
||||
srsUpcoming: [],
|
||||
srsLoading: false,
|
||||
showIntensiveStatusDialog: false,
|
||||
@@ -445,11 +449,19 @@ export default {
|
||||
return Array.isArray(this.srsDueItems) ? this.srsDueItems.length : 0;
|
||||
},
|
||||
srsDailyCount() {
|
||||
// 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() {
|
||||
return Array.isArray(this.hardVocabList) ? this.hardVocabList.length : 0;
|
||||
},
|
||||
@@ -584,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() {
|
||||
|
||||
@@ -123,8 +123,8 @@ private:
|
||||
ON fu1.id = c1.user_id
|
||||
LEFT JOIN falukant_data.falukant_user fu2
|
||||
ON fu2.id = c2.user_id
|
||||
WHERE c1.birthdate <= CURRENT_DATE - INTERVAL '16 days'
|
||||
AND c2.birthdate <= CURRENT_DATE - INTERVAL '16 days'
|
||||
WHERE c1.birthdate <= CURRENT_DATE - INTERVAL '14 days'
|
||||
AND c2.birthdate <= CURRENT_DATE - INTERVAL '14 days'
|
||||
AND random()*100 < (
|
||||
100.0 /
|
||||
(1
|
||||
|
||||
@@ -107,13 +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 rel2.character1_id = c.id
|
||||
OR rel2.character2_id = c.id
|
||||
ON p.relationship_id = rel2.id
|
||||
JOIN falukant_type.relationship AS rt2
|
||||
ON rt2.id = rel2.relationship_type_id
|
||||
AND rt2.tr = 'engaged'
|
||||
@@ -121,19 +116,15 @@ private:
|
||||
-- Die Feier muss für diese bereits bestehende Verlobung bestellt worden sein.
|
||||
AND p.created_at >= rel2.created_at
|
||||
-- Ein realer Tag entspricht einem Spieljahr: Hochzeit ab 14.
|
||||
AND c.birthdate <= CURRENT_DATE - INTERVAL '14 days'
|
||||
AND (
|
||||
(rel2.character1_id = c.id AND EXISTS (
|
||||
SELECT 1 FROM falukant_data."character" partner
|
||||
WHERE partner.id = rel2.character2_id
|
||||
AND partner.birthdate <= CURRENT_DATE - INTERVAL '14 days'
|
||||
))
|
||||
OR
|
||||
(rel2.character2_id = c.id AND EXISTS (
|
||||
SELECT 1 FROM falukant_data."character" partner
|
||||
WHERE partner.id = rel2.character1_id
|
||||
AND partner.birthdate <= CURRENT_DATE - INTERVAL '14 days'
|
||||
))
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM falukant_data."character" spouse1
|
||||
WHERE spouse1.id = rel2.character1_id
|
||||
AND spouse1.birthdate <= CURRENT_DATE - INTERVAL '14 days'
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM falukant_data."character" spouse2
|
||||
WHERE spouse2.id = rel2.character2_id
|
||||
AND spouse2.birthdate <= CURRENT_DATE - INTERVAL '14 days'
|
||||
)
|
||||
)
|
||||
RETURNING character1_id, character2_id
|
||||
|
||||
@@ -7,6 +7,7 @@ FRONTEND_DIR="$TARGET_DIR/frontend"
|
||||
CURRENT_LINK="/opt/yourpart"
|
||||
CURRENT_FRONTEND="$CURRENT_LINK/frontend"
|
||||
CURRENT_ROOT_ENV="$CURRENT_LINK/.env"
|
||||
MODELS_MANIFEST="$TARGET_DIR/falukant-models.env"
|
||||
|
||||
echo "=== YourPart Frontend Update ==="
|
||||
echo "Ziel: $FRONTEND_DIR"
|
||||
@@ -54,14 +55,36 @@ echo "VITE_CHAT_WS_URL=$VITE_CHAT_WS_URL"
|
||||
echo "Installiere Dependencies..."
|
||||
npm install
|
||||
|
||||
if [ ! -f "$MODELS_MANIFEST" ]; then
|
||||
echo "ERROR: Falukant model manifest missing: $MODELS_MANIFEST" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source GLBs are versioned as a Gitea release attachment rather than Git
|
||||
# objects, keeping routine clones and deployments small.
|
||||
# shellcheck disable=SC1090
|
||||
source "$MODELS_MANIFEST"
|
||||
: "${FALUKANT_MODELS_URL:?Falukant model URL is missing}"
|
||||
: "${FALUKANT_MODELS_SHA256:?Falukant model checksum is missing}"
|
||||
|
||||
MODELS_ARCHIVE="$(mktemp /tmp/falukant-models-XXXXXX.tar.gz)"
|
||||
trap 'rm -f "$TEMP_ENV" "$MODELS_ARCHIVE"' EXIT
|
||||
|
||||
echo "Lade Falukant-3D-Modelle (${FALUKANT_MODELS_URL})..."
|
||||
curl --fail --location --retry 3 --retry-delay 2 \
|
||||
--output "$MODELS_ARCHIVE" "$FALUKANT_MODELS_URL"
|
||||
echo "${FALUKANT_MODELS_SHA256} ${MODELS_ARCHIVE}" | sha256sum --check --status
|
||||
|
||||
echo "Entpacke Falukant-3D-Modelle..."
|
||||
rm -rf "$FRONTEND_DIR/models-src/3d/falukant"
|
||||
tar -xzf "$MODELS_ARCHIVE" -C "$TARGET_DIR"
|
||||
|
||||
echo "Optimiere Falukant-3D-Modelle..."
|
||||
npm run optimize-models
|
||||
|
||||
echo "Baue Frontend..."
|
||||
npm run build
|
||||
|
||||
rm -f "$TEMP_ENV"
|
||||
|
||||
if [ -f "$FRONTEND_DIR/.env" ]; then
|
||||
echo "✓ Bestehende .env-Datei wurde beibehalten"
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user