20 Commits

Author SHA1 Message Date
Torsten Schulz (local)
afbfdd8ccc feat(falukant): optimize retrieval of not baptized children by using character IDs
All checks were successful
Deploy to production / deploy (push) Successful in 3m16s
2026-09-17 06:42:27 +02:00
Torsten Schulz (local)
57a0e89156 feat(vocab): stabilize daily batch handling in SRS session and improve due item display
All checks were successful
Deploy to production / deploy (push) Successful in 3m8s
2026-09-16 10:13:24 +02:00
Torsten Schulz (local)
20505f7545 feat(i18n): add 'due' status message for vocabulary reviews in multiple languages
All checks were successful
Deploy to production / deploy (push) Successful in 2m52s
2026-09-11 14:26:33 +02:00
Torsten Schulz (local)
01ee72c700 feat(migration): add migration to remove invalid past marker from vocab_srs_item
All checks were successful
Deploy to production / deploy (push) Successful in 2m40s
2026-09-09 21:06:25 +02:00
Torsten Schulz (local)
b48d03ab70 feat(bisaya): add concrete choice question handling and update daily count logic for clarity
All checks were successful
Deploy to production / deploy (push) Successful in 3m8s
2026-09-09 15:41:17 +02:00
Torsten Schulz (local)
c462001ff0 feat(bisaya): update household and children age rules to 14, adjust related instructions and migration
All checks were successful
Deploy to production / deploy (push) Successful in 3m14s
2026-09-09 14:16:41 +02:00
Torsten Schulz (local)
3d235a621a feat(bisaya): implement child marriage proposal and wooing features, update age rules and relationships
All checks were successful
Deploy to production / deploy (push) Successful in 3m15s
2026-09-08 14:46:26 +02:00
Torsten Schulz (local)
0a9174c486 feat(bisaya): update instruction for soft refusal exercise for clarity
All checks were successful
Deploy to production / deploy (push) Successful in 3m19s
2026-09-07 15:52:53 +02:00
Torsten Schulz (local)
f317ed9ef0 feat(bisaya): update health gap exercise instruction for clarity
All checks were successful
Deploy to production / deploy (push) Successful in 3m26s
2026-09-07 15:31:57 +02:00
Torsten Schulz (local)
1149814f64 feat(bisaya): update gap fill exercise instruction to clarify first person usage
All checks were successful
Deploy to production / deploy (push) Successful in 3m40s
2026-09-07 15:13:55 +02:00
Torsten Schulz (local)
86f3b4c247 feat(bisaya): update kitchen dialog exercise and add migration for alignment
All checks were successful
Deploy to production / deploy (push) Successful in 3m27s
2026-09-04 16:07:49 +02:00
Torsten Schulz (local)
90686388c6 feat(bisaya): update prompts and add migration for food context gap exercise
All checks were successful
Deploy to production / deploy (push) Successful in 3m28s
2026-09-04 15:13:25 +02:00
Torsten Schulz (local)
7edb9d79e5 refactor(dashboard): simplify layout and remove unused sections in LoggedInView
All checks were successful
Deploy to production / deploy (push) Successful in 3m0s
2026-09-03 14:21:40 +02:00
Torsten Schulz (local)
d9a46dcb34 feat(news): implement news section with loading state and error handling in NoLoginView
All checks were successful
Deploy to production / deploy (push) Successful in 3m8s
2026-09-03 14:18:14 +02:00
Torsten Schulz (local)
c4977ec826 feat(migrations): add migration to replace tautological vocabulary prompts in Cebu travel lesson
All checks were successful
Deploy to production / deploy (push) Successful in 2m58s
2026-09-02 16:02:09 +02:00
Torsten Schulz (local)
b2e5d3d950 feat(course): add Cebu travel path with lessons and didactics for Bisaya courses
All checks were successful
Deploy to production / deploy (push) Successful in 24s
2026-09-02 14:03:39 +02:00
Torsten Schulz (local)
504f527a3f feat(exercises): enhance context in gap fill exercises and update migration for route descriptions
All checks were successful
Deploy to production / deploy (push) Successful in 3m21s
2026-09-02 13:52:26 +02:00
Torsten Schulz (local)
76da586b8e fix(deploy): provision Falukant release models
All checks were successful
Deploy to production / deploy (push) Successful in 3m3s
2026-09-02 07:44:28 +02:00
Torsten Schulz (local)
a3e2aaece8 chore(deploy): verify regular rollout [force-deploy]
Some checks failed
Deploy to production / deploy (push) Failing after 1m2s
2026-09-02 07:33:48 +02:00
Torsten Schulz (local)
691e840c7f feat(deploy): load Falukant models from release asset
All checks were successful
Deploy to production / deploy (push) Successful in 11s
2026-09-02 07:31:11 +02:00
48 changed files with 1155 additions and 310 deletions

View File

@@ -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

View File

@@ -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));

View File

@@ -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);

View File

@@ -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.
}
};

View File

@@ -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.
}
};

View File

@@ -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.
}
};

View File

@@ -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.
}
};

View File

@@ -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.
}
};

View File

@@ -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.
}
};

View File

@@ -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() {}
};

View File

@@ -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;`);
}
};

View File

@@ -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() {}
};

View File

@@ -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() {}
};

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View 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.' }
];

View File

@@ -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}',

View File

@@ -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,

View 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;
});

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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']],
});

View File

@@ -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 };

View File

@@ -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;
}
/**

View File

@@ -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
View 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

View File

@@ -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;

View File

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

View File

@@ -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",

View File

@@ -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."

View File

@@ -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",

View File

@@ -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 RandomChat."
},
"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",

View File

@@ -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."

View File

@@ -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",

View File

@@ -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."

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 nest disponible pour le moment.",
"unavailable": "Les nouvelles ne sont pas disponibles actuellement."
},
"languageTrainerSeo": {
"title": "Formations langues pour le quotidien (debutants)",
"introBefore": "YourPart propose deux",

View File

@@ -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`, {

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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() {

View File

@@ -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

View File

@@ -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

View File

@@ -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