feat(MemberTtrHistory): implement TTR history management and UI enhancements

- Added new endpoints in the member controller for retrieving and refreshing TTR history.
- Integrated TTR history functionality into the member service, allowing for seamless data retrieval and updates.
- Updated the member model to include a field for TTR history player ID, enhancing data tracking.
- Enhanced the MembersView to display TTR history with a dedicated dialog for better user interaction.
- Improved the MyTischtennisClient to support fetching historical player IDs, enriching the data provided to users.
- Refactored various components to ensure consistent styling and functionality across the application.
This commit is contained in:
Torsten Schulz (local)
2026-03-18 15:34:10 +01:00
parent 79adad9564
commit 563a7e8dde
16 changed files with 1471 additions and 108 deletions

View File

@@ -869,7 +869,8 @@ class MyTischtennisClient {
* @param {string} fedNickname - Federation nickname (e.g., "HeTTV")
* @returns {Promise<Object>} Rankings with player entries (all pages)
*/
async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes') {
async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes', options = {}) {
const { includeHistoryPlayerIds = false } = options;
const allEntries = [];
let currentPage = 0;
let hasMorePages = true;
@@ -877,8 +878,6 @@ class MyTischtennisClient {
while (hasMorePages) {
const endpoint = `/rankings/andro-rangliste?all-players=on&clubnr=${clubId}&fednickname=${fedNickname}&current-ranking=${currentRanking}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`;
const result = await this.authenticatedRequest(endpoint, cookie, {
method: 'GET'
});
@@ -917,15 +916,39 @@ class MyTischtennisClient {
error: 'Keine entries in blockLoaderData gefunden'
};
}
let historyPlayerIdsByName = null;
if (includeHistoryPlayerIds) {
const htmlEndpoint = `/rankings/andro-rangliste?clubnr=${clubId}&fednickname=${fedNickname}&all-players=on&continent=all&country=all&current-ranking=${currentRanking}&results-per-page=100&page=${currentPage + 1}`;
const htmlResult = await this.authenticatedRequest(htmlEndpoint, cookie, {
method: 'GET',
headers: {
Accept: 'text/html,application/xhtml+xml'
}
});
historyPlayerIdsByName = htmlResult.success
? this.extractHistoryPlayerIdsFromAndroRankingHtml(htmlResult.data)
: new Map();
}
const enrichedEntries = entries.map((entry) => {
const nameKey = this._buildRankingNameKey(entry?.firstname, entry?.lastname);
const historyPlayerId = historyPlayerIdsByName?.get(nameKey) || null;
return {
...entry,
historyPlayerId,
myTischtennisHistoryPlayerId: historyPlayerId
};
});
// Füge Entries hinzu
allEntries.push(...entries);
allEntries.push(...enrichedEntries);
// Prüfe ob es weitere Seiten gibt
// Wenn die aktuelle Seite weniger Einträge hat als das Limit, sind wir am Ende
// Oder wenn wir alle erwarteten Einträge haben
if (entries.length === 0) {
if (enrichedEntries.length === 0) {
hasMorePages = false;
} else if (rankingData.numberOfPages && currentPage >= rankingData.numberOfPages - 1) {
hasMorePages = false;
@@ -946,6 +969,45 @@ class MyTischtennisClient {
}
};
}
extractHistoryPlayerIdsFromAndroRankingHtml(html) {
const result = new Map();
const source = typeof html === 'string' ? html : String(html || '');
const anchorPattern = /href="\/community\/external-profile\?player-id=(P[A-Z0-9]+)"[^>]*>([^<]+)<\/a>/gi;
let match = null;
while ((match = anchorPattern.exec(source)) !== null) {
const playerId = match[1];
const fullName = this._decodeHtmlEntities(match[2] || '');
const key = this._buildRankingFullNameKey(fullName);
if (key && playerId && !result.has(key)) {
result.set(key, playerId);
}
}
return result;
}
_buildRankingNameKey(firstname, lastname) {
return this._buildRankingFullNameKey(`${firstname || ''} ${lastname || ''}`);
}
_buildRankingFullNameKey(name) {
return String(name || '')
.normalize('NFKC')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
_decodeHtmlEntities(value) {
return String(value || '')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
}
export default new MyTischtennisClient();

View File

@@ -93,6 +93,30 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
}
};
const getMemberTtrHistory = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.getMemberTtrHistory(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[getMemberTtrHistory] - Error:', error);
res.status(500).json({ success: false, error: 'TTR-Historie konnte nicht geladen werden.' });
}
};
const refreshMemberTtrHistory = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.refreshMemberTtrHistory(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[refreshMemberTtrHistory] - Error:', error);
res.status(500).json({ success: false, error: 'TTR-Historie konnte nicht aktualisiert werden.' });
}
};
const rotateMemberImage = async (req, res) => {
try {
const { clubId, memberId, imageId } = req.params;
@@ -269,6 +293,8 @@ export {
uploadMemberImage,
getMemberImage,
updateRatingsFromMyTischtennis,
getMemberTtrHistory,
refreshMemberTtrHistory,
rotateMemberImage,
transferMembers,
quickUpdateTestMembership,

View File

@@ -0,0 +1,5 @@
-- Add my_tischtennis_history_player_id column
ALTER TABLE member
ADD COLUMN my_tischtennis_history_player_id VARCHAR(255) NULL COMMENT 'TTR history player ID from myTischtennis (e.g. P14EC4981D)';
CREATE INDEX idx_member_my_tischtennis_history_player_id ON member(my_tischtennis_history_player_id);

View File

@@ -166,6 +166,12 @@ const Member = sequelize.define('Member', {
allowNull: true,
comment: 'Player ID from myTischtennis (e.g. NU2705037)',
field: 'my_tischtennis_player_id'
},
myTischtennisHistoryPlayerId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'TTR history player ID from myTischtennis (e.g. P14EC4981D)',
field: 'my_tischtennis_history_player_id'
}
}, {
underscored: true,

View File

@@ -0,0 +1,79 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const MemberTtrHistory = sequelize.define('MemberTtrHistory', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
memberId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'member_id'
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
playerId: {
type: DataTypes.STRING,
allowNull: true,
field: 'player_id'
},
sourceDate: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'source_date'
},
ttr: {
type: DataTypes.INTEGER,
allowNull: true
},
qttr: {
type: DataTypes.INTEGER,
allowNull: true
},
label: {
type: DataTypes.STRING,
allowNull: true
},
sourceType: {
type: DataTypes.STRING,
allowNull: true,
field: 'source_type'
},
fetchedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'fetched_at'
},
rawPayload: {
type: DataTypes.JSON,
allowNull: true,
field: 'raw_payload'
}
}, {
underscored: true,
tableName: 'member_ttr_history',
timestamps: true,
indexes: [
{
fields: ['member_id']
},
{
fields: ['club_id']
},
{
fields: ['player_id']
},
{
fields: ['source_date']
}
]
});
export default MemberTtrHistory;

View File

