diff --git a/GerbilManager.Tests/ExportTests.cs b/GerbilManager.Tests/ExportTests.cs
new file mode 100644
index 0000000..3e56061
--- /dev/null
+++ b/GerbilManager.Tests/ExportTests.cs
@@ -0,0 +1,144 @@
+using System.IO.Compression;
+using System.Text;
+using System.Text.Json;
+using GerbilManagerWebAPI.Export;
+using GerbilManagerWebAPI.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GerbilManager.Tests
+{
+ ///
+ /// EXPORT-1: CSV-Escaping-Units + voller Round-Trip über GET /export
+ /// (seed -> Zip herunterladen -> Einträge parsen -> Zähler + Beispielzeile).
+ ///
+ public class ExportTests
+ {
+ // ── CSV-Escaping ───────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(null, "")]
+ [InlineData("", "")]
+ [InlineData("Krümel", "Krümel")] // Umlaute bleiben unquotiert erhalten
+ [InlineData("a;b", "\"a;b\"")] // Semikolon = Trennzeichen -> quoten
+ [InlineData("sagt \"hallo\"", "\"sagt \"\"hallo\"\"\"")] // Anführungszeichen verdoppeln
+ [InlineData("Zeile1\nZeile2", "\"Zeile1\nZeile2\"")] // Zeilenumbruch -> quoten
+ [InlineData("Meier, Hans", "Meier, Hans")] // Komma ist KEIN Trennzeichen (Semikolon-CSV)
+ public void Escape_behandelt_Sonderfälle(string? input, string expected)
+ => Assert.Equal(expected, ExportService.Escape(input));
+
+ [Fact]
+ public void BuildCsv_schreibt_Kopfzeile_und_quotierte_Zeilen()
+ {
+ var csv = ExportService.BuildCsv(
+ ["Name", "Notizen"],
+ [["Krümel", "frisst; gerne \"Hirse\""], ["Bo", null]]);
+
+ var lines = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal("Name;Notizen", lines[0]);
+ Assert.Equal("Krümel;\"frisst; gerne \"\"Hirse\"\"\"", lines[1]);
+ Assert.Equal("Bo;", lines[2]);
+ }
+
+ // ── Round-Trip über die API ────────────────────────────────────────
+
+ [Fact]
+ public async Task Export_liefert_Zip_mit_allen_Einträgen_und_korrekten_Daten()
+ {
+ using var factory = new ApiFactory();
+
+ // Seed: Kontakt, Becken, Wurf, Tier (mit Bezügen), Gesundheit, Gewicht
+ Guid gerbilId;
+ using (var scope = factory.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var contact = new Contact { Id = Guid.NewGuid(), Name = "Zoohandlung; \"Meier\"", Email = "meier@example.de" };
+ var enclosure = new Enclosure { Id = Guid.NewGuid(), Name = "Großbecken" };
+ var litter = new Litter { Id = Guid.NewGuid(), Name = "Wurf K", Date = new DateOnly(2025, 3, 12), TotalBorn = 5 };
+ var colorVariety = db.ColorVarieties.First(); // HasData-Seed
+ var gerbil = new Gerbil
+ {
+ Id = Guid.NewGuid(),
+ Name = "Krümel",
+ Gender = Gender.female,
+ Status = GerbilStatus.Active,
+ DateOfBirth = new DateOnly(2025, 3, 12),
+ LitterId = litter.Id,
+ EnclosureId = enclosure.Id,
+ OriginContactId = contact.Id,
+ ColorVarietyId = colorVariety.Id,
+ Genotype = "Aa CC Dd EE GG Pp Spsp rere",
+ ImportSource = "Stammbaum von Akio Kids.xlsx",
+ };
+ gerbilId = gerbil.Id;
+ db.AddRange(contact, enclosure, litter, gerbil,
+ new HealthRecord
+ {
+ Id = Guid.NewGuid(), GerbilId = gerbil.Id, Date = new DateOnly(2026, 1, 15),
+ Type = HealthRecordType.Vaccination, Description = "Jahresimpfung",
+ CreatedAt = DateTimeOffset.UtcNow,
+ },
+ new WeightRecord
+ {
+ Id = Guid.NewGuid(), GerbilId = gerbil.Id,
+ Date = new DateOnly(2026, 5, 1), WeightGrams = 78,
+ });
+ await db.SaveChangesAsync();
+ }
+
+ var client = factory.CreateClient();
+ var response = await client.GetAsync("/export");
+ response.EnsureSuccessStatusCode();
+ Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType);
+ Assert.Contains("rennmaus-export-", response.Content.Headers.ContentDisposition?.FileName);
+
+ using var zip = new ZipArchive(await response.Content.ReadAsStreamAsync(), ZipArchiveMode.Read);
+ string[] expectedEntries =
+ ["export.json", "tiere.csv", "wuerfe.csv", "kontakte.csv",
+ "gesundheit.csv", "gewichte.csv", "LIESMICH.txt"];
+ foreach (var name in expectedEntries)
+ Assert.NotNull(zip.GetEntry(name));
+
+ // export.json: Zähler + volle Treue (Genotyp, Import-Herkunft)
+ using var json = JsonDocument.Parse(ReadEntry(zip, "export.json", out _));
+ var root = json.RootElement;
+ Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
+ Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
+ Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
+ Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 70); // HasData-Seed
+ var g = root.GetProperty("gerbils")[0];
+ Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
+ Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());
+ Assert.Equal(gerbilId.ToString(), g.GetProperty("id").GetString());
+
+ // tiere.csv: BOM, deutsche Kopfzeile, aufgelöste Namen + deutsches Datum
+ var tiere = ReadEntry(zip, "tiere.csv", out var hadBom);
+ Assert.True(hadBom, "tiere.csv braucht ein UTF-8-BOM für Excel");
+ var lines = tiere.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
+ Assert.StartsWith("Name;Geschlecht;Status;Geburtsdatum", lines[0]);
+ var row = lines[1];
+ Assert.Contains("Krümel", row);
+ Assert.Contains("Weiblich", row);
+ Assert.Contains("Aktiv", row);
+ Assert.Contains("12.03.2025", row);
+ Assert.Contains("Großbecken", row);
+ Assert.Contains("Wurf K", row);
+
+ // kontakte.csv: Escaping im Ernstfall (Semikolon + Anführungszeichen im Namen)
+ var kontakte = ReadEntry(zip, "kontakte.csv", out _);
+ Assert.Contains("\"Zoohandlung; \"\"Meier\"\"\"", kontakte);
+
+ // LIESMICH erklärt den Foto-Speicherort
+ Assert.Contains("photo-storage", ReadEntry(zip, "LIESMICH.txt", out _));
+ }
+
+ private static string ReadEntry(ZipArchive zip, string name, out bool hadBom)
+ {
+ using var stream = zip.GetEntry(name)!.Open();
+ using var ms = new MemoryStream();
+ stream.CopyTo(ms);
+ var bytes = ms.ToArray();
+ hadBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
+ return Encoding.UTF8.GetString(bytes, hadBom ? 3 : 0, bytes.Length - (hadBom ? 3 : 0));
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs
new file mode 100644
index 0000000..7f0ea52
--- /dev/null
+++ b/GerbilManagerWebAPI/Endpoints/ExportEndpoints.cs
@@ -0,0 +1,36 @@
+using GerbilManagerWebAPI.Export;
+using Microsoft.EntityFrameworkCore;
+
+namespace GerbilManagerWebAPI.Endpoints
+{
+ ///
+ /// EXPORT-1: GET /export — komplette Datensicherung als Zip
+ /// (export.json voll, CSVs deutsch/Excel-freundlich, LIESMICH.txt; OHNE Fotos).
+ ///
+ public static class ExportEndpoints
+ {
+ public static IEndpointRouteBuilder MapExportEndpoints(this IEndpointRouteBuilder app)
+ {
+ app.MapGet("/export", async (ApplicationContext db, CancellationToken ct) =>
+ {
+ var data = new ExportService.ExportData(
+ await db.Gerbils.AsNoTracking().ToListAsync(ct),
+ await db.Litters.AsNoTracking().ToListAsync(ct),
+ await db.Contacts.AsNoTracking().ToListAsync(ct),
+ await db.Enclosures.AsNoTracking().ToListAsync(ct),
+ await db.ColorVarieties.AsNoTracking().ToListAsync(ct),
+ await db.HealthRecords.AsNoTracking().ToListAsync(ct),
+ await db.WeightRecords.AsNoTracking().ToListAsync(ct),
+ await db.GerbilPhotos.AsNoTracking().ToListAsync(ct));
+
+ var today = DateOnly.FromDateTime(DateTime.Now);
+ var bytes = ExportService.BuildZip(data, today);
+ return Results.File(bytes, "application/zip",
+ $"rennmaus-export-{today:yyyy-MM-dd}.zip");
+ })
+ .WithTags("Export");
+
+ return app;
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Export/ExportService.cs b/GerbilManagerWebAPI/Export/ExportService.cs
new file mode 100644
index 0000000..9f83ff5
--- /dev/null
+++ b/GerbilManagerWebAPI/Export/ExportService.cs
@@ -0,0 +1,212 @@
+using System.Globalization;
+using System.IO.Compression;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using GerbilManagerWebAPI.Models;
+
+namespace GerbilManagerWebAPI.Export
+{
+ ///
+ /// EXPORT-1: user-facing Datenexport — builds a zip containing
+ /// - export.json (full fidelity: every entity incl. genotypes + import provenance)
+ /// - *.csv (Tiere, Würfe, Kontakte, Gesundheit, Gewichte — German headers,
+ /// de-DE formats, semicolon-separated, UTF-8 with BOM for Excel)
+ /// - LIESMICH.txt (German explainer; notes that photos live on disk, not in the zip)
+ ///
+ /// Pure function over (no DbContext) so the round-trip
+ /// and escaping tests run without a database.
+ ///
+ public static class ExportService
+ {
+ public sealed record ExportData(
+ List Gerbils,
+ List Litters,
+ List Contacts,
+ List Enclosures,
+ List ColorVarieties,
+ List HealthRecords,
+ List WeightRecords,
+ List Photos);
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new JsonStringEnumConverter() },
+ WriteIndented = true,
+ ReferenceHandler = ReferenceHandler.IgnoreCycles,
+ };
+
+ // German labels for enum values (CSV is the wife-facing backup format;
+ // the JSON keeps the enum member names for lossless re-import).
+ private static readonly Dictionary GenderDe = new()
+ {
+ [Gender.unknown] = "Unbekannt",
+ [Gender.male] = "Männlich",
+ [Gender.female] = "Weiblich",
+ };
+
+ private static readonly Dictionary StatusDe = new()
+ {
+ [GerbilStatus.Active] = "Aktiv",
+ [GerbilStatus.Deceased] = "Verstorben",
+ [GerbilStatus.GivenAway] = "Abgegeben",
+ };
+
+ private static readonly Dictionary HealthTypeDe = new()
+ {
+ [HealthRecordType.Examination] = "Untersuchung",
+ [HealthRecordType.Treatment] = "Behandlung",
+ [HealthRecordType.Injury] = "Verletzung",
+ [HealthRecordType.Vaccination] = "Impfung",
+ [HealthRecordType.Other] = "Sonstiges",
+ };
+
+ public static byte[] BuildZip(ExportData data, DateOnly exportDate)
+ {
+ var gerbilName = data.Gerbils.ToDictionary(g => g.Id, g => g.Name);
+ var litterName = data.Litters.ToDictionary(l => l.Id, l => l.Name);
+ var contactName = data.Contacts.ToDictionary(c => c.Id, c => c.Name);
+ var enclosureName = data.Enclosures.ToDictionary(e => e.Id, e => e.Name);
+ var colorName = data.ColorVarieties.ToDictionary(c => c.Id, c => c.Name);
+
+ string? Lookup(Dictionary map, TKey? key) where TKey : struct =>
+ key is null ? null : map.GetValueOrDefault(key.Value);
+
+ using var stream = new MemoryStream();
+ using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ AddText(zip, "export.json", JsonSerializer.Serialize(new
+ {
+ exportedAt = exportDate.ToString("yyyy-MM-dd"),
+ data.Gerbils,
+ data.Litters,
+ data.Contacts,
+ data.Enclosures,
+ data.ColorVarieties,
+ data.HealthRecords,
+ data.WeightRecords,
+ data.Photos,
+ }, JsonOptions), bom: false);
+
+ AddCsv(zip, "tiere.csv",
+ ["Name", "Geschlecht", "Status", "Geburtsdatum", "Todesdatum", "Todesursache",
+ "Abgabedatum", "Farbschlag", "Becken", "Wurf", "Herkunft", "Abnehmer",
+ "Genotyp", "Notizen"],
+ data.Gerbils.OrderBy(g => g.Name).Select(g => new[]
+ {
+ g.Name, GenderDe[g.Gender], StatusDe[g.Status],
+ De(g.DateOfBirth), De(g.DateOfDeath), g.CauseOfDeath,
+ De(g.GoHomeDate), Lookup(colorName, g.ColorVarietyId),
+ Lookup(enclosureName, g.EnclosureId), Lookup(litterName, g.LitterId),
+ Lookup(contactName, g.OriginContactId), Lookup(contactName, g.ReceiverContactId),
+ g.Genotype, g.Notes,
+ }));
+
+ AddCsv(zip, "wuerfe.csv",
+ ["Bezeichnung", "Wurfdatum", "Vater", "Mutter", "Wurfstärke",
+ "Voraussichtliches Abgabedatum", "Notizen"],
+ data.Litters.OrderBy(l => l.Date).Select(l => new[]
+ {
+ l.Name, De(l.Date), Lookup(gerbilName, l.FatherId), Lookup(gerbilName, l.MotherId),
+ l.TotalBorn?.ToString(CultureInfo.InvariantCulture),
+ De(l.ExpectedGoHomeDate), l.Notes,
+ }));
+
+ AddCsv(zip, "kontakte.csv",
+ ["Name", "E-Mail", "Telefon", "Adresse", "Notizen"],
+ data.Contacts.OrderBy(c => c.Name).Select(c => new[]
+ {
+ c.Name, c.Email, c.Phone, c.Address, c.Notes,
+ }));
+
+ AddCsv(zip, "gesundheit.csv",
+ ["Tier", "Datum", "Art", "Beschreibung", "Tierarzt"],
+ data.HealthRecords.OrderBy(h => h.Date).Select(h => new[]
+ {
+ gerbilName.GetValueOrDefault(h.GerbilId), De(h.Date),
+ HealthTypeDe[h.Type], h.Description, h.Veterinarian,
+ }));
+
+ AddCsv(zip, "gewichte.csv",
+ ["Tier", "Datum", "Gewicht (g)", "Notizen"],
+ data.WeightRecords.OrderBy(w => w.Date).Select(w => new[]
+ {
+ gerbilName.GetValueOrDefault(w.GerbilId), De(w.Date),
+ w.WeightGrams.ToString(CultureInfo.InvariantCulture), w.Notes,
+ }));
+
+ AddText(zip, "LIESMICH.txt", $"""
+ Rennmaus-Manager — Datenexport vom {exportDate.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}
+
+ Enthalten:
+ - export.json : vollständige Datensicherung (alle Tiere, Würfe, Kontakte,
+ Becken, Farbschläge, Gesundheits- und Gewichtseinträge,
+ Foto-Verzeichnis, inkl. Genotypen und Import-Herkunft).
+ - tiere.csv : alle Tiere als Tabelle (für Excel/LibreOffice).
+ - wuerfe.csv : alle Würfe.
+ - kontakte.csv : alle Kontakte.
+ - gesundheit.csv: alle Gesundheitseinträge.
+ - gewichte.csv : alle Gewichtseinträge.
+
+ Die CSV-Dateien sind mit Semikolon getrennt und öffnen sich in einem
+ deutschen Excel per Doppelklick. Datumsangaben im Format TT.MM.JJJJ.
+
+ FOTOS sind aus Platzgründen NICHT im Export enthalten. Sie liegen als
+ normale Bilddateien im Datenordner der Anwendung (Ordner "photo-storage"
+ neben der API bzw. der in Photos:RootPath konfigurierte Pfad) und können
+ von dort direkt kopiert/gesichert werden. Die Zuordnung Foto -> Tier
+ steht in export.json (Abschnitt "photos").
+ """, bom: false);
+ }
+ return stream.ToArray();
+ }
+
+ // ── CSV building blocks ────────────────────────────────────────────
+
+ /// "2024-03-12" -> "12.03.2024"; null -> empty.
+ private static string? De(DateOnly? date) =>
+ date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
+
+ private static string De(DateOnly date) =>
+ date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
+
+ ///
+ /// Semicolon-separated (German Excel default), CRLF rows, fields quoted when
+ /// they contain separator/quote/newline; quotes doubled.
+ ///
+ internal static string BuildCsv(string[] header, IEnumerable rows)
+ {
+ var sb = new StringBuilder();
+ sb.Append(string.Join(';', header.Select(Escape))).Append("\r\n");
+ foreach (var row in rows)
+ sb.Append(string.Join(';', row.Select(Escape))).Append("\r\n");
+ return sb.ToString();
+ }
+
+ internal static string Escape(string? field)
+ {
+ if (string.IsNullOrEmpty(field)) return "";
+ return field.Contains(';') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')
+ ? $"\"{field.Replace("\"", "\"\"")}\""
+ : field;
+ }
+
+ private static void AddCsv(ZipArchive zip, string name, string[] header,
+ IEnumerable rows) =>
+ AddText(zip, name, BuildCsv(header, rows), bom: true);
+
+ private static void AddText(ZipArchive zip, string name, string content, bool bom)
+ {
+ var entry = zip.CreateEntry(name, CompressionLevel.Optimal);
+ using var entryStream = entry.Open();
+ if (bom)
+ {
+ // UTF-8 BOM: ohne ihn zeigt ein deutsches Excel Umlaute kaputt an.
+ entryStream.Write(Encoding.UTF8.GetPreamble());
+ }
+ var bytes = Encoding.UTF8.GetBytes(content);
+ entryStream.Write(bytes);
+ }
+ }
+}
diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs
index 1ae2173..76dd181 100644
--- a/GerbilManagerWebAPI/Program.cs
+++ b/GerbilManagerWebAPI/Program.cs
@@ -75,6 +75,7 @@ app.MapSaleAdEndpoints();
app.MapImportEndpoints();
app.MapContractEndpoints();
app.MapSettingsEndpoints();
+app.MapExportEndpoints();
app.Run();
diff --git a/gerbil-manager-web/e2e/einstellungen.spec.ts b/gerbil-manager-web/e2e/einstellungen.spec.ts
new file mode 100644
index 0000000..ef9bbae
--- /dev/null
+++ b/gerbil-manager-web/e2e/einstellungen.spec.ts
@@ -0,0 +1,20 @@
+/** EXPORT-1: Einstellungen — Datenexport-Karte + Download-Smoke. */
+import { de, expect, gotoSection, test } from './fixtures'
+
+const t = de.pages.datenexport
+
+test('Einstellungen zeigt die Datenexport-Karte und der Download startet', async ({ page }) => {
+ await gotoSection(page, de.nav.settings)
+ await expect(page.getByRole('heading', { name: de.pages.einstellungen.title })).toBeVisible()
+
+ // Datenexport-Karte mit deutschem Erklärtext
+ await expect(page.getByRole('heading', { name: t.title })).toBeVisible()
+ await expect(page.getByText(t.intro)).toBeVisible()
+ await expect(page.getByText(t.photoNote)).toBeVisible()
+
+ // Klick startet den Zip-Download
+ const downloadPromise = page.waitForEvent('download')
+ await page.getByRole('link', { name: t.button }).click()
+ const download = await downloadPromise
+ expect(download.suggestedFilename()).toContain('rennmaus-export')
+})
diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts
index 240aaa9..2aea058 100644
--- a/gerbil-manager-web/e2e/mock-api.ts
+++ b/gerbil-manager-web/e2e/mock-api.ts
@@ -98,11 +98,37 @@ export async function installMockApi(page: Page): Promise {
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
}
- await page.route(`${API_ORIGIN}/**`, async (route) => {
+ const handler = async (route: Route) => {
const request = route.request()
const url = new URL(request.url())
const method = request.method()
- const path = url.pathname
+ // OPS-1: in Produktion ist die API-Basis der relative Pfad /api (nginx-Proxy);
+ // den Präfix normalisieren, damit der Mock unter beiden Basen funktioniert.
+ const path = url.pathname.replace(/^\/api(?=\/)/, '')
+
+ // EXPORT-1: Zip-Download (Inhalt egal — der Smoke prüft nur, dass der
+ // Download startet; ein leeres Zip = End-of-central-directory-Record).
+ if (path === '/export' && method === 'GET') {
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/zip',
+ headers: { 'Content-Disposition': 'attachment; filename="rennmaus-export-e2e.zip"' },
+ body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]),
+ })
+ }
+
+ // FEAT-13: Zuchtprofil (Einstellungen-Seite lädt es vor dem Rendern)
+ if (path === '/settings/breeder-profile') {
+ if (method === 'GET') {
+ return json(route, 200, {
+ zuchtName: 'Zucht der kleinen Chaoten',
+ name: 'Frau Erika Muster',
+ address: 'Musterweg 1, 12345 Musterstadt',
+ phone: '', email: '', homepage: '', city: 'Musterstadt',
+ })
+ }
+ if (method === 'PUT') return json(route, 204)
+ }
// Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge)
let m = path.match(/^\/gerbils\/([^/]+)\/photos$/)
@@ -157,7 +183,15 @@ export async function installMockApi(page: Page): Promise {
return json(route, 204)
}
return json(route, 405)
- })
+ }
+
+ // Beide API-Basen abfangen: absolute Dev-URL und /api-relativ (OPS-1-Proxy).
+ // URL-Prädikat statt Glob: '**/api/**' würde auch Vites Modul-Requests
+ // (/src/api/client.ts …) treffen und die App selbst kaputt-intercepten.
+ await page.route(
+ (url) => url.origin === API_ORIGIN || url.pathname.startsWith('/api/'),
+ handler,
+ )
return db
}
diff --git a/gerbil-manager-web/playwright.config.ts b/gerbil-manager-web/playwright.config.ts
index cc8dff3..bc6f2b9 100644
--- a/gerbil-manager-web/playwright.config.ts
+++ b/gerbil-manager-web/playwright.config.ts
@@ -22,6 +22,10 @@ export default defineConfig({
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
+ // CI-Flake-Schutz (Dwights Beobachtung, 2× 'Kontakt anlegen'): alle Worker
+ // teilen sich EINEN Vite-Dev-Server; unbegrenzte Parallelität erzeugt dort
+ // Timing-Druck (on-demand-Transforms). Lokal bleibt Playwrights Default.
+ workers: process.env.CI ? 4 : undefined,
reporter: [['list']],
timeout: 30_000,
use: {
diff --git a/gerbil-manager-web/src/components/DatenexportCard.tsx b/gerbil-manager-web/src/components/DatenexportCard.tsx
new file mode 100644
index 0000000..28817f8
--- /dev/null
+++ b/gerbil-manager-web/src/components/DatenexportCard.tsx
@@ -0,0 +1,21 @@
+/**
+ * EXPORT-1: Datenexport-Karte (auf /einstellungen) — lädt GET /export als Zip.
+ * Schlichter Anker-Download (kein fetch/Blob nötig); der Server setzt
+ * Content-Disposition mit Datumsdateinamen.
+ */
+import { API_BASE_URL } from '../api/client'
+import { de } from '../strings/de'
+
+export default function DatenexportCard() {
+ const t = de.pages.datenexport
+ return (
+
+ )
+}
diff --git a/gerbil-manager-web/src/pages/EinstellungenPage.tsx b/gerbil-manager-web/src/pages/EinstellungenPage.tsx
index a340778..4eaac87 100644
--- a/gerbil-manager-web/src/pages/EinstellungenPage.tsx
+++ b/gerbil-manager-web/src/pages/EinstellungenPage.tsx
@@ -13,6 +13,8 @@ import {
type BreederProfile,
} from '../api/settings'
import { useApi, useMutation } from '../hooks/useApi'
+// EXPORT-1 (Oscar): Datenexport-Karte
+import DatenexportCard from '../components/DatenexportCard'
export default function EinstellungenPage() {
const t = de.pages.einstellungen
@@ -95,6 +97,8 @@ export default function EinstellungenPage() {
{saved && {tz.saved}}
+
+
)
}
diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts
index e7b1ace..24747d0 100644
--- a/gerbil-manager-web/src/strings/de.ts
+++ b/gerbil-manager-web/src/strings/de.ts
@@ -565,6 +565,15 @@ export const de = {
},
},
},
+ // ── EXPORT-1 (Oscar): Datenexport (Karte auf /einstellungen) ──
+ datenexport: {
+ title: 'Datenexport',
+ intro:
+ 'Sicherung deiner Daten — alle Tiere, Würfe und Kontakte als Tabellen (CSV für Excel) plus eine vollständige Datensicherung (JSON), gebündelt als Zip.',
+ button: 'Export herunterladen',
+ photoNote:
+ 'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.',
+ },
},
// ── HELP-1: In-App-Anleitung ──
hilfe: {