feat(bug-reports): trace reports by code and tell Live ATC from Classroom

Every report now gets a UUID that the notification mail quotes as
"Nenne den Fehlercode <uuid> beim Commit", so a fix can be tied back to
the report it closes. The mail also names the area it came from, and the
comment field is relabelled "Fehlerbeschreibung/Featurewunsch" since it
collects feature wishes just as often as bugs.

Classroom loses its plain feedback link and gets the same reporter as
Live ATC — screenshot, arrow annotation and all. `useBugReport` takes an
options object so the communications-engine snapshot stays optional; the
dialog moves out of `live-atc/cockpit` now that both pages use it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-26 21:11:43 +02:00
parent 4dbd0bda9f
commit 034ddbab2e
6 changed files with 116 additions and 34 deletions

View File

@@ -20,6 +20,7 @@ const {
bugReportLoading,
bugReportError,
bugReportSuccess,
bugReportCode,
bugReportCanvasRef,
bugReportImgRef,
setupAnnotationCanvas,
@@ -43,6 +44,9 @@ const {
<div v-if="bugReportSuccess" class="rounded-xl bg-emerald-500/10 border border-emerald-400/30 p-4 text-center">
<v-icon icon="mdi-check-circle-outline" color="emerald" size="32" class="mb-2" />
<p class="text-emerald-300 font-semibold">Danke! Bug Report wurde gesendet.</p>
<p v-if="bugReportCode" class="mt-2 text-xs text-emerald-200/70">
Fehlercode <span class="font-mono">{{ bugReportCode }}</span>
</p>
</div>
<template v-else>
<div v-if="bugReportScreenshot" class="space-y-2">
@@ -103,7 +107,9 @@ const {
<v-textarea
v-model="bugReportComment"
label="Was ist kaputt? Was sollte stattdessen passieren?"
label="Fehlerbeschreibung / Featurewunsch"
placeholder="Was ist kaputt? Was sollte stattdessen passieren?"
persistent-placeholder
variant="outlined"
color="red"
rows="3"

View File

@@ -5,19 +5,24 @@ import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
export type BugReportArrow = { fx: number; fy: number; tx: number; ty: number }
export interface BugReportDeps {
/** Which part of the app the report is filed from — reported in the mail. */
export type BugReportSource = 'live-atc' | 'classroom'
export interface BugReportOptions {
source: BugReportSource
/**
* Live ATC hands over its communications engine so the report carries the
* flow state. Classroom has no such engine — the snapshot is simply omitted.
*/
engine?: ReturnType<typeof useCommunicationsEngine>
/** Scenario the report is captured in — stored alongside the state snapshot. */
activeScenario: Ref<{ id?: string; startFlow?: string } | null | undefined>
activeScenario?: Ref<{ id?: string; startFlow?: string } | null | undefined>
}
export function useBugReport(
engine: ReturnType<typeof useCommunicationsEngine>,
deps: BugReportDeps,
) {
export function useBugReport(options: BugReportOptions) {
const api = useApi()
const auth = useAuthStore()
const { currentState, variables: vars, flags, flightContext, communicationLog: log } = engine
const { activeScenario } = deps
const { source, engine, activeScenario } = options
const showBugReportDialog = ref(false)
const bugReportComment = ref('')
@@ -28,6 +33,7 @@ export function useBugReport(
const bugReportCapturing = ref(false)
const bugReportError = ref('')
const bugReportSuccess = ref(false)
const bugReportCode = ref('')
const bugReportCanvasRef = ref<HTMLCanvasElement | null>(null)
const bugReportImgRef = ref<HTMLImageElement | null>(null)
let _arrowDrawing = false
@@ -115,6 +121,7 @@ export function useBugReport(
async function openBugReport() {
bugReportError.value = ''
bugReportSuccess.value = false
bugReportCode.value = ''
bugReportComment.value = ''
bugReportArrows.value = []
bugReportScreenshot.value = null
@@ -147,7 +154,7 @@ export function useBugReport(
}
async function submitBugReport() {
if (!bugReportComment.value.trim()) { bugReportError.value = 'Bitte einen Kommentar eingeben.'; return }
if (!bugReportComment.value.trim()) { bugReportError.value = 'Bitte eine Fehlerbeschreibung eingeben.'; return }
bugReportLoading.value = true
bugReportError.value = ''
@@ -169,25 +176,29 @@ export function useBugReport(
finalScreenshot = out.toDataURL('image/jpeg', 0.8)
}
const pmState = {
flowSlug: activeScenario.value?.startFlow || '',
scenarioId: activeScenario.value?.id || '',
currentStateId: (currentState.value as any)?.id || '',
variables: (vars as any)?.value || {},
flags: (flags as any)?.value || {},
flightContext: (flightContext as any)?.value || {},
communicationLog: ((log as any)?.value || [] as any[]).slice(-20),
}
const pmState = engine
? {
flowSlug: activeScenario?.value?.startFlow || '',
scenarioId: activeScenario?.value?.id || '',
currentStateId: (engine.currentState.value as any)?.id || '',
variables: (engine.variables as any)?.value || {},
flags: (engine.flags as any)?.value || {},
flightContext: (engine.flightContext as any)?.value || {},
communicationLog: ((engine.communicationLog as any)?.value || [] as any[]).slice(-20),
}
: undefined
await api.post('/api/bug-reports', {
const res = await api.post<{ code?: string }>('/api/bug-reports', {
source,
comment: bugReportComment.value.trim(),
contact: bugReportContact.value.trim(),
screenshot: finalScreenshot,
pmState,
})
bugReportCode.value = res?.code || ''
bugReportSuccess.value = true
setTimeout(() => { showBugReportDialog.value = false; bugReportSuccess.value = false }, 2500)
setTimeout(() => { showBugReportDialog.value = false; bugReportSuccess.value = false }, 5000)
} catch (err: any) {
bugReportError.value = err?.data?.statusMessage || err?.message || 'Fehler beim Senden.'
} finally {
@@ -205,6 +216,7 @@ export function useBugReport(
bugReportCapturing,
bugReportError,
bugReportSuccess,
bugReportCode,
bugReportCanvasRef,
bugReportImgRef,
setupAnnotationCanvas,

View File

@@ -129,10 +129,15 @@
</div>
<div class="hud-right">
<NuxtLink class="btn ghost" to="/feedback" title="Share feedback or ideas">
<v-icon size="18">mdi-message-draw</v-icon>
<span class="btn-label">Feedback</span>
</NuxtLink>
<button
class="btn ghost"
title="Report an issue or request a feature"
:disabled="bugReportCapturing"
@click="openBugReport"
>
<v-icon size="18">{{ bugReportCapturing ? 'mdi-loading mdi-spin' : 'mdi-bug-outline' }}</v-icon>
<span class="btn-label">{{ bugReportCapturing ? '…' : 'Report issue' }}</span>
</button>
<!-- ATC Einstellungen -->
<button class="btn ghost" @click="showSettings=true" title="Settings">
@@ -1251,7 +1256,9 @@
Visit home</a>
</span>
&middot;
<NuxtLink to="/feedback" target="_blank" class="link">Give feedback </NuxtLink>
<button type="button" class="link link-button" :disabled="bugReportCapturing" @click="openBugReport">
Report an issue
</button>
</div>
</div>
</footer>
@@ -1420,6 +1427,9 @@
</div>
</v-dialog>
<!-- BUG REPORT -->
<BugReportDialog :bug="bugReport" theme="dark" />
<!-- TOAST -->
<v-snackbar v-model="toast.show" timeout="2200" location="top" color="#22d3ee">
<v-icon class="mr-2">mdi-trophy</v-icon>
@@ -1457,6 +1467,8 @@ import {
classroomVoiceFor,
} from '~~/shared/utils/voicePool'
import {DEFAULT_AIRLINE_TELEPHONY, normalizeRadioPhrase, normalizeMetarPhrase} from '~~/shared/utils/radioSpeech'
import {useBugReport} from '~/composables/useBugReport'
import BugReportDialog from '~/components/BugReportDialog.vue'
definePageMeta({middleware: ['require-auth', 'require-classroom-intro']})
@@ -2798,6 +2810,11 @@ const showOnlineTtsSuggestion = ref(false)
const api = useApi()
const isClient = typeof window !== 'undefined'
const auth = useAuthStore()
// Bug reports work exactly as in Live ATC (screenshot + arrow annotation), just
// without a communications engine to snapshot — the source tells the mail apart.
const bugReport = useBugReport({source: 'classroom'})
const {bugReportCapturing, openBugReport} = bugReport
const browserTtsAvailable = computed(() => isClient && 'speechSynthesis' in window)
// STT (Speech-to-Text) for the readback — pilot speaks the readback into a mic,
@@ -6192,6 +6209,17 @@ onBeforeUnmount(() => {
gap: 6px
}
.link-button {
font: inherit;
cursor: pointer;
padding: 0
}
.link-button:disabled {
opacity: .6;
cursor: default
}
.stats {
display: flex;
gap: 8px

View File

@@ -185,7 +185,7 @@ import { useBugReport } from '~/composables/useBugReport'
import { useSimBridgeSync } from '~/composables/useSimBridgeSync'
import FlightInfoSheet from '~/components/live-atc/cockpit/FlightInfoSheet.vue'
import SettingsSheet from '~/components/live-atc/cockpit/SettingsSheet.vue'
import BugReportDialog from '~/components/live-atc/cockpit/BugReportDialog.vue'
import BugReportDialog from '~/components/BugReportDialog.vue'
import TransmissionIssueDialog from '~/components/live-atc/cockpit/TransmissionIssueDialog.vue'
import HelpDialog from '~/components/live-atc/cockpit/HelpDialog.vue'
import DebugPanel from '~/components/live-atc/cockpit/DebugPanel.vue'
@@ -320,7 +320,7 @@ const aiTrafficEnabled = ref(false)
// ── Bug Report ───────────────────────────────────────────────────────────────
// Owned here rather than by the dialog: the HUD button starts the screenshot
// capture before the dialog ever renders. The dialog gets the whole handle.
const bugReport = useBugReport(engine, { activeScenario })
const bugReport = useBugReport({ source: 'live-atc', engine, activeScenario })
const { bugReportCapturing, openBugReport } = bugReport
// Layout / view state. The tab, HUD menu and mode-switch state live inside