+
+
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index be28ee26..c69487e0 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -628,8 +628,12 @@ export default {
reanalyze: 'Erneut analysieren',
patches: {
heading: 'Patch-Vorschläge',
- hint: 'Unified-Diff-Patches, die aus der KI-Analyse extrahiert wurden. Prüfen, kopieren und manuell im Editor anwenden.',
+ hint: 'Unified-Diff-Patches aus der KI-Analyse. Prüfe den Diff und wende ihn automatisch an oder kopiere ihn zur manuellen Bearbeitung.',
copy: 'Patch kopieren',
+ apply: 'Automatisch beheben',
+ applying: 'Wird angewendet…',
+ applied: 'Angewendet ✓',
+ applyError: 'Konnte nicht automatisch angewendet werden — die Datei hat sich seit der Analyse geändert. Patch kopieren und manuell anwenden.',
},
},
missingLibraries: {
@@ -689,6 +693,7 @@ export default {
lastStatus: 'Letzter Status',
noFlaky: 'Keine flaky Tests erkannt. Gut so!',
runsTooltip: '{total} Runs',
+ noRunsDay: 'Keine Ausführungen',
quarantine: {
column: 'Quarantäne',
quarantined: 'In Quarantäne',
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 1b679217..7d7cedea 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -628,8 +628,12 @@ export default {
reanalyze: 'Re-analyze',
patches: {
heading: 'Suggested patches',
- hint: 'Unified-diff patches extracted from the AI analysis. Review, copy, and apply manually in your editor.',
+ hint: 'Unified-diff patches extracted from the AI analysis. Review the diff, then apply it automatically or copy it for manual editing.',
copy: 'Copy patch',
+ apply: 'Fix automatically',
+ applying: 'Applying…',
+ applied: 'Applied ✓',
+ applyError: 'Could not apply automatically — the file changed since the analysis. Copy the patch and apply it manually.',
},
},
missingLibraries: {
@@ -689,6 +693,7 @@ export default {
lastStatus: 'Last Status',
noFlaky: 'No flaky tests detected. Great!',
runsTooltip: '{total} runs',
+ noRunsDay: 'No executions',
quarantine: {
column: 'Quarantine',
quarantined: 'Quarantined',
diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts
index 7dac7a9b..e576d88c 100644
--- a/frontend/src/i18n/locales/es.ts
+++ b/frontend/src/i18n/locales/es.ts
@@ -628,8 +628,12 @@ export default {
reanalyze: 'Reanalizar',
patches: {
heading: 'Parches sugeridos',
- hint: 'Parches en formato unified-diff extraídos del análisis IA. Revisa, copia y aplica manualmente en tu editor.',
+ hint: 'Parches en formato unified-diff extraídos del análisis IA. Revisa el diff y aplícalo automáticamente o cópialo para editarlo manualmente.',
copy: 'Copiar parche',
+ apply: 'Corregir automáticamente',
+ applying: 'Aplicando…',
+ applied: 'Aplicado ✓',
+ applyError: 'No se pudo aplicar automáticamente — el archivo cambió desde el análisis. Copia el parche y aplícalo manualmente.',
},
},
missingLibraries: {
@@ -689,6 +693,7 @@ export default {
lastStatus: 'Último estado',
noFlaky: 'No se detectaron tests inestables. ¡Excelente!',
runsTooltip: '{total} runs',
+ noRunsDay: 'Sin ejecuciones',
quarantine: {
column: 'Cuarentena',
quarantined: 'En cuarentena',
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index a97bb0b8..0dd94064 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -628,8 +628,12 @@ export default {
reanalyze: 'Réanalyser',
patches: {
heading: 'Patchs suggérés',
- hint: "Patches au format unified-diff extraits de l'analyse IA. Vérifiez, copiez et appliquez manuellement dans votre éditeur.",
+ hint: "Patches au format unified-diff extraits de l'analyse IA. Vérifiez le diff, puis appliquez-le automatiquement ou copiez-le pour une édition manuelle.",
copy: 'Copier le patch',
+ apply: 'Corriger automatiquement',
+ applying: 'Application…',
+ applied: 'Appliqué ✓',
+ applyError: "Impossible d'appliquer automatiquement — le fichier a changé depuis l'analyse. Copiez le patch et appliquez-le manuellement.",
},
},
missingLibraries: {
@@ -689,6 +693,7 @@ export default {
lastStatus: 'Dernier statut',
noFlaky: 'Aucun test instable détecté. Parfait !',
runsTooltip: '{total} runs',
+ noRunsDay: 'Aucune exécution',
quarantine: {
column: 'Quarantaine',
quarantined: 'En quarantaine',
diff --git a/frontend/src/stores/ai.store.ts b/frontend/src/stores/ai.store.ts
index 63851915..cb89ff1e 100644
--- a/frontend/src/stores/ai.store.ts
+++ b/frontend/src/stores/ai.store.ts
@@ -108,12 +108,13 @@ export const useAiStore = defineStore('ai', () => {
// --- Analysis ---
- async function analyzeFailures(reportId: number, providerId?: number) {
+ async function analyzeFailures(reportId: number, providerId?: number, language?: string) {
loading.value = true
try {
const job = await aiApi.analyzeFailures({
report_id: reportId,
provider_id: providerId,
+ language,
})
analysisJob.value = job
startAnalysisPolling(job.id)
@@ -169,6 +170,23 @@ export const useAiStore = defineStore('ai', () => {
}
}
+ /** Drop the current analysis and stop polling. Call this when leaving or
+ * switching the execution/report being viewed so a stale analysis from a
+ * previously-opened run can never bleed into a different one. The store
+ * holds a single `analysisJob`, so consumers also guard their display by
+ * `report_id` (see `analyzeFailures`); this just frees it and halts the
+ * background poll. */
+ function clearAnalysis() {
+ stopAnalysisPolling()
+ analysisJob.value = null
+ }
+
+ /** Apply one suggested unified-diff patch to a repo file. Throws (422) if
+ * the patch no longer matches the file on disk. */
+ async function applyPatch(repositoryId: number, filePath: string, unifiedDiff: string) {
+ return await aiApi.applyPatch(repositoryId, filePath, unifiedDiff)
+ }
+
// --- Validation & Drift ---
async function validateSpec(content: string): Promise
{
@@ -311,6 +329,8 @@ export const useAiStore = defineStore('ai', () => {
stopPolling,
startAnalysisPolling,
stopAnalysisPolling,
+ clearAnalysis,
+ applyPatch,
validateSpec,
fetchDrift,
fetchRfKnowledgeStatus,
diff --git a/frontend/src/tests/stores/aiStoreAnalysis.spec.ts b/frontend/src/tests/stores/aiStoreAnalysis.spec.ts
new file mode 100644
index 00000000..0564a7d3
--- /dev/null
+++ b/frontend/src/tests/stores/aiStoreAnalysis.spec.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { useAiStore } from '@/stores/ai.store'
+import type { AiJob } from '@/types/domain.types'
+
+vi.mock('@/api/ai.api', () => ({
+ analyzeFailures: vi.fn(),
+ getJobStatus: vi.fn(),
+}))
+
+import * as aiApi from '@/api/ai.api'
+
+function job(overrides: Partial = {}): AiJob {
+ return {
+ id: 1,
+ job_type: 'analyze',
+ status: 'running',
+ repository_id: 1,
+ provider_id: 1,
+ report_id: 42,
+ spec_path: '',
+ target_path: null,
+ result_preview: null,
+ error_message: null,
+ token_usage: null,
+ triggered_by: 1,
+ started_at: null,
+ completed_at: null,
+ created_at: '2026-06-17T00:00:00Z',
+ ...overrides,
+ }
+}
+
+describe('ai.store — analysis lifecycle', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.useFakeTimers()
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.clearAllMocks()
+ })
+
+ it('analyzeFailures stores the job keyed by report_id and starts polling', async () => {
+ vi.mocked(aiApi.analyzeFailures).mockResolvedValue(job({ report_id: 42 }))
+ vi.mocked(aiApi.getJobStatus).mockResolvedValue(job({ report_id: 42, status: 'running' }))
+ const store = useAiStore()
+
+ await store.analyzeFailures(42)
+ expect(store.analysisJob?.report_id).toBe(42)
+
+ // Poll tick keeps refreshing while running.
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(aiApi.getJobStatus).toHaveBeenCalled()
+ })
+
+ it('clearAnalysis drops the job and stops the poll', async () => {
+ vi.mocked(aiApi.analyzeFailures).mockResolvedValue(job({ report_id: 42 }))
+ vi.mocked(aiApi.getJobStatus).mockResolvedValue(job({ report_id: 42, status: 'running' }))
+ const store = useAiStore()
+ await store.analyzeFailures(42)
+
+ store.clearAnalysis()
+ expect(store.analysisJob).toBeNull()
+
+ // No further polling after clear.
+ vi.mocked(aiApi.getJobStatus).mockClear()
+ await vi.advanceTimersByTimeAsync(6000)
+ expect(aiApi.getJobStatus).not.toHaveBeenCalled()
+ })
+
+ it('completed/failed status stops polling on its own', async () => {
+ vi.mocked(aiApi.analyzeFailures).mockResolvedValue(job({ report_id: 7 }))
+ vi.mocked(aiApi.getJobStatus).mockResolvedValue(job({ report_id: 7, status: 'completed', result_preview: 'done' }))
+ const store = useAiStore()
+ await store.analyzeFailures(7)
+
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(store.analysisJob?.status).toBe('completed')
+
+ vi.mocked(aiApi.getJobStatus).mockClear()
+ await vi.advanceTimersByTimeAsync(6000)
+ expect(aiApi.getJobStatus).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/tests/utils/chartGaps.spec.ts b/frontend/src/tests/utils/chartGaps.spec.ts
new file mode 100644
index 00000000..7d3ea2eb
--- /dev/null
+++ b/frontend/src/tests/utils/chartGaps.spec.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect } from 'vitest'
+import { fillDailySuccessRate } from '@/utils/chartGaps'
+import type { SuccessRatePoint } from '@/types/domain.types'
+
+function pt(date: string, rate: number, runs = 1): SuccessRatePoint {
+ return { date, success_rate: rate, total_runs: runs }
+}
+
+describe('fillDailySuccessRate', () => {
+ it('returns empty for no points', () => {
+ expect(fillDailySuccessRate([])).toEqual([])
+ })
+
+ it('keeps a single point as one slot', () => {
+ const out = fillDailySuccessRate([pt('2026-06-10', 90)])
+ expect(out).toHaveLength(1)
+ expect(out[0].date).toBe('2026-06-10')
+ expect(out[0].point?.success_rate).toBe(90)
+ })
+
+ it('inserts bar-less slots for days without executions', () => {
+ // 10th and 13th have runs; 11th + 12th are gaps.
+ const out = fillDailySuccessRate([pt('2026-06-10', 100), pt('2026-06-13', 50)])
+ expect(out.map((s) => s.date)).toEqual([
+ '2026-06-10', '2026-06-11', '2026-06-12', '2026-06-13',
+ ])
+ expect(out[0].point).not.toBeNull()
+ expect(out[1].point).toBeNull()
+ expect(out[2].point).toBeNull()
+ expect(out[3].point?.success_rate).toBe(50)
+ })
+
+ it('gives every day the same slot regardless of how many gaps', () => {
+ const out = fillDailySuccessRate([pt('2026-01-01', 80), pt('2026-01-08', 80)])
+ expect(out).toHaveLength(8) // 1..8 inclusive
+ expect(out.filter((s) => s.point === null)).toHaveLength(6)
+ })
+
+ it('does not drift across a DST boundary (Europe spring-forward)', () => {
+ // 2026 CET→CEST is 2026-03-29. Iterating in local time would skip a day.
+ const out = fillDailySuccessRate([pt('2026-03-28', 100), pt('2026-03-31', 100)])
+ expect(out.map((s) => s.date)).toEqual([
+ '2026-03-28', '2026-03-29', '2026-03-30', '2026-03-31',
+ ])
+ })
+
+ it('tolerates timestamps with a time component', () => {
+ const out = fillDailySuccessRate([
+ pt('2026-06-10T07:58:04', 100),
+ pt('2026-06-12T23:00:00', 100),
+ ])
+ expect(out.map((s) => s.date)).toEqual(['2026-06-10', '2026-06-11', '2026-06-12'])
+ })
+
+ it('caps the range and degrades gracefully on a reversed range', () => {
+ // maxDays guard: a huge span is clamped, never an infinite loop.
+ const out = fillDailySuccessRate([pt('2020-01-01', 100), pt('2026-01-01', 100)], 366)
+ expect(out.length).toBe(366)
+ })
+
+ it('falls back to raw points on malformed dates', () => {
+ const out = fillDailySuccessRate([pt('not-a-date', 100), pt('also-bad', 50)])
+ expect(out).toHaveLength(2)
+ expect(out[0].point?.success_rate).toBe(100)
+ })
+})
diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts
index a865554e..351b2559 100644
--- a/frontend/src/types/api.types.ts
+++ b/frontend/src/types/api.types.ts
@@ -109,6 +109,9 @@ export interface AiReverseRequest {
export interface AiAnalyzeRequest {
report_id: number
provider_id?: number | null
+ /** Frontend i18n locale (de/en/fr/es/zh) so the analysis prose comes back
+ * in the user's current UI language. */
+ language?: string | null
}
export interface AiValidateSpecRequest {
diff --git a/frontend/src/utils/chartGaps.ts b/frontend/src/utils/chartGaps.ts
new file mode 100644
index 00000000..aa7661ec
--- /dev/null
+++ b/frontend/src/utils/chartGaps.ts
@@ -0,0 +1,49 @@
+import type { SuccessRatePoint } from '@/types/domain.types'
+
+/** One horizontal slot in the success-rate chart. `point` is null for a
+ * calendar day that had no test executions — the chart renders that slot
+ * bar-less so the time axis stays continuous and evenly spaced. */
+export interface DailySlot {
+ date: string
+ point: SuccessRatePoint | null
+}
+
+const DAY_MS = 86_400_000
+
+/**
+ * Expand a success-rate series into one slot per calendar day between the
+ * earliest and latest data point. Days without a data point get a slot with
+ * `point: null` so the chart can render an empty (bar-less) column there
+ * instead of collapsing the gap — every day occupies the same width.
+ *
+ * Dates are pure calendar days (`YYYY-MM-DD`); we iterate in UTC to avoid the
+ * local-timezone drift that would otherwise skip or double a day around DST.
+ * The `maxDays` cap (default 366 — one year window + 1) is a safety valve
+ * against a malformed range producing an unbounded loop.
+ */
+export function fillDailySuccessRate(
+ points: SuccessRatePoint[],
+ maxDays = 366,
+): DailySlot[] {
+ if (points.length === 0) return []
+
+ const byDay = new Map()
+ for (const p of points) byDay.set(p.date.slice(0, 10), p)
+
+ const days = [...byDay.keys()].sort()
+ const start = Date.parse(days[0] + 'T00:00:00Z')
+ const end = Date.parse(days[days.length - 1] + 'T00:00:00Z')
+
+ // Malformed dates — degrade gracefully to one slot per raw point rather
+ // than throwing or looping forever.
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) {
+ return points.map((p) => ({ date: p.date.slice(0, 10), point: p }))
+ }
+
+ const out: DailySlot[] = []
+ for (let ms = start, n = 0; ms <= end && n < maxDays; ms += DAY_MS, n++) {
+ const day = new Date(ms).toISOString().slice(0, 10)
+ out.push({ date: day, point: byDay.get(day) ?? null })
+ }
+ return out
+}
diff --git a/frontend/src/views/ReportDetailView.vue b/frontend/src/views/ReportDetailView.vue
index 84c935f6..0e0293fe 100644
--- a/frontend/src/views/ReportDetailView.vue
+++ b/frontend/src/views/ReportDetailView.vue
@@ -1,5 +1,5 @@
@@ -345,54 +359,31 @@ async function copyPatch(unifiedDiff: string) {