feat(MatchReportApiDialog): implement draft status indicators and local draft persistence
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s

- Added visual indicators for draft status in the MatchReportApiDialog, displaying messages based on syncing state.
- Introduced local draft persistence functionality, allowing users to save and restore match report drafts.
- Enhanced state management for draft synchronization, including timers and error handling.
- Updated methods to ensure local drafts are persisted and cleared appropriately during the match report submission process.
This commit is contained in:
Torsten Schulz (local)
2026-04-23 10:35:24 +02:00
parent 37c3ffa899
commit be1108511f

View File

@@ -591,6 +591,13 @@
<!-- Absenden-Button -->
<div class="submit-section">
<div
v-if="draftStatusText && !isMatchCompleted"
class="draft-status-indicator"
:class="`state-${draftStatusState}`"
>
{{ draftStatusText }}
</div>
<button
@click="submitMatchReport"
class="btn-primary submit-btn"
@@ -752,9 +759,15 @@ export default {
// Aktive Zelle der schwebenden Satz-Tastatur: { matchIndex, setIndex } oder null
editingSetCell: null,
broadcastDraftTimer: null,
draftPersistTimer: null,
draftSyncTimer: null,
meetingDetailsPollInterval: null,
isMeetingDetailsPolling: false,
lastMeetingDetailsSignature: '',
isDraftSyncing: false,
lastDraftSyncError: '',
draftLastSavedAt: null,
draftLastSyncedAt: null,
// Abschluss-Felder
protestText: '',
finalHomePin: '',
@@ -909,12 +922,37 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
return !this.areAllMatchResultsValid() || !this.areStartAndEndTimesValid();
},
draftStatusState() {
if (this.isDraftSyncing) {
return 'syncing';
}
if (this.lastDraftSyncError) {
return 'fallback';
}
if (this.draftLastSavedAt) {
return 'saved';
}
return 'idle';
},
draftStatusText() {
if (this.draftStatusState === 'syncing') {
return 'Synchronisiere Entwurf...';
}
if (this.draftStatusState === 'fallback') {
return 'Offline-Fallback aktiv (lokal gespeichert)';
}
if (this.draftStatusState === 'saved') {
return 'Zwischengespeichert';
}
return '';
}
},
async mounted() {
await this.loadData();
this.initializeResults();
this.initializeFinalPins();
this.restoreLocalDraft();
this.startMeetingDetailsPolling();
this._matchReportSubmittedHandler = (payload) => {
if (!payload?.matchCode || !this.match?.code || String(payload.matchCode) !== String(this.match.code)) {
@@ -936,6 +974,12 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
if (this.broadcastDraftTimer) {
clearTimeout(this.broadcastDraftTimer);
}
if (this.draftPersistTimer) {
clearTimeout(this.draftPersistTimer);
}
if (this.draftSyncTimer) {
clearTimeout(this.draftSyncTimer);
}
['home', 'guest'].forEach(team => {
if (this.pinFeedbackTimers[team]) {
clearTimeout(this.pinFeedbackTimers[team]);
@@ -980,6 +1024,39 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
if (newValue === null && this.activeSection === 'completion' && !this.canOpenNextStages) {
this.activeSection = 'general';
}
this.persistLocalDraftDebounced();
this.broadcastDraftDebounced();
},
results: {
handler() {
this.persistLocalDraftDebounced();
},
deep: true
},
meetingDetails: {
handler() {
this.persistLocalDraftDebounced();
},
deep: true
},
homePin() {
this.persistLocalDraftDebounced();
},
guestPin() {
this.persistLocalDraftDebounced();
},
finalHomePin() {
this.persistLocalDraftDebounced();
},
finalGuestPin() {
this.persistLocalDraftDebounced();
},
protestText() {
this.persistLocalDraftDebounced();
},
activeSection() {
this.persistLocalDraftDebounced();
}
},
methods: {
@@ -1813,6 +1890,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
console.log('✅ Spielbericht erfolgreich abgesendet:', result);
this.submitSucceeded = true;
this.clearLocalDraft();
alert('✅ Spielbericht erfolgreich abgesendet!');
// Dialog schließen
@@ -2626,6 +2704,133 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
return state;
},
getDraftStorageKey() {
const code = this.match?.code ? String(this.match.code) : '';
const uuid = this.meetingData?.nuLigaMeetingUuid ? String(this.meetingData.nuLigaMeetingUuid) : '';
if (!code && !uuid) return null;
return `matchReportDraft:${code || uuid}:${uuid || 'nouuid'}`;
},
buildLocalDraftPayload() {
return {
savedAt: Date.now(),
activeSection: this.activeSection,
teamNotAppeared: this.teamNotAppeared,
homePin: this.homePin,
guestPin: this.guestPin,
finalHomePin: this.finalHomePin,
finalGuestPin: this.finalGuestPin,
protestText: this.protestText,
matchStartDate: this.match?.startDate || null,
matchEndDate: this.match?.endDate || null,
isHomeLineupCertified: this.isHomeLineupCertified,
isGuestLineupCertified: this.isGuestLineupCertified,
results: JSON.parse(JSON.stringify(this.results || [])),
meetingDetails: this.meetingDetails ? {
signature: this.meetingDetails.signature ? { ...this.meetingDetails.signature } : null,
teamLineupHomePlayers: Array.isArray(this.meetingDetails.teamLineupHomePlayers)
? JSON.parse(JSON.stringify(this.meetingDetails.teamLineupHomePlayers))
: [],
teamLineupGuestPlayers: Array.isArray(this.meetingDetails.teamLineupGuestPlayers)
? JSON.parse(JSON.stringify(this.meetingDetails.teamLineupGuestPlayers))
: []
} : null
};
},
persistLocalDraft() {
const key = this.getDraftStorageKey();
if (!key) return;
try {
this.draftLastSavedAt = Date.now();
window.localStorage.setItem(key, JSON.stringify(this.buildLocalDraftPayload()));
} catch (error) {
console.warn('⚠️ Draft konnte nicht lokal gespeichert werden:', error);
}
},
persistLocalDraftDebounced() {
if (this.draftPersistTimer) {
clearTimeout(this.draftPersistTimer);
}
this.draftPersistTimer = setTimeout(() => {
this.draftPersistTimer = null;
this.persistLocalDraft();
}, 250);
},
restoreLocalDraft() {
const key = this.getDraftStorageKey();
if (!key) return;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return;
const draft = JSON.parse(raw);
if (!draft || typeof draft !== 'object') return;
this.draftLastSavedAt = draft.savedAt || Date.now();
if (draft.matchStartDate) this.match.startDate = draft.matchStartDate;
if (draft.matchEndDate) this.match.endDate = draft.matchEndDate;
if (draft.activeSection) this.activeSection = draft.activeSection;
if (typeof draft.teamNotAppeared !== 'undefined') this.teamNotAppeared = draft.teamNotAppeared;
if (typeof draft.homePin === 'string') this.homePin = draft.homePin;
if (typeof draft.guestPin === 'string') this.guestPin = draft.guestPin;
if (typeof draft.finalHomePin === 'string') this.finalHomePin = draft.finalHomePin;
if (typeof draft.finalGuestPin === 'string') this.finalGuestPin = draft.finalGuestPin;
if (typeof draft.protestText === 'string') this.protestText = draft.protestText;
if (Array.isArray(draft.results) && draft.results.length > 0) {
this.results = JSON.parse(JSON.stringify(draft.results));
this.syncResultsToMatch();
}
if (draft.meetingDetails && this.meetingDetails) {
if (Array.isArray(draft.meetingDetails.teamLineupHomePlayers)) {
this.meetingDetails.teamLineupHomePlayers = JSON.parse(JSON.stringify(draft.meetingDetails.teamLineupHomePlayers));
}
if (Array.isArray(draft.meetingDetails.teamLineupGuestPlayers)) {
this.meetingDetails.teamLineupGuestPlayers = JSON.parse(JSON.stringify(draft.meetingDetails.teamLineupGuestPlayers));
}
if (draft.meetingDetails.signature && typeof draft.meetingDetails.signature === 'object') {
this.meetingDetails.signature = { ...(this.meetingDetails.signature || {}), ...draft.meetingDetails.signature };
}
}
this.isHomeLineupCertified = Boolean(draft.isHomeLineupCertified);
this.isGuestLineupCertified = Boolean(draft.isGuestLineupCertified);
} catch (error) {
console.warn('⚠️ Draft konnte nicht wiederhergestellt werden:', error);
}
},
clearLocalDraft() {
const key = this.getDraftStorageKey();
if (!key) return;
try {
window.localStorage.removeItem(key);
} catch (error) {
// ignore
}
},
syncDraftToServerDebounced(delayMs = 600) {
if (this.draftSyncTimer) {
clearTimeout(this.draftSyncTimer);
}
this.draftSyncTimer = setTimeout(async () => {
this.draftSyncTimer = null;
await this.syncDraftToServer();
}, delayMs);
},
async syncDraftToServer() {
if (this.isDraftSyncing || !this.meetingData?.nuLigaMeetingUuid) {
return;
}
this.isDraftSyncing = true;
this.lastDraftSyncError = '';
try {
const matchData = JSON.parse(JSON.stringify(this.match));
this.updateMatchData(matchData, { finalizeReport: false });
await this.validateReport(matchData);
this.draftLastSyncedAt = Date.now();
} catch (error) {
this.lastDraftSyncError = error?.message || 'sync_failed';
console.warn('⚠️ Draft-Sync zu nuscore fehlgeschlagen:', error);
} finally {
this.isDraftSyncing = false;
}
},
/** Entwurf für Broadcast: meetingDetails-Form mit aktuellen Satzergebnissen aus results. */
getDraftMatchData() {
if (!this.meetingDetails || !Array.isArray(this.meetingDetails.matches)) return null;
@@ -2851,6 +3056,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Synchronisiere zurück ins Match-Objekt
this.syncResultsToMatch();
this.broadcastDraftDebounced();
this.persistLocalDraftDebounced();
this.syncDraftToServerDebounced(800);
},
appendToSet(matchIndex, setIndex, char) {
@@ -2911,6 +3118,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
m.completed = false;
m.result = '1:0';
this.broadcastDraftDebounced();
this.persistLocalDraftDebounced();
this.syncDraftToServerDebounced(800);
},
async loadClubSettings() {
try {
@@ -3194,6 +3403,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Synchronisiere zurück ins Match-Objekt
this.syncResultsToMatch();
this.persistLocalDraftDebounced();
this.broadcastDraftDebounced();
},
validateSetScore(homeScore, guestScore, originalInput) {
@@ -3346,6 +3557,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
const now = new Date();
const timeString = now.toTimeString().slice(0, 5); // HH:MM format
this.match.startDate = this.createDateTimeFromTimeString(timeString);
this.persistLocalDraftDebounced();
this.syncDraftToServerDebounced(500);
},
setCurrentEndTime() {
@@ -3367,10 +3580,14 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
console.log('✅ Endzeit wurde auf nächsten Tag verschoben:', nextDay.toISOString());
}
}
this.persistLocalDraftDebounced();
this.syncDraftToServerDebounced(500);
},
setStartTime(timeString) {
this.match.startDate = this.createDateTimeFromTimeString(timeString);
this.persistLocalDraftDebounced();
this.syncDraftToServerDebounced(500);
},
setEndTime(timeString) {
@@ -3390,6 +3607,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
console.log('✅ Endzeit wurde auf nächsten Tag verschoben:', nextDay.toISOString());
}
}
this.persistLocalDraftDebounced();
this.syncDraftToServerDebounced(500);
},
getFormattedTime(dateValue) {
@@ -3537,6 +3756,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
}
this.persistLocalDraftDebounced();
this.broadcastDraftDebounced();
this.syncDraftToServerDebounced(700);
},
toggleDoublePosition(player, team, doublePosition) {
@@ -3568,6 +3790,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
}
}
this.persistLocalDraftDebounced();
this.broadcastDraftDebounced();
this.syncDraftToServerDebounced(700);
},
canSelectDoublePosition(player, team, doublePosition) {
@@ -3646,6 +3871,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
this.clearPinFeedback(team);
this.clearSigningFeedback(team);
this.persistLocalDraftDebounced();
},
pinInputClasses(team) {
@@ -3752,6 +3978,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Validiere und speichere die Änderungen
await this.validateReport();
this.persistLocalDraft();
this.broadcastDraftDebounced();
await this.syncDraftToServer();
} else {
this.showSigningError(team);
}
@@ -5010,6 +5239,38 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
padding: 16px;
}
.draft-status-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
border: 1px solid var(--border-color);
background: var(--surface-muted);
color: var(--text-muted);
}
.draft-status-indicator.state-syncing {
border-color: rgba(59, 130, 246, 0.35);
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.draft-status-indicator.state-saved {
border-color: rgba(16, 185, 129, 0.3);
background: rgba(16, 185, 129, 0.12);
color: #047857;
}
.draft-status-indicator.state-fallback {
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.15);
color: #b45309;
}
.submit-btn {
font-size: 16px;
padding: 12px 24px;