Files
OpenSquawk/app/composables/useBugReport.ts
itsrubberduck 034ddbab2e 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>
2026-07-26 21:11:43 +02:00

232 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ref, type Ref } from 'vue'
import { useApi } from '~/composables/useApi'
import { useAuthStore } from '~/stores/auth'
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
export type BugReportArrow = { fx: number; fy: number; tx: number; ty: number }
/** 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>
}
export function useBugReport(options: BugReportOptions) {
const api = useApi()
const auth = useAuthStore()
const { source, engine, activeScenario } = options
const showBugReportDialog = ref(false)
const bugReportComment = ref('')
const bugReportContact = ref('')
const bugReportScreenshot = ref<string | null>(null)
const bugReportArrows = ref<BugReportArrow[]>([])
const bugReportLoading = ref(false)
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
let _arrowStart = { x: 0, y: 0 }
function setupAnnotationCanvas() {
const canvas = bugReportCanvasRef.value
const img = bugReportImgRef.value
if (!canvas || !img) return
canvas.width = img.clientWidth
canvas.height = img.clientHeight
}
function _drawArrow(ctx: CanvasRenderingContext2D, fx: number, fy: number, tx: number, ty: number) {
const headLen = 14
const angle = Math.atan2(ty - fy, tx - fx)
ctx.strokeStyle = '#ef4444'
ctx.fillStyle = '#ef4444'
ctx.lineWidth = 3
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.beginPath()
ctx.moveTo(fx, fy)
ctx.lineTo(tx, ty)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(tx, ty)
ctx.lineTo(tx - headLen * Math.cos(angle - Math.PI / 6), ty - headLen * Math.sin(angle - Math.PI / 6))
ctx.lineTo(tx - headLen * Math.cos(angle + Math.PI / 6), ty - headLen * Math.sin(angle + Math.PI / 6))
ctx.closePath()
ctx.fill()
}
function _redrawAnnotations(preview?: BugReportArrow) {
const canvas = bugReportCanvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx, a.fy, a.tx, a.ty)
if (preview) {
_drawArrow(ctx, preview.fx, preview.fy, preview.tx, preview.ty)
}
}
function _canvasCoords(e: MouseEvent) {
const canvas = bugReportCanvasRef.value!
const rect = canvas.getBoundingClientRect()
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
function onCanvasMouseDown(e: MouseEvent) {
_arrowStart = _canvasCoords(e)
_arrowDrawing = true
}
function onCanvasMouseMove(e: MouseEvent) {
if (!_arrowDrawing) return
const { x, y } = _canvasCoords(e)
_redrawAnnotations({ fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y })
}
function onCanvasMouseUp(e: MouseEvent) {
if (!_arrowDrawing) return
_arrowDrawing = false
const { x, y } = _canvasCoords(e)
const dx = x - _arrowStart.x
const dy = y - _arrowStart.y
if (Math.sqrt(dx * dx + dy * dy) < 8) { _redrawAnnotations(); return }
bugReportArrows.value = [...bugReportArrows.value, { fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y }]
_redrawAnnotations()
}
function onCanvasMouseLeave() {
if (!_arrowDrawing) return
_arrowDrawing = false
_redrawAnnotations()
}
function undoLastArrow() {
bugReportArrows.value = bugReportArrows.value.slice(0, -1)
_redrawAnnotations()
}
async function openBugReport() {
bugReportError.value = ''
bugReportSuccess.value = false
bugReportCode.value = ''
bugReportComment.value = ''
bugReportArrows.value = []
bugReportScreenshot.value = null
bugReportContact.value = [auth.user?.name, auth.user?.email].filter(Boolean).join(' — ')
// Capture the screenshot BEFORE opening the dialog, otherwise the open
// dialog overlay would appear in the shot instead of the actual bug state.
bugReportCapturing.value = true
try {
// modern-screenshot renders via a native SVG <foreignObject>, so modern CSS
// such as color-mix()/oklch() (used throughout the app) is supported.
// html2canvas could not parse those and silently produced no screenshot.
const { domToJpeg } = await import('modern-screenshot')
bugReportScreenshot.value = await domToJpeg(document.body, {
quality: 0.75,
scale: 0.55,
// Skip assets we cannot read (cross-origin tiles/avatars) instead of failing.
filter: (node) => !(node instanceof Element && node.getAttribute?.('data-no-screenshot') === 'true'),
})
} catch (err) {
// Screenshot is optional — keep the report flow usable, but surface why.
console.warn('[PM] Bug report screenshot capture failed', err)
bugReportScreenshot.value = null
bugReportError.value = 'Screenshot konnte nicht erstellt werden Bug-Report ohne Bild möglich.'
} finally {
bugReportCapturing.value = false
}
showBugReportDialog.value = true
}
async function submitBugReport() {
if (!bugReportComment.value.trim()) { bugReportError.value = 'Bitte eine Fehlerbeschreibung eingeben.'; return }
bugReportLoading.value = true
bugReportError.value = ''
try {
let finalScreenshot: string | undefined
if (bugReportScreenshot.value) {
const img = bugReportImgRef.value
const src = new Image()
await new Promise<void>((res) => { src.onload = () => res(); src.src = bugReportScreenshot.value! })
const out = document.createElement('canvas')
out.width = src.naturalWidth; out.height = src.naturalHeight
const ctx = out.getContext('2d')!
ctx.drawImage(src, 0, 0)
if (bugReportArrows.value.length > 0 && img) {
const sx = src.naturalWidth / img.clientWidth
const sy = src.naturalHeight / img.clientHeight
for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx * sx, a.fy * sy, a.tx * sx, a.ty * sy)
}
finalScreenshot = out.toDataURL('image/jpeg', 0.8)
}
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
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 }, 5000)
} catch (err: any) {
bugReportError.value = err?.data?.statusMessage || err?.message || 'Fehler beim Senden.'
} finally {
bugReportLoading.value = false
}
}
return {
showBugReportDialog,
bugReportComment,
bugReportContact,
bugReportScreenshot,
bugReportArrows,
bugReportLoading,
bugReportCapturing,
bugReportError,
bugReportSuccess,
bugReportCode,
bugReportCanvasRef,
bugReportImgRef,
setupAnnotationCanvas,
onCanvasMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
undoLastArrow,
openBugReport,
submitBugReport,
}
}