feat(vocab): add dashboard learning summary and related endpoints
All checks were successful
Deploy to production / deploy (push) Successful in 2m52s

- Introduced `getDashboardLearningSummary` method in `VocabService` to provide a compact overview of enrolled courses and current lessons for users.
- Updated `vocabController` to include a new route for the dashboard widget, allowing users to access their learning summary.
- Enhanced `vocabRouter` to route requests for the new dashboard widget endpoint.
- Added localization support for the new dashboard features across multiple languages, improving user engagement and accessibility.
- Updated UI components to integrate the new dashboard widget, ensuring a seamless user experience.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 15:06:50 +02:00
parent 77e6f8d3e8
commit 5fcd55be43
28 changed files with 1095 additions and 39 deletions

View File

@@ -65,6 +65,8 @@ import PoliticalOfficeHistory from '../models/falukant/log/political_office_hist
import UndergroundType from '../models/falukant/type/underground.js';
import Notification from '../models/falukant/log/notification.js';
import PoliticalOffice from '../models/falukant/data/political_office.js';
import PoliticalOfficeBenefit from '../models/falukant/predefine/political_office_benefit.js';
import PoliticalOfficeBenefitType from '../models/falukant/type/political_office_benefit_type.js';
import Underground from '../models/falukant/data/underground.js';
import VehicleType from '../models/falukant/type/vehicle.js';
import Vehicle from '../models/falukant/data/vehicle.js';
@@ -131,6 +133,17 @@ const POLITICAL_OFFICE_RANKS = {
chancellor: 6
};
function computePoliticalDailySalaryPayout(value, officeName) {
const v = value && typeof value === 'object' ? value : {};
if (v.dailyAmount != null && Number.isFinite(Number(v.dailyAmount))) {
return Math.round(Number(v.dailyAmount) * 100) / 100;
}
const rank = POLITICAL_OFFICE_RANKS[officeName] ?? 0;
const base = Number(v.base ?? 0);
const perRank = Number(v.perRank ?? v.per_rank ?? 0);
return Math.round((base + perRank * rank) * 100) / 100;
}
const CERTIFICATE_THRESHOLDS = {
2: { avgKnowledge: 15, completedProductions: 4, statusMode: 'none', statusRequiredCount: 0 },
3: { avgKnowledge: 28, completedProductions: 15, statusMode: 'none', statusRequiredCount: 0 },
@@ -6067,9 +6080,106 @@ class FalukantService extends BaseService {
return this.healthChange(user, delta);
}
politicsBenefitEntriesFromRows(benefitRows, officeName) {
if (!benefitRows?.length) {
return [];
}
const out = [];
for (const row of benefitRows) {
const plain = row.get ? row.get({ plain: true }) : row;
const tr = plain.benefitDefinition?.tr || '';
const v = plain.value && typeof plain.value === 'object' ? plain.value : {};
if (tr === 'tax_exemption') {
const regions = Array.isArray(v.regions) ? v.regions : [];
if (regions.includes('*')) {
out.push({ tr: 'tax_exemption', params: { all: true } });
} else {
out.push({ tr: 'tax_exemption', params: { regions } });
}
} else if (tr === 'daily_salary' || tr === 'salary') {
const amount = computePoliticalDailySalaryPayout(v, officeName);
if (amount > 0) {
out.push({ tr: 'daily_salary', params: { amount } });
}
} else if (tr) {
out.push({ tr: 'generic_benefit', params: { code: tr } });
}
}
return out;
}
async _maybeGrantPoliticalDailySalary(falukantUser, characterId) {
const rawLast = falukantUser.lastPoliticalDailySalaryOn;
const lastStr = rawLast
? (typeof rawLast === 'string'
? rawLast.slice(0, 10)
: new Date(rawLast).toISOString().slice(0, 10))
: '';
const todayStr = new Date().toISOString().slice(0, 10);
if (lastStr === todayStr) {
return;
}
const held = await PoliticalOffice.findAll({
where: { characterId },
include: [{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] }]
});
if (!held.length) {
return;
}
const officeTypeIds = [...new Set(held.map((h) => h.officeTypeId))];
const benefitRows = await PoliticalOfficeBenefit.findAll({
where: { officeTypeId: { [Op.in]: officeTypeIds } },
include: [{ model: PoliticalOfficeBenefitType, as: 'benefitDefinition', attributes: ['tr'] }]
});
const byType = new Map();
for (const br of benefitRows) {
const oid = br.officeTypeId;
if (!byType.has(oid)) {
byType.set(oid, []);
}
byType.get(oid).push(br);
}
let total = 0;
for (const h of held) {
const name = h.type?.name;
const rows = byType.get(h.officeTypeId) || [];
for (const row of rows) {
const tr = row.benefitDefinition?.tr;
if (tr !== 'daily_salary') {
continue;
}
const v = typeof row.value === 'object' && row.value ? row.value : {};
total += computePoliticalDailySalaryPayout(v, name);
}
}
total = Math.round(total * 100) / 100;
if (total <= 0) {
return;
}
const moneyResult = await updateFalukantUserMoney(
falukantUser.id,
total,
'Politisches Tagesamtshonorar',
falukantUser.id
);
if (!moneyResult.success) {
console.error('[getPoliticsOverview] Tageshonorar konnte nicht gebucht werden');
return;
}
await FalukantUser.update(
{ lastPoliticalDailySalaryOn: todayStr },
{ where: { id: falukantUser.id } }
);
}
async getPoliticsOverview(hashedUserId) {
// Liefert alle aktuell besetzten Ämter im eigenen Gebiet inklusive
// Inhaber und berechnetem Enddatum der Amtszeit.
// Inhaber, Vorteile (aus political_office_benefit) und berechnetem Enddatum der Amtszeit.
const user = await getFalukantUserOrFail(hashedUserId);
// Charakter des Users bestimmen (Region ist dort hinterlegt)
@@ -6081,6 +6191,8 @@ class FalukantService extends BaseService {
return [];
}
await this._maybeGrantPoliticalDailySalary(user, character.id);
// Alle relevanten Regionen (Region + Eltern) laden
const relevantRegionIds = await this.getRegionAndParentIds(character.regionId);
@@ -6138,7 +6250,23 @@ class FalukantService extends BaseService {
]
});
return offices.map(office => {
const officeTypeIds = [...new Set(offices.map((o) => o.officeTypeId))];
let benefitByType = new Map();
if (officeTypeIds.length) {
const allBenefits = await PoliticalOfficeBenefit.findAll({
where: { officeTypeId: { [Op.in]: officeTypeIds } },
include: [{ model: PoliticalOfficeBenefitType, as: 'benefitDefinition', attributes: ['tr'] }]
});
for (const br of allBenefits) {
const oid = br.officeTypeId;
if (!benefitByType.has(oid)) {
benefitByType.set(oid, []);
}
benefitByType.get(oid).push(br);
}
}
return offices.map((office) => {
const o = office.get({ plain: true });
// Enddatum der Amtszeit berechnen: Start = createdAt, Dauer = termLength Jahre
@@ -6152,10 +6280,16 @@ class FalukantService extends BaseService {
}
}
const officeName = o.type?.name;
const benefit = this.politicsBenefitEntriesFromRows(
benefitByType.get(o.officeTypeId) || [],
officeName
);
return {
id: o.id,
officeType: {
name: o.type?.name
name: officeName
},
region: {
name: o.region?.name,
@@ -6172,7 +6306,8 @@ class FalukantService extends BaseService {
gender: o.holder.gender
}
: null,
termEnds
termEnds,
benefit
};
});
}
@@ -7006,11 +7141,11 @@ ORDER BY r.id`,
// Unikate nach character.id
const map = new Map();
const POLITICAL_TAX_EXEMPTIONS = {
'council': ['city'],
'taxman': ['city','county'],
'treasurerer': ['city','county','shire'],
'super-state-administrator': ['city','county','shire','markgrave','duchy'],
'chancellor': ['*']
council: ['city'],
taxman: ['city', 'county'],
treasurer: ['city', 'county', 'shire'],
'super-state-administrator': ['city', 'county', 'shire', 'markgrave', 'duchy'],
chancellor: ['*']
};
histories.forEach(h => {

View File

@@ -2636,6 +2636,111 @@ export default class VocabService {
}));
}
/**
* Kompakte Übersicht für das Start-Dashboard: eingeschriebene Kurse und „aktuelle“ Lektion
* (gleiche Logik wie VocabCourseView.currentLesson: erste unvollständige, sonst letzte).
*/
async getDashboardLearningSummary(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const enrollments = await VocabCourseEnrollment.findAll({
where: { userId: user.id },
include: [
{
model: VocabCourse,
as: 'course',
required: true,
attributes: ['id', 'title']
}
],
order: [['enrolledAt', 'DESC']]
});
const courseById = new Map();
for (const e of enrollments) {
const c = e.course?.get({ plain: true });
if (!c?.id || courseById.has(c.id)) {
continue;
}
courseById.set(c.id, { id: c.id, title: c.title || '' });
}
const coursesMeta = [...courseById.values()];
if (coursesMeta.length === 0) {
return { courses: [] };
}
const courseIds = coursesMeta.map((c) => c.id);
const lessons = await VocabCourseLesson.findAll({
where: { courseId: { [Op.in]: courseIds } },
attributes: ['id', 'courseId', 'lessonNumber', 'title'],
order: [
['courseId', 'ASC'],
['lessonNumber', 'ASC']
]
});
const progressRows = await VocabCourseProgress.findAll({
where: { userId: user.id, courseId: { [Op.in]: courseIds } },
attributes: ['lessonId', 'completed']
});
const completedByLessonId = new Map();
for (const row of progressRows) {
const plain = row.get({ plain: true });
completedByLessonId.set(plain.lessonId, Boolean(plain.completed));
}
const lessonsByCourse = new Map();
for (const row of lessons) {
const plain = row.get({ plain: true });
const list = lessonsByCourse.get(plain.courseId) || [];
list.push(plain);
lessonsByCourse.set(plain.courseId, list);
}
const courses = [];
for (const meta of coursesMeta) {
const sorted = lessonsByCourse.get(meta.id) || [];
if (sorted.length === 0) {
courses.push({
courseId: meta.id,
title: meta.title,
currentLesson: null,
allLessonsCompleted: false
});
continue;
}
let current = null;
for (const lesson of sorted) {
if (!completedByLessonId.get(lesson.id)) {
current = lesson;
break;
}
}
if (!current) {
current = sorted[sorted.length - 1];
}
const allLessonsCompleted = sorted.every((lesson) => completedByLessonId.get(lesson.id) === true);
courses.push({
courseId: meta.id,
title: meta.title,
currentLesson: {
id: current.id,
lessonNumber: current.lessonNumber,
title: current.title || ''
},
allLessonsCompleted
});
}
return { courses };
}
/**
* Kurse, in die der Nutzer (per Hash) eingeschrieben ist — jede courseId nur einmal,
* bei mehrfachen Einschreibungen zählt die jeweils neueste Zeile.