@@ -48,6 +48,7 @@ import ApiLog from './ApiLog.js';
import MemberTransferConfig from './MemberTransferConfig.js';
import MemberContact from './MemberContact.js';
import MemberImage from './MemberImage.js';
import MemberTtrHistory from './MemberTtrHistory.js';
import TrainingGroup from './TrainingGroup.js';
import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
@@ -90,6 +91,9 @@ DiaryNote.belongsTo(Member, { foreignKey: 'memberId' });
Member.hasMany(MemberNote, { as: 'memberNotes', foreignKey: 'memberId' });
MemberNote.belongsTo(Member, { foreignKey: 'memberId' });
Member.hasMany(MemberTtrHistory, { as: 'ttrHistoryEntries', foreignKey: 'memberId' });
MemberTtrHistory.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
DiaryDate.hasMany(DiaryNote, { as: 'diaryNotes', foreignKey: 'diaryDateId' });
DiaryNote.belongsTo(DiaryDate, { foreignKey: 'diaryDateId' });
@@ -417,6 +421,7 @@ export {
MemberTransferConfig,
MemberContact,
MemberImage,
MemberTtrHistory,
TrainingGroup,
MemberTrainingGroup,
ClubDisabledPresetGroup,

View File

@@ -5,6 +5,8 @@ import {
uploadMemberImage,
getMemberImage,
updateRatingsFromMyTischtennis,
getMemberTtrHistory,
refreshMemberTtrHistory,
rotateMemberImage,
transferMembers,
quickUpdateTestMembership,
@@ -35,6 +37,8 @@ router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), gener
router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers);
router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals);
router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis);
router.get('/ttr-history/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberTtrHistory);
router.post('/ttr-history/:clubId/:memberId/refresh', authenticate, authorize('members', 'read'), refreshMemberTtrHistory);
router.post('/rotate-image/:clubId/:memberId/:imageId', authenticate, authorize('members', 'write'), rotateMemberImage);
router.post('/transfer/:id', authenticate, authorize('members', 'write'), transferMembers);
router.post('/quick-update-test-membership/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateTestMembership);

View File

@@ -188,18 +188,6 @@ router.put('/submit/:uuid', async (req, res) => {
const { uuid } = req.params;
const reportData = req.body;
console.log('[nuscore submit] request', {
uuid,
wo: reportData?.wo ?? null,
isCompleted: reportData?.isCompleted ?? null,
homePin: reportData?.homePin ?? null,
guestPin: reportData?.guestPin ?? null,
releaseSignatureHome: reportData?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: reportData?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: reportData?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: reportData?.signature?.lineupSignatureGuest ?? null
});
try {
// Hole Cookies für diese UUID (falls vorhanden)
// Versuche zuerst UUID, dann Code als Fallback
@@ -241,17 +229,6 @@ router.put('/submit/:uuid', async (req, res) => {
responseData = { message: responseText };
}
console.log('[nuscore submit] response', {
uuid,
httpStatus: response.status,
resultState: responseData?.resultState ?? null,
validationErrors: responseData?.validationErrors ?? [],
releaseSignatureHome: responseData?.object?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: responseData?.object?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: responseData?.object?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: responseData?.object?.signature?.lineupSignatureGuest ?? null
});
const resultState = responseData?.resultState;
const validationErrors = Array.isArray(responseData?.validationErrors) ? responseData.validationErrors : [];
@@ -320,18 +297,6 @@ router.put('/validate/:uuid', async (req, res) => {
const { uuid } = req.params;
const reportData = req.body;
console.log('[nuscore validate] request', {
uuid,
wo: reportData?.wo ?? null,
isCompleted: reportData?.isCompleted ?? null,
homePin: reportData?.homePin ?? null,
guestPin: reportData?.guestPin ?? null,
releaseSignatureHome: reportData?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: reportData?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: reportData?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: reportData?.signature?.lineupSignatureGuest ?? null
});
try {
// Hole Cookies für diese UUID (falls vorhanden)
// Versuche zuerst UUID, dann Code als Fallback
@@ -373,17 +338,6 @@ router.put('/validate/:uuid', async (req, res) => {
responseData = { message: responseText };
}
console.log('[nuscore validate] response', {
uuid,
httpStatus: response.status,
resultState: responseData?.resultState ?? null,
validationErrors: responseData?.validationErrors ?? [],
releaseSignatureHome: responseData?.object?.signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: responseData?.object?.signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: responseData?.object?.signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: responseData?.object?.signature?.lineupSignatureGuest ?? null
});
// Speichere neue Cookies falls vorhanden
const newCookies = extractCookies(response.headers.raw()['set-cookie']);
if (Object.keys(newCookies).length > 0) {

View File

@@ -13,7 +13,7 @@ import {
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -327,6 +327,7 @@ app.use((err, req, res, next) => {
await safeSync(ApiLog);
await safeSync(MemberTransferConfig);
await safeSync(MemberContact);
await safeSync(MemberTtrHistory);
await safeSync(ClubTeam);
await safeSync(TeamDocument);

View File

@@ -65,15 +65,14 @@ class MatchService {
} else {
seasonStartYear = currentYear - 1;
}
const seasonEndYear = seasonStartYear + 1;
const seasonEndYear = seasonStartYear + 1;
return `${seasonStartYear}/${seasonEndYear}`;
}
async importCSV(userToken, clubId, filePath) {
await checkAccess(userToken, clubId);
async importCSV(userToken, clubId, filePath) {
await checkAccess(userToken, clubId);
let seasonString = '';
const matches = [];
try {
try {
const fileStream = fs.createReadStream(filePath)
.pipe(iconv.decodeStream('utf8'))
.pipe(csv({ separator: ';' }));

View File

@@ -3,6 +3,7 @@ import Club from "../models/Club.js";
import { checkAccess, getUserByToken, hasUserClubAccess } from "../utils/userUtils.js";
import Member from "../models/Member.js";
import MemberImage from "../models/MemberImage.js";
import MemberTtrHistory from "../models/MemberTtrHistory.js";
import Participant from "../models/Participant.js";
import DiaryDate from "../models/DiaryDates.js";
import path from 'path';
@@ -419,7 +420,17 @@ class MemberService {
};
}
// 2. Ranglisten vom Verein abrufen (Logging hinzufügen)
// 2. Mitglieder laden und prüfen, ob wir zusätzlich die TTR-History-ID auflösen müssen
const members = await Member.findAll({ where: { clubId } });
const shouldResolveHistoryPlayerIds = members.some((member) =>
!member.myTischtennisHistoryPlayerId && (
member.myTischtennisPlayerId ||
member.ttr != null ||
member.qttr != null
)
);
// 3. Ranglisten vom Verein abrufen
// TTR (aktuell)
try {
await (await import('./apiLogService.js')).default.logRequest({
@@ -440,7 +451,8 @@ class MemberService {
session.cookie,
effectiveClubId,
effectiveFedNickname,
'yes'
'yes',
{ includeHistoryPlayerIds: shouldResolveHistoryPlayerIds }
);
try {
await (await import('./apiLogService.js')).default.logRequest({
@@ -477,7 +489,8 @@ class MemberService {
session.cookie,
effectiveClubId,
effectiveFedNickname,
'no'
'no',
{ includeHistoryPlayerIds: shouldResolveHistoryPlayerIds }
);
let qttrWarning = null;
try {
@@ -515,9 +528,6 @@ class MemberService {
qttrWarning = rankingsQuarter.error || 'QTTR Abruf fehlgeschlagen';
}
// 3. Alle Mitglieder des Clubs laden
const members = await Member.findAll({ where: { clubId } });
let updated = 0;
const errors = [];
if (qttrWarning) {
@@ -561,6 +571,7 @@ class MemberService {
try {
const oldTtr = member.ttr;
const oldQttr = member.qttr;
const historyPlayerId = this._extractTtrHistoryPlayerId(rankingEntry) || this._extractTtrHistoryPlayerId(rankingQuarterEntry);
if (rankingEntry && typeof rankingEntry.fedRank === 'number') {
member.ttr = rankingEntry.fedRank;
if (member.ttr !== oldTtr) updatedTtr++;
@@ -576,6 +587,9 @@ class MemberService {
if (member.qttr !== oldQttr) updatedQttr++;
}
}
if (historyPlayerId && member.myTischtennisHistoryPlayerId !== historyPlayerId) {
member.myTischtennisHistoryPlayerId = historyPlayerId;
}
await member.save();
updated++;
matched.push({
@@ -583,7 +597,8 @@ class MemberService {
oldTtr: oldTtr,
newTtr: member.ttr,
oldQttr: oldQttr,
newQttr: member.qttr
newQttr: member.qttr,
historyPlayerId: member.myTischtennisHistoryPlayerId || null
});
} catch (error) {
console.error(`[updateRatingsFromMyTischtennis] - Error updating ${firstName} ${lastName}:`, error);
@@ -654,6 +669,444 @@ class MemberService {
return this._updateRatingsInternal(userId, clubId);
}
async getMemberTtrHistory(userToken, clubId, memberId) {
await checkAccess(userToken, clubId);
const member = await Member.findOne({
where: { id: memberId, clubId }
});
if (!member) {
return {
status: 404,
response: { success: false, error: 'Mitglied nicht gefunden.' }
};
}
const history = await MemberTtrHistory.findAll({
where: { memberId: member.id, clubId },
order: [['sourceDate', 'DESC'], ['createdAt', 'DESC']]
});
const entries = history.map(entry => ({
id: entry.id,
sourceDate: entry.sourceDate,
ttr: entry.ttr,
qttr: entry.qttr,
label: entry.label,
sourceType: entry.sourceType,
fetchedAt: entry.fetchedAt
}));
const latestFetchedAt = history.reduce((latest, entry) => {
if (!entry.fetchedAt) {
return latest;
}
const fetchedAt = new Date(entry.fetchedAt);
if (!latest || fetchedAt > latest) {
return fetchedAt;
}
return latest;
}, null);
const resolvedHistoryPlayerId = member.myTischtennisHistoryPlayerId || history.find(entry => entry.playerId)?.playerId || null;
return {
status: 200,
response: {
success: true,
member: {
id: member.id,
firstName: member.firstName,
lastName: member.lastName,
ttr: member.ttr,
qttr: member.qttr,
myTischtennisPlayerId: member.myTischtennisPlayerId || null,
myTischtennisHistoryPlayerId: resolvedHistoryPlayerId
},
history: entries,
meta: {
count: entries.length,
lastFetchedAt: latestFetchedAt ? latestFetchedAt.toISOString() : null,
refreshPolicy: this._buildTtrHistoryRefreshPolicy(latestFetchedAt)
}
}
};
}
async refreshMemberTtrHistory(userToken, clubId, memberId) {
await checkAccess(userToken, clubId);
const user = await getUserByToken(userToken);
const member = await Member.findOne({
where: { id: memberId, clubId }
});
if (!member) {
return {
status: 404,
response: { success: false, error: 'Mitglied nicht gefunden.' }
};
}
const latestHistoryEntry = await MemberTtrHistory.findOne({
where: { memberId: member.id, clubId },
order: [['fetchedAt', 'DESC'], ['createdAt', 'DESC']]
});
const historyPlayerId = member.myTischtennisHistoryPlayerId || latestHistoryEntry?.playerId || null;
const refreshPolicy = this._buildTtrHistoryRefreshPolicy(latestHistoryEntry?.fetchedAt || null);
if (!refreshPolicy.canRefresh) {
return {
status: 200,
response: {
success: true,
skipped: true,
message: refreshPolicy.message,
member: {
id: member.id,
myTischtennisPlayerId: member.myTischtennisPlayerId || null,
myTischtennisHistoryPlayerId: historyPlayerId
},
meta: {
lastFetchedAt: latestHistoryEntry?.fetchedAt ? new Date(latestHistoryEntry.fetchedAt).toISOString() : null,
refreshPolicy
}
}
};
}
if (!historyPlayerId) {
return {
status: 409,
response: {
success: false,
error: 'Für dieses Mitglied ist noch keine myTischtennis-TTR-History-ID vorhanden. Bitte zuerst die myTischtennis-Ranglisten aktualisieren.'
}
};
}
const myTischtennisService = (await import('./myTischtennisService.js')).default;
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
let session;
try {
session = await myTischtennisService.getSession(user.id);
} catch (sessionError) {
try {
await myTischtennisService.verifyLogin(user.id);
session = await myTischtennisService.getSession(user.id);
} catch (loginError) {
return {
status: 401,
response: {
success: false,
needsReauth: true,
error: 'myTischtennis-Session abgelaufen. Bitte einmal neu einloggen.'
}
};
}
}
const rootEndpoint = `/rankings/ttr-historie?player-id=${encodeURIComponent(historyPlayerId)}&_data=root`;
const historyEndpoint = `/rankings/ttr-historie?player-id=${encodeURIComponent(historyPlayerId)}&show=everything&_data=routes%2F%24`;
const [rootResult, historyResult] = await Promise.all([
myTischtennisClient.authenticatedRequest(rootEndpoint, session.cookie, { method: 'GET' }),
myTischtennisClient.authenticatedRequest(historyEndpoint, session.cookie, { method: 'GET' })
]);
if (!rootResult.success) {
return {
status: 502,
response: {
success: false,
error: rootResult.error || 'myTischtennis-Root-Endpunkt konnte nicht geladen werden.'
}
};
}
if (!historyResult.success) {
return {
status: 502,
response: {
success: false,
error: historyResult.error || 'myTischtennis-TTR-Historie konnte nicht geladen werden.'
}
};
}
const parsedHistory = this._parseMyTischtennisHistoryResponse(historyResult.data);
if (!parsedHistory.success) {
return {
status: 502,
response: {
success: false,
error: parsedHistory.error || 'Die myTischtennis-TTR-Historie konnte nicht verarbeitet werden.'
}
};
}
const fetchedAt = new Date();
const historyEntries = this._mapTtrHistoryEventsToRows({
clubId,
memberId: member.id,
playerId: historyPlayerId,
fetchedAt,
historyData: parsedHistory.historyData
});
await MemberTtrHistory.destroy({
where: { memberId: member.id, clubId }
});
if (historyEntries.length > 0) {
await MemberTtrHistory.bulkCreate(historyEntries);
}
if (member.myTischtennisHistoryPlayerId !== historyPlayerId) {
member.myTischtennisHistoryPlayerId = historyPlayerId;
await member.save();
}
return {
status: 200,
response: {
success: true,
message: `${historyEntries.length} TTR-Historien-Einträge aktualisiert.`,
member: {
id: member.id,
myTischtennisPlayerId: member.myTischtennisPlayerId || null,
myTischtennisHistoryPlayerId: historyPlayerId
},
meta: {
count: historyEntries.length,
lastFetchedAt: fetchedAt.toISOString(),
refreshPolicy: this._buildTtrHistoryRefreshPolicy(fetchedAt)
}
}
};
}
_extractTtrHistoryPlayerId(entry) {
if (!entry || typeof entry !== 'object') {
return null;
}
const directCandidates = [
entry.myTischtennisHistoryPlayerId,
entry.historyPlayerId,
entry.history_player_id,
entry.ttrHistoryPlayerId,
entry.ttr_history_player_id
];
for (const candidate of directCandidates) {
if (typeof candidate === 'string' && /^P[A-Z0-9]+$/i.test(candidate.trim())) {
return candidate.trim();
}
}
const serialized = JSON.stringify(entry);
const urlMatch = serialized.match(/player-id=([P][A-Z0-9]+)/i);
if (urlMatch?.[1]) {
return urlMatch[1];
}
const fieldMatch = serialized.match(/"(?:historyPlayerId|history_player_id|ttrHistoryPlayerId|ttr_history_player_id|playerId|player_id)"\s*:\s*"(P[A-Z0-9]+)"/i);
return fieldMatch?.[1] || null;
}
_getBerlinDateParts(value = new Date()) {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
const parts = formatter.formatToParts(value).reduce((acc, part) => {
if (part.type !== 'literal') {
acc[part.type] = part.value;
}
return acc;
}, {});
return {
year: parts.year,
month: parts.month,
day: parts.day,
hour: Number(parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
dateKey: `${parts.year}-${parts.month}-${parts.day}`
};
}
_buildTtrHistoryRefreshPolicy(lastFetchedAt) {
const now = new Date();
const nowParts = this._getBerlinDateParts(now);
const hour = nowParts.hour;
if (hour < 7) {
return {
canRefresh: false,
reason: 'before_7am',
message: 'Die TTR-Historie wird nur einmal täglich und erst nach 07:00 Uhr aktualisiert.',
nextRefreshDate: nowParts.dateKey
};
}
if (!lastFetchedAt) {
return {
canRefresh: true,
reason: 'no_cache',
message: 'Die TTR-Historie kann jetzt aktualisiert werden.',
nextRefreshDate: nowParts.dateKey
};
}
const fetchedParts = this._getBerlinDateParts(new Date(lastFetchedAt));
if (fetchedParts.dateKey === nowParts.dateKey) {
return {
canRefresh: false,
reason: 'already_refreshed_today',
message: 'Die TTR-Historie wurde heute bereits aktualisiert.',
nextRefreshDate: nowParts.dateKey
};
}
return {
canRefresh: true,
reason: 'stale_cache',
message: 'Die TTR-Historie kann jetzt aktualisiert werden.',
nextRefreshDate: nowParts.dateKey
};
}
_parseMyTischtennisHistoryResponse(rawPayload) {
try {
const parsed = this._splitDeferredJsonPayload(rawPayload);
const deferredData = Object.values(parsed.deferred || {}).find((value) => value && Array.isArray(value.event));
if (!deferredData) {
return {
success: false,
error: 'Keine TTR-Historien-Daten in der myTischtennis-Antwort gefunden.'
};
}
return {
success: true,
root: parsed.root,
historyData: deferredData
};
} catch (error) {
return {
success: false,
error: error.message || 'TTR-Historien-Antwort konnte nicht geparst werden.'
};
}
}
_splitDeferredJsonPayload(rawPayload) {
if (rawPayload && typeof rawPayload === 'object' && !Array.isArray(rawPayload)) {
return { root: rawPayload, deferred: {} };
}
const text = typeof rawPayload === 'string' ? rawPayload : String(rawPayload || '');
const lines = text.split(/\r?\n/);
let root = null;
const deferred = {};
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
continue;
}
if (line.startsWith('data:')) {
const payload = line.slice(5).trim();
if (!payload) {
continue;
}
const parsed = JSON.parse(payload);
Object.assign(deferred, parsed);
continue;
}
if (!root && line.startsWith('{')) {
root = JSON.parse(line);
}
}
return { root, deferred };
}
_mapTtrHistoryEventsToRows({ clubId, memberId, playerId, fetchedAt, historyData }) {
const events = Array.isArray(historyData?.event) ? historyData.event : [];
const rows = [];
const seenKeys = new Set();
for (const event of events) {
const sourceDate = this._normalizeTtrHistoryDate(event?.event_date_time || event?.formattedEventDate);
if (!sourceDate) {
continue;
}
const key = `${event?.event_id || 'event'}:${sourceDate}:${event?.ttr_after ?? ''}`;
if (seenKeys.has(key)) {
continue;
}
seenKeys.add(key);
rows.push({
memberId,
clubId,
playerId,
sourceDate,
ttr: Number.isFinite(Number(event?.ttr_after)) ? Number(event.ttr_after) : null,
qttr: null,
label: event?.event_name || null,
sourceType: event?.type || null,
fetchedAt,
rawPayload: event
});
}
return rows.sort((a, b) => String(b.sourceDate).localeCompare(String(a.sourceDate)));
}
_normalizeTtrHistoryDate(value) {
if (!value) {
return null;
}
if (typeof value === 'string') {
const isoMatch = value.match(/^(\d{4}-\d{2}-\d{2})/);
if (isoMatch?.[1]) {
return isoMatch[1];
}
const germanMatch = value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (germanMatch) {
return `${germanMatch[3]}-${germanMatch[2]}-${germanMatch[1]}`;
}
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString().slice(0, 10);
}
async rotateMemberImage(userToken, clubId, memberId, imageId, direction) {
try {
await checkAccess(userToken, clubId);
@@ -1335,4 +1788,4 @@ class MemberService {
}
}
export default new MemberService();
export default new MemberService();

View File

@@ -78,6 +78,16 @@ export default {
default: 'medium',
validator: (value) => ['small', 'medium', 'large', 'fullscreen'].includes(value)
},
width: {
type: [String, Number],
default: null
},
maxWidth: {
type: [String, Number],
default: null
},
// Position für nicht-modale Dialoge
position: {
@@ -136,6 +146,14 @@ export default {
const style = {
zIndex: this.zIndex
};
if (this.width !== null) {
style.width = typeof this.width === 'number' ? `${this.width}px` : this.width;
}
if (this.maxWidth !== null) {
style.maxWidth = typeof this.maxWidth === 'number' ? `${this.maxWidth}px` : this.maxWidth;
}
if (!this.isModal) {
style.left = `${this.localPosition.x}px`;
@@ -311,6 +329,7 @@ export default {
font-size: 1rem;
font-weight: 600;
flex: 1;
color: var(--text-on-primary);
}
/* Controls */

View File

@@ -731,6 +731,8 @@ export default {
home: null,
guest: null
},
initialCompletionState: false,
submitSucceeded: false,
isHomeLineupCertified: false,
isGuestLineupCertified: false,
isGreetingCompleted: false,
@@ -885,13 +887,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Prüfe ob das Match bereits abgeschlossen ist
isMatchCompleted() {
if (typeof this.meetingDetails?.isCompleted === 'boolean') {
return this.meetingDetails.isCompleted;
}
if (typeof this.meetingData?.isCompleted === 'boolean') {
return this.meetingData.isCompleted;
}
return this.match && this.match.isCompleted === true;
return this.submitSucceeded || this.initialCompletionState === true;
},
isSubmitDisabled() {
if (this.isMatchCompleted) {
@@ -975,18 +971,38 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
},
methods: {
logSignatureState(label, payload) {
const signature = payload?.signature || null;
console.log(`[match-report] ${label}`, {
wo: payload?.wo ?? null,
isCompleted: payload?.isCompleted ?? null,
homePin: payload?.homePin ?? null,
guestPin: payload?.guestPin ?? null,
releaseSignatureHome: signature?.releaseSignatureHome ?? null,
releaseSignatureGuest: signature?.releaseSignatureGuest ?? null,
lineupSignatureHome: signature?.lineupSignatureHome ?? null,
lineupSignatureGuest: signature?.lineupSignatureGuest ?? null
});
getFriendlySubmitErrorMessage(message) {
const normalized = (message || '').toString();
if (normalized.includes('Meeting wurde bereits freigegeben')) {
return 'Der Spielbericht ist in nuScore bereits freigegeben.\n\nDie dort gespeicherten Daten sind offenbar nicht vollständig und können aus dieser Ansicht nicht mehr zuverlässig überschrieben werden.\n\nBitte den Spielbericht in nuScore bzw. durch eine Person mit den nötigen Rechten prüfen und dort korrigieren lassen.';
}
return normalized || 'Fehler beim Absenden des Spielberichts.';
},
hasReleaseSignatureForCompletion(source) {
if (!source) {
return false;
}
const signature = source.signature || {};
const fallbackWo = this.teamNotAppeared === 'home'
? 'A'
: (this.teamNotAppeared === 'guest' ? 'B' : null);
const wo = source.wo ?? fallbackWo;
const hasHomeRelease = typeof signature.releaseSignatureHome === 'string' && signature.releaseSignatureHome.trim() !== '';
const hasGuestRelease = typeof signature.releaseSignatureGuest === 'string' && signature.releaseSignatureGuest.trim() !== '';
if (wo === 'A') {
return hasGuestRelease;
}
if (wo === 'B') {
return hasHomeRelease;
}
return hasHomeRelease && hasGuestRelease;
},
// Effektive Spieleranzahl (berücksichtigt Braunschweiger-Regel)
@@ -1580,10 +1596,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Aktualisiere die Match-Daten mit unseren Eingaben
console.log('🔄 Aktualisiere Match-Daten...');
this.updateMatchData(matchData);
this.updateMatchData(matchData, { finalizeReport: true });
console.log('✅ Match-Daten aktualisiert');
this.logSignatureState('payload before validate', matchData);
// Für WebSocket-Broadcast: clubId und gameCode mitsenden
const clubId = this.$store?.getters?.currentClub;
if (clubId) matchData.clubId = String(clubId);
@@ -1597,10 +1611,13 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
throw new Error('nuLigaMeetingUuid nicht gefunden');
}
const validationResult = await this.validateReport(matchData);
if (validationResult?.resultState === 'VALIDATION_ERROR') {
const validationMessage = Array.isArray(validationResult.validationErrors) && validationResult.validationErrors.length > 0
? validationResult.validationErrors.join('\n')
const validationPayload = JSON.parse(JSON.stringify(matchData));
validationPayload.isCompleted = false;
const validationResult = await this.validateReport(validationPayload);
const validationErrors = Array.isArray(validationResult?.validationErrors) ? validationResult.validationErrors : [];
if (validationResult?.resultState === 'VALIDATION_ERROR' || validationErrors.length > 0) {
const validationMessage = validationErrors.length > 0
? validationErrors.join('\n')
: 'Validierung fehlgeschlagen';
throw new Error(validationMessage);
}
@@ -1608,8 +1625,16 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
const submitPayload = validationResult?.object
? JSON.parse(JSON.stringify(validationResult.object))
: matchData;
this.logSignatureState('payload before submit', submitPayload);
submitPayload.isCompleted = true;
if (!submitPayload.signature || typeof submitPayload.signature !== 'object') {
submitPayload.signature = {};
}
if (!isHomeNotAppeared && this.finalHomePin && this.finalHomePin.trim() !== '') {
submitPayload.homePin = this.finalHomePin.trim();
}
if (!isGuestNotAppeared && this.finalGuestPin && this.finalGuestPin.trim() !== '') {
submitPayload.guestPin = this.finalGuestPin.trim();
}
const response = await fetch(`${backendBaseUrl}/api/nuscore/submit/${uuid}`, {
method: 'PUT',
headers: {
@@ -1619,7 +1644,6 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
});
const result = await response.json();
console.log('[match-report] submit response', result);
if (!response.ok) {
const validationMessage = Array.isArray(result?.details?.validationErrors) && result.details.validationErrors.length > 0
@@ -1629,6 +1653,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
console.log('✅ Spielbericht erfolgreich abgesendet:', result);
this.submitSucceeded = true;
alert('✅ Spielbericht erfolgreich abgesendet!');
// Dialog schließen
@@ -1637,7 +1662,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
} catch (error) {
console.error('❌ Fehler beim Absenden:', error);
console.error('❌ Fehler-Stack:', error.stack);
alert(`Fehler beim Absenden des Spielberichts: ${error.message}`);
alert(this.getFriendlySubmitErrorMessage(error.message));
}
},
@@ -1653,7 +1678,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
: JSON.parse(JSON.stringify(this.match));
if (!matchDataOverride) {
this.updateMatchData(matchData);
this.updateMatchData(matchData, { finalizeReport: false });
}
const uuid = this.meetingData.nuLigaMeetingUuid;
@@ -1669,7 +1694,6 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
});
const result = await response.json();
console.log('[match-report] validate response', result);
if (result?.object) {
this.meetingDetails = JSON.parse(JSON.stringify(result.object));
@@ -1705,9 +1729,10 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
},
updateMatchData(matchData) {
updateMatchData(matchData, options = {}) {
try {
console.log('🔄 updateMatchData: Verwende kompletten Original-Meeting-Daten...');
const { finalizeReport = false } = options;
// Verwende ausschließlich die Meeting-Details vom /meetingdetails/:uuid Endpoint
if (!this.meetingDetails) {
@@ -1748,6 +1773,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
matchData.guestPin = this.finalGuestPin.trim();
}
// Zwischenstände dürfen nie als bereits freigegeben an nuscore zurückgeschickt werden.
matchData.isCompleted = finalizeReport;
// Wenn eine Mannschaft nicht angetreten ist, nur wo-Flag und PIN der angetretenen Mannschaft setzen
if (this.teamNotAppeared !== null) {
console.log('⚠️ Mannschaft nicht angetreten - nur wo-Flag und PIN der angetretenen Mannschaft werden gesetzt');
@@ -1782,8 +1810,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// 5. PINs - Original-Hashes beibehalten (werden vom Backend beim Senden aktualisiert)
// matchData.homePin und matchData.guestPin bleiben die ursprünglichen Hashes aus baseData
// 6. Match-Status auf abgeschlossen setzen
matchData.isCompleted = true;
// 6. Match-Status nur beim finalen Submit auf abgeschlossen setzen
matchData.isCompleted = finalizeReport;
// 7. Gesamtstatistik berechnen und eintragen
const overallScore = this.getOverallMatchScore();
@@ -2061,6 +2089,11 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
}
this.initialCompletionState = Boolean(
((typeof this.meetingDetails?.isCompleted === 'boolean' && this.meetingDetails.isCompleted) && this.hasReleaseSignatureForCompletion(this.meetingDetails)) ||
((typeof this.meetingData?.isCompleted === 'boolean' && this.meetingData.isCompleted) && this.hasReleaseSignatureForCompletion(this.meetingData))
);
// PINs automatisch laden
this.loadPinsAutomatically();

View File

@@ -0,0 +1,669 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="TTR-Historie"
size="large"
:width="1170"
max-width="95vw"
@close="closeDialog"
>
<div class="ttr-history-content">
<div class="dialog-intro">
<div>
<div class="member-name">{{ memberName }}</div>
</div>
<div class="meta-block">
<span class="meta-label">Letzter Abruf</span>
<span class="meta-value">{{ formatDateTime(lastFetchedAt) }}</span>
</div>
</div>
<div class="filter-row">
<label class="filter-label" for="ttr-history-range">Zeitraum</label>
<select id="ttr-history-range" v-model="selectedRange" class="filter-select">
<option v-for="option in rangeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div v-if="loading" class="state-panel">Historie wird geladen...</div>
<div v-else-if="errorMessage && entries.length === 0" class="state-panel state-panel-error">{{ errorMessage }}</div>
<div v-else class="content-grid">
<div v-if="refreshing" class="state-panel state-panel-info">
Historie wird im Hintergrund aktualisiert...
</div>
<div v-if="errorMessage" class="state-panel state-panel-error state-panel-full">{{ errorMessage }}</div>
<div v-if="infoMessage" class="state-panel state-panel-info">{{ infoMessage }}</div>
<div class="chart-panel">
<div class="panel-title">Verlauf</div>
<div v-if="chartPoints.length < 2" class="state-panel state-panel-muted">
Noch nicht genug Datenpunkte für einen Verlauf.
</div>
<svg
v-else
class="history-chart"
:viewBox="`0 0 ${chartMeta.width} ${chartMeta.height}`"
preserveAspectRatio="none"
aria-label="TTR-Verlauf"
>
<line
class="chart-axis"
:x1="chartMeta.paddingLeft"
:y1="chartMeta.height - chartMeta.paddingBottom"
:x2="chartMeta.width - chartMeta.paddingRight"
:y2="chartMeta.height - chartMeta.paddingBottom"
/>
<line
class="chart-axis"
:x1="chartMeta.paddingLeft"
:y1="chartMeta.paddingTop"
:x2="chartMeta.paddingLeft"
:y2="chartMeta.height - chartMeta.paddingBottom"
/>
<g v-for="tick in chartYTicks" :key="`y-${tick.value}`">
<line
class="chart-grid-line"
:x1="chartMeta.paddingLeft"
:y1="tick.y"
:x2="chartMeta.width - chartMeta.paddingRight"
:y2="tick.y"
/>
<text class="chart-y-label" :x="chartMeta.paddingLeft - 10" :y="tick.y + 4">
{{ tick.label }}
</text>
</g>
<polyline class="chart-line" :points="chartPolyline" />
<circle
v-for="point in chartPoints"
:key="point.id"
class="chart-point"
:cx="point.x"
:cy="point.y"
r="4"
/>
<g v-for="tick in chartXTicks" :key="`x-${tick.id}`">
<line
class="chart-tick"
:x1="tick.x"
:y1="chartMeta.height - chartMeta.paddingBottom"
:x2="tick.x"
:y2="chartMeta.height - chartMeta.paddingBottom + 6"
/>
<text
class="chart-x-label"
:transform="`translate(${tick.x + 6}, ${chartMeta.height - chartMeta.paddingBottom + 12}) rotate(90)`"
>
{{ tick.label }}
</text>
</g>
</svg>
</div>
<div class="table-panel">
<div class="panel-title">Historie</div>
<div v-if="tableRows.length === 0" class="state-panel state-panel-muted">
Noch keine gespeicherte TTR-Historie vorhanden.
</div>
<table v-else class="history-table">
<thead>
<tr>
<th>Datum</th>
<th>TTR</th>
<th>Veränderung</th>
<th>Quelle</th>
</tr>
</thead>
<tbody>
<tr v-for="row in tableRows" :key="row.id">
<td>{{ formatDate(row.sourceDate) }}</td>
<td class="ttr-transition-cell">{{ formatTtrTransition(row.beforeTtr, row.afterTtr) }}</td>
<td :class="changeClass(row.change)">
{{ formatChange(row.change) }}
</td>
<td>{{ row.label || row.sourceType || '' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<template #footer>
<button type="button" class="btn-secondary" @click="closeDialog">Schließen</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
import apiClient from '../apiClient.js';
import { getSafeErrorMessage } from '../utils/errorMessages.js';
export default {
name: 'MemberTtrHistoryDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
member: {
type: Object,
required: true
},
clubId: {
type: [String, Number],
required: true
}
},
emits: ['update:modelValue'],
data() {
return {
loading: false,
refreshing: false,
errorMessage: '',
infoMessage: '',
entries: [],
lastFetchedAt: null,
selectedRange: 'all',
playerId: this.member?.myTischtennisHistoryPlayerId || this.member?.myTischtennisPlayerId || null
};
},
computed: {
memberName() {
return `${this.member?.firstName || ''} ${this.member?.lastName || ''}`.trim() || 'Mitglied';
},
chartMeta() {
return {
width: 760,
height: 320,
paddingTop: 24,
paddingRight: 24,
paddingBottom: 88,
paddingLeft: 62
};
},
rangeOptions() {
return [
{ value: 'all', label: 'Alle' },
{ value: '36m', label: '3 Jahre' },
{ value: '24m', label: '2 Jahre' },
{ value: '12m', label: '1 Jahr' },
{ value: '9m', label: '9 Monate' },
{ value: '6m', label: '6 Monate' },
{ value: '3m', label: '3 Monate' },
{ value: '2m', label: '2 Monate' },
{ value: '1m', label: '1 Monat' }
];
},
filteredEntries() {
if (this.selectedRange === 'all') {
return this.entries;
}
const monthCount = Number.parseInt(this.selectedRange.replace('m', ''), 10);
if (!Number.isFinite(monthCount)) {
return this.entries;
}
const cutoff = new Date();
cutoff.setHours(0, 0, 0, 0);
cutoff.setMonth(cutoff.getMonth() - monthCount);
return this.entries.filter((entry) => {
if (!entry?.sourceDate) {
return false;
}
const date = new Date(entry.sourceDate);
return !Number.isNaN(date.getTime()) && date >= cutoff;
});
},
tableRows() {
return this.filteredEntries.map((entry, index) => {
const previousEntry = this.filteredEntries[index + 1] || null;
const beforeTtr = Number.isFinite(previousEntry?.ttr) ? previousEntry.ttr : null;
const afterTtr = Number.isFinite(entry?.ttr) ? entry.ttr : null;
const change = Number.isFinite(beforeTtr) && Number.isFinite(afterTtr)
? afterTtr - beforeTtr
: null;
return {
...entry,
beforeTtr,
afterTtr,
change
};
});
},
chartPoints() {
if (this.filteredEntries.length < 2) {
return [];
}
const chronological = [...this.filteredEntries]
.reverse()
.map((entry) => ({
...entry,
value: entry.ttr
}))
.filter((entry) => Number.isFinite(entry.value));
if (chronological.length < 2) {
return [];
}
const { width, height, paddingTop, paddingRight, paddingBottom, paddingLeft } = this.chartMeta;
const values = chronological.map((entry) => entry.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const valueRange = Math.max(maxValue - minValue, 1);
return chronological.map((entry, index) => {
const ratioX = chronological.length === 1 ? 0.5 : index / (chronological.length - 1);
const ratioY = (entry.value - minValue) / valueRange;
return {
id: entry.id,
label: this.formatDate(entry.sourceDate),
value: entry.value,
x: paddingLeft + ratioX * (width - paddingLeft - paddingRight),
y: height - paddingBottom - ratioY * (height - paddingTop - paddingBottom)
};
});
},
chartPolyline() {
return this.chartPoints.map((point) => `${point.x},${point.y}`).join(' ');
},
chartXTicks() {
if (this.chartPoints.length === 0) {
return [];
}
if (this.chartPoints.length <= 12) {
return this.chartPoints;
}
const usableWidth = this.chartMeta.width - this.chartMeta.paddingLeft - this.chartMeta.paddingRight;
const estimatedLabelSpace = 70;
const maxTicks = Math.min(
this.chartPoints.length,
Math.max(2, Math.floor(usableWidth / estimatedLabelSpace))
);
if (this.chartPoints.length <= maxTicks) {
return this.chartPoints;
}
const step = Math.ceil((this.chartPoints.length - 1) / (maxTicks - 1));
const ticks = this.chartPoints.filter((_, index) => index % step === 0);
const lastPoint = this.chartPoints[this.chartPoints.length - 1];
if (!ticks.some((tick) => tick.id === lastPoint.id)) {
ticks.push(lastPoint);
}
return ticks;
},
chartYTicks() {
if (this.chartPoints.length === 0) {
return [];
}
const values = this.chartPoints.map((point) => point.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const steps = 4;
const range = Math.max(maxValue - minValue, 1);
const { height, paddingTop, paddingBottom } = this.chartMeta;
const usableHeight = height - paddingTop - paddingBottom;
return Array.from({ length: steps + 1 }, (_, index) => {
const value = minValue + (range * index) / steps;
const ratioY = (value - minValue) / range;
return {
value: `${index}-${Math.round(value)}`,
label: Math.round(value),
y: height - paddingBottom - ratioY * usableHeight
};
}).reverse();
}
},
mounted() {
if (this.modelValue) {
this.openDialog();
}
},
watch: {
modelValue(newValue) {
if (newValue) {
this.openDialog();
}
},
member: {
deep: true,
handler(newMember) {
this.playerId = newMember?.myTischtennisHistoryPlayerId || newMember?.myTischtennisPlayerId || null;
this.selectedRange = 'all';
}
}
},
methods: {
closeDialog() {
this.$emit('update:modelValue', false);
},
async openDialog() {
await this.loadHistory();
await this.refreshHistory({ silentStart: false });
},
async loadHistory(options = {}) {
const { preserveMessages = false } = options;
this.loading = true;
if (!preserveMessages) {
this.errorMessage = '';
this.infoMessage = '';
}
try {
const response = await apiClient.get(`/clubmembers/ttr-history/${this.clubId}/${this.member.id}`);
this.entries = Array.isArray(response.data?.history) ? response.data.history : [];
this.lastFetchedAt = response.data?.meta?.lastFetchedAt || null;
this.playerId = response.data?.member?.myTischtennisHistoryPlayerId || response.data?.member?.myTischtennisPlayerId || this.playerId;
} catch (error) {
this.errorMessage = getSafeErrorMessage(error, 'TTR-Historie konnte nicht geladen werden.');
} finally {
this.loading = false;
}
},
async refreshHistory(options = {}) {
const { silentStart = false } = options;
this.refreshing = true;
if (!silentStart) {
this.errorMessage = '';
this.infoMessage = '';
}
try {
const response = await apiClient.post(`/clubmembers/ttr-history/${this.clubId}/${this.member.id}/refresh`);
if (response.data?.error) {
this.errorMessage = response.data.error;
return;
}
if (response.data?.skipped && response.data?.message) {
this.infoMessage = response.data.message;
this.lastFetchedAt = response.data?.meta?.lastFetchedAt || this.lastFetchedAt;
return;
}
await this.loadHistory({ preserveMessages: true });
if (response.data?.message) {
this.infoMessage = response.data.message;
}
} catch (error) {
this.errorMessage = getSafeErrorMessage(error, 'TTR-Historie konnte nicht aktualisiert werden.');
} finally {
this.refreshing = false;
}
},
formatDate(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString('de-DE');
},
formatDateTime(value) {
if (!value) {
return 'Noch nie';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('de-DE');
},
formatTtrTransition(before, after) {
if (!Number.isFinite(after)) {
return '';
}
if (!Number.isFinite(before)) {
return ` -> ${after}`;
}
return `${before} -> ${after}`;
},
formatChange(value) {
if (!Number.isFinite(value) || value === 0) {
return '±0';
}
return value > 0 ? `+${value}` : `${value}`;
},
changeClass(value) {
if (!Number.isFinite(value) || value === 0) {
return 'change-neutral';
}
return value > 0 ? 'change-positive' : 'change-negative';
}
}
};
</script>
<style scoped>
.ttr-history-content {
padding: 1rem;
}
.dialog-intro {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.member-name {
font-size: 1.1rem;
font-weight: 600;
color: #203040;
}
.meta-block {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 180px;
}
.meta-label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #7b8794;
}
.meta-value {
color: #203040;
font-weight: 600;
}
.filter-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.filter-label {
font-weight: 600;
color: #2c3e50;
}
.filter-select {
min-width: 150px;
padding: 0.55rem 0.85rem;
border: 1px solid #d6dde6;
border-radius: 8px;
background: #fff;
color: #203040;
font-size: 0.95rem;
}
.content-grid {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 18px;
}
.chart-panel,
.table-panel {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 16px;
}
.panel-title {
font-weight: 700;
color: #1f2d3d;
margin-bottom: 12px;
}
.state-panel {
padding: 18px;
border-radius: 12px;
background: #f8fafc;
color: #415164;
}
.state-panel-error {
background: #fff1f0;
color: #8b1e1e;
}
.state-panel-info {
background: #eff6ff;
color: #1d4ed8;
grid-column: 1 / -1;
}
.state-panel-full {
grid-column: 1 / -1;
}
.state-panel-muted {
background: #f4f7fb;
color: #66788a;
}
.history-chart {
width: 100%;
height: 320px;
background: linear-gradient(180deg, #ffffff 0%, #f7fafc 100%);
border-radius: 12px;
}
.chart-axis {
stroke: #94a3b8;
stroke-width: 1.5;
}
.chart-grid-line {
stroke: #e2e8f0;
stroke-width: 1;
}
.chart-line {
fill: none;
stroke: #1f6feb;
stroke-width: 3;
stroke-linejoin: round;
stroke-linecap: round;
}
.chart-point {
fill: #0d4f9a;
}
.chart-tick {
stroke: #94a3b8;
stroke-width: 1;
}
.chart-x-label,
.chart-y-label {
fill: #475569;
font-size: 11px;
}
.chart-y-label {
text-anchor: end;
}
.history-table {
width: 100%;
border-collapse: collapse;
}
.history-table th,
.history-table td {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid #e5ebf3;
}
.history-table thead th {
color: #fff;
border-bottom: 2px solid #ddd;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ttr-transition-cell {
white-space: nowrap;
}
.change-positive {
color: #1f7a1f;
font-weight: 700;
}
.change-negative {
color: #c0392b;
font-weight: 700;
}
.change-neutral {
color: #475569;
font-weight: 600;
}
.btn-secondary {
border: none;
border-radius: 10px;
cursor: pointer;
padding: 10px 16px;
font-weight: 600;
}
.btn-secondary {
background: #eef2f7;
color: #203040;
}
@media (max-width: 900px) {
.dialog-intro {
flex-direction: column;
}
.filter-row {
flex-direction: column;
align-items: flex-start;
}
.content-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -326,12 +326,20 @@
</div>
</td>
<td class="rating-cell">
<span v-if="member.ttr || member.qttr">
<span v-if="member.ttr" class="ttr-value">{{ member.ttr }}</span>
<span v-if="member.ttr && member.qttr" class="rating-separator">/</span>
<span v-if="member.qttr" class="qttr-value">{{ member.qttr }}</span>
</span>
<span v-else class="no-rating">-</span>
<button
type="button"
class="rating-button"
:disabled="!member.myTischtennisPlayerId && !member.ttr && !member.qttr"
:title="member.myTischtennisHistoryPlayerId || member.myTischtennisPlayerId || member.ttr || member.qttr ? 'TTR-Historie anzeigen' : 'Keine myTischtennis-ID hinterlegt'"
@click.stop="openTtrHistoryDialog(member)"
>
<span v-if="member.ttr || member.qttr">
<span v-if="member.ttr" class="ttr-value">{{ member.ttr }}</span>
<span v-if="member.ttr && member.qttr" class="rating-separator">/</span>
<span v-if="member.qttr" class="qttr-value">{{ member.qttr }}</span>
</span>
<span v-else class="no-rating">-</span>
</button>
</td>
<td>
<div class="member-contact-cell">
@@ -467,6 +475,14 @@
@success="handleTransferSuccess"
@error="handleTransferError"
/>
<MemberTtrHistoryDialog
v-if="showMemberTtrHistoryDialog && selectedMemberForTtrHistory"
v-model="showMemberTtrHistoryDialog"
:member="selectedMemberForTtrHistory"
:club-id="currentClub"
@close="closeTtrHistoryDialog"
/>
</div>
</template>
@@ -483,6 +499,7 @@ import BaseDialog from '../components/BaseDialog.vue';
import MemberNotesDialog from '../components/MemberNotesDialog.vue';
import MemberActivitiesDialog from '../components/MemberActivitiesDialog.vue';
import MemberTransferDialog from '../components/MemberTransferDialog.vue';
import MemberTtrHistoryDialog from '../components/MemberTtrHistoryDialog.vue';
import MembersOverviewSection from '../components/members/MembersOverviewSection.vue';
import { debounce } from '../utils/debounce.js';
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage, sanitizeText } from '../utils/dialogUtils.js';
@@ -496,6 +513,7 @@ export default {
MemberNotesDialog,
MemberActivitiesDialog,
MemberTransferDialog,
MemberTtrHistoryDialog,
MembersOverviewSection
},
computed: {
@@ -750,7 +768,9 @@ export default {
isUpdatingRatings: false,
showMemberInfo: false,
showActivitiesModal: false,
showMemberTtrHistoryDialog: false,
selectedMemberForActivities: null,
selectedMemberForTtrHistory: null,
memberTrainingGroups: [],
trainingGroups: [],
selectedGroupToAdd: '',
@@ -2072,6 +2092,17 @@ export default {
this.editMember(member);
}
},
openTtrHistoryDialog(member) {
if (!member) {
return;
}
this.selectedMemberForTtrHistory = member;
this.showMemberTtrHistoryDialog = true;
},
closeTtrHistoryDialog() {
this.showMemberTtrHistoryDialog = false;
this.selectedMemberForTtrHistory = null;
},
async updateRatingsFromMyTischtennis() {
this.isUpdatingRatings = true;
try {
@@ -2364,6 +2395,25 @@ table td {
font-size: 0.95em;
}
.rating-button {
border: none;
background: transparent;
padding: 0;
font: inherit;
cursor: pointer;
color: inherit;
}
.rating-button:disabled {
cursor: default;
}
.rating-button:not(:disabled):hover .ttr-value,
.rating-button:not(:disabled):hover .qttr-value,
.rating-button:not(:disabled):hover .no-rating {
text-decoration: underline;
}
.ttr-value {
font-weight: 600;
color: #1a73e8;

View File

@@ -319,7 +319,6 @@ import apiClient from '../apiClient.js';
import PDFGenerator from '../components/PDFGenerator.js';
import { getSafeErrorMessage } from '../utils/errorMessages.js';
import SeasonSelector from '../components/SeasonSelector.vue';
import MatchReportApiDialog from '../components/MatchReportApiDialog.vue';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
@@ -338,7 +337,6 @@ export default {
name: 'ScheduleView',
components: {
SeasonSelector,
MatchReportApiDialog,
InfoDialog,
ConfirmDialog,
BaseDialog,