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

@@ -28,10 +28,12 @@ import ListWidget from './widgets/ListWidget.vue';
import BirthdayWidget from './widgets/BirthdayWidget.vue';
import UpcomingEventsWidget from './widgets/UpcomingEventsWidget.vue';
import MiniCalendarWidget from './widgets/MiniCalendarWidget.vue';
import VocabCoursesWidget from './widgets/VocabCoursesWidget.vue';
function getWidgetComponent(endpoint) {
if (!endpoint || typeof endpoint !== 'string') return ListWidget;
const ep = endpoint.toLowerCase();
if (ep.includes('vocab/dashboard-widget')) return VocabCoursesWidget;
if (ep.includes('falukant')) return FalukantWidget;
if (ep.includes('news')) return NewsWidget;
if (ep.includes('calendar/widget/birthdays')) return BirthdayWidget;
@@ -42,7 +44,7 @@ function getWidgetComponent(endpoint) {
export default {
name: 'DashboardWidget',
components: { FalukantWidget, NewsWidget, ListWidget, BirthdayWidget, UpcomingEventsWidget, MiniCalendarWidget },
components: { FalukantWidget, NewsWidget, ListWidget, BirthdayWidget, UpcomingEventsWidget, MiniCalendarWidget, VocabCoursesWidget },
props: {
widgetId: { type: String, required: true },
title: { type: String, required: true },

View File

@@ -0,0 +1,165 @@
<template>
<div class="vocab-courses-widget">
<p v-if="!courses.length" class="vocab-courses-widget__empty">
{{ $t('widgets.vocabCourses.empty') }}
<router-link class="vocab-courses-widget__link" to="/socialnetwork/vocab/courses">
{{ $t('widgets.vocabCourses.browseCourses') }}
</router-link>
</p>
<ul v-else class="vocab-courses-widget__list">
<li v-for="c in courses" :key="c.courseId" class="vocab-courses-widget__item">
<div class="vocab-courses-widget__main">
<strong class="vocab-courses-widget__course-title">{{ c.title || $t('widgets.vocabCourses.unnamedCourse') }}</strong>
<span v-if="c.currentLesson" class="vocab-courses-widget__lesson">
{{ $t('widgets.vocabCourses.lessonLine', { number: c.currentLesson.lessonNumber, title: c.currentLesson.title }) }}
</span>
<span v-else class="vocab-courses-widget__lesson vocab-courses-widget__lesson--muted">
{{ $t('widgets.vocabCourses.noLessons') }}
</span>
<span v-if="c.allLessonsCompleted && c.currentLesson" class="vocab-courses-widget__badge">
{{ $t('widgets.vocabCourses.allDone') }}
</span>
</div>
<button
v-if="c.currentLesson"
type="button"
class="vocab-courses-widget__btn"
@click="goToLesson(c.courseId, c.currentLesson.id)"
>
{{ $t('widgets.vocabCourses.openLesson') }}
</button>
<button
v-else
type="button"
class="vocab-courses-widget__btn vocab-courses-widget__btn--secondary"
@click="goToCourse(c.courseId)"
>
{{ $t('widgets.vocabCourses.openCourse') }}
</button>
</li>
</ul>
</div>
</template>
<script>
export default {
name: 'VocabCoursesWidget',
props: {
data: { type: Object, default: null }
},
computed: {
courses() {
const raw = this.data;
if (!raw || typeof raw !== 'object') {
return [];
}
const list = raw.courses ?? raw.Courses;
return Array.isArray(list) ? list : [];
}
},
methods: {
goToLesson(courseId, lessonId) {
this.$router.push({
name: 'VocabLesson',
params: { courseId: String(courseId), lessonId: String(lessonId) }
});
},
goToCourse(courseId) {
this.$router.push({
name: 'VocabCourse',
params: { courseId: String(courseId) }
});
}
}
};
</script>
<style scoped>
.vocab-courses-widget__empty {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-secondary, #555);
line-height: 1.45;
}
.vocab-courses-widget__link {
display: inline-block;
margin-top: 0.35rem;
font-weight: 600;
color: var(--color-primary-orange-dark, #c2410c);
}
.vocab-courses-widget__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.vocab-courses-widget__item {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 8px 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--dashboard-widget-border, #e9ecef);
}
.vocab-courses-widget__item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.vocab-courses-widget__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.vocab-courses-widget__course-title {
font-size: 0.92rem;
color: var(--color-text-primary, #222);
}
.vocab-courses-widget__lesson {
font-size: 0.82rem;
color: var(--color-text-secondary, #555);
line-height: 1.35;
}
.vocab-courses-widget__lesson--muted {
font-style: italic;
}
.vocab-courses-widget__badge {
font-size: 0.75rem;
font-weight: 600;
color: #198754;
margin-top: 2px;
}
.vocab-courses-widget__btn {
flex-shrink: 0;
padding: 6px 12px;
font-size: 0.82rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
background: var(--color-primary-orange, #f97316);
color: #fff;
}
.vocab-courses-widget__btn:hover {
filter: brightness(1.05);
}
.vocab-courses-widget__btn--secondary {
background: var(--color-text-secondary, #6c757d);
}
</style>

View File

@@ -433,7 +433,28 @@
"bookmarkCandidate": "Timan-i kining kandidatura",
"voteError": "Sayop sa paghatag sa boto",
"voteAllError": "Sayop sa paghatag sa mga boto",
"applyError": "Dili mapadala ang aplikasyon."
"applyError": "Dili mapadala ang aplikasyon.",
"benefits": {
"daily_salary": "Adlaw-adlaw nga suhol (usa ra kada adlaw): mga {amount}",
"tax_exemption": "Way buhis: {regions}",
"tax_exemption_all": "Way buhis: tanang lebel sa rehiyon",
"generic": "Benepisyo ({code})"
},
"regionLevels": {
"city": "Siyudad",
"county": "County",
"shire": "Shire",
"markgrave": "Margravate",
"duchy": "Duchy",
"country": "Nasud"
},
"current": {
"benefit": "Benepisyo",
"benefit_all": "Tanang rehiyon",
"holder": "Tag-iya",
"none": "Walay karon nga posisyon.",
"termEnds": "Matapos sa"
}
},
"family": {
"title": "Pamilya",

View File

@@ -81,6 +81,16 @@
},
"falukant": {
"emptyValue": "—"
},
"vocabCourses": {
"empty": "Wala ka pa nakasulod sa bisan unsang kurso sa bokabularyo.",
"browseCourses": "Tan-awa ang mga kurso",
"unnamedCourse": "Kurso nga walay titulo",
"lessonLine": "Leksyon #{number}: {title}",
"noLessons": "Walay mga leksyon niini nga kurso.",
"allDone": "Natapos na ang tanang leksyon",
"openLesson": "Adto sa leksyon",
"openCourse": "Ablihi ang kurso"
}
},
"gender": {

View File

@@ -23,7 +23,8 @@
"news": "Balita",
"birthdays": "Mga adlawng natawhan",
"upcoming": "Umaabot nga mga appointment",
"calendar": "Kalendaryo"
"calendar": "Kalendaryo",
"vocabCourses": "Mga kurso sa pinulongan"
},
"overview": {
"activeWidgetsLabel": "Aktibong mga widget",

View File

@@ -1353,6 +1353,20 @@
"voteError": "Fehler beim Abgeben der Stimme",
"voteAllError": "Fehler beim Abgeben der Stimmen",
"applyError": "Bewerbung konnte nicht eingereicht werden.",
"benefits": {
"daily_salary": "Tagesamtshonorar (einmal pro Tag): ca. {amount}",
"tax_exemption": "Steuerbefreiung: {regions}",
"tax_exemption_all": "Steuerbefreiung: alle Regionsebenen",
"generic": "Vorteil ({code})"
},
"regionLevels": {
"city": "Stadt",
"county": "Landkreis",
"shire": "Grafschaft",
"markgrave": "Markgrafschaft",
"duchy": "Herzogtum",
"country": "Land"
},
"current": {
"office": "Amt",
"region": "Region",

View File

@@ -92,6 +92,16 @@
},
"falukant": {
"emptyValue": "—"
},
"vocabCourses": {
"empty": "Du bist derzeit in keinem Vokabelkurs eingeschrieben.",
"browseCourses": "Kurse entdecken",
"unnamedCourse": "Kurs ohne Titel",
"lessonLine": "Lektion #{number}: {title}",
"noLessons": "Noch keine Lektionen in diesem Kurs.",
"allDone": "Alle Lektionen absolviert",
"openLesson": "Zur Lektion",
"openCourse": "Zum Kurs"
}
},
"gender": {

View File

@@ -23,7 +23,8 @@
"news": "News",
"birthdays": "Geburtstage",
"upcoming": "Nächste Termine",
"calendar": "Kalender"
"calendar": "Kalender",
"vocabCourses": "Sprachkurse"
},
"overview": {
"activeWidgetsLabel": "Aktive Widgets",

View File

@@ -574,6 +574,20 @@
"voteError": "Error while submitting the vote",
"voteAllError": "Error while submitting the votes",
"applyError": "Application could not be submitted.",
"benefits": {
"daily_salary": "Daily office stipend (once per day): about {amount}",
"tax_exemption": "Tax exemption: {regions}",
"tax_exemption_all": "Tax exemption: all regional levels",
"generic": "Benefit ({code})"
},
"regionLevels": {
"city": "City",
"county": "County",
"shire": "Shire",
"markgrave": "Margravate",
"duchy": "Duchy",
"country": "Country"
},
"current": {
"office": "Office",
"region": "Region",

View File

@@ -92,6 +92,16 @@
},
"falukant": {
"emptyValue": "—"
},
"vocabCourses": {
"empty": "You are not enrolled in any vocabulary course yet.",
"browseCourses": "Browse courses",
"unnamedCourse": "Untitled course",
"lessonLine": "Lesson #{number}: {title}",
"noLessons": "No lessons in this course yet.",
"allDone": "All lessons completed",
"openLesson": "Go to lesson",
"openCourse": "Open course"
}
},
"gender": {

View File

@@ -23,7 +23,8 @@
"news": "News",
"birthdays": "Birthdays",
"upcoming": "Upcoming appointments",
"calendar": "Calendar"
"calendar": "Calendar",
"vocabCourses": "Language courses"
},
"overview": {
"activeWidgetsLabel": "Active widgets",

View File

@@ -1261,6 +1261,20 @@
"upcoming": "Cargos pendientes de (re)elección",
"elections": "Elecciones"
},
"benefits": {
"daily_salary": "Estipendio diario (una vez al día): unos {amount}",
"tax_exemption": "Exención fiscal: {regions}",
"tax_exemption_all": "Exención fiscal: todos los niveles regionales",
"generic": "Ventaja ({code})"
},
"regionLevels": {
"city": "Ciudad",
"county": "Condado",
"shire": "Condado (shire)",
"markgrave": "Margraviato",
"duchy": "Ducado",
"country": "País"
},
"current": {
"office": "Cargo",
"region": "Región",

View File

@@ -92,6 +92,16 @@
},
"falukant": {
"emptyValue": "—"
},
"vocabCourses": {
"empty": "Aún no estás inscrito en ningún curso de vocabulario.",
"browseCourses": "Ver cursos",
"unnamedCourse": "Curso sin título",
"lessonLine": "Lección n.º {number}: {title}",
"noLessons": "Este curso aún no tiene lecciones.",
"allDone": "Todas las lecciones completadas",
"openLesson": "Ir a la lección",
"openCourse": "Abrir curso"
}
},
"gender": {

View File

@@ -23,7 +23,8 @@
"news": "Noticias",
"birthdays": "Cumpleaños",
"upcoming": "Próximas citas",
"calendar": "Calendario"
"calendar": "Calendario",
"vocabCourses": "Cursos de idiomas"
},
"overview": {
"activeWidgetsLabel": "Widgets activos",

View File

@@ -25,9 +25,11 @@
</span>
<span>
{{ $t('falukant.politics.current.benefit') }}:
<template v-if="pos.benefit && pos.benefit.length">
<span v-if="pos.benefit.includes('*')">{{ $t('falukant.politics.current.benefit_all') }}</span>
<span v-else>{{ pos.benefit.join(', ') }}</span>
<template v-if="politicsBenefitItems(pos).length">
<template v-for="(b, i) in politicsBenefitItems(pos)" :key="i">
<span>{{ formatPoliticsBenefitItem(b) }}</span>
<span v-if="i < politicsBenefitItems(pos).length - 1">, </span>
</template>
</template>
<template v-else></template>
</span>
@@ -178,6 +180,49 @@ export default {
this.loadCurrentPositions();
},
methods: {
politicsBenefitItems(pos) {
const raw = pos?.benefit;
if (!Array.isArray(raw) || !raw.length) {
return [];
}
if (raw.every((x) => typeof x === 'string') && raw.includes('*')) {
return [this.$t('falukant.politics.current.benefit_all')];
}
return raw;
},
formatPoliticsRegionLevel(key) {
const k = String(key || '');
const path = `falukant.politics.regionLevels.${k}`;
const t = this.$t(path);
return t !== path ? t : k;
},
formatPoliticsBenefitItem(b) {
if (b == null) {
return '';
}
if (typeof b === 'string') {
return b;
}
if (typeof b === 'object' && b.tr) {
if (b.tr === 'tax_exemption' && b.params?.all) {
return this.$t('falukant.politics.benefits.tax_exemption_all');
}
if (b.tr === 'tax_exemption' && Array.isArray(b.params?.regions)) {
const labels = b.params.regions.map((r) => this.formatPoliticsRegionLevel(r)).join(', ');
return this.$t('falukant.politics.benefits.tax_exemption', { regions: labels });
}
if (b.tr === 'daily_salary') {
return this.$t('falukant.politics.benefits.daily_salary', { amount: b.params?.amount ?? '—' });
}
if (b.tr === 'generic_benefit') {
return this.$t('falukant.politics.benefits.generic', { code: b.params?.code || '' });
}
}
return String(b);
},
onTabChange(tab) {
if (tab === 'current') {
this.loadCurrentPositions();

View File

@@ -192,7 +192,8 @@ export default {
'/api/news': 'home.dashboard.widgetLabels.news',
'/api/calendar/widget/birthdays': 'home.dashboard.widgetLabels.birthdays',
'/api/calendar/widget/upcoming': 'home.dashboard.widgetLabels.upcoming',
'/api/calendar/widget/mini': 'home.dashboard.widgetLabels.calendar'
'/api/calendar/widget/mini': 'home.dashboard.widgetLabels.calendar',
'/api/vocab/dashboard-widget': 'home.dashboard.widgetLabels.vocabCourses'
}[endpoint];
return key ? this.$t(key) : fallbackLabel;
},
@@ -211,7 +212,8 @@ export default {
'News',
'Geburtstage',
'Nächste Termine',
'Kalender'
'Kalender',
'Sprachkurse'
];
return {
...widget,