code spliting und language 2

This commit is contained in:
Ashikagi
2026-03-23 23:57:26 +01:00
parent 8b21c5e3d4
commit fe27ec2beb
19 changed files with 335 additions and 349 deletions

View File

@@ -1,7 +1,10 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(supabase --version)" "Bash(supabase --version)",
"Bash(grep -n \"Radar-Profil\\\\|Ist/Soll\\\\|Trend \\(Gesamtpunkte\\)\\\\|Für den Trend\\\\|Kein Radarprofil\\\\|Deutsch\\\\|Englisch\\\\|Italienisch\\\\|Spanisch\\\\|Portugiesisch\\\\|Test final abschließen\\\\|Test als Link teilen\" /d/Projekte/PAT-STATS/src/components/**/*.jsx)",
"Bash(find /d/Projekte/PAT-STATS/src -type f \\\\\\(-name *route* -o -name *Router* \\\\\\))",
"Bash(npm run:*)"
] ]
} }
} }

1
.gitignore vendored
View File

@@ -1 +1,2 @@
node_modules node_modules
dist

View File

@@ -1,7 +1,7 @@
Offen Offen
Rangliste Rangliste
Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch
code splitting
Admin Oberfläche (anzahl user mit Mail Adressen/ anelegten Tests / anbindung von Werbung Links und rechts von den Test Übersichten Admin Oberfläche (anzahl user mit Mail Adressen/ anelegten Tests / anbindung von Werbung Links und rechts von den Test Übersichten
2fa 2fa
@@ -11,11 +11,12 @@ Erledigt:
Final Speichern do Final Speichern do
Max Punkte bei Bewertung PAT1 Max Punkte bei Bewertung PAT1
User Profil mit Land und Sprach Auswahl User Profil mit Land und Sprach Auswahl
Übersetzen in gängigge Sprachen Englisch Italienisch Spanisch Portugisisch
Termninal Termninal
vercel deploy vercel deploy
git add . git add .
git commit -m "BenutzerProfil " git commit -m "Übersetzung Teil 1"
git push git push

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
dist/index.html vendored
View File

@@ -5,8 +5,11 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PAT Test Manager</title> <title>PAT Test Manager</title>
<script type="module" crossorigin src="/assets/index-Cg2UxsRL.js"></script> <script type="module" crossorigin src="/assets/index-Bp6S7Gpx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BaBfIFek.css"> <link rel="modulepreload" crossorigin href="/assets/vendor-react-VZ-Yj73K.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-supabase-PyIEoSKG.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-ui-epoOyxwp.js">
<link rel="stylesheet" crossorigin href="/assets/index-CRRMtHNa.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,18 +1,25 @@
import React, { useEffect, useState } from 'react' import React, { lazy, Suspense, useEffect, useState } from 'react'
import { Moon, Sun } from 'lucide-react' import { Moon, Sun } from 'lucide-react'
import PatTestManager from './components/PatTestManager'
import AuthPanel from './components/AuthPanel'
import { supabase } from './lib/supabaseClient' import { supabase } from './lib/supabaseClient'
import Landing from './components/Landing'
import SharedAssessmentPage from './components/SharedAssessmentPage'
import PublicInfoPage from './components/public/PublicInfoPage'
import ProfileTab from './components/ProfileTab'
import { useUserProfile } from './hooks/useUserProfile' import { useUserProfile } from './hooks/useUserProfile'
import { getCountryLabel, getCountryOption } from './data/countries' import { getCountryLabel, getCountryOption } from './data/countries'
import CountryFlag from './components/CountryFlag' import CountryFlag from './components/CountryFlag'
import { LanguageProvider } from './i18n/LanguageContext' import { LanguageProvider } from './i18n/LanguageContext'
import { getTranslator } from './i18n/translations' import { getTranslator } from './i18n/translations'
const PatTestManager = lazy(() => import('./components/PatTestManager'))
const AuthPanel = lazy(() => import('./components/AuthPanel'))
const Landing = lazy(() => import('./components/Landing'))
const SharedAssessmentPage = lazy(() => import('./components/SharedAssessmentPage'))
const PublicInfoPage = lazy(() => import('./components/public/PublicInfoPage'))
const ProfileTab = lazy(() => import('./components/ProfileTab'))
const PageSpinner = () => (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 text-gray-600 dark:text-gray-300">
<span>Laden</span>
</div>
)
const readShareToken = () => { const readShareToken = () => {
if (typeof window === 'undefined') return '' if (typeof window === 'undefined') return ''
return new URLSearchParams(window.location.search).get('share') || '' return new URLSearchParams(window.location.search).get('share') || ''
@@ -156,7 +163,9 @@ function App() {
if (shareToken) { if (shareToken) {
return ( return (
<LanguageProvider language={language}> <LanguageProvider language={language}>
<SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} /> <Suspense fallback={<PageSpinner />}>
<SharedAssessmentPage shareToken={shareToken} themeToggle={renderThemeToggle()} />
</Suspense>
</LanguageProvider> </LanguageProvider>
) )
} }
@@ -197,7 +206,9 @@ function App() {
if (showAuth) { if (showAuth) {
return ( return (
<LanguageProvider language={language}> <LanguageProvider language={language}>
<AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} /> <Suspense fallback={<PageSpinner />}>
<AuthPanel onAuth={setSession} themeToggle={renderThemeToggle()} onBack={() => setShowAuth(false)} />
</Suspense>
</LanguageProvider> </LanguageProvider>
) )
} }
@@ -205,23 +216,27 @@ function App() {
if (publicPath === '/datenschutz' || publicPath === '/impressum' || publicPath === '/kontakt') { if (publicPath === '/datenschutz' || publicPath === '/impressum' || publicPath === '/kontakt') {
return ( return (
<LanguageProvider language={language}> <LanguageProvider language={language}>
<PublicInfoPage <Suspense fallback={<PageSpinner />}>
path={publicPath} <PublicInfoPage
onGetStarted={() => setShowAuth(true)} path={publicPath}
onNavigate={navigatePublic} onGetStarted={() => setShowAuth(true)}
themeToggle={renderThemeToggle()} onNavigate={navigatePublic}
/> themeToggle={renderThemeToggle()}
/>
</Suspense>
</LanguageProvider> </LanguageProvider>
) )
} }
return ( return (
<LanguageProvider language={language}> <LanguageProvider language={language}>
<Landing <Suspense fallback={<PageSpinner />}>
onGetStarted={() => setShowAuth(true)} <Landing
onNavigate={navigatePublic} onGetStarted={() => setShowAuth(true)}
themeToggle={renderThemeToggle()} onNavigate={navigatePublic}
/> themeToggle={renderThemeToggle()}
/>
</Suspense>
</LanguageProvider> </LanguageProvider>
) )
} }
@@ -306,27 +321,29 @@ function App() {
</div> </div>
</header> </header>
{activeTab !== 'profile' && ( <Suspense fallback={<PageSpinner />}>
<PatTestManager {activeTab !== 'profile' && (
user={session?.user} <PatTestManager
activeTab={activeTab} user={session?.user}
onEditingStateChange={setIsEditingAssessment} activeTab={activeTab}
testsVisible={Boolean(profile?.testsVisible)} onEditingStateChange={setIsEditingAssessment}
isProfileLoading={profileLoading} testsVisible={Boolean(profile?.testsVisible)}
/> isProfileLoading={profileLoading}
)} />
)}
{activeTab === 'profile' && ( {activeTab === 'profile' && (
<ProfileTab <ProfileTab
session={session} session={session}
profile={profile} profile={profile}
loading={profileLoading} loading={profileLoading}
error={profileError} error={profileError}
onSave={saveProfile} onSave={saveProfile}
onDelete={deleteProfileData} onDelete={deleteProfileData}
onSessionChange={setSession} onSessionChange={setSession}
/> />
)} )}
</Suspense>
</div> </div>
</LanguageProvider> </LanguageProvider>
) )

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
const polarToCartesian = (centerX, centerY, radius, angle) => ({ const polarToCartesian = (centerX, centerY, radius, angle) => ({
x: centerX + radius * Math.cos(angle), x: centerX + radius * Math.cos(angle),
@@ -8,12 +9,13 @@ const polarToCartesian = (centerX, centerY, radius, angle) => ({
const truncate = (value, maxLength = 16) => (value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value); const truncate = (value, maxLength = 16) => (value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value);
export default function RadarChart({ radarSeries = [] }) { export default function RadarChart({ radarSeries = [] }) {
const t = useTranslation();
const data = radarSeries.slice(0, 6); const data = radarSeries.slice(0, 6);
if (!data.length) { if (!data.length) {
return ( return (
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300"> <div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
Kein Radarprofil verfügbar. {t('radar.no_data')}
</div> </div>
); );
} }
@@ -40,8 +42,8 @@ export default function RadarChart({ radarSeries = [] }) {
return ( return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm h-full flex flex-col"> <div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm h-full flex flex-col">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Radar-Profil (Ist/Soll)</h3> <h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">{t('radar.title')}</h3>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto max-w-[420px] mx-auto" role="img" aria-label="Radarprofil"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto max-w-[420px] mx-auto" role="img" aria-label={t('radar.aria')}>
{[0.25, 0.5, 0.75, 1].map((level) => ( {[0.25, 0.5, 0.75, 1].map((level) => (
<polygon <polygon
key={level} key={level}

View File

@@ -1,10 +1,13 @@
import React from 'react'; import React from 'react';
import { useTranslation } from '../../i18n/LanguageContext';
export default function TrendChart({ trendSeries = [] }) { export default function TrendChart({ trendSeries = [] }) {
const t = useTranslation();
if (trendSeries.length < 2) { if (trendSeries.length < 2) {
return ( return (
<div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300"> <div className="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-900/70 p-4 text-sm text-gray-600 dark:text-gray-300">
Für den Trend werden mindestens 2 Assessments benötigt. {t('trend.no_data')}
</div> </div>
); );
} }
@@ -35,8 +38,8 @@ export default function TrendChart({ trendSeries = [] }) {
return ( return (
<div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm"> <div className="rounded-2xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Trend (Gesamtpunkte)</h3> <h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">{t('trend.title')}</h3>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label="Trenddiagramm"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" role="img" aria-label={t('trend.aria')}>
<line x1={left} y1={top} x2={left} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" /> <line x1={left} y1={top} x2={left} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" />
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" /> <line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} className="stroke-gray-300 dark:stroke-gray-700" strokeWidth="1" />

View File

@@ -3,7 +3,8 @@ import { CheckCircle2, FileDown, Lock, Save, Share2 } from 'lucide-react';
import ExerciseInput from './ExerciseInput'; import ExerciseInput from './ExerciseInput';
import SaveDialog from './SaveDialog'; import SaveDialog from './SaveDialog';
import { downloadAssessmentPdf } from '../../utils/pdfExport'; import { downloadAssessmentPdf } from '../../utils/pdfExport';
import { useTranslation } from '../../i18n/LanguageContext'; import { useTranslation, useLanguage } from '../../i18n/LanguageContext';
import { translateExerciseName } from '../../i18n/exerciseNames';
const PatDetail = ({ const PatDetail = ({
currentAssessment, currentAssessment,
@@ -32,11 +33,18 @@ const PatDetail = ({
onRevokeShare onRevokeShare
}) => { }) => {
const t = useTranslation(); const t = useTranslation();
const language = useLanguage();
if (!currentAssessment) return null; if (!currentAssessment) return null;
const totalPoints = calculateTotal(); const totalPoints = calculateTotal();
const achievement = getAchievement(currentAssessment?.patType, totalPoints); const achievement = getAchievement(currentAssessment?.patType, totalPoints);
const getAchievementName = (name) => {
if (name === 'Nicht bestanden') return t('achievement.not_passed');
if (name.endsWith(' Teilnahme')) return name.replace(' Teilnahme', ` ${t('achievement.participation')}`);
return name;
};
const handleDownloadPdf = async () => { const handleDownloadPdf = async () => {
await downloadAssessmentPdf({ await downloadAssessmentPdf({
assessment: currentAssessment, assessment: currentAssessment,
@@ -104,7 +112,7 @@ const PatDetail = ({
<button <button
onClick={onFinalize} onClick={onFinalize}
disabled={!canFinalize} disabled={!canFinalize}
title={finalizeDisabledReason || 'Test final abschließen'} title={finalizeDisabledReason || t('detail.finalize_title')}
className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${ className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
canFinalize canFinalize
? 'bg-emerald-700 text-white hover:bg-emerald-800' ? 'bg-emerald-700 text-white hover:bg-emerald-800'
@@ -126,7 +134,7 @@ const PatDetail = ({
<button <button
onClick={onShare} onClick={onShare}
disabled={!canShare} disabled={!canShare}
title={shareDisabledReason || 'Test als Link teilen'} title={shareDisabledReason || t('detail.share_title')}
className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${ className={`px-4 py-2 rounded-lg transition flex items-center gap-2 text-sm ${
canShare canShare
? 'bg-sky-600 text-white hover:bg-sky-700' ? 'bg-sky-600 text-white hover:bg-sky-700'
@@ -191,7 +199,7 @@ const PatDetail = ({
{currentAssessment?.exercises.map((exercise, exIndex) => ( {currentAssessment?.exercises.map((exercise, exIndex) => (
<div key={exIndex} className="mb-6 border-b pb-4 border-gray-200 dark:border-gray-800"> <div key={exIndex} className="mb-6 border-b pb-4 border-gray-200 dark:border-gray-800">
<div className="font-bold text-lg mb-2 text-gray-700 dark:text-gray-200">{exercise.name}</div> <div className="font-bold text-lg mb-2 text-gray-700 dark:text-gray-200">{translateExerciseName(exercise.name, language)}</div>
<ExerciseInput exercise={exercise} exIndex={exIndex} onChange={onUpdateExercise} disabled={isReadOnly} /> <ExerciseInput exercise={exercise} exIndex={exIndex} onChange={onUpdateExercise} disabled={isReadOnly} />
@@ -222,7 +230,7 @@ const PatDetail = ({
</div> </div>
<div className={`mt-4 px-6 py-4 rounded-lg ${achievement.color} ${achievement.text} text-center`}> <div className={`mt-4 px-6 py-4 rounded-lg ${achievement.color} ${achievement.text} text-center`}>
<p className="text-2xl font-bold">{achievement.name}</p> <p className="text-2xl font-bold">{getAchievementName(achievement.name)}</p>
{achievement.subtitle && <p className="text-lg mt-2">{achievement.subtitle}</p>} {achievement.subtitle && <p className="text-lg mt-2">{achievement.subtitle}</p>}
</div> </div>
</div> </div>

View File

@@ -218,7 +218,7 @@ const PatList = ({
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">{title}</h2> <h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">{title}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('patlist.count', { count: entries.length })} {t('patlist.count', { count: entries.length })}
{entries.length !== totalEntries ? ` von ${totalEntries}` : ''} {entries.length !== totalEntries ? ` ${t('patlist.of')} ${totalEntries}` : ''}
</p> </p>
</div> </div>

View File

@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from '../i18n/LanguageContext'; import { useTranslation } from '../i18n/LanguageContext';
import PatList from './PatList/PatList'; import PatList from './PatList/PatList';
import PatDetail from './PatDetail/PatDetail'; import PatDetail from './PatDetail/PatDetail';
import AnalysisTab from './Analysis/AnalysisTab'; const AnalysisTab = lazy(() => import('./Analysis/AnalysisTab'));
import { patTypes } from '../data/patTypes'; import { patTypes } from '../data/patTypes';
import { useAssessments } from '../hooks/useAssessments'; import { useAssessments } from '../hooks/useAssessments';
import { getAchievement, getPatTypeColor } from '../utils/patCalculations'; import { getAchievement, getPatTypeColor } from '../utils/patCalculations';
@@ -309,13 +309,15 @@ export default function PatTestManager({
if (activeTab === 'analysis') { if (activeTab === 'analysis') {
return ( return (
<AnalysisTab <Suspense fallback={<div className="min-h-screen flex items-center justify-center text-gray-500 dark:text-gray-400">Laden</div>}>
assessments={visibleAssessments} <AnalysisTab
selectedPatType={selectedPatType} assessments={visibleAssessments}
onPatTypeChange={setSelectedPatType} selectedPatType={selectedPatType}
patTypes={visiblePatTypes} onPatTypeChange={setSelectedPatType}
user={user} patTypes={visiblePatTypes}
/> user={user}
/>
</Suspense>
); );
} }

View File

@@ -3,6 +3,7 @@ import { Check, ChevronDown } from 'lucide-react';
import { countryOptions, getCountryLabel, getCountryOption } from '../data/countries'; import { countryOptions, getCountryLabel, getCountryOption } from '../data/countries';
import CountryFlag from './CountryFlag'; import CountryFlag from './CountryFlag';
import { useTranslation } from '../i18n/LanguageContext'; import { useTranslation } from '../i18n/LanguageContext';
import { getTranslator } from '../i18n/translations';
const emptyStatus = { const emptyStatus = {
message: '', message: '',
@@ -81,6 +82,8 @@ export default function ProfileTab({
setStatus(emptyStatus); setStatus(emptyStatus);
const result = await onSave(formValues); const result = await onSave(formValues);
// Nach dem Speichern in der neu gewählten Sprache übersetzen
const tNew = getTranslator(formValues.language);
if (result?.ok) { if (result?.ok) {
if (result.session) { if (result.session) {
@@ -89,14 +92,14 @@ export default function ProfileTab({
setStatus({ setStatus({
message: result.emailUpdateRequested message: result.emailUpdateRequested
? t('profile.saved_email') ? tNew('profile.saved_email')
: t('profile.saved'), : tNew('profile.saved'),
error: '' error: ''
}); });
} else { } else {
setStatus({ setStatus({
message: '', message: '',
error: result?.error?.message || t('profile.error_save') error: result?.error?.message || tNew('profile.error_save')
}); });
} }

View File

@@ -1,18 +1,24 @@
import React, { createContext, useContext, useMemo } from 'react'; import React, { createContext, useContext, useMemo } from 'react';
import { getTranslator } from './translations'; import { getTranslator, SUPPORTED_LANGUAGES } from './translations';
const LanguageContext = createContext(() => (key) => key); const LanguageContext = createContext({ t: (key) => key, language: 'de' });
export function LanguageProvider({ language, children }) { export function LanguageProvider({ language, children }) {
const t = useMemo(() => getTranslator(language), [language]); const lang = SUPPORTED_LANGUAGES.includes(language) ? language : 'de';
const t = useMemo(() => getTranslator(lang), [lang]);
const value = useMemo(() => ({ t, language: lang }), [t, lang]);
return ( return (
<LanguageContext.Provider value={t}> <LanguageContext.Provider value={value}>
{children} {children}
</LanguageContext.Provider> </LanguageContext.Provider>
); );
} }
export function useTranslation() { export function useTranslation() {
return useContext(LanguageContext); return useContext(LanguageContext).t;
}
export function useLanguage() {
return useContext(LanguageContext).language;
} }

138
src/i18n/exerciseNames.js Normal file
View File

@@ -0,0 +1,138 @@
/**
* Übersetzungen für PAT-Übungsnamen.
* Der deutsche Name (wie er in der Datenbank steht) ist der Schlüssel.
*/
const exerciseNameMap = {
// ── PAT Start ──────────────────────────────────────────────────────────────
'1) Stoß-Geschwindigkeit': {
en: '1) Stroke Speed',
it: '1) Velocità del colpo',
es: '1) Velocidad del golpe',
pt: '1) Velocidade do golpe',
},
'2) Stoß-Gradlinigkeit': {
en: '2) Stroke Straightness',
it: '2) Linearità del colpo',
es: '2) Linealidad del golpe',
pt: '2) Linearidade do golpe',
},
'3) Winkelbälle': {
en: '3) Angle Balls',
it: '3) Palle angolate',
es: '3) Bolas en ángulo',
pt: '3) Bolas em ângulo',
},
'4) Nachlauf Wirkung': {
en: '4) Follow-Through Effect',
it: '4) Effetto di seguita',
es: '4) Efecto de seguimiento',
pt: '4) Efeito de avanço',
},
'5) Rücklauf-Wirkung': {
en: '5) Back Spin Effect',
it: '5) Effetto di ritorno',
es: '5) Efecto de retroceso',
pt: '5) Efeito de retorno',
},
'6) Kl. Positions-Spiel': {
en: '6) Sm. Position Play',
it: '6) Pic. gioco di posizione',
es: '6) Peq. juego de posición',
pt: '6) Peq. jogo de posição',
},
'7) Gr. Pos. Bereich': {
en: '7) Lg. Position Range',
it: '7) Gr. area di posizione',
es: '7) Gr. rango de posición',
pt: '7) Gr. área de posição',
},
'8) Press Bande': {
en: '8) Cushion Press',
it: '8) Pressione sponda',
es: '8) Presión de banda',
pt: '8) Pressão de banda',
},
'9) Standardbälle': {
en: '9) Standard Balls',
it: '9) Palle standard',
es: '9) Bolas estándar',
pt: '9) Bolas padrão',
},
'10) 8-Ball Situation': {
en: '10) 8-Ball Situation',
it: '10) Situazione 8-Ball',
es: '10) Situación 8-Ball',
pt: '10) Situação 8-Ball',
},
// ── PAT 1 ──────────────────────────────────────────────────────────────────
'1) Geschwindigkeit': {
en: '1) Speed',
it: '1) Velocità',
es: '1) Velocidad',
pt: '1) Velocidade',
},
'2) Geradlinigkeit (Speed 2)': {
en: '2) Straightness (Speed 2)',
it: '2) Linearità (Speed 2)',
es: '2) Linealidad (Speed 2)',
pt: '2) Linearidade (Speed 2)',
},
'3) Nachlauf': {
en: '3) Follow-Through',
it: '3) Seguita',
es: '3) Seguimiento',
pt: '3) Avanço',
},
'4) Rücklauf': {
en: '4) Back Spin',
it: '4) Effetto retro',
es: '4) Retroceso',
pt: '4) Retorno',
},
'5) Kl. Pos.-Bereich': {
en: '5) Sm. Position Range',
it: '5) Pic. area di posizione',
es: '5) Peq. rango de posición',
pt: '5) Peq. área de posição',
},
'6) Gr. Pos.-Bereich': {
en: '6) Lg. Position Range',
it: '6) Gr. area di posizione',
es: '6) Gr. rango de posición',
pt: '6) Gr. área de posição',
},
'7) Press Bande': {
en: '7) Cushion Press',
it: '7) Pressione sponda',
es: '7) Presión de banda',
pt: '7) Pressão de banda',
},
'8) Endlos-Übung (Max: 9)': {
en: '8) Continuous Exercise (Max: 9)',
it: '8) Esercizio continuo (Max: 9)',
es: '8) Ejercicio continuo (Máx: 9)',
pt: '8) Exercício contínuo (Máx: 9)',
},
'9) Standardbälle': {
en: '9) Standard Balls',
it: '9) Palle standard',
es: '9) Bolas estándar',
pt: '9) Bolas padrão',
},
'10) 9-Ball Situation 5': {
en: '10) 9-Ball Situation 5',
it: '10) Situazione 9-Ball 5',
es: '10) Situación 9-Ball 5',
pt: '10) Situação 9-Ball 5',
},
};
/**
* Gibt den übersetzten Übungsnamen zurück.
* Fällt auf den deutschen Namen zurück, wenn keine Übersetzung vorhanden.
*/
export function translateExerciseName(germanName, language) {
if (!language || language === 'de') return germanName;
return exerciseNameMap[germanName]?.[language] ?? germanName;
}

View File

@@ -112,6 +112,9 @@ const translations = {
'detail.col_factor': 'Faktor', 'detail.col_factor': 'Faktor',
'detail.col_points': 'Points', 'detail.col_points': 'Points',
'detail.col_result': 'Ergebnis', 'detail.col_result': 'Ergebnis',
'detail.finalize_title': 'Test final abschließen',
'detail.share_title': 'Test als Link teilen',
'patlist.of': 'von',
// ── Speichern-Dialog ───────────────────────────────────────────────────── // ── Speichern-Dialog ─────────────────────────────────────────────────────
'savedialog.title': 'Ungespeicherte Änderungen', 'savedialog.title': 'Ungespeicherte Änderungen',
@@ -182,6 +185,20 @@ const translations = {
'training.history': 'Plan-Historie', 'training.history': 'Plan-Historie',
'training.no_history': 'Keine archivierten Pläne vorhanden.', 'training.no_history': 'Keine archivierten Pläne vorhanden.',
// ── Ergebnis-Bewertungen ─────────────────────────────────────────────────
'achievement.not_passed': 'Nicht bestanden',
'achievement.participation': 'Teilnahme',
// ── Radar-Chart ──────────────────────────────────────────────────────────
'radar.no_data': 'Kein Radarprofil verfügbar.',
'radar.title': 'Radar-Profil (Ist/Soll)',
'radar.aria': 'Radarprofil',
// ── Trend-Chart ──────────────────────────────────────────────────────────
'trend.no_data': 'Für den Trend werden mindestens 2 Assessments benötigt.',
'trend.title': 'Trend (Gesamtpunkte)',
'trend.aria': 'Trenddiagramm',
// ── Gap-Chart ──────────────────────────────────────────────────────────── // ── Gap-Chart ────────────────────────────────────────────────────────────
'gapchart.no_data': 'Kein Gap-Chart verfügbar.', 'gapchart.no_data': 'Kein Gap-Chart verfügbar.',
'gapchart.title': 'Gap-Bar-Chart (Priorität)', 'gapchart.title': 'Gap-Bar-Chart (Priorität)',
@@ -347,6 +364,9 @@ const translations = {
'detail.col_factor': 'Factor', 'detail.col_factor': 'Factor',
'detail.col_points': 'Points', 'detail.col_points': 'Points',
'detail.col_result': 'Result', 'detail.col_result': 'Result',
'detail.finalize_title': 'Finalize test',
'detail.share_title': 'Share test as link',
'patlist.of': 'of',
'savedialog.title': 'Unsaved changes', 'savedialog.title': 'Unsaved changes',
'savedialog.message': 'You have unsaved changes. Would you like to save them?', 'savedialog.message': 'You have unsaved changes. Would you like to save them?',
@@ -412,6 +432,14 @@ const translations = {
'training.history': 'Plan history', 'training.history': 'Plan history',
'training.no_history': 'No archived plans available.', 'training.no_history': 'No archived plans available.',
'achievement.not_passed': 'Not passed',
'achievement.participation': 'Participation',
'radar.no_data': 'No radar profile available.',
'radar.title': 'Radar Profile (Actual/Target)',
'radar.aria': 'Radar profile',
'trend.no_data': 'At least 2 assessments are needed for the trend.',
'trend.title': 'Trend (Total points)',
'trend.aria': 'Trend chart',
'gapchart.no_data': 'No gap chart available.', 'gapchart.no_data': 'No gap chart available.',
'gapchart.title': 'Gap Bar Chart (Priority)', 'gapchart.title': 'Gap Bar Chart (Priority)',
'gapchart.aria': 'Gap bar chart', 'gapchart.aria': 'Gap bar chart',
@@ -573,6 +601,9 @@ const translations = {
'detail.col_factor': 'Fattore', 'detail.col_factor': 'Fattore',
'detail.col_points': 'Punti', 'detail.col_points': 'Punti',
'detail.col_result': 'Risultato', 'detail.col_result': 'Risultato',
'detail.finalize_title': 'Finalizza il test',
'detail.share_title': 'Condividi il test come link',
'patlist.of': 'di',
'savedialog.title': 'Modifiche non salvate', 'savedialog.title': 'Modifiche non salvate',
'savedialog.message': 'Hai modifiche non salvate. Vuoi salvarle?', 'savedialog.message': 'Hai modifiche non salvate. Vuoi salvarle?',
@@ -638,6 +669,14 @@ const translations = {
'training.history': 'Cronologia piani', 'training.history': 'Cronologia piani',
'training.no_history': 'Nessun piano archiviato disponibile.', 'training.no_history': 'Nessun piano archiviato disponibile.',
'achievement.not_passed': 'Non superato',
'achievement.participation': 'Partecipazione',
'radar.no_data': 'Nessun profilo radar disponibile.',
'radar.title': 'Profilo Radar (Effettivo/Obiettivo)',
'radar.aria': 'Profilo radar',
'trend.no_data': 'Sono necessari almeno 2 assessment per il trend.',
'trend.title': 'Trend (Punti totali)',
'trend.aria': 'Grafico trend',
'gapchart.no_data': 'Nessun grafico gap disponibile.', 'gapchart.no_data': 'Nessun grafico gap disponibile.',
'gapchart.title': 'Grafico Gap (Priorità)', 'gapchart.title': 'Grafico Gap (Priorità)',
'gapchart.aria': 'Grafico a barre gap', 'gapchart.aria': 'Grafico a barre gap',
@@ -799,6 +838,9 @@ const translations = {
'detail.col_factor': 'Factor', 'detail.col_factor': 'Factor',
'detail.col_points': 'Puntos', 'detail.col_points': 'Puntos',
'detail.col_result': 'Resultado', 'detail.col_result': 'Resultado',
'detail.finalize_title': 'Finalizar test',
'detail.share_title': 'Compartir test como enlace',
'patlist.of': 'de',
'savedialog.title': 'Cambios sin guardar', 'savedialog.title': 'Cambios sin guardar',
'savedialog.message': 'Tienes cambios sin guardar. ¿Deseas guardarlos?', 'savedialog.message': 'Tienes cambios sin guardar. ¿Deseas guardarlos?',
@@ -864,6 +906,14 @@ const translations = {
'training.history': 'Historial de planes', 'training.history': 'Historial de planes',
'training.no_history': 'No hay planes archivados disponibles.', 'training.no_history': 'No hay planes archivados disponibles.',
'achievement.not_passed': 'No aprobado',
'achievement.participation': 'Participación',
'radar.no_data': 'No hay perfil radar disponible.',
'radar.title': 'Perfil Radar (Real/Objetivo)',
'radar.aria': 'Perfil radar',
'trend.no_data': 'Se necesitan al menos 2 evaluaciones para la tendencia.',
'trend.title': 'Tendencia (Puntos totales)',
'trend.aria': 'Gráfico de tendencia',
'gapchart.no_data': 'No hay gráfico de gap disponible.', 'gapchart.no_data': 'No hay gráfico de gap disponible.',
'gapchart.title': 'Gráfico de Gap (Prioridad)', 'gapchart.title': 'Gráfico de Gap (Prioridad)',
'gapchart.aria': 'Gráfico de barras de gap', 'gapchart.aria': 'Gráfico de barras de gap',
@@ -1025,6 +1075,9 @@ const translations = {
'detail.col_factor': 'Fator', 'detail.col_factor': 'Fator',
'detail.col_points': 'Pontos', 'detail.col_points': 'Pontos',
'detail.col_result': 'Resultado', 'detail.col_result': 'Resultado',
'detail.finalize_title': 'Finalizar teste',
'detail.share_title': 'Partilhar teste como link',
'patlist.of': 'de',
'savedialog.title': 'Alterações não guardadas', 'savedialog.title': 'Alterações não guardadas',
'savedialog.message': 'Tem alterações não guardadas. Deseja guardá-las?', 'savedialog.message': 'Tem alterações não guardadas. Deseja guardá-las?',
@@ -1090,6 +1143,14 @@ const translations = {
'training.history': 'Histórico de planos', 'training.history': 'Histórico de planos',
'training.no_history': 'Sem planos arquivados disponíveis.', 'training.no_history': 'Sem planos arquivados disponíveis.',
'achievement.not_passed': 'Não aprovado',
'achievement.participation': 'Participação',
'radar.no_data': 'Sem perfil radar disponível.',
'radar.title': 'Perfil Radar (Real/Objetivo)',
'radar.aria': 'Perfil radar',
'trend.no_data': 'São necessárias pelo menos 2 avaliações para a tendência.',
'trend.title': 'Tendência (Pontos totais)',
'trend.aria': 'Gráfico de tendência',
'gapchart.no_data': 'Sem gráfico de gap disponível.', 'gapchart.no_data': 'Sem gráfico de gap disponível.',
'gapchart.title': 'Gráfico de Gap (Prioridade)', 'gapchart.title': 'Gráfico de Gap (Prioridade)',
'gapchart.aria': 'Gráfico de barras de gap', 'gapchart.aria': 'Gráfico de barras de gap',

View File

@@ -3,6 +3,19 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: {
chunkSizeWarningLimit: 600,
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-supabase': ['@supabase/supabase-js'],
'vendor-ui': ['lucide-react', 'country-flag-icons'],
'vendor-xlsx': ['xlsx'],
},
},
},
},
test: { test: {
environment: 'node', environment: 'node',
include: ['src/**/*.test.{js,jsx}'] include: ['src/**/*.test.{js,jsx}